mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-24 23:19:39 -05:00
New VoD form UI improvements (#2667)
This commit is contained in:
parent
457f747f7a
commit
85f246ef06
20
app/components/StageSelect.module.css
Normal file
20
app/components/StageSelect.module.css
Normal 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;
|
||||
}
|
||||
88
app/components/StageSelect.tsx
Normal file
88
app/components/StageSelect.tsx
Normal 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}`),
|
||||
}));
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
24
app/features/vods/routes/vods.new.module.css
Normal file
24
app/features/vods/routes/vods.new.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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")}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -805,6 +805,10 @@ abbr[title] {
|
|||
padding-bottom: 56.25%;
|
||||
}
|
||||
|
||||
.youtube__container--api {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.youtube__iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "פרסים בכסף",
|
||||
|
|
|
|||
|
|
@ -27,7 +27,10 @@
|
|||
"teamSize.4v4": "",
|
||||
"forms.action.addMatch": "הוספת קרב",
|
||||
"forms.action.deleteMatch": "מחיקת קרב",
|
||||
"forms.action.setAsCurrent": "",
|
||||
"forms.action.copyFromPrevious": "",
|
||||
"noVods": "",
|
||||
"gainPerms": "",
|
||||
"deleteConfirm": ""
|
||||
"deleteConfirm": "",
|
||||
"errors.youtubePreviewFailed": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "賞金",
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "상금",
|
||||
|
|
|
|||
|
|
@ -27,7 +27,10 @@
|
|||
"teamSize.4v4": "",
|
||||
"forms.action.addMatch": "",
|
||||
"forms.action.deleteMatch": "",
|
||||
"forms.action.setAsCurrent": "",
|
||||
"forms.action.copyFromPrevious": "",
|
||||
"noVods": "",
|
||||
"gainPerms": "",
|
||||
"deleteConfirm": ""
|
||||
"deleteConfirm": "",
|
||||
"errors.youtubePreviewFailed": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -27,7 +27,10 @@
|
|||
"teamSize.4v4": "",
|
||||
"forms.action.addMatch": "",
|
||||
"forms.action.deleteMatch": "",
|
||||
"forms.action.setAsCurrent": "",
|
||||
"forms.action.copyFromPrevious": "",
|
||||
"noVods": "",
|
||||
"gainPerms": "",
|
||||
"deleteConfirm": ""
|
||||
"deleteConfirm": "",
|
||||
"errors.youtubePreviewFailed": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -27,7 +27,10 @@
|
|||
"teamSize.4v4": "",
|
||||
"forms.action.addMatch": "",
|
||||
"forms.action.deleteMatch": "",
|
||||
"forms.action.setAsCurrent": "",
|
||||
"forms.action.copyFromPrevious": "",
|
||||
"noVods": "",
|
||||
"gainPerms": "",
|
||||
"deleteConfirm": ""
|
||||
"deleteConfirm": "",
|
||||
"errors.youtubePreviewFailed": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "Днежные призы",
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "奖金",
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
30
types/youtube.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user