mirror of
https://github.com/movie-web/movie-web.git
synced 2024-06-03 05:18:44 +02:00
219 lines
6.7 KiB
TypeScript
219 lines
6.7 KiB
TypeScript
import classNames from "classnames";
|
|
import { useCallback } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Link } from "react-router-dom";
|
|
|
|
import { mediaItemToId } from "@/backend/metadata/tmdb";
|
|
import { DotList } from "@/components/text/DotList";
|
|
import { Flare } from "@/components/utils/Flare";
|
|
import { MediaItem } from "@/utils/mediaTypes";
|
|
|
|
import { IconPatch } from "../buttons/IconPatch";
|
|
import { Icons } from "../Icon";
|
|
import { MediaCardBookmarkButton } from "../player/Player";
|
|
|
|
export interface MediaCardProps {
|
|
media: MediaItem;
|
|
linkable?: boolean;
|
|
series?: {
|
|
episode: number;
|
|
season?: number;
|
|
episodeId: string;
|
|
seasonId: string;
|
|
};
|
|
percentage?: number;
|
|
closable?: boolean;
|
|
shouldShowBookMark?: boolean;
|
|
onClose?: () => void;
|
|
}
|
|
|
|
function checkReleased(media: MediaItem): boolean {
|
|
const isReleasedYear = Boolean(
|
|
media.year && media.year <= new Date().getFullYear(),
|
|
);
|
|
const isReleasedDate = Boolean(
|
|
media.release_date && media.release_date <= new Date(),
|
|
);
|
|
|
|
// If the media has a release date, use that, otherwise use the year
|
|
const isReleased = media.release_date ? isReleasedDate : isReleasedYear;
|
|
|
|
return isReleased;
|
|
}
|
|
|
|
function MediaCardContent({
|
|
media,
|
|
linkable,
|
|
series,
|
|
percentage,
|
|
closable,
|
|
shouldShowBookMark = true,
|
|
onClose,
|
|
}: MediaCardProps) {
|
|
const { t } = useTranslation();
|
|
const percentageString = `${Math.round(percentage ?? 0).toFixed(0)}%`;
|
|
|
|
const isReleased = useCallback(() => checkReleased(media), [media]);
|
|
|
|
const canLink = linkable && !closable && isReleased();
|
|
|
|
const dotListContent = [t(`media.types.${media.type}`)];
|
|
|
|
if (media.year) {
|
|
dotListContent.push(media.year.toFixed());
|
|
}
|
|
|
|
if (!isReleased()) {
|
|
dotListContent.push(t("media.unreleased"));
|
|
}
|
|
|
|
return (
|
|
<Flare.Base
|
|
className={`group -m-3 mb-2 rounded-xl bg-background-main transition-colors duration-100 focus:relative focus:z-10 ${
|
|
canLink ? "hover:bg-mediaCard-hoverBackground tabbable" : ""
|
|
}`}
|
|
tabIndex={canLink ? 0 : -1}
|
|
onKeyUp={(e) => e.key === "Enter" && e.currentTarget.click()}
|
|
>
|
|
<Flare.Light
|
|
flareSize={300}
|
|
cssColorVar="--colors-mediaCard-hoverAccent"
|
|
backgroundClass="bg-mediaCard-hoverBackground duration-100"
|
|
className={classNames({
|
|
"rounded-xl bg-background-main group-hover:opacity-100": canLink,
|
|
})}
|
|
/>
|
|
<Flare.Child
|
|
className={`pointer-events-auto relative mb-2 p-3 transition-transform duration-100 ${
|
|
canLink ? "group-hover:scale-95" : "opacity-60"
|
|
}`}
|
|
>
|
|
<div
|
|
className={classNames(
|
|
"relative mb-4 pb-[150%] w-full overflow-hidden rounded-xl bg-mediaCard-hoverBackground bg-cover bg-center transition-[border-radius] duration-100",
|
|
{
|
|
"group-hover:rounded-lg": canLink,
|
|
},
|
|
)}
|
|
style={{
|
|
backgroundImage: media.poster ? `url(${media.poster})` : undefined,
|
|
}}
|
|
>
|
|
{series ? (
|
|
<div
|
|
className={[
|
|
"absolute right-2 top-2 rounded-md bg-mediaCard-badge px-2 py-1 transition-colors",
|
|
].join(" ")}
|
|
>
|
|
<p
|
|
className={[
|
|
"text-center text-xs font-bold text-mediaCard-badgeText transition-colors",
|
|
closable ? "" : "group-hover:text-white",
|
|
].join(" ")}
|
|
>
|
|
{t("media.episodeDisplay", {
|
|
season: series.season || 1,
|
|
episode: series.episode,
|
|
})}
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{percentage !== undefined ? (
|
|
<>
|
|
<div
|
|
className={`absolute inset-x-0 -bottom-px pb-1 h-12 bg-gradient-to-t from-mediaCard-shadow to-transparent transition-colors ${
|
|
canLink ? "group-hover:from-mediaCard-hoverShadow" : ""
|
|
}`}
|
|
/>
|
|
<div
|
|
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-mediaCard-shadow to-transparent transition-colors ${
|
|
canLink ? "group-hover:from-mediaCard-hoverShadow" : ""
|
|
}`}
|
|
/>
|
|
<div className="absolute inset-x-0 bottom-0 p-3">
|
|
<div className="relative h-1 overflow-hidden rounded-full bg-mediaCard-barColor">
|
|
<div
|
|
className="absolute inset-y-0 left-0 rounded-full bg-mediaCard-barFillColor"
|
|
style={{
|
|
width: percentageString,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
|
|
<div
|
|
className={`absolute inset-0 flex items-center justify-center bg-mediaCard-badge bg-opacity-80 transition-opacity duration-200 ${
|
|
closable ? "opacity-100" : "pointer-events-none opacity-0"
|
|
}`}
|
|
>
|
|
<IconPatch
|
|
clickable
|
|
className="text-2xl text-mediaCard-badgeText"
|
|
onClick={() => closable && onClose?.()}
|
|
icon={Icons.X}
|
|
/>
|
|
</div>
|
|
|
|
{shouldShowBookMark && (
|
|
<div
|
|
className={classNames(
|
|
`absolute left-2 top-2 rounded-md transition-opacity opacity-0 group-hover:opacity-100 duration-300`,
|
|
{
|
|
"opacity-100": closable,
|
|
},
|
|
)}
|
|
>
|
|
<MediaCardBookmarkButton media={media} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<h1 className="mb-1 line-clamp-3 max-h-[4.5rem] text-ellipsis break-words font-bold text-white">
|
|
<span>{media.title}</span>
|
|
</h1>
|
|
<DotList className="text-xs" content={dotListContent} />
|
|
</Flare.Child>
|
|
</Flare.Base>
|
|
);
|
|
}
|
|
|
|
export function MediaCard(props: MediaCardProps) {
|
|
const content = <MediaCardContent {...props} />;
|
|
|
|
const isReleased = useCallback(
|
|
() => checkReleased(props.media),
|
|
[props.media],
|
|
);
|
|
|
|
const canLink = props.linkable && !props.closable && isReleased();
|
|
|
|
let link = canLink
|
|
? `/media/${encodeURIComponent(mediaItemToId(props.media))}`
|
|
: "#";
|
|
if (canLink && props.series) {
|
|
if (props.series.season === 0 && !props.series.episodeId) {
|
|
link += `/${encodeURIComponent(props.series.seasonId)}`;
|
|
} else {
|
|
link += `/${encodeURIComponent(
|
|
props.series.seasonId,
|
|
)}/${encodeURIComponent(props.series.episodeId)}`;
|
|
}
|
|
}
|
|
|
|
if (!canLink) return <span>{content}</span>;
|
|
return (
|
|
<Link
|
|
to={link}
|
|
tabIndex={-1}
|
|
className={classNames(
|
|
"tabbable",
|
|
props.closable ? "hover:cursor-default" : "",
|
|
)}
|
|
>
|
|
{content}
|
|
</Link>
|
|
);
|
|
}
|