first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
import {EpisodePoster} from '@app/episodes/episode-poster/episode-poster';
import {CompactSeasonEpisode} from '@app/episodes/compact-season-episode';
import {EpisodeLink} from '@app/episodes/episode-link';
import {InteractableRating} from '@app/reviews/interactable-rating';
import React, {ReactNode} from 'react';
import {Episode} from '@app/titles/models/episode';
import {Title} from '@app/titles/models/title';
import {TitleRating} from '@app/reviews/title-rating';
import clsx from 'clsx';
import {Trans} from '@common/i18n/trans';
import {FormattedDate} from '@common/i18n/formatted-date';
interface Props {
episode: Episode;
title: Title;
allowRating?: boolean;
className?: string;
children?: ReactNode;
showPlayButton?: boolean;
centerPlayButton?: boolean;
}
export function EpisodeListItem({
episode,
title,
allowRating = true,
className,
children,
showPlayButton,
}: Props) {
return (
<div className={clsx('flex items-center gap-20', className)}>
<div className="relative w-288 flex-shrink-0 overflow-hidden rounded max-md:hidden">
<EpisodePoster
title={title}
episode={episode}
seasonNumber={episode.season_number}
lazy={true}
srcSize="md"
showPlayButton={showPlayButton}
/>
<div className="absolute bottom-0 left-0 w-full bg-black/50 p-6 text-center text-sm text-white">
<CompactSeasonEpisode episode={episode} />
</div>
</div>
<div className="flex-auto">
<EpisodeLink
title={title}
seasonNumber={episode.season_number}
episode={episode}
color="primary"
className="text-base font-semibold"
/>
<div className="mt-4 text-xs text-muted">
<FormattedDate date={episode.release_date} preset="long" />
</div>
<div className="my-12">
<EpisodeRating
title={title}
episode={episode}
allowRating={allowRating}
/>
</div>
<div className="text-sm">
{episode.description || (
<span className="italic">
<Trans message="We have no overview for this episode yet." />
</span>
)}
</div>
{children}
</div>
</div>
);
}
interface EpisodeRatingProps {
title: Title;
episode: Episode;
allowRating: boolean;
}
function EpisodeRating({title, episode, allowRating}: EpisodeRatingProps) {
if (episode.status === 'upcoming') {
return null;
}
return allowRating ? (
<InteractableRating title={title} episode={episode} />
) : (
<TitleRating score={episode.rating} />
);
}

View File

@@ -0,0 +1,31 @@
import {useQuery} from '@tanstack/react-query';
import {apiClient} from '@common/http/query-client';
import {useParams} from 'react-router-dom';
import {BackendResponse} from '@common/http/backend-response/backend-response';
interface Response extends BackendResponse {
episodeNumbers: number[];
}
export function useSeasonEpisodeNumbers() {
const {titleId, season} = useParams();
return useQuery({
queryKey: [
'titles',
`${titleId}`,
'seasons',
`${season}`,
'episodeNumbers',
],
queryFn: () => fetchEpisodeNumbers(titleId!, season!),
});
}
function fetchEpisodeNumbers(
titleId: number | string,
seasonNumber: number | string,
) {
return apiClient
.get<Response>(`titles/${titleId}/seasons/${seasonNumber}/episode-numbers`)
.then(response => response.data);
}

View File

@@ -0,0 +1,60 @@
import {useQuery} from '@tanstack/react-query';
import {apiClient} from '@common/http/query-client';
import {useParams} from 'react-router-dom';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {Season} from '@app/titles/models/season';
import {Title} from '@app/titles/models/title';
import {Episode} from '@app/titles/models/episode';
import {PaginationResponse} from '@common/http/backend-response/pagination-response';
import {getBootstrapData} from '@common/core/bootstrap-data/use-backend-bootstrap-data';
export interface GetSeasonResponse extends BackendResponse {
season: Season;
title: Title;
episodes?: PaginationResponse<Episode>;
}
interface Params {
skipUpdating?: boolean;
truncateDescriptions?: boolean;
load?: string;
}
export const seasonQueryKey = (
titleId: number | string,
season: number | string,
params?: any,
) => {
const key = ['titles', `${titleId}`, 'seasons', `${season}`];
if (params) {
key.push(params);
}
return key;
};
export function useSeason(loader: 'seasonPage' | 'season' | 'editSeasonPage') {
const {titleId, season} = useParams();
return useQuery({
queryKey: seasonQueryKey(titleId!, season!, loader),
queryFn: () => fetchSeason(titleId!, season!, loader),
initialData: () => {
const data = getBootstrapData().loaders?.[loader];
if (data?.title?.id == titleId && data?.season?.number == season) {
return data;
}
return undefined;
},
});
}
function fetchSeason(
titleId: number | string,
seasonNumber: number | string,
loader: string,
) {
return apiClient
.get<GetSeasonResponse>(`titles/${titleId}/seasons/${seasonNumber}`, {
params: {loader},
})
.then(response => response.data);
}

View File

@@ -0,0 +1,38 @@
import React, {useMemo} from 'react';
import {Title} from '@app/titles/models/title';
import {getTitleLink} from '@app/titles/title-link';
import {Trans} from '@common/i18n/trans';
import {BaseMediaLink, BaseMediaLinkProps} from '@app/base-media-link';
export interface SeasonLinkProps extends Omit<BaseMediaLinkProps, 'link'> {
title: Title;
seasonNumber: number;
}
export function SeasonLink({
title,
seasonNumber,
children,
color = 'inherit',
...linkProps
}: SeasonLinkProps) {
const link = useMemo(() => {
return getSeasonLink(title, seasonNumber);
}, [title, seasonNumber]);
return (
<BaseMediaLink {...linkProps} link={link}>
{children ?? (
<Trans message="Season :number" values={{number: seasonNumber}} />
)}
</BaseMediaLink>
);
}
export function getSeasonLink(
title: Title,
seasonNumber: number | string,
{absolute}: {absolute?: boolean} = {}
): string {
const titleLink = getTitleLink(title, {absolute});
return `${titleLink}/season/${seasonNumber}`;
}

View File

@@ -0,0 +1,113 @@
import React, {Fragment} from 'react';
import {PageMetaTags} from '@common/http/page-meta-tags';
import {PageStatus} from '@common/http/page-status';
import {GetSeasonResponse, useSeason} from '@app/seasons/requests/use-season';
import {TitlePageHeaderImage} from '@app/titles/pages/title-page/title-page-header-image';
import {Title} from '@app/titles/models/title';
import {TitlePoster} from '@app/titles/title-poster/title-poster';
import {Trans} from '@common/i18n/trans';
import {TitleLink} from '@app/titles/title-link';
import {SeasonLink} from '@app/seasons/season-link';
import {useParams} from 'react-router-dom';
import clsx from 'clsx';
import {SitePageLayout} from '@app/site-page-layout';
import {EpisodeListItem} from '@app/seasons/episode-list-item';
import {useSeasonEpisodes} from '@app/titles/requests/use-season-episodes';
import {InfiniteScrollSentinel} from '@common/ui/infinite-scroll/infinite-scroll-sentinel';
export function SeasonPage() {
const query = useSeason('seasonPage');
const content = query.data ? (
<Fragment>
<PageMetaTags query={query} />
<PageContent data={query.data} />
</Fragment>
) : (
<PageStatus query={query} loaderClassName="absolute inset-0 m-auto" />
);
return <SitePageLayout>{content}</SitePageLayout>;
}
interface PageContentProps {
data: GetSeasonResponse;
}
function PageContent({data}: PageContentProps) {
const {title, season} = data;
return (
<div>
<TitlePageHeaderImage title={title} season={season} />
<div className="container mx-auto mt-24 px-14 md:mt-40 md:px-24">
<div className="mb-24 flex items-center gap-12">
<TitlePoster size="w-70" srcSize="sm" title={title} />
<div>
<TitleLink title={title} color="primary" className="text-xl" />
<div className="text-lg">
<Trans message="Episode list" />
</div>
</div>
</div>
<SeasonList title={title} />
<EpisodeList data={data} />
<SeasonList title={title} />
</div>
</div>
);
}
interface SeasonListProps {
title: Title;
}
function SeasonList({title}: SeasonListProps) {
const {season} = useParams();
return (
<div>
<div className="mb-4 text-base font-semibold">
<Trans message="Seasons" />:
</div>
<div className="mb-34 flex items-center gap-10">
{[...new Array(title.seasons_count).keys()].map(index => {
const number = index + 1;
const isActive = season === `${number}`;
return (
<SeasonLink
key={number}
title={title}
seasonNumber={number}
className={clsx(
'flex h-30 w-30 flex-shrink-0 items-center justify-center rounded border text-base',
isActive
? 'pointer-events-none bg-primary text-white'
: 'text-primary'
)}
>
{number}
</SeasonLink>
);
})}
</div>
</div>
);
}
interface EpisodeListProps {
data: GetSeasonResponse;
}
function EpisodeList({data: {episodes, title}}: EpisodeListProps) {
const query = useSeasonEpisodes(episodes);
return (
<main>
{query.items.map(episode => (
<EpisodeListItem
key={episode.id}
episode={episode}
title={title}
allowRating
showPlayButton
className="mb-34"
/>
))}
<InfiniteScrollSentinel query={query} />
</main>
);
}

View File

@@ -0,0 +1,70 @@
import {useTrans} from '@common/i18n/use-trans';
import {message} from '@common/i18n/message';
import clsx from 'clsx';
import {ImageSize, useImageSrc} from '@app/images/use-image-src';
import {Season} from '@app/titles/models/season';
import {SeasonLink} from '@app/seasons/season-link';
import {Title} from '@app/titles/models/title';
import {Link} from 'react-router-dom';
import {MovieIcon} from '@common/icons/material/Movie';
interface Props {
title: Title;
season: Season;
className?: string;
size?: string;
lazy?: boolean;
srcSize?: ImageSize;
link?: string;
}
export function SeasonPoster({
title,
season,
className,
size = 'w-full',
srcSize,
lazy = true,
link,
}: Props) {
const {trans} = useTrans();
const src = useImageSrc(season.poster || title.poster, {size: srcSize});
const imageClassName = clsx(
className,
size,
'object-cover bg-fg-base/4 aspect-poster rounded',
!src ? 'flex items-center justify-center' : 'block'
);
const image = src ? (
<img
className={imageClassName}
draggable={false}
loading={lazy ? 'lazy' : 'eager'}
src={src}
alt={trans(
message('Poster for season :number of :title', {
values: {number: season.number, title: title.name},
})
)}
/>
) : (
<span className={imageClassName}>
<MovieIcon className="max-w-[60%] text-divider" size="text-6xl" />
</span>
);
return link ? (
<Link to={link} className="flex-shrink-0">
{image}
</Link>
) : (
<SeasonLink
title={title}
seasonNumber={season.number}
className="flex-shrink-0"
>
{image}
</SeasonLink>
);
}