sendou.ink/app/features/vods/routes/vods.$id.tsx
2026-03-21 15:19:32 +02:00

240 lines
6.2 KiB
TypeScript

import clsx from "clsx";
import { SquarePen, Trash } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { useLoaderData } from "react-router";
import { LinkButton } from "~/components/elements/Button";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Image, WeaponImage } from "~/components/Image";
import { Main } from "~/components/Main";
import { YouTubeEmbed } from "~/components/YouTubeEmbed";
import { useUser } from "~/features/auth/core/user";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import { metaTags, type SerializeFrom } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import type { Unpacked } from "~/utils/types";
import {
modeImageUrl,
navIconUrl,
newVodPage,
stageImageUrl,
VODS_PAGE,
vodVideoPage,
} from "~/utils/urls";
import { SendouButton } from "../../../components/elements/Button";
import { action } from "../actions/vods.$id.server";
import { PovUser } from "../components/VodPov";
import { loader } from "../loaders/vods.$id.server";
import type { Vod } from "../vods-types";
import { canEditVideo, secondsToHoursMinutesSecondString } from "../vods-utils";
import styles from "./vods.$id.module.css";
export { action, loader };
export const handle: SendouRouteHandle = {
breadcrumb: ({ match }) => {
const data = match.data as SerializeFrom<typeof loader> | undefined;
if (!data) return [];
return [
{
imgPath: navIconUrl("vods"),
href: VODS_PAGE,
type: "IMAGE",
},
{
text: data.vod.title,
href: vodVideoPage(data.vod.id),
type: "TEXT",
},
];
},
};
export const meta: MetaFunction<typeof loader> = (args) => {
if (!args.data) return [];
return metaTags({
title: args.data.vod.title,
description:
"Splatoon 3 VoD with timestamps to check out specific weapons as well as map and mode combinations.",
location: args.location,
});
};
export default function VodPage() {
const [start, setStart] = useSearchParamState({
name: "start",
defaultValue: 0,
revive: Number,
});
const isMounted = useIsMounted();
const [autoplay, setAutoplay] = React.useState(false);
const data = useLoaderData<typeof loader>();
const { t } = useTranslation(["common", "vods"]);
const user = useUser();
const { formatDate } = useTimeFormat();
return (
<Main className="stack lg">
<div className="stack sm">
<YouTubeEmbed
key={start}
id={data.vod.youtubeId}
start={start}
autoplay={autoplay}
/>
<h2 className="text-sm">{data.vod.title}</h2>
<div className="stack horizontal justify-between">
<div className="stack horizontal sm items-center">
<PovUser pov={data.vod.pov} />
<time
className={clsx("text-lighter text-xs", {
invisible: !isMounted,
})}
>
{isMounted
? formatDate(databaseTimestampToDate(data.vod.youtubeDate), {
day: "numeric",
month: "numeric",
year: "numeric",
})
: "t"}
</time>
</div>
{canEditVideo({
submitterUserId: data.vod.submitterUserId,
userId: user?.id,
povUserId:
typeof data.vod.pov === "string" ? undefined : data.vod.pov?.id,
}) ? (
<div className="stack horizontal md">
<LinkButton
to={newVodPage(data.vod.id)}
size="small"
testId="edit-vod-button"
icon={<SquarePen />}
>
{t("common:actions.edit")}
</LinkButton>
<FormWithConfirm
dialogHeading={t("vods:deleteConfirm", {
title: data.vod.title,
})}
>
<SendouButton
variant="minimal-destructive"
size="small"
type="submit"
icon={<Trash />}
>
{t("common:actions.delete")}
</SendouButton>
</FormWithConfirm>
</div>
) : null}
</div>
</div>
<div className={styles.matches}>
{data.vod.matches.map((match) => (
<Match
key={match.id}
match={match}
setStart={(newStart) => {
setStart(newStart);
setAutoplay(true);
window.scrollTo(0, 0);
}}
/>
))}
</div>
</Main>
);
}
function Match({
match,
setStart,
}: {
match: Unpacked<Vod["matches"]>;
setStart: (start: number) => void;
}) {
const { t } = useTranslation(["game-misc", "weapons"]);
const weapon = match.weapons.length === 1 ? match.weapons[0] : null;
const weapons = match.weapons.length > 1 ? match.weapons : null;
const teamSize = weapons ? weapons.length / 2 : 0;
return (
<div className={styles.match}>
<Image
alt=""
path={stageImageUrl(match.stageId)}
width={120}
className="rounded"
/>
{typeof weapon === "number" ? (
<WeaponImage
weaponSplId={weapon}
variant="badge"
width={42}
className={styles.matchWeapon}
testId={`weapon-img-${weapon}`}
/>
) : null}
<Image
path={modeImageUrl(match.mode)}
width={32}
className={clsx(styles.matchMode, { [styles.cast]: Boolean(weapons) })}
alt={t(`game-misc:MODE_LONG_${match.mode}`)}
title={t(`game-misc:MODE_LONG_${match.mode}`)}
/>
{weapons ? (
<div className="stack horizontal md">
<div className={styles.matchWeapons}>
{weapons.slice(0, teamSize).map((weapon, i) => {
return (
<WeaponImage
key={i}
testId={`weapon-img-${weapon}-${i}`}
weaponSplId={weapon}
variant="badge"
width={30}
/>
);
})}
</div>
<div className={styles.matchWeapons}>
{weapons.slice(teamSize).map((weapon, i) => {
const adjustedI = i + teamSize;
return (
<WeaponImage
key={i}
testId={`weapon-img-${weapon}-${adjustedI}`}
weaponSplId={weapon}
variant="badge"
width={30}
/>
);
})}
</div>
</div>
) : null}
<SendouButton
size="small"
onPress={() => setStart(match.startsAt)}
variant="outlined"
>
{secondsToHoursMinutesSecondString(match.startsAt)}
</SendouButton>
</div>
);
}