Merge branch 'sendou-ink:main' into Replace-TanStack/react-charts-library-2293

This commit is contained in:
lderuijter
2026-06-17 10:42:16 +02:00
committed by GitHub
83 changed files with 1073 additions and 149 deletions

View File

@@ -0,0 +1,59 @@
.root {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.inputRow {
position: relative;
display: flex;
align-items: center;
& input {
padding-right: calc(var(--field-size) + var(--s-1));
}
& button {
position: absolute;
right: var(--s-1-5);
}
}
.picker {
border: var(--border-style);
border-radius: var(--radius-field);
background-color: var(--color-bg-high);
gap: var(--s-1);
padding-top: var(--s-0-5);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(2.25rem, 1fr));
gap: var(--s-1);
max-height: 300px;
overflow-y: auto;
padding: var(--s-2);
}
.glyph {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
border: var(--border-style);
border-radius: var(--radius-selector);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-sm);
cursor: pointer;
&:hover {
background-color: var(--color-bg-higher);
}
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
}

View File

@@ -0,0 +1,165 @@
import clsx from "clsx";
import { Languages } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { SendouButton } from "~/components/elements/Button";
import {
SendouTab,
SendouTabList,
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
import {
IN_GAME_NAME_CHARACTER_CATEGORIES,
IN_GAME_NAME_MAX_LENGTH,
inGameNameLength,
sanitizeInGameName,
} from "~/features/user-page/in-game-name";
import styles from "./IngameNameInput.module.css";
interface IngameNameInputProps
extends Pick<
React.AriaAttributes,
"aria-invalid" | "aria-describedby" | "aria-errormessage" | "aria-required"
> {
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
id?: string;
name?: string;
disabled?: boolean;
placeholder?: string;
}
export function IngameNameInput({
value,
onChange,
onBlur,
id,
name,
disabled,
placeholder,
...ariaProps
}: IngameNameInputProps) {
const { t } = useTranslation(["forms"]);
const [isPickerOpen, setIsPickerOpen] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const selectionRef = React.useRef({ start: value.length, end: value.length });
const pendingCaretRef = React.useRef<number | null>(null);
React.useEffect(() => {
if (pendingCaretRef.current === null) return;
const caret = pendingCaretRef.current;
pendingCaretRef.current = null;
const input = inputRef.current;
input?.focus();
input?.setSelectionRange(caret, caret);
});
const rememberSelection = () => {
const input = inputRef.current;
if (!input) return;
selectionRef.current = {
start: input.selectionStart ?? value.length,
end: input.selectionEnd ?? value.length,
};
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const raw = event.target.value;
const cleaned = sanitizeInGameName(raw);
if (cleaned !== raw) {
const caret = event.target.selectionStart ?? cleaned.length;
pendingCaretRef.current = Math.max(
0,
Math.min(cleaned.length, caret - (raw.length - cleaned.length)),
);
}
onChange(cleaned);
};
const insertCharacter = (character: string) => {
const { start, end } = selectionRef.current;
const before = value.slice(0, start);
const after = value.slice(end);
if (
inGameNameLength(before + after) + inGameNameLength(character) >
IN_GAME_NAME_MAX_LENGTH
) {
return;
}
const caret = before.length + character.length;
pendingCaretRef.current = caret;
selectionRef.current = { start: caret, end: caret };
onChange(before + character + after);
};
return (
<div className={styles.root}>
<div className={styles.inputRow}>
<input
ref={inputRef}
id={id}
name={name}
type="text"
value={value}
disabled={disabled}
placeholder={placeholder}
maxLength={IN_GAME_NAME_MAX_LENGTH}
autoComplete="off"
spellCheck={false}
onChange={handleChange}
onBlur={onBlur}
onSelect={rememberSelection}
{...ariaProps}
/>
<SendouButton
variant="minimal"
size="small"
shape="square"
icon={<Languages />}
isDisabled={disabled}
aria-label={t("forms:inGameName.addCharacter")}
aria-expanded={isPickerOpen}
onPress={() => setIsPickerOpen((open) => !open)}
/>
</div>
{isPickerOpen ? (
<SendouTabs className={styles.picker}>
<SendouTabList>
{IN_GAME_NAME_CHARACTER_CATEGORIES.map((category) => (
<SendouTab key={category.id} id={category.id}>
{t(category.label)}
</SendouTab>
))}
</SendouTabList>
{IN_GAME_NAME_CHARACTER_CATEGORIES.map((category) => (
<SendouTabPanel key={category.id} id={category.id}>
<div className={clsx(styles.grid, "scrollbar")}>
{category.characters.map((character) => (
<button
key={character}
type="button"
className={styles.glyph}
onMouseDown={(event) => event.preventDefault()}
onClick={() => insertCharacter(character)}
>
{character}
</button>
))}
</div>
</SendouTabPanel>
))}
</SendouTabs>
) : null}
</div>
);
}

View File

@@ -7,14 +7,16 @@ import styles from "./SubNav.module.css";
export function SubNav({
children,
secondary,
className,
}: {
children: React.ReactNode;
secondary?: boolean;
className?: string;
}) {
return (
<div>
<nav
className={clsx(styles.container, {
className={clsx(styles.container, className, {
[styles.secondary]: secondary,
})}
>

View File

@@ -1229,6 +1229,15 @@ export interface TournamentStreamer {
twitchAccount: string;
}
export interface ExternalStream {
id: GeneratedAlways<number>;
name: string;
url: string;
avatarImgId: number | null;
startTime: number;
createdAt: Generated<number>;
}
export interface TournamentMatchVod {
id: GeneratedAlways<number>;
matchId: number;
@@ -1473,6 +1482,7 @@ export interface DB {
CalendarEventDate: CalendarEventDate;
CalendarEventResultPlayer: CalendarEventResultPlayer;
CalendarEventResultTeam: CalendarEventResultTeam;
ExternalStream: ExternalStream;
Group: Group;
GroupLike: GroupLike;

View File

@@ -0,0 +1,80 @@
import { db } from "~/db/sql";
import type { TablesInsertable } from "~/db/tables";
import { databaseTimestampNow } from "~/utils/dates";
import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server";
/** Number of seconds an external stream keeps showing in the sidebar after its start time. */
const SIDEBAR_VISIBLE_SECONDS = 6 * 60 * 60;
/** Number of seconds after the start time before an external stream row is deleted. */
const RETENTION_SECONDS = 24 * 60 * 60;
/** Inserts a new admin-curated external stream. */
export function insert(
args: Pick<
TablesInsertable["ExternalStream"],
"name" | "url" | "avatarImgId" | "startTime"
>,
) {
return db.insertInto("ExternalStream").values(args).execute();
}
/** Deletes an external stream by its id. */
export function deleteById(id: number) {
return db.deleteFrom("ExternalStream").where("id", "=", id).execute();
}
/** Lists all external streams (for the admin management page), soonest start time first. */
export function all() {
return db
.selectFrom("ExternalStream")
.leftJoin(
"UserSubmittedImage",
"UserSubmittedImage.id",
"ExternalStream.avatarImgId",
)
.select((eb) => [
"ExternalStream.id",
"ExternalStream.name",
"ExternalStream.url",
"ExternalStream.startTime",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"avatarUrl",
),
])
.orderBy("ExternalStream.startTime", "asc")
.execute();
}
/** External streams that should currently show in the sidebar (started under 6h ago or upcoming). */
export function forSidebar() {
return db
.selectFrom("ExternalStream")
.leftJoin(
"UserSubmittedImage",
"UserSubmittedImage.id",
"ExternalStream.avatarImgId",
)
.select((eb) => [
"ExternalStream.id",
"ExternalStream.name",
"ExternalStream.url",
"ExternalStream.startTime",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"avatarUrl",
),
])
.where(
"ExternalStream.startTime",
">=",
databaseTimestampNow() - SIDEBAR_VISIBLE_SECONDS,
)
.execute();
}
/** Deletes external streams whose start time is more than 24h in the past. */
export function deleteOld() {
return db
.deleteFrom("ExternalStream")
.where("startTime", "<", databaseTimestampNow() - RETENTION_SECONDS)
.executeTakeFirst();
}

View File

@@ -0,0 +1,46 @@
import type { ActionFunctionArgs } from "react-router";
import { clearCombinedStreamsCache } from "~/features/core/streams/streams.server";
import { parseFormDataWithImages } from "~/form/parse.server";
import { requireRole } from "~/modules/permissions/guards.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { assertUnreachable } from "~/utils/types";
import { externalStreamActionSchema } from "../admin-schemas";
import * as ExternalStreamRepository from "../ExternalStreamRepository.server";
export const action = async ({ request }: ActionFunctionArgs) => {
requireRole("ADMIN");
const result = await parseFormDataWithImages({
request,
schema: externalStreamActionSchema,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
switch (data._action) {
case "CREATE": {
await ExternalStreamRepository.insert({
name: data.name,
url: data.url,
avatarImgId: data.avatar,
startTime: dateToDatabaseTimestamp(data.startTime),
});
break;
}
case "DELETE": {
await ExternalStreamRepository.deleteById(data.id);
break;
}
default: {
assertUnreachable(data);
}
}
clearCombinedStreamsCache();
return null;
};

View File

@@ -1,6 +1,34 @@
import { z } from "zod";
import { friendCode } from "~/utils/zod";
import {
datetimeRequired,
image,
stringConstant,
textFieldRequired,
} from "~/form/fields";
import { friendCode, id } from "~/utils/zod";
export const adminActionSearchParamsSchema = z.object({
friendCode,
});
export const createExternalStreamSchema = z.object({
_action: stringConstant("CREATE"),
name: textFieldRequired({ label: "labels.name", maxLength: 64 }),
url: textFieldRequired({
label: "labels.link",
maxLength: 200,
validate: "url",
}),
avatar: image({ label: "labels.logo", autoValidate: true }),
startTime: datetimeRequired({ label: "labels.startTime" }),
});
const deleteExternalStreamSchema = z.object({
_action: stringConstant("DELETE"),
id,
});
export const externalStreamActionSchema = z.union([
createExternalStreamSchema,
deleteExternalStreamSchema,
]);

View File

@@ -0,0 +1,10 @@
import { requireRole } from "~/modules/permissions/guards.server";
import * as ExternalStreamRepository from "../ExternalStreamRepository.server";
export const loader = async () => {
requireRole("ADMIN");
return {
streams: await ExternalStreamRepository.all(),
};
};

View File

@@ -0,0 +1,12 @@
.streamRow {
display: flex;
align-items: center;
gap: var(--s-2);
}
.streamAvatar {
width: 2.5rem;
height: 2.5rem;
border-radius: 100%;
object-fit: cover;
}

View File

@@ -0,0 +1,98 @@
import type { MetaFunction } from "react-router";
import { Link, useLoaderData } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { LocaleTime } from "~/components/LocaleTime";
import { Main } from "~/components/Main";
import { SendouForm } from "~/form/SendouForm";
import { metaTags } from "~/utils/remix";
import { action } from "../actions/admin.streams.server";
import { createExternalStreamSchema } from "../admin-schemas";
import { loader } from "../loaders/admin.streams.server";
import styles from "./admin.streams.module.css";
export { action, loader };
export const meta: MetaFunction = (args) => {
return metaTags({
title: "External streams",
location: args.location,
});
};
export default function AdminStreamsPage() {
return (
<Main className="stack lg">
<SendouForm
schema={createExternalStreamSchema}
title="Add external stream"
>
{({ FormField }) => (
<>
<FormField name="name" />
<FormField name="url" />
<FormField name="avatar" />
<FormField name="startTime" />
</>
)}
</SendouForm>
<ExternalStreamList />
</Main>
);
}
function ExternalStreamList() {
const { streams } = useLoaderData<typeof loader>();
if (streams.length === 0) {
return <div className="text-lighter">No external streams</div>;
}
return (
<div className="stack md">
<h2>Current external streams</h2>
<ul className="stack sm">
{streams.map((stream) => (
<li key={stream.id} className={styles.streamRow}>
{stream.avatarUrl ? (
<img
src={stream.avatarUrl}
alt=""
className={styles.streamAvatar}
/>
) : null}
<div className="stack xxs">
<Link to={stream.url} className="text-main-forced">
{stream.name}
</Link>
<span className="text-xs text-lighter">
<LocaleTime
date={stream.startTime}
inline
options={{
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
}}
/>
</span>
</div>
<FormWithConfirm
dialogHeading={`Delete external stream "${stream.name}"?`}
fields={[
["_action", "DELETE"],
["id", stream.id],
]}
>
<SendouButton variant="minimal-destructive" className="ml-auto">
Delete
</SendouButton>
</FormWithConfirm>
</li>
))}
</ul>
</div>
);
}

View File

@@ -6,7 +6,7 @@ import {
clearTournamentDataCache,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import { IN_GAME_NAME_REGEXP } from "~/features/user-page/user-page-constants";
import { inGameNameIsValid } from "~/features/user-page/in-game-name";
import {
badRequestIfFalsy,
errorToastIfFalsy,
@@ -23,7 +23,7 @@ const paramsSchema = z.object({
const bodySchema = z.object({
userId: id,
inGameName: z.string().regex(IN_GAME_NAME_REGEXP),
inGameName: z.string().refine(inGameNameIsValid),
});
export const action = async (args: ActionFunctionArgs) => {

View File

@@ -42,6 +42,8 @@ export interface CalendarEvent extends CommonEvent {
export interface ShowcaseCalendarEvent extends CommonEvent {
type: "showcase";
startTime: number;
/** Id of the organization the event belongs to, if any */
organizationId: number | null;
/** Tournament is hidden from the public (test tournament) */
hidden: boolean;
isFinalized: boolean;

View File

@@ -6,16 +6,25 @@ import {
tournamentToSidebarEvent,
} from "~/features/sidebar/core/sidebar.server";
import * as SavedCalendarEventRepository from "~/features/tournament/SavedCalendarEventRepository.server";
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
export type EventsLoaderData = typeof loader;
export const loader = async () => {
const user = requireUser();
const [tournamentsData, scrimsData, savedTournaments] = await Promise.all([
const [
tournamentsData,
scrimsData,
savedTournaments,
upcomingTournaments,
userOrganizations,
] = await Promise.all([
ShowcaseTournaments.categorizedTournamentsByUserId(user.id),
ScrimPostRepository.findUserScrims(user.id),
SavedCalendarEventRepository.upcoming(user.id),
ShowcaseTournaments.upcomingTournaments(),
TournamentOrganizationRepository.findByUserId(user.id),
]);
const registered = tournamentsData.participatingFor
@@ -34,5 +43,16 @@ export const loader = async () => {
.map(tournamentToSidebarEvent)
.sort((a, b) => a.startTime - b.startTime);
return { registered, hosting, scrims, saved };
const userOrganizationIds = new Set(userOrganizations.map((org) => org.id));
const organization = upcomingTournaments
.filter(
(tournament) =>
!tournament.hidden &&
tournament.organizationId !== null &&
userOrganizationIds.has(tournament.organizationId),
)
.map(tournamentToSidebarEvent)
.sort((a, b) => a.startTime - b.startTime);
return { registered, hosting, scrims, saved, organization };
};

View File

@@ -6,3 +6,7 @@
gap: var(--s-2);
margin-block-end: var(--s-2);
}
.subNav {
justify-content: start;
}

View File

@@ -14,7 +14,13 @@ export const handle: SendouRouteHandle = {
i18n: ["calendar"],
};
const VIEW_FILTERS = ["registered", "hosting", "scrims", "saved"] as const;
const VIEW_FILTERS = [
"registered",
"hosting",
"scrims",
"saved",
"organization",
] as const;
type ViewFilter = (typeof VIEW_FILTERS)[number];
export default function EventsPage() {
@@ -33,29 +39,19 @@ export default function EventsPage() {
hosting: `${t("calendar:events.view.hosting")} (${data.hosting.length})`,
scrims: `${t("calendar:events.view.scrims")} (${data.scrims.length})`,
saved: `${t("calendar:events.view.saved")} (${data.saved.length})`,
organization: `${t("calendar:events.view.organization")} (${data.organization.length})`,
};
const shownEvents =
filter === "registered"
? data.registered
: filter === "hosting"
? data.hosting
: filter === "saved"
? data.saved
: data.scrims;
const shownEvents = data[filter];
const hasNoEventsAtAll =
data.registered.length === 0 &&
data.hosting.length === 0 &&
data.scrims.length === 0 &&
data.saved.length === 0;
const hasNoEventsAtAll = VIEW_FILTERS.every((key) => data[key].length === 0);
return (
<Main halfWidth>
<div className={styles.eventsListHeader}>
<h2 className="text-lg mx-2">{t("calendar:events.title")}</h2>
{hasNoEventsAtAll ? null : (
<SubNav secondary>
<SubNav secondary className={styles.subNav}>
{VIEW_FILTERS.map((value) => (
<SubNavLink
key={value}

View File

@@ -275,10 +275,6 @@ async function tournamentsToParticipationInfoMap(
addToMap(userId, tournament.id, "organizer");
}
for (const { userId } of tournament.organizationMembers) {
addToMap(userId, tournament.id, "organizer");
}
addToMap(tournament.authorId, tournament.id, "organizer");
}
@@ -304,6 +300,7 @@ function mapTournamentFromDB(
url: tournamentPage(tournament.id),
id: tournament.id,
authorId: tournament.authorId,
organizationId: tournament.organizationId,
name: tournament.name,
startTime: tournament.startTime,
teamsCount: tournament.teamsCount,

View File

@@ -4,6 +4,12 @@ import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
type RankedStream = { stream: SidebarStream; score: number };
/**
* Score for admin-curated external streams. Below every other source's minimum (0) so they sort
* to the top of the ranking, reserving the first sidebar slots.
*/
export const EXTERNAL_STREAM_SCORE = -1;
export function rank(
streams: RankedStream[],
maxStreams: number,

View File

@@ -2,6 +2,7 @@ import { cachified } from "@epic-web/cachified";
import { addDays } from "date-fns";
import { href } from "react-router";
import * as R from "remeda";
import * as ExternalStreamRepository from "~/features/admin/ExternalStreamRepository.server";
import { userIsBanned } from "~/features/ban/core/banned.server";
import type { ShowcaseCalendarEvent } from "~/features/calendar/calendar-types";
import {
@@ -137,11 +138,13 @@ function combinedStreamsCached(): Promise<SidebarStream[]> {
async function combinedStreams(): Promise<SidebarStream[]> {
const tournamentStreams = getLiveTournamentStreams();
const [sendouQEntries, xRankRows, upcomingTournaments] = await Promise.all([
getSendouQSidebarStreams(),
LiveStreamRepository.findXRankStreams(),
ShowcaseTournaments.upcomingTournaments(),
]);
const [sendouQEntries, xRankRows, upcomingTournaments, externalStreams] =
await Promise.all([
getSendouQSidebarStreams(),
LiveStreamRepository.findXRankStreams(),
ShowcaseTournaments.upcomingTournaments(),
ExternalStreamRepository.forSidebar(),
]);
const seenUsernames = new Set([
...getLiveTournamentStreamerTwitchNames(),
@@ -152,6 +155,21 @@ async function combinedStreams(): Promise<SidebarStream[]> {
const ranked: { stream: SidebarStream; score: number }[] = [];
for (const externalStream of externalStreams) {
ranked.push({
stream: {
id: `external-${externalStream.id}`,
name: externalStream.name,
imageUrl: externalStream.avatarUrl ?? BLANK_IMAGE_URL,
url: externalStream.url,
subtitle: "",
startsAt: externalStream.startTime,
tier: null,
},
score: StreamRanking.EXTERNAL_STREAM_SCORE,
});
}
for (const stream of tournamentStreams) {
ranked.push({
stream,

View File

@@ -5,6 +5,7 @@ import {
fieldset,
idConstantOptional,
image,
inGameName,
selectDynamic,
stringConstant,
teamSearchOptional,
@@ -14,21 +15,12 @@ import {
userSearch,
} from "~/form/fields";
import { TEAM } from "../team/team-constants";
import { IN_GAME_NAME_REGEXP } from "../user-page/user-page-constants";
/** Combined in-game name e.g. `Sendou#1234` is at most 10 + `#` + 5 characters. */
const IN_GAME_NAME_MAX_LENGTH = 16;
const memberFieldset = fieldset({
fields: z.object({
userId: userSearch({ label: "labels.player" }),
inGameName: textFieldOptional({
inGameName: inGameName({
label: "labels.inGameName",
maxLength: IN_GAME_NAME_MAX_LENGTH,
regExp: {
pattern: IN_GAME_NAME_REGEXP,
message: "forms:errors.profileInGameName",
},
}),
}),
});

View File

@@ -465,25 +465,6 @@ export function relatedUsersByTournamentIds(tournamentIds: number[]) {
.whereRef("TournamentStaff.tournamentId", "=", "Tournament.id")
.where("TournamentStaff.role", "=", "ORGANIZER"),
).as("staff"),
jsonArrayFrom(
eb
.selectFrom("TournamentOrganization")
.innerJoin(
"TournamentOrganizationMember",
"TournamentOrganization.id",
"TournamentOrganizationMember.organizationId",
)
.select(["TournamentOrganizationMember.userId"])
.whereRef(
"TournamentOrganization.id",
"=",
"CalendarEvent.organizationId",
)
.where("TournamentOrganizationMember.role", "in", [
"ADMIN",
"ORGANIZER",
]),
).as("organizationMembers"),
jsonArrayFrom(
eb
.selectFrom("TournamentTeam")
@@ -499,7 +480,6 @@ export function relatedUsersByTournamentIds(tournamentIds: number[]) {
.where("Tournament.id", "in", tournamentIds)
.$narrowType<{
staff: NotNull;
organizationMembers: NotNull;
teamMembers: NotNull;
}>()
.execute();

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { inGameNameIsValid } from "./in-game-name";
describe("inGameNameIsValid", () => {
it("should pass valid in-game names", () => {
const validNames = [
"Sendou#12345",
"The Player#12345",
" a#1234",
"A#1234",
"Player#abcd",
"Café#1234",
"Ελλαδα#1234",
"テストab#1234",
"★Test★#1234",
"½#1234",
"naïve#1234",
];
for (const name of validNames) {
expect(inGameNameIsValid(name), `expected "${name}" to pass`).toBe(true);
}
});
it("should not pass invalid in-game names", () => {
const invalidNames = [
"#1234",
"Sendou1234",
"Sendou#123",
"Sendou# 1234",
"Sendou#123456",
"Sendou#ABCD",
"12345678901#1234",
"𝓔𝓔𝓔𝓔𝓔𝓔𝓔𝓔𝓔𝓔𝓔#1234",
"名前テスト1234#ab12c",
"☆CR☆Sh𝓔𝓔p!#1234",
"日本語#1234",
];
for (const name of invalidNames) {
expect(inGameNameIsValid(name), `expected "${name}" to fail`).toBe(false);
}
});
it("should reject characters the Switch keyboard does not allow in names", () => {
const invalidNames = [
"test@me#1234",
"100%#1234",
"a\\b#1234",
"●#1234",
"♥#1234",
];
for (const name of invalidNames) {
expect(inGameNameIsValid(name), `expected "${name}" to fail`).toBe(false);
}
});
});

View File

@@ -0,0 +1,107 @@
import type { FormsTranslationKey } from "~/form/types";
const IN_GAME_NAME = {
NAME_MAX_LENGTH: 10,
DISCRIMINATOR_MIN_LENGTH: 4,
DISCRIMINATOR_MAX_LENGTH: 5,
};
export const IN_GAME_NAME_MAX_LENGTH =
IN_GAME_NAME.NAME_MAX_LENGTH + 1 + IN_GAME_NAME.DISCRIMINATOR_MAX_LENGTH;
/**
* @see {@link https://github.com/kjhf/NintendoSwitchKeyboard}
*/
export const IN_GAME_NAME_CHARACTER_CATEGORIES = [
{
id: "symbols",
label: "inGameName.categories.symbols",
characters: [
..."¿¡′‘’‚‛•…″“”„«»←→↑↓⇒⇔˜ˊˋ¢€£¥¤𝑓×÷±∞√¬∀⊂⊃∴∵⁀∂№°¹²³¼½¾♪♭♀♂⚪⚫◎◻◼◇◆△▲▽▼☆★©®™§¶†⍑",
],
},
{
id: "accented",
label: "inGameName.categories.accented",
characters: [
..."àáâãäåæāăąçćċčðďdždzèéêëēęěğġģħìíîïīįıijķĺļľłÀÁÂÃÄÅÆĀĂĄÇĆĊČÐĎDžDzÈÉÊËĒĘĚĞĠĢĦÌÍÎÏĪĮİIJĶĹĻĽŁñńņňòóôõöøœőŕřšßśşþťţùúûüūůűųýÿźżžÑŃŅŇÒÓÔÕÖØŒŐŔŘŠẞŚŞÞŤŢÙÚÛÜŪŮŰŲÝŸŹŻŽ",
],
},
{
id: "greek",
label: "inGameName.categories.greek",
characters: [..."αβγδεζηθικλμνξοπρστυφχψωΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ"],
},
{
id: "cyrillic",
label: "inGameName.categories.cyrillic",
characters: range(0x0410, 0x044f),
},
{
id: "hiragana",
label: "inGameName.categories.hiragana",
characters: range(0x3041, 0x3096),
},
{
id: "katakana",
label: "inGameName.categories.katakana",
characters: range(0x30a1, 0x30fa),
},
] as const satisfies ReadonlyArray<{
id: string;
label: FormsTranslationKey;
characters: ReadonlyArray<string>;
}>;
const SPECIAL_CHARACTERS = IN_GAME_NAME_CHARACTER_CATEGORIES.flatMap(
(category) => category.characters,
);
const ASCII_NOT_VALID = new Set(["%", "@", "\\"]);
const ASCII_CHARACTERS = range(0x20, 0x7e).filter(
(character) => !ASCII_NOT_VALID.has(character),
);
const ALLOWED_CHARACTERS = new Set<string>([
...ASCII_CHARACTERS,
...SPECIAL_CHARACTERS,
]);
const IN_GAME_NAME_REGEXP = new RegExp(
`^(.+)#([0-9a-z]{${IN_GAME_NAME.DISCRIMINATOR_MIN_LENGTH},${IN_GAME_NAME.DISCRIMINATOR_MAX_LENGTH}})$`,
"u",
);
/** Length of a string counted in code points (so astral characters count as one). */
export function inGameNameLength(value: string): number {
return [...value].length;
}
export function sanitizeInGameName(value: string): string {
return [...value.normalize("NFC")]
.filter((character) => ALLOWED_CHARACTERS.has(character))
.join("");
}
export function inGameNameIsValid(value: string): boolean {
const match = IN_GAME_NAME_REGEXP.exec(value);
if (!match) return false;
const nameCharacters = [...match[1]];
if (
nameCharacters.length < 1 ||
nameCharacters.length > IN_GAME_NAME.NAME_MAX_LENGTH
) {
return false;
}
return nameCharacters.every((character) => ALLOWED_CHARACTERS.has(character));
}
function range(from: number, to: number): string[] {
const characters: string[] = [];
for (let codePoint = from; codePoint <= to; codePoint++) {
characters.push(String.fromCodePoint(codePoint));
}
return characters;
}

View File

@@ -1,41 +0,0 @@
import { describe, expect, it } from "vitest";
import { IN_GAME_NAME_REGEXP } from "./user-page-constants";
describe("IN_GAME_NAME_REGEXP", () => {
it("should pass valid in-game names", () => {
const validNames = [
"Sendou#12345",
"The Player#12345",
" a#1234",
"A#1234",
"Player#abcd",
"名前テスト1234#ab12c",
"☆CR☆Sh𝓔𝓔p!#1234",
];
for (const name of validNames) {
expect(IN_GAME_NAME_REGEXP.test(name), `expected "${name}" to pass`).toBe(
true,
);
}
});
it("should not pass invalid in-game names", () => {
const invalidNames = [
"#1234",
"Sendou1234",
"Sendou#123",
"Sendou# 1234",
"Sendou#123456",
"Sendou#ABCD",
"12345678901#1234",
"𝓔𝓔𝓔𝓔𝓔𝓔𝓔𝓔𝓔𝓔𝓔#1234",
];
for (const name of invalidNames) {
expect(IN_GAME_NAME_REGEXP.test(name), `expected "${name}" to fail`).toBe(
false,
);
}
});
});

View File

@@ -7,8 +7,6 @@ export const USER = {
CUSTOM_URL_MAX_LENGTH: 32,
CUSTOM_NAME_MAX_LENGTH: 32,
BATTLEFY_MAX_LENGTH: 32,
IN_GAME_NAME_TEXT_MAX_LENGTH: 20,
IN_GAME_NAME_DISCRIMINATOR_MAX_LENGTH: 5,
WEAPON_POOL_MAX_SIZE: 5,
COMMISSION_TEXT_MAX_LENGTH: 1000,
MOD_NOTE_MAX_LENGTH: 2000,
@@ -20,8 +18,6 @@ export const USER = {
export const SPL2_JOIN_ORDER_CUTOFF = 13_589;
export const IN_GAME_NAME_REGEXP = /^.{1,10}#[0-9a-z]{4,5}$/u;
export const MATCHES_PER_SEASONS_PAGE = 8;
export const RESULTS_PER_PAGE = 25;
export const HIGHLIGHTS_RESULTS_MAX = 500;

View File

@@ -9,6 +9,7 @@ import {
dualSelectOptional,
idConstantOptional,
image,
inGameName,
selectDynamicOptional,
stringConstant,
textAreaOptional,
@@ -38,7 +39,6 @@ import { allWidgetsFlat, findWidgetById } from "./core/widgets/portfolio";
import {
HIGHLIGHT_CHECKBOX_NAME,
HIGHLIGHT_TOURNAMENT_CHECKBOX_NAME,
IN_GAME_NAME_REGEXP,
USER,
} from "./user-page-constants";
@@ -87,17 +87,9 @@ export const userEditProfileBaseSchema = z.object({
message: "forms:errors.profileCustomUrlNumbers",
},
}),
inGameName: textFieldOptional({
inGameName: inGameName({
label: "labels.inGameName",
bottomText: "bottomTexts.profileInGameName",
maxLength:
USER.IN_GAME_NAME_TEXT_MAX_LENGTH +
1 +
USER.IN_GAME_NAME_DISCRIMINATOR_MAX_LENGTH,
regExp: {
pattern: IN_GAME_NAME_REGEXP,
message: "forms:errors.profileInGameName",
},
}),
sensitivity: dualSelectOptional({
fields: [

View File

@@ -8,6 +8,7 @@ import { DatetimeFormField } from "./fields/DatetimeFormField";
import { DualSelectFormField } from "./fields/DualSelectFormField";
import { FieldsetFormField } from "./fields/FieldsetFormField";
import { ImageFormField } from "./fields/ImageFormField";
import { InGameNameFormField } from "./fields/InGameNameFormField";
import { InputFormField } from "./fields/InputFormField";
import {
CheckboxGroupFormField,
@@ -189,6 +190,18 @@ export function FormField({
);
}
if (formField.type === "in-game-name") {
return (
<InGameNameFormField
{...commonProps}
{...formField}
disabled={disabled}
value={value as string}
onChange={handleChange as (v: string) => void}
/>
);
}
if (formField.type === "switch") {
return (
<SwitchFormField

View File

@@ -1,5 +1,9 @@
import * as R from "remeda";
import { z } from "zod";
import {
IN_GAME_NAME_MAX_LENGTH,
inGameNameIsValid,
} from "~/features/user-page/in-game-name";
import { canonicalWeaponSplId } from "~/modules/in-game-lists/weapon-ids";
import {
date,
@@ -199,6 +203,29 @@ function textFieldRefined<T extends z.ZodType<string | null>>(
return result as T;
}
export function inGameName(
args: WithTypedTranslationKeys<{
label?: FormsTranslationKey;
bottomText?: FormsTranslationKey;
}>,
) {
const schema = safeNullableStringSchema({
max: IN_GAME_NAME_MAX_LENGTH,
}).refine((val) => val === null || inGameNameIsValid(val), {
message: "forms:errors.profileInGameName",
});
return schema.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
maxLength: IN_GAME_NAME_MAX_LENGTH,
required: false,
type: "in-game-name",
initialValue: "",
});
}
export function numberField(
args: WithTypedTranslationKeys<
Omit<

View File

@@ -0,0 +1,49 @@
import * as React from "react";
import { IngameNameInput } from "~/components/IngameNameInput";
import { inGameNameLength } from "~/features/user-page/in-game-name";
import type { FormFieldProps } from "../types";
import { ariaAttributes } from "../utils";
import { FormFieldWrapper } from "./FormFieldWrapper";
type InGameNameFormFieldProps = FormFieldProps<"in-game-name"> & {
disabled?: boolean;
value: string;
onChange: (value: string) => void;
};
export function InGameNameFormField({
name,
label,
bottomText,
maxLength,
error,
onBlur,
required,
disabled,
value,
onChange,
}: InGameNameFormFieldProps) {
const id = React.useId();
return (
<FormFieldWrapper
id={id}
name={name}
label={label}
required={required}
error={error}
bottomText={bottomText}
valueLimits={{ current: inGameNameLength(value), max: maxLength }}
>
<IngameNameInput
id={id}
name={name}
value={value}
onChange={onChange}
onBlur={() => onBlur?.()}
disabled={disabled}
{...ariaAttributes({ id, bottomText, error, required })}
/>
</FormFieldWrapper>
);
}

View File

@@ -45,6 +45,11 @@ interface FormFieldTextarea<T extends string> extends FormFieldBase<T> {
maxLength: number;
}
interface FormFieldInGameName<T extends string> extends FormFieldBase<T> {
maxLength: number;
required: boolean;
}
interface FormFieldItem<V extends string> {
label: string | number | ((lang: string) => string);
value: V;
@@ -181,6 +186,7 @@ interface FormFieldWeaponSelect<T extends string> extends FormFieldBase<T> {
export type FormField<V extends string = string> =
| FormFieldBase<"custom">
| FormFieldText<"text-field">
| FormFieldInGameName<"in-game-name">
| FormFieldTextarea<"text-area">
| FormFieldBase<"switch">
| FormFieldSelect<"select", V>

View File

@@ -268,6 +268,7 @@ export default [
]),
route("/admin", "features/admin/routes/admin.tsx"),
route("/admin/streams", "features/admin/routes/admin.streams.tsx"),
route("/api/chat-users", "features/chat/routes/api.chat-users.ts"),
route("/room", "features/chat/routes/room.ts"),
route("/api", "features/api/routes/api.tsx"),

View File

@@ -0,0 +1,11 @@
import * as ExternalStreamRepository from "../features/admin/ExternalStreamRepository.server";
import { logger } from "../utils/logger";
import { Routine } from "./routine.server";
export const DeleteOldExternalStreamsRoutine = new Routine({
name: "DeleteOldExternalStreams",
func: async () => {
const { numDeletedRows } = await ExternalStreamRepository.deleteOld();
logger.info(`Deleted ${numDeletedRows} old external streams`);
},
});

View File

@@ -1,6 +1,7 @@
import { CloseExpiredCommissionsRoutine } from "./closeExpiredCommissions";
import { CloseExpiredContinueVotesRoutine } from "./closeExpiredContinueVotes";
import { DeleteObsoleteMatchVodsRoutine } from "./deleteObsoleteMatchVods";
import { DeleteOldExternalStreamsRoutine } from "./deleteOldExternalStreams";
import { DeleteOldNotificationsRoutine } from "./deleteOldNotifications";
import { DeleteOldRoomLinksRoutine } from "./deleteOldRoomLinks";
import { DeleteOldTournamentAuditLogsRoutine } from "./deleteOldTournamentAuditLogs";
@@ -32,6 +33,7 @@ export const everyHourAt30 = [
UpdatePatreonDataRoutine,
CloseExpiredContinueVotesRoutine,
DeleteOldRoomLinksRoutine,
DeleteOldExternalStreamsRoutine,
];
/** List of Routines that should occur daily */

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -293,8 +293,21 @@ async function selectMapWinner(page: Page, winner: "ALPHA" | "BRAVO") {
page.locator('[data-testid^="winner-radio-"][data-selected="true"]'),
).toHaveCount(0);
// react-aria's Radio renders a hidden input behind a span overlay; click the
// wrapping label so the press handler fires and updates winnerId.
await page.locator(`label:has(input[aria-label="${teamName}"])`).click();
// wrapping label so the press handler fires and updates winnerId. The press
// occasionally registers a press-start without a press-end (same React Aria
// nondeterminism as in waitForPOSTResponse), so the selection silently drops
// and Submit stays disabled. Re-issue the click until the radio reports
// selected; otherwise the Submit-click retry loop spins on a disabled button.
const label = page.locator(`label:has(input[aria-label="${teamName}"])`);
const radio = page.locator(
`[data-testid^="winner-radio-"]:has(input[aria-label="${teamName}"])`,
);
await expect(async () => {
await label.click();
await expect(radio).toHaveAttribute("data-selected", "true", {
timeout: 1_000,
});
}).toPass();
}
async function voteNo(page: Page) {

View File

@@ -85,6 +85,7 @@
"events.view.hosting": "",
"events.view.scrims": "",
"events.view.saved": "",
"events.view.organization": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""

View File

@@ -4,6 +4,7 @@
"labels.bio": "Biografi",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.teamEditor": "",
@@ -263,6 +264,13 @@
"errors.profileCustomUrlDuplicate": "Brugerdefineret URL er allerede i brug",
"errors.profileSensBothOrNeither": "Bevægelsesfølsomhed kan ikke indstilles før at Styrepindsfølsomheden er indstillet",
"errors.profileInGameName": "",
"inGameName.addCharacter": "",
"inGameName.categories.symbols": "",
"inGameName.categories.accented": "",
"inGameName.categories.greek": "",
"inGameName.categories.cyrillic": "",
"inGameName.categories.hiragana": "",
"inGameName.categories.katakana": "",
"labels.pronoun": "",
"bottomTexts.profilePronouns": "",
"errors.profilePronounsBothOrNeither": "",

View File

@@ -85,6 +85,7 @@
"events.view.hosting": "",
"events.view.scrims": "",
"events.view.saved": "",
"events.view.organization": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""

View File

@@ -4,6 +4,7 @@
"labels.bio": "Über mich",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.teamEditor": "",
@@ -263,6 +264,13 @@
"errors.profileCustomUrlDuplicate": "Diese Benutzerdefinierte URL wird bereits verwendet",
"errors.profileSensBothOrNeither": "Empfindlichkeit der Bewegungssteuerung kann nur festgelegt werden, wenn Empfindlichkeit R-Stick festgelegt ist",
"errors.profileInGameName": "",
"inGameName.addCharacter": "",
"inGameName.categories.symbols": "",
"inGameName.categories.accented": "",
"inGameName.categories.greek": "",
"inGameName.categories.cyrillic": "",
"inGameName.categories.hiragana": "",
"inGameName.categories.katakana": "",
"labels.pronoun": "",
"bottomTexts.profilePronouns": "",
"errors.profilePronounsBothOrNeither": "",

View File

@@ -85,6 +85,7 @@
"events.view.hosting": "Hosting",
"events.view.scrims": "Scrims",
"events.view.saved": "Saved",
"events.view.organization": "Organization",
"events.empty": "No events in this category",
"events.emptyAll": "You have no upcoming events.",
"events.findOnCalendar": "Find an event to join on the calendar!"

View File

@@ -4,6 +4,7 @@
"labels.bio": "Bio",
"labels.logo": "Logo",
"labels.banner": "Banner",
"labels.link": "Link",
"labels.tag": "Tag",
"labels.teamBsky": "Team Bluesky",
"labels.teamEditor": "Editor",
@@ -263,6 +264,13 @@
"errors.profileCustomUrlDuplicate": "Someone is already using this custom URL",
"errors.profileSensBothOrNeither": "Motion sens can't be set if R-stick sens isn't",
"errors.profileInGameName": "Must match format: Name#disc (1-10 characters, #, 4-5 alphanumeric)",
"inGameName.addCharacter": "Add special character",
"inGameName.categories.symbols": "Symbols",
"inGameName.categories.accented": "Accented",
"inGameName.categories.greek": "Greek",
"inGameName.categories.cyrillic": "Cyrillic",
"inGameName.categories.hiragana": "Hiragana",
"inGameName.categories.katakana": "Katakana",
"labels.pronoun": "Pronoun",
"bottomTexts.profilePronouns": "This setting is optional! Your pronouns will be displayed on your profile, tournament rosters, SendouQ groups, and text channels.",
"errors.profilePronounsBothOrNeither": "Select both pronouns or neither",

View File

@@ -87,6 +87,7 @@
"events.view.hosting": "",
"events.view.scrims": "",
"events.view.saved": "",
"events.view.organization": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""

View File

@@ -4,6 +4,7 @@
"labels.bio": "Biografía",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "Etiqueta",
"labels.teamBsky": "Bluesky del equipo",
"labels.teamEditor": "Editor",
@@ -263,6 +264,13 @@
"errors.profileCustomUrlDuplicate": "Alguien ya tiene ese enlace personalizado",
"errors.profileSensBothOrNeither": "Motion sens can't be set if R-stick sens isn't",
"errors.profileInGameName": "",
"inGameName.addCharacter": "",
"inGameName.categories.symbols": "",
"inGameName.categories.accented": "",
"inGameName.categories.greek": "",
"inGameName.categories.cyrillic": "",
"inGameName.categories.hiragana": "",
"inGameName.categories.katakana": "",
"labels.pronoun": "",
"bottomTexts.profilePronouns": "",
"errors.profilePronounsBothOrNeither": "",

View File

@@ -87,6 +87,7 @@
"events.view.hosting": "",
"events.view.scrims": "",
"events.view.saved": "",
"events.view.organization": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""

View File

@@ -4,6 +4,7 @@
"labels.bio": "Biografía",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.teamEditor": "",
@@ -263,6 +264,13 @@
"errors.profileCustomUrlDuplicate": "Alguien ya tiene ese enlace personalizado",
"errors.profileSensBothOrNeither": "Sens de giroscopio no se poner sin la sens de palanca",
"errors.profileInGameName": "",
"inGameName.addCharacter": "",
"inGameName.categories.symbols": "",
"inGameName.categories.accented": "",
"inGameName.categories.greek": "",
"inGameName.categories.cyrillic": "",
"inGameName.categories.hiragana": "",
"inGameName.categories.katakana": "",
"labels.pronoun": "",
"bottomTexts.profilePronouns": "",
"errors.profilePronounsBothOrNeither": "",

View File

@@ -87,6 +87,7 @@
"events.view.hosting": "",
"events.view.scrims": "",
"events.view.saved": "",
"events.view.organization": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""

View File

@@ -4,6 +4,7 @@
"labels.bio": "Bio",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.teamEditor": "",
@@ -263,6 +264,13 @@
"errors.profileCustomUrlDuplicate": "Cette URL a déjà été choisie par quelqu'un",
"errors.profileSensBothOrNeither": "La sensibilité du gyroscope ne peut pas être choisie si la sensibilité du stick droit ne l'est pas",
"errors.profileInGameName": "",
"inGameName.addCharacter": "",
"inGameName.categories.symbols": "",
"inGameName.categories.accented": "",
"inGameName.categories.greek": "",
"inGameName.categories.cyrillic": "",
"inGameName.categories.hiragana": "",
"inGameName.categories.katakana": "",
"labels.pronoun": "",
"bottomTexts.profilePronouns": "",
"errors.profilePronounsBothOrNeither": "",

View File

@@ -87,6 +87,7 @@
"events.view.hosting": "",
"events.view.scrims": "",
"events.view.saved": "",
"events.view.organization": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""

View File

@@ -4,6 +4,7 @@
"labels.bio": "Bio",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "Team Bluesky",
"labels.teamEditor": "Editer",
@@ -263,6 +264,13 @@
"errors.profileCustomUrlDuplicate": "Cette URL a déjà été choisie par quelqu'un",
"errors.profileSensBothOrNeither": "La sensibilité du gyroscope ne peut pas être choisie si la sensibilité du stick droit ne l'est pas",
"errors.profileInGameName": "",
"inGameName.addCharacter": "",
"inGameName.categories.symbols": "",
"inGameName.categories.accented": "",
"inGameName.categories.greek": "",
"inGameName.categories.cyrillic": "",
"inGameName.categories.hiragana": "",
"inGameName.categories.katakana": "",
"labels.pronoun": "",
"bottomTexts.profilePronouns": "",
"errors.profilePronounsBothOrNeither": "",

View File

@@ -87,6 +87,7 @@
"events.view.hosting": "",
"events.view.scrims": "",
"events.view.saved": "",
"events.view.organization": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""

View File

@@ -4,6 +4,7 @@
"labels.bio": "ביו",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.teamEditor": "",
@@ -263,6 +264,13 @@
"errors.profileCustomUrlDuplicate": "מישהו כבר משתמש בכתובת URL המותאמת אישית הזו",
"errors.profileSensBothOrNeither": "לא ניתן להגדיר את רגישות התנועה אם רגישות הסטיק לא מוגדרת",
"errors.profileInGameName": "",
"inGameName.addCharacter": "",
"inGameName.categories.symbols": "",
"inGameName.categories.accented": "",
"inGameName.categories.greek": "",
"inGameName.categories.cyrillic": "",
"inGameName.categories.hiragana": "",
"inGameName.categories.katakana": "",
"labels.pronoun": "",
"bottomTexts.profilePronouns": "",
"errors.profilePronounsBothOrNeither": "",

View File

@@ -87,6 +87,7 @@
"events.view.hosting": "",
"events.view.scrims": "",
"events.view.saved": "",
"events.view.organization": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""

View File

@@ -4,6 +4,7 @@
"labels.bio": "Biografia",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "Bluesky del team",
"labels.teamEditor": "Editor",
@@ -263,6 +264,13 @@
"errors.profileCustomUrlDuplicate": "L'URL personalizzato è già in uso da un altro utente",
"errors.profileSensBothOrNeither": "La sensibilità del giroscopio non può essere impostata se non hai impostato la sensibilità del joystick destro",
"errors.profileInGameName": "",
"inGameName.addCharacter": "",
"inGameName.categories.symbols": "",
"inGameName.categories.accented": "",
"inGameName.categories.greek": "",
"inGameName.categories.cyrillic": "",
"inGameName.categories.hiragana": "",
"inGameName.categories.katakana": "",
"labels.pronoun": "",
"bottomTexts.profilePronouns": "",
"errors.profilePronounsBothOrNeither": "",

View File

@@ -6,26 +6,26 @@
"filterByTag": "タグで絞り込み",
"filteringByTag": "#{{tag}} で絞り込まれた結果を表示中",
"commissionsOpen": "依頼を受付中",
"commissionsClosed": "依頼の受付なし",
"commissionsClosed": "依頼の受付終了中",
"gainPerms": "作品をアップロードしたい場合は私たちのディスコードサーバーのヘルプデスクで許可を得てください。アップロードするには作品の作者でないといけません。また、スプラトゥーン関連の作品のみアップロードできます。",
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "ちょっとした注意: 1) スプラトゥーンの作品のみ追加してください 2) 自分で作成した作品のみ追加してください 3) NSFW(R18系)は NG. 他のユーザーに公表される前に確認プロセスが入ります。",
"tabs.recentlyUploaded": "最近のアップロード",
"tabs.showcase": "作品紹介",
"forms.caveats": "注意: 1) スプラトゥーンの作品のみ追加してください 2) 自分で作成した作品のみ追加してください 3) NSFW(R18系)は NG. 他のユーザーに公表される前に確認プロセスが入ります。",
"forms.description.title": "説明",
"forms.linkedUsers.title": "リンクされたユーザー",
"forms.linkedUsers.anotherOne": "別のユーザーを追加",
"forms.linkedUsers.info": "作中の人は誰? リンクすることで、リンクされた人のプロフィールに作品が表示されます。",
"forms.showcase.title": "ショーケース",
"forms.showcase.info": "ショーケースの作品はイラストページに表示されます。ショーケースには1つの作品しか設定できません。",
"forms.showcase.title": "作品紹介",
"forms.showcase.info": "作品紹介の作品はイラストページに表示されます。ショーケースには1つの作品しか設定できません。",
"forms.tags.title": "タグ",
"forms.tags.selectFromExisting": "既存のタグから選択する",
"forms.tags.cantFindExisting": "タグが見つからない場合:",
"forms.tags.addNew": "新規作成してください。",
"forms.tags.addNew.placeholder": "新しいタグを作成",
"forms.tags.search.placeholder": "",
"forms.tags.placeholder": "",
"forms.tags.search.placeholder": "タグを検索",
"forms.tags.placeholder": "タグを選択してください。",
"forms.tags.maxReached": "タグの最大数に到達しました",
"delete.title": "",
"unlink.title": "",
"noArtForTag": ""
"delete.title": "本当にこの作品を削除しますか?",
"unlink.title": "本当にこの作品をプロフィールから削除しますか?{{username}}しか再び追加することはできません。",
"noArtForTag": "#{{tag}}の作品が見つかりませんでした。"
}

View File

@@ -1,11 +1,13 @@
{
"patreon": "Patreon での sendou.ink Supporter",
"patreon+": "Patoreon での sendou.ink Supporter+",
"patreon": "Patreon での sendou.ink サポーター",
"patreon+": "Patoreon での sendou.ink サポーター+",
"xp": "{{xpText}} に到達したことで獲得",
"tournament_one": "{{tournament}}を勝利したことで獲得",
"tournament_other": "{{tournament}}を(×{{count}})回勝利したことで獲得",
"forYourEvent": "イベント専用のバッジ?",
"managedBy": "<0></0> によって管理されています",
"madeBy": "製作者: <0></0>",
"own.divider": "管理しているバッジ",
"other.divider": "他のバッジ",
"noBadgesFound": ""
"noBadgesFound": "お探しのバッジは見つかりませんでした。"
}

View File

@@ -1,23 +1,23 @@
{
"addBuild": "ギア構成を追加",
"forms.title.new": "",
"forms.title.edit": "",
"forms.title.new": "新規のギア構成作成",
"forms.title.edit": "ギア構成を編集",
"reachBuildMaxCount": "作成可能なギア構成の上限に到達しました。",
"noBuilds": "ギア構成はまだ作成されていません。最初のギア構成を作成しましょう!",
"buildCard.info": "詳細",
"buildCard.edit": "編集",
"forms.title": "タイトル",
"forms.modes": "モード",
"forms.title": "題名",
"forms.modes": "ルール",
"forms.weapons": "ブキ",
"forms.gear.HEAD": "ギア (アタマ)",
"forms.gear.CLOTHES": "ギア (フク)",
"forms.gear.SHOES": "ギア (クツ)",
"forms.abilities": "",
"forms.abilities": "ギアパワー",
"forms.private.info": "非公開ギア構成は作成者しか見ることができません",
"deleteConfirm": "'{{title}}'を削除しますか?",
"stats.count.title": "{{count}} による統計 {{weapon}} のギア構成",
"stats.ap.title": "ギアポイント平均",
"stats.percentage.title": "基本ギアのみの見た目",
"stats.percentage.title": "メインパワーでのみ使用",
"stats.all": "すべて",
"stats.public": "公開",
"stats.private": "非公開",
@@ -25,18 +25,18 @@
"linkButton.abilityStats": "ギア統計",
"linkButton.popularBuilds": "人気のあるギア構成",
"noPopularBuilds": "現在、このブキに対する人気のギア構成はないようです。",
"emptyAbilitySlot": "ギア設定をリセット",
"filters.type.ability": "能力で",
"filters.type.mode": "モードで",
"emptyAbilitySlot": "空欄",
"filters.type.ability": "ギアパワーで",
"filters.type.mode": "ルールで",
"filters.type.date": "日付で",
"filters.ability.title": "能力フィルター",
"filters.mode.title": "モードフィルター",
"filters.ability.title": "ギアパワーフィルター",
"filters.mode.title": "ルールフィルター",
"filters.date.title": "日付フィルター",
"filters.has": "含む",
"filters.does.not.have": "含まない",
"filters.atLeast": "最小",
"filters.atMost": "最大",
"filters.date.since": "から",
"filters.date.since": "",
"filters.date.custom": "カスタム",
"filters.filterByWeapon": "武器で"
}

View File

@@ -81,6 +81,7 @@
"events.view.hosting": "",
"events.view.scrims": "",
"events.view.saved": "",
"events.view.organization": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""

View File

@@ -4,6 +4,7 @@
"labels.bio": "自己紹介",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "チームの Bluesky",
"labels.teamEditor": "",
@@ -263,6 +264,13 @@
"errors.profileCustomUrlDuplicate": "このカスタム URL はすでに使用されています",
"errors.profileSensBothOrNeither": "右スティックの感度が設定されていない場合、感度を設定することはできません",
"errors.profileInGameName": "",
"inGameName.addCharacter": "",
"inGameName.categories.symbols": "",
"inGameName.categories.accented": "",
"inGameName.categories.greek": "",
"inGameName.categories.cyrillic": "",
"inGameName.categories.hiragana": "",
"inGameName.categories.katakana": "",
"labels.pronoun": "",
"bottomTexts.profilePronouns": "",
"errors.profilePronounsBothOrNeither": "",

View File

@@ -81,6 +81,7 @@
"events.view.hosting": "",
"events.view.scrims": "",
"events.view.saved": "",
"events.view.organization": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""

View File

@@ -4,6 +4,7 @@
"labels.bio": "소개",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.teamEditor": "",
@@ -263,6 +264,13 @@
"errors.profileCustomUrlDuplicate": "",
"errors.profileSensBothOrNeither": "",
"errors.profileInGameName": "",
"inGameName.addCharacter": "",
"inGameName.categories.symbols": "",
"inGameName.categories.accented": "",
"inGameName.categories.greek": "",
"inGameName.categories.cyrillic": "",
"inGameName.categories.hiragana": "",
"inGameName.categories.katakana": "",
"labels.pronoun": "",
"bottomTexts.profilePronouns": "",
"errors.profilePronounsBothOrNeither": "",

View File

@@ -85,6 +85,7 @@
"events.view.hosting": "",
"events.view.scrims": "",
"events.view.saved": "",
"events.view.organization": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""

View File

@@ -4,6 +4,7 @@
"labels.bio": "Bio",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.teamEditor": "",
@@ -263,6 +264,13 @@
"errors.profileCustomUrlDuplicate": "Deze URL is al in gebruik",
"errors.profileSensBothOrNeither": "Bewegingsgevoeligheid kan niet worden ingesteld als er niets voor de R-stick ingevoerd is",
"errors.profileInGameName": "",
"inGameName.addCharacter": "",
"inGameName.categories.symbols": "",
"inGameName.categories.accented": "",
"inGameName.categories.greek": "",
"inGameName.categories.cyrillic": "",
"inGameName.categories.hiragana": "",
"inGameName.categories.katakana": "",
"labels.pronoun": "",
"bottomTexts.profilePronouns": "",
"errors.profilePronounsBothOrNeither": "",

View File

@@ -89,6 +89,7 @@
"events.view.hosting": "",
"events.view.scrims": "",
"events.view.saved": "",
"events.view.organization": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""

View File

@@ -4,6 +4,7 @@
"labels.bio": "Opis",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.teamEditor": "",
@@ -263,6 +264,13 @@
"errors.profileCustomUrlDuplicate": "Te niestandardowe URl jest już przez kogoś zajęte",
"errors.profileSensBothOrNeither": "Motion sens nie może być ustawione jeśli R-stick sens nie jest",
"errors.profileInGameName": "",
"inGameName.addCharacter": "",
"inGameName.categories.symbols": "",
"inGameName.categories.accented": "",
"inGameName.categories.greek": "",
"inGameName.categories.cyrillic": "",
"inGameName.categories.hiragana": "",
"inGameName.categories.katakana": "",
"labels.pronoun": "",
"bottomTexts.profilePronouns": "",
"errors.profilePronounsBothOrNeither": "",

View File

@@ -87,6 +87,7 @@
"events.view.hosting": "",
"events.view.scrims": "",
"events.view.saved": "",
"events.view.organization": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""

View File

@@ -4,6 +4,7 @@
"labels.bio": "Bio",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.teamEditor": "",
@@ -263,6 +264,13 @@
"errors.profileCustomUrlDuplicate": "Alguém já está usando esse URL personalizado",
"errors.profileSensBothOrNeither": "A sensibilidade de Movimento não pode ser definida se a sensibilidade do Analógico Direito não está",
"errors.profileInGameName": "",
"inGameName.addCharacter": "",
"inGameName.categories.symbols": "",
"inGameName.categories.accented": "",
"inGameName.categories.greek": "",
"inGameName.categories.cyrillic": "",
"inGameName.categories.hiragana": "",
"inGameName.categories.katakana": "",
"labels.pronoun": "",
"bottomTexts.profilePronouns": "",
"errors.profilePronounsBothOrNeither": "",

View File

@@ -89,6 +89,7 @@
"events.view.hosting": "",
"events.view.scrims": "",
"events.view.saved": "",
"events.view.organization": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""

View File

@@ -4,6 +4,7 @@
"labels.bio": "Описание",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "Bluesky команды",
"labels.teamEditor": "Редактор",
@@ -263,6 +264,13 @@
"errors.profileCustomUrlDuplicate": "Кто-то уже использует этот пользовательский URL",
"errors.profileSensBothOrNeither": "Чувствительность наклона не может быть указана, если не указана чувствительность стика",
"errors.profileInGameName": "",
"inGameName.addCharacter": "",
"inGameName.categories.symbols": "",
"inGameName.categories.accented": "",
"inGameName.categories.greek": "",
"inGameName.categories.cyrillic": "",
"inGameName.categories.hiragana": "",
"inGameName.categories.katakana": "",
"labels.pronoun": "",
"bottomTexts.profilePronouns": "",
"errors.profilePronounsBothOrNeither": "",

View File

@@ -81,6 +81,7 @@
"events.view.hosting": "",
"events.view.scrims": "",
"events.view.saved": "",
"events.view.organization": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""

View File

@@ -4,6 +4,7 @@
"labels.bio": "简介",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.teamEditor": "",
@@ -263,6 +264,13 @@
"errors.profileCustomUrlDuplicate": "这个自定义URL已被使用",
"errors.profileSensBothOrNeither": "设置体感感度前请先设置摇杆感度",
"errors.profileInGameName": "",
"inGameName.addCharacter": "",
"inGameName.categories.symbols": "",
"inGameName.categories.accented": "",
"inGameName.categories.greek": "",
"inGameName.categories.cyrillic": "",
"inGameName.categories.hiragana": "",
"inGameName.categories.katakana": "",
"labels.pronoun": "",
"bottomTexts.profilePronouns": "",
"errors.profilePronounsBothOrNeither": "",

View File

@@ -0,0 +1,19 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/* sql */ `
create table "ExternalStream" (
"id" integer primary key autoincrement,
"name" text not null,
"url" text not null,
"avatarImgId" integer,
"startTime" integer not null,
"createdAt" integer default (strftime('%s', 'now')) not null,
foreign key ("avatarImgId") references "UnvalidatedUserSubmittedImage"("id") on delete set null
) strict
`,
).run();
db.pragma("foreign_key_check");
})();
}

View File

@@ -89,7 +89,7 @@
"react-i18next": "17.0.8",
"react-router": "7.17.0",
"react-use-draggable-scroll": "0.4.7",
"remeda": "2.37.0",
"remeda": "2.38.0",
"remix-auth": "4.2.0",
"remix-auth-oauth2": "3.4.1",
"remix-i18next": "7.5.0",

10
pnpm-lock.yaml generated
View File

@@ -166,8 +166,8 @@ importers:
specifier: 0.4.7
version: 0.4.7(react@19.2.7)
remeda:
specifier: 2.37.0
version: 2.37.0
specifier: 2.38.0
version: 2.38.0
remix-auth:
specifier: 4.2.0
version: 4.2.0
@@ -3720,8 +3720,8 @@ packages:
rematrix@0.2.2:
resolution: {integrity: sha512-agFFS3RzrLXJl5LY5xg/xYyXvUuVAnkhgKO7RaO9J1Ssth6yvbO+PIiV67V59MB5NCdAK2flvGvNT4mdKVniFA==}
remeda@2.37.0:
resolution: {integrity: sha512-wN6BXWua0t4o7vDamqc27J3VRxnokG9cDezsFN2nOnt2JD/IkJQHTYqM6UvmEctAZETAoviwEFQZJO3kZ4Ohew==}
remeda@2.38.0:
resolution: {integrity: sha512-yhZjp7dd+L0NWS8gn4caKOHI6ALfbN3/2H5WNBCnFyzPYcG5vOw5b30FVpDFdQ+7Ui62QiCWKubJhG9Y5SqF5A==}
engines: {node: '>=18.0.0'}
remix-auth-oauth2@3.4.1:
@@ -7781,7 +7781,7 @@ snapshots:
rematrix@0.2.2: {}
remeda@2.37.0: {}
remeda@2.38.0: {}
remix-auth-oauth2@3.4.1(remix-auth@4.2.0):
dependencies: