mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2026-01-06 22:58:09 -05:00
Merge remote-tracking branch 'o/develop' into develop
This commit is contained in:
@@ -755,6 +755,24 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"doc"
|
"doc"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "ceptonit",
|
||||||
|
"name": "ceptonit",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/12678743?v=4",
|
||||||
|
"profile": "https://github.com/ceptonit",
|
||||||
|
"contributions": [
|
||||||
|
"doc"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "aedelbro",
|
||||||
|
"name": "aedelbro",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/36162221?v=4",
|
||||||
|
"profile": "https://github.com/aedelbro",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
||||||
|
|||||||
5
.github/holopin.yml
vendored
Normal file
5
.github/holopin.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
organization: overseerr
|
||||||
|
defaultSticker: clcyagj1j329008l468ya8pu2
|
||||||
|
stickers:
|
||||||
|
- id: clcyagj1j329008l468ya8pu2
|
||||||
|
alias: overseerr-contributor
|
||||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -91,9 +91,9 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
failures=(neutral, skipped, timed_out, action_required)
|
failures=(neutral, skipped, timed_out, action_required)
|
||||||
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
||||||
echo ::set-output name=status::failure
|
echo "status=failure" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
echo ::set-output name=status::$WORKFLOW_CONCLUSION
|
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
- name: Post Status to Discord
|
- name: Post Status to Discord
|
||||||
uses: sarisia/actions-status-discord@v1
|
uses: sarisia/actions-status-discord@v1
|
||||||
|
|||||||
41
.github/workflows/codeql.yml
vendored
Normal file
41
.github/workflows/codeql.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: 'CodeQL'
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['develop']
|
||||||
|
pull_request:
|
||||||
|
branches: ['develop']
|
||||||
|
schedule:
|
||||||
|
- cron: '50 7 * * 5'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
language: [javascript]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v2
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
queries: +security-and-quality
|
||||||
|
|
||||||
|
- name: Autobuild
|
||||||
|
uses: github/codeql-action/autobuild@v2
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v2
|
||||||
|
with:
|
||||||
|
category: '/language:${{ matrix.language }}'
|
||||||
2
.github/workflows/preview.yml
vendored
2
.github/workflows/preview.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
|||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
- name: Get the version
|
- name: Get the version
|
||||||
id: get_version
|
id: get_version
|
||||||
run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/}
|
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v2
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
|
|||||||
15
.github/workflows/release.yml
vendored
15
.github/workflows/release.yml
vendored
@@ -59,10 +59,10 @@ jobs:
|
|||||||
id: prepare
|
id: prepare
|
||||||
run: |
|
run: |
|
||||||
git fetch --prune --tags
|
git fetch --prune --tags
|
||||||
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/main ]]; then
|
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
|
||||||
echo ::set-output name=RELEASE::stable
|
echo "RELEASE=stable" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
echo ::set-output name=RELEASE::edge
|
echo "RELEASE=edge" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
- name: Set Up QEMU
|
- name: Set Up QEMU
|
||||||
uses: docker/setup-qemu-action@v1
|
uses: docker/setup-qemu-action@v1
|
||||||
@@ -84,8 +84,9 @@ jobs:
|
|||||||
snap: ${{ steps.build.outputs.snap }}
|
snap: ${{ steps.build.outputs.snap }}
|
||||||
- name: Publish Snap Package
|
- name: Publish Snap Package
|
||||||
uses: snapcore/action-publish@v1
|
uses: snapcore/action-publish@v1
|
||||||
|
env:
|
||||||
|
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
|
||||||
with:
|
with:
|
||||||
store_login: ${{ secrets.SNAP_LOGIN }}
|
|
||||||
snap: ${{ steps.build.outputs.snap }}
|
snap: ${{ steps.build.outputs.snap }}
|
||||||
release: ${{ steps.prepare.outputs.RELEASE }}
|
release: ${{ steps.prepare.outputs.RELEASE }}
|
||||||
|
|
||||||
@@ -93,7 +94,7 @@ jobs:
|
|||||||
name: Send Discord Notification
|
name: Send Discord Notification
|
||||||
needs: semantic-release
|
needs: semantic-release
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: self-hosted
|
runs-on: ubuntu-20.04
|
||||||
steps:
|
steps:
|
||||||
- name: Get Build Job Status
|
- name: Get Build Job Status
|
||||||
uses: technote-space/workflow-conclusion-action@v3
|
uses: technote-space/workflow-conclusion-action@v3
|
||||||
@@ -102,9 +103,9 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
failures=(neutral, skipped, timed_out, action_required)
|
failures=(neutral, skipped, timed_out, action_required)
|
||||||
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
||||||
echo ::set-output name=status::failure
|
echo "status=failure" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
echo ::set-output name=status::$WORKFLOW_CONCLUSION
|
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
- name: Post Status to Discord
|
- name: Post Status to Discord
|
||||||
uses: sarisia/actions-status-discord@v1
|
uses: sarisia/actions-status-discord@v1
|
||||||
|
|||||||
11
.github/workflows/snap.yaml
vendored
11
.github/workflows/snap.yaml
vendored
@@ -35,9 +35,9 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
git fetch --prune --unshallow --tags
|
git fetch --prune --unshallow --tags
|
||||||
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
|
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
|
||||||
echo ::set-output name=RELEASE::stable
|
echo "RELEASE=stable" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
echo ::set-output name=RELEASE::edge
|
echo "RELEASE=edge" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
- name: Set Up QEMU
|
- name: Set Up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v2
|
||||||
@@ -57,8 +57,9 @@ jobs:
|
|||||||
snap: ${{ steps.build.outputs.snap }}
|
snap: ${{ steps.build.outputs.snap }}
|
||||||
- name: Publish Snap Package
|
- name: Publish Snap Package
|
||||||
uses: snapcore/action-publish@v1
|
uses: snapcore/action-publish@v1
|
||||||
|
env:
|
||||||
|
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
|
||||||
with:
|
with:
|
||||||
store_login: ${{ secrets.SNAP_LOGIN }}
|
|
||||||
snap: ${{ steps.build.outputs.snap }}
|
snap: ${{ steps.build.outputs.snap }}
|
||||||
release: ${{ steps.prepare.outputs.RELEASE }}
|
release: ${{ steps.prepare.outputs.RELEASE }}
|
||||||
|
|
||||||
@@ -75,9 +76,9 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
failures=(neutral, skipped, timed_out, action_required)
|
failures=(neutral, skipped, timed_out, action_required)
|
||||||
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
||||||
echo ::set-output name=status::failure
|
echo "status=failure" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
echo ::set-output name=status::$WORKFLOW_CONCLUSION
|
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
- name: Post Status to Discord
|
- name: Post Status to Discord
|
||||||
uses: sarisia/actions-status-discord@v1
|
uses: sarisia/actions-status-discord@v1
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
.next/
|
.next/
|
||||||
dist/
|
dist/
|
||||||
config/
|
config/
|
||||||
|
CHANGELOG.md
|
||||||
|
|
||||||
# assets
|
# assets
|
||||||
src/assets/
|
src/assets/
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ describe('Discover', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('loads upcoming movies', () => {
|
it('loads upcoming movies', () => {
|
||||||
cy.intercept('/api/v1/discover/movies/upcoming*').as('getUpcomingMovies');
|
cy.intercept('/api/v1/discover/movies?page=1&primaryReleaseDateGte*').as(
|
||||||
|
'getUpcomingMovies'
|
||||||
|
);
|
||||||
cy.visit('/');
|
cy.visit('/');
|
||||||
cy.wait('@getUpcomingMovies');
|
cy.wait('@getUpcomingMovies');
|
||||||
clickFirstTitleCardInSlider('Upcoming Movies');
|
clickFirstTitleCardInSlider('Upcoming Movies');
|
||||||
@@ -50,7 +52,9 @@ describe('Discover', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('loads upcoming series', () => {
|
it('loads upcoming series', () => {
|
||||||
cy.intercept('/api/v1/discover/tv/upcoming*').as('getUpcomingSeries');
|
cy.intercept('/api/v1/discover/tv?page=1&firstAirDateGte=*').as(
|
||||||
|
'getUpcomingSeries'
|
||||||
|
);
|
||||||
cy.visit('/');
|
cy.visit('/');
|
||||||
cy.wait('@getUpcomingSeries');
|
cy.wait('@getUpcomingSeries');
|
||||||
clickFirstTitleCardInSlider('Upcoming Series');
|
clickFirstTitleCardInSlider('Upcoming Series');
|
||||||
|
|||||||
163
cypress/e2e/settings/discover-customization.cy.ts
Normal file
163
cypress/e2e/settings/discover-customization.cy.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
describe('Discover Customization', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.loginAsAdmin();
|
||||||
|
cy.intercept('/api/v1/settings/discover').as('getDiscoverSliders');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('show the discover customization settings', () => {
|
||||||
|
cy.visit('/');
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-start-editing]').click();
|
||||||
|
|
||||||
|
cy.get('[data-testid=create-slider-header')
|
||||||
|
.should('contain', 'Create New Slider')
|
||||||
|
.scrollIntoView();
|
||||||
|
|
||||||
|
// There should be some built in options
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]').should(
|
||||||
|
'contain',
|
||||||
|
'Recently Added'
|
||||||
|
);
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]').should(
|
||||||
|
'contain',
|
||||||
|
'Recent Requests'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can drag to re-order elements and save to persist the changes', () => {
|
||||||
|
let dataTransfer = new DataTransfer();
|
||||||
|
cy.visit('/');
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-start-editing]').click();
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.first()
|
||||||
|
.trigger('dragstart', { dataTransfer });
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.eq(1)
|
||||||
|
.trigger('drop', { dataTransfer });
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.eq(1)
|
||||||
|
.trigger('dragend', { dataTransfer });
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.eq(1)
|
||||||
|
.should('contain', 'Recently Added');
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-customize-submit').click();
|
||||||
|
cy.wait('@getDiscoverSliders');
|
||||||
|
|
||||||
|
cy.reload();
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-start-editing]').click();
|
||||||
|
|
||||||
|
dataTransfer = new DataTransfer();
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.eq(1)
|
||||||
|
.should('contain', 'Recently Added');
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.first()
|
||||||
|
.trigger('dragstart', { dataTransfer });
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.eq(1)
|
||||||
|
.trigger('drop', { dataTransfer });
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.eq(1)
|
||||||
|
.trigger('dragend', { dataTransfer });
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.eq(1)
|
||||||
|
.should('contain', 'Recent Requests');
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-customize-submit').click();
|
||||||
|
cy.wait('@getDiscoverSliders');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can create a new discover option and remove it', () => {
|
||||||
|
cy.visit('/');
|
||||||
|
cy.intercept('/api/v1/settings/discover/*').as('discoverSlider');
|
||||||
|
cy.intercept('/api/v1/search/keyword*').as('searchKeyword');
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-start-editing]').click();
|
||||||
|
|
||||||
|
const sliderTitle = 'Custom Keyword Slider';
|
||||||
|
|
||||||
|
cy.get('#sliderType').select('TMDB Movie Keyword');
|
||||||
|
|
||||||
|
cy.get('#title').type(sliderTitle);
|
||||||
|
// First confirm that an invalid keyword doesn't allow us to submit anything
|
||||||
|
cy.get('#data').type('invalidkeyword{enter}', { delay: 100 });
|
||||||
|
cy.wait('@searchKeyword');
|
||||||
|
|
||||||
|
cy.get('[data-testid=create-discover-option-form]')
|
||||||
|
.find('button')
|
||||||
|
.should('be.disabled');
|
||||||
|
|
||||||
|
cy.get('#data').clear();
|
||||||
|
cy.get('#data').type('time travel{enter}', { delay: 100 });
|
||||||
|
|
||||||
|
// Confirming we have some results
|
||||||
|
cy.contains('.slider-header', sliderTitle)
|
||||||
|
.next('[data-testid=media-slider]')
|
||||||
|
.find('[data-testid=title-card]');
|
||||||
|
|
||||||
|
cy.get('[data-testid=create-discover-option-form]').submit();
|
||||||
|
|
||||||
|
cy.wait('@discoverSlider');
|
||||||
|
cy.wait('@getDiscoverSliders');
|
||||||
|
cy.wait(1000);
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.first()
|
||||||
|
.should('contain', sliderTitle);
|
||||||
|
|
||||||
|
// Make sure its still there even if we reload
|
||||||
|
cy.reload();
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-start-editing]').click();
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.first()
|
||||||
|
.should('contain', sliderTitle);
|
||||||
|
|
||||||
|
// Verify it's not rendering on our discover page (its still disabled!)
|
||||||
|
cy.visit('/');
|
||||||
|
|
||||||
|
cy.get('.slider-header').should('not.contain', sliderTitle);
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-start-editing]').click();
|
||||||
|
|
||||||
|
// Enable it, and check again
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.first()
|
||||||
|
.find('[role="checkbox"]')
|
||||||
|
.click();
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-customize-submit').click();
|
||||||
|
cy.wait('@getDiscoverSliders');
|
||||||
|
|
||||||
|
cy.visit('/');
|
||||||
|
|
||||||
|
cy.contains('.slider-header', sliderTitle)
|
||||||
|
.next('[data-testid=media-slider]')
|
||||||
|
.find('[data-testid=title-card]');
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-start-editing]').click();
|
||||||
|
|
||||||
|
// let's delete it and confirm its deleted.
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.first()
|
||||||
|
.find('[data-testid=discover-slider-remove-button]')
|
||||||
|
.click();
|
||||||
|
|
||||||
|
cy.wait('@discoverSlider');
|
||||||
|
cy.wait('@getDiscoverSliders');
|
||||||
|
cy.wait(1000);
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.first()
|
||||||
|
.should('not.contain', sliderTitle);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,7 +16,7 @@ describe('General Settings', () => {
|
|||||||
cy.visit('/settings');
|
cy.visit('/settings');
|
||||||
|
|
||||||
cy.get('#trustProxy').click();
|
cy.get('#trustProxy').click();
|
||||||
cy.get('form').submit();
|
cy.get('[data-testid=settings-main-form]').submit();
|
||||||
cy.get('[data-testid=modal-title]').should(
|
cy.get('[data-testid=modal-title]').should(
|
||||||
'contain',
|
'contain',
|
||||||
'Server Restart Required'
|
'Server Restart Required'
|
||||||
@@ -26,7 +26,7 @@ describe('General Settings', () => {
|
|||||||
cy.get('[data-testid=modal-title]').should('not.exist');
|
cy.get('[data-testid=modal-title]').should('not.exist');
|
||||||
|
|
||||||
cy.get('[type=checkbox]#trustProxy').click();
|
cy.get('[type=checkbox]#trustProxy').click();
|
||||||
cy.get('form').submit();
|
cy.get('[data-testid=settings-main-form]').submit();
|
||||||
cy.get('[data-testid=modal-title]').should('not.exist');
|
cy.get('[data-testid=modal-title]').should('not.exist');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ docker run -d \
|
|||||||
--name overseerr \
|
--name overseerr \
|
||||||
-e LOG_LEVEL=debug \
|
-e LOG_LEVEL=debug \
|
||||||
-e TZ=Asia/Tokyo \
|
-e TZ=Asia/Tokyo \
|
||||||
|
-e PORT=5055 `#optional` \
|
||||||
-p 5055:5055 \
|
-p 5055:5055 \
|
||||||
-v /path/to/appdata/config:/app/config \
|
-v /path/to/appdata/config:/app/config \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
@@ -81,6 +82,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- LOG_LEVEL=debug
|
- LOG_LEVEL=debug
|
||||||
- TZ=Asia/Tokyo
|
- TZ=Asia/Tokyo
|
||||||
|
- PORT=5055 #optional
|
||||||
ports:
|
ports:
|
||||||
- 5055:5055
|
- 5055:5055
|
||||||
volumes:
|
volumes:
|
||||||
@@ -88,7 +90,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, start all services defined in the your Compose file:
|
Then, start all services defined in the Compose file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
@@ -146,8 +148,6 @@ Then, create and start the Overseerr container:
|
|||||||
docker run -d --name overseerr -e LOG_LEVEL=debug -e TZ=Asia/Tokyo -p 5055:5055 -v "overseerr-data:/app/config" --restart unless-stopped fallenbagel/jellyseerr:latest
|
docker run -d --name overseerr -e LOG_LEVEL=debug -e TZ=Asia/Tokyo -p 5055:5055 -v "overseerr-data:/app/config" --restart unless-stopped fallenbagel/jellyseerr:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
If using a named volume like above, you can safely ignore the warning about the `/app/config` folder being incorrectly mounted on the setup page.
|
|
||||||
|
|
||||||
To access the files inside the volume created above, navigate to `\\wsl$\docker-desktop-data\version-pack-data\community\docker\volumes\overseerr-data\_data` using File Explorer.
|
To access the files inside the volume created above, navigate to `\\wsl$\docker-desktop-data\version-pack-data\community\docker\volumes\overseerr-data\_data` using File Explorer.
|
||||||
|
|
||||||
{% hint style="info" %}
|
{% hint style="info" %}
|
||||||
@@ -155,7 +155,7 @@ Docker on Windows works differently than it does on Linux; it runs Docker inside
|
|||||||
|
|
||||||
**If you must run Docker on Windows, you should put the `/app/config` directory mount inside the VM and not on the Windows host.** (This also applies to other containers with SQLite databases.)
|
**If you must run Docker on Windows, you should put the `/app/config` directory mount inside the VM and not on the Windows host.** (This also applies to other containers with SQLite databases.)
|
||||||
|
|
||||||
Named volumes, like in the example commands above, are automatically mounted inside the VM.
|
Named volumes, like in the example commands above, are automatically mounted inside the VM. Therefore the warning on the setup about the `/app/config` folder being incorrectly mounted page should be ignored.
|
||||||
{% endhint %}
|
{% endhint %}
|
||||||
|
|
||||||
## Linux
|
## Linux
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ tags:
|
|||||||
description: Endpoints related to retrieving movies and their details.
|
description: Endpoints related to retrieving movies and their details.
|
||||||
- name: tv
|
- name: tv
|
||||||
description: Endpoints related to retrieving TV series and their details.
|
description: Endpoints related to retrieving TV series and their details.
|
||||||
|
- name: other
|
||||||
|
description: Endpoints related to other TMDB data
|
||||||
- name: person
|
- name: person
|
||||||
description: Endpoints related to retrieving person details.
|
description: Endpoints related to retrieving person details.
|
||||||
- name: media
|
- name: media
|
||||||
@@ -648,6 +650,17 @@ components:
|
|||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
example: Adventure
|
example: Adventure
|
||||||
|
Company:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
logo_path:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
ProductionCompany:
|
ProductionCompany:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -1087,6 +1100,8 @@ components:
|
|||||||
nullable: true
|
nullable: true
|
||||||
status:
|
status:
|
||||||
type: number
|
type: number
|
||||||
|
example: 0
|
||||||
|
description: Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE`
|
||||||
requests:
|
requests:
|
||||||
type: array
|
type: array
|
||||||
readOnly: true
|
readOnly: true
|
||||||
@@ -1828,6 +1843,40 @@ components:
|
|||||||
message:
|
message:
|
||||||
type: string
|
type: string
|
||||||
example: A comment
|
example: A comment
|
||||||
|
DiscoverSlider:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
type:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
isBuiltIn:
|
||||||
|
type: boolean
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
data:
|
||||||
|
type: string
|
||||||
|
example: '1234'
|
||||||
|
nullable: true
|
||||||
|
required:
|
||||||
|
- type
|
||||||
|
- enabled
|
||||||
|
- title
|
||||||
|
- data
|
||||||
|
WatchProviderRegion:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
iso_3166_1:
|
||||||
|
type: string
|
||||||
|
english_name:
|
||||||
|
type: string
|
||||||
|
native_name:
|
||||||
|
type: string
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
cookieAuth:
|
cookieAuth:
|
||||||
type: apiKey
|
type: apiKey
|
||||||
@@ -3234,6 +3283,133 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'204':
|
'204':
|
||||||
description: Test notification attempted
|
description: Test notification attempted
|
||||||
|
/settings/discover:
|
||||||
|
get:
|
||||||
|
summary: Get all discover sliders
|
||||||
|
description: Returns all discovery sliders. Built-in and custom made.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Returned all discovery sliders
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/DiscoverSlider'
|
||||||
|
post:
|
||||||
|
summary: Batch update all sliders.
|
||||||
|
description: |
|
||||||
|
Batch update all sliders at once. Should also be used for creation. Will only update sliders provided
|
||||||
|
and will not delete any sliders not present in the request. If a slider is missing a required field,
|
||||||
|
it will be ignored. Requires the `ADMIN` permission.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/DiscoverSlider'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Returned all newly updated discovery sliders
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/DiscoverSlider'
|
||||||
|
/settings/discover/{sliderId}:
|
||||||
|
put:
|
||||||
|
summary: Update a single slider
|
||||||
|
description: |
|
||||||
|
Updates a single slider and return the newly updated slider. Requires the `ADMIN` permission.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
example: 'Slider Title'
|
||||||
|
type:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
data:
|
||||||
|
type: string
|
||||||
|
example: '1'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Returns newly added discovery slider
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DiscoverSlider'
|
||||||
|
delete:
|
||||||
|
summary: Delete slider by ID
|
||||||
|
description: Deletes the slider with the provided sliderId. Requires the `ADMIN` permission.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: sliderId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Slider successfully deleted
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DiscoverSlider'
|
||||||
|
/settings/discover/add:
|
||||||
|
post:
|
||||||
|
summary: Add a new slider
|
||||||
|
description: |
|
||||||
|
Add a single slider and return the newly created slider. Requires the `ADMIN` permission.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
example: 'New Slider'
|
||||||
|
type:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
data:
|
||||||
|
type: string
|
||||||
|
example: '1'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Returns newly added discovery slider
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DiscoverSlider'
|
||||||
|
/settings/discover/reset:
|
||||||
|
get:
|
||||||
|
summary: Reset all discover sliders
|
||||||
|
description: Resets all discovery sliders to the default values. Requires the `ADMIN` permission.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: All sliders reset to defaults
|
||||||
/settings/about:
|
/settings/about:
|
||||||
get:
|
get:
|
||||||
summary: Get server stats
|
summary: Get server stats
|
||||||
@@ -4115,6 +4291,86 @@ paths:
|
|||||||
- $ref: '#/components/schemas/MovieResult'
|
- $ref: '#/components/schemas/MovieResult'
|
||||||
- $ref: '#/components/schemas/TvResult'
|
- $ref: '#/components/schemas/TvResult'
|
||||||
- $ref: '#/components/schemas/PersonResult'
|
- $ref: '#/components/schemas/PersonResult'
|
||||||
|
/search/keyword:
|
||||||
|
get:
|
||||||
|
summary: Search for keywords
|
||||||
|
description: Returns a list of TMDB keywords matching the search query
|
||||||
|
tags:
|
||||||
|
- search
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: 'christmas'
|
||||||
|
- in: query
|
||||||
|
name: page
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
default: 1
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Results
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
page:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
totalPages:
|
||||||
|
type: number
|
||||||
|
example: 20
|
||||||
|
totalResults:
|
||||||
|
type: number
|
||||||
|
example: 200
|
||||||
|
results:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Keyword'
|
||||||
|
/search/company:
|
||||||
|
get:
|
||||||
|
summary: Search for companies
|
||||||
|
description: Returns a list of TMDB companies matching the search query. (Will not return origin country)
|
||||||
|
tags:
|
||||||
|
- search
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: 'Disney'
|
||||||
|
- in: query
|
||||||
|
name: page
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
default: 1
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Results
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
page:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
totalPages:
|
||||||
|
type: number
|
||||||
|
example: 20
|
||||||
|
totalResults:
|
||||||
|
type: number
|
||||||
|
example: 200
|
||||||
|
results:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Company'
|
||||||
/discover/movies:
|
/discover/movies:
|
||||||
get:
|
get:
|
||||||
summary: Discover movies
|
summary: Discover movies
|
||||||
@@ -4136,13 +4392,63 @@ paths:
|
|||||||
- in: query
|
- in: query
|
||||||
name: genre
|
name: genre
|
||||||
schema:
|
schema:
|
||||||
type: number
|
type: string
|
||||||
example: 18
|
example: 18
|
||||||
- in: query
|
- in: query
|
||||||
name: studio
|
name: studio
|
||||||
schema:
|
schema:
|
||||||
type: number
|
type: number
|
||||||
example: 1
|
example: 1
|
||||||
|
- in: query
|
||||||
|
name: keywords
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: 1,2
|
||||||
|
- in: query
|
||||||
|
name: sortBy
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: popularity.desc
|
||||||
|
- in: query
|
||||||
|
name: primaryReleaseDateGte
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: 2022-01-01
|
||||||
|
- in: query
|
||||||
|
name: primaryReleaseDateLte
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: 2023-01-01
|
||||||
|
- in: query
|
||||||
|
name: withRuntimeGte
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 60
|
||||||
|
- in: query
|
||||||
|
name: withRuntimeLte
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 120
|
||||||
|
- in: query
|
||||||
|
name: voteAverageGte
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 7
|
||||||
|
- in: query
|
||||||
|
name: voteAverageLte
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 10
|
||||||
|
- in: query
|
||||||
|
name: watchRegion
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: US
|
||||||
|
- in: query
|
||||||
|
name: watchProviders
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: 8|9
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Results
|
description: Results
|
||||||
@@ -4365,13 +4671,63 @@ paths:
|
|||||||
- in: query
|
- in: query
|
||||||
name: genre
|
name: genre
|
||||||
schema:
|
schema:
|
||||||
type: number
|
type: string
|
||||||
example: 18
|
example: 18
|
||||||
- in: query
|
- in: query
|
||||||
name: network
|
name: network
|
||||||
schema:
|
schema:
|
||||||
type: number
|
type: number
|
||||||
example: 1
|
example: 1
|
||||||
|
- in: query
|
||||||
|
name: keywords
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: 1,2
|
||||||
|
- in: query
|
||||||
|
name: sortBy
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: popularity.desc
|
||||||
|
- in: query
|
||||||
|
name: firstAirDateGte
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: 2022-01-01
|
||||||
|
- in: query
|
||||||
|
name: firstAirDateLte
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: 2023-01-01
|
||||||
|
- in: query
|
||||||
|
name: withRuntimeGte
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 60
|
||||||
|
- in: query
|
||||||
|
name: withRuntimeLte
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 120
|
||||||
|
- in: query
|
||||||
|
name: voteAverageGte
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 7
|
||||||
|
- in: query
|
||||||
|
name: voteAverageLte
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 10
|
||||||
|
- in: query
|
||||||
|
name: watchRegion
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: US
|
||||||
|
- in: query
|
||||||
|
name: watchProviders
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: 8|9
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Results
|
description: Results
|
||||||
@@ -5050,7 +5406,7 @@ paths:
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
enum: [pending, approve, decline, available]
|
enum: [approve, decline]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Request status changed
|
description: Request status changed
|
||||||
@@ -6170,6 +6526,89 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Issue'
|
$ref: '#/components/schemas/Issue'
|
||||||
|
/keyword/{keywordId}:
|
||||||
|
get:
|
||||||
|
summary: Get keyword
|
||||||
|
description: |
|
||||||
|
Returns a single keyword in JSON format.
|
||||||
|
tags:
|
||||||
|
- other
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: keywordId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Keyword returned
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Keyword'
|
||||||
|
/watchproviders/regions:
|
||||||
|
get:
|
||||||
|
summary: Get watch provider regions
|
||||||
|
description: |
|
||||||
|
Returns a list of all available watch provider regions.
|
||||||
|
tags:
|
||||||
|
- other
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Watch provider regions returned
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/WatchProviderRegion'
|
||||||
|
/watchproviders/movies:
|
||||||
|
get:
|
||||||
|
summary: Get watch provider movies
|
||||||
|
description: |
|
||||||
|
Returns a list of all available watch providers for movies.
|
||||||
|
tags:
|
||||||
|
- other
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: watchRegion
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: US
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Watch providers for movies returned
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/WatchProviderDetails'
|
||||||
|
/watchproviders/tv:
|
||||||
|
get:
|
||||||
|
summary: Get watch provider series
|
||||||
|
description: |
|
||||||
|
Returns a list of all available watch providers for series.
|
||||||
|
tags:
|
||||||
|
- other
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: watchRegion
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: US
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Watch providers for series returned
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/WatchProviderDetails'
|
||||||
security:
|
security:
|
||||||
- cookieAuth: []
|
- cookieAuth: []
|
||||||
- apiKey: []
|
- apiKey: []
|
||||||
|
|||||||
146
package.json
146
package.json
@@ -29,145 +29,149 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/intl-displaynames": "6.0.3",
|
"@formatjs/intl-displaynames": "6.2.3",
|
||||||
"@formatjs/intl-locale": "3.0.3",
|
"@formatjs/intl-locale": "3.0.11",
|
||||||
"@formatjs/intl-pluralrules": "5.0.3",
|
"@formatjs/intl-pluralrules": "5.1.8",
|
||||||
"@formatjs/intl-utils": "3.8.4",
|
"@formatjs/intl-utils": "3.8.4",
|
||||||
"@headlessui/react": "0.0.0-insiders.b301f04",
|
"@headlessui/react": "1.7.7",
|
||||||
"@heroicons/react": "1.0.6",
|
"@heroicons/react": "2.0.13",
|
||||||
"@supercharge/request-ip": "1.2.0",
|
"@supercharge/request-ip": "1.2.0",
|
||||||
"@svgr/webpack": "6.3.1",
|
"@svgr/webpack": "6.5.1",
|
||||||
"@tanem/react-nprogress": "5.0.11",
|
"@tanem/react-nprogress": "5.0.22",
|
||||||
"ace-builds": "1.9.6",
|
"ace-builds": "1.14.0",
|
||||||
"axios": "0.27.2",
|
"axios": "1.2.2",
|
||||||
"axios-rate-limit": "1.3.0",
|
"axios-rate-limit": "1.3.0",
|
||||||
"bcrypt": "5.0.1",
|
"bcrypt": "5.1.0",
|
||||||
"bowser": "2.11.0",
|
"bowser": "2.11.0",
|
||||||
"connect-typeorm": "1.1.4",
|
"connect-typeorm": "1.1.4",
|
||||||
"cookie-parser": "1.4.6",
|
"cookie-parser": "1.4.6",
|
||||||
"copy-to-clipboard": "3.3.2",
|
"copy-to-clipboard": "3.3.3",
|
||||||
"country-flag-icons": "1.5.5",
|
"country-flag-icons": "1.5.5",
|
||||||
"cronstrue": "2.11.0",
|
"cronstrue": "2.21.0",
|
||||||
"csurf": "1.11.0",
|
"csurf": "1.11.0",
|
||||||
"date-fns": "2.29.1",
|
"date-fns": "2.29.3",
|
||||||
|
"dayjs": "1.11.7",
|
||||||
"email-templates": "9.0.0",
|
"email-templates": "9.0.0",
|
||||||
"email-validator": "2.0.4",
|
"email-validator": "2.0.4",
|
||||||
"express": "4.18.1",
|
"express": "4.18.2",
|
||||||
"express-openapi-validator": "4.13.8",
|
"express-openapi-validator": "4.13.8",
|
||||||
"express-rate-limit": "6.5.1",
|
"express-rate-limit": "6.7.0",
|
||||||
"express-session": "1.17.3",
|
"express-session": "1.17.3",
|
||||||
"formik": "2.2.9",
|
"formik": "2.2.9",
|
||||||
"gravatar-url": "3.1.0",
|
"gravatar-url": "3.1.0",
|
||||||
"intl": "1.2.5",
|
"intl": "1.2.5",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"next": "12.2.5",
|
"next": "12.3.4",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"node-gyp": "9.1.0",
|
"node-gyp": "9.3.1",
|
||||||
"node-schedule": "2.1.0",
|
"node-schedule": "2.1.0",
|
||||||
"nodemailer": "6.7.8",
|
"nodemailer": "6.8.0",
|
||||||
"openpgp": "5.4.0",
|
"openpgp": "5.5.0",
|
||||||
"plex-api": "5.3.2",
|
"plex-api": "5.3.2",
|
||||||
"pug": "3.0.2",
|
"pug": "3.0.2",
|
||||||
"pulltorefreshjs": "0.1.22",
|
"pulltorefreshjs": "0.1.22",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-ace": "10.1.0",
|
"react-ace": "10.1.0",
|
||||||
"react-animate-height": "2.1.2",
|
"react-animate-height": "2.1.2",
|
||||||
|
"react-aria": "3.22.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-intersection-observer": "9.4.0",
|
"react-intersection-observer": "9.4.1",
|
||||||
"react-intl": "6.0.5",
|
"react-intl": "6.2.5",
|
||||||
"react-markdown": "8.0.3",
|
"react-markdown": "8.0.4",
|
||||||
"react-popper-tooltip": "4.4.2",
|
"react-popper-tooltip": "4.4.2",
|
||||||
"react-select": "5.4.0",
|
"react-select": "5.7.0",
|
||||||
"react-spring": "9.5.2",
|
"react-spring": "9.6.1",
|
||||||
|
"react-tailwindcss-datepicker-sct": "1.3.4",
|
||||||
"react-toast-notifications": "2.5.1",
|
"react-toast-notifications": "2.5.1",
|
||||||
"react-truncate-markup": "5.1.2",
|
"react-truncate-markup": "5.1.2",
|
||||||
"react-use-clipboard": "1.0.8",
|
"react-use-clipboard": "1.0.9",
|
||||||
"reflect-metadata": "0.1.13",
|
"reflect-metadata": "0.1.13",
|
||||||
"secure-random-password": "0.2.3",
|
"secure-random-password": "0.2.3",
|
||||||
"semver": "7.3.7",
|
"semver": "7.3.8",
|
||||||
"sqlite3": "5.0.11",
|
"sqlite3": "5.1.4",
|
||||||
"swagger-ui-express": "4.5.0",
|
"swagger-ui-express": "4.6.0",
|
||||||
"swr": "1.3.0",
|
"swr": "2.0.0",
|
||||||
"typeorm": "0.3.7",
|
"typeorm": "0.3.11",
|
||||||
"web-push": "3.5.0",
|
"web-push": "3.5.0",
|
||||||
"winston": "3.8.1",
|
"winston": "3.8.2",
|
||||||
"winston-daily-rotate-file": "4.7.1",
|
"winston-daily-rotate-file": "4.7.1",
|
||||||
"xml2js": "0.4.23",
|
"xml2js": "0.4.23",
|
||||||
"yamljs": "0.3.0",
|
"yamljs": "0.3.0",
|
||||||
"yup": "0.32.11"
|
"yup": "0.32.11",
|
||||||
|
"zod": "3.20.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/cli": "7.18.10",
|
"@babel/cli": "7.20.7",
|
||||||
"@commitlint/cli": "17.0.3",
|
"@commitlint/cli": "17.4.0",
|
||||||
"@commitlint/config-conventional": "17.0.3",
|
"@commitlint/config-conventional": "17.4.0",
|
||||||
"@semantic-release/changelog": "6.0.1",
|
"@semantic-release/changelog": "6.0.2",
|
||||||
"@semantic-release/commit-analyzer": "9.0.2",
|
"@semantic-release/commit-analyzer": "9.0.2",
|
||||||
"@semantic-release/exec": "6.0.3",
|
"@semantic-release/exec": "6.0.3",
|
||||||
"@semantic-release/git": "10.0.1",
|
"@semantic-release/git": "10.0.1",
|
||||||
"@tailwindcss/aspect-ratio": "0.4.0",
|
"@tailwindcss/aspect-ratio": "0.4.2",
|
||||||
"@tailwindcss/forms": "0.5.2",
|
"@tailwindcss/forms": "0.5.3",
|
||||||
"@tailwindcss/typography": "0.5.4",
|
"@tailwindcss/typography": "0.5.8",
|
||||||
"@types/bcrypt": "5.0.0",
|
"@types/bcrypt": "5.0.0",
|
||||||
"@types/cookie-parser": "1.4.3",
|
"@types/cookie-parser": "1.4.3",
|
||||||
"@types/country-flag-icons": "1.2.0",
|
"@types/country-flag-icons": "1.2.0",
|
||||||
"@types/csurf": "1.11.2",
|
"@types/csurf": "1.11.2",
|
||||||
"@types/email-templates": "8.0.4",
|
"@types/email-templates": "8.0.4",
|
||||||
"@types/express": "4.17.13",
|
"@types/express": "4.17.15",
|
||||||
"@types/express-session": "1.17.4",
|
"@types/express-session": "1.17.5",
|
||||||
"@types/lodash": "4.14.183",
|
"@types/lodash": "4.14.191",
|
||||||
"@types/node": "17.0.36",
|
"@types/node": "17.0.36",
|
||||||
"@types/node-schedule": "2.1.0",
|
"@types/node-schedule": "2.1.0",
|
||||||
"@types/nodemailer": "6.4.5",
|
"@types/nodemailer": "6.4.7",
|
||||||
"@types/pulltorefreshjs": "0.1.5",
|
"@types/pulltorefreshjs": "0.1.5",
|
||||||
"@types/react": "18.0.17",
|
"@types/react": "18.0.26",
|
||||||
"@types/react-dom": "18.0.6",
|
"@types/react-dom": "18.0.10",
|
||||||
"@types/react-transition-group": "4.4.5",
|
"@types/react-transition-group": "4.4.5",
|
||||||
"@types/secure-random-password": "0.2.1",
|
"@types/secure-random-password": "0.2.1",
|
||||||
"@types/semver": "7.3.12",
|
"@types/semver": "7.3.13",
|
||||||
"@types/swagger-ui-express": "4.1.3",
|
"@types/swagger-ui-express": "4.1.3",
|
||||||
"@types/web-push": "3.3.2",
|
"@types/web-push": "3.3.2",
|
||||||
"@types/xml2js": "0.4.11",
|
"@types/xml2js": "0.4.11",
|
||||||
"@types/yamljs": "0.2.31",
|
"@types/yamljs": "0.2.31",
|
||||||
"@types/yup": "0.29.14",
|
"@types/yup": "0.29.14",
|
||||||
"@typescript-eslint/eslint-plugin": "5.33.1",
|
"@typescript-eslint/eslint-plugin": "5.48.0",
|
||||||
"@typescript-eslint/parser": "5.33.1",
|
"@typescript-eslint/parser": "5.48.0",
|
||||||
"autoprefixer": "10.4.8",
|
"autoprefixer": "10.4.13",
|
||||||
"babel-plugin-react-intl": "8.2.25",
|
"babel-plugin-react-intl": "8.2.25",
|
||||||
"babel-plugin-react-intl-auto": "3.3.0",
|
"babel-plugin-react-intl-auto": "3.3.0",
|
||||||
"commitizen": "4.2.5",
|
"commitizen": "4.2.6",
|
||||||
"copyfiles": "2.4.1",
|
"copyfiles": "2.4.1",
|
||||||
"cy-mobile-commands": "0.3.0",
|
"cy-mobile-commands": "0.3.0",
|
||||||
"cypress": "10.6.0",
|
"cypress": "12.3.0",
|
||||||
"cz-conventional-changelog": "3.3.0",
|
"cz-conventional-changelog": "3.3.0",
|
||||||
"eslint": "8.22.0",
|
"eslint": "8.31.0",
|
||||||
"eslint-config-next": "12.2.5",
|
"eslint-config-next": "12.3.4",
|
||||||
"eslint-config-prettier": "8.5.0",
|
"eslint-config-prettier": "8.6.0",
|
||||||
"eslint-plugin-formatjs": "4.1.0",
|
"eslint-plugin-formatjs": "4.3.9",
|
||||||
"eslint-plugin-jsx-a11y": "6.6.1",
|
"eslint-plugin-jsx-a11y": "6.6.1",
|
||||||
"eslint-plugin-no-relative-import-paths": "1.4.0",
|
"eslint-plugin-no-relative-import-paths": "1.5.2",
|
||||||
"eslint-plugin-prettier": "4.2.1",
|
"eslint-plugin-prettier": "4.2.1",
|
||||||
"eslint-plugin-react": "7.30.1",
|
"eslint-plugin-react": "7.31.11",
|
||||||
"eslint-plugin-react-hooks": "4.6.0",
|
"eslint-plugin-react-hooks": "4.6.0",
|
||||||
"extract-react-intl-messages": "4.1.1",
|
"extract-react-intl-messages": "4.1.1",
|
||||||
"husky": "8.0.1",
|
"husky": "8.0.3",
|
||||||
"lint-staged": "12.4.3",
|
"lint-staged": "13.1.0",
|
||||||
"nodemon": "2.0.19",
|
"nodemon": "2.0.20",
|
||||||
"postcss": "8.4.16",
|
"postcss": "8.4.20",
|
||||||
"prettier": "2.7.1",
|
"prettier": "2.8.1",
|
||||||
"prettier-plugin-organize-imports": "3.1.0",
|
"prettier-plugin-organize-imports": "3.2.1",
|
||||||
"prettier-plugin-tailwindcss": "0.1.13",
|
"prettier-plugin-tailwindcss": "0.2.1",
|
||||||
"semantic-release": "19.0.3",
|
"semantic-release": "19.0.5",
|
||||||
"semantic-release-docker-buildx": "1.0.1",
|
"semantic-release-docker-buildx": "1.0.1",
|
||||||
"tailwindcss": "3.1.8",
|
"tailwindcss": "3.2.4",
|
||||||
"ts-node": "10.9.1",
|
"ts-node": "10.9.1",
|
||||||
"tsc-alias": "1.7.0",
|
"tsc-alias": "1.8.2",
|
||||||
"tsconfig-paths": "4.1.0",
|
"tsconfig-paths": "4.1.2",
|
||||||
"typescript": "4.7.4"
|
"typescript": "4.9.4"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"sqlite3/node-gyp": "8.4.1",
|
"sqlite3/node-gyp": "8.4.1",
|
||||||
"@types/react": "18.0.17",
|
"@types/react": "18.0.26",
|
||||||
"@types/react-dom": "18.0.6"
|
"@types/react-dom": "18.0.10"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"commitizen": {
|
"commitizen": {
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
[ZoneTransfer]
|
|
||||||
LastWriterPackageFamilyName=Microsoft.ScreenSketch_8wekyb3d8bbwe
|
|
||||||
ZoneId=3
|
|
||||||
@@ -226,12 +226,13 @@ class PlexAPI {
|
|||||||
id: string,
|
id: string,
|
||||||
options: { addedAt: number } = {
|
options: { addedAt: number } = {
|
||||||
addedAt: Date.now() - 1000 * 60 * 60,
|
addedAt: Date.now() - 1000 * 60 * 60,
|
||||||
}
|
},
|
||||||
|
mediaType: 'movie' | 'show'
|
||||||
): Promise<PlexLibraryItem[]> {
|
): Promise<PlexLibraryItem[]> {
|
||||||
const response = await this.plexClient.query<PlexLibraryResponse>({
|
const response = await this.plexClient.query<PlexLibraryResponse>({
|
||||||
uri: `/library/sections/${id}/all?sort=addedAt%3Adesc&addedAt>>=${Math.floor(
|
uri: `/library/sections/${id}/all?type=${
|
||||||
options.addedAt / 1000
|
mediaType === 'show' ? '4' : '1'
|
||||||
)}`,
|
}&sort=addedAt%3Adesc&addedAt>>=${Math.floor(options.addedAt / 1000)}`,
|
||||||
extraHeaders: {
|
extraHeaders: {
|
||||||
'X-Plex-Container-Start': `0`,
|
'X-Plex-Container-Start': `0`,
|
||||||
'X-Plex-Container-Size': `500`,
|
'X-Plex-Container-Size': `500`,
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import cacheManager from '@server/lib/cache';
|
|||||||
import { sortBy } from 'lodash';
|
import { sortBy } from 'lodash';
|
||||||
import type {
|
import type {
|
||||||
TmdbCollection,
|
TmdbCollection,
|
||||||
|
TmdbCompanySearchResponse,
|
||||||
TmdbExternalIdResponse,
|
TmdbExternalIdResponse,
|
||||||
TmdbGenre,
|
TmdbGenre,
|
||||||
TmdbGenresResult,
|
TmdbGenresResult,
|
||||||
|
TmdbKeyword,
|
||||||
|
TmdbKeywordSearchResponse,
|
||||||
TmdbLanguage,
|
TmdbLanguage,
|
||||||
TmdbMovieDetails,
|
TmdbMovieDetails,
|
||||||
TmdbNetwork,
|
TmdbNetwork,
|
||||||
@@ -19,6 +22,8 @@ import type {
|
|||||||
TmdbSeasonWithEpisodes,
|
TmdbSeasonWithEpisodes,
|
||||||
TmdbTvDetails,
|
TmdbTvDetails,
|
||||||
TmdbUpcomingMoviesResponse,
|
TmdbUpcomingMoviesResponse,
|
||||||
|
TmdbWatchProviderDetails,
|
||||||
|
TmdbWatchProviderRegion,
|
||||||
} from './interfaces';
|
} from './interfaces';
|
||||||
|
|
||||||
interface SearchOptions {
|
interface SearchOptions {
|
||||||
@@ -32,16 +37,7 @@ interface SingleSearchOptions extends SearchOptions {
|
|||||||
year?: number;
|
year?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DiscoverMovieOptions {
|
export type SortOptions =
|
||||||
page?: number;
|
|
||||||
includeAdult?: boolean;
|
|
||||||
language?: string;
|
|
||||||
primaryReleaseDateGte?: string;
|
|
||||||
primaryReleaseDateLte?: string;
|
|
||||||
originalLanguage?: string;
|
|
||||||
genre?: number;
|
|
||||||
studio?: number;
|
|
||||||
sortBy?:
|
|
||||||
| 'popularity.asc'
|
| 'popularity.asc'
|
||||||
| 'popularity.desc'
|
| 'popularity.desc'
|
||||||
| 'release_date.asc'
|
| 'release_date.asc'
|
||||||
@@ -55,7 +51,27 @@ interface DiscoverMovieOptions {
|
|||||||
| 'vote_average.asc'
|
| 'vote_average.asc'
|
||||||
| 'vote_average.desc'
|
| 'vote_average.desc'
|
||||||
| 'vote_count.asc'
|
| 'vote_count.asc'
|
||||||
| 'vote_count.desc';
|
| 'vote_count.desc'
|
||||||
|
| 'first_air_date.asc'
|
||||||
|
| 'first_air_date.desc';
|
||||||
|
|
||||||
|
interface DiscoverMovieOptions {
|
||||||
|
page?: number;
|
||||||
|
includeAdult?: boolean;
|
||||||
|
language?: string;
|
||||||
|
primaryReleaseDateGte?: string;
|
||||||
|
primaryReleaseDateLte?: string;
|
||||||
|
withRuntimeGte?: string;
|
||||||
|
withRuntimeLte?: string;
|
||||||
|
voteAverageGte?: string;
|
||||||
|
voteAverageLte?: string;
|
||||||
|
originalLanguage?: string;
|
||||||
|
genre?: string;
|
||||||
|
studio?: string;
|
||||||
|
keywords?: string;
|
||||||
|
sortBy?: SortOptions;
|
||||||
|
watchRegion?: string;
|
||||||
|
watchProviders?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DiscoverTvOptions {
|
interface DiscoverTvOptions {
|
||||||
@@ -63,19 +79,18 @@ interface DiscoverTvOptions {
|
|||||||
language?: string;
|
language?: string;
|
||||||
firstAirDateGte?: string;
|
firstAirDateGte?: string;
|
||||||
firstAirDateLte?: string;
|
firstAirDateLte?: string;
|
||||||
|
withRuntimeGte?: string;
|
||||||
|
withRuntimeLte?: string;
|
||||||
|
voteAverageGte?: string;
|
||||||
|
voteAverageLte?: string;
|
||||||
includeEmptyReleaseDate?: boolean;
|
includeEmptyReleaseDate?: boolean;
|
||||||
originalLanguage?: string;
|
originalLanguage?: string;
|
||||||
genre?: number;
|
genre?: string;
|
||||||
network?: number;
|
network?: number;
|
||||||
sortBy?:
|
keywords?: string;
|
||||||
| 'popularity.asc'
|
sortBy?: SortOptions;
|
||||||
| 'popularity.desc'
|
watchRegion?: string;
|
||||||
| 'vote_average.asc'
|
watchProviders?: string;
|
||||||
| 'vote_average.desc'
|
|
||||||
| 'vote_count.asc'
|
|
||||||
| 'vote_count.desc'
|
|
||||||
| 'first_air_date.asc'
|
|
||||||
| 'first_air_date.desc';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class TheMovieDb extends ExternalAPI {
|
class TheMovieDb extends ExternalAPI {
|
||||||
@@ -237,7 +252,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
params: {
|
params: {
|
||||||
language,
|
language,
|
||||||
append_to_response:
|
append_to_response:
|
||||||
'credits,external_ids,videos,release_dates,watch/providers',
|
'credits,external_ids,videos,keywords,release_dates,watch/providers',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
43200
|
43200
|
||||||
@@ -440,8 +455,25 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
originalLanguage,
|
originalLanguage,
|
||||||
genre,
|
genre,
|
||||||
studio,
|
studio,
|
||||||
|
keywords,
|
||||||
|
withRuntimeGte,
|
||||||
|
withRuntimeLte,
|
||||||
|
voteAverageGte,
|
||||||
|
voteAverageLte,
|
||||||
|
watchProviders,
|
||||||
|
watchRegion,
|
||||||
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
|
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
|
||||||
try {
|
try {
|
||||||
|
const defaultFutureDate = new Date(
|
||||||
|
Date.now() + 1000 * 60 * 60 * 24 * (365 * 1.5)
|
||||||
|
)
|
||||||
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
|
||||||
|
const defaultPastDate = new Date('1900-01-01')
|
||||||
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
|
||||||
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
|
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
|
||||||
params: {
|
params: {
|
||||||
sort_by: sortBy,
|
sort_by: sortBy,
|
||||||
@@ -449,11 +481,31 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
include_adult: includeAdult,
|
include_adult: includeAdult,
|
||||||
language,
|
language,
|
||||||
region: this.region,
|
region: this.region,
|
||||||
with_original_language: originalLanguage ?? this.originalLanguage,
|
with_original_language:
|
||||||
'primary_release_date.gte': primaryReleaseDateGte,
|
originalLanguage && originalLanguage !== 'all'
|
||||||
'primary_release_date.lte': primaryReleaseDateLte,
|
? originalLanguage
|
||||||
|
: originalLanguage === 'all'
|
||||||
|
? undefined
|
||||||
|
: this.originalLanguage,
|
||||||
|
// Set our release date values, but check if one is set and not the other,
|
||||||
|
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
||||||
|
'primary_release_date.gte':
|
||||||
|
!primaryReleaseDateGte && primaryReleaseDateLte
|
||||||
|
? defaultPastDate
|
||||||
|
: primaryReleaseDateGte,
|
||||||
|
'primary_release_date.lte':
|
||||||
|
!primaryReleaseDateLte && primaryReleaseDateGte
|
||||||
|
? defaultFutureDate
|
||||||
|
: primaryReleaseDateLte,
|
||||||
with_genres: genre,
|
with_genres: genre,
|
||||||
with_companies: studio,
|
with_companies: studio,
|
||||||
|
with_keywords: keywords,
|
||||||
|
'with_runtime.gte': withRuntimeGte,
|
||||||
|
'with_runtime.lte': withRuntimeLte,
|
||||||
|
'vote_average.gte': voteAverageGte,
|
||||||
|
'vote_average.lte': voteAverageLte,
|
||||||
|
watch_region: watchRegion,
|
||||||
|
with_watch_providers: watchProviders,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -473,20 +525,57 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
originalLanguage,
|
originalLanguage,
|
||||||
genre,
|
genre,
|
||||||
network,
|
network,
|
||||||
|
keywords,
|
||||||
|
withRuntimeGte,
|
||||||
|
withRuntimeLte,
|
||||||
|
voteAverageGte,
|
||||||
|
voteAverageLte,
|
||||||
|
watchProviders,
|
||||||
|
watchRegion,
|
||||||
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
||||||
try {
|
try {
|
||||||
|
const defaultFutureDate = new Date(
|
||||||
|
Date.now() + 1000 * 60 * 60 * 24 * (365 * 1.5)
|
||||||
|
)
|
||||||
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
|
||||||
|
const defaultPastDate = new Date('1900-01-01')
|
||||||
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
|
||||||
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
|
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
|
||||||
params: {
|
params: {
|
||||||
sort_by: sortBy,
|
sort_by: sortBy,
|
||||||
page,
|
page,
|
||||||
language,
|
language,
|
||||||
region: this.region,
|
region: this.region,
|
||||||
'first_air_date.gte': firstAirDateGte,
|
// Set our release date values, but check if one is set and not the other,
|
||||||
'first_air_date.lte': firstAirDateLte,
|
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
||||||
with_original_language: originalLanguage ?? this.originalLanguage,
|
'first_air_date.gte':
|
||||||
|
!firstAirDateGte && firstAirDateLte
|
||||||
|
? defaultPastDate
|
||||||
|
: firstAirDateGte,
|
||||||
|
'first_air_date.lte':
|
||||||
|
!firstAirDateLte && firstAirDateGte
|
||||||
|
? defaultFutureDate
|
||||||
|
: firstAirDateLte,
|
||||||
|
with_original_language:
|
||||||
|
originalLanguage && originalLanguage !== 'all'
|
||||||
|
? originalLanguage
|
||||||
|
: originalLanguage === 'all'
|
||||||
|
? undefined
|
||||||
|
: this.originalLanguage,
|
||||||
include_null_first_air_dates: includeEmptyReleaseDate,
|
include_null_first_air_dates: includeEmptyReleaseDate,
|
||||||
with_genres: genre,
|
with_genres: genre,
|
||||||
with_networks: network,
|
with_networks: network,
|
||||||
|
with_keywords: keywords,
|
||||||
|
'with_runtime.gte': withRuntimeGte,
|
||||||
|
'with_runtime.lte': withRuntimeLte,
|
||||||
|
'vote_average.gte': voteAverageGte,
|
||||||
|
'vote_average.lte': voteAverageLte,
|
||||||
|
with_watch_providers: watchProviders,
|
||||||
|
watch_region: watchRegion,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -874,6 +963,152 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
throw new Error(`[TMDB] Failed to fetch TV genres: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch TV genres: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getKeywordDetails({
|
||||||
|
keywordId,
|
||||||
|
}: {
|
||||||
|
keywordId: number;
|
||||||
|
}): Promise<TmdbKeyword> {
|
||||||
|
try {
|
||||||
|
const data = await this.get<TmdbKeyword>(
|
||||||
|
`/keyword/${keywordId}`,
|
||||||
|
undefined,
|
||||||
|
604800 // 7 days
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[TMDB] Failed to fetch keyword: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async searchKeyword({
|
||||||
|
query,
|
||||||
|
page = 1,
|
||||||
|
}: {
|
||||||
|
query: string;
|
||||||
|
page?: number;
|
||||||
|
}): Promise<TmdbKeywordSearchResponse> {
|
||||||
|
try {
|
||||||
|
const data = await this.get<TmdbKeywordSearchResponse>(
|
||||||
|
'/search/keyword',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
query,
|
||||||
|
page,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
86400 // 24 hours
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[TMDB] Failed to search keyword: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async searchCompany({
|
||||||
|
query,
|
||||||
|
page = 1,
|
||||||
|
}: {
|
||||||
|
query: string;
|
||||||
|
page?: number;
|
||||||
|
}): Promise<TmdbCompanySearchResponse> {
|
||||||
|
try {
|
||||||
|
const data = await this.get<TmdbCompanySearchResponse>(
|
||||||
|
'/search/company',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
query,
|
||||||
|
page,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
86400 // 24 hours
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[TMDB] Failed to search companies: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAvailableWatchProviderRegions({
|
||||||
|
language,
|
||||||
|
}: {
|
||||||
|
language?: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const data = await this.get<{ results: TmdbWatchProviderRegion[] }>(
|
||||||
|
'/watch/providers/regions',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
language: language ?? this.originalLanguage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
86400 // 24 hours
|
||||||
|
);
|
||||||
|
|
||||||
|
return data.results;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`[TMDB] Failed to fetch available watch regions: ${e.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getMovieWatchProviders({
|
||||||
|
language,
|
||||||
|
watchRegion,
|
||||||
|
}: {
|
||||||
|
language?: string;
|
||||||
|
watchRegion: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
|
||||||
|
'/watch/providers/movie',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
language: language ?? this.originalLanguage,
|
||||||
|
watch_region: watchRegion,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
86400 // 24 hours
|
||||||
|
);
|
||||||
|
|
||||||
|
return data.results;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`[TMDB] Failed to fetch movie watch providers: ${e.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getTvWatchProviders({
|
||||||
|
language,
|
||||||
|
watchRegion,
|
||||||
|
}: {
|
||||||
|
language?: string;
|
||||||
|
watchRegion: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
|
||||||
|
'/watch/providers/tv',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
language: language ?? this.originalLanguage,
|
||||||
|
watch_region: watchRegion,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
86400 // 24 hours
|
||||||
|
);
|
||||||
|
|
||||||
|
return data.results;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`[TMDB] Failed to fetch TV watch providers: ${e.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TheMovieDb;
|
export default TheMovieDb;
|
||||||
|
|||||||
@@ -171,6 +171,9 @@ export interface TmdbMovieDetails {
|
|||||||
id: number;
|
id: number;
|
||||||
results?: { [iso_3166_1: string]: TmdbWatchProviders };
|
results?: { [iso_3166_1: string]: TmdbWatchProviders };
|
||||||
};
|
};
|
||||||
|
keywords: {
|
||||||
|
keywords: TmdbKeyword[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TmdbVideo {
|
export interface TmdbVideo {
|
||||||
@@ -428,3 +431,24 @@ export interface TmdbWatchProviderDetails {
|
|||||||
provider_id: number;
|
provider_id: number;
|
||||||
provider_name: string;
|
provider_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TmdbKeywordSearchResponse extends TmdbPaginatedResponse {
|
||||||
|
results: TmdbKeyword[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// We have production companies, but the company search results return less data
|
||||||
|
export interface TmdbCompany {
|
||||||
|
id: number;
|
||||||
|
logo_path?: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TmdbCompanySearchResponse extends TmdbPaginatedResponse {
|
||||||
|
results: TmdbCompany[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TmdbWatchProviderRegion {
|
||||||
|
iso_3166_1: string;
|
||||||
|
english_name: string;
|
||||||
|
native_name: string;
|
||||||
|
}
|
||||||
|
|||||||
98
server/constants/discover.ts
Normal file
98
server/constants/discover.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import type DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||||
|
|
||||||
|
export enum DiscoverSliderType {
|
||||||
|
RECENTLY_ADDED = 1,
|
||||||
|
RECENT_REQUESTS,
|
||||||
|
PLEX_WATCHLIST,
|
||||||
|
TRENDING,
|
||||||
|
POPULAR_MOVIES,
|
||||||
|
MOVIE_GENRES,
|
||||||
|
UPCOMING_MOVIES,
|
||||||
|
STUDIOS,
|
||||||
|
POPULAR_TV,
|
||||||
|
TV_GENRES,
|
||||||
|
UPCOMING_TV,
|
||||||
|
NETWORKS,
|
||||||
|
TMDB_MOVIE_KEYWORD,
|
||||||
|
TMDB_MOVIE_GENRE,
|
||||||
|
TMDB_TV_KEYWORD,
|
||||||
|
TMDB_TV_GENRE,
|
||||||
|
TMDB_SEARCH,
|
||||||
|
TMDB_STUDIO,
|
||||||
|
TMDB_NETWORK,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultSliders: Partial<DiscoverSlider>[] = [
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.RECENTLY_ADDED,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.RECENT_REQUESTS,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.PLEX_WATCHLIST,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.TRENDING,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.POPULAR_MOVIES,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.MOVIE_GENRES,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.UPCOMING_MOVIES,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.STUDIOS,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.POPULAR_TV,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.TV_GENRES,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.UPCOMING_TV,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.NETWORKS,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 11,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -34,7 +34,7 @@ const dataSource = new DataSource(
|
|||||||
process.env.NODE_ENV !== 'production' ? devConfig : prodConfig
|
process.env.NODE_ENV !== 'production' ? devConfig : prodConfig
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getRepository = <Entity>(
|
export const getRepository = <Entity extends object>(
|
||||||
target: EntityTarget<Entity>
|
target: EntityTarget<Entity>
|
||||||
): Repository<Entity> => {
|
): Repository<Entity> => {
|
||||||
return dataSource.getRepository(target);
|
return dataSource.getRepository(target);
|
||||||
|
|||||||
69
server/entity/DiscoverSlider.ts
Normal file
69
server/entity/DiscoverSlider.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import type { DiscoverSliderType } from '@server/constants/discover';
|
||||||
|
import { defaultSliders } from '@server/constants/discover';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
class DiscoverSlider {
|
||||||
|
public static async bootstrapSliders(): Promise<void> {
|
||||||
|
const sliderRepository = getRepository(DiscoverSlider);
|
||||||
|
|
||||||
|
for (const slider of defaultSliders) {
|
||||||
|
const existingSlider = await sliderRepository.findOne({
|
||||||
|
where: {
|
||||||
|
type: slider.type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingSlider) {
|
||||||
|
logger.info('Creating built-in discovery slider', {
|
||||||
|
label: 'Discover Slider',
|
||||||
|
slider,
|
||||||
|
});
|
||||||
|
await sliderRepository.save(new DiscoverSlider(slider));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
public id: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int' })
|
||||||
|
public type: DiscoverSliderType;
|
||||||
|
|
||||||
|
@Column({ type: 'int' })
|
||||||
|
public order: number;
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
public isBuiltIn: boolean;
|
||||||
|
|
||||||
|
@Column({ default: true })
|
||||||
|
public enabled: boolean;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
// Title is not required for built in sliders because we will
|
||||||
|
// use translations for them.
|
||||||
|
public title?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public data?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
public createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
public updatedAt: Date;
|
||||||
|
|
||||||
|
constructor(init?: Partial<DiscoverSlider>) {
|
||||||
|
Object.assign(this, init);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DiscoverSlider;
|
||||||
@@ -767,7 +767,16 @@ export class MediaRequest {
|
|||||||
if (
|
if (
|
||||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||||
) {
|
) {
|
||||||
throw new Error('Media already available');
|
logger.warn('Media already exists, marking request as APPROVED', {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
this.status = MediaRequestStatus.APPROVED;
|
||||||
|
await requestRepository.save(this);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const radarrMovieOptions: RadarrMovieOptions = {
|
const radarrMovieOptions: RadarrMovieOptions = {
|
||||||
@@ -908,7 +917,16 @@ export class MediaRequest {
|
|||||||
if (
|
if (
|
||||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||||
) {
|
) {
|
||||||
throw new Error('Media already available');
|
logger.warn('Media already exists, marking request as APPROVED', {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
this.status = MediaRequestStatus.APPROVED;
|
||||||
|
await requestRepository.save(this);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import PlexAPI from '@server/api/plexapi';
|
import PlexAPI from '@server/api/plexapi';
|
||||||
import dataSource, { getRepository } from '@server/datasource';
|
import dataSource, { getRepository } from '@server/datasource';
|
||||||
|
import DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||||
import { Session } from '@server/entity/Session';
|
import { Session } from '@server/entity/Session';
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
import { startJobs } from '@server/job/schedule';
|
import { startJobs } from '@server/job/schedule';
|
||||||
@@ -105,6 +106,9 @@ app
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bootstrap Discovery Sliders
|
||||||
|
await DiscoverSlider.bootstrapSliders();
|
||||||
|
|
||||||
const server = express();
|
const server = express();
|
||||||
if (settings.main.trustProxy) {
|
if (settings.main.trustProxy) {
|
||||||
server.enable('trust proxy');
|
server.enable('trust proxy');
|
||||||
|
|||||||
@@ -192,9 +192,11 @@ class ImageProxy {
|
|||||||
|
|
||||||
const buffer = Buffer.from(response.data, 'binary');
|
const buffer = Buffer.from(response.data, 'binary');
|
||||||
const extension = path.split('.').pop() ?? '';
|
const extension = path.split('.').pop() ?? '';
|
||||||
const maxAge = Number(response.headers['cache-control'].split('=')[1]);
|
const maxAge = Number(
|
||||||
|
(response.headers['cache-control'] ?? '0').split('=')[1]
|
||||||
|
);
|
||||||
const expireAt = Date.now() + maxAge * 1000;
|
const expireAt = Date.now() + maxAge * 1000;
|
||||||
const etag = response.headers.etag.replace(/"/g, '');
|
const etag = (response.headers.etag ?? '').replace(/"/g, '');
|
||||||
|
|
||||||
await this.writeToCacheDir(
|
await this.writeToCacheDir(
|
||||||
directory,
|
directory,
|
||||||
|
|||||||
@@ -96,7 +96,8 @@ class PlexScanner
|
|||||||
// We remove 10 minutes from the last scan as a buffer
|
// We remove 10 minutes from the last scan as a buffer
|
||||||
addedAt: library.lastScan - 1000 * 60 * 10,
|
addedAt: library.lastScan - 1000 * 60 * 10,
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined,
|
||||||
|
library.type
|
||||||
);
|
);
|
||||||
|
|
||||||
// Bundle items up by rating keys
|
// Bundle items up by rating keys
|
||||||
|
|||||||
15
server/migration/1672041273674-AddDiscoverSlider.ts
Normal file
15
server/migration/1672041273674-AddDiscoverSlider.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddDiscoverSlider1672041273674 implements MigrationInterface {
|
||||||
|
name = 'AddDiscoverSlider1672041273674';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "discover_slider" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "type" integer NOT NULL, "order" integer NOT NULL, "isBuiltIn" boolean NOT NULL DEFAULT (0), "enabled" boolean NOT NULL DEFAULT (1), "title" varchar, "data" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE "discover_slider"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
Crew,
|
Crew,
|
||||||
ExternalIds,
|
ExternalIds,
|
||||||
Genre,
|
Genre,
|
||||||
|
Keyword,
|
||||||
ProductionCompany,
|
ProductionCompany,
|
||||||
WatchProviders,
|
WatchProviders,
|
||||||
} from './common';
|
} from './common';
|
||||||
@@ -83,6 +84,7 @@ export interface MovieDetails {
|
|||||||
externalIds: ExternalIds;
|
externalIds: ExternalIds;
|
||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
watchProviders?: WatchProviders[];
|
watchProviders?: WatchProviders[];
|
||||||
|
keywords: Keyword[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mapProductionCompany = (
|
export const mapProductionCompany = (
|
||||||
@@ -142,4 +144,8 @@ export const mapMovieDetails = (
|
|||||||
externalIds: mapExternalIds(movie.external_ids),
|
externalIds: mapExternalIds(movie.external_ids),
|
||||||
mediaInfo: media,
|
mediaInfo: media,
|
||||||
watchProviders: mapWatchProviders(movie['watch/providers']?.results ?? {}),
|
watchProviders: mapWatchProviders(movie['watch/providers']?.results ?? {}),
|
||||||
|
keywords: movie.keywords.keywords.map((keyword) => ({
|
||||||
|
id: keyword.id,
|
||||||
|
name: keyword.name,
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import PlexTvAPI from '@server/api/plextv';
|
import PlexTvAPI from '@server/api/plextv';
|
||||||
|
import type { SortOptions } from '@server/api/themoviedb';
|
||||||
import TheMovieDb from '@server/api/themoviedb';
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
|
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
||||||
import { MediaType } from '@server/constants/media';
|
import { MediaType } from '@server/constants/media';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
@@ -20,6 +22,7 @@ import { mapNetwork } from '@server/models/Tv';
|
|||||||
import { isMovie, isPerson } from '@server/utils/typeHelpers';
|
import { isMovie, isPerson } from '@server/utils/typeHelpers';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { sortBy } from 'lodash';
|
import { sortBy } from 'lodash';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => {
|
export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
@@ -46,25 +49,76 @@ export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => {
|
|||||||
|
|
||||||
const discoverRoutes = Router();
|
const discoverRoutes = Router();
|
||||||
|
|
||||||
|
const QueryFilterOptions = z.object({
|
||||||
|
page: z.coerce.string().optional(),
|
||||||
|
sortBy: z.coerce.string().optional(),
|
||||||
|
primaryReleaseDateGte: z.coerce.string().optional(),
|
||||||
|
primaryReleaseDateLte: z.coerce.string().optional(),
|
||||||
|
firstAirDateGte: z.coerce.string().optional(),
|
||||||
|
firstAirDateLte: z.coerce.string().optional(),
|
||||||
|
studio: z.coerce.string().optional(),
|
||||||
|
genre: z.coerce.string().optional(),
|
||||||
|
keywords: z.coerce.string().optional(),
|
||||||
|
language: z.coerce.string().optional(),
|
||||||
|
withRuntimeGte: z.coerce.string().optional(),
|
||||||
|
withRuntimeLte: z.coerce.string().optional(),
|
||||||
|
voteAverageGte: z.coerce.string().optional(),
|
||||||
|
voteAverageLte: z.coerce.string().optional(),
|
||||||
|
network: z.coerce.string().optional(),
|
||||||
|
watchProviders: z.coerce.string().optional(),
|
||||||
|
watchRegion: z.coerce.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
||||||
|
|
||||||
discoverRoutes.get('/movies', async (req, res, next) => {
|
discoverRoutes.get('/movies', async (req, res, next) => {
|
||||||
const tmdb = createTmdbWithRegionLanguage(req.user);
|
const tmdb = createTmdbWithRegionLanguage(req.user);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const query = QueryFilterOptions.parse(req.query);
|
||||||
|
const keywords = query.keywords;
|
||||||
const data = await tmdb.getDiscoverMovies({
|
const data = await tmdb.getDiscoverMovies({
|
||||||
page: Number(req.query.page),
|
page: Number(query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
sortBy: query.sortBy as SortOptions,
|
||||||
genre: req.query.genre ? Number(req.query.genre) : undefined,
|
language: req.locale ?? query.language,
|
||||||
studio: req.query.studio ? Number(req.query.studio) : undefined,
|
originalLanguage: query.language,
|
||||||
|
genre: query.genre,
|
||||||
|
studio: query.studio,
|
||||||
|
primaryReleaseDateLte: query.primaryReleaseDateLte
|
||||||
|
? new Date(query.primaryReleaseDateLte).toISOString().split('T')[0]
|
||||||
|
: undefined,
|
||||||
|
primaryReleaseDateGte: query.primaryReleaseDateGte
|
||||||
|
? new Date(query.primaryReleaseDateGte).toISOString().split('T')[0]
|
||||||
|
: undefined,
|
||||||
|
keywords,
|
||||||
|
withRuntimeGte: query.withRuntimeGte,
|
||||||
|
withRuntimeLte: query.withRuntimeLte,
|
||||||
|
voteAverageGte: query.voteAverageGte,
|
||||||
|
voteAverageLte: query.voteAverageLte,
|
||||||
|
watchProviders: query.watchProviders,
|
||||||
|
watchRegion: query.watchRegion,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const media = await Media.getRelatedMedia(
|
||||||
data.results.map((result) => result.id)
|
data.results.map((result) => result.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let keywordData: TmdbKeyword[] = [];
|
||||||
|
if (keywords) {
|
||||||
|
const splitKeywords = keywords.split(',');
|
||||||
|
|
||||||
|
keywordData = await Promise.all(
|
||||||
|
splitKeywords.map(async (keywordId) => {
|
||||||
|
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
page: data.page,
|
page: data.page,
|
||||||
totalPages: data.total_pages,
|
totalPages: data.total_pages,
|
||||||
totalResults: data.total_results,
|
totalResults: data.total_results,
|
||||||
|
keywords: keywordData,
|
||||||
results: data.results.map((result) =>
|
results: data.results.map((result) =>
|
||||||
mapMovieResult(
|
mapMovieResult(
|
||||||
result,
|
result,
|
||||||
@@ -163,7 +217,7 @@ discoverRoutes.get<{ genreId: string }>(
|
|||||||
const data = await tmdb.getDiscoverMovies({
|
const data = await tmdb.getDiscoverMovies({
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: req.locale ?? (req.query.language as string),
|
||||||
genre: Number(req.params.genreId),
|
genre: req.params.genreId as string,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const media = await Media.getRelatedMedia(
|
||||||
@@ -210,7 +264,7 @@ discoverRoutes.get<{ studioId: string }>(
|
|||||||
const data = await tmdb.getDiscoverMovies({
|
const data = await tmdb.getDiscoverMovies({
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: req.locale ?? (req.query.language as string),
|
||||||
studio: Number(req.params.studioId),
|
studio: req.params.studioId as string,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const media = await Media.getRelatedMedia(
|
||||||
@@ -296,21 +350,50 @@ discoverRoutes.get('/tv', async (req, res, next) => {
|
|||||||
const tmdb = createTmdbWithRegionLanguage(req.user);
|
const tmdb = createTmdbWithRegionLanguage(req.user);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const query = QueryFilterOptions.parse(req.query);
|
||||||
|
const keywords = query.keywords;
|
||||||
const data = await tmdb.getDiscoverTv({
|
const data = await tmdb.getDiscoverTv({
|
||||||
page: Number(req.query.page),
|
page: Number(query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
sortBy: query.sortBy as SortOptions,
|
||||||
genre: req.query.genre ? Number(req.query.genre) : undefined,
|
language: req.locale ?? query.language,
|
||||||
network: req.query.network ? Number(req.query.network) : undefined,
|
genre: query.genre,
|
||||||
|
network: query.network ? Number(query.network) : undefined,
|
||||||
|
firstAirDateLte: query.firstAirDateLte
|
||||||
|
? new Date(query.firstAirDateLte).toISOString().split('T')[0]
|
||||||
|
: undefined,
|
||||||
|
firstAirDateGte: query.firstAirDateGte
|
||||||
|
? new Date(query.firstAirDateGte).toISOString().split('T')[0]
|
||||||
|
: undefined,
|
||||||
|
originalLanguage: query.language,
|
||||||
|
keywords,
|
||||||
|
withRuntimeGte: query.withRuntimeGte,
|
||||||
|
withRuntimeLte: query.withRuntimeLte,
|
||||||
|
voteAverageGte: query.voteAverageGte,
|
||||||
|
voteAverageLte: query.voteAverageLte,
|
||||||
|
watchProviders: query.watchProviders,
|
||||||
|
watchRegion: query.watchRegion,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const media = await Media.getRelatedMedia(
|
||||||
data.results.map((result) => result.id)
|
data.results.map((result) => result.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let keywordData: TmdbKeyword[] = [];
|
||||||
|
if (keywords) {
|
||||||
|
const splitKeywords = keywords.split(',');
|
||||||
|
|
||||||
|
keywordData = await Promise.all(
|
||||||
|
splitKeywords.map(async (keywordId) => {
|
||||||
|
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
page: data.page,
|
page: data.page,
|
||||||
totalPages: data.total_pages,
|
totalPages: data.total_pages,
|
||||||
totalResults: data.total_results,
|
totalResults: data.total_results,
|
||||||
|
keywords: keywordData,
|
||||||
results: data.results.map((result) =>
|
results: data.results.map((result) =>
|
||||||
mapTvResult(
|
mapTvResult(
|
||||||
result,
|
result,
|
||||||
@@ -408,7 +491,7 @@ discoverRoutes.get<{ genreId: string }>(
|
|||||||
const data = await tmdb.getDiscoverTv({
|
const data = await tmdb.getDiscoverTv({
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: req.locale ?? (req.query.language as string),
|
||||||
genre: Number(req.params.genreId),
|
genre: req.params.genreId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const media = await Media.getRelatedMedia(
|
||||||
@@ -643,7 +726,9 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
|
|||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
genres.map(async (genre) => {
|
genres.map(async (genre) => {
|
||||||
const genreData = await tmdb.getDiscoverMovies({ genre: genre.id });
|
const genreData = await tmdb.getDiscoverMovies({
|
||||||
|
genre: genre.id.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
mappedGenres.push({
|
mappedGenres.push({
|
||||||
id: genre.id,
|
id: genre.id,
|
||||||
@@ -685,7 +770,9 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
|
|||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
genres.map(async (genre) => {
|
genres.map(async (genre) => {
|
||||||
const genreData = await tmdb.getDiscoverTv({ genre: genre.id });
|
const genreData = await tmdb.getDiscoverTv({
|
||||||
|
genre: genre.id.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
mappedGenres.push({
|
mappedGenres.push({
|
||||||
id: genre.id,
|
id: genre.id,
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import type {
|
|||||||
TmdbMovieResult,
|
TmdbMovieResult,
|
||||||
TmdbTvResult,
|
TmdbTvResult,
|
||||||
} from '@server/api/themoviedb/interfaces';
|
} from '@server/api/themoviedb/interfaces';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||||
import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces';
|
import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces';
|
||||||
import { Permission } from '@server/lib/permissions';
|
import { Permission } from '@server/lib/permissions';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { checkUser, isAuthenticated } from '@server/middleware/auth';
|
import { checkUser, isAuthenticated } from '@server/middleware/auth';
|
||||||
|
import { mapWatchProviderDetails } from '@server/models/common';
|
||||||
import { mapProductionCompany } from '@server/models/Movie';
|
import { mapProductionCompany } from '@server/models/Movie';
|
||||||
import { mapNetwork } from '@server/models/Tv';
|
import { mapNetwork } from '@server/models/Tv';
|
||||||
import settingsRoutes from '@server/routes/settings';
|
import settingsRoutes from '@server/routes/settings';
|
||||||
@@ -102,6 +105,13 @@ router.get('/settings/public', async (req, res) => {
|
|||||||
return res.status(200).json(settings.fullPublicSettings);
|
return res.status(200).json(settings.fullPublicSettings);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
router.get('/settings/discover', isAuthenticated(), async (_req, res) => {
|
||||||
|
const sliderRepository = getRepository(DiscoverSlider);
|
||||||
|
|
||||||
|
const sliders = await sliderRepository.find({ order: { order: 'ASC' } });
|
||||||
|
|
||||||
|
return res.json(sliders);
|
||||||
|
});
|
||||||
router.use('/settings', isAuthenticated(Permission.ADMIN), settingsRoutes);
|
router.use('/settings', isAuthenticated(Permission.ADMIN), settingsRoutes);
|
||||||
router.use('/search', isAuthenticated(), searchRoutes);
|
router.use('/search', isAuthenticated(), searchRoutes);
|
||||||
router.use('/discover', isAuthenticated(), discoverRoutes);
|
router.use('/discover', isAuthenticated(), discoverRoutes);
|
||||||
@@ -269,6 +279,87 @@ router.get('/backdrops', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/keyword/:keywordId', async (req, res, next) => {
|
||||||
|
const tmdb = createTmdbWithRegionLanguage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await tmdb.getKeywordDetails({
|
||||||
|
keywordId: Number(req.params.keywordId),
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json(result);
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('Something went wrong retrieving keyword data', {
|
||||||
|
label: 'API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'Unable to retrieve keyword data.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/watchproviders/regions', async (req, res, next) => {
|
||||||
|
const tmdb = createTmdbWithRegionLanguage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await tmdb.getAvailableWatchProviderRegions({});
|
||||||
|
return res.status(200).json(result);
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('Something went wrong retrieving watch provider regions', {
|
||||||
|
label: 'API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'Unable to retrieve watch provider regions.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/watchproviders/movies', async (req, res, next) => {
|
||||||
|
const tmdb = createTmdbWithRegionLanguage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await tmdb.getMovieWatchProviders({
|
||||||
|
watchRegion: req.query.watchRegion as string,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json(mapWatchProviderDetails(result));
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('Something went wrong retrieving movie watch providers', {
|
||||||
|
label: 'API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'Unable to retrieve movie watch providers.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/watchproviders/tv', async (req, res, next) => {
|
||||||
|
const tmdb = createTmdbWithRegionLanguage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await tmdb.getTvWatchProviders({
|
||||||
|
watchRegion: req.query.watchRegion as string,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json(mapWatchProviderDetails(result));
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('Something went wrong retrieving tv watch providers', {
|
||||||
|
label: 'API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'Unable to retrieve tv watch providers.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/', (_req, res) => {
|
router.get('/', (_req, res) => {
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
api: 'Overseerr API',
|
api: 'Overseerr API',
|
||||||
|
|||||||
@@ -308,7 +308,9 @@ issueRoutes.post<{ issueId: string }, Issue, { message: string }>(
|
|||||||
|
|
||||||
issueRoutes.post<{ issueId: string; status: string }, Issue>(
|
issueRoutes.post<{ issueId: string; status: string }, Issue>(
|
||||||
'/:issueId/:status',
|
'/:issueId/:status',
|
||||||
isAuthenticated(Permission.MANAGE_ISSUES),
|
isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
|
||||||
|
type: 'or',
|
||||||
|
}),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
const issueRepository = getRepository(Issue);
|
const issueRepository = getRepository(Issue);
|
||||||
// Satisfy typescript here. User is set, we assure you!
|
// Satisfy typescript here. User is set, we assure you!
|
||||||
@@ -321,6 +323,16 @@ issueRoutes.post<{ issueId: string; status: string }, Issue>(
|
|||||||
where: { id: Number(req.params.issueId) },
|
where: { id: Number(req.params.issueId) },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
!req.user?.hasPermission(Permission.MANAGE_ISSUES) &&
|
||||||
|
issue.createdBy.id !== req.user?.id
|
||||||
|
) {
|
||||||
|
return next({
|
||||||
|
status: 401,
|
||||||
|
message: 'You do not have permission to modify this issue.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let newStatus: IssueStatus | undefined;
|
let newStatus: IssueStatus | undefined;
|
||||||
|
|
||||||
switch (req.params.status) {
|
switch (req.params.status) {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import TheMovieDb from '@server/api/themoviedb';
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
import { MediaStatus } from '@server/constants/media';
|
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
import { getSettings } from '@server/lib/settings';
|
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import {
|
import {
|
||||||
mapCastCredits,
|
mapCastCredits,
|
||||||
@@ -36,7 +34,6 @@ personRoutes.get('/:id', async (req, res, next) => {
|
|||||||
|
|
||||||
personRoutes.get('/:id/combined_credits', async (req, res, next) => {
|
personRoutes.get('/:id/combined_credits', async (req, res, next) => {
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
const settings = getSettings();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const combinedCredits = await tmdb.getPersonCombinedCredits({
|
const combinedCredits = await tmdb.getPersonCombinedCredits({
|
||||||
@@ -44,30 +41,14 @@ personRoutes.get('/:id/combined_credits', async (req, res, next) => {
|
|||||||
language: req.locale ?? (req.query.language as string),
|
language: req.locale ?? (req.query.language as string),
|
||||||
});
|
});
|
||||||
|
|
||||||
let castMedia = await Media.getRelatedMedia(
|
const castMedia = await Media.getRelatedMedia(
|
||||||
combinedCredits.cast.map((result) => result.id)
|
combinedCredits.cast.map((result) => result.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
let crewMedia = await Media.getRelatedMedia(
|
const crewMedia = await Media.getRelatedMedia(
|
||||||
combinedCredits.crew.map((result) => result.id)
|
combinedCredits.crew.map((result) => result.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (settings.main.hideAvailable) {
|
|
||||||
castMedia = castMedia.filter(
|
|
||||||
(media) =>
|
|
||||||
(media.mediaType === 'movie' || media.mediaType === 'tv') &&
|
|
||||||
media.status !== MediaStatus.AVAILABLE &&
|
|
||||||
media.status !== MediaStatus.PARTIALLY_AVAILABLE
|
|
||||||
);
|
|
||||||
|
|
||||||
crewMedia = crewMedia.filter(
|
|
||||||
(media) =>
|
|
||||||
(media.mediaType === 'movie' || media.mediaType === 'tv') &&
|
|
||||||
media.status !== MediaStatus.AVAILABLE &&
|
|
||||||
media.status !== MediaStatus.PARTIALLY_AVAILABLE
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
cast: combinedCredits.cast
|
cast: combinedCredits.cast
|
||||||
.map((result) =>
|
.map((result) =>
|
||||||
|
|||||||
@@ -492,8 +492,10 @@ requestRoutes.post<{
|
|||||||
relations: { requestedBy: true, modifiedBy: true },
|
relations: { requestedBy: true, modifiedBy: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
await request.updateParentStatus();
|
// this also triggers updating the parent media's status & sending to *arr
|
||||||
await request.sendMedia();
|
request.status = MediaRequestStatus.APPROVED;
|
||||||
|
await requestRepository.save(request);
|
||||||
|
|
||||||
return res.status(200).json(request);
|
return res.status(200).json(request);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Error processing request retry', {
|
logger.error('Error processing request retry', {
|
||||||
|
|||||||
@@ -56,4 +56,50 @@ searchRoutes.get('/', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
searchRoutes.get('/keyword', async (req, res, next) => {
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await tmdb.searchKeyword({
|
||||||
|
query: req.query.query as string,
|
||||||
|
page: Number(req.query.page),
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json(results);
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('Something went wrong retrieving keyword search results', {
|
||||||
|
label: 'API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
query: req.query.query,
|
||||||
|
});
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'Unable to retrieve keyword search results.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
searchRoutes.get('/company', async (req, res, next) => {
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await tmdb.searchCompany({
|
||||||
|
query: req.query.query as string,
|
||||||
|
page: Number(req.query.page),
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json(results);
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('Something went wrong retrieving company search results', {
|
||||||
|
label: 'API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
query: req.query.query,
|
||||||
|
});
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'Unable to retrieve company search results.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default searchRoutes;
|
export default searchRoutes;
|
||||||
|
|||||||
131
server/routes/settings/discover.ts
Normal file
131
server/routes/settings/discover.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import { Router } from 'express';
|
||||||
|
|
||||||
|
const discoverSettingRoutes = Router();
|
||||||
|
|
||||||
|
discoverSettingRoutes.post('/', async (req, res) => {
|
||||||
|
const sliderRepository = getRepository(DiscoverSlider);
|
||||||
|
|
||||||
|
const sliders = req.body as DiscoverSlider[];
|
||||||
|
|
||||||
|
if (!Array.isArray(sliders)) {
|
||||||
|
return res.status(400).json({ message: 'Invalid request body.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let x = 0; x < sliders.length; x++) {
|
||||||
|
const slider = sliders[x];
|
||||||
|
const existingSlider = await sliderRepository.findOne({
|
||||||
|
where: {
|
||||||
|
id: slider.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingSlider && slider.id) {
|
||||||
|
existingSlider.enabled = slider.enabled;
|
||||||
|
existingSlider.order = x;
|
||||||
|
|
||||||
|
// Only allow changes to the following when the slider is not built in
|
||||||
|
if (!existingSlider.isBuiltIn) {
|
||||||
|
existingSlider.title = slider.title;
|
||||||
|
existingSlider.data = slider.data;
|
||||||
|
existingSlider.type = slider.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sliderRepository.save(existingSlider);
|
||||||
|
} else {
|
||||||
|
const newSlider = new DiscoverSlider({
|
||||||
|
isBuiltIn: false,
|
||||||
|
data: slider.data,
|
||||||
|
title: slider.title,
|
||||||
|
enabled: slider.enabled,
|
||||||
|
order: x,
|
||||||
|
type: slider.type,
|
||||||
|
});
|
||||||
|
await sliderRepository.save(newSlider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(sliders);
|
||||||
|
});
|
||||||
|
|
||||||
|
discoverSettingRoutes.post('/add', async (req, res) => {
|
||||||
|
const sliderRepository = getRepository(DiscoverSlider);
|
||||||
|
|
||||||
|
const slider = req.body as DiscoverSlider;
|
||||||
|
|
||||||
|
const newSlider = new DiscoverSlider({
|
||||||
|
isBuiltIn: false,
|
||||||
|
data: slider.data,
|
||||||
|
title: slider.title,
|
||||||
|
enabled: false,
|
||||||
|
order: -1,
|
||||||
|
type: slider.type,
|
||||||
|
});
|
||||||
|
await sliderRepository.save(newSlider);
|
||||||
|
|
||||||
|
return res.json(newSlider);
|
||||||
|
});
|
||||||
|
|
||||||
|
discoverSettingRoutes.get('/reset', async (_req, res) => {
|
||||||
|
const sliderRepository = getRepository(DiscoverSlider);
|
||||||
|
|
||||||
|
await sliderRepository.clear();
|
||||||
|
await DiscoverSlider.bootstrapSliders();
|
||||||
|
|
||||||
|
return res.status(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
discoverSettingRoutes.put('/:sliderId', async (req, res, next) => {
|
||||||
|
const sliderRepository = getRepository(DiscoverSlider);
|
||||||
|
|
||||||
|
const slider = req.body as DiscoverSlider;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existingSlider = await sliderRepository.findOneOrFail({
|
||||||
|
where: {
|
||||||
|
id: Number(req.params.sliderId),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only allow changes to the following when the slider is not built in
|
||||||
|
if (!existingSlider.isBuiltIn) {
|
||||||
|
existingSlider.title = slider.title;
|
||||||
|
existingSlider.data = slider.data;
|
||||||
|
existingSlider.type = slider.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sliderRepository.save(existingSlider);
|
||||||
|
|
||||||
|
return res.status(200).json(existingSlider);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Something went wrong updating a slider.', {
|
||||||
|
label: 'API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
next({ status: 404, message: 'Slider not found or cannot be updated.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
discoverSettingRoutes.delete('/:sliderId', async (req, res, next) => {
|
||||||
|
const sliderRepository = getRepository(DiscoverSlider);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const slider = await sliderRepository.findOneOrFail({
|
||||||
|
where: { id: Number(req.params.sliderId), isBuiltIn: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await sliderRepository.remove(slider);
|
||||||
|
|
||||||
|
return res.status(204).send();
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Something went wrong deleting a slider.', {
|
||||||
|
label: 'API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
next({ status: 404, message: 'Slider not found or cannot be deleted.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default discoverSettingRoutes;
|
||||||
@@ -23,6 +23,7 @@ import type { JobId, Library, MainSettings } from '@server/lib/settings';
|
|||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { isAuthenticated } from '@server/middleware/auth';
|
import { isAuthenticated } from '@server/middleware/auth';
|
||||||
|
import discoverSettingRoutes from '@server/routes/settings/discover';
|
||||||
import { appDataPath } from '@server/utils/appDataVolume';
|
import { appDataPath } from '@server/utils/appDataVolume';
|
||||||
import { getAppVersion } from '@server/utils/appVersion';
|
import { getAppVersion } from '@server/utils/appVersion';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
@@ -42,6 +43,7 @@ const settingsRoutes = Router();
|
|||||||
settingsRoutes.use('/notifications', notificationRoutes);
|
settingsRoutes.use('/notifications', notificationRoutes);
|
||||||
settingsRoutes.use('/radarr', radarrRoutes);
|
settingsRoutes.use('/radarr', radarrRoutes);
|
||||||
settingsRoutes.use('/sonarr', sonarrRoutes);
|
settingsRoutes.use('/sonarr', sonarrRoutes);
|
||||||
|
settingsRoutes.use('/discover', discoverSettingRoutes);
|
||||||
|
|
||||||
const filteredMainSettings = (
|
const filteredMainSettings = (
|
||||||
user: User,
|
user: User,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { MediaType } from '@server/constants/media';
|
|||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import IssueComment from '@server/entity/IssueComment';
|
import IssueComment from '@server/entity/IssueComment';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
|
import { User } from '@server/entity/User';
|
||||||
import notificationManager, { Notification } from '@server/lib/notifications';
|
import notificationManager, { Notification } from '@server/lib/notifications';
|
||||||
import { Permission } from '@server/lib/permissions';
|
import { Permission } from '@server/lib/permissions';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
@@ -32,6 +33,10 @@ export class IssueCommentSubscriber
|
|||||||
})
|
})
|
||||||
).issue;
|
).issue;
|
||||||
|
|
||||||
|
const createdBy = await getRepository(User).findOneOrFail({
|
||||||
|
where: { id: issue.createdBy.id },
|
||||||
|
});
|
||||||
|
|
||||||
const media = await getRepository(Media).findOneOrFail({
|
const media = await getRepository(Media).findOneOrFail({
|
||||||
where: { id: issue.media.id },
|
where: { id: issue.media.id },
|
||||||
});
|
});
|
||||||
@@ -71,9 +76,9 @@ export class IssueCommentSubscriber
|
|||||||
notifyAdmin: true,
|
notifyAdmin: true,
|
||||||
notifySystem: true,
|
notifySystem: true,
|
||||||
notifyUser:
|
notifyUser:
|
||||||
!issue.createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
|
!createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
|
||||||
issue.createdBy.id !== entity.user.id
|
createdBy.id !== entity.user.id
|
||||||
? issue.createdBy
|
? createdBy
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
|
|||||||
notifySystem: true,
|
notifySystem: true,
|
||||||
notifyUser:
|
notifyUser:
|
||||||
!entity.createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
|
!entity.createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
|
||||||
|
entity.modifiedBy?.id !== entity.createdBy.id &&
|
||||||
(type === Notification.ISSUE_RESOLVED ||
|
(type === Notification.ISSUE_RESOLVED ||
|
||||||
type === Notification.ISSUE_REOPENED)
|
type === Notification.ISSUE_REOPENED)
|
||||||
? entity.createdBy
|
? entity.createdBy
|
||||||
|
|||||||
9
server/types/express-session.d.ts
vendored
Normal file
9
server/types/express-session.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import 'express-session';
|
||||||
|
|
||||||
|
// Declaration merging to apply our own types to SessionData
|
||||||
|
// See: (https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/express-session/index.d.ts#L23)
|
||||||
|
declare module 'express-session' {
|
||||||
|
interface SessionData {
|
||||||
|
userId: number;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
server/types/express.d.ts
vendored
9
server/types/express.d.ts
vendored
@@ -1,6 +1,7 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
import type { User } from '@server/entity/User';
|
import type { User } from '@server/entity/User';
|
||||||
import type { NextFunction, Request, Response } from 'express';
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
import 'express-session';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace Express {
|
namespace Express {
|
||||||
@@ -16,11 +17,3 @@ declare global {
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
) => Promise<void | NextFunction> | void | NextFunction;
|
) => Promise<void | NextFunction> | void | NextFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Declaration merging to apply our own types to SessionData
|
|
||||||
// See: (https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/express-session/index.d.ts#L23)
|
|
||||||
declare module 'express-session' {
|
|
||||||
export interface SessionData {
|
|
||||||
userId: number;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const AirDateBadge = ({ airDate }: AirDateBadgeProps) => {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
})}
|
})}
|
||||||
</Badge>
|
</Badge>
|
||||||
{showRelative && (
|
{showRelative && (
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import useSettings from '@app/hooks/useSettings';
|
|||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
import { DownloadIcon } from '@heroicons/react/outline';
|
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
|
||||||
import { MediaStatus } from '@server/constants/media';
|
import { MediaStatus } from '@server/constants/media';
|
||||||
import type { Collection } from '@server/models/Collection';
|
import type { Collection } from '@server/models/Collection';
|
||||||
import { uniq } from 'lodash';
|
import { uniq } from 'lodash';
|
||||||
@@ -276,7 +276,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
}}
|
}}
|
||||||
text={
|
text={
|
||||||
<>
|
<>
|
||||||
<DownloadIcon />
|
<ArrowDownTrayIcon />
|
||||||
<span>
|
<span>
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
hasRequestable
|
hasRequestable
|
||||||
@@ -295,7 +295,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
setIs4k(true);
|
setIs4k(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DownloadIcon />
|
<ArrowDownTrayIcon />
|
||||||
<span>
|
<span>
|
||||||
{intl.formatMessage(messages.requestcollection4k)}
|
{intl.formatMessage(messages.requestcollection4k)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
ExclamationIcon,
|
ExclamationTriangleIcon,
|
||||||
InformationCircleIcon,
|
InformationCircleIcon,
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
} from '@heroicons/react/solid';
|
} from '@heroicons/react/24/solid';
|
||||||
|
|
||||||
interface AlertProps {
|
interface AlertProps {
|
||||||
title?: React.ReactNode;
|
title?: React.ReactNode;
|
||||||
@@ -16,7 +16,7 @@ const Alert = ({ title, children, type }: AlertProps) => {
|
|||||||
'border border-yellow-500 backdrop-blur bg-yellow-400 bg-opacity-20',
|
'border border-yellow-500 backdrop-blur bg-yellow-400 bg-opacity-20',
|
||||||
titleColor: 'text-yellow-100',
|
titleColor: 'text-yellow-100',
|
||||||
textColor: 'text-yellow-300',
|
textColor: 'text-yellow-300',
|
||||||
svg: <ExclamationIcon className="h-5 w-5" />,
|
svg: <ExclamationTriangleIcon className="h-5 w-5" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ function Button<P extends ElementTypes = 'button'>(
|
|||||||
ref?: React.Ref<Element<P>>
|
ref?: React.Ref<Element<P>>
|
||||||
): JSX.Element {
|
): JSX.Element {
|
||||||
const buttonStyle = [
|
const buttonStyle = [
|
||||||
'inline-flex items-center justify-center border border-transparent leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer disabled:opacity-50 whitespace-nowrap',
|
'inline-flex items-center justify-center border leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer disabled:opacity-50 whitespace-nowrap',
|
||||||
];
|
];
|
||||||
switch (buttonType) {
|
switch (buttonType) {
|
||||||
case 'primary':
|
case 'primary':
|
||||||
@@ -71,7 +71,7 @@ function Button<P extends ElementTypes = 'button'>(
|
|||||||
break;
|
break;
|
||||||
case 'ghost':
|
case 'ghost':
|
||||||
buttonStyle.push(
|
buttonStyle.push(
|
||||||
'text-white bg-transaprent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'
|
'text-white bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import useClickOutside from '@app/hooks/useClickOutside';
|
import useClickOutside from '@app/hooks/useClickOutside';
|
||||||
import { withProperties } from '@app/utils/typeHelpers';
|
import { withProperties } from '@app/utils/typeHelpers';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import { ChevronDownIcon } from '@heroicons/react/solid';
|
import { ChevronDownIcon } from '@heroicons/react/24/solid';
|
||||||
import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from 'react';
|
import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from 'react';
|
||||||
import { Fragment, useRef, useState } from 'react';
|
import { Fragment, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
import useClickOutside from '@app/hooks/useClickOutside';
|
import useClickOutside from '@app/hooks/useClickOutside';
|
||||||
import { useRef, useState } from 'react';
|
import { forwardRef, useRef, useState } from 'react';
|
||||||
|
|
||||||
interface ConfirmButtonProps {
|
interface ConfirmButtonProps {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
@@ -9,17 +9,14 @@ interface ConfirmButtonProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConfirmButton = ({
|
const ConfirmButton = forwardRef<HTMLButtonElement, ConfirmButtonProps>(
|
||||||
onClick,
|
({ onClick, children, confirmText, className }, parentRef) => {
|
||||||
children,
|
|
||||||
confirmText,
|
|
||||||
className,
|
|
||||||
}: ConfirmButtonProps) => {
|
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
useClickOutside(ref, () => setIsClicked(false));
|
useClickOutside(ref, () => setIsClicked(false));
|
||||||
const [isClicked, setIsClicked] = useState(false);
|
const [isClicked, setIsClicked] = useState(false);
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
ref={parentRef}
|
||||||
buttonType="danger"
|
buttonType="danger"
|
||||||
className={`relative overflow-hidden ${className}`}
|
className={`relative overflow-hidden ${className}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -32,10 +29,9 @@ const ConfirmButton = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={`absolute inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
|
className={`relative inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
|
||||||
isClicked
|
isClicked
|
||||||
? '-translate-y-full opacity-0'
|
? '-translate-y-full opacity-0'
|
||||||
: 'translate-y-0 opacity-100'
|
: 'translate-y-0 opacity-100'
|
||||||
@@ -46,13 +42,18 @@ const ConfirmButton = ({
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={`absolute inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
|
className={`absolute inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
|
||||||
isClicked ? 'translate-y-0 opacity-100' : 'translate-y-full opacity-0'
|
isClicked
|
||||||
|
? 'translate-y-0 opacity-100'
|
||||||
|
: 'translate-y-full opacity-0'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{confirmText}
|
{confirmText}
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ConfirmButton.displayName = 'ConfirmButton';
|
||||||
|
|
||||||
export default ConfirmButton;
|
export default ConfirmButton;
|
||||||
|
|||||||
113
src/components/Common/MultiRangeSlider/index.tsx
Normal file
113
src/components/Common/MultiRangeSlider/index.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
|
import useDebouncedState from '@app/hooks/useDebouncedState';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
type MultiRangeSliderProps = {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
defaultMinValue?: number;
|
||||||
|
defaultMaxValue?: number;
|
||||||
|
subText?: string;
|
||||||
|
onUpdateMin: (min: number) => void;
|
||||||
|
onUpdateMax: (max: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MultiRangeSlider = ({
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
defaultMinValue,
|
||||||
|
defaultMaxValue,
|
||||||
|
subText,
|
||||||
|
onUpdateMin,
|
||||||
|
onUpdateMax,
|
||||||
|
}: MultiRangeSliderProps) => {
|
||||||
|
const touched = useRef(false);
|
||||||
|
const [valueMin, finalValueMin, setValueMin] = useDebouncedState(
|
||||||
|
defaultMinValue ?? min
|
||||||
|
);
|
||||||
|
const [valueMax, finalValueMax, setValueMax] = useDebouncedState(
|
||||||
|
defaultMaxValue ?? max
|
||||||
|
);
|
||||||
|
|
||||||
|
const minThumb = ((valueMin - min) / (max - min)) * 100;
|
||||||
|
const maxThumb = ((valueMax - min) / (max - min)) * 100;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (touched.current) {
|
||||||
|
onUpdateMin(finalValueMin);
|
||||||
|
}
|
||||||
|
}, [finalValueMin, onUpdateMin]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (touched.current) {
|
||||||
|
onUpdateMax(finalValueMax);
|
||||||
|
}
|
||||||
|
}, [finalValueMax, onUpdateMax]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
touched.current = false;
|
||||||
|
setValueMax(defaultMaxValue ?? max);
|
||||||
|
setValueMin(defaultMinValue ?? min);
|
||||||
|
}, [defaultMinValue, defaultMaxValue, setValueMax, setValueMin, min, max]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${subText ? 'h-8' : 'h-4'} w-full`}>
|
||||||
|
<Tooltip
|
||||||
|
content={valueMin.toString()}
|
||||||
|
tooltipConfig={{
|
||||||
|
placement: 'top',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
value={valueMin}
|
||||||
|
className={`pointer-events-none absolute h-2 w-full cursor-pointer appearance-none rounded-lg bg-gray-700 ${
|
||||||
|
valueMin >= valueMax && valueMin !== min ? 'z-30' : 'z-10'
|
||||||
|
}`}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Number(e.target.value);
|
||||||
|
|
||||||
|
if (value <= valueMax) {
|
||||||
|
touched.current = true;
|
||||||
|
setValueMin(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content={valueMax}>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
value={valueMax}
|
||||||
|
step="1"
|
||||||
|
className={`pointer-events-none absolute top-0 left-0 right-0 z-20 h-2 w-full cursor-pointer appearance-none rounded-lg bg-transparent`}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Number(e.target.value);
|
||||||
|
|
||||||
|
if (value >= valueMin) {
|
||||||
|
touched.current = true;
|
||||||
|
setValueMax(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute top-0 z-30 ml-1 mr-1 h-2 bg-indigo-500"
|
||||||
|
style={{
|
||||||
|
left: `${minThumb}%`,
|
||||||
|
right: `${100 - maxThumb}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{subText && (
|
||||||
|
<div className="relative top-4 z-30 flex w-full justify-center text-sm text-gray-400">
|
||||||
|
<span>{subText}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MultiRangeSlider;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { EyeIcon, EyeOffIcon } from '@heroicons/react/solid';
|
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/solid';
|
||||||
import { Field } from 'formik';
|
import { Field } from 'formik';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ const SensitiveInput = ({ as = 'input', ...props }: SensitiveInputProps) => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="input-action"
|
className="input-action"
|
||||||
>
|
>
|
||||||
{isHidden ? <EyeOffIcon /> : <EyeIcon />}
|
{isHidden ? <EyeSlashIcon /> : <EyeIcon />}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
38
src/components/Common/SlideCheckbox/index.tsx
Normal file
38
src/components/Common/SlideCheckbox/index.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
type SlideCheckboxProps = {
|
||||||
|
onClick: () => void;
|
||||||
|
checked?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SlideCheckbox = ({ onClick, checked = false }: SlideCheckboxProps) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
role="checkbox"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-checked={false}
|
||||||
|
onClick={() => {
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === 'Space') {
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`${
|
||||||
|
checked ? 'bg-indigo-500' : 'bg-gray-700'
|
||||||
|
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`${
|
||||||
|
checked ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SlideCheckbox;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||||
import { useLockBodyScroll } from '@app/hooks/useLockBodyScroll';
|
import { useLockBodyScroll } from '@app/hooks/useLockBodyScroll';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import { XIcon } from '@heroicons/react/outline';
|
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
|
|
||||||
@@ -67,11 +67,11 @@ const SlideOver = ({
|
|||||||
>
|
>
|
||||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||||
<div
|
<div
|
||||||
className="slideover h-full w-screen max-w-md p-2 sm:p-4"
|
className="slideover relative h-full w-screen max-w-md p-2 sm:p-4"
|
||||||
ref={slideoverRef}
|
ref={slideoverRef}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="hide-scrollbar flex h-full flex-col overflow-y-scroll rounded-lg bg-gray-800 bg-opacity-80 shadow-xl ring-1 ring-gray-700 backdrop-blur">
|
<div className="flex h-full flex-col rounded-lg bg-gray-800 bg-opacity-80 shadow-xl ring-1 ring-gray-700 backdrop-blur">
|
||||||
<header className="space-y-1 border-b border-gray-700 py-4 px-4">
|
<header className="space-y-1 border-b border-gray-700 py-4 px-4">
|
||||||
<div className="flex items-center justify-between space-x-3">
|
<div className="flex items-center justify-between space-x-3">
|
||||||
<h2 className="text-overseerr text-2xl font-bold leading-7">
|
<h2 className="text-overseerr text-2xl font-bold leading-7">
|
||||||
@@ -83,7 +83,7 @@ const SlideOver = ({
|
|||||||
className="text-gray-200 transition duration-150 ease-in-out hover:text-white"
|
className="text-gray-200 transition duration-150 ease-in-out hover:text-white"
|
||||||
onClick={() => onClose()}
|
onClick={() => onClose()}
|
||||||
>
|
>
|
||||||
<XIcon className="h-6 w-6" />
|
<XMarkIcon className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,11 +95,13 @@ const SlideOver = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
<div className="relative flex-1 px-4 py-6 text-white">
|
<div className="hide-scrollbar flex flex-1 flex-col overflow-y-auto">
|
||||||
|
<div className="flex-1 px-4 py-6 text-white">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,41 +1,67 @@
|
|||||||
import {
|
import Spinner from '@app/assets/spinner.svg';
|
||||||
BellIcon,
|
import { CheckCircleIcon } from '@heroicons/react/20/solid';
|
||||||
CheckIcon,
|
import { BellIcon, ClockIcon, MinusSmallIcon } from '@heroicons/react/24/solid';
|
||||||
ClockIcon,
|
|
||||||
MinusSmIcon,
|
|
||||||
} from '@heroicons/react/solid';
|
|
||||||
import { MediaStatus } from '@server/constants/media';
|
import { MediaStatus } from '@server/constants/media';
|
||||||
|
|
||||||
interface StatusBadgeMiniProps {
|
interface StatusBadgeMiniProps {
|
||||||
status: MediaStatus;
|
status: MediaStatus;
|
||||||
is4k?: boolean;
|
is4k?: boolean;
|
||||||
|
inProgress?: boolean;
|
||||||
|
// Should the badge shrink on mobile to a smaller size? (TitleCard)
|
||||||
|
shrink?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatusBadgeMini = ({ status, is4k = false }: StatusBadgeMiniProps) => {
|
const StatusBadgeMini = ({
|
||||||
const badgeStyle = ['w-5 rounded-full p-0.5 text-white ring-1'];
|
status,
|
||||||
|
is4k = false,
|
||||||
|
inProgress = false,
|
||||||
|
shrink = false,
|
||||||
|
}: StatusBadgeMiniProps) => {
|
||||||
|
const badgeStyle = [
|
||||||
|
`rounded-full bg-opacity-80 shadow-md ${
|
||||||
|
shrink ? 'w-4 sm:w-5 border p-0' : 'w-5 ring-1 p-0.5'
|
||||||
|
}`,
|
||||||
|
];
|
||||||
|
|
||||||
let indicatorIcon: React.ReactNode;
|
let indicatorIcon: React.ReactNode;
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case MediaStatus.PROCESSING:
|
case MediaStatus.PROCESSING:
|
||||||
badgeStyle.push('bg-indigo-500 ring-indigo-400');
|
badgeStyle.push(
|
||||||
|
'bg-indigo-500 border-indigo-400 ring-indigo-400 text-indigo-100'
|
||||||
|
);
|
||||||
indicatorIcon = <ClockIcon />;
|
indicatorIcon = <ClockIcon />;
|
||||||
break;
|
break;
|
||||||
case MediaStatus.AVAILABLE:
|
case MediaStatus.AVAILABLE:
|
||||||
badgeStyle.push('bg-green-500 ring-green-400');
|
badgeStyle.push(
|
||||||
indicatorIcon = <CheckIcon />;
|
'bg-green-500 border-green-400 ring-green-400 text-green-100'
|
||||||
|
);
|
||||||
|
indicatorIcon = <CheckCircleIcon />;
|
||||||
break;
|
break;
|
||||||
case MediaStatus.PENDING:
|
case MediaStatus.PENDING:
|
||||||
badgeStyle.push('bg-yellow-500 ring-yellow-400');
|
badgeStyle.push(
|
||||||
|
'bg-yellow-500 border-yellow-400 ring-yellow-400 text-yellow-100'
|
||||||
|
);
|
||||||
indicatorIcon = <BellIcon />;
|
indicatorIcon = <BellIcon />;
|
||||||
break;
|
break;
|
||||||
case MediaStatus.PARTIALLY_AVAILABLE:
|
case MediaStatus.PARTIALLY_AVAILABLE:
|
||||||
badgeStyle.push('bg-green-500 ring-green-400');
|
badgeStyle.push(
|
||||||
indicatorIcon = <MinusSmIcon />;
|
'bg-green-500 border-green-400 ring-green-400 text-green-100'
|
||||||
|
);
|
||||||
|
indicatorIcon = <MinusSmallIcon />;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (inProgress) {
|
||||||
|
indicatorIcon = <Spinner />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex whitespace-nowrap rounded-full text-xs font-semibold leading-5 ring-1 ring-gray-700">
|
<div
|
||||||
|
className={`relative inline-flex whitespace-nowrap rounded-full border-gray-700 text-xs font-semibold leading-5 ring-gray-700 ${
|
||||||
|
shrink ? '' : 'ring-1'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div className={badgeStyle.join(' ')}>{indicatorIcon}</div>
|
<div className={badgeStyle.join(' ')}>{indicatorIcon}</div>
|
||||||
{is4k && <span className="pl-1 pr-2 text-gray-200">4K</span>}
|
{is4k && <span className="pl-1 pr-2 text-gray-200">4K</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
24
src/components/Common/Tag/index.tsx
Normal file
24
src/components/Common/Tag/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { TagIcon } from '@heroicons/react/24/outline';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type TagProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
iconSvg?: JSX.Element;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Tag = ({ children, iconSvg }: TagProps) => {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex cursor-pointer items-center rounded-full bg-gray-800 px-2 py-1 text-sm text-gray-200 ring-1 ring-gray-600 transition hover:bg-gray-700">
|
||||||
|
{iconSvg ? (
|
||||||
|
React.cloneElement(iconSvg, {
|
||||||
|
className: 'mr-1 h-4 w-4',
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<TagIcon className="mr-1 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span>{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Tag;
|
||||||
28
src/components/CompanyTag/index.tsx
Normal file
28
src/components/CompanyTag/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import Spinner from '@app/assets/spinner.svg';
|
||||||
|
import Tag from '@app/components/Common/Tag';
|
||||||
|
import { BuildingOffice2Icon } from '@heroicons/react/24/outline';
|
||||||
|
import type { ProductionCompany, TvNetwork } from '@server/models/common';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
type CompanyTagProps = {
|
||||||
|
type: 'studio' | 'network';
|
||||||
|
companyId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CompanyTag = ({ companyId, type }: CompanyTagProps) => {
|
||||||
|
const { data, error } = useSWR<TvNetwork | ProductionCompany>(
|
||||||
|
`/api/v1/${type}/${companyId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data && !error) {
|
||||||
|
return (
|
||||||
|
<Tag>
|
||||||
|
<Spinner className="h-4 w-4" />
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Tag iconSvg={<BuildingOffice2Icon />}>{data?.name}</Tag>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CompanyTag;
|
||||||
506
src/components/Discover/CreateSlider/index.tsx
Normal file
506
src/components/Discover/CreateSlider/index.tsx
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
import Button from '@app/components/Common/Button';
|
||||||
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
|
import { sliderTitles } from '@app/components/Discover/constants';
|
||||||
|
import MediaSlider from '@app/components/MediaSlider';
|
||||||
|
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
||||||
|
import type {
|
||||||
|
TmdbCompanySearchResponse,
|
||||||
|
TmdbGenre,
|
||||||
|
TmdbKeywordSearchResponse,
|
||||||
|
} from '@server/api/themoviedb/interfaces';
|
||||||
|
import { DiscoverSliderType } from '@server/constants/discover';
|
||||||
|
import type DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||||
|
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
||||||
|
import type { Keyword, ProductionCompany } from '@server/models/common';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { Field, Form, Formik } from 'formik';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import AsyncSelect from 'react-select/async';
|
||||||
|
import { useToasts } from 'react-toast-notifications';
|
||||||
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
addSlider: 'Add Slider',
|
||||||
|
editSlider: 'Edit Slider',
|
||||||
|
slidernameplaceholder: 'Slider Name',
|
||||||
|
providetmdbkeywordid: 'Provide a TMDB Keyword ID',
|
||||||
|
providetmdbgenreid: 'Provide a TMDB Genre ID',
|
||||||
|
providetmdbsearch: 'Provide a search query',
|
||||||
|
providetmdbstudio: 'Provide TMDB Studio ID',
|
||||||
|
providetmdbnetwork: 'Provide TMDB Network ID',
|
||||||
|
addsuccess: 'Created new slider and saved discover customization settings.',
|
||||||
|
addfail: 'Failed to create new slider.',
|
||||||
|
editsuccess: 'Edited slider and saved discover customization settings.',
|
||||||
|
editfail: 'Failed to edit slider.',
|
||||||
|
needresults: 'You need to have at least 1 result.',
|
||||||
|
validationDatarequired: 'You must provide a data value.',
|
||||||
|
validationTitlerequired: 'You must provide a title.',
|
||||||
|
addcustomslider: 'Create Custom Slider',
|
||||||
|
searchKeywords: 'Search keywords…',
|
||||||
|
searchGenres: 'Search genres…',
|
||||||
|
searchStudios: 'Search studios…',
|
||||||
|
starttyping: 'Starting typing to search.',
|
||||||
|
nooptions: 'No results.',
|
||||||
|
});
|
||||||
|
|
||||||
|
type CreateSliderProps = {
|
||||||
|
onCreate: () => void;
|
||||||
|
slider?: Partial<DiscoverSlider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateOption = {
|
||||||
|
type: DiscoverSliderType;
|
||||||
|
title: string;
|
||||||
|
dataUrl: string;
|
||||||
|
params?: string;
|
||||||
|
titlePlaceholderText: string;
|
||||||
|
dataPlaceholderText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { addToast } = useToasts();
|
||||||
|
const [resultCount, setResultCount] = useState(0);
|
||||||
|
const [defaultDataValue, setDefaultDataValue] = useState<
|
||||||
|
{ label: string; value: number }[] | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (slider) {
|
||||||
|
const loadDefaultKeywords = async (): Promise<void> => {
|
||||||
|
if (!slider.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keywords = await Promise.all(
|
||||||
|
slider.data.split(',').map(async (keywordId) => {
|
||||||
|
const keyword = await axios.get<Keyword>(
|
||||||
|
`/api/v1/keyword/${keywordId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return keyword.data;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
setDefaultDataValue(
|
||||||
|
keywords.map((keyword) => ({
|
||||||
|
label: keyword.name,
|
||||||
|
value: keyword.id,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDefaultGenre = async (): Promise<void> => {
|
||||||
|
if (!slider.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.get<TmdbGenre[]>(
|
||||||
|
`/api/v1/genres/${
|
||||||
|
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE ? 'movie' : 'tv'
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const genre = response.data.find(
|
||||||
|
(genre) => genre.id === Number(slider.data)
|
||||||
|
);
|
||||||
|
|
||||||
|
setDefaultDataValue([
|
||||||
|
{
|
||||||
|
label: genre?.name ?? '',
|
||||||
|
value: genre?.id ?? 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDefaultCompany = async (): Promise<void> => {
|
||||||
|
if (!slider.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.get<ProductionCompany>(
|
||||||
|
`/api/v1/studio/${slider.data}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const studio = response.data;
|
||||||
|
|
||||||
|
setDefaultDataValue([
|
||||||
|
{
|
||||||
|
label: studio.name ?? '',
|
||||||
|
value: studio.id ?? 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (slider.type) {
|
||||||
|
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
|
||||||
|
case DiscoverSliderType.TMDB_TV_KEYWORD:
|
||||||
|
loadDefaultKeywords();
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.TMDB_MOVIE_GENRE:
|
||||||
|
case DiscoverSliderType.TMDB_TV_GENRE:
|
||||||
|
loadDefaultGenre();
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.TMDB_STUDIO:
|
||||||
|
loadDefaultCompany();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [slider]);
|
||||||
|
|
||||||
|
const CreateSliderSchema = Yup.object().shape({
|
||||||
|
title: Yup.string().required(
|
||||||
|
intl.formatMessage(messages.validationTitlerequired)
|
||||||
|
),
|
||||||
|
data: Yup.string().required(
|
||||||
|
intl.formatMessage(messages.validationDatarequired)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateResultCount = useCallback(
|
||||||
|
(count: number) => {
|
||||||
|
setResultCount(count);
|
||||||
|
},
|
||||||
|
[setResultCount]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadKeywordOptions = async (inputValue: string) => {
|
||||||
|
const results = await axios.get<TmdbKeywordSearchResponse>(
|
||||||
|
'/api/v1/search/keyword',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
query: encodeURIExtraParams(inputValue),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return results.data.results.map((result) => ({
|
||||||
|
label: result.name,
|
||||||
|
value: result.id,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCompanyOptions = async (inputValue: string) => {
|
||||||
|
if (inputValue === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await axios.get<TmdbCompanySearchResponse>(
|
||||||
|
'/api/v1/search/company',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
query: encodeURIExtraParams(inputValue),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return results.data.results.map((result) => ({
|
||||||
|
label: result.name,
|
||||||
|
value: result.id,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMovieGenreOptions = async () => {
|
||||||
|
const results = await axios.get<GenreSliderItem[]>(
|
||||||
|
'/api/v1/discover/genreslider/movie'
|
||||||
|
);
|
||||||
|
|
||||||
|
return results.data.map((result) => ({
|
||||||
|
label: result.name,
|
||||||
|
value: result.id,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadTvGenreOptions = async () => {
|
||||||
|
const results = await axios.get<GenreSliderItem[]>(
|
||||||
|
'/api/v1/discover/genreslider/tv'
|
||||||
|
);
|
||||||
|
|
||||||
|
return results.data.map((result) => ({
|
||||||
|
label: result.name,
|
||||||
|
value: result.id,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: CreateOption[] = [
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.TMDB_MOVIE_KEYWORD,
|
||||||
|
title: intl.formatMessage(sliderTitles.tmdbmoviekeyword),
|
||||||
|
dataUrl: '/api/v1/discover/movies',
|
||||||
|
params: 'keywords=$value',
|
||||||
|
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||||
|
dataPlaceholderText: intl.formatMessage(messages.providetmdbkeywordid),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.TMDB_TV_KEYWORD,
|
||||||
|
title: intl.formatMessage(sliderTitles.tmdbtvkeyword),
|
||||||
|
dataUrl: '/api/v1/discover/tv',
|
||||||
|
params: 'keywords=$value',
|
||||||
|
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||||
|
dataPlaceholderText: intl.formatMessage(messages.providetmdbkeywordid),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.TMDB_MOVIE_GENRE,
|
||||||
|
title: intl.formatMessage(sliderTitles.tmdbmoviegenre),
|
||||||
|
dataUrl: '/api/v1/discover/movies/genre/$value',
|
||||||
|
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||||
|
dataPlaceholderText: intl.formatMessage(messages.providetmdbgenreid),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.TMDB_TV_GENRE,
|
||||||
|
title: intl.formatMessage(sliderTitles.tmdbtvgenre),
|
||||||
|
dataUrl: '/api/v1/discover/tv/genre/$value',
|
||||||
|
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||||
|
dataPlaceholderText: intl.formatMessage(messages.providetmdbgenreid),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.TMDB_STUDIO,
|
||||||
|
title: intl.formatMessage(sliderTitles.tmdbstudio),
|
||||||
|
dataUrl: '/api/v1/discover/movies/studio/$value',
|
||||||
|
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||||
|
dataPlaceholderText: intl.formatMessage(messages.providetmdbstudio),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.TMDB_NETWORK,
|
||||||
|
title: intl.formatMessage(sliderTitles.tmdbnetwork),
|
||||||
|
dataUrl: '/api/v1/discover/tv/network/$value',
|
||||||
|
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||||
|
dataPlaceholderText: intl.formatMessage(messages.providetmdbnetwork),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.TMDB_SEARCH,
|
||||||
|
title: intl.formatMessage(sliderTitles.tmdbsearch),
|
||||||
|
dataUrl: '/api/v1/search',
|
||||||
|
params: 'query=$value',
|
||||||
|
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||||
|
dataPlaceholderText: intl.formatMessage(messages.providetmdbsearch),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
initialValues={
|
||||||
|
slider
|
||||||
|
? {
|
||||||
|
sliderType: slider.type,
|
||||||
|
title: slider.title,
|
||||||
|
data: slider.data,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
sliderType: DiscoverSliderType.TMDB_MOVIE_KEYWORD,
|
||||||
|
title: '',
|
||||||
|
data: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validationSchema={CreateSliderSchema}
|
||||||
|
enableReinitialize
|
||||||
|
onSubmit={async (values, { resetForm }) => {
|
||||||
|
try {
|
||||||
|
if (slider) {
|
||||||
|
await axios.put(`/api/v1/settings/discover/${slider.id}`, {
|
||||||
|
type: Number(values.sliderType),
|
||||||
|
title: values.title,
|
||||||
|
data: values.data,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await axios.post('/api/v1/settings/discover/add', {
|
||||||
|
type: Number(values.sliderType),
|
||||||
|
title: values.title,
|
||||||
|
data: values.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addToast(
|
||||||
|
intl.formatMessage(
|
||||||
|
slider ? messages.editsuccess : messages.addsuccess
|
||||||
|
),
|
||||||
|
{
|
||||||
|
appearance: 'success',
|
||||||
|
autoDismiss: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
onCreate();
|
||||||
|
resetForm();
|
||||||
|
} catch (e) {
|
||||||
|
addToast(
|
||||||
|
intl.formatMessage(slider ? messages.editfail : messages.addfail),
|
||||||
|
{
|
||||||
|
appearance: 'error',
|
||||||
|
autoDismiss: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ values, isValid, isSubmitting, errors, touched, setFieldValue }) => {
|
||||||
|
const activeOption = options.find(
|
||||||
|
(option) => option.type === Number(values.sliderType)
|
||||||
|
);
|
||||||
|
|
||||||
|
let dataInput: React.ReactNode;
|
||||||
|
|
||||||
|
switch (activeOption?.type) {
|
||||||
|
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
|
||||||
|
case DiscoverSliderType.TMDB_TV_KEYWORD:
|
||||||
|
dataInput = (
|
||||||
|
<AsyncSelect
|
||||||
|
key={`keyword-select-${defaultDataValue}`}
|
||||||
|
inputId="data"
|
||||||
|
isMulti
|
||||||
|
className="react-select-container"
|
||||||
|
classNamePrefix="react-select"
|
||||||
|
noOptionsMessage={({ inputValue }) =>
|
||||||
|
inputValue === ''
|
||||||
|
? intl.formatMessage(messages.starttyping)
|
||||||
|
: intl.formatMessage(messages.nooptions)
|
||||||
|
}
|
||||||
|
defaultValue={defaultDataValue}
|
||||||
|
loadOptions={loadKeywordOptions}
|
||||||
|
placeholder={intl.formatMessage(messages.searchKeywords)}
|
||||||
|
onChange={(value) => {
|
||||||
|
const keywords = value.map((item) => item.value).join(',');
|
||||||
|
|
||||||
|
setFieldValue('data', keywords);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.TMDB_MOVIE_GENRE:
|
||||||
|
dataInput = (
|
||||||
|
<AsyncSelect
|
||||||
|
key={`movie-genre-select-${defaultDataValue}`}
|
||||||
|
className="react-select-container"
|
||||||
|
classNamePrefix="react-select"
|
||||||
|
defaultValue={defaultDataValue?.[0]}
|
||||||
|
defaultOptions
|
||||||
|
cacheOptions
|
||||||
|
loadOptions={loadMovieGenreOptions}
|
||||||
|
placeholder={intl.formatMessage(messages.searchGenres)}
|
||||||
|
onChange={(value) => {
|
||||||
|
setFieldValue('data', value?.value.toString());
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.TMDB_TV_GENRE:
|
||||||
|
dataInput = (
|
||||||
|
<AsyncSelect
|
||||||
|
key={`tv-genre-select-${defaultDataValue}}`}
|
||||||
|
className="react-select-container"
|
||||||
|
classNamePrefix="react-select"
|
||||||
|
defaultValue={defaultDataValue?.[0]}
|
||||||
|
defaultOptions
|
||||||
|
cacheOptions
|
||||||
|
loadOptions={loadTvGenreOptions}
|
||||||
|
placeholder={intl.formatMessage(messages.searchGenres)}
|
||||||
|
onChange={(value) => {
|
||||||
|
setFieldValue('data', value?.value.toString());
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.TMDB_STUDIO:
|
||||||
|
dataInput = (
|
||||||
|
<AsyncSelect
|
||||||
|
key={`studio-select-${defaultDataValue}`}
|
||||||
|
className="react-select-container"
|
||||||
|
classNamePrefix="react-select"
|
||||||
|
defaultValue={defaultDataValue?.[0]}
|
||||||
|
defaultOptions
|
||||||
|
cacheOptions
|
||||||
|
loadOptions={loadCompanyOptions}
|
||||||
|
placeholder={intl.formatMessage(messages.searchStudios)}
|
||||||
|
onChange={(value) => {
|
||||||
|
setFieldValue('data', value?.value.toString());
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
dataInput = (
|
||||||
|
<Field
|
||||||
|
type="text"
|
||||||
|
name="data"
|
||||||
|
id="data"
|
||||||
|
placeholder={activeOption?.dataPlaceholderText}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form data-testid="create-discover-option-form">
|
||||||
|
<div className="flex flex-col space-y-2 text-gray-100">
|
||||||
|
<Field as="select" id="sliderType" name="sliderType">
|
||||||
|
{options.map((option) => (
|
||||||
|
<option value={option.type} key={`type-${option.type}`}>
|
||||||
|
{option.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
type="text"
|
||||||
|
name="title"
|
||||||
|
id="title"
|
||||||
|
placeholder={activeOption?.titlePlaceholderText}
|
||||||
|
/>
|
||||||
|
{errors.title &&
|
||||||
|
touched.title &&
|
||||||
|
typeof errors.title === 'string' && (
|
||||||
|
<div className="error">{errors.title}</div>
|
||||||
|
)}
|
||||||
|
{dataInput}
|
||||||
|
{errors.data &&
|
||||||
|
touched.data &&
|
||||||
|
typeof errors.data === 'string' && (
|
||||||
|
<div className="error">{errors.data}</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1"></div>
|
||||||
|
{resultCount === 0 ? (
|
||||||
|
<Tooltip content={intl.formatMessage(messages.needresults)}>
|
||||||
|
<div>
|
||||||
|
<Button buttonType="primary" buttonSize="sm" disabled>
|
||||||
|
{intl.formatMessage(messages.addSlider)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
buttonType="primary"
|
||||||
|
buttonSize="sm"
|
||||||
|
disabled={isSubmitting || !isValid}
|
||||||
|
>
|
||||||
|
{intl.formatMessage(
|
||||||
|
slider ? messages.editSlider : messages.addSlider
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeOption && values.title && values.data && (
|
||||||
|
<div className="relative py-4">
|
||||||
|
<MediaSlider
|
||||||
|
sliderKey={`preview-${values.title}`}
|
||||||
|
title={values.title}
|
||||||
|
url={activeOption?.dataUrl.replace(
|
||||||
|
'$value',
|
||||||
|
encodeURIExtraParams(values.data)
|
||||||
|
)}
|
||||||
|
extraParams={activeOption.params?.replace(
|
||||||
|
'$value',
|
||||||
|
encodeURIExtraParams(values.data)
|
||||||
|
)}
|
||||||
|
onNewTitles={updateResultCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateSlider;
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
import Header from '@app/components/Common/Header';
|
import Header from '@app/components/Common/Header';
|
||||||
import ListView from '@app/components/Common/ListView';
|
import ListView from '@app/components/Common/ListView';
|
||||||
import PageTitle from '@app/components/Common/PageTitle';
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
import useDiscover from '@app/hooks/useDiscover';
|
import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
||||||
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
|
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
||||||
import type { MovieResult } from '@server/models/Search';
|
import type { MovieResult } from '@server/models/Search';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
discovermovies: 'Popular Movies',
|
keywordMovies: '{keywordTitle} Movies',
|
||||||
});
|
});
|
||||||
|
|
||||||
const DiscoverMovies = () => {
|
const DiscoverMovieKeyword = () => {
|
||||||
|
const router = useRouter();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -21,13 +25,25 @@ const DiscoverMovies = () => {
|
|||||||
titles,
|
titles,
|
||||||
fetchMore,
|
fetchMore,
|
||||||
error,
|
error,
|
||||||
} = useDiscover<MovieResult>('/api/v1/discover/movies');
|
firstResultData,
|
||||||
|
} = useDiscover<MovieResult, { keywords: TmdbKeyword[] }>(
|
||||||
|
`/api/v1/discover/movies`,
|
||||||
|
{
|
||||||
|
keywords: encodeURIExtraParams(router.query.keywords as string),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <Error statusCode={500} />;
|
return <Error statusCode={500} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = intl.formatMessage(messages.discovermovies);
|
const title = isLoadingInitialData
|
||||||
|
? intl.formatMessage(globalMessages.loading)
|
||||||
|
: intl.formatMessage(messages.keywordMovies, {
|
||||||
|
keywordTitle: firstResultData?.keywords
|
||||||
|
.map((k) => `${k.name[0].toUpperCase()}${k.name.substring(1)}`)
|
||||||
|
.join(', '),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -48,4 +64,4 @@ const DiscoverMovies = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DiscoverMovies;
|
export default DiscoverMovieKeyword;
|
||||||
147
src/components/Discover/DiscoverMovies/index.tsx
Normal file
147
src/components/Discover/DiscoverMovies/index.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import Button from '@app/components/Common/Button';
|
||||||
|
import Header from '@app/components/Common/Header';
|
||||||
|
import ListView from '@app/components/Common/ListView';
|
||||||
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
|
import type { FilterOptions } from '@app/components/Discover/constants';
|
||||||
|
import {
|
||||||
|
countActiveFilters,
|
||||||
|
prepareFilterValues,
|
||||||
|
} from '@app/components/Discover/constants';
|
||||||
|
import FilterSlideover from '@app/components/Discover/FilterSlideover';
|
||||||
|
import useDiscover from '@app/hooks/useDiscover';
|
||||||
|
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
|
||||||
|
import Error from '@app/pages/_error';
|
||||||
|
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
|
||||||
|
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
|
||||||
|
import type { MovieResult } from '@server/models/Search';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
discovermovies: 'Movies',
|
||||||
|
activefilters:
|
||||||
|
'{count, plural, one {# Active Filter} other {# Active Filters}}',
|
||||||
|
sortPopularityAsc: 'Popularity Ascending',
|
||||||
|
sortPopularityDesc: 'Popularity Descending',
|
||||||
|
sortReleaseDateAsc: 'Release Date Ascending',
|
||||||
|
sortReleaseDateDesc: 'Release Date Descending',
|
||||||
|
sortTmdbRatingAsc: 'TMDB Rating Ascending',
|
||||||
|
sortTmdbRatingDesc: 'TMDB Rating Descending',
|
||||||
|
sortTitleAsc: 'Title (A-Z) Ascending',
|
||||||
|
sortTitleDesc: 'Title (Z-A) Descending',
|
||||||
|
});
|
||||||
|
|
||||||
|
const SortOptions: Record<string, TMDBSortOptions> = {
|
||||||
|
PopularityAsc: 'popularity.asc',
|
||||||
|
PopularityDesc: 'popularity.desc',
|
||||||
|
ReleaseDateAsc: 'release_date.asc',
|
||||||
|
ReleaseDateDesc: 'release_date.desc',
|
||||||
|
TmdbRatingAsc: 'vote_average.asc',
|
||||||
|
TmdbRatingDesc: 'vote_average.desc',
|
||||||
|
TitleAsc: 'original_title.asc',
|
||||||
|
TitleDesc: 'original_title.desc',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const DiscoverMovies = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const router = useRouter();
|
||||||
|
const updateQueryParams = useUpdateQueryParams({});
|
||||||
|
|
||||||
|
const preparedFilters = prepareFilterValues(router.query);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLoadingInitialData,
|
||||||
|
isEmpty,
|
||||||
|
isLoadingMore,
|
||||||
|
isReachingEnd,
|
||||||
|
titles,
|
||||||
|
fetchMore,
|
||||||
|
error,
|
||||||
|
} = useDiscover<MovieResult, unknown, FilterOptions>(
|
||||||
|
'/api/v1/discover/movies',
|
||||||
|
preparedFilters
|
||||||
|
);
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <Error statusCode={500} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = intl.formatMessage(messages.discovermovies);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle title={title} />
|
||||||
|
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
|
||||||
|
<Header>{title}</Header>
|
||||||
|
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
|
||||||
|
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
||||||
|
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
||||||
|
<BarsArrowDownIcon className="h-6 w-6" />
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
id="sortBy"
|
||||||
|
name="sortBy"
|
||||||
|
className="rounded-r-only"
|
||||||
|
value={preparedFilters.sortBy}
|
||||||
|
onChange={(e) => updateQueryParams('sortBy', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value={SortOptions.PopularityDesc}>
|
||||||
|
{intl.formatMessage(messages.sortPopularityDesc)}
|
||||||
|
</option>
|
||||||
|
<option value={SortOptions.PopularityAsc}>
|
||||||
|
{intl.formatMessage(messages.sortPopularityAsc)}
|
||||||
|
</option>
|
||||||
|
<option value={SortOptions.ReleaseDateDesc}>
|
||||||
|
{intl.formatMessage(messages.sortReleaseDateDesc)}
|
||||||
|
</option>
|
||||||
|
<option value={SortOptions.ReleaseDateAsc}>
|
||||||
|
{intl.formatMessage(messages.sortReleaseDateAsc)}
|
||||||
|
</option>
|
||||||
|
<option value={SortOptions.TmdbRatingDesc}>
|
||||||
|
{intl.formatMessage(messages.sortTmdbRatingDesc)}
|
||||||
|
</option>
|
||||||
|
<option value={SortOptions.TmdbRatingAsc}>
|
||||||
|
{intl.formatMessage(messages.sortTmdbRatingAsc)}
|
||||||
|
</option>
|
||||||
|
<option value={SortOptions.TitleAsc}>
|
||||||
|
{intl.formatMessage(messages.sortTitleAsc)}
|
||||||
|
</option>
|
||||||
|
<option value={SortOptions.TitleDesc}>
|
||||||
|
{intl.formatMessage(messages.sortTitleDesc)}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<FilterSlideover
|
||||||
|
type="movie"
|
||||||
|
currentFilters={preparedFilters}
|
||||||
|
onClose={() => setShowFilters(false)}
|
||||||
|
show={showFilters}
|
||||||
|
/>
|
||||||
|
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
|
||||||
|
<Button onClick={() => setShowFilters(true)} className="w-full">
|
||||||
|
<FunnelIcon />
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage(messages.activefilters, {
|
||||||
|
count: countActiveFilters(preparedFilters),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
items={titles}
|
||||||
|
isEmpty={isEmpty}
|
||||||
|
isLoading={
|
||||||
|
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||||
|
}
|
||||||
|
isReachingEnd={isReachingEnd}
|
||||||
|
onScrollBottom={fetchMore}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DiscoverMovies;
|
||||||
334
src/components/Discover/DiscoverSliderEdit/index.tsx
Normal file
334
src/components/Discover/DiscoverSliderEdit/index.tsx
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
import Button from '@app/components/Common/Button';
|
||||||
|
import SlideCheckbox from '@app/components/Common/SlideCheckbox';
|
||||||
|
import Tag from '@app/components/Common/Tag';
|
||||||
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
|
import CompanyTag from '@app/components/CompanyTag';
|
||||||
|
import { sliderTitles } from '@app/components/Discover/constants';
|
||||||
|
import CreateSlider from '@app/components/Discover/CreateSlider';
|
||||||
|
import GenreTag from '@app/components/GenreTag';
|
||||||
|
import KeywordTag from '@app/components/KeywordTag';
|
||||||
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
|
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||||
|
import {
|
||||||
|
ArrowUturnLeftIcon,
|
||||||
|
Bars3Icon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronUpIcon,
|
||||||
|
PencilIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
} from '@heroicons/react/24/solid';
|
||||||
|
import { DiscoverSliderType } from '@server/constants/discover';
|
||||||
|
import type DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useDrag, useDrop } from 'react-aria';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import { useToasts } from 'react-toast-notifications';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
deletesuccess: 'Sucessfully deleted slider.',
|
||||||
|
deletefail: 'Failed to delete slider.',
|
||||||
|
remove: 'Remove',
|
||||||
|
enable: 'Toggle Visibility',
|
||||||
|
});
|
||||||
|
|
||||||
|
const Position = {
|
||||||
|
None: 'None',
|
||||||
|
Above: 'Above',
|
||||||
|
Below: 'Below',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type DiscoverSliderEditProps = {
|
||||||
|
slider: Partial<DiscoverSlider>;
|
||||||
|
onEnable: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onPositionUpdate: (
|
||||||
|
updatedItemId: number,
|
||||||
|
position: keyof typeof Position,
|
||||||
|
isClickable: boolean
|
||||||
|
) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
disableUpButton: boolean;
|
||||||
|
disableDownButton: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DiscoverSliderEdit = ({
|
||||||
|
slider,
|
||||||
|
children,
|
||||||
|
onEnable,
|
||||||
|
onDelete,
|
||||||
|
onPositionUpdate,
|
||||||
|
disableUpButton,
|
||||||
|
disableDownButton,
|
||||||
|
}: DiscoverSliderEditProps) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { addToast } = useToasts();
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [hoverPosition, setHoverPosition] = useState<keyof typeof Position>(
|
||||||
|
Position.None
|
||||||
|
);
|
||||||
|
|
||||||
|
const { dragProps, isDragging } = useDrag({
|
||||||
|
getItems() {
|
||||||
|
return [{ id: (slider.id ?? -1).toString(), title: slider.title ?? '' }];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteSlider = async () => {
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/v1/settings/discover/${slider.id}`);
|
||||||
|
addToast(intl.formatMessage(messages.deletesuccess), {
|
||||||
|
appearance: 'success',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
onDelete();
|
||||||
|
} catch (e) {
|
||||||
|
addToast(intl.formatMessage(messages.deletefail), {
|
||||||
|
appearance: 'error',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { dropProps } = useDrop({
|
||||||
|
ref,
|
||||||
|
onDropMove: (e) => {
|
||||||
|
if (ref.current) {
|
||||||
|
const middlePoint = ref.current.offsetHeight / 2;
|
||||||
|
|
||||||
|
if (e.y < middlePoint) {
|
||||||
|
setHoverPosition(Position.Above);
|
||||||
|
} else {
|
||||||
|
setHoverPosition(Position.Below);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDropExit: () => {
|
||||||
|
setHoverPosition(Position.None);
|
||||||
|
},
|
||||||
|
onDrop: async (e) => {
|
||||||
|
const items = await Promise.all(
|
||||||
|
e.items
|
||||||
|
.filter((item) => item.kind === 'text' && item.types.has('id'))
|
||||||
|
.map(async (item) => {
|
||||||
|
if (item.kind === 'text') {
|
||||||
|
return item.getText('id');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (items?.[0]) {
|
||||||
|
const dropped = Number(items[0]);
|
||||||
|
onPositionUpdate(dropped, hoverPosition, false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const getSliderTitle = (slider: Partial<DiscoverSlider>): string => {
|
||||||
|
switch (slider.type) {
|
||||||
|
case DiscoverSliderType.RECENTLY_ADDED:
|
||||||
|
return intl.formatMessage(sliderTitles.recentlyAdded);
|
||||||
|
case DiscoverSliderType.RECENT_REQUESTS:
|
||||||
|
return intl.formatMessage(sliderTitles.recentrequests);
|
||||||
|
case DiscoverSliderType.PLEX_WATCHLIST:
|
||||||
|
return intl.formatMessage(sliderTitles.plexwatchlist);
|
||||||
|
case DiscoverSliderType.TRENDING:
|
||||||
|
return intl.formatMessage(sliderTitles.trending);
|
||||||
|
case DiscoverSliderType.POPULAR_MOVIES:
|
||||||
|
return intl.formatMessage(sliderTitles.popularmovies);
|
||||||
|
case DiscoverSliderType.MOVIE_GENRES:
|
||||||
|
return intl.formatMessage(sliderTitles.moviegenres);
|
||||||
|
case DiscoverSliderType.UPCOMING_MOVIES:
|
||||||
|
return intl.formatMessage(sliderTitles.upcoming);
|
||||||
|
case DiscoverSliderType.STUDIOS:
|
||||||
|
return intl.formatMessage(sliderTitles.studios);
|
||||||
|
case DiscoverSliderType.POPULAR_TV:
|
||||||
|
return intl.formatMessage(sliderTitles.populartv);
|
||||||
|
case DiscoverSliderType.TV_GENRES:
|
||||||
|
return intl.formatMessage(sliderTitles.tvgenres);
|
||||||
|
case DiscoverSliderType.UPCOMING_TV:
|
||||||
|
return intl.formatMessage(sliderTitles.upcomingtv);
|
||||||
|
case DiscoverSliderType.NETWORKS:
|
||||||
|
return intl.formatMessage(sliderTitles.networks);
|
||||||
|
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
|
||||||
|
return intl.formatMessage(sliderTitles.tmdbmoviekeyword);
|
||||||
|
case DiscoverSliderType.TMDB_TV_KEYWORD:
|
||||||
|
return intl.formatMessage(sliderTitles.tmdbtvkeyword);
|
||||||
|
case DiscoverSliderType.TMDB_MOVIE_GENRE:
|
||||||
|
return intl.formatMessage(sliderTitles.tmdbmoviegenre);
|
||||||
|
case DiscoverSliderType.TMDB_TV_GENRE:
|
||||||
|
return intl.formatMessage(sliderTitles.tmdbtvgenre);
|
||||||
|
case DiscoverSliderType.TMDB_STUDIO:
|
||||||
|
return intl.formatMessage(sliderTitles.tmdbstudio);
|
||||||
|
case DiscoverSliderType.TMDB_NETWORK:
|
||||||
|
return intl.formatMessage(sliderTitles.tmdbnetwork);
|
||||||
|
case DiscoverSliderType.TMDB_SEARCH:
|
||||||
|
return intl.formatMessage(sliderTitles.tmdbsearch);
|
||||||
|
default:
|
||||||
|
return 'Unknown Slider';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`discover-slider-${slider.id}-editing`}
|
||||||
|
data-testid="discover-slider-edit-mode"
|
||||||
|
className={`relative mb-4 rounded-lg bg-gray-800 shadow-md ${
|
||||||
|
isDragging ? 'opacity-0' : 'opacity-100'
|
||||||
|
}`}
|
||||||
|
{...dragProps}
|
||||||
|
{...dropProps}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
{hoverPosition === Position.Above && (
|
||||||
|
<div
|
||||||
|
className={`absolute -top-3 left-0 w-full border-t-4 border-indigo-500`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hoverPosition === Position.Below && (
|
||||||
|
<div
|
||||||
|
className={`absolute -bottom-2 left-0 w-full border-t-4 border-indigo-500`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex w-full flex-col rounded-t-lg border-t border-l border-r border-gray-800 bg-gray-900 p-4 text-gray-400 md:flex-row md:items-center md:space-x-2">
|
||||||
|
<div
|
||||||
|
className={`${slider.data ? 'mb-4' : 'mb-0'} flex space-x-2 md:mb-0`}
|
||||||
|
>
|
||||||
|
<Bars3Icon className="h-6 w-6" />
|
||||||
|
<div>{getSliderTitle(slider)}</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`pointer-events-none ${
|
||||||
|
slider.data ? 'mb-4' : ''
|
||||||
|
} flex-1 md:mb-0`}
|
||||||
|
>
|
||||||
|
{(slider.type === DiscoverSliderType.TMDB_MOVIE_KEYWORD ||
|
||||||
|
slider.type === DiscoverSliderType.TMDB_TV_KEYWORD) && (
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
{slider.data?.split(',').map((keywordId) => (
|
||||||
|
<KeywordTag
|
||||||
|
key={`slider-keywords-${slider.id}-${keywordId}`}
|
||||||
|
keywordId={Number(keywordId)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(slider.type === DiscoverSliderType.TMDB_NETWORK ||
|
||||||
|
slider.type === DiscoverSliderType.TMDB_STUDIO) && (
|
||||||
|
<CompanyTag
|
||||||
|
type={
|
||||||
|
slider.type === DiscoverSliderType.TMDB_STUDIO
|
||||||
|
? 'studio'
|
||||||
|
: 'network'
|
||||||
|
}
|
||||||
|
companyId={Number(slider.data)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(slider.type === DiscoverSliderType.TMDB_TV_GENRE ||
|
||||||
|
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE) && (
|
||||||
|
<GenreTag
|
||||||
|
type={
|
||||||
|
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE
|
||||||
|
? 'movie'
|
||||||
|
: 'tv'
|
||||||
|
}
|
||||||
|
genreId={Number(slider.data)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{slider.type === DiscoverSliderType.TMDB_SEARCH && (
|
||||||
|
<Tag iconSvg={<MagnifyingGlassIcon />}>{slider.data}</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{!slider.isBuiltIn && (
|
||||||
|
<>
|
||||||
|
{!isEditing ? (
|
||||||
|
<Button
|
||||||
|
buttonType="warning"
|
||||||
|
buttonSize="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setIsEditing(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PencilIcon />
|
||||||
|
<span>{intl.formatMessage(globalMessages.edit)}</span>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
buttonType="default"
|
||||||
|
buttonSize="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setIsEditing(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowUturnLeftIcon />
|
||||||
|
<span>{intl.formatMessage(globalMessages.cancel)}</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
data-testid="discover-slider-remove-button"
|
||||||
|
buttonType="danger"
|
||||||
|
buttonSize="sm"
|
||||||
|
onClick={() => {
|
||||||
|
deleteSlider();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XMarkIcon />
|
||||||
|
<span>{intl.formatMessage(messages.remove)}</span>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="absolute right-14 top-4 flex px-2 md:relative md:top-0 md:right-0">
|
||||||
|
<button
|
||||||
|
className={'hover:text-white disabled:text-gray-800'}
|
||||||
|
onClick={() =>
|
||||||
|
onPositionUpdate(Number(slider.id), Position.Above, true)
|
||||||
|
}
|
||||||
|
disabled={disableUpButton}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="h-7 w-7 md:h-6 md:w-6" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={'hover:text-white disabled:text-gray-800'}
|
||||||
|
onClick={() =>
|
||||||
|
onPositionUpdate(Number(slider.id), Position.Below, true)
|
||||||
|
}
|
||||||
|
disabled={disableDownButton}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="h-7 w-7 md:h-6 md:w-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-4 right-4 flex-1 text-right md:relative md:top-0 md:right-0">
|
||||||
|
<Tooltip content={intl.formatMessage(messages.enable)}>
|
||||||
|
<div>
|
||||||
|
<SlideCheckbox
|
||||||
|
onClick={() => {
|
||||||
|
onEnable();
|
||||||
|
}}
|
||||||
|
checked={slider.enabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="p-4">
|
||||||
|
<CreateSlider
|
||||||
|
onCreate={() => {
|
||||||
|
onDelete();
|
||||||
|
setIsEditing(false);
|
||||||
|
}}
|
||||||
|
slider={slider}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={`-mt-6 p-4 ${!slider.enabled ? 'opacity-50' : ''}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DiscoverSliderEdit;
|
||||||
145
src/components/Discover/DiscoverTv/index.tsx
Normal file
145
src/components/Discover/DiscoverTv/index.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import Button from '@app/components/Common/Button';
|
||||||
|
import Header from '@app/components/Common/Header';
|
||||||
|
import ListView from '@app/components/Common/ListView';
|
||||||
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
|
import type { FilterOptions } from '@app/components/Discover/constants';
|
||||||
|
import {
|
||||||
|
countActiveFilters,
|
||||||
|
prepareFilterValues,
|
||||||
|
} from '@app/components/Discover/constants';
|
||||||
|
import FilterSlideover from '@app/components/Discover/FilterSlideover';
|
||||||
|
import useDiscover from '@app/hooks/useDiscover';
|
||||||
|
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
|
||||||
|
import Error from '@app/pages/_error';
|
||||||
|
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
|
||||||
|
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
|
||||||
|
import type { TvResult } from '@server/models/Search';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
discovertv: 'Series',
|
||||||
|
activefilters:
|
||||||
|
'{count, plural, one {# Active Filter} other {# Active Filters}}',
|
||||||
|
sortPopularityAsc: 'Popularity Ascending',
|
||||||
|
sortPopularityDesc: 'Popularity Descending',
|
||||||
|
sortFirstAirDateAsc: 'First Air Date Ascending',
|
||||||
|
sortFirstAirDateDesc: 'First Air Date Descending',
|
||||||
|
sortTmdbRatingAsc: 'TMDB Rating Ascending',
|
||||||
|
sortTmdbRatingDesc: 'TMDB Rating Descending',
|
||||||
|
sortTitleAsc: 'Title (A-Z) Ascending',
|
||||||
|
sortTitleDesc: 'Title (Z-A) Descending',
|
||||||
|
});
|
||||||
|
|
||||||
|
const SortOptions: Record<string, TMDBSortOptions> = {
|
||||||
|
PopularityAsc: 'popularity.asc',
|
||||||
|
PopularityDesc: 'popularity.desc',
|
||||||
|
FirstAirDateAsc: 'first_air_date.asc',
|
||||||
|
FirstAirDateDesc: 'first_air_date.desc',
|
||||||
|
TmdbRatingAsc: 'vote_average.asc',
|
||||||
|
TmdbRatingDesc: 'vote_average.desc',
|
||||||
|
TitleAsc: 'original_title.asc',
|
||||||
|
TitleDesc: 'original_title.desc',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const DiscoverTv = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const router = useRouter();
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
const preparedFilters = prepareFilterValues(router.query);
|
||||||
|
const updateQueryParams = useUpdateQueryParams({});
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLoadingInitialData,
|
||||||
|
isEmpty,
|
||||||
|
isLoadingMore,
|
||||||
|
isReachingEnd,
|
||||||
|
titles,
|
||||||
|
fetchMore,
|
||||||
|
error,
|
||||||
|
} = useDiscover<TvResult, never, FilterOptions>('/api/v1/discover/tv', {
|
||||||
|
...preparedFilters,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <Error statusCode={500} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = intl.formatMessage(messages.discovertv);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle title={title} />
|
||||||
|
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
|
||||||
|
<Header>{title}</Header>
|
||||||
|
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
|
||||||
|
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
||||||
|
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
||||||
|
<BarsArrowDownIcon className="h-6 w-6" />
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
id="sortBy"
|
||||||
|
name="sortBy"
|
||||||
|
className="rounded-r-only"
|
||||||
|
value={preparedFilters.sortBy}
|
||||||
|
onChange={(e) => updateQueryParams('sortBy', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value={SortOptions.PopularityDesc}>
|
||||||
|
{intl.formatMessage(messages.sortPopularityDesc)}
|
||||||
|
</option>
|
||||||
|
<option value={SortOptions.PopularityAsc}>
|
||||||
|
{intl.formatMessage(messages.sortPopularityAsc)}
|
||||||
|
</option>
|
||||||
|
<option value={SortOptions.FirstAirDateDesc}>
|
||||||
|
{intl.formatMessage(messages.sortFirstAirDateDesc)}
|
||||||
|
</option>
|
||||||
|
<option value={SortOptions.FirstAirDateAsc}>
|
||||||
|
{intl.formatMessage(messages.sortFirstAirDateAsc)}
|
||||||
|
</option>
|
||||||
|
<option value={SortOptions.TmdbRatingDesc}>
|
||||||
|
{intl.formatMessage(messages.sortTmdbRatingDesc)}
|
||||||
|
</option>
|
||||||
|
<option value={SortOptions.TmdbRatingAsc}>
|
||||||
|
{intl.formatMessage(messages.sortTmdbRatingAsc)}
|
||||||
|
</option>
|
||||||
|
<option value={SortOptions.TitleAsc}>
|
||||||
|
{intl.formatMessage(messages.sortTitleAsc)}
|
||||||
|
</option>
|
||||||
|
<option value={SortOptions.TitleDesc}>
|
||||||
|
{intl.formatMessage(messages.sortTitleDesc)}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<FilterSlideover
|
||||||
|
type="tv"
|
||||||
|
currentFilters={preparedFilters}
|
||||||
|
onClose={() => setShowFilters(false)}
|
||||||
|
show={showFilters}
|
||||||
|
/>
|
||||||
|
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
|
||||||
|
<Button onClick={() => setShowFilters(true)} className="w-full">
|
||||||
|
<FunnelIcon />
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage(messages.activefilters, {
|
||||||
|
count: countActiveFilters(preparedFilters),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
items={titles}
|
||||||
|
isEmpty={isEmpty}
|
||||||
|
isReachingEnd={isReachingEnd}
|
||||||
|
isLoading={
|
||||||
|
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||||
|
}
|
||||||
|
onScrollBottom={fetchMore}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DiscoverTv;
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
import Header from '@app/components/Common/Header';
|
import Header from '@app/components/Common/Header';
|
||||||
import ListView from '@app/components/Common/ListView';
|
import ListView from '@app/components/Common/ListView';
|
||||||
import PageTitle from '@app/components/Common/PageTitle';
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
import useDiscover from '@app/hooks/useDiscover';
|
import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
||||||
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
|
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
||||||
import type { TvResult } from '@server/models/Search';
|
import type { TvResult } from '@server/models/Search';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
discovertv: 'Popular Series',
|
keywordSeries: '{keywordTitle} Series',
|
||||||
});
|
});
|
||||||
|
|
||||||
const DiscoverTv = () => {
|
const DiscoverTvKeyword = () => {
|
||||||
|
const router = useRouter();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -21,13 +25,25 @@ const DiscoverTv = () => {
|
|||||||
titles,
|
titles,
|
||||||
fetchMore,
|
fetchMore,
|
||||||
error,
|
error,
|
||||||
} = useDiscover<TvResult>('/api/v1/discover/tv');
|
firstResultData,
|
||||||
|
} = useDiscover<TvResult, { keywords: TmdbKeyword[] }>(
|
||||||
|
`/api/v1/discover/tv`,
|
||||||
|
{
|
||||||
|
keywords: encodeURIExtraParams(router.query.keywords as string),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <Error statusCode={500} />;
|
return <Error statusCode={500} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = intl.formatMessage(messages.discovertv);
|
const title = isLoadingInitialData
|
||||||
|
? intl.formatMessage(globalMessages.loading)
|
||||||
|
: intl.formatMessage(messages.keywordSeries, {
|
||||||
|
keywordTitle: firstResultData?.keywords
|
||||||
|
.map((k) => `${k.name[0].toUpperCase()}${k.name.substring(1)}`)
|
||||||
|
.join(', '),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -38,14 +54,14 @@ const DiscoverTv = () => {
|
|||||||
<ListView
|
<ListView
|
||||||
items={titles}
|
items={titles}
|
||||||
isEmpty={isEmpty}
|
isEmpty={isEmpty}
|
||||||
isReachingEnd={isReachingEnd}
|
|
||||||
isLoading={
|
isLoading={
|
||||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||||
}
|
}
|
||||||
|
isReachingEnd={isReachingEnd}
|
||||||
onScrollBottom={fetchMore}
|
onScrollBottom={fetchMore}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DiscoverTv;
|
export default DiscoverTvKeyword;
|
||||||
297
src/components/Discover/FilterSlideover/index.tsx
Normal file
297
src/components/Discover/FilterSlideover/index.tsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import Button from '@app/components/Common/Button';
|
||||||
|
import MultiRangeSlider from '@app/components/Common/MultiRangeSlider';
|
||||||
|
import SlideOver from '@app/components/Common/SlideOver';
|
||||||
|
import type { FilterOptions } from '@app/components/Discover/constants';
|
||||||
|
import { countActiveFilters } from '@app/components/Discover/constants';
|
||||||
|
import LanguageSelector from '@app/components/LanguageSelector';
|
||||||
|
import {
|
||||||
|
CompanySelector,
|
||||||
|
GenreSelector,
|
||||||
|
KeywordSelector,
|
||||||
|
WatchProviderSelector,
|
||||||
|
} from '@app/components/Selector';
|
||||||
|
import useSettings from '@app/hooks/useSettings';
|
||||||
|
import {
|
||||||
|
useBatchUpdateQueryParams,
|
||||||
|
useUpdateQueryParams,
|
||||||
|
} from '@app/hooks/useUpdateQueryParams';
|
||||||
|
import { XCircleIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import Datepicker from 'react-tailwindcss-datepicker-sct';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
filters: 'Filters',
|
||||||
|
activefilters:
|
||||||
|
'{count, plural, one {# Active Filter} other {# Active Filters}}',
|
||||||
|
releaseDate: 'Release Date',
|
||||||
|
firstAirDate: 'First Air Date',
|
||||||
|
from: 'From',
|
||||||
|
to: 'To',
|
||||||
|
studio: 'Studio',
|
||||||
|
genres: 'Genres',
|
||||||
|
keywords: 'Keywords',
|
||||||
|
originalLanguage: 'Original Language',
|
||||||
|
runtimeText: '{minValue}-{maxValue} minute runtime',
|
||||||
|
ratingText: 'Ratings between {minValue} and {maxValue}',
|
||||||
|
clearfilters: 'Clear Active Filters',
|
||||||
|
tmdbuserscore: 'TMDB User Score',
|
||||||
|
runtime: 'Runtime',
|
||||||
|
streamingservices: 'Streaming Services',
|
||||||
|
});
|
||||||
|
|
||||||
|
type FilterSlideoverProps = {
|
||||||
|
show: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
type: 'movie' | 'tv';
|
||||||
|
currentFilters: FilterOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FilterSlideover = ({
|
||||||
|
show,
|
||||||
|
onClose,
|
||||||
|
type,
|
||||||
|
currentFilters,
|
||||||
|
}: FilterSlideoverProps) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { currentSettings } = useSettings();
|
||||||
|
const updateQueryParams = useUpdateQueryParams({});
|
||||||
|
const batchUpdateQueryParams = useBatchUpdateQueryParams({});
|
||||||
|
|
||||||
|
const dateGte =
|
||||||
|
type === 'movie' ? 'primaryReleaseDateGte' : 'firstAirDateGte';
|
||||||
|
const dateLte =
|
||||||
|
type === 'movie' ? 'primaryReleaseDateLte' : 'firstAirDateLte';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SlideOver
|
||||||
|
show={show}
|
||||||
|
title={intl.formatMessage(messages.filters)}
|
||||||
|
subText={intl.formatMessage(messages.activefilters, {
|
||||||
|
count: countActiveFilters(currentFilters),
|
||||||
|
})}
|
||||||
|
onClose={() => onClose()}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-lg font-semibold">
|
||||||
|
{intl.formatMessage(
|
||||||
|
type === 'movie' ? messages.releaseDate : messages.firstAirDate
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative z-40 flex space-x-2">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="mb-2">{intl.formatMessage(messages.from)}</div>
|
||||||
|
<Datepicker
|
||||||
|
primaryColor="indigo"
|
||||||
|
value={{
|
||||||
|
startDate: currentFilters[dateGte] ?? null,
|
||||||
|
endDate: currentFilters[dateGte] ?? null,
|
||||||
|
}}
|
||||||
|
onChange={(value) => {
|
||||||
|
updateQueryParams(
|
||||||
|
dateGte,
|
||||||
|
value?.startDate ? (value.startDate as string) : undefined
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
inputName="fromdate"
|
||||||
|
useRange={false}
|
||||||
|
asSingle
|
||||||
|
containerClassName="datepicker-wrapper"
|
||||||
|
inputClassName="pr-1 sm:pr-4 text-base leading-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="mb-2">{intl.formatMessage(messages.to)}</div>
|
||||||
|
<Datepicker
|
||||||
|
primaryColor="indigo"
|
||||||
|
value={{
|
||||||
|
startDate: currentFilters[dateLte] ?? null,
|
||||||
|
endDate: currentFilters[dateLte] ?? null,
|
||||||
|
}}
|
||||||
|
onChange={(value) => {
|
||||||
|
updateQueryParams(
|
||||||
|
dateLte,
|
||||||
|
value?.startDate ? (value.startDate as string) : undefined
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
inputName="todate"
|
||||||
|
useRange={false}
|
||||||
|
asSingle
|
||||||
|
containerClassName="datepicker-wrapper"
|
||||||
|
inputClassName="pr-1 sm:pr-4 text-base leading-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{type === 'movie' && (
|
||||||
|
<>
|
||||||
|
<span className="text-lg font-semibold">
|
||||||
|
{intl.formatMessage(messages.studio)}
|
||||||
|
</span>
|
||||||
|
<CompanySelector
|
||||||
|
defaultValue={currentFilters.studio}
|
||||||
|
onChange={(value) => {
|
||||||
|
updateQueryParams('studio', value?.value.toString());
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="text-lg font-semibold">
|
||||||
|
{intl.formatMessage(messages.genres)}
|
||||||
|
</span>
|
||||||
|
<GenreSelector
|
||||||
|
type={type}
|
||||||
|
defaultValue={currentFilters.genre}
|
||||||
|
isMulti
|
||||||
|
onChange={(value) => {
|
||||||
|
updateQueryParams('genre', value?.map((v) => v.value).join(','));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-lg font-semibold">
|
||||||
|
{intl.formatMessage(messages.keywords)}
|
||||||
|
</span>
|
||||||
|
<KeywordSelector
|
||||||
|
defaultValue={currentFilters.keywords}
|
||||||
|
isMulti
|
||||||
|
onChange={(value) => {
|
||||||
|
updateQueryParams('keywords', value?.map((v) => v.value).join(','));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-lg font-semibold">
|
||||||
|
{intl.formatMessage(messages.originalLanguage)}
|
||||||
|
</span>
|
||||||
|
<LanguageSelector
|
||||||
|
value={currentFilters.language}
|
||||||
|
serverValue={currentSettings.originalLanguage}
|
||||||
|
isUserSettings
|
||||||
|
setFieldValue={(_key, value) => {
|
||||||
|
updateQueryParams('language', value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-lg font-semibold">
|
||||||
|
{intl.formatMessage(messages.runtime)}
|
||||||
|
</span>
|
||||||
|
<div className="relative z-0">
|
||||||
|
<MultiRangeSlider
|
||||||
|
min={0}
|
||||||
|
max={400}
|
||||||
|
onUpdateMin={(min) => {
|
||||||
|
updateQueryParams(
|
||||||
|
'withRuntimeGte',
|
||||||
|
min !== 0 && Number(currentFilters.withRuntimeLte) !== 400
|
||||||
|
? min.toString()
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onUpdateMax={(max) => {
|
||||||
|
updateQueryParams(
|
||||||
|
'withRuntimeLte',
|
||||||
|
max !== 400 && Number(currentFilters.withRuntimeGte) !== 0
|
||||||
|
? max.toString()
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
defaultMaxValue={
|
||||||
|
currentFilters.withRuntimeLte
|
||||||
|
? Number(currentFilters.withRuntimeLte)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
defaultMinValue={
|
||||||
|
currentFilters.withRuntimeGte
|
||||||
|
? Number(currentFilters.withRuntimeGte)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
subText={intl.formatMessage(messages.runtimeText, {
|
||||||
|
minValue: currentFilters.withRuntimeGte ?? 0,
|
||||||
|
maxValue: currentFilters.withRuntimeLte ?? 400,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-semibold">
|
||||||
|
{intl.formatMessage(messages.tmdbuserscore)}
|
||||||
|
</span>
|
||||||
|
<div className="relative z-0">
|
||||||
|
<MultiRangeSlider
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
defaultMaxValue={
|
||||||
|
currentFilters.voteAverageLte
|
||||||
|
? Number(currentFilters.voteAverageLte)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
defaultMinValue={
|
||||||
|
currentFilters.voteAverageGte
|
||||||
|
? Number(currentFilters.voteAverageGte)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onUpdateMin={(min) => {
|
||||||
|
updateQueryParams(
|
||||||
|
'voteAverageGte',
|
||||||
|
min !== 1 && Number(currentFilters.voteAverageLte) !== 10
|
||||||
|
? min.toString()
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onUpdateMax={(max) => {
|
||||||
|
updateQueryParams(
|
||||||
|
'voteAverageLte',
|
||||||
|
max !== 10 && Number(currentFilters.voteAverageGte) !== 1
|
||||||
|
? max.toString()
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
subText={intl.formatMessage(messages.ratingText, {
|
||||||
|
minValue: currentFilters.voteAverageGte ?? 1,
|
||||||
|
maxValue: currentFilters.voteAverageLte ?? 10,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-semibold">
|
||||||
|
{intl.formatMessage(messages.streamingservices)}
|
||||||
|
</span>
|
||||||
|
<WatchProviderSelector
|
||||||
|
type={type}
|
||||||
|
region={currentFilters.watchRegion}
|
||||||
|
activeProviders={
|
||||||
|
currentFilters.watchProviders?.split('|').map((v) => Number(v)) ??
|
||||||
|
[]
|
||||||
|
}
|
||||||
|
onChange={(region, providers) => {
|
||||||
|
if (providers.length) {
|
||||||
|
batchUpdateQueryParams({
|
||||||
|
watchRegion: region,
|
||||||
|
watchProviders: providers.join('|'),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
batchUpdateQueryParams({
|
||||||
|
watchRegion: undefined,
|
||||||
|
watchProviders: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="pt-4">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
disabled={Object.keys(currentFilters).length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
const copyCurrent = Object.assign({}, currentFilters);
|
||||||
|
(
|
||||||
|
Object.keys(copyCurrent) as (keyof typeof currentFilters)[]
|
||||||
|
).forEach((k) => {
|
||||||
|
copyCurrent[k] = undefined;
|
||||||
|
});
|
||||||
|
batchUpdateQueryParams(copyCurrent);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XCircleIcon />
|
||||||
|
<span>{intl.formatMessage(messages.clearfilters)}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SlideOver>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FilterSlideover;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { genreColorMap } from '@app/components/Discover/constants';
|
import { genreColorMap } from '@app/components/Discover/constants';
|
||||||
import GenreCard from '@app/components/GenreCard';
|
import GenreCard from '@app/components/GenreCard';
|
||||||
import Slider from '@app/components/Slider';
|
import Slider from '@app/components/Slider';
|
||||||
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
|
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
|
||||||
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -28,7 +28,7 @@ const MovieGenreSlider = () => {
|
|||||||
<Link href="/discover/movies/genres">
|
<Link href="/discover/movies/genres">
|
||||||
<a className="slider-title">
|
<a className="slider-title">
|
||||||
<span>{intl.formatMessage(messages.moviegenres)}</span>
|
<span>{intl.formatMessage(messages.moviegenres)}</span>
|
||||||
<ArrowCircleRightIcon />
|
<ArrowRightCircleIcon />
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,7 +43,7 @@ const MovieGenreSlider = () => {
|
|||||||
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
|
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
|
||||||
genreColorMap[genre.id] ?? genreColorMap[0]
|
genreColorMap[genre.id] ?? genreColorMap[0]
|
||||||
})${genre.backdrops[4]}`}
|
})${genre.backdrops[4]}`}
|
||||||
url={`/discover/movies/genre/${genre.id}`}
|
url={`/discover/movies?genre=${genre.id}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
placeholder={<GenreCard.Placeholder />}
|
placeholder={<GenreCard.Placeholder />}
|
||||||
|
|||||||
79
src/components/Discover/PlexWatchlistSlider/index.tsx
Normal file
79
src/components/Discover/PlexWatchlistSlider/index.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import Slider from '@app/components/Slider';
|
||||||
|
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
|
||||||
|
import { UserType, useUser } from '@app/hooks/useUser';
|
||||||
|
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
|
||||||
|
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
plexwatchlist: 'Your Plex Watchlist',
|
||||||
|
emptywatchlist:
|
||||||
|
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const PlexWatchlistSlider = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { user } = useUser();
|
||||||
|
|
||||||
|
const { data: watchlistItems, error: watchlistError } = useSWR<{
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalResults: number;
|
||||||
|
results: WatchlistItem[];
|
||||||
|
}>(user?.userType === UserType.PLEX ? '/api/v1/discover/watchlist' : null, {
|
||||||
|
revalidateOnMount: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
user?.userType !== UserType.PLEX ||
|
||||||
|
(watchlistItems &&
|
||||||
|
watchlistItems.results.length === 0 &&
|
||||||
|
!user?.settings?.watchlistSyncMovies &&
|
||||||
|
!user?.settings?.watchlistSyncTv) ||
|
||||||
|
watchlistError
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="slider-header">
|
||||||
|
<Link href="/discover/watchlist">
|
||||||
|
<a className="slider-title">
|
||||||
|
<span>{intl.formatMessage(messages.plexwatchlist)}</span>
|
||||||
|
<ArrowRightCircleIcon />
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
sliderKey="watchlist"
|
||||||
|
isLoading={!watchlistItems}
|
||||||
|
isEmpty={!!watchlistItems && watchlistItems.results.length === 0}
|
||||||
|
emptyMessage={intl.formatMessage(messages.emptywatchlist, {
|
||||||
|
PlexWatchlistSupportLink: (msg: React.ReactNode) => (
|
||||||
|
<a
|
||||||
|
href="https://support.plex.tv/articles/universal-watchlist/"
|
||||||
|
className="text-white transition duration-300 hover:underline"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{msg}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
items={watchlistItems?.results.map((item) => (
|
||||||
|
<TmdbTitleCard
|
||||||
|
id={item.tmdbId}
|
||||||
|
key={`watchlist-slider-item-${item.ratingKey}`}
|
||||||
|
tmdbId={item.tmdbId}
|
||||||
|
type={item.mediaType}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlexWatchlistSlider;
|
||||||
49
src/components/Discover/RecentRequestsSlider/index.tsx
Normal file
49
src/components/Discover/RecentRequestsSlider/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { sliderTitles } from '@app/components/Discover/constants';
|
||||||
|
import RequestCard from '@app/components/RequestCard';
|
||||||
|
import Slider from '@app/components/Slider';
|
||||||
|
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
|
||||||
|
import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
const RecentRequestsSlider = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { data: requests, error: requestError } =
|
||||||
|
useSWR<RequestResultsResponse>(
|
||||||
|
'/api/v1/request?filter=all&take=10&sort=modified&skip=0',
|
||||||
|
{
|
||||||
|
revalidateOnMount: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (requests && requests.results.length === 0 && !requestError) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="slider-header">
|
||||||
|
<Link href="/requests?filter=all">
|
||||||
|
<a className="slider-title">
|
||||||
|
<span>{intl.formatMessage(sliderTitles.recentrequests)}</span>
|
||||||
|
<ArrowRightCircleIcon />
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
sliderKey="requests"
|
||||||
|
isLoading={!requests}
|
||||||
|
items={(requests?.results ?? []).map((request) => (
|
||||||
|
<RequestCard
|
||||||
|
key={`request-slider-item-${request.id}`}
|
||||||
|
request={request}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
placeholder={<RequestCard.Placeholder />}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RecentRequestsSlider;
|
||||||
53
src/components/Discover/RecentlyAddedSlider/index.tsx
Normal file
53
src/components/Discover/RecentlyAddedSlider/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import Slider from '@app/components/Slider';
|
||||||
|
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
|
||||||
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
|
import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
recentlyAdded: 'Recently Added',
|
||||||
|
});
|
||||||
|
|
||||||
|
const RecentlyAddedSlider = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { hasPermission } = useUser();
|
||||||
|
const { data: media, error: mediaError } = useSWR<MediaResultsResponse>(
|
||||||
|
'/api/v1/media?filter=allavailable&take=20&sort=mediaAdded',
|
||||||
|
{ revalidateOnMount: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
(media && !media.results.length && !mediaError) ||
|
||||||
|
!hasPermission([Permission.MANAGE_REQUESTS, Permission.RECENT_VIEW], {
|
||||||
|
type: 'or',
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="slider-header">
|
||||||
|
<div className="slider-title">
|
||||||
|
<span>{intl.formatMessage(messages.recentlyAdded)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
sliderKey="media"
|
||||||
|
isLoading={!media}
|
||||||
|
items={(media?.results ?? []).map((item) => (
|
||||||
|
<TmdbTitleCard
|
||||||
|
key={`media-slider-item-${item.id}`}
|
||||||
|
id={item.id}
|
||||||
|
tmdbId={item.tmdbId}
|
||||||
|
tvdbId={item.tvdbId}
|
||||||
|
type={item.mediaType}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RecentlyAddedSlider;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { genreColorMap } from '@app/components/Discover/constants';
|
import { genreColorMap } from '@app/components/Discover/constants';
|
||||||
import GenreCard from '@app/components/GenreCard';
|
import GenreCard from '@app/components/GenreCard';
|
||||||
import Slider from '@app/components/Slider';
|
import Slider from '@app/components/Slider';
|
||||||
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
|
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
|
||||||
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -28,7 +28,7 @@ const TvGenreSlider = () => {
|
|||||||
<Link href="/discover/tv/genres">
|
<Link href="/discover/tv/genres">
|
||||||
<a className="slider-title">
|
<a className="slider-title">
|
||||||
<span>{intl.formatMessage(messages.tvgenres)}</span>
|
<span>{intl.formatMessage(messages.tvgenres)}</span>
|
||||||
<ArrowCircleRightIcon />
|
<ArrowRightCircleIcon />
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,7 +43,7 @@ const TvGenreSlider = () => {
|
|||||||
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
|
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
|
||||||
genreColorMap[genre.id] ?? genreColorMap[0]
|
genreColorMap[genre.id] ?? genreColorMap[0]
|
||||||
})${genre.backdrops[4]}`}
|
})${genre.backdrops[4]}`}
|
||||||
url={`/discover/tv/genre/${genre.id}`}
|
url={`/discover/tv?genre=${genre.id}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
placeholder={<GenreCard.Placeholder />}
|
placeholder={<GenreCard.Placeholder />}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import type { ParsedUrlQuery } from 'querystring';
|
||||||
|
import { defineMessages } from 'react-intl';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
type AvailableColors =
|
type AvailableColors =
|
||||||
| 'black'
|
| 'black'
|
||||||
| 'red'
|
| 'red'
|
||||||
@@ -61,3 +65,142 @@ export const genreColorMap: Record<number, [string, string]> = {
|
|||||||
10767: colorTones.lightgreen, // Talk
|
10767: colorTones.lightgreen, // Talk
|
||||||
10768: colorTones.darkred, // War & Politics
|
10768: colorTones.darkred, // War & Politics
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const sliderTitles = defineMessages({
|
||||||
|
recentrequests: 'Recent Requests',
|
||||||
|
popularmovies: 'Popular Movies',
|
||||||
|
populartv: 'Popular Series',
|
||||||
|
upcomingtv: 'Upcoming Series',
|
||||||
|
recentlyAdded: 'Recently Added',
|
||||||
|
upcoming: 'Upcoming Movies',
|
||||||
|
trending: 'Trending',
|
||||||
|
plexwatchlist: 'Your Plex Watchlist',
|
||||||
|
moviegenres: 'Movie Genres',
|
||||||
|
tvgenres: 'Series Genres',
|
||||||
|
studios: 'Studios',
|
||||||
|
networks: 'Networks',
|
||||||
|
tmdbmoviekeyword: 'TMDB Movie Keyword',
|
||||||
|
tmdbtvkeyword: 'TMDB Series Keyword',
|
||||||
|
tmdbmoviegenre: 'TMDB Movie Genre',
|
||||||
|
tmdbtvgenre: 'TMDB Series Genre',
|
||||||
|
tmdbnetwork: 'TMDB Network',
|
||||||
|
tmdbstudio: 'TMDB Studio',
|
||||||
|
tmdbsearch: 'TMDB Search',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const QueryFilterOptions = z.object({
|
||||||
|
sortBy: z.string().optional(),
|
||||||
|
primaryReleaseDateGte: z.string().optional(),
|
||||||
|
primaryReleaseDateLte: z.string().optional(),
|
||||||
|
firstAirDateGte: z.string().optional(),
|
||||||
|
firstAirDateLte: z.string().optional(),
|
||||||
|
studio: z.string().optional(),
|
||||||
|
genre: z.string().optional(),
|
||||||
|
keywords: z.string().optional(),
|
||||||
|
language: z.string().optional(),
|
||||||
|
withRuntimeGte: z.string().optional(),
|
||||||
|
withRuntimeLte: z.string().optional(),
|
||||||
|
voteAverageGte: z.string().optional(),
|
||||||
|
voteAverageLte: z.string().optional(),
|
||||||
|
watchRegion: z.string().optional(),
|
||||||
|
watchProviders: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
||||||
|
|
||||||
|
export const prepareFilterValues = (
|
||||||
|
inputValues: ParsedUrlQuery
|
||||||
|
): FilterOptions => {
|
||||||
|
const filterValues: FilterOptions = {};
|
||||||
|
|
||||||
|
const values = QueryFilterOptions.parse(inputValues);
|
||||||
|
|
||||||
|
if (values.sortBy) {
|
||||||
|
filterValues.sortBy = values.sortBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.primaryReleaseDateGte) {
|
||||||
|
filterValues.primaryReleaseDateGte = values.primaryReleaseDateGte;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.primaryReleaseDateLte) {
|
||||||
|
filterValues.primaryReleaseDateLte = values.primaryReleaseDateLte;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.firstAirDateGte) {
|
||||||
|
filterValues.firstAirDateGte = values.firstAirDateGte;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.firstAirDateLte) {
|
||||||
|
filterValues.firstAirDateLte = values.firstAirDateLte;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.studio) {
|
||||||
|
filterValues.studio = values.studio;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.genre) {
|
||||||
|
filterValues.genre = values.genre;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.keywords) {
|
||||||
|
filterValues.keywords = values.keywords;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.language) {
|
||||||
|
filterValues.language = values.language;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.withRuntimeGte) {
|
||||||
|
filterValues.withRuntimeGte = values.withRuntimeGte;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.withRuntimeLte) {
|
||||||
|
filterValues.withRuntimeLte = values.withRuntimeLte;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.voteAverageGte) {
|
||||||
|
filterValues.voteAverageGte = values.voteAverageGte;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.voteAverageLte) {
|
||||||
|
filterValues.voteAverageLte = values.voteAverageLte;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.watchProviders) {
|
||||||
|
filterValues.watchProviders = values.watchProviders;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.watchRegion) {
|
||||||
|
filterValues.watchRegion = values.watchRegion;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filterValues;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const countActiveFilters = (filterValues: FilterOptions): number => {
|
||||||
|
let totalCount = 0;
|
||||||
|
const clonedFilters = Object.assign({}, filterValues);
|
||||||
|
|
||||||
|
if (clonedFilters.voteAverageGte || filterValues.voteAverageLte) {
|
||||||
|
totalCount += 1;
|
||||||
|
delete clonedFilters.voteAverageGte;
|
||||||
|
delete clonedFilters.voteAverageLte;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clonedFilters.withRuntimeGte || filterValues.withRuntimeLte) {
|
||||||
|
totalCount += 1;
|
||||||
|
delete clonedFilters.withRuntimeGte;
|
||||||
|
delete clonedFilters.withRuntimeLte;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clonedFilters.watchProviders) {
|
||||||
|
totalCount += 1;
|
||||||
|
delete clonedFilters.watchProviders;
|
||||||
|
delete clonedFilters.watchRegion;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalCount += Object.keys(clonedFilters).length;
|
||||||
|
|
||||||
|
return totalCount;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,189 +1,430 @@
|
|||||||
|
import Button from '@app/components/Common/Button';
|
||||||
|
import ConfirmButton from '@app/components/Common/ConfirmButton';
|
||||||
|
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||||
import PageTitle from '@app/components/Common/PageTitle';
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
|
import { sliderTitles } from '@app/components/Discover/constants';
|
||||||
|
import CreateSlider from '@app/components/Discover/CreateSlider';
|
||||||
|
import DiscoverSliderEdit from '@app/components/Discover/DiscoverSliderEdit';
|
||||||
import MovieGenreSlider from '@app/components/Discover/MovieGenreSlider';
|
import MovieGenreSlider from '@app/components/Discover/MovieGenreSlider';
|
||||||
import NetworkSlider from '@app/components/Discover/NetworkSlider';
|
import NetworkSlider from '@app/components/Discover/NetworkSlider';
|
||||||
|
import PlexWatchlistSlider from '@app/components/Discover/PlexWatchlistSlider';
|
||||||
|
import RecentlyAddedSlider from '@app/components/Discover/RecentlyAddedSlider';
|
||||||
|
import RecentRequestsSlider from '@app/components/Discover/RecentRequestsSlider';
|
||||||
import StudioSlider from '@app/components/Discover/StudioSlider';
|
import StudioSlider from '@app/components/Discover/StudioSlider';
|
||||||
import TvGenreSlider from '@app/components/Discover/TvGenreSlider';
|
import TvGenreSlider from '@app/components/Discover/TvGenreSlider';
|
||||||
import MediaSlider from '@app/components/MediaSlider';
|
import MediaSlider from '@app/components/MediaSlider';
|
||||||
import RequestCard from '@app/components/RequestCard';
|
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
||||||
import Slider from '@app/components/Slider';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import { Permission, UserType, useUser } from '@app/hooks/useUser';
|
import { Transition } from '@headlessui/react';
|
||||||
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
|
import {
|
||||||
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
ArrowDownOnSquareIcon,
|
||||||
import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces';
|
ArrowPathIcon,
|
||||||
import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces';
|
ArrowUturnLeftIcon,
|
||||||
import Link from 'next/link';
|
PencilIcon,
|
||||||
|
PlusIcon,
|
||||||
|
} from '@heroicons/react/24/solid';
|
||||||
|
import { DiscoverSliderType } from '@server/constants/discover';
|
||||||
|
import type DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
discover: 'Discover',
|
discover: 'Discover',
|
||||||
recentrequests: 'Recent Requests',
|
|
||||||
popularmovies: 'Popular Movies',
|
|
||||||
populartv: 'Popular Series',
|
|
||||||
upcomingtv: 'Upcoming Series',
|
|
||||||
recentlyAdded: 'Recently Added',
|
|
||||||
upcoming: 'Upcoming Movies',
|
|
||||||
trending: 'Trending',
|
|
||||||
plexwatchlist: 'Your Plex Watchlist',
|
|
||||||
emptywatchlist:
|
emptywatchlist:
|
||||||
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.',
|
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.',
|
||||||
|
resettodefault: 'Reset to Default',
|
||||||
|
resetwarning:
|
||||||
|
'Reset all sliders to default. This will also delete any custom sliders!',
|
||||||
|
updatesuccess: 'Updated discover customization settings.',
|
||||||
|
updatefailed:
|
||||||
|
'Something went wrong updating the discover customization settings.',
|
||||||
|
resetsuccess: 'Sucessfully reset discover customization settings.',
|
||||||
|
resetfailed:
|
||||||
|
'Something went wrong resetting the discover customization settings.',
|
||||||
|
customizediscover: 'Customize Discover',
|
||||||
|
stopediting: 'Stop Editing',
|
||||||
|
createnewslider: 'Create New Slider',
|
||||||
});
|
});
|
||||||
|
|
||||||
const Discover = () => {
|
const Discover = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { user, hasPermission } = useUser();
|
const { hasPermission } = useUser();
|
||||||
|
const { addToast } = useToasts();
|
||||||
|
const {
|
||||||
|
data: discoverData,
|
||||||
|
error: discoverError,
|
||||||
|
mutate,
|
||||||
|
} = useSWR<DiscoverSlider[]>('/api/v1/settings/discover');
|
||||||
|
const [sliders, setSliders] = useState<Partial<DiscoverSlider>[]>([]);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
const { data: media, error: mediaError } = useSWR<MediaResultsResponse>(
|
// We need to sync the state here so that we can modify the changes locally without commiting
|
||||||
'/api/v1/media?filter=allavailable&take=20&sort=mediaAdded',
|
// anything to the server until the user decides to save the changes
|
||||||
{ revalidateOnMount: true }
|
useEffect(() => {
|
||||||
);
|
if (discoverData && !isEditing) {
|
||||||
|
setSliders(discoverData);
|
||||||
const { data: requests, error: requestError } =
|
|
||||||
useSWR<RequestResultsResponse>(
|
|
||||||
'/api/v1/request?filter=all&take=10&sort=modified&skip=0',
|
|
||||||
{
|
|
||||||
revalidateOnMount: true,
|
|
||||||
}
|
}
|
||||||
);
|
}, [discoverData, isEditing]);
|
||||||
|
|
||||||
const { data: watchlistItems, error: watchlistError } = useSWR<{
|
const hasChanged = () => !Object.is(discoverData, sliders);
|
||||||
page: number;
|
|
||||||
totalPages: number;
|
const updateSliders = async () => {
|
||||||
totalResults: number;
|
try {
|
||||||
results: WatchlistItem[];
|
await axios.post('/api/v1/settings/discover', sliders);
|
||||||
}>(user?.userType === UserType.PLEX ? '/api/v1/discover/watchlist' : null, {
|
|
||||||
revalidateOnMount: true,
|
addToast(intl.formatMessage(messages.updatesuccess), {
|
||||||
|
appearance: 'success',
|
||||||
|
autoDismiss: true,
|
||||||
});
|
});
|
||||||
|
setIsEditing(false);
|
||||||
|
mutate();
|
||||||
|
} catch (e) {
|
||||||
|
addToast(intl.formatMessage(messages.updatefailed), {
|
||||||
|
appearance: 'error',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetSliders = async () => {
|
||||||
|
try {
|
||||||
|
await axios.get('/api/v1/settings/discover/reset');
|
||||||
|
|
||||||
|
addToast(intl.formatMessage(messages.resetsuccess), {
|
||||||
|
appearance: 'success',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
setIsEditing(false);
|
||||||
|
mutate();
|
||||||
|
} catch (e) {
|
||||||
|
addToast(intl.formatMessage(messages.resetfailed), {
|
||||||
|
appearance: 'error',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const offset = now.getTimezoneOffset();
|
||||||
|
const upcomingDate = new Date(now.getTime() - offset * 60 * 1000)
|
||||||
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
|
||||||
|
if (!discoverData && !discoverError) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle title={intl.formatMessage(messages.discover)} />
|
<PageTitle title={intl.formatMessage(messages.discover)} />
|
||||||
{(!media || !!media.results.length) &&
|
{hasPermission(Permission.ADMIN) && (
|
||||||
!mediaError &&
|
|
||||||
hasPermission([Permission.MANAGE_REQUESTS, Permission.RECENT_VIEW], {
|
|
||||||
type: 'or',
|
|
||||||
}) && (
|
|
||||||
<>
|
<>
|
||||||
<div className="slider-header">
|
{isEditing && (
|
||||||
<div className="slider-title">
|
<div className="my-6 rounded-lg bg-gray-800">
|
||||||
<span>{intl.formatMessage(messages.recentlyAdded)}</span>
|
<div className="flex items-center space-x-2 rounded-t-lg border-t border-l border-r border-gray-800 bg-gray-900 p-4 text-lg font-semibold text-gray-400">
|
||||||
|
<PlusIcon className="w-6" />
|
||||||
|
<span data-testid="create-slider-header">
|
||||||
|
{intl.formatMessage(messages.createnewslider)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<CreateSlider
|
||||||
|
onCreate={async () => {
|
||||||
|
const newSliders = await mutate();
|
||||||
|
|
||||||
|
if (newSliders) {
|
||||||
|
setSliders(newSliders);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
|
||||||
sliderKey="media"
|
|
||||||
isLoading={!media}
|
|
||||||
items={(media?.results ?? []).map((item) => (
|
|
||||||
<TmdbTitleCard
|
|
||||||
key={`media-slider-item-${item.id}`}
|
|
||||||
id={item.id}
|
|
||||||
tmdbId={item.tmdbId}
|
|
||||||
tvdbId={item.tvdbId}
|
|
||||||
type={item.mediaType}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{(!requests || !!requests.results.length) && !requestError && (
|
<Transition
|
||||||
<>
|
show={!isEditing}
|
||||||
<div className="slider-header">
|
enter="transition-opacity duration-300"
|
||||||
<Link href="/requests?filter=all">
|
enterFrom="opacity-0"
|
||||||
<a className="slider-title">
|
enterTo="opacity-100"
|
||||||
<span>{intl.formatMessage(messages.recentrequests)}</span>
|
leave="transition-opacity duration-300"
|
||||||
<ArrowCircleRightIcon />
|
leaveFrom="opacity-100"
|
||||||
</a>
|
leaveTo="opacity-0"
|
||||||
</Link>
|
className="absolute-bottom-shift fixed right-6 z-50 flex items-center sm:bottom-8"
|
||||||
</div>
|
|
||||||
<Slider
|
|
||||||
sliderKey="requests"
|
|
||||||
isLoading={!requests}
|
|
||||||
items={(requests?.results ?? []).map((request) => (
|
|
||||||
<RequestCard
|
|
||||||
key={`request-slider-item-${request.id}`}
|
|
||||||
request={request}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
placeholder={<RequestCard.Placeholder />}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{user?.userType === UserType.PLEX &&
|
|
||||||
(!watchlistItems ||
|
|
||||||
!!watchlistItems.results.length ||
|
|
||||||
user.settings?.watchlistSyncMovies ||
|
|
||||||
user.settings?.watchlistSyncTv) &&
|
|
||||||
!watchlistError && (
|
|
||||||
<>
|
|
||||||
<div className="slider-header">
|
|
||||||
<Link href="/discover/watchlist">
|
|
||||||
<a className="slider-title">
|
|
||||||
<span>{intl.formatMessage(messages.plexwatchlist)}</span>
|
|
||||||
<ArrowCircleRightIcon />
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<Slider
|
|
||||||
sliderKey="watchlist"
|
|
||||||
isLoading={!watchlistItems}
|
|
||||||
isEmpty={!!watchlistItems && watchlistItems.results.length === 0}
|
|
||||||
emptyMessage={intl.formatMessage(messages.emptywatchlist, {
|
|
||||||
PlexWatchlistSupportLink: (msg: React.ReactNode) => (
|
|
||||||
<a
|
|
||||||
href="https://support.plex.tv/articles/universal-watchlist/"
|
|
||||||
className="text-white transition duration-300 hover:underline"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
>
|
||||||
{msg}
|
<button
|
||||||
</a>
|
onClick={() => setIsEditing(true)}
|
||||||
),
|
data-testid="discover-start-editing"
|
||||||
})}
|
className="h-12 w-12 rounded-full border-2 border-gray-600 bg-gray-700 bg-opacity-90 p-3 text-gray-400 shadow transition-all hover:bg-opacity-100"
|
||||||
items={watchlistItems?.results.map((item) => (
|
>
|
||||||
<TmdbTitleCard
|
<PencilIcon className="h-full w-full" />
|
||||||
id={item.tmdbId}
|
</button>
|
||||||
key={`watchlist-slider-item-${item.ratingKey}`}
|
</Transition>
|
||||||
tmdbId={item.tmdbId}
|
<Transition
|
||||||
type={item.mediaType}
|
show={isEditing}
|
||||||
/>
|
enter="transition transform duration-300"
|
||||||
))}
|
enterFrom="opacity-0 translate-y-6"
|
||||||
/>
|
enterTo="opacity-100 translate-y-0"
|
||||||
|
leave="transition duration-300 transform"
|
||||||
|
leaveFrom="opacity-100 translate-y-0"
|
||||||
|
leaveTo="opacity-0 translate-y-6"
|
||||||
|
className="safe-shift-edit-menu fixed right-0 left-0 z-50 flex flex-col items-center justify-end space-x-0 space-y-2 border-t border-gray-700 bg-gray-800 bg-opacity-80 p-4 backdrop-blur sm:bottom-0 sm:flex-row sm:space-y-0 sm:space-x-3"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
buttonType="default"
|
||||||
|
onClick={() => setIsEditing(false)}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<ArrowUturnLeftIcon />
|
||||||
|
<span>{intl.formatMessage(messages.stopediting)}</span>
|
||||||
|
</Button>
|
||||||
|
<Tooltip content={intl.formatMessage(messages.resetwarning)}>
|
||||||
|
<ConfirmButton
|
||||||
|
onClick={() => resetSliders()}
|
||||||
|
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<ArrowPathIcon />
|
||||||
|
<span>{intl.formatMessage(messages.resettodefault)}</span>
|
||||||
|
</ConfirmButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Button
|
||||||
|
buttonType="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={!hasChanged()}
|
||||||
|
onClick={() => updateSliders()}
|
||||||
|
data-testid="discover-customize-submit"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<ArrowDownOnSquareIcon />
|
||||||
|
<span>{intl.formatMessage(globalMessages.save)}</span>
|
||||||
|
</Button>
|
||||||
|
</Transition>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{(isEditing ? sliders : discoverData)?.map((slider, index) => {
|
||||||
|
let sliderComponent: React.ReactNode;
|
||||||
|
|
||||||
|
switch (slider.type) {
|
||||||
|
case DiscoverSliderType.RECENTLY_ADDED:
|
||||||
|
sliderComponent = <RecentlyAddedSlider />;
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.RECENT_REQUESTS:
|
||||||
|
sliderComponent = <RecentRequestsSlider />;
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.PLEX_WATCHLIST:
|
||||||
|
sliderComponent = <PlexWatchlistSlider />;
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.TRENDING:
|
||||||
|
sliderComponent = (
|
||||||
<MediaSlider
|
<MediaSlider
|
||||||
sliderKey="trending"
|
sliderKey="trending"
|
||||||
title={intl.formatMessage(messages.trending)}
|
title={intl.formatMessage(sliderTitles.trending)}
|
||||||
url="/api/v1/discover/trending"
|
url="/api/v1/discover/trending"
|
||||||
linkUrl="/discover/trending"
|
linkUrl="/discover/trending"
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.POPULAR_MOVIES:
|
||||||
|
sliderComponent = (
|
||||||
<MediaSlider
|
<MediaSlider
|
||||||
sliderKey="popular-movies"
|
sliderKey="popular-movies"
|
||||||
title={intl.formatMessage(messages.popularmovies)}
|
title={intl.formatMessage(sliderTitles.popularmovies)}
|
||||||
url="/api/v1/discover/movies"
|
url="/api/v1/discover/movies"
|
||||||
linkUrl="/discover/movies"
|
linkUrl="/discover/movies"
|
||||||
/>
|
/>
|
||||||
<MovieGenreSlider />
|
);
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.MOVIE_GENRES:
|
||||||
|
sliderComponent = <MovieGenreSlider />;
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.UPCOMING_MOVIES:
|
||||||
|
sliderComponent = (
|
||||||
<MediaSlider
|
<MediaSlider
|
||||||
sliderKey="upcoming"
|
sliderKey="upcoming"
|
||||||
title={intl.formatMessage(messages.upcoming)}
|
title={intl.formatMessage(sliderTitles.upcoming)}
|
||||||
linkUrl="/discover/movies/upcoming"
|
linkUrl={`/discover/movies?primaryReleaseDateGte=${upcomingDate}`}
|
||||||
url="/api/v1/discover/movies/upcoming"
|
url="/api/v1/discover/movies"
|
||||||
|
extraParams={`primaryReleaseDateGte=${upcomingDate}`}
|
||||||
/>
|
/>
|
||||||
<StudioSlider />
|
);
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.STUDIOS:
|
||||||
|
sliderComponent = <StudioSlider />;
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.POPULAR_TV:
|
||||||
|
sliderComponent = (
|
||||||
<MediaSlider
|
<MediaSlider
|
||||||
sliderKey="popular-tv"
|
sliderKey="popular-tv"
|
||||||
title={intl.formatMessage(messages.populartv)}
|
title={intl.formatMessage(sliderTitles.populartv)}
|
||||||
url="/api/v1/discover/tv"
|
url="/api/v1/discover/tv"
|
||||||
linkUrl="/discover/tv"
|
linkUrl="/discover/tv"
|
||||||
/>
|
/>
|
||||||
<TvGenreSlider />
|
);
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.TV_GENRES:
|
||||||
|
sliderComponent = <TvGenreSlider />;
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.UPCOMING_TV:
|
||||||
|
sliderComponent = (
|
||||||
<MediaSlider
|
<MediaSlider
|
||||||
sliderKey="upcoming-tv"
|
sliderKey="upcoming-tv"
|
||||||
title={intl.formatMessage(messages.upcomingtv)}
|
title={intl.formatMessage(sliderTitles.upcomingtv)}
|
||||||
url="/api/v1/discover/tv/upcoming"
|
linkUrl={`/discover/tv?firstAirDateGte=${upcomingDate}`}
|
||||||
linkUrl="/discover/tv/upcoming"
|
url="/api/v1/discover/tv"
|
||||||
|
extraParams={`firstAirDateGte=${upcomingDate}`}
|
||||||
/>
|
/>
|
||||||
<NetworkSlider />
|
);
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.NETWORKS:
|
||||||
|
sliderComponent = <NetworkSlider />;
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
|
||||||
|
sliderComponent = (
|
||||||
|
<MediaSlider
|
||||||
|
sliderKey={`custom-slider-${slider.id}`}
|
||||||
|
title={slider.title ?? ''}
|
||||||
|
url="/api/v1/discover/movies"
|
||||||
|
extraParams={
|
||||||
|
slider.data
|
||||||
|
? `keywords=${encodeURIExtraParams(slider.data)}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
linkUrl={`/discover/movies?keywords=${slider.data}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.TMDB_TV_KEYWORD:
|
||||||
|
sliderComponent = (
|
||||||
|
<MediaSlider
|
||||||
|
sliderKey={`custom-slider-${slider.id}`}
|
||||||
|
title={slider.title ?? ''}
|
||||||
|
url="/api/v1/discover/tv"
|
||||||
|
extraParams={
|
||||||
|
slider.data
|
||||||
|
? `keywords=${encodeURIExtraParams(slider.data)}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
linkUrl={`/discover/tv?keywords=${slider.data}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.TMDB_MOVIE_GENRE:
|
||||||
|
sliderComponent = (
|
||||||
|
<MediaSlider
|
||||||
|
sliderKey={`custom-slider-${slider.id}`}
|
||||||
|
title={slider.title ?? ''}
|
||||||
|
url={`/api/v1/discover/movies`}
|
||||||
|
extraParams={`genre=${slider.data}`}
|
||||||
|
linkUrl={`/discover/movies?genre=${slider.data}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.TMDB_TV_GENRE:
|
||||||
|
sliderComponent = (
|
||||||
|
<MediaSlider
|
||||||
|
sliderKey={`custom-slider-${slider.id}`}
|
||||||
|
title={slider.title ?? ''}
|
||||||
|
url={`/api/v1/discover/tv`}
|
||||||
|
extraParams={`genre=${slider.data}`}
|
||||||
|
linkUrl={`/discover/tv?genre=${slider.data}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.TMDB_STUDIO:
|
||||||
|
sliderComponent = (
|
||||||
|
<MediaSlider
|
||||||
|
sliderKey={`custom-slider-${slider.id}`}
|
||||||
|
title={slider.title ?? ''}
|
||||||
|
url={`/api/v1/discover/movies/studio/${slider.data}`}
|
||||||
|
linkUrl={`/discover/movies/studio/${slider.data}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.TMDB_NETWORK:
|
||||||
|
sliderComponent = (
|
||||||
|
<MediaSlider
|
||||||
|
sliderKey={`custom-slider-${slider.id}`}
|
||||||
|
title={slider.title ?? ''}
|
||||||
|
url={`/api/v1/discover/tv/network/${slider.data}`}
|
||||||
|
linkUrl={`/discover/tv/network/${slider.data}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.TMDB_SEARCH:
|
||||||
|
sliderComponent = (
|
||||||
|
<MediaSlider
|
||||||
|
sliderKey={`custom-slider-${slider.id}`}
|
||||||
|
title={slider.title ?? ''}
|
||||||
|
url="/api/v1/search"
|
||||||
|
extraParams={`query=${slider.data}`}
|
||||||
|
linkUrl={`/search?query=${slider.data}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<DiscoverSliderEdit
|
||||||
|
key={`discover-slider-${slider.id}-edit`}
|
||||||
|
slider={slider}
|
||||||
|
onDelete={async () => {
|
||||||
|
const newSliders = await mutate();
|
||||||
|
|
||||||
|
if (newSliders) {
|
||||||
|
setSliders(newSliders);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEnable={() => {
|
||||||
|
const tempSliders = sliders.slice();
|
||||||
|
tempSliders[index].enabled = !tempSliders[index].enabled;
|
||||||
|
setSliders(tempSliders);
|
||||||
|
}}
|
||||||
|
onPositionUpdate={(updatedItemId, position, hasClickedArrows) => {
|
||||||
|
const originalPosition = sliders.findIndex(
|
||||||
|
(item) => item.id === updatedItemId
|
||||||
|
);
|
||||||
|
const originalItem = sliders[originalPosition];
|
||||||
|
|
||||||
|
const tempSliders = sliders.slice();
|
||||||
|
|
||||||
|
tempSliders.splice(originalPosition, 1);
|
||||||
|
hasClickedArrows
|
||||||
|
? tempSliders.splice(
|
||||||
|
position === 'Above' ? index - 1 : index + 1,
|
||||||
|
0,
|
||||||
|
originalItem
|
||||||
|
)
|
||||||
|
: tempSliders.splice(
|
||||||
|
position === 'Above' && index > originalPosition
|
||||||
|
? Math.max(index - 1, 0)
|
||||||
|
: index,
|
||||||
|
0,
|
||||||
|
originalItem
|
||||||
|
);
|
||||||
|
|
||||||
|
setSliders(tempSliders);
|
||||||
|
}}
|
||||||
|
disableUpButton={index === 0}
|
||||||
|
disableDownButton={index === sliders.length - 1}
|
||||||
|
>
|
||||||
|
{sliderComponent}
|
||||||
|
</DiscoverSliderEdit>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!slider.enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`discover-slider-${slider.id}`}>{sliderComponent}</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
28
src/components/GenreTag/index.tsx
Normal file
28
src/components/GenreTag/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import Spinner from '@app/assets/spinner.svg';
|
||||||
|
import Tag from '@app/components/Common/Tag';
|
||||||
|
import { RectangleStackIcon } from '@heroicons/react/24/outline';
|
||||||
|
import type { TmdbGenre } from '@server/api/themoviedb/interfaces';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
type GenreTagProps = {
|
||||||
|
type: 'tv' | 'movie';
|
||||||
|
genreId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GenreTag = ({ genreId, type }: GenreTagProps) => {
|
||||||
|
const { data, error } = useSWR<TmdbGenre[]>(`/api/v1/genres/${type}`);
|
||||||
|
|
||||||
|
if (!data && !error) {
|
||||||
|
return (
|
||||||
|
<Tag>
|
||||||
|
<Spinner className="h-4 w-4" />
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const genre = data?.find((genre) => genre.id === genreId);
|
||||||
|
|
||||||
|
return <Tag iconSvg={<RectangleStackIcon />}>{genre?.name}</Tag>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GenreTag;
|
||||||
@@ -3,10 +3,10 @@ import { issueOptions } from '@app/components/IssueModal/constants';
|
|||||||
import { useUser } from '@app/hooks/useUser';
|
import { useUser } from '@app/hooks/useUser';
|
||||||
import {
|
import {
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
ExclamationIcon,
|
ExclamationTriangleIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
} from '@heroicons/react/solid';
|
} from '@heroicons/react/24/solid';
|
||||||
import type Issue from '@server/entity/Issue';
|
import type Issue from '@server/entity/Issue';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
@@ -31,7 +31,7 @@ const IssueBlock = ({ issue }: IssueBlockProps) => {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="mr-6 min-w-0 flex-1 flex-col items-center text-sm leading-5">
|
<div className="mr-6 min-w-0 flex-1 flex-col items-center text-sm leading-5">
|
||||||
<div className="flex flex-nowrap">
|
<div className="flex flex-nowrap">
|
||||||
<ExclamationIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
|
<ExclamationTriangleIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
|
||||||
<span className="w-40 truncate md:w-auto">
|
<span className="w-40 truncate md:w-auto">
|
||||||
{intl.formatMessage(issueOption.name)}
|
{intl.formatMessage(issueOption.name)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Button from '@app/components/Common/Button';
|
|||||||
import Modal from '@app/components/Common/Modal';
|
import Modal from '@app/components/Common/Modal';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import { Menu, Transition } from '@headlessui/react';
|
import { Menu, Transition } from '@headlessui/react';
|
||||||
import { DotsVerticalIcon } from '@heroicons/react/solid';
|
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
|
||||||
import type { default as IssueCommentType } from '@server/entity/IssueComment';
|
import type { default as IssueCommentType } from '@server/entity/IssueComment';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
@@ -104,7 +104,7 @@ const IssueComment = ({
|
|||||||
<div>
|
<div>
|
||||||
<Menu.Button className="flex items-center rounded-full text-gray-400 hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-100">
|
<Menu.Button className="flex items-center rounded-full text-gray-400 hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-100">
|
||||||
<span className="sr-only">Open options</span>
|
<span className="sr-only">Open options</span>
|
||||||
<DotsVerticalIcon
|
<EllipsisVerticalIcon
|
||||||
className="h-5 w-5"
|
className="h-5 w-5"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Button from '@app/components/Common/Button';
|
|||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import { Menu, Transition } from '@headlessui/react';
|
import { Menu, Transition } from '@headlessui/react';
|
||||||
import { DotsVerticalIcon } from '@heroicons/react/solid';
|
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
@@ -46,7 +46,10 @@ const IssueDescription = ({
|
|||||||
<div>
|
<div>
|
||||||
<Menu.Button className="flex items-center rounded-full text-gray-400 hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-100">
|
<Menu.Button className="flex items-center rounded-full text-gray-400 hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-100">
|
||||||
<span className="sr-only">Open options</span>
|
<span className="sr-only">Open options</span>
|
||||||
<DotsVerticalIcon className="h-5 w-5" aria-hidden="true" />
|
<EllipsisVerticalIcon
|
||||||
|
className="h-5 w-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ import globalMessages from '@app/i18n/globalMessages';
|
|||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import {
|
import {
|
||||||
ChatIcon,
|
ChatBubbleOvalLeftEllipsisIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
} from '@heroicons/react/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { RefreshIcon } from '@heroicons/react/solid';
|
import { ArrowPathIcon } from '@heroicons/react/24/solid';
|
||||||
import { IssueStatus } from '@server/constants/issue';
|
import { IssueStatus } from '@server/constants/issue';
|
||||||
import { MediaType } from '@server/constants/media';
|
import { MediaType } from '@server/constants/media';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
@@ -390,7 +390,8 @@ const IssueDetails = () => {
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{issueData?.media.serviceUrl && hasPermission(Permission.ADMIN) && (
|
{issueData?.media.serviceUrl &&
|
||||||
|
hasPermission(Permission.ADMIN) && (
|
||||||
<Button
|
<Button
|
||||||
as="a"
|
as="a"
|
||||||
href={issueData?.media.serviceUrl}
|
href={issueData?.media.serviceUrl}
|
||||||
@@ -505,7 +506,8 @@ const IssueDetails = () => {
|
|||||||
className="h-20"
|
className="h-20"
|
||||||
/>
|
/>
|
||||||
<div className="mt-4 flex items-center justify-end space-x-2">
|
<div className="mt-4 flex items-center justify-end space-x-2">
|
||||||
{hasPermission(Permission.MANAGE_ISSUES) && (
|
{(hasPermission(Permission.MANAGE_ISSUES) ||
|
||||||
|
belongsToUser) && (
|
||||||
<>
|
<>
|
||||||
{issueData.status === IssueStatus.OPEN ? (
|
{issueData.status === IssueStatus.OPEN ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -540,7 +542,7 @@ const IssueDetails = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RefreshIcon />
|
<ArrowPathIcon />
|
||||||
<span>
|
<span>
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
values.message
|
values.message
|
||||||
@@ -559,7 +561,7 @@ const IssueDetails = () => {
|
|||||||
!isValid || isSubmitting || !values.message
|
!isValid || isSubmitting || !values.message
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ChatIcon />
|
<ChatBubbleOvalLeftEllipsisIcon />
|
||||||
<span>
|
<span>
|
||||||
{intl.formatMessage(messages.leavecomment)}
|
{intl.formatMessage(messages.leavecomment)}
|
||||||
</span>
|
</span>
|
||||||
@@ -698,7 +700,8 @@ const IssueDetails = () => {
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{issueData?.media.serviceUrl4k && hasPermission(Permission.ADMIN) && (
|
{issueData?.media.serviceUrl4k &&
|
||||||
|
hasPermission(Permission.ADMIN) && (
|
||||||
<Button
|
<Button
|
||||||
as="a"
|
as="a"
|
||||||
href={issueData?.media.serviceUrl4k}
|
href={issueData?.media.serviceUrl4k}
|
||||||
@@ -721,6 +724,7 @@ const IssueDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="extra-bottom-space" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import CachedImage from '@app/components/Common/CachedImage';
|
|||||||
import { issueOptions } from '@app/components/IssueModal/constants';
|
import { issueOptions } from '@app/components/IssueModal/constants';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import { EyeIcon } from '@heroicons/react/solid';
|
import { EyeIcon } from '@heroicons/react/24/solid';
|
||||||
import { IssueStatus } from '@server/constants/issue';
|
import { IssueStatus } from '@server/constants/issue';
|
||||||
import { MediaType } from '@server/constants/media';
|
import { MediaType } from '@server/constants/media';
|
||||||
import type Issue from '@server/entity/Issue';
|
import type Issue from '@server/entity/Issue';
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import IssueItem from '@app/components/IssueList/IssueItem';
|
|||||||
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
|
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import {
|
import {
|
||||||
|
BarsArrowDownIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
FilterIcon,
|
FunnelIcon,
|
||||||
SortDescendingIcon,
|
} from '@heroicons/react/24/solid';
|
||||||
} from '@heroicons/react/solid';
|
|
||||||
import type { IssueResultsResponse } from '@server/interfaces/api/issueInterfaces';
|
import type { IssueResultsResponse } from '@server/interfaces/api/issueInterfaces';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
@@ -98,7 +98,7 @@ const IssueList = () => {
|
|||||||
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
|
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
|
||||||
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
||||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
|
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
|
||||||
<FilterIcon className="h-6 w-6" />
|
<FunnelIcon className="h-6 w-6" />
|
||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
id="filter"
|
id="filter"
|
||||||
@@ -128,7 +128,7 @@ const IssueList = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
|
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
|
||||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
||||||
<SortDescendingIcon className="h-6 w-6" />
|
<BarsArrowDownIcon className="h-6 w-6" />
|
||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
id="sort"
|
id="sort"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import useSettings from '@app/hooks/useSettings';
|
|||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import { RadioGroup } from '@headlessui/react';
|
import { RadioGroup } from '@headlessui/react';
|
||||||
import { ArrowCircleRightIcon } from '@heroicons/react/solid';
|
import { ArrowRightCircleIcon } from '@heroicons/react/24/solid';
|
||||||
import { MediaStatus } from '@server/constants/media';
|
import { MediaStatus } from '@server/constants/media';
|
||||||
import type Issue from '@server/entity/Issue';
|
import type Issue from '@server/entity/Issue';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
@@ -121,7 +121,7 @@ const CreateIssueModal = ({
|
|||||||
<Link href={`/issues/${newIssue.data.id}`}>
|
<Link href={`/issues/${newIssue.data.id}`}>
|
||||||
<Button as="a" className="mt-4">
|
<Button as="a" className="mt-4">
|
||||||
<span>{intl.formatMessage(messages.toastviewissue)}</span>
|
<span>{intl.formatMessage(messages.toastviewissue)}</span>
|
||||||
<ArrowCircleRightIcon />
|
<ArrowRightCircleIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</>,
|
</>,
|
||||||
|
|||||||
24
src/components/KeywordTag/index.tsx
Normal file
24
src/components/KeywordTag/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import Spinner from '@app/assets/spinner.svg';
|
||||||
|
import Tag from '@app/components/Common/Tag';
|
||||||
|
import type { Keyword } from '@server/models/common';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
type KeywordTagProps = {
|
||||||
|
keywordId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const KeywordTag = ({ keywordId }: KeywordTagProps) => {
|
||||||
|
const { data, error } = useSWR<Keyword>(`/api/v1/keyword/${keywordId}`);
|
||||||
|
|
||||||
|
if (!data && !error) {
|
||||||
|
return (
|
||||||
|
<Tag>
|
||||||
|
<Spinner className="h-4 w-4" />
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Tag>{data?.name}</Tag>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default KeywordTag;
|
||||||
@@ -3,7 +3,7 @@ import { availableLanguages } from '@app/context/LanguageContext';
|
|||||||
import useClickOutside from '@app/hooks/useClickOutside';
|
import useClickOutside from '@app/hooks/useClickOutside';
|
||||||
import useLocale from '@app/hooks/useLocale';
|
import useLocale from '@app/hooks/useLocale';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import { TranslateIcon } from '@heroicons/react/solid';
|
import { LanguageIcon } from '@heroicons/react/24/solid';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ const LanguagePicker = () => {
|
|||||||
aria-label="Language Picker"
|
aria-label="Language Picker"
|
||||||
onClick={() => setDropdownOpen(true)}
|
onClick={() => setDropdownOpen(true)}
|
||||||
>
|
>
|
||||||
<TranslateIcon className="h-6 w-6" />
|
<LanguageIcon className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Transition
|
<Transition
|
||||||
|
|||||||
212
src/components/Layout/MobileMenu/index.tsx
Normal file
212
src/components/Layout/MobileMenu/index.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { menuMessages } from '@app/components/Layout/Sidebar';
|
||||||
|
import useClickOutside from '@app/hooks/useClickOutside';
|
||||||
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
|
import { Transition } from '@headlessui/react';
|
||||||
|
import {
|
||||||
|
ClockIcon,
|
||||||
|
CogIcon,
|
||||||
|
EllipsisHorizontalIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
FilmIcon,
|
||||||
|
SparklesIcon,
|
||||||
|
TvIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import {
|
||||||
|
ClockIcon as FilledClockIcon,
|
||||||
|
CogIcon as FilledCogIcon,
|
||||||
|
ExclamationTriangleIcon as FilledExclamationTriangleIcon,
|
||||||
|
FilmIcon as FilledFilmIcon,
|
||||||
|
SparklesIcon as FilledSparklesIcon,
|
||||||
|
TvIcon as FilledTvIcon,
|
||||||
|
UsersIcon as FilledUsersIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
} from '@heroicons/react/24/solid';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { cloneElement, useRef, useState } from 'react';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
interface MenuLink {
|
||||||
|
href: string;
|
||||||
|
svgIcon: JSX.Element;
|
||||||
|
svgIconSelected: JSX.Element;
|
||||||
|
content: React.ReactNode;
|
||||||
|
activeRegExp: RegExp;
|
||||||
|
as?: string;
|
||||||
|
requiredPermission?: Permission | Permission[];
|
||||||
|
permissionType?: 'and' | 'or';
|
||||||
|
dataTestId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MobileMenu = () => {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const intl = useIntl();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const { hasPermission } = useUser();
|
||||||
|
const router = useRouter();
|
||||||
|
useClickOutside(ref, () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggle = () => setIsOpen(!isOpen);
|
||||||
|
|
||||||
|
const menuLinks: MenuLink[] = [
|
||||||
|
{
|
||||||
|
href: '/',
|
||||||
|
content: intl.formatMessage(menuMessages.dashboard),
|
||||||
|
svgIcon: <SparklesIcon className="h-6 w-6" />,
|
||||||
|
svgIconSelected: <FilledSparklesIcon className="h-6 w-6" />,
|
||||||
|
activeRegExp: /^\/(discover\/?)?$/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/discover/movies',
|
||||||
|
content: intl.formatMessage(menuMessages.browsemovies),
|
||||||
|
svgIcon: <FilmIcon className="h-6 w-6" />,
|
||||||
|
svgIconSelected: <FilledFilmIcon className="h-6 w-6" />,
|
||||||
|
activeRegExp: /^\/discover\/movies$/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/discover/tv',
|
||||||
|
content: intl.formatMessage(menuMessages.browsetv),
|
||||||
|
svgIcon: <TvIcon className="h-6 w-6" />,
|
||||||
|
svgIconSelected: <FilledTvIcon className="h-6 w-6" />,
|
||||||
|
activeRegExp: /^\/discover\/tv$/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/requests',
|
||||||
|
content: intl.formatMessage(menuMessages.requests),
|
||||||
|
svgIcon: <ClockIcon className="h-6 w-6" />,
|
||||||
|
svgIconSelected: <FilledClockIcon className="h-6 w-6" />,
|
||||||
|
activeRegExp: /^\/requests/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/issues',
|
||||||
|
content: intl.formatMessage(menuMessages.issues),
|
||||||
|
svgIcon: <ExclamationTriangleIcon className="h-6 w-6" />,
|
||||||
|
svgIconSelected: <FilledExclamationTriangleIcon className="h-6 w-6" />,
|
||||||
|
activeRegExp: /^\/issues/,
|
||||||
|
requiredPermission: [
|
||||||
|
Permission.MANAGE_ISSUES,
|
||||||
|
Permission.CREATE_ISSUES,
|
||||||
|
Permission.VIEW_ISSUES,
|
||||||
|
],
|
||||||
|
permissionType: 'or',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/users',
|
||||||
|
content: intl.formatMessage(menuMessages.users),
|
||||||
|
svgIcon: <UsersIcon className="mr-3 h-6 w-6" />,
|
||||||
|
svgIconSelected: <FilledUsersIcon className="mr-3 h-6 w-6" />,
|
||||||
|
activeRegExp: /^\/users/,
|
||||||
|
requiredPermission: Permission.MANAGE_USERS,
|
||||||
|
dataTestId: 'sidebar-menu-users',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/settings',
|
||||||
|
content: intl.formatMessage(menuMessages.settings),
|
||||||
|
svgIcon: <CogIcon className="mr-3 h-6 w-6" />,
|
||||||
|
svgIconSelected: <FilledCogIcon className="mr-3 h-6 w-6" />,
|
||||||
|
activeRegExp: /^\/settings/,
|
||||||
|
requiredPermission: Permission.ADMIN,
|
||||||
|
dataTestId: 'sidebar-menu-settings',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const filteredLinks = menuLinks.filter(
|
||||||
|
(link) =>
|
||||||
|
!link.requiredPermission ||
|
||||||
|
hasPermission(link.requiredPermission, {
|
||||||
|
type: link.permissionType ?? 'and',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 z-50">
|
||||||
|
<Transition
|
||||||
|
show={isOpen}
|
||||||
|
as="div"
|
||||||
|
ref={ref}
|
||||||
|
enter="transition transform duration-500"
|
||||||
|
enterFrom="opacity-0 translate-y-0"
|
||||||
|
enterTo="opacity-100 -translate-y-full"
|
||||||
|
leave="transition duration-500 transform"
|
||||||
|
leaveFrom="opacity-100 -translate-y-full"
|
||||||
|
leaveTo="opacity-0 translate-y-0"
|
||||||
|
className="absolute top-0 left-0 right-0 flex w-full -translate-y-full transform flex-col space-y-6 border-t border-gray-600 bg-gray-900 bg-opacity-90 px-6 py-6 font-semibold text-gray-100 backdrop-blur"
|
||||||
|
>
|
||||||
|
{filteredLinks.map((link) => {
|
||||||
|
const isActive = router.pathname.match(link.activeRegExp);
|
||||||
|
return (
|
||||||
|
<Link key={`mobile-menu-link-${link.href}`} href={link.href}>
|
||||||
|
<a
|
||||||
|
className={`flex items-center space-x-2 ${
|
||||||
|
isActive ? 'text-indigo-500' : ''
|
||||||
|
}`}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{cloneElement(isActive ? link.svgIconSelected : link.svgIcon, {
|
||||||
|
className: 'h-5 w-5',
|
||||||
|
})}
|
||||||
|
<span>{link.content}</span>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Transition>
|
||||||
|
<div className="padding-bottom-safe border-t border-gray-600 bg-gray-800 bg-opacity-90 backdrop-blur">
|
||||||
|
<div className="flex h-full items-center justify-between px-6 py-4 text-gray-100">
|
||||||
|
{filteredLinks
|
||||||
|
.slice(0, filteredLinks.length === 5 ? 5 : 4)
|
||||||
|
.map((link) => {
|
||||||
|
const isActive =
|
||||||
|
router.pathname.match(link.activeRegExp) && !isOpen;
|
||||||
|
return (
|
||||||
|
<Link key={`mobile-menu-link-${link.href}`} href={link.href}>
|
||||||
|
<a
|
||||||
|
className={`flex flex-col items-center space-y-1 ${
|
||||||
|
isActive ? 'text-indigo-500' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cloneElement(
|
||||||
|
isActive ? link.svgIconSelected : link.svgIcon,
|
||||||
|
{
|
||||||
|
className: 'h-6 w-6',
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filteredLinks.length > 4 && filteredLinks.length !== 5 && (
|
||||||
|
<button
|
||||||
|
className={`flex flex-col items-center space-y-1 ${
|
||||||
|
isOpen ? 'text-indigo-500' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => toggle()}
|
||||||
|
>
|
||||||
|
{isOpen ? (
|
||||||
|
<XMarkIcon className="h-6 w-6" />
|
||||||
|
) : (
|
||||||
|
<EllipsisHorizontalIcon className="h-6 w-6" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MobileMenu;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BellIcon } from '@heroicons/react/outline';
|
import { BellIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
const Notifications = () => {
|
const Notifications = () => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import useSearchInput from '@app/hooks/useSearchInput';
|
import useSearchInput from '@app/hooks/useSearchInput';
|
||||||
import { XCircleIcon } from '@heroicons/react/outline';
|
import { XCircleIcon } from '@heroicons/react/24/outline';
|
||||||
import { SearchIcon } from '@heroicons/react/solid';
|
import { MagnifyingGlassIcon } from '@heroicons/react/24/solid';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
@@ -18,7 +18,7 @@ const SearchInput = () => {
|
|||||||
</label>
|
</label>
|
||||||
<div className="relative flex w-full items-center text-white focus-within:text-gray-200">
|
<div className="relative flex w-full items-center text-white focus-within:text-gray-200">
|
||||||
<div className="pointer-events-none absolute inset-y-0 left-4 flex items-center">
|
<div className="pointer-events-none absolute inset-y-0 left-4 flex items-center">
|
||||||
<SearchIcon className="h-5 w-5" />
|
<MagnifyingGlassIcon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
id="search_field"
|
id="search_field"
|
||||||
|
|||||||
@@ -6,18 +6,22 @@ import { Transition } from '@headlessui/react';
|
|||||||
import {
|
import {
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
CogIcon,
|
CogIcon,
|
||||||
ExclamationIcon,
|
ExclamationTriangleIcon,
|
||||||
|
FilmIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
|
TvIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
XIcon,
|
XMarkIcon,
|
||||||
} from '@heroicons/react/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { Fragment, useRef } from 'react';
|
import { Fragment, useRef } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
export const menuMessages = defineMessages({
|
||||||
dashboard: 'Discover',
|
dashboard: 'Discover',
|
||||||
|
browsemovies: 'Movies',
|
||||||
|
browsetv: 'Series',
|
||||||
requests: 'Requests',
|
requests: 'Requests',
|
||||||
issues: 'Issues',
|
issues: 'Issues',
|
||||||
users: 'Users',
|
users: 'Users',
|
||||||
@@ -32,7 +36,7 @@ interface SidebarProps {
|
|||||||
interface SidebarLinkProps {
|
interface SidebarLinkProps {
|
||||||
href: string;
|
href: string;
|
||||||
svgIcon: React.ReactNode;
|
svgIcon: React.ReactNode;
|
||||||
messagesKey: keyof typeof messages;
|
messagesKey: keyof typeof menuMessages;
|
||||||
activeRegExp: RegExp;
|
activeRegExp: RegExp;
|
||||||
as?: string;
|
as?: string;
|
||||||
requiredPermission?: Permission | Permission[];
|
requiredPermission?: Permission | Permission[];
|
||||||
@@ -45,7 +49,19 @@ const SidebarLinks: SidebarLinkProps[] = [
|
|||||||
href: '/',
|
href: '/',
|
||||||
messagesKey: 'dashboard',
|
messagesKey: 'dashboard',
|
||||||
svgIcon: <SparklesIcon className="mr-3 h-6 w-6" />,
|
svgIcon: <SparklesIcon className="mr-3 h-6 w-6" />,
|
||||||
activeRegExp: /^\/(discover\/?(movies|tv)?)?$/,
|
activeRegExp: /^\/(discover\/?)?$/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/discover/movies',
|
||||||
|
messagesKey: 'browsemovies',
|
||||||
|
svgIcon: <FilmIcon className="mr-3 h-6 w-6" />,
|
||||||
|
activeRegExp: /^\/discover\/movies$/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/discover/tv',
|
||||||
|
messagesKey: 'browsetv',
|
||||||
|
svgIcon: <TvIcon className="mr-3 h-6 w-6" />,
|
||||||
|
activeRegExp: /^\/discover\/tv$/,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/requests',
|
href: '/requests',
|
||||||
@@ -57,7 +73,7 @@ const SidebarLinks: SidebarLinkProps[] = [
|
|||||||
href: '/issues',
|
href: '/issues',
|
||||||
messagesKey: 'issues',
|
messagesKey: 'issues',
|
||||||
svgIcon: (
|
svgIcon: (
|
||||||
<ExclamationIcon className="mr-3 h-6 w-6 text-gray-300 transition duration-150 ease-in-out group-hover:text-gray-100 group-focus:text-gray-300" />
|
<ExclamationTriangleIcon className="mr-3 h-6 w-6 text-gray-300 transition duration-150 ease-in-out group-hover:text-gray-100 group-focus:text-gray-300" />
|
||||||
),
|
),
|
||||||
activeRegExp: /^\/issues/,
|
activeRegExp: /^\/issues/,
|
||||||
requiredPermission: [
|
requiredPermission: [
|
||||||
@@ -127,7 +143,7 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
|
|||||||
aria-label="Close sidebar"
|
aria-label="Close sidebar"
|
||||||
onClick={() => setClosed()}
|
onClick={() => setClosed()}
|
||||||
>
|
>
|
||||||
<XIcon className="h-6 w-6 text-white" />
|
<XMarkIcon className="h-6 w-6 text-white" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -177,7 +193,7 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
|
|||||||
>
|
>
|
||||||
{sidebarLink.svgIcon}
|
{sidebarLink.svgIcon}
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
messages[sidebarLink.messagesKey]
|
menuMessages[sidebarLink.messagesKey]
|
||||||
)}
|
)}
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -242,7 +258,9 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
|
|||||||
data-testid={sidebarLink.dataTestId}
|
data-testid={sidebarLink.dataTestId}
|
||||||
>
|
>
|
||||||
{sidebarLink.svgIcon}
|
{sidebarLink.svgIcon}
|
||||||
{intl.formatMessage(messages[sidebarLink.messagesKey])}
|
{intl.formatMessage(
|
||||||
|
menuMessages[sidebarLink.messagesKey]
|
||||||
|
)}
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay';
|
import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay';
|
||||||
import { useUser } from '@app/hooks/useUser';
|
import { useUser } from '@app/hooks/useUser';
|
||||||
import { Menu, Transition } from '@headlessui/react';
|
import { Menu, Transition } from '@headlessui/react';
|
||||||
import { ClockIcon, LogoutIcon } from '@heroicons/react/outline';
|
import {
|
||||||
import { CogIcon, UserIcon } from '@heroicons/react/solid';
|
ArrowRightOnRectangleIcon,
|
||||||
|
ClockIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { LinkProps } from 'next/link';
|
import type { LinkProps } from 'next/link';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -147,7 +150,7 @@ const UserDropdown = () => {
|
|||||||
}`}
|
}`}
|
||||||
onClick={() => logout()}
|
onClick={() => logout()}
|
||||||
>
|
>
|
||||||
<LogoutIcon className="mr-2 inline h-5 w-5" />
|
<ArrowRightOnRectangleIcon className="mr-2 inline h-5 w-5" />
|
||||||
<span>{intl.formatMessage(messages.signout)}</span>
|
<span>{intl.formatMessage(messages.signout)}</span>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useUser } from '@app/hooks/useUser';
|
import { useUser } from '@app/hooks/useUser';
|
||||||
import { ExclamationIcon } from '@heroicons/react/outline';
|
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
@@ -49,7 +49,7 @@ const UserWarnings: React.FC<UserWarningsProps> = ({ onClick }) => {
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className="mx-2 mb-2 flex items-center rounded-lg bg-yellow-500 p-2 text-xs text-white ring-1 ring-gray-700 transition duration-300 hover:bg-yellow-400"
|
className="mx-2 mb-2 flex items-center rounded-lg bg-yellow-500 p-2 text-xs text-white ring-1 ring-gray-700 transition duration-300 hover:bg-yellow-400"
|
||||||
>
|
>
|
||||||
<ExclamationIcon className="h-6 w-6" />
|
<ExclamationTriangleIcon className="h-6 w-6" />
|
||||||
<div className="flex min-w-0 flex-1 flex-col truncate px-2 last:pr-0">
|
<div className="flex min-w-0 flex-1 flex-col truncate px-2 last:pr-0">
|
||||||
<span className="font-bold">{warningTitle}</span>
|
<span className="font-bold">{warningTitle}</span>
|
||||||
<span className="truncate">{warningText}</span>
|
<span className="truncate">{warningText}</span>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
ArrowCircleUpIcon,
|
ArrowUpCircleIcon,
|
||||||
BeakerIcon,
|
BeakerIcon,
|
||||||
CodeIcon,
|
CodeBracketIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
} from '@heroicons/react/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces';
|
import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
@@ -56,7 +56,7 @@ const VersionStatus = ({ onClick }: VersionStatusProps) => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{data.commitTag === 'local' ? (
|
{data.commitTag === 'local' ? (
|
||||||
<CodeIcon className="h-6 w-6" />
|
<CodeBracketIcon className="h-6 w-6" />
|
||||||
) : data.version.startsWith('develop-') ? (
|
) : data.version.startsWith('develop-') ? (
|
||||||
<BeakerIcon className="h-6 w-6" />
|
<BeakerIcon className="h-6 w-6" />
|
||||||
) : (
|
) : (
|
||||||
@@ -80,7 +80,7 @@ const VersionStatus = ({ onClick }: VersionStatusProps) => {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{data.updateAvailable && <ArrowCircleUpIcon className="h-6 w-6" />}
|
{data.updateAvailable && <ArrowUpCircleIcon className="h-6 w-6" />}
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import MobileMenu from '@app/components/Layout/MobileMenu';
|
||||||
import SearchInput from '@app/components/Layout/SearchInput';
|
import SearchInput from '@app/components/Layout/SearchInput';
|
||||||
import Sidebar from '@app/components/Layout/Sidebar';
|
import Sidebar from '@app/components/Layout/Sidebar';
|
||||||
import UserDropdown from '@app/components/Layout/UserDropdown';
|
import UserDropdown from '@app/components/Layout/UserDropdown';
|
||||||
@@ -6,8 +7,7 @@ import type { AvailableLocale } from '@app/context/LanguageContext';
|
|||||||
import useLocale from '@app/hooks/useLocale';
|
import useLocale from '@app/hooks/useLocale';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import { useUser } from '@app/hooks/useUser';
|
import { useUser } from '@app/hooks/useUser';
|
||||||
import { MenuAlt2Icon } from '@heroicons/react/outline';
|
import { ArrowLeftIcon, Bars3BottomLeftIcon } from '@heroicons/react/24/solid';
|
||||||
import { ArrowLeftIcon } from '@heroicons/react/solid';
|
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
@@ -57,6 +57,9 @@ const Layout = ({ children }: LayoutProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Sidebar open={isSidebarOpen} setClosed={() => setSidebarOpen(false)} />
|
<Sidebar open={isSidebarOpen} setClosed={() => setSidebarOpen(false)} />
|
||||||
|
<div className="sm:hidden">
|
||||||
|
<MobileMenu />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="relative mb-16 flex w-0 min-w-0 flex-1 flex-col lg:ml-64">
|
<div className="relative mb-16 flex w-0 min-w-0 flex-1 flex-col lg:ml-64">
|
||||||
<PullToRefresh />
|
<PullToRefresh />
|
||||||
@@ -69,17 +72,17 @@ const Layout = ({ children }: LayoutProps) => {
|
|||||||
WebkitBackdropFilter: isScrolled ? 'blur(5px)' : undefined,
|
WebkitBackdropFilter: isScrolled ? 'blur(5px)' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div className="flex flex-1 items-center justify-between px-4 md:pr-4 md:pl-4">
|
||||||
<button
|
<button
|
||||||
className={`px-4 text-white ${
|
className={`mr-2 hidden text-white sm:block ${
|
||||||
isScrolled ? 'opacity-90' : 'opacity-70'
|
isScrolled ? 'opacity-90' : 'opacity-70'
|
||||||
} transition duration-300 focus:outline-none lg:hidden`}
|
} transition duration-300 focus:outline-none lg:hidden`}
|
||||||
aria-label="Open sidebar"
|
aria-label="Open sidebar"
|
||||||
onClick={() => setSidebarOpen(true)}
|
onClick={() => setSidebarOpen(true)}
|
||||||
data-testid="sidebar-toggle"
|
data-testid="sidebar-toggle"
|
||||||
>
|
>
|
||||||
<MenuAlt2Icon className="h-6 w-6" />
|
<Bars3BottomLeftIcon className="h-7 w-7" />
|
||||||
</button>
|
</button>
|
||||||
<div className="flex flex-1 items-center justify-between pr-4 md:pr-4 md:pl-4">
|
|
||||||
<button
|
<button
|
||||||
className={`mr-2 text-white ${
|
className={`mr-2 text-white ${
|
||||||
isScrolled ? 'opacity-90' : 'opacity-70'
|
isScrolled ? 'opacity-90' : 'opacity-70'
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import { LoginIcon, SupportIcon } from '@heroicons/react/outline';
|
import {
|
||||||
|
ArrowLeftOnRectangleIcon,
|
||||||
|
LifebuoyIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -124,7 +127,7 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
|
|||||||
disabled={isSubmitting || !isValid}
|
disabled={isSubmitting || !isValid}
|
||||||
data-testid="local-signin-button"
|
data-testid="local-signin-button"
|
||||||
>
|
>
|
||||||
<LoginIcon />
|
<ArrowLeftOnRectangleIcon />
|
||||||
<span>
|
<span>
|
||||||
{isSubmitting
|
{isSubmitting
|
||||||
? intl.formatMessage(messages.signingin)
|
? intl.formatMessage(messages.signingin)
|
||||||
@@ -136,7 +139,7 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
|
|||||||
<span className="inline-flex rounded-md shadow-sm">
|
<span className="inline-flex rounded-md shadow-sm">
|
||||||
<Link href="/resetpassword" passHref>
|
<Link href="/resetpassword" passHref>
|
||||||
<Button as="a" buttonType="ghost">
|
<Button as="a" buttonType="ghost">
|
||||||
<SupportIcon />
|
<LifebuoyIcon />
|
||||||
<span>
|
<span>
|
||||||
{intl.formatMessage(messages.forgotpassword)}
|
{intl.formatMessage(messages.forgotpassword)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import PlexLoginButton from '@app/components/PlexLoginButton';
|
|||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import { useUser } from '@app/hooks/useUser';
|
import { useUser } from '@app/hooks/useUser';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import { XCircleIcon } from '@heroicons/react/solid';
|
import { XCircleIcon } from '@heroicons/react/24/solid';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import getConfig from 'next/config';
|
import getConfig from 'next/config';
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import RequestBlock from '@app/components/RequestBlock';
|
|||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import { ServerIcon, ViewListIcon } from '@heroicons/react/outline';
|
import { Bars4Icon, ServerIcon } from '@heroicons/react/24/outline';
|
||||||
import { CheckCircleIcon, DocumentRemoveIcon } from '@heroicons/react/solid';
|
import { CheckCircleIcon, DocumentMinusIcon } from '@heroicons/react/24/solid';
|
||||||
import { IssueStatus } from '@server/constants/issue';
|
import { IssueStatus } from '@server/constants/issue';
|
||||||
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
@@ -302,7 +302,7 @@ const ManageSlideOver = ({
|
|||||||
watchData?.data ? 'rounded-t-none' : ''
|
watchData?.data ? 'rounded-t-none' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ViewListIcon />
|
<Bars4Icon />
|
||||||
<span>
|
<span>
|
||||||
{intl.formatMessage(messages.opentautulli)}
|
{intl.formatMessage(messages.opentautulli)}
|
||||||
</span>
|
</span>
|
||||||
@@ -423,7 +423,7 @@ const ManageSlideOver = ({
|
|||||||
watchData?.data4k ? 'rounded-t-none' : ''
|
watchData?.data4k ? 'rounded-t-none' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ViewListIcon />
|
<Bars4Icon />
|
||||||
<span>
|
<span>
|
||||||
{intl.formatMessage(messages.opentautulli)}
|
{intl.formatMessage(messages.opentautulli)}
|
||||||
</span>
|
</span>
|
||||||
@@ -497,7 +497,7 @@ const ManageSlideOver = ({
|
|||||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<DocumentRemoveIcon />
|
<DocumentMinusIcon />
|
||||||
<span>
|
<span>
|
||||||
{intl.formatMessage(messages.manageModalClearMedia)}
|
{intl.formatMessage(messages.manageModalClearMedia)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import TitleCard from '@app/components/TitleCard';
|
import TitleCard from '@app/components/TitleCard';
|
||||||
import { ArrowCircleRightIcon } from '@heroicons/react/solid';
|
import { ArrowRightCircleIcon } from '@heroicons/react/24/solid';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useInView } from 'react-intersection-observer';
|
import { useInView } from 'react-intersection-observer';
|
||||||
@@ -94,7 +94,7 @@ const ShowMoreCard = ({ url, posters }: ShowMoreCardProps) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center text-white">
|
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center text-white">
|
||||||
<ArrowCircleRightIcon className="w-14" />
|
<ArrowRightCircleIcon className="w-14" />
|
||||||
<div className="mt-2 font-extrabold">
|
<div className="mt-2 font-extrabold">
|
||||||
{intl.formatMessage(messages.seemore)}
|
{intl.formatMessage(messages.seemore)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import PersonCard from '@app/components/PersonCard';
|
|||||||
import Slider from '@app/components/Slider';
|
import Slider from '@app/components/Slider';
|
||||||
import TitleCard from '@app/components/TitleCard';
|
import TitleCard from '@app/components/TitleCard';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
|
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
|
||||||
import { MediaStatus } from '@server/constants/media';
|
import { MediaStatus } from '@server/constants/media';
|
||||||
import type {
|
import type {
|
||||||
MovieResult,
|
MovieResult,
|
||||||
@@ -27,14 +27,18 @@ interface MediaSliderProps {
|
|||||||
linkUrl?: string;
|
linkUrl?: string;
|
||||||
sliderKey: string;
|
sliderKey: string;
|
||||||
hideWhenEmpty?: boolean;
|
hideWhenEmpty?: boolean;
|
||||||
|
extraParams?: string;
|
||||||
|
onNewTitles?: (titleCount: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MediaSlider = ({
|
const MediaSlider = ({
|
||||||
title,
|
title,
|
||||||
url,
|
url,
|
||||||
linkUrl,
|
linkUrl,
|
||||||
|
extraParams,
|
||||||
sliderKey,
|
sliderKey,
|
||||||
hideWhenEmpty = false,
|
hideWhenEmpty = false,
|
||||||
|
onNewTitles,
|
||||||
}: MediaSliderProps) => {
|
}: MediaSliderProps) => {
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const { data, error, setSize, size } = useSWRInfinite<MixedResult>(
|
const { data, error, setSize, size } = useSWRInfinite<MixedResult>(
|
||||||
@@ -43,7 +47,9 @@ const MediaSlider = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${url}?page=${pageIndex + 1}`;
|
return `${url}?page=${pageIndex + 1}${
|
||||||
|
extraParams ? `&${extraParams}` : ''
|
||||||
|
}`;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
initialSize: 2,
|
initialSize: 2,
|
||||||
@@ -72,7 +78,13 @@ const MediaSlider = ({
|
|||||||
) {
|
) {
|
||||||
setSize(size + 1);
|
setSize(size + 1);
|
||||||
}
|
}
|
||||||
}, [titles, setSize, size, data]);
|
|
||||||
|
if (onNewTitles) {
|
||||||
|
// We aren't reporting all titles. We just want to know if there are any titles
|
||||||
|
// at all for our purposes.
|
||||||
|
onNewTitles(titles.length);
|
||||||
|
}
|
||||||
|
}, [titles, setSize, size, data, onNewTitles]);
|
||||||
|
|
||||||
if (hideWhenEmpty && (data?.[0].results ?? []).length === 0) {
|
if (hideWhenEmpty && (data?.[0].results ?? []).length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -137,9 +149,9 @@ const MediaSlider = ({
|
|||||||
<div className="slider-header">
|
<div className="slider-header">
|
||||||
{linkUrl ? (
|
{linkUrl ? (
|
||||||
<Link href={linkUrl}>
|
<Link href={linkUrl}>
|
||||||
<a className="slider-title">
|
<a className="slider-title min-w-0 pr-16">
|
||||||
<span>{title}</span>
|
<span className="truncate">{title}</span>
|
||||||
<ArrowCircleRightIcon />
|
<ArrowRightCircleIcon />
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
|||||||
import PageTitle from '@app/components/Common/PageTitle';
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
import type { PlayButtonLink } from '@app/components/Common/PlayButton';
|
import type { PlayButtonLink } from '@app/components/Common/PlayButton';
|
||||||
import PlayButton from '@app/components/Common/PlayButton';
|
import PlayButton from '@app/components/Common/PlayButton';
|
||||||
|
import Tag from '@app/components/Common/Tag';
|
||||||
import Tooltip from '@app/components/Common/Tooltip';
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
import ExternalLinkBlock from '@app/components/ExternalLinkBlock';
|
import ExternalLinkBlock from '@app/components/ExternalLinkBlock';
|
||||||
import IssueModal from '@app/components/IssueModal';
|
import IssueModal from '@app/components/IssueModal';
|
||||||
@@ -26,18 +27,18 @@ import globalMessages from '@app/i18n/globalMessages';
|
|||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
import { sortCrewPriority } from '@app/utils/creditHelpers';
|
import { sortCrewPriority } from '@app/utils/creditHelpers';
|
||||||
import {
|
import {
|
||||||
ArrowCircleRightIcon,
|
ArrowRightCircleIcon,
|
||||||
CloudIcon,
|
CloudIcon,
|
||||||
CogIcon,
|
CogIcon,
|
||||||
ExclamationIcon,
|
ExclamationTriangleIcon,
|
||||||
FilmIcon,
|
FilmIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
TicketIcon,
|
TicketIcon,
|
||||||
} from '@heroicons/react/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import {
|
import {
|
||||||
ChevronDoubleDownIcon,
|
ChevronDoubleDownIcon,
|
||||||
ChevronDoubleUpIcon,
|
ChevronDoubleUpIcon,
|
||||||
} from '@heroicons/react/solid';
|
} from '@heroicons/react/24/solid';
|
||||||
import type { RTRating } from '@server/api/rottentomatoes';
|
import type { RTRating } from '@server/api/rottentomatoes';
|
||||||
import { IssueStatus } from '@server/constants/issue';
|
import { IssueStatus } from '@server/constants/issue';
|
||||||
import { MediaStatus } from '@server/constants/media';
|
import { MediaStatus } from '@server/constants/media';
|
||||||
@@ -228,7 +229,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
movieAttributes.push(
|
movieAttributes.push(
|
||||||
data.genres
|
data.genres
|
||||||
.map((g) => (
|
.map((g) => (
|
||||||
<Link href={`/discover/movies/genre/${g.id}`} key={`genre-${g.id}`}>
|
<Link href={`/discover/movies?genre=${g.id}`} key={`genre-${g.id}`}>
|
||||||
<a className="hover:underline">{g.name}</a>
|
<a className="hover:underline">{g.name}</a>
|
||||||
</Link>
|
</Link>
|
||||||
))
|
))
|
||||||
@@ -419,7 +420,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
onClick={() => setShowIssueModal(true)}
|
onClick={() => setShowIssueModal(true)}
|
||||||
className="ml-2 first:ml-0"
|
className="ml-2 first:ml-0"
|
||||||
>
|
>
|
||||||
<ExclamationIcon />
|
<ExclamationTriangleIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
@@ -477,12 +478,26 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
<Link href={`/movie/${data.id}/crew`}>
|
<Link href={`/movie/${data.id}/crew`}>
|
||||||
<a className="flex items-center text-gray-400 transition duration-300 hover:text-gray-100">
|
<a className="flex items-center text-gray-400 transition duration-300 hover:text-gray-100">
|
||||||
<span>{intl.formatMessage(messages.viewfullcrew)}</span>
|
<span>{intl.formatMessage(messages.viewfullcrew)}</span>
|
||||||
<ArrowCircleRightIcon className="ml-1.5 inline-block h-5 w-5" />
|
<ArrowRightCircleIcon className="ml-1.5 inline-block h-5 w-5" />
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{data.keywords.length > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
{data.keywords.map((keyword) => (
|
||||||
|
<Link
|
||||||
|
href={`/discover/movies?keywords=${keyword.id}`}
|
||||||
|
key={`keyword-id-${keyword.id}`}
|
||||||
|
>
|
||||||
|
<a className="mb-2 mr-2 inline-flex last:mr-0">
|
||||||
|
<Tag>{keyword.name}</Tag>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="media-overview-right">
|
<div className="media-overview-right">
|
||||||
{data.collection && (
|
{data.collection && (
|
||||||
@@ -636,6 +651,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -655,6 +671,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -817,7 +834,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
<Link href="/movie/[movieId]/cast" as={`/movie/${data.id}/cast`}>
|
<Link href="/movie/[movieId]/cast" as={`/movie/${data.id}/cast`}>
|
||||||
<a className="slider-title">
|
<a className="slider-title">
|
||||||
<span>{intl.formatMessage(messages.cast)}</span>
|
<span>{intl.formatMessage(messages.cast)}</span>
|
||||||
<ArrowCircleRightIcon />
|
<ArrowRightCircleIcon />
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -851,7 +868,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
linkUrl={`/movie/${data.id}/similar`}
|
linkUrl={`/movie/${data.id}/similar`}
|
||||||
hideWhenEmpty
|
hideWhenEmpty
|
||||||
/>
|
/>
|
||||||
<div className="pb-8" />
|
<div className="extra-bottom-space relative" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import CachedImage from '@app/components/Common/CachedImage';
|
import CachedImage from '@app/components/Common/CachedImage';
|
||||||
import { UserCircleIcon } from '@heroicons/react/solid';
|
import { UserCircleIcon } from '@heroicons/react/24/solid';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
|||||||
@@ -91,11 +91,13 @@ const PersonDetails = () => {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
}),
|
}),
|
||||||
deathdate: intl.formatDate(data.deathday, {
|
deathdate: intl.formatDate(data.deathday, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -106,6 +108,7 @@ const PersonDetails = () => {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import PlexOAuth from '@app/utils/plex';
|
import PlexOAuth from '@app/utils/plex';
|
||||||
import { LoginIcon } from '@heroicons/react/outline';
|
import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ const PlexLoginButton = ({
|
|||||||
disabled={loading || isProcessing}
|
disabled={loading || isProcessing}
|
||||||
className="plex-button"
|
className="plex-button"
|
||||||
>
|
>
|
||||||
<LoginIcon />
|
<ArrowLeftOnRectangleIcon />
|
||||||
<span>
|
<span>
|
||||||
{loading
|
{loading
|
||||||
? intl.formatMessage(globalMessages.loading)
|
? intl.formatMessage(globalMessages.loading)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { RefreshIcon } from '@heroicons/react/outline';
|
import { ArrowPathIcon } from '@heroicons/react/24/outline';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import PR from 'pulltorefreshjs';
|
import PR from 'pulltorefreshjs';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
@@ -15,7 +15,7 @@ const PullToRefresh = () => {
|
|||||||
},
|
},
|
||||||
iconArrow: ReactDOMServer.renderToString(
|
iconArrow: ReactDOMServer.renderToString(
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<RefreshIcon className="z-50 m-auto h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 text-indigo-500 ring-1 ring-gray-700" />
|
<ArrowPathIcon className="z-50 m-auto h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 text-indigo-500 ring-1 ring-gray-700" />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
iconRefreshing: ReactDOMServer.renderToString(
|
iconRefreshing: ReactDOMServer.renderToString(
|
||||||
@@ -23,7 +23,7 @@ const PullToRefresh = () => {
|
|||||||
className="animate-spin p-2"
|
className="animate-spin p-2"
|
||||||
style={{ animationDirection: 'reverse' }}
|
style={{ animationDirection: 'reverse' }}
|
||||||
>
|
>
|
||||||
<RefreshIcon className="z-50 m-auto h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 text-indigo-500 ring-1 ring-gray-700" />
|
<ArrowPathIcon className="z-50 m-auto h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 text-indigo-500 ring-1 ring-gray-700" />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
instructionsPullToRefresh: ReactDOMServer.renderToString(<div />),
|
instructionsPullToRefresh: ReactDOMServer.renderToString(<div />),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import { Listbox, Transition } from '@headlessui/react';
|
import { Listbox, Transition } from '@headlessui/react';
|
||||||
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/solid';
|
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/24/solid';
|
||||||
import type { Region } from '@server/lib/settings';
|
import type { Region } from '@server/lib/settings';
|
||||||
import { hasFlag } from 'country-flag-icons';
|
import { hasFlag } from 'country-flag-icons';
|
||||||
import 'country-flag-icons/3x2/flags.css';
|
import 'country-flag-icons/3x2/flags.css';
|
||||||
@@ -18,6 +18,8 @@ interface RegionSelectorProps {
|
|||||||
value: string;
|
value: string;
|
||||||
name: string;
|
name: string;
|
||||||
isUserSetting?: boolean;
|
isUserSetting?: boolean;
|
||||||
|
disableAll?: boolean;
|
||||||
|
watchProviders?: boolean;
|
||||||
onChange?: (fieldName: string, region: string) => void;
|
onChange?: (fieldName: string, region: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,11 +27,15 @@ const RegionSelector = ({
|
|||||||
name,
|
name,
|
||||||
value,
|
value,
|
||||||
isUserSetting = false,
|
isUserSetting = false,
|
||||||
|
disableAll = false,
|
||||||
|
watchProviders = false,
|
||||||
onChange,
|
onChange,
|
||||||
}: RegionSelectorProps) => {
|
}: RegionSelectorProps) => {
|
||||||
const { currentSettings } = useSettings();
|
const { currentSettings } = useSettings();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { data: regions } = useSWR<Region[]>('/api/v1/regions');
|
const { data: regions } = useSWR<Region[]>(
|
||||||
|
watchProviders ? '/api/v1/watchproviders/regions' : '/api/v1/regions'
|
||||||
|
);
|
||||||
const [selectedRegion, setSelectedRegion] = useState<Region | null>(null);
|
const [selectedRegion, setSelectedRegion] = useState<Region | null>(null);
|
||||||
|
|
||||||
const allRegion: Region = useMemo(
|
const allRegion: Region = useMemo(
|
||||||
@@ -70,8 +76,8 @@ const RegionSelector = ({
|
|||||||
}, [value, regions, allRegion]);
|
}, [value, regions, allRegion]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (onChange && regions) {
|
if (onChange && regions && selectedRegion) {
|
||||||
onChange(name, selectedRegion?.iso_3166_1 ?? '');
|
onChange(name, selectedRegion.iso_3166_1);
|
||||||
}
|
}
|
||||||
}, [onChange, selectedRegion, name, regions]);
|
}, [onChange, selectedRegion, name, regions]);
|
||||||
|
|
||||||
@@ -166,6 +172,7 @@ const RegionSelector = ({
|
|||||||
)}
|
)}
|
||||||
</Listbox.Option>
|
</Listbox.Option>
|
||||||
)}
|
)}
|
||||||
|
{!disableAll && (
|
||||||
<Listbox.Option value={isUserSetting ? allRegion : null}>
|
<Listbox.Option value={isUserSetting ? allRegion : null}>
|
||||||
{({ selected, active }) => (
|
{({ selected, active }) => (
|
||||||
<div
|
<div
|
||||||
@@ -192,6 +199,7 @@ const RegionSelector = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Listbox.Option>
|
</Listbox.Option>
|
||||||
|
)}
|
||||||
{sortedRegions?.map((region) => (
|
{sortedRegions?.map((region) => (
|
||||||
<Listbox.Option key={region.iso_3166_1} value={region}>
|
<Listbox.Option key={region.iso_3166_1} value={region}>
|
||||||
{({ selected, active }) => (
|
{({ selected, active }) => (
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
PencilIcon,
|
PencilIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
XIcon,
|
XMarkIcon,
|
||||||
} from '@heroicons/react/solid';
|
} from '@heroicons/react/24/solid';
|
||||||
import { MediaRequestStatus } from '@server/constants/media';
|
import { MediaRequestStatus } from '@server/constants/media';
|
||||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@@ -149,7 +149,7 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
|
|||||||
onClick={() => updateRequest('decline')}
|
onClick={() => updateRequest('decline')}
|
||||||
disabled={isUpdating}
|
disabled={isUpdating}
|
||||||
>
|
>
|
||||||
<XIcon />
|
<XMarkIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content={intl.formatMessage(messages.edit)}>
|
<Tooltip content={intl.formatMessage(messages.edit)}>
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import RequestModal from '@app/components/RequestModal';
|
|||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import { DownloadIcon } from '@heroicons/react/outline';
|
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
|
||||||
import {
|
import {
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
InformationCircleIcon,
|
InformationCircleIcon,
|
||||||
XIcon,
|
XMarkIcon,
|
||||||
} from '@heroicons/react/solid';
|
} from '@heroicons/react/24/solid';
|
||||||
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
||||||
import type Media from '@server/entity/Media';
|
import type Media from '@server/entity/Media';
|
||||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
@@ -158,7 +158,7 @@ const RequestButton = ({
|
|||||||
action: () => {
|
action: () => {
|
||||||
modifyRequest(activeRequest, 'decline');
|
modifyRequest(activeRequest, 'decline');
|
||||||
},
|
},
|
||||||
svg: <XIcon />,
|
svg: <XMarkIcon />,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} else if (
|
} else if (
|
||||||
@@ -186,7 +186,7 @@ const RequestButton = ({
|
|||||||
action: () => {
|
action: () => {
|
||||||
modifyRequests(activeRequests, 'decline');
|
modifyRequests(activeRequests, 'decline');
|
||||||
},
|
},
|
||||||
svg: <XIcon />,
|
svg: <XMarkIcon />,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -228,7 +228,7 @@ const RequestButton = ({
|
|||||||
action: () => {
|
action: () => {
|
||||||
modifyRequest(active4kRequest, 'decline');
|
modifyRequest(active4kRequest, 'decline');
|
||||||
},
|
},
|
||||||
svg: <XIcon />,
|
svg: <XMarkIcon />,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} else if (
|
} else if (
|
||||||
@@ -256,7 +256,7 @@ const RequestButton = ({
|
|||||||
action: () => {
|
action: () => {
|
||||||
modifyRequests(active4kRequests, 'decline');
|
modifyRequests(active4kRequests, 'decline');
|
||||||
},
|
},
|
||||||
svg: <XIcon />,
|
svg: <XMarkIcon />,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -282,7 +282,7 @@ const RequestButton = ({
|
|||||||
setEditRequest(false);
|
setEditRequest(false);
|
||||||
setShowRequestModal(true);
|
setShowRequestModal(true);
|
||||||
},
|
},
|
||||||
svg: <DownloadIcon />,
|
svg: <ArrowDownTrayIcon />,
|
||||||
});
|
});
|
||||||
} else if (
|
} else if (
|
||||||
mediaType === 'tv' &&
|
mediaType === 'tv' &&
|
||||||
@@ -301,7 +301,7 @@ const RequestButton = ({
|
|||||||
setEditRequest(false);
|
setEditRequest(false);
|
||||||
setShowRequestModal(true);
|
setShowRequestModal(true);
|
||||||
},
|
},
|
||||||
svg: <DownloadIcon />,
|
svg: <ArrowDownTrayIcon />,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +327,7 @@ const RequestButton = ({
|
|||||||
setEditRequest(false);
|
setEditRequest(false);
|
||||||
setShowRequest4kModal(true);
|
setShowRequest4kModal(true);
|
||||||
},
|
},
|
||||||
svg: <DownloadIcon />,
|
svg: <ArrowDownTrayIcon />,
|
||||||
});
|
});
|
||||||
} else if (
|
} else if (
|
||||||
mediaType === 'tv' &&
|
mediaType === 'tv' &&
|
||||||
@@ -347,7 +347,7 @@ const RequestButton = ({
|
|||||||
setEditRequest(false);
|
setEditRequest(false);
|
||||||
setShowRequest4kModal(true);
|
setShowRequest4kModal(true);
|
||||||
},
|
},
|
||||||
svg: <DownloadIcon />,
|
svg: <ArrowDownTrayIcon />,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ import { Permission, useUser } from '@app/hooks/useUser';
|
|||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import { withProperties } from '@app/utils/typeHelpers';
|
import { withProperties } from '@app/utils/typeHelpers';
|
||||||
import {
|
import {
|
||||||
|
ArrowPathIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
RefreshIcon,
|
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
XIcon,
|
XMarkIcon,
|
||||||
} from '@heroicons/react/solid';
|
} from '@heroicons/react/24/solid';
|
||||||
import { MediaRequestStatus } from '@server/constants/media';
|
import { MediaRequestStatus } from '@server/constants/media';
|
||||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
@@ -441,7 +441,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
disabled={isRetrying}
|
disabled={isRetrying}
|
||||||
onClick={() => retryRequest()}
|
onClick={() => retryRequest()}
|
||||||
>
|
>
|
||||||
<RefreshIcon
|
<ArrowPathIcon
|
||||||
className={isRetrying ? 'animate-spin' : ''}
|
className={isRetrying ? 'animate-spin' : ''}
|
||||||
style={{ marginRight: '0', animationDirection: 'reverse' }}
|
style={{ marginRight: '0', animationDirection: 'reverse' }}
|
||||||
/>
|
/>
|
||||||
@@ -483,7 +483,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
className="hidden sm:block"
|
className="hidden sm:block"
|
||||||
onClick={() => modifyRequest('decline')}
|
onClick={() => modifyRequest('decline')}
|
||||||
>
|
>
|
||||||
<XIcon />
|
<XMarkIcon />
|
||||||
<span>{intl.formatMessage(globalMessages.decline)}</span>
|
<span>{intl.formatMessage(globalMessages.decline)}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@@ -495,7 +495,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
className="sm:hidden"
|
className="sm:hidden"
|
||||||
onClick={() => modifyRequest('decline')}
|
onClick={() => modifyRequest('decline')}
|
||||||
>
|
>
|
||||||
<XIcon />
|
<XMarkIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -540,7 +540,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
className="hidden sm:block"
|
className="hidden sm:block"
|
||||||
onClick={() => deleteRequest()}
|
onClick={() => deleteRequest()}
|
||||||
>
|
>
|
||||||
<XIcon />
|
<XMarkIcon />
|
||||||
<span>{intl.formatMessage(globalMessages.cancel)}</span>
|
<span>{intl.formatMessage(globalMessages.cancel)}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Tooltip content={intl.formatMessage(messages.cancelrequest)}>
|
<Tooltip content={intl.formatMessage(messages.cancelrequest)}>
|
||||||
@@ -550,7 +550,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
className="sm:hidden"
|
className="sm:hidden"
|
||||||
onClick={() => deleteRequest()}
|
onClick={() => deleteRequest()}
|
||||||
>
|
>
|
||||||
<XIcon />
|
<XMarkIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ import useDeepLinks from '@app/hooks/useDeepLinks';
|
|||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import {
|
import {
|
||||||
|
ArrowPathIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
RefreshIcon,
|
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
XIcon,
|
XMarkIcon,
|
||||||
} from '@heroicons/react/solid';
|
} from '@heroicons/react/24/solid';
|
||||||
import { MediaRequestStatus } from '@server/constants/media';
|
import { MediaRequestStatus } from '@server/constants/media';
|
||||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
@@ -601,7 +601,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
disabled={isRetrying}
|
disabled={isRetrying}
|
||||||
onClick={() => retryRequest()}
|
onClick={() => retryRequest()}
|
||||||
>
|
>
|
||||||
<RefreshIcon
|
<ArrowPathIcon
|
||||||
className={isRetrying ? 'animate-spin' : ''}
|
className={isRetrying ? 'animate-spin' : ''}
|
||||||
style={{ animationDirection: 'reverse' }}
|
style={{ animationDirection: 'reverse' }}
|
||||||
/>
|
/>
|
||||||
@@ -642,7 +642,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
buttonType="danger"
|
buttonType="danger"
|
||||||
onClick={() => modifyRequest('decline')}
|
onClick={() => modifyRequest('decline')}
|
||||||
>
|
>
|
||||||
<XIcon />
|
<XMarkIcon />
|
||||||
<span>{intl.formatMessage(globalMessages.decline)}</span>
|
<span>{intl.formatMessage(globalMessages.decline)}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
@@ -672,7 +672,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<XIcon />
|
<XMarkIcon />
|
||||||
<span>{intl.formatMessage(messages.cancelRequest)}</span>
|
<span>{intl.formatMessage(messages.cancelRequest)}</span>
|
||||||
</ConfirmButton>
|
</ConfirmButton>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
|
|||||||
import { useUser } from '@app/hooks/useUser';
|
import { useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import {
|
import {
|
||||||
|
BarsArrowDownIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
FilterIcon,
|
FunnelIcon,
|
||||||
SortDescendingIcon,
|
} from '@heroicons/react/24/solid';
|
||||||
} from '@heroicons/react/solid';
|
|
||||||
import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces';
|
import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
@@ -139,7 +139,7 @@ const RequestList = () => {
|
|||||||
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
|
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
|
||||||
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
||||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
|
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
|
||||||
<FilterIcon className="h-6 w-6" />
|
<FunnelIcon className="h-6 w-6" />
|
||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
id="filter"
|
id="filter"
|
||||||
@@ -181,7 +181,7 @@ const RequestList = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
|
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
|
||||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
||||||
<SortDescendingIcon className="h-6 w-6" />
|
<BarsArrowDownIcon className="h-6 w-6" />
|
||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
id="sort"
|
id="sort"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user