mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2026-01-02 04:39:14 -05:00
feat: issues (#2180)
This commit is contained in:
257
src/components/IssueList/IssueItem/index.tsx
Normal file
257
src/components/IssueList/IssueItem/index.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { EyeIcon } from '@heroicons/react/solid';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import { IssueStatus } from '../../../../server/constants/issue';
|
||||
import { MediaType } from '../../../../server/constants/media';
|
||||
import Issue from '../../../../server/entity/Issue';
|
||||
import { MovieDetails } from '../../../../server/models/Movie';
|
||||
import { TvDetails } from '../../../../server/models/Tv';
|
||||
import { Permission, useUser } from '../../../hooks/useUser';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import Badge from '../../Common/Badge';
|
||||
import Button from '../../Common/Button';
|
||||
import CachedImage from '../../Common/CachedImage';
|
||||
import { issueOptions } from '../../IssueModal/constants';
|
||||
|
||||
const messages = defineMessages({
|
||||
openeduserdate: '{date} by {user}',
|
||||
allseasons: 'All Seasons',
|
||||
season: 'Season {seasonNumber}',
|
||||
problemepisode: 'Affected Episode',
|
||||
allepisodes: 'All Episodes',
|
||||
episode: 'Episode {episodeNumber}',
|
||||
issuetype: 'Type',
|
||||
issuestatus: 'Status',
|
||||
opened: 'Opened',
|
||||
viewissue: 'View Issue',
|
||||
unknownissuetype: 'Unknown',
|
||||
});
|
||||
|
||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||
return (movie as MovieDetails).title !== undefined;
|
||||
};
|
||||
|
||||
interface IssueItemProps {
|
||||
issue: Issue;
|
||||
}
|
||||
|
||||
const IssueItem: React.FC<IssueItemProps> = ({ issue }) => {
|
||||
const intl = useIntl();
|
||||
const { hasPermission } = useUser();
|
||||
const { ref, inView } = useInView({
|
||||
triggerOnce: true,
|
||||
});
|
||||
const url =
|
||||
issue.media.mediaType === 'movie'
|
||||
? `/api/v1/movie/${issue.media.tmdbId}`
|
||||
: `/api/v1/tv/${issue.media.tmdbId}`;
|
||||
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
|
||||
inView ? url : null
|
||||
);
|
||||
|
||||
if (!title && !error) {
|
||||
return (
|
||||
<div
|
||||
className="w-full bg-gray-800 h-52 sm:h-40 xl:h-24 rounded-xl animate-pulse"
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
return <div>uh oh</div>;
|
||||
}
|
||||
|
||||
const issueOption = issueOptions.find(
|
||||
(opt) => opt.issueType === issue?.issueType
|
||||
);
|
||||
|
||||
const problemSeasonEpisodeLine = [];
|
||||
|
||||
if (!isMovie(title) && issue) {
|
||||
problemSeasonEpisodeLine.push(
|
||||
issue.problemSeason > 0
|
||||
? intl.formatMessage(messages.season, {
|
||||
seasonNumber: issue.problemSeason,
|
||||
})
|
||||
: intl.formatMessage(messages.allseasons)
|
||||
);
|
||||
|
||||
if (issue.problemSeason > 0) {
|
||||
problemSeasonEpisodeLine.push(
|
||||
issue.problemEpisode > 0
|
||||
? intl.formatMessage(messages.episode, {
|
||||
episodeNumber: issue.problemEpisode,
|
||||
})
|
||||
: intl.formatMessage(messages.allepisodes)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col justify-between w-full py-2 overflow-hidden text-gray-400 bg-gray-800 shadow-md h-52 sm:h-40 xl:h-24 ring-1 ring-gray-700 rounded-xl xl:flex-row">
|
||||
{title.backdropPath && (
|
||||
<div className="absolute inset-0 z-0 w-full bg-center bg-cover xl:w-2/3">
|
||||
<CachedImage
|
||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
|
||||
alt=""
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(90deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 1) 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative flex flex-col justify-between w-full overflow-hidden sm:flex-row">
|
||||
<div className="relative z-10 flex items-center w-full pl-4 pr-4 overflow-hidden xl:w-7/12 2xl:w-2/3 sm:pr-0">
|
||||
<Link
|
||||
href={
|
||||
issue.media.mediaType === MediaType.MOVIE
|
||||
? `/movie/${issue.media.tmdbId}`
|
||||
: `/tv/${issue.media.tmdbId}`
|
||||
}
|
||||
>
|
||||
<a className="relative flex-shrink-0 w-10 h-auto overflow-hidden transition duration-300 scale-100 rounded-md transform-gpu hover:scale-105">
|
||||
<CachedImage
|
||||
src={
|
||||
title.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||
: '/images/overseerr_poster_not_found.png'
|
||||
}
|
||||
alt=""
|
||||
layout="responsive"
|
||||
width={600}
|
||||
height={900}
|
||||
objectFit="cover"
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
<div className="flex flex-col justify-center pl-2 overflow-hidden xl:pl-4">
|
||||
<div className="pt-0.5 sm:pt-1 text-xs text-white">
|
||||
{(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice(
|
||||
0,
|
||||
4
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href={
|
||||
issue.media.mediaType === MediaType.MOVIE
|
||||
? `/movie/${issue.media.tmdbId}`
|
||||
: `/tv/${issue.media.tmdbId}`
|
||||
}
|
||||
>
|
||||
<a className="min-w-0 mr-2 text-lg font-bold text-white truncate xl:text-xl hover:underline">
|
||||
{isMovie(title) ? title.title : title.name}
|
||||
</a>
|
||||
</Link>
|
||||
{problemSeasonEpisodeLine.length > 0 && (
|
||||
<div className="text-sm text-gray-200">
|
||||
{problemSeasonEpisodeLine.join(' | ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="z-10 flex flex-col justify-center w-full pr-4 mt-4 ml-4 overflow-hidden text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0">
|
||||
<div className="card-field">
|
||||
<span className="card-field-name">
|
||||
{intl.formatMessage(messages.issuestatus)}
|
||||
</span>
|
||||
{issue.status === IssueStatus.OPEN ? (
|
||||
<Badge badgeType="primary">
|
||||
{intl.formatMessage(globalMessages.open)}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge badgeType="success">
|
||||
{intl.formatMessage(globalMessages.resolved)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-field">
|
||||
<span className="card-field-name">
|
||||
{intl.formatMessage(messages.issuetype)}
|
||||
</span>
|
||||
<span className="flex text-sm text-gray-300 truncate">
|
||||
{intl.formatMessage(
|
||||
issueOption?.name ?? messages.unknownissuetype
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="card-field">
|
||||
{hasPermission([Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], {
|
||||
type: 'or',
|
||||
}) ? (
|
||||
<>
|
||||
<span className="card-field-name">
|
||||
{intl.formatMessage(messages.opened)}
|
||||
</span>
|
||||
<span className="flex text-sm text-gray-300 truncate">
|
||||
{intl.formatMessage(messages.openeduserdate, {
|
||||
date: (
|
||||
<FormattedRelativeTime
|
||||
value={Math.floor(
|
||||
(new Date(issue.createdAt).getTime() - Date.now()) /
|
||||
1000
|
||||
)}
|
||||
updateIntervalInSeconds={1}
|
||||
numeric="auto"
|
||||
/>
|
||||
),
|
||||
user: (
|
||||
<Link href={`/users/${issue.createdBy.id}`}>
|
||||
<a className="flex items-center truncate group">
|
||||
<img
|
||||
src={issue.createdBy.avatar}
|
||||
alt=""
|
||||
className="ml-1.5 avatar-sm"
|
||||
/>
|
||||
<span className="text-sm truncate group-hover:underline">
|
||||
{issue.createdBy.displayName}
|
||||
</span>
|
||||
</a>
|
||||
</Link>
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="card-field-name">
|
||||
{intl.formatMessage(messages.opened)}
|
||||
</span>
|
||||
<span className="flex text-sm text-gray-300 truncate">
|
||||
<FormattedRelativeTime
|
||||
value={Math.floor(
|
||||
(new Date(issue.createdAt).getTime() - Date.now()) / 1000
|
||||
)}
|
||||
updateIntervalInSeconds={1}
|
||||
numeric="auto"
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="z-10 flex flex-col justify-center w-full pl-4 pr-4 mt-4 xl:mt-0 xl:items-end xl:w-96 xl:pl-0">
|
||||
<span className="w-full">
|
||||
<Link href={`/issues/${issue.id}`} passHref>
|
||||
<Button as="a" className="w-full" buttonType="primary">
|
||||
<EyeIcon />
|
||||
<span>{intl.formatMessage(messages.viewissue)}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IssueItem;
|
||||
256
src/components/IssueList/index.tsx
Normal file
256
src/components/IssueList/index.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
FilterIcon,
|
||||
SortDescendingIcon,
|
||||
} from '@heroicons/react/solid';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import { IssueResultsResponse } from '../../../server/interfaces/api/issueInterfaces';
|
||||
import Button from '../../components/Common/Button';
|
||||
import { useUpdateQueryParams } from '../../hooks/useUpdateQueryParams';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import Header from '../Common/Header';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import IssueItem from './IssueItem';
|
||||
|
||||
const messages = defineMessages({
|
||||
issues: 'Issues',
|
||||
sortAdded: 'Request Date',
|
||||
sortModified: 'Last Modified',
|
||||
showallissues: 'Show All Issues',
|
||||
});
|
||||
|
||||
enum Filter {
|
||||
ALL = 'all',
|
||||
OPEN = 'open',
|
||||
RESOLVED = 'resolved',
|
||||
}
|
||||
|
||||
type Sort = 'added' | 'modified';
|
||||
|
||||
const IssueList: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const router = useRouter();
|
||||
const [currentFilter, setCurrentFilter] = useState<Filter>(Filter.OPEN);
|
||||
const [currentSort, setCurrentSort] = useState<Sort>('added');
|
||||
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
|
||||
|
||||
const page = router.query.page ? Number(router.query.page) : 1;
|
||||
const pageIndex = page - 1;
|
||||
const updateQueryParams = useUpdateQueryParams({ page: page.toString() });
|
||||
|
||||
const { data, error } = useSWR<IssueResultsResponse>(
|
||||
`/api/v1/issue?take=${currentPageSize}&skip=${
|
||||
pageIndex * currentPageSize
|
||||
}&filter=${currentFilter}&sort=${currentSort}`
|
||||
);
|
||||
|
||||
// Restore last set filter values on component mount
|
||||
useEffect(() => {
|
||||
const filterString = window.localStorage.getItem('il-filter-settings');
|
||||
|
||||
if (filterString) {
|
||||
const filterSettings = JSON.parse(filterString);
|
||||
|
||||
setCurrentFilter(filterSettings.currentFilter);
|
||||
setCurrentSort(filterSettings.currentSort);
|
||||
setCurrentPageSize(filterSettings.currentPageSize);
|
||||
}
|
||||
|
||||
// If filter value is provided in query, use that instead
|
||||
if (Object.values(Filter).includes(router.query.filter as Filter)) {
|
||||
setCurrentFilter(router.query.filter as Filter);
|
||||
}
|
||||
}, [router.query.filter]);
|
||||
|
||||
// Set filter values to local storage any time they are changed
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(
|
||||
'il-filter-settings',
|
||||
JSON.stringify({
|
||||
currentFilter,
|
||||
currentSort,
|
||||
currentPageSize,
|
||||
})
|
||||
);
|
||||
}, [currentFilter, currentSort, currentPageSize]);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
const hasNextPage = data.pageInfo.pages > pageIndex + 1;
|
||||
const hasPrevPage = pageIndex > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.issues)} />
|
||||
<div className="flex flex-col justify-between mb-4 lg:items-end lg:flex-row">
|
||||
<Header>Issues</Header>
|
||||
<div className="flex flex-col flex-grow mt-2 sm:flex-row lg:flex-grow-0">
|
||||
<div className="flex flex-grow mb-2 sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
||||
<span className="inline-flex items-center px-3 text-sm text-gray-100 bg-gray-800 border border-r-0 border-gray-500 cursor-default rounded-l-md">
|
||||
<FilterIcon className="w-6 h-6" />
|
||||
</span>
|
||||
<select
|
||||
id="filter"
|
||||
name="filter"
|
||||
onChange={(e) => {
|
||||
setCurrentFilter(e.target.value as Filter);
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: router.query.userId
|
||||
? { userId: router.query.userId }
|
||||
: {},
|
||||
});
|
||||
}}
|
||||
value={currentFilter}
|
||||
className="rounded-r-only"
|
||||
>
|
||||
<option value="all">
|
||||
{intl.formatMessage(globalMessages.all)}
|
||||
</option>
|
||||
<option value="open">
|
||||
{intl.formatMessage(globalMessages.open)}
|
||||
</option>
|
||||
<option value="resolved">
|
||||
{intl.formatMessage(globalMessages.resolved)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-grow mb-2 sm:mb-0 lg:flex-grow-0">
|
||||
<span className="inline-flex items-center px-3 text-gray-100 bg-gray-800 border border-r-0 border-gray-500 cursor-default sm:text-sm rounded-l-md">
|
||||
<SortDescendingIcon className="w-6 h-6" />
|
||||
</span>
|
||||
<select
|
||||
id="sort"
|
||||
name="sort"
|
||||
onChange={(e) => {
|
||||
setCurrentSort(e.target.value as Sort);
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: router.query.userId
|
||||
? { userId: router.query.userId }
|
||||
: {},
|
||||
});
|
||||
}}
|
||||
value={currentSort}
|
||||
className="rounded-r-only"
|
||||
>
|
||||
<option value="added">
|
||||
{intl.formatMessage(messages.sortAdded)}
|
||||
</option>
|
||||
<option value="modified">
|
||||
{intl.formatMessage(messages.sortModified)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{data.results.map((issue) => {
|
||||
return (
|
||||
<div className="mb-2" key={`issue-item-${issue.id}`}>
|
||||
<IssueItem issue={issue} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{data.results.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center w-full py-24 text-white">
|
||||
<span className="text-2xl text-gray-400">
|
||||
{intl.formatMessage(globalMessages.noresults)}
|
||||
</span>
|
||||
{currentFilter !== Filter.ALL && (
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
onClick={() => setCurrentFilter(Filter.ALL)}
|
||||
>
|
||||
{intl.formatMessage(messages.showallissues)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="actions">
|
||||
<nav
|
||||
className="flex flex-col items-center mb-3 space-y-3 sm:space-y-0 sm:flex-row"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<div className="hidden lg:flex lg:flex-1">
|
||||
<p className="text-sm">
|
||||
{data.results.length > 0 &&
|
||||
intl.formatMessage(globalMessages.showingresults, {
|
||||
from: pageIndex * currentPageSize + 1,
|
||||
to:
|
||||
data.results.length < currentPageSize
|
||||
? pageIndex * currentPageSize + data.results.length
|
||||
: (pageIndex + 1) * currentPageSize,
|
||||
total: data.pageInfo.results,
|
||||
strong: function strong(msg) {
|
||||
return <span className="font-medium">{msg}</span>;
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-center sm:flex-1 sm:justify-start lg:justify-center">
|
||||
<span className="items-center -mt-3 text-sm truncate sm:mt-0">
|
||||
{intl.formatMessage(globalMessages.resultsperpage, {
|
||||
pageSize: (
|
||||
<select
|
||||
id="pageSize"
|
||||
name="pageSize"
|
||||
onChange={(e) => {
|
||||
setCurrentPageSize(Number(e.target.value));
|
||||
router
|
||||
.push({
|
||||
pathname: router.pathname,
|
||||
query: router.query.userId
|
||||
? { userId: router.query.userId }
|
||||
: {},
|
||||
})
|
||||
.then(() => window.scrollTo(0, 0));
|
||||
}}
|
||||
value={currentPageSize}
|
||||
className="inline short"
|
||||
>
|
||||
<option value="5">5</option>
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-center flex-auto space-x-2 sm:justify-end sm:flex-1">
|
||||
<Button
|
||||
disabled={!hasPrevPage}
|
||||
onClick={() => updateQueryParams('page', (page - 1).toString())}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<span>{intl.formatMessage(globalMessages.previous)}</span>
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!hasNextPage}
|
||||
onClick={() => updateQueryParams('page', (page + 1).toString())}
|
||||
>
|
||||
<span>{intl.formatMessage(globalMessages.next)}</span>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default IssueList;
|
||||
Reference in New Issue
Block a user