diff --git a/src/pages/firstrun/MetadataSourcesTabs/AnilistTab.tsx b/src/pages/firstrun/MetadataSourcesTabs/AnilistTab.tsx
new file mode 100644
index 000000000..74441c0ea
--- /dev/null
+++ b/src/pages/firstrun/MetadataSourcesTabs/AnilistTab.tsx
@@ -0,0 +1,24 @@
+import AnilistDownloadSettings from '@/components/Settings/MetadataSitesSettings/AnilistDownloadSettings';
+import AnilistSettings from '@/components/Settings/MetadataSitesSettings/AnilistSettings';
+import TransitionDiv from '@/components/TransitionDiv';
+import useFirstRunSettingsContext from '@/hooks/useFirstRunSettingsContext';
+
+const AnilistTab = () => {
+ const { newSettings, setNewSettings, updateSetting } = useFirstRunSettingsContext();
+
+ return (
+
+ Linking Options
+
+
+ Download Options
+
+
+ );
+};
+
+export default AnilistTab;
diff --git a/src/pages/settings/SettingsPage.tsx b/src/pages/settings/SettingsPage.tsx
index 6576e2322..ae6941c5a 100644
--- a/src/pages/settings/SettingsPage.tsx
+++ b/src/pages/settings/SettingsPage.tsx
@@ -32,6 +32,7 @@ const items = [
{ name: 'Hashing & Release', path: 'hashing-release' },
{ name: 'AniDB', path: 'anidb' },
{ name: 'TMDB', path: 'tmdb' },
+ { name: 'AniList', path: 'anilist' },
{ name: 'Collection', path: 'collection' },
{ name: 'Integrations', path: 'integrations' },
{ name: 'Plugin Management', path: 'plugin-management' },
diff --git a/src/pages/settings/tabs/AnilistSettings.tsx b/src/pages/settings/tabs/AnilistSettings.tsx
new file mode 100644
index 000000000..878f00355
--- /dev/null
+++ b/src/pages/settings/tabs/AnilistSettings.tsx
@@ -0,0 +1,45 @@
+import AnilistDownloadSettings from '@/components/Settings/MetadataSitesSettings/AnilistDownloadSettings';
+import AnilistSettingsOptions from '@/components/Settings/MetadataSitesSettings/AnilistSettings';
+import useSettingsContext from '@/hooks/useSettingsContext';
+
+const AnilistSettings = () => {
+ const { newSettings, setNewSettings, updateSetting } = useSettingsContext();
+
+ return (
+ <>
+
Settings > AniList | Shoko
+
+
AniList
+
+ Customize the information and images that Shoko downloads for the series in your collection
+
+
+
+
+
+
+
+
+
+
+
AniList Download Options
+
+
+
+
+ >
+ );
+};
+
+export default AnilistSettings;
diff --git a/src/pages/utilities/Calendar.tsx b/src/pages/utilities/Calendar.tsx
new file mode 100644
index 000000000..a01af36dc
--- /dev/null
+++ b/src/pages/utilities/Calendar.tsx
@@ -0,0 +1,319 @@
+import { useMemo, useState } from 'react';
+import { Link } from 'react-router';
+import { mdiCalendarTodayOutline, mdiChevronLeft, mdiChevronRight, mdiLoading } from '@mdi/js';
+import { Icon } from '@mdi/react';
+import cx from 'classnames';
+import { groupBy, range, sortBy, xor } from 'lodash';
+import { useToggle } from 'usehooks-ts';
+
+import BackgroundImagePlaceholderDiv from '@/components/BackgroundImagePlaceholderDiv';
+import Button from '@/components/Input/Button';
+import Checkbox from '@/components/Input/Checkbox';
+import MultiStateButton from '@/components/Input/MultiStateButton';
+import ShokoPanel from '@/components/Panels/ShokoPanel';
+import ItemCount from '@/components/Utilities/ItemCount';
+import { useDashboardCalendarEpisodesQuery } from '@/core/react-query/dashboard/queries';
+import { useSettingsQuery } from '@/core/react-query/settings/queries';
+import { dayjs, getAnidbAnimeLink } from '@/core/util';
+import { getEpisodePrefixAlt } from '@/core/utilities/getEpisodePrefix';
+
+import type { DashboardEpisodeDetailsType } from '@/core/types/api/dashboard';
+import type { EpisodeTypeValues } from '@/core/types/api/episode';
+import type { Dayjs } from 'dayjs';
+
+type CalendarViewType = 'month' | 'week';
+
+const viewStates: { label?: string, value: CalendarViewType }[] = [
+ { label: 'Month', value: 'month' },
+ { label: 'Week', value: 'week' },
+];
+
+const dateKeyFormat = 'YYYY-MM-DD';
+
+const episodeTypeOptions: { label: string, value: EpisodeTypeValues }[] = [
+ { label: 'Episodes', value: 'Episode' },
+ { label: 'Specials', value: 'Special' },
+ { label: 'Credits', value: 'Credits' },
+ { label: 'Trailers', value: 'Trailer' },
+ { label: 'Parodies', value: 'Parody' },
+ { label: 'Others', value: 'Other' },
+];
+
+// What the server assumes when no `type` is sent.
+const defaultEpisodeTypes: EpisodeTypeValues[] = ['Episode'];
+
+// The day an episode belongs to for the viewer: the local day of the broadcast when the time is known,
+// otherwise the (UTC) air date as-is so a date-only entry never drifts to a neighbouring day.
+const getLocalDay = (episode: DashboardEpisodeDetailsType) => {
+ if (episode.HasAirTime && episode.AiredAt) return dayjs(episode.AiredAt);
+ return dayjs(episode.AirDate ?? episode.AiredAt ?? undefined);
+};
+
+const getVisibleRange = (anchor: Dayjs, view: CalendarViewType) => {
+ if (view === 'week') {
+ const start = anchor.startOf('week');
+ return { start, end: start.add(6, 'day') };
+ }
+ return {
+ start: anchor.startOf('month').startOf('week'),
+ end: anchor.endOf('month').endOf('week').startOf('day'),
+ };
+};
+
+const weekdayNames = range(7).map(day => dayjs().day(day).format('ddd'));
+
+type EpisodeCardProps = {
+ episode: DashboardEpisodeDetailsType;
+};
+
+const EpisodeCard = ({ episode }: EpisodeCardProps) => {
+ const inCollection = episode.IDs.ShokoSeries !== null;
+ const episodeLabel = `${getEpisodePrefixAlt(episode.Type)}${episode.Number} - ${episode.Title}`;
+ const airTime = episode.HasAirTime && episode.AiredAt ? dayjs(episode.AiredAt).format('HH:mm') : null;
+
+ const content = (
+ <>
+
+
+
+ {episode.SeriesTitle}
+
+
+ {episodeLabel}
+
+ {airTime && (
+
+ {episode.IsAirTimeEstimated ? `~${airTime}` : airTime}
+
+ )}
+
+ >
+ );
+
+ const cardClassName = cx(
+ 'flex gap-x-2 rounded-md border border-panel-border p-1.5 text-xs transition-colors hover:bg-panel-toggle-background-hover',
+ inCollection ? 'bg-panel-background-alt' : 'bg-panel-background opacity-75',
+ );
+
+ if (inCollection) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return (
+
+ {content}
+
+ );
+};
+
+const Calendar = () => {
+ const { hideR18Content } = useSettingsQuery().data.WebUI_Settings.dashboard;
+
+ const [view, setView] = useState
('month');
+ const [anchor, setAnchor] = useState(() => dayjs().startOf('day'));
+ const [includeMissing, toggleIncludeMissing] = useToggle(false);
+ const [includeRestricted, toggleIncludeRestricted] = useToggle(!hideR18Content);
+ const [onlyWithAirTime, toggleOnlyWithAirTime] = useToggle(false);
+ const [episodeTypes, setEpisodeTypes] = useState(defaultEpisodeTypes);
+
+ const handleViewChange = (newView: CalendarViewType) => {
+ setView(newView);
+ };
+
+ const toggleEpisodeType = (type: EpisodeTypeValues) => {
+ setEpisodeTypes(prev => xor(prev, [type]));
+ };
+
+ // Leave the parameter out when the selection matches the server default.
+ const isDefaultEpisodeTypes = xor(episodeTypes, defaultEpisodeTypes).length === 0;
+
+ const visibleRange = useMemo(() => getVisibleRange(anchor, view), [anchor, view]);
+
+ // The server works in UTC days while the grid is in local days, so pad the request by a day on each side.
+ const episodesQuery = useDashboardCalendarEpisodesQuery({
+ startDate: visibleRange.start.subtract(1, 'day').format(dateKeyFormat),
+ endDate: visibleRange.end.add(1, 'day').format(dateKeyFormat),
+ includeMissing: includeMissing ? 'True' : 'False',
+ includeRestricted: includeRestricted ? 'True' : 'False',
+ includeWithAirTime: onlyWithAirTime ? 'Only' : 'True',
+ type: isDefaultEpisodeTypes ? undefined : episodeTypes,
+ });
+
+ const days = useMemo(() => {
+ const dayCount = visibleRange.end.diff(visibleRange.start, 'day') + 1;
+ return range(dayCount).map(offset => visibleRange.start.add(offset, 'day'));
+ }, [visibleRange]);
+
+ const episodesByDay = useMemo(() => {
+ const sorted = sortBy(episodesQuery.data ?? [], [
+ episode => (episode.HasAirTime && episode.AiredAt ? dayjs(episode.AiredAt).valueOf() : 0),
+ 'SeriesTitle',
+ 'Number',
+ ]);
+ return groupBy(sorted, episode => getLocalDay(episode).format(dateKeyFormat));
+ }, [episodesQuery.data]);
+
+ const visibleEpisodeCount = useMemo(
+ () => days.reduce((count, day) => count + (episodesByDay[day.format(dateKeyFormat)]?.length ?? 0), 0),
+ [days, episodesByDay],
+ );
+
+ const today = dayjs().format(dateKeyFormat);
+ const unit = view === 'week' ? 'week' : 'month';
+ const rangeLabel = view === 'week'
+ ? `${visibleRange.start.format('MMM D')} – ${visibleRange.end.format('MMM D, YYYY')}`
+ : anchor.format('MMMM YYYY');
+
+ return (
+ <>
+ Calendar | Shoko
+
+
+
}
+ >
+
+
+
+
+
+
+
+ {rangeLabel}
+ {episodesQuery.isFetching && !episodesQuery.isPending && (
+
+ )}
+
+
+
+
+
+
+
+ {episodeTypeOptions.map(option => (
+
+ ))}
+
+
+
+
+
+
+
+ {episodesQuery.isPending && (
+
+
+
+ )}
+
+ {!episodesQuery.isPending && (
+
+
+ {weekdayNames.map(name => (
+
+ {name}
+
+ ))}
+
+
+ {days.map((day) => {
+ const dateKey = day.format(dateKeyFormat);
+ const isToday = dateKey === today;
+ const isOutsideMonth = view === 'month' && !day.isSame(anchor, 'month');
+ const episodes = episodesByDay[dateKey] ?? [];
+
+ return (
+
+
+ {day.date() === 1 || view === 'week' ? day.format('MMM D') : day.date()}
+ {episodes.length > 0 && {episodes.length}}
+
+ {episodes.map(episode =>
)}
+
+ );
+ })}
+
+
+ )}
+
+
+ >
+ );
+};
+
+export default Calendar;