mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-14 15:16:09 -05:00
Merge branch 'sendou-ink:main' into Replace-TanStack/react-charts-library-2293
This commit is contained in:
59
app/components/IngameNameInput.module.css
Normal file
59
app/components/IngameNameInput.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
165
app/components/IngameNameInput.tsx
Normal file
165
app/components/IngameNameInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
80
app/features/admin/ExternalStreamRepository.server.ts
Normal file
80
app/features/admin/ExternalStreamRepository.server.ts
Normal 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();
|
||||
}
|
||||
46
app/features/admin/actions/admin.streams.server.ts
Normal file
46
app/features/admin/actions/admin.streams.server.ts
Normal 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;
|
||||
};
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
10
app/features/admin/loaders/admin.streams.server.ts
Normal file
10
app/features/admin/loaders/admin.streams.server.ts
Normal 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(),
|
||||
};
|
||||
};
|
||||
12
app/features/admin/routes/admin.streams.module.css
Normal file
12
app/features/admin/routes/admin.streams.module.css
Normal 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;
|
||||
}
|
||||
98
app/features/admin/routes/admin.streams.tsx
Normal file
98
app/features/admin/routes/admin.streams.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -6,3 +6,7 @@
|
||||
gap: var(--s-2);
|
||||
margin-block-end: var(--s-2);
|
||||
}
|
||||
|
||||
.subNav {
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
58
app/features/user-page/in-game-name.test.ts
Normal file
58
app/features/user-page/in-game-name.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
107
app/features/user-page/in-game-name.ts
Normal file
107
app/features/user-page/in-game-name.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<
|
||||
|
||||
49
app/form/fields/InGameNameFormField.tsx
Normal file
49
app/form/fields/InGameNameFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"),
|
||||
|
||||
11
app/routines/deleteOldExternalStreams.ts
Normal file
11
app/routines/deleteOldExternalStreams.ts
Normal 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`);
|
||||
},
|
||||
});
|
||||
@@ -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 */
|
||||
|
||||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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) {
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
"events.view.hosting": "",
|
||||
"events.view.scrims": "",
|
||||
"events.view.saved": "",
|
||||
"events.view.organization": "",
|
||||
"events.empty": "",
|
||||
"events.emptyAll": "",
|
||||
"events.findOnCalendar": ""
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
"events.view.hosting": "",
|
||||
"events.view.scrims": "",
|
||||
"events.view.saved": "",
|
||||
"events.view.organization": "",
|
||||
"events.empty": "",
|
||||
"events.emptyAll": "",
|
||||
"events.findOnCalendar": ""
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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!"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"events.view.hosting": "",
|
||||
"events.view.scrims": "",
|
||||
"events.view.saved": "",
|
||||
"events.view.organization": "",
|
||||
"events.empty": "",
|
||||
"events.emptyAll": "",
|
||||
"events.findOnCalendar": ""
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"events.view.hosting": "",
|
||||
"events.view.scrims": "",
|
||||
"events.view.saved": "",
|
||||
"events.view.organization": "",
|
||||
"events.empty": "",
|
||||
"events.emptyAll": "",
|
||||
"events.findOnCalendar": ""
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"events.view.hosting": "",
|
||||
"events.view.scrims": "",
|
||||
"events.view.saved": "",
|
||||
"events.view.organization": "",
|
||||
"events.empty": "",
|
||||
"events.emptyAll": "",
|
||||
"events.findOnCalendar": ""
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"events.view.hosting": "",
|
||||
"events.view.scrims": "",
|
||||
"events.view.saved": "",
|
||||
"events.view.organization": "",
|
||||
"events.empty": "",
|
||||
"events.emptyAll": "",
|
||||
"events.findOnCalendar": ""
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"events.view.hosting": "",
|
||||
"events.view.scrims": "",
|
||||
"events.view.saved": "",
|
||||
"events.view.organization": "",
|
||||
"events.empty": "",
|
||||
"events.emptyAll": "",
|
||||
"events.findOnCalendar": ""
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"events.view.hosting": "",
|
||||
"events.view.scrims": "",
|
||||
"events.view.saved": "",
|
||||
"events.view.organization": "",
|
||||
"events.empty": "",
|
||||
"events.emptyAll": "",
|
||||
"events.findOnCalendar": ""
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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}}の作品が見つかりませんでした。"
|
||||
}
|
||||
|
||||
@@ -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": "お探しのバッジは見つかりませんでした。"
|
||||
}
|
||||
|
||||
@@ -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": "武器で"
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"events.view.hosting": "",
|
||||
"events.view.scrims": "",
|
||||
"events.view.saved": "",
|
||||
"events.view.organization": "",
|
||||
"events.empty": "",
|
||||
"events.emptyAll": "",
|
||||
"events.findOnCalendar": ""
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"events.view.hosting": "",
|
||||
"events.view.scrims": "",
|
||||
"events.view.saved": "",
|
||||
"events.view.organization": "",
|
||||
"events.empty": "",
|
||||
"events.emptyAll": "",
|
||||
"events.findOnCalendar": ""
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
"events.view.hosting": "",
|
||||
"events.view.scrims": "",
|
||||
"events.view.saved": "",
|
||||
"events.view.organization": "",
|
||||
"events.empty": "",
|
||||
"events.emptyAll": "",
|
||||
"events.findOnCalendar": ""
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"events.view.hosting": "",
|
||||
"events.view.scrims": "",
|
||||
"events.view.saved": "",
|
||||
"events.view.organization": "",
|
||||
"events.empty": "",
|
||||
"events.emptyAll": "",
|
||||
"events.findOnCalendar": ""
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"events.view.hosting": "",
|
||||
"events.view.scrims": "",
|
||||
"events.view.saved": "",
|
||||
"events.view.organization": "",
|
||||
"events.empty": "",
|
||||
"events.emptyAll": "",
|
||||
"events.findOnCalendar": ""
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"events.view.hosting": "",
|
||||
"events.view.scrims": "",
|
||||
"events.view.saved": "",
|
||||
"events.view.organization": "",
|
||||
"events.empty": "",
|
||||
"events.emptyAll": "",
|
||||
"events.findOnCalendar": ""
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"events.view.hosting": "",
|
||||
"events.view.scrims": "",
|
||||
"events.view.saved": "",
|
||||
"events.view.organization": "",
|
||||
"events.empty": "",
|
||||
"events.emptyAll": "",
|
||||
"events.findOnCalendar": ""
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
19
migrations/152-external-stream.js
Normal file
19
migrations/152-external-stream.js
Normal 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");
|
||||
})();
|
||||
}
|
||||
@@ -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
10
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user