Scrim improvements (#2365)

* Initial

* wip

* wip

* Finish?

* remove comment
This commit is contained in:
Kalle 2025-06-07 17:21:29 +03:00 committed by GitHub
parent 61ecaa252f
commit 7901ee8cab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 458 additions and 57 deletions

View File

@ -1,6 +1,9 @@
## General
- for new code only rarely use comments, prefer descriptive variable and function names
- only rarely use comments, prefer descriptive variable and function names (leave existing comments as is)
- if you encounter an existing TODO comment assume it is there for a reason and do not remove it
- for running scripts `npm` is used
- all the imports should be at the top of the file
## Commands
@ -27,6 +30,7 @@
- split bigger components into smaller ones
- one file can have many components
- all texts should be provided translations via the i18next library's `useTranslations` hook's `t` function
- instead of `&&` operator for conditional rendering, use the ternary operator
## Styling
@ -39,3 +43,10 @@
- database is Sqlite3 used with the Kysely library
- database code should only be written in Repository files
- down migrations are not needed, only up migrations
- every database id is of type number
## Playwright
- `page.goto` is forbidden, use the `navigate` function to do a page navigation
- to submit a form you use the `submit` function

View File

@ -18,10 +18,12 @@ export default function TimePopover({
month: "long",
},
underline = true,
className,
}: {
time: Date;
options?: Intl.DateTimeFormatOptions;
underline?: boolean;
className?: string;
}) {
const { i18n } = useTranslation();
@ -49,6 +51,7 @@ export default function TimePopover({
type="button"
ref={triggerRef}
className={clsx(
className,
"clickable text-only-button",
underline ? "dotted" : "",
)}
@ -66,7 +69,7 @@ export default function TimePopover({
>
<Dialog>
<div className="stack sm">
<div className="text-center">
<div className="text-center" suppressHydrationWarning>
{time.toLocaleTimeString(i18n.language, {
timeZoneName: "long",
hour: "numeric",

View File

@ -25,6 +25,8 @@ interface SendouDialogProps extends ModalOverlayProps {
"aria-label"?: string;
/** If true, the modal takes over the full screen with the content below hidden */
isFullScreen?: boolean;
/** If true, shows the close button even if onClose is not provided */
showCloseButton?: boolean;
}
/**
@ -77,11 +79,12 @@ function DialogModal({
heading,
showHeading = true,
className,
showCloseButton: showCloseButtonProp,
...rest
}: Omit<SendouDialogProps, "trigger">) {
const navigate = useNavigate();
const showCloseButton = rest.onClose || rest.onCloseTo;
const showCloseButton = showCloseButtonProp || rest.onClose || rest.onCloseTo;
const onClose = () => {
if (rest.onCloseTo) {
navigate(rest.onCloseTo);

View File

@ -15,12 +15,14 @@ export function SendouForm<T extends z.ZodTypeAny>({
heading,
children,
cancelLink,
submitButtonTestId,
}: {
schema: T;
defaultValues?: DefaultValues<z.infer<T>>;
heading?: string;
children: React.ReactNode;
cancelLink?: string;
submitButtonTestId?: string;
}) {
const { t } = useTranslation(["common"]);
const fetcher = useFetcher<any>();
@ -56,7 +58,7 @@ export function SendouForm<T extends z.ZodTypeAny>({
{heading ? <h1 className="text-lg">{heading}</h1> : null}
{children}
<div className="stack horizontal lg justify-between mt-6 w-full">
<SubmitButton state={fetcher.state}>
<SubmitButton state={fetcher.state} testId={submitButtonTestId}>
{t("common:actions.submit")}
</SubmitButton>
{cancelLink ? (

View File

@ -2295,22 +2295,29 @@ async function lfgPosts() {
async function scrimPosts() {
const allUsers = userIdsInRandomOrder(true);
const date = () => {
// Only schedule admin's scrim at least 1 hour in the future, others can be 'now'
const date = (isAdmin = false) => {
if (isAdmin) {
const randomFuture = faker.date.between({
from: add(new Date(), { hours: 1 }),
to: add(new Date(), { days: 7 }),
});
randomFuture.setMinutes(0);
randomFuture.setSeconds(0);
randomFuture.setMilliseconds(0);
return dateToDatabaseTimestamp(randomFuture);
}
const isNow = faker.number.float(1) > 0.5;
if (isNow) {
return databaseTimestampNow();
}
const randomFuture = faker.date.between({
from: new Date(),
to: add(new Date(), { days: 7 }),
});
randomFuture.setMinutes(0);
randomFuture.setSeconds(0);
randomFuture.setMilliseconds(0);
return dateToDatabaseTimestamp(randomFuture);
};
@ -2355,7 +2362,6 @@ async function scrimPosts() {
for (let i = 0; i < 20; i++) {
const divs = divRange();
await ScrimPostRepository.insert({
at: date(),
maxDiv: divs?.maxDiv,
@ -2367,11 +2373,12 @@ async function scrimPosts() {
: null,
visibility: null,
users: users(),
managedByAnyone: true,
});
}
const adminPostId = await ScrimPostRepository.insert({
at: date(),
at: date(true), // admin's scrim is always at least 1 hour in the future
text:
faker.number.float(1) > 0.5
? faker.lorem.sentences({ min: 1, max: 5 })
@ -2380,6 +2387,7 @@ async function scrimPosts() {
users: users()
.map((u) => ({ ...u, isOwner: 0 }))
.concat({ userId: ADMIN_ID, isOwner: 1 }),
managedByAnyone: true,
});
await ScrimPostRepository.insertRequest({
scrimPostId: adminPostId,

View File

@ -948,6 +948,14 @@ export interface ScrimPost {
chatCode: string;
/** Refers to the team looking for the team (can also be a pick-up) */
teamId: number | null;
/** Indicates if anyone in the post can manage it */
managedByAnyone: DBBoolean;
/** When the scrim was canceled */
canceledAt: number | null;
/** User id who canceled the scrim */
canceledByUserId: number | null;
/** Reason for canceling the scrim */
cancelReason: string | null;
createdAt: GeneratedAlways<number>;
updatedAt: Generated<number>;
}

View File

@ -2,7 +2,7 @@ import { sub } from "date-fns";
import type { Insertable } from "kysely";
import { jsonArrayFrom, jsonBuildObject } from "kysely/helpers/sqlite";
import type { Tables, TablesInsertable } from "~/db/tables";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import { shortNanoid } from "~/utils/id";
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
import { db } from "../../db/sql";
@ -10,7 +10,7 @@ import invariant from "../../utils/invariant";
import type { Unwrapped } from "../../utils/types";
import type { AssociationVisibility } from "../associations/associations-types";
import * as Scrim from "./core/Scrim";
import type { ScrimPost } from "./scrims-types";
import type { ScrimPost, ScrimPostUser } from "./scrims-types";
import { getPostRequestCensor, parseLutiDiv } from "./scrims-utils";
type InsertArgs = Pick<
@ -20,6 +20,7 @@ type InsertArgs = Pick<
/** users related to the post other than the author */
users: Array<Pick<Insertable<Tables["ScrimPostUser"]>, "userId" | "isOwner">>;
visibility: AssociationVisibility | null;
managedByAnyone: boolean;
};
export function insert(args: InsertArgs) {
@ -38,6 +39,7 @@ export function insert(args: InsertArgs) {
text: args.text,
visibility: args.visibility ? JSON.stringify(args.visibility) : null,
chatCode: shortNanoid(),
managedByAnyone: args.managedByAnyone ? 1 : 0,
})
.returning("id")
.executeTakeFirstOrThrow();
@ -101,6 +103,10 @@ const baseFindQuery = db
"ScrimPost.maxDiv",
"ScrimPost.minDiv",
"ScrimPost.text",
"ScrimPost.managedByAnyone",
"ScrimPost.canceledAt",
"ScrimPost.canceledByUserId",
"ScrimPost.cancelReason",
jsonBuildObject({
name: eb.ref("Team.name"),
customUrl: eb.ref("Team.customUrl"),
@ -168,9 +174,34 @@ const mapDBRowToScrimPost = (
? row.requests.filter((request) => request.isAccepted)
: row.requests;
const ownerIds = row.users
.filter((user) => user.isOwner)
.map((user) => user.id);
const users: ScrimPostUser[] = row.users.map((user) => ({
...user,
isOwner: Boolean(user.isOwner),
}));
const ownerIds = users.filter((user) => user.isOwner).map((user) => user.id);
const managerIds = row.managedByAnyone
? users.map((user) => user.id)
: ownerIds;
let canceled: ScrimPost["canceled"] = null;
if (row.canceledAt && row.cancelReason) {
let cancelingUser = users.find((u) => u.id === row.canceledByUserId);
if (!cancelingUser) {
const allRequestUsers = requests.flatMap((request) => request.users);
const found = allRequestUsers.find((u) => u.id === row.canceledByUserId);
if (found) {
cancelingUser = { ...found, isOwner: Boolean(found.isOwner) };
}
}
if (cancelingUser) {
canceled = {
at: row.canceledAt,
byUser: cancelingUser,
reason: row.cancelReason,
};
}
}
return {
id: row.id,
@ -201,29 +232,23 @@ const mapDBRowToScrimPost = (
avatarUrl: request.team.avatarUrl,
}
: null,
users: request.users.map((user) => {
return {
...user,
isVerified: false,
isOwner: Boolean(user.isOwner),
};
}),
users: request.users.map((user) => ({
...user,
isOwner: Boolean(user.isOwner),
})),
permissions: {
CANCEL: request.users.map((u) => u.id),
},
};
}),
users: row.users.map((user) => {
return {
...user,
isVerified: false,
isOwner: Boolean(user.isOwner),
};
}),
users,
permissions: {
MANAGE_REQUESTS: ownerIds,
DELETE_POST: ownerIds,
MANAGE_REQUESTS: managerIds,
DELETE_POST: managerIds,
CANCEL: managerIds.concat(requests.at(0)?.users.map((u) => u.id) ?? []),
},
managedByAnyone: Boolean(row.managedByAnyone),
canceled,
};
};
@ -268,3 +293,19 @@ export function deleteRequest(scrimPostRequestId: number) {
.where("id", "=", scrimPostRequestId)
.execute();
}
export async function cancelScrim(
id: number,
{ userId, reason }: { userId: number; reason: string },
) {
await db
.updateTable("ScrimPost")
.set({
canceledAt: databaseTimestampNow(),
canceledByUserId: userId,
cancelReason: reason,
})
.where("id", "=", id)
.where("canceledAt", "is", null)
.execute();
}

View File

@ -0,0 +1,37 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { requirePermission } from "~/modules/permissions/guards.server";
import {
notFoundIfFalsy,
parseParams,
parseRequestPayload,
} from "~/utils/remix.server";
import { idObject } from "~/utils/zod";
import { databaseTimestampToDate } from "../../../utils/dates";
import { errorToast } from "../../../utils/remix.server";
import { requireUser } from "../../auth/core/user.server";
import * as ScrimPostRepository from "../ScrimPostRepository.server";
import { cancelScrimSchema } from "../scrims-schemas";
export const action = async ({ request, params }: ActionFunctionArgs) => {
const { id } = parseParams({ params, schema: idObject });
const post = notFoundIfFalsy(await ScrimPostRepository.findById(id));
const user = await requireUser(request);
const data = await parseRequestPayload({
request,
schema: cancelScrimSchema,
});
requirePermission(post, "CANCEL", user);
if (databaseTimestampToDate(post.at) < new Date()) {
errorToast("Cannot cancel a scrim that was already scheduled to start");
}
await ScrimPostRepository.cancelScrim(id, {
userId: user.id,
reason: data.reason,
});
return null;
};

View File

@ -53,6 +53,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
maxDiv: data.divs ? serializeLutiDiv(data.divs.max!) : null,
minDiv: data.divs ? serializeLutiDiv(data.divs.min!) : null,
text: data.postText,
managedByAnyone: data.managedByAnyone,
visibility:
data.baseVisibility !== "PUBLIC"
? {

View File

@ -1,7 +1,17 @@
import { Link, useLoaderData } from "@remix-run/react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { z } from "zod";
import { Alert } from "~/components/Alert";
import TimePopover from "~/components/TimePopover";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { SendouForm } from "~/components/form/SendouForm";
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
import { SCRIM } from "~/features/scrims/scrims-constants";
import { cancelScrimSchema } from "~/features/scrims/scrims-schemas";
import { resolveRoomPass } from "~/features/tournament-bracket/tournament-bracket-utils";
import { useHasPermission } from "~/modules/permissions/hooks";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { Avatar } from "../../../components/Avatar";
import { Main } from "../../../components/Main";
@ -12,10 +22,10 @@ import { ConnectedChat } from "../../chat/components/Chat";
import * as Scrim from "../core/Scrim";
import type { ScrimPost as ScrimPostType } from "../scrims-types";
import { action } from "../actions/scrims.$id.server";
import { loader } from "../loaders/scrims.$id.server";
export { loader };
export { loader, action };
import TimePopover from "~/components/TimePopover";
import styles from "./scrims.$id.module.css";
export const handle: SendouRouteHandle = {
@ -23,12 +33,46 @@ export const handle: SendouRouteHandle = {
};
export default function ScrimPage() {
const { t } = useTranslation(["q"]);
const { t } = useTranslation(["q", "scrims", "common"]);
const data = useLoaderData<typeof loader>();
const allowedToCancel = useHasPermission(data.post, "CANCEL");
const isCanceled = Boolean(data.post.canceled);
const canCancel =
allowedToCancel &&
!isCanceled &&
databaseTimestampToDate(data.post.at) > new Date();
return (
<Main className="stack lg">
<ScrimHeader />
<div className="stack horizontal justify-between">
<ScrimHeader />
{canCancel && (
<div>
<SendouDialog
trigger={
<SendouButton size="small" variant="minimal-destructive">
{t("common:actions.cancel")}
</SendouButton>
}
heading={t("scrims:cancelModal.scrim.title")}
showCloseButton
>
<CancelScrimForm />
</SendouDialog>
</div>
)}
</div>
{data.post.canceled && (
<div className="mx-auto">
<Alert variation="WARNING">
{t("scrims:alert.canceled", {
user: data.post.canceled.byUser.username,
reason: data.post.canceled.reason,
})}
</Alert>
</div>
)}
<div className={styles.groupsContainer}>
<GroupCard group={data.post} side="ALPHA" />
<GroupCard group={data.post.requests[0]} side="BRAVO" />
@ -48,13 +92,34 @@ export default function ScrimPage() {
);
}
type FormFields = z.infer<typeof cancelScrimSchema>;
function CancelScrimForm() {
const { t } = useTranslation(["scrims"]);
return (
<SendouForm
schema={cancelScrimSchema}
defaultValues={{ reason: "" }}
submitButtonTestId="cancel-scrim-submit"
>
<TextAreaFormField<FormFields>
name="reason"
label={t("cancelModal.scrim.reasonLabel")}
maxLength={SCRIM.CANCEL_REASON_MAX_LENGTH}
bottomText={t("scrims:cancelModal.scrim.reasonExplanation")}
/>
</SendouForm>
);
}
function ScrimHeader() {
const { t } = useTranslation(["scrims"]);
const data = useLoaderData<typeof loader>();
return (
<div className="line-height-tight" data-testid="match-header">
<h2 className="text-lg" suppressHydrationWarning>
<h2 className="text-lg">
<TimePopover
time={databaseTimestampToDate(data.post.at)}
options={{
@ -65,6 +130,7 @@ function ScrimHeader() {
hour: "numeric",
minute: "numeric",
}}
className="text-left"
/>
</h2>
<div className="text-lighter text-xs font-bold">

View File

@ -53,6 +53,14 @@
fill: var(--theme-info);
}
.postStatusCanceled {
background-color: var(--theme-warning-transparent);
}
.postStatusCanceled svg {
fill: var(--theme-warning);
}
.postFloatingActionCell {
position: sticky;
right: 0;

View File

@ -7,6 +7,7 @@ import { Label } from "~/components/Label";
import { DateFormField } from "~/components/form/DateFormField";
import { SendouForm } from "~/components/form/SendouForm";
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
import { ToggleFormField } from "~/components/form/ToggleFormField";
import { nullFilledArray } from "~/utils/arrays";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { FormMessage } from "../../../components/FormMessage";
@ -58,6 +59,7 @@ export default function NewScrimPage() {
SCRIM.MAX_PICKUP_SIZE_EXCLUDING_OWNER,
) as unknown as number[],
},
managedByAnyone: true,
}}
>
<WithFormField usersTeams={data.teams} />
@ -80,6 +82,12 @@ export default function NewScrimPage() {
name="postText"
maxLength={MAX_SCRIM_POST_TEXT_LENGTH}
/>
<ToggleFormField<FormFields>
label={t("scrims:forms.managedByAnyone.title")}
name="managedByAnyone"
bottomText={t("scrims:forms.managedByAnyone.explanation")}
/>
</SendouForm>
</Main>
);

View File

@ -11,6 +11,7 @@ import { LinkButton } from "~/components/Button";
import { Divider } from "~/components/Divider";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Table } from "~/components/Table";
import TimePopover from "~/components/TimePopover";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { SendouPopover } from "~/components/elements/Popover";
@ -50,7 +51,6 @@ import { action } from "../actions/scrims.server";
import { loader } from "../loaders/scrims.server";
export { loader, action };
import TimePopover from "~/components/TimePopover";
import styles from "./scrims.module.css";
export type NewRequestFormFields = z.infer<typeof newRequestSchema>;
@ -138,6 +138,7 @@ export default function ScrimsPage() {
posts={data.posts.owned}
showDeletePost
showRequestRows
showStatus
/>
),
},
@ -297,6 +298,7 @@ function ScrimsTable({
);
const getStatus = (post: ScrimPost) => {
if (post.canceled) return "CANCELED";
if (post.requests.at(0)?.isAccepted) return "CONFIRMED";
if (
post.requests.some((r) => r.users.some((rUser) => user?.id === rUser.id))
@ -457,6 +459,7 @@ function ScrimsTable({
className={clsx(styles.postStatus, {
[styles.postStatusConfirmed]: status === "CONFIRMED",
[styles.postStatusPending]: status === "PENDING",
[styles.postStatusCanceled]: status === "CANCELED",
})}
>
{status === "CONFIRMED" ? (
@ -469,6 +472,11 @@ function ScrimsTable({
<ClockIcon /> {t("scrims:status.pending")}
</>
) : null}
{status === "CANCELED" ? (
<>
<CrossIcon /> {t("scrims:status.canceled")}
</>
) : null}
</div>
</td>
) : null}
@ -515,7 +523,9 @@ function ScrimsTable({
</SendouButton>
}
>
{t("scrims:deleteModal.prevented")}
{t("scrims:deleteModal.prevented", {
username: owner.username,
})}
</SendouPopover>
)}
</td>
@ -637,6 +647,7 @@ function RequestRow({
</td>
<td />
<td />
<td />
<td className={styles.postFloatingActionCell}>
{!request.isAccepted && canAccept ? (
<FormWithConfirm

View File

@ -16,6 +16,7 @@ export const LUTI_DIVS = [
export const SCRIM = {
MAX_PICKUP_SIZE_EXCLUDING_OWNER: 5,
MIN_MEMBERS_PER_TEAM: 4,
CANCEL_REASON_MAX_LENGTH: 500,
};
export const FF_SCRIMS_ENABLED = true;

View File

@ -50,6 +50,10 @@ export const cancelRequestSchema = z.object({
scrimPostRequestId: id,
});
export const cancelScrimSchema = z.object({
reason: z.string().trim().min(1).max(SCRIM.CANCEL_REASON_MAX_LENGTH),
});
export const scrimsActionSchema = z.union([
deletePostSchema,
newRequestSchema,
@ -140,6 +144,7 @@ export const scrimsNewActionSchema = z
falsyToNull,
z.string().max(MAX_SCRIM_POST_TEXT_LENGTH).nullable(),
),
managedByAnyone: z.boolean(),
})
.superRefine((post, ctx) => {
if (

View File

@ -24,7 +24,14 @@ export interface ScrimPost {
permissions: {
MANAGE_REQUESTS: number[];
DELETE_POST: number[];
CANCEL: number[];
};
managedByAnyone: boolean;
canceled: {
at: number;
byUser: ScrimPostUser;
reason: string;
} | null;
}
export interface ScrimPostRequest {
@ -38,7 +45,7 @@ export interface ScrimPostRequest {
createdAt: number;
}
interface ScrimPostUser extends CommonUser {
export interface ScrimPostUser extends CommonUser {
isOwner: boolean;
}

View File

@ -30,6 +30,10 @@
text-align: center;
}
.text-left {
text-align: left;
}
.text-main-forced {
color: var(--text) !important;
}

View File

@ -0,0 +1,24 @@
```ts
// some-feature/actions/route.server.ts
import type { ActionFunctionArgs } from "@remix-run/node";
import { requireUserId } from "~/features/auth/core/user.server";
import { parseRequestPayload } from "~/utils/remix.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const user = await requireUserId(request);
const data = await parseRequestPayload({
request,
schema: actionSchema,
});
// check permissions via requirePermission
// update via Repository
return null;
};
// some-feature/routes/route.ts
import { action } from "../actions/route.server.ts"
export { action }
```

View File

@ -92,4 +92,31 @@ test.describe("Scrims", () => {
await expect(page.getByText("Scheduled scrim")).toBeVisible();
});
test("cancels a scrim and shows canceled status", async ({ page }) => {
await seed(page);
await impersonate(page, ADMIN_ID);
await navigate({
page,
url: scrimsPage(),
});
// Accept the first available scrim request to make it possible to access the scrim details page
await page.getByRole("button", { name: "Accept" }).first().click();
await page.getByTestId("confirm-button").click();
await page.getByRole("link", { name: "Contact" }).click();
// Cancel the scrim
await page.getByRole("button", { name: "Cancel" }).click();
await page.getByLabel("Reason").fill("Oops something came up");
await page.getByTestId("cancel-scrim-submit").click();
// Go back to the scrims page and check if the scrim is marked as canceled
await navigate({
page,
url: scrimsPage(),
});
await expect(page.getByText("Canceled")).toBeVisible();
});
});

View File

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

View File

@ -17,6 +17,9 @@
"deleteModal.title": "",
"deleteModal.prevented": "",
"cancelModal.title": "",
"cancelModal.scrim.title": "",
"cancelModal.scrim.reasonLabel": "",
"cancelModal.scrim.reasonExplanation": "",
"actions.contact": "",
"acceptModal.title": "",
"acceptModal.prevented": "",
@ -47,5 +50,8 @@
"associations.shareLink.reset": "",
"associations.removeMember.title": "",
"associations.forms.title": "",
"associations.forms.name.title": ""
"associations.forms.name.title": "",
"forms.managedByAnyone.title": "",
"forms.managedByAnyone.explanation": "",
"alert.canceled": ""
}

View File

@ -17,6 +17,9 @@
"deleteModal.title": "Scrim post löschen?",
"deleteModal.prevented": "Der Post muss von Ersteller ({{username}}) gelöscht werden.",
"cancelModal.title": "Anfrage zurückziehen?",
"cancelModal.scrim.title": "",
"cancelModal.scrim.reasonLabel": "",
"cancelModal.scrim.reasonExplanation": "",
"actions.contact": "Kontaktieren",
"acceptModal.title": "Scrim-Anfrage von {{groupName}} akzeptieren & andere ablehnen (falls vorhanden)?",
"acceptModal.prevented": "Bitte den Ersteller des Scrim-Posts, diese Anfrage anzunehmen.",
@ -47,5 +50,8 @@
"associations.shareLink.reset": "Link zurücksetzen",
"associations.removeMember.title": "{{username}} aus der Assoziation entfernen?",
"associations.forms.title": "Neue Assoziation erstellen",
"associations.forms.name.title": "Name"
"associations.forms.name.title": "Name",
"forms.managedByAnyone.title": "",
"forms.managedByAnyone.explanation": "",
"alert.canceled": ""
}

View File

@ -13,10 +13,14 @@
"pickup": "{{username}}'s pickup",
"status.booked": "Booked",
"status.pending": "Pending",
"status.canceled": "Canceled",
"actions.request": "Request",
"deleteModal.title": "Delete the scrim post?",
"deleteModal.prevented": "The post must be deleted by the owner ({{username}})",
"cancelModal.title": "Cancel the request?",
"cancelModal.scrim.title": "Cancel the scrim?",
"cancelModal.scrim.reasonLabel": "Reason for cancellation",
"cancelModal.scrim.reasonExplanation": "Explain why you are cancelling the scrim. This will be visible to the other team.",
"actions.contact": "Contact",
"acceptModal.title": "Accept the request to scrim by {{groupName}} & reject others (if any)?",
"acceptModal.prevented": "Ask the person who posted the scrim to accept this request",
@ -47,5 +51,8 @@
"associations.shareLink.reset": "Reset link",
"associations.removeMember.title": "Remove {{username}} from the association?",
"associations.forms.title": "Creating a new association",
"associations.forms.name.title": "Name"
"associations.forms.name.title": "Name",
"forms.managedByAnyone.title": "Anyone can manage",
"forms.managedByAnyone.explanation": "If enabled, all users in this post can accept requests and delete the post, not just the owner.",
"alert.canceled": "This scrim was canceled by {{user}}. Reason: {{reason}}"
}

View File

@ -99,6 +99,7 @@
"actions.showMore": "",
"actions.showLess": "",
"actions.copyToClipboard": "Copiar a clipboard",
"actions.copyTimestampForDiscord": "",
"actions.create": "Crear",
"actions.close": "Cerrar",
"actions.cancel": "Cancelar",

View File

@ -17,6 +17,9 @@
"deleteModal.title": "",
"deleteModal.prevented": "",
"cancelModal.title": "",
"cancelModal.scrim.title": "",
"cancelModal.scrim.reasonLabel": "",
"cancelModal.scrim.reasonExplanation": "",
"actions.contact": "",
"acceptModal.title": "",
"acceptModal.prevented": "",
@ -47,5 +50,8 @@
"associations.shareLink.reset": "",
"associations.removeMember.title": "",
"associations.forms.title": "",
"associations.forms.name.title": ""
"associations.forms.name.title": "",
"forms.managedByAnyone.title": "",
"forms.managedByAnyone.explanation": "",
"alert.canceled": ""
}

View File

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

View File

@ -17,6 +17,9 @@
"deleteModal.title": "",
"deleteModal.prevented": "",
"cancelModal.title": "",
"cancelModal.scrim.title": "",
"cancelModal.scrim.reasonLabel": "",
"cancelModal.scrim.reasonExplanation": "",
"actions.contact": "",
"acceptModal.title": "",
"acceptModal.prevented": "",
@ -47,5 +50,8 @@
"associations.shareLink.reset": "",
"associations.removeMember.title": "",
"associations.forms.title": "",
"associations.forms.name.title": ""
"associations.forms.name.title": "",
"forms.managedByAnyone.title": "",
"forms.managedByAnyone.explanation": "",
"alert.canceled": ""
}

View File

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

View File

@ -17,6 +17,9 @@
"deleteModal.title": "",
"deleteModal.prevented": "",
"cancelModal.title": "",
"cancelModal.scrim.title": "",
"cancelModal.scrim.reasonLabel": "",
"cancelModal.scrim.reasonExplanation": "",
"actions.contact": "",
"acceptModal.title": "",
"acceptModal.prevented": "",
@ -47,5 +50,8 @@
"associations.shareLink.reset": "",
"associations.removeMember.title": "",
"associations.forms.title": "",
"associations.forms.name.title": ""
"associations.forms.name.title": "",
"forms.managedByAnyone.title": "",
"forms.managedByAnyone.explanation": "",
"alert.canceled": ""
}

View File

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

View File

@ -17,6 +17,9 @@
"deleteModal.title": "Supprimer le post?",
"deleteModal.prevented": "Le post doit être supprimée par le propriétaire ({{username}})",
"cancelModal.title": "Annuler la demande ?",
"cancelModal.scrim.title": "",
"cancelModal.scrim.reasonLabel": "",
"cancelModal.scrim.reasonExplanation": "",
"actions.contact": "Contact",
"acceptModal.title": "Accepter la demande de scrim de {{groupName}} et rejeter les autres (s'il y en a)?",
"acceptModal.prevented": "Demandez à la personne qui a posté le scrim d'accepter cette demande",
@ -36,7 +39,6 @@
"forms.divs.minDiv.title": "Min div",
"forms.divs.maxDiv.title": "Max div",
"page.scheduledScrim": "Scrim programmé",
"associations.title": "Association",
"associations.explanation": "Créez une ''association'' pour regarder dans un groupe plus petit (par exemple, créez une association avec les adversaires habituels de votre équipe ou avec la division LUTI).",
"associations.join.title": "Rejoindre l'association {{name}} ?",
@ -48,5 +50,8 @@
"associations.shareLink.reset": "Réinitialiser le lien",
"associations.removeMember.title": "Enlever {{username}} de l'association?",
"associations.forms.title": "Créer une nouvelle association",
"associations.forms.name.title": "Nom"
"associations.forms.name.title": "Nom",
"forms.managedByAnyone.title": "",
"forms.managedByAnyone.explanation": "",
"alert.canceled": ""
}

View File

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

View File

@ -17,6 +17,9 @@
"deleteModal.title": "",
"deleteModal.prevented": "",
"cancelModal.title": "",
"cancelModal.scrim.title": "",
"cancelModal.scrim.reasonLabel": "",
"cancelModal.scrim.reasonExplanation": "",
"actions.contact": "",
"acceptModal.title": "",
"acceptModal.prevented": "",
@ -47,5 +50,8 @@
"associations.shareLink.reset": "",
"associations.removeMember.title": "",
"associations.forms.title": "",
"associations.forms.name.title": ""
"associations.forms.name.title": "",
"forms.managedByAnyone.title": "",
"forms.managedByAnyone.explanation": "",
"alert.canceled": ""
}

View File

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

View File

@ -17,6 +17,9 @@
"deleteModal.title": "",
"deleteModal.prevented": "",
"cancelModal.title": "",
"cancelModal.scrim.title": "",
"cancelModal.scrim.reasonLabel": "",
"cancelModal.scrim.reasonExplanation": "",
"actions.contact": "",
"acceptModal.title": "",
"acceptModal.prevented": "",
@ -47,5 +50,8 @@
"associations.shareLink.reset": "",
"associations.removeMember.title": "",
"associations.forms.title": "",
"associations.forms.name.title": ""
"associations.forms.name.title": "",
"forms.managedByAnyone.title": "",
"forms.managedByAnyone.explanation": "",
"alert.canceled": ""
}

View File

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

View File

@ -17,6 +17,9 @@
"deleteModal.title": "",
"deleteModal.prevented": "",
"cancelModal.title": "",
"cancelModal.scrim.title": "",
"cancelModal.scrim.reasonLabel": "",
"cancelModal.scrim.reasonExplanation": "",
"actions.contact": "",
"acceptModal.title": "",
"acceptModal.prevented": "",
@ -47,5 +50,8 @@
"associations.shareLink.reset": "",
"associations.removeMember.title": "",
"associations.forms.title": "",
"associations.forms.name.title": ""
"associations.forms.name.title": "",
"forms.managedByAnyone.title": "",
"forms.managedByAnyone.explanation": "",
"alert.canceled": ""
}

View File

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

View File

@ -17,6 +17,9 @@
"deleteModal.title": "",
"deleteModal.prevented": "",
"cancelModal.title": "",
"cancelModal.scrim.title": "",
"cancelModal.scrim.reasonLabel": "",
"cancelModal.scrim.reasonExplanation": "",
"actions.contact": "",
"acceptModal.title": "",
"acceptModal.prevented": "",
@ -47,5 +50,8 @@
"associations.shareLink.reset": "",
"associations.removeMember.title": "",
"associations.forms.title": "",
"associations.forms.name.title": ""
"associations.forms.name.title": "",
"forms.managedByAnyone.title": "",
"forms.managedByAnyone.explanation": "",
"alert.canceled": ""
}

View File

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

View File

@ -17,6 +17,9 @@
"deleteModal.title": "",
"deleteModal.prevented": "",
"cancelModal.title": "",
"cancelModal.scrim.title": "",
"cancelModal.scrim.reasonLabel": "",
"cancelModal.scrim.reasonExplanation": "",
"actions.contact": "",
"acceptModal.title": "",
"acceptModal.prevented": "",
@ -47,5 +50,8 @@
"associations.shareLink.reset": "",
"associations.removeMember.title": "",
"associations.forms.title": "",
"associations.forms.name.title": ""
"associations.forms.name.title": "",
"forms.managedByAnyone.title": "",
"forms.managedByAnyone.explanation": "",
"alert.canceled": ""
}

View File

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

View File

@ -17,6 +17,9 @@
"deleteModal.title": "",
"deleteModal.prevented": "",
"cancelModal.title": "",
"cancelModal.scrim.title": "",
"cancelModal.scrim.reasonLabel": "",
"cancelModal.scrim.reasonExplanation": "",
"actions.contact": "",
"acceptModal.title": "",
"acceptModal.prevented": "",
@ -47,5 +50,8 @@
"associations.shareLink.reset": "",
"associations.removeMember.title": "",
"associations.forms.title": "",
"associations.forms.name.title": ""
"associations.forms.name.title": "",
"forms.managedByAnyone.title": "",
"forms.managedByAnyone.explanation": "",
"alert.canceled": ""
}

View File

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

View File

@ -17,6 +17,9 @@
"deleteModal.title": "",
"deleteModal.prevented": "",
"cancelModal.title": "",
"cancelModal.scrim.title": "",
"cancelModal.scrim.reasonLabel": "",
"cancelModal.scrim.reasonExplanation": "",
"actions.contact": "",
"acceptModal.title": "",
"acceptModal.prevented": "",
@ -47,5 +50,8 @@
"associations.shareLink.reset": "",
"associations.removeMember.title": "",
"associations.forms.title": "",
"associations.forms.name.title": ""
"associations.forms.name.title": "",
"forms.managedByAnyone.title": "",
"forms.managedByAnyone.explanation": "",
"alert.canceled": ""
}

View File

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

View File

@ -17,6 +17,9 @@
"deleteModal.title": "",
"deleteModal.prevented": "",
"cancelModal.title": "",
"cancelModal.scrim.title": "",
"cancelModal.scrim.reasonLabel": "",
"cancelModal.scrim.reasonExplanation": "",
"actions.contact": "",
"acceptModal.title": "",
"acceptModal.prevented": "",
@ -47,5 +50,8 @@
"associations.shareLink.reset": "",
"associations.removeMember.title": "",
"associations.forms.title": "",
"associations.forms.name.title": ""
"associations.forms.name.title": "",
"forms.managedByAnyone.title": "",
"forms.managedByAnyone.explanation": "",
"alert.canceled": ""
}

View File

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

View File

@ -17,6 +17,9 @@
"deleteModal.title": "",
"deleteModal.prevented": "",
"cancelModal.title": "",
"cancelModal.scrim.title": "",
"cancelModal.scrim.reasonLabel": "",
"cancelModal.scrim.reasonExplanation": "",
"actions.contact": "",
"acceptModal.title": "",
"acceptModal.prevented": "",
@ -47,5 +50,8 @@
"associations.shareLink.reset": "",
"associations.removeMember.title": "",
"associations.forms.title": "",
"associations.forms.name.title": ""
"associations.forms.name.title": "",
"forms.managedByAnyone.title": "",
"forms.managedByAnyone.explanation": "",
"alert.canceled": ""
}

View File

@ -0,0 +1,16 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/* sql */ `alter table "ScrimPost" add column "managedByAnyone" integer default 0 not null`,
).run();
db.prepare(
/* sql */ `alter table "ScrimPost" add column "canceledAt" integer`,
).run();
db.prepare(
/* sql */ `alter table "ScrimPost" add column "canceledByUserId" integer references "User"("id") on delete restrict`,
).run();
db.prepare(
/* sql */ `alter table "ScrimPost" add column "cancelReason" text`,
).run();
})();
}