mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 02:39:18 -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', () => {
|
it('loads plex watchlist', () => {
|
||||||
cy.intercept('/api/v1/discover/watchlist', { fixture: 'watchlist' }).as(
|
cy.intercept('/api/v1/discover/watchlist', {
|
||||||
'getWatchlist'
|
fixture: 'watchlist.json',
|
||||||
);
|
}).as('getWatchlist');
|
||||||
// Wait for one of the watchlist movies to resolve
|
// Wait for one of the watchlist movies to resolve
|
||||||
cy.intercept('/api/v1/movie/361743').as('getTmdbMovie');
|
cy.intercept('/api/v1/movie/361743').as('getTmdbMovie');
|
||||||
|
|
||||||
@@ -183,7 +183,7 @@ describe('Discover', () => {
|
|||||||
|
|
||||||
cy.wait('@getWatchlist');
|
cy.wait('@getWatchlist');
|
||||||
|
|
||||||
const sliderHeader = cy.contains('.slider-header', 'Plex Watchlist');
|
const sliderHeader = cy.contains('.slider-header', 'Your Plex Watchlist');
|
||||||
|
|
||||||
sliderHeader.scrollIntoView();
|
sliderHeader.scrollIntoView();
|
||||||
|
|
||||||
@@ -203,7 +203,6 @@ describe('Discover', () => {
|
|||||||
.next('[data-testid=media-slider]')
|
.next('[data-testid=media-slider]')
|
||||||
.find('[data-testid=title-card]')
|
.find('[data-testid=title-card]')
|
||||||
.first()
|
.first()
|
||||||
.click()
|
|
||||||
.click();
|
.click();
|
||||||
cy.get('[data-testid=media-title]').should('contain', text);
|
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,
|
"page": 1,
|
||||||
"totalPages": 1,
|
"totalPages": 1,
|
||||||
"totalResults": 20,
|
"totalResults": 3,
|
||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"ratingKey": "5d776be17a53e9001e732ab9",
|
"ratingKey": "5d776be17a53e9001e732ab9",
|
||||||
|
|||||||
@@ -3512,6 +3512,53 @@ paths:
|
|||||||
restricted:
|
restricted:
|
||||||
type: boolean
|
type: boolean
|
||||||
example: false
|
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:
|
/user/{userId}/settings/main:
|
||||||
get:
|
get:
|
||||||
summary: Get general settings for a user
|
summary: Get general settings for a user
|
||||||
|
|||||||
@@ -10,3 +10,10 @@ export interface WatchlistItem {
|
|||||||
mediaType: 'movie' | 'tv';
|
mediaType: 'movie' | 'tv';
|
||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WatchlistResponse {
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalResults: number;
|
||||||
|
results: WatchlistItem[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface QuotaResponse {
|
|||||||
movie: QuotaStatus;
|
movie: QuotaStatus;
|
||||||
tv: QuotaStatus;
|
tv: QuotaStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserWatchDataResponse {
|
export interface UserWatchDataResponse {
|
||||||
recentlyWatched: Media[];
|
recentlyWatched: Media[];
|
||||||
playCount: number;
|
playCount: number;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export enum Permission {
|
|||||||
AUTO_REQUEST_MOVIE = 16777216,
|
AUTO_REQUEST_MOVIE = 16777216,
|
||||||
AUTO_REQUEST_TV = 33554432,
|
AUTO_REQUEST_TV = 33554432,
|
||||||
RECENT_VIEW = 67108864,
|
RECENT_VIEW = 67108864,
|
||||||
|
WATCHLIST_VIEW = 134217728,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PermissionCheckOptions {
|
export interface PermissionCheckOptions {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Media from '@server/entity/Media';
|
|||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
import type {
|
import type {
|
||||||
GenreSliderItem,
|
GenreSliderItem,
|
||||||
WatchlistItem,
|
WatchlistResponse,
|
||||||
} from '@server/interfaces/api/discoverInterfaces';
|
} from '@server/interfaces/api/discoverInterfaces';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
@@ -713,50 +713,45 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
discoverRoutes.get<
|
discoverRoutes.get<{ page?: number }, WatchlistResponse>(
|
||||||
{ page?: number },
|
'/watchlist',
|
||||||
{
|
async (req, res) => {
|
||||||
page: number;
|
const userRepository = getRepository(User);
|
||||||
totalPages: number;
|
const itemsPerPage = 20;
|
||||||
totalResults: number;
|
const page = req.params.page ?? 1;
|
||||||
results: WatchlistItem[];
|
const offset = (page - 1) * itemsPerPage;
|
||||||
}
|
|
||||||
>('/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({
|
const activeUser = await userRepository.findOne({
|
||||||
where: { id: req.user?.id },
|
where: { id: req.user?.id },
|
||||||
select: ['id', 'plexToken'],
|
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({
|
return res.json({
|
||||||
page: 1,
|
page,
|
||||||
totalPages: 1,
|
totalPages: Math.ceil(watchlist.size / itemsPerPage),
|
||||||
totalResults: 0,
|
totalResults: watchlist.size,
|
||||||
results: [],
|
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;
|
export default discoverRoutes;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Media from '@server/entity/Media';
|
|||||||
import { MediaRequest } from '@server/entity/MediaRequest';
|
import { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
import { UserPushSubscription } from '@server/entity/UserPushSubscription';
|
import { UserPushSubscription } from '@server/entity/UserPushSubscription';
|
||||||
|
import type { WatchlistResponse } from '@server/interfaces/api/discoverInterfaces';
|
||||||
import type {
|
import type {
|
||||||
QuotaResponse,
|
QuotaResponse,
|
||||||
UserRequestsResponse,
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { UserType } from '@server/constants/user';
|
|||||||
import dataSource, { getRepository } from '@server/datasource';
|
import dataSource, { getRepository } from '@server/datasource';
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
import { copyFileSync } from 'fs';
|
import { copyFileSync } from 'fs';
|
||||||
|
import gravatarUrl from 'gravatar-url';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
const prepareDb = async () => {
|
const prepareDb = async () => {
|
||||||
@@ -27,9 +28,17 @@ const prepareDb = async () => {
|
|||||||
|
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
|
|
||||||
|
const admin = await userRepository.findOne({
|
||||||
|
select: { id: true, plexId: true },
|
||||||
|
where: { id: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
// Create the admin user
|
// Create the admin user
|
||||||
const user = new User();
|
const user =
|
||||||
user.plexId = 1;
|
(await userRepository.findOne({
|
||||||
|
where: { email: 'admin@seerr.dev' },
|
||||||
|
})) ?? new User();
|
||||||
|
user.plexId = admin?.plexId ?? 1;
|
||||||
user.plexToken = '1234';
|
user.plexToken = '1234';
|
||||||
user.plexUsername = 'admin';
|
user.plexUsername = 'admin';
|
||||||
user.username = 'admin';
|
user.username = 'admin';
|
||||||
@@ -37,12 +46,15 @@ const prepareDb = async () => {
|
|||||||
user.userType = UserType.PLEX;
|
user.userType = UserType.PLEX;
|
||||||
await user.setPassword('test1234');
|
await user.setPassword('test1234');
|
||||||
user.permissions = 2;
|
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);
|
await userRepository.save(user);
|
||||||
|
|
||||||
// Create the other user
|
// Create the other user
|
||||||
const otherUser = new User();
|
const otherUser =
|
||||||
otherUser.plexId = 1;
|
(await userRepository.findOne({
|
||||||
|
where: { email: 'friend@seerr.dev' },
|
||||||
|
})) ?? new User();
|
||||||
|
otherUser.plexId = admin?.plexId ?? 1;
|
||||||
otherUser.plexToken = '1234';
|
otherUser.plexToken = '1234';
|
||||||
otherUser.plexUsername = 'friend';
|
otherUser.plexUsername = 'friend';
|
||||||
otherUser.username = 'friend';
|
otherUser.username = 'friend';
|
||||||
@@ -50,7 +62,10 @@ const prepareDb = async () => {
|
|||||||
otherUser.userType = UserType.PLEX;
|
otherUser.userType = UserType.PLEX;
|
||||||
await otherUser.setPassword('test1234');
|
await otherUser.setPassword('test1234');
|
||||||
otherUser.permissions = 32;
|
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);
|
await userRepository.save(otherUser);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,25 @@ 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 from '@app/hooks/useDiscover';
|
||||||
|
import { useUser } from '@app/hooks/useUser';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
discoverwatchlist: 'Your Plex Watchlist',
|
discoverwatchlist: 'Your Plex Watchlist',
|
||||||
|
watchlist: 'Plex Watchlist',
|
||||||
});
|
});
|
||||||
|
|
||||||
const DiscoverWatchlist = () => {
|
const DiscoverWatchlist = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
const router = useRouter();
|
||||||
|
const { user } = useUser({
|
||||||
|
id: Number(router.query.userId),
|
||||||
|
});
|
||||||
|
const { user: currentUser } = useUser();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isLoadingInitialData,
|
isLoadingInitialData,
|
||||||
@@ -21,19 +30,43 @@ const DiscoverWatchlist = () => {
|
|||||||
titles,
|
titles,
|
||||||
fetchMore,
|
fetchMore,
|
||||||
error,
|
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) {
|
if (error) {
|
||||||
return <Error statusCode={500} />;
|
return <Error statusCode={500} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = intl.formatMessage(messages.discoverwatchlist);
|
const title = intl.formatMessage(
|
||||||
|
router.query.userId ? messages.watchlist : messages.discoverwatchlist
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle title={title} />
|
<PageTitle
|
||||||
|
title={[title, router.query.userId ? user?.displayName : '']}
|
||||||
|
/>
|
||||||
<div className="mt-1 mb-5">
|
<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>
|
</div>
|
||||||
<ListView
|
<ListView
|
||||||
plexItems={titles}
|
plexItems={titles}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const UserDropdown = () => {
|
|||||||
aria-label="User menu"
|
aria-label="User menu"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
onClick={() => setDropdownOpen(true)}
|
onClick={() => setDropdownOpen(true)}
|
||||||
|
data-testid="user-menu"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
|
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
|
||||||
@@ -76,6 +77,7 @@ const UserDropdown = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClick={() => setDropdownOpen(false)}
|
onClick={() => setDropdownOpen(false)}
|
||||||
|
data-testid="user-menu-profile"
|
||||||
>
|
>
|
||||||
<UserIcon className="mr-2 inline h-5 w-5" />
|
<UserIcon className="mr-2 inline h-5 w-5" />
|
||||||
<span>{intl.formatMessage(messages.myprofile)}</span>
|
<span>{intl.formatMessage(messages.myprofile)}</span>
|
||||||
@@ -92,6 +94,7 @@ const UserDropdown = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClick={() => setDropdownOpen(false)}
|
onClick={() => setDropdownOpen(false)}
|
||||||
|
data-testid="user-menu-settings"
|
||||||
>
|
>
|
||||||
<CogIcon className="mr-2 inline h-5 w-5" />
|
<CogIcon className="mr-2 inline h-5 w-5" />
|
||||||
<span>{intl.formatMessage(messages.settings)}</span>
|
<span>{intl.formatMessage(messages.settings)}</span>
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ export const messages = defineMessages({
|
|||||||
viewrecent: 'View Recently Added',
|
viewrecent: 'View Recently Added',
|
||||||
viewrecentDescription:
|
viewrecentDescription:
|
||||||
'Grant permission to view the list of recently added media.',
|
'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 {
|
interface PermissionEditProps {
|
||||||
@@ -126,6 +129,12 @@ export const PermissionEdit = ({
|
|||||||
description: intl.formatMessage(messages.viewrecentDescription),
|
description: intl.formatMessage(messages.viewrecentDescription),
|
||||||
permission: Permission.RECENT_VIEW,
|
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 { Permission, UserType, useUser } from '@app/hooks/useUser';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
|
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
|
||||||
|
import type { WatchlistResponse } from '@server/interfaces/api/discoverInterfaces';
|
||||||
import type {
|
import type {
|
||||||
QuotaResponse,
|
QuotaResponse,
|
||||||
UserRequestsResponse,
|
UserRequestsResponse,
|
||||||
@@ -33,6 +34,7 @@ const messages = defineMessages({
|
|||||||
movierequests: 'Movie Requests',
|
movierequests: 'Movie Requests',
|
||||||
seriesrequest: 'Series Requests',
|
seriesrequest: 'Series Requests',
|
||||||
recentlywatched: 'Recently Watched',
|
recentlywatched: 'Recently Watched',
|
||||||
|
plexwatchlist: 'Plex Watchlist',
|
||||||
});
|
});
|
||||||
|
|
||||||
type MediaTitle = MovieDetails | TvDetails;
|
type MediaTitle = MovieDetails | TvDetails;
|
||||||
@@ -74,6 +76,21 @@ const UserProfile = () => {
|
|||||||
? `/api/v1/user/${user.id}/watch_data`
|
? `/api/v1/user/${user.id}/watch_data`
|
||||||
: null
|
: 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(
|
const updateAvailableTitles = useCallback(
|
||||||
(requestId: number, mediaTitle: MediaTitle) => {
|
(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 ||
|
{(user.id === currentUser?.id ||
|
||||||
currentHasPermission(Permission.ADMIN)) &&
|
currentHasPermission(Permission.ADMIN)) &&
|
||||||
!!watchData?.recentlyWatched.length && (
|
!!watchData?.recentlyWatched.length && (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"components.Discover.DiscoverTvGenre.genreSeries": "{genre} Series",
|
"components.Discover.DiscoverTvGenre.genreSeries": "{genre} Series",
|
||||||
"components.Discover.DiscoverTvLanguage.languageSeries": "{language} Series",
|
"components.Discover.DiscoverTvLanguage.languageSeries": "{language} Series",
|
||||||
"components.Discover.DiscoverWatchlist.discoverwatchlist": "Your Plex Watchlist",
|
"components.Discover.DiscoverWatchlist.discoverwatchlist": "Your Plex Watchlist",
|
||||||
|
"components.Discover.DiscoverWatchlist.watchlist": "Plex Watchlist",
|
||||||
"components.Discover.MovieGenreList.moviegenres": "Movie Genres",
|
"components.Discover.MovieGenreList.moviegenres": "Movie Genres",
|
||||||
"components.Discover.MovieGenreSlider.moviegenres": "Movie Genres",
|
"components.Discover.MovieGenreSlider.moviegenres": "Movie Genres",
|
||||||
"components.Discover.NetworkSlider.networks": "Networks",
|
"components.Discover.NetworkSlider.networks": "Networks",
|
||||||
@@ -268,6 +269,8 @@
|
|||||||
"components.PermissionEdit.viewrecentDescription": "Grant permission to view the list of recently added media.",
|
"components.PermissionEdit.viewrecentDescription": "Grant permission to view the list of recently added media.",
|
||||||
"components.PermissionEdit.viewrequests": "View Requests",
|
"components.PermissionEdit.viewrequests": "View Requests",
|
||||||
"components.PermissionEdit.viewrequestsDescription": "Grant permission to view media requests submitted by other users.",
|
"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.alsoknownas": "Also Known As: {names}",
|
||||||
"components.PersonDetails.appearsin": "Appearances",
|
"components.PersonDetails.appearsin": "Appearances",
|
||||||
"components.PersonDetails.ascharacter": "as {character}",
|
"components.PersonDetails.ascharacter": "as {character}",
|
||||||
@@ -1015,6 +1018,7 @@
|
|||||||
"components.UserProfile.movierequests": "Movie Requests",
|
"components.UserProfile.movierequests": "Movie Requests",
|
||||||
"components.UserProfile.norequests": "No requests.",
|
"components.UserProfile.norequests": "No requests.",
|
||||||
"components.UserProfile.pastdays": "{type} (past {days} days)",
|
"components.UserProfile.pastdays": "{type} (past {days} days)",
|
||||||
|
"components.UserProfile.plexwatchlist": "Plex Watchlist",
|
||||||
"components.UserProfile.recentlywatched": "Recently Watched",
|
"components.UserProfile.recentlywatched": "Recently Watched",
|
||||||
"components.UserProfile.recentrequests": "Recent Requests",
|
"components.UserProfile.recentrequests": "Recent Requests",
|
||||||
"components.UserProfile.requestsperdays": "{limit} remaining",
|
"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