New VoD form UI improvements (#2667)

This commit is contained in:
Kalle 2025-12-18 20:29:40 +02:00 committed by GitHub
parent 457f747f7a
commit 85f246ef06
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 530 additions and 50 deletions

View File

@ -0,0 +1,20 @@
.selectWidthWider {
--select-width: 250px;
}
.item {
display: flex;
gap: var(--s-2);
align-items: center;
}
.stageImg {
border-radius: var(--rounded-sm);
}
.stageLabel {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
min-width: 0;
}

View File

@ -0,0 +1,88 @@
import type { Key } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { SendouSelect, SendouSelectItem } from "~/components/elements/Select";
import { StageImage } from "~/components/Image";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import type { StageId } from "~/modules/in-game-lists/types";
import styles from "./StageSelect.module.css";
interface StageSelectProps<Clearable extends boolean | undefined = undefined> {
label?: string;
value?: StageId | null;
initialValue?: StageId;
onChange?: (
stageId: StageId | (Clearable extends true ? null : never),
) => void;
clearable?: Clearable;
testId?: string;
isRequired?: boolean;
}
export function StageSelect<Clearable extends boolean | undefined = undefined>({
label,
value,
initialValue,
onChange,
clearable,
testId = "stage-select",
isRequired,
}: StageSelectProps<Clearable>) {
const { t } = useTranslation(["common", "game-misc"]);
const items = useStageItems();
const isControlled = value !== undefined;
const handleOnChange = (key: Key | null) => {
if (key === null) return onChange?.(null as any);
onChange?.(Number(key) as any);
};
return (
<SendouSelect
aria-label={
!label ? t("common:forms.stageSearch.placeholder") : undefined
}
items={items}
label={label}
placeholder={t("common:forms.stageSearch.placeholder")}
search={{
placeholder: t("common:forms.stageSearch.search.placeholder"),
}}
className={styles.selectWidthWider}
popoverClassName={styles.selectWidthWider}
selectedKey={isControlled ? value : undefined}
defaultSelectedKey={isControlled ? undefined : (initialValue as Key)}
onSelectionChange={handleOnChange}
clearable={clearable}
data-testid={testId}
isRequired={isRequired}
>
{({ id, name }) => (
<SendouSelectItem key={id} id={id} textValue={name}>
<div className={styles.item}>
<StageImage
stageId={id as StageId}
width={42}
className={styles.stageImg}
/>
<span
className={styles.stageLabel}
data-testid={`stage-select-option-${name}`}
>
{name}
</span>
</div>
</SendouSelectItem>
)}
</SendouSelect>
);
}
function useStageItems() {
const { t } = useTranslation(["game-misc"]);
return stageIds.map((id) => ({
id,
name: t(`game-misc:STAGE_${id}`),
}));
}

View File

@ -1,12 +1,128 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toastQueue } from "./elements/Toast";
export function YouTubeEmbed({
id,
start,
autoplay = false,
enableApi = false,
onPlayerReady,
}: {
id: string;
start?: number;
autoplay?: boolean;
enableApi?: boolean;
onPlayerReady?: (player: YT.Player) => void;
}) {
const { t } = useTranslation(["vods"]);
const playerRef = useRef<YT.Player | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isApiReady, setIsApiReady] = useState(false);
const [hasError, setHasError] = useState(false);
useEffect(() => {
if (!enableApi || typeof window === "undefined") return;
if (window.YT?.Player) {
setIsApiReady(true);
return;
}
const tag = document.createElement("script");
tag.src = "https://www.youtube.com/iframe_api";
const firstScriptTag = document.getElementsByTagName("script")[0];
tag.onerror = () => {
toastQueue.add({
message: t("vods:errors.youtubePreviewFailed"),
variant: "error",
});
setHasError(true);
};
firstScriptTag.parentNode?.insertBefore(tag, firstScriptTag);
window.onYouTubeIframeAPIReady = () => {
setIsApiReady(true);
};
const timeout = setTimeout(() => {
if (!window.YT?.Player) {
toastQueue.add({
message: t("vods:errors.youtubePreviewFailed"),
variant: "error",
});
setHasError(true);
}
}, 10000);
return () => clearTimeout(timeout);
}, [enableApi, t]);
useEffect(() => {
if (!enableApi || !isApiReady || !containerRef.current) return;
if (playerRef.current) {
try {
playerRef.current.loadVideoById({ videoId: id, startSeconds: start });
} catch {
toastQueue.add({
message: t("vods:errors.youtubePreviewFailed"),
variant: "error",
});
setHasError(true);
}
return;
}
try {
const player = new window.YT.Player(containerRef.current, {
height: "281",
width: "500",
videoId: id,
playerVars: {
autoplay: autoplay ? 1 : 0,
controls: 1,
rel: 0,
modestbranding: 1,
start: start ?? 0,
},
events: {
onReady: () => {
playerRef.current = player;
onPlayerReady?.(player);
},
onError: () => {
toastQueue.add({
message: t("vods:errors.youtubePreviewFailed"),
variant: "error",
});
setHasError(true);
},
},
});
} catch {
toastQueue.add({
message: t("vods:errors.youtubePreviewFailed"),
variant: "error",
});
setHasError(true);
}
}, [enableApi, isApiReady, id, start, autoplay, onPlayerReady, t]);
if (enableApi) {
if (hasError) {
return null;
}
return (
<div className="youtube__container--api">
<div ref={containerRef} />
</div>
);
}
return (
<div className="youtube__container">
<iframe

View File

@ -1,3 +1,4 @@
import clsx from "clsx";
import * as React from "react";
import {
Controller,
@ -11,6 +12,7 @@ interface InputGroupFormFieldProps<T extends FieldValues> {
label: string;
name: FieldPath<T>;
bottomText?: string;
direction?: "horizontal" | "vertical";
type: "checkbox" | "radio";
values: Array<{
label: string;
@ -24,6 +26,7 @@ export function InputGroupFormField<T extends FieldValues>({
bottomText,
values,
type,
direction = "vertical",
}: InputGroupFormFieldProps<T>) {
const methods = useFormContext();
@ -50,7 +53,12 @@ export function InputGroupFormField<T extends FieldValues>({
return (
<div>
<fieldset className="stack sm" ref={ref}>
<fieldset
className={clsx("stack sm", {
"horizontal md": direction === "horizontal",
})}
ref={ref}
>
<legend>{label}</legend>
{values.map((checkbox) => {

View File

@ -0,0 +1,24 @@
.layout {
position: relative;
}
.embedContainer {
margin-block: var(--s-2);
}
@media (min-width: 1425px) {
.embedContainer {
position: fixed;
left: max(24px, calc((100vw - 800px) / 2 - 320px - 48px));
top: 120px;
width: 320px;
margin-block: 0;
}
}
@media (min-width: 1700px) {
.embedContainer {
left: max(24px, calc((100vw - 800px) / 2 - 400px - 48px));
width: 400px;
}
}

View File

@ -1,4 +1,5 @@
import { useLoaderData } from "@remix-run/react";
import { useEffect, useState } from "react";
import {
Controller,
get,
@ -12,13 +13,16 @@ import { SendouButton } from "~/components/elements/Button";
import { UserSearch } from "~/components/elements/UserSearch";
import { FormMessage } from "~/components/FormMessage";
import { AddFieldButton } from "~/components/form/AddFieldButton";
import { InputGroupFormField } from "~/components/form/InputGroupFormField";
import { RemoveFieldButton } from "~/components/form/RemoveFieldButton";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { StageSelect } from "~/components/StageSelect";
import { WeaponSelect } from "~/components/WeaponSelect";
import { YouTubeEmbed } from "~/components/YouTubeEmbed";
import type { Tables } from "~/db/tables";
import { useRecentlyReportedWeapons } from "~/features/sendouq/q-hooks";
import { modesShort } from "~/modules/in-game-lists/modes";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import { useHasRole } from "~/modules/permissions/hooks";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { Alert } from "../../../components/Alert";
@ -30,6 +34,8 @@ import { action } from "../actions/vods.new.server";
import { loader } from "../loaders/vods.new.server";
import { videoMatchTypes } from "../vods-constants";
import { videoInputSchema } from "../vods-schemas";
import { extractYoutubeIdFromVideoUrl } from "../vods-utils";
import styles from "./vods.new.module.css";
export { action, loader };
export const handle: SendouRouteHandle = {
@ -42,6 +48,7 @@ export default function NewVodPage() {
const isVideoAdder = useHasRole("VIDEO_ADDER");
const data = useLoaderData<typeof loader>();
const { t } = useTranslation(["vods"]);
const [player, setPlayer] = useState<YT.Player | null>(null);
if (!isVideoAdder) {
return (
@ -52,7 +59,7 @@ export default function NewVodPage() {
}
return (
<Main halfWidth>
<Main halfWidth className={styles.layout}>
<SendouForm
heading={
data.vodToEdit
@ -83,13 +90,35 @@ export default function NewVodPage() {
}
}
>
<FormFields />
<YouTubeEmbedWrapper onPlayerReady={setPlayer} />
<FormFields player={player} />
</SendouForm>
</Main>
);
}
function FormFields() {
function YouTubeEmbedWrapper({
onPlayerReady,
}: {
onPlayerReady: (player: YT.Player) => void;
}) {
const youtubeUrl = useWatch<VodFormFields>({
name: "video.youtubeUrl",
}) as string | undefined;
if (!youtubeUrl) return null;
const videoId = extractYoutubeIdFromVideoUrl(youtubeUrl);
if (!videoId) return null;
return (
<div className={styles.embedContainer}>
<YouTubeEmbed id={videoId} enableApi onPlayerReady={onPlayerReady} />
</div>
);
}
function FormFields({ player }: { player: YT.Player | null }) {
const { t } = useTranslation(["vods"]);
const videoType = useWatch({
name: "video.type",
@ -132,7 +161,7 @@ function FormFields() {
{videoType === "CAST" ? <TeamSizeField /> : <PovFormField />}
<MatchesFormfield videoType={videoType} />
<MatchesFormfield videoType={videoType} player={player} />
</>
);
}
@ -266,8 +295,10 @@ function PovFormField() {
function MatchesFormfield({
videoType,
player,
}: {
videoType: Tables["Video"]["type"];
player: YT.Player | null;
}) {
const {
formState: { errors },
@ -289,6 +320,7 @@ function MatchesFormfield({
remove={remove}
canRemove={fields.length > 1}
videoType={videoType}
player={player}
/>
);
})}
@ -315,13 +347,34 @@ function MatchesFieldset({
remove,
canRemove,
videoType,
player,
}: {
idx: number;
remove: (idx: number) => void;
canRemove: boolean;
videoType: Tables["Video"]["type"];
player: YT.Player | null;
}) {
const { t } = useTranslation(["vods", "game-misc"]);
const { setValue } = useFormContext<VodFormFields>();
const [currentTime, setCurrentTime] = useState<string>("");
useEffect(() => {
if (!player) return;
const interval = setInterval(() => {
try {
const time = player.getCurrentTime();
if (time) {
setCurrentTime(formatTime(time));
}
} catch {
// Silently ignore errors when getting current time
}
}, 250);
return () => clearInterval(interval);
}, [player]);
return (
<div className="stack md">
@ -330,34 +383,50 @@ function MatchesFieldset({
{canRemove ? <RemoveFieldButton onClick={() => remove(idx)} /> : null}
</div>
<InputFormField<VodFormFields>
required
label={t("vods:forms.title.startTimestamp")}
name={`video.matches.${idx}.startsAt`}
placeholder="10:22"
<div>
<InputFormField<VodFormFields>
required
label={t("vods:forms.title.startTimestamp")}
name={`video.matches.${idx}.startsAt`}
placeholder="10:22"
/>
{currentTime ? (
<SendouButton
variant="minimal"
size="miniscule"
onPress={() =>
setValue(`video.matches.${idx}.startsAt`, currentTime)
}
className="mt-2"
>
{t("vods:forms.action.setAsCurrent", { time: currentTime })}
</SendouButton>
) : null}
</div>
<InputGroupFormField<VodFormFields>
type="radio"
label={t("vods:forms.title.mode")}
name={`video.matches.${idx}.mode`}
values={modesShort.map((mode) => ({
value: mode,
label: t(`game-misc:MODE_SHORT_${mode}`),
}))}
direction="horizontal"
/>
<div className="stack horizontal sm">
<SelectFormField<VodFormFields>
required
label={t("vods:forms.title.mode")}
name={`video.matches.${idx}.mode`}
values={modesShort.map((mode) => ({
value: mode,
label: t(`game-misc:MODE_SHORT_${mode}`),
}))}
/>
<SelectFormField<VodFormFields>
required
label={t("vods:forms.title.stage")}
name={`video.matches.${idx}.stageId`}
values={stageIds.map((stageId) => ({
value: stageId,
label: t(`game-misc:STAGE_${stageId}`),
}))}
/>
</div>
<Controller
control={useFormContext<VodFormFields>().control}
name={`video.matches.${idx}.stageId`}
render={({ field: { onChange, value } }) => (
<StageSelect
isRequired
label={t("vods:forms.title.stage")}
value={value}
onChange={onChange}
/>
)}
/>
<WeaponsField idx={idx} videoType={videoType} />
</div>
@ -376,12 +445,21 @@ function WeaponsField({
name: "video.teamSize",
});
const teamSize = typeof watchedTeamSize === "number" ? watchedTeamSize : 4;
const { recentlyReportedWeapons, addRecentlyReportedWeapon } =
useRecentlyReportedWeapons();
const matches = useWatch<VodFormFields>({
name: "video.matches",
}) as VodFormFields["video"]["matches"];
return (
<Controller
control={useFormContext<VodFormFields>().control}
name={`video.matches.${idx}.weapons`}
render={({ field: { onChange, value } }) => {
const previousWeapons =
idx > 0 && matches?.[idx - 1]?.weapons
? matches[idx - 1].weapons
: null;
return (
<div>
{videoType === "CAST" ? (
@ -395,11 +473,15 @@ function WeaponsField({
isRequired
testId={`player-${i}-weapon`}
value={value[i] ?? null}
quickSelectWeaponsIds={recentlyReportedWeapons}
onChange={(weaponId) => {
const weapons = [...value];
weapons[i] = weaponId;
onChange(weapons);
if (weaponId) {
addRecentlyReportedWeapon(weaponId);
}
}}
/>
);
@ -416,11 +498,15 @@ function WeaponsField({
isRequired
testId={`player-${adjustedI}-weapon`}
value={value[adjustedI] ?? null}
quickSelectWeaponsIds={recentlyReportedWeapons}
onChange={(weaponId) => {
const weapons = [...value];
weapons[adjustedI] = weaponId;
onChange(weapons);
if (weaponId) {
addRecentlyReportedWeapon(weaponId);
}
}}
/>
);
@ -434,12 +520,36 @@ function WeaponsField({
isRequired
testId={`match-${idx}-weapon`}
value={value[0] ?? null}
onChange={(weaponId) => onChange([weaponId])}
quickSelectWeaponsIds={recentlyReportedWeapons}
onChange={(weaponId) => {
onChange([weaponId]);
if (weaponId) {
addRecentlyReportedWeapon(weaponId);
}
}}
/>
)}
{previousWeapons && previousWeapons.length > 0 ? (
<SendouButton
variant="minimal"
size="miniscule"
onPress={() => {
onChange([...previousWeapons]);
}}
className="mt-2"
>
{t("vods:forms.action.copyFromPrevious")}
</SendouButton>
) : null}
</div>
);
}}
/>
);
}
function formatTime(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${minutes}:${secs.toString().padStart(2, "0")}`;
}

View File

@ -805,6 +805,10 @@ abbr[title] {
padding-bottom: 56.25%;
}
.youtube__container--api {
width: fit-content;
}
.youtube__iframe {
position: absolute;
top: 0;

View File

@ -171,6 +171,8 @@
"forms.weaponSearch.quickSelect": "",
"forms.gearSearch.placeholder": "",
"forms.gearSearch.search.placeholder": "",
"forms.stageSearch.placeholder": "",
"forms.stageSearch.search.placeholder": "",
"tag.name.SPECIAL": "Særregler",
"tag.name.ART": "Kunstpræmier",
"tag.name.MONEY": "Pengepræmier",

View File

@ -27,7 +27,10 @@
"teamSize.4v4": "",
"forms.action.addMatch": "Tilføj kamp",
"forms.action.deleteMatch": "Slet kamp",
"forms.action.setAsCurrent": "",
"forms.action.copyFromPrevious": "",
"noVods": "",
"gainPerms": "Skriv venligst en besked til helpdesken på vores Discordserver for at få rettigheder til at uploade videoer.",
"deleteConfirm": "Vil du slette videoen: '{{title}}'?"
"deleteConfirm": "Vil du slette videoen: '{{title}}'?",
"errors.youtubePreviewFailed": ""
}

View File

@ -171,6 +171,8 @@
"forms.weaponSearch.quickSelect": "",
"forms.gearSearch.placeholder": "",
"forms.gearSearch.search.placeholder": "",
"forms.stageSearch.placeholder": "",
"forms.stageSearch.search.placeholder": "",
"tag.name.SPECIAL": "Spezielle Regeln",
"tag.name.ART": "Kunst-Preise",
"tag.name.MONEY": "Geld-Preise",

View File

@ -27,7 +27,10 @@
"teamSize.4v4": "",
"forms.action.addMatch": "Match hinzufügen",
"forms.action.deleteMatch": "Match löschen",
"forms.action.setAsCurrent": "",
"forms.action.copyFromPrevious": "",
"noVods": "",
"gainPerms": "",
"deleteConfirm": ""
"deleteConfirm": "",
"errors.youtubePreviewFailed": ""
}

View File

@ -171,6 +171,8 @@
"forms.weaponSearch.quickSelect": "Recent",
"forms.gearSearch.placeholder": "Select a gear",
"forms.gearSearch.search.placeholder": "Search gear...",
"forms.stageSearch.placeholder": "Select a stage",
"forms.stageSearch.search.placeholder": "Search stages...",
"tag.name.SPECIAL": "Special rules",
"tag.name.ART": "Art prizes",
"tag.name.MONEY": "Money prizes",

View File

@ -27,7 +27,10 @@
"teamSize.4v4": "4v4",
"forms.action.addMatch": "Add match",
"forms.action.deleteMatch": "Delete match",
"forms.action.setAsCurrent": "Set as current ({{time}})",
"forms.action.copyFromPrevious": "Copy from previous game",
"noVods": "No videos found matching this filter. Are we missing something? See the FAQ page on information about how to gain VoD uploading permissions.",
"gainPerms": "Please post on the helpdesk of our Discord to gain permissions to upload videos",
"deleteConfirm": "Delete video with the title '{{title}}'?"
"deleteConfirm": "Delete video with the title '{{title}}'?",
"errors.youtubePreviewFailed": "Failed to load YouTube preview"
}

View File

@ -172,6 +172,8 @@
"forms.weaponSearch.quickSelect": "",
"forms.gearSearch.placeholder": "",
"forms.gearSearch.search.placeholder": "",
"forms.stageSearch.placeholder": "",
"forms.stageSearch.search.placeholder": "",
"tag.name.SPECIAL": "Reglas especiales",
"tag.name.ART": "Premios de arte",
"tag.name.MONEY": "Premios de dinero",

View File

@ -27,7 +27,10 @@
"teamSize.4v4": "",
"forms.action.addMatch": "Agregar partido",
"forms.action.deleteMatch": "Borrar partido",
"forms.action.setAsCurrent": "",
"forms.action.copyFromPrevious": "",
"noVods": "",
"gainPerms": "Por favor envia un mensaje en el 'helpdesk' de nuestro Discord para obtener permiso para subir videos.",
"deleteConfirm": ""
"deleteConfirm": "",
"errors.youtubePreviewFailed": ""
}

View File

@ -172,6 +172,8 @@
"forms.weaponSearch.quickSelect": "",
"forms.gearSearch.placeholder": "",
"forms.gearSearch.search.placeholder": "",
"forms.stageSearch.placeholder": "",
"forms.stageSearch.search.placeholder": "",
"tag.name.SPECIAL": "Reglas especiales",
"tag.name.ART": "Premios de arte",
"tag.name.MONEY": "Premios de dinero",

View File

@ -27,7 +27,10 @@
"teamSize.4v4": "",
"forms.action.addMatch": "Agregar partido",
"forms.action.deleteMatch": "Borrar partido",
"forms.action.setAsCurrent": "",
"forms.action.copyFromPrevious": "",
"noVods": "",
"gainPerms": "Por favor envia un mensaje en el 'helpdesk' de nuestro Discord para obtener permiso para subir videos.",
"deleteConfirm": "¿Borrar video con el título de '{{title}}'?"
"deleteConfirm": "¿Borrar video con el título de '{{title}}'?",
"errors.youtubePreviewFailed": ""
}

View File

@ -172,6 +172,8 @@
"forms.weaponSearch.quickSelect": "",
"forms.gearSearch.placeholder": "",
"forms.gearSearch.search.placeholder": "",
"forms.stageSearch.placeholder": "",
"forms.stageSearch.search.placeholder": "",
"tag.name.SPECIAL": "Règles spéciales",
"tag.name.ART": "Illustration à gagner",
"tag.name.MONEY": "Argent à gagner",

View File

@ -27,7 +27,10 @@
"teamSize.4v4": "",
"forms.action.addMatch": "Ajouter un match",
"forms.action.deleteMatch": "Effacer un match",
"forms.action.setAsCurrent": "",
"forms.action.copyFromPrevious": "",
"noVods": "",
"gainPerms": "",
"deleteConfirm": ""
"deleteConfirm": "",
"errors.youtubePreviewFailed": ""
}

View File

@ -172,6 +172,8 @@
"forms.weaponSearch.quickSelect": "",
"forms.gearSearch.placeholder": "",
"forms.gearSearch.search.placeholder": "",
"forms.stageSearch.placeholder": "",
"forms.stageSearch.search.placeholder": "",
"tag.name.SPECIAL": "Règles spéciales",
"tag.name.ART": "Illustration à gagner",
"tag.name.MONEY": "Argent à gagner",

View File

@ -27,7 +27,10 @@
"teamSize.4v4": "",
"forms.action.addMatch": "Ajouter un match",
"forms.action.deleteMatch": "Effacer un match",
"forms.action.setAsCurrent": "",
"forms.action.copyFromPrevious": "",
"noVods": "Aucune vidéo a été trouvé. Est-ce qu'il nous manque quelque chose ? Consultez la page FAQ pour savoir comment obtenir les autorisations de téléchargement VoD.",
"gainPerms": "Demandez sur le channel helpdesk de notre discord pour avoir les permissions de publier vos VoD",
"deleteConfirm": "Supprimer la vidéo '{{title}}'?"
"deleteConfirm": "Supprimer la vidéo '{{title}}'?",
"errors.youtubePreviewFailed": ""
}

View File

@ -171,6 +171,8 @@
"forms.weaponSearch.quickSelect": "",
"forms.gearSearch.placeholder": "",
"forms.gearSearch.search.placeholder": "",
"forms.stageSearch.placeholder": "",
"forms.stageSearch.search.placeholder": "",
"tag.name.SPECIAL": "חוקים מיוחדים",
"tag.name.ART": "פרסי ציור",
"tag.name.MONEY": "פרסים בכסף",

View File

@ -27,7 +27,10 @@
"teamSize.4v4": "",
"forms.action.addMatch": "הוספת קרב",
"forms.action.deleteMatch": "מחיקת קרב",
"forms.action.setAsCurrent": "",
"forms.action.copyFromPrevious": "",
"noVods": "",
"gainPerms": "",
"deleteConfirm": ""
"deleteConfirm": "",
"errors.youtubePreviewFailed": ""
}

View File

@ -172,6 +172,8 @@
"forms.weaponSearch.quickSelect": "",
"forms.gearSearch.placeholder": "",
"forms.gearSearch.search.placeholder": "",
"forms.stageSearch.placeholder": "",
"forms.stageSearch.search.placeholder": "",
"tag.name.SPECIAL": "Regole speciali",
"tag.name.ART": "Premi artistici",
"tag.name.MONEY": "Premi monetari",

View File

@ -27,7 +27,10 @@
"teamSize.4v4": "",
"forms.action.addMatch": "Aggiungi partita",
"forms.action.deleteMatch": "Elimina partita",
"forms.action.setAsCurrent": "",
"forms.action.copyFromPrevious": "",
"noVods": "Nessun video trovato con questo filtro. Ci stiamo dimenticando di qualcosa? Consulta il FAQ per informazioni su come ottenere il permesso per caricare VoD",
"gainPerms": "Si prega di postare sull'helpdesk del nostro Discord per ottenere permessi per caricare VoD",
"deleteConfirm": "Cancellare video il titolo '{{title}}'?"
"deleteConfirm": "Cancellare video il titolo '{{title}}'?",
"errors.youtubePreviewFailed": ""
}

View File

@ -169,6 +169,8 @@
"forms.weaponSearch.quickSelect": "",
"forms.gearSearch.placeholder": "",
"forms.gearSearch.search.placeholder": "",
"forms.stageSearch.placeholder": "",
"forms.stageSearch.search.placeholder": "",
"tag.name.SPECIAL": "特別ルール",
"tag.name.ART": "イラスト賞品",
"tag.name.MONEY": "賞金",

View File

@ -27,7 +27,10 @@
"teamSize.4v4": "",
"forms.action.addMatch": "対戦を追加",
"forms.action.deleteMatch": "対戦を削除",
"forms.action.setAsCurrent": "",
"forms.action.copyFromPrevious": "",
"noVods": "",
"gainPerms": "動画をアップロードする権限を取得するためディスコードのヘルプデスクにメッセージを送ってください。",
"deleteConfirm": "動画:'{{title}}'を消去しますか??"
"deleteConfirm": "動画:'{{title}}'を消去しますか??",
"errors.youtubePreviewFailed": ""
}

View File

@ -169,6 +169,8 @@
"forms.weaponSearch.quickSelect": "",
"forms.gearSearch.placeholder": "",
"forms.gearSearch.search.placeholder": "",
"forms.stageSearch.placeholder": "",
"forms.stageSearch.search.placeholder": "",
"tag.name.SPECIAL": "특별 규칙",
"tag.name.ART": "그림 상품",
"tag.name.MONEY": "상금",

View File

@ -27,7 +27,10 @@
"teamSize.4v4": "",
"forms.action.addMatch": "",
"forms.action.deleteMatch": "",
"forms.action.setAsCurrent": "",
"forms.action.copyFromPrevious": "",
"noVods": "",
"gainPerms": "",
"deleteConfirm": ""
"deleteConfirm": "",
"errors.youtubePreviewFailed": ""
}

View File

@ -171,6 +171,8 @@
"forms.weaponSearch.quickSelect": "",
"forms.gearSearch.placeholder": "",
"forms.gearSearch.search.placeholder": "",
"forms.stageSearch.placeholder": "",
"forms.stageSearch.search.placeholder": "",
"tag.name.SPECIAL": "Speciale spelregels",
"tag.name.ART": "Kunst prijzen",
"tag.name.MONEY": "Geld prijzen",

View File

@ -27,7 +27,10 @@
"teamSize.4v4": "",
"forms.action.addMatch": "",
"forms.action.deleteMatch": "",
"forms.action.setAsCurrent": "",
"forms.action.copyFromPrevious": "",
"noVods": "",
"gainPerms": "",
"deleteConfirm": ""
"deleteConfirm": "",
"errors.youtubePreviewFailed": ""
}

View File

@ -172,6 +172,8 @@
"forms.weaponSearch.quickSelect": "",
"forms.gearSearch.placeholder": "",
"forms.gearSearch.search.placeholder": "",
"forms.stageSearch.placeholder": "",
"forms.stageSearch.search.placeholder": "",
"tag.name.SPECIAL": "Zasady specjalne",
"tag.name.ART": "Rysunki",
"tag.name.MONEY": "Nagrody pieniężne",

View File

@ -27,7 +27,10 @@
"teamSize.4v4": "",
"forms.action.addMatch": "",
"forms.action.deleteMatch": "",
"forms.action.setAsCurrent": "",
"forms.action.copyFromPrevious": "",
"noVods": "",
"gainPerms": "",
"deleteConfirm": ""
"deleteConfirm": "",
"errors.youtubePreviewFailed": ""
}

View File

@ -172,6 +172,8 @@
"forms.weaponSearch.quickSelect": "",
"forms.gearSearch.placeholder": "",
"forms.gearSearch.search.placeholder": "",
"forms.stageSearch.placeholder": "",
"forms.stageSearch.search.placeholder": "",
"tag.name.SPECIAL": "Regras especiais",
"tag.name.ART": "Prêmio(s) em arte",
"tag.name.MONEY": "Prêmio(s) em dinheiro",

View File

@ -27,7 +27,10 @@
"teamSize.4v4": "",
"forms.action.addMatch": "Adicionar partida",
"forms.action.deleteMatch": "Excluir partida",
"forms.action.setAsCurrent": "",
"forms.action.copyFromPrevious": "",
"noVods": "",
"gainPerms": "Por favor, faça uma postagem (em Inglês) no helpdesk do nosso Discord para obter permissões de upload de vídeos.",
"deleteConfirm": ""
"deleteConfirm": "",
"errors.youtubePreviewFailed": ""
}

View File

@ -172,6 +172,8 @@
"forms.weaponSearch.quickSelect": "",
"forms.gearSearch.placeholder": "",
"forms.gearSearch.search.placeholder": "",
"forms.stageSearch.placeholder": "",
"forms.stageSearch.search.placeholder": "",
"tag.name.SPECIAL": "Особые правила",
"tag.name.ART": "Арт-призы",
"tag.name.MONEY": "Днежные призы",

View File

@ -27,7 +27,10 @@
"teamSize.4v4": "",
"forms.action.addMatch": "Добавить матч",
"forms.action.deleteMatch": "Удалить матч",
"forms.action.setAsCurrent": "",
"forms.action.copyFromPrevious": "",
"noVods": "Видео, совпадающие с фильтрами, не найдены. Может, мы что-то упустили? Посмотрите страницу FAQ с информацией о получении прав на загрузку VoD.",
"gainPerms": "Чтобы получить возможность загружать видео, пожалуйста, обратитесь в helpdesk на нашем Discord сервере",
"deleteConfirm": "Удалить видео под названием '{{title}}'?"
"deleteConfirm": "Удалить видео под названием '{{title}}'?",
"errors.youtubePreviewFailed": ""
}

View File

@ -169,6 +169,8 @@
"forms.weaponSearch.quickSelect": "",
"forms.gearSearch.placeholder": "",
"forms.gearSearch.search.placeholder": "",
"forms.stageSearch.placeholder": "",
"forms.stageSearch.search.placeholder": "",
"tag.name.SPECIAL": "特殊规则",
"tag.name.ART": "插画奖励",
"tag.name.MONEY": "奖金",

View File

@ -27,7 +27,10 @@
"teamSize.4v4": "",
"forms.action.addMatch": "添加比赛",
"forms.action.deleteMatch": "删除比赛",
"forms.action.setAsCurrent": "",
"forms.action.copyFromPrevious": "",
"noVods": "",
"gainPerms": "请在我们的Discord服务器的helpdesk频道申请获取上传视频的资格。",
"deleteConfirm": "确认要删除 '{{title}}' 吗?"
"deleteConfirm": "确认要删除 '{{title}}' 吗?",
"errors.youtubePreviewFailed": ""
}

View File

@ -1,5 +1,5 @@
{
"include": ["./types/env.d.ts", "**/*.ts", "**/*.tsx"],
"include": ["./types/**/*.d.ts", "**/*.ts", "**/*.tsx"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "es2024"],
"module": "ESNext",

30
types/youtube.d.ts vendored Normal file
View File

@ -0,0 +1,30 @@
interface Window {
YT: typeof YT;
onYouTubeIframeAPIReady: () => void;
}
declare namespace YT {
class Player {
constructor(
elementId: HTMLElement,
options: {
height: string;
width: string;
videoId: string;
playerVars?: {
autoplay?: number;
controls?: number;
rel?: number;
modestbranding?: number;
start?: number;
};
events?: {
onReady?: () => void;
onError?: (event: { data: number }) => void;
};
},
);
loadVideoById(options: { videoId: string; startSeconds?: number }): void;
getCurrentTime(): number;
}
}