272 lines
10 KiB
TypeScript

import { useCallback, useMemo } from "react";
import { useRouter } from "next/router";
import { Chip } from "./Chip";
import { Ico, Icon } from "./Ico";
import { Img } from "./Img";
import { Link } from "./Inputs/Link";
import { DatePickerFragment, PricePickerFragment, UploadImageFragment } from "graphql/generated";
import { cIf, cJoin } from "helpers/className";
import { prettyDate, prettyDuration, prettyPrice, prettyShortenNumber } from "helpers/formatters";
import { ImageQuality } from "helpers/img";
import { useDeviceSupportsHover } from "hooks/useMediaQuery";
import { useSmartLanguage } from "hooks/useSmartLanguage";
import { TranslatedProps } from "types/TranslatedProps";
import { useUserSettings } from "contexts/UserSettingsContext";
import { useLocalData } from "contexts/LocalDataContext";
/*
* ╭─────────────╮
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
*/
interface Props {
thumbnail?: UploadImageFragment | string | null | undefined;
thumbnailAspectRatio?: string;
thumbnailForceAspectRatio?: boolean;
thumbnailRounded?: boolean;
href: string;
pre_title?: string | null | undefined;
title: string | null | undefined;
subtitle?: string | null | undefined;
description?: string | null | undefined;
topChips?: string[];
bottomChips?: string[];
keepInfoVisible?: boolean;
stackNumber?: number;
metadata?: {
releaseDate?: DatePickerFragment | null;
releaseDateFormat?: Intl.DateTimeFormatOptions["dateStyle"];
price?: PricePickerFragment | null;
views?: number;
author?: string;
position: "Bottom" | "Top";
};
infoAppend?: React.ReactNode;
hoverlay?:
| {
__typename: "Video";
duration: number;
}
| { __typename: "anotherHoverlayName" };
}
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const PreviewCard = ({
href,
thumbnail,
thumbnailAspectRatio = "4/3",
thumbnailForceAspectRatio = false,
thumbnailRounded = true,
pre_title,
title,
subtitle,
description,
stackNumber = 0,
topChips,
bottomChips,
keepInfoVisible,
metadata,
hoverlay,
infoAppend,
}: Props): JSX.Element => {
const { currency } = useUserSettings();
const { currencies } = useLocalData();
const isHoverable = useDeviceSupportsHover();
const router = useRouter();
const metadataJSX = useMemo(
() => (
<>
{metadata && (metadata.releaseDate || metadata.price) && (
<div className="flex w-full flex-row flex-wrap gap-x-3">
{metadata.releaseDate && (
<p className="text-sm">
<Ico icon={Icon.Event} className="mr-1 translate-y-[.15em] !text-base" />
{prettyDate(metadata.releaseDate, router.locale)}
</p>
)}
{metadata.price && (
<p className="justify-self-end text-sm">
<Ico icon={Icon.ShoppingCart} className="mr-1 translate-y-[.15em] !text-base" />
{prettyPrice(metadata.price, currencies, currency)}
</p>
)}
{metadata.views && (
<p className="text-sm">
<Ico icon={Icon.Visibility} className="mr-1 translate-y-[.15em] !text-base" />
{prettyShortenNumber(metadata.views)}
</p>
)}
{metadata.author && (
<p className="text-sm">
<Ico icon={Icon.Person} className="mr-1 translate-y-[.15em] !text-base" />
{metadata.author}
</p>
)}
</div>
)}
</>
),
[currencies, currency, metadata, router.locale]
);
return (
<Link
href={href}
className="group grid cursor-pointer items-end text-left transition-transform
drop-shadow-shade-xl hover:scale-[1.02]">
{stackNumber > 0 && (
<>
<div
className={cJoin(
`absolute inset-0 scale-[.85] overflow-hidden bg-light brightness-[0.8] sepia-[0.5]
transition-[top_transform] group-hover:-top-9`,
cIf(thumbnailRounded, "rounded-md")
)}>
{thumbnail && (
<Img className="opacity-30" src={thumbnail} quality={ImageQuality.Medium} />
)}
</div>
<div
className={cJoin(
`absolute inset-0 overflow-hidden bg-light brightness-[0.9] sepia-[0.2]
transition-[top_transform] group-hover:-top-4 group-hover:scale-[.94]`,
cIf(thumbnailRounded, "rounded-md")
)}>
{thumbnail && (
<Img className="opacity-70" src={thumbnail} quality={ImageQuality.Medium} />
)}
</div>
</>
)}
{thumbnail ? (
<div
className="relative"
style={{
aspectRatio: thumbnailForceAspectRatio ? thumbnailAspectRatio : "unset",
}}>
<Img
className={cJoin(
cIf(
thumbnailRounded,
cIf(keepInfoVisible, "rounded-t-md", "rounded-md notHoverable:rounded-b-none")
),
cIf(thumbnailForceAspectRatio, "h-full w-full object-cover")
)}
src={thumbnail}
quality={ImageQuality.Medium}
/>
{stackNumber > 0 && (
<div
className="absolute right-2 top-2 rounded-full bg-black
bg-opacity-60 px-2 text-light">
{stackNumber}
</div>
)}
{hoverlay && hoverlay.__typename === "Video" && (
<>
<div
className="absolute inset-0 grid place-content-center bg-shade bg-opacity-0
text-light transition-colors drop-shadow-shade-lg group-hover:bg-opacity-50">
<Ico
icon={Icon.PlayCircleOutline}
className="!text-6xl opacity-0 transition-opacity group-hover:opacity-100"
/>
</div>
<div
className="absolute right-2 bottom-2 rounded-full bg-black bg-opacity-60 px-2
text-light">
{prettyDuration(hoverlay.duration)}
</div>
</>
)}
</div>
) : (
<div
style={{ aspectRatio: thumbnailAspectRatio }}
className={cJoin(
"relative w-full bg-light",
cIf(keepInfoVisible, "rounded-t-md", "rounded-md notHoverable:rounded-b-none")
)}>
{stackNumber > 0 && (
<div
className="absolute right-2 top-2 rounded-full bg-black
bg-opacity-60 px-2 text-light">
{stackNumber}
</div>
)}
</div>
)}
<div
className={cJoin(
"z-20 grid gap-2 p-4 transition-opacity linearbg-obi",
cIf(
!keepInfoVisible && isHoverable,
`-inset-x-0.5 bottom-2 opacity-0 [border-radius:10%_10%_10%_10%_/_1%_1%_3%_3%]
group-hover:opacity-100 hoverable:absolute hoverable:drop-shadow-shade-lg
notHoverable:rounded-b-md notHoverable:opacity-100`,
"[border-radius:0%_0%_10%_10%_/_0%_0%_3%_3%]"
)
)}>
{metadata?.position === "Top" && metadataJSX}
{topChips && topChips.length > 0 && (
<div className="grid grid-flow-col place-content-start gap-1 overflow-hidden">
{topChips.map((text, index) => (
<Chip key={index} text={text} />
))}
</div>
)}
<div className="my-1">
{pre_title && <p className="mb-1 break-words leading-none">{pre_title}</p>}
{title && (
<p className="break-words font-headers text-lg font-bold leading-none">{title}</p>
)}
{subtitle && <p className="break-words leading-none">{subtitle}</p>}
</div>
{description && <p>{description}</p>}
{bottomChips && bottomChips.length > 0 && (
<div
className="grid grid-flow-col place-content-start gap-1 overflow-x-scroll
[scrollbar-width:none] webkit-scrollbar:h-0">
{bottomChips.map((text, index) => (
<Chip key={index} className="text-sm" text={text} />
))}
</div>
)}
{metadata?.position === "Bottom" && metadataJSX}
{infoAppend}
</div>
</Link>
);
};
/*
* ╭──────────────────────╮
* ───────────────────────────────────╯ TRANSLATED VARIANT ╰──────────────────────────────────────
*/
export const TranslatedPreviewCard = ({
translations,
fallback,
...otherProps
}: TranslatedProps<Props, "description" | "pre_title" | "subtitle" | "title">): JSX.Element => {
const [selectedTranslation] = useSmartLanguage({
items: translations,
languageExtractor: useCallback((item: { language: string }): string => item.language, []),
});
return (
<PreviewCard
pre_title={selectedTranslation?.pre_title ?? fallback.pre_title}
title={selectedTranslation?.title ?? fallback.title}
subtitle={selectedTranslation?.subtitle ?? fallback.subtitle}
description={selectedTranslation?.description ?? fallback.description}
{...otherProps}
/>
);
};