Edit vods feature Closes #1278

This commit is contained in:
Kalle
2023-03-08 22:57:56 +02:00
parent 6a55bceaf0
commit 3a7549c732
11 changed files with 260 additions and 57 deletions

View File

@@ -5,12 +5,17 @@ import type { VideoBeingAdded } from "../vods-types";
const createVideoStm = sql.prepare(/* sql */ `
insert into "UnvalidatedVideo"
("title", "type", "youtubeDate", "eventId", "youtubeId", "submitterUserId", "validatedAt")
("id", "title", "type", "youtubeDate", "eventId", "youtubeId", "submitterUserId", "validatedAt")
values
(@title, @type, @youtubeDate, @eventId, @youtubeId, @submitterUserId, @validatedAt)
(@id, @title, @type, @youtubeDate, @eventId, @youtubeId, @submitterUserId, @validatedAt)
returning *
`);
const deleteVideoStm = sql.prepare(/* sql */ `
delete from "UnvalidatedVideo"
where "id" = @id
`);
const createVideoMatchStm = sql.prepare(/* sql */ `
insert into "VideoMatch"
("videoId", "startsAt", "stageId", "mode")
@@ -28,9 +33,14 @@ const createVideoMatchPlayerStm = sql.prepare(/* sql */ `
export const createVod = sql.transaction(
(
args: VideoBeingAdded & { submitterUserId: number; isValidated: boolean }
args: VideoBeingAdded & {
submitterUserId: number;
isValidated: boolean;
id?: number;
}
) => {
const video = createVideoStm.get({
id: args.id,
title: args.title,
type: args.type,
youtubeDate: args.youtubeDate,
@@ -64,3 +74,18 @@ export const createVod = sql.transaction(
return video;
}
);
export const updateVodByReplacing = sql.transaction(
(
args: VideoBeingAdded & {
submitterUserId: number;
isValidated: boolean;
id: number;
}
) => {
deleteVideoStm.run({ id: args.id });
const video = createVod(args);
return video;
}
);

View File

@@ -7,7 +7,9 @@ const videoStm = sql.prepare(/* sql */ `
v."id",
v."title",
v."youtubeDate",
v."youtubeId"
v."youtubeId",
v."type",
v."submitterUserId"
from "Video" v
where v."id" = @id
`);
@@ -31,7 +33,9 @@ const videoMatchesStm = sql.prepare(/* sql */ `
'discordDiscriminator',
"u"."discordDiscriminator",
'customUrl',
"u"."customUrl"
"u"."customUrl",
'id',
"u"."id"
)
) as "players"
from "VideoMatch" vm

View File

@@ -7,13 +7,14 @@ import type {
import { useLoaderData } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import { Button } from "~/components/Button";
import { Button, LinkButton } from "~/components/Button";
import { Image, WeaponImage } from "~/components/Image";
import { Main } from "~/components/Main";
import { YouTubeEmbed } from "~/components/YouTubeEmbed";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import { useTranslation } from "~/hooks/useTranslation";
import { useUser } from "~/modules/auth";
import { databaseTimestampToDate } from "~/utils/dates";
import { secondsToMinutes } from "~/utils/number";
import { notFoundIfFalsy, type SendouRouteHandle } from "~/utils/remix";
@@ -22,6 +23,7 @@ import type { Unpacked } from "~/utils/types";
import {
modeImageUrl,
navIconUrl,
newVodPage,
stageImageUrl,
VODS_PAGE,
vodVideoPage,
@@ -29,6 +31,7 @@ import {
import { PovUser } from "../components/VodPov";
import { findVodById } from "../queries/findVodById.server";
import type { Vod } from "../vods-types";
import { canEditVideo } from "../vods-utils";
import styles from "../vods.css";
export const links: LinksFunction = () => {
@@ -82,6 +85,8 @@ export default function VodPage() {
const isMounted = useIsMounted();
const [autoplay, setAutoplay] = React.useState(false);
const data = useLoaderData<typeof loader>();
const { t } = useTranslation(["common"]);
const user = useUser();
return (
<Main className="stack lg">
@@ -93,23 +98,40 @@ export default function VodPage() {
autoplay={autoplay}
/>
<h2 className="text-sm">{data.vod.title}</h2>
<div className="stack horizontal sm items-center">
<PovUser pov={data.vod.pov} />
<time
className={clsx("text-lighter text-xs", {
invisible: !isMounted,
})}
>
{isMounted
? databaseTimestampToDate(
data.vod.youtubeDate
).toLocaleDateString(i18n.language, {
day: "numeric",
month: "numeric",
year: "numeric",
})
: "t"}
</time>
<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
? databaseTimestampToDate(
data.vod.youtubeDate
).toLocaleDateString(i18n.language, {
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,
}) ? (
<LinkButton
to={newVodPage(data.vod.id)}
size="tiny"
testId="edit-vod-button"
>
{t("common:actions.edit")}
</LinkButton>
) : null}
</div>
</div>
<div className="vods__matches">

View File

@@ -1,35 +1,46 @@
import type { ActionFunction, LoaderFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import {
type ActionFunction,
type LoaderArgs,
redirect,
} from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import * as React from "react";
import { z } from "zod";
import { Button } from "~/components/Button";
import { UserCombobox, WeaponCombobox } from "~/components/Combobox";
import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { YouTubeEmbed } from "~/components/YouTubeEmbed";
import type { Video, VideoMatch } from "~/db/types";
import { useTranslation } from "~/hooks/useTranslation";
import { requireUser } from "~/modules/auth";
import {
type MainWeaponId,
stageIds,
type MainWeaponId,
type StageId,
} from "~/modules/in-game-lists";
import { modesShort } from "~/modules/in-game-lists/modes";
import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
dateToYearMonthDayString,
} from "~/utils/dates";
import { parseRequestFormData, type SendouRouteHandle } from "~/utils/remix";
import { createVod } from "../queries/createVod.server";
import { secondsToMinutesNumberTuple } from "~/utils/number";
import {
notFoundIfFalsy,
parseRequestFormData,
type SendouRouteHandle,
} from "~/utils/remix";
import { VODS_PAGE, vodVideoPage } from "~/utils/urls";
import { actualNumber, id } from "~/utils/zod";
import { createVod, updateVodByReplacing } from "../queries/createVod.server";
import { findVodById } from "../queries/findVodById.server";
import { videoMatchTypes, VOD } from "../vods-constants";
import { videoInputSchema } from "../vods-schemas";
import type { VideoBeingAdded, VideoMatchBeingAdded } from "../vods-types";
import { dateToYearMonthDayString } from "~/utils/dates";
import { SubmitButton } from "~/components/SubmitButton";
import { Form } from "@remix-run/react";
import { YouTubeEmbed } from "~/components/YouTubeEmbed";
import { VODS_PAGE, vodVideoPage } from "~/utils/urls";
import { canAddVideo } from "../vods-utils";
import { canAddVideo, canEditVideo, vodToVideoBeingAdded } from "../vods-utils";
export const handle: SendouRouteHandle = {
i18n: ["vods", "calendar"],
@@ -46,38 +57,92 @@ export const action: ActionFunction = async ({ request }) => {
throw new Response(null, { status: 401 });
}
const video = createVod({
...data.video,
submitterUserId: user.id,
isValidated: true,
});
let video;
if (data.vodToEditId) {
const vod = notFoundIfFalsy(findVodById(data.vodToEditId));
if (
!canEditVideo({
userId: user.id,
submitterUserId: vod.submitterUserId,
povUserId: typeof vod.pov === "string" ? undefined : vod.pov?.id,
})
) {
throw new Response("no permissions to edit this vod", { status: 401 });
}
video = updateVodByReplacing({
...data.video,
submitterUserId: user.id,
isValidated: true,
id: data.vodToEditId,
});
} else {
video = createVod({
...data.video,
submitterUserId: user.id,
isValidated: true,
});
}
return redirect(vodVideoPage(video.id));
};
export const loader: LoaderFunction = async ({ request }) => {
const newVodLoaderParamsSchema = z.object({
vod: z.preprocess(actualNumber, id),
});
export const loader = async ({ request }: LoaderArgs) => {
const user = await requireUser(request);
if (!canAddVideo(user)) {
return redirect(VODS_PAGE);
throw redirect(VODS_PAGE);
}
return null;
const url = new URL(request.url);
const params = newVodLoaderParamsSchema.safeParse(
Object.fromEntries(url.searchParams)
);
if (!params.success) {
return { vodToEdit: null };
}
const vod = notFoundIfFalsy(findVodById(params.data.vod));
const vodToEdit = vodToVideoBeingAdded(vod);
if (
!canEditVideo({
submitterUserId: vod.submitterUserId,
userId: user.id,
povUserId: vodToEdit.povUserId,
})
) {
return { vodToEdit: null };
}
return { vodToEdit, vodToEditId: vod.id };
};
export default function NewVodPage() {
const data = useLoaderData<typeof loader>();
const { t } = useTranslation(["vods", "common"]);
const [video, setVideo] = React.useState<VideoBeingAdded>({
type: "TOURNAMENT",
matches: [newMatch()],
youtubeId: "",
title: "",
youtubeDate: dateToDatabaseTimestamp(new Date()),
});
const [video, setVideo] = React.useState<VideoBeingAdded>(
data.vodToEdit ?? {
type: "TOURNAMENT",
matches: [newMatch()],
youtubeId: "",
title: "",
youtubeDate: dateToDatabaseTimestamp(new Date()),
}
);
return (
<Form method="post">
<input type="hidden" name="video" value={JSON.stringify(video)} />
{data.vodToEdit ? (
<input type="hidden" name="vodToEditId" value={data.vodToEditId} />
) : null}
<Main halfWidth className="stack md">
<div>
<Label required htmlFor="url">
@@ -85,6 +150,11 @@ export default function NewVodPage() {
</Label>
<Input
id="url"
defaultValue={
data.vodToEdit && video.youtubeId
? `https://www.youtube.com/watch?v=${video.youtubeId}`
: undefined
}
onChange={(e) =>
setVideo({
...video,
@@ -102,6 +172,7 @@ export default function NewVodPage() {
</Label>
<Input
id="title"
value={video.title}
onChange={(e) =>
setVideo({
...video,
@@ -345,8 +416,13 @@ function Match({
type: Video["type"];
}) {
const id = React.useId();
const [minutes, setMinutes] = React.useState(0);
const [seconds, setSeconds] = React.useState(0);
const [minutes, setMinutes] = React.useState(
secondsToMinutesNumberTuple(match.startsAt)[0]
);
const [seconds, setSeconds] = React.useState(
secondsToMinutesNumberTuple(match.startsAt)[1]
);
const { t } = useTranslation(["game-misc", "vods"]);

View File

@@ -1,5 +1,11 @@
import { z } from "zod";
import { modeShort, safeJSONParse, stageId, weaponSplId } from "~/utils/zod";
import {
id,
modeShort,
safeJSONParse,
stageId,
weaponSplId,
} from "~/utils/zod";
import { videoMatchTypes, VOD } from "./vods-constants";
export const videoMatchSchema = z.object({
@@ -51,4 +57,5 @@ export const videoSchema = z
export const videoInputSchema = z.object({
video: z.preprocess(safeJSONParse, videoSchema),
vodToEditId: id.optional(),
});

View File

@@ -19,12 +19,15 @@ export interface Vod {
| "discordAvatar"
| "discordDiscriminator"
| "customUrl"
| "id"
>
| string;
title: Video["title"];
type: Video["type"];
youtubeDate: Video["youtubeDate"];
youtubeId: Video["youtubeId"];
matches: Array<VodMatch>;
submitterUserId: Video["submitterUserId"];
}
export type VodMatch = Pick<

View File

@@ -1,5 +1,37 @@
import type { User } from "~/db/types";
import { isAdmin } from "~/permissions";
import { type VideoBeingAdded, type Vod } from "./vods-types";
export function canAddVideo(user: Pick<User, "isVideoAdder">) {
return user.isVideoAdder;
}
export function vodToVideoBeingAdded(vod: Vod): VideoBeingAdded {
return {
title: vod.title,
youtubeId: vod.youtubeId,
youtubeDate: vod.youtubeDate,
matches: vod.matches,
type: vod.type,
povUserId: typeof vod.pov === "string" ? undefined : vod.pov?.id,
povUserName: typeof vod.pov === "string" ? vod.pov : undefined,
};
}
export function canEditVideo({
userId,
submitterUserId,
povUserId,
}: {
userId?: User["id"];
submitterUserId: User["id"];
povUserId?: User["id"];
}) {
if (!userId) return false;
return (
isAdmin({ id: userId }) ||
userId === submitterUserId ||
userId === povUserId
);
}

View File

@@ -198,6 +198,10 @@
justify-content: flex-end;
}
.justify-between {
justify-content: space-between;
}
.justify-self-end {
justify-self: flex-end;
}

View File

@@ -7,3 +7,9 @@ export function secondsToMinutes(seconds: number) {
const secondsLeft = seconds % 60;
return `${minutes}:${secondsLeft.toString().padStart(2, "0")}`;
}
export function secondsToMinutesNumberTuple(seconds: number) {
const minutes = Math.floor(seconds / 60);
const secondsLeft = seconds % 60;
return [minutes, secondsLeft] as const;
}

View File

@@ -78,7 +78,6 @@ export const MAPS_URL = "/maps";
export const ANALYZER_URL = "/analyzer";
export const OBJECT_DAMAGE_CALCULATOR_URL = "/object-damage-calculator";
export const VODS_PAGE = "/vods";
export const NEW_VOD_PAGE = `${VODS_PAGE}/new`;
export const BLANK_IMAGE_URL = "/static-assets/img/blank.gif";
export const COMMON_PREVIEW_IMAGE =
@@ -114,6 +113,8 @@ export const userBuildsPage = (user: UserLinkArgs) =>
export const userResultsPage = (user: UserLinkArgs) =>
`${userPage(user)}/results`;
export const userVodsPage = (user: UserLinkArgs) => `${userPage(user)}/vods`;
export const newVodPage = (vodToEditId?: number) =>
`${VODS_PAGE}/new${vodToEditId ? `?vod=${vodToEditId}` : ""}`;
export const userResultsEditHighlightsPage = (user: UserLinkArgs) =>
`${userResultsPage(user)}/highlights`;
export const userNewBuildPage = (

View File

@@ -1,4 +1,4 @@
import test from "@playwright/test";
import test, { expect } from "@playwright/test";
import {
impersonate,
isNotVisible,
@@ -7,7 +7,7 @@ import {
selectWeapon,
submit,
} from "~/utils/playwright";
import { NEW_VOD_PAGE, VODS_PAGE } from "~/utils/urls";
import { newVodPage, VODS_PAGE, vodVideoPage } from "~/utils/urls";
test.describe("VoDs page", () => {
test("adds video (pov)", async ({ page }) => {
@@ -15,7 +15,7 @@ test.describe("VoDs page", () => {
await impersonate(page);
await navigate({
page,
url: NEW_VOD_PAGE,
url: newVodPage(),
});
await page
@@ -67,7 +67,7 @@ test.describe("VoDs page", () => {
await impersonate(page);
await navigate({
page,
url: NEW_VOD_PAGE,
url: newVodPage(),
});
await page
@@ -106,6 +106,29 @@ test.describe("VoDs page", () => {
}
});
test("edits vod", async ({ page }) => {
await seed(page);
await impersonate(page);
await navigate({
page,
url: vodVideoPage(1),
});
await page.getByTestId("edit-vod-button").click();
await selectWeapon({
name: "Luna Blaster",
page,
inputName: "match-4-weapon",
});
await submit(page);
await expect(page).toHaveURL(vodVideoPage(1));
await page.getByTestId(`weapon-img-200-4`).isVisible();
});
test("operates vod filters", async ({ page }) => {
await seed(page);
await impersonate(page);