mirror of
https://github.com/Sonarr/Sonarr
synced 2026-05-07 04:20:50 +02:00
Merge branch 'feature/standardize-trakt-settings' of https://github.com/Andrew-Ukkonen/Sonarr into feature/standardize-trakt-settings
This commit is contained in:
commit
bdd09957f5
424 changed files with 11732 additions and 3902 deletions
2
.github/actions/build/action.yml
vendored
2
.github/actions/build/action.yml
vendored
|
|
@ -188,7 +188,7 @@ runs:
|
|||
runtime: ${{ inputs.runtime }}
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: build-${{ inputs.runtime }}
|
||||
path: _artifacts/**/*
|
||||
|
|
|
|||
6
.github/actions/package/action.yml
vendored
6
.github/actions/package/action.yml
vendored
|
|
@ -25,13 +25,13 @@ runs:
|
|||
using: "composite"
|
||||
steps:
|
||||
- name: Download Artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ inputs.artifact }}
|
||||
path: _output
|
||||
|
||||
- name: Download UI Artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: build_ui
|
||||
path: _output/UI
|
||||
|
|
@ -67,7 +67,7 @@ runs:
|
|||
build.bat
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-${{ inputs.runtime }}
|
||||
compression-level: 0
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ inputs:
|
|||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: tests-${{ inputs.runtime }}
|
||||
path: _tests/${{ inputs.framework }}/${{ inputs.runtime }}/publish/**/*
|
||||
|
|
|
|||
2
.github/actions/test/action.yml
vendored
2
.github/actions/test/action.yml
vendored
|
|
@ -85,7 +85,7 @@ runs:
|
|||
|
||||
- name: Upload Test Results
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: results-${{ env.RESULTS_NAME }}
|
||||
path: TestResults/*.trx
|
||||
|
|
|
|||
2
.github/workflows/api_docs.yml
vendored
2
.github/workflows/api_docs.yml
vendored
|
|
@ -26,7 +26,7 @@ jobs:
|
|||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
|
|
|
|||
12
.github/workflows/build_v5.yml
vendored
12
.github/workflows/build_v5.yml
vendored
|
|
@ -82,7 +82,7 @@ jobs:
|
|||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Build
|
||||
uses: ./.github/actions/build
|
||||
|
|
@ -97,7 +97,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Volta
|
||||
uses: volta-cli/action@v4
|
||||
|
|
@ -115,7 +115,7 @@ jobs:
|
|||
run: yarn build --env production
|
||||
|
||||
- name: Publish UI Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: build_ui
|
||||
path: _output/UI/**/*
|
||||
|
|
@ -139,7 +139,7 @@ jobs:
|
|||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Test
|
||||
uses: ./.github/actions/test
|
||||
|
|
@ -158,7 +158,7 @@ jobs:
|
|||
postgres-version: [16, 17, 18]
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Test
|
||||
uses: ./.github/actions/test
|
||||
|
|
@ -195,7 +195,7 @@ jobs:
|
|||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Test
|
||||
uses: ./.github/actions/test
|
||||
|
|
|
|||
26
.github/workflows/close_invalid_issues.yml
vendored
Normal file
26
.github/workflows/close_invalid_issues.yml
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
name: Close issues without labels
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
|
||||
jobs:
|
||||
close-issue:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github
|
||||
- name: Close issue if no labels found
|
||||
if: join(github.event.issue.labels) == ''
|
||||
run: |
|
||||
gh issue comment ${{ github.event.issue.number }} --body ":wave: @${{ github.event.issue.user.login }}, this issue was closed automatically because it was created without following an issue template. Please update the issue following the correct template for this issue. Once updated please reply to this issue so we can review and re-open. In the future, use the [issue templates](https://github.com/${{ github.repository }}/issues/new/choose) instead of creating your own."
|
||||
gh issue close ${{ github.event.issue.number }} --reason "not planned"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
26
.github/workflows/close_stale_draft_prs.yml
vendored
Normal file
26
.github/workflows/close_stale_draft_prs.yml
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
name: Close stale draft PRs
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
|
||||
jobs:
|
||||
close-stale-drafts:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'Sonarr/Sonarr'
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Close draft PRs inactive for 90+ days
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
cutoff=$(date -u -d '90 days ago' +%Y-%m-%dT%H:%M:%SZ)
|
||||
gh pr list --draft --state open --limit 1000 \
|
||||
--json number,updatedAt \
|
||||
--jq ".[] | select(.updatedAt < \"$cutoff\") | .number" \
|
||||
| while read -r pr; do
|
||||
gh pr close "$pr" --comment ":wave: This draft pull request has been closed automatically because it has not had any activity in 90 days. If you are still working on this, please update it and let us know so we can reopen it."
|
||||
done
|
||||
6
.github/workflows/deploy.yml
vendored
6
.github/workflows/deploy.yml
vendored
|
|
@ -52,7 +52,7 @@ jobs:
|
|||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Package
|
||||
uses: ./.github/actions/package
|
||||
|
|
@ -71,10 +71,10 @@ jobs:
|
|||
contents: write
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download release artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: _artifacts
|
||||
pattern: release-*
|
||||
|
|
|
|||
|
|
@ -109,9 +109,10 @@ function BlocklistContent() {
|
|||
const handleClearBlocklistConfirmed = useCallback(() => {
|
||||
executeCommand({ name: CommandNames.ClearBlocklist }, () => {
|
||||
goToPage(1);
|
||||
refetch();
|
||||
});
|
||||
setIsConfirmClearModalOpen(false);
|
||||
}, [setIsConfirmClearModalOpen, goToPage, executeCommand]);
|
||||
}, [setIsConfirmClearModalOpen, executeCommand, goToPage, refetch]);
|
||||
|
||||
const handleConfirmClearModalClose = useCallback(() => {
|
||||
setIsConfirmClearModalOpen(false);
|
||||
|
|
|
|||
|
|
@ -137,10 +137,15 @@ function BlocklistRow({
|
|||
if (name === 'actions') {
|
||||
return (
|
||||
<TableRowCell key={name} className={styles.actions}>
|
||||
<IconButton name={icons.INFO} onPress={handleDetailsPress} />
|
||||
<IconButton
|
||||
name={icons.INFO}
|
||||
aria-label={translate('Details')}
|
||||
onPress={handleDetailsPress}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
title={translate('RemoveFromBlocklist')}
|
||||
aria-label={translate('RemoveFromBlocklist')}
|
||||
name={icons.REMOVE}
|
||||
kind={kinds.DANGER}
|
||||
isSpinning={isRemoving}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { useSingleSeries } from 'Series/useSeries';
|
|||
import CustomFormat from 'typings/CustomFormat';
|
||||
import { HistoryData, HistoryEventType } from 'typings/History';
|
||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import HistoryDetailsModal from './Details/HistoryDetailsModal';
|
||||
import HistoryEventTypeCell from './HistoryEventTypeCell';
|
||||
import styles from './HistoryRow.css';
|
||||
|
|
@ -221,7 +222,11 @@ function HistoryRow(props: HistoryRowProps) {
|
|||
if (name === 'details') {
|
||||
return (
|
||||
<TableRowCell key={name} className={styles.details}>
|
||||
<IconButton name={icons.INFO} onPress={handleDetailsPress} />
|
||||
<IconButton
|
||||
name={icons.INFO}
|
||||
aria-label={translate('Details')}
|
||||
onPress={handleDetailsPress}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export function useQueueItemForEpisode(episodeId: number) {
|
|||
const queue = useContext(QueueDetailsContext);
|
||||
|
||||
return useMemo(() => {
|
||||
return queue?.find((item) => item.episodeIds.includes(episodeId));
|
||||
return queue?.find((item) => item.episodeIds?.includes(episodeId));
|
||||
}, [episodeId, queue]);
|
||||
}
|
||||
|
||||
|
|
@ -89,15 +89,15 @@ export function useQueueDetailsForSeries(
|
|||
return acc;
|
||||
}
|
||||
|
||||
if (seasonNumber != null && item.seasonNumber !== seasonNumber) {
|
||||
if (
|
||||
seasonNumber != null &&
|
||||
!item.seasonNumbers?.includes(seasonNumber)
|
||||
) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.count++;
|
||||
|
||||
if (item.episodeHasFile) {
|
||||
acc.episodesWithFiles++;
|
||||
}
|
||||
acc.count += item.episodeIds.length;
|
||||
acc.episodesWithFiles += item.episodesWithFilesCount;
|
||||
|
||||
return acc;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -365,7 +365,7 @@ function QueueContent() {
|
|||
selectedIds.every((id: number) => {
|
||||
const item = records.find((i) => i.id === id);
|
||||
|
||||
return !!(item && item.seriesId && item.episodeId);
|
||||
return !!(item && item.seriesId && item.episodeIds.length);
|
||||
})
|
||||
}
|
||||
isPending={
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ import Queue, {
|
|||
QueueTrackedDownloadStatus,
|
||||
StatusMessage,
|
||||
} from 'typings/Queue';
|
||||
import formatDateTime from 'Utilities/Date/formatDateTime';
|
||||
import getRelativeDate from 'Utilities/Date/getRelativeDate';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
|
@ -106,7 +108,7 @@ function QueueRow(props: QueueRowProps) {
|
|||
|
||||
const series = useSingleSeries(seriesId);
|
||||
const episodes = useEpisodesWithIds(episodeIds);
|
||||
const { showRelativeDates, shortDateFormat, timeFormat } =
|
||||
const { showRelativeDates, shortDateFormat, longDateFormat, timeFormat } =
|
||||
useUiSettingsValues();
|
||||
const { removeQueueItem, isRemoving } = useRemoveQueueItem(id);
|
||||
const { grabQueueItem, isGrabbing, grabError } = useGrabQueueItem(id);
|
||||
|
|
@ -248,17 +250,39 @@ function QueueRow(props: QueueRowProps) {
|
|||
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
<RelativeDateCell
|
||||
key={name}
|
||||
component="span"
|
||||
date={episodes[0].airDateUtc}
|
||||
/>
|
||||
{' - '}
|
||||
<RelativeDateCell
|
||||
key={name}
|
||||
component="span"
|
||||
date={episodes[episodes.length - 1].airDateUtc}
|
||||
/>
|
||||
<span
|
||||
title={`${formatDateTime(
|
||||
episodes[0].airDateUtc,
|
||||
longDateFormat,
|
||||
timeFormat,
|
||||
{
|
||||
includeRelativeDay: !showRelativeDates,
|
||||
}
|
||||
)} - ${formatDateTime(
|
||||
episodes[episodes.length - 1].airDateUtc,
|
||||
longDateFormat,
|
||||
timeFormat,
|
||||
{
|
||||
includeRelativeDay: !showRelativeDates,
|
||||
}
|
||||
)}`}
|
||||
>
|
||||
{getRelativeDate({
|
||||
date: episodes[0].airDateUtc,
|
||||
shortDateFormat,
|
||||
showRelativeDates,
|
||||
timeFormat,
|
||||
timeForToday: true,
|
||||
})}
|
||||
{' - '}
|
||||
{getRelativeDate({
|
||||
date: episodes[episodes.length - 1].airDateUtc,
|
||||
shortDateFormat,
|
||||
showRelativeDates,
|
||||
timeFormat,
|
||||
timeForToday: true,
|
||||
})}
|
||||
</span>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
|
@ -369,6 +393,7 @@ function QueueRow(props: QueueRowProps) {
|
|||
{showInteractiveImport ? (
|
||||
<IconButton
|
||||
name={icons.INTERACTIVE}
|
||||
aria-label={translate('InteractiveSearch')}
|
||||
onPress={handleInteractiveImportPress}
|
||||
/>
|
||||
) : null}
|
||||
|
|
@ -377,6 +402,7 @@ function QueueRow(props: QueueRowProps) {
|
|||
<SpinnerIconButton
|
||||
name={icons.DOWNLOAD}
|
||||
kind={grabError ? kinds.DANGER : kinds.DEFAULT}
|
||||
aria-label={translate('Grab')}
|
||||
isSpinning={isGrabbing}
|
||||
onPress={handleGrabPress}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import TextInput from 'Components/Form/TextInput';
|
||||
import Icon from 'Components/Icon';
|
||||
|
|
@ -22,6 +22,7 @@ function AddNewSeries() {
|
|||
const { term: initialTerm = '' } = useQueryParams<{ term: string }>();
|
||||
const hasSeries = useHasSeries();
|
||||
const [term, setTerm] = useState(initialTerm);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const query = useDebounce(term, term ? 300 : 0);
|
||||
|
||||
|
|
@ -36,6 +37,7 @@ function AddNewSeries() {
|
|||
const handleClearSeriesLookupPress = useCallback(() => {
|
||||
setTerm('');
|
||||
setIsFetching(false);
|
||||
searchInputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const { isFetching: isFetchingApi, error, data } = useLookupSeries(query);
|
||||
|
|
@ -57,6 +59,7 @@ function AddNewSeries() {
|
|||
</div>
|
||||
|
||||
<TextInput
|
||||
ref={searchInputRef}
|
||||
className={styles.searchInput}
|
||||
name="seriesLookup"
|
||||
value={term}
|
||||
|
|
|
|||
|
|
@ -97,6 +97,12 @@
|
|||
pointer-events: all;
|
||||
}
|
||||
|
||||
.excludedIcon {
|
||||
margin-left: 10px;
|
||||
color: var(--dangerColor);
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.overview {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
interface CssExports {
|
||||
'alreadyExistsIcon': string;
|
||||
'content': string;
|
||||
'excludedIcon': string;
|
||||
'genres': string;
|
||||
'icons': string;
|
||||
'network': string;
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ function AddNewSeriesSearchResult({ series }: AddNewSeriesSearchResultProps) {
|
|||
overview,
|
||||
seriesType,
|
||||
images,
|
||||
isExcluded,
|
||||
} = series;
|
||||
|
||||
const isExistingSeries = useExistingSeries(tvdbId);
|
||||
|
|
@ -64,7 +65,13 @@ function AddNewSeriesSearchResult({ series }: AddNewSeriesSearchResultProps) {
|
|||
|
||||
return (
|
||||
<div className={styles.searchResult}>
|
||||
<Link className={styles.underlay} {...linkProps} />
|
||||
<Link
|
||||
className={styles.underlay}
|
||||
aria-label={
|
||||
isExistingSeries ? title : translate('AddSeriesWithTitle', { title })
|
||||
}
|
||||
{...linkProps}
|
||||
/>
|
||||
|
||||
<div className={styles.overlay}>
|
||||
{isSmallScreen ? null : (
|
||||
|
|
@ -100,15 +107,26 @@ function AddNewSeriesSearchResult({ series }: AddNewSeriesSearchResultProps) {
|
|||
/>
|
||||
) : null}
|
||||
|
||||
{isExcluded ? (
|
||||
<Icon
|
||||
className={styles.excludedIcon}
|
||||
name={icons.DANGER}
|
||||
size={36}
|
||||
title={translate('SeriesInImportListExclusions')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Link
|
||||
className={styles.tvdbLink}
|
||||
to={`https://www.thetvdb.com/?tab=series&id=${tvdbId}`}
|
||||
aria-label={translate('ViewSeriesOnTvdb', { title })}
|
||||
onPress={handleTvdbLinkPress}
|
||||
>
|
||||
<Icon
|
||||
className={styles.tvdbLinkIcon}
|
||||
name={icons.EXTERNAL_LINK}
|
||||
size={28}
|
||||
aria-hidden={true}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import AddSeries from 'AddSeries/AddSeries';
|
||||
import { AddSeriesOptions } from 'AddSeries/addSeriesOptionsStore';
|
||||
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
||||
import useApiMutation, {
|
||||
addOrUpdateQueryClientItem,
|
||||
} from 'Helpers/Hooks/useApiMutation';
|
||||
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||
import Series from 'Series/Series';
|
||||
|
||||
|
|
@ -42,13 +44,9 @@ export const useAddSeries = () => {
|
|||
method: 'POST',
|
||||
mutationOptions: {
|
||||
onSuccess: (newSeries) => {
|
||||
queryClient.setQueryData<Series[]>(['/series'], (oldSeries) => {
|
||||
if (!oldSeries) {
|
||||
return [newSeries];
|
||||
}
|
||||
|
||||
return [...oldSeries, newSeries];
|
||||
});
|
||||
queryClient.setQueryData<Series[]>(['/series'], (oldSeries = []) =>
|
||||
addOrUpdateQueryClientItem(oldSeries, newSeries, 'id')
|
||||
);
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import Series from 'Series/Series';
|
|||
|
||||
interface AddSeries extends Series {
|
||||
folder: string;
|
||||
isExcluded: boolean;
|
||||
}
|
||||
|
||||
export default AddSeries;
|
||||
|
|
|
|||
|
|
@ -4,22 +4,15 @@ import AppSectionState, {
|
|||
AppSectionListState,
|
||||
AppSectionSaveState,
|
||||
AppSectionSchemaState,
|
||||
PagedAppSectionState,
|
||||
} from 'App/State/AppSectionState';
|
||||
import Language from 'Language/Language';
|
||||
import AutoTagging, { AutoTaggingSpecification } from 'typings/AutoTagging';
|
||||
import CustomFormat from 'typings/CustomFormat';
|
||||
import CustomFormatSpecification from 'typings/CustomFormatSpecification';
|
||||
import DelayProfile from 'typings/DelayProfile';
|
||||
import DownloadClient from 'typings/DownloadClient';
|
||||
import ImportList from 'typings/ImportList';
|
||||
import ImportListExclusion from 'typings/ImportListExclusion';
|
||||
import ImportListOptionsSettings from 'typings/ImportListOptionsSettings';
|
||||
import Indexer from 'typings/Indexer';
|
||||
import IndexerFlag from 'typings/IndexerFlag';
|
||||
import DownloadClientOptions from 'typings/Settings/DownloadClientOptions';
|
||||
import General from 'typings/Settings/General';
|
||||
import IndexerOptions from 'typings/Settings/IndexerOptions';
|
||||
|
||||
type Presets<T> = T & {
|
||||
presets: T[];
|
||||
|
|
@ -53,10 +46,6 @@ export interface DownloadClientOptionsAppState
|
|||
extends AppSectionItemState<DownloadClientOptions>,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface GeneralAppState
|
||||
extends AppSectionItemState<General>,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface ImportListAppState
|
||||
extends AppSectionState<ImportList>,
|
||||
AppSectionDeleteState,
|
||||
|
|
@ -65,18 +54,6 @@ export interface ImportListAppState
|
|||
isTestingAll: boolean;
|
||||
}
|
||||
|
||||
export interface IndexerOptionsAppState
|
||||
extends AppSectionItemState<IndexerOptions>,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface IndexerAppState
|
||||
extends AppSectionState<Indexer>,
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState,
|
||||
AppSectionSchemaState<Presets<Indexer>> {
|
||||
isTestingAll: boolean;
|
||||
}
|
||||
|
||||
export interface CustomFormatAppState
|
||||
extends AppSectionState<CustomFormat>,
|
||||
AppSectionDeleteState,
|
||||
|
|
@ -92,17 +69,6 @@ export interface ImportListOptionsSettingsAppState
|
|||
extends AppSectionItemState<ImportListOptionsSettings>,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface ImportListExclusionsSettingsAppState
|
||||
extends AppSectionState<ImportListExclusion>,
|
||||
AppSectionSaveState,
|
||||
PagedAppSectionState,
|
||||
AppSectionDeleteState {
|
||||
pendingChanges: Partial<ImportListExclusion>;
|
||||
}
|
||||
|
||||
export type IndexerFlagSettingsAppState = AppSectionState<IndexerFlag>;
|
||||
export type LanguageSettingsAppState = AppSectionState<Language>;
|
||||
|
||||
interface SettingsAppState {
|
||||
autoTaggings: AutoTaggingAppState;
|
||||
autoTaggingSpecifications: AutoTaggingSpecificationAppState;
|
||||
|
|
@ -111,14 +77,8 @@ interface SettingsAppState {
|
|||
delayProfiles: DelayProfileAppState;
|
||||
downloadClients: DownloadClientAppState;
|
||||
downloadClientOptions: DownloadClientOptionsAppState;
|
||||
general: GeneralAppState;
|
||||
importListExclusions: ImportListExclusionsSettingsAppState;
|
||||
importListOptions: ImportListOptionsSettingsAppState;
|
||||
importLists: ImportListAppState;
|
||||
indexerFlags: IndexerFlagSettingsAppState;
|
||||
indexerOptions: IndexerOptionsAppState;
|
||||
indexers: IndexerAppState;
|
||||
languages: LanguageSettingsAppState;
|
||||
}
|
||||
|
||||
export default SettingsAppState;
|
||||
|
|
|
|||
|
|
@ -154,10 +154,7 @@ function AgendaEvent(props: AgendaEventProps) {
|
|||
|
||||
{queueItem ? (
|
||||
<span className={styles.statusIcon}>
|
||||
<CalendarEventQueueDetails
|
||||
seasonNumber={seasonNumber}
|
||||
{...queueItem}
|
||||
/>
|
||||
<CalendarEventQueueDetails {...queueItem} />
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,9 +39,7 @@ const useMissingEpisodeIdsSelector = () => {
|
|||
moment(airDateUtc).isAfter(start) &&
|
||||
moment(airDateUtc).isBefore(end) &&
|
||||
isBefore(episode.airDateUtc) &&
|
||||
!queueDetails.some(
|
||||
(details) => !!details.episode && details.episode.id === episode.id
|
||||
)
|
||||
!queueDetails.some((details) => details.episodeIds?.includes(episode.id))
|
||||
) {
|
||||
acc.push(episode.id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ const useCalendar = () => {
|
|||
return acc;
|
||||
},
|
||||
{
|
||||
includeUnmonitored: false,
|
||||
includeUnmonitored: true,
|
||||
includeSpecials: true,
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ enum CommandNames {
|
|||
ClearLog = 'ClearLog',
|
||||
CutoffUnmetEpisodeSearch = 'CutoffUnmetEpisodeSearch',
|
||||
DeleteLogFiles = 'DeleteLogFiles',
|
||||
DeleteSeriesFiles = 'DeleteSeriesFiles',
|
||||
DeleteUpdateLogFiles = 'DeleteUpdateLogFiles',
|
||||
DownloadedEpisodesScan = 'DownloadedEpisodesScan',
|
||||
EpisodeSearch = 'EpisodeSearch',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ import { useQueryClient } from '@tanstack/react-query';
|
|||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { showMessage } from 'App/messagesStore';
|
||||
import Command, { CommandBody, NewCommandBody } from 'Commands/Command';
|
||||
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
||||
import useApiMutation, {
|
||||
addOrUpdateQueryClientItem,
|
||||
} from 'Helpers/Hooks/useApiMutation';
|
||||
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||
import {
|
||||
ERROR,
|
||||
|
|
@ -45,11 +47,8 @@ export const useExecuteCommand = () => {
|
|||
path: '/command',
|
||||
mutationOptions: {
|
||||
onSuccess: (newCommand: Command) => {
|
||||
queryClient.setQueryData<Command[]>(
|
||||
['/command'],
|
||||
(oldCommands = []) => {
|
||||
return [...oldCommands, newCommand];
|
||||
}
|
||||
queryClient.setQueryData<Command[]>(['/command'], (oldCommands = []) =>
|
||||
addOrUpdateQueryClientItem(oldCommands, newCommand, 'id')
|
||||
);
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -133,7 +133,11 @@ function FileBrowserModalContent({
|
|||
className={styles.scroller}
|
||||
scrollDirection="both"
|
||||
>
|
||||
{error ? <div>{translate('ErrorLoadingContents')}</div> : null}
|
||||
{error ? (
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('ErrorLoadingContents')}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{isFetched && !error ? (
|
||||
<Table horizontalScroll={false} columns={columns}>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { DateFilterValue, FilterType } from 'Helpers/Props/filterTypes';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue';
|
||||
import DateFilterBuilderRowValue from './DateFilterBuilderRowValue';
|
||||
import DefaultFilterBuilderRowValue from './DefaultFilterBuilderRowValue';
|
||||
|
|
@ -21,6 +22,7 @@ import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue';
|
|||
import QualityFilterBuilderRowValue from './QualityFilterBuilderRowValue';
|
||||
import QualityProfileFilterBuilderRowValue from './QualityProfileFilterBuilderRowValue';
|
||||
import QueueStatusFilterBuilderRowValue from './QueueStatusFilterBuilderRowValue';
|
||||
import ReleaseTypeFilterBuilderRowValue from './ReleaseTypeFilterBuilderRowValue';
|
||||
import SeriesFilterBuilderRowValue from './SeriesFilterBuilderRowValue';
|
||||
import SeriesStatusFilterBuilderRowValue from './SeriesStatusFilterBuilderRowValue';
|
||||
import SeriesTypeFilterBuilderRowValue from './SeriesTypeFilterBuilderRowValue';
|
||||
|
|
@ -112,6 +114,9 @@ function getRowValueConnector<T>(
|
|||
case filterBuilderValueTypes.MONITORED_STATUS:
|
||||
return MonitoredStatusFilterBuilderRowValue;
|
||||
|
||||
case filterBuilderValueTypes.RELEASE_TYPES:
|
||||
return ReleaseTypeFilterBuilderRowValue;
|
||||
|
||||
case filterBuilderValueTypes.SERIES:
|
||||
return SeriesFilterBuilderRowValue;
|
||||
|
||||
|
|
@ -300,11 +305,16 @@ function FilterBuilderRow<T>({
|
|||
<div className={styles.actionsContainer}>
|
||||
<IconButton
|
||||
name={icons.SUBTRACT}
|
||||
aria-label={translate('Remove')}
|
||||
isDisabled={filterCount === 1}
|
||||
onPress={handleRemovePress}
|
||||
/>
|
||||
|
||||
<IconButton name={icons.ADD} onPress={handleAddPress} />
|
||||
<IconButton
|
||||
name={icons.ADD}
|
||||
aria-label={translate('Add')}
|
||||
onPress={handleAddPress}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import React, { useEffect, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import { fetchIndexers } from 'Store/Actions/settingsActions';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useSortedIndexers } from 'Settings/Indexers/useIndexers';
|
||||
import FilterBuilderRowValue, {
|
||||
FilterBuilderRowValueProps,
|
||||
} from './FilterBuilderRowValue';
|
||||
|
|
@ -14,26 +12,16 @@ type IndexerFilterBuilderRowValueProps<T> = Omit<
|
|||
function IndexerFilterBuilderRowValue<T>(
|
||||
props: IndexerFilterBuilderRowValueProps<T>
|
||||
) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { isPopulated, items } = useSelector(
|
||||
(state: AppState) => state.settings.indexers
|
||||
);
|
||||
const { data } = useSortedIndexers();
|
||||
|
||||
const tagList = useMemo(() => {
|
||||
return items.map((item) => {
|
||||
return data.map((item) => {
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
};
|
||||
});
|
||||
}, [items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPopulated) {
|
||||
dispatch(fetchIndexers());
|
||||
}
|
||||
}, [isPopulated, dispatch]);
|
||||
}, [data]);
|
||||
|
||||
return <FilterBuilderRowValue {...props} tagList={tagList} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import createLanguagesSelector from 'Store/Selectors/createLanguagesSelector';
|
||||
import { useLanguages } from 'Language/useLanguages';
|
||||
import FilterBuilderRowValue, {
|
||||
FilterBuilderRowValueProps,
|
||||
} from './FilterBuilderRowValue';
|
||||
|
|
@ -13,7 +12,7 @@ type LanguageFilterBuilderRowValueProps<T> = Omit<
|
|||
function LanguageFilterBuilderRowValue<T>(
|
||||
props: LanguageFilterBuilderRowValueProps<T>
|
||||
) {
|
||||
const { items } = useSelector(createLanguagesSelector());
|
||||
const { data: items = [] } = useLanguages();
|
||||
|
||||
return <FilterBuilderRowValue {...props} tagList={items} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
import React from 'react';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import FilterBuilderRowValue, {
|
||||
FilterBuilderRowValueProps,
|
||||
} from './FilterBuilderRowValue';
|
||||
|
||||
const releaseTypeList = [
|
||||
{
|
||||
id: 'unknown',
|
||||
get name() {
|
||||
return translate('Unknown');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'singleEpisode',
|
||||
get name() {
|
||||
return translate('SingleEpisode');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'multiEpisode',
|
||||
get name() {
|
||||
return translate('MultiEpisode');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'seasonPack',
|
||||
get name() {
|
||||
return translate('SeasonPack');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
type ReleaseTypeFilterBuilderRowValueProps<T> = Omit<
|
||||
FilterBuilderRowValueProps<T, string, string>,
|
||||
'tagList'
|
||||
>;
|
||||
|
||||
function ReleaseTypeFilterBuilderRowValue<T>(
|
||||
props: ReleaseTypeFilterBuilderRowValueProps<T>
|
||||
) {
|
||||
return <FilterBuilderRowValue tagList={releaseTypeList} {...props} />;
|
||||
}
|
||||
|
||||
export default ReleaseTypeFilterBuilderRowValue;
|
||||
|
|
@ -59,7 +59,11 @@ function CustomFilter({
|
|||
<div className={styles.label}>{label}</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<IconButton name={icons.EDIT} onPress={handleEditPress} />
|
||||
<IconButton
|
||||
name={icons.EDIT}
|
||||
aria-label={translate('Edit')}
|
||||
onPress={handleEditPress}
|
||||
/>
|
||||
|
||||
<SpinnerIconButton
|
||||
title={translate('RemoveFilter')}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import TextInput from './TextInput';
|
||||
import styles from './KeyValueListInputItem.css';
|
||||
|
||||
|
|
@ -77,6 +78,7 @@ function KeyValueListInputItem({
|
|||
{isNew ? null : (
|
||||
<IconButton
|
||||
name={icons.REMOVE}
|
||||
aria-label={translate('Remove')}
|
||||
tabIndex={-1}
|
||||
onPress={handleRemovePress}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,35 +1,8 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import useIndexerFlags from 'Settings/Indexers/useIndexerFlags';
|
||||
import { EnhancedSelectInputChanged } from 'typings/inputs';
|
||||
import EnhancedSelectInput from './EnhancedSelectInput';
|
||||
|
||||
const selectIndexerFlagsValues = (selectedFlags: number) =>
|
||||
createSelector(
|
||||
(state: AppState) => state.settings.indexerFlags,
|
||||
(indexerFlags) => {
|
||||
const value = indexerFlags.items.reduce((acc: number[], { id }) => {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
if ((selectedFlags & id) === id) {
|
||||
acc.push(id);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const values = indexerFlags.items.map(({ id, name }) => ({
|
||||
key: id,
|
||||
value: name,
|
||||
}));
|
||||
|
||||
return {
|
||||
value,
|
||||
values,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export interface IndexerFlagsSelectInputProps {
|
||||
name: string;
|
||||
indexerFlags: number;
|
||||
|
|
@ -42,7 +15,29 @@ function IndexerFlagsSelectInput({
|
|||
onChange,
|
||||
...otherProps
|
||||
}: IndexerFlagsSelectInputProps) {
|
||||
const { value, values } = useSelector(selectIndexerFlagsValues(indexerFlags));
|
||||
const { data: allIndexerFlags } = useIndexerFlags();
|
||||
|
||||
const value = useMemo(
|
||||
() =>
|
||||
allIndexerFlags.reduce((acc: number[], { id }) => {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
if ((indexerFlags & id) === id) {
|
||||
acc.push(id);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []),
|
||||
[allIndexerFlags, indexerFlags]
|
||||
);
|
||||
|
||||
const values = useMemo(
|
||||
() =>
|
||||
allIndexerFlags.map(({ id, name }) => ({
|
||||
key: id,
|
||||
value: name,
|
||||
})),
|
||||
[allIndexerFlags]
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(change: EnhancedSelectInputChanged<number[]>) => {
|
||||
|
|
|
|||
|
|
@ -1,43 +1,9 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import { fetchIndexers } from 'Store/Actions/settingsActions';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useSortedIndexers } from 'Settings/Indexers/useIndexers';
|
||||
import { EnhancedSelectInputChanged } from 'typings/inputs';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EnhancedSelectInput from './EnhancedSelectInput';
|
||||
|
||||
function createIndexersSelector(includeAny: boolean) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.settings.indexers,
|
||||
(indexers) => {
|
||||
const { isFetching, isPopulated, error, items } = indexers;
|
||||
|
||||
const values = items.sort(sortByProp('name')).map((indexer) => {
|
||||
return {
|
||||
key: indexer.id,
|
||||
value: indexer.name,
|
||||
};
|
||||
});
|
||||
|
||||
if (includeAny) {
|
||||
values.unshift({
|
||||
key: 0,
|
||||
value: `(${translate('Any')})`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
values,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export interface IndexerSelectInputProps {
|
||||
name: string;
|
||||
value: number | number[];
|
||||
|
|
@ -51,16 +17,23 @@ function IndexerSelectInput({
|
|||
includeAny = false,
|
||||
onChange,
|
||||
}: IndexerSelectInputProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { isFetching, isPopulated, values } = useSelector(
|
||||
createIndexersSelector(includeAny)
|
||||
);
|
||||
const { isFetching, data } = useSortedIndexers();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPopulated) {
|
||||
dispatch(fetchIndexers());
|
||||
const values = useMemo(() => {
|
||||
const indexerOptions = data.map((indexer) => ({
|
||||
key: indexer.id,
|
||||
value: indexer.name,
|
||||
}));
|
||||
|
||||
if (includeAny) {
|
||||
indexerOptions.unshift({
|
||||
key: 0,
|
||||
value: `(${translate('Any')})`,
|
||||
});
|
||||
}
|
||||
}, [isPopulated, dispatch]);
|
||||
|
||||
return indexerOptions;
|
||||
}, [data, includeAny]);
|
||||
|
||||
return (
|
||||
<EnhancedSelectInput
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Language from 'Language/Language';
|
||||
import createLanguagesSelector from 'Store/Selectors/createLanguagesSelector';
|
||||
import { useFilteredLanguages } from 'Language/useLanguages';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EnhancedSelectInput, {
|
||||
EnhancedSelectInputValue,
|
||||
|
|
@ -19,6 +18,9 @@ export interface LanguageSelectInputProps {
|
|||
includeNoChange?: boolean;
|
||||
includeNoChangeDisabled?: boolean;
|
||||
includeMixed?: boolean;
|
||||
includeAny?: boolean;
|
||||
includeOriginal?: boolean;
|
||||
includeUnknown?: boolean;
|
||||
isDisabled?: boolean;
|
||||
onChange: (payload: LanguageSelectInputOnChangeProps) => void;
|
||||
}
|
||||
|
|
@ -28,16 +30,17 @@ export default function LanguageSelectInput({
|
|||
includeNoChange = false,
|
||||
includeNoChangeDisabled,
|
||||
includeMixed = false,
|
||||
includeAny = true,
|
||||
includeOriginal = false,
|
||||
includeUnknown = false,
|
||||
onChange,
|
||||
...otherProps
|
||||
}: LanguageSelectInputProps) {
|
||||
const { items } = useSelector(
|
||||
createLanguagesSelector({
|
||||
Any: true,
|
||||
Original: true,
|
||||
Unknown: true,
|
||||
})
|
||||
);
|
||||
const { data: items = [] } = useFilteredLanguages({
|
||||
Any: !includeAny,
|
||||
Original: !includeOriginal,
|
||||
Unknown: !includeUnknown,
|
||||
});
|
||||
|
||||
const values = useMemo(() => {
|
||||
const result: EnhancedSelectInputValue<number | string>[] = items.map(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import IconButton from 'Components/Link/IconButton';
|
|||
import Link from 'Components/Link/Link';
|
||||
import MiddleTruncate from 'Components/MiddleTruncate';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import { TagBase } from './TagInput';
|
||||
import styles from './TagInputTag.css';
|
||||
|
||||
|
|
@ -66,6 +67,7 @@ function TagInputTag<T extends TagBase>({
|
|||
<IconButton
|
||||
className={styles.editButton}
|
||||
name={icons.EDIT}
|
||||
aria-label={translate('Edit')}
|
||||
size={9}
|
||||
onPress={handleEdit}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ import classNames from 'classnames';
|
|||
import React, {
|
||||
ChangeEvent,
|
||||
FocusEvent,
|
||||
forwardRef,
|
||||
SyntheticEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import useCombinedRefs from 'Helpers/Hooks/useCombinedRefs';
|
||||
import { FileInputChanged, InputChanged } from 'typings/inputs';
|
||||
import styles from './TextInput.css';
|
||||
|
||||
|
|
@ -39,147 +41,153 @@ export interface FileInputProps extends CommonTextInputProps {
|
|||
onChange: (change: FileInputChanged) => void;
|
||||
}
|
||||
|
||||
function TextInput({
|
||||
className = styles.input,
|
||||
type = 'text',
|
||||
readOnly = false,
|
||||
autoFocus = false,
|
||||
placeholder,
|
||||
name,
|
||||
value = '',
|
||||
hasError,
|
||||
hasWarning,
|
||||
hasButton,
|
||||
step,
|
||||
min,
|
||||
max,
|
||||
onBlur,
|
||||
onFocus,
|
||||
onCopy,
|
||||
onChange,
|
||||
onSelectionChange,
|
||||
}: TextInputProps | FileInputProps): JSX.Element {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const selectionTimeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
const selectionStart = useRef<number | null>();
|
||||
const selectionEnd = useRef<number | null>();
|
||||
const isMouseTarget = useRef(false);
|
||||
const TextInput = forwardRef<HTMLInputElement, TextInputProps | FileInputProps>(
|
||||
(
|
||||
{
|
||||
className = styles.input,
|
||||
type = 'text',
|
||||
readOnly = false,
|
||||
autoFocus = false,
|
||||
placeholder,
|
||||
name,
|
||||
value = '',
|
||||
hasError,
|
||||
hasWarning,
|
||||
hasButton,
|
||||
step,
|
||||
min,
|
||||
max,
|
||||
onBlur,
|
||||
onFocus,
|
||||
onCopy,
|
||||
onChange,
|
||||
onSelectionChange,
|
||||
}: TextInputProps | FileInputProps,
|
||||
ref
|
||||
) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const combinedRef = useCombinedRefs(ref, inputRef);
|
||||
const selectionTimeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
const selectionStart = useRef<number | null>();
|
||||
const selectionEnd = useRef<number | null>();
|
||||
const isMouseTarget = useRef(false);
|
||||
|
||||
const selectionChanged = useCallback(() => {
|
||||
if (selectionTimeout.current) {
|
||||
clearTimeout(selectionTimeout.current);
|
||||
}
|
||||
|
||||
selectionTimeout.current = setTimeout(() => {
|
||||
if (!inputRef.current) {
|
||||
return;
|
||||
const selectionChanged = useCallback(() => {
|
||||
if (selectionTimeout.current) {
|
||||
clearTimeout(selectionTimeout.current);
|
||||
}
|
||||
|
||||
const start = inputRef.current.selectionStart;
|
||||
const end = inputRef.current.selectionEnd;
|
||||
selectionTimeout.current = setTimeout(() => {
|
||||
if (!inputRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectionChanged =
|
||||
selectionStart.current !== start || selectionEnd.current !== end;
|
||||
const start = inputRef.current.selectionStart;
|
||||
const end = inputRef.current.selectionEnd;
|
||||
|
||||
selectionStart.current = start;
|
||||
selectionEnd.current = end;
|
||||
const selectionChanged =
|
||||
selectionStart.current !== start || selectionEnd.current !== end;
|
||||
|
||||
if (selectionChanged) {
|
||||
onSelectionChange?.(start, end);
|
||||
selectionStart.current = start;
|
||||
selectionEnd.current = end;
|
||||
|
||||
if (selectionChanged) {
|
||||
onSelectionChange?.(start, end);
|
||||
}
|
||||
}, 10);
|
||||
}, [onSelectionChange]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange({
|
||||
name,
|
||||
value: event.target.value,
|
||||
files: type === 'file' ? event.target.files : undefined,
|
||||
});
|
||||
},
|
||||
[name, type, onChange]
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(event: FocusEvent<HTMLInputElement, Element>) => {
|
||||
onFocus?.(event);
|
||||
|
||||
selectionChanged();
|
||||
},
|
||||
[selectionChanged, onFocus]
|
||||
);
|
||||
|
||||
const handleKeyUp = useCallback(() => {
|
||||
selectionChanged();
|
||||
}, [selectionChanged]);
|
||||
|
||||
const handleMouseDown = useCallback(() => {
|
||||
isMouseTarget.current = true;
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
selectionChanged();
|
||||
}, [selectionChanged]);
|
||||
|
||||
const handleWheel = useCallback(() => {
|
||||
if (type === 'number') {
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
}, 10);
|
||||
}, [onSelectionChange]);
|
||||
}, [type]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange({
|
||||
name,
|
||||
value: event.target.value,
|
||||
files: type === 'file' ? event.target.files : undefined,
|
||||
});
|
||||
},
|
||||
[name, type, onChange]
|
||||
);
|
||||
const handleDocumentMouseUp = useCallback(() => {
|
||||
if (isMouseTarget.current) {
|
||||
selectionChanged();
|
||||
}
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(event: FocusEvent<HTMLInputElement, Element>) => {
|
||||
onFocus?.(event);
|
||||
isMouseTarget.current = false;
|
||||
}, [selectionChanged]);
|
||||
|
||||
selectionChanged();
|
||||
},
|
||||
[selectionChanged, onFocus]
|
||||
);
|
||||
useEffect(() => {
|
||||
window.addEventListener('mouseup', handleDocumentMouseUp);
|
||||
|
||||
const handleKeyUp = useCallback(() => {
|
||||
selectionChanged();
|
||||
}, [selectionChanged]);
|
||||
return () => {
|
||||
window.removeEventListener('mouseup', handleDocumentMouseUp);
|
||||
};
|
||||
}, [handleDocumentMouseUp]);
|
||||
|
||||
const handleMouseDown = useCallback(() => {
|
||||
isMouseTarget.current = true;
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeout(selectionTimeout.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
selectionChanged();
|
||||
}, [selectionChanged]);
|
||||
|
||||
const handleWheel = useCallback(() => {
|
||||
if (type === 'number') {
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
}, [type]);
|
||||
|
||||
const handleDocumentMouseUp = useCallback(() => {
|
||||
if (isMouseTarget.current) {
|
||||
selectionChanged();
|
||||
}
|
||||
|
||||
isMouseTarget.current = false;
|
||||
}, [selectionChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('mouseup', handleDocumentMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mouseup', handleDocumentMouseUp);
|
||||
};
|
||||
}, [handleDocumentMouseUp]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeout(selectionTimeout.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type={type}
|
||||
readOnly={readOnly}
|
||||
autoFocus={autoFocus}
|
||||
placeholder={placeholder}
|
||||
className={classNames(
|
||||
className,
|
||||
readOnly && styles.readOnly,
|
||||
hasError && styles.hasError,
|
||||
hasWarning && styles.hasWarning,
|
||||
hasButton && styles.hasButton
|
||||
)}
|
||||
name={name}
|
||||
value={value}
|
||||
step={step}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={onBlur}
|
||||
onCopy={onCopy}
|
||||
onCut={onCopy}
|
||||
onKeyUp={handleKeyUp}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onWheel={handleWheel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
ref={combinedRef}
|
||||
type={type}
|
||||
readOnly={readOnly}
|
||||
autoFocus={autoFocus}
|
||||
placeholder={placeholder}
|
||||
className={classNames(
|
||||
className,
|
||||
readOnly && styles.readOnly,
|
||||
hasError && styles.hasError,
|
||||
hasWarning && styles.hasWarning,
|
||||
hasButton && styles.hasButton
|
||||
)}
|
||||
name={name}
|
||||
value={value}
|
||||
step={step}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={onBlur}
|
||||
onCopy={onCopy}
|
||||
onCut={onCopy}
|
||||
onKeyUp={handleKeyUp}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onWheel={handleWheel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default TextInput;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@
|
|||
position: relative;
|
||||
}
|
||||
|
||||
.buttonText {
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.stateIconContainer {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'button': string;
|
||||
'buttonText': string;
|
||||
'clipboardIconContainer': string;
|
||||
'showStateIcon': string;
|
||||
'stateIconContainer': string;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import styles from './ClipboardButton.css';
|
|||
|
||||
export interface ClipboardButtonProps extends Omit<ButtonProps, 'children'> {
|
||||
value: string;
|
||||
label?: string | number;
|
||||
}
|
||||
|
||||
export type ClipboardState = 'success' | 'error' | null;
|
||||
|
|
@ -15,6 +16,7 @@ export type ClipboardState = 'success' | 'error' | null;
|
|||
export default function ClipboardButton({
|
||||
id,
|
||||
value,
|
||||
label,
|
||||
className = styles.button,
|
||||
...otherProps
|
||||
}: ClipboardButtonProps) {
|
||||
|
|
@ -68,6 +70,7 @@ export default function ClipboardButton({
|
|||
) : null}
|
||||
|
||||
<span className={styles.clipboardIconContainer}>
|
||||
{label ? <span className={styles.buttonText}>{label}</span> : null}
|
||||
<Icon name={icons.CLIPBOARD} />
|
||||
</span>
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import Icon, { IconProps } from 'Components/Icon';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import Link, { LinkProps } from './Link';
|
||||
import styles from './IconButton.css';
|
||||
|
||||
|
|
@ -26,7 +25,6 @@ export default function IconButton({
|
|||
className,
|
||||
otherProps.isDisabled && styles.isDisabled
|
||||
)}
|
||||
aria-label={translate('TableOptionsButton')}
|
||||
{...otherProps}
|
||||
>
|
||||
<Icon
|
||||
|
|
@ -35,6 +33,7 @@ export default function IconButton({
|
|||
kind={kind}
|
||||
size={size}
|
||||
isSpinning={isSpinning}
|
||||
aria-hidden={true}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ function Menu({
|
|||
{React.cloneElement(childrenArray[1] as ReactElement, {
|
||||
forwardedRef: refs.setFloating,
|
||||
style: {
|
||||
maxHeight,
|
||||
maxHeight: enforceMaxHeight ? maxHeight : undefined,
|
||||
...floatingStyles,
|
||||
},
|
||||
isOpen: isMenuOpen,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { Size } from 'Helpers/Props/sizes';
|
|||
import { isIOS } from 'Utilities/browser';
|
||||
import * as keyCodes from 'Utilities/Constants/keyCodes';
|
||||
import { setScrollLock } from 'Utilities/scrollLock';
|
||||
import { ModalContext } from './ModalContext';
|
||||
import ModalError from './ModalError';
|
||||
import styles from './Modal.css';
|
||||
|
||||
|
|
@ -163,26 +164,36 @@ function Modal({
|
|||
return null;
|
||||
}
|
||||
|
||||
const headerId = `${modalId}-header`;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<FocusLock disabled={false}>
|
||||
<div className={styles.modalContainer}>
|
||||
<div
|
||||
ref={backgroundRef}
|
||||
className={backdropClassName}
|
||||
onMouseDown={handleBackdropBeginPress}
|
||||
onMouseUp={handleBackdropEndPress}
|
||||
>
|
||||
<div className={classNames(className, styles[size])} style={style}>
|
||||
<ErrorBoundary
|
||||
errorComponent={ModalError}
|
||||
onModalClose={onModalClose}
|
||||
<ModalContext.Provider value={{ headerId }}>
|
||||
<FocusLock disabled={false}>
|
||||
<div className={styles.modalContainer}>
|
||||
<div
|
||||
ref={backgroundRef}
|
||||
className={backdropClassName}
|
||||
onMouseDown={handleBackdropBeginPress}
|
||||
onMouseUp={handleBackdropEndPress}
|
||||
>
|
||||
<div
|
||||
className={classNames(className, styles[size])}
|
||||
style={style}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={headerId}
|
||||
>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary
|
||||
errorComponent={ModalError}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FocusLock>,
|
||||
</FocusLock>
|
||||
</ModalContext.Provider>,
|
||||
node!
|
||||
);
|
||||
}
|
||||
|
|
|
|||
11
frontend/src/Components/Modal/ModalContext.ts
Normal file
11
frontend/src/Components/Modal/ModalContext.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
|
||||
interface ModalContextValue {
|
||||
headerId: string;
|
||||
}
|
||||
|
||||
export const ModalContext = createContext<ModalContextValue>({ headerId: '' });
|
||||
|
||||
export function useModalContext() {
|
||||
return useContext(ModalContext);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { ForwardedRef, forwardRef, ReactNode } from 'react';
|
||||
import { useModalContext } from './ModalContext';
|
||||
import styles from './ModalHeader.css';
|
||||
|
||||
interface ModalHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
|
|
@ -10,8 +11,15 @@ const ModalHeader = forwardRef(
|
|||
{ children, ...otherProps }: ModalHeaderProps,
|
||||
ref: ForwardedRef<HTMLDivElement>
|
||||
) => {
|
||||
const { headerId } = useModalContext();
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.modalHeader} {...otherProps}>
|
||||
<div
|
||||
ref={ref}
|
||||
id={headerId}
|
||||
className={styles.modalHeader}
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ function MonitorToggleButton(props: MonitorToggleButtonProps) {
|
|||
name={iconName}
|
||||
size={size}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
isDisabled={isDisabled}
|
||||
isSpinning={isSaving}
|
||||
{...otherProps}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ function PageHeader() {
|
|||
<IconButton
|
||||
id="sidebar-toggle-button"
|
||||
name={icons.NAVBAR_COLLAPSE}
|
||||
aria-label={translate('Menu')}
|
||||
onPress={handleSidebarToggle}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { ForwardedRef, forwardRef, ReactNode, useCallback } from 'react';
|
||||
import Scroller, { OnScroll } from 'Components/Scroller/Scroller';
|
||||
import useScrollPosition from 'Helpers/Hooks/useScrollPosition';
|
||||
import { isLocked } from 'Utilities/scrollLock';
|
||||
import styles from './PageContentBody.css';
|
||||
|
||||
|
|
@ -7,7 +8,7 @@ interface PageContentBodyProps {
|
|||
className?: string;
|
||||
innerClassName?: string;
|
||||
children: ReactNode;
|
||||
initialScrollTop?: number;
|
||||
scrollPositionKey?: string;
|
||||
onScroll?: (payload: OnScroll) => void;
|
||||
}
|
||||
|
||||
|
|
@ -17,26 +18,32 @@ const PageContentBody = forwardRef(
|
|||
className = styles.contentBody,
|
||||
innerClassName = styles.innerContentBody,
|
||||
children,
|
||||
scrollPositionKey,
|
||||
onScroll,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const onScrollWrapper = useCallback(
|
||||
const { initialScrollTop, onScroll: onScrollMemo } =
|
||||
useScrollPosition(scrollPositionKey);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(payload: OnScroll) => {
|
||||
if (onScroll && !isLocked()) {
|
||||
onScroll(payload);
|
||||
if (isLocked()) {
|
||||
return;
|
||||
}
|
||||
|
||||
onScrollMemo(payload);
|
||||
onScroll?.(payload);
|
||||
},
|
||||
[onScroll]
|
||||
[onScroll, onScrollMemo]
|
||||
);
|
||||
|
||||
return (
|
||||
<Scroller
|
||||
ref={ref}
|
||||
{...otherProps}
|
||||
className={className}
|
||||
scrollDirection="vertical"
|
||||
onScroll={onScrollWrapper}
|
||||
initialScrollTop={initialScrollTop}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div className={innerClassName}>{children}</div>
|
||||
</Scroller>
|
||||
|
|
|
|||
|
|
@ -435,10 +435,11 @@ function PageSidebar() {
|
|||
const ScrollerComponent = isSmallScreen ? Scroller : OverlayScroller;
|
||||
|
||||
return (
|
||||
<div
|
||||
<nav
|
||||
ref={sidebarRef}
|
||||
className={styles.sidebarContainer}
|
||||
style={containerStyle}
|
||||
aria-label={translate('MainNavigation')}
|
||||
>
|
||||
{isSmallScreen ? (
|
||||
<div className={styles.sidebarHeader}>
|
||||
|
|
@ -521,7 +522,7 @@ function PageSidebar() {
|
|||
|
||||
<Messages />
|
||||
</ScrollerComponent>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,11 +46,12 @@ function PageSidebarItem({
|
|||
isActive && styles.isActiveLink
|
||||
)}
|
||||
to={to}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{!!iconName && (
|
||||
<span className={styles.iconContainer}>
|
||||
<Icon name={iconName} />
|
||||
<Icon name={iconName} aria-hidden={true} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import Episode from 'Episode/Episode';
|
|||
import { EpisodeFile } from 'EpisodeFile/EpisodeFile';
|
||||
import { PagedQueryResponse } from 'Helpers/Hooks/usePagedApiQuery';
|
||||
import Series from 'Series/Series';
|
||||
import { IndexerModel } from 'Settings/Indexers/useIndexers';
|
||||
import { NotificationModel } from 'Settings/Notifications/useConnections';
|
||||
import { removeItem, updateItem } from 'Store/Actions/baseActions';
|
||||
import { repopulatePage } from 'Utilities/pagePopulator';
|
||||
import SignalRLogger from 'Utilities/SignalRLogger';
|
||||
|
|
@ -141,30 +143,11 @@ function SignalRListener() {
|
|||
if (body.action === 'updated') {
|
||||
const updatedItem = body.resource as Episode;
|
||||
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: ['/episode'] },
|
||||
(oldData: Episode[] | undefined) => {
|
||||
if (!oldData) {
|
||||
return oldData;
|
||||
}
|
||||
|
||||
const itemIndex = oldData.findIndex(
|
||||
(item) => item.id === updatedItem.id
|
||||
);
|
||||
|
||||
// Don't add episode if not found
|
||||
if (itemIndex === -1) {
|
||||
return oldData;
|
||||
}
|
||||
|
||||
return oldData.map((item) => {
|
||||
if (item.id === updatedItem.id) {
|
||||
return updatedItem;
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
}
|
||||
updateQueryClientItem(
|
||||
queryClient,
|
||||
['/episode'],
|
||||
updatedItem,
|
||||
false // Don't add the episode to the list if it doesn't exist. Episodes should already be in the list since they are included in the series details.
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -179,30 +162,11 @@ function SignalRListener() {
|
|||
if (body.action === 'updated') {
|
||||
const updatedItem = body.resource as EpisodeFile;
|
||||
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: ['/episodeFile'] },
|
||||
(oldData: EpisodeFile[] | undefined) => {
|
||||
if (!oldData) {
|
||||
return oldData;
|
||||
}
|
||||
|
||||
const itemIndex = oldData.findIndex(
|
||||
(item) => item.id === updatedItem.id
|
||||
);
|
||||
|
||||
// Add episode file to the end
|
||||
if (itemIndex === -1) {
|
||||
return [...oldData, updatedItem];
|
||||
}
|
||||
|
||||
return oldData.map((item) => {
|
||||
if (item.id === updatedItem.id) {
|
||||
return updatedItem;
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
}
|
||||
updateQueryClientItem(
|
||||
queryClient,
|
||||
['/episodeFile'],
|
||||
updatedItem,
|
||||
true // Add the episode file to the list if it doesn't exist. This can happen when an episode file is imported and wasn't previously in the list of episode files.
|
||||
);
|
||||
|
||||
// Repopulate the page to handle recently imported file
|
||||
|
|
@ -210,24 +174,7 @@ function SignalRListener() {
|
|||
} else if (body.action === 'deleted') {
|
||||
const id = body.resource.id;
|
||||
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: ['/episodeFile'] },
|
||||
(oldData: EpisodeFile[] | undefined) => {
|
||||
if (!oldData) {
|
||||
return oldData;
|
||||
}
|
||||
|
||||
const itemIndex = oldData.findIndex((item) => item.id === id);
|
||||
|
||||
// Add episode file to the end
|
||||
if (itemIndex === -1) {
|
||||
return oldData;
|
||||
}
|
||||
|
||||
return oldData.filter((item) => item.id !== id);
|
||||
}
|
||||
);
|
||||
|
||||
removeQueryClientItem(queryClient, ['/episodeFile'], id);
|
||||
repopulatePage('episodeFileDeleted');
|
||||
}
|
||||
|
||||
|
|
@ -256,34 +203,39 @@ function SignalRListener() {
|
|||
}
|
||||
|
||||
if (name === 'indexer') {
|
||||
const section = 'settings.indexers';
|
||||
const updatedItem = body.resource as IndexerModel;
|
||||
|
||||
if (body.action === 'created' || body.action === 'updated') {
|
||||
dispatch(updateItem({ section, ...body.resource }));
|
||||
updateQueryClientItem(queryClient, ['/indexer'], updatedItem, true);
|
||||
} else if (body.action === 'deleted') {
|
||||
dispatch(removeItem({ section, id: body.resource.id }));
|
||||
removeQueryClientItem(queryClient, ['/indexer'], body.resource.id);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'metadata') {
|
||||
const section = 'settings.metadata';
|
||||
const updatedItem = body.resource as ModelBase;
|
||||
|
||||
if (body.action === 'updated') {
|
||||
dispatch(updateItem({ section, ...body.resource }));
|
||||
updateQueryClientItem(queryClient, ['/metadata'], updatedItem, false);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'notification') {
|
||||
const section = 'settings.notifications';
|
||||
if (name === 'connection') {
|
||||
const updatedItem = body.resource as NotificationModel;
|
||||
|
||||
if (body.action === 'created' || body.action === 'updated') {
|
||||
dispatch(updateItem({ section, ...body.resource }));
|
||||
updateQueryClientItem(
|
||||
queryClient,
|
||||
['/connection'],
|
||||
updatedItem,
|
||||
body.action === 'created' // Only add the connection to the list if it was created. If it was updated and it doesn't exist in the list, it likely means the connection is disabled and shouldn't be shown in the list.
|
||||
);
|
||||
} else if (body.action === 'deleted') {
|
||||
dispatch(removeItem({ section, id: body.resource.id }));
|
||||
removeQueryClientItem(queryClient, ['/connection'], body.resource.id);
|
||||
}
|
||||
|
||||
return;
|
||||
|
|
@ -350,42 +302,16 @@ function SignalRListener() {
|
|||
if (body.action === 'updated') {
|
||||
const updatedItem = body.resource as Series;
|
||||
|
||||
queryClient.setQueryData<Series[]>(
|
||||
updateQueryClientItem(
|
||||
queryClient,
|
||||
['/series'],
|
||||
(oldData: Series[] | undefined) => {
|
||||
if (!oldData) {
|
||||
return oldData;
|
||||
}
|
||||
|
||||
return oldData.map((item) => {
|
||||
if (item.id === updatedItem.id) {
|
||||
return {
|
||||
...item,
|
||||
...updatedItem,
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
}
|
||||
updatedItem,
|
||||
false // Don't add the series to the list if it doesn't exist. Series should already be in the list since they are included in the calendar and series details.
|
||||
);
|
||||
|
||||
repopulatePage('seriesUpdated');
|
||||
} else if (body.action === 'deleted') {
|
||||
dispatch(removeItem({ section: 'series', id: body.resource.id }));
|
||||
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: ['/series'] },
|
||||
(oldData: Series[] | undefined) => {
|
||||
if (!oldData) {
|
||||
return oldData;
|
||||
}
|
||||
|
||||
return oldData.filter((item) => {
|
||||
return item.id !== body.resource.id;
|
||||
});
|
||||
}
|
||||
);
|
||||
removeQueryClientItem(queryClient, ['/series'], body.resource.id);
|
||||
}
|
||||
|
||||
return;
|
||||
|
|
@ -521,3 +447,50 @@ const updatePagedItem = <T extends ModelBase>(
|
|||
}
|
||||
);
|
||||
};
|
||||
|
||||
const updateQueryClientItem = <T extends ModelBase>(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
queryKey: QueryKey,
|
||||
updatedItem: T,
|
||||
addMissing: boolean
|
||||
) => {
|
||||
queryClient.setQueriesData({ queryKey }, (oldData: T[] | undefined) => {
|
||||
if (!oldData) {
|
||||
return oldData;
|
||||
}
|
||||
|
||||
const itemIndex = oldData.findIndex((item) => item.id === updatedItem.id);
|
||||
|
||||
if (itemIndex === -1 && addMissing) {
|
||||
return [...oldData, updatedItem];
|
||||
}
|
||||
|
||||
return oldData.map((item) => {
|
||||
if (item.id === updatedItem.id) {
|
||||
return updatedItem;
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const removeQueryClientItem = <T extends ModelBase>(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
queryKey: QueryKey,
|
||||
id: T['id']
|
||||
) => {
|
||||
queryClient.setQueriesData({ queryKey }, (oldData: T[] | undefined) => {
|
||||
if (!oldData) {
|
||||
return oldData;
|
||||
}
|
||||
|
||||
const itemIndex = oldData.findIndex((item) => item.id === id);
|
||||
|
||||
if (itemIndex === -1) {
|
||||
return oldData;
|
||||
}
|
||||
|
||||
return oldData.filter((item) => item.id !== id);
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { icons, scrollDirections } from 'Helpers/Props';
|
|||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import { CheckInputChanged } from 'typings/inputs';
|
||||
import { TableOptionsChangePayload } from 'typings/Table';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import Column from './Column';
|
||||
import TableHeader from './TableHeader';
|
||||
import TableHeaderCell from './TableHeaderCell';
|
||||
|
|
@ -94,7 +95,10 @@ function Table({
|
|||
canModifyColumns={canModifyColumns}
|
||||
onTableOptionChange={onTableOptionChange}
|
||||
>
|
||||
<IconButton name={icons.ADVANCED_SETTINGS} />
|
||||
<IconButton
|
||||
name={icons.ADVANCED_SETTINGS}
|
||||
aria-label={translate('AdvancedSettings')}
|
||||
/>
|
||||
</TableOptionsModalWrapper>
|
||||
</TableHeaderCell>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import { icons, sortDirections } from 'Helpers/Props';
|
||||
|
|
@ -41,6 +41,20 @@ function TableHeaderCell({
|
|||
? icons.SORT_ASCENDING
|
||||
: icons.SORT_DESCENDING;
|
||||
|
||||
const ariaSortValue = useMemo(() => {
|
||||
if (!isSortable) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!isSorting) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
return sortDirection === sortDirections.ASCENDING
|
||||
? 'ascending'
|
||||
: 'descending';
|
||||
}, [isSorting, sortDirection, isSortable]);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (fixedSortDirection) {
|
||||
onSortPress?.(name, fixedSortDirection);
|
||||
|
|
@ -56,14 +70,20 @@ function TableHeaderCell({
|
|||
className={className}
|
||||
// label={typeof label === 'function' ? label() : label}
|
||||
title={typeof columnLabel === 'function' ? columnLabel() : columnLabel}
|
||||
scope="col"
|
||||
aria-sort={ariaSortValue}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{children}
|
||||
|
||||
{isSorting && <Icon name={sortIcon} className={styles.sortIcon} />}
|
||||
{isSorting ? (
|
||||
<Icon name={sortIcon} className={styles.sortIcon} aria-hidden={true} />
|
||||
) : null}
|
||||
</Link>
|
||||
) : (
|
||||
<th className={className}>{children}</th>
|
||||
<th className={className} scope="col">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ function TableOptionsModal({
|
|||
|
||||
const handleColumnDragEnd = useCallback(
|
||||
(didDrop: boolean) => {
|
||||
if (didDrop && dragIndex && dropIndex !== null) {
|
||||
if (didDrop && dragIndex !== null && dropIndex !== null) {
|
||||
const newColumns = [...columns];
|
||||
const items = newColumns.splice(dragIndex, 1);
|
||||
newColumns.splice(dropIndex, 0, items[0]);
|
||||
|
|
|
|||
|
|
@ -108,9 +108,10 @@ function TablePager({
|
|||
isFirstPage && styles.disabledPageButton
|
||||
)}
|
||||
isDisabled={isFirstPage}
|
||||
aria-label={translate('PagerGoToFirstPage')}
|
||||
onPress={handleFirstPagePress}
|
||||
>
|
||||
<Icon name={icons.PAGE_FIRST} />
|
||||
<Icon name={icons.PAGE_FIRST} aria-hidden={true} />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
|
|
@ -119,15 +120,20 @@ function TablePager({
|
|||
isFirstPage && styles.disabledPageButton
|
||||
)}
|
||||
isDisabled={isFirstPage}
|
||||
aria-label={translate('PagerGoToPreviousPage')}
|
||||
onPress={onPreviousPagePress}
|
||||
>
|
||||
<Icon name={icons.PAGE_PREVIOUS} />
|
||||
<Icon name={icons.PAGE_PREVIOUS} aria-hidden={true} />
|
||||
</Link>
|
||||
|
||||
<div className={styles.pageNumber}>
|
||||
{isShowingPageSelect ? null : (
|
||||
<Link
|
||||
isDisabled={totalPages === 1}
|
||||
aria-label={translate('PagerGoToPage', {
|
||||
page,
|
||||
totalPages: totalPages ?? 0,
|
||||
})}
|
||||
onPress={handleOpenPageSelectClick}
|
||||
>
|
||||
{page} / {totalPages}
|
||||
|
|
@ -153,9 +159,10 @@ function TablePager({
|
|||
isLastPage && styles.disabledPageButton
|
||||
)}
|
||||
isDisabled={isLastPage}
|
||||
aria-label={translate('PagerGoToNextPage')}
|
||||
onPress={onNextPagePress}
|
||||
>
|
||||
<Icon name={icons.PAGE_NEXT} />
|
||||
<Icon name={icons.PAGE_NEXT} aria-hidden={true} />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
|
|
@ -164,9 +171,10 @@ function TablePager({
|
|||
isLastPage && styles.disabledPageButton
|
||||
)}
|
||||
isDisabled={isLastPage}
|
||||
aria-label={translate('PagerGoToLastPage')}
|
||||
onPress={onLastPagePress}
|
||||
>
|
||||
<Icon name={icons.PAGE_LAST} />
|
||||
<Icon name={icons.PAGE_LAST} aria-hidden={true} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
import React from 'react';
|
||||
import { RouteComponentProps } from 'react-router-dom';
|
||||
import scrollPositions from 'Store/scrollPositions';
|
||||
|
||||
interface WrappedComponentProps {
|
||||
initialScrollTop: number;
|
||||
}
|
||||
|
||||
interface ScrollPositionProps {
|
||||
history: RouteComponentProps['history'];
|
||||
location: RouteComponentProps['location'];
|
||||
match: RouteComponentProps['match'];
|
||||
}
|
||||
|
||||
function withScrollPosition(
|
||||
WrappedComponent: React.FC<WrappedComponentProps>,
|
||||
scrollPositionKey: string
|
||||
) {
|
||||
function ScrollPosition(props: ScrollPositionProps) {
|
||||
const { history } = props;
|
||||
|
||||
const initialScrollTop =
|
||||
history.action === 'POP' ? scrollPositions[scrollPositionKey] : 0;
|
||||
|
||||
return <WrappedComponent {...props} initialScrollTop={initialScrollTop} />;
|
||||
}
|
||||
|
||||
return ScrollPosition;
|
||||
}
|
||||
|
||||
export default withScrollPosition;
|
||||
BIN
frontend/src/Content/Images/thetvdb-dark.png
Normal file
BIN
frontend/src/Content/Images/thetvdb-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
BIN
frontend/src/Content/Images/thetvdb-light.png
Normal file
BIN
frontend/src/Content/Images/thetvdb-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
|
|
@ -54,6 +54,7 @@ function EpisodeSearchCell({
|
|||
<IconButton
|
||||
name={icons.INTERACTIVE}
|
||||
title={translate('InteractiveSearch')}
|
||||
aria-label={translate('InteractiveSearch')}
|
||||
onPress={setDetailsModalOpen}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ function EpisodeHistoryRow({
|
|||
{eventType === 'grabbed' && (
|
||||
<IconButton
|
||||
title={translate('MarkAsFailed')}
|
||||
aria-label={translate('MarkAsFailed')}
|
||||
name={icons.REMOVE}
|
||||
size={14}
|
||||
onPress={handleMarkAsFailedPress}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import createIndexerFlagsSelector from 'Store/Selectors/createIndexerFlagsSelector';
|
||||
import useIndexerFlags from 'Settings/Indexers/useIndexerFlags';
|
||||
|
||||
interface IndexerFlagsProps {
|
||||
indexerFlags: number;
|
||||
}
|
||||
|
||||
function IndexerFlags({ indexerFlags = 0 }: IndexerFlagsProps) {
|
||||
const allIndexerFlags = useSelector(createIndexerFlagsSelector);
|
||||
const { data: allIndexerFlags } = useIndexerFlags();
|
||||
|
||||
const flags = allIndexerFlags.items.filter(
|
||||
const flags = allIndexerFlags.filter(
|
||||
// eslint-disable-next-line no-bitwise
|
||||
(item) => (indexerFlags & item.id) === item.id
|
||||
);
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ function EpisodeFileRow(props: EpisodeFileRowProps) {
|
|||
|
||||
<IconButton
|
||||
title={translate('DeleteEpisodeFromDisk')}
|
||||
aria-label={translate('DeleteEpisodeFromDisk')}
|
||||
name={icons.REMOVE}
|
||||
onPress={setRemoveEpisodeFileModalOpen}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import React from 'react';
|
||||
import DescriptionList from 'Components/DescriptionList/DescriptionList';
|
||||
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
|
||||
import useLanguageName from 'Language/useLanguageName';
|
||||
import MediaInfoProps from 'typings/MediaInfo';
|
||||
import formatBitrate from 'Utilities/Number/formatBitrate';
|
||||
import getEntries from 'Utilities/Object/getEntries';
|
||||
import getLanguageName from 'Utilities/String/getLanguageName';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
function MediaInfo(props: MediaInfoProps) {
|
||||
const getLanguageName = useLanguageName();
|
||||
|
||||
return (
|
||||
<DescriptionList>
|
||||
{getEntries(props).map(([key, value]) => {
|
||||
|
|
@ -21,7 +23,10 @@ function MediaInfo(props: MediaInfoProps) {
|
|||
|
||||
if (key === 'audioStreams') {
|
||||
return value.map((audioStream, index) => {
|
||||
const language = getLanguageName(audioStream.language);
|
||||
const language =
|
||||
audioStream.language === 'und'
|
||||
? translate('Unknown')
|
||||
: getLanguageName(audioStream.language);
|
||||
|
||||
let line = `${language}`;
|
||||
|
||||
|
|
@ -56,39 +61,50 @@ function MediaInfo(props: MediaInfoProps) {
|
|||
<DescriptionListItem
|
||||
key={key}
|
||||
title={translate('MediaInfoSubtitlesHeader')}
|
||||
data={value.reduce(
|
||||
(acc: React.ReactNode[] | null, subtitleStream, index) => {
|
||||
const language = getLanguageName(subtitleStream.language);
|
||||
data={
|
||||
value.length > 0
|
||||
? value.reduce(
|
||||
(
|
||||
acc: React.ReactNode[] | null,
|
||||
subtitleStream,
|
||||
index
|
||||
) => {
|
||||
const language = getLanguageName(
|
||||
subtitleStream.language
|
||||
);
|
||||
|
||||
let line = `${
|
||||
subtitleStream.format?.toUpperCase() || translate('Unknown')
|
||||
}`;
|
||||
let line = `${
|
||||
subtitleStream.format?.toUpperCase() ||
|
||||
translate('Unknown')
|
||||
}`;
|
||||
|
||||
if (
|
||||
subtitleStream.title !== undefined &&
|
||||
subtitleStream.title !== language
|
||||
) {
|
||||
line += ` | ${subtitleStream.title}`;
|
||||
}
|
||||
if (
|
||||
subtitleStream.title !== undefined &&
|
||||
subtitleStream.title !== language
|
||||
) {
|
||||
line += ` | ${subtitleStream.title}`;
|
||||
}
|
||||
|
||||
if (subtitleStream.forced) {
|
||||
line += ` | ${translate('MediaInfoForced')}`;
|
||||
}
|
||||
if (subtitleStream.forced) {
|
||||
line += ` | ${translate('MediaInfoForced')}`;
|
||||
}
|
||||
|
||||
if (subtitleStream.hearingImpaired) {
|
||||
line += ` | ${translate('MediaInfoHearingImpaired')}`;
|
||||
}
|
||||
if (subtitleStream.hearingImpaired) {
|
||||
line += ` | ${translate('MediaInfoHearingImpaired')}`;
|
||||
}
|
||||
|
||||
const curr = (
|
||||
<span key={index} title={line}>
|
||||
{language}
|
||||
</span>
|
||||
);
|
||||
const curr = (
|
||||
<span key={index} title={line}>
|
||||
{language}
|
||||
</span>
|
||||
);
|
||||
|
||||
return acc === null ? [curr] : [acc, ' / ', curr];
|
||||
},
|
||||
null
|
||||
)}
|
||||
return acc === null ? [curr] : [acc, ' / ', curr];
|
||||
},
|
||||
null
|
||||
)
|
||||
: translate('None')
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import React from 'react';
|
||||
import getLanguageName from 'Utilities/String/getLanguageName';
|
||||
import useLanguageName from 'Language/useLanguageName';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import { useEpisodeFile } from './EpisodeFileProvider';
|
||||
|
||||
function formatLanguages(languages: string[] | undefined) {
|
||||
function formatLanguages(
|
||||
languages: string[] | undefined,
|
||||
getLanguageName: (code: string) => string
|
||||
) {
|
||||
if (!languages) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -43,6 +46,7 @@ interface MediaInfoProps {
|
|||
}
|
||||
|
||||
function MediaInfo({ episodeFileId, type }: MediaInfoProps) {
|
||||
const getLanguageName = useLanguageName();
|
||||
const episodeFile = useEpisodeFile(episodeFileId);
|
||||
|
||||
if (!episodeFile?.mediaInfo) {
|
||||
|
|
@ -76,11 +80,17 @@ function MediaInfo({ episodeFileId, type }: MediaInfoProps) {
|
|||
}
|
||||
|
||||
if (type === 'audioLanguages') {
|
||||
return formatLanguages(audioStreams.map(({ language }) => language));
|
||||
return formatLanguages(
|
||||
audioStreams.map(({ language }) => language),
|
||||
getLanguageName
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'subtitles') {
|
||||
return formatLanguages(subtitleStreams.map(({ language }) => language));
|
||||
return formatLanguages(
|
||||
subtitleStreams.map(({ language }) => language),
|
||||
getLanguageName
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'video') {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import Alert from 'Components/Alert';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
|
|
@ -16,31 +15,22 @@ import {
|
|||
authenticationMethodOptions,
|
||||
authenticationRequiredOptions,
|
||||
} from 'Settings/General/SecuritySettings';
|
||||
import { clearPendingChanges } from 'Store/Actions/baseActions';
|
||||
import {
|
||||
fetchGeneralSettings,
|
||||
saveGeneralSettings,
|
||||
setGeneralSettingsValue,
|
||||
} from 'Store/Actions/settingsActions';
|
||||
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
|
||||
import { useManageGeneralSettings } from 'Settings/General/useGeneralSettings';
|
||||
import useSystemStatus from 'System/Status/useSystemStatus';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './AuthenticationRequiredModalContent.css';
|
||||
|
||||
const SECTION = 'general';
|
||||
|
||||
const selector = createSettingsSectionSelector(SECTION);
|
||||
|
||||
function onModalClose() {
|
||||
// No-op
|
||||
}
|
||||
|
||||
export default function AuthenticationRequiredModalContent() {
|
||||
const { isPopulated, error, isSaving, settings } = useSelector(selector);
|
||||
const dispatch = useDispatch();
|
||||
const { refetch: refetchStatus } = useSystemStatus();
|
||||
|
||||
const { settings, isFetched, error, isSaving, saveSettings, updateSetting } =
|
||||
useManageGeneralSettings();
|
||||
|
||||
const {
|
||||
authenticationMethod,
|
||||
authenticationRequired,
|
||||
|
|
@ -51,20 +41,12 @@ export default function AuthenticationRequiredModalContent() {
|
|||
|
||||
const wasSaving = usePrevious(isSaving);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchGeneralSettings());
|
||||
|
||||
return () => {
|
||||
dispatch(clearPendingChanges({ section: `settings.${SECTION}` }));
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
const onInputChange = useCallback(
|
||||
(args: InputChanged) => {
|
||||
// @ts-expect-error Actions aren't typed
|
||||
dispatch(setGeneralSettingsValue(args));
|
||||
(change: InputChanged) => {
|
||||
// @ts-expect-error input change events aren't typed
|
||||
updateSetting(change.name, change.value);
|
||||
},
|
||||
[dispatch]
|
||||
[updateSetting]
|
||||
);
|
||||
|
||||
const authenticationEnabled =
|
||||
|
|
@ -79,8 +61,8 @@ export default function AuthenticationRequiredModalContent() {
|
|||
}, [isSaving, wasSaving, refetchStatus]);
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
dispatch(saveGeneralSettings());
|
||||
}, [dispatch]);
|
||||
saveSettings();
|
||||
}, [saveSettings]);
|
||||
|
||||
return (
|
||||
<ModalContent showCloseButton={false} onModalClose={onModalClose}>
|
||||
|
|
@ -91,7 +73,7 @@ export default function AuthenticationRequiredModalContent() {
|
|||
{translate('AuthenticationRequiredWarning')}
|
||||
</Alert>
|
||||
|
||||
{isPopulated && !error ? (
|
||||
{isFetched && !error ? (
|
||||
<div>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('AuthenticationMethod')}</FormLabel>
|
||||
|
|
@ -177,7 +159,7 @@ export default function AuthenticationRequiredModalContent() {
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{!isPopulated && !error ? <LoadingIndicator /> : null}
|
||||
{!isFetched && !error ? <LoadingIndicator /> : null}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import ModelBase from 'App/ModelBase';
|
||||
import { ValidationFailures } from 'Store/Selectors/selectSettings';
|
||||
import {
|
||||
ValidationError,
|
||||
|
|
@ -71,3 +72,16 @@ export function getValidationFailures(
|
|||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function addOrUpdateQueryClientItem<
|
||||
T extends ModelBase,
|
||||
K extends keyof T
|
||||
>(oldData: T[] = [], newItem: T, key: K) {
|
||||
const existingIndex = oldData.findIndex((item) => item[key] === newItem[key]);
|
||||
|
||||
if (existingIndex === -1) {
|
||||
return [...oldData, newItem];
|
||||
}
|
||||
|
||||
return oldData.map((item) => (item[key] === newItem[key] ? newItem : item));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,41 +5,42 @@ import AppState from 'App/State/AppState';
|
|||
import { useTranslations } from 'App/useTranslations';
|
||||
import useCommands from 'Commands/useCommands';
|
||||
import useCustomFilters from 'Filters/useCustomFilters';
|
||||
import { useInitializeLanguage } from 'Language/useLanguageName';
|
||||
import { useLanguages } from 'Language/useLanguages';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import useIndexerFlags from 'Settings/Indexers/useIndexerFlags';
|
||||
import { useQualityProfiles } from 'Settings/Profiles/Quality/useQualityProfiles';
|
||||
import { useUiSettings } from 'Settings/UI/useUiSettings';
|
||||
import { fetchCustomFilters } from 'Store/Actions/customFilterActions';
|
||||
import {
|
||||
fetchImportLists,
|
||||
fetchIndexerFlags,
|
||||
fetchLanguages,
|
||||
} from 'Store/Actions/settingsActions';
|
||||
import { fetchImportLists } from 'Store/Actions/settingsActions';
|
||||
import useSystemStatus from 'System/Status/useSystemStatus';
|
||||
import useTags from 'Tags/useTags';
|
||||
import { ApiError } from 'Utilities/Fetch/fetchJson';
|
||||
|
||||
const createErrorsSelector = ({
|
||||
customFiltersError,
|
||||
indexerFlagsError,
|
||||
systemStatusError,
|
||||
tagsError,
|
||||
translationsError,
|
||||
uiSettingsError,
|
||||
seriesError,
|
||||
qualityProfilesError,
|
||||
languagesError,
|
||||
}: {
|
||||
customFiltersError: ApiError | null;
|
||||
indexerFlagsError: ApiError | null;
|
||||
systemStatusError: ApiError | null;
|
||||
tagsError: ApiError | null;
|
||||
translationsError: ApiError | null;
|
||||
uiSettingsError: ApiError | null;
|
||||
seriesError: ApiError | null;
|
||||
qualityProfilesError: ApiError | null;
|
||||
languagesError: ApiError | null;
|
||||
}) =>
|
||||
createSelector(
|
||||
(state: AppState) => state.settings.languages.error,
|
||||
(state: AppState) => state.settings.importLists.error,
|
||||
(state: AppState) => state.settings.indexerFlags.error,
|
||||
(languagesError, importListsError, indexerFlagsError) => {
|
||||
(importListsError) => {
|
||||
const hasError = !!(
|
||||
customFiltersError ||
|
||||
seriesError ||
|
||||
|
|
@ -76,11 +77,12 @@ const useAppPage = () => {
|
|||
const dispatch = useDispatch();
|
||||
|
||||
useCommands();
|
||||
useInitializeLanguage();
|
||||
|
||||
const { isFetched: isCustomFiltersFetched, error: customFiltersError } =
|
||||
useCustomFilters();
|
||||
|
||||
const { isSuccess: isSeriesFetched, error: seriesError } = useSeries();
|
||||
const { isFetched: isSeriesFetched, error: seriesError } = useSeries();
|
||||
|
||||
const { isFetched: isSystemStatusFetched, error: systemStatusError } =
|
||||
useSystemStatus();
|
||||
|
|
@ -96,32 +98,39 @@ const useAppPage = () => {
|
|||
const { isFetched: isQualityProfilesFetched, error: qualityProfilesError } =
|
||||
useQualityProfiles();
|
||||
|
||||
const { isFetched: isLanguagesFetched, error: languagesError } =
|
||||
useLanguages();
|
||||
|
||||
const { isFetched: isIndexerFlagsFetched, error: indexerFlagsError } =
|
||||
useIndexerFlags();
|
||||
|
||||
const isAppStatePopulated = useSelector(
|
||||
(state: AppState) =>
|
||||
state.settings.languages.isPopulated &&
|
||||
state.settings.importLists.isPopulated &&
|
||||
state.settings.indexerFlags.isPopulated
|
||||
(state: AppState) => state.settings.importLists.isPopulated
|
||||
);
|
||||
|
||||
const isPopulated =
|
||||
isAppStatePopulated &&
|
||||
isCustomFiltersFetched &&
|
||||
isIndexerFlagsFetched &&
|
||||
isSeriesFetched &&
|
||||
isSystemStatusFetched &&
|
||||
isTagsFetched &&
|
||||
isTranslationsFetched &&
|
||||
isUiSettingsFetched &&
|
||||
isQualityProfilesFetched;
|
||||
isQualityProfilesFetched &&
|
||||
isLanguagesFetched;
|
||||
|
||||
const { hasError, errors } = useSelector(
|
||||
createErrorsSelector({
|
||||
customFiltersError,
|
||||
indexerFlagsError,
|
||||
seriesError,
|
||||
systemStatusError,
|
||||
tagsError,
|
||||
translationsError,
|
||||
uiSettingsError,
|
||||
qualityProfilesError,
|
||||
languagesError,
|
||||
})
|
||||
);
|
||||
|
||||
|
|
@ -140,9 +149,7 @@ const useAppPage = () => {
|
|||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCustomFilters());
|
||||
dispatch(fetchLanguages());
|
||||
dispatch(fetchImportLists());
|
||||
dispatch(fetchIndexerFlags());
|
||||
}, [dispatch]);
|
||||
|
||||
return useMemo(() => {
|
||||
|
|
|
|||
39
frontend/src/Helpers/Hooks/useCombinedRefs.ts
Normal file
39
frontend/src/Helpers/Hooks/useCombinedRefs.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { ForwardedRef, useCallback, useRef } from 'react';
|
||||
|
||||
type OptionalRef<T> = ForwardedRef<T> | undefined;
|
||||
|
||||
function setRef<T>(ref: OptionalRef<T>, value: T | null) {
|
||||
if (typeof ref === 'function') {
|
||||
ref(value);
|
||||
} else if (ref) {
|
||||
ref.current = value;
|
||||
}
|
||||
}
|
||||
|
||||
function useCombinedRefs<T>(...refs: OptionalRef<T>[]) {
|
||||
const previousRefs = useRef<OptionalRef<T>[]>([]);
|
||||
|
||||
return useCallback((value: T | null) => {
|
||||
let index = 0;
|
||||
for (; index < refs.length; index++) {
|
||||
const ref = refs[index];
|
||||
const prev = previousRefs.current[index];
|
||||
|
||||
if (prev !== ref) {
|
||||
setRef(prev, null);
|
||||
}
|
||||
setRef(ref, value);
|
||||
}
|
||||
|
||||
for (; index < previousRefs.current.length; index++) {
|
||||
const prev = previousRefs.current[index];
|
||||
setRef(prev, null);
|
||||
}
|
||||
|
||||
previousRefs.current = refs;
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, refs);
|
||||
}
|
||||
|
||||
export default useCombinedRefs;
|
||||
|
|
@ -7,6 +7,7 @@ interface PageStore {
|
|||
cutoffUnmet: number;
|
||||
events: number;
|
||||
history: number;
|
||||
importListExclusion: number;
|
||||
missing: number;
|
||||
queue: number;
|
||||
}
|
||||
|
|
@ -16,6 +17,7 @@ const pageStore = create<PageStore>(() => ({
|
|||
cutoffUnmet: 1,
|
||||
events: 1,
|
||||
history: 1,
|
||||
importListExclusion: 1,
|
||||
missing: 1,
|
||||
queue: 1,
|
||||
}));
|
||||
|
|
|
|||
37
frontend/src/Helpers/Hooks/useScrollPosition.ts
Normal file
37
frontend/src/Helpers/Hooks/useScrollPosition.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router';
|
||||
import { OnScroll } from 'Components/Scroller/Scroller';
|
||||
import scrollPositions from 'Store/scrollPositions';
|
||||
|
||||
function useScrollPosition(key?: string) {
|
||||
const { pathname } = useLocation();
|
||||
const { action } = useHistory();
|
||||
|
||||
// Reset window scroll on PUSH/REPLACE (mobile's scroll container).
|
||||
// Reset the scroll position unless we're going back, this will allow the scroll
|
||||
// position to reset when moving forward (PUSH/REPLACE) and restore when
|
||||
// moving backwards (POP).
|
||||
useEffect(() => {
|
||||
if (action !== 'POP') {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
}, [pathname, action]);
|
||||
|
||||
const initialScrollTop = useMemo(
|
||||
() => (key && action === 'POP' ? scrollPositions[key] ?? 0 : 0),
|
||||
[key, action]
|
||||
);
|
||||
|
||||
const onScroll = useCallback(
|
||||
({ scrollTop }: OnScroll) => {
|
||||
if (key) {
|
||||
scrollPositions[key] = scrollTop;
|
||||
}
|
||||
},
|
||||
[key]
|
||||
);
|
||||
|
||||
return { initialScrollTop, onScroll };
|
||||
}
|
||||
|
||||
export default useScrollPosition;
|
||||
|
|
@ -2,10 +2,18 @@ import { useEffect, useState } from 'react';
|
|||
import { useUiSettingsValues } from 'Settings/UI/useUiSettings';
|
||||
import themes from 'Styles/Themes';
|
||||
|
||||
const useTheme = () => {
|
||||
const useTheme = (): 'dark' | 'light' => {
|
||||
const { theme } = useUiSettingsValues();
|
||||
const selectedTheme = theme ?? window.Sonarr.theme;
|
||||
const [resolvedTheme, setResolvedTheme] = useState(selectedTheme);
|
||||
const [resolvedTheme, setResolvedTheme] = useState(() => {
|
||||
if (selectedTheme === 'auto') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
}
|
||||
|
||||
return selectedTheme;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTheme !== 'auto') {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export const QUALITY = 'quality';
|
|||
export const QUALITY_PROFILE = 'qualityProfile';
|
||||
export const QUEUE_STATUS = 'queueStatus';
|
||||
export const MONITORED_STATUS = 'monitoredStatus';
|
||||
export const RELEASE_TYPES = 'releaseTypes';
|
||||
export const SERIES = 'series';
|
||||
export const SERIES_STATUS = 'seriesStatus';
|
||||
export const SERIES_TYPES = 'seriesType';
|
||||
|
|
@ -28,6 +29,7 @@ export type FilterBuildValueType =
|
|||
| 'qualityProfile'
|
||||
| 'queueStatus'
|
||||
| 'monitoredStatus'
|
||||
| 'releaseTypes'
|
||||
| 'series'
|
||||
| 'seriesStatus'
|
||||
| 'seriesType'
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ import {
|
|||
faFolderOpen as fasFolderOpen,
|
||||
faFolderTree as farFolderTree,
|
||||
faForward as fasForward,
|
||||
faGlobe as fasGlobe,
|
||||
faHeart as fasHeart,
|
||||
faHistory as fasHistory,
|
||||
faHome as fasHome,
|
||||
|
|
@ -166,6 +167,7 @@ export const FOOTNOTE = fasAsterisk;
|
|||
export const FOLDER = farFolder;
|
||||
export const FOLDER_OPEN = fasFolderOpen;
|
||||
export const GENRE = fasTheaterMasks;
|
||||
export const GLOBE = fasGlobe;
|
||||
export const GROUP = farObjectGroup;
|
||||
export const HEALTH = fasMedkit;
|
||||
export const HEART = fasHeart;
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ function FavoriteFolderRow({ folder, onPress }: FavoriteFolderRowProps) {
|
|||
<TableRowCell className={styles.actions}>
|
||||
<IconButton
|
||||
title={translate('FavoriteFolderRemove')}
|
||||
aria-label={translate('FavoriteFolderRemove')}
|
||||
kind="danger"
|
||||
name={icons.HEART}
|
||||
onPress={handleRemoveFavoritePress}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,11 @@ function RecentFolderRow({
|
|||
? translate('FavoriteFolderRemove')
|
||||
: translate('FavoriteFolderAdd')
|
||||
}
|
||||
aria-label={
|
||||
isFavorite
|
||||
? translate('FavoriteFolderRemove')
|
||||
: translate('FavoriteFolderAdd')
|
||||
}
|
||||
kind={isFavorite ? 'danger' : 'default'}
|
||||
name={isFavorite ? icons.HEART : icons.HEART_OUTLINE}
|
||||
onPress={handleFavoritePress}
|
||||
|
|
@ -71,6 +76,7 @@ function RecentFolderRow({
|
|||
|
||||
<IconButton
|
||||
title={translate('Remove')}
|
||||
aria-label={translate('Remove')}
|
||||
name={icons.REMOVE}
|
||||
onPress={handleRemovePress}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
.leftButtons,
|
||||
.rightButtons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -500,6 +500,9 @@ function InteractiveImportModalContentInner(
|
|||
return;
|
||||
}
|
||||
|
||||
const seenEpisodeIds = new Set<number>();
|
||||
let hasDuplicateEpisodes = false;
|
||||
|
||||
items.forEach((item) => {
|
||||
const isSelected = selectedIds.indexOf(item.id) > -1;
|
||||
|
||||
|
|
@ -552,6 +555,19 @@ function InteractiveImportModalContentInner(
|
|||
return;
|
||||
}
|
||||
|
||||
if (!hasDuplicateEpisodes) {
|
||||
for (const episode of episodes) {
|
||||
const hasAlreadySeen = seenEpisodeIds.has(episode.id);
|
||||
|
||||
seenEpisodeIds.add(episode.id);
|
||||
|
||||
if (hasAlreadySeen) {
|
||||
hasDuplicateEpisodes = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setInteractiveImportErrorMessage(null);
|
||||
|
||||
if (episodeFileId) {
|
||||
|
|
@ -587,6 +603,14 @@ function InteractiveImportModalContentInner(
|
|||
}
|
||||
});
|
||||
|
||||
if (hasDuplicateEpisodes) {
|
||||
setInteractiveImportErrorMessage(
|
||||
translate('InteractiveImportDuplicateEpisodes')
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let shouldClose = false;
|
||||
|
||||
if (existingFiles.length) {
|
||||
|
|
@ -953,13 +977,13 @@ function InteractiveImportModalContentInner(
|
|||
</div>
|
||||
|
||||
<div className={styles.rightButtons}>
|
||||
<Button onPress={onModalClose}>Cancel</Button>
|
||||
|
||||
{interactiveImportErrorMessage && (
|
||||
{interactiveImportErrorMessage ? (
|
||||
<span className={styles.errorMessage}>
|
||||
{interactiveImportErrorMessage}
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<Button onPress={onModalClose}>Cancel</Button>
|
||||
|
||||
<Button
|
||||
kind={kinds.SUCCESS}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Alert from 'Components/Alert';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
|
|
@ -13,7 +12,7 @@ import ModalFooter from 'Components/Modal/ModalFooter';
|
|||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { inputTypes, kinds, sizes } from 'Helpers/Props';
|
||||
import Language from 'Language/Language';
|
||||
import createLanguagesSelector from 'Store/Selectors/createLanguagesSelector';
|
||||
import { useFilteredLanguages } from 'Language/useLanguages';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './SelectLanguageModalContent.css';
|
||||
|
||||
|
|
@ -27,12 +26,15 @@ interface SelectLanguageModalContentProps {
|
|||
function SelectLanguageModalContent(props: SelectLanguageModalContentProps) {
|
||||
const { modalTitle, onLanguagesSelect, onModalClose } = props;
|
||||
|
||||
const { isFetching, isPopulated, error, items } = useSelector(
|
||||
createLanguagesSelector({
|
||||
Any: true,
|
||||
Original: true,
|
||||
})
|
||||
);
|
||||
const {
|
||||
data: items = [],
|
||||
isFetching,
|
||||
isFetched: isPopulated,
|
||||
error,
|
||||
} = useFilteredLanguages({
|
||||
Any: true,
|
||||
Original: true,
|
||||
});
|
||||
|
||||
const [languageIds, setLanguageIds] = useState(props.languageIds);
|
||||
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ export const useUpdateInteractiveImportItems = () => {
|
|||
|
||||
interface ReprocessInteractiveImportItem extends ModelBase {
|
||||
path: string;
|
||||
relativePath: string;
|
||||
seriesId: number | undefined;
|
||||
seasonNumber: number | undefined;
|
||||
episodeIds: number[] | undefined;
|
||||
|
|
@ -179,6 +180,7 @@ export const useReprocessInteractiveImportItems = () => {
|
|||
acc.push({
|
||||
id,
|
||||
path: item.path,
|
||||
relativePath: item.relativePath,
|
||||
seriesId: item.series ? item.series.id : undefined,
|
||||
seasonNumber: item.seasonNumber,
|
||||
episodeIds: (item.episodes || []).map((e) => e.id),
|
||||
|
|
|
|||
262
frontend/src/Internationalization/getCountryCode.ts
Normal file
262
frontend/src/Internationalization/getCountryCode.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
// Mapping from ISO 3166-1 alpha-3 (3-letter) to alpha-2 (2-letter) country codes
|
||||
const alpha3ToAlpha2: Record<string, string> = {
|
||||
AFG: 'AF',
|
||||
ALB: 'AL',
|
||||
DZA: 'DZ',
|
||||
ASM: 'AS',
|
||||
AND: 'AD',
|
||||
AGO: 'AO',
|
||||
AIA: 'AI',
|
||||
ATA: 'AQ',
|
||||
ATG: 'AG',
|
||||
ARG: 'AR',
|
||||
ARM: 'AM',
|
||||
ABW: 'AW',
|
||||
AUS: 'AU',
|
||||
AUT: 'AT',
|
||||
AZE: 'AZ',
|
||||
BHS: 'BS',
|
||||
BHR: 'BH',
|
||||
BGD: 'BD',
|
||||
BRB: 'BB',
|
||||
BLR: 'BY',
|
||||
BEL: 'BE',
|
||||
BLZ: 'BZ',
|
||||
BEN: 'BJ',
|
||||
BMU: 'BM',
|
||||
BTN: 'BT',
|
||||
BOL: 'BO',
|
||||
BES: 'BQ',
|
||||
BIH: 'BA',
|
||||
BWA: 'BW',
|
||||
BVT: 'BV',
|
||||
BRA: 'BR',
|
||||
IOT: 'IO',
|
||||
BRN: 'BN',
|
||||
BGR: 'BG',
|
||||
BFA: 'BF',
|
||||
BDI: 'BI',
|
||||
CPV: 'CV',
|
||||
KHM: 'KH',
|
||||
CMR: 'CM',
|
||||
CAN: 'CA',
|
||||
CYM: 'KY',
|
||||
CAF: 'CF',
|
||||
TCD: 'TD',
|
||||
CHL: 'CL',
|
||||
CHN: 'CN',
|
||||
CXR: 'CX',
|
||||
CCK: 'CC',
|
||||
COL: 'CO',
|
||||
COM: 'KM',
|
||||
COG: 'CG',
|
||||
COD: 'CD',
|
||||
COK: 'CK',
|
||||
CRI: 'CR',
|
||||
CIV: 'CI',
|
||||
HRV: 'HR',
|
||||
CUB: 'CU',
|
||||
CUW: 'CW',
|
||||
CYP: 'CY',
|
||||
CZE: 'CZ',
|
||||
DNK: 'DK',
|
||||
DJI: 'DJ',
|
||||
DMA: 'DM',
|
||||
DOM: 'DO',
|
||||
ECU: 'EC',
|
||||
EGY: 'EG',
|
||||
SLV: 'SV',
|
||||
GNQ: 'GQ',
|
||||
ERI: 'ER',
|
||||
EST: 'EE',
|
||||
SWZ: 'SZ',
|
||||
ETH: 'ET',
|
||||
FLK: 'FK',
|
||||
FRO: 'FO',
|
||||
FJI: 'FJ',
|
||||
FIN: 'FI',
|
||||
FRA: 'FR',
|
||||
GUF: 'GF',
|
||||
PYF: 'PF',
|
||||
ATF: 'TF',
|
||||
GAB: 'GA',
|
||||
GMB: 'GM',
|
||||
GEO: 'GE',
|
||||
DEU: 'DE',
|
||||
GHA: 'GH',
|
||||
GIB: 'GI',
|
||||
GRC: 'GR',
|
||||
GRL: 'GL',
|
||||
GRD: 'GD',
|
||||
GLP: 'GP',
|
||||
GUM: 'GU',
|
||||
GTM: 'GT',
|
||||
GGY: 'GG',
|
||||
GIN: 'GN',
|
||||
GNB: 'GW',
|
||||
GUY: 'GY',
|
||||
HTI: 'HT',
|
||||
HMD: 'HM',
|
||||
VAT: 'VA',
|
||||
HND: 'HN',
|
||||
HKG: 'HK',
|
||||
HUN: 'HU',
|
||||
ISL: 'IS',
|
||||
IND: 'IN',
|
||||
IDN: 'ID',
|
||||
IRN: 'IR',
|
||||
IRQ: 'IQ',
|
||||
IRL: 'IE',
|
||||
IMN: 'IM',
|
||||
ISR: 'IL',
|
||||
ITA: 'IT',
|
||||
JAM: 'JM',
|
||||
JPN: 'JP',
|
||||
JEY: 'JE',
|
||||
JOR: 'JO',
|
||||
KAZ: 'KZ',
|
||||
KEN: 'KE',
|
||||
KIR: 'KI',
|
||||
PRK: 'KP',
|
||||
KOR: 'KR',
|
||||
KWT: 'KW',
|
||||
KGZ: 'KG',
|
||||
LAO: 'LA',
|
||||
LVA: 'LV',
|
||||
LBN: 'LB',
|
||||
LSO: 'LS',
|
||||
LBR: 'LR',
|
||||
LBY: 'LY',
|
||||
LIE: 'LI',
|
||||
LTU: 'LT',
|
||||
LUX: 'LU',
|
||||
MAC: 'MO',
|
||||
MDG: 'MG',
|
||||
MWI: 'MW',
|
||||
MYS: 'MY',
|
||||
MDV: 'MV',
|
||||
MLI: 'ML',
|
||||
MLT: 'MT',
|
||||
MHL: 'MH',
|
||||
MTQ: 'MQ',
|
||||
MRT: 'MR',
|
||||
MUS: 'MU',
|
||||
MYT: 'YT',
|
||||
MEX: 'MX',
|
||||
FSM: 'FM',
|
||||
MDA: 'MD',
|
||||
MCO: 'MC',
|
||||
MNG: 'MN',
|
||||
MNE: 'ME',
|
||||
MSR: 'MS',
|
||||
MAR: 'MA',
|
||||
MOZ: 'MZ',
|
||||
MMR: 'MM',
|
||||
NAM: 'NA',
|
||||
NRU: 'NR',
|
||||
NPL: 'NP',
|
||||
NLD: 'NL',
|
||||
NCL: 'NC',
|
||||
NZL: 'NZ',
|
||||
NIC: 'NI',
|
||||
NER: 'NE',
|
||||
NGA: 'NG',
|
||||
NIU: 'NU',
|
||||
NFK: 'NF',
|
||||
MKD: 'MK',
|
||||
MNP: 'MP',
|
||||
NOR: 'NO',
|
||||
OMN: 'OM',
|
||||
PAK: 'PK',
|
||||
PLW: 'PW',
|
||||
PSE: 'PS',
|
||||
PAN: 'PA',
|
||||
PNG: 'PG',
|
||||
PRY: 'PY',
|
||||
PER: 'PE',
|
||||
PHL: 'PH',
|
||||
PCN: 'PN',
|
||||
POL: 'PL',
|
||||
PRT: 'PT',
|
||||
PRI: 'PR',
|
||||
QAT: 'QA',
|
||||
REU: 'RE',
|
||||
ROU: 'RO',
|
||||
RUS: 'RU',
|
||||
RWA: 'RW',
|
||||
BLM: 'BL',
|
||||
SHN: 'SH',
|
||||
KNA: 'KN',
|
||||
LCA: 'LC',
|
||||
MAF: 'MF',
|
||||
SPM: 'PM',
|
||||
VCT: 'VC',
|
||||
WSM: 'WS',
|
||||
SMR: 'SM',
|
||||
STP: 'ST',
|
||||
SAU: 'SA',
|
||||
SEN: 'SN',
|
||||
SRB: 'RS',
|
||||
SYC: 'SC',
|
||||
SLE: 'SL',
|
||||
SGP: 'SG',
|
||||
SXM: 'SX',
|
||||
SVK: 'SK',
|
||||
SVN: 'SI',
|
||||
SLB: 'SB',
|
||||
SOM: 'SO',
|
||||
ZAF: 'ZA',
|
||||
SGS: 'GS',
|
||||
SSD: 'SS',
|
||||
ESP: 'ES',
|
||||
LKA: 'LK',
|
||||
SDN: 'SD',
|
||||
SUR: 'SR',
|
||||
SJM: 'SJ',
|
||||
SWE: 'SE',
|
||||
CHE: 'CH',
|
||||
SYR: 'SY',
|
||||
TWN: 'TW',
|
||||
TJK: 'TJ',
|
||||
TZA: 'TZ',
|
||||
THA: 'TH',
|
||||
TLS: 'TL',
|
||||
TGO: 'TG',
|
||||
TKL: 'TK',
|
||||
TON: 'TO',
|
||||
TTO: 'TT',
|
||||
TUN: 'TN',
|
||||
TUR: 'TR',
|
||||
TKM: 'TM',
|
||||
TCA: 'TC',
|
||||
TUV: 'TV',
|
||||
UGA: 'UG',
|
||||
UKR: 'UA',
|
||||
ARE: 'AE',
|
||||
GBR: 'GB',
|
||||
USA: 'US',
|
||||
UMI: 'UM',
|
||||
URY: 'UY',
|
||||
UZB: 'UZ',
|
||||
VUT: 'VU',
|
||||
VEN: 'VE',
|
||||
VNM: 'VN',
|
||||
VGB: 'VG',
|
||||
VIR: 'VI',
|
||||
WLF: 'WF',
|
||||
ESH: 'EH',
|
||||
YEM: 'YE',
|
||||
ZMB: 'ZM',
|
||||
ZWE: 'ZW',
|
||||
};
|
||||
|
||||
const getCountryCode = (countryCode: string) => {
|
||||
const normalizedCode =
|
||||
countryCode.length === 3
|
||||
? alpha3ToAlpha2[countryCode.toUpperCase()]
|
||||
: countryCode;
|
||||
|
||||
return normalizedCode;
|
||||
};
|
||||
|
||||
export default getCountryCode;
|
||||
32
frontend/src/Internationalization/useCountryName.ts
Normal file
32
frontend/src/Internationalization/useCountryName.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useLanguage } from 'Language/useLanguageName';
|
||||
import getCountryCode from './getCountryCode';
|
||||
|
||||
const useCountryName = (countryCode: string | undefined) => {
|
||||
const { data } = useLanguage();
|
||||
|
||||
return useMemo(() => {
|
||||
if (!countryCode) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const locale = data?.identifier ?? 'en';
|
||||
|
||||
const getDisplayName = Intl.DisplayNames
|
||||
? new Intl.DisplayNames([locale], { type: 'region', fallback: 'code' })
|
||||
: null;
|
||||
|
||||
if (!getDisplayName) {
|
||||
return countryCode;
|
||||
}
|
||||
|
||||
try {
|
||||
return getDisplayName.of(getCountryCode(countryCode)) ?? countryCode;
|
||||
} catch (e) {
|
||||
console.warn('Error getting country name for code:', countryCode, e);
|
||||
return countryCode;
|
||||
}
|
||||
}, [countryCode, data]);
|
||||
};
|
||||
|
||||
export default useCountryName;
|
||||
58
frontend/src/Language/useLanguageName.ts
Normal file
58
frontend/src/Language/useLanguageName.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import moment from 'moment';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||
|
||||
interface LanguageResponse {
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
function getDisplayName(code: string) {
|
||||
return Intl.DisplayNames
|
||||
? new Intl.DisplayNames([code], { type: 'language' })
|
||||
: null;
|
||||
}
|
||||
|
||||
export const useLanguage = () => {
|
||||
return useApiQuery<LanguageResponse>({
|
||||
path: '/localization/language',
|
||||
queryOptions: {
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useInitializeLanguage = () => {
|
||||
const { data } = useLanguage();
|
||||
|
||||
useEffect(() => {
|
||||
moment.locale(data?.identifier);
|
||||
}, [data]);
|
||||
};
|
||||
|
||||
const useLanguageName = () => {
|
||||
const { data } = useLanguage();
|
||||
|
||||
const getLanguageName = useCallback(
|
||||
(code: string): string => {
|
||||
const languageNames = data?.identifier
|
||||
? getDisplayName(data.identifier)
|
||||
: getDisplayName('en');
|
||||
|
||||
if (!languageNames) {
|
||||
return code;
|
||||
}
|
||||
|
||||
try {
|
||||
return languageNames.of(code) ?? code;
|
||||
} catch {
|
||||
return code;
|
||||
}
|
||||
},
|
||||
[data]
|
||||
);
|
||||
|
||||
return getLanguageName;
|
||||
};
|
||||
|
||||
export default useLanguageName;
|
||||
67
frontend/src/Language/useLanguages.ts
Normal file
67
frontend/src/Language/useLanguages.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { useMemo } from 'react';
|
||||
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||
import Language from 'Language/Language';
|
||||
|
||||
interface LanguageFilter {
|
||||
[key: string]: boolean | undefined;
|
||||
Any: boolean;
|
||||
Original?: boolean;
|
||||
Unknown?: boolean;
|
||||
}
|
||||
|
||||
const PATH = '/language';
|
||||
|
||||
export const useLanguages = () => {
|
||||
return useApiQuery<Language[]>({
|
||||
path: PATH,
|
||||
queryOptions: {
|
||||
gcTime: Infinity,
|
||||
staleTime: Infinity,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useFilteredLanguages = (
|
||||
excludeLanguages: LanguageFilter = { Any: true }
|
||||
) => {
|
||||
const { data, isFetching, isFetched, error } = useLanguages();
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.filter((lang) => !excludeLanguages[lang.name]);
|
||||
}, [data, excludeLanguages]);
|
||||
|
||||
return {
|
||||
data: filteredItems,
|
||||
isFetching,
|
||||
isFetched,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export const useLanguageById = (id: number | undefined) => {
|
||||
const { data } = useLanguages();
|
||||
|
||||
return useMemo(() => {
|
||||
if (id === undefined || !data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return data.find((language) => language.id === id);
|
||||
}, [data, id]);
|
||||
};
|
||||
|
||||
export const useLanguageByName = (name: string | undefined) => {
|
||||
const { data } = useLanguages();
|
||||
|
||||
return useMemo(() => {
|
||||
if (!name || !data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return data.find((language) => language.name === name);
|
||||
}, [data, name]);
|
||||
};
|
||||
|
|
@ -47,7 +47,6 @@ const usePaths = ({
|
|||
path: '/filesystem',
|
||||
queryParams: { path, allowFoldersWithoutTrailingSlashes, includeFiles },
|
||||
queryOptions: {
|
||||
enabled: path.trim().length > 0,
|
||||
placeholderData: keepPreviousData,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ function RootFolderRow(props: RootFolderRowProps) {
|
|||
<TableRowCell className={styles.actions}>
|
||||
<IconButton
|
||||
title={translate('RemoveRootFolder')}
|
||||
aria-label={translate('RemoveRootFolder')}
|
||||
name={icons.REMOVE}
|
||||
onPress={onDeletePress}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import ModelBase from 'App/ModelBase';
|
||||
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
||||
import useApiMutation, {
|
||||
addOrUpdateQueryClientItem,
|
||||
} from 'Helpers/Hooks/useApiMutation';
|
||||
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||
|
||||
export interface UnmappedFolder {
|
||||
|
|
@ -86,9 +88,8 @@ export const useAddRootFolder = () => {
|
|||
onSuccess: (newRootFolder) => {
|
||||
queryClient.setQueryData<RootFolder[]>(
|
||||
['/rootFolder'],
|
||||
(oldRootFolders = []) => {
|
||||
return [...oldRootFolders, newRootFolder];
|
||||
}
|
||||
(oldRootFolders = []) =>
|
||||
addOrUpdateQueryClientItem(oldRootFolders, newRootFolder, 'id')
|
||||
);
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
.header {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
|
@ -133,6 +134,7 @@
|
|||
.path,
|
||||
.sizeOnDisk,
|
||||
.qualityProfileName,
|
||||
.originalCountry,
|
||||
.originalLanguageName,
|
||||
.statusName,
|
||||
.network,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ interface CssExports {
|
|||
'links': string;
|
||||
'monitorToggleButton': string;
|
||||
'network': string;
|
||||
'originalCountry': string;
|
||||
'originalLanguageName': string;
|
||||
'overview': string;
|
||||
'path': string;
|
||||
|
|
|
|||
|
|
@ -30,12 +30,13 @@ import {
|
|||
tooltipPositions,
|
||||
} from 'Helpers/Props';
|
||||
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
|
||||
import useCountryName from 'Internationalization/useCountryName';
|
||||
import OrganizePreviewModal from 'Organize/OrganizePreviewModal';
|
||||
import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
|
||||
import EditSeriesModal from 'Series/Edit/EditSeriesModal';
|
||||
import SeriesHistoryModal from 'Series/History/SeriesHistoryModal';
|
||||
import MonitoringOptionsModal from 'Series/MonitoringOptions/MonitoringOptionsModal';
|
||||
import { Image, Statistics } from 'Series/Series';
|
||||
import { Image, SeriesStatus, Statistics } from 'Series/Series';
|
||||
import SeriesGenres from 'Series/SeriesGenres';
|
||||
import SeriesPoster from 'Series/SeriesPoster';
|
||||
import { getSeriesStatusDetails } from 'Series/SeriesStatus';
|
||||
|
|
@ -66,10 +67,22 @@ function getFanartUrl(images: Image[]) {
|
|||
return images.find((image) => image.coverType === 'fanart')?.url;
|
||||
}
|
||||
|
||||
function getDateYear(date: string | undefined) {
|
||||
const dateDate = moment.utc(date);
|
||||
function getDateYear(date: string) {
|
||||
return moment.utc(date).format('YYYY');
|
||||
}
|
||||
|
||||
return dateDate.format('YYYY');
|
||||
function getRunningYears(
|
||||
status: SeriesStatus,
|
||||
year: number,
|
||||
lastAired: string | undefined
|
||||
) {
|
||||
if (year === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return status === 'ended' && lastAired
|
||||
? `${year}-${getDateYear(lastAired)}`
|
||||
: `${year}-`;
|
||||
}
|
||||
|
||||
interface ExpandedState {
|
||||
|
|
@ -348,6 +361,8 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
|
|||
refetchEpisodeFiles();
|
||||
}, [refetchEpisodes, refetchEpisodeFiles]);
|
||||
|
||||
const originalCountryName = useCountryName(series?.originalCountry);
|
||||
|
||||
useEffect(() => {
|
||||
populate();
|
||||
}, [populate]);
|
||||
|
|
@ -391,18 +406,13 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
|
|||
genres,
|
||||
tags,
|
||||
year,
|
||||
lastAired,
|
||||
} = series;
|
||||
|
||||
const {
|
||||
episodeCount = 0,
|
||||
episodeFileCount = 0,
|
||||
sizeOnDisk = 0,
|
||||
lastAired,
|
||||
} = statistics;
|
||||
const { episodeCount = 0, episodeFileCount = 0, sizeOnDisk = 0 } = statistics;
|
||||
|
||||
const statusDetails = getSeriesStatusDetails(status);
|
||||
const runningYears =
|
||||
status === 'ended' ? `${year}-${getDateYear(lastAired)}` : `${year}-`;
|
||||
const runningYears = getRunningYears(status, year, lastAired);
|
||||
|
||||
let episodeFilesCountMessage = translate('SeriesDetailsNoEpisodeFiles');
|
||||
|
||||
|
|
@ -571,6 +581,9 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
|
|||
title={translate('SeriesDetailsGoTo', {
|
||||
title: previousSeries.title,
|
||||
})}
|
||||
aria-label={translate('SeriesDetailsGoTo', {
|
||||
title: previousSeries.title,
|
||||
})}
|
||||
to={`/series/${previousSeries.titleSlug}`}
|
||||
/>
|
||||
) : null}
|
||||
|
|
@ -583,6 +596,9 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
|
|||
title={translate('SeriesDetailsGoTo', {
|
||||
title: nextSeries.title,
|
||||
})}
|
||||
aria-label={translate('SeriesDetailsGoTo', {
|
||||
title: nextSeries.title,
|
||||
})}
|
||||
to={`/series/${nextSeries.titleSlug}`}
|
||||
/>
|
||||
) : null}
|
||||
|
|
@ -694,6 +710,21 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
|
|||
</Label>
|
||||
) : null}
|
||||
|
||||
{originalCountryName ? (
|
||||
<Label
|
||||
className={styles.detailsLabel}
|
||||
title={translate('OriginalCountry')}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<div>
|
||||
<Icon name={icons.GLOBE} size={17} />
|
||||
<span className={styles.originalCountry}>
|
||||
{originalCountryName}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
) : null}
|
||||
|
||||
{network ? (
|
||||
<Label
|
||||
className={styles.detailsLabel}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
.links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
|
@ -9,12 +11,22 @@
|
|||
.linkLabel {
|
||||
composes: label from '~Components/Label.css';
|
||||
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.linkBlock {
|
||||
display: flex;
|
||||
margin: 3px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointExtraSmall) {
|
||||
.links {
|
||||
display: flex;
|
||||
flex-flow: column wrap;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'link': string;
|
||||
'linkBlock': string;
|
||||
'linkLabel': string;
|
||||
'links': string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import Label from 'Components/Label';
|
||||
import ClipboardButton from 'Components/Link/ClipboardButton';
|
||||
import Link from 'Components/Link/Link';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
import Series from 'Series/Series';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './SeriesDetailsLinks.css';
|
||||
|
||||
type SeriesDetailsLinksProps = Pick<
|
||||
|
|
@ -10,96 +12,90 @@ type SeriesDetailsLinksProps = Pick<
|
|||
'tvdbId' | 'tvMazeId' | 'imdbId' | 'tmdbId'
|
||||
>;
|
||||
|
||||
interface SeriesDetailsLink {
|
||||
externalId: string | number;
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
function SeriesDetailsLinks(props: SeriesDetailsLinksProps) {
|
||||
const { tvdbId, tvMazeId, imdbId, tmdbId } = props;
|
||||
|
||||
const links = useMemo(() => {
|
||||
const validLinks: SeriesDetailsLink[] = [];
|
||||
|
||||
if (tvdbId) {
|
||||
validLinks.push(
|
||||
{
|
||||
externalId: tvdbId,
|
||||
name: 'The TVDB',
|
||||
url: `https://www.thetvdb.com/?tab=series&id=${tvdbId}`,
|
||||
},
|
||||
{
|
||||
externalId: tvdbId,
|
||||
name: 'Trakt',
|
||||
url: `https://trakt.tv/search/tvdb/${tvdbId}?id_type=show`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (tvMazeId) {
|
||||
validLinks.push({
|
||||
externalId: tvMazeId,
|
||||
name: 'TV Maze',
|
||||
url: `https://www.tvmaze.com/shows/${tvMazeId}/_`,
|
||||
});
|
||||
}
|
||||
|
||||
if (imdbId) {
|
||||
validLinks.push(
|
||||
{
|
||||
externalId: imdbId,
|
||||
name: 'IMDB',
|
||||
url: `https://imdb.com/title/${imdbId}/`,
|
||||
},
|
||||
{
|
||||
externalId: imdbId,
|
||||
name: 'MDBList',
|
||||
url: `https://mdblist.com/show/${imdbId}`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (tmdbId) {
|
||||
validLinks.push({
|
||||
externalId: tmdbId,
|
||||
name: 'TMDB',
|
||||
url: `https://www.themoviedb.org/tv/${tmdbId}`,
|
||||
});
|
||||
}
|
||||
|
||||
return validLinks;
|
||||
}, [tvdbId, tvMazeId, imdbId, tmdbId]);
|
||||
|
||||
return (
|
||||
<div className={styles.links}>
|
||||
<Link
|
||||
className={styles.link}
|
||||
to={`https://www.thetvdb.com/?tab=series&id=${tvdbId}`}
|
||||
>
|
||||
<Label
|
||||
className={styles.linkLabel}
|
||||
kind={kinds.INFO}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
The TVDB
|
||||
</Label>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
className={styles.link}
|
||||
to={`https://trakt.tv/search/tvdb/${tvdbId}?id_type=show`}
|
||||
>
|
||||
<Label
|
||||
className={styles.linkLabel}
|
||||
kind={kinds.INFO}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
Trakt
|
||||
</Label>
|
||||
</Link>
|
||||
|
||||
{tvMazeId ? (
|
||||
<Link
|
||||
className={styles.link}
|
||||
to={`https://www.tvmaze.com/shows/${tvMazeId}/_`}
|
||||
>
|
||||
<Label
|
||||
className={styles.linkLabel}
|
||||
kind={kinds.INFO}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
TV Maze
|
||||
</Label>
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{imdbId ? (
|
||||
<>
|
||||
<Link
|
||||
className={styles.link}
|
||||
to={`https://imdb.com/title/${imdbId}/`}
|
||||
>
|
||||
{links.map((link) => (
|
||||
<div key={link.name} className={styles.linkBlock}>
|
||||
<Link className={styles.link} to={link.url}>
|
||||
<Label
|
||||
className={styles.linkLabel}
|
||||
kind={kinds.INFO}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
IMDB
|
||||
{link.name}
|
||||
</Label>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
className={styles.link}
|
||||
to={`http://mdblist.com/show/${imdbId}`}
|
||||
>
|
||||
<Label
|
||||
className={styles.linkLabel}
|
||||
kind={kinds.INFO}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
MDBList
|
||||
</Label>
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{tmdbId ? (
|
||||
<Link
|
||||
className={styles.link}
|
||||
to={`https://www.themoviedb.org/tv/${tmdbId}`}
|
||||
>
|
||||
<Label
|
||||
className={styles.linkLabel}
|
||||
kind={kinds.INFO}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
TMDB
|
||||
</Label>
|
||||
</Link>
|
||||
) : null}
|
||||
<ClipboardButton
|
||||
value={`${link.externalId}`}
|
||||
title={translate('CopyToClipboard')}
|
||||
kind={kinds.DEFAULT}
|
||||
size={sizes.SMALL}
|
||||
label={link.externalId}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -432,6 +432,7 @@ function SeriesDetailsSeason({
|
|||
className={styles.actionButton}
|
||||
name={icons.INTERACTIVE}
|
||||
title={translate('InteractiveSearchSeason')}
|
||||
aria-label={translate('InteractiveSearchSeason')}
|
||||
size={24}
|
||||
isDisabled={!totalEpisodeCount}
|
||||
onPress={handleInteractiveSearchPress}
|
||||
|
|
@ -441,6 +442,7 @@ function SeriesDetailsSeason({
|
|||
className={styles.actionButton}
|
||||
name={icons.ORGANIZE}
|
||||
title={translate('PreviewRenameSeason')}
|
||||
aria-label={translate('PreviewRenameSeason')}
|
||||
size={24}
|
||||
isDisabled={!episodeFileCount}
|
||||
onPress={handleOrganizePress}
|
||||
|
|
@ -450,6 +452,7 @@ function SeriesDetailsSeason({
|
|||
className={styles.actionButton}
|
||||
name={icons.EPISODE_FILE}
|
||||
title={translate('ManageEpisodesSeason')}
|
||||
aria-label={translate('ManageEpisodesSeason')}
|
||||
size={24}
|
||||
isDisabled={!episodeFileCount}
|
||||
onPress={handleManageEpisodesPress}
|
||||
|
|
@ -459,6 +462,7 @@ function SeriesDetailsSeason({
|
|||
className={styles.actionButton}
|
||||
name={icons.HISTORY}
|
||||
title={translate('HistorySeason')}
|
||||
aria-label={translate('HistorySeason')}
|
||||
size={24}
|
||||
isDisabled={!totalEpisodeCount}
|
||||
onPress={handleHistoryPress}
|
||||
|
|
@ -506,6 +510,7 @@ function SeriesDetailsSeason({
|
|||
name={icons.COLLAPSE}
|
||||
size={20}
|
||||
title={translate('HideEpisodes')}
|
||||
aria-label={translate('HideEpisodes')}
|
||||
onPress={handleExpandPress}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ function SeriesHistoryRow({
|
|||
{eventType === 'grabbed' ? (
|
||||
<IconButton
|
||||
title={translate('MarkAsFailed')}
|
||||
aria-label={translate('MarkAsFailed')}
|
||||
name={icons.REMOVE}
|
||||
size={14}
|
||||
onPress={handleMarkAsFailedPress}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,15 @@ function SeriesIndexSortMenu(props: SeriesIndexSortMenuProps) {
|
|||
{translate('Network')}
|
||||
</SortMenuItem>
|
||||
|
||||
<SortMenuItem
|
||||
name="originalCountry"
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
onPress={onSortSelect}
|
||||
>
|
||||
{translate('OriginalCountry')}
|
||||
</SortMenuItem>
|
||||
|
||||
<SortMenuItem
|
||||
name="originalLanguage"
|
||||
sortKey={sortKey}
|
||||
|
|
@ -145,6 +154,15 @@ function SeriesIndexSortMenu(props: SeriesIndexSortMenuProps) {
|
|||
{translate('SizeOnDisk')}
|
||||
</SortMenuItem>
|
||||
|
||||
<SortMenuItem
|
||||
name="averageSizePerEpisode"
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
onPress={onSortSelect}
|
||||
>
|
||||
{translate('AverageSizePerEpisode')}
|
||||
</SortMenuItem>
|
||||
|
||||
<SortMenuItem
|
||||
name="tags"
|
||||
sortKey={sortKey}
|
||||
|
|
|
|||
|
|
@ -215,6 +215,7 @@ function SeriesIndexOverview(props: SeriesIndexOverviewProps) {
|
|||
<IconButton
|
||||
name={icons.EDIT}
|
||||
title={translate('EditSeries')}
|
||||
aria-label={translate('EditSeries')}
|
||||
onPress={onEditSeriesPress}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
|
|||
status,
|
||||
path,
|
||||
titleSlug,
|
||||
originalCountry,
|
||||
originalLanguage,
|
||||
network,
|
||||
nextAiring,
|
||||
|
|
@ -161,6 +162,7 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
|
|||
className={styles.action}
|
||||
name={icons.EDIT}
|
||||
title={translate('EditSeries')}
|
||||
aria-label={translate('EditSeries')}
|
||||
tabIndex={-1}
|
||||
onPress={onEditSeriesPress}
|
||||
/>
|
||||
|
|
@ -256,6 +258,7 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
|
|||
) : null}
|
||||
|
||||
<SeriesIndexPosterInfo
|
||||
originalCountry={originalCountry}
|
||||
originalLanguage={originalLanguage}
|
||||
network={network}
|
||||
previousAiring={previousAiring}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue