diff --git a/app/features/vods/queries/createVod.server.ts b/app/features/vods/queries/createVod.server.ts index fb924998a..7bfd07dc6 100644 --- a/app/features/vods/queries/createVod.server.ts +++ b/app/features/vods/queries/createVod.server.ts @@ -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; + } +); diff --git a/app/features/vods/queries/findVodById.server.ts b/app/features/vods/queries/findVodById.server.ts index ff8143324..c607f6f2f 100644 --- a/app/features/vods/queries/findVodById.server.ts +++ b/app/features/vods/queries/findVodById.server.ts @@ -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 diff --git a/app/features/vods/routes/vods.$id.tsx b/app/features/vods/routes/vods.$id.tsx index a4997f14e..9a0b42e26 100644 --- a/app/features/vods/routes/vods.$id.tsx +++ b/app/features/vods/routes/vods.$id.tsx @@ -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(); + const { t } = useTranslation(["common"]); + const user = useUser(); return (
@@ -93,23 +98,40 @@ export default function VodPage() { autoplay={autoplay} />

{data.vod.title}

-
- - +
+
+ + +
+ + {canEditVideo({ + submitterUserId: data.vod.submitterUserId, + userId: user?.id, + povUserId: + typeof data.vod.pov === "string" ? undefined : data.vod.pov?.id, + }) ? ( + + {t("common:actions.edit")} + + ) : null}
diff --git a/app/features/vods/routes/vods.new.tsx b/app/features/vods/routes/vods.new.tsx index 08c54f54d..416077ce6 100644 --- a/app/features/vods/routes/vods.new.tsx +++ b/app/features/vods/routes/vods.new.tsx @@ -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(); const { t } = useTranslation(["vods", "common"]); - const [video, setVideo] = React.useState({ - type: "TOURNAMENT", - matches: [newMatch()], - youtubeId: "", - title: "", - youtubeDate: dateToDatabaseTimestamp(new Date()), - }); + const [video, setVideo] = React.useState( + data.vodToEdit ?? { + type: "TOURNAMENT", + matches: [newMatch()], + youtubeId: "", + title: "", + youtubeDate: dateToDatabaseTimestamp(new Date()), + } + ); return (
+ {data.vodToEdit ? ( + + ) : null}
setVideo({ ...video, @@ -102,6 +172,7 @@ export default function NewVodPage() { 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"]); diff --git a/app/features/vods/vods-schemas.ts b/app/features/vods/vods-schemas.ts index b01cea95e..2ed3f33b5 100644 --- a/app/features/vods/vods-schemas.ts +++ b/app/features/vods/vods-schemas.ts @@ -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(), }); diff --git a/app/features/vods/vods-types.ts b/app/features/vods/vods-types.ts index 39799664d..4c13fe29c 100644 --- a/app/features/vods/vods-types.ts +++ b/app/features/vods/vods-types.ts @@ -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; + submitterUserId: Video["submitterUserId"]; } export type VodMatch = Pick< diff --git a/app/features/vods/vods-utils.ts b/app/features/vods/vods-utils.ts index 849ec7f2f..7ec21527e 100644 --- a/app/features/vods/vods-utils.ts +++ b/app/features/vods/vods-utils.ts @@ -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) { 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 + ); +} diff --git a/app/styles/utils.css b/app/styles/utils.css index 3806abde8..c412ef694 100644 --- a/app/styles/utils.css +++ b/app/styles/utils.css @@ -198,6 +198,10 @@ justify-content: flex-end; } +.justify-between { + justify-content: space-between; +} + .justify-self-end { justify-self: flex-end; } diff --git a/app/utils/number.ts b/app/utils/number.ts index 30c13f7f8..19ded79c6 100644 --- a/app/utils/number.ts +++ b/app/utils/number.ts @@ -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; +} diff --git a/app/utils/urls.ts b/app/utils/urls.ts index 95206eff2..a8cc91365 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -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 = ( diff --git a/e2e/vods.spec.ts b/e2e/vods.spec.ts index 7d3f5d8ce..7bae3af81 100644 --- a/e2e/vods.spec.ts +++ b/e2e/vods.spec.ts @@ -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);