Skip to content
Closed
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
32 changes: 32 additions & 0 deletions src/components/Collection/MetadataSourceIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useState } from 'react';
import { mdiDatabaseOutline } from '@mdi/js';
import { Icon } from '@mdi/react';
import cx from 'classnames';

type Props = {
className?: string;
/** Whether the server has an icon for the source, from `GET Metadata/Source`. */
hasIcon?: boolean;
/** The source, as the metadata routes take it. */
source: string;
};

/** A source's icon from the server, or a generic database icon when it has none. */
const MetadataSourceIcon = ({ className, hasIcon = false, source }: Props) => {
const [failed, setFailed] = useState(false);

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

return (
<img
alt=""
className={cx('size-6 shrink-0 object-contain', className)}
onError={() => setFailed(true)}
src={`/api/v3/Metadata/Source/${encodeURIComponent(source)}/Icon`}
/>
);
};

export default MetadataSourceIcon;
40 changes: 38 additions & 2 deletions src/components/Dashboard/DashboardSettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { toNumber } from 'lodash';
import Button from '@/components/Input/Button';
import Checkbox from '@/components/Input/Checkbox';
import InputSmall from '@/components/Input/InputSmall';
import SelectSmall from '@/components/Input/SelectSmall';
import ModalPanel from '@/components/Panels/ModalPanel';
import { usePatchSettingsMutation } from '@/core/react-query/settings/mutations';
import { useSettingsQuery } from '@/core/react-query/settings/queries';
Expand Down Expand Up @@ -68,10 +69,13 @@ const DashboardSettingsModal = ({ onClose, show }: Props) => {
recentlyImportedEpisodesCount,
recentlyImportedSeriesCount,
shokoNewsPostsCount,
upcomingAnimeDays,
upcomingAnimeShowTimes,
upcomingAnimeView,
useThumbnailsForEpisodes,
} = newSettings.WebUI_Settings.dashboard;

const updateSetting = (key: string, value: boolean | number) => {
const updateSetting = (key: string, value: boolean | number | string) => {
const tempSettings = produce(newSettings, (draftState) => {
draftState.WebUI_Settings.dashboard[key] = value;
});
Expand All @@ -96,6 +100,10 @@ const DashboardSettingsModal = ({ onClose, show }: Props) => {
);
};

const handleUpcomingAnimeDaysUpdate = (event: ChangeEvent<HTMLInputElement>) => {
updateSetting('upcomingAnimeDays', Math.min(Math.max(toNumber(event.target.value), 1), 100));
};

return (
<ModalPanel
show={show}
Expand Down Expand Up @@ -221,7 +229,7 @@ const DashboardSettingsModal = ({ onClose, show }: Props) => {
)}

{activeTab === 'options' && (
<div className="flex flex-col gap-y-2">
<div className="flex flex-col gap-y-2 overflow-y-scroll pr-4">
<Checkbox
justify
label="Combine Continue Watching & Next Up"
Expand Down Expand Up @@ -273,6 +281,34 @@ const DashboardSettingsModal = ({ onClose, show }: Props) => {
className="w-14 px-2 py-0.5 text-center"
/>
</div>
<SelectSmall
id="upcomingAnimeView"
label="Upcoming Anime Series"
value={upcomingAnimeView}
onChange={event => updateSetting('upcomingAnimeView', event.target.value)}
>
<option value="collection">My Collection</option>
<option value="all">All</option>
</SelectSmall>
<div className="flex items-center justify-between">
Upcoming Anime Days Ahead
<InputSmall
id="upcomingAnimeDays"
type="number"
value={upcomingAnimeDays}
onChange={handleUpcomingAnimeDaysUpdate}
min={1}
max={100}
className="w-14 px-2 py-0.5 text-center"
/>
</div>
<Checkbox
justify
label="Show Upcoming Anime Air Times"
id="upcomingAnimeShowTimes"
isChecked={upcomingAnimeShowTimes}
onChange={handleUpdate}
/>
</div>
)}
</div>
Expand Down
112 changes: 112 additions & 0 deletions src/components/Dialogs/AiringChannelAliasesModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { useState } from 'react';
import { mdiMinusCircleOutline, mdiPlusCircleOutline } from '@mdi/js';
import { Icon } from '@mdi/react';
import { isEqual } from 'lodash';

import Button from '@/components/Input/Button';
import Input from '@/components/Input/Input';
import ModalPanel from '@/components/Panels/ModalPanel';
import AiringChannelLabel from '@/components/Settings/AiringScheduleSettings/AiringChannelLabel';
import { useUpdateAiringChannelAliasesMutation } from '@/core/react-query/airing-schedule/mutations';
import toast from '@/core/toast';
import useSyncedState from '@/hooks/useSyncedState';

import type { AiringChannelType } from '@/core/types/api/airing-schedule';

type Props = {
/** The channel whose aliases are edited; the modal is shown while it is set. */
channel: AiringChannelType | null;
onClose: () => void;
};

/** Lists a channel's aliases to add to and remove from, saved together through `PUT AiringSchedule/Channel/{id}/Aliases`. */
const AiringChannelAliasesModal = ({ channel: openedChannel, onClose }: Props) => {
const [newAlias, setNewAlias] = useState('');
// The last channel opened, so the modal keeps its content while it closes.
const [channel] = useSyncedState(openedChannel, (next, prev) => next ?? prev ?? null);
// Starts over from the channel's aliases each time the modal opens, and keeps them while it closes.
const [aliases, setAliases] = useSyncedState(openedChannel, (next, prev: string[] = []) => next?.Aliases ?? prev);
const { isPending, mutate: updateAliases } = useUpdateAiringChannelAliasesMutation();

const trimmed = newAlias.trim();
// The name and the listed aliases, ignoring case, are already the channel's.
const isKnown = [channel?.Name ?? '', ...aliases].some(name => name.toLowerCase() === trimmed.toLowerCase());
const isEdited = !!channel && !isEqual(aliases, channel.Aliases);

const handleAdd = () => {
if (!trimmed || isKnown) return;
setAliases([...aliases, trimmed]);
setNewAlias('');
};

const handleClose = () => {
if (isPending) return;
setNewAlias('');
onClose();
};

const handleSave = () => {
if (!channel) return;
updateAliases({ channelId: channel.ID, aliases }, {
onSuccess: () => {
toast.success('Aliases saved!');
setNewAlias('');
onClose();
},
});
};

return (
<ModalPanel show={!!openedChannel} onRequestClose={handleClose} header="Channel Aliases" size="sm">
{channel && (
<div className="flex flex-col gap-y-1">
<AiringChannelLabel name={channel.Name} countryCode={channel.CountryCode} type={channel.Type} />
<div className="text-sm opacity-65">
Other names the channel goes by. The channel searches match them too.
</div>
</div>
)}
<div className="flex min-h-10 flex-col rounded-lg border border-panel-border bg-panel-input px-4 py-2">
{aliases.length === 0 && <div className="py-1 text-sm opacity-65">No aliases.</div>}
{aliases.map(alias => (
<div key={alias} className="flex items-center justify-between py-1">
<span className="truncate">{alias}</span>
<Button onClick={() => setAliases(aliases.filter(item => item !== alias))} tooltip="Remove">
<Icon className="text-panel-icon-action" path={mdiMinusCircleOutline} size={1} />
</Button>
</div>
))}
</div>
<div className="flex items-center gap-x-2">
<Input
id="airing-channel-alias-new"
type="text"
placeholder="New alias..."
value={newAlias}
onChange={event => setNewAlias(event.target.value)}
onKeyUp={event => event.key === 'Enter' && handleAdd()}
className="grow"
/>
<Button onClick={handleAdd} tooltip="Add Alias" disabled={!trimmed || isKnown}>
<Icon className="text-panel-icon-action" path={mdiPlusCircleOutline} size={1} />
</Button>
</div>
<div className="flex justify-end gap-x-3 font-semibold">
<Button onClick={handleClose} buttonType="secondary" className="px-5 py-2" disabled={isPending}>
Discard
</Button>
<Button
onClick={handleSave}
buttonType="primary"
className="px-5 py-2"
disabled={!isEdited}
loading={isPending}
>
Save
</Button>
</div>
</ModalPanel>
);
};

export default AiringChannelAliasesModal;
Loading
Loading