mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
240 lines
6.2 KiB
TypeScript
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>
|
|
);
|
|
}
|