Compare commits
338 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03e14ce3ce | ||
|
|
d51de68ef3 | ||
|
|
090ae0e205 | ||
|
|
7ba27ef9f1 | ||
|
|
48e1808992 | ||
|
|
85b1a2d7da | ||
|
|
f680c01547 | ||
|
|
4f94999b07 | ||
|
|
cf89141f82 | ||
|
|
b46bdea72a | ||
|
|
98af9794ec | ||
|
|
3d63226372 | ||
|
|
2e8dff346e | ||
|
|
430f3b52e9 | ||
|
|
901b68732f | ||
|
|
d83900e134 | ||
|
|
6f0902d473 | ||
|
|
a1d3fc66da | ||
|
|
c76ef9643b | ||
|
|
67a274804f | ||
|
|
c157c94d95 | ||
|
|
bd0d5bc663 | ||
|
|
91d079c5ab | ||
|
|
fe8919c6ad | ||
|
|
f792a0df96 | ||
|
|
1283b48d6b | ||
|
|
6adb796b26 | ||
|
|
528e899066 | ||
|
|
659222643c | ||
|
|
20d61c8d2a | ||
|
|
53e0b098ff | ||
|
|
9fa4836038 | ||
|
|
439874e811 | ||
|
|
60fc6732b8 | ||
|
|
772fe7622d | ||
|
|
1e69e3a2b0 | ||
|
|
b430e24cdb | ||
|
|
f9caf6ef26 | ||
|
|
9a53f5d1ee | ||
|
|
04874e69f2 | ||
|
|
1741829761 | ||
|
|
67f19b5186 | ||
|
|
68d1068059 | ||
|
|
03dd4b33ac | ||
|
|
60ef0fe5d6 | ||
|
|
70814d0bc6 | ||
|
|
b489c07177 | ||
|
|
a3bc9ab9f4 | ||
|
|
40a76593a2 | ||
|
|
0e3c9e7ba8 | ||
|
|
8abf2af212 | ||
|
|
13d70cf0fd | ||
|
|
f0bb3f08b0 | ||
|
|
d07b51f67d | ||
|
|
6dfda07713 | ||
|
|
fd3f58b501 | ||
|
|
e4f91a1c00 | ||
|
|
f0d1c6daf9 | ||
|
|
33268fda53 | ||
|
|
275aa30d45 | ||
|
|
7c0c986564 | ||
|
|
c4d8fb2e00 | ||
|
|
33b84b9039 | ||
|
|
498598424b | ||
|
|
762690493a | ||
|
|
456f0ff2ee | ||
|
|
12c13de1bd | ||
|
|
847e0855d8 | ||
|
|
4e75605ac0 | ||
|
|
eff2fc5ac7 | ||
|
|
09a8dd7db8 | ||
|
|
318dc83d2d | ||
|
|
c501cfae76 | ||
|
|
8bc74f4e0f | ||
|
|
af001d8dfa | ||
|
|
0e1419cc9a | ||
|
|
a7bcc5689a | ||
|
|
bc05038427 | ||
|
|
b4bdf3737a | ||
|
|
1fa2060e2b | ||
|
|
a8c5f07fb2 | ||
|
|
ff5a334f79 | ||
|
|
84fdd705b6 | ||
|
|
818bfad5f4 | ||
|
|
678059b1d3 | ||
|
|
9f9566b27c | ||
|
|
d4d9e5cfcb | ||
|
|
7fcdb17d84 | ||
|
|
aa990671c1 | ||
|
|
0ce3c4cd83 | ||
|
|
77daffcc4b | ||
|
|
b04171aa76 | ||
|
|
a3f5b252b9 | ||
|
|
1f2d560893 | ||
|
|
87c55f264e | ||
|
|
3fe8a4f7bc | ||
|
|
2cf89a1eb3 | ||
|
|
68d81b97b4 | ||
|
|
c0917e31ed | ||
|
|
d438faa3d8 | ||
|
|
9dd5d50034 | ||
|
|
1d734633f0 | ||
|
|
2186756535 | ||
|
|
702428d24f | ||
|
|
c8b0e7013d | ||
|
|
385b4a3b24 | ||
|
|
5ccdf735ae | ||
|
|
81a7789f9c | ||
|
|
a4defd330c | ||
|
|
4628d1d1d7 | ||
|
|
7b719c2273 | ||
|
|
a9b840452e | ||
|
|
3b0658fee2 | ||
|
|
b5f1491fbb | ||
|
|
539903f053 | ||
|
|
f7aa6338f1 | ||
|
|
f20c2d4472 | ||
|
|
d1d13396f8 | ||
|
|
bed08c84de | ||
|
|
c0e1747e09 | ||
|
|
ea8df25620 | ||
|
|
cd9e844001 | ||
|
|
8eac0bed84 | ||
|
|
d2eb31f510 | ||
|
|
ed72ab6ec7 | ||
|
|
02d3766d60 | ||
|
|
5b4f166216 | ||
|
|
75ceab0cf1 | ||
|
|
91181aed13 | ||
|
|
3234f06a2d | ||
|
|
cac1059c16 | ||
|
|
632376bed5 | ||
|
|
64a29e7f4c | ||
|
|
c6d8c9b2d8 | ||
|
|
6915a1bfaf | ||
|
|
00751eeca5 | ||
|
|
715a4bd6c7 | ||
|
|
5df2c67c2f | ||
|
|
ce0f27bb6e | ||
|
|
5c1a171832 | ||
|
|
fd8ab2f643 | ||
|
|
c750eed5ef | ||
|
|
c446bf1a1f | ||
|
|
0fdfa55067 | ||
|
|
c313eacefd | ||
|
|
649f7521bc | ||
|
|
7065b06c82 | ||
|
|
c4e01e482e | ||
|
|
e56c4b6b56 | ||
|
|
ce38163c6d | ||
|
|
0406d6d5ee | ||
|
|
4b92c52ea8 | ||
|
|
be770d282a | ||
|
|
0bf95483f9 | ||
|
|
60b88389a6 | ||
|
|
72832a5767 | ||
|
|
eb0313f551 | ||
|
|
c0ecc3d4c6 | ||
|
|
da7b478d81 | ||
|
|
2702c9a7cf | ||
|
|
3bda6c2b76 | ||
|
|
1a66bfb8be | ||
|
|
41be0e6362 | ||
|
|
e93a3a3b5f | ||
|
|
9945ef892e | ||
|
|
812de35149 | ||
|
|
035224b02b | ||
|
|
72aba9d8cd | ||
|
|
df7e833b84 | ||
|
|
aab1492934 | ||
|
|
1ae074db8f | ||
|
|
f21004e944 | ||
|
|
7c421cc52f | ||
|
|
d8e407ab22 | ||
|
|
37565284e6 | ||
|
|
b758df9f44 | ||
|
|
a735ae47c5 | ||
|
|
97d585dc17 | ||
|
|
7f3db9add1 | ||
|
|
6d6964f086 | ||
|
|
2a4012f73a | ||
|
|
9385315f03 | ||
|
|
ee824f0b27 | ||
|
|
792af504c7 | ||
|
|
cd3c062a24 | ||
|
|
a5f477c19b | ||
|
|
85164d79fc | ||
|
|
7aedc4111f | ||
|
|
d1f89847f5 | ||
|
|
57170847a1 | ||
|
|
45de715390 | ||
|
|
c29d6f58dd | ||
|
|
f0bae49830 | ||
|
|
c3ceae4dc6 | ||
|
|
d654fb39e5 | ||
|
|
7dc205fa66 | ||
|
|
91a249d953 | ||
|
|
356afda9c7 | ||
|
|
35f02a2296 | ||
|
|
16bcec0deb | ||
|
|
16ec57081b | ||
|
|
690f09fcf3 | ||
|
|
2f960169bb | ||
|
|
14a40d9f66 | ||
|
|
e5abd67f83 | ||
|
|
399ba7e2fc | ||
|
|
7780ae3d7a | ||
|
|
80d3f16473 | ||
|
|
a8c0dfcd0c | ||
|
|
6ee7d6ec8d | ||
|
|
544fae3808 | ||
|
|
4516dde1f4 | ||
|
|
a20c5f8d12 | ||
|
|
60e5c0d165 | ||
|
|
b7bf18250d | ||
|
|
93256b7a6a | ||
|
|
47a4437a01 | ||
|
|
92470c619e | ||
|
|
7cb3dfbd16 | ||
|
|
d69e4f41a1 | ||
|
|
4980254e89 | ||
|
|
5133286e04 | ||
|
|
ca2713a12c | ||
|
|
4981823c37 | ||
|
|
5d31e414f0 | ||
|
|
8ec2b9d0cd | ||
|
|
bd920dfc86 | ||
|
|
b5540a9958 | ||
|
|
778988de58 | ||
|
|
5b1437552d | ||
|
|
e8a8fbe6ac | ||
|
|
5c0a074219 | ||
|
|
58ec74bb68 | ||
|
|
6ac82bda40 | ||
|
|
2c6e86840a | ||
|
|
df85fc6b7d | ||
|
|
89804dafd1 | ||
|
|
98eaee1234 | ||
|
|
433edafddd | ||
|
|
e39d5741b6 | ||
|
|
21c08cbe63 | ||
|
|
ec92a1d89c | ||
|
|
0f2c5dbce2 | ||
|
|
8eae5a908c | ||
|
|
a37f0fdee6 | ||
|
|
08799aac18 | ||
|
|
06531e0fb8 | ||
|
|
0f56ead24f | ||
|
|
922caa76da | ||
|
|
0acb1f6b6d | ||
|
|
8d645ca404 | ||
|
|
a5c4f30f57 | ||
|
|
562a05adf5 | ||
|
|
de77e06b18 | ||
|
|
03c499d822 | ||
|
|
169d08f3b6 | ||
|
|
437807a9e0 | ||
|
|
4866fd74b5 | ||
|
|
426ba69afd | ||
|
|
74f87b570d | ||
|
|
fed5f6df52 | ||
|
|
5cc160473c | ||
|
|
4833157061 | ||
|
|
a0c8518d22 | ||
|
|
c0c816d3db | ||
|
|
ac47de72ee | ||
|
|
d631865f71 | ||
|
|
4ee6562e35 | ||
|
|
19f80b9b4c | ||
|
|
949deacd6d | ||
|
|
b0f4a91878 | ||
|
|
68f2e79056 | ||
|
|
43e68e1bbf | ||
|
|
5033323b7c | ||
|
|
7519b4a6b2 | ||
|
|
e6eedefec4 | ||
|
|
845d6a3d87 | ||
|
|
f75da289c2 | ||
|
|
063a6447c0 | ||
|
|
4dac730412 | ||
|
|
de6e0f645f | ||
|
|
b26ab50c8d | ||
|
|
423f8110b9 | ||
|
|
84ae49ed2a | ||
|
|
fb291c5411 | ||
|
|
901798055b | ||
|
|
d32d599098 | ||
|
|
76e02cf148 | ||
|
|
f19b4675ad | ||
|
|
4f1640b70a | ||
|
|
c1d17ec8b2 | ||
|
|
d2f1268520 | ||
|
|
b72afc2270 | ||
|
|
de0c625f88 | ||
|
|
29c9f3ecac | ||
|
|
a321095daf | ||
|
|
ced18da65a | ||
|
|
1a642ad7b4 | ||
|
|
838f196937 | ||
|
|
6af5166aa5 | ||
|
|
7935fb6616 | ||
|
|
ed567065b4 | ||
|
|
06035fb6f0 | ||
|
|
c1af0a087d | ||
|
|
6067c5dfcf | ||
|
|
bf7b9637f7 | ||
|
|
c552104413 | ||
|
|
6fd23cf6a0 | ||
|
|
e2f59383d6 | ||
|
|
8b92135a80 | ||
|
|
aef4a30512 | ||
|
|
ace8bd75e7 | ||
|
|
2e461b4e7a | ||
|
|
3f87e939c9 | ||
|
|
1d9dfc5102 | ||
|
|
80a94d3778 | ||
|
|
39d66faf4e | ||
|
|
c50e11c75b | ||
|
|
9a3ebb56cb | ||
|
|
1d1495453a | ||
|
|
26cfc485c2 | ||
|
|
83b4da282a | ||
|
|
ea972effb4 | ||
|
|
9686761c3d | ||
|
|
13a5a4a263 | ||
|
|
339919cfff | ||
|
|
2594a7caa5 | ||
|
|
2966be4fc4 | ||
|
|
5e21a7df9c | ||
|
|
64eb00f2ee | ||
|
|
00928ae709 | ||
|
|
bbb912479b | ||
|
|
5b16589360 | ||
|
|
39674fc769 | ||
|
|
bdaf70f26b | ||
|
|
1de20d1583 | ||
|
|
596db5fefc | ||
|
|
7ee56bd6ed |
@@ -2,5 +2,8 @@ Dockerfile
|
||||
.dockerignore
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
*.md
|
||||
.git
|
||||
.github
|
||||
LICENSE
|
||||
docs/
|
||||
|
||||
@@ -3,7 +3,6 @@ module.exports = {
|
||||
'mantine',
|
||||
'plugin:@next/next/recommended',
|
||||
'plugin:jest/recommended',
|
||||
'plugin:storybook/recommended',
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
|
||||
10
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
10
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
@@ -22,13 +22,3 @@ body:
|
||||
- High (App breaking feature)
|
||||
validations:
|
||||
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
|
||||
|
||||
7
.github/pull_request_template.md
vendored
7
.github/pull_request_template.md
vendored
@@ -14,10 +14,3 @@
|
||||
|
||||
### Screenshot _(if applicable)_
|
||||
> 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
|
||||
|
||||
103
.github/workflows/docker.yml
vendored
103
.github/workflows/docker.yml
vendored
@@ -1,6 +1,8 @@
|
||||
name: Master docker CI
|
||||
# Workflow to build and publish docker image
|
||||
|
||||
name: Master CI
|
||||
# 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:
|
||||
push:
|
||||
branches: [master]
|
||||
@@ -22,72 +24,46 @@ jobs:
|
||||
# Push image to GitHub Packages.
|
||||
# See also https://docs.docker.com/docker-hub/builds/
|
||||
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 --immutable
|
||||
- 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
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
|
||||
- name: Setup
|
||||
uses: actions/setup-node@v3
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
- uses: actions/cache@v2
|
||||
id: restore-build
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- 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:
|
||||
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: |
|
||||
./next.config.js
|
||||
./pages/
|
||||
./public/
|
||||
./.next/static/
|
||||
./.next/standalone/
|
||||
./packages.json
|
||||
key: ${{ github.sha }}
|
||||
${{ 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 --immutable
|
||||
|
||||
- run: yarn build
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
@@ -98,10 +74,13 @@ jobs:
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=pep440,pattern={{version}}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
@@ -117,3 +96,5 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
73
.github/workflows/docker_dev.yml
vendored
73
.github/workflows/docker_dev.yml
vendored
@@ -15,9 +15,9 @@ on:
|
||||
- '**.md'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tags:
|
||||
requierd: true
|
||||
description: 'Tags to deploy to'
|
||||
tag:
|
||||
required: true
|
||||
description: 'Tag to deploy to'
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
@@ -30,6 +30,9 @@ jobs:
|
||||
# See also https://docs.docker.com/docker-hub/builds/
|
||||
yarn_install_and_build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
|
||||
- name: Setup
|
||||
@@ -40,68 +43,34 @@ jobs:
|
||||
|
||||
- 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'`)
|
||||
run: echo "::set-output name=dir::$(yarn config get cacheFolder)"
|
||||
|
||||
- uses: actions/cache@v3
|
||||
id: yarn-cache
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: ${{ runner.os }}-yarn-
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
|
||||
- name: Nextjs cache
|
||||
- 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
|
||||
# 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') }}-
|
||||
restore-keys: |
|
||||
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
|
||||
|
||||
- run: yarn install --immutable
|
||||
|
||||
- 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
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- uses: actions/cache@v2
|
||||
id: restore-build
|
||||
with:
|
||||
path: |
|
||||
./next.config.js
|
||||
./pages/
|
||||
./public/
|
||||
./.next/static/
|
||||
./.next/standalone/
|
||||
./packages.json
|
||||
key: ${{ github.sha }}
|
||||
|
||||
- name: Docker meta
|
||||
if: github.event_name != 'pull_request'
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
@@ -110,7 +79,8 @@ jobs:
|
||||
# generate Docker tags based on the following events/attributes
|
||||
tags: |
|
||||
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
|
||||
uses: docker/setup-qemu-action@v2
|
||||
@@ -127,6 +97,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
@@ -134,3 +105,5 @@ jobs:
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
module.exports = {
|
||||
stories: ['../src/components/**/*.story.mdx', '../src/components/**/*.story.*'],
|
||||
addons: [
|
||||
'@storybook/addon-links',
|
||||
'storybook-addon-mock/register',
|
||||
'@storybook/addon-essentials',
|
||||
],
|
||||
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;
|
||||
},
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
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 withGlobalStyles withNormalizeCSS>
|
||||
<NotificationsProvider>{props.children}</NotificationsProvider>
|
||||
</MantineProvider>
|
||||
</ColorSchemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const decorators = [(renderStory: Function) => <ThemeWrapper>{renderStory()}</ThemeWrapper>];
|
||||
28
.vscode/launch.json
vendored
Normal file
28
.vscode/launch.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
22
Dockerfile
22
Dockerfile
@@ -1,13 +1,21 @@
|
||||
FROM node:16-alpine
|
||||
WORKDIR /app
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
ENV NODE_ENV production
|
||||
COPY /next.config.js ./
|
||||
COPY /public ./public
|
||||
COPY /package.json ./package.json
|
||||
# Automatically leverage output traces to reduce image size. https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY /.next/standalone ./
|
||||
COPY /.next/static ./.next/static
|
||||
|
||||
COPY next.config.js ./
|
||||
COPY public ./public
|
||||
COPY package.json ./package.json
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY .next/standalone ./
|
||||
COPY .next/static ./.next/static
|
||||
|
||||
EXPOSE 7575
|
||||
|
||||
ENV PORT 7575
|
||||
VOLUME /app/data/configs
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
15
README.md
15
README.md
@@ -21,7 +21,7 @@
|
||||
<i>Join the discord! — Don't forget to star the repo if you are enjoying the project!</i>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://homarr.ajnart.fr/"><strong> Demo ↗️ </strong></a> • <a href="#-installation"><strong> Install ➡️ </strong></a> • <a href="https://github.com/ajnart/homarr/wiki"><strong> Read the Wiki 📄 </strong></a>
|
||||
<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>
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -33,7 +33,7 @@ Homarr is a simple and lightweight homepage for your server, that helps you easi
|
||||
|
||||
It integrates with the services you use to display information on the homepage (E.g. Show upcoming Sonarr/Radarr releases).
|
||||
|
||||
For a full list of integrations look at: [wiki/integrations](#).
|
||||
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:
|
||||
|
||||
@@ -42,7 +42,7 @@ If you have any questions about Homarr or want to share information with us, ple
|
||||
|
||||
*Before you file an [issue](https://github.com/ajnart/homarr/issues/new/choose), make sure you have the read [known issues](#-known-issues) section.*
|
||||
|
||||
**For more information, [read the wiki!](https://github.com/ajnart/homarr/wiki)**
|
||||
**For more information, [read the documentation!](https://homarr.vercel.app/docs/about)**
|
||||
|
||||
<details>
|
||||
<summary><b>Table of Contents</b></summary>
|
||||
@@ -64,9 +64,9 @@ If you have any questions about Homarr or want to share information with us, ple
|
||||
|
||||
## ✨ Features
|
||||
- Integrates with services you use.
|
||||
- Search the web direcetly from your homepage.
|
||||
- Search the web directly from your homepage.
|
||||
- Real-time status indicator for every service.
|
||||
- Automatically finds icons while you type the name of a serivce.
|
||||
- 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.
|
||||
@@ -195,10 +195,7 @@ SOFTWARE.
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<i>Thank you for visiting! <b>For more information <a href="https://github.com/ajnart/homarr/wiki">read the wiki!</a></b></i>
|
||||
<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/>
|
||||
<a href="https://trackgit.com">
|
||||
<img src="https://us-central1-trackgit-analytics.cloudfunctions.net/token/ping/l3khzc3a3pexzw5w5whl" alt="trackgit-views" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -15,9 +15,6 @@
|
||||
"modules": {
|
||||
"Search Bar": {
|
||||
"enabled": true
|
||||
},
|
||||
"Date": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
export const REPO_URL = 'ajnart/homarr';
|
||||
export const CURRENT_VERSION = 'v0.6.0';
|
||||
export const CURRENT_VERSION = 'v0.9.2';
|
||||
|
||||
@@ -5,12 +5,12 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
||||
});
|
||||
|
||||
module.exports = withBundleAnalyzer({
|
||||
reactStrictMode: false,
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
images: {
|
||||
domains: ['cdn.jsdelivr.net'],
|
||||
},
|
||||
reactStrictMode: false,
|
||||
experimental: {
|
||||
outputStandalone: true,
|
||||
},
|
||||
basePath: env.BASE_URL,
|
||||
output: 'standalone',
|
||||
});
|
||||
|
||||
100
package.json
100
package.json
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "0.6.0",
|
||||
"version": "0.9.2",
|
||||
"description": "Homarr - A homepage for your server.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ajnart/homarr"
|
||||
@@ -19,69 +20,72 @@
|
||||
"prettier:check": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"prettier:write": "prettier --write \"**/*.{ts,tsx}\"",
|
||||
"test": "npm run prettier:check && npm run lint && npm run typecheck && npm run jest",
|
||||
"storybook": "start-storybook -p 7001",
|
||||
"storybook:build": "build-storybook",
|
||||
"ci": "yarn test && yarn lint --fix && yarn typecheck && yarn prettier:write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ctrl/deluge": "^4.0.0",
|
||||
"@ctrl/qbittorrent": "^4.0.0",
|
||||
"@ctrl/shared-torrent": "^4.1.0",
|
||||
"@dnd-kit/core": "^6.0.1",
|
||||
"@dnd-kit/sortable": "^7.0.0",
|
||||
"@ctrl/deluge": "^4.1.0",
|
||||
"@ctrl/qbittorrent": "^4.1.0",
|
||||
"@ctrl/shared-torrent": "^4.1.1",
|
||||
"@ctrl/transmission": "^4.1.1",
|
||||
"@dnd-kit/core": "^6.0.5",
|
||||
"@dnd-kit/sortable": "^7.0.1",
|
||||
"@dnd-kit/utilities": "^3.2.0",
|
||||
"@mantine/core": "^4.2.6",
|
||||
"@mantine/dates": "^4.2.6",
|
||||
"@mantine/dropzone": "^4.2.6",
|
||||
"@mantine/form": "^4.2.6",
|
||||
"@mantine/hooks": "^4.2.6",
|
||||
"@mantine/next": "^4.2.6",
|
||||
"@mantine/notifications": "^4.2.6",
|
||||
"@mantine/prism": "^4.2.6",
|
||||
"@emotion/react": "^11.10.0",
|
||||
"@emotion/server": "^11.10.0",
|
||||
"@mantine/carousel": "^5.1.0",
|
||||
"@mantine/core": "^5.1.0",
|
||||
"@mantine/dates": "^5.1.0",
|
||||
"@mantine/dropzone": "^5.1.0",
|
||||
"@mantine/form": "^5.1.0",
|
||||
"@mantine/hooks": "^5.1.0",
|
||||
"@mantine/modals": "^5.1.0",
|
||||
"@mantine/next": "^5.1.0",
|
||||
"@mantine/notifications": "^5.1.0",
|
||||
"@mantine/prism": "^5.0.0",
|
||||
"@nivo/core": "^0.79.0",
|
||||
"@nivo/line": "^0.79.1",
|
||||
"@tabler/icons": "^1.68.0",
|
||||
"@tabler/icons": "^1.78.0",
|
||||
"add": "^2.0.6",
|
||||
"axios": "^0.27.2",
|
||||
"cookies-next": "^2.0.4",
|
||||
"dayjs": "^1.11.2",
|
||||
"framer-motion": "^6.3.1",
|
||||
"consola": "^2.15.3",
|
||||
"cookies-next": "^2.1.1",
|
||||
"dayjs": "^1.11.4",
|
||||
"dockerode": "^3.3.2",
|
||||
"embla-carousel-react": "^7.0.0",
|
||||
"framer-motion": "^6.5.1",
|
||||
"js-file-download": "^0.4.12",
|
||||
"next": "12.1.6",
|
||||
"prism-react-renderer": "^1.3.1",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"systeminformation": "^5.11.16",
|
||||
"uuid": "^8.3.2"
|
||||
"prism-react-renderer": "^1.3.5",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"sharp": "^0.30.7",
|
||||
"systeminformation": "^5.12.1",
|
||||
"uuid": "^8.3.2",
|
||||
"yarn": "^1.22.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.17.8",
|
||||
"@next/bundle-analyzer": "^12.1.4",
|
||||
"@next/eslint-plugin-next": "^12.1.4",
|
||||
"@storybook/react": "^6.5.4",
|
||||
"@types/node": "^17.0.23",
|
||||
"@types/react": "17.0.43",
|
||||
"@types/dockerode": "^3.3.9",
|
||||
"@types/node": "17.0.1",
|
||||
"@types/react": "17.0.1",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@typescript-eslint/eslint-plugin": "^5.16.0",
|
||||
"@typescript-eslint/parser": "^5.16.0",
|
||||
"eslint": "^8.11.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.30.7",
|
||||
"@typescript-eslint/parser": "^5.30.7",
|
||||
"eslint": "^8.20.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^16.1.0",
|
||||
"eslint-config-mantine": "1.1.0",
|
||||
"eslint-plugin-import": "^2.25.4",
|
||||
"eslint-plugin-jest": "^26.1.3",
|
||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||
"eslint-plugin-react": "^7.29.4",
|
||||
"eslint-plugin-react-hooks": "^4.3.0",
|
||||
"eslint-plugin-storybook": "^0.5.11",
|
||||
"eslint-plugin-testing-library": "^5.2.0",
|
||||
"eslint-config-airbnb-typescript": "^17.0.0",
|
||||
"eslint-config-mantine": "^2.0.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jest": "^26.6.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.6.1",
|
||||
"eslint-plugin-react": "^7.30.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-testing-library": "^5.5.1",
|
||||
"eslint-plugin-unused-imports": "^2.0.0",
|
||||
"jest": "^28.1.0",
|
||||
"prettier": "^2.6.2",
|
||||
"require-from-string": "^2.0.2",
|
||||
"typescript": "4.6.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "17.0.30"
|
||||
"jest": "^28.1.3",
|
||||
"prettier": "^2.7.1",
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
"packageManager": "yarn@3.2.1"
|
||||
}
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
import {
|
||||
Modal,
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
TextInput,
|
||||
Image,
|
||||
Button,
|
||||
Select,
|
||||
LoadingOverlay,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
PasswordInput,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Tabs,
|
||||
TextInput,
|
||||
Title,
|
||||
Anchor,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useState } from 'react';
|
||||
import { IconApps as Apps } from '@tabler/icons';
|
||||
import { IconApps } from '@tabler/icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { useConfig } from '../../tools/state';
|
||||
import { ServiceTypeList } from '../../tools/types';
|
||||
import { tryMatchPort, ServiceTypeList, StatusCodes } from '../../tools/types';
|
||||
import Tip from '../layout/Tip';
|
||||
|
||||
export function AddItemShelfButton(props: any) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
@@ -33,27 +39,29 @@ export function AddItemShelfButton(props: any) {
|
||||
>
|
||||
<AddAppShelfItemForm setOpened={setOpened} />
|
||||
</Modal>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="xl"
|
||||
color="blue"
|
||||
style={props.style}
|
||||
onClick={() => setOpened(true)}
|
||||
>
|
||||
<Tooltip label="Add a service">
|
||||
<Apps />
|
||||
</Tooltip>
|
||||
</ActionIcon>
|
||||
<Tooltip withinPortal label="Add a service">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="xl"
|
||||
color="blue"
|
||||
style={props.style}
|
||||
onClick={() => setOpened(true)}
|
||||
>
|
||||
<IconApps />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchIcon(name: string, form: any) {
|
||||
function MatchIcon(name: string | undefined, form: any) {
|
||||
if (name === undefined || name === '') return null;
|
||||
fetch(
|
||||
`https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/${name
|
||||
.replace(/\s+/g, '-')
|
||||
.toLowerCase()}.png`
|
||||
.toLowerCase()
|
||||
.replace(/^dash\.$/, 'dashdot')}.png`
|
||||
).then((res) => {
|
||||
if (res.ok) {
|
||||
form.setFieldValue('icon', res.url);
|
||||
@@ -64,28 +72,13 @@ function MatchIcon(name: string, form: any) {
|
||||
}
|
||||
|
||||
function MatchService(name: string, form: any) {
|
||||
const service = ServiceTypeList.find((s) => s === name);
|
||||
const service = ServiceTypeList.find((s) => s.toLowerCase() === name.toLowerCase());
|
||||
if (service) {
|
||||
form.setFieldValue('type', service);
|
||||
}
|
||||
}
|
||||
|
||||
function MatchPort(name: string, form: any) {
|
||||
const portmap = [
|
||||
{ name: 'qBittorrent', value: '8080' },
|
||||
{ name: 'Sonarr', value: '8989' },
|
||||
{ name: 'Radarr', value: '7878' },
|
||||
{ name: 'Lidarr', value: '8686' },
|
||||
{ name: 'Readarr', value: '8686' },
|
||||
{ name: 'Deluge', value: '8112' },
|
||||
{ name: 'Transmission', value: '9091' },
|
||||
];
|
||||
// Match name with portmap key
|
||||
const port = portmap.find((p) => p.name === name);
|
||||
if (port) {
|
||||
form.setFieldValue('url', `http://localhost:${port.value}`);
|
||||
}
|
||||
}
|
||||
const DEFAULT_ICON = '/favicon.png';
|
||||
|
||||
export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } & any) {
|
||||
const { setOpened } = props;
|
||||
@@ -93,35 +86,35 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
|
||||
// Extract all the categories from the services in config
|
||||
const categoryList = config.services.reduce((acc, cur) => {
|
||||
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({
|
||||
initialValues: {
|
||||
id: props.id ?? uuidv4(),
|
||||
type: props.type ?? 'Other',
|
||||
category: props.category ?? undefined,
|
||||
category: props.category ?? null,
|
||||
name: props.name ?? '',
|
||||
icon: props.icon ?? '/favicon.svg',
|
||||
icon: props.icon ?? DEFAULT_ICON,
|
||||
url: props.url ?? '',
|
||||
apiKey: props.apiKey ?? (undefined as unknown as string),
|
||||
username: props.username ?? (undefined as unknown as string),
|
||||
password: props.password ?? (undefined as unknown as string),
|
||||
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: {
|
||||
apiKey: () => null,
|
||||
// Validate icon with a regex
|
||||
icon: (value: string) => {
|
||||
// Regex to match everything that ends with and icon extension
|
||||
if (!value.match(/\.(png|jpg|jpeg|gif|svg)$/)) {
|
||||
return 'Please enter a valid icon URL';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
icon: (value: string) =>
|
||||
// Disable matching to allow any values
|
||||
null,
|
||||
// Validate url with a regex http/https
|
||||
url: (value: string) => {
|
||||
try {
|
||||
@@ -131,9 +124,29 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
|
||||
}
|
||||
return null;
|
||||
},
|
||||
status: (value: string[]) => {
|
||||
if (!value.length) {
|
||||
return 'Please select a status code';
|
||||
}
|
||||
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;
|
||||
@@ -145,7 +158,7 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
|
||||
|
||||
return (
|
||||
<>
|
||||
<Center>
|
||||
<Center mb="lg">
|
||||
<Image
|
||||
height={120}
|
||||
width={120}
|
||||
@@ -157,15 +170,22 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
|
||||
</Center>
|
||||
<form
|
||||
onSubmit={form.onSubmit(() => {
|
||||
const newForm = { ...form.values };
|
||||
if (newForm.newTab === true) newForm.newTab = undefined;
|
||||
if (newForm.openedUrl === '') newForm.openedUrl = 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 (config.services && config.services.find((s) => s.id === form.values.id)) {
|
||||
if (config.services && config.services.find((s) => s.id === newForm.id)) {
|
||||
setConfig({
|
||||
...config,
|
||||
// replace the found item by matching ID
|
||||
services: config.services.map((s) => {
|
||||
if (s.id === form.values.id) {
|
||||
if (s.id === newForm.id) {
|
||||
return {
|
||||
...form.values,
|
||||
...newForm,
|
||||
};
|
||||
}
|
||||
return s;
|
||||
@@ -174,140 +194,182 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
|
||||
} else {
|
||||
setConfig({
|
||||
...config,
|
||||
services: [...config.services, form.values],
|
||||
services: [...config.services, newForm],
|
||||
});
|
||||
}
|
||||
setOpened(false);
|
||||
form.reset();
|
||||
})}
|
||||
>
|
||||
<Group direction="column" grow>
|
||||
<TextInput
|
||||
required
|
||||
label="Service name"
|
||||
placeholder="Plex"
|
||||
value={form.values.name}
|
||||
onChange={(event) => {
|
||||
form.setFieldValue('name', event.currentTarget.value);
|
||||
MatchIcon(event.currentTarget.value, form);
|
||||
MatchService(event.currentTarget.value, form);
|
||||
MatchPort(event.currentTarget.value, form);
|
||||
}}
|
||||
error={form.errors.name && 'Invalid icon url'}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
required
|
||||
label="Icon url"
|
||||
placeholder="https://i.gifer.com/ANPC.gif"
|
||||
{...form.getInputProps('icon')}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label="Service url"
|
||||
placeholder="http://localhost:7575"
|
||||
{...form.getInputProps('url')}
|
||||
/>
|
||||
<Select
|
||||
label="Service type"
|
||||
defaultValue="Other"
|
||||
placeholder="Pick one"
|
||||
required
|
||||
searchable
|
||||
data={ServiceTypeList}
|
||||
{...form.getInputProps('type')}
|
||||
/>
|
||||
<Select
|
||||
label="Category"
|
||||
data={categoryList}
|
||||
placeholder="Select a category or create a new one"
|
||||
nothingFound="Nothing found"
|
||||
searchable
|
||||
clearable
|
||||
creatable
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
getCreateLabel={(query) => `+ Create "${query}"`}
|
||||
onCreate={(query) => {}}
|
||||
{...form.getInputProps('category')}
|
||||
/>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
{(form.values.type === 'Sonarr' ||
|
||||
form.values.type === 'Radarr' ||
|
||||
form.values.type === 'Lidarr' ||
|
||||
form.values.type === 'Readarr') && (
|
||||
<>
|
||||
<Tabs defaultValue="Options">
|
||||
<Tabs.List grow>
|
||||
<Tabs.Tab value="Options">Options</Tabs.Tab>
|
||||
<Tabs.Tab value="Advanced Options">Advanced options</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="Options">
|
||||
<Stack>
|
||||
<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'}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
alignSelf: 'center',
|
||||
fontSize: '0.75rem',
|
||||
textAlign: 'center',
|
||||
color: 'gray',
|
||||
}}
|
||||
>
|
||||
Tip: Get your API key{' '}
|
||||
<Anchor
|
||||
target="_blank"
|
||||
weight="bold"
|
||||
style={{ fontStyle: 'inherit', fontSize: 'inherit' }}
|
||||
href={`${hostname}/settings/general`}
|
||||
>
|
||||
here.
|
||||
</Anchor>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{form.values.type === 'qBittorrent' && (
|
||||
<>
|
||||
<TextInput
|
||||
required
|
||||
label="Username"
|
||||
placeholder="admin"
|
||||
value={form.values.username}
|
||||
onChange={(event) => {
|
||||
form.setFieldValue('username', event.currentTarget.value);
|
||||
}}
|
||||
error={form.errors.username && 'Invalid username'}
|
||||
label="Service name"
|
||||
placeholder="Plex"
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label="Password"
|
||||
placeholder="adminadmin"
|
||||
value={form.values.password}
|
||||
onChange={(event) => {
|
||||
form.setFieldValue('password', event.currentTarget.value);
|
||||
}}
|
||||
error={form.errors.password && 'Invalid password'}
|
||||
label="Icon URL"
|
||||
placeholder={DEFAULT_ICON}
|
||||
{...form.getInputProps('icon')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{form.values.type === 'Deluge' && (
|
||||
<>
|
||||
<TextInput
|
||||
required
|
||||
label="Password"
|
||||
placeholder="deluge"
|
||||
value={form.values.password}
|
||||
onChange={(event) => {
|
||||
form.setFieldValue('password', event.currentTarget.value);
|
||||
}}
|
||||
error={form.errors.password && 'Invalid password'}
|
||||
label="Service URL"
|
||||
placeholder="http://localhost:7575"
|
||||
{...form.getInputProps('url')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
label="On Click URL"
|
||||
placeholder="http://sonarr.example.com"
|
||||
{...form.getInputProps('openedUrl')}
|
||||
/>
|
||||
<Select
|
||||
label="Service type"
|
||||
defaultValue="Other"
|
||||
placeholder="Pick one"
|
||||
required
|
||||
searchable
|
||||
data={ServiceTypeList}
|
||||
{...form.getInputProps('type')}
|
||||
/>
|
||||
<Select
|
||||
label="Category"
|
||||
data={categories}
|
||||
placeholder="Select a category or create a new one"
|
||||
nothingFound="Nothing found"
|
||||
searchable
|
||||
clearable
|
||||
creatable
|
||||
onCreate={(query) => {
|
||||
const item = { value: query, label: query };
|
||||
setCategories([...InitialCategories, query]);
|
||||
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">
|
||||
<Button type="submit">{props.message ?? 'Add service'}</Button>
|
||||
</Group>
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { SimpleGrid } from '@mantine/core';
|
||||
import AppShelf from './AppShelf';
|
||||
import { AppShelfItem } from './AppShelfItem';
|
||||
|
||||
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>
|
||||
);
|
||||
@@ -1,28 +1,52 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Grid, Group, Title } from '@mantine/core';
|
||||
import { Accordion, Grid, Paper, Stack, useMantineColorScheme } 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 { SortableAppShelfItem, AppShelfItem } from './AppShelfItem';
|
||||
import { ModuleWrapper } from '../modules/moduleWrapper';
|
||||
import { DownloadsModule } from '../modules';
|
||||
import { ModuleMenu, ModuleWrapper } from '../../modules/moduleWrapper';
|
||||
import { DownloadsModule } from '../../modules';
|
||||
import DownloadComponent from '../../modules/downloads/DownloadsModule';
|
||||
|
||||
const AppShelf = (props: any) => {
|
||||
const [activeId, setActiveId] = useState(null);
|
||||
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: 250,
|
||||
delay: 500,
|
||||
tolerance: 5,
|
||||
},
|
||||
})
|
||||
@@ -47,15 +71,8 @@ const AppShelf = (props: any) => {
|
||||
|
||||
setActiveId(null);
|
||||
}
|
||||
// Extract all the categories from the services in config
|
||||
const categoryList = config.services.reduce((acc, cur) => {
|
||||
if (cur.category && !acc.includes(cur.category)) {
|
||||
acc.push(cur.category);
|
||||
}
|
||||
return acc;
|
||||
}, [] as string[]);
|
||||
|
||||
const item = (filter?: string) => {
|
||||
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) {
|
||||
@@ -75,7 +92,14 @@ const AppShelf = (props: any) => {
|
||||
<SortableContext items={config.services}>
|
||||
<Grid gutter="xl" align="center">
|
||||
{filtered.map((service) => (
|
||||
<Grid.Col key={service.id} span={6} xl={2} xs={4} sm={3} md={3}>
|
||||
<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>
|
||||
))}
|
||||
@@ -99,34 +123,65 @@ const AppShelf = (props: any) => {
|
||||
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 (
|
||||
// Return one item for each category
|
||||
<Group grow direction="column">
|
||||
{categoryList.map((category) => (
|
||||
<>
|
||||
<Title order={3} key={category}>
|
||||
{category}
|
||||
</Title>
|
||||
{item(category)}
|
||||
</>
|
||||
))}
|
||||
{/* Return the item for all services without category */}
|
||||
{noCategory && noCategory.length > 0 ? (
|
||||
<>
|
||||
<Title order={3}>Other</Title>
|
||||
{item()}
|
||||
</>
|
||||
) : null}
|
||||
<ModuleWrapper mt="xl" module={DownloadsModule} />
|
||||
</Group>
|
||||
// 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 (
|
||||
<Group grow direction="column">
|
||||
{item()}
|
||||
<Stack>
|
||||
{getItems()}
|
||||
<ModuleWrapper mt="xl" module={DownloadsModule} />
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { Text, Card, Anchor, AspectRatio, Image, Center, createStyles } from '@mantine/core';
|
||||
import {
|
||||
Text,
|
||||
Card,
|
||||
Anchor,
|
||||
AspectRatio,
|
||||
Center,
|
||||
createStyles,
|
||||
useMantineColorScheme,
|
||||
Image,
|
||||
} from '@mantine/core';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useState } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { serviceItem } from '../../tools/types';
|
||||
import PingComponent from '../modules/ping/PingModule';
|
||||
import PingComponent from '../../modules/ping/PingModule';
|
||||
import AppShelfMenu from './AppShelfMenu';
|
||||
import { useConfig } from '../../tools/state';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
item: {
|
||||
@@ -15,6 +25,9 @@ const useStyles = createStyles((theme) => ({
|
||||
boxShadow: `${theme.shadows.md} !important`,
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
[theme.fn.smallerThan('sm')]: {
|
||||
WebkitUserSelect: 'none',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -38,7 +51,9 @@ export function SortableAppShelfItem(props: any) {
|
||||
export function AppShelfItem(props: any) {
|
||||
const { service }: { service: serviceItem } = props;
|
||||
const [hovering, setHovering] = useState(false);
|
||||
const { classes, theme } = useStyles();
|
||||
const { config } = useConfig();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const { classes } = useStyles();
|
||||
return (
|
||||
<motion.div
|
||||
animate={{
|
||||
@@ -54,11 +69,22 @@ export function AppShelfItem(props: any) {
|
||||
setHovering(false);
|
||||
}}
|
||||
>
|
||||
<Card withBorder radius="lg" shadow="md" className={classes.item}>
|
||||
<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="_blank"
|
||||
href={service.url}
|
||||
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}>
|
||||
@@ -94,19 +120,21 @@ export function AppShelfItem(props: any) {
|
||||
scale: 1.1,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
styles={{ root: { cursor: 'pointer' } }}
|
||||
width={80}
|
||||
height={80}
|
||||
src={service.icon}
|
||||
fit="contain"
|
||||
onClick={() => {
|
||||
window.open(service.url);
|
||||
}}
|
||||
/>
|
||||
<Anchor
|
||||
href={service.openedUrl ?? service.url}
|
||||
target={service.newTab === false ? '_top' : '_blank'}
|
||||
>
|
||||
<Image
|
||||
styles={{ root: { cursor: 'pointer' } }}
|
||||
width={80}
|
||||
height={80}
|
||||
src={service.icon}
|
||||
fit="contain"
|
||||
/>
|
||||
</Anchor>
|
||||
</motion.i>
|
||||
</AspectRatio>
|
||||
<PingComponent url={service.url} />
|
||||
<PingComponent url={service.url} status={service.status} />
|
||||
</Card.Section>
|
||||
</Center>
|
||||
</Card>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { Menu, Modal, Text, useMantineTheme } from '@mantine/core';
|
||||
import { ActionIcon, Menu, Modal, Text, useMantineTheme } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { useState } from 'react';
|
||||
import { IconCheck as Check, IconEdit as Edit, IconTrash as Trash } from '@tabler/icons';
|
||||
import { IconCheck as Check, IconEdit as Edit, IconMenu, IconTrash as Trash } from '@tabler/icons';
|
||||
import { useConfig } from '../../tools/state';
|
||||
import { serviceItem } from '../../tools/types';
|
||||
import { AddAppShelfItemForm } from './AddAppShelfItem';
|
||||
import { useColorTheme } from '../../tools/color';
|
||||
|
||||
export default function AppShelfMenu(props: any) {
|
||||
const { service }: { service: serviceItem } = props;
|
||||
const { config, setConfig } = useConfig();
|
||||
const { secondaryColor } = useColorTheme();
|
||||
const theme = useMantineTheme();
|
||||
const [opened, setOpened] = useState(false);
|
||||
return (
|
||||
@@ -20,64 +22,57 @@ export default function AppShelfMenu(props: any) {
|
||||
onClose={() => setOpened(false)}
|
||||
title="Modify a service"
|
||||
>
|
||||
<AddAppShelfItemForm
|
||||
setOpened={setOpened}
|
||||
name={service.name}
|
||||
id={service.id}
|
||||
category={service.category}
|
||||
type={service.type}
|
||||
url={service.url}
|
||||
icon={service.icon}
|
||||
apiKey={service.apiKey}
|
||||
username={service.username}
|
||||
password={service.password}
|
||||
message="Save service"
|
||||
/>
|
||||
<AddAppShelfItemForm setOpened={setOpened} {...service} message="Save service" />
|
||||
</Modal>
|
||||
<Menu
|
||||
position="right"
|
||||
radius="md"
|
||||
withinPortal
|
||||
width={150}
|
||||
shadow="xl"
|
||||
withArrow
|
||||
radius="md"
|
||||
position="right"
|
||||
styles={{
|
||||
body: {
|
||||
dropdown: {
|
||||
// Add shadow and elevation to the body
|
||||
boxShadow: '0 0 14px 14px rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Menu.Label>Settings</Menu.Label>
|
||||
<Menu.Item
|
||||
color="primary"
|
||||
icon={<Edit />}
|
||||
// TODO: #2 Add the ability to edit the service.
|
||||
onClick={() => setOpened(true)}
|
||||
>
|
||||
Edit
|
||||
</Menu.Item>
|
||||
<Menu.Label>Danger zone</Menu.Label>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
onClick={(e: any) => {
|
||||
setConfig({
|
||||
...config,
|
||||
services: config.services.filter((s) => s.id !== service.id),
|
||||
});
|
||||
showNotification({
|
||||
autoClose: 5000,
|
||||
title: (
|
||||
<Text>
|
||||
Service <b>{service.name}</b> removed successfully!
|
||||
</Text>
|
||||
),
|
||||
color: 'green',
|
||||
icon: <Check />,
|
||||
message: undefined,
|
||||
});
|
||||
}}
|
||||
icon={<Trash />}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
<Menu.Target>
|
||||
<ActionIcon style={{}}>
|
||||
<IconMenu />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Settings</Menu.Label>
|
||||
<Menu.Item color={secondaryColor} icon={<Edit />} onClick={() => setOpened(true)}>
|
||||
Edit
|
||||
</Menu.Item>
|
||||
<Menu.Label>Danger zone</Menu.Label>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
onClick={(e: any) => {
|
||||
setConfig({
|
||||
...config,
|
||||
services: config.services.filter((s) => s.id !== service.id),
|
||||
});
|
||||
showNotification({
|
||||
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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { createStyles, Switch, Group, useMantineColorScheme, Kbd } from '@mantine/core';
|
||||
import { IconSun as Sun, IconMoonStars as MoonStars } from '@tabler/icons';
|
||||
import { useConfig } from '../../tools/state';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
root: {
|
||||
@@ -29,6 +30,7 @@ const useStyles = createStyles((theme) => ({
|
||||
}));
|
||||
|
||||
export function ColorSchemeSwitch() {
|
||||
const { config } = useConfig();
|
||||
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||
const { classes, cx } = useStyles();
|
||||
|
||||
|
||||
@@ -1,32 +1,37 @@
|
||||
import { Center, Loader, Select, Tooltip } from '@mantine/core';
|
||||
import { setCookies } from 'cookies-next';
|
||||
import { setCookie } from 'cookies-next';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useConfig } from '../../tools/state';
|
||||
|
||||
export default function ConfigChanger() {
|
||||
const { config, loadConfig, setConfig, getConfigs } = useConfig();
|
||||
const [configList, setConfigList] = useState([] as string[]);
|
||||
const [configList, setConfigList] = useState<string[]>([]);
|
||||
const [value, setValue] = useState(config.name);
|
||||
useEffect(() => {
|
||||
getConfigs().then((configs) => setConfigList(configs));
|
||||
// setConfig(initialConfig);
|
||||
}, [config]);
|
||||
// If configlist is empty, return a loading indicator
|
||||
if (configList.length === 0) {
|
||||
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 />
|
||||
</Tooltip>
|
||||
</Center>
|
||||
</Center>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
// return <Select data={[{ value: '1', label: '1' },]} onChange={(e) => console.log(e)} value="1" />;
|
||||
return (
|
||||
<Select
|
||||
defaultValue={config.name}
|
||||
label="Config loader"
|
||||
value={value}
|
||||
defaultValue={config.name}
|
||||
onChange={(e) => {
|
||||
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={
|
||||
// If config list is empty, return the current config
|
||||
|
||||
@@ -1,68 +1,18 @@
|
||||
import { Group, Text, useMantineTheme, MantineTheme } from '@mantine/core';
|
||||
import {
|
||||
IconUpload as Upload,
|
||||
IconPhoto as Photo,
|
||||
IconX as X,
|
||||
IconCheck as Check,
|
||||
TablerIcon,
|
||||
} from '@tabler/icons';
|
||||
import { DropzoneStatus, FullScreenDropzone } from '@mantine/dropzone';
|
||||
import { Group, Text, useMantineTheme } from '@mantine/core';
|
||||
import { IconX as X, IconCheck as Check, IconX, IconPhoto, IconUpload } from '@tabler/icons';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { useRef } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { setCookies } from 'cookies-next';
|
||||
import { setCookie } from 'cookies-next';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useConfig } from '../../tools/state';
|
||||
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) {
|
||||
const { setConfig } = useConfig();
|
||||
const theme = useMantineTheme();
|
||||
const router = useRouter();
|
||||
const openRef = useRef<() => void>();
|
||||
|
||||
return (
|
||||
<FullScreenDropzone
|
||||
<Dropzone.FullScreen
|
||||
onDrop={(files) => {
|
||||
files[0].text().then((e) => {
|
||||
try {
|
||||
@@ -90,14 +40,41 @@ export default function LoadConfigComponent(props: any) {
|
||||
icon: <Check />,
|
||||
message: undefined,
|
||||
});
|
||||
setCookies('config-name', newConfig.name, { maxAge: 60 * 60 * 24 * 30 });
|
||||
setCookie('config-name', newConfig.name, {
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
sameSite: 'strict',
|
||||
});
|
||||
const migratedConfig = migrateToIdConfig(newConfig);
|
||||
setConfig(migratedConfig);
|
||||
});
|
||||
}}
|
||||
accept={['application/json']}
|
||||
>
|
||||
{(status) => dropzoneChildren(status, theme)}
|
||||
</FullScreenDropzone>
|
||||
<Group position="center" spacing="xl" style={{ minHeight: 220, pointerEvents: 'none' }}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function SaveConfigComponent(props: any) {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Group>
|
||||
<Group spacing="xs">
|
||||
<Modal
|
||||
radius="md"
|
||||
opened={opened}
|
||||
@@ -59,10 +59,11 @@ export default function SaveConfigComponent(props: any) {
|
||||
</Group>
|
||||
</form>
|
||||
</Modal>
|
||||
<Button leftIcon={<Download />} variant="outline" onClick={onClick}>
|
||||
<Button size="xs" leftIcon={<Download />} variant="outline" onClick={onClick}>
|
||||
Download config
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
leftIcon={<Trash />}
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
@@ -93,7 +94,7 @@ export default function SaveConfigComponent(props: any) {
|
||||
>
|
||||
Delete config
|
||||
</Button>
|
||||
<Button leftIcon={<Plus />} variant="outline" onClick={() => setOpened(true)}>
|
||||
<Button size="xs" leftIcon={<Plus />} variant="outline" onClick={() => setOpened(true)}>
|
||||
Save a copy
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
65
src/components/Settings/AdvancedSettings.tsx
Normal file
65
src/components/Settings/AdvancedSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/components/Settings/AppCardWidthSelector.tsx
Normal file
32
src/components/Settings/AppCardWidthSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
src/components/Settings/ColorSelector.tsx
Normal file
88
src/components/Settings/ColorSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
src/components/Settings/CommonSettings.tsx
Normal file
86
src/components/Settings/CommonSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src/components/Settings/Credits.tsx
Normal file
44
src/components/Settings/Credits.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,35 @@
|
||||
import { Group, Switch } from '@mantine/core';
|
||||
import * as Modules from '../modules';
|
||||
import { Checkbox, SimpleGrid, Stack, Title } from '@mantine/core';
|
||||
import * as Modules from '../../modules';
|
||||
import { useConfig } from '../../tools/state';
|
||||
|
||||
export default function ModuleEnabler(props: any) {
|
||||
const { config, setConfig } = useConfig();
|
||||
const modules = Object.values(Modules).map((module) => module);
|
||||
return (
|
||||
<Group direction="column">
|
||||
{modules.map((module) => (
|
||||
<Switch
|
||||
key={module.title}
|
||||
size="md"
|
||||
checked={config.modules?.[module.title]?.enabled ?? false}
|
||||
label={`Enable ${module.title}`}
|
||||
onChange={(e) => {
|
||||
setConfig({
|
||||
...config,
|
||||
modules: {
|
||||
...config.modules,
|
||||
[module.title]: {
|
||||
...config.modules?.[module.title],
|
||||
enabled: e.currentTarget.checked,
|
||||
<Stack>
|
||||
<Title order={4}>Module enabler</Title>
|
||||
<SimpleGrid cols={3} spacing="xs">
|
||||
{modules.map((module) => (
|
||||
<Checkbox
|
||||
key={module.title}
|
||||
size="md"
|
||||
checked={config.modules?.[module.title]?.enabled ?? false}
|
||||
label={`${module.title}`}
|
||||
onChange={(e) => {
|
||||
setConfig({
|
||||
...config,
|
||||
modules: {
|
||||
...config.modules,
|
||||
[module.title]: {
|
||||
...config.modules?.[module.title],
|
||||
enabled: e.currentTarget.checked,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
44
src/components/Settings/OpacitySelector.tsx
Normal file
44
src/components/Settings/OpacitySelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { SettingsMenuButton } from './SettingsMenu';
|
||||
|
||||
export default {
|
||||
title: ' menu',
|
||||
args: {
|
||||
opened: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = (args: any) => <SettingsMenuButton {...args} />;
|
||||
@@ -1,131 +1,29 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Group,
|
||||
Title,
|
||||
Text,
|
||||
Tooltip,
|
||||
SegmentedControl,
|
||||
TextInput,
|
||||
Drawer,
|
||||
Anchor,
|
||||
} from '@mantine/core';
|
||||
import { useColorScheme, useHotkeys } from '@mantine/hooks';
|
||||
import { ActionIcon, Title, Tooltip, Drawer, Tabs, ScrollArea } from '@mantine/core';
|
||||
import { useHotkeys } from '@mantine/hooks';
|
||||
import { useState } from 'react';
|
||||
import { IconBrandGithub as BrandGithub, IconSettings } from '@tabler/icons';
|
||||
import { CURRENT_VERSION } 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';
|
||||
import { IconSettings } from '@tabler/icons';
|
||||
import AdvancedSettings from './AdvancedSettings';
|
||||
import CommonSettings from './CommonSettings';
|
||||
import Credits from './Credits';
|
||||
|
||||
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 (
|
||||
<Group direction="column" grow>
|
||||
<Group grow direction="column" spacing={0}>
|
||||
<Text>Search engine</Text>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
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' && (
|
||||
<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,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
<ModuleEnabler />
|
||||
<ColorSchemeSwitch />
|
||||
<ConfigChanger />
|
||||
<SaveConfigComponent />
|
||||
<Text
|
||||
style={{
|
||||
alignSelf: 'center',
|
||||
fontSize: '0.75rem',
|
||||
textAlign: 'center',
|
||||
color: 'gray',
|
||||
}}
|
||||
>
|
||||
Tip: You can upload your config file by dragging and dropping it onto the page!
|
||||
</Text>
|
||||
<Group position="center" direction="row" mr="xs">
|
||||
<Group spacing={0}>
|
||||
<ActionIcon<'a'> component="a" href="https://github.com/ajnart/homarr" size="lg">
|
||||
<BrandGithub size={18} />
|
||||
</ActionIcon>
|
||||
<Text
|
||||
style={{
|
||||
position: 'relative',
|
||||
fontSize: '0.90rem',
|
||||
color: 'gray',
|
||||
}}
|
||||
>
|
||||
{CURRENT_VERSION}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: '0.90rem',
|
||||
textAlign: 'center',
|
||||
color: 'gray',
|
||||
}}
|
||||
>
|
||||
Made with ❤️ by @
|
||||
<Anchor
|
||||
href="https://github.com/ajnart"
|
||||
style={{ color: 'inherit', fontStyle: 'inherit', fontSize: 'inherit' }}
|
||||
>
|
||||
ajnart
|
||||
</Anchor>
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<Tabs defaultValue="Common">
|
||||
<Tabs.List grow>
|
||||
<Tabs.Tab value="Common">Common</Tabs.Tab>
|
||||
<Tabs.Tab value="Customizations">Customizations</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel data-autofocus value="Common">
|
||||
<ScrollArea style={{ height: '78vh' }} offsetScrollbars>
|
||||
<CommonSettings />
|
||||
</ScrollArea>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="Customizations">
|
||||
<ScrollArea style={{ height: '78vh' }} offsetScrollbars>
|
||||
<AdvancedSettings />
|
||||
</ScrollArea>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,27 +34,28 @@ export function SettingsMenuButton(props: any) {
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
size="auto"
|
||||
padding="xl"
|
||||
size="xl"
|
||||
padding="lg"
|
||||
position="right"
|
||||
title={<Title order={3}>Settings</Title>}
|
||||
title={<Title order={5}>Settings</Title>}
|
||||
opened={props.opened || opened}
|
||||
onClose={() => setOpened(false)}
|
||||
>
|
||||
<SettingsMenu />
|
||||
<Credits />
|
||||
</Drawer>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="xl"
|
||||
color="blue"
|
||||
style={props.style}
|
||||
onClick={() => setOpened(true)}
|
||||
>
|
||||
<Tooltip label="Settings">
|
||||
<Tooltip label="Settings">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="xl"
|
||||
color="blue"
|
||||
style={props.style}
|
||||
onClick={() => setOpened(true)}
|
||||
>
|
||||
<IconSettings />
|
||||
</Tooltip>
|
||||
</ActionIcon>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
100
src/components/Settings/ShadeSelector.tsx
Normal file
100
src/components/Settings/ShadeSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,36 @@
|
||||
import { Aside as MantineAside, Group } from '@mantine/core';
|
||||
import {
|
||||
WeatherModule,
|
||||
DateModule,
|
||||
CalendarModule,
|
||||
TotalDownloadsModule,
|
||||
SystemModule,
|
||||
} from '../modules';
|
||||
import { ModuleWrapper } from '../modules/moduleWrapper';
|
||||
import { Aside as MantineAside, createStyles } from '@mantine/core';
|
||||
import Widgets from './Widgets';
|
||||
|
||||
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 (
|
||||
<MantineAside
|
||||
pr="md"
|
||||
hiddenBreakpoint="md"
|
||||
hiddenBreakpoint="sm"
|
||||
hidden
|
||||
className={cx(classes.hide)}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
}}
|
||||
width={{
|
||||
base: 'auto',
|
||||
}}
|
||||
>
|
||||
<Group my="sm" grow direction="column" style={{ width: 300 }}>
|
||||
<ModuleWrapper module={CalendarModule} />
|
||||
<ModuleWrapper module={TotalDownloadsModule} />
|
||||
<ModuleWrapper module={WeatherModule} />
|
||||
<ModuleWrapper module={DateModule} />
|
||||
<ModuleWrapper module={SystemModule} />
|
||||
</Group>
|
||||
<Widgets />
|
||||
</MantineAside>
|
||||
);
|
||||
}
|
||||
|
||||
20
src/components/layout/Background.tsx
Normal file
20
src/components/layout/Background.tsx
Normal 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',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { createStyles, Footer as FooterComponent } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { CURRENT_VERSION, REPO_URL } from '../../../data/constants';
|
||||
import { IconAlertCircle as AlertCircle } from '@tabler/icons';
|
||||
import { CURRENT_VERSION, REPO_URL } from '../../../data/constants';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
footer: {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react';
|
||||
import { createStyles, Header as Head, Group, Box } from '@mantine/core';
|
||||
import { Logo } from './Logo';
|
||||
import SearchBar from '../modules/search/SearchModule';
|
||||
import { Box, createStyles, Group, Header as Head } from '@mantine/core';
|
||||
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;
|
||||
|
||||
@@ -13,6 +14,11 @@ const useStyles = createStyles((theme) => ({
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
burger: {
|
||||
[theme.fn.largerThan('sm')]: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export function Header(props: any) {
|
||||
@@ -20,12 +26,13 @@ export function Header(props: any) {
|
||||
|
||||
return (
|
||||
<Head height="auto">
|
||||
<Group m="xs" position="apart">
|
||||
<Group p="xs" position="apart">
|
||||
<Box className={classes.hide}>
|
||||
<Logo style={{ fontSize: 22 }} />
|
||||
</Box>
|
||||
<Group noWrap>
|
||||
<SearchBar />
|
||||
<DockerMenuButton />
|
||||
<SettingsMenuButton />
|
||||
<AddItemShelfButton />
|
||||
</Group>
|
||||
|
||||
14
src/components/layout/HeaderConfig.tsx
Normal file
14
src/components/layout/HeaderConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import { AppShell, createStyles } from '@mantine/core';
|
||||
import { Header } from './Header';
|
||||
import { Footer } from './Footer';
|
||||
import Aside from './Aside';
|
||||
import Navbar from './Navbar';
|
||||
import { HeaderConfig } from './HeaderConfig';
|
||||
import { Background } from './Background';
|
||||
import { useConfig } from '../../tools/state';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
main: {},
|
||||
@@ -9,8 +13,19 @@ const useStyles = createStyles((theme) => ({
|
||||
|
||||
export default function Layout({ children, style }: any) {
|
||||
const { classes, cx } = useStyles();
|
||||
const { config } = useConfig();
|
||||
const widgetPosition = config?.settings?.widgetPosition === 'left';
|
||||
|
||||
return (
|
||||
<AppShell aside={<Aside />} header={<Header />} footer={<Footer links={[]} />}>
|
||||
<AppShell
|
||||
fixed={false}
|
||||
header={<Header />}
|
||||
navbar={widgetPosition ? <Navbar /> : undefined}
|
||||
aside={widgetPosition ? undefined : <Aside />}
|
||||
footer={<Footer links={[]} />}
|
||||
>
|
||||
<HeaderConfig />
|
||||
<Background />
|
||||
<main
|
||||
className={cx(classes.main)}
|
||||
style={{
|
||||
|
||||
@@ -1,33 +1,44 @@
|
||||
import { Group, Image, Text } from '@mantine/core';
|
||||
import { NextLink } from '@mantine/next';
|
||||
import * as React from 'react';
|
||||
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 (
|
||||
<Group spacing="xs">
|
||||
<Image
|
||||
width={50}
|
||||
src="/imgs/logo.png"
|
||||
src={config.settings.logo || '/imgs/logo.png'}
|
||||
style={{
|
||||
position: 'relative',
|
||||
}}
|
||||
/>
|
||||
<NextLink
|
||||
href="/"
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
sx={style}
|
||||
weight="bold"
|
||||
variant="gradient"
|
||||
gradient={{ from: 'red', to: 'orange', deg: 145 }}
|
||||
{withoutText ? null : (
|
||||
<NextLink
|
||||
href="/"
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
Homarr
|
||||
</Text>
|
||||
</NextLink>
|
||||
<Text
|
||||
sx={style}
|
||||
weight="bold"
|
||||
variant="gradient"
|
||||
gradient={{
|
||||
from: primaryColor,
|
||||
to: secondaryColor,
|
||||
deg: 145,
|
||||
}}
|
||||
>
|
||||
{config.settings.title || 'Homarr'}
|
||||
</Text>
|
||||
</NextLink>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,37 @@
|
||||
import { Group, Navbar as MantineNavbar } from '@mantine/core';
|
||||
import { WeatherModule, DateModule } from '../modules';
|
||||
import { ModuleWrapper } from '../modules/moduleWrapper';
|
||||
import { createStyles, Navbar as MantineNavbar } from '@mantine/core';
|
||||
import Widgets from './Widgets';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
hide: {
|
||||
[theme.fn.smallerThan('xs')]: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
burger: {
|
||||
[theme.fn.largerThan('sm')]: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export default function Navbar() {
|
||||
const { classes, cx } = useStyles();
|
||||
|
||||
return (
|
||||
<MantineNavbar
|
||||
hiddenBreakpoint="lg"
|
||||
pl="md"
|
||||
hiddenBreakpoint="sm"
|
||||
hidden
|
||||
className={cx(classes.hide)}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
}}
|
||||
width={{
|
||||
base: 'auto',
|
||||
}}
|
||||
>
|
||||
<Group mt="sm" direction="column" align="center">
|
||||
<ModuleWrapper module={DateModule} />
|
||||
<ModuleWrapper module={WeatherModule} />
|
||||
<ModuleWrapper module={WeatherModule} />
|
||||
</Group>
|
||||
<Widgets />
|
||||
</MantineNavbar>
|
||||
);
|
||||
}
|
||||
|
||||
19
src/components/layout/Tip.tsx
Normal file
19
src/components/layout/Tip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/components/layout/Widgets.tsx
Normal file
16
src/components/layout/Widgets.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import CalendarComponent from './CalendarModule';
|
||||
|
||||
export default {
|
||||
title: 'Calendar component',
|
||||
};
|
||||
|
||||
export const Default = (args: any) => <CalendarComponent {...args} />;
|
||||
@@ -1,216 +0,0 @@
|
||||
/* eslint-disable react/no-children-prop */
|
||||
import { Box, Divider, Indicator, Popover, ScrollArea } 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 { useConfig } from '../../../tools/state';
|
||||
import { IModule } from '../modules';
|
||||
import {
|
||||
SonarrMediaDisplay,
|
||||
RadarrMediaDisplay,
|
||||
LidarrMediaDisplay,
|
||||
ReadarrMediaDisplay,
|
||||
} from '../common';
|
||||
import { serviceItem } from '../../../tools/types';
|
||||
|
||||
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 [lidarrMedias, setLidarrMedias] = useState([] as any);
|
||||
const [radarrMedias, setRadarrMedias] = useState([] as any);
|
||||
const [readarrMedias, setReadarrMedias] = useState([] as any);
|
||||
const sonarrService = config.services.filter((service) => service.type === 'Sonarr').at(0);
|
||||
const radarrService = config.services.filter((service) => service.type === 'Radarr').at(0);
|
||||
const lidarrService = config.services.filter((service) => service.type === 'Lidarr').at(0);
|
||||
const readarrService = config.services.filter((service) => service.type === 'Readarr').at(0);
|
||||
|
||||
function getMedias(service: serviceItem | undefined, type: string) {
|
||||
if (!service || !service.apiKey) {
|
||||
return Promise.resolve({ data: [] });
|
||||
}
|
||||
return axios.post(`/api/modules/calendar?type=${type}`, { ...service });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Filter only sonarr and radarr services
|
||||
|
||||
// Get the url and apiKey for all Sonarr and Radarr services
|
||||
getMedias(sonarrService, 'sonarr').then((res) => setSonarrMedias(res.data));
|
||||
getMedias(radarrService, 'radarr').then((res) => setRadarrMedias(res.data));
|
||||
getMedias(lidarrService, 'lidarr').then((res) => setLidarrMedias(res.data));
|
||||
getMedias(readarrService, 'readarr').then((res) => setReadarrMedias(res.data));
|
||||
}, [config.services]);
|
||||
|
||||
return (
|
||||
<Calendar
|
||||
onChange={(day: any) => {}}
|
||||
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, setOpened] = useState(false);
|
||||
|
||||
const day = renderdate.getDate();
|
||||
|
||||
const readarrFiltered = readarrmedias.filter((media: any) => {
|
||||
const date = new Date(media.releaseDate);
|
||||
return date.getDate() === day && date.getMonth() === renderdate.getMonth();
|
||||
});
|
||||
|
||||
const lidarrFiltered = lidarrmedias.filter((media: any) => {
|
||||
const date = new Date(media.releaseDate);
|
||||
// Return true if the date is renerdate without counting hours and minutes
|
||||
return date.getDate() === day && date.getMonth() === renderdate.getMonth();
|
||||
});
|
||||
const sonarrFiltered = sonarrmedias.filter((media: any) => {
|
||||
const date = new Date(media.airDate);
|
||||
// Return true if the date is renerdate without counting hours and minutes
|
||||
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 &&
|
||||
lidarrFiltered.length === 0 &&
|
||||
readarrFiltered.length === 0
|
||||
) {
|
||||
return <div>{day}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={() => {
|
||||
setOpened(true);
|
||||
}}
|
||||
>
|
||||
{readarrFiltered.length > 0 && (
|
||||
<Indicator
|
||||
size={10}
|
||||
withBorder
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
left: 8,
|
||||
}}
|
||||
color="red"
|
||||
children={null}
|
||||
/>
|
||||
)}
|
||||
{radarrFiltered.length > 0 && (
|
||||
<Indicator
|
||||
size={10}
|
||||
withBorder
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: 8,
|
||||
}}
|
||||
color="yellow"
|
||||
children={null}
|
||||
/>
|
||||
)}
|
||||
{sonarrFiltered.length > 0 && (
|
||||
<Indicator
|
||||
size={10}
|
||||
withBorder
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
}}
|
||||
color="blue"
|
||||
children={null}
|
||||
/>
|
||||
)}
|
||||
{lidarrFiltered.length > 0 && (
|
||||
<Indicator
|
||||
size={10}
|
||||
withBorder
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
}}
|
||||
color="green"
|
||||
children={undefined}
|
||||
/>
|
||||
)}
|
||||
<Popover
|
||||
position="left"
|
||||
radius="lg"
|
||||
shadow="xl"
|
||||
transition="pop"
|
||||
styles={{
|
||||
body: {
|
||||
boxShadow: '0 0 14px 14px rgba(0, 0, 0, 0.1), 0 14px 11px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
}}
|
||||
width={700}
|
||||
onClose={() => setOpened(false)}
|
||||
opened={opened}
|
||||
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>
|
||||
))}
|
||||
{lidarrFiltered.map((media: any, index: number) => (
|
||||
<React.Fragment key={index}>
|
||||
<LidarrMediaDisplay media={media} />
|
||||
{index < lidarrFiltered.length - 1 && <Divider variant="dashed" my="xl" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{readarrFiltered.map((media: any, index: number) => (
|
||||
<React.Fragment key={index}>
|
||||
<ReadarrMediaDisplay media={media} />
|
||||
{index < readarrFiltered.length - 1 && <Divider variant="dashed" my="xl" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</Popover>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
@@ -1,172 +0,0 @@
|
||||
import { Image, Group, Title, Badge, Text, ActionIcon, Anchor, ScrollArea } from '@mantine/core';
|
||||
import { IconLink as Link } from '@tabler/icons';
|
||||
import { useConfig } from '../../../tools/state';
|
||||
import { serviceItem } from '../../../tools/types';
|
||||
|
||||
export interface IMedia {
|
||||
overview: string;
|
||||
imdbId?: any;
|
||||
artist?: string;
|
||||
title: string;
|
||||
poster?: string;
|
||||
genres: string[];
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
}
|
||||
|
||||
export function MediaDisplay(props: { media: IMedia }) {
|
||||
const { media }: { media: IMedia } = props;
|
||||
return (
|
||||
<Group position="apart">
|
||||
<Text>
|
||||
{media.poster && (
|
||||
<Image
|
||||
style={{
|
||||
float: 'right',
|
||||
}}
|
||||
radius="md"
|
||||
fit="cover"
|
||||
src={media.poster}
|
||||
alt={media.title}
|
||||
width={250}
|
||||
height={400}
|
||||
/>
|
||||
)}
|
||||
<Group direction="column">
|
||||
<Group noWrap mr="sm" style={{ minWidth: 400 }}>
|
||||
<Title order={3}>{media.title}</Title>
|
||||
{media.imdbId && (
|
||||
<Anchor href={`https://www.imdb.com/title/${media.imdbId}`} target="_blank">
|
||||
<ActionIcon>
|
||||
<Link />
|
||||
</ActionIcon>
|
||||
</Anchor>
|
||||
)}
|
||||
</Group>
|
||||
{media.artist && (
|
||||
<Text
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
color: 'gray',
|
||||
}}
|
||||
>
|
||||
New release from {media.artist}
|
||||
</Text>
|
||||
)}
|
||||
{media.episodeNumber && media.seasonNumber && (
|
||||
<Text
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
color: 'gray',
|
||||
}}
|
||||
>
|
||||
Season {media.seasonNumber} episode {media.episodeNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Group direction="column" position="apart">
|
||||
<ScrollArea style={{ height: 250 }}>{media.overview}</ScrollArea>
|
||||
<Group align="center" position="center" spacing="xs">
|
||||
{media.genres.map((genre: string, i: number) => (
|
||||
<Badge size="sm" key={i}>
|
||||
{genre}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</Group>
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReadarrMediaDisplay(props: any) {
|
||||
const { media }: { media: any } = props;
|
||||
const { config } = useConfig();
|
||||
// Find lidarr in services
|
||||
const readarr = config.services.find((service: serviceItem) => service.type === 'Readarr');
|
||||
// Find a poster CoverType
|
||||
const poster = media.images.find((image: any) => image.coverType === 'cover');
|
||||
if (!readarr) {
|
||||
return null;
|
||||
}
|
||||
const baseUrl = new URL(readarr.url).origin;
|
||||
// Remove '/' from the end of the lidarr url
|
||||
const fullLink = `${baseUrl}${poster.url}`;
|
||||
// Return a movie poster containting the title and the description
|
||||
return (
|
||||
<MediaDisplay
|
||||
media={{
|
||||
title: media.title,
|
||||
poster: fullLink,
|
||||
artist: media.author.authorName,
|
||||
overview: media.overview,
|
||||
genres: media.genres,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LidarrMediaDisplay(props: any) {
|
||||
const { media }: { media: any } = props;
|
||||
const { config } = useConfig();
|
||||
// Find lidarr in services
|
||||
const lidarr = config.services.find((service: serviceItem) => service.type === 'Lidarr');
|
||||
// Find a poster CoverType
|
||||
const poster = media.images.find((image: any) => image.coverType === 'cover');
|
||||
if (!lidarr) {
|
||||
return null;
|
||||
}
|
||||
const baseUrl = new URL(lidarr.url).origin;
|
||||
// Remove '/' from the end of the lidarr url
|
||||
const fullLink = poster ? `${baseUrl}${poster.url}` : undefined;
|
||||
// Return a movie poster containting the title and the description
|
||||
return (
|
||||
<MediaDisplay
|
||||
media={{
|
||||
title: media.title,
|
||||
poster: fullLink,
|
||||
artist: media.artist.artistName,
|
||||
overview: media.overview,
|
||||
genres: media.genres,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RadarrMediaDisplay(props: any) {
|
||||
const { media }: { media: any } = props;
|
||||
// Find a poster CoverType
|
||||
const poster = media.images.find((image: any) => image.coverType === 'poster');
|
||||
// Return a movie poster containting the title and the description
|
||||
return (
|
||||
<MediaDisplay
|
||||
media={{
|
||||
imdbId: media.imdbId,
|
||||
title: media.title,
|
||||
overview: media.overview,
|
||||
poster: poster.url,
|
||||
genres: media.genres,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SonarrMediaDisplay(props: any) {
|
||||
const { media }: { media: any } = props;
|
||||
// Find a poster CoverType
|
||||
const poster = media.series.images.find((image: any) => image.coverType === 'poster');
|
||||
// Return a movie poster containting the title and the description
|
||||
return (
|
||||
<MediaDisplay
|
||||
media={{
|
||||
imdbId: media.series.imdbId,
|
||||
title: media.series.title,
|
||||
overview: media.series.overview,
|
||||
poster: poster.url,
|
||||
genres: media.series.genres,
|
||||
seasonNumber: media.seasonNumber,
|
||||
episodeNumber: media.episodeNumber,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import DateComponent from './DateModule';
|
||||
|
||||
export default {
|
||||
title: 'Date module',
|
||||
};
|
||||
|
||||
export const Default = (args: any) => <DateComponent {...args} />;
|
||||
@@ -1,142 +0,0 @@
|
||||
import { Table, Text, Tooltip, Title, Group, Progress, Skeleton, ScrollArea } from '@mantine/core';
|
||||
import { IconDownload as Download } from '@tabler/icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { NormalizedTorrent } from '@ctrl/shared-torrent';
|
||||
import { IModule } from '../modules';
|
||||
import { useConfig } from '../../../tools/state';
|
||||
import { AddItemShelfButton } from '../../AppShelf/AddAppShelfItem';
|
||||
|
||||
export const DownloadsModule: IModule = {
|
||||
title: 'Torrent',
|
||||
description: 'Show the current download speed of supported services',
|
||||
icon: Download,
|
||||
component: DownloadComponent,
|
||||
options: {
|
||||
hidecomplete: {
|
||||
name: 'Hide completed torrents',
|
||||
value: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function DownloadComponent() {
|
||||
const { config } = useConfig();
|
||||
const qBittorrentService = config.services
|
||||
.filter((service) => service.type === 'qBittorrent')
|
||||
.at(0);
|
||||
const delugeService = config.services.filter((service) => service.type === 'Deluge').at(0);
|
||||
const hideComplete: boolean =
|
||||
(config?.modules?.[DownloadsModule.title]?.options?.hidecomplete?.value as boolean) ?? false;
|
||||
|
||||
const [delugeTorrents, setDelugeTorrents] = useState<NormalizedTorrent[]>([]);
|
||||
const [qBittorrentTorrents, setqBittorrentTorrents] = useState<NormalizedTorrent[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (qBittorrentService) {
|
||||
setInterval(() => {
|
||||
axios
|
||||
.post('/api/modules/downloads?dlclient=qbit', { ...qBittorrentService })
|
||||
.then((res) => {
|
||||
setqBittorrentTorrents(res.data.torrents);
|
||||
});
|
||||
}, 3000);
|
||||
}
|
||||
if (delugeService) {
|
||||
setInterval(() => {
|
||||
axios.post('/api/modules/downloads?dlclient=deluge', { ...delugeService }).then((res) => {
|
||||
setDelugeTorrents(res.data.torrents);
|
||||
});
|
||||
}, 3000);
|
||||
}
|
||||
}, [config.modules]);
|
||||
|
||||
if (!qBittorrentService && !delugeService) {
|
||||
return (
|
||||
<Group direction="column">
|
||||
<Title order={3}>No supported download clients found!</Title>
|
||||
<Group>
|
||||
<Text>Add a download service to view your current downloads...</Text>
|
||||
<AddItemShelfButton />
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (qBittorrentTorrents.length === 0 && delugeTorrents.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<Skeleton height={40} mt={10} />
|
||||
<Skeleton height={40} mt={10} />
|
||||
<Skeleton height={40} mt={10} />
|
||||
<Skeleton height={40} mt={10} />
|
||||
<Skeleton height={40} mt={10} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const ths = (
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Download</th>
|
||||
<th>Upload</th>
|
||||
<th>Progress</th>
|
||||
</tr>
|
||||
);
|
||||
// Loop over qBittorrent torrents merging with deluge torrents
|
||||
const torrents: NormalizedTorrent[] = [];
|
||||
delugeTorrents.forEach((delugeTorrent) =>
|
||||
torrents.push({ ...delugeTorrent, progress: delugeTorrent.progress / 100 })
|
||||
);
|
||||
qBittorrentTorrents.forEach((torrent) => torrents.push(torrent));
|
||||
const rows = torrents.map((torrent) => {
|
||||
if (torrent.progress === 1 && hideComplete) {
|
||||
return [];
|
||||
}
|
||||
const downloadSpeed = torrent.downloadSpeed / 1024 / 1024;
|
||||
const uploadSpeed = torrent.uploadSpeed / 1024 / 1024;
|
||||
return (
|
||||
<tr key={torrent.id}>
|
||||
<td>
|
||||
<Tooltip position="top" label={torrent.name}>
|
||||
<Text
|
||||
style={{
|
||||
maxWidth: '30vw',
|
||||
}}
|
||||
size="xs"
|
||||
lineClamp={1}
|
||||
>
|
||||
{torrent.name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</td>
|
||||
<td>
|
||||
<Text size="xs">{downloadSpeed > 0 ? `${downloadSpeed.toFixed(1)} Mb/s` : '-'}</Text>
|
||||
</td>
|
||||
<td>
|
||||
<Text size="xs">{uploadSpeed > 0 ? `${uploadSpeed.toFixed(1)} Mb/s` : '-'}</Text>
|
||||
</td>
|
||||
<td>
|
||||
<Text>{(torrent.progress * 100).toFixed(1)}%</Text>
|
||||
<Progress
|
||||
radius="lg"
|
||||
color={torrent.progress === 1 ? 'green' : 'blue'}
|
||||
value={torrent.progress * 100}
|
||||
size="lg"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<Group noWrap grow direction="column">
|
||||
<Title order={4}>Your torrents</Title>
|
||||
<ScrollArea sx={{ height: 300 }}>
|
||||
<Table highlightOnHover>
|
||||
<thead>{ths}</thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { Button, Card, Group, Menu, Switch, TextInput, useMantineTheme } from '@mantine/core';
|
||||
import { useConfig } from '../../tools/state';
|
||||
import { IModule } from './modules';
|
||||
|
||||
function getItems(module: IModule) {
|
||||
const { config, setConfig } = useConfig();
|
||||
const enabledModules = config.modules ?? {};
|
||||
const items: JSX.Element[] = [];
|
||||
if (module.options) {
|
||||
const keys = Object.keys(module.options);
|
||||
const values = Object.values(module.options);
|
||||
// Get the value and the name of the option
|
||||
const types = values.map((v) => typeof v.value);
|
||||
// Loop over all the types with a for each loop
|
||||
types.forEach((type, index) => {
|
||||
const optionName = `${module.title}.${keys[index]}`;
|
||||
const moduleInConfig = config.modules?.[module.title];
|
||||
if (type === 'string') {
|
||||
items.push(
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setConfig({
|
||||
...config,
|
||||
modules: {
|
||||
...config.modules,
|
||||
[module.title]: {
|
||||
...config.modules[module.title],
|
||||
options: {
|
||||
...config.modules[module.title].options,
|
||||
[keys[index]]: {
|
||||
...config.modules[module.title].options?.[keys[index]],
|
||||
value: (e.target as any)[0].value,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Group noWrap align="end" position="center" mt={0}>
|
||||
<TextInput
|
||||
key={optionName}
|
||||
id={optionName}
|
||||
name={optionName}
|
||||
label={values[index].name}
|
||||
defaultValue={(moduleInConfig?.options?.[keys[index]]?.value as string) ?? ''}
|
||||
onChange={(e) => {}}
|
||||
/>
|
||||
|
||||
<Button type="submit">Save</Button>
|
||||
</Group>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
// TODO: Add support for other types
|
||||
if (type === 'boolean') {
|
||||
items.push(
|
||||
<Switch
|
||||
defaultChecked={
|
||||
// Set default checked to the value of the option if it exists
|
||||
(moduleInConfig?.options?.[keys[index]]?.value as boolean) ?? false
|
||||
}
|
||||
key={keys[index]}
|
||||
onClick={(e) => {
|
||||
setConfig({
|
||||
...config,
|
||||
modules: {
|
||||
...config.modules,
|
||||
[module.title]: {
|
||||
...config.modules[module.title],
|
||||
options: {
|
||||
...config.modules[module.title].options,
|
||||
[keys[index]]: {
|
||||
...config.modules[module.title].options?.[keys[index]],
|
||||
value: e.currentTarget.checked,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
label={values[index].name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function ModuleWrapper(props: any) {
|
||||
const { module }: { module: IModule } = props;
|
||||
const { config, setConfig } = useConfig();
|
||||
const enabledModules = config.modules ?? {};
|
||||
// Remove 'Module' from enabled modules titles
|
||||
const isShown = enabledModules[module.title]?.enabled ?? false;
|
||||
const theme = useMantineTheme();
|
||||
const items: JSX.Element[] = getItems(module);
|
||||
|
||||
if (!isShown) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Card {...props} hidden={!isShown} withBorder radius="lg" shadow="sm">
|
||||
{module.options && (
|
||||
<Menu
|
||||
size="lg"
|
||||
shadow="xl"
|
||||
closeOnItemClick={false}
|
||||
radius="md"
|
||||
position="left"
|
||||
styles={{
|
||||
root: {
|
||||
position: 'absolute',
|
||||
top: 15,
|
||||
right: 15,
|
||||
},
|
||||
body: {
|
||||
// Add shadow and elevation to the body
|
||||
boxShadow: '0 0 14px 14px rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Menu.Label>Settings</Menu.Label>
|
||||
{items.map((item) => (
|
||||
<Menu.Item key={item.key}>{item}</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
)}
|
||||
<module.component />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { serviceItem } from '../../../tools/types';
|
||||
import PingComponent from './PingModule';
|
||||
|
||||
export default {
|
||||
title: 'Modules/Search bar',
|
||||
};
|
||||
|
||||
const service: serviceItem = {
|
||||
id: '1',
|
||||
type: 'Other',
|
||||
name: 'YouTube',
|
||||
icon: 'https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/youtube.png',
|
||||
url: 'https://youtube.com/',
|
||||
};
|
||||
|
||||
export const Default = (args: any) => <PingComponent service={service} />;
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Indicator, Tooltip } from '@mantine/core';
|
||||
import axios from 'axios';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IconPlug as Plug } from '@tabler/icons';
|
||||
import { useConfig } from '../../../tools/state';
|
||||
import { IModule } from '../modules';
|
||||
|
||||
export const PingModule: IModule = {
|
||||
title: 'Ping Services',
|
||||
description: 'Pings your services and shows their status as an indicator',
|
||||
icon: Plug,
|
||||
component: PingComponent,
|
||||
};
|
||||
|
||||
export default function PingComponent(props: any) {
|
||||
type State = 'loading' | 'down' | 'online';
|
||||
const { config } = useConfig();
|
||||
|
||||
const { url }: { url: string } = props;
|
||||
const [isOnline, setOnline] = useState<State>('loading');
|
||||
const exists = config.modules?.[PingModule.title]?.enabled ?? false;
|
||||
useEffect(() => {
|
||||
if (!exists) {
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.get('/api/modules/ping', { params: { url } })
|
||||
.then(() => {
|
||||
setOnline('online');
|
||||
})
|
||||
.catch(() => {
|
||||
setOnline('down');
|
||||
});
|
||||
}, []);
|
||||
if (!exists) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Tooltip
|
||||
radius="lg"
|
||||
style={{ position: 'absolute', bottom: 20, right: 20 }}
|
||||
label={isOnline === 'loading' ? 'Loading...' : isOnline === 'online' ? 'Online' : 'Offline'}
|
||||
>
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: isOnline === 'online' ? [1, 0.8, 1] : 1,
|
||||
}}
|
||||
transition={{ repeat: Infinity, duration: 2.5, ease: 'easeInOut' }}
|
||||
>
|
||||
<Indicator
|
||||
size={13}
|
||||
color={isOnline === 'online' ? 'green' : isOnline === 'down' ? 'red' : 'yellow'}
|
||||
>
|
||||
{null}
|
||||
</Indicator>
|
||||
</motion.div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import SearchBar from './SearchModule';
|
||||
|
||||
export default {
|
||||
title: 'Search bar',
|
||||
config: {
|
||||
searchBar: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = (args: any) => <SearchBar {...args} />;
|
||||
@@ -1,125 +0,0 @@
|
||||
import { TextInput, Kbd, createStyles, Text, Popover } from '@mantine/core';
|
||||
import { useForm, useHotkeys } from '@mantine/hooks';
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
IconSearch as Search,
|
||||
IconBrandYoutube as BrandYoutube,
|
||||
IconDownload as Download,
|
||||
} from '@tabler/icons';
|
||||
import { useConfig } from '../../../tools/state';
|
||||
import { IModule } from '../modules';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
hide: {
|
||||
[theme.fn.smallerThan('sm')]: {
|
||||
display: 'none',
|
||||
},
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
}));
|
||||
|
||||
export const SearchModule: IModule = {
|
||||
title: 'Search Bar',
|
||||
description: 'Show the current time and date in a card',
|
||||
icon: Search,
|
||||
component: SearchBar,
|
||||
};
|
||||
|
||||
export default function SearchBar(props: any) {
|
||||
const { config, setConfig } = useConfig();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [icon, setIcon] = useState(<Search />);
|
||||
const queryUrl = config.settings.searchUrl ?? 'https://www.google.com/search?q=';
|
||||
const textInput = useRef<HTMLInputElement>();
|
||||
useHotkeys([['ctrl+K', () => textInput.current && textInput.current.focus()]]);
|
||||
|
||||
const { classes, cx } = useStyles();
|
||||
const rightSection = (
|
||||
<div className={classes.hide}>
|
||||
<Kbd>Ctrl</Kbd>
|
||||
<span style={{ margin: '0 5px' }}>+</span>
|
||||
<Kbd>K</Kbd>
|
||||
</div>
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
query: '',
|
||||
},
|
||||
});
|
||||
|
||||
// If enabled modules doesn't contain the module, return null
|
||||
// If module in enabled
|
||||
|
||||
const exists = config.modules?.[SearchModule.title]?.enabled ?? false;
|
||||
if (!exists) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onChange={() => {
|
||||
// If query contains !yt or !t add "Searching on YouTube" or "Searching torrent"
|
||||
const query = form.values.query.trim();
|
||||
const isYoutube = query.startsWith('!yt');
|
||||
const isTorrent = query.startsWith('!t');
|
||||
if (isYoutube) {
|
||||
setIcon(<BrandYoutube size={22} />);
|
||||
} else if (isTorrent) {
|
||||
setIcon(<Download size={22} />);
|
||||
} else {
|
||||
setIcon(<Search size={22} />);
|
||||
}
|
||||
}}
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
const query = values.query.trim();
|
||||
const isYoutube = query.startsWith('!yt');
|
||||
const isTorrent = query.startsWith('!t');
|
||||
form.setValues({ query: '' });
|
||||
setTimeout(() => {
|
||||
if (isYoutube) {
|
||||
window.open(`https://www.youtube.com/results?search_query=${query.substring(3)}`);
|
||||
} else if (isTorrent) {
|
||||
window.open(`https://bitsearch.to/search?q=${query.substring(3)}`);
|
||||
} else {
|
||||
window.open(`${queryUrl}${values.query}`);
|
||||
}
|
||||
}, 20);
|
||||
})}
|
||||
>
|
||||
<Popover
|
||||
opened={opened}
|
||||
position="bottom"
|
||||
placement="start"
|
||||
width={260}
|
||||
withArrow
|
||||
radius="md"
|
||||
trapFocus={false}
|
||||
transition="pop-bottom-right"
|
||||
onFocusCapture={() => setOpened(true)}
|
||||
onBlurCapture={() => setOpened(false)}
|
||||
target={
|
||||
<TextInput
|
||||
variant="filled"
|
||||
icon={icon}
|
||||
ref={textInput}
|
||||
rightSectionWidth={90}
|
||||
rightSection={rightSection}
|
||||
radius="md"
|
||||
size="md"
|
||||
styles={{ rightSection: { pointerEvents: 'none' } }}
|
||||
placeholder="Search the web..."
|
||||
{...props}
|
||||
{...form.getInputProps('query')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import {
|
||||
Center,
|
||||
Group,
|
||||
RingProgress,
|
||||
Title,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { IconCpu } from '@tabler/icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import si from 'systeminformation';
|
||||
import { useListState } from '@mantine/hooks';
|
||||
import { IModule } from '../modules';
|
||||
|
||||
export const SystemModule: IModule = {
|
||||
title: 'System info',
|
||||
description: 'Show the current CPU usage and memory usage',
|
||||
icon: IconCpu,
|
||||
component: SystemInfo,
|
||||
};
|
||||
|
||||
interface ApiResponse {
|
||||
cpu: si.Systeminformation.CpuData;
|
||||
os: si.Systeminformation.OsData;
|
||||
memory: si.Systeminformation.MemData;
|
||||
load: si.Systeminformation.CurrentLoadData;
|
||||
}
|
||||
|
||||
export default function SystemInfo(args: any) {
|
||||
const [data, setData] = useState<ApiResponse>();
|
||||
|
||||
// Refresh data every second
|
||||
useEffect(() => {
|
||||
setInterval(() => {
|
||||
axios.get('/api/modules/systeminfo').then((res) => setData(res.data));
|
||||
}, 1000);
|
||||
}, [args]);
|
||||
|
||||
// Update data every time data changes
|
||||
const [cpuLoadHistory, cpuLoadHistoryHandlers] =
|
||||
useListState<si.Systeminformation.CurrentLoadData>([]);
|
||||
|
||||
// useEffect(() => {
|
||||
|
||||
// }, [data]);
|
||||
|
||||
const theme = useMantineTheme();
|
||||
const currentLoad = data?.load?.currentLoad ?? 0;
|
||||
|
||||
return (
|
||||
<Center>
|
||||
<Group p="sm" direction="column" align="center">
|
||||
<Title order={3}>Current CPU load</Title>
|
||||
<RingProgress
|
||||
size={150}
|
||||
label={<Center>{`${currentLoad.toFixed(2)}%`}</Center>}
|
||||
thickness={15}
|
||||
roundCaps
|
||||
sections={[{ value: currentLoad ?? 0, color: 'cyan' }]}
|
||||
/>
|
||||
</Group>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { SystemModule } from './SystemModule';
|
||||
@@ -1,11 +1,14 @@
|
||||
// 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:
|
||||
// TODO: Add a function to register a module
|
||||
|
||||
import { TablerIcon } from '@tabler/icons';
|
||||
|
||||
// Note: Maybe use context to keep track of the modules
|
||||
export interface IModule {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
icon: TablerIcon;
|
||||
component: React.ComponentType;
|
||||
options?: Option;
|
||||
}
|
||||
@@ -16,5 +19,6 @@ interface Option {
|
||||
|
||||
export interface OptionValues {
|
||||
name: string;
|
||||
value: boolean | string;
|
||||
value: boolean | string | string[];
|
||||
options?: string[];
|
||||
}
|
||||
320
src/modules/calendar/CalendarModule.tsx
Normal file
320
src/modules/calendar/CalendarModule.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
260
src/modules/common/MediaDisplay.tsx
Normal file
260
src/modules/common/MediaDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
src/modules/common/examples/book.json
Normal file
57
src/modules/common/examples/book.json
Normal 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
|
||||
}
|
||||
70
src/modules/common/examples/movie.json
Normal file
70
src/modules/common/examples/movie.json
Normal 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
|
||||
}
|
||||
3324
src/modules/common/examples/multiplemovies.json
Normal file
3324
src/modules/common/examples/multiplemovies.json
Normal file
File diff suppressed because it is too large
Load Diff
409
src/modules/common/examples/multipletvshows.json
Normal file
409
src/modules/common/examples/multipletvshows.json
Normal 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. C’est là que vit la famille Cody. Profession: criminels. L’irruption 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 d’Animal 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 s’approchent 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 l’endroit 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": "L’histoire 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 d’eux, auprès de son ami Ian, et, incroyablement ingénieux, il découvre le feu, la chasse, l’habitat moderne, l’amour et même… l’espoir. Généreux, il veut tout partager, révolutionne l’ordre é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: Disney’s Animal Kingdom",
|
||||
"overview": "",
|
||||
"popularity": 0.6,
|
||||
"releaseDate": "1998-04-14",
|
||||
"title": "A New species of Theme Park: Disney’s 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
832
src/modules/common/examples/music.json
Normal file
832
src/modules/common/examples/music.json
Normal 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
|
||||
}
|
||||
47
src/modules/common/examples/request.json
Normal file
47
src/modules/common/examples/request.json
Normal 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"
|
||||
}
|
||||
}
|
||||
490
src/modules/common/examples/search-response.json
Normal file
490
src/modules/common/examples/search-response.json
Normal 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 d’action, où vérité, trahison, jeunesse éternelle et mort forment un cocktail explosif, le capitaine Jack Sparrow retrouve une femme qu’il a connu autrefois. Leurs liens sont‐ils faits d’amour ou, cette femme n’est‐elle qu’une aventurière sans scrupules qui cherche à l’utiliser pour découvrir la légendaire Fontaine de Jouvence ? Lorsqu’elle l’oblige à embarquer à bord du Queen Anne’s Revenge, le bateau du terrible pirate Barbe‐Noire, Jack ne sait plus ce qu’il 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 d’une modeste domestique, le docteur Faraday s’est construit une existence tranquille et respectable en devenant médecin de campagne. En 1947, lors d’un été particulièrement long et chaud, il est appelé au chevet d’une 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 aujourd’hui 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 s’imagine 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 d’une nuit où elle garde les enfants d’un 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
110
src/modules/common/examples/tvshow.json
Normal file
110
src/modules/common/examples/tvshow.json
Normal 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
|
||||
}
|
||||
251
src/modules/dashdot/DashdotModule.tsx
Normal file
251
src/modules/dashdot/DashdotModule.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { createStyles, Stack, Title, 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) => (
|
||||
<Stack>
|
||||
<Title style={{ position: 'absolute', right: 0 }} order={4} mt={10} mr={25}>
|
||||
{graph.name}
|
||||
</Title>
|
||||
<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"
|
||||
/>
|
||||
</Stack>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/modules/dashdot/index.ts
Normal file
1
src/modules/dashdot/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { DashdotModule } from './DashdotModule';
|
||||
@@ -2,8 +2,9 @@ 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 '../modules';
|
||||
import { useConfig } from '../../tools/state';
|
||||
import { IModule } from '../ModuleTypes';
|
||||
import { useSetSafeInterval } from '../../tools/hooks/useSetSafeInterval';
|
||||
|
||||
export const DateModule: IModule = {
|
||||
title: 'Date',
|
||||
@@ -20,21 +21,22 @@ export const DateModule: IModule = {
|
||||
|
||||
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 ?? false;
|
||||
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(() => {
|
||||
setInterval(() => {
|
||||
setSafeInterval(() => {
|
||||
setDate(new Date());
|
||||
}, 1000 * 60);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Group p="sm" spacing="xs" direction="column">
|
||||
<Group p="sm" spacing="xs">
|
||||
<Title>{dayjs(date).format(formatString)}</Title>
|
||||
<Text size="xl">{dayjs(date).format('dddd, MMMM D')}</Text>
|
||||
<Text size="lg">{dayjs(date).format('dddd, MMMM D')}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
164
src/modules/docker/ContainerActionBar.tsx
Normal file
164
src/modules/docker/ContainerActionBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
src/modules/docker/ContainerState.tsx
Normal file
49
src/modules/docker/ContainerState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
83
src/modules/docker/DockerModule.tsx
Normal file
83
src/modules/docker/DockerModule.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
124
src/modules/docker/DockerTable.tsx
Normal file
124
src/modules/docker/DockerTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/modules/docker/index.ts
Normal file
1
src/modules/docker/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { DockerModule } from './DockerModule';
|
||||
203
src/modules/downloads/DownloadsModule.tsx
Normal file
203
src/modules/downloads/DownloadsModule.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Text, Title, Group, useMantineTheme, Box, Card, ColorSwatch } from '@mantine/core';
|
||||
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';
|
||||
@@ -6,41 +6,12 @@ import { NormalizedTorrent } from '@ctrl/shared-torrent';
|
||||
import { linearGradientDef } from '@nivo/core';
|
||||
import { Datum, ResponsiveLine } from '@nivo/line';
|
||||
import { useListState } from '@mantine/hooks';
|
||||
import { AddItemShelfButton } from '../../AppShelf/AddAppShelfItem';
|
||||
import { useConfig } from '../../../tools/state';
|
||||
import { IModule } from '../modules';
|
||||
|
||||
/**
|
||||
* Format bytes as human-readable text.
|
||||
*
|
||||
* @param bytes Number of bytes.
|
||||
* @param si True to use metric (SI) units, aka powers of 1000. False to use
|
||||
* binary (IEC), aka powers of 1024.
|
||||
* @param dp Number of decimal places to display.
|
||||
*
|
||||
* @return Formatted string.
|
||||
*/
|
||||
function humanFileSize(initialBytes: number, si = true, dp = 1) {
|
||||
const thresh = si ? 1000 : 1024;
|
||||
let bytes = initialBytes;
|
||||
|
||||
if (Math.abs(bytes) < thresh) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
const units = si
|
||||
? ['kb', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
|
||||
let u = -1;
|
||||
const r = 10 ** dp;
|
||||
|
||||
do {
|
||||
bytes /= thresh;
|
||||
u += 1;
|
||||
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
|
||||
|
||||
return `${bytes.toFixed(dp)} ${units[u]}`;
|
||||
}
|
||||
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',
|
||||
@@ -56,42 +27,47 @@ interface torrentHistory {
|
||||
}
|
||||
|
||||
export default function TotalDownloadsComponent() {
|
||||
const setSafeInterval = useSetSafeInterval();
|
||||
const { config } = useConfig();
|
||||
const qBittorrentService = config.services
|
||||
.filter((service) => service.type === 'qBittorrent')
|
||||
.at(0);
|
||||
const delugeService = config.services.filter((service) => service.type === 'Deluge').at(0);
|
||||
const downloadServices =
|
||||
config.services.filter(
|
||||
(service) =>
|
||||
service.type === 'qBittorrent' ||
|
||||
service.type === 'Transmission' ||
|
||||
service.type === 'Deluge'
|
||||
) ?? [];
|
||||
|
||||
const [delugeTorrents, setDelugeTorrents] = useState<NormalizedTorrent[]>([]);
|
||||
const [torrentHistory, torrentHistoryHandlers] = useListState<torrentHistory>([]);
|
||||
const [qBittorrentTorrents, setqBittorrentTorrents] = useState<NormalizedTorrent[]>([]);
|
||||
|
||||
const torrents: NormalizedTorrent[] = [];
|
||||
delugeTorrents.forEach((delugeTorrent) =>
|
||||
torrents.push({ ...delugeTorrent, progress: delugeTorrent.progress / 100 })
|
||||
);
|
||||
qBittorrentTorrents.forEach((torrent) => torrents.push(torrent));
|
||||
const [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(() => {
|
||||
const interval = setInterval(() => {
|
||||
// Get the current download speed of qBittorrent.
|
||||
if (qBittorrentService) {
|
||||
axios
|
||||
.post('/api/modules/downloads?dlclient=qbit', { ...qBittorrentService })
|
||||
.then((res) => {
|
||||
setqBittorrentTorrents(res.data.torrents);
|
||||
if (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',
|
||||
});
|
||||
if (delugeService) {
|
||||
axios.post('/api/modules/downloads?dlclient=deluge', { ...delugeService }).then((res) => {
|
||||
setDelugeTorrents(res.data.torrents);
|
||||
});
|
||||
}
|
||||
}
|
||||
clearInterval(interval);
|
||||
});
|
||||
}, 1000);
|
||||
}, [config.modules]);
|
||||
}, [config.services]);
|
||||
|
||||
useEffect(() => {
|
||||
torrentHistoryHandlers.append({
|
||||
@@ -101,14 +77,18 @@ export default function TotalDownloadsComponent() {
|
||||
});
|
||||
}, [totalDownloadSpeed, totalUploadSpeed]);
|
||||
|
||||
if (!qBittorrentService && !delugeService) {
|
||||
if (downloadServices.length === 0) {
|
||||
return (
|
||||
<Group direction="column">
|
||||
<Group>
|
||||
<Title order={4}>No supported download clients found!</Title>
|
||||
<Group noWrap>
|
||||
<Text>Add a download service to view your current downloads...</Text>
|
||||
<AddItemShelfButton />
|
||||
</Group>
|
||||
<div>
|
||||
<AddItemShelfButton
|
||||
style={{
|
||||
float: 'inline-end',
|
||||
}}
|
||||
/>
|
||||
Add a download service to view your current downloads
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -126,9 +106,9 @@ export default function TotalDownloadsComponent() {
|
||||
})) as Datum[];
|
||||
|
||||
return (
|
||||
<Group noWrap direction="column" grow>
|
||||
<Stack>
|
||||
<Title order={4}>Current download speed</Title>
|
||||
<Group direction="column">
|
||||
<Stack>
|
||||
<Group>
|
||||
<ColorSwatch size={12} color={theme.colors.green[5]} />
|
||||
<Text>Download: {humanFileSize(totalDownloadSpeed)}/s</Text>
|
||||
@@ -137,7 +117,7 @@ export default function TotalDownloadsComponent() {
|
||||
<ColorSwatch size={12} color={theme.colors.blue[5]} />
|
||||
<Text>Upload: {humanFileSize(totalUploadSpeed)}/s</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
<Box
|
||||
style={{
|
||||
height: 200,
|
||||
@@ -158,7 +138,7 @@ export default function TotalDownloadsComponent() {
|
||||
<Card p="sm" radius="md" withBorder>
|
||||
<Text size="md">{roundedSeconds} seconds ago</Text>
|
||||
<Card.Section p="sm">
|
||||
<Group direction="column">
|
||||
<Stack>
|
||||
<Group>
|
||||
<ColorSwatch size={10} color={theme.colors.green[5]} />
|
||||
<Text size="md">Download: {humanFileSize(Download)}</Text>
|
||||
@@ -167,7 +147,7 @@ export default function TotalDownloadsComponent() {
|
||||
<ColorSwatch size={10} color={theme.colors.blue[5]} />
|
||||
<Text size="md">Upload: {humanFileSize(Upload)}</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
@@ -206,6 +186,6 @@ export default function TotalDownloadsComponent() {
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
export * from './date';
|
||||
export * from './calendar';
|
||||
export * from './search';
|
||||
export * from './ping';
|
||||
export * from './weather';
|
||||
export * from './dashdot';
|
||||
export * from './date';
|
||||
export * from './downloads';
|
||||
export * from './system';
|
||||
export * from './ping';
|
||||
export * from './search';
|
||||
export * from './weather';
|
||||
export * from './docker';
|
||||
export * from './overseerr';
|
||||
229
src/modules/moduleWrapper.tsx
Normal file
229
src/modules/moduleWrapper.tsx
Normal 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
248
src/modules/overseerr/Movie.d.ts
vendored
Normal 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[];
|
||||
}
|
||||
14
src/modules/overseerr/OverseerrModule.tsx
Normal file
14
src/modules/overseerr/OverseerrModule.tsx
Normal 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;
|
||||
}
|
||||
240
src/modules/overseerr/RequestModal.tsx
Normal file
240
src/modules/overseerr/RequestModal.tsx
Normal 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
66
src/modules/overseerr/SearchResult.d.ts
vendored
Normal 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',
|
||||
}
|
||||
295
src/modules/overseerr/TvShow.d.ts
vendored
Normal file
295
src/modules/overseerr/TvShow.d.ts
vendored
Normal file
@@ -0,0 +1,295 @@
|
||||
export interface TvShowResult {
|
||||
createdBy: CreatedBy[];
|
||||
episodeRunTime: number[];
|
||||
firstAirDate: Date;
|
||||
genres: Genre[];
|
||||
relatedVideos: RelatedVideo[];
|
||||
homepage: string;
|
||||
id: number;
|
||||
inProduction: boolean;
|
||||
languages: string[];
|
||||
lastAirDate: Date;
|
||||
name: string;
|
||||
networks: Network[];
|
||||
numberOfEpisodes: number;
|
||||
numberOfSeasons: number;
|
||||
originCountry: string[];
|
||||
originalLanguage: string;
|
||||
originalName: string;
|
||||
tagline: string;
|
||||
overview: string;
|
||||
popularity: number;
|
||||
productionCompanies: Network[];
|
||||
productionCountries: ProductionCountry[];
|
||||
contentRatings: ContentRatings;
|
||||
spokenLanguages: SpokenLanguage[];
|
||||
seasons: TvShowResultSeason[];
|
||||
status: string;
|
||||
type: string;
|
||||
voteAverage: number;
|
||||
voteCount: number;
|
||||
backdropPath: string;
|
||||
lastEpisodeToAir: LastEpisodeToAir;
|
||||
posterPath: string;
|
||||
credits: Credits;
|
||||
externalIds: ExternalIDS;
|
||||
keywords: Genre[];
|
||||
mediaInfo: Media;
|
||||
watchProviders: WatchProvider[];
|
||||
}
|
||||
|
||||
export interface ContentRatings {
|
||||
results: Result[];
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
iso_3166_1: string;
|
||||
rating: string;
|
||||
}
|
||||
|
||||
export interface CreatedBy {
|
||||
id: number;
|
||||
credit_id: string;
|
||||
name: string;
|
||||
gender: number;
|
||||
profile_path: string;
|
||||
}
|
||||
|
||||
export interface Credits {
|
||||
cast: Cast[];
|
||||
crew: Crew[];
|
||||
}
|
||||
|
||||
export interface Cast {
|
||||
character: string;
|
||||
creditId: string;
|
||||
id: number;
|
||||
name: string;
|
||||
order: number;
|
||||
gender: number;
|
||||
profilePath: null | string;
|
||||
}
|
||||
|
||||
export interface Crew {
|
||||
creditId: string;
|
||||
department: string;
|
||||
id: number;
|
||||
job: string;
|
||||
name: string;
|
||||
gender: number;
|
||||
profilePath: string;
|
||||
}
|
||||
|
||||
export interface ExternalIDS {
|
||||
facebookId: string;
|
||||
freebaseId: null;
|
||||
freebaseMid: string;
|
||||
imdbId: string;
|
||||
instagramId: string;
|
||||
tvdbId: number;
|
||||
tvrageId: number;
|
||||
twitterId: string;
|
||||
}
|
||||
|
||||
export interface Genre {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface LastEpisodeToAir {
|
||||
id: number;
|
||||
airDate: Date;
|
||||
episodeNumber: number;
|
||||
name: string;
|
||||
overview: string;
|
||||
productionCode: string;
|
||||
seasonNumber: number;
|
||||
showId: number;
|
||||
voteAverage: number;
|
||||
stillPath: string;
|
||||
}
|
||||
|
||||
export interface Request {
|
||||
id: number;
|
||||
status: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
type: Type;
|
||||
is4k: boolean;
|
||||
serverId: null;
|
||||
profileId: null;
|
||||
rootFolder: null;
|
||||
languageProfileId: null;
|
||||
tags: null;
|
||||
media: Media;
|
||||
requestedBy: EdBy;
|
||||
modifiedBy: EdBy;
|
||||
seasons: MediaInfoSeason[];
|
||||
seasonCount: number;
|
||||
}
|
||||
|
||||
export interface Media {
|
||||
downloadStatus: DownloadStatus[];
|
||||
downloadStatus4k: any[];
|
||||
id: number;
|
||||
mediaType: Type;
|
||||
tmdbId: number;
|
||||
tvdbId: number;
|
||||
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: MediaInfoSeason[];
|
||||
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 MediaInfoSeason {
|
||||
id: number;
|
||||
seasonNumber: number;
|
||||
status: number;
|
||||
status4k?: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export enum Type {
|
||||
Tv = 'tv',
|
||||
}
|
||||
|
||||
export interface DownloadStatus {
|
||||
externalId: number;
|
||||
estimatedCompletionTime: Date;
|
||||
mediaType: Type;
|
||||
size: number;
|
||||
sizeLeft: number;
|
||||
status: Status;
|
||||
timeLeft: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export enum Status {
|
||||
Completed = 'completed',
|
||||
Downloading = 'downloading',
|
||||
}
|
||||
|
||||
export interface Network {
|
||||
id: number;
|
||||
name: Name;
|
||||
originCountry?: string;
|
||||
logoPath: LogoPath | null;
|
||||
displayPriority?: number;
|
||||
}
|
||||
|
||||
export enum LogoPath {
|
||||
HbifXPpM55B1FL5WPo7T72VzN78PNG = '/hbifXPpM55B1fL5wPo7t72vzN78.png',
|
||||
KhiCshsZBdtUUYOr4VLoCtuqCEqPNG = '/khiCshsZBdtUUYOr4VLoCtuqCEq.png',
|
||||
O9ExgOSLF3OTwR6T3DJOuwOKJgqJpg = '/o9ExgOSLF3OTwR6T3DJOuwOKJgq.jpg',
|
||||
PEURlLlr8JggOwK53FJ5WdQl05YJpg = '/peURlLlr8jggOwK53fJ5wdQl05y.jpg',
|
||||
T2YyOv40HZeVlLjYsCSPHnWLk4WJpg = '/t2yyOv40HZeVlLjYsCsPHnWLk4W.jpg',
|
||||
TBEdFQDwx5LEVr8WpSEXQSIirVqJpg = '/tbEdFQDwx5LEVr8WpSeXQSIirVq.jpg',
|
||||
The5NyLm42TmCqCMOZFvH4FcoSNKEWJpg = '/5NyLm42TmCqCMOZFvH4fcoSNKEW.jpg',
|
||||
WwemzKWzjKYJFfCeiB57Q3R4BcmPNG = '/wwemzKWzjKYJFfCeiB57q3r4Bcm.png',
|
||||
}
|
||||
|
||||
export enum Name {
|
||||
AmazonVideo = 'Amazon Video',
|
||||
AppleITunes = 'Apple iTunes',
|
||||
Channel4 = 'Channel 4',
|
||||
GooglePlayMovies = 'Google Play Movies',
|
||||
HouseOfTomorrow = 'House of Tomorrow',
|
||||
Ivi = 'Ivi',
|
||||
Netflix = 'Netflix',
|
||||
Zeppotron = 'Zeppotron',
|
||||
}
|
||||
|
||||
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 TvShowResultSeason {
|
||||
airDate: Date;
|
||||
episodeCount: number;
|
||||
id: number;
|
||||
name: string;
|
||||
overview: string;
|
||||
seasonNumber: number;
|
||||
posterPath: string;
|
||||
}
|
||||
|
||||
export interface SpokenLanguage {
|
||||
englishName: string;
|
||||
iso_639_1: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface WatchProvider {
|
||||
iso_3166_1: string;
|
||||
link: string;
|
||||
buy: Network[];
|
||||
flatrate: Network[];
|
||||
}
|
||||
72
src/modules/overseerr/example.json
Normal file
72
src/modules/overseerr/example.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"id": 86831,
|
||||
"firstAirDate": "2019-03-15",
|
||||
"genreIds": [
|
||||
16,
|
||||
10765
|
||||
],
|
||||
"mediaType": "tv",
|
||||
"name": "Love, Death & Robots",
|
||||
"originCountry": [
|
||||
"US"
|
||||
],
|
||||
"originalLanguage": "en",
|
||||
"originalName": "Love, Death & Robots",
|
||||
"overview": "Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.",
|
||||
"popularity": 623.833,
|
||||
"voteAverage": 8.2,
|
||||
"voteCount": 1720,
|
||||
"backdropPath": "/78NtUwwo3lhH7QGh4vG3U1qK1mc.jpg",
|
||||
"posterPath": "/cRiDlzzZC5lL7fvImuSjs04SUIJ.jpg",
|
||||
"mediaInfo": {
|
||||
"downloadStatus": [],
|
||||
"downloadStatus4k": [],
|
||||
"id": 79,
|
||||
"mediaType": "tv",
|
||||
"tmdbId": 86831,
|
||||
"tvdbId": 357888,
|
||||
"imdbId": null,
|
||||
"status": 4,
|
||||
"status4k": 1,
|
||||
"createdAt": "2022-02-05T04:30:01.000Z",
|
||||
"updatedAt": "2022-02-05T09:25:22.000Z",
|
||||
"lastSeasonChange": "2022-02-05T04:30:01.000Z",
|
||||
"mediaAddedAt": "2022-02-04T01:16:35.000Z",
|
||||
"serviceId": 0,
|
||||
"serviceId4k": null,
|
||||
"externalServiceId": 7,
|
||||
"externalServiceId4k": null,
|
||||
"externalServiceSlug": "love-death-and-robots",
|
||||
"externalServiceSlug4k": null,
|
||||
"ratingKey": "182",
|
||||
"ratingKey4k": null,
|
||||
"seasons": [
|
||||
{
|
||||
"id": 11,
|
||||
"seasonNumber": 1,
|
||||
"status": 1,
|
||||
"status4k": 1,
|
||||
"createdAt": "2022-02-05T04:30:01.000Z",
|
||||
"updatedAt": "2022-02-05T04:30:01.000Z"
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"seasonNumber": 2,
|
||||
"status": 5,
|
||||
"status4k": 1,
|
||||
"createdAt": "2022-02-05T04:30:01.000Z",
|
||||
"updatedAt": "2022-02-05T04:30:01.000Z"
|
||||
},
|
||||
{
|
||||
"id": 85,
|
||||
"seasonNumber": 3,
|
||||
"status": 3,
|
||||
"status4k": 1,
|
||||
"createdAt": "2022-04-26T04:30:02.000Z",
|
||||
"updatedAt": "2022-04-26T04:30:02.000Z"
|
||||
}
|
||||
],
|
||||
"plexUrl": "https://app.plex.tv/desktop#!/server/5b88b3c20d2d092c0ee848f9044f3f3bee033d91/details?key=%2Flibrary%2Fmetadata%2F182",
|
||||
"serviceUrl": "http://server:8989/series/love-death-and-robots"
|
||||
}
|
||||
}
|
||||
1
src/modules/overseerr/index.ts
Normal file
1
src/modules/overseerr/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { OverseerrModule } from './OverseerrModule';
|
||||
86
src/modules/ping/PingModule.tsx
Normal file
86
src/modules/ping/PingModule.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Indicator, Tooltip } from '@mantine/core';
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IconPlug as Plug } from '@tabler/icons';
|
||||
import { useConfig } from '../../tools/state';
|
||||
import { IModule } from '../ModuleTypes';
|
||||
|
||||
export const PingModule: IModule = {
|
||||
title: 'Ping Services',
|
||||
description: 'Pings your services and shows their status as an indicator',
|
||||
icon: Plug,
|
||||
component: PingComponent,
|
||||
};
|
||||
|
||||
export default function PingComponent(props: any) {
|
||||
type State = 'loading' | 'down' | 'online';
|
||||
const { config } = useConfig();
|
||||
|
||||
const { url }: { url: string } = props;
|
||||
const [isOnline, setOnline] = useState<State>('loading');
|
||||
const [response, setResponse] = useState(500);
|
||||
const exists = config.modules?.[PingModule.title]?.enabled ?? false;
|
||||
|
||||
function statusCheck(response: AxiosResponse) {
|
||||
const { status }: { status: string[] } = props;
|
||||
//Default Status
|
||||
let acceptableStatus = ['200'];
|
||||
if (status !== undefined && status.length) {
|
||||
acceptableStatus = status;
|
||||
}
|
||||
// Checks if reported status is in acceptable status array
|
||||
if (acceptableStatus.indexOf(response.status.toString()) >= 0) {
|
||||
setOnline('online');
|
||||
setResponse(response.status);
|
||||
} else {
|
||||
setOnline('down');
|
||||
setResponse(response.status);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!exists) {
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.get('/api/modules/ping', { params: { url } })
|
||||
.then((response) => {
|
||||
statusCheck(response);
|
||||
})
|
||||
.catch((error) => {
|
||||
statusCheck(error.response);
|
||||
});
|
||||
}, [config.modules?.[PingModule.title]?.enabled]);
|
||||
if (!exists) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<motion.div
|
||||
style={{ position: 'absolute', bottom: 20, right: 20 }}
|
||||
animate={{
|
||||
scale: isOnline === 'online' ? [1, 0.8, 1] : 1,
|
||||
}}
|
||||
transition={{ repeat: Infinity, duration: 2.5, ease: 'easeInOut' }}
|
||||
>
|
||||
<Tooltip
|
||||
withinPortal
|
||||
radius="lg"
|
||||
label={
|
||||
isOnline === 'loading'
|
||||
? 'Loading...'
|
||||
: isOnline === 'online'
|
||||
? `Online - ${response}`
|
||||
: `Offline - ${response}`
|
||||
}
|
||||
>
|
||||
<Indicator
|
||||
size={13}
|
||||
color={isOnline === 'online' ? 'green' : isOnline === 'down' ? 'red' : 'yellow'}
|
||||
>
|
||||
{null}
|
||||
</Indicator>
|
||||
</Tooltip>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
199
src/modules/search/SearchModule.tsx
Normal file
199
src/modules/search/SearchModule.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { Kbd, createStyles, Autocomplete, Popover, ScrollArea, Divider } from '@mantine/core';
|
||||
import { useClickOutside, useDebouncedValue, useHotkeys } from '@mantine/hooks';
|
||||
import { useForm } from '@mantine/form';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
IconSearch as Search,
|
||||
IconBrandYoutube as BrandYoutube,
|
||||
IconDownload as Download,
|
||||
IconMovie,
|
||||
} from '@tabler/icons';
|
||||
import axios from 'axios';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { useConfig } from '../../tools/state';
|
||||
import { IModule } from '../ModuleTypes';
|
||||
import { OverseerrModule } from '../overseerr';
|
||||
import { OverseerrMediaDisplay } from '../common';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
hide: {
|
||||
[theme.fn.smallerThan('sm')]: {
|
||||
display: 'none',
|
||||
},
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
}));
|
||||
|
||||
export const SearchModule: IModule = {
|
||||
title: 'Search Bar',
|
||||
description: 'Search bar to search the web, youtube, torrents or overseerr',
|
||||
icon: Search,
|
||||
component: SearchBar,
|
||||
};
|
||||
|
||||
export default function SearchBar(props: any) {
|
||||
const { classes, cx } = useStyles();
|
||||
// Config
|
||||
const { config } = useConfig();
|
||||
const isModuleEnabled = config.modules?.[SearchModule.title]?.enabled ?? false;
|
||||
const isOverseerrEnabled = config.modules?.[OverseerrModule.title]?.enabled ?? false;
|
||||
const OverseerrService = config.services.find(
|
||||
(service) => service.type === 'Overseerr' || service.type === 'Jellyseerr'
|
||||
);
|
||||
const queryUrl = config.settings.searchUrl ?? 'https://www.google.com/search?q=';
|
||||
|
||||
const [OverseerrResults, setOverseerrResults] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [icon, setIcon] = useState(<Search />);
|
||||
const [results, setResults] = useState<any[]>([]);
|
||||
const [opened, setOpened] = useState(false);
|
||||
const ref = useClickOutside(() => setOpened(false));
|
||||
|
||||
const textInput = useRef<HTMLInputElement>();
|
||||
useHotkeys([['ctrl+K', () => textInput.current && textInput.current.focus()]]);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
query: '',
|
||||
},
|
||||
});
|
||||
const [debounced, cancel] = useDebouncedValue(form.values.query, 250);
|
||||
|
||||
useEffect(() => {
|
||||
if (OverseerrService === undefined && isOverseerrEnabled) {
|
||||
showNotification({
|
||||
title: 'Overseerr integration',
|
||||
message:
|
||||
'Module enabled but no service is configured with the type "Overseerr" / "Jellyseerr"',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
}, [OverseerrService, isOverseerrEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
form.values.query !== debounced ||
|
||||
form.values.query === '' ||
|
||||
(form.values.query.startsWith('!') && !form.values.query.startsWith('!os'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (form.values.query.startsWith('!os')) {
|
||||
axios
|
||||
.get(`/api/modules/overseerr?query=${form.values.query.replace('!os', '').trim()}`)
|
||||
.then((res) => {
|
||||
setOverseerrResults(res.data.results ?? []);
|
||||
setLoading(false);
|
||||
});
|
||||
setLoading(true);
|
||||
} else {
|
||||
setOverseerrResults([]);
|
||||
axios
|
||||
.get(`/api/modules/search?q=${form.values.query}`)
|
||||
.then((res) => setResults(res.data ?? []));
|
||||
}
|
||||
}, [debounced]);
|
||||
|
||||
if (!isModuleEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const autocompleteData = results.map((result) => ({
|
||||
label: result.phrase,
|
||||
value: result.phrase,
|
||||
}));
|
||||
return (
|
||||
<form
|
||||
onChange={() => {
|
||||
// If query contains !yt or !t add "Searching on YouTube" or "Searching torrent"
|
||||
const query = form.values.query.trim();
|
||||
switch (query.substring(0, 3)) {
|
||||
case '!yt':
|
||||
setIcon(<BrandYoutube />);
|
||||
break;
|
||||
case '!t ':
|
||||
setIcon(<Download />);
|
||||
break;
|
||||
case '!os':
|
||||
setIcon(<IconMovie />);
|
||||
break;
|
||||
default:
|
||||
setIcon(<Search />);
|
||||
break;
|
||||
}
|
||||
}}
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
const query = values.query.trim();
|
||||
setTimeout(() => {
|
||||
form.setValues({ query: '' });
|
||||
switch (query.substring(0, 3)) {
|
||||
case '!yt':
|
||||
window.open(`https://www.youtube.com/results?search_query=${query.substring(3)}`);
|
||||
break;
|
||||
case '!t ':
|
||||
window.open(`https://www.torrentdownloads.me/search/?search=${query.substring(3)}`);
|
||||
break;
|
||||
case '!os':
|
||||
break;
|
||||
default:
|
||||
window.open(
|
||||
`${queryUrl.includes('%s') ? queryUrl.replace('%s', query) : `${queryUrl}${query}`}`
|
||||
);
|
||||
break;
|
||||
}
|
||||
}, 500);
|
||||
})}
|
||||
>
|
||||
<Popover
|
||||
opened={OverseerrResults.length > 0 && opened}
|
||||
position="bottom"
|
||||
withArrow
|
||||
withinPortal
|
||||
shadow="md"
|
||||
radius="md"
|
||||
zIndex={100}
|
||||
trapFocus
|
||||
transition="pop-top-right"
|
||||
>
|
||||
<Popover.Target>
|
||||
<Autocomplete
|
||||
onFocusCapture={() => setOpened(true)}
|
||||
autoFocus
|
||||
variant="filled"
|
||||
data={autocompleteData}
|
||||
icon={icon}
|
||||
ref={textInput}
|
||||
rightSectionWidth={90}
|
||||
rightSection={
|
||||
<div className={classes.hide}>
|
||||
<Kbd>Ctrl</Kbd>
|
||||
<span style={{ margin: '0 5px' }}>+</span>
|
||||
<Kbd>K</Kbd>
|
||||
</div>
|
||||
}
|
||||
radius="md"
|
||||
size="md"
|
||||
styles={{ rightSection: { pointerEvents: 'none' } }}
|
||||
placeholder="Search the web..."
|
||||
{...props}
|
||||
{...form.getInputProps('query')}
|
||||
/>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown>
|
||||
<div ref={ref}>
|
||||
<ScrollArea style={{ height: 400, width: 400 }} offsetScrollbars>
|
||||
{OverseerrResults.slice(0, 5).map((result, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<OverseerrMediaDisplay key={result.id} media={result} />
|
||||
{index < OverseerrResults.length - 1 && <Divider variant="dashed" my="xl" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Group, Space, Title, Tooltip } from '@mantine/core';
|
||||
import { Group, Space, Title, Tooltip, Skeleton, Stack, Box } from '@mantine/core';
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
@@ -13,12 +13,12 @@ import {
|
||||
IconSnowflake as Snowflake,
|
||||
IconSun as Sun,
|
||||
} from '@tabler/icons';
|
||||
import { useConfig } from '../../../tools/state';
|
||||
import { IModule } from '../modules';
|
||||
import { useConfig } from '../../tools/state';
|
||||
import { IModule } from '../ModuleTypes';
|
||||
import { WeatherResponse } from './WeatherInterface';
|
||||
|
||||
export const WeatherModule: IModule = {
|
||||
title: 'Weather (beta)',
|
||||
title: 'Weather',
|
||||
description: 'Look up the current weather in your location',
|
||||
icon: Sun,
|
||||
component: WeatherComponent,
|
||||
@@ -29,7 +29,7 @@ export const WeatherModule: IModule = {
|
||||
},
|
||||
location: {
|
||||
name: 'Current location',
|
||||
value: '',
|
||||
value: 'Paris',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -124,8 +124,10 @@ export function WeatherIcon(props: any) {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Tooltip label={data.name}>
|
||||
<data.icon size={50} />
|
||||
<Tooltip withinPortal withArrow label={data.name}>
|
||||
<Box>
|
||||
<data.icon size={50} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -135,7 +137,7 @@ export default function WeatherComponent(props: any) {
|
||||
const { config } = useConfig();
|
||||
const [weather, setWeather] = useState({} as WeatherResponse);
|
||||
const cityInput: string =
|
||||
(config?.modules?.[WeatherModule.title]?.options?.location?.value as string) ?? '';
|
||||
(config?.modules?.[WeatherModule.title]?.options?.location?.value as string) ?? 'Paris';
|
||||
const isFahrenheit: boolean =
|
||||
(config?.modules?.[WeatherModule.title]?.options?.freedomunit?.value as boolean) ?? false;
|
||||
|
||||
@@ -157,13 +159,24 @@ export default function WeatherComponent(props: any) {
|
||||
});
|
||||
}, [cityInput]);
|
||||
if (!weather.current_weather) {
|
||||
return null;
|
||||
return (
|
||||
<>
|
||||
<Skeleton height={40} width={100} mb="xl" />
|
||||
<Group noWrap>
|
||||
<Skeleton height={50} circle />
|
||||
<Group>
|
||||
<Skeleton height={25} width={70} mr="lg" />
|
||||
<Skeleton height={25} width={70} />
|
||||
</Group>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
function usePerferedUnit(value: number): string {
|
||||
return isFahrenheit ? `${(value * (9 / 5)).toFixed(1)}°F` : `${value.toFixed(1)}°C`;
|
||||
return isFahrenheit ? `${(value * (9 / 5) + 32).toFixed(1)}°F` : `${value.toFixed(1)}°C`;
|
||||
}
|
||||
return (
|
||||
<Group p="sm" spacing="xs" direction="column">
|
||||
<Stack p="sm" spacing="xs">
|
||||
<Title>{usePerferedUnit(weather.current_weather.temperature)}</Title>
|
||||
<Group spacing={0}>
|
||||
<WeatherIcon code={weather.current_weather.weathercode} />
|
||||
@@ -174,6 +187,6 @@ export default function WeatherComponent(props: any) {
|
||||
<span>{usePerferedUnit(weather.daily.temperature_2m_min[0])}</span>
|
||||
<ArrowDownRight size={16} />
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user