mirror of
https://github.com/seerr-team/seerr.git
synced 2025-12-23 23:58:07 -05:00
feat: view other users' watchlists (#2959)
* feat: view other users' watchlists * test: add cypress tests * feat(lang): translation keys * refactor: yarn format * fix: manage requests perm is parent of view watchlist perm
This commit is contained in:
@@ -173,9 +173,9 @@ describe('Discover', () => {
|
||||
});
|
||||
|
||||
it('loads plex watchlist', () => {
|
||||
cy.intercept('/api/v1/discover/watchlist', { fixture: 'watchlist' }).as(
|
||||
'getWatchlist'
|
||||
);
|
||||
cy.intercept('/api/v1/discover/watchlist', {
|
||||
fixture: 'watchlist.json',
|
||||
}).as('getWatchlist');
|
||||
// Wait for one of the watchlist movies to resolve
|
||||
cy.intercept('/api/v1/movie/361743').as('getTmdbMovie');
|
||||
|
||||
@@ -183,7 +183,7 @@ describe('Discover', () => {
|
||||
|
||||
cy.wait('@getWatchlist');
|
||||
|
||||
const sliderHeader = cy.contains('.slider-header', 'Plex Watchlist');
|
||||
const sliderHeader = cy.contains('.slider-header', 'Your Plex Watchlist');
|
||||
|
||||
sliderHeader.scrollIntoView();
|
||||
|
||||
@@ -203,7 +203,6 @@ describe('Discover', () => {
|
||||
.next('[data-testid=media-slider]')
|
||||
.find('[data-testid=title-card]')
|
||||
.first()
|
||||
.click()
|
||||
.click();
|
||||
cy.get('[data-testid=media-title]').should('contain', text);
|
||||
});
|
||||
|
||||
50
cypress/e2e/user/profile.cy.ts
Normal file
50
cypress/e2e/user/profile.cy.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
describe('User Profile', () => {
|
||||
beforeEach(() => {
|
||||
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
|
||||
});
|
||||
|
||||
it('opens user profile page from the home page', () => {
|
||||
cy.visit('/');
|
||||
|
||||
cy.get('[data-testid=user-menu]').click();
|
||||
cy.get('[data-testid=user-menu-profile]').click();
|
||||
|
||||
cy.get('h1').should('contain', Cypress.env('ADMIN_EMAIL'));
|
||||
});
|
||||
|
||||
it('loads plex watchlist', () => {
|
||||
cy.intercept('/api/v1/user/[0-9]*/watchlist', {
|
||||
fixture: 'watchlist.json',
|
||||
}).as('getWatchlist');
|
||||
// Wait for one of the watchlist movies to resolve
|
||||
cy.intercept('/api/v1/movie/361743').as('getTmdbMovie');
|
||||
|
||||
cy.visit('/profile');
|
||||
|
||||
cy.wait('@getWatchlist');
|
||||
|
||||
const sliderHeader = cy.contains('.slider-header', 'Plex Watchlist');
|
||||
|
||||
sliderHeader.scrollIntoView();
|
||||
|
||||
cy.wait('@getTmdbMovie');
|
||||
// Wait a little longer to make sure the movie component reloaded
|
||||
cy.wait(500);
|
||||
|
||||
sliderHeader
|
||||
.next('[data-testid=media-slider]')
|
||||
.find('[data-testid=title-card]')
|
||||
.first()
|
||||
.trigger('mouseover')
|
||||
.find('[data-testid=title-card-title]')
|
||||
.invoke('text')
|
||||
.then((text) => {
|
||||
cy.contains('.slider-header', 'Plex Watchlist')
|
||||
.next('[data-testid=media-slider]')
|
||||
.find('[data-testid=title-card]')
|
||||
.first()
|
||||
.click();
|
||||
cy.get('[data-testid=media-title]').should('contain', text);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"page": 1,
|
||||
"totalPages": 1,
|
||||
"totalResults": 20,
|
||||
"totalResults": 3,
|
||||
"results": [
|
||||
{
|
||||
"ratingKey": "5d776be17a53e9001e732ab9",
|
||||
|
||||
@@ -3512,6 +3512,53 @@ paths:
|
||||
restricted:
|
||||
type: boolean
|
||||
example: false
|
||||
/user/{userId}/watchlist:
|
||||
get:
|
||||
summary: Get user by ID
|
||||
description: |
|
||||
Retrieves a user's Plex Watchlist in a JSON object.
|
||||
tags:
|
||||
- users
|
||||
parameters:
|
||||
- in: path
|
||||
name: userId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
- in: query
|
||||
name: page
|
||||
schema:
|
||||
type: number
|
||||
example: 1
|
||||
default: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Watchlist data returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
page:
|
||||
type: number
|
||||
totalPages:
|
||||
type: number
|
||||
totalResults:
|
||||
type: number
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
tmdbId:
|
||||
type: number
|
||||
example: 1
|
||||
ratingKey:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
/user/{userId}/settings/main:
|
||||
get:
|
||||
summary: Get general settings for a user
|
||||
|
||||
@@ -10,3 +10,10 @@ export interface WatchlistItem {
|
||||
mediaType: 'movie' | 'tv';
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface WatchlistResponse {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
totalResults: number;
|
||||
results: WatchlistItem[];
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface QuotaResponse {
|
||||
movie: QuotaStatus;
|
||||
tv: QuotaStatus;
|
||||
}
|
||||
|
||||
export interface UserWatchDataResponse {
|
||||
recentlyWatched: Media[];
|
||||
playCount: number;
|
||||
|
||||
@@ -25,6 +25,7 @@ export enum Permission {
|
||||
AUTO_REQUEST_MOVIE = 16777216,
|
||||
AUTO_REQUEST_TV = 33554432,
|
||||
RECENT_VIEW = 67108864,
|
||||
WATCHLIST_VIEW = 134217728,
|
||||
}
|
||||
|
||||
export interface PermissionCheckOptions {
|
||||
|
||||
@@ -6,7 +6,7 @@ import Media from '@server/entity/Media';
|
||||
import { User } from '@server/entity/User';
|
||||
import type {
|
||||
GenreSliderItem,
|
||||
WatchlistItem,
|
||||
WatchlistResponse,
|
||||
} from '@server/interfaces/api/discoverInterfaces';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
@@ -713,50 +713,45 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
|
||||
}
|
||||
);
|
||||
|
||||
discoverRoutes.get<
|
||||
{ page?: number },
|
||||
{
|
||||
page: number;
|
||||
totalPages: number;
|
||||
totalResults: number;
|
||||
results: WatchlistItem[];
|
||||
}
|
||||
>('/watchlist', async (req, res) => {
|
||||
const userRepository = getRepository(User);
|
||||
const itemsPerPage = 20;
|
||||
const page = req.params.page ?? 1;
|
||||
const offset = (page - 1) * itemsPerPage;
|
||||
discoverRoutes.get<{ page?: number }, WatchlistResponse>(
|
||||
'/watchlist',
|
||||
async (req, res) => {
|
||||
const userRepository = getRepository(User);
|
||||
const itemsPerPage = 20;
|
||||
const page = req.params.page ?? 1;
|
||||
const offset = (page - 1) * itemsPerPage;
|
||||
|
||||
const activeUser = await userRepository.findOne({
|
||||
where: { id: req.user?.id },
|
||||
select: ['id', 'plexToken'],
|
||||
});
|
||||
const activeUser = await userRepository.findOne({
|
||||
where: { id: req.user?.id },
|
||||
select: ['id', 'plexToken'],
|
||||
});
|
||||
|
||||
if (!activeUser?.plexToken) {
|
||||
// We will just return an empty array if the user has no Plex token
|
||||
return res.json({
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
totalResults: 0,
|
||||
results: [],
|
||||
});
|
||||
}
|
||||
|
||||
const plexTV = new PlexTvAPI(activeUser.plexToken);
|
||||
|
||||
const watchlist = await plexTV.getWatchlist({ offset });
|
||||
|
||||
if (!activeUser?.plexToken) {
|
||||
// We will just return an empty array if the user has no plex token
|
||||
return res.json({
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
totalResults: 0,
|
||||
results: [],
|
||||
page,
|
||||
totalPages: Math.ceil(watchlist.size / itemsPerPage),
|
||||
totalResults: watchlist.size,
|
||||
results: watchlist.items.map((item) => ({
|
||||
ratingKey: item.ratingKey,
|
||||
title: item.title,
|
||||
mediaType: item.type === 'show' ? 'tv' : 'movie',
|
||||
tmdbId: item.tmdbId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const plexTV = new PlexTvAPI(activeUser?.plexToken);
|
||||
|
||||
const watchlist = await plexTV.getWatchlist({ offset });
|
||||
|
||||
return res.json({
|
||||
page,
|
||||
totalPages: Math.ceil(watchlist.size / itemsPerPage),
|
||||
totalResults: watchlist.size,
|
||||
results: watchlist.items.map((item) => ({
|
||||
ratingKey: item.ratingKey,
|
||||
title: item.title,
|
||||
mediaType: item.type === 'show' ? 'tv' : 'movie',
|
||||
tmdbId: item.tmdbId,
|
||||
})),
|
||||
});
|
||||
});
|
||||
);
|
||||
|
||||
export default discoverRoutes;
|
||||
|
||||
@@ -7,6 +7,7 @@ import Media from '@server/entity/Media';
|
||||
import { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import { User } from '@server/entity/User';
|
||||
import { UserPushSubscription } from '@server/entity/UserPushSubscription';
|
||||
import type { WatchlistResponse } from '@server/interfaces/api/discoverInterfaces';
|
||||
import type {
|
||||
QuotaResponse,
|
||||
UserRequestsResponse,
|
||||
@@ -606,4 +607,60 @@ router.get<{ id: string }, UserWatchDataResponse>(
|
||||
}
|
||||
);
|
||||
|
||||
router.get<{ id: string; page?: number }, WatchlistResponse>(
|
||||
'/:id/watchlist',
|
||||
async (req, res, next) => {
|
||||
if (
|
||||
Number(req.params.id) !== req.user?.id &&
|
||||
!req.user?.hasPermission(
|
||||
[Permission.MANAGE_REQUESTS, Permission.WATCHLIST_VIEW],
|
||||
{
|
||||
type: 'or',
|
||||
}
|
||||
)
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message:
|
||||
"You do not have permission to view this user's Plex Watchlist.",
|
||||
});
|
||||
}
|
||||
|
||||
const itemsPerPage = 20;
|
||||
const page = req.params.page ?? 1;
|
||||
const offset = (page - 1) * itemsPerPage;
|
||||
|
||||
const user = await getRepository(User).findOneOrFail({
|
||||
where: { id: Number(req.params.id) },
|
||||
select: { id: true, plexToken: true },
|
||||
});
|
||||
|
||||
if (!user?.plexToken) {
|
||||
// We will just return an empty array if the user has no Plex token
|
||||
return res.json({
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
totalResults: 0,
|
||||
results: [],
|
||||
});
|
||||
}
|
||||
|
||||
const plexTV = new PlexTvAPI(user.plexToken);
|
||||
|
||||
const watchlist = await plexTV.getWatchlist({ offset });
|
||||
|
||||
return res.json({
|
||||
page,
|
||||
totalPages: Math.ceil(watchlist.size / itemsPerPage),
|
||||
totalResults: watchlist.size,
|
||||
results: watchlist.items.map((item) => ({
|
||||
ratingKey: item.ratingKey,
|
||||
title: item.title,
|
||||
mediaType: item.type === 'show' ? 'tv' : 'movie',
|
||||
tmdbId: item.tmdbId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { UserType } from '@server/constants/user';
|
||||
import dataSource, { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import { copyFileSync } from 'fs';
|
||||
import gravatarUrl from 'gravatar-url';
|
||||
import path from 'path';
|
||||
|
||||
const prepareDb = async () => {
|
||||
@@ -27,9 +28,17 @@ const prepareDb = async () => {
|
||||
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
const admin = await userRepository.findOne({
|
||||
select: { id: true, plexId: true },
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
// Create the admin user
|
||||
const user = new User();
|
||||
user.plexId = 1;
|
||||
const user =
|
||||
(await userRepository.findOne({
|
||||
where: { email: 'admin@seerr.dev' },
|
||||
})) ?? new User();
|
||||
user.plexId = admin?.plexId ?? 1;
|
||||
user.plexToken = '1234';
|
||||
user.plexUsername = 'admin';
|
||||
user.username = 'admin';
|
||||
@@ -37,12 +46,15 @@ const prepareDb = async () => {
|
||||
user.userType = UserType.PLEX;
|
||||
await user.setPassword('test1234');
|
||||
user.permissions = 2;
|
||||
user.avatar = 'https://plex.tv/assets/images/avatar/default.png';
|
||||
user.avatar = gravatarUrl('admin@seerr.dev', { default: 'mm', size: 200 });
|
||||
await userRepository.save(user);
|
||||
|
||||
// Create the other user
|
||||
const otherUser = new User();
|
||||
otherUser.plexId = 1;
|
||||
const otherUser =
|
||||
(await userRepository.findOne({
|
||||
where: { email: 'friend@seerr.dev' },
|
||||
})) ?? new User();
|
||||
otherUser.plexId = admin?.plexId ?? 1;
|
||||
otherUser.plexToken = '1234';
|
||||
otherUser.plexUsername = 'friend';
|
||||
otherUser.username = 'friend';
|
||||
@@ -50,7 +62,10 @@ const prepareDb = async () => {
|
||||
otherUser.userType = UserType.PLEX;
|
||||
await otherUser.setPassword('test1234');
|
||||
otherUser.permissions = 32;
|
||||
otherUser.avatar = 'https://plex.tv/assets/images/avatar/default.png';
|
||||
otherUser.avatar = gravatarUrl('friend@seerr.dev', {
|
||||
default: 'mm',
|
||||
size: 200,
|
||||
});
|
||||
await userRepository.save(otherUser);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,16 +2,25 @@ import Header from '@app/components/Common/Header';
|
||||
import ListView from '@app/components/Common/ListView';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
import useDiscover from '@app/hooks/useDiscover';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import Error from '@app/pages/_error';
|
||||
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
discoverwatchlist: 'Your Plex Watchlist',
|
||||
watchlist: 'Plex Watchlist',
|
||||
});
|
||||
|
||||
const DiscoverWatchlist = () => {
|
||||
const intl = useIntl();
|
||||
const router = useRouter();
|
||||
const { user } = useUser({
|
||||
id: Number(router.query.userId),
|
||||
});
|
||||
const { user: currentUser } = useUser();
|
||||
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
@@ -21,19 +30,43 @@ const DiscoverWatchlist = () => {
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<WatchlistItem>('/api/v1/discover/watchlist');
|
||||
} = useDiscover<WatchlistItem>(
|
||||
`/api/v1/${
|
||||
router.pathname.startsWith('/profile')
|
||||
? `user/${currentUser?.id}`
|
||||
: router.query.userId
|
||||
? `user/${router.query.userId}`
|
||||
: 'discover'
|
||||
}/watchlist`
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const title = intl.formatMessage(messages.discoverwatchlist);
|
||||
const title = intl.formatMessage(
|
||||
router.query.userId ? messages.watchlist : messages.discoverwatchlist
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={title} />
|
||||
<PageTitle
|
||||
title={[title, router.query.userId ? user?.displayName : '']}
|
||||
/>
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>{title}</Header>
|
||||
<Header
|
||||
subtext={
|
||||
router.query.userId ? (
|
||||
<Link href={`/users/${user?.id}`}>
|
||||
<a className="hover:underline">{user?.displayName}</a>
|
||||
</Link>
|
||||
) : (
|
||||
''
|
||||
)
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</Header>
|
||||
</div>
|
||||
<ListView
|
||||
plexItems={titles}
|
||||
|
||||
@@ -38,6 +38,7 @@ const UserDropdown = () => {
|
||||
aria-label="User menu"
|
||||
aria-haspopup="true"
|
||||
onClick={() => setDropdownOpen(true)}
|
||||
data-testid="user-menu"
|
||||
>
|
||||
<img
|
||||
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
|
||||
@@ -76,6 +77,7 @@ const UserDropdown = () => {
|
||||
}
|
||||
}}
|
||||
onClick={() => setDropdownOpen(false)}
|
||||
data-testid="user-menu-profile"
|
||||
>
|
||||
<UserIcon className="mr-2 inline h-5 w-5" />
|
||||
<span>{intl.formatMessage(messages.myprofile)}</span>
|
||||
@@ -92,6 +94,7 @@ const UserDropdown = () => {
|
||||
}
|
||||
}}
|
||||
onClick={() => setDropdownOpen(false)}
|
||||
data-testid="user-menu-settings"
|
||||
>
|
||||
<CogIcon className="mr-2 inline h-5 w-5" />
|
||||
<span>{intl.formatMessage(messages.settings)}</span>
|
||||
|
||||
@@ -72,6 +72,9 @@ export const messages = defineMessages({
|
||||
viewrecent: 'View Recently Added',
|
||||
viewrecentDescription:
|
||||
'Grant permission to view the list of recently added media.',
|
||||
viewwatchlists: 'View Plex Watchlists',
|
||||
viewwatchlistsDescription:
|
||||
"Grant permission to view other users' Plex Watchlists.",
|
||||
});
|
||||
|
||||
interface PermissionEditProps {
|
||||
@@ -126,6 +129,12 @@ export const PermissionEdit = ({
|
||||
description: intl.formatMessage(messages.viewrecentDescription),
|
||||
permission: Permission.RECENT_VIEW,
|
||||
},
|
||||
{
|
||||
id: 'viewwatchlists',
|
||||
name: intl.formatMessage(messages.viewwatchlists),
|
||||
description: intl.formatMessage(messages.viewwatchlistsDescription),
|
||||
permission: Permission.WATCHLIST_VIEW,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ import ProfileHeader from '@app/components/UserProfile/ProfileHeader';
|
||||
import { Permission, UserType, useUser } from '@app/hooks/useUser';
|
||||
import Error from '@app/pages/_error';
|
||||
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
|
||||
import type { WatchlistResponse } from '@server/interfaces/api/discoverInterfaces';
|
||||
import type {
|
||||
QuotaResponse,
|
||||
UserRequestsResponse,
|
||||
@@ -33,6 +34,7 @@ const messages = defineMessages({
|
||||
movierequests: 'Movie Requests',
|
||||
seriesrequest: 'Series Requests',
|
||||
recentlywatched: 'Recently Watched',
|
||||
plexwatchlist: 'Plex Watchlist',
|
||||
});
|
||||
|
||||
type MediaTitle = MovieDetails | TvDetails;
|
||||
@@ -74,6 +76,21 @@ const UserProfile = () => {
|
||||
? `/api/v1/user/${user.id}/watch_data`
|
||||
: null
|
||||
);
|
||||
const { data: watchlistItems, error: watchlistError } =
|
||||
useSWR<WatchlistResponse>(
|
||||
user?.id === currentUser?.id ||
|
||||
currentHasPermission(
|
||||
[Permission.MANAGE_REQUESTS, Permission.WATCHLIST_VIEW],
|
||||
{
|
||||
type: 'or',
|
||||
}
|
||||
)
|
||||
? `/api/v1/user/${user?.id}/watchlist`
|
||||
: null,
|
||||
{
|
||||
revalidateOnMount: true,
|
||||
}
|
||||
);
|
||||
|
||||
const updateAvailableTitles = useCallback(
|
||||
(requestId: number, mediaTitle: MediaTitle) => {
|
||||
@@ -277,6 +294,36 @@ const UserProfile = () => {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{(!watchlistItems || !!watchlistItems.results.length) && !watchlistError && (
|
||||
<>
|
||||
<div className="slider-header">
|
||||
<Link
|
||||
href={
|
||||
user.id === currentUser?.id
|
||||
? '/profile/watchlist'
|
||||
: `/users/${user?.id}/watchlist`
|
||||
}
|
||||
>
|
||||
<a className="slider-title">
|
||||
<span>{intl.formatMessage(messages.plexwatchlist)}</span>
|
||||
<ArrowCircleRightIcon />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="watchlist"
|
||||
isLoading={!watchlistItems && !watchlistError}
|
||||
items={watchlistItems?.results.map((item) => (
|
||||
<TmdbTitleCard
|
||||
id={item.tmdbId}
|
||||
key={`watchlist-slider-item-${item.ratingKey}`}
|
||||
tmdbId={item.tmdbId}
|
||||
type={item.mediaType}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{(user.id === currentUser?.id ||
|
||||
currentHasPermission(Permission.ADMIN)) &&
|
||||
!!watchData?.recentlyWatched.length && (
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"components.Discover.DiscoverTvGenre.genreSeries": "{genre} Series",
|
||||
"components.Discover.DiscoverTvLanguage.languageSeries": "{language} Series",
|
||||
"components.Discover.DiscoverWatchlist.discoverwatchlist": "Your Plex Watchlist",
|
||||
"components.Discover.DiscoverWatchlist.watchlist": "Plex Watchlist",
|
||||
"components.Discover.MovieGenreList.moviegenres": "Movie Genres",
|
||||
"components.Discover.MovieGenreSlider.moviegenres": "Movie Genres",
|
||||
"components.Discover.NetworkSlider.networks": "Networks",
|
||||
@@ -268,6 +269,8 @@
|
||||
"components.PermissionEdit.viewrecentDescription": "Grant permission to view the list of recently added media.",
|
||||
"components.PermissionEdit.viewrequests": "View Requests",
|
||||
"components.PermissionEdit.viewrequestsDescription": "Grant permission to view media requests submitted by other users.",
|
||||
"components.PermissionEdit.viewwatchlists": "View Plex Watchlists",
|
||||
"components.PermissionEdit.viewwatchlistsDescription": "Grant permission to view other users' Plex Watchlists.",
|
||||
"components.PersonDetails.alsoknownas": "Also Known As: {names}",
|
||||
"components.PersonDetails.appearsin": "Appearances",
|
||||
"components.PersonDetails.ascharacter": "as {character}",
|
||||
@@ -1015,6 +1018,7 @@
|
||||
"components.UserProfile.movierequests": "Movie Requests",
|
||||
"components.UserProfile.norequests": "No requests.",
|
||||
"components.UserProfile.pastdays": "{type} (past {days} days)",
|
||||
"components.UserProfile.plexwatchlist": "Plex Watchlist",
|
||||
"components.UserProfile.recentlywatched": "Recently Watched",
|
||||
"components.UserProfile.recentrequests": "Recent Requests",
|
||||
"components.UserProfile.requestsperdays": "{limit} remaining",
|
||||
|
||||
8
src/pages/profile/watchlist.tsx
Normal file
8
src/pages/profile/watchlist.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import DiscoverWatchlist from '@app/components/Discover/DiscoverWatchlist';
|
||||
import type { NextPage } from 'next';
|
||||
|
||||
const UserWatchlistPage: NextPage = () => {
|
||||
return <DiscoverWatchlist />;
|
||||
};
|
||||
|
||||
export default UserWatchlistPage;
|
||||
13
src/pages/users/[userId]/watchlist.tsx
Normal file
13
src/pages/users/[userId]/watchlist.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import DiscoverWatchlist from '@app/components/Discover/DiscoverWatchlist';
|
||||
import useRouteGuard from '@app/hooks/useRouteGuard';
|
||||
import { Permission } from '@app/hooks/useUser';
|
||||
import type { NextPage } from 'next';
|
||||
|
||||
const UserRequestsPage: NextPage = () => {
|
||||
useRouteGuard([Permission.MANAGE_REQUESTS, Permission.WATCHLIST_VIEW], {
|
||||
type: 'or',
|
||||
});
|
||||
return <DiscoverWatchlist />;
|
||||
};
|
||||
|
||||
export default UserRequestsPage;
|
||||
Reference in New Issue
Block a user