Merge branch 'feature/standardize-trakt-settings' of https://github.com/Andrew-Ukkonen/Sonarr into feature/standardize-trakt-settings

This commit is contained in:
Andrew Nekowitsch 2026-05-04 16:52:38 -05:00
commit bdd09957f5
424 changed files with 11732 additions and 3902 deletions

View file

@ -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/**/*

View file

@ -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

View file

@ -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/**/*

View file

@ -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

View file

@ -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

View file

@ -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

View 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 }}

View 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

View file

@ -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-*

View file

@ -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);

View file

@ -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}

View file

@ -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>
);
}

View file

@ -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;
},

View file

@ -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={

View file

@ -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}
/>

View file

@ -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}

View file

@ -97,6 +97,12 @@
pointer-events: all;
}
.excludedIcon {
margin-left: 10px;
color: var(--dangerColor);
pointer-events: all;
}
.overview {
margin-top: 20px;
}

View file

@ -3,6 +3,7 @@
interface CssExports {
'alreadyExistsIcon': string;
'content': string;
'excludedIcon': string;
'genres': string;
'icons': string;
'network': string;

View file

@ -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>

View file

@ -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')
);
},
},
}

View file

@ -2,6 +2,7 @@ import Series from 'Series/Series';
interface AddSeries extends Series {
folder: string;
isExcluded: boolean;
}
export default AddSeries;

View file

@ -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;

View file

@ -154,10 +154,7 @@ function AgendaEvent(props: AgendaEventProps) {
{queueItem ? (
<span className={styles.statusIcon}>
<CalendarEventQueueDetails
seasonNumber={seasonNumber}
{...queueItem}
/>
<CalendarEventQueueDetails {...queueItem} />
</span>
) : null}

View file

@ -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);
}

View file

@ -118,7 +118,7 @@ const useCalendar = () => {
return acc;
},
{
includeUnmonitored: false,
includeUnmonitored: true,
includeSpecials: true,
}
);

View file

@ -5,6 +5,7 @@ enum CommandNames {
ClearLog = 'ClearLog',
CutoffUnmetEpisodeSearch = 'CutoffUnmetEpisodeSearch',
DeleteLogFiles = 'DeleteLogFiles',
DeleteSeriesFiles = 'DeleteSeriesFiles',
DeleteUpdateLogFiles = 'DeleteUpdateLogFiles',
DownloadedEpisodesScan = 'DownloadedEpisodesScan',
EpisodeSearch = 'EpisodeSearch',

View file

@ -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')
);
},
},

View file

@ -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}>

View file

@ -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>
);

View file

@ -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} />;
}

View file

@ -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} />;
}

View file

@ -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;

View file

@ -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')}

View file

@ -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}
/>

View file

@ -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[]>) => {

View file

@ -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

View file

@ -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(

View file

@ -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}
/>

View file

@ -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;

View file

@ -4,6 +4,10 @@
position: relative;
}
.buttonText {
margin: 0 5px;
}
.stateIconContainer {
position: absolute;
top: 50%;

View file

@ -2,6 +2,7 @@
// Please do not change this file!
interface CssExports {
'button': string;
'buttonText': string;
'clipboardIconContainer': string;
'showStateIcon': string;
'stateIconContainer': string;

View file

@ -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>

View file

@ -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>
);

View file

@ -157,7 +157,7 @@ function Menu({
{React.cloneElement(childrenArray[1] as ReactElement, {
forwardedRef: refs.setFloating,
style: {
maxHeight,
maxHeight: enforceMaxHeight ? maxHeight : undefined,
...floatingStyles,
},
isOpen: isMenuOpen,

View file

@ -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!
);
}

View 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);
}

View file

@ -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>
);

View file

@ -54,6 +54,7 @@ function MonitorToggleButton(props: MonitorToggleButtonProps) {
name={iconName}
size={size}
title={title}
aria-label={title}
isDisabled={isDisabled}
isSpinning={isSaving}
{...otherProps}

View file

@ -55,6 +55,7 @@ function PageHeader() {
<IconButton
id="sidebar-toggle-button"
name={icons.NAVBAR_COLLAPSE}
aria-label={translate('Menu')}
onPress={handleSidebarToggle}
/>
</div>

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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>
)}

View file

@ -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);
});
};

View file

@ -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>
);

View file

@ -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>
);
}

View file

@ -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]);

View file

@ -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>

View file

@ -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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View file

@ -54,6 +54,7 @@ function EpisodeSearchCell({
<IconButton
name={icons.INTERACTIVE}
title={translate('InteractiveSearch')}
aria-label={translate('InteractiveSearch')}
onPress={setDetailsModalOpen}
/>

View file

@ -128,6 +128,7 @@ function EpisodeHistoryRow({
{eventType === 'grabbed' && (
<IconButton
title={translate('MarkAsFailed')}
aria-label={translate('MarkAsFailed')}
name={icons.REMOVE}
size={14}
onPress={handleMarkAsFailedPress}

View file

@ -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
);

View file

@ -124,6 +124,7 @@ function EpisodeFileRow(props: EpisodeFileRowProps) {
<IconButton
title={translate('DeleteEpisodeFromDisk')}
aria-label={translate('DeleteEpisodeFromDisk')}
name={icons.REMOVE}
onPress={setRemoveEpisodeFileModalOpen}
/>

View file

@ -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')
}
/>
);
}

View file

@ -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') {

View file

@ -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>

View file

@ -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));
}

View file

@ -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(() => {

View 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;

View file

@ -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,
}));

View 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;

View file

@ -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') {

View file

@ -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'

View file

@ -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;

View file

@ -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}

View file

@ -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}
/>

View file

@ -18,6 +18,7 @@
.leftButtons,
.rightButtons {
display: flex;
align-items: center;
flex-wrap: wrap;
min-width: 0;
}

View file

@ -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}

View file

@ -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);

View file

@ -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),

View 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;

View 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;

View 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;

View 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]);
};

View file

@ -47,7 +47,6 @@ const usePaths = ({
path: '/filesystem',
queryParams: { path, allowFoldersWithoutTrailingSlashes, includeFiles },
queryOptions: {
enabled: path.trim().length > 0,
placeholderData: keepPreviousData,
},
});

View file

@ -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}
/>

View file

@ -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')
);
},
},

View file

@ -4,6 +4,7 @@
.header {
position: relative;
z-index: 0;
width: 100%;
}
@ -133,6 +134,7 @@
.path,
.sizeOnDisk,
.qualityProfileName,
.originalCountry,
.originalLanguageName,
.statusName,
.network,

View file

@ -16,6 +16,7 @@ interface CssExports {
'links': string;
'monitorToggleButton': string;
'network': string;
'originalCountry': string;
'originalLanguageName': string;
'overview': string;
'path': string;

View file

@ -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}

View file

@ -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;
}
}

View file

@ -2,6 +2,7 @@
// Please do not change this file!
interface CssExports {
'link': string;
'linkBlock': string;
'linkLabel': string;
'links': string;
}

View file

@ -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>
);
}

View file

@ -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>

View file

@ -158,6 +158,7 @@ function SeriesHistoryRow({
{eventType === 'grabbed' ? (
<IconButton
title={translate('MarkAsFailed')}
aria-label={translate('MarkAsFailed')}
name={icons.REMOVE}
size={14}
onPress={handleMarkAsFailedPress}

View file

@ -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}

View file

@ -215,6 +215,7 @@ function SeriesIndexOverview(props: SeriesIndexOverviewProps) {
<IconButton
name={icons.EDIT}
title={translate('EditSeries')}
aria-label={translate('EditSeries')}
onPress={onEditSeriesPress}
/>
</div>

View file

@ -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