SendouFormify different things

This commit is contained in:
Kalle
2026-03-03 18:08:13 +02:00
parent 9d5dacaa0e
commit df70b56f4e
40 changed files with 169 additions and 119 deletions

View File

@@ -6,6 +6,7 @@ import type {
Selectable,
Updateable,
} from "kysely";
import type { AssociationIdentifier } from "~/features/associations/associations-constants";
import type { AssociationVisibility } from "~/features/associations/associations-types";
import type { tags } from "~/features/calendar/calendar-constants";
import type { CalendarFilters } from "~/features/calendar/calendar-types";
@@ -721,7 +722,7 @@ export interface TournamentTeam {
avatarImgId: number | null;
isLooking: Generated<DBBoolean>;
isPlaceholder: Generated<DBBoolean>;
lfgVisibility: JSONColumnTypeNullable<AssociationVisibility>;
lfgVisibility: JSONColumnTypeNullable<AssociationIdentifier>;
lfgNote: string | null;
}

View File

@@ -9,7 +9,7 @@ import {
import { assertUnreachable } from "~/utils/types";
import { idObject } from "~/utils/zod";
import * as TournamentLFGRepository from "../TournamentLFGRepository.server";
import { lookingSchema } from "../tournament-lfg-schemas.server";
import { lookingSchema } from "../tournament-lfg-schemas";
import { survivingTeamId } from "../tournament-lfg-utils";
// xxx: prevent actions in certain cases? like when tournament has started
@@ -64,6 +64,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
tournamentId,
userId: user.id,
isStayAsSub: data.stayAsSub ?? false,
lfgNote: data.note ?? undefined,
});
}
@@ -171,25 +172,19 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
break;
}
case "UPDATE_NOTE": {
case "UPDATE_GROUP": {
const ownGroup = await findOwnGroup();
if (!ownGroup) return null;
await TournamentLFGRepository.updateTeamNote({
teamId: ownGroup.id,
value: data.value,
value: data.note ?? null,
});
break;
}
case "UPDATE_STAY_AS_SUB": {
const ownGroup = await findOwnGroup();
if (!ownGroup) return null;
await TournamentLFGRepository.updateStayAsSub({
teamId: ownGroup.id,
userId: user.id,
value: data.value,
value: data.stayAsSub ?? false,
});
break;

View File

@@ -62,10 +62,6 @@
min-height: 24px;
}
.noteTextarea {
height: 4rem !important;
}
.vcIcon {
height: 15px;
stroke-width: 2;

View File

@@ -15,10 +15,11 @@ import { StarFilledIcon } from "~/components/icons/StarFilled";
import { SubmitButton } from "~/components/SubmitButton";
import type { Pronouns } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import { SendouForm } from "~/form/SendouForm";
import { languagesUnified } from "~/modules/i18n/config";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { navIconUrl, userPage } from "~/utils/urls";
import { TOURNAMENT_LFG } from "../tournament-lfg-constants";
import { updateGroupFormSchema } from "../tournament-lfg-schemas";
import styles from "./LFGGroupCard.module.css";
export type LFGGroupMember = {
@@ -47,7 +48,6 @@ export type LFGGroup = {
usersRole: "OWNER" | "MANAGER" | "REGULAR" | null;
};
// xxx: "cancel" & "save" buttons off
export function LFGGroupCard({
group,
action,
@@ -59,10 +59,15 @@ export function LFGGroupCard({
}) {
const { t } = useTranslation(["common", "q"]);
const fetcher = useFetcher();
const user = useUser();
const isOwnGroup = group.id === ownGroup?.id;
const showActions = isOwnGroup && group.usersRole === "OWNER";
const currentMember = user
? group.members.find((m) => m.id === user.id)
: undefined;
return (
<section className={styles.group}>
{group.teamName ? (
@@ -84,7 +89,11 @@ export function LFGGroupCard({
))}
</div>
{isOwnGroup ? (
<LFGTeamNote note={group.note} editable={group.usersRole === "OWNER"} />
<LFGTeamNote
note={group.note}
editable={group.usersRole === "OWNER"}
isStayAsSub={currentMember?.isStayAsSub ?? false}
/>
) : null}
{action &&
(ownGroup?.usersRole === "OWNER" || ownGroup?.usersRole === "MANAGER") ? (
@@ -170,20 +179,29 @@ function LFGGroupMemberRow({
function LFGTeamNote({
note,
editable,
isStayAsSub,
}: {
note: string | null;
editable: boolean;
isStayAsSub: boolean;
}) {
const { t } = useTranslation(["common", "q"]);
const [editing, setEditing] = React.useState(false);
// biome-ignore lint/correctness/useExhaustiveDependencies(note): when note updates exit editing mode
// biome-ignore lint/correctness/useExhaustiveDependencies(isStayAsSub): when isStayAsSub updates exit editing mode
React.useEffect(() => {
setEditing(false);
}, [note]);
}, [note, isStayAsSub]);
if (editing) {
return <LFGAddNoteForm note={note} stopEditing={() => setEditing(false)} />;
return (
<LFGEditGroupForm
note={note}
isStayAsSub={isStayAsSub}
stopEditing={() => setEditing(false)}
/>
);
}
if (note) {
@@ -217,38 +235,23 @@ function LFGTeamNote({
);
}
function LFGAddNoteForm({
function LFGEditGroupForm({
note,
isStayAsSub,
stopEditing,
}: {
note: string | null;
isStayAsSub: boolean;
stopEditing: () => void;
}) {
const fetcher = useFetcher();
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const { t } = useTranslation(["common"]);
const [value, setValue] = React.useState(note ?? "");
const newValueLegal = value.length <= TOURNAMENT_LFG.PUBLIC_NOTE_MAX_LENGTH;
React.useEffect(() => {
if (!textareaRef.current) return;
textareaRef.current.focus();
textareaRef.current.selectionStart = textareaRef.current.selectionEnd =
textareaRef.current.value.length;
}, []);
return (
<fetcher.Form method="post">
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
rows={2}
className={`${styles.noteTextarea} mt-1`}
name="value"
ref={textareaRef}
/>
<div className="stack horizontal justify-between">
<SendouForm
schema={updateGroupFormSchema}
defaultValues={{ note: note ?? undefined, stayAsSub: isStayAsSub }}
submitButtonText={t("common:actions.save")}
secondarySubmit={
<SendouButton
variant="minimal-destructive"
size="miniscule"
@@ -256,21 +259,15 @@ function LFGAddNoteForm({
>
{t("common:actions.cancel")}
</SendouButton>
{newValueLegal ? (
<SubmitButton
_action="UPDATE_NOTE"
variant="minimal"
size="miniscule"
>
{t("common:actions.save")}
</SubmitButton>
) : (
<span className="text-warning text-xxs font-semi-bold">
{value.length}/{TOURNAMENT_LFG.PUBLIC_NOTE_MAX_LENGTH}
</span>
)}
</div>
</fetcher.Form>
}
>
{({ FormField }) => (
<>
<FormField name="note" />
<FormField name="stayAsSub" />
</>
)}
</SendouForm>
);
}

View File

@@ -420,7 +420,12 @@ function JoinQueueForm() {
schema={joinQueueFormSchema}
submitButtonText={t("q:looking.joinQPromptTeam")}
>
{({ FormField }) => <FormField name="stayAsSub" />}
{({ FormField }) => (
<>
<FormField name="note" />
<FormField name="stayAsSub" />
</>
)}
</SendouForm>
);
}

View File

@@ -1,51 +0,0 @@
import { z } from "zod";
import { _action, checkboxValueToBoolean, falsyToNull, id } from "~/utils/zod";
import { TOURNAMENT_LFG } from "./tournament-lfg-constants";
import { joinQueueFormSchema } from "./tournament-lfg-schemas";
export const lookingSchema = z.union([
joinQueueFormSchema,
z.object({
_action: _action("LIKE"),
targetTeamId: id,
}),
z.object({
_action: _action("UNLIKE"),
targetTeamId: id,
}),
z.object({
_action: _action("ACCEPT"),
targetTeamId: id,
}),
z.object({
_action: _action("GIVE_MANAGER"),
userId: id,
}),
z.object({
_action: _action("REMOVE_MANAGER"),
userId: id,
}),
z.object({
_action: _action("UPDATE_NOTE"),
value: z.preprocess(
falsyToNull,
// xxx: use SendouForm style? (but would need SQ to be migrated too)
z.string().max(TOURNAMENT_LFG.PUBLIC_NOTE_MAX_LENGTH).nullable(),
),
}),
z.object({
_action: _action("UPDATE_STAY_AS_SUB"),
value: z.preprocess(checkboxValueToBoolean, z.boolean()),
}),
z.object({
_action: _action("LEAVE_GROUP"),
}),
z.object({
_action: _action("ADD_SUB"),
message: z.string().max(TOURNAMENT_LFG.PUBLIC_NOTE_MAX_LENGTH).optional(),
}),
z.object({
_action: _action("DELETE_SUB"),
userId: id,
}),
]);

View File

@@ -1,10 +1,62 @@
import { z } from "zod";
import { stringConstant, toggle } from "~/form/fields";
import { stringConstant, textAreaOptional, toggle } from "~/form/fields";
import { _action, id } from "~/utils/zod";
import { TOURNAMENT_LFG } from "./tournament-lfg-constants";
const lfgNoteField = textAreaOptional({
label: "labels.note",
maxLength: TOURNAMENT_LFG.PUBLIC_NOTE_MAX_LENGTH,
});
const lfgStayAsSubField = toggle({
label: "labels.stayAsSub",
bottomText: "bottomTexts.stayAsSub",
});
export const joinQueueFormSchema = z.object({
_action: stringConstant("JOIN_QUEUE"),
stayAsSub: toggle({
label: "labels.stayAsSub",
bottomText: "bottomTexts.stayAsSub",
}),
note: lfgNoteField,
stayAsSub: lfgStayAsSubField,
});
export const updateGroupFormSchema = z.object({
_action: stringConstant("UPDATE_GROUP"),
note: lfgNoteField,
stayAsSub: lfgStayAsSubField,
});
export const lookingSchema = z.union([
joinQueueFormSchema,
z.object({
_action: _action("LIKE"),
targetTeamId: id,
}),
z.object({
_action: _action("UNLIKE"),
targetTeamId: id,
}),
z.object({
_action: _action("ACCEPT"),
targetTeamId: id,
}),
z.object({
_action: _action("GIVE_MANAGER"),
userId: id,
}),
z.object({
_action: _action("REMOVE_MANAGER"),
userId: id,
}),
updateGroupFormSchema,
z.object({
_action: _action("LEAVE_GROUP"),
}),
z.object({
_action: _action("ADD_SUB"),
message: z.string().max(TOURNAMENT_LFG.PUBLIC_NOTE_MAX_LENGTH).optional(),
}),
z.object({
_action: _action("DELETE_SUB"),
userId: id,
}),
]);

View File

@@ -115,5 +115,6 @@ export function concatUserSubmittedImagePrefix<T extends string | null>(
]);
}
export type JSONColumnTypeNullable<SelectType extends object | null> =
ColumnType<SelectType | null, string | null, string | null>;
export type JSONColumnTypeNullable<
SelectType extends object | string | number | null,
> = ColumnType<SelectType | null, string | null, string | null>;

View File

@@ -1,5 +1,3 @@
// xxx: update tournament lfg stuff etc.
Note: some simple features omitted with only a few relations and no special notes
See `tables.ts` for some more documentation on column-level.
@@ -177,6 +175,31 @@ erDiagram
The database structure is mimicking the `brackets-manager.js` library. See this issue for a schema: [https://github.com/Drarig29/brackets-manager.js/issues/111#issuecomment-997417423](https://github.com/Drarig29/brackets-manager.js/issues/111#issuecomment-997417423)
## Tournament Teams
```mermaid
erDiagram
Tournament ||--o{ TournamentTeam : has
TournamentTeam ||--|{ TournamentTeamMember : has
User ||--o{ TournamentTeamMember : member_of
TournamentTeam }o--o| AllTeam : team
```
### Notes
- `isPlaceholder` - temporary team created automatically when an unregistered user joins the LFG queue
- `isLooking` - team is visible in the LFG page
- `teamId` - optional link to a sendou.ink team (for avatar/name)
#### Placeholder team lifecycle
- Unregistered user joins LFG -> placeholder team created (`isPlaceholder: 1`)
- Registered user joins LFG -> existing team reused, only `isLooking` flag toggled
- Placeholder user registers for tournament -> placeholder team deleted, new regular team created
- Placeholder + placeholder merge -> surviving team set to `isPlaceholder: 0`
- Placeholder + registered team merge -> registered team always survives
- On merge, each member's `isStayAsSub` preference is preserved
## Tournament organizations
```mermaid

View File

@@ -183,6 +183,7 @@
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -120,6 +120,7 @@
"looking.rankCalculating": "",
"looking.allTiers": "",
"looking.joinQPrompt": "",
"looking.joinQPromptTeam": "",
"looking.joinQPromptMembers": "",
"looking.range.or": "",
"looking.range.or.explanation": "",

View File

@@ -183,6 +183,7 @@
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -120,6 +120,7 @@
"looking.rankCalculating": "",
"looking.allTiers": "",
"looking.joinQPrompt": "",
"looking.joinQPromptTeam": "",
"looking.joinQPromptMembers": "",
"looking.range.or": "",
"looking.range.or.explanation": "",

View File

@@ -183,6 +183,7 @@
"options.artSource.MADE-BY": "Made by me",
"options.artSource.MADE-OF": "Made of me",
"labels.tierListUrl": "Tier List URL",
"labels.note": "Note",
"labels.stayAsSub": "Stay as sub",
"bottomTexts.stayAsSub": "If you don't find a full team before registration ends, you will stay as a solo sub for the duration of the tournament"
}

View File

@@ -183,6 +183,7 @@
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -120,6 +120,7 @@
"looking.rankCalculating": "Menos de {{count}} sets jugados. Rango aún se está calculando...",
"looking.allTiers": "Todos los niveles",
"looking.joinQPrompt": "Únete a la fila para encontrar un grupo",
"looking.joinQPromptTeam": "",
"looking.joinQPromptMembers": "",
"looking.range.or": "o",
"looking.range.or.explanation": "",

View File

@@ -183,6 +183,7 @@
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -120,6 +120,7 @@
"looking.rankCalculating": "Menos de {{count}} sets jugados. Rango aún se está calculando...",
"looking.allTiers": "Todos los niveles",
"looking.joinQPrompt": "Únete a la fila para encontrar un grupo",
"looking.joinQPromptTeam": "",
"looking.joinQPromptMembers": "",
"looking.range.or": "o",
"looking.range.or.explanation": "",

View File

@@ -183,6 +183,7 @@
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -120,6 +120,7 @@
"looking.rankCalculating": "",
"looking.allTiers": "",
"looking.joinQPrompt": "",
"looking.joinQPromptTeam": "",
"looking.joinQPromptMembers": "",
"looking.range.or": "",
"looking.range.or.explanation": "",

View File

@@ -183,6 +183,7 @@
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -120,6 +120,7 @@
"looking.rankCalculating": "Moins de {{count}} sets jouer. Le rank est toujours en calcul...",
"looking.allTiers": "Tout les ranks",
"looking.joinQPrompt": "Rejoindre la queue pour trouvez un groupe",
"looking.joinQPromptTeam": "",
"looking.joinQPromptMembers": "",
"looking.range.or": "ou",
"looking.range.or.explanation": "",

View File

@@ -183,6 +183,7 @@
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -120,6 +120,7 @@
"looking.rankCalculating": "",
"looking.allTiers": "",
"looking.joinQPrompt": "",
"looking.joinQPromptTeam": "",
"looking.joinQPromptMembers": "",
"looking.range.or": "",
"looking.range.or.explanation": "",

View File

@@ -183,6 +183,7 @@
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -120,6 +120,7 @@
"looking.rankCalculating": "Meno di {{count}} sets giocati. Il rango sta ancora venendo calcolato...",
"looking.allTiers": "Tutti i tier",
"looking.joinQPrompt": "Unisciti alla coda o trova un gruppo",
"looking.joinQPromptTeam": "",
"looking.joinQPromptMembers": "",
"looking.range.or": "o",
"looking.range.or.explanation": "",

View File

@@ -183,6 +183,7 @@
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -120,6 +120,7 @@
"looking.rankCalculating": "遊んだセットが{{count}}個以下です。ランクはまだ計算中です...",
"looking.allTiers": "全てのティア",
"looking.joinQPrompt": "グループを見つけるには列に入ってください",
"looking.joinQPromptTeam": "",
"looking.joinQPromptMembers": "",
"looking.range.or": "か",
"looking.range.or.explanation": "",

View File

@@ -183,6 +183,7 @@
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -120,6 +120,7 @@
"looking.rankCalculating": "",
"looking.allTiers": "",
"looking.joinQPrompt": "",
"looking.joinQPromptTeam": "",
"looking.joinQPromptMembers": "",
"looking.range.or": "",
"looking.range.or.explanation": "",

View File

@@ -183,6 +183,7 @@
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -120,6 +120,7 @@
"looking.rankCalculating": "",
"looking.allTiers": "",
"looking.joinQPrompt": "",
"looking.joinQPromptTeam": "",
"looking.joinQPromptMembers": "",
"looking.range.or": "",
"looking.range.or.explanation": "",

View File

@@ -183,6 +183,7 @@
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -120,6 +120,7 @@
"looking.rankCalculating": "",
"looking.allTiers": "",
"looking.joinQPrompt": "",
"looking.joinQPromptTeam": "",
"looking.joinQPromptMembers": "",
"looking.range.or": "",
"looking.range.or.explanation": "",

View File

@@ -183,6 +183,7 @@
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -120,6 +120,7 @@
"looking.rankCalculating": "Menos que {{count}} sets jogados. O rank ainda está sendo calculado...",
"looking.allTiers": "Todas as tiers",
"looking.joinQPrompt": "Entre na fila para achar um grupo",
"looking.joinQPromptTeam": "",
"looking.joinQPromptMembers": "",
"looking.range.or": "",
"looking.range.or.explanation": "",

View File

@@ -183,6 +183,7 @@
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -120,6 +120,7 @@
"looking.rankCalculating": "Менее чем {{count}} матчей сыграно. Ранг ещё рассчитывается...",
"looking.allTiers": "Все ранги",
"looking.joinQPrompt": "Присоединитесь к очереди, чтобы найти группу",
"looking.joinQPromptTeam": "",
"looking.joinQPromptMembers": "",
"looking.range.or": "или",
"looking.range.or.explanation": "",

View File

@@ -183,6 +183,7 @@
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
"labels.tierListUrl": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -120,6 +120,7 @@
"looking.rankCalculating": "对战数少于 {{count}} 场。段位定分中...",
"looking.allTiers": "所有段位",
"looking.joinQPrompt": "参加匹配以寻找小队",
"looking.joinQPromptTeam": "",
"looking.joinQPromptMembers": "",
"looking.range.or": "或",
"looking.range.or.explanation": "",