mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-27 05:36:28 -05:00
Edit vods feature Closes #1278
This commit is contained in:
@@ -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;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -198,6 +198,10 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.justify-self-end {
|
||||
justify-self: flex-end;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user