VoD YouTube timestamps generator (#2912)

This commit is contained in:
Kalle 2026-03-24 21:26:34 +02:00 committed by GitHub
parent 0fae97a189
commit 48152e1185
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 444 additions and 23 deletions

View File

@ -9,6 +9,7 @@ import {
} from "~/components/elements/ChipRadio";
import { ModeImage, StageImage } from "~/components/Image";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { shortStageName } from "~/modules/in-game-lists/stage-ids";
import type { RankedModeShort, StageId } from "~/modules/in-game-lists/types";
import {
databaseTimestampNow,
@ -264,8 +265,8 @@ function RotationCard({
/>
) : null}
<ModeImage mode={shownNext.mode as RankedModeShort} width={16} />{" "}
{t(`game-misc:STAGE_${shownNext.stageId1}` as any).split(" ")[0]},{" "}
{t(`game-misc:STAGE_${shownNext.stageId2}` as any).split(" ")[0]}
{shortStageName(t(`game-misc:STAGE_${shownNext.stageId1}` as any))},{" "}
{shortStageName(t(`game-misc:STAGE_${shownNext.stageId2}` as any))}
</div>
) : null}
</div>

View File

@ -4,7 +4,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { Divider } from "~/components/Divider";
import { ModeImage } from "~/components/Image";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import { shortStageName, stageIds } from "~/modules/in-game-lists/stage-ids";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { nullFilledArray } from "~/utils/arrays";
import { stageImageUrl } from "~/utils/urls";
@ -166,7 +166,7 @@ function MapButton({
<div className={clsx(styles.mapButtonText, "text-error")}>Banned</div>
) : null}
<div className={styles.mapButtonLabel}>
{t(`game-misc:STAGE_${stageId}`).split(" ")[0]}
{shortStageName(t(`game-misc:STAGE_${stageId}`))}
</div>
</div>
);

View File

@ -10,6 +10,7 @@ import type { TournamentRoundMaps } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import { useTournament } from "~/features/tournament/routes/to.$id";
import { modesShort } from "~/modules/in-game-lists/modes";
import { shortStageName } from "~/modules/in-game-lists/stage-ids";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import invariant from "~/utils/invariant";
import { stageImageUrl } from "~/utils/urls";
@ -232,7 +233,7 @@ function MapButton({
</span>
) : null}
<div className={styles.mapButtonLabel}>
{t(`game-misc:STAGE_${stageId}`).split(" ")[0]}
{shortStageName(t(`game-misc:STAGE_${stageId}`))}
</div>
</div>
);

View File

@ -1,10 +1,11 @@
import clsx from "clsx";
import { SquarePen, Trash } from "lucide-react";
import { Check, ClipboardCopy, Copy, SquarePen, Trash } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { useLoaderData } from "react-router";
import { LinkButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Image, WeaponImage } from "~/components/Image";
import { Main } from "~/components/Main";
@ -13,6 +14,7 @@ import { useUser } from "~/features/auth/core/user";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { shortStageName } from "~/modules/in-game-lists/stage-ids";
import { databaseTimestampToDate } from "~/utils/dates";
import { metaTags, type SerializeFrom } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
@ -30,12 +32,17 @@ import { action } from "../actions/vods.$id.server";
import { PovUser } from "../components/VodPov";
import { loader } from "../loaders/vods.$id.server";
import type { Vod } from "../vods-types";
import { canEditVideo, secondsToHoursMinutesSecondString } from "../vods-utils";
import {
canEditVideo,
generateYoutubeTimestamps,
secondsToHoursMinutesSecondString,
} from "../vods-utils";
import styles from "./vods.$id.module.css";
export { action, loader };
export const handle: SendouRouteHandle = {
i18n: ["vods"],
breadcrumb: ({ match }) => {
const data = match.data as SerializeFrom<typeof loader> | undefined;
@ -115,6 +122,12 @@ export default function VodPage() {
typeof data.vod.pov === "string" ? undefined : data.vod.pov?.id,
}) ? (
<div className="stack horizontal md">
{user?.id === data.vod.submitterUserId ? (
<CopyTimestampsButton
matches={data.vod.matches}
type={data.vod.type}
/>
) : null}
<LinkButton
to={newVodPage(data.vod.id)}
size="small"
@ -237,3 +250,122 @@ function Match({
</div>
);
}
function CopyTimestampsButton({
matches,
type,
}: {
matches: Vod["matches"];
type: Vod["type"];
}) {
const { t } = useTranslation(["vods", "weapons", "game-misc", "common"]);
const [dialogOpen, setDialogOpen] = React.useState(false);
const [copied, setCopied] = React.useState(false);
const [copyTrigger, setCopyTrigger] = React.useState(0);
const [modeFormat, setModeFormat] = React.useState<"short" | "long">("short");
const [stageFormat, setStageFormat] = React.useState<"short" | "long">(
"long",
);
const timestamps = generateYoutubeTimestamps(matches, type, {
weaponName: (id) => t(`weapons:MAIN_${id}` as "weapons:MAIN_0"),
stageName: (id) => {
const fullName = t(`game-misc:STAGE_${id}` as "game-misc:STAGE_0");
return stageFormat === "short" ? shortStageName(fullName) : fullName;
},
modeName: (mode) =>
modeFormat === "long"
? t(`game-misc:MODE_LONG_${mode}` as "game-misc:MODE_LONG_SZ")
: mode,
});
React.useEffect(() => {
if (copyTrigger === 0) return;
setCopied(true);
const timeout = setTimeout(() => setCopied(false), 2000);
return () => clearTimeout(timeout);
}, [copyTrigger]);
const handleCopy = () => {
navigator.clipboard.writeText(timestamps);
setCopyTrigger((prev) => prev + 1);
};
return (
<>
<SendouButton
size="small"
variant="outlined"
icon={<ClipboardCopy />}
onPress={() => {
setDialogOpen(true);
setCopied(false);
}}
data-testid="copy-timestamps-button"
>
{t("vods:copyTimestamps")}
</SendouButton>
<SendouDialog
isOpen={dialogOpen}
onClose={() => setDialogOpen(false)}
heading={t("vods:copyTimestamps")}
>
<div className="stack md">
<div className="stack horizontal md w-full">
<label className="flex-same-size">
{t("vods:copyTimestamps.modeFormat")}
<select
value={modeFormat}
onChange={(e) =>
setModeFormat(e.target.value as "short" | "long")
}
>
<option value="short">
{t("vods:copyTimestamps.format.short")}
</option>
<option value="long">
{t("vods:copyTimestamps.format.long")}
</option>
</select>
</label>
<label className="flex-same-size">
{t("vods:copyTimestamps.stageFormat")}
<select
value={stageFormat}
onChange={(e) =>
setStageFormat(e.target.value as "short" | "long")
}
>
<option value="short">
{t("vods:copyTimestamps.format.short")}
</option>
<option value="long">
{t("vods:copyTimestamps.format.long")}
</option>
</select>
</label>
</div>
<textarea
readOnly
value={timestamps}
rows={Math.min(matches.length + 2, 15)}
className="w-full"
/>
<p className="text-lighter text-xs">
{t("vods:copyTimestamps.help")}
</p>
<SendouButton
onPress={handleCopy}
icon={copied ? <Check /> : <Copy />}
>
{copied
? t("common:actions.copied")
: t("common:actions.copyToClipboard")}
</SendouButton>
</div>
</SendouDialog>
</>
);
}

View File

@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest";
import type {
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import {
extractYoutubeIdFromVideoUrl,
generateYoutubeTimestamps,
hoursMinutesSecondsStringToSeconds,
secondsToHoursMinutesSecondString,
} from "./vods-utils";
@ -66,6 +72,129 @@ describe("secondsToHoursMinutesSecondString", () => {
});
});
function makeMatch(overrides: {
startsAt: number;
mode: ModeShort;
stageId: StageId;
weapons: MainWeaponId[];
}) {
return { id: 1, ...overrides };
}
const WEAPON_NAMES: Record<number, string> = {
40: "Splattershot",
200: "Luna Blaster",
6010: "Tenta Brella",
7010: "Tri-Stringer",
};
const STAGE_NAMES: Record<number, string> = {
0: "Scorch Gorge",
2: "Hagglefish Market",
7: "Mahi-Mahi Resort",
10: "MakoMart",
};
const MODE_LONG_NAMES: Record<string, string> = {
SZ: "Splat Zones",
TC: "Tower Control",
RM: "Rainmaker",
CB: "Clam Blitz",
};
const RESOLVERS = {
weaponName: (id: number) => WEAPON_NAMES[id] ?? String(id),
stageName: (id: number) => STAGE_NAMES[id] ?? String(id),
modeName: (mode: string) => mode,
};
const LONG_MODE_RESOLVERS = {
...RESOLVERS,
modeName: (mode: string) => MODE_LONG_NAMES[mode] ?? mode,
};
describe("generateYoutubeTimestamps", () => {
it("should include intro line when first match starts after 0", () => {
const matches = [
makeMatch({
startsAt: 521,
mode: "SZ",
stageId: 7,
weapons: [40 as MainWeaponId],
}),
makeMatch({
startsAt: 759,
mode: "TC",
stageId: 2,
weapons: [7010 as MainWeaponId],
}),
];
const result = generateYoutubeTimestamps(matches, "TOURNAMENT", RESOLVERS);
expect(result).toBe(
"0:00 Intro\n8:41 Splattershot / SZ Mahi-Mahi Resort\n12:39 Tri-Stringer / TC Hagglefish Market",
);
});
it("should not include intro line when first match starts at 0", () => {
const matches = [
makeMatch({
startsAt: 0,
mode: "RM",
stageId: 0,
weapons: [40 as MainWeaponId],
}),
];
const result = generateYoutubeTimestamps(matches, "SCRIM", RESOLVERS);
expect(result).toBe("0:00 Splattershot / RM Scorch Gorge");
});
it("should not include weapon for CAST type", () => {
const matches = [
makeMatch({
startsAt: 25,
mode: "CB",
stageId: 10,
weapons: [200 as MainWeaponId, 6010 as MainWeaponId],
}),
];
const result = generateYoutubeTimestamps(matches, "CAST", RESOLVERS);
expect(result).toBe("0:00 Intro\n0:25 CB MakoMart");
});
it("should use long mode names when resolver returns them", () => {
const matches = [
makeMatch({
startsAt: 521,
mode: "SZ",
stageId: 7,
weapons: [40 as MainWeaponId],
}),
makeMatch({
startsAt: 759,
mode: "RM",
stageId: 2,
weapons: [7010 as MainWeaponId],
}),
];
const result = generateYoutubeTimestamps(
matches,
"TOURNAMENT",
LONG_MODE_RESOLVERS,
);
expect(result).toBe(
"0:00 Intro\n8:41 Splattershot / Splat Zones Mahi-Mahi Resort\n12:39 Tri-Stringer / Rainmaker Hagglefish Market",
);
});
});
describe("hoursMinutesSecondsStringToSeconds", () => {
it("should convert HH:MM:SS format to seconds", () => {
const result = hoursMinutesSecondsStringToSeconds("1:01:01");

View File

@ -95,3 +95,36 @@ export function hoursMinutesSecondsStringToSeconds(
export function youtubeIdToYoutubeUrl(youtubeId: string) {
return `https://www.youtube.com/watch?v=${youtubeId}`;
}
export function generateYoutubeTimestamps(
matches: Vod["matches"],
type: Vod["type"],
resolvers: {
weaponName: (weaponId: number) => string;
stageName: (stageId: number) => string;
modeName: (mode: string) => string;
},
) {
const lines: string[] = [];
if (matches.length > 0 && matches[0].startsAt > 0) {
lines.push("0:00 Intro");
}
const isCast = type === "CAST";
for (const match of matches) {
const timestamp = secondsToHoursMinutesSecondString(match.startsAt);
const stage = resolvers.stageName(match.stageId);
const mode = resolvers.modeName(match.mode);
const weaponPart =
!isCast && match.weapons.length === 1
? `${resolvers.weaponName(match.weapons[0])} / `
: "";
lines.push(`${timestamp} ${weaponPart}${mode} ${stage}`);
}
return lines.join("\n");
}

View File

@ -54,3 +54,7 @@ export const stagesObj = {
LEMURIA_HUB: 23,
URCHIN_UNDERPASS: 24,
} as const;
export function shortStageName(fullName: string) {
return fullName.split(" ")[0];
}

View File

@ -71,6 +71,15 @@ test.describe("VoDs page", () => {
await page.getByText(formattedDate).isVisible();
await page.getByTestId("weapon-img-4001").isVisible();
await page.getByTestId("weapon-img-6010").isVisible();
await page.getByTestId("copy-timestamps-button").click();
await page.getByText("0:00 Intro").isVisible();
await page
.getByText("0:20 Zink Mini Splatling / TC Hammerhead Bridge")
.isVisible();
await page
.getByText("5:55 Tenta Brella / RM Museum d'Alfonsino")
.isVisible();
});
test("adds video (cast)", async ({ page }) => {

View File

@ -118,6 +118,7 @@
"actions.showMore": "",
"actions.showLess": "",
"actions.copyToClipboard": "Kopiér til udklipsholder",
"actions.copied": "",
"actions.copyTimestampForDiscord": "",
"actions.create": "Skab",
"actions.close": "Luk",

View File

@ -32,5 +32,11 @@
"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}}'?",
"errors.youtubePreviewFailed": ""
"errors.youtubePreviewFailed": "",
"copyTimestamps": "",
"copyTimestamps.help": "",
"copyTimestamps.modeFormat": "",
"copyTimestamps.stageFormat": "",
"copyTimestamps.format.short": "",
"copyTimestamps.format.long": ""
}

View File

@ -118,6 +118,7 @@
"actions.showMore": "",
"actions.showLess": "",
"actions.copyToClipboard": "In die Zwischenablage kopieren",
"actions.copied": "",
"actions.copyTimestampForDiscord": "Discord-Zeitstempel kopieren",
"actions.create": "Erstellen",
"actions.close": "Schließen",

View File

@ -32,5 +32,11 @@
"noVods": "",
"gainPerms": "",
"deleteConfirm": "",
"errors.youtubePreviewFailed": ""
"errors.youtubePreviewFailed": "",
"copyTimestamps": "",
"copyTimestamps.help": "",
"copyTimestamps.modeFormat": "",
"copyTimestamps.stageFormat": "",
"copyTimestamps.format.short": "",
"copyTimestamps.format.long": ""
}

View File

@ -118,6 +118,7 @@
"actions.showMore": "Show more",
"actions.showLess": "Show less",
"actions.copyToClipboard": "Copy to clipboard",
"actions.copied": "Copied!",
"actions.copyTimestampForDiscord": "Copy Discord timestamp",
"actions.create": "Create",
"actions.close": "Close",

View File

@ -32,5 +32,11 @@
"noVods": "No videos found matching this filter. Are we missing something? See the FAQ page for information on 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}}'?",
"errors.youtubePreviewFailed": "Failed to load YouTube preview"
"errors.youtubePreviewFailed": "Failed to load YouTube preview",
"copyTimestamps": "Copy timestamps",
"copyTimestamps.help": "Paste these into your YouTube video description to create chapter markers.",
"copyTimestamps.modeFormat": "Mode format",
"copyTimestamps.stageFormat": "Stage format",
"copyTimestamps.format.short": "Short",
"copyTimestamps.format.long": "Long"
}

View File

@ -118,6 +118,7 @@
"actions.showMore": "Mostrar más",
"actions.showLess": "Mostrar menos",
"actions.copyToClipboard": "Copiar al portapapeles",
"actions.copied": "",
"actions.copyTimestampForDiscord": "Copiar timestamp para Discord",
"actions.create": "Crear",
"actions.close": "Cerrar",

View File

@ -32,5 +32,11 @@
"noVods": "No se han encontrado vídeos que coincidan con este filtro. ¿Nos falta alguno? Consulta la página de Preguntas frecuentes para saber cómo obtener permisos para subir vídeos (VoD).",
"gainPerms": "Por favor envía un mensaje en el 'helpdesk' de nuestro Discord para obtener permiso para subir videos.",
"deleteConfirm": "¿Borrar el vídeo con el título '{{title}}'?",
"errors.youtubePreviewFailed": "Error al cargar la vista previa de YouTube"
"errors.youtubePreviewFailed": "Error al cargar la vista previa de YouTube",
"copyTimestamps": "",
"copyTimestamps.help": "",
"copyTimestamps.modeFormat": "",
"copyTimestamps.stageFormat": "",
"copyTimestamps.format.short": "",
"copyTimestamps.format.long": ""
}

View File

@ -118,6 +118,7 @@
"actions.showMore": "Mostrar más",
"actions.showLess": "Mostrar menos",
"actions.copyToClipboard": "Copiar a clipboard",
"actions.copied": "",
"actions.copyTimestampForDiscord": "",
"actions.create": "Crear",
"actions.close": "Cerrar",

View File

@ -32,5 +32,11 @@
"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}}'?",
"errors.youtubePreviewFailed": ""
"errors.youtubePreviewFailed": "",
"copyTimestamps": "",
"copyTimestamps.help": "",
"copyTimestamps.modeFormat": "",
"copyTimestamps.stageFormat": "",
"copyTimestamps.format.short": "",
"copyTimestamps.format.long": ""
}

View File

@ -118,6 +118,7 @@
"actions.showMore": "",
"actions.showLess": "",
"actions.copyToClipboard": "Copier dans le presse-papier",
"actions.copied": "",
"actions.copyTimestampForDiscord": "",
"actions.create": "Créer",
"actions.close": "Fermer",

View File

@ -32,5 +32,11 @@
"noVods": "",
"gainPerms": "",
"deleteConfirm": "",
"errors.youtubePreviewFailed": ""
"errors.youtubePreviewFailed": "",
"copyTimestamps": "",
"copyTimestamps.help": "",
"copyTimestamps.modeFormat": "",
"copyTimestamps.stageFormat": "",
"copyTimestamps.format.short": "",
"copyTimestamps.format.long": ""
}

View File

@ -118,6 +118,7 @@
"actions.showMore": "Montrer more",
"actions.showLess": "Montrer moins",
"actions.copyToClipboard": "Copier dans le presse-papier",
"actions.copied": "",
"actions.copyTimestampForDiscord": "",
"actions.create": "Créer",
"actions.close": "Fermer",

View File

@ -32,5 +32,11 @@
"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}}'?",
"errors.youtubePreviewFailed": ""
"errors.youtubePreviewFailed": "",
"copyTimestamps": "",
"copyTimestamps.help": "",
"copyTimestamps.modeFormat": "",
"copyTimestamps.stageFormat": "",
"copyTimestamps.format.short": "",
"copyTimestamps.format.long": ""
}

View File

@ -118,6 +118,7 @@
"actions.showMore": "",
"actions.showLess": "",
"actions.copyToClipboard": "ההעתקה ללוח",
"actions.copied": "",
"actions.copyTimestampForDiscord": "",
"actions.create": "יצירה",
"actions.close": "סגירה",

View File

@ -32,5 +32,11 @@
"noVods": "",
"gainPerms": "",
"deleteConfirm": "",
"errors.youtubePreviewFailed": ""
"errors.youtubePreviewFailed": "",
"copyTimestamps": "",
"copyTimestamps.help": "",
"copyTimestamps.modeFormat": "",
"copyTimestamps.stageFormat": "",
"copyTimestamps.format.short": "",
"copyTimestamps.format.long": ""
}

View File

@ -118,6 +118,7 @@
"actions.showMore": "Mostra di più",
"actions.showLess": "Mostra di meno",
"actions.copyToClipboard": "Copia",
"actions.copied": "",
"actions.copyTimestampForDiscord": "",
"actions.create": "Crea",
"actions.close": "Chiudi",

View File

@ -32,5 +32,11 @@
"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}}'?",
"errors.youtubePreviewFailed": ""
"errors.youtubePreviewFailed": "",
"copyTimestamps": "",
"copyTimestamps.help": "",
"copyTimestamps.modeFormat": "",
"copyTimestamps.stageFormat": "",
"copyTimestamps.format.short": "",
"copyTimestamps.format.long": ""
}

View File

@ -118,6 +118,7 @@
"actions.showMore": "もっと見せる",
"actions.showLess": "表示を減らす",
"actions.copyToClipboard": "クリップボードにコピーする",
"actions.copied": "",
"actions.copyTimestampForDiscord": "",
"actions.create": "作成",
"actions.close": "閉じる",

View File

@ -32,5 +32,11 @@
"noVods": "",
"gainPerms": "動画をアップロードする権限を取得するためディスコードのヘルプデスクにメッセージを送ってください。",
"deleteConfirm": "動画:'{{title}}'を消去しますか??",
"errors.youtubePreviewFailed": ""
"errors.youtubePreviewFailed": "",
"copyTimestamps": "",
"copyTimestamps.help": "",
"copyTimestamps.modeFormat": "",
"copyTimestamps.stageFormat": "",
"copyTimestamps.format.short": "",
"copyTimestamps.format.long": ""
}

View File

@ -118,6 +118,7 @@
"actions.showMore": "",
"actions.showLess": "",
"actions.copyToClipboard": "클립보드로 복사",
"actions.copied": "",
"actions.copyTimestampForDiscord": "",
"actions.create": "생성",
"actions.close": "닫기",

View File

@ -32,5 +32,11 @@
"noVods": "",
"gainPerms": "",
"deleteConfirm": "",
"errors.youtubePreviewFailed": ""
"errors.youtubePreviewFailed": "",
"copyTimestamps": "",
"copyTimestamps.help": "",
"copyTimestamps.modeFormat": "",
"copyTimestamps.stageFormat": "",
"copyTimestamps.format.short": "",
"copyTimestamps.format.long": ""
}

View File

@ -118,6 +118,7 @@
"actions.showMore": "",
"actions.showLess": "",
"actions.copyToClipboard": "Kopiëren naar klembord",
"actions.copied": "",
"actions.copyTimestampForDiscord": "",
"actions.create": "",
"actions.close": "",

View File

@ -32,5 +32,11 @@
"noVods": "",
"gainPerms": "",
"deleteConfirm": "",
"errors.youtubePreviewFailed": ""
"errors.youtubePreviewFailed": "",
"copyTimestamps": "",
"copyTimestamps.help": "",
"copyTimestamps.modeFormat": "",
"copyTimestamps.stageFormat": "",
"copyTimestamps.format.short": "",
"copyTimestamps.format.long": ""
}

View File

@ -118,6 +118,7 @@
"actions.showMore": "",
"actions.showLess": "",
"actions.copyToClipboard": "Skopiuj do schowka",
"actions.copied": "",
"actions.copyTimestampForDiscord": "",
"actions.create": "Stwórz",
"actions.close": "Zamknij",

View File

@ -32,5 +32,11 @@
"noVods": "",
"gainPerms": "",
"deleteConfirm": "",
"errors.youtubePreviewFailed": ""
"errors.youtubePreviewFailed": "",
"copyTimestamps": "",
"copyTimestamps.help": "",
"copyTimestamps.modeFormat": "",
"copyTimestamps.stageFormat": "",
"copyTimestamps.format.short": "",
"copyTimestamps.format.long": ""
}

View File

@ -118,6 +118,7 @@
"actions.showMore": "Mostrar mais",
"actions.showLess": "Mostrar menos",
"actions.copyToClipboard": "Copiar para a área de transferência",
"actions.copied": "",
"actions.copyTimestampForDiscord": "",
"actions.create": "Criar",
"actions.close": "Fechar",

View File

@ -32,5 +32,11 @@
"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": "",
"errors.youtubePreviewFailed": ""
"errors.youtubePreviewFailed": "",
"copyTimestamps": "",
"copyTimestamps.help": "",
"copyTimestamps.modeFormat": "",
"copyTimestamps.stageFormat": "",
"copyTimestamps.format.short": "",
"copyTimestamps.format.long": ""
}

View File

@ -118,6 +118,7 @@
"actions.showMore": "Показать больше",
"actions.showLess": "Показать меньше",
"actions.copyToClipboard": "Скопировать в буфер обмена",
"actions.copied": "",
"actions.copyTimestampForDiscord": "",
"actions.create": "Создать",
"actions.close": "Закрыть",

View File

@ -32,5 +32,11 @@
"noVods": "Видео, совпадающие с фильтрами, не найдены. Может, мы что-то упустили? Посмотрите страницу FAQ с информацией о получении прав на загрузку VoD.",
"gainPerms": "Чтобы получить возможность загружать видео, пожалуйста, обратитесь в helpdesk на нашем Discord сервере",
"deleteConfirm": "Удалить видео под названием '{{title}}'?",
"errors.youtubePreviewFailed": ""
"errors.youtubePreviewFailed": "",
"copyTimestamps": "",
"copyTimestamps.help": "",
"copyTimestamps.modeFormat": "",
"copyTimestamps.stageFormat": "",
"copyTimestamps.format.short": "",
"copyTimestamps.format.long": ""
}

View File

@ -118,6 +118,7 @@
"actions.showMore": "显示更多",
"actions.showLess": "显示更少",
"actions.copyToClipboard": "复制到剪贴板",
"actions.copied": "",
"actions.copyTimestampForDiscord": "",
"actions.create": "创建",
"actions.close": "关闭",

View File

@ -32,5 +32,11 @@
"noVods": "",
"gainPerms": "请在我们的Discord服务器的helpdesk频道申请获取上传视频的资格。",
"deleteConfirm": "确认要删除 '{{title}}' 吗?",
"errors.youtubePreviewFailed": ""
"errors.youtubePreviewFailed": "",
"copyTimestamps": "",
"copyTimestamps.help": "",
"copyTimestamps.modeFormat": "",
"copyTimestamps.stageFormat": "",
"copyTimestamps.format.short": "",
"copyTimestamps.format.long": ""
}