Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/components/AiringChannelCountryBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// One shared formatter: the names come from the browser, in English like the rest of the WebUI.
const regionNames = new Intl.DisplayNames(['en'], { type: 'region', fallback: 'none' });

const getCountryName = (countryCode: string) => {
try {
return regionNames.of(countryCode) ?? countryCode;
} catch {
return countryCode;
}
};

/** A channel's country as a small badge with its two-letter code, named in the tooltip. Nothing without a country. */
const AiringChannelCountryBadge = ({ countryCode }: { countryCode: string | null | undefined }) => {
if (!countryCode) return null;
return (
<span
className="shrink-0 rounded-sm border border-panel-border px-1 text-xs opacity-80"
data-tooltip-id="tooltip"
data-tooltip-content={getCountryName(countryCode)}
>
{countryCode.toUpperCase()}
</span>
);
};

export default AiringChannelCountryBadge;
50 changes: 50 additions & 0 deletions src/components/AiringProviderIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useState } from 'react';
import { mdiDatabaseOutline } from '@mdi/js';
import { Icon } from '@mdi/react';
import cx from 'classnames';

import { getAiringProviderIconUrl } from '@/core/react-query/airing-schedule/helpers';

type Props = {
className?: string;
/** Whether the server has an icon for the provider, from the provider's `HasIcon`. */
hasIcon: boolean;
/**
* The provider's name, for the tooltip and the accessible label where the icon stands in for the name. Left out
* where the name is shown next to the icon.
*/
label?: string;
providerId: string;
/** In `@mdi/react` units: 1 is 1.5rem. */
size?: number;
};

/** An airing provider's icon from the server, or the generic icon the metadata sources use when it has none. */
const AiringProviderIcon = ({ className, hasIcon, label, providerId, size = 1 }: Props) => {
const [failed, setFailed] = useState(false);
const labelProps = label
? { 'aria-label': label, 'data-tooltip-id': 'tooltip', 'data-tooltip-content': label, role: 'img' }
: { 'aria-hidden': true };

if (!hasIcon || failed) {
return (
<span className={cx('flex shrink-0', className)} {...labelProps}>
<Icon className="text-panel-icon" path={mdiDatabaseOutline} size={size} />
</span>
);
}

return (
<img
alt={label ?? ''}
className={cx('shrink-0 object-contain', className)}
data-tooltip-id={label ? 'tooltip' : undefined}
data-tooltip-content={label}
onError={() => setFailed(true)}
src={getAiringProviderIconUrl(providerId)}
style={{ width: `${size * 1.5}rem`, height: `${size * 1.5}rem` }}
/>
);
};

export default AiringProviderIcon;
7 changes: 6 additions & 1 deletion src/components/BackgroundImagePlaceholderDiv.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ type Props = {
children?: ReactNode;
className?: string;
contain?: boolean;
/** Where the image sits in its frame, as a CSS `background-position`. Defaults to `center`. */
position?: string;
image?: ImageLinkType;
hidePlaceholderOnHover?: boolean;
overlayOnHover?: boolean;
Expand All @@ -30,6 +32,7 @@ const BackgroundImagePlaceholderDiv = (props: Props) => {
inCollection,
linkToImage,
overlayOnHover,
position = 'center',
zoomOnHover,
} = props;
const fit = contain ? 'contain' : 'cover';
Expand Down Expand Up @@ -92,7 +95,9 @@ const BackgroundImagePlaceholderDiv = (props: Props) => {
'absolute top-0 left-0 z-[-1] flex size-full flex-col rounded-lg text-center',
zoomOnHover && 'transition-transform duration-600 group-hover:scale-105',
)}
style={{ background: backgroundImage ? `center / ${fit} no-repeat url('${backgroundImage.src}')` : undefined }}
style={{
background: backgroundImage ? `${position} / ${fit} no-repeat url('${backgroundImage.src}')` : undefined,
}}
>
{imageError && (
<div
Expand Down
80 changes: 67 additions & 13 deletions src/components/Dialogs/LanguagesModal.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ChangeEvent } from 'react';
import { mdiLoading } from '@mdi/js';
import { Icon } from '@mdi/react';
import { keys, map, remove } from 'lodash';
import { keys, map, omit, remove } from 'lodash';

import Button from '@/components/Input/Button';
import Checkbox from '@/components/Input/Checkbox';
Expand All @@ -13,12 +13,31 @@ import useSyncedState from '@/hooks/useSyncedState';

import type { SettingsType } from '@/core/types/api/settings';

/** Languages picked for the caller instead of for the settings. */
export type LanguageSelectionType = {
languages: string[];
/** Takes the picked languages when the modal is saved; nothing is sent to the server. */
onChange: (languages: string[]) => void;
/** Whether several languages can be picked, as with a `<select multiple>`. Defaults to `true`. */
multiple?: boolean;
/** Offer an explicit choice of no language, e.g. "Any Language", which saves an empty list. */
noneLabel?: string;
};

type Props = {
type: 'Series' | 'Episode' | 'Description' | 'Image' | null;
type: 'Series' | 'Episode' | 'Description' | 'Image' | 'Track' | null;
onClose: () => void;
/** Pick for the caller instead of saving the language preference of `type`. */
selection?: LanguageSelectionType;
/** Language codes not offered, e.g. pseudo-languages that make no sense for the caller. */
exclude?: string[];
};

const getLanguagePreference = (type: Props['type'], settings: SettingsType) => {
// The key of the explicit "no language" choice. Not a language code the server knows.
const NONE_KEY = 'x-none-selected';

const getLanguagePreference = (type: Props['type'], settings: SettingsType, selection?: LanguageSelectionType) => {
if (selection) return selection.languages;
switch (type) {
case 'Episode':
return settings.Language.EpisodeTitleLanguageOrder;
Expand All @@ -31,23 +50,37 @@ const getLanguagePreference = (type: Props['type'], settings: SettingsType) => {
}
};

const LanguagesModal = ({ onClose, type }: Props) => {
const LanguagesModal = ({ exclude, onClose, selection, type }: Props) => {
const settings = useSettingsQuery().data;

const languagesQuery = useSupportedLanguagesQuery();
const supportedLanguages = omit(languagesQuery.data ?? {}, exclude ?? []);
const languageDescription = type === 'Image'
? addNoLanguageOption(languagesQuery.data ?? {})
: languagesQuery.data ?? {};
? addNoLanguageOption(supportedLanguages)
: supportedLanguages;
const allowEmpty = type === 'Image' || !!selection?.noneLabel;

const LanguagePreference = getLanguagePreference(type, settings);
const LanguagePreference = getLanguagePreference(type, settings, selection);
const { mutate: patchSettings } = usePatchSettingsMutation();

const [languages, setLanguages] = useSyncedState<string[] | null, string[]>(
type !== null ? LanguagePreference : null,
source => source ?? [],
// A caller's languages are compared by value, as a caller may build the list anew on every render.
let languageSource: string[] | string | null = null;
if (type !== null) languageSource = selection ? selection.languages.join(',') : LanguagePreference;
const [languages, setLanguages] = useSyncedState<string[] | string | null, string[]>(
languageSource,
(source) => {
if (typeof source === 'string') return source === '' ? [] : source.split(',');
return source ?? [];
},
);

const handleSave = () => {
if (selection) {
selection.onChange(languages);
onClose();
return;
}

if (type === 'Image') {
patchSettings({
...settings,
Expand Down Expand Up @@ -82,6 +115,17 @@ const LanguagesModal = ({ onClose, type }: Props) => {
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
const { checked: value, id } = event.target;

if (id === NONE_KEY) {
if (value) setLanguages([]);
return;
}

if (selection?.multiple === false) {
// Picking another language replaces the one picked; unpicking it leaves the explicit "no language" choice.
setLanguages(value ? [id] : []);
return;
}

const newLanguages = languages.slice();

if (value) newLanguages.push(id);
Expand All @@ -94,14 +138,23 @@ const LanguagesModal = ({ onClose, type }: Props) => {
<ModalPanel
show={type !== null}
onRequestClose={onClose}
header={`${type} Languages`}
header={selection?.multiple === false ? `${type} Language` : `${type} Languages`}
size="md"
>
{languagesQuery.isPending
&& <Icon path={mdiLoading} spin size={3} className="mx-auto text-panel-text-primary" />}
{Object.keys(languageDescription).length > 0 && (
<div className="w-full rounded-lg border border-panel-border bg-panel-input p-4 capitalize">
<div className="flex h-80 flex-col gap-y-1.5 overflow-y-auto rounded-lg bg-panel-input px-3 py-2">
{selection?.noneLabel && (
<Checkbox
id={NONE_KEY}
isChecked={languages.length === 0}
onChange={handleInputChange}
label={selection.noneLabel}
justify
/>
)}
{map(keys(languageDescription), (key: keyof typeof languageDescription) => (
<Checkbox
id={key}
Expand All @@ -123,8 +176,9 @@ const LanguagesModal = ({ onClose, type }: Props) => {
className="px-5 py-2"
// An empty image language order is meaningful on the server: TMDB images are
// downloaded in all languages when the order is empty. Other language orders
// have no such fallback, so an empty list is invalid there.
disabled={languages.length === 0 && type !== 'Image'}
// have no such fallback, so an empty list is invalid there, unless the caller
// offers an explicit "no language" choice.
disabled={languages.length === 0 && !allowEmpty}
>
Save
</Button>
Expand Down
7 changes: 5 additions & 2 deletions src/components/Input/MultiStateButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,19 @@ type Props = {
states: readonly { label?: string, value: string }[];
className?: string;
alternateColor?: boolean;
/** Narrower, lower buttons sized to their labels, for a toolbar. */
compact?: boolean;
activeState: string;
onStateChange: (state: string) => void;
};

const MultiStateButton = ({ activeState, alternateColor, className, onStateChange, states }: Props) => (
const MultiStateButton = ({ activeState, alternateColor, className, compact, onStateChange, states }: Props) => (
<div className={className ?? 'flex gap-x-2'}>
{states.map(option => (
<Button
className={cx(
'w-40 rounded-lg px-4 py-3 text-sm font-medium',
'rounded-lg text-sm font-medium',
compact ? 'px-3 py-1' : 'w-40 px-4 py-3',
activeState === option.value && 'bg-panel-toggle-background! text-panel-toggle-text',
activeState !== option.value && 'text-panel-toggle-text-alt hover:bg-panel-toggle-background-hover',
activeState !== option.value && !alternateColor ? 'bg-panel-background' : 'bg-panel-toggle-background-alt',
Expand Down
7 changes: 7 additions & 0 deletions src/components/Layout/TopNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { MouseEvent } from 'react';
import AnimateHeight from 'react-animate-height';
import { Link, NavLink, useLocation } from 'react-router';
import {
mdiCalendarMonthOutline,
mdiCogOutline,
mdiDownloadCircleOutline,
mdiFileDocumentAlertOutline,
Expand Down Expand Up @@ -338,6 +339,12 @@ const TopNav = () => {
path="utilities/missing-episodes"
text="Missing Episodes"
/>
<LinkMenuItem
icon={mdiCalendarMonthOutline}
onClick={closeModalsAndSubmenus}
path="utilities/airing-schedule"
text="Airing Schedule"
/>
</div>
</AnimateHeight>
</div>
Expand Down
Loading
Loading