mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
feat: upcoming/trending list views and larger title cards
This commit is contained in:
21
src/components/Common/Header/index.tsx
Normal file
21
src/components/Common/Header/index.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
interface HeaderProps {
|
||||
extraMargin?: number;
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({ children, extraMargin = 0 }) => {
|
||||
return (
|
||||
<div className="md:flex md:items-center md:justify-between mt-8 mb-8">
|
||||
<div className={`flex-1 min-w-0 mx-${extraMargin}`}>
|
||||
<h2 className="text-2xl font-bold leading-7 text-cool-gray-100 sm:text-4xl sm:leading-9 truncate sm:overflow-visible">
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-br from-indigo-400 to-purple-400">
|
||||
{children}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
@@ -31,7 +31,7 @@ const ListView: React.FC<ListViewProps> = ({
|
||||
No Results
|
||||
</div>
|
||||
)}
|
||||
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7">
|
||||
{items?.map((title) => {
|
||||
let titleCard: React.ReactNode;
|
||||
|
||||
@@ -47,6 +47,7 @@ const ListView: React.FC<ListViewProps> = ({
|
||||
userScore={title.voteAverage}
|
||||
year={title.releaseDate}
|
||||
mediaType={title.mediaType}
|
||||
canExpand
|
||||
/>
|
||||
);
|
||||
break;
|
||||
@@ -61,12 +62,17 @@ const ListView: React.FC<ListViewProps> = ({
|
||||
userScore={title.voteAverage}
|
||||
year={title.firstAirDate}
|
||||
mediaType={title.mediaType}
|
||||
canExpand
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'person':
|
||||
titleCard = (
|
||||
<PersonCard name={title.name} profilePath={title.profilePath} />
|
||||
<PersonCard
|
||||
name={title.name}
|
||||
profilePath={title.profilePath}
|
||||
canExpand
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -82,12 +88,12 @@ const ListView: React.FC<ListViewProps> = ({
|
||||
})}
|
||||
{isLoading &&
|
||||
!isReachingEnd &&
|
||||
[...Array(10)].map((_item, i) => (
|
||||
[...Array(20)].map((_item, i) => (
|
||||
<li
|
||||
key={`placeholder-${i}`}
|
||||
className="col-span-1 flex flex-col text-center items-center"
|
||||
>
|
||||
<TitleCard.Placeholder />
|
||||
<TitleCard.Placeholder canExpand />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
@@ -4,9 +4,10 @@ import type { MovieResult } from '../../../server/models/Search';
|
||||
import ListView from '../Common/ListView';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import { defineMessages, FormattedMessage } from 'react-intl';
|
||||
import Header from '../Common/Header';
|
||||
|
||||
const messages = defineMessages({
|
||||
discovermovies: 'Discover Movies',
|
||||
discovermovies: 'Popular Movies',
|
||||
});
|
||||
|
||||
interface SearchResult {
|
||||
@@ -55,13 +56,9 @@ const DiscoverMovies: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="md:flex md:items-center md:justify-between mb-8 mt-6">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl leading-7 text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<FormattedMessage {...messages.discovermovies} />
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<Header>
|
||||
<FormattedMessage {...messages.discovermovies} />
|
||||
</Header>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
|
@@ -1,12 +1,13 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { useSWRInfinite } from 'swr';
|
||||
import { TvResult } from '../../../server/models/Search';
|
||||
import type { TvResult } from '../../../server/models/Search';
|
||||
import ListView from '../Common/ListView';
|
||||
import { defineMessages, FormattedMessage } from 'react-intl';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import Header from '../Common/Header';
|
||||
|
||||
const messages = defineMessages({
|
||||
discovertv: 'Discover Series',
|
||||
discovertv: 'Popular Series',
|
||||
});
|
||||
|
||||
interface SearchResult {
|
||||
@@ -52,13 +53,9 @@ const DiscoverTv: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="md:flex md:items-center md:justify-between mb-8 mt-6">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl leading-7 text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<FormattedMessage {...messages.discovertv} />
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<Header>
|
||||
<FormattedMessage {...messages.discovertv} />
|
||||
</Header>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
|
81
src/components/Discover/Trending.tsx
Normal file
81
src/components/Discover/Trending.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { useSWRInfinite } from 'swr';
|
||||
import type {
|
||||
MovieResult,
|
||||
TvResult,
|
||||
PersonResult,
|
||||
} from '../../../server/models/Search';
|
||||
import ListView from '../Common/ListView';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import { defineMessages, FormattedMessage } from 'react-intl';
|
||||
import Header from '../Common/Header';
|
||||
|
||||
const messages = defineMessages({
|
||||
trending: 'Trending',
|
||||
});
|
||||
|
||||
interface SearchResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: (MovieResult | TvResult | PersonResult)[];
|
||||
}
|
||||
|
||||
const Trending: React.FC = () => {
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/api/v1/discover/trending?page=${
|
||||
pageIndex + 1
|
||||
}&language=${locale}`;
|
||||
},
|
||||
{
|
||||
initialSize: 3,
|
||||
}
|
||||
);
|
||||
|
||||
const isLoadingInitialData = !data && !error;
|
||||
const isLoadingMore =
|
||||
isLoadingInitialData ||
|
||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
||||
|
||||
const fetchMore = () => {
|
||||
setSize(size + 1);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return <div>{error}</div>;
|
||||
}
|
||||
|
||||
const titles = data?.reduce(
|
||||
(a, v) => [...a, ...v.results],
|
||||
[] as (MovieResult | TvResult | PersonResult)[]
|
||||
);
|
||||
|
||||
const isEmpty = !isLoadingInitialData && titles?.length === 0;
|
||||
const isReachingEnd =
|
||||
isEmpty || (data && data[data.length - 1]?.results.length < 20);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<FormattedMessage {...messages.trending} />
|
||||
</Header>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
isReachingEnd={isReachingEnd}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Trending;
|
77
src/components/Discover/Upcoming.tsx
Normal file
77
src/components/Discover/Upcoming.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { useSWRInfinite } from 'swr';
|
||||
import type { MovieResult } from '../../../server/models/Search';
|
||||
import ListView from '../Common/ListView';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import { defineMessages, FormattedMessage } from 'react-intl';
|
||||
import Header from '../Common/Header';
|
||||
|
||||
const messages = defineMessages({
|
||||
upcomingmovies: 'Upcoming Movies',
|
||||
});
|
||||
|
||||
interface SearchResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: MovieResult[];
|
||||
}
|
||||
|
||||
const UpcomingMovies: React.FC = () => {
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/api/v1/discover/movies/upcoming?page=${
|
||||
pageIndex + 1
|
||||
}&language=${locale}`;
|
||||
},
|
||||
{
|
||||
initialSize: 3,
|
||||
}
|
||||
);
|
||||
|
||||
const isLoadingInitialData = !data && !error;
|
||||
const isLoadingMore =
|
||||
isLoadingInitialData ||
|
||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
||||
|
||||
const fetchMore = () => {
|
||||
setSize(size + 1);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return <div>{error}</div>;
|
||||
}
|
||||
|
||||
const titles = data?.reduce(
|
||||
(a, v) => [...a, ...v.results],
|
||||
[] as MovieResult[]
|
||||
);
|
||||
|
||||
const isEmpty = !isLoadingInitialData && titles?.length === 0;
|
||||
const isReachingEnd =
|
||||
isEmpty || (data && data[data.length - 1]?.results.length < 20);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<FormattedMessage {...messages.upcomingmovies} />
|
||||
</Header>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
isReachingEnd={isReachingEnd}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpcomingMovies;
|
@@ -77,27 +77,11 @@ const Discover: React.FC = () => {
|
||||
<>
|
||||
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link href="/recent">
|
||||
<a className="inline-flex text-xl leading-7 text-cool-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
|
||||
<span>
|
||||
<FormattedMessage {...messages.recentlyAdded} />
|
||||
</span>
|
||||
<svg
|
||||
className="w-6 h-6 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
<div className="inline-flex text-xl leading-7 text-cool-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
|
||||
<span>
|
||||
<FormattedMessage {...messages.recentlyAdded} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
|
@@ -4,15 +4,21 @@ interface PersonCardProps {
|
||||
name: string;
|
||||
subName?: string;
|
||||
profilePath?: string;
|
||||
canExpand?: boolean;
|
||||
}
|
||||
|
||||
const PersonCard: React.FC<PersonCardProps> = ({
|
||||
name,
|
||||
subName,
|
||||
profilePath,
|
||||
canExpand = false,
|
||||
}) => {
|
||||
return (
|
||||
<div className="relative w-36 sm:w-36 md:w-44 bg-cool-gray-600 rounded-lg text-white shadow-lg hover:bg-cool-gray-500 transition ease-in-out duration-150 cursor-pointer">
|
||||
<div
|
||||
className={`relative ${
|
||||
canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'
|
||||
} bg-cool-gray-600 rounded-lg text-white shadow-lg hover:bg-cool-gray-500 transition ease-in-out duration-150 cursor-pointer`}
|
||||
>
|
||||
<div style={{ paddingBottom: '150%' }}>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
{profilePath && (
|
||||
|
@@ -1,8 +1,16 @@
|
||||
import React from 'react';
|
||||
|
||||
const Placeholder: React.FC = () => {
|
||||
interface PlaceholderProps {
|
||||
canExpand?: boolean;
|
||||
}
|
||||
|
||||
const Placeholder: React.FC<PlaceholderProps> = ({ canExpand = false }) => {
|
||||
return (
|
||||
<div className="relative animate-pulse rounded-lg bg-cool-gray-700 w-36 sm:w-36 md:w-44 ">
|
||||
<div
|
||||
className={`relative animate-pulse rounded-lg bg-cool-gray-700 ${
|
||||
canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'
|
||||
}`}
|
||||
>
|
||||
<div className="w-full" style={{ paddingBottom: '150%' }} />
|
||||
</div>
|
||||
);
|
||||
|
@@ -19,7 +19,7 @@ interface TitleCardProps {
|
||||
userScore: number;
|
||||
mediaType: MediaType;
|
||||
status?: MediaStatus;
|
||||
requestId?: number;
|
||||
canExpand?: boolean;
|
||||
}
|
||||
|
||||
const TitleCard: React.FC<TitleCardProps> = ({
|
||||
@@ -30,7 +30,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
|
||||
title,
|
||||
status,
|
||||
mediaType,
|
||||
requestId,
|
||||
canExpand = false,
|
||||
}) => {
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [currentStatus, setCurrentStatus] = useState(status);
|
||||
@@ -55,7 +55,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
|
||||
const closeModal = useCallback(() => setShowRequestModal(false), []);
|
||||
|
||||
return (
|
||||
<div className="w-36 sm:w-36 md:w-44">
|
||||
<div className={canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'}>
|
||||
<RequestModal
|
||||
tmdbId={id}
|
||||
show={showRequestModal}
|
||||
|
@@ -7,6 +7,7 @@ import Button from '../Common/Button';
|
||||
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
import axios from 'axios';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import Header from '../Common/Header';
|
||||
|
||||
const messages = defineMessages({
|
||||
edituser: 'Edit User',
|
||||
@@ -143,184 +144,183 @@ const UserEdit: React.FC = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="py-6 px-4 space-y-6 sm:p-6 lg:pb-8">
|
||||
<div className="md:flex md:items-center md:justify-between mt-8 mb-6">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-2xl font-bold leading-7 text-cool-gray-100 sm:text-3xl sm:leading-9 sm:truncate">
|
||||
<FormattedMessage {...messages.edituser} />
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-6 lg:flex-row lg:space-y-0 lg:space-x-6 text-white">
|
||||
<div className="flex-grow space-y-6">
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block text-sm font-medium leading-5 text-cool-gray-400"
|
||||
>
|
||||
<FormattedMessage {...messages.username} />
|
||||
</label>
|
||||
<div className="rounded-md shadow-sm flex">
|
||||
<input
|
||||
id="username"
|
||||
className="form-input flex-grow block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-cool-gray-700 border border-cool-gray-500"
|
||||
value={user?.username}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium leading-5 text-cool-gray-400"
|
||||
>
|
||||
<FormattedMessage {...messages.email} />
|
||||
</label>
|
||||
<div className="rounded-md shadow-sm flex">
|
||||
<input
|
||||
id="email"
|
||||
className="form-input flex-grow block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-cool-gray-700 border border-cool-gray-500"
|
||||
value={user?.email}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow space-y-1 lg:flex-grow-0 lg:flex-shrink-0">
|
||||
<p
|
||||
className="block text-sm leading-5 font-medium text-cool-gray-400"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<FormattedMessage {...messages.avatar} />
|
||||
</p>
|
||||
<div className="lg:hidden">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="flex-shrink-0 inline-block rounded-full overflow-hidden h-12 w-12"
|
||||
aria-hidden="true"
|
||||
<>
|
||||
<Header extraMargin={4}>Edit User</Header>
|
||||
<div className="px-4 space-y-6 sm:p-6 lg:pb-8">
|
||||
<div className="flex flex-col space-y-6 lg:flex-row lg:space-y-0 lg:space-x-6 text-white">
|
||||
<div className="flex-grow space-y-6">
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block text-sm font-medium leading-5 text-cool-gray-400"
|
||||
>
|
||||
<img
|
||||
className="rounded-full h-full w-full"
|
||||
src={user?.avatar}
|
||||
alt=""
|
||||
<FormattedMessage {...messages.username} />
|
||||
</label>
|
||||
<div className="rounded-md shadow-sm flex">
|
||||
<input
|
||||
id="username"
|
||||
className="form-input flex-grow block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-cool-gray-700 border border-cool-gray-500"
|
||||
value={user?.username}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium leading-5 text-cool-gray-400"
|
||||
>
|
||||
<FormattedMessage {...messages.email} />
|
||||
</label>
|
||||
<div className="rounded-md shadow-sm flex">
|
||||
<input
|
||||
id="email"
|
||||
className="form-input flex-grow block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-cool-gray-700 border border-cool-gray-500"
|
||||
value={user?.email}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden relative rounded-full overflow-hidden lg:block transition duration-150 ease-in-out">
|
||||
<img
|
||||
className="relative rounded-full w-40 h-40"
|
||||
src={user?.avatar}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-white">
|
||||
<div className="sm:border-t sm:border-gray-200 sm:pt-5">
|
||||
<div role="group" aria-labelledby="label-permissions">
|
||||
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-baseline">
|
||||
<div>
|
||||
<div className="flex-grow space-y-1 lg:flex-grow-0 lg:flex-shrink-0">
|
||||
<p
|
||||
className="block text-sm leading-5 font-medium text-cool-gray-400"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<FormattedMessage {...messages.avatar} />
|
||||
</p>
|
||||
<div className="lg:hidden">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="text-base leading-6 font-medium sm:text-sm sm:leading-5"
|
||||
id="label-permissions"
|
||||
className="flex-shrink-0 inline-block rounded-full overflow-hidden h-12 w-12"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<FormattedMessage {...messages.permissions} />
|
||||
<img
|
||||
className="rounded-full h-full w-full"
|
||||
src={user?.avatar}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg">
|
||||
{permissionList.map((permissionOption) => (
|
||||
<div
|
||||
className={`relative flex items-start first:mt-0 mt-4 ${
|
||||
(permissionOption.permission !== Permission.ADMIN &&
|
||||
hasPermission(Permission.ADMIN, currentPermission)) ||
|
||||
(currentUser?.id !== 1 &&
|
||||
permissionOption.permission === Permission.ADMIN) ||
|
||||
(!currentHasPermission(Permission.MANAGE_SETTINGS) &&
|
||||
permissionOption.permission ===
|
||||
Permission.MANAGE_SETTINGS)
|
||||
? 'opacity-50'
|
||||
: ''
|
||||
}`}
|
||||
key={`permission-option-${permissionOption.id}`}
|
||||
>
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
id={permissionOption.id}
|
||||
name="permissions"
|
||||
type="checkbox"
|
||||
className="form-checkbox h-4 w-4 text-indigo-600 transition duration-150 ease-in-out"
|
||||
disabled={
|
||||
(permissionOption.permission !== Permission.ADMIN &&
|
||||
hasPermission(
|
||||
Permission.ADMIN,
|
||||
currentPermission
|
||||
)) ||
|
||||
(currentUser?.id !== 1 &&
|
||||
permissionOption.permission ===
|
||||
Permission.ADMIN) ||
|
||||
(!currentHasPermission(
|
||||
Permission.MANAGE_SETTINGS
|
||||
) &&
|
||||
permissionOption.permission ===
|
||||
Permission.MANAGE_SETTINGS)
|
||||
}
|
||||
onClick={() => {
|
||||
setCurrentPermission((current) =>
|
||||
hasPermission(
|
||||
permissionOption.permission,
|
||||
currentPermission
|
||||
)
|
||||
? current - permissionOption.permission
|
||||
: current + permissionOption.permission
|
||||
);
|
||||
}}
|
||||
checked={hasPermission(
|
||||
permissionOption.permission,
|
||||
currentPermission
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="hidden relative rounded-full overflow-hidden lg:block transition duration-150 ease-in-out">
|
||||
<img
|
||||
className="relative rounded-full w-40 h-40"
|
||||
src={user?.avatar}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-white">
|
||||
<div className="sm:border-t sm:border-gray-200 sm:pt-5">
|
||||
<div role="group" aria-labelledby="label-permissions">
|
||||
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-baseline">
|
||||
<div>
|
||||
<div
|
||||
className="text-base leading-6 font-medium sm:text-sm sm:leading-5"
|
||||
id="label-permissions"
|
||||
>
|
||||
<FormattedMessage {...messages.permissions} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg">
|
||||
{permissionList.map((permissionOption) => (
|
||||
<div
|
||||
className={`relative flex items-start first:mt-0 mt-4 ${
|
||||
(permissionOption.permission !== Permission.ADMIN &&
|
||||
hasPermission(
|
||||
Permission.ADMIN,
|
||||
currentPermission
|
||||
)) ||
|
||||
(currentUser?.id !== 1 &&
|
||||
permissionOption.permission === Permission.ADMIN) ||
|
||||
(!currentHasPermission(Permission.MANAGE_SETTINGS) &&
|
||||
permissionOption.permission ===
|
||||
Permission.MANAGE_SETTINGS)
|
||||
? 'opacity-50'
|
||||
: ''
|
||||
}`}
|
||||
key={`permission-option-${permissionOption.id}`}
|
||||
>
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
id={permissionOption.id}
|
||||
name="permissions"
|
||||
type="checkbox"
|
||||
className="form-checkbox h-4 w-4 text-indigo-600 transition duration-150 ease-in-out"
|
||||
disabled={
|
||||
(permissionOption.permission !==
|
||||
Permission.ADMIN &&
|
||||
hasPermission(
|
||||
Permission.ADMIN,
|
||||
currentPermission
|
||||
)) ||
|
||||
(currentUser?.id !== 1 &&
|
||||
permissionOption.permission ===
|
||||
Permission.ADMIN) ||
|
||||
(!currentHasPermission(
|
||||
Permission.MANAGE_SETTINGS
|
||||
) &&
|
||||
permissionOption.permission ===
|
||||
Permission.MANAGE_SETTINGS)
|
||||
}
|
||||
onClick={() => {
|
||||
setCurrentPermission((current) =>
|
||||
hasPermission(
|
||||
permissionOption.permission,
|
||||
currentPermission
|
||||
)
|
||||
? current - permissionOption.permission
|
||||
: current + permissionOption.permission
|
||||
);
|
||||
}}
|
||||
checked={hasPermission(
|
||||
permissionOption.permission,
|
||||
currentPermission
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm leading-5">
|
||||
<label
|
||||
htmlFor={permissionOption.id}
|
||||
className="font-medium"
|
||||
>
|
||||
{permissionOption.name}
|
||||
</label>
|
||||
<p className="text-gray-500">
|
||||
{permissionOption.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-3 text-sm leading-5">
|
||||
<label
|
||||
htmlFor={permissionOption.id}
|
||||
className="font-medium"
|
||||
>
|
||||
{permissionOption.name}
|
||||
</label>
|
||||
<p className="text-gray-500">
|
||||
{permissionOption.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 border-t border-cool-gray-700 pt-5">
|
||||
<div className="flex justify-end">
|
||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isUpdating}
|
||||
onClick={() => updateUser()}
|
||||
>
|
||||
{isUpdating
|
||||
? intl.formatMessage(messages.saving)
|
||||
: intl.formatMessage(messages.save)}
|
||||
</Button>
|
||||
</span>
|
||||
<div className="mt-8 border-t border-cool-gray-700 pt-5">
|
||||
<div className="flex justify-end">
|
||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isUpdating}
|
||||
onClick={() => updateUser()}
|
||||
>
|
||||
{isUpdating
|
||||
? intl.formatMessage(messages.saving)
|
||||
: intl.formatMessage(messages.save)}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
@@ -8,6 +8,7 @@ import Button from '../Common/Button';
|
||||
import { hasPermission } from '../../../server/lib/permissions';
|
||||
import { Permission } from '../../hooks/useUser';
|
||||
import { useRouter } from 'next/router';
|
||||
import Header from '../Common/Header';
|
||||
|
||||
const UserList: React.FC = () => {
|
||||
const router = useRouter();
|
||||
@@ -19,13 +20,7 @@ const UserList: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="md:flex md:items-center md:justify-between mt-8 mb-6">
|
||||
<div className="flex-1 min-w-0 mx-4">
|
||||
<h2 className="text-2xl font-bold leading-7 text-cool-gray-100 sm:text-3xl sm:leading-9 sm:truncate">
|
||||
User List
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<Header extraMargin={4}>User List</Header>
|
||||
<div className="flex flex-col">
|
||||
<div className="my-2 overflow-x-auto -mx-6 sm:-mx-6 md:mx-4 lg:mx-4">
|
||||
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
|
||||
|
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"components.Discover.discovermovies": "Discover Movies",
|
||||
"components.Discover.discovertv": "Discover Series",
|
||||
"components.Discover.discovermovies": "Popular Movies",
|
||||
"components.Discover.discovertv": "Popular Series",
|
||||
"components.Discover.nopending": "No Pending Requests",
|
||||
"components.Discover.popularmovies": "Popular Movies",
|
||||
"components.Discover.populartv": "Popular Series",
|
||||
"components.Discover.recentlyAdded": "Recently Added",
|
||||
"components.Discover.recentrequests": "Recent Requests",
|
||||
"components.Discover.trending": "Trending",
|
||||
"components.Discover.upcoming": "Upcoming Movies",
|
||||
"components.Layout.LanguagePicker.changelanguage": "Change Language",
|
||||
"components.Layout.SearchInput.searchPlaceholder": "Search Movies & TV",
|
||||
"components.Layout.Sidebar.dashboard": "Dashboard",
|
||||
"components.Layout.Sidebar.dashboard": "Discover",
|
||||
"components.Layout.Sidebar.requests": "Requests",
|
||||
"components.Layout.Sidebar.settings": "Settings",
|
||||
"components.Layout.Sidebar.users": "Users",
|
||||
@@ -76,6 +77,29 @@
|
||||
"components.TvDetails.status": "Status",
|
||||
"components.TvDetails.unavailable": "Unavailable",
|
||||
"components.TvDetails.userrating": "User Rating",
|
||||
"components.UserEdit.admin": "Admin",
|
||||
"components.UserEdit.adminDescription": "Full administrator access. Bypasses all permission checks.",
|
||||
"components.UserEdit.autoapprove": "Auto Approve",
|
||||
"components.UserEdit.autoapproveDescription": "Grants auto approval for any requests made by this user.",
|
||||
"components.UserEdit.avatar": "Avatar",
|
||||
"components.UserEdit.edituser": "Edit User",
|
||||
"components.UserEdit.email": "Email",
|
||||
"components.UserEdit.managerequests": "Manage Requests",
|
||||
"components.UserEdit.managerequestsDescription": "Grants permission to manage Overseerr requests. This includes approving and denying requests.",
|
||||
"components.UserEdit.permissions": "Permissions",
|
||||
"components.UserEdit.request": "Request",
|
||||
"components.UserEdit.requestDescription": "Grants permission to make requests for movies or tv shows.",
|
||||
"components.UserEdit.save": "Save",
|
||||
"components.UserEdit.saving": "Saving...",
|
||||
"components.UserEdit.settings": "Manage Settings",
|
||||
"components.UserEdit.settingsDescription": "Grants permission to modify all Overseerr settings. User must have this permission to be able to grant it to others.",
|
||||
"components.UserEdit.userfail": "Something went wrong saving the user.",
|
||||
"components.UserEdit.username": "Username",
|
||||
"components.UserEdit.users": "Manage Users",
|
||||
"components.UserEdit.usersDescription": "Grants permission to manage Overseerr users. Users with this permission cannot modify users with Administrator privilege, or grant it.",
|
||||
"components.UserEdit.usersaved": "User succesfully saved",
|
||||
"components.UserEdit.vote": "Vote",
|
||||
"components.UserEdit.voteDescription": "Grants permission to vote on requests (voting not yet implemented)",
|
||||
"pages.internalServerError": "{statusCode} - Internal Server Error",
|
||||
"pages.oops": "Oops",
|
||||
"pages.pageNotFound": "404 - Page Not Found",
|
||||
|
@@ -6,6 +6,7 @@
|
||||
"components.Discover.populartv": "人気のテレビ番組",
|
||||
"components.Discover.recentlyAdded": "",
|
||||
"components.Discover.recentrequests": "最近のリクエスト",
|
||||
"components.Discover.trending": "",
|
||||
"components.Discover.upcoming": "",
|
||||
"components.Layout.LanguagePicker.changelanguage": "言語",
|
||||
"components.Layout.SearchInput.searchPlaceholder": "作品名で検索",
|
||||
@@ -76,6 +77,29 @@
|
||||
"components.TvDetails.status": "",
|
||||
"components.TvDetails.unavailable": "",
|
||||
"components.TvDetails.userrating": "",
|
||||
"components.UserEdit.admin": "",
|
||||
"components.UserEdit.adminDescription": "",
|
||||
"components.UserEdit.autoapprove": "",
|
||||
"components.UserEdit.autoapproveDescription": "",
|
||||
"components.UserEdit.avatar": "",
|
||||
"components.UserEdit.edituser": "",
|
||||
"components.UserEdit.email": "",
|
||||
"components.UserEdit.managerequests": "",
|
||||
"components.UserEdit.managerequestsDescription": "",
|
||||
"components.UserEdit.permissions": "",
|
||||
"components.UserEdit.request": "",
|
||||
"components.UserEdit.requestDescription": "",
|
||||
"components.UserEdit.save": "",
|
||||
"components.UserEdit.saving": "",
|
||||
"components.UserEdit.settings": "",
|
||||
"components.UserEdit.settingsDescription": "",
|
||||
"components.UserEdit.userfail": "",
|
||||
"components.UserEdit.username": "",
|
||||
"components.UserEdit.users": "",
|
||||
"components.UserEdit.usersDescription": "",
|
||||
"components.UserEdit.usersaved": "",
|
||||
"components.UserEdit.vote": "",
|
||||
"components.UserEdit.voteDescription": "",
|
||||
"pages.internalServerError": "",
|
||||
"pages.oops": "ああ",
|
||||
"pages.pageNotFound": "",
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { NextPage } from 'next';
|
||||
import DiscoverMovies from '../../components/Discover/DiscoverMovies';
|
||||
import DiscoverMovies from '../../../components/Discover/DiscoverMovies';
|
||||
|
||||
const DiscoverMoviesPage: NextPage = () => {
|
||||
return <DiscoverMovies />;
|
9
src/pages/discover/movies/upcoming.tsx
Normal file
9
src/pages/discover/movies/upcoming.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import { NextPage } from 'next';
|
||||
import UpcomingMovies from '../../../components/Discover/Upcoming';
|
||||
|
||||
const UpcomingMoviesPage: NextPage = () => {
|
||||
return <UpcomingMovies />;
|
||||
};
|
||||
|
||||
export default UpcomingMoviesPage;
|
9
src/pages/discover/trending.tsx
Normal file
9
src/pages/discover/trending.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import type { NextPage } from 'next';
|
||||
import Trending from '../../components/Discover/Trending';
|
||||
|
||||
const TrendingPage: NextPage = () => {
|
||||
return <Trending />;
|
||||
};
|
||||
|
||||
export default TrendingPage;
|
Reference in New Issue
Block a user