Art feature improvements (#2564)

This commit is contained in:
Kalle 2025-10-11 11:46:22 +03:00 committed by GitHub
parent b3de79cee8
commit 0ddc73666d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
114 changed files with 647 additions and 181 deletions

View File

@ -2,6 +2,7 @@
- only rarely use comments, prefer descriptive variable and function names (leave existing comments as is)
- if you encounter an existing TODO comment assume it is there for a reason and do not remove it
- task is not considered completely until `npm run checks` passes
## Commands
@ -19,6 +20,7 @@
- for constants use ALL_CAPS
- always use named exports
- Remeda is the utility library of choice
- date-fns should be used for date related logic
## React
@ -46,6 +48,10 @@
- database code should only be written in Repository files
- down migrations are not needed, only up migrations
- every database id is of type number
- `/app/db/tables.ts` contains all tables and columns available
- `db.sqlite3` is development database
- `db-test.sqlite3` is the unit test database (should be blank sans migrations ran)
- `db-prod.sqlite3` is a copy of the production environment db
## E2E testing

View File

@ -825,6 +825,7 @@ export interface User {
bannedReason: string | null;
bio: string | null;
commissionsOpen: Generated<number | null>;
commissionsOpenedAt: number | null;
commissionText: string | null;
country: string | null;
css: JSONColumnTypeNullable<Record<string, string>>;

View File

@ -1,4 +1,7 @@
import { db } from "~/db/sql";
import type { Tables } from "~/db/tables";
import { seededRandom } from "~/utils/random";
import type { ListedArt } from "./art-types";
export function unlinkUserFromArt({
userId,
@ -13,3 +16,123 @@ export function unlinkUserFromArt({
.where("userId", "=", userId)
.execute();
}
function getDailySeed() {
const today = new Date();
const year = today.getFullYear();
const month = today.getMonth() + 1;
const day = today.getDate();
return `${year}-${month}-${day}`;
}
export async function findShowcaseArts(): Promise<ListedArt[]> {
const arts = await db
.selectFrom("Art")
.innerJoin("User", "User.id", "Art.authorId")
.innerJoin("UserSubmittedImage", "UserSubmittedImage.id", "Art.imgId")
.select([
"Art.id",
"Art.createdAt",
"User.discordId",
"User.username",
"User.discordAvatar",
"User.commissionsOpen",
"UserSubmittedImage.url",
])
.where("Art.isShowcase", "=", 1)
.execute();
const mappedArts = arts.map((a) => ({
id: a.id,
createdAt: a.createdAt,
url: a.url,
author: {
commissionsOpen: a.commissionsOpen,
discordAvatar: a.discordAvatar,
discordId: a.discordId,
username: a.username,
},
}));
const { seededShuffle } = seededRandom(getDailySeed());
return seededShuffle(mappedArts);
}
export async function findShowcaseArtsByTag(
tagId: Tables["ArtTag"]["id"],
): Promise<ListedArt[]> {
const arts = await db
.selectFrom("TaggedArt")
.innerJoin("Art", "Art.id", "TaggedArt.artId")
.innerJoin("User", "User.id", "Art.authorId")
.innerJoin("UserSubmittedImage", "UserSubmittedImage.id", "Art.imgId")
.select([
"Art.id",
"Art.createdAt",
"User.id as userId",
"User.discordId",
"User.username",
"User.discordAvatar",
"User.commissionsOpen",
"UserSubmittedImage.url",
])
.where("TaggedArt.tagId", "=", tagId)
.orderBy("Art.isShowcase", "desc")
.orderBy("Art.createdAt", "desc")
.execute();
const encounteredUserIds = new Set<number>();
return arts
.filter((row) => {
if (encounteredUserIds.has(row.userId)) {
return false;
}
encounteredUserIds.add(row.userId);
return true;
})
.map((a) => ({
id: a.id,
createdAt: a.createdAt,
url: a.url,
author: {
commissionsOpen: a.commissionsOpen,
discordAvatar: a.discordAvatar,
discordId: a.discordId,
username: a.username,
},
}));
}
export async function findRecentlyUploadedArts(): Promise<ListedArt[]> {
const arts = await db
.selectFrom("Art")
.innerJoin("User", "User.id", "Art.authorId")
.innerJoin("UserSubmittedImage", "UserSubmittedImage.id", "Art.imgId")
.select([
"Art.id",
"Art.createdAt",
"User.discordId",
"User.username",
"User.discordAvatar",
"User.commissionsOpen",
"UserSubmittedImage.url",
])
.orderBy("Art.createdAt", "desc")
.limit(100)
.execute();
return arts.map((a) => ({
id: a.id,
createdAt: a.createdAt,
url: a.url,
author: {
commissionsOpen: a.commissionsOpen,
discordAvatar: a.discordAvatar,
discordId: a.discordId,
username: a.username,
},
}));
}

View File

@ -1,5 +1,6 @@
import { Link } from "@remix-run/react";
import clsx from "clsx";
import { formatDistanceToNow } from "date-fns";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
@ -26,10 +27,12 @@ export function ArtGrid({
arts,
enablePreview = false,
canEdit = false,
showUploadDate = false,
}: {
arts: ListedArt[];
enablePreview?: boolean;
canEdit?: boolean;
showUploadDate?: boolean;
}) {
const {
itemsToDisplay,
@ -67,18 +70,21 @@ export function ArtGrid({
art={art}
canEdit={canEdit}
enablePreview={enablePreview}
showUploadDate={showUploadDate}
onClick={enablePreview ? () => setBigArtId(art.id) : undefined}
/>
))}
</ResponsiveMasonry>
{!everythingVisible ? (
<Pagination
currentPage={currentPage}
pagesCount={pagesCount}
nextPage={nextPage}
previousPage={previousPage}
setPage={setPage}
/>
<div className="mt-6">
<Pagination
currentPage={currentPage}
pagesCount={pagesCount}
nextPage={nextPage}
previousPage={previousPage}
setPage={setPage}
/>
</div>
) : null}
</>
);
@ -154,11 +160,13 @@ function ImagePreview({
onClick,
enablePreview = false,
canEdit = false,
showUploadDate = false,
}: {
art: ListedArt;
onClick?: () => void;
enablePreview?: boolean;
canEdit?: boolean;
showUploadDate?: boolean;
}) {
const [imageLoaded, setImageLoaded] = React.useState(false);
const { t } = useTranslation(["common", "art"]);
@ -211,6 +219,12 @@ function ImagePreview({
}
if (!art.author) return img;
const uploadDateText = showUploadDate
? formatDistanceToNow(databaseTimestampToDate(art.createdAt), {
addSuffix: true,
})
: null;
// whole thing is not a link so we can preview the image
if (enablePreview) {
return (
@ -230,6 +244,15 @@ function ImagePreview({
<Avatar user={art.author} size="xxs" />
{t("art:madeBy")} {art.author.username}
</Link>
{uploadDateText ? (
<div
className={clsx("text-xs text-lighter", {
invisible: !imageLoaded,
})}
>
{uploadDateText}
</div>
) : null}
{canEdit ? (
<FormWithConfirm
dialogHeading={t("art:unlink.title", {
@ -256,13 +279,24 @@ function ImagePreview({
return (
<Link to={userArtPage(art.author, "MADE-BY")}>
{img}
<div
className={clsx("stack sm horizontal text-xs items-center mt-1", {
invisible: !imageLoaded,
})}
>
<Avatar user={art.author} size="xxs" />
{art.author.username}
<div className="stack horizontal justify-between">
<div
className={clsx("stack sm horizontal text-xs items-center mt-1", {
invisible: !imageLoaded,
})}
>
<Avatar user={art.author} size="xxs" />
{art.author.username}
</div>
{uploadDateText ? (
<div
className={clsx("text-xxs mt-1 text-lighter", {
invisible: !imageLoaded,
})}
>
{uploadDateText}
</div>
) : null}
</div>
</Link>
);

View File

@ -1,10 +1,7 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import * as ArtRepository from "../ArtRepository.server";
import { FILTERED_TAG_KEY_SEARCH_PARAM_KEY } from "../art-constants";
import { allArtTags } from "../queries/allArtTags.server";
import {
showcaseArts,
showcaseArtsByTag,
} from "../queries/showcaseArts.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const allTags = allArtTags();
@ -15,7 +12,10 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const filteredTag = allTags.find((t) => t.name === filteredTagName);
return {
arts: filteredTag ? showcaseArtsByTag(filteredTag.id) : showcaseArts(),
showcaseArts: filteredTag
? await ArtRepository.findShowcaseArtsByTag(filteredTag.id)
: await ArtRepository.findShowcaseArts(),
recentlyUploadedArts: await ArtRepository.findRecentlyUploadedArts(),
allTags,
};
};

View File

@ -1,82 +0,0 @@
import { sql } from "~/db/sql";
import type { Tables } from "~/db/tables";
import type { ListedArt } from "../art-types";
const showcaseArtsStm = sql.prepare(/* sql */ `
select
"Art"."id",
"User"."id" as "userId",
"User"."discordId",
"User"."username",
"User"."discordAvatar",
"User"."commissionsOpen",
"UserSubmittedImage"."url"
from
"Art"
left join "User" on "User"."id" = "Art"."authorId"
inner join "UserSubmittedImage" on "UserSubmittedImage"."id" = "Art"."imgId"
where
"Art"."isShowcase" = 1
order by random()
`);
export function showcaseArts(): ListedArt[] {
return showcaseArtsStm.all().map((a: any) => ({
id: a.id,
createdAt: a.createdAt,
url: a.url,
author: {
commissionsOpen: a.commissionsOpen,
discordAvatar: a.discordAvatar,
discordId: a.discordId,
username: a.username,
},
}));
}
const showcaseArtsByTagStm = sql.prepare(/* sql */ `
select
"Art"."id",
"User"."id" as "userId",
"User"."discordId",
"User"."username",
"User"."discordAvatar",
"User"."commissionsOpen",
"UserSubmittedImage"."url"
from
"TaggedArt"
inner join "Art" on "Art"."id" = "TaggedArt"."artId"
left join "User" on "User"."id" = "Art"."authorId"
inner join "UserSubmittedImage" on "UserSubmittedImage"."id" = "Art"."imgId"
where
"TaggedArt"."tagId" = @tagId
order by
"Art"."isShowcase" desc, random()
`);
export function showcaseArtsByTag(tagId: Tables["ArtTag"]["id"]): ListedArt[] {
const encounteredUserIds = new Set<number>();
return showcaseArtsByTagStm
.all({ tagId })
.filter((row: any) => {
if (encounteredUserIds.has(row.userId)) {
return false;
}
encounteredUserIds.add(row.userId);
return true;
})
.map((a: any) => ({
id: a.id,
createdAt: a.createdAt,
url: a.url,
author: {
commissionsOpen: a.commissionsOpen,
discordAvatar: a.discordAvatar,
discordId: a.discordId,
username: a.username,
},
}));
}

View File

@ -1,11 +1,18 @@
import type { MetaFunction, SerializeFrom } from "@remix-run/node";
import type { ShouldRevalidateFunction } from "@remix-run/react";
import { useLoaderData, useSearchParams } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { AddNewButton } from "~/components/AddNewButton";
import { SendouButton } from "~/components/elements/Button";
import { SendouSwitch } from "~/components/elements/Switch";
import {
SendouTab,
SendouTabList,
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
import { CrossIcon } from "~/components/icons/Cross";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
@ -15,11 +22,15 @@ import { metaTags } from "../../../utils/remix";
import { FILTERED_TAG_KEY_SEARCH_PARAM_KEY } from "../art-constants";
import { ArtGrid } from "../components/ArtGrid";
import { TagSelect } from "../components/TagSelect";
import { loader } from "../loaders/art.server";
export { loader };
const OPEN_COMMISIONS_KEY = "open";
const TAB_KEY = "tab";
const TABS = {
RECENTLY_UPLOADED: "recently-uploaded",
SHOWCASE: "showcase",
} as const;
export const shouldRevalidate: ShouldRevalidateFunction = (args) => {
const currentFilteredTag = args.currentUrl.searchParams.get(
@ -63,12 +74,17 @@ export default function ArtPage() {
const [searchParams, setSearchParams] = useSearchParams();
const switchId = React.useId();
const selectedTab = searchParams.get(TAB_KEY) ?? TABS.RECENTLY_UPLOADED;
const filteredTag = searchParams.get(FILTERED_TAG_KEY_SEARCH_PARAM_KEY);
const showOpenCommissions = searchParams.get(OPEN_COMMISIONS_KEY) === "true";
const arts = !showOpenCommissions
? data.arts
: data.arts.filter((art) => art.author?.commissionsOpen);
const showcaseArts = !showOpenCommissions
? data.showcaseArts
: data.showcaseArts.filter((art) => art.author?.commissionsOpen);
const recentlyUploadedArts = !showOpenCommissions
? data.recentlyUploadedArts
: data.recentlyUploadedArts.filter((art) => art.author?.commissionsOpen);
return (
<Main className="stack lg">
@ -89,16 +105,25 @@ export default function ArtPage() {
</Label>
</div>
<div className="stack horizontal sm items-center">
<TagSelect
key={filteredTag}
tags={data.allTags}
onSelectionChange={(tagName) => {
setSearchParams((prev) => {
prev.set(FILTERED_TAG_KEY_SEARCH_PARAM_KEY, tagName as string);
return prev;
});
}}
/>
<div
className={clsx({
invisible: selectedTab !== TABS.SHOWCASE,
})}
>
<TagSelect
key={filteredTag}
tags={data.allTags}
onSelectionChange={(tagName) => {
setSearchParams((prev) => {
prev.set(
FILTERED_TAG_KEY_SEARCH_PARAM_KEY,
tagName as string,
);
return prev;
});
}}
/>
</div>
<AddNewButton navIcon="art" to={newArtPage()} />
</div>
</div>
@ -121,7 +146,31 @@ export default function ArtPage() {
</SendouButton>
</div>
) : null}
<ArtGrid arts={arts} />
<SendouTabs
selectedKey={selectedTab}
onSelectionChange={(key) => {
setSearchParams((prev) => {
prev.set(TAB_KEY, key as string);
if (key === TABS.RECENTLY_UPLOADED) {
prev.delete(FILTERED_TAG_KEY_SEARCH_PARAM_KEY);
}
return prev;
});
}}
>
<SendouTabList>
<SendouTab id={TABS.RECENTLY_UPLOADED}>
{t("art:tabs.recentlyUploaded")}
</SendouTab>
<SendouTab id={TABS.SHOWCASE}>{t("art:tabs.showcase")}</SendouTab>
</SendouTabList>
<SendouTabPanel id={TABS.RECENTLY_UPLOADED}>
<ArtGrid arts={recentlyUploadedArts} showUploadDate />
</SendouTabPanel>
<SendouTabPanel id={TABS.SHOWCASE}>
<ArtGrid arts={showcaseArts} />
</SendouTabPanel>
</SendouTabs>
</Main>
);
}

View File

@ -63,7 +63,8 @@ export type Notification =
| NotificationItem<"SEASON_STARTED", { seasonNth: number }>
| NotificationItem<"SCRIM_NEW_REQUEST", { fromUsername: string }>
| NotificationItem<"SCRIM_SCHEDULED", { id: number; at: number }>
| NotificationItem<"SCRIM_CANCELED", { id: number; at: number }>;
| NotificationItem<"SCRIM_CANCELED", { id: number; at: number }>
| NotificationItem<"COMMISSIONS_CLOSED", { discordId: string }>;
type NotificationItem<
T extends string,

View File

@ -11,6 +11,7 @@ import {
tournamentRegisterPage,
tournamentTeamPage,
userArtPage,
userEditProfilePage,
} from "~/utils/urls";
import type { Notification } from "./notifications-types";
@ -27,6 +28,7 @@ export const notificationNavIcon = (type: Notification["type"]) => {
case "SEASON_STARTED":
return "sendouq";
case "TAGGED_TO_ART":
case "COMMISSIONS_CLOSED":
return "art";
case "TO_ADDED_TO_TEAM":
case "TO_BRACKET_STARTED":
@ -83,6 +85,9 @@ export const notificationLink = (notification: Notification) => {
case "SCRIM_SCHEDULED": {
return scrimPage(notification.meta.id);
}
case "COMMISSIONS_CLOSED": {
return userEditProfilePage({ discordId: notification.meta.discordId });
}
default:
assertUnreachable(notification);
}

View File

@ -6,8 +6,8 @@ import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { sourceTypes } from "~/modules/tournament-map-list-generator/constants";
import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator/types";
import { seededRandom } from "~/modules/tournament-map-list-generator/utils";
import { logger } from "~/utils/logger";
import { seededRandom } from "~/utils/random";
import type { TournamentLoaderData } from "../tournament/loaders/to.$id.server";
import type { FindMatchById } from "../tournament-bracket/queries/findMatchById.server";
import type { Standing } from "./core/Bracket";
@ -40,11 +40,11 @@ const NUM_MAP = {
export function resolveRoomPass(seed: number | string) {
let pass = "5";
for (let i = 0; i < 3; i++) {
const { shuffle } = seededRandom(`${seed}-${i}`);
const { seededShuffle } = seededRandom(`${seed}-${i}`);
const key = pass[i] as keyof typeof NUM_MAP;
const opts = NUM_MAP[key];
const next = shuffle(opts)[0];
const next = seededShuffle(opts)[0];
pass += next;
}

View File

@ -835,6 +835,8 @@ export function updateProfile(args: UpdateProfileArgs) {
showDiscordUniqueName: args.showDiscordUniqueName,
commissionText: args.commissionText,
commissionsOpen: args.commissionsOpen,
commissionsOpenedAt:
args.commissionsOpen === 1 ? databaseTimestampNow() : null,
})
.where("id", "=", args.userId)
.returning(["User.id", "User.customUrl", "User.discordId"])

View File

@ -472,6 +472,9 @@ function CommissionsOpenToggle({
onChange={setChecked}
name="commissionsOpen"
/>
<FormMessage type="info">
{t("user:forms.commissionsOpen.info")}
</FormMessage>
</div>
);
}

View File

@ -3,6 +3,7 @@ import { err, ok } from "neverthrow";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import { seededRandom } from "~/utils/random";
import type { ModeShort, StageId } from "../in-game-lists/types";
import { DEFAULT_MAP_POOL } from "./constants";
import type {
@ -10,7 +11,6 @@ import type {
TournamentMaplistInput,
TournamentMaplistSource,
} from "./types";
import { seededRandom } from "./utils";
type ModeWithStageAndScore = TournamentMapListMap & { score: number };
@ -50,8 +50,8 @@ function generateWithInput(
> {
validateInput(input);
const { shuffle } = seededRandom(input.seed);
const stages = shuffle(resolveCommonStages());
const { seededShuffle } = seededRandom(input.seed);
const stages = seededShuffle(resolveCommonStages());
const mapList: Array<ModeWithStageAndScore & { score: number }> = [];
const bestMapList: { maps?: Array<ModeWithStageAndScore>; score: number } = {
score: Number.POSITIVE_INFINITY,
@ -165,7 +165,7 @@ function generateWithInput(
// no overlap so we need to use a random map for tiebreaker
if (tournamentIsOneModeOnly()) {
return shuffle([...stageIds])
return seededShuffle([...stageIds])
.filter(
(stageId) =>
!input.teams[0].maps.hasStage(stageId) &&

View File

@ -6,4 +6,3 @@ export type {
TournamentMaplistInput,
TournamentMaplistSource,
} from "./types";
export { seededRandom } from "./utils";

View File

@ -4,10 +4,10 @@
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import { logger } from "~/utils/logger";
import { seededRandom } from "~/utils/random";
import { modesShort } from "../in-game-lists/modes";
import type { ModeWithStage } from "../in-game-lists/types";
import type { TournamentMapListMap, TournamentMaplistInput } from "./types";
import { seededRandom } from "./utils";
type StarterMapArgs = Pick<
TournamentMaplistInput,
@ -15,7 +15,7 @@ type StarterMapArgs = Pick<
>;
export function starterMap(args: StarterMapArgs): Array<TournamentMapListMap> {
const { shuffle } = seededRandom(args.seed);
const { seededShuffle } = seededRandom(args.seed);
const isRecentlyPlayed = (map: ModeWithStage) => {
return Boolean(
@ -27,7 +27,7 @@ export function starterMap(args: StarterMapArgs): Array<TournamentMapListMap> {
const commonMap = resolveRandomCommonMap(
args.teams,
shuffle,
seededShuffle,
isRecentlyPlayed,
);
if (commonMap) {
@ -35,7 +35,7 @@ export function starterMap(args: StarterMapArgs): Array<TournamentMapListMap> {
}
if (!args.tiebreakerMaps.isEmpty()) {
const tiebreakers = shuffle(args.tiebreakerMaps.stageModePairs);
const tiebreakers = seededShuffle(args.tiebreakerMaps.stageModePairs);
const nonRecentTiebreaker = tiebreakers.find((tb) => !isRecentlyPlayed(tb));
const randomTiebreaker = nonRecentTiebreaker ?? tiebreakers[0];
@ -50,7 +50,7 @@ export function starterMap(args: StarterMapArgs): Array<TournamentMapListMap> {
// should be only one mode here always but just in case
// making it capable of handling many modes too
const allAvailableMaps = shuffle(
const allAvailableMaps = seededShuffle(
args.modesIncluded
.sort((a, b) => modesShort.indexOf(a) - modesShort.indexOf(b))
.flatMap((mode) => stageIds.map((stageId) => ({ mode, stageId }))),

View File

@ -0,0 +1,54 @@
import { sub } from "date-fns";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { logger } from "~/utils/logger";
import { db } from "../db/sql";
import { notify } from "../features/notifications/core/notify.server";
import { Routine } from "./routine.server";
export const CloseExpiredCommissionsRoutine = new Routine({
name: "CloseExpiredCommissions",
func: async () => {
const usersWithExpiredCommissions = await db
.selectFrom("User")
.select(["id", "discordId"])
.where("commissionsOpen", "=", 1)
.where("commissionsOpenedAt", "is not", null)
.where(
"commissionsOpenedAt",
"<=",
dateToDatabaseTimestamp(sub(new Date(), { months: 1 })),
)
.execute();
if (usersWithExpiredCommissions.length === 0) {
return;
}
const userIds = usersWithExpiredCommissions.map((user) => user.id);
await db
.updateTable("User")
.set({
commissionsOpen: 0,
commissionsOpenedAt: null,
})
.where("id", "in", userIds)
.execute();
logger.info(
`Closed commissions for ${usersWithExpiredCommissions.length} users`,
);
for (const user of usersWithExpiredCommissions) {
await notify({
notification: {
type: "COMMISSIONS_CLOSED",
meta: {
discordId: user.discordId,
},
},
userIds: [user.id],
});
}
},
});

View File

@ -1,3 +1,4 @@
import { CloseExpiredCommissionsRoutine } from "./closeExpiredCommissions";
import { DeleteOldNotificationsRoutine } from "./deleteOldNotifications";
import { DeleteOldTrustRoutine } from "./deleteOldTrusts";
import { NotifyCheckInStartRoutine } from "./notifyCheckInStart";
@ -20,4 +21,8 @@ export const everyHourAt30 = [
];
/** List of Routines that should occur daily */
export const daily = [DeleteOldTrustRoutine, DeleteOldNotificationsRoutine];
export const daily = [
DeleteOldTrustRoutine,
DeleteOldNotificationsRoutine,
CloseExpiredCommissionsRoutine,
];

142
app/utils/random.test.ts Normal file
View File

@ -0,0 +1,142 @@
import { describe, expect, it } from "vitest";
import { seededRandom } from "./random";
describe("seededRandom", () => {
describe("random", () => {
it("produces same values for same seed", () => {
const rng1 = seededRandom("test-seed");
const rng2 = seededRandom("test-seed");
expect(rng1.random()).toBe(rng2.random());
expect(rng1.random()).toBe(rng2.random());
expect(rng1.random()).toBe(rng2.random());
});
it("produces different values for different seeds", () => {
const rng1 = seededRandom("seed-1");
const rng2 = seededRandom("seed-2");
expect(rng1.random()).not.toBe(rng2.random());
});
it("returns values between 0 and 1 by default", () => {
const rng = seededRandom("test");
for (let i = 0; i < 100; i++) {
const value = rng.random();
expect(value).toBeGreaterThanOrEqual(0);
expect(value).toBeLessThan(1);
}
});
it("returns values between lo and hi when both provided", () => {
const rng = seededRandom("test");
for (let i = 0; i < 100; i++) {
const value = rng.random(5, 10);
expect(value).toBeGreaterThanOrEqual(5);
expect(value).toBeLessThan(10);
}
});
it("returns values between 0 and hi when only hi provided", () => {
const rng = seededRandom("test");
for (let i = 0; i < 100; i++) {
const value = rng.random(5);
expect(value).toBeGreaterThanOrEqual(0);
expect(value).toBeLessThan(5);
}
});
});
describe("randomInteger", () => {
it("produces same values for same seed", () => {
const rng1 = seededRandom("test-seed");
const rng2 = seededRandom("test-seed");
expect(rng1.randomInteger(10)).toBe(rng2.randomInteger(10));
expect(rng1.randomInteger(10)).toBe(rng2.randomInteger(10));
expect(rng1.randomInteger(10)).toBe(rng2.randomInteger(10));
});
it("produces different values for different seeds", () => {
const rng1 = seededRandom("seed-1");
const rng2 = seededRandom("seed-2");
expect(rng1.randomInteger(100)).not.toBe(rng2.randomInteger(100));
});
it("returns integers between 0 and hi when only hi provided", () => {
const rng = seededRandom("test");
for (let i = 0; i < 100; i++) {
const value = rng.randomInteger(10);
expect(Number.isInteger(value)).toBe(true);
expect(value).toBeGreaterThanOrEqual(0);
expect(value).toBeLessThan(10);
}
});
it("returns integers between lo and hi when both provided", () => {
const rng = seededRandom("test");
for (let i = 0; i < 100; i++) {
const value = rng.randomInteger(5, 10);
expect(Number.isInteger(value)).toBe(true);
expect(value).toBeGreaterThanOrEqual(5);
expect(value).toBeLessThan(10);
}
});
});
describe("seededShuffle", () => {
it("produces same shuffle for same seed", () => {
const array = [1, 2, 3, 4, 5];
const rng1 = seededRandom("test-seed");
const rng2 = seededRandom("test-seed");
expect(rng1.seededShuffle(array)).toEqual(rng2.seededShuffle(array));
});
it("produces different shuffles for different seeds", () => {
const array = [1, 2, 3, 4, 5];
const rng1 = seededRandom("seed-1");
const rng2 = seededRandom("seed-2");
expect(rng1.seededShuffle(array)).not.toEqual(rng2.seededShuffle(array));
});
it("does not mutate original array", () => {
const array = [1, 2, 3, 4, 5];
const original = [...array];
const rng = seededRandom("test");
rng.seededShuffle(array);
expect(array).toEqual(original);
});
it("returns array with same elements", () => {
const array = [1, 2, 3, 4, 5];
const rng = seededRandom("test");
const shuffled = rng.seededShuffle(array);
expect(shuffled.sort()).toEqual(array.sort());
});
it("handles empty array", () => {
const array: number[] = [];
const rng = seededRandom("test");
const shuffled = rng.seededShuffle(array);
expect(shuffled).toEqual([]);
});
it("handles single element array", () => {
const array = [1];
const rng = seededRandom("test");
const shuffled = rng.seededShuffle(array);
expect(shuffled).toEqual([1]);
});
});
});

View File

@ -1,5 +1,3 @@
// https://stackoverflow.com/a/68523152
function cyrb128(str: string) {
let h1 = 1779033703;
let h2 = 3144134277;
@ -36,27 +34,38 @@ function mulberry32(a: number) {
};
}
/**
* Creates a seeded pseudo-random number generator that produces consistent results for the same seed.
* Uses mulberry32 algorithm with cyrb128 hash function for string-to-number conversion.
*
* @param seed - String seed value (e.g., "2025-1-8" for daily rotation)
* @returns Object with random number generation methods:
* - `random(lo?, hi?)` - Returns random float between lo (inclusive) and hi (exclusive)
* - `randomInteger(lo, hi?)` - Returns random integer between lo (inclusive) and hi (exclusive)
* - `seededShuffle(array)` - Returns shuffled copy of array using seeded Fisher-Yates algorithm
*
* @example
* const { seededShuffle } = seededRandom("2025-1-8");
* const shuffled = seededShuffle([1, 2, 3, 4, 5]);
*/
export const seededRandom = (seed: string) => {
const rng = mulberry32(cyrb128(seed)[0]);
const rnd = (lo: number, hi?: number, defaultHi = 1) => {
if (hi === undefined) {
// biome-ignore lint/style/noParameterAssign: biome migration
hi = lo === undefined ? defaultHi : lo;
// biome-ignore lint/style/noParameterAssign: biome migration
lo = 0;
}
const random = (lo?: number, hi?: number, defaultHi = 1) => {
const actualLo = hi === undefined ? 0 : (lo ?? 0);
const actualHi = hi === undefined ? (lo ?? defaultHi) : hi;
return rng() * (hi - lo) + lo;
return rng() * (actualHi - actualLo) + actualLo;
};
const rndInt = (lo: number, hi?: number) => Math.floor(rnd(lo, hi, 2));
const randomInteger = (lo: number, hi?: number) =>
Math.floor(random(lo, hi, 2));
const shuffle = <T>(o: T[]) => {
const seededShuffle = <T>(o: T[]) => {
const a = o.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = rndInt(i + 1);
const j = randomInteger(i + 1);
const x = a[i];
a[i] = a[j]!;
a[j] = x!;
@ -65,5 +74,5 @@ export const seededRandom = (seed: string) => {
return a;
};
return { rnd, rndInt, shuffle };
return { random, randomInteger, seededShuffle };
};

Binary file not shown.

View File

@ -11,6 +11,8 @@
"commissionsClosed": "Lukket for bestillinger",
"openCommissionsOnly": "Vis kunstnere med åbne bestillinger",
"gainPerms": "Lav venligt et opslad til vores helpdesk på vores Discord-server for at få tilladelse til at uploade kunst. Bemærk venligt, at du skal være kunstneren af det kunst, som du uploader, og kun Splatoon-relateret kunst tillades.",
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "Vær opmærksom på følgende: 1) Upload kun Splatoon-kunst 2) upload kun kunst, som du selv har lavet 3) Ingen NSFW-kunst. 4) Kunst skal igennem en valideringsproces før det vises til andre brugere.",
"forms.description.title": "Beskrivelse",
"forms.linkedUsers.title": "tilknyttede brugere",

View File

@ -77,6 +77,8 @@
"notifications.text.SCRIM_SCHEDULED": "",
"notifications.title.SCRIM_CANCELED": "",
"notifications.text.SCRIM_CANCELED": "",
"notifications.title.COMMISSIONS_CLOSED": "",
"notifications.text.COMMISSIONS_CLOSED": "",
"auth.errors.aborted": "logindforsøg afbrudt",
"auth.errors.failed": "Loginforsøg fejlet",
"auth.errors.discordPermissions": "Før at du kan oprette en profil på sendou.ink, skal sendou.ink have adgang til din Discordprofils navn, brugerbillede og sociale forbindelser (de sociale medier, som du har tilknyttet din discordprofil).",

View File

@ -1,5 +1,6 @@
{
"q1": "Hvad er Plus Serveren?",
"a1": "",
"q2": "Hvordan får jeg et præmiemærke til min begivenhed?",
"a2": "",
"q3": "Hvordan opdaterer jeg min avatar eller brugernavn?",

View File

@ -67,6 +67,7 @@
"settings.sounds.likeReceived": "",
"settings.sounds.groupNewMember": "",
"settings.sounds.matchStarted": "",
"settings.sounds.tournamentMatchStarted": "",
"settings.mapPool.notOk": "",
"settings.misc.header": "",
"settings.banned": "",

View File

@ -137,5 +137,6 @@
"progression.error.NAME_MISSING": "",
"progression.error.NEGATIVE_PROGRESSION": "",
"progression.error.NO_SE_SOURCE": "",
"progression.error.NO_DE_POSITIVE": ""
"progression.error.NO_DE_POSITIVE": "",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": ""
}

View File

@ -17,6 +17,7 @@
"forms.showDiscordUniqueName": "Vis Discord-brugernavn",
"forms.showDiscordUniqueName.info": "Vil du gøre dit unikke Discord-brugernavn ({{discordUniqueName}}) synligt for offentligheden?",
"forms.commissionsOpen": "Åben for bestillinger",
"forms.commissionsOpen.info": "",
"forms.commissionText": "info om bestilling",
"forms.commissionText.info": "Pris, åbne pladser eller andre relevante informationer der er relateret til at afgive en bestilling til dig.",
"forms.customName.info": "Hvis feltet ikke udfyldes bruges dit discordbrugernavn: \"{{discordName}}\"",

View File

@ -11,6 +11,8 @@
"commissionsClosed": "",
"openCommissionsOnly": "",
"gainPerms": "",
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "",
"forms.description.title": "",
"forms.linkedUsers.title": "",

View File

@ -77,6 +77,8 @@
"notifications.text.SCRIM_SCHEDULED": "",
"notifications.title.SCRIM_CANCELED": "",
"notifications.text.SCRIM_CANCELED": "",
"notifications.title.COMMISSIONS_CLOSED": "",
"notifications.text.COMMISSIONS_CLOSED": "",
"auth.errors.aborted": "Einloggen abgebrochen",
"auth.errors.failed": "Einloggen fehlgeschlagen",
"auth.errors.discordPermissions": "Für dein sendou.ink-Profil benötigt die Seite Zugriff auf den Namen, Avatar und verbundene Social-Media-Accounts in deinem Discord-Profil.",

View File

@ -1,5 +1,6 @@
{
"q1": "Was ist der Plus Server?",
"a1": "",
"q2": "Wie erhalte ich ein Abzeichen als Preis für mein Event?",
"a2": "",
"q3": "Wie aktualisiere ich meinen Avatar oder Nutzernamen?",

View File

@ -67,6 +67,7 @@
"settings.sounds.likeReceived": "",
"settings.sounds.groupNewMember": "",
"settings.sounds.matchStarted": "",
"settings.sounds.tournamentMatchStarted": "",
"settings.mapPool.notOk": "",
"settings.misc.header": "",
"settings.banned": "",

View File

@ -137,5 +137,6 @@
"progression.error.NAME_MISSING": "",
"progression.error.NEGATIVE_PROGRESSION": "",
"progression.error.NO_SE_SOURCE": "",
"progression.error.NO_DE_POSITIVE": ""
"progression.error.NO_DE_POSITIVE": "",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": ""
}

View File

@ -17,6 +17,7 @@
"forms.showDiscordUniqueName": "",
"forms.showDiscordUniqueName.info": "",
"forms.commissionsOpen": "",
"forms.commissionsOpen.info": "",
"forms.commissionText": "",
"forms.commissionText.info": "",
"forms.customName.info": "",

View File

@ -11,6 +11,8 @@
"commissionsClosed": "Commissions are closed",
"openCommissionsOnly": "Show artists with open commissions",
"gainPerms": "Please post on the helpdesk of our Discord to gain permissions to upload art. Note that you must be the artist of the art you are uploading and only Splatoon related art is allowed.",
"tabs.recentlyUploaded": "Recently Uploaded",
"tabs.showcase": "Showcase",
"forms.caveats": "Few things to note: 1) Only upload Splatoon art 2) Only upload art you made yourself 3) No NSFW art. There is a validation process before art is shown to other users.",
"forms.description.title": "Description",
"forms.linkedUsers.title": "Linked users",

View File

@ -77,6 +77,8 @@
"notifications.text.SCRIM_SCHEDULED": "New scrim scheduled at {{timeString}}",
"notifications.title.SCRIM_CANCELED": "Scrim Canceled",
"notifications.text.SCRIM_CANCELED": "The scrim at {{timeString}} was canceled",
"notifications.title.COMMISSIONS_CLOSED": "Commissions Closed",
"notifications.text.COMMISSIONS_CLOSED": "If your commissions are still open, please re-enable them",
"auth.errors.aborted": "Login Aborted",
"auth.errors.failed": "Login Failed",
"auth.errors.discordPermissions": "For your sendou.ink profile, the site needs access to your Discord profile's name, avatar and social connections.",

View File

@ -17,6 +17,7 @@
"forms.showDiscordUniqueName": "Show Discord username",
"forms.showDiscordUniqueName.info": "Show your unique Discord name ({{discordUniqueName}}) publicly?",
"forms.commissionsOpen": "Commissions open",
"forms.commissionsOpen.info": "Commissions automatically close and need to be re-enabled after one month to prevent stale listings",
"forms.commissionText": "Commission info",
"forms.commissionText.info": "Price, slots open or other info related to commissioning you",
"forms.customName.info": "If missing, your Discord display name is used: \"{{discordName}}\"",

View File

@ -12,6 +12,8 @@
"commissionsClosed": "Comisiones cerradas",
"openCommissionsOnly": "Mostrar artistas con comisiones abiertas",
"gainPerms": "Por favor manda mensaje en el 'helpdesk' de nuestro Discord para obtener permiso para subir arte. Debes ser el artista que creó el arte que subas, y solo se permite arte relacionada con Splatoon.",
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "NOTAS: 1) Solo sube arte de Splatoon; 2) Solo sube arte que tu creaste; 3) No se permite arte inapropiada (NSFW). Hay un proceso de evaluación antes de que se muestre tu arte públicamente.",
"forms.description.title": "Descripción",
"forms.linkedUsers.title": "Enlaces de usuarios",

View File

@ -77,6 +77,8 @@
"notifications.text.SCRIM_SCHEDULED": "",
"notifications.title.SCRIM_CANCELED": "",
"notifications.text.SCRIM_CANCELED": "",
"notifications.title.COMMISSIONS_CLOSED": "",
"notifications.text.COMMISSIONS_CLOSED": "",
"auth.errors.aborted": "Ingreso cancelado",
"auth.errors.failed": "Ingreso fallido",
"auth.errors.discordPermissions": "Para tu perfil en sendou.ink, el sitio requiere aceso a tu nombre en Discord, avatar, y redes sociales.",

View File

@ -1,5 +1,6 @@
{
"q1": "¿Qué es el Plus Server?",
"a1": "",
"q2": "¿Cómo puedo obtener un premio de insignia para mi evento?",
"a2": "",
"q3": "¿Cómo puedo actualizar mi avatar o nombre de usuario?",

View File

@ -67,6 +67,7 @@
"settings.sounds.likeReceived": "Has recibido un like",
"settings.sounds.groupNewMember": "Nuevo miembro al grupo",
"settings.sounds.matchStarted": "Comenzo el partido",
"settings.sounds.tournamentMatchStarted": "",
"settings.mapPool.notOk": "Elige {{count}} mapas por modo que no evitaste para guardar tus preferencias",
"settings.misc.header": "Misc",
"settings.banned": "Prohibidos",

View File

@ -138,5 +138,6 @@
"progression.error.NAME_MISSING": "",
"progression.error.NEGATIVE_PROGRESSION": "",
"progression.error.NO_SE_SOURCE": "",
"progression.error.NO_DE_POSITIVE": ""
"progression.error.NO_DE_POSITIVE": "",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": ""
}

View File

@ -17,6 +17,7 @@
"forms.showDiscordUniqueName": "Mostrar usuario de Discord",
"forms.showDiscordUniqueName.info": "¿Mostrar tu nombre de Discord ({{discordUniqueName}}) publicamente?",
"forms.commissionsOpen": "Comisiones abiertas",
"forms.commissionsOpen.info": "",
"forms.commissionText": "Info de comisiones",
"forms.commissionText.info": "Precio, espacios abiertos, o cualquier otra información sobre tus comiciones.",
"forms.customName.info": "",

View File

@ -12,6 +12,8 @@
"commissionsClosed": "Comisiones cerradas",
"openCommissionsOnly": "Mostrar artistas con comisiones abiertas",
"gainPerms": "Por favor manda mensaje en el 'helpdesk' de nuestro Discord para obtener permiso para subir arte. Debes ser el artista que creó el arte que subas, y solo se permite arte relacionada con Splatoon.",
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "NOTAS: 1) Solo sube arte de Splatoon; 2) Solo sube arte que tu creaste; 3) No se permite arte inapropiada (NSFW). Hay un proceso de evaluación antes de que se muestre tu arte públicamente.",
"forms.description.title": "Descripción",
"forms.linkedUsers.title": "Enlaces de usuarios",

View File

@ -77,6 +77,8 @@
"notifications.text.SCRIM_SCHEDULED": "",
"notifications.title.SCRIM_CANCELED": "",
"notifications.text.SCRIM_CANCELED": "",
"notifications.title.COMMISSIONS_CLOSED": "",
"notifications.text.COMMISSIONS_CLOSED": "",
"auth.errors.aborted": "Ingreso cancelado",
"auth.errors.failed": "Ingreso fallido",
"auth.errors.discordPermissions": "Para tu perfil en sendou.ink, el sitio requiere aceso a tu nombre en Discord, avatar, y redes sociales.",

View File

@ -1,5 +1,6 @@
{
"q1": "¿Qué es el Plus Server?",
"a1": "",
"q2": "¿Cómo puedo obtener un premio de insignia para mi evento?",
"a2": "",
"q3": "¿Cómo puedo actualizar mi avatar o nombre de usuario?",

View File

@ -67,6 +67,7 @@
"settings.sounds.likeReceived": "Has recibido un like",
"settings.sounds.groupNewMember": "Nuevo miembro al grupo",
"settings.sounds.matchStarted": "Comenzo el partido",
"settings.sounds.tournamentMatchStarted": "",
"settings.mapPool.notOk": "Elige {{count}} escenarios por estilo que no evitaste para guardar tus preferencias",
"settings.misc.header": "Misc",
"settings.banned": "Prohibidos",

View File

@ -138,5 +138,6 @@
"progression.error.NAME_MISSING": "",
"progression.error.NEGATIVE_PROGRESSION": "",
"progression.error.NO_SE_SOURCE": "",
"progression.error.NO_DE_POSITIVE": ""
"progression.error.NO_DE_POSITIVE": "",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": ""
}

View File

@ -17,6 +17,7 @@
"forms.showDiscordUniqueName": "Mostrar usuario de Discord",
"forms.showDiscordUniqueName.info": "¿Mostrar tu nombre de Discord ({{discordUniqueName}}) publicamente?",
"forms.commissionsOpen": "Comisiones abiertas",
"forms.commissionsOpen.info": "",
"forms.commissionText": "Info de comisiones",
"forms.commissionText.info": "Precio, espacios abiertos, o cualquier otra información sobre tus comiciones.",
"forms.customName.info": "Si vacío, se mostrará tu nombre de Discord: \"{{discordName}}\"",

View File

@ -12,6 +12,8 @@
"commissionsClosed": "N'accepte pas les commissions",
"openCommissionsOnly": "Ne montrer que les artistes qui acceptent les commissions",
"gainPerms": "",
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "Quelques notes: 1) Doit être en rapport avec Splatoon 2) Doit avoir été créé par vous 3) Pas de contenu NSFW/explicite. Il y a une procédure de validation avant que votre poste puisse être vu par les autres.",
"forms.description.title": "Description",
"forms.linkedUsers.title": "Utilisateurs liés",

View File

@ -77,6 +77,8 @@
"notifications.text.SCRIM_SCHEDULED": "",
"notifications.title.SCRIM_CANCELED": "",
"notifications.text.SCRIM_CANCELED": "",
"notifications.title.COMMISSIONS_CLOSED": "",
"notifications.text.COMMISSIONS_CLOSED": "",
"auth.errors.aborted": "Connexion abandonnée",
"auth.errors.failed": "Connexion échouée",
"auth.errors.discordPermissions": "Pour mettre en place votre profil, sendou.ink a besoin de votre nom de profil Discord, de votre avatar et de vos réseaux connectés.",

View File

@ -1,5 +1,6 @@
{
"q1": "Qu'est-ce que le Plus Server ?",
"a1": "",
"q2": "Comment obtenir un badge pour mon événement ?",
"a2": "",
"q3": "Comment mettre à jour mon pseudo ou mon avatar ?",

View File

@ -67,6 +67,7 @@
"settings.sounds.likeReceived": "",
"settings.sounds.groupNewMember": "",
"settings.sounds.matchStarted": "",
"settings.sounds.tournamentMatchStarted": "",
"settings.mapPool.notOk": "",
"settings.misc.header": "",
"settings.banned": "",

View File

@ -138,5 +138,6 @@
"progression.error.NAME_MISSING": "",
"progression.error.NEGATIVE_PROGRESSION": "",
"progression.error.NO_SE_SOURCE": "",
"progression.error.NO_DE_POSITIVE": ""
"progression.error.NO_DE_POSITIVE": "",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": ""
}

View File

@ -17,6 +17,7 @@
"forms.showDiscordUniqueName": "Montrer le pseudo Discord",
"forms.showDiscordUniqueName.info": "Show your unique Discord name ({{discordUniqueName}}) publicly?",
"forms.commissionsOpen": "Commissions acceptées",
"forms.commissionsOpen.info": "",
"forms.commissionText": "Info pour les commissions",
"forms.commissionText.info": "Prix, disponibilités et tout autres info nécéssaires",
"forms.customName.info": "",

View File

@ -12,6 +12,8 @@
"commissionsClosed": "N'accepte pas les commissions",
"openCommissionsOnly": "Ne montrer que les artistes qui acceptent les commissions",
"gainPerms": "Vous pouvez demander dans le salon ''helpdesk'' sur notre discord pour avoir cette permission. Note: vous devez êtres l'artist pour publier votre création, celle-ci doit être seulement en rapport avec Splatoon.",
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "Quelques notes: 1) Doit être en rapport avec Splatoon 2) Doit avoir été créé par vous 3) Pas de contenu NSFW/explicite. Il y a une procédure de validation avant que votre poste puisse être vu par les autres.",
"forms.description.title": "Description",
"forms.linkedUsers.title": "Utilisateurs liés",

View File

@ -77,6 +77,8 @@
"notifications.text.SCRIM_SCHEDULED": "Nouveau scrim programmé à {{timeString}}",
"notifications.title.SCRIM_CANCELED": "",
"notifications.text.SCRIM_CANCELED": "",
"notifications.title.COMMISSIONS_CLOSED": "",
"notifications.text.COMMISSIONS_CLOSED": "",
"auth.errors.aborted": "Connexion abandonnée",
"auth.errors.failed": "Connexion échouée",
"auth.errors.discordPermissions": "Pour mettre en place votre profil, sendou.ink a besoin de votre nom de profil Discord, de votre avatar et de vos réseaux connectés.",

View File

@ -1,5 +1,6 @@
{
"q1": "Qu'est-ce que le Plus Server ?",
"a1": "",
"q2": "Comment obtenir un badge pour mon événement ?",
"a2": "Depuis septembre 2024, toute personne ayant les compétences nécessaires peut réaliser un badge. Consultez le lien en bas de la page des badges pour plus d'informations.",
"q3": "Comment mettre à jour mon pseudo ou mon avatar ?",

View File

@ -19,7 +19,6 @@
"privateNote.sentiment.NEUTRAL": "Neutre",
"privateNote.sentiment.NEGATIVE": "Negatif",
"privateNote.delete.header": "Enlever votre note à propos de {{name}}?",
"front.cities.la": "Los Angeles",
"front.cities.nyc": "New York",
"front.cities.paris": "Paris",
@ -51,7 +50,6 @@
"front.seasonOpen": "La saison {{nth}} est ouverte",
"front.preview": "Regarder qui est dans la queue sans la rejoindre",
"front.preview.explanation": "Cette fonctionnalité n'est disponible que pour les utilisateurs de niveau Supporter (ou supérieur) sur le patreon de sendou.ink.",
"settings.maps.header": "Stages et modes",
"settings.maps.avoid": "Détester",
"settings.maps.prefer": "Préférer",
@ -69,6 +67,7 @@
"settings.sounds.likeReceived": "Invitation accepté",
"settings.sounds.groupNewMember": "Nouveau membre dans le groupe",
"settings.sounds.matchStarted": "Le match a commencé",
"settings.sounds.tournamentMatchStarted": "",
"settings.mapPool.notOk": "Sélectionner {{count}} stages par mode que vous n'avez pas évité pour enregistrer vos préférences",
"settings.misc.header": "Divers",
"settings.banned": "Bannis",
@ -78,7 +77,6 @@
"settings.trusted.trustedExplanation": "Les utilisateurs de confiance peuvent vous ajouter directement à des groupes et à des équipes de tournoi. S'il est supprimé de cette liste, vous devrez à l'avenir vous inscrire via un lien qu'il aura partager.",
"settings.trusted.noTrustedExplanation": "Vous ne faites actuellement confiance à aucun utilisateur. Vous pouvez ajouter des utilisateurs de confiance lorsque vous rejoignez leur groupe SendouQ ou leur équipe de tournoi via un lien. Les utilisateurs de confiance peuvent vous ajouter directement à des groupes et à des équipes de tournoi.",
"settings.trusted.teamExplanation": "En plus des utilisateurs ci-dessus, un membre de votre équipe <2>{{name}}</2> peut vous ajouter directement.",
"looking.joiningGroupError": "Avant de rejoindre un nouveau groupe, quitté celui actuel",
"looking.goToSettingsPrompt": "Pour aider votre recherche de groupe, selectionner vos armes et votre statut vocal dans les paramètres",
"looking.inactiveGroup.soon": "Le groupe a été marqué comme inactif. Recherchez-vous toujours?",
@ -122,7 +120,6 @@
"looking.allTiers": "Tout les ranks",
"looking.joinQPrompt": "Rejoindre la queue pour trouvez un groupe",
"looking.range.or": "ou",
"match.header": "Match #{{number}}",
"match.spInfo": "Les SP après que les deux teams est reportées le même résultat",
"match.dispute.button": "Dispute?",
@ -165,14 +162,11 @@
"match.outcome.loss": "Perdu",
"match.screen.ban": "Les armes avec {{special}} ne sont pas autoriser pendant ce match",
"match.screen.allowed": "Les armes avec {{special}} sont autoriser pendant ce match",
"preparing.joinQ": "Rejoindre la queue",
"tiers.currentCriteria": "Critères actuels",
"tiers.info.p1": "Par exemple, Les Léviathans font partie des 5 % des meilleurs joueurs. Le diamant est le top 15%, etc.",
"tiers.info.p2": "Note: personne n'a le rang Léviathan avant qu'il n'y ait au moins {{usersMin}} joueurs dans le classement (ou {{teamsMin}} pour les équipes)",
"tiers.info.p3": "Chaque rang a également un niveau '+' (voir BRONZE+ comme l'exemple ci-dessous). Cela signifie que vous faites partie des 50 % supérieurs de ce classement.",
"streams.noStreams": "Pas de match stream pour le moment",
"streams.ownStreamInfo": "Vous ne voyez pas votre stream? Assurez-vous que votre compte Twitch est lié et que le jeu est défini sur Splatoon 3.",
"streams.ownStreamInfo.linkText": "Consultez la FAQ pour plus d'informations sur la façon de lier votre compte Twitch."

View File

@ -138,5 +138,6 @@
"progression.error.NAME_MISSING": "Bracket name missing",
"progression.error.NEGATIVE_PROGRESSION": "Negative progression only possible for double elimination",
"progression.error.NO_SE_SOURCE": "Single elimination is not a valid source bracket",
"progression.error.NO_DE_POSITIVE": "Double elimination is not valid for positive progression"
"progression.error.NO_DE_POSITIVE": "Double elimination is not valid for positive progression",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": ""
}

View File

@ -17,6 +17,7 @@
"forms.showDiscordUniqueName": "Montrer le pseudo Discord",
"forms.showDiscordUniqueName.info": "Show your unique Discord name ({{discordUniqueName}}) publicly?",
"forms.commissionsOpen": "Commissions acceptées",
"forms.commissionsOpen.info": "",
"forms.commissionText": "Info pour les commissions",
"forms.commissionText.info": "Prix, disponibilités et tout autres info nécéssaires",
"forms.customName.info": "Si il n'est pas présent, votre pseudo discord est utilisé: \"{{discordName}}\"",

View File

@ -12,6 +12,8 @@
"commissionsClosed": "בקשות סגורות",
"openCommissionsOnly": "הראה אומנים עם בקשות פתוחות",
"gainPerms": "נא לכתוב בערוץ helpdesk בדיסקורד כדי לקבל הרשאות להעלות ציורים. שימו לב שאתם חייבים להיות יוצר הציור ורק אמנות הקשורה ל-Splatoon מותרת..",
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "כמה הבהרות: 1) רק להעלות ציור של Splatoon 2) רק להעלות ציור שנעשתה על ידכם 3) בלי NSFW. יש תהליך בדיקה לפני שציור מופיעה למשתמשים אחרים.",
"forms.description.title": "תיאור",
"forms.linkedUsers.title": "תיוג משתמשים",

View File

@ -77,6 +77,8 @@
"notifications.text.SCRIM_SCHEDULED": "",
"notifications.title.SCRIM_CANCELED": "",
"notifications.text.SCRIM_CANCELED": "",
"notifications.title.COMMISSIONS_CLOSED": "",
"notifications.text.COMMISSIONS_CLOSED": "",
"auth.errors.aborted": "הכניסה בוטלה",
"auth.errors.failed": "הכניסה נכשלה",
"auth.errors.discordPermissions": "עבור פרופיל sendou.ink שלך, האתר זקוק לגישה לשם, הפרופיל והקשרים החברתיים של פרופיל ה-Discord שלך.",

View File

@ -1,5 +1,6 @@
{
"q1": "מה זה השרת פלוס?",
"a1": "",
"q2": "איך מקבלים פרס תג לאירוע שלי?",
"a2": "",
"q3": "איך לעדכן את הפרופיל או את שם המשתמש שלי?",

View File

@ -67,6 +67,7 @@
"settings.sounds.likeReceived": "",
"settings.sounds.groupNewMember": "",
"settings.sounds.matchStarted": "",
"settings.sounds.tournamentMatchStarted": "",
"settings.mapPool.notOk": "",
"settings.misc.header": "",
"settings.banned": "",

View File

@ -138,5 +138,6 @@
"progression.error.NAME_MISSING": "",
"progression.error.NEGATIVE_PROGRESSION": "",
"progression.error.NO_SE_SOURCE": "",
"progression.error.NO_DE_POSITIVE": ""
"progression.error.NO_DE_POSITIVE": "",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": ""
}

View File

@ -17,6 +17,7 @@
"forms.showDiscordUniqueName": "הראה שם משתמש Discord",
"forms.showDiscordUniqueName.info": "להראות את שם ה-Discord היחודי שלכם ({{discordUniqueName}}) בפומבי?",
"forms.commissionsOpen": "בקשות פתוחות",
"forms.commissionsOpen.info": "",
"forms.commissionText": "מידע עבור בקשות",
"forms.commissionText.info": "מחיר, כמות בקשות או מידע אחר שקשור לבקשות אלכם",
"forms.customName.info": "",

View File

@ -12,6 +12,8 @@
"commissionsClosed": "Commissioni chiuse",
"openCommissionsOnly": "Mostra artisti con commissioni aperte",
"gainPerms": "Si prega di postare sull'helpdesk del nostro Discord per ottenere i permessi per caricare art. Nota che devi essere tu l'artista dell'art che stai caricando, e solo art relative a Splatoon sono ammesse.",
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "Un paio di cose di cui tener conto: 1) Puoi caricare soltanto art relative a Splatoon 2) Puoi caricare soltanto art create da te 3) Niente art NSFW. Vi è un processo di convalida svolto prima che la propria art sia visibile agli altri utenti.",
"forms.description.title": "Descrizione",
"forms.linkedUsers.title": "Utenti collegati",

View File

@ -77,6 +77,8 @@
"notifications.text.SCRIM_SCHEDULED": "",
"notifications.title.SCRIM_CANCELED": "",
"notifications.text.SCRIM_CANCELED": "",
"notifications.title.COMMISSIONS_CLOSED": "",
"notifications.text.COMMISSIONS_CLOSED": "",
"auth.errors.aborted": "Accesso cancellato",
"auth.errors.failed": "Accesso fallito",
"auth.errors.discordPermissions": "Per il tuo profilo di sendou.ink, il sito ha bisogno di accesso al nome utente, avatar e connessioni social del tuo profilo Discord.",

View File

@ -1,5 +1,6 @@
{
"q1": "Cos'è il Server Plus?",
"a1": "",
"q2": "Come faccio ad avere una medaglia come premio per il mio evento?",
"a2": "Da settembre 2024, le medaglie possono essere create da chiunque abbia le competenze necessarie. Visita il link in fondo alla pagina delle medaglie per ulteriori informazioni.",
"q3": "Come faccio ad aggiornare il mio nome utente o avatar?",

View File

@ -19,7 +19,6 @@
"privateNote.sentiment.NEUTRAL": "Neutrale",
"privateNote.sentiment.NEGATIVE": "Negativo",
"privateNote.delete.header": "Cancellare la tua nota su {{name}}?",
"front.cities.la": "Los Angeles",
"front.cities.nyc": "New York",
"front.cities.paris": "Parigi",
@ -51,7 +50,6 @@
"front.seasonOpen": "Stagione {{nth}} aperta",
"front.preview": "Visualizza gruppi nella coda prima di unirti",
"front.preview.explanation": "Questa funzione è disponibile solo per iscritti al Patreon di sendou.ink di tier Supporter o più",
"settings.maps.header": "Mappe e modalità",
"settings.maps.avoid": "Evita",
"settings.maps.prefer": "Preferisci",
@ -69,6 +67,7 @@
"settings.sounds.likeReceived": "Ricevuto un like",
"settings.sounds.groupNewMember": "Nuovo membro nel gruppo",
"settings.sounds.matchStarted": "Match iniziato",
"settings.sounds.tournamentMatchStarted": "",
"settings.mapPool.notOk": "Scegli {{count}} mappe per modalità che non hai evitato per salvare le tue preferenze",
"settings.misc.header": "Misc",
"settings.banned": "Bannata",
@ -78,7 +77,6 @@
"settings.trusted.trustedExplanation": "Gli utenti fidati possono aggiungerti direttamente al proprio gruppo SendouQ o ad un torneo. Se rimossi da questa lista, nel futuro necessiterai di unirti tramite un link che loro condividono",
"settings.trusted.noTrustedExplanation": "Non hai nessun utente fidato. Possono essere aggiunti quando entri nel loro gruppo SendouQ o torneo tramite un link. Gli utenti fidati possono aggiungerti direttamente al proprio gruppo SendouQ o ad un torneo.",
"settings.trusted.teamExplanation": "Oltre agli utenti sopracitati, un membro del tuo team <2>{{name}}</2> può aggiungerti direttamente.",
"looking.joiningGroupError": "Prima di unirti a un nuovo gruppo, lascia quello attuale",
"looking.goToSettingsPrompt": "Per aiutarti a trovare un gruppo, imposta la tua pool armi e stato voice chat nella pagina delle impostazioni",
"looking.inactiveGroup.soon": "Il gruppo verrà segnato come inattivo. Stai ancora cercando?",
@ -122,7 +120,6 @@
"looking.allTiers": "Tutti i tier",
"looking.joinQPrompt": "Unisciti alla coda o trova un gruppo",
"looking.range.or": "o",
"match.header": "Match #{{number}}",
"match.spInfo": "Gli SP verranno sistemati una volta che entrambi i team riporteranno lo stesso punteggio",
"match.dispute.button": "Disputa?",
@ -165,14 +162,11 @@
"match.outcome.loss": "sconfitta",
"match.screen.ban": "Armi con {{special}} non sono permesse in questo match",
"match.screen.allowed": "Armi con {{special}} non sono permesse in questo match",
"preparing.joinQ": "Unisciti alla coda",
"tiers.currentCriteria": "Criterio corrente",
"tiers.info.p1": "Per esempio Leviathan è la top 5% dei giocatori. Diamante è l' 85esimo percentile etc.",
"tiers.info.p2": "Nota bene: Nessuno ha rango Leviathan prima che ci siano {{usersMin}} giocatori sulla classifica (o {{teamsMin}} per i team)",
"tiers.info.p3": "Ogni rango ha anche un tier + (see BRONZE+ as an example below). Ciò significa che sei nella top 50% di quel rango.",
"streams.noStreams": "Nessun match streammato al momento",
"streams.ownStreamInfo": "La tua stream è mancante? Assicurati che il tuo account Twitch sia collegato e il gioco settato sia Splatoon 3.",
"streams.ownStreamInfo.linkText": "Consulta il FAQ per ulteriori informazioni su come collegare il tuo account Twitch."

View File

@ -138,5 +138,6 @@
"progression.error.NAME_MISSING": "Nome bracket mancante",
"progression.error.NEGATIVE_PROGRESSION": "La progressione negativa è disponibile solo in doppia eliminazione",
"progression.error.NO_SE_SOURCE": "Eliminazione singola non è un bracket sorgente valido",
"progression.error.NO_DE_POSITIVE": "Doppia eliminazione non è valida per progressione positiva"
"progression.error.NO_DE_POSITIVE": "Doppia eliminazione non è valida per progressione positiva",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": ""
}

View File

@ -17,6 +17,7 @@
"forms.showDiscordUniqueName": "Mostra username Discord",
"forms.showDiscordUniqueName.info": "Mostrare il proprio nome unico Discord ({{discordUniqueName}}) pubblicamente?",
"forms.commissionsOpen": "Commissioni aperte",
"forms.commissionsOpen.info": "",
"forms.commissionText": "Info sulle commissioni",
"forms.commissionText.info": "Prezzo, posti liberi o altre info relative al commissionarti",
"forms.customName.info": "Se mancante, viene usato il tuo nome visualizzato Discord: \"{{discordName}}\"",

View File

@ -9,6 +9,8 @@
"commissionsClosed": "依頼の受付なし",
"openCommissionsOnly": "依頼を受付中のアーティストを表示",
"gainPerms": "作品をアップロードしたい場合は私たちのディスコードサーバーのヘルプデスクで許可を得てください。アップロードするには作品の作者でないといけません。また、スプラトゥーン関連の作品のみアップロードできます。",
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "ちょっとした注意: 1) スプラトゥーンの作品のみ追加してください 2) 自分で作成した作品のみ追加してください 3) NSFW(R18系)は NG. 他のユーザーに公表される前に確認プロセスが入ります。",
"forms.description.title": "説明",
"forms.linkedUsers.title": "リンクされたユーザー",

View File

@ -77,6 +77,8 @@
"notifications.text.SCRIM_SCHEDULED": "",
"notifications.title.SCRIM_CANCELED": "",
"notifications.text.SCRIM_CANCELED": "",
"notifications.title.COMMISSIONS_CLOSED": "",
"notifications.text.COMMISSIONS_CLOSED": "",
"auth.errors.aborted": "ログインを中断しました",
"auth.errors.failed": "ログインに失敗しました",
"auth.errors.discordPermissions": "sendou.ink は、Discord のプロファイル名、アバター、SNS連携をサイトのプロファイルに使用します。",

View File

@ -1,5 +1,6 @@
{
"q1": "Plus Server とはなんですか?",
"a1": "",
"q2": "どうやって自分のイベントでバッジプライズを得ることができますか?",
"a2": "2024の九月からバッジはうまく作る技量があれば誰でも作れます。ページの下のリンクを参照してください。",
"q3": "アバターとユーザー名はどうやって更新すればよいですか?",

View File

@ -67,6 +67,7 @@
"settings.sounds.likeReceived": "いいねをもらいました",
"settings.sounds.groupNewMember": "新しいメンバーをグループに入れる",
"settings.sounds.matchStarted": "マッチ開始",
"settings.sounds.tournamentMatchStarted": "",
"settings.mapPool.notOk": "モードにつきステージを {{count}} 個選んでください。(避けるステージに入っていない)",
"settings.misc.header": "他",
"settings.banned": "禁止",

View File

@ -135,5 +135,6 @@
"progression.error.NAME_MISSING": "ブラケットの名前がありません",
"progression.error.NEGATIVE_PROGRESSION": "逆の進行はダブルエリ三ネーションの時のみ可能です",
"progression.error.NO_SE_SOURCE": "シングルエリ三ネーションは妥当なブラケットではないです",
"progression.error.NO_DE_POSITIVE": "ダブルエリミネーションは普通の進行(前向き)では妥当ではないです"
"progression.error.NO_DE_POSITIVE": "ダブルエリミネーションは普通の進行(前向き)では妥当ではないです",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": ""
}

View File

@ -17,6 +17,7 @@
"forms.showDiscordUniqueName": "Discord のユーザー名を表示する",
"forms.showDiscordUniqueName.info": "Discord のユニーク名 ({{discordUniqueName}}) 公表しますか?",
"forms.commissionsOpen": "依頼を受付中",
"forms.commissionsOpen.info": "",
"forms.commissionText": "依頼に関する情報",
"forms.commissionText.info": "価格、受付数、その他依頼に関する情報",
"forms.customName.info": "記入されてない場合ディスコードの表示名 \"{{discordName}}\"を使います",

View File

@ -9,6 +9,8 @@
"commissionsClosed": "",
"openCommissionsOnly": "",
"gainPerms": "",
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "",
"forms.description.title": "",
"forms.linkedUsers.title": "",

View File

@ -77,6 +77,8 @@
"notifications.text.SCRIM_SCHEDULED": "",
"notifications.title.SCRIM_CANCELED": "",
"notifications.text.SCRIM_CANCELED": "",
"notifications.title.COMMISSIONS_CLOSED": "",
"notifications.text.COMMISSIONS_CLOSED": "",
"auth.errors.aborted": "로그인 중단됨",
"auth.errors.failed": "로그인 실패",
"auth.errors.discordPermissions": "sendou.ink 프로필을 위해 디스코드 프로필의 이름, 아바타와 연락처에 대한 접근이 필요합니다.",

View File

@ -1,5 +1,6 @@
{
"q1": "Plus Server는 무엇인가요?",
"a1": "",
"q2": "제 이벤트에 어떻게 배지 상품을 받을 수 있죠?",
"a2": "",
"q3": "어떻게 제 아바타 또는 닉네임을 변경할 수 있죠?",

View File

@ -67,6 +67,7 @@
"settings.sounds.likeReceived": "",
"settings.sounds.groupNewMember": "",
"settings.sounds.matchStarted": "",
"settings.sounds.tournamentMatchStarted": "",
"settings.mapPool.notOk": "",
"settings.misc.header": "",
"settings.banned": "",

View File

@ -135,5 +135,6 @@
"progression.error.NAME_MISSING": "",
"progression.error.NEGATIVE_PROGRESSION": "",
"progression.error.NO_SE_SOURCE": "",
"progression.error.NO_DE_POSITIVE": ""
"progression.error.NO_DE_POSITIVE": "",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": ""
}

View File

@ -17,6 +17,7 @@
"forms.showDiscordUniqueName": "",
"forms.showDiscordUniqueName.info": "",
"forms.commissionsOpen": "",
"forms.commissionsOpen.info": "",
"forms.commissionText": "",
"forms.commissionText.info": "",
"forms.customName.info": "",

View File

@ -11,6 +11,8 @@
"commissionsClosed": "",
"openCommissionsOnly": "",
"gainPerms": "",
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "",
"forms.description.title": "",
"forms.linkedUsers.title": "",

View File

@ -77,6 +77,8 @@
"notifications.text.SCRIM_SCHEDULED": "",
"notifications.title.SCRIM_CANCELED": "",
"notifications.text.SCRIM_CANCELED": "",
"notifications.title.COMMISSIONS_CLOSED": "",
"notifications.text.COMMISSIONS_CLOSED": "",
"auth.errors.aborted": "",
"auth.errors.failed": "",
"auth.errors.discordPermissions": "",

View File

@ -1,5 +1,6 @@
{
"q1": "Wat is de Plus Server?",
"a1": "",
"q2": "Hoe krijg ik een badge prijs voor mijn evenement?",
"a2": "",
"q3": "Hoe update ik mijn avatar of gebruikersnaam?",

View File

@ -67,6 +67,7 @@
"settings.sounds.likeReceived": "",
"settings.sounds.groupNewMember": "",
"settings.sounds.matchStarted": "",
"settings.sounds.tournamentMatchStarted": "",
"settings.mapPool.notOk": "",
"settings.misc.header": "",
"settings.banned": "",

View File

@ -137,5 +137,6 @@
"progression.error.NAME_MISSING": "",
"progression.error.NEGATIVE_PROGRESSION": "",
"progression.error.NO_SE_SOURCE": "",
"progression.error.NO_DE_POSITIVE": ""
"progression.error.NO_DE_POSITIVE": "",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": ""
}

View File

@ -17,6 +17,7 @@
"forms.showDiscordUniqueName": "",
"forms.showDiscordUniqueName.info": "",
"forms.commissionsOpen": "",
"forms.commissionsOpen.info": "",
"forms.commissionText": "",
"forms.commissionText.info": "",
"forms.customName.info": "",

View File

@ -13,6 +13,8 @@
"commissionsClosed": "",
"openCommissionsOnly": "",
"gainPerms": "",
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "",
"forms.description.title": "",
"forms.linkedUsers.title": "",

View File

@ -77,6 +77,8 @@
"notifications.text.SCRIM_SCHEDULED": "",
"notifications.title.SCRIM_CANCELED": "",
"notifications.text.SCRIM_CANCELED": "",
"notifications.title.COMMISSIONS_CLOSED": "",
"notifications.text.COMMISSIONS_CLOSED": "",
"auth.errors.aborted": "Logowanie przerwane",
"auth.errors.failed": "Logowanie nieudane",
"auth.errors.discordPermissions": "Do twojego profilu sendou.ink, ta strona potrzebuje dostęp do twojej nazwy, avataru i połączeń konta Discord.",

View File

@ -1,5 +1,6 @@
{
"q1": "Czym jest Plus Server?",
"a1": "",
"q2": "Jak mieć odznake dla mojego wydarzenia?",
"a2": "",
"q3": "Jak zaktualizować nazwę/avatar na stronie?",

View File

@ -67,6 +67,7 @@
"settings.sounds.likeReceived": "",
"settings.sounds.groupNewMember": "",
"settings.sounds.matchStarted": "",
"settings.sounds.tournamentMatchStarted": "",
"settings.mapPool.notOk": "",
"settings.misc.header": "",
"settings.banned": "",

View File

@ -139,5 +139,6 @@
"progression.error.NAME_MISSING": "",
"progression.error.NEGATIVE_PROGRESSION": "",
"progression.error.NO_SE_SOURCE": "",
"progression.error.NO_DE_POSITIVE": ""
"progression.error.NO_DE_POSITIVE": "",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": ""
}

View File

@ -17,6 +17,7 @@
"forms.showDiscordUniqueName": "",
"forms.showDiscordUniqueName.info": "",
"forms.commissionsOpen": "",
"forms.commissionsOpen.info": "",
"forms.commissionText": "",
"forms.commissionText.info": "",
"forms.customName.info": "",

View File

@ -12,6 +12,8 @@
"commissionsClosed": "Comissões estão fechadas",
"openCommissionsOnly": "Mostrar somente artistas com comissões abertas",
"gainPerms": "Por favor, poste na central de ajuda (helpdesk em Inglês) do nosso Discord para ganhar permissões para fazer o upload de arte. Lembre-se que você precisa ser o artista da arte da qual você está fazendo o upload e que apenas arte relacionada com Splatoon é permitida.",
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "Algumas coisas para lembrar: 1) Só faça upload de arte que envolva Splatoon 2) Só faça o upload de arte que você mesmo(a) fez 3) Sem arte +18. Há um processo de validação antes da arte ser mostrada para outros usuários.",
"forms.description.title": "Descrição",
"forms.linkedUsers.title": "Usuários conectados",

View File

@ -77,6 +77,8 @@
"notifications.text.SCRIM_SCHEDULED": "",
"notifications.title.SCRIM_CANCELED": "",
"notifications.text.SCRIM_CANCELED": "",
"notifications.title.COMMISSIONS_CLOSED": "",
"notifications.text.COMMISSIONS_CLOSED": "",
"auth.errors.aborted": "Login Abortado",
"auth.errors.failed": "Login Falhou",
"auth.errors.discordPermissions": "Para o seu perfil do sendou.ink, o site precisa de acesso ao nome do perfil do seu Discord, incluindo também o avatar e conexões sociais.",

View File

@ -1,5 +1,6 @@
{
"q1": "O que é o Servidor Plus?",
"a1": "",
"q2": "Como conseguir um prêmio de insígnia para o meu evento?",
"a2": "",
"q3": "Como atualizar meu avatar ou nome de usuário?",

View File

@ -67,6 +67,7 @@
"settings.sounds.likeReceived": "Curtida recebida",
"settings.sounds.groupNewMember": "Agrupar novo membro",
"settings.sounds.matchStarted": "A partida começou",
"settings.sounds.tournamentMatchStarted": "",
"settings.mapPool.notOk": "Escolha {{count}} mapas por modo que você não evitou para salvar suas preferências",
"settings.misc.header": "Diversos",
"settings.banned": "Banido(a)",

View File

@ -138,5 +138,6 @@
"progression.error.NAME_MISSING": "",
"progression.error.NEGATIVE_PROGRESSION": "",
"progression.error.NO_SE_SOURCE": "",
"progression.error.NO_DE_POSITIVE": ""
"progression.error.NO_DE_POSITIVE": "",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": ""
}

Some files were not shown because too many files have changed in this diff Show More