Merge branch 'sqified-tournaments' into css-rework-sidenav

This commit is contained in:
Kalle
2026-03-15 15:48:42 +02:00
86 changed files with 5631 additions and 952 deletions

329
TOURNAMENT_LFG_PLAN.md Normal file
View File

@@ -0,0 +1,329 @@
# Tournament LFG Implementation Plan
## Overview
Add a new `/to/:id/looking` route that provides SendouQ-style matchmaking for tournament team formation. Players and teams can find each other before the tournament starts.
## Phase 1: Database Migration
**File**: `migrations/118-tournament-lfg.js`
Create three new tables:
### TournamentLFGGroup
| Column | Type | Notes |
|--------|------|-------|
| id | integer primary key | Auto-increment |
| tournamentId | integer not null | FK to Tournament |
| tournamentTeamId | integer | FK to TournamentTeam (null for unregistered groups) |
| visibility | text | JSON (AssociationVisibility) |
| chatCode | text not null | Unique room code for group chat |
| createdAt | integer | Default now |
### TournamentLFGGroupMember
| Column | Type | Notes |
|--------|------|-------|
| groupId | integer not null | FK to TournamentLFGGroup |
| userId | integer not null | FK to User |
| role | text not null | OWNER / MANAGER / REGULAR |
| note | text | Public note |
| isStayAsSub | integer | Boolean (0/1), default 0 |
| createdAt | integer | Default now |
### TournamentLFGLike
| Column | Type | Notes |
|--------|------|-------|
| likerGroupId | integer not null | FK to TournamentLFGGroup |
| targetGroupId | integer not null | FK to TournamentLFGGroup |
| createdAt | integer | Default now |
All tables use `STRICT` mode, `ON DELETE CASCADE` for FKs, and have indexes on FK columns.
---
## Phase 2: TypeScript Types
**File**: `app/db/tables.ts`
Add interfaces for the three new tables following existing patterns (`GeneratedAlways`, `Generated`, `JSONColumnTypeNullable`).
Add to the `DB` interface:
- `TournamentLFGGroup`
- `TournamentLFGGroupMember`
- `TournamentLFGLike`
---
## Phase 3: Feature File Structure
```
app/features/tournament-lfg/
├── TournamentLFGRepository.server.ts # Database operations
├── tournament-lfg-types.ts # TypeScript types (LFGGroup, LFGGroupMember, etc.)
├── tournament-lfg-schemas.server.ts # Zod validation schemas
├── tournament-lfg-constants.ts # Constants (note max length, etc.)
├── tournament-lfg-utils.ts # Utility functions
├── routes/
│ └── to.$id.looking.tsx # Main route (loader + action + component)
├── loaders/
│ └── to.$id.looking.server.ts # Data loader
├── actions/
│ └── to.$id.looking.server.ts # Action handler
└── components/
└── LFGGroupCard.tsx # Group card (mirrors SendouQ GroupCard pattern)
```
---
## Phase 4: Repository Implementation
**File**: `app/features/tournament-lfg/TournamentLFGRepository.server.ts`
Mirror `SQGroupRepository.server.ts` pattern. Key functions:
**Group Management:**
- `findGroupsByTournamentId(tournamentId)` - Get all active groups
- `addMember(groupId, { userId, role, stayAsSub? })` - Add member to group
- `morphGroups({ survivingGroupId, otherGroupId })` - Merge two groups
**Likes:**
- `addLike({ likerGroupId, targetGroupId })` - Add like
- `deleteLike({ likerGroupId, targetGroupId })` - Remove like
- `allLikesByGroupId(groupId)` - Get { given: [], received: [] }
**Member Management:**
- `updateMemberNote({ groupId, userId, value })` - Update public note
- `updateMemberRole({ userId, groupId, role })` - Change role
- `updateStayAsSub({ groupId, userId, value })` - Toggle sub preference
- `kickMember({ groupId, userId })` - Owner kicks member
**Tournament Integration:**
- `cleanupForTournamentStart(tournamentId)` - Delete groups, preserve stayAsSub members
- `getSubsForTournament(tournamentId)` - Get users who opted to stay as sub
---
## Phase 5: Route Implementation
### 5.1 Route Registration
**File**: `app/routes.ts`
Add inside `/to/:id` children:
```typescript
route("looking", "features/tournament-lfg/routes/to.$id.looking.tsx"),
```
### 5.2 Loader
**File**: `app/features/tournament-lfg/loaders/to.$id.looking.server.ts`
Returns:
- `groups` - All visible groups (filtered by visibility)
- `ownGroup` - User's current group (if any)
- `likes` - { given: [], received: [] } for own group
- `privateNotes` - User's private notes on other players (reuse from SendouQ)
- `lastUpdated` - Timestamp for auto-refresh
### 5.3 Action
**File**: `app/features/tournament-lfg/actions/to.$id.looking.server.ts`
Actions:
- `JOIN_QUEUE` - Create new group
- `LIKE` / `UNLIKE` - Like/unlike another group
- `ACCEPT` - Accept mutual like (triggers team creation/merge)
- `LEAVE_GROUP` - Leave current group
- `KICK_FROM_GROUP` - Owner kicks member
- `GIVE_MANAGER` / `REMOVE_MANAGER` - Role management
- `UPDATE_NOTE` - Update public note
- `UPDATE_STAY_AS_SUB` - Toggle sub preference
- `REFRESH_GROUP` - Refresh activity timestamp
**ACCEPT Action Flow:**
1. Verify mutual like exists
2. Check if either group has `tournamentTeamId`
3. If neither: Create new `TournamentTeam` (use auto-generated name)
4. Merge groups (use `morphGroups`)
5. Add all members to `TournamentTeamMember`
6. Send `TO_LFG_TEAM_FORMED` notification
7. If team reaches `maxMembersPerTeam`: delete the LFG group
### 5.4 Component
**File**: `app/features/tournament-lfg/routes/to.$id.looking.tsx`
Structure (mirror `/q/looking`):
- Three-column desktop layout: My Group | Groups | Invitations
- Tab structure on mobile
- Reuse `GroupCard` display pattern (weapons, VC, tier)
- Reuse `MemberAdder` for quick-add trusted players
- Reuse `GroupLeaver` component
- Chat integration for groups with 2+ members
- "Stay as sub" checkbox in join form
---
## Phase 6: Tournament Integration
### 6.1 Add "Looking" Tab
**File**: `app/features/tournament/routes/to.$id.tsx`
Add new `SubNavLink` (show only before tournament starts, not for invitationals):
```tsx
{!tournament.hasStarted && !tournament.isInvitational && (
<SubNavLink to="looking">{t("tournament:tabs.looking")}</SubNavLink>
)}
```
### 6.2 Tournament.ts Getter
**File**: `app/features/tournament-bracket/core/Tournament.ts`
Add:
```typescript
get lfgEnabled() {
return !this.isInvitational && !this.hasStarted && this.subsFeatureEnabled;
}
```
### 6.3 Auto-cleanup on Tournament Start
When tournament bracket starts, call `TournamentLFGRepository.cleanupForTournamentStart(tournamentId)`:
- Delete all `TournamentLFGGroup` records
- Preserve `TournamentLFGGroupMember` records where `stayAsSub = 1` for subs list
---
## Phase 7: Notifications
**File**: `app/features/notifications/notifications-types.ts`
Add three new notification types:
```typescript
| NotificationItem<
"TO_LFG_LIKED",
{
tournamentId: number;
tournamentName: string;
likerUsername: string;
}
>
| NotificationItem<
"TO_LFG_TEAM_FORMED",
{
tournamentId: number;
tournamentName: string;
teamName: string;
tournamentTeamId: number;
}
>
| NotificationItem<
"TO_LFG_CHAT_MESSAGE",
{
tournamentId: number;
tournamentName: string;
teamName: string;
tournamentTeamId: number;
}
>
```
**File**: `app/features/notifications/notifications-utils.ts`
Add notification link handlers and icon mappings.
---
## Phase 8: Translations
**File**: `public/locales/en/tournament.json`
Add keys:
- `tabs.looking`
- `lfg.join.header`, `lfg.join.stayAsSub`, `lfg.join.visibility`
- `lfg.myGroup.header`, `lfg.myGroup.empty`
- `lfg.groups.header`, `lfg.groups.empty`
- `lfg.invitations.header`, `lfg.invitations.empty`, `lfg.invitations.accept`
- `lfg.actions.like`, `lfg.actions.unlike`, `lfg.actions.leave`
Run `npm run i18n:sync` after adding.
---
## Phase 9: Group Merging Logic
When two groups merge via ACCEPT:
| Group A has team | Group B has team | Result |
|------------------|------------------|--------|
| No | No | Create new TournamentTeam, both join it |
| Yes | No | B's members join A's team |
| No | Yes | A's members join B's team |
| Yes | Yes | Accepting team absorbs liker team (accepting team name persists) |
Auto-generated team name format: `"<owner_username>'s Team"` (e.g., "Sendou's Team" - can be changed later on registration page)
After merge:
- Combined group stays in LFG queue
- When `maxMembersPerTeam` reached, group is auto-removed from queue
---
## Key Files to Reference
| Purpose | File |
|---------|------|
| Repository pattern | `app/features/sendouq/SQGroupRepository.server.ts` |
| Route pattern | `app/features/sendouq/routes/q.looking.tsx` |
| Action pattern | `app/features/sendouq/actions/q.looking.server.ts` |
| GroupCard UI | `app/features/sendouq/components/GroupCard.tsx` |
| Tournament tabs | `app/features/tournament/routes/to.$id.tsx` |
| Team creation | `app/features/tournament/TournamentTeamRepository.server.ts` |
| Notification types | `app/features/notifications/notifications-types.ts` |
| Visibility type | `app/features/associations/associations-types.ts` |
---
## Implementation Order
1. Migration (100-tournament-lfg.js)
2. Types (tables.ts + tournament-lfg-types.ts)
3. Repository (TournamentLFGRepository.server.ts)
4. Schemas (tournament-lfg-schemas.server.ts)
5. Loader (to.$id.looking.server.ts)
6. Action (to.$id.looking.server.ts)
7. Route component (to.$id.looking.tsx)
8. Tournament integration (tab, cleanup hook)
9. Notifications (types + utils)
10. Translations
11. Testing
---
## Verification
1. **Manual Testing:**
- Join LFG as solo player
- Create group with 2 players
- Like another group, verify notification sent
- Accept mutual like, verify team created
- Verify team appears on registration page
- Test "stay as sub" checkbox
- Test visibility filtering
2. **Unit Tests:**
- Repository functions (create, merge, delete, likes)
- Visibility filtering logic
3. **E2E Tests:**
- Full flow: join -> like -> accept -> team formed
- Leave group
- Kick from group
4. **Run checks:**
```bash
npm run checks
```

166
TOURNAMENT_LFG_SPEC.md Normal file
View File

@@ -0,0 +1,166 @@
# Tournament LFG Feature Spec
## Overview
New `/to/:id/looking` route that provides SendouQ-style matchmaking for tournament team formation. Players and teams can find each other before tournament starts.
## Route
`/to/:id/looking` (new route, mirrors `/q/looking`)
## Data Model
Separate tables from SendouQ (cleaner separation):
### TournamentLFGGroup
| Column | Type | Description |
|--------|------|-------------|
| id | number | Primary key |
| tournamentTeamId | number | null | FK to TournamentTeam |
| visibility | string | JSON visibility info (/scrims style) |
| chatCode | string | Unique room code for group chat |
| createdAt | number | Timestamp |
### TournamentLFGGroupMember
| Column | Type | Description |
|--------|------|-------------|
| groupId | number | FK to TournamentLFGGroup |
| userId | number | FK to User |
| role | string | OWNER / MANAGER / REGULAR |
| note | string | Public note visible to group members |
| stayAsSub | boolean | Convert to sub if team not formed by start |
| createdAt | number | Timestamp |
### TournamentLFGLike
| Column | Type | Description |
|--------|------|-------------|
| likerGroupId | number | FK to TournamentLFGGroup |
| targetGroupId | number | FK to TournamentLFGGroup |
| createdAt | number | Timestamp |
### TournamentSub
Redundant, removed.
## Who Can Join
- **Solo players** - Looking for a team
- **Partial groups (2-3 players)** - Looking for more members
- **Already-registered tournament teams** - Recruiting up to `maxMembersPerTeam`
## Features
### Joining the Queue
- Players reuse weapon/VC data from `/q/settings` (`User.qWeaponPool`, `User.vc`, `User.languages`)
- Checkbox on join: "Add me as sub if I don't find a team"
- Support for notes (like SendouQ public member notes)
- Uses schema based SendouForm (forms.md for details)
### Visibility System (Scrims-style)
- **Base visibility**: team/org/public
- **Not-found visibility**: Time-delayed expansion if no match found
- Uses existing `AssociationVisibility` system from scrims
### Likes & Matching
1. Players/groups can like each other
2. Target group receives `TO_LFG_LIKED` notification
3. On mutual like, accepting party sees accept button
4. Accept click triggers:
- If neither party is registered team → create a team (some default name is used)
- If one party is registered team → Other party joins that team
- If both parties are teams, the accepting team absorbs the liker team (accepting team's name is used for the new merged team)
5. After merge, combined group stays in queue to recruit more members
### Team Formation
- **Immediate registration**: When first two players merge, default name is used
- Newly formed team stays in LFG queue
- Teams can grow up to `maxMembersPerTeam` (typically 6 for 4v4)
- When `maxMembersPerTeam` is reached, team is automatically removed from the queue
- Solo players liking registered teams get absorbed as new members
### Tournament Start Auto-Cleanup
When tournament starts:
1. All unregistered LFG groups are deleted
2. Players who checked "stay as sub" are shown in a simple list "`TournamentLFGGroupMember` reused here even if they technically are no longer members of anything)
3. Their sub data uses existing `/q/settings` weapon/VC preferences
## Notifications
| Type | When | Meta |
|------|------|------|
| `TO_LFG_LIKED` | Someone likes your group | `{ tournamentId, tournamentName, likerUsername }` |
| `TO_LFG_TEAM_FORMED` | You join/form a team via LFG | `{ tournamentId, tournamentName, teamName, tournamentTeamId }` |
| `TO_LFG_CHAT_MESSAGE` | Chat message sent | `{ tournamentId, tournamentName, teamName, tournamentTeamId }` |
## UI
### Reuse from `/q/looking`
- `GroupCard` component (weapons, VC, tier display)
- Tab structure (My Group, Groups, Invitations)
- `MemberAdder` component (invite link, quick add), note for these same invite link and quick add endpoint is used as on /to/:id/register page
- `GroupLeaver` component
- Private user notes system
### Tab Structure
1. **My Group** - Current group members, invite link, leave button, chat (if 2+ members)
2. **Groups** - Other groups looking, with like/unlike buttons
3. **Invitations** - Groups that have liked your group, with accept/decline
### Accept Flow
Simple button click (SendouQ-style)
## Files to Create/Modify
### New Files
```
app/features/tournament-lfg/
├── core/
│ └── TournamentLFG.server.ts # Main class (like SendouQ.server.ts)
├── routes/
│ ├── to.$id.looking.tsx # Main LFG page
│ └── to.$id.looking.new.tsx # Join LFG form (if needed)
├── loaders/
│ └── to.$id.looking.server.ts # Data loader
├── actions/
│ └── to.$id.looking.server.ts # Action handler
├── components/
│ └── (reuse from sendouq where possible)
├── TournamentLFGRepository.server.ts # Database queries
├── tournament-lfg-types.ts # TypeScript types
├── tournament-lfg-schemas.server.ts # Zod validation
└── tournament-lfg-constants.ts # Constants
```
### Migrations
```
migrations/XXX-tournament-lfg.js # Create new tables
```
### Modified Files
- `app/routes.ts` - Add new route
- `app/features/notifications/notifications-types.ts` - Add new notification types
- `app/features/tournament-bracket/core/Tournament.ts` - Add LFG-related getters
- `app/features/tournament/components/TournamentTabs.tsx` - Add "Looking" tab
## Differences to SendouQ
- no invite code (tournament teams have their own invite code by default)
- no expiredAt
## Open Questions
- How we should define the autogenerated tournament team name?

View File

@@ -37,8 +37,8 @@ import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.s
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE } from "~/features/sendouq-settings/q-settings-constants";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server";
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import * as VodRepository from "~/features/vods/VodRepository.server";
@@ -70,6 +70,7 @@ import {
} from "~/utils/dates";
import { shortNanoid } from "~/utils/id";
import invariant from "~/utils/invariant";
import { randomTeamName } from "~/utils/team-name";
import { mySlugify } from "~/utils/urls";
import {
getArtFilename,
@@ -220,6 +221,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [
calendarEventWithToToolsLUTI,
calendarEventWithToToolsTeamsLUTI,
tournamentSubs,
variation === "NO_TOURNAMENT_TEAMS" ? undefined : tournamentLfgGroups,
adminBuilds,
manySplattershotBuilds,
detailedTeam(variation),
@@ -279,6 +281,7 @@ function wipeDB() {
"MapPoolMap",
"TournamentMatchGameResult",
"TournamentTeamCheckIn",
"TournamentLFGLike",
"TournamentTeam",
"TournamentStage",
"TournamentResult",
@@ -896,9 +899,7 @@ function calendarEvents() {
)
.run({
id,
name: `${R.capitalize(faker.word.adjective())} ${R.capitalize(
faker.word.noun(),
)}`,
name: randomTeamName(),
description: faker.lorem.paragraph(),
discordInviteCode: faker.lorem.word(),
bracketUrl: faker.internet.url(),
@@ -1014,7 +1015,7 @@ async function calendarEventResults() {
.fill(null)
.map((_, i) => ({
placement: i + 1,
teamName: R.capitalize(faker.word.noun()),
teamName: randomTeamName(),
players: new Array(
faker.helpers.arrayElement([1, 2, 3, 4, 4, 4, 4, 4, 5, 6]),
)
@@ -1376,12 +1377,7 @@ function calendarEventWithToToolsToSetMapPool() {
}
}
const validTournamentTeamName = () => {
while (true) {
const name = faker.music.songName();
if (name.length <= TOURNAMENT.TEAM_NAME_MAX_LENGTH) return name;
}
};
const validTournamentTeamName = () => randomTeamName();
const availableStages: StageId[] = [1, 2, 3, 4, 6, 7, 8, 10, 11];
const availablePairs = rankedModesShort
@@ -1490,12 +1486,14 @@ function calendarEventWithToToolsTeams(
"tournamentTeamId",
"userId",
"isOwner",
"createdAt"
"createdAt",
"role"
) values (
$tournamentTeamId,
$userId,
$isOwner,
$createdAt
$createdAt,
$role
)
`,
)
@@ -1504,6 +1502,7 @@ function calendarEventWithToToolsTeams(
userId,
isOwner: i === 0 ? 1 : 0,
createdAt: dateToDatabaseTimestamp(yesterday),
role: i === 0 ? "OWNER" : "REGULAR",
});
}
@@ -1635,6 +1634,107 @@ function tournamentSubs() {
return null;
}
async function tournamentLfgGroups() {
const availableUsers = userIdsInAscendingOrderById().slice(300);
const MAX_GROUP_SIZE = 6;
const tournaments = [1, 2, 3];
let userIndex = 0;
for (const tournamentId of tournaments) {
const users = availableUsers.slice(userIndex, userIndex + 8);
userIndex += 8;
// Group 1: solo placeholder, has note, isStayAsSub=1
const { id: team1Id } = await TournamentLFGRepository.createPlaceholderTeam(
{
tournamentId,
userId: users[0],
isStayAsSub: true,
},
);
await TournamentLFGRepository.updateTeamNote({
teamId: team1Id,
value: "Looking for a team, can play any role",
});
// Group 2: solo placeholder
const { id: team2Id } = await TournamentLFGRepository.createPlaceholderTeam(
{
tournamentId,
userId: users[1],
},
);
// Group 3: solo placeholder
const { id: team3Id } = await TournamentLFGRepository.createPlaceholderTeam(
{
tournamentId,
userId: users[2],
},
);
// Group 4: solo placeholder
const { id: team4Id } = await TournamentLFGRepository.createPlaceholderTeam(
{
tournamentId,
userId: users[3],
},
);
// Group 5: 2-member group (merged from two placeholders)
const { id: mergeTarget1 } =
await TournamentLFGRepository.createPlaceholderTeam({
tournamentId,
userId: users[4],
});
const { id: mergeSource1 } =
await TournamentLFGRepository.createPlaceholderTeam({
tournamentId,
userId: users[5],
});
await TournamentLFGRepository.mergeTeams({
survivingTeamId: mergeTarget1,
otherTeamId: mergeSource1,
maxGroupSize: MAX_GROUP_SIZE,
});
// Group 6: 2-member group (merged from two placeholders)
const { id: mergeTarget2 } =
await TournamentLFGRepository.createPlaceholderTeam({
tournamentId,
userId: users[6],
});
const { id: mergeSource2 } =
await TournamentLFGRepository.createPlaceholderTeam({
tournamentId,
userId: users[7],
});
await TournamentLFGRepository.mergeTeams({
survivingTeamId: mergeTarget2,
otherTeamId: mergeSource2,
maxGroupSize: MAX_GROUP_SIZE,
});
// Team 1 -> Team 2 (one-way like)
await TournamentLFGRepository.addLike({
likerTeamId: team1Id,
targetTeamId: team2Id,
});
// Team 2 -> Team 1 (mutual — tests invitation UI)
await TournamentLFGRepository.addLike({
likerTeamId: team2Id,
targetTeamId: team1Id,
});
// Team 3 -> Team 4 (one-way like)
await TournamentLFGRepository.addLike({
likerTeamId: team3Id,
targetTeamId: team4Id,
});
}
}
const randomAbility = (legalTypes: AbilityType[]) => {
const randomOrderAbilities = faker.helpers.shuffle([...abilities]);
@@ -1826,12 +1926,7 @@ function otherTeams() {
);
for (let i = 3; i < 50; i++) {
const teamName =
i === 3
? "Team Olive"
: `${R.capitalize(faker.word.adjective())} ${R.capitalize(
faker.word.noun(),
)}`;
const teamName = i === 3 ? "Team Olive" : randomTeamName();
const teamCustomUrl = mySlugify(teamName);
sql

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";
@@ -740,6 +741,12 @@ export interface TournamentSub {
visibility: "+1" | "+2" | "+3" | "ALL";
}
export interface TournamentLFGLike {
likerTeamId: number;
targetTeamId: number;
createdAt: Generated<number>;
}
export interface TournamentStaff {
tournamentId: number;
userId: number;
@@ -760,6 +767,10 @@ export interface TournamentTeam {
tournamentId: number;
teamId: number | null;
avatarImgId: number | null;
isLooking: Generated<DBBoolean>;
isPlaceholder: Generated<DBBoolean>;
lfgVisibility: JSONColumnTypeNullable<AssociationIdentifier>;
lfgNote: string | null;
}
export interface TournamentTeamCheckIn {
@@ -777,6 +788,10 @@ export interface TournamentTeamMember {
inGameName: string | null;
tournamentTeamId: number;
userId: number;
role: Generated<"OWNER" | "MANAGER" | "REGULAR">;
isStayAsSub: Generated<DBBoolean>;
// denormalized from TournamentTeam.isLooking
isLooking: Generated<DBBoolean>;
}
export interface TournamentOrganization {
@@ -1282,6 +1297,7 @@ export interface DB {
Tournament: Tournament;
TournamentStaff: TournamentStaff;
TournamentGroup: TournamentGroup;
TournamentLFGLike: TournamentLFGLike;
TournamentMatch: TournamentMatch;
TournamentMatchPickBanEvent: TournamentMatchPickBanEvent;
TournamentMatchGameResult: TournamentMatchGameResult;

View File

@@ -0,0 +1,620 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { db } from "~/db/sql";
import { dbInsertUsers, dbReset } from "~/utils/Test";
import * as TournamentLFGRepository from "./TournamentLFGRepository.server";
const createTournament = () =>
db
.insertInto("Tournament")
.values({
mapPickingStyle: "TO",
settings: JSON.stringify({ bracketProgression: [] }),
})
.returning("id")
.executeTakeFirstOrThrow();
const createPlaceholder = (tournamentId: number, userId: number) =>
TournamentLFGRepository.createPlaceholderTeam({ tournamentId, userId });
describe("createPlaceholderTeam", () => {
beforeEach(async () => {
await dbInsertUsers(2);
});
afterEach(() => {
dbReset();
});
test("creates a placeholder team with owner member", async () => {
const tournament = await createTournament();
const team = await createPlaceholder(tournament.id, 1);
const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
expect(groups).toHaveLength(1);
expect(groups[0].id).toBe(team.id);
});
test("owner has OWNER role", async () => {
const tournament = await createTournament();
await createPlaceholder(tournament.id, 1);
const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
const members = groups[0].members;
expect(members[0].role).toBe("OWNER");
});
});
describe("findLookingTeamsByTournamentId", () => {
beforeEach(async () => {
await dbInsertUsers(3);
});
afterEach(() => {
dbReset();
});
test("returns looking teams for given tournament", async () => {
const tournament = await createTournament();
await createPlaceholder(tournament.id, 1);
await createPlaceholder(tournament.id, 2);
const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
expect(groups).toHaveLength(2);
});
test("returns empty array when no looking teams exist", async () => {
const tournament = await createTournament();
const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
expect(groups).toHaveLength(0);
});
test("returns groups with member data", async () => {
const tournament = await createTournament();
await createPlaceholder(tournament.id, 1);
const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
const members = groups[0].members;
expect(members[0].id).toBe(1);
expect(members[0].username).toBeDefined();
expect(members[0].role).toBe("OWNER");
});
test("does not return teams from other tournaments", async () => {
const tournament1 = await createTournament();
const tournament2 = await createTournament();
await createPlaceholder(tournament1.id, 1);
await createPlaceholder(tournament2.id, 2);
const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament1.id,
);
expect(groups).toHaveLength(1);
});
});
describe("addLike / deleteLike", () => {
beforeEach(async () => {
await dbInsertUsers(2);
});
afterEach(() => {
dbReset();
});
test("adds a like between two teams", async () => {
const tournament = await createTournament();
const team1 = await createPlaceholder(tournament.id, 1);
const team2 = await createPlaceholder(tournament.id, 2);
await TournamentLFGRepository.addLike({
likerTeamId: team1.id,
targetTeamId: team2.id,
});
const likes = await TournamentLFGRepository.allLikesByTeamId(team1.id);
expect(likes.given).toHaveLength(1);
expect(likes.given[0].teamId).toBe(team2.id);
});
test("duplicate like does not throw", async () => {
const tournament = await createTournament();
const team1 = await createPlaceholder(tournament.id, 1);
const team2 = await createPlaceholder(tournament.id, 2);
await TournamentLFGRepository.addLike({
likerTeamId: team1.id,
targetTeamId: team2.id,
});
await TournamentLFGRepository.addLike({
likerTeamId: team1.id,
targetTeamId: team2.id,
});
const likes = await TournamentLFGRepository.allLikesByTeamId(team1.id);
expect(likes.given).toHaveLength(1);
});
test("deleteLike removes the like", async () => {
const tournament = await createTournament();
const team1 = await createPlaceholder(tournament.id, 1);
const team2 = await createPlaceholder(tournament.id, 2);
await TournamentLFGRepository.addLike({
likerTeamId: team1.id,
targetTeamId: team2.id,
});
await TournamentLFGRepository.deleteLike({
likerTeamId: team1.id,
targetTeamId: team2.id,
});
const likes = await TournamentLFGRepository.allLikesByTeamId(team1.id);
expect(likes.given).toHaveLength(0);
});
});
describe("allLikesByTeamId", () => {
beforeEach(async () => {
await dbInsertUsers(3);
});
afterEach(() => {
dbReset();
});
test("separates likes into given and received", async () => {
const tournament = await createTournament();
const team1 = await createPlaceholder(tournament.id, 1);
const team2 = await createPlaceholder(tournament.id, 2);
const team3 = await createPlaceholder(tournament.id, 3);
await TournamentLFGRepository.addLike({
likerTeamId: team1.id,
targetTeamId: team2.id,
});
await TournamentLFGRepository.addLike({
likerTeamId: team3.id,
targetTeamId: team1.id,
});
const likes = await TournamentLFGRepository.allLikesByTeamId(team1.id);
expect(likes.given).toHaveLength(1);
expect(likes.given[0].teamId).toBe(team2.id);
expect(likes.received).toHaveLength(1);
expect(likes.received[0].teamId).toBe(team3.id);
});
test("returns empty given/received when no likes", async () => {
const tournament = await createTournament();
const team = await createPlaceholder(tournament.id, 1);
const likes = await TournamentLFGRepository.allLikesByTeamId(team.id);
expect(likes.given).toHaveLength(0);
expect(likes.received).toHaveLength(0);
});
});
describe("mergeTeams", () => {
beforeEach(async () => {
await dbInsertUsers(5);
});
afterEach(() => {
dbReset();
});
test("merges two teams, other team is deleted", async () => {
const tournament = await createTournament();
const team1 = await createPlaceholder(tournament.id, 1);
const team2 = await createPlaceholder(tournament.id, 2);
await TournamentLFGRepository.mergeTeams({
survivingTeamId: team1.id,
otherTeamId: team2.id,
maxGroupSize: 4,
});
const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
expect(groups).toHaveLength(1);
expect(groups[0].id).toBe(team1.id);
const members = groups[0].members;
expect(members).toHaveLength(2);
});
test("demotes other team's OWNER to MANAGER", async () => {
const tournament = await createTournament();
const team1 = await createPlaceholder(tournament.id, 1);
const team2 = await createPlaceholder(tournament.id, 2);
await TournamentLFGRepository.mergeTeams({
survivingTeamId: team1.id,
otherTeamId: team2.id,
maxGroupSize: 4,
});
const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
const members = groups[0].members;
const user2Member = members.find((m) => m.id === 2);
expect(user2Member?.role).toBe("MANAGER");
});
test("throws when merged size exceeds maxGroupSize", async () => {
const tournament = await createTournament();
const team1 = await createPlaceholder(tournament.id, 1);
const team2 = await createPlaceholder(tournament.id, 2);
await expect(
TournamentLFGRepository.mergeTeams({
survivingTeamId: team1.id,
otherTeamId: team2.id,
maxGroupSize: 1,
}),
).rejects.toThrow("Group has too many members after merge");
});
test("stops looking when merged team reaches max capacity", async () => {
const tournament = await createTournament();
const team1 = await createPlaceholder(tournament.id, 1);
const team2 = await createPlaceholder(tournament.id, 2);
await TournamentLFGRepository.mergeTeams({
survivingTeamId: team1.id,
otherTeamId: team2.id,
maxGroupSize: 2,
});
const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
expect(groups).toHaveLength(0);
});
test("clears likes on surviving team after merge", async () => {
const tournament = await createTournament();
const team1 = await createPlaceholder(tournament.id, 1);
const team2 = await createPlaceholder(tournament.id, 2);
const team3 = await createPlaceholder(tournament.id, 3);
await TournamentLFGRepository.addLike({
likerTeamId: team1.id,
targetTeamId: team3.id,
});
await TournamentLFGRepository.addLike({
likerTeamId: team3.id,
targetTeamId: team1.id,
});
await TournamentLFGRepository.mergeTeams({
survivingTeamId: team1.id,
otherTeamId: team2.id,
maxGroupSize: 4,
});
const likes = await TournamentLFGRepository.allLikesByTeamId(team1.id);
expect(likes.given).toHaveLength(0);
expect(likes.received).toHaveLength(0);
});
});
describe("updateTeamNote", () => {
beforeEach(async () => {
await dbInsertUsers(1);
});
afterEach(() => {
dbReset();
});
test("sets and clears a team note", async () => {
const tournament = await createTournament();
const team = await createPlaceholder(tournament.id, 1);
await TournamentLFGRepository.updateTeamNote({
teamId: team.id,
value: "Looking for support",
});
let groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
expect(groups[0].note).toBe("Looking for support");
await TournamentLFGRepository.updateTeamNote({
teamId: team.id,
value: null,
});
groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
expect(groups[0].note).toBeNull();
});
});
describe("updateMemberRole", () => {
beforeEach(async () => {
await dbInsertUsers(2);
});
afterEach(() => {
dbReset();
});
test("changes role from REGULAR to MANAGER", async () => {
const tournament = await createTournament();
const team = await createPlaceholder(tournament.id, 1);
await db
.insertInto("TournamentTeamMember")
.values({
tournamentTeamId: team.id,
userId: 2,
isOwner: 0,
role: "REGULAR",
})
.execute();
await TournamentLFGRepository.updateMemberRole({
userId: 2,
teamId: team.id,
role: "MANAGER",
});
const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
const members = groups[0].members;
const user2Member = members.find((m) => m.id === 2);
expect(user2Member?.role).toBe("MANAGER");
});
test("throws when attempting to set OWNER role", () => {
expect(() =>
TournamentLFGRepository.updateMemberRole({
userId: 1,
teamId: 1,
role: "OWNER",
}),
).toThrow("Can't set role to OWNER with this function");
});
});
describe("updateStayAsSub", () => {
beforeEach(async () => {
await dbInsertUsers(1);
});
afterEach(() => {
dbReset();
});
test("toggles isStayAsSub on/off", async () => {
const tournament = await createTournament();
const team = await createPlaceholder(tournament.id, 1);
await TournamentLFGRepository.updateStayAsSub({
teamId: team.id,
userId: 1,
value: true,
});
let subs = await TournamentLFGRepository.getSubsForTournament(
tournament.id,
);
expect(subs).toContain(1);
await TournamentLFGRepository.updateStayAsSub({
teamId: team.id,
userId: 1,
value: false,
});
subs = await TournamentLFGRepository.getSubsForTournament(tournament.id);
expect(subs).not.toContain(1);
});
});
describe("leaveLfg", () => {
beforeEach(async () => {
await dbInsertUsers(3);
});
afterEach(() => {
dbReset();
});
test("removes member from placeholder team", async () => {
const tournament = await createTournament();
const team = await createPlaceholder(tournament.id, 1);
await db
.insertInto("TournamentTeamMember")
.values({
tournamentTeamId: team.id,
userId: 2,
isOwner: 0,
role: "REGULAR",
})
.execute();
await TournamentLFGRepository.leaveLfg({
userId: 2,
tournamentId: tournament.id,
});
const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
const members = groups[0].members;
expect(members).toHaveLength(1);
expect(members[0].id).toBe(1);
});
test("deletes placeholder team when last member leaves", async () => {
const tournament = await createTournament();
await createPlaceholder(tournament.id, 1);
await TournamentLFGRepository.leaveLfg({
userId: 1,
tournamentId: tournament.id,
});
const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
expect(groups).toHaveLength(0);
});
test("promotes MANAGER to OWNER when owner leaves placeholder", async () => {
const tournament = await createTournament();
const team = await createPlaceholder(tournament.id, 1);
await db
.insertInto("TournamentTeamMember")
.values({
tournamentTeamId: team.id,
userId: 2,
isOwner: 0,
role: "MANAGER",
})
.execute();
await db
.insertInto("TournamentTeamMember")
.values({
tournamentTeamId: team.id,
userId: 3,
isOwner: 0,
role: "REGULAR",
})
.execute();
await TournamentLFGRepository.leaveLfg({
userId: 1,
tournamentId: tournament.id,
});
const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
const members = groups[0].members;
const newOwner = members.find((m) => m.id === 2);
expect(newOwner?.role).toBe("OWNER");
});
test("sets isLooking=0 for non-placeholder team", async () => {
const tournament = await createTournament();
const team = await db
.insertInto("TournamentTeam")
.values({
tournamentId: tournament.id,
name: "Real Team",
inviteCode: "abc",
isLooking: 1,
isPlaceholder: 0,
})
.returning("id")
.executeTakeFirstOrThrow();
await db
.insertInto("TournamentTeamMember")
.values({
tournamentTeamId: team.id,
userId: 1,
isOwner: 1,
role: "OWNER",
})
.execute();
await TournamentLFGRepository.leaveLfg({
userId: 1,
tournamentId: tournament.id,
});
const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
expect(groups).toHaveLength(0);
const teamRow = await db
.selectFrom("TournamentTeam")
.select("isLooking")
.where("id", "=", team.id)
.executeTakeFirstOrThrow();
expect(teamRow.isLooking).toBe(0);
});
});
describe("getSubsForTournament", () => {
beforeEach(async () => {
await dbInsertUsers(2);
});
afterEach(() => {
dbReset();
});
test("returns userIds with isStayAsSub", async () => {
const tournament = await createTournament();
await TournamentLFGRepository.createPlaceholderTeam({
tournamentId: tournament.id,
userId: 1,
isStayAsSub: true,
});
await createPlaceholder(tournament.id, 2);
const subs = await TournamentLFGRepository.getSubsForTournament(
tournament.id,
);
expect(subs).toEqual([1]);
});
test("returns empty when nobody opted in", async () => {
const tournament = await createTournament();
await createPlaceholder(tournament.id, 1);
const subs = await TournamentLFGRepository.getSubsForTournament(
tournament.id,
);
expect(subs).toHaveLength(0);
});
});

View File

@@ -0,0 +1,458 @@
import type { Transaction } from "kysely";
import { jsonBuildObject } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { DB, Tables } from "~/db/tables";
import { shortNanoid } from "~/utils/id";
import invariant from "~/utils/invariant";
import {
concatUserSubmittedImagePrefix,
userChatNameColorForJson,
} from "~/utils/kysely.server";
import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql";
import { randomTeamName } from "~/utils/team-name";
// xxx: handle visibility like scrim post
export function startLooking(teamId: number) {
return db
.updateTable("TournamentTeam")
.set({ isLooking: 1 })
.where("id", "=", teamId)
.execute();
}
type CreatePlaceholderTeamArgs = {
tournamentId: number;
userId: number;
isStayAsSub?: boolean;
lfgNote?: string;
};
export function createPlaceholderTeam(args: CreatePlaceholderTeamArgs) {
return db.transaction().execute(async (trx) => {
const createdTeam = await trx
.insertInto("TournamentTeam")
.values({
tournamentId: args.tournamentId,
name: randomTeamName(),
inviteCode: shortNanoid(),
isPlaceholder: 1,
isLooking: 1,
lfgNote: args.lfgNote ?? null,
})
.returning("id")
.executeTakeFirstOrThrow();
await trx
.insertInto("TournamentTeamMember")
.values({
tournamentTeamId: createdTeam.id,
userId: args.userId,
isOwner: 1,
role: "OWNER",
isStayAsSub: args.isStayAsSub ? 1 : 0,
})
.execute();
return createdTeam;
});
}
type TournamentLFGMemberObject = {
id: Tables["User"]["id"];
username: Tables["User"]["username"];
discordId: Tables["User"]["discordId"];
discordAvatar: Tables["User"]["discordAvatar"];
customUrl: Tables["User"]["customUrl"];
languages: Tables["User"]["languages"];
vc: Tables["User"]["vc"];
pronouns: Tables["User"]["pronouns"];
country: Tables["User"]["country"];
role: Tables["TournamentTeamMember"]["role"];
isStayAsSub: Tables["TournamentTeamMember"]["isStayAsSub"];
weapons: Tables["User"]["qWeaponPool"];
chatNameColor: string | null;
plusTier: Tables["PlusTier"]["tier"] | null;
};
export async function findLookingTeamsByTournamentId(tournamentId: number) {
return db
.selectFrom("TournamentTeam")
.innerJoin(
"TournamentTeamMember",
"TournamentTeamMember.tournamentTeamId",
"TournamentTeam.id",
)
.innerJoin("User", "User.id", "TournamentTeamMember.userId")
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
.leftJoin(
"UserSubmittedImage",
"UserSubmittedImage.id",
"TournamentTeam.avatarImgId",
)
.select(({ fn, eb }) => [
"TournamentTeam.id",
"TournamentTeam.isPlaceholder",
"TournamentTeam.lfgVisibility as visibility",
"TournamentTeam.lfgNote as note",
"TournamentTeam.name as teamName",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"teamAvatarUrl",
),
fn
.agg("json_group_array", [
jsonBuildObject({
id: eb.ref("User.id"),
username: eb.ref("User.username"),
discordId: eb.ref("User.discordId"),
discordAvatar: eb.ref("User.discordAvatar"),
customUrl: eb.ref("User.customUrl"),
languages: eb.ref("User.languages"),
vc: eb.ref("User.vc"),
pronouns: eb.ref("User.pronouns"),
// xxx: is it better to show country rather than languages?
country: eb.ref("User.country"),
role: eb.ref("TournamentTeamMember.role"),
isStayAsSub: eb.ref("TournamentTeamMember.isStayAsSub"),
weapons: eb.ref("User.qWeaponPool"),
chatNameColor: userChatNameColorForJson,
plusTier: eb.ref("PlusTier.tier"),
}),
])
.$castTo<TournamentLFGMemberObject[]>()
.as("members"),
])
.where("TournamentTeam.tournamentId", "=", tournamentId)
.where("TournamentTeam.isLooking", "=", 1)
.groupBy("TournamentTeam.id")
.execute();
}
export async function findSubGroups(tournamentId: number) {
const rows = await db
.selectFrom("TournamentTeam")
.innerJoin(
"TournamentTeamMember",
"TournamentTeamMember.tournamentTeamId",
"TournamentTeam.id",
)
.innerJoin("User", "User.id", "TournamentTeamMember.userId")
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
.select(({ fn, eb }) => [
"TournamentTeam.id",
"TournamentTeam.lfgNote as message",
fn
.agg("json_group_array", [
jsonBuildObject({
id: eb.ref("User.id"),
username: eb.ref("User.username"),
discordId: eb.ref("User.discordId"),
discordAvatar: eb.ref("User.discordAvatar"),
customUrl: eb.ref("User.customUrl"),
languages: eb.ref("User.languages"),
vc: eb.ref("User.vc"),
pronouns: eb.ref("User.pronouns"),
country: eb.ref("User.country"),
role: eb.ref("TournamentTeamMember.role"),
isStayAsSub: eb.ref("TournamentTeamMember.isStayAsSub"),
weapons: eb.ref("User.qWeaponPool"),
chatNameColor: userChatNameColorForJson,
plusTier: eb.ref("PlusTier.tier"),
}),
])
.$castTo<TournamentLFGMemberObject[]>()
.as("members"),
])
// xxx: is this correct?
.where("TournamentTeam.tournamentId", "=", tournamentId)
.where("TournamentTeam.isPlaceholder", "=", 1)
.where("TournamentTeamMember.isStayAsSub", "=", 1)
.groupBy("TournamentTeam.id")
.having(({ fn }) => fn.countAll<number>(), "=", 1)
.execute();
return rows;
}
// xxx: ensure that the team size does not exceed the max, also filter out in frontend
export function mergeTeams({
survivingTeamId,
otherTeamId,
maxGroupSize,
}: {
survivingTeamId: number;
otherTeamId: number;
maxGroupSize: number;
}) {
return db.transaction().execute(async (trx) => {
const otherMembers = await trx
.selectFrom("TournamentTeamMember")
.select(["TournamentTeamMember.userId", "TournamentTeamMember.role"])
.where("TournamentTeamMember.tournamentTeamId", "=", otherTeamId)
.execute();
for (const member of otherMembers) {
await trx
.updateTable("TournamentTeamMember")
.set({
role: member.role === "OWNER" ? "MANAGER" : member.role,
tournamentTeamId: survivingTeamId,
})
.where("TournamentTeamMember.tournamentTeamId", "=", otherTeamId)
.where("TournamentTeamMember.userId", "=", member.userId)
.execute();
}
await deleteLikesByTeamId(survivingTeamId, trx);
await trx
.deleteFrom("TournamentTeam")
.where("TournamentTeam.id", "=", otherTeamId)
.execute();
const memberCount = await getMemberCount(survivingTeamId, trx);
invariant(
memberCount <= maxGroupSize,
"Group has too many members after merge",
);
await trx
.updateTable("TournamentTeam")
.set({
isLooking: memberCount >= maxGroupSize ? 0 : undefined,
isPlaceholder: 0,
})
.where("id", "=", survivingTeamId)
.execute();
});
}
export async function addLike({
likerTeamId,
targetTeamId,
}: {
likerTeamId: number;
targetTeamId: number;
}) {
try {
await db
.insertInto("TournamentLFGLike")
.values({ likerTeamId, targetTeamId })
.onConflict((oc) =>
oc.columns(["likerTeamId", "targetTeamId"]).doNothing(),
)
.execute();
} catch (error) {
if (errorIsSqliteForeignKeyConstraintFailure(error)) {
return;
}
throw error;
}
}
export function deleteLike({
likerTeamId,
targetTeamId,
}: {
likerTeamId: number;
targetTeamId: number;
}) {
return db
.deleteFrom("TournamentLFGLike")
.where("likerTeamId", "=", likerTeamId)
.where("targetTeamId", "=", targetTeamId)
.execute();
}
export async function allLikesByTeamId(teamId: number) {
const rows = await db
.selectFrom("TournamentLFGLike")
.select(["TournamentLFGLike.likerTeamId", "TournamentLFGLike.targetTeamId"])
.where((eb) =>
eb.or([
eb("TournamentLFGLike.likerTeamId", "=", teamId),
eb("TournamentLFGLike.targetTeamId", "=", teamId),
]),
)
.execute();
return {
given: rows
.filter((row) => row.likerTeamId === teamId)
.map((row) => ({ teamId: row.targetTeamId })),
received: rows
.filter((row) => row.targetTeamId === teamId)
.map((row) => ({ teamId: row.likerTeamId })),
};
}
export function updateTeamNote({
teamId,
value,
}: {
teamId: number;
value: string | null;
}) {
return db
.updateTable("TournamentTeam")
.set({ lfgNote: value })
.where("id", "=", teamId)
.execute();
}
export function updateMemberRole({
userId,
teamId,
role,
}: {
userId: number;
teamId: number;
role: Tables["TournamentTeamMember"]["role"];
}) {
if (role === "OWNER") {
throw new Error("Can't set role to OWNER with this function");
}
return db
.updateTable("TournamentTeamMember")
.set({ role })
.where("userId", "=", userId)
.where("tournamentTeamId", "=", teamId)
.execute();
}
export function updateStayAsSub({
teamId,
userId,
value,
}: {
teamId: number;
userId: number;
value: boolean;
}) {
return db
.updateTable("TournamentTeamMember")
.set({ isStayAsSub: value ? 1 : 0 })
.where("tournamentTeamId", "=", teamId)
.where("userId", "=", userId)
.execute();
}
export function leaveLfg({
userId,
tournamentId,
}: {
userId: number;
tournamentId: number;
}) {
return db.transaction().execute(async (trx) => {
const userTeam = await trx
.selectFrom("TournamentTeamMember")
.innerJoin(
"TournamentTeam",
"TournamentTeam.id",
"TournamentTeamMember.tournamentTeamId",
)
.select([
"TournamentTeamMember.tournamentTeamId",
"TournamentTeamMember.role",
"TournamentTeam.isPlaceholder",
])
.where("TournamentTeamMember.userId", "=", userId)
.where("TournamentTeam.tournamentId", "=", tournamentId)
.where("TournamentTeam.isLooking", "=", 1)
.executeTakeFirst();
if (!userTeam) return;
if (!userTeam.isPlaceholder) {
await trx
.updateTable("TournamentTeam")
.set({ isLooking: 0 })
.where("id", "=", userTeam.tournamentTeamId)
.execute();
await trx
.updateTable("TournamentTeamMember")
.set({ isStayAsSub: 0 })
.where("tournamentTeamId", "=", userTeam.tournamentTeamId)
.execute();
return;
}
// xxx: or just call TournamentTeam.unregister?
// xxx: also leaving team should not be possible via this so remove that code
await trx
.deleteFrom("TournamentTeamMember")
.where("userId", "=", userId)
.where("tournamentTeamId", "=", userTeam.tournamentTeamId)
.execute();
const remainingMembers = await trx
.selectFrom("TournamentTeamMember")
.select(["userId", "role"])
.where("tournamentTeamId", "=", userTeam.tournamentTeamId)
.execute();
if (remainingMembers.length === 0) {
await trx
.deleteFrom("TournamentTeam")
.where("id", "=", userTeam.tournamentTeamId)
.execute();
return;
}
if (userTeam.role === "OWNER") {
const newOwner =
remainingMembers.find((m) => m.role === "MANAGER") ??
remainingMembers[0];
await trx
.updateTable("TournamentTeamMember")
.set({ role: "OWNER" })
.where("userId", "=", newOwner.userId)
.where("tournamentTeamId", "=", userTeam.tournamentTeamId)
.execute();
}
});
}
export async function getSubsForTournament(tournamentId: number) {
const rows = await db
.selectFrom("TournamentTeamMember")
.innerJoin(
"TournamentTeam",
"TournamentTeam.id",
"TournamentTeamMember.tournamentTeamId",
)
.select("TournamentTeamMember.userId")
.where("TournamentTeam.tournamentId", "=", tournamentId)
.where("TournamentTeamMember.isStayAsSub", "=", 1)
.execute();
return rows.map((row) => row.userId);
}
function deleteLikesByTeamId(teamId: number, trx: Transaction<DB>) {
return trx
.deleteFrom("TournamentLFGLike")
.where((eb) =>
eb.or([
eb("TournamentLFGLike.likerTeamId", "=", teamId),
eb("TournamentLFGLike.targetTeamId", "=", teamId),
]),
)
.execute();
}
async function getMemberCount(
teamId: number,
trx: Transaction<DB>,
): Promise<number> {
const members = await trx
.selectFrom("TournamentTeamMember")
.select("TournamentTeamMember.userId")
.where("TournamentTeamMember.tournamentTeamId", "=", teamId)
.execute();
return members.length;
}

View File

@@ -0,0 +1,252 @@
import type { ActionFunctionArgs } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server";
import {
errorToastIfFalsy,
parseParams,
parseRequestPayload,
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { idObject } from "~/utils/zod";
import * as TournamentLFGRepository from "../TournamentLFGRepository.server";
import { lookingSchema } from "../tournament-lfg-schemas";
import { survivingTeamId } from "../tournament-lfg-utils";
// xxx: prevent actions in certain cases? like when tournament has started
// xxx: add `requireNotBannedByOrganization` & other checks from deleted to.$id.subs.new.tsx route
// xxx: add `clearTournamentDataCache`
export const action = async ({ request, params }: ActionFunctionArgs) => {
const user = requireUser();
const { id: tournamentId } = parseParams({ params, schema: idObject });
const data = await parseRequestPayload({
request,
schema: lookingSchema,
});
const findOwnGroup = async () => {
const groups =
await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournamentId,
);
return groups.find((g) => g.members.some((m) => m.id === user.id));
};
const isGroupManager = (group: Awaited<ReturnType<typeof findOwnGroup>>) => {
const member = group?.members.find((m) => m.id === user.id);
return member?.role === "OWNER" || member?.role === "MANAGER";
};
const isGroupOwner = (group: Awaited<ReturnType<typeof findOwnGroup>>) => {
const member = group?.members.find((m) => m.id === user.id);
return member?.role === "OWNER";
};
switch (data._action) {
case "JOIN_QUEUE": {
const existingGroup = await findOwnGroup();
if (existingGroup) return null;
const tournament = await tournamentFromDBCached({
tournamentId,
user,
});
const team = tournament.teamMemberOfByUser(user);
if (team) {
errorToastIfFalsy(
team.members.length < tournament.maxMembersPerTeam,
"Team is already at max capacity",
);
await TournamentLFGRepository.startLooking(team.id);
} else {
await TournamentLFGRepository.createPlaceholderTeam({
tournamentId,
userId: user.id,
isStayAsSub: data.stayAsSub ?? false,
lfgNote: data.note ?? undefined,
});
}
break;
}
case "LIKE": {
const ownGroup = await findOwnGroup();
if (!ownGroup || !isGroupManager(ownGroup)) return null;
await TournamentLFGRepository.addLike({
likerTeamId: ownGroup.id,
targetTeamId: data.targetTeamId,
});
break;
}
case "UNLIKE": {
const ownGroup = await findOwnGroup();
if (!ownGroup || !isGroupManager(ownGroup)) return null;
await TournamentLFGRepository.deleteLike({
likerTeamId: ownGroup.id,
targetTeamId: data.targetTeamId,
});
break;
}
case "ACCEPT": {
const groups =
await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournamentId,
);
const ownGroup = groups.find((g) =>
g.members.some((m) => m.id === user.id),
);
if (!ownGroup || !isGroupManager(ownGroup)) return null;
const theirGroup = groups.find((g) => g.id === data.targetTeamId);
if (!theirGroup) return null;
const theirLikes = await TournamentLFGRepository.allLikesByTeamId(
data.targetTeamId,
);
if (!theirLikes.given.some((like) => like.teamId === ownGroup.id)) {
return null;
}
const surviving = survivingTeamId({
ourGroup: {
id: ownGroup.id,
isPlaceholder: Boolean(ownGroup.isPlaceholder),
teamName: null,
teamAvatarUrl: null,
note: null,
members: [],
usersRole: null,
},
theirGroup: {
id: theirGroup.id,
isPlaceholder: Boolean(theirGroup.isPlaceholder),
teamName: null,
teamAvatarUrl: null,
note: null,
members: [],
usersRole: null,
},
});
const otherGroup = surviving === ownGroup.id ? theirGroup : ownGroup;
const tournament = await tournamentFromDBCached({
tournamentId,
user,
});
await TournamentLFGRepository.mergeTeams({
survivingTeamId: surviving,
otherTeamId: otherGroup.id,
maxGroupSize: tournament.maxMembersPerTeam,
});
break;
}
case "GIVE_MANAGER": {
const ownGroup = await findOwnGroup();
errorToastIfFalsy(ownGroup && isGroupOwner(ownGroup), "Not owner");
await TournamentLFGRepository.updateMemberRole({
teamId: ownGroup!.id,
userId: data.userId,
role: "MANAGER",
});
break;
}
case "REMOVE_MANAGER": {
const ownGroup = await findOwnGroup();
errorToastIfFalsy(ownGroup && isGroupOwner(ownGroup), "Not owner");
await TournamentLFGRepository.updateMemberRole({
teamId: ownGroup!.id,
userId: data.userId,
role: "REGULAR",
});
break;
}
case "UPDATE_GROUP": {
const ownGroup = await findOwnGroup();
if (!ownGroup) return null;
await TournamentLFGRepository.updateTeamNote({
teamId: ownGroup.id,
value: data.note ?? null,
});
await TournamentLFGRepository.updateStayAsSub({
teamId: ownGroup.id,
userId: user.id,
value: data.stayAsSub ?? false,
});
break;
}
case "LEAVE_GROUP": {
await TournamentLFGRepository.leaveLfg({
userId: user.id,
tournamentId,
});
break;
}
case "ADD_SUB": {
const tournament = await tournamentFromDBCached({
tournamentId,
user,
});
errorToastIfFalsy(
tournament.canAddNewSubPost,
"Cannot add sub post at this time",
);
const team = tournament.teamMemberOfByUser(user);
errorToastIfFalsy(!team, "Already on a team");
const existingSubGroups =
await TournamentLFGRepository.findSubGroups(tournamentId);
const hasExistingSubPost = existingSubGroups.some((g) =>
g.members.some((m) => m.id === user.id),
);
errorToastIfFalsy(!hasExistingSubPost, "Already have a sub post");
await TournamentLFGRepository.createPlaceholderTeam({
tournamentId,
userId: user.id,
isStayAsSub: true,
lfgNote: data.message ?? undefined,
});
break;
}
case "DELETE_SUB": {
const tournament = await tournamentFromDBCached({
tournamentId,
user,
});
errorToastIfFalsy(
user.id === data.userId || tournament.isOrganizer(user),
"You can only delete your own sub post",
);
await TournamentLFGRepository.leaveLfg({
userId: data.userId,
tournamentId,
});
break;
}
default: {
assertUnreachable(data);
}
}
return null;
};

View File

@@ -0,0 +1,90 @@
.group {
background-color: var(--bg-lighter-solid);
width: 100%;
border-radius: var(--rounded);
padding: var(--s-2-5);
display: flex;
flex-direction: column;
gap: var(--s-4);
position: relative;
color: var(--text);
}
.member {
display: flex;
gap: var(--s-2);
align-items: center;
background-color: var(--bg-darker);
border-radius: var(--rounded);
font-size: var(--fonts-xsm);
font-weight: var(--semi-bold);
padding-inline-end: var(--s-2-5);
}
.name {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 7.5rem;
font-size: var(--fonts-xs);
color: var(--text);
}
.avatar {
min-width: 36px;
}
.extraInfo {
font-size: var(--fonts-xs);
background-color: var(--bg-darker);
border-radius: var(--rounded);
padding: var(--s-0-5) var(--s-1-5);
width: max-content;
display: flex;
align-items: center;
gap: var(--s-1);
font-weight: var(--semi-bold);
min-height: 24px;
}
.extraInfoButton {
font-size: var(--fonts-xs);
background-color: var(--bg-darker);
border-radius: var(--rounded);
padding: var(--s-0-5) var(--s-1-5);
width: max-content;
display: flex;
align-items: center;
gap: var(--s-1);
font-weight: var(--semi-bold);
color: var(--text);
border: none;
min-height: 24px;
}
.vcIcon {
height: 15px;
stroke-width: 2;
}
.star {
min-width: 18px;
max-width: 18px;
color: var(--theme-secondary);
stroke-width: 2;
}
.starInactive {
color: var(--text-lighter);
}
.teamHeader {
display: flex;
align-items: center;
gap: var(--s-2);
}
.teamName {
font-size: var(--fonts-xxs);
font-weight: var(--semi-bold);
}

View File

@@ -0,0 +1,386 @@
import clsx from "clsx";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link, useFetcher } from "react-router";
import { Avatar } from "~/components/Avatar";
import { Divider } from "~/components/Divider";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { Image, WeaponImage } from "~/components/Image";
import { MicrophoneIcon } from "~/components/icons/Microphone";
import { SpeakerIcon } from "~/components/icons/Speaker";
import { SpeakerXIcon } from "~/components/icons/SpeakerX";
import { StarIcon } from "~/components/icons/Star";
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 { updateGroupFormSchema } from "../tournament-lfg-schemas";
import styles from "./LFGGroupCard.module.css";
export type LFGGroupMember = {
id: number;
username: string;
discordId: string;
discordAvatar: string | null;
customUrl: string | null;
languages: string[];
vc: "YES" | "NO" | "LISTEN_ONLY" | null;
pronouns: Pronouns | null;
role: "OWNER" | "MANAGER" | "REGULAR";
isStayAsSub: boolean;
weapons: Array<{ weaponSplId: MainWeaponId; isFavorite: boolean }> | null;
chatNameColor: string | null;
plusTier: number | null;
};
export type LFGGroup = {
id: number;
isPlaceholder: boolean;
teamName: string | null;
teamAvatarUrl: string | null;
note: string | null;
members: LFGGroupMember[];
usersRole: "OWNER" | "MANAGER" | "REGULAR" | null;
};
export function LFGGroupCard({
group,
action,
ownGroup,
}: {
group: LFGGroup;
action?: "LIKE" | "UNLIKE" | "ACCEPT";
ownGroup?: LFGGroup;
}) {
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 ? (
<Divider smallText className={styles.teamHeader}>
{group.teamAvatarUrl ? (
<Avatar size="xxs" url={group.teamAvatarUrl} />
) : null}
<div className={styles.teamName}>{group.teamName}</div>
</Divider>
) : null}
<div className="stack md">
{group.members.map((member) => (
<LFGGroupMemberRow
key={member.discordId}
member={member}
showActions={showActions}
isOwnGroup={isOwnGroup}
/>
))}
</div>
{isOwnGroup ? (
<LFGTeamNote
note={group.note}
editable={group.usersRole === "OWNER"}
isStayAsSub={currentMember?.isStayAsSub ?? false}
/>
) : null}
{action &&
(ownGroup?.usersRole === "OWNER" || ownGroup?.usersRole === "MANAGER") ? (
<fetcher.Form className="stack items-center" method="post">
<input type="hidden" name="targetTeamId" value={group.id} />
<SubmitButton
size="small"
variant={action === "UNLIKE" ? "destructive" : "outlined"}
_action={action}
state={fetcher.state}
>
{action === "LIKE"
? t("q:looking.groups.actions.invite")
: action === "ACCEPT"
? t("common:actions.accept")
: t("q:looking.groups.actions.undo")}
</SubmitButton>
</fetcher.Form>
) : null}
</section>
);
}
function LFGGroupMemberRow({
member,
showActions,
}: {
member: LFGGroupMember;
showActions: boolean;
isOwnGroup: boolean;
}) {
return (
<div className="stack xxs">
<div className={styles.member}>
<div className="text-main-forced stack xs horizontal items-center">
<Avatar user={member} size="xs" />
<Link to={userPage(member)} className={styles.name}>
{member.username}
</Link>
{member.pronouns ? (
<span className="text-lighter ml-1 text-xxxs">
{member.pronouns.subject}/{member.pronouns.object}
</span>
) : null}
</div>
<div className="ml-auto stack horizontal sm items-center">
{showActions || (!showActions && member.role === "OWNER") ? (
<LFGMemberRoleManager member={member} showActions={showActions} />
) : null}
</div>
</div>
<div className="stack horizontal justify-between">
<div className="stack horizontal items-center xxs">
{member.vc ? (
<div className={styles.extraInfo}>
<LFGVoiceChatInfo member={member} />
</div>
) : null}
{member.plusTier ? (
<div className={styles.extraInfo}>
<Image path={navIconUrl("plus")} width={20} height={20} alt="" />
{member.plusTier}
</div>
) : null}
</div>
{member.weapons && member.weapons.length > 0 ? (
<div className={styles.extraInfo}>
{member.weapons.map((weapon) => (
<WeaponImage
key={weapon.weaponSplId}
weaponSplId={weapon.weaponSplId}
variant={weapon.isFavorite ? "badge-5-star" : "badge"}
size={26}
/>
))}
</div>
) : null}
</div>
</div>
);
}
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, isStayAsSub]);
if (editing) {
return (
<LFGEditGroupForm
note={note}
isStayAsSub={isStayAsSub}
stopEditing={() => setEditing(false)}
/>
);
}
if (note) {
return (
<div className="text-lighter text-center text-xs mt-1">
{note}{" "}
{editable ? (
<SendouButton
size="miniscule"
variant="minimal"
onPress={() => setEditing(true)}
className="mt-2 ml-auto"
>
{t("q:looking.groups.editNote")}
</SendouButton>
) : null}
</div>
);
}
if (!editable) return null;
return (
<SendouButton
variant="minimal"
size="miniscule"
onPress={() => setEditing(true)}
>
{t("q:looking.groups.addNote")}
</SendouButton>
);
}
function LFGEditGroupForm({
note,
isStayAsSub,
stopEditing,
}: {
note: string | null;
isStayAsSub: boolean;
stopEditing: () => void;
}) {
const { t } = useTranslation(["common"]);
return (
<SendouForm
schema={updateGroupFormSchema}
defaultValues={{ note: note ?? undefined, stayAsSub: isStayAsSub }}
submitButtonText={t("common:actions.save")}
secondarySubmit={
<SendouButton
variant="minimal-destructive"
size="miniscule"
onPress={stopEditing}
>
{t("common:actions.cancel")}
</SendouButton>
}
>
{({ FormField }) => (
<>
<FormField name="note" />
<FormField name="stayAsSub" />
</>
)}
</SendouForm>
);
}
function LFGMemberRoleManager({
member,
showActions,
}: {
member: Pick<LFGGroupMember, "id" | "role">;
showActions: boolean;
}) {
const fetcher = useFetcher();
const { t } = useTranslation(["q"]);
const Icon = member.role === "OWNER" ? StarFilledIcon : StarIcon;
if (!showActions && member.role !== "OWNER") return null;
return (
<SendouPopover
trigger={
<SendouButton
variant="minimal"
icon={
<Icon
className={clsx(styles.star, {
[styles.starInactive]: member.role === "REGULAR",
})}
/>
}
/>
}
>
<div className="stack sm items-center">
<div>{t(`q:roles.${member.role}`)}</div>
{member.role !== "OWNER" && showActions ? (
<fetcher.Form method="post" className="stack md items-center">
<input type="hidden" name="userId" value={member.id} />
{member.role === "REGULAR" ? (
<SubmitButton
variant="outlined"
size="small"
_action="GIVE_MANAGER"
state={fetcher.state}
>
{t("q:looking.groups.actions.giveManager")}
</SubmitButton>
) : null}
{member.role === "MANAGER" ? (
<SubmitButton
variant="destructive"
size="small"
_action="REMOVE_MANAGER"
state={fetcher.state}
>
{t("q:looking.groups.actions.removeManager")}
</SubmitButton>
) : null}
</fetcher.Form>
) : null}
</div>
</SendouPopover>
);
}
function LFGVoiceChatInfo({
member,
}: {
member: Pick<LFGGroupMember, "id" | "vc" | "languages">;
}) {
const user = useUser();
const { t } = useTranslation(["q"]);
if (!member.languages || !member.vc) return null;
const Icon =
member.vc === "YES"
? MicrophoneIcon
: member.vc === "LISTEN_ONLY"
? SpeakerIcon
: SpeakerXIcon;
const color = () => {
const languagesMatch =
member.id === user?.id ||
member.languages?.some((l) => user?.languages.includes(l));
if (!languagesMatch) return "text-error";
return member.vc === "YES"
? "text-success"
: member.vc === "LISTEN_ONLY"
? "text-warning"
: "text-error";
};
const languageToFull = (code: string) =>
languagesUnified.find((l) => l.code === code)?.name ?? "";
const languagesString =
member.languages.length > 0
? `(${member.languages.map(languageToFull).join(", ")})`
: null;
return (
<SendouPopover
trigger={
<SendouButton
variant="minimal"
size="small"
icon={<Icon className={clsx(styles.vcIcon, color())} />}
/>
}
>
{t(`q:vc.${member.vc}`)} {languagesString}
</SendouPopover>
);
}

View File

@@ -0,0 +1,228 @@
import type { LoaderFunctionArgs } from "react-router";
import type { Pronouns } from "~/db/tables";
import { getUser } from "~/features/auth/core/user.server";
import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import type { SerializeFrom } from "~/utils/remix";
import { parseParams } from "~/utils/remix.server";
import { idObject } from "~/utils/zod";
import type { LFGGroup, LFGGroupMember } from "../components/LFGGroupCard";
import * as TournamentLFGRepository from "../TournamentLFGRepository.server";
export type SubEntry = Extract<
LookingLoaderData,
{ mode: "subs" }
>["subs"][number];
export type LookingLoaderData = SerializeFrom<typeof loader>;
export const loader = async ({ params }: LoaderFunctionArgs) => {
const user = getUser();
const { id: tournamentId } = parseParams({ params, schema: idObject });
const tournament = await tournamentFromDBCached({
tournamentId,
user,
});
if (tournament.registrationOpen) {
return lookingMode({ tournamentId, user });
}
return subsMode({ tournamentId, user });
};
async function lookingMode({
tournamentId,
user,
}: {
tournamentId: number;
user: ReturnType<typeof getUser>;
}) {
const rawGroups =
await TournamentLFGRepository.findLookingTeamsByTournamentId(tournamentId);
const groups: LFGGroup[] = rawGroups.map((group) => {
const members = transformMembers(group.members);
return {
id: group.id,
isPlaceholder: Boolean(group.isPlaceholder),
teamName: group.isPlaceholder ? null : (group.teamName ?? null),
teamAvatarUrl: group.isPlaceholder ? null : (group.teamAvatarUrl ?? null),
note: group.note ?? null,
members,
usersRole: members.find((m) => m.id === user?.id)?.role ?? null,
};
});
const ownGroup =
groups.find((g) => g.members.some((m) => m.id === user?.id)) ?? null;
const otherGroups = groups.filter((g) => g.id !== ownGroup?.id);
const likes = ownGroup
? await TournamentLFGRepository.allLikesByTeamId(ownGroup.id)
: { given: [], received: [] };
const ownTeam = await resolveOwnTeam({
user,
tournamentId,
ownGroup,
});
return {
mode: "looking" as const,
groups: otherGroups,
ownGroup,
ownTeam,
likes,
tournamentId,
};
}
async function subsMode({
tournamentId,
user,
}: {
tournamentId: number;
user: ReturnType<typeof getUser>;
}) {
const rawSubGroups =
await TournamentLFGRepository.findSubGroups(tournamentId);
const subs = rawSubGroups.map((group) => {
const member = group.members[0];
const weapons = parseWeapons(member.weapons);
const languages =
typeof member.languages === "string"
? member.languages.split(",").filter(Boolean)
: [];
return {
teamId: group.id,
userId: member.id,
username: member.username,
discordId: member.discordId,
discordAvatar: member.discordAvatar,
customUrl: member.customUrl,
vc: member.vc,
languages,
plusTier: member.plusTier,
weapons,
message: group.message ?? null,
};
});
return {
mode: "subs" as const,
subs,
hasOwnSubPost: subs.some((sub) => sub.userId === user?.id),
tournamentId,
};
}
async function resolveOwnTeam({
user,
tournamentId,
ownGroup,
}: {
user: ReturnType<typeof getUser>;
tournamentId: number;
ownGroup: LFGGroup | null;
}): Promise<LFGGroup | null> {
if (!user) return null;
if (ownGroup) return null;
const tournament = await tournamentFromDBCached({
tournamentId,
user,
});
const team = tournament.teamMemberOfByUser(user);
if (!team) return null;
const members: LFGGroupMember[] = team.members.map((m) => ({
id: m.userId,
username: m.username,
discordId: m.discordId,
discordAvatar: m.discordAvatar,
customUrl: m.customUrl,
languages: [],
vc: null,
pronouns: null,
role: m.isOwner ? "OWNER" : "REGULAR",
isStayAsSub: false,
weapons: null,
chatNameColor: null,
plusTier: m.plusTier,
}));
return {
id: team.id,
isPlaceholder: false,
teamName: team.name,
teamAvatarUrl: team.pickupAvatarUrl,
note: null,
members,
usersRole: members.find((m) => m.id === user.id)?.role ?? null,
};
}
function transformMembers(
rawMembers: Awaited<
ReturnType<typeof TournamentLFGRepository.findLookingTeamsByTournamentId>
>[number]["members"],
): LFGGroupMember[] {
return rawMembers.map((m) => {
const languages =
typeof m.languages === "string"
? m.languages.split(",").filter(Boolean)
: [];
const weapons = parseWeapons(m.weapons);
const pronouns = parsePronouns(m.pronouns);
return {
id: m.id,
username: m.username,
discordId: m.discordId,
discordAvatar: m.discordAvatar,
customUrl: m.customUrl,
languages,
vc: m.vc,
pronouns,
role: m.role,
isStayAsSub: m.isStayAsSub === 1,
weapons,
chatNameColor: m.chatNameColor,
plusTier: m.plusTier,
};
});
}
function parseWeapons(
raw: unknown,
): Array<{ weaponSplId: MainWeaponId; isFavorite: boolean }> | null {
if (!raw) return null;
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
if (!Array.isArray(parsed) || parsed.length === 0) return null;
return parsed.map(
(w: { weaponSplId: MainWeaponId; isFavorite: number | boolean }) => ({
weaponSplId: w.weaponSplId,
isFavorite: Boolean(w.isFavorite),
}),
);
}
function parsePronouns(raw: unknown): Pronouns | null {
if (!raw) return null;
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
if (!parsed || typeof parsed !== "object") return null;
return parsed as Pronouns;
}

View File

@@ -1,4 +1,41 @@
.listPageContainer {
.container {
display: grid;
justify-content: center;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--s-7);
}
.containerMobile {
grid-template-columns: 1fr;
min-width: 300px;
margin: 0 auto;
}
.innerContainer {
max-width: 94vw;
}
.header {
text-align: center;
font-size: var(--fonts-xxs);
font-weight: var(--semi-bold);
text-transform: uppercase;
color: var(--theme);
display: flex;
align-items: center;
}
.header::before,
.header::after {
flex: 1;
content: "";
padding: 2px;
background-color: var(--theme-transparent);
margin: 5px;
border-radius: var(--rounded);
}
.subsContainer {
max-width: 52rem;
display: flex;
flex-direction: column;
@@ -6,7 +43,7 @@
margin-inline: auto;
}
.section {
.subsSection {
display: grid;
grid-template-columns: max-content 1fr;
grid-template-areas:
@@ -14,25 +51,25 @@
"avatar info"
"spacer spacer"
"weapon-top-text weapon-top-images"
"weapon-bottom-text weapon-bottom-images"
"message message";
border-radius: var(--radius-box);
background-color: var(--color-bg-high);
padding: var(--s-4);
column-gap: var(--s-3);
column-gap: var(--s-2-5);
border: var(--border-style);
}
.sectionAvatar {
.subsSectionAvatar {
grid-area: avatar;
align-self: center;
}
.sectionName {
.subsSectionName {
grid-area: name;
align-self: flex-end;
}
.sectionInfo {
.subsSectionInfo {
grid-area: info;
font-size: var(--font-sm);
gap: var(--s-2);
@@ -43,67 +80,59 @@
align-items: center;
}
.sectionInfoVc {
.subsInfoVc {
display: flex;
align-items: center;
gap: var(--s-0-5);
}
.sectionInfoVc > svg {
.subsInfoVc > svg {
width: 18px;
}
.sectionMessage {
.subsSectionMessage {
grid-area: message;
font-size: var(--font-sm);
padding-block-start: var(--s-4);
white-space: pre-wrap;
}
.sectionWeaponText {
font-weight: var(--weight-semi);
font-size: var(--font-xs);
.subsSectionWeaponText {
font-weight: var(--semi-bold);
font-size: var(--fonts-xs);
align-self: center;
}
.sectionWeaponTopText {
.subsSectionWeaponTopText {
grid-area: weapon-top-text;
}
.sectionWeaponBottomText {
grid-area: weapon-bottom-text;
}
.sectionWeaponImages {
.subsSectionWeaponImages {
display: flex;
gap: var(--s-1);
width: 11rem;
align-items: center;
}
.sectionWeaponTopImages {
.subsSectionWeaponTopImages {
grid-area: weapon-top-images;
}
.sectionWeaponBottomImages {
grid-area: weapon-bottom-images;
}
.sectionSpacer {
.subsSectionSpacer {
grid-area: spacer;
height: 18px;
}
@media screen and (min-width: 640px) {
.section {
.subsSection {
grid-template-columns: max-content 1fr max-content max-content;
grid-template-areas:
"avatar name weapon-top-text weapon-top-images"
"avatar info weapon-bottom-text weapon-bottom-images"
"avatar info . ."
"message message message message";
}
.sectionSpacer {
.subsSectionSpacer {
display: none;
}
}

View File

@@ -0,0 +1,479 @@
import clsx from "clsx";
import * as React from "react";
import { Flipper } from "react-flip-toolkit";
import { useTranslation } from "react-i18next";
import { Link, useFetcher, useLoaderData } from "react-router";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import {
SendouTab,
SendouTabList,
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { WeaponImage } from "~/components/Image";
import { MicrophoneIcon } from "~/components/icons/Microphone";
import { TrashIcon } from "~/components/icons/Trash";
import { Placeholder } from "~/components/Placeholder";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import { useTournament } from "~/features/tournament/routes/to.$id";
import { SendouForm } from "~/form/SendouForm";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useWindowSize } from "~/hooks/useWindowSize";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { userPage } from "~/utils/urls";
import { LFGGroupCard } from "../components/LFGGroupCard";
import {
type LookingLoaderData,
loader,
type SubEntry,
} from "../loaders/to.$id.looking.server";
import {
addSubFormSchema,
joinQueueFormSchema,
} from "../tournament-lfg-schemas";
export { action } from "../actions/to.$id.looking.server";
export { loader };
import styles from "./to.$id.looking.module.css";
export const handle: SendouRouteHandle = {
i18n: ["q", "tournament", "forms", "common"],
};
export default function TournamentLFGShell() {
const isMounted = useIsMounted();
if (!isMounted) return <Placeholder />;
return <TournamentLFGPage />;
}
// xxx: show one button to join at the top with dialog opening? instead of two inline
// xxx: no button if already in the tournament
// xxx: can we reuse the column layout with sendouq?
function TournamentLFGPage() {
const data = useLoaderData<typeof loader>();
if (data.mode === "subs") {
return <SubsView data={data} />;
}
return <GroupsView data={data} />;
}
function GroupsView({
data,
}: {
data: Extract<LookingLoaderData, { mode: "looking" }>;
}) {
const { t } = useTranslation(["q"]);
const tournament = useTournament();
const { width } = useWindowSize();
const isMobile = width < 750;
const ownMemberCount =
data.ownGroup?.members.length ?? data.ownTeam?.members.length ?? 0;
const availableSlots = tournament.maxMembersPerTeam - ownMemberCount;
const compatibleGroups = data.groups.filter(
(group) => group.members.length <= availableSlots,
);
const neutralGroups = compatibleGroups.filter(
(group) =>
!data.likes.given.some((like) => like.teamId === group.id) &&
!data.likes.received.some((like) => like.teamId === group.id),
);
const likedGroups = compatibleGroups.filter((group) =>
data.likes.given.some((like) => like.teamId === group.id),
);
const groupsReceivedLikesFrom = compatibleGroups.filter((group) =>
data.likes.received.some((like) => like.teamId === group.id),
);
const flipKey = `${neutralGroups.map((g) => `${g.id}-${isMobile ? true : data.likes.given.some((l) => l.teamId === g.id)}`).join(":")}:${groupsReceivedLikesFrom.map((g) => g.id).join(":")}`;
const invitedGroupsDesktop = (
<div className="stack sm">
<ColumnHeader>{t("q:looking.columns.invited")}</ColumnHeader>
{likedGroups.map((group) => (
<LFGGroupCard
key={group.id}
group={group}
action="UNLIKE"
ownGroup={data.ownGroup ?? undefined}
/>
))}
</div>
);
const ownTabMemberCount = data.ownGroup
? data.ownGroup.members.length
: data.ownTeam
? data.ownTeam.members.length
: 0;
const ownGroupElement = data.ownGroup ? (
<div className="stack md">
<LFGGroupCard group={data.ownGroup} ownGroup={data.ownGroup} />
<LFGGroupLeaver
type={data.ownGroup.members.length === 1 ? "LEAVE_Q" : "LEAVE_GROUP"}
/>
{!isMobile ? invitedGroupsDesktop : null}
</div>
) : null;
const leftColumnContent = data.ownGroup ? (
ownGroupElement
) : data.ownTeam ? (
<TeamQueueSection />
) : (
<JoinQueueForm />
);
return (
<Flipper flipKey={flipKey}>
<div
className={clsx(styles.container, {
[styles.containerMobile]: isMobile,
})}
>
{!isMobile ? (
<div>
<SendouTabs>
<SendouTabList>
<SendouTab id="own" number={ownTabMemberCount}>
{t("q:looking.columns.myGroup")}
</SendouTab>
</SendouTabList>
<SendouTabPanel id="own">{leftColumnContent}</SendouTabPanel>
</SendouTabs>
</div>
) : null}
<div className={styles.innerContainer}>
<SendouTabs>
<SendouTabList scrolling={isMobile}>
<SendouTab id="groups" number={neutralGroups.length}>
{t("q:looking.columns.groups")}
</SendouTab>
{isMobile ? (
<SendouTab
id="received"
number={groupsReceivedLikesFrom.length}
>
{t("q:looking.columns.invitations")}
</SendouTab>
) : null}
{isMobile ? (
<SendouTab id="own" number={ownTabMemberCount}>
{t("q:looking.columns.myGroup")}
</SendouTab>
) : null}
</SendouTabList>
<SendouTabPanel id="groups">
<div className="stack sm">
<ColumnHeader>{t("q:looking.columns.available")}</ColumnHeader>
{(isMobile
? compatibleGroups.filter(
(group) =>
!data.likes.received.some(
(like) => like.teamId === group.id,
),
)
: neutralGroups
).map((group) => (
<LFGGroupCard
key={group.id}
group={group}
action={
data.likes.given.some((like) => like.teamId === group.id)
? "UNLIKE"
: "LIKE"
}
ownGroup={data.ownGroup ?? undefined}
/>
))}
</div>
</SendouTabPanel>
<SendouTabPanel id="received">
<div className="stack sm">
{groupsReceivedLikesFrom.map((group) => (
<LFGGroupCard
key={group.id}
group={group}
action="ACCEPT"
ownGroup={data.ownGroup ?? undefined}
/>
))}
</div>
</SendouTabPanel>
<SendouTabPanel id="own">{leftColumnContent}</SendouTabPanel>
</SendouTabs>
</div>
{!isMobile ? (
<div className="stack sm">
<ColumnHeader>{t("q:looking.columns.invitations")}</ColumnHeader>
{groupsReceivedLikesFrom.map((group) => (
<LFGGroupCard
key={group.id}
group={group}
action="ACCEPT"
ownGroup={data.ownGroup ?? undefined}
/>
))}
</div>
) : null}
</div>
</Flipper>
);
}
function SubsView({
data,
}: {
data: Extract<LookingLoaderData, { mode: "subs" }>;
}) {
const user = useUser();
const tournament = useTournament();
const isOnTeam = Boolean(tournament.teamMemberOfByUser(user));
return (
<div className={styles.subsContainer}>
{tournament.canAddNewSubPost &&
!data.hasOwnSubPost &&
user &&
!isOnTeam ? (
<AddSubForm />
) : null}
{data.subs.map((sub) => (
<SubCard key={sub.userId} sub={sub} />
))}
</div>
);
}
function AddSubForm() {
const { t } = useTranslation(["tournament"]);
return (
<div className="stack items-end">
<SendouDialog
heading={t("tournament:subs.addPost")}
showCloseButton
trigger={
<SendouButton size="small">
{t("tournament:subs.addPost")}
</SendouButton>
}
>
<SendouForm schema={addSubFormSchema}>
{({ FormField }) => <FormField name="message" />}
</SendouForm>
</SendouDialog>
</div>
);
}
function SubCard({ sub }: { sub: SubEntry }) {
const { t } = useTranslation(["common", "tournament"]);
const user = useUser();
const tournament = useTournament();
const infos = [
<div key="vc" className={styles.subsInfoVc}>
<MicrophoneIcon
className={
sub.vc === "YES"
? "text-success"
: sub.vc === "LISTEN_ONLY"
? "text-warning"
: "text-error"
}
/>
{sub.vc === "YES"
? t("tournament:subs.canVC")
: sub.vc === "LISTEN_ONLY"
? t("tournament:subs.listenOnlyVC")
: t("tournament:subs.noVC")}
</div>,
];
if (sub.plusTier) {
infos.push(
<React.Fragment key="dot-1">{"\u00B7"}</React.Fragment>,
<div key="plus">+{sub.plusTier}</div>,
);
}
if (sub.languages.length > 0) {
infos.push(
<React.Fragment key="dot-2">{"\u00B7"}</React.Fragment>,
<div key="languages">
{sub.languages.map((lang) => lang.toUpperCase()).join(" / ")}
</div>,
);
}
return (
<div>
<section className={styles.subsSection}>
<Avatar user={sub} size="sm" className={styles.subsSectionAvatar} />
<Link to={userPage(sub)} className={styles.subsSectionName}>
{sub.username}
</Link>
<div className={styles.subsSectionSpacer} />
<div className={styles.subsSectionInfo}>{infos}</div>
{sub.weapons ? (
<>
<div
className={clsx(
styles.subsSectionWeaponTopText,
styles.subsSectionWeaponText,
)}
>
{t("tournament:subs.prefersToPlay")}
</div>
<div
className={clsx(
styles.subsSectionWeaponTopImages,
styles.subsSectionWeaponImages,
)}
>
{sub.weapons.map((wpn) => (
<WeaponImage
key={wpn.weaponSplId}
weaponSplId={wpn.weaponSplId}
size={32}
variant="badge"
/>
))}
</div>
</>
) : null}
{sub.message ? (
<div className={styles.subsSectionMessage}>{sub.message}</div>
) : null}
</section>
{user?.id === sub.userId || tournament.isOrganizer(user) ? (
<div className="stack mt-1 items-end">
<FormWithConfirm
dialogHeading={
user?.id === sub.userId
? "Delete your sub post?"
: `Delete sub post by ${sub.username}?`
}
fields={[
["_action", "DELETE_SUB"],
["userId", sub.userId],
]}
>
<SendouButton
variant="minimal-destructive"
size="small"
type="submit"
icon={<TrashIcon />}
>
{t("common:actions.delete")}
</SendouButton>
</FormWithConfirm>
</div>
) : null}
</div>
);
}
function ColumnHeader({ children }: { children: React.ReactNode }) {
const { width } = useWindowSize();
const isMobile = width < 750;
if (isMobile) return null;
return <div className={styles.header}>{children}</div>;
}
function JoinQueueForm() {
const { t } = useTranslation(["q"]);
return (
<SendouForm
schema={joinQueueFormSchema}
submitButtonText={t("q:looking.joinQPromptTeam")}
>
{({ FormField }) => (
<>
<FormField name="note" />
<FormField name="stayAsSub" />
</>
)}
</SendouForm>
);
}
function TeamQueueSection() {
const { t } = useTranslation(["q"]);
const data = useLoaderData<typeof loader>() as Extract<
LookingLoaderData,
{ mode: "looking" }
>;
const tournament = useTournament();
const fetcher = useFetcher();
if (!data.ownTeam) return null;
const isAtMaxMembers =
data.ownTeam.members.length >= tournament.maxMembersPerTeam;
return (
<div className="stack md">
<LFGGroupCard group={data.ownTeam} />
{!isAtMaxMembers ? (
<fetcher.Form method="post" className="stack items-center">
<SubmitButton
_action="JOIN_QUEUE"
state={fetcher.state}
variant="outlined"
>
{t("q:looking.joinQPromptMembers")}
</SubmitButton>
</fetcher.Form>
) : null}
</div>
);
}
function LFGGroupLeaver({ type }: { type: "LEAVE_GROUP" | "LEAVE_Q" }) {
const { t } = useTranslation(["q"]);
const fetcher = useFetcher();
if (type === "LEAVE_GROUP") {
return (
<FormWithConfirm
dialogHeading="Leave this group?"
fields={[["_action", "LEAVE_GROUP"]]}
submitButtonText="Leave"
>
<SendouButton variant="minimal-destructive" size="small">
{t("q:looking.groups.actions.stopLooking")}
</SendouButton>
</FormWithConfirm>
);
}
return (
<fetcher.Form method="POST">
<SubmitButton
_action="LEAVE_GROUP"
variant="minimal-destructive"
size="small"
state={fetcher.state}
>
{t("q:looking.groups.actions.leaveQ")}
</SubmitButton>
</fetcher.Form>
);
}

View File

@@ -0,0 +1,63 @@
import { z } from "zod";
import { stringConstant, textAreaOptional, toggle } from "~/form/fields";
import { _action, id } from "~/utils/zod";
const noteFieldSchema = textAreaOptional({
label: "labels.note",
maxLength: 160,
});
export const addSubFormSchema = z.object({
_action: stringConstant("ADD_SUB"),
message: noteFieldSchema,
});
const stayAsSubFieldSchema = toggle({
label: "labels.stayAsSub",
bottomText: "bottomTexts.stayAsSub",
});
export const joinQueueFormSchema = z.object({
_action: stringConstant("JOIN_QUEUE"),
note: noteFieldSchema,
stayAsSub: stayAsSubFieldSchema,
});
export const updateGroupFormSchema = z.object({
_action: stringConstant("UPDATE_GROUP"),
note: noteFieldSchema,
stayAsSub: stayAsSubFieldSchema,
});
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"),
}),
addSubFormSchema,
z.object({
_action: _action("DELETE_SUB"),
userId: id,
}),
]);

View File

@@ -0,0 +1,18 @@
import type { LFGGroup } from "./components/LFGGroupCard";
export function survivingTeamId({
ourGroup,
theirGroup,
}: {
ourGroup: LFGGroup;
theirGroup: LFGGroup;
}) {
if (!ourGroup.isPlaceholder && theirGroup.isPlaceholder) {
return ourGroup.id;
}
if (ourGroup.isPlaceholder && !theirGroup.isPlaceholder) {
return theirGroup.id;
}
return ourGroup.id;
}

View File

@@ -1,164 +0,0 @@
import { sql } from "kysely";
import { db } from "~/db/sql";
import type { TablesInsertable } from "~/db/tables";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
export async function findSubsVisibleToUser({
tournamentId,
userId,
}: {
tournamentId: number;
userId?: number;
}) {
const userPlusTier = await getUserPlusTier(userId);
const rows = await baseQuery(tournamentId)
.where((eb) =>
eb.or([
eb("TournamentSub.visibility", "=", "ALL"),
eb.and([
eb("TournamentSub.visibility", "=", "+1"),
eb(sql`${userPlusTier}`, "=", 1),
]),
eb.and([
eb("TournamentSub.visibility", "=", "+2"),
eb(sql`${userPlusTier}`, "<=", 2),
]),
eb.and([
eb("TournamentSub.visibility", "=", "+3"),
eb(sql`${userPlusTier}`, "<=", 3),
]),
]),
)
.orderBy((eb) =>
eb
.case()
.when("TournamentSub.userId", "=", userId ?? null)
.then(0)
.else(1)
.end(),
)
.orderBy((eb) =>
eb
.case()
.when(eb("PlusTier.tier", "is", null))
.then(4)
.else(eb.ref("PlusTier.tier"))
.end(),
)
.orderBy("TournamentSub.createdAt", "desc")
.execute();
return mapRows(rows);
}
export async function findUserSubPost({
tournamentId,
userId,
}: {
tournamentId: number;
userId: number;
}) {
const row = await baseQuery(tournamentId)
.where("TournamentSub.userId", "=", userId)
.executeTakeFirst();
if (!row) return null;
return mapRows([row])[0];
}
export function upsert(
args: Omit<TablesInsertable["TournamentSub"], "bestWeapons" | "okWeapons"> & {
bestWeapons: MainWeaponId[];
okWeapons: MainWeaponId[];
},
) {
return db.transaction().execute(async (trx) => {
const bestWeaponsStr = args.bestWeapons.join(",");
const okWeaponsStr =
args.okWeapons.length > 0 ? args.okWeapons.join(",") : null;
await trx
.insertInto("TournamentSub")
.values({
userId: args.userId,
tournamentId: args.tournamentId,
canVc: args.canVc,
bestWeapons: bestWeaponsStr,
okWeapons: okWeaponsStr,
message: args.message,
visibility: args.visibility,
})
.onConflict((oc) =>
oc.columns(["userId", "tournamentId"]).doUpdateSet({
canVc: args.canVc,
bestWeapons: bestWeaponsStr,
okWeapons: okWeaponsStr,
message: args.message,
visibility: args.visibility,
}),
)
.execute();
await trx
.updateTable("User")
.set({ lastSubMessage: args.message })
.where("id", "=", args.userId)
.execute();
});
}
async function getUserPlusTier(userId?: number) {
if (!userId) return 4;
const row = await db
.selectFrom("PlusTier")
.select("tier")
.where("userId", "=", userId)
.executeTakeFirst();
return row?.tier ?? 4;
}
function baseQuery(tournamentId: number) {
return db
.selectFrom("TournamentSub")
.innerJoin("User", "User.id", "TournamentSub.userId")
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
.select([
"TournamentSub.canVc",
"TournamentSub.bestWeapons",
"TournamentSub.okWeapons",
"TournamentSub.message",
"TournamentSub.visibility",
"TournamentSub.createdAt",
"TournamentSub.userId",
"User.username",
"User.discordAvatar",
"User.country",
"User.discordId",
"User.customUrl",
"PlusTier.tier as plusTier",
])
.where("TournamentSub.tournamentId", "=", tournamentId);
}
function mapRows<
T extends {
bestWeapons: string;
okWeapons: string | null;
},
>(rows: T[]) {
return rows.map((row) => ({
...row,
bestWeapons: parseWeaponsArray(row.bestWeapons)!,
okWeapons: parseWeaponsArray(row.okWeapons),
}));
}
function parseWeaponsArray(value: string | null) {
if (!value) return null;
return value.split(",").map(Number) as MainWeaponId[];
}

View File

@@ -1,54 +0,0 @@
import { type ActionFunction, redirect } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import { requireNotBannedByOrganization } from "~/features/tournament/tournament-utils.server";
import {
clearTournamentDataCache,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import {
errorToastIfFalsy,
parseParams,
parseRequestPayload,
} from "~/utils/remix.server";
import { tournamentSubsPage } from "~/utils/urls";
import { idObject } from "~/utils/zod";
import * as TournamentSubRepository from "../TournamentSubRepository.server";
import { subSchema } from "../tournament-subs-schemas.server";
export const action: ActionFunction = async ({ params, request }) => {
const user = requireUser();
const data = await parseRequestPayload({
request,
schema: subSchema,
});
const { id: tournamentId } = parseParams({
params,
schema: idObject,
});
const tournament = await tournamentFromDB({ tournamentId, user });
await requireNotBannedByOrganization({ tournament, user });
errorToastIfFalsy(!tournament.everyBracketOver, "Tournament is over");
errorToastIfFalsy(
tournament.canAddNewSubPost,
"Registration is closed or subs feature disabled",
);
errorToastIfFalsy(
!tournament.teamMemberOfByUser(user),
"Can't register as a sub and be in a team at the same time",
);
await TournamentSubRepository.upsert({
bestWeapons: data.bestWeapons,
okWeapons: data.okWeapons,
canVc: data.canVc,
visibility: data.visibility,
message: data.message ?? null,
tournamentId,
userId: user.id,
});
clearTournamentDataCache(tournamentId);
throw redirect(tournamentSubsPage(tournamentId));
};

View File

@@ -1,41 +0,0 @@
import type { ActionFunction } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import {
clearTournamentDataCache,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import {
errorToastIfFalsy,
parseParams,
parseRequestPayload,
} from "~/utils/remix.server";
import { idObject } from "~/utils/zod";
import { deleteSub } from "../queries/deleteSub.server";
import { deleteSubSchema } from "../tournament-subs-schemas.server";
export const action: ActionFunction = async ({ request, params }) => {
const user = requireUser();
const { id: tournamentId } = parseParams({
params,
schema: idObject,
});
const tournament = await tournamentFromDB({ tournamentId, user });
const data = await parseRequestPayload({
request,
schema: deleteSubSchema,
});
errorToastIfFalsy(
user.id === data.userId || tournament.isOrganizer(user),
"You can only delete your own sub post",
);
deleteSub({
tournamentId,
userId: data.userId,
});
clearTournamentDataCache(tournamentId);
return null;
};

View File

@@ -1,29 +0,0 @@
import { type LoaderFunctionArgs, redirect } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { parseParams } from "~/utils/remix.server";
import { tournamentSubsPage } from "~/utils/urls";
import { idObject } from "~/utils/zod";
import * as TournamentSubRepository from "../TournamentSubRepository.server";
export const loader = async ({ params }: LoaderFunctionArgs) => {
const user = requireUser();
const { id: tournamentId } = parseParams({
params,
schema: idObject,
});
const tournament = await tournamentFromDB({ tournamentId, user });
if (!tournament.canAddNewSubPost) {
throw redirect(tournamentSubsPage(tournamentId));
}
return {
sub: await TournamentSubRepository.findUserSubPost({
tournamentId,
userId: user.id,
}),
userDefaults: await UserRepository.findSubDefaultsByUserId(user.id),
};
};

View File

@@ -1,30 +1,13 @@
import { type LoaderFunctionArgs, redirect } from "react-router";
import { getUser } from "~/features/auth/core/user.server";
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
import { parseParams } from "~/utils/remix.server";
import { tournamentRegisterPage } from "~/utils/urls";
import { tournamentSubsPage } from "~/utils/urls";
import { idObject } from "~/utils/zod";
import * as TournamentSubRepository from "../TournamentSubRepository.server";
export const loader = async ({ params }: LoaderFunctionArgs) => {
const user = getUser();
const { id: tournamentId } = parseParams({
params,
schema: idObject,
});
const tournament = await tournamentFromDB({ tournamentId, user });
if (!tournament.subsFeatureEnabled) {
throw redirect(tournamentRegisterPage(tournamentId));
}
const subs = await TournamentSubRepository.findSubsVisibleToUser({
tournamentId,
userId: user?.id,
});
return {
subs,
hasOwnSubPost: subs.some((sub) => sub.userId === user?.id),
};
throw redirect(tournamentSubsPage(tournamentId));
};

View File

@@ -1,9 +0,0 @@
.selectedWeapon {
padding: var(--s-2);
border-radius: 100%;
background-color: var(--color-bg-high);
}
.weaponPool {
width: 20rem;
}

View File

@@ -1,297 +0,0 @@
import { Trash } from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
import { Form, Link, useLoaderData } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { FormMessage } from "~/components/FormMessage";
import { WeaponImage } from "~/components/Image";
import { Label } from "~/components/Label";
import { RequiredHiddenInput } from "~/components/RequiredHiddenInput";
import { SubmitButton } from "~/components/SubmitButton";
import { WeaponSelect } from "~/components/WeaponSelect";
import { useUser } from "~/features/auth/core/user";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { SENDOUQ_SETTINGS_PAGE } from "~/utils/urls";
import { action } from "../actions/to.$id.subs.new.server";
import { loader } from "../loaders/to.$id.subs.new.server";
import { TOURNAMENT_SUB } from "../tournament-subs-constants";
export { action, loader };
import clsx from "clsx";
import { mainStyles } from "~/components/Main";
import styles from "./to.$id.subs.new.module.css";
export const handle: SendouRouteHandle = {
i18n: ["user"],
};
export default function NewTournamentSubPage() {
const user = useUser();
const { t } = useTranslation(["common", "tournament"]);
const data = useLoaderData<typeof loader>();
const [bestWeapons, setBestWeapons] = React.useState<MainWeaponId[]>(
data.sub ? data.sub.bestWeapons : (data.userDefaults?.bestWeapons ?? []),
);
const [okWeapons, setOkWeapons] = React.useState<MainWeaponId[]>(
data.sub
? (data.sub.okWeapons ?? [])
: (data.userDefaults?.okWeapons ?? []),
);
return (
<div className={mainStyles.narrow}>
<Form method="post" className="stack md items-start">
<div className="stack">
<h2>{t("tournament:subs.addPost")}</h2>
<FormMessage type="info">
{t("tournament:subs.defaultsNote")}{" "}
<Link to={SENDOUQ_SETTINGS_PAGE}>
{t("tournament:subs.defaultsPage")}
</Link>
</FormMessage>
</div>
<VCRadios />
<WeaponPoolSelect
label={t("tournament:subs.weapons.prefer.header")}
weapons={bestWeapons}
otherWeapons={okWeapons}
setWeapons={setBestWeapons}
id="bestWeapons"
infoText={t("tournament:subs.weapons.info", {
min: 1,
max: TOURNAMENT_SUB.WEAPON_POOL_MAX_SIZE,
})}
required
/>
<WeaponPoolSelect
label={t("tournament:subs.weapons.ok.header")}
weapons={okWeapons}
otherWeapons={bestWeapons}
setWeapons={setOkWeapons}
id="okWeapons"
infoText={t("tournament:subs.weapons.info", {
min: 0,
max: TOURNAMENT_SUB.WEAPON_POOL_MAX_SIZE,
})}
/>
<Message />
{user?.plusTier ? <VisibilityRadios /> : null}
<SubmitButton>{t("common:actions.save")}</SubmitButton>
</Form>
</div>
);
}
function VCRadios() {
const { t } = useTranslation(["common", "tournament"]);
const data = useLoaderData<typeof loader>();
const isDefaultChecked = (value: number) => {
if (data.sub) {
return data.sub.canVc === value;
}
const defaultCanVc = data.userDefaults?.canVc ?? 1;
return defaultCanVc === value;
};
return (
<div>
<Label required>{t("tournament:subs.vc.header")}</Label>
<div className="stack xs">
<div className="stack horizontal sm items-center">
<input
type="radio"
id="vc-true"
name="canVc"
value="1"
required
defaultChecked={isDefaultChecked(1)}
/>
<label htmlFor="vc-true" className="mb-0">
{t("common:yes")}
</label>
</div>
<div className="stack horizontal sm items-center">
<input
type="radio"
id="vc-false"
name="canVc"
value="0"
defaultChecked={isDefaultChecked(0)}
/>
<label htmlFor="vc-false" className="mb-0">
{t("common:no")}
</label>
</div>
<div className="stack horizontal sm items-center">
<input
type="radio"
id="vc-listen-only"
name="canVc"
value="2"
defaultChecked={isDefaultChecked(2)}
/>
<label htmlFor="vc-listen-only" className="mb-0">
{t("tournament:subs.listenOnlyVC")}
</label>
</div>
</div>
</div>
);
}
function Message() {
const { t } = useTranslation(["tournament"]);
const data = useLoaderData<typeof loader>();
const [value, setValue] = React.useState(
data.sub?.message ?? data.userDefaults?.message ?? "",
);
return (
<div className="u-edit__bio-container">
<Label
htmlFor="message"
valueLimits={{
current: value.length,
max: TOURNAMENT_SUB.MESSAGE_MAX_LENGTH,
}}
>
{t("tournament:subs.message.header")}
</Label>
<textarea
id="message"
name="message"
value={value}
onChange={(e) => setValue(e.target.value)}
maxLength={TOURNAMENT_SUB.MESSAGE_MAX_LENGTH}
/>
</div>
);
}
function VisibilityRadios() {
const { t } = useTranslation(["tournament"]);
const data = useLoaderData<typeof loader>();
const user = useUser();
const userPlusTier = user?.plusTier ?? 4;
return (
<div>
<Label required>{t("tournament:subs.visibility.header")}</Label>
<div className="stack xs">
{[1, 2, 3]
.filter((tier) => tier >= userPlusTier)
.map((tier) => {
const id = `+${tier}`;
return (
<div key={tier} className="stack horizontal sm items-center">
<input
type="radio"
id={id}
name="visibility"
value={id}
defaultChecked={data.sub?.visibility === id}
/>
<label htmlFor={id} className="mb-0">
+{tier} {tier !== 1 && "(and above)"}
</label>
</div>
);
})}
<div className="stack horizontal sm items-center">
<input
type="radio"
id="all"
name="visibility"
value="ALL"
required
defaultChecked={data.sub?.visibility === "ALL"}
/>
<label htmlFor="all" className="mb-0">
{t("tournament:subs.visibility.everyone")}
</label>
</div>
</div>
</div>
);
}
function WeaponPoolSelect({
weapons,
otherWeapons,
setWeapons,
label,
id,
infoText,
required = false,
}: {
weapons: Array<MainWeaponId>;
otherWeapons: Array<MainWeaponId>;
setWeapons: (weapons: Array<MainWeaponId>) => void;
label: string;
id: string;
infoText: string;
required?: boolean;
}) {
const { t } = useTranslation(["user"]);
return (
<div className={clsx("stack md", styles.weaponPool)}>
<RequiredHiddenInput
isValid={!required || weapons.length > 0}
name={id}
value={JSON.stringify(weapons)}
/>
{weapons.length < TOURNAMENT_SUB.WEAPON_POOL_MAX_SIZE ? (
<>
<WeaponSelect
label={label}
onChange={(weaponId) => {
setWeapons([...weapons, weaponId]);
}}
disabledWeaponIds={[...weapons, ...otherWeapons]}
// empty on selection
key={weapons[weapons.length - 1]}
/>
<FormMessage type="info">{infoText}</FormMessage>
</>
) : (
<span className="text-xs text-warning">
{t("user:forms.errors.maxWeapons")}
</span>
)}
{weapons.length > 0 ? (
<div className="stack horizontal sm justify-center">
{weapons.map((weapon) => {
return (
<div key={weapon} className="stack xs">
<div className={styles.selectedWeapon}>
<WeaponImage
weaponSplId={weapon}
variant="badge"
width={38}
height={38}
/>
</div>
<SendouButton
icon={<Trash />}
variant="minimal-destructive"
aria-label="Delete weapon"
onPress={() =>
setWeapons(weapons.filter((w) => w !== weapon))
}
/>
</div>
);
})}
</div>
) : null}
</div>
);
}

View File

@@ -1,196 +1 @@
import clsx from "clsx";
import { Mic, Trash } from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
import { Link, useLoaderData } from "react-router";
import { Avatar } from "~/components/Avatar";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { Flag } from "~/components/Flag";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { WeaponImage } from "~/components/Image";
import { Redirect } from "~/components/Redirect";
import { useUser } from "~/features/auth/core/user";
import { useTournament } from "~/features/tournament/routes/to.$id";
import type { SerializeFrom } from "~/utils/remix";
import { tournamentRegisterPage, userPage } from "~/utils/urls";
import { action } from "../actions/to.$id.subs.server";
import { loader } from "../loaders/to.$id.subs.server";
export { action, loader };
import styles from "./to.$id.subs.module.css";
export default function TournamentSubsPage() {
const user = useUser();
const data = useLoaderData<typeof loader>();
const tournament = useTournament();
if (tournament.everyBracketOver) {
return <Redirect to={tournamentRegisterPage(tournament.ctx.id)} />;
}
return (
<div className={styles.listPageContainer}>
{!tournament.teamMemberOfByUser(user) && user ? (
<div className="stack items-end">
<AddOrEditSubButton />
</div>
) : null}
{data.subs.map((sub) => {
return <SubInfoSection key={sub.userId} sub={sub} />;
})}
</div>
);
}
function AddOrEditSubButton() {
const { t } = useTranslation(["tournament"]);
const data = useLoaderData<typeof loader>();
const tournament = useTournament();
const buttonText = data.hasOwnSubPost
? t("tournament:subs.editPost")
: t("tournament:subs.addPost");
if (!tournament.canAddNewSubPost) {
return (
<SendouPopover
trigger={<SendouButton size="small">{buttonText}</SendouButton>}
>
{data.hasOwnSubPost
? "Sub post can't be edited anymore since registration has closed"
: "Sub post can't be added anymore since registration has closed"}
</SendouPopover>
);
}
return (
<LinkButton to="new" size="small">
{buttonText}
</LinkButton>
);
}
function SubInfoSection({
sub,
}: {
sub: SerializeFrom<typeof loader>["subs"][number];
}) {
const { t } = useTranslation(["common", "tournament"]);
const user = useUser();
const tournament = useTournament();
const infos = [
<div key="vc" className={styles.sectionInfoVc}>
<Mic
className={
sub.canVc === 1
? "text-success"
: sub.canVc === 2
? "text-warning"
: "text-error"
}
/>
{sub.canVc === 1
? t("tournament:subs.canVC")
: sub.canVc === 2
? t("tournament:subs.listenOnlyVC")
: t("tournament:subs.noVC")}
</div>,
];
if (sub.plusTier) {
infos.push(<React.Fragment key="slash-1">/</React.Fragment>);
infos.push(<div key="plus">+{sub.plusTier}</div>);
}
if (sub.country) {
infos.push(<React.Fragment key="slash-2">/</React.Fragment>);
infos.push(<Flag key="flag" countryCode={sub.country} tiny />);
}
return (
<div>
<section className={styles.section}>
<Avatar user={sub} size="sm" className={styles.sectionAvatar} />
<Link to={userPage(sub)} className={styles.sectionName}>
{sub.username}
</Link>
<div className={styles.sectionSpacer} />
<div className={styles.sectionInfo}>{infos}</div>
<div
className={clsx(
styles.sectionWeaponTopText,
styles.sectionWeaponText,
)}
>
{t("tournament:subs.prefersToPlay")}
</div>
<div
className={clsx(
styles.sectionWeaponTopImages,
styles.sectionWeaponImages,
)}
>
{sub.bestWeapons.map((wpn) => (
<WeaponImage
key={wpn}
weaponSplId={wpn}
size={32}
variant="badge"
/>
))}
</div>
{sub.okWeapons ? (
<>
<div
className={clsx(
styles.sectionWeaponBottomText,
styles.sectionWeaponText,
)}
>
{t("tournament:subs.canPlay")}
</div>
<div
className={clsx(
styles.sectionWeaponBottomImages,
styles.sectionWeaponImages,
)}
>
{sub.okWeapons.map((wpn) => (
<WeaponImage
key={wpn}
weaponSplId={wpn}
size={32}
variant="badge"
/>
))}
</div>
</>
) : null}
{sub.message ? (
<div className={styles.sectionMessage}>{sub.message}</div>
) : null}
</section>
{user?.id === sub.userId || tournament.isOrganizer(user) ? (
<div className="stack mt-1 items-end">
<FormWithConfirm
dialogHeading={
user?.id === sub.userId
? "Delete your sub post?"
: `Delete sub post by ${sub.username}?`
}
fields={[["userId", sub.userId]]}
>
<SendouButton
variant="minimal-destructive"
size="small"
type="submit"
icon={<Trash />}
>
{t("common:actions.delete")}
</SendouButton>
</FormWithConfirm>
</div>
) : null}
</div>
);
}
export { loader } from "../loaders/to.$id.subs.server";

View File

@@ -1,4 +0,0 @@
export const TOURNAMENT_SUB = {
WEAPON_POOL_MAX_SIZE: 5,
MESSAGE_MAX_LENGTH: 280,
};

View File

@@ -1,27 +0,0 @@
import { z } from "zod";
import {
id,
processMany,
removeDuplicates,
safeJSONParse,
weaponSplId,
} from "~/utils/zod";
import { TOURNAMENT_SUB } from "./tournament-subs-constants";
export const subSchema = z.object({
canVc: z.coerce.number().int().min(0).max(2),
bestWeapons: z.preprocess(
processMany(safeJSONParse, removeDuplicates),
z.array(weaponSplId).min(1).max(TOURNAMENT_SUB.WEAPON_POOL_MAX_SIZE),
),
okWeapons: z.preprocess(
processMany(safeJSONParse, removeDuplicates),
z.array(weaponSplId).max(TOURNAMENT_SUB.WEAPON_POOL_MAX_SIZE),
),
message: z.string().max(TOURNAMENT_SUB.MESSAGE_MAX_LENGTH).nullish(),
visibility: z.enum(["+1", "+2", "+3", "ALL"]).default("ALL"),
});
export const deleteSubSchema = z.object({
userId: id,
});

View File

@@ -123,13 +123,20 @@ export async function findById(id: number) {
).as("staff"),
jsonArrayFrom(
eb
.selectFrom("TournamentSub")
.selectFrom("TournamentTeamMember")
.innerJoin(
"TournamentTeam",
"TournamentTeam.id",
"TournamentTeamMember.tournamentTeamId",
)
.select(({ fn }) => [
"TournamentSub.visibility",
sql<string>`'ALL'`.as("visibility"),
fn.countAll<number>().as("count"),
])
.where("TournamentSub.tournamentId", "=", id)
.groupBy("TournamentSub.visibility"),
.where("TournamentTeam.tournamentId", "=", id)
// xxx: this is not correct
.where("TournamentTeam.isPlaceholder", "=", 1)
.where("TournamentTeamMember.isStayAsSub", "=", 1),
).as("subCounts"),
jsonArrayFrom(
eb
@@ -252,6 +259,7 @@ export async function findById(id: number) {
).as("team"),
])
.where("TournamentTeam.tournamentId", "=", id)
.where("TournamentTeam.isPlaceholder", "=", 0)
.orderBy("TournamentTeam.seed", "asc")
.orderBy("TournamentTeam.createdAt", "asc")
.orderBy("TournamentTeam.id", "asc"),
@@ -485,6 +493,7 @@ export function forShowcase() {
),
)
.whereRef("TournamentTeam.tournamentId", "=", "Tournament.id")
.where("TournamentTeam.isPlaceholder", "=", 0)
.where((eb) =>
eb.or([
eb("TournamentTeamCheckIn.checkedInAt", "is not", null),

View File

@@ -149,6 +149,7 @@ export function create({
tournamentTeamId: tournamentTeam.id,
userId,
isOwner: 1,
role: "OWNER",
inGameName: ownerInGameName,
})
.execute();

View File

@@ -6,6 +6,7 @@ import {
clearTournamentDataCache,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import invariant from "~/utils/invariant";
import {
@@ -81,6 +82,7 @@ export const action: ActionFunction = async ({ request, params }) => {
? "DELETE"
: "LEAVE";
await TournamentLFGRepository.leaveLfg({ userId: user.id, tournamentId });
joinTeam({
userId: user.id,
newTeamId: teamToJoin.id,

View File

@@ -11,6 +11,7 @@ import {
clearTournamentDataCache,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server";
import { deleteSub } from "~/features/tournament-subs/queries/deleteSub.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { logger } from "~/utils/logger";
@@ -120,6 +121,10 @@ export const action: ActionFunction = async ({ request, params }) => {
"Team name already taken for this tournament",
);
await TournamentLFGRepository.leaveLfg({
userId: user.id,
tournamentId,
});
await TournamentTeamRepository.create({
ownerInGameName: await inGameNameIfNeeded({
tournament,
@@ -277,6 +282,10 @@ export const action: ActionFunction = async ({ request, params }) => {
message: "The user is banned from events hosted by this organization",
});
await TournamentLFGRepository.leaveLfg({
userId: data.userId,
tournamentId,
});
joinTeam({
userId: data.userId,
newTeamId: ownTeam.id,

View File

@@ -74,6 +74,8 @@ import {
import { useTournament } from "./to.$id";
export { loader, action };
// xxx: handle placeholder team made
// xxx: allow promoting members to managers
export default function TournamentRegisterPage() {
const isMounted = useIsMounted();
const tournament = useTournament();

View File

@@ -181,11 +181,13 @@ export function TournamentLayout() {
>
{t("tournament:tabs.teams", { count: tournament.ctx.teams.length })}
</SubNavLink>
{!tournament.everyBracketOver && tournament.subsFeatureEnabled && (
<SubNavLink to="subs" end={false}>
{t("tournament:tabs.subs", { count: subsCount() })}
{!tournament.isInvitational && !tournament.everyBracketOver ? (
<SubNavLink to="looking">
{tournament.registrationOpen
? t("tournament:tabs.looking")
: t("tournament:tabs.subs", { count: subsCount() })}
</SubNavLink>
)}
) : null}
{tournament.hasStarted && !tournament.everyBracketOver ? (
<SubNavLink to="streams">
{t("tournament:tabs.streams", {

View File

@@ -372,43 +372,6 @@ export function findByFriendCode(friendCode: string) {
.execute();
}
export async function findSubDefaultsByUserId(userId: number) {
const user = await db
.selectFrom("User")
.select(["User.vc", "User.qWeaponPool", "User.lastSubMessage"])
.where("User.id", "=", userId)
.executeTakeFirst();
if (!user) return null;
const vcToCanVc = (vc: "YES" | "NO" | "LISTEN_ONLY"): 0 | 1 | 2 => {
if (vc === "YES") return 1;
if (vc === "NO") return 0;
return 2;
};
const qWeaponPool = user.qWeaponPool ?? [];
let bestWeapons = qWeaponPool
.filter((w) => w.isFavorite === 1)
.map((w) => w.weaponSplId);
let okWeapons = qWeaponPool
.filter((w) => w.isFavorite === 0)
.map((w) => w.weaponSplId);
if (bestWeapons.length === 0) {
bestWeapons = okWeapons;
okWeapons = [];
}
return {
canVc: vcToCanVc(user.vc),
bestWeapons,
okWeapons,
message: user.lastSubMessage,
};
}
export async function findLeanById(id: number) {
const user = await db
.selectFrom("User")

View File

@@ -120,8 +120,9 @@ export default [
route("results", "features/tournament/routes/to.$id.results.tsx"),
route("streams", "features/tournament/routes/to.$id.streams.tsx"),
route("looking", "features/tournament-lfg/routes/to.$id.looking.tsx"),
route("subs", "features/tournament-subs/routes/to.$id.subs.tsx"),
route("subs/new", "features/tournament-subs/routes/to.$id.subs.new.tsx"),
route(
"divisions",

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>;

2055
app/utils/team-name-data.ts Normal file

File diff suppressed because it is too large Load Diff

12
app/utils/team-name.ts Normal file
View File

@@ -0,0 +1,12 @@
import { ADJECTIVES, SUBTITLES_PLURALIZED } from "./team-name-data";
/** Generates a random team name by combining a Splatoon 3 title adjective with a pluralized subtitle e.g. "Prestigious Heat Haters" */
export function randomTeamName() {
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
const subtitle =
SUBTITLES_PLURALIZED[
Math.floor(Math.random() * SUBTITLES_PLURALIZED.length)
];
return `${adjective} ${subtitle}`;
}

View File

@@ -358,7 +358,7 @@ export const tournamentJoinPage = ({
inviteCode: string;
}) => `/to/${tournamentId}/join?code=${inviteCode}`;
export const tournamentSubsPage = (tournamentId: number) => {
return `/to/${tournamentId}/subs`;
return `/to/${tournamentId}/looking`;
};
export const tournamentStreamsPage = (tournamentId: number) => {
return `/to/${tournamentId}/streams`;

View File

@@ -175,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

@@ -220,5 +220,8 @@
"errors.alreadyFriends": "",
"errors.friendRequestExists": "",
"labels.type": "",
"labels.visibility": ""
"labels.visibility": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -94,6 +94,7 @@
"looking.groups.actions.removeManager": "",
"looking.groups.actions.kick": "",
"looking.groups.actions.leaveGroup": "",
"looking.groups.actions.stopLooking": "",
"looking.groups.actions.leaveQ": "",
"looking.groups.actions.goBack": "",
"looking.groups.adder.quickAdd": "",
@@ -103,6 +104,7 @@
"looking.groups.adder.select": "",
"looking.groups.editNote": "",
"looking.groups.addNote": "",
"looking.groups.stayAsSub": "",
"looking.replay": "",
"looking.teamSP": "",
"looking.teamSP.calculated": "",
@@ -112,6 +114,8 @@
"looking.rankCalculating": "",
"looking.allTiers": "",
"looking.joinQPrompt": "",
"looking.joinQPromptTeam": "",
"looking.joinQPromptMembers": "",
"looking.range.or": "",
"looking.range.or.explanation": "",
"match.header": "",

View File

@@ -8,6 +8,7 @@
"tabs.results": "",
"tabs.streams": "Streams ({{count}})",
"tabs.subs": "Suppleanter ({{count}})",
"tabs.looking": "",
"pre.steps.header": "Fuldfør disse trin for at spille",
"pre.steps.name": "Holdnavn",
"pre.steps.roster": "Holdmedlemmer",

View File

@@ -220,5 +220,8 @@
"errors.alreadyFriends": "",
"errors.friendRequestExists": "",
"labels.type": "",
"labels.visibility": ""
"labels.visibility": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -94,6 +94,7 @@
"looking.groups.actions.removeManager": "",
"looking.groups.actions.kick": "",
"looking.groups.actions.leaveGroup": "",
"looking.groups.actions.stopLooking": "",
"looking.groups.actions.leaveQ": "",
"looking.groups.actions.goBack": "",
"looking.groups.adder.quickAdd": "",
@@ -103,6 +104,7 @@
"looking.groups.adder.select": "",
"looking.groups.editNote": "",
"looking.groups.addNote": "",
"looking.groups.stayAsSub": "",
"looking.replay": "",
"looking.teamSP": "",
"looking.teamSP.calculated": "",
@@ -112,6 +114,8 @@
"looking.rankCalculating": "",
"looking.allTiers": "",
"looking.joinQPrompt": "",
"looking.joinQPromptTeam": "",
"looking.joinQPromptMembers": "",
"looking.range.or": "",
"looking.range.or.explanation": "",
"match.header": "",

View File

@@ -8,6 +8,7 @@
"tabs.results": "Ergebnisse",
"tabs.streams": "Streams ({{count}})",
"tabs.subs": "Ersatzspieler ({{count}})",
"tabs.looking": "",
"pre.steps.header": "Folge diesen Schritten, um zu spielen",
"pre.steps.name": "Teamname",
"pre.steps.roster": "Volles Roster",

View File

@@ -220,5 +220,8 @@
"errors.alreadyFriends": "You are already friends with this user",
"errors.friendRequestExists": "A friend request already exists between you and this user",
"labels.type": "Type",
"labels.visibility": "Visibility"
"labels.visibility": "Visibility",
"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

@@ -94,6 +94,7 @@
"looking.groups.actions.removeManager": "Remove manager",
"looking.groups.actions.kick": "Kick",
"looking.groups.actions.leaveGroup": "Leave group",
"looking.groups.actions.stopLooking": "Stop looking",
"looking.groups.actions.leaveQ": "Leave queue",
"looking.groups.actions.goBack": "Go back",
"looking.groups.adder.quickAdd": "Quick add",
@@ -103,6 +104,7 @@
"looking.groups.adder.select": "Select user...",
"looking.groups.editNote": "Edit note",
"looking.groups.addNote": "Add note",
"looking.groups.stayAsSub": "Sub",
"looking.replay": "Replay",
"looking.teamSP": "Team SP",
"looking.teamSP.calculated": "Team SP calculated",
@@ -112,6 +114,8 @@
"looking.rankCalculating": "Less than {{count}} sets played. Rank is still calculating...",
"looking.allTiers": "All tiers",
"looking.joinQPrompt": "Join the queue to find a group",
"looking.joinQPromptTeam": "Look for a team",
"looking.joinQPromptMembers": "Look for more members",
"looking.range.or": "or",
"looking.range.or.explanation": "You will be matched with a group whose average rank is one of these. The exact rank is not shown to avoid behavior where groups only play against tiers lower than themselves.",
"match.header": "Match #{{number}}",

View File

@@ -8,6 +8,7 @@
"tabs.results": "Results",
"tabs.streams": "Streams ({{count}})",
"tabs.subs": "Subs ({{count}})",
"tabs.looking": "LFG",
"pre.steps.header": "Complete these steps to play",
"pre.steps.name": "Team name",
"pre.steps.roster": "Full roster",

View File

@@ -220,5 +220,8 @@
"errors.alreadyFriends": "",
"errors.friendRequestExists": "",
"labels.type": "",
"labels.visibility": ""
"labels.visibility": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -94,6 +94,7 @@
"looking.groups.actions.removeManager": "Quitar mánager",
"looking.groups.actions.kick": "Expulsar",
"looking.groups.actions.leaveGroup": "Dejar grupo",
"looking.groups.actions.stopLooking": "",
"looking.groups.actions.leaveQ": "Dejar fila",
"looking.groups.actions.goBack": "Regresar",
"looking.groups.adder.quickAdd": "Añadir rapido",
@@ -103,6 +104,7 @@
"looking.groups.adder.select": "Seleccionar usuario...",
"looking.groups.editNote": "Editar nota",
"looking.groups.addNote": "Añadir nota",
"looking.groups.stayAsSub": "",
"looking.replay": "Repetir",
"looking.teamSP": "Fuerza Sendou de Equipo",
"looking.teamSP.calculated": "Fuerza Sendou de Equipo calculada",
@@ -112,6 +114,8 @@
"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": "Serás emparejado con un grupo cuyo rango promedio sea uno de estos. El rango exacto no se muestra para evitar que los grupos jueguen únicamente contra niveles inferiores al suyo.",
"match.header": "Partido #{{number}}",

View File

@@ -8,6 +8,7 @@
"tabs.results": "Resultados",
"tabs.streams": "Streams ({{count}})",
"tabs.subs": "Subs ({{count}})",
"tabs.looking": "",
"pre.steps.header": "Completa estos pasos para jugar",
"pre.steps.name": "Nombre de equipo",
"pre.steps.roster": "Equipo lleno",

View File

@@ -220,5 +220,8 @@
"errors.alreadyFriends": "",
"errors.friendRequestExists": "",
"labels.type": "",
"labels.visibility": ""
"labels.visibility": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -94,6 +94,7 @@
"looking.groups.actions.removeManager": "Quitar mánager",
"looking.groups.actions.kick": "Expulsar",
"looking.groups.actions.leaveGroup": "Dejar grupo",
"looking.groups.actions.stopLooking": "",
"looking.groups.actions.leaveQ": "Dejar fila",
"looking.groups.actions.goBack": "Regresar",
"looking.groups.adder.quickAdd": "Añadir rápido",
@@ -103,6 +104,7 @@
"looking.groups.adder.select": "",
"looking.groups.editNote": "Editar nota",
"looking.groups.addNote": "Añadir nota",
"looking.groups.stayAsSub": "",
"looking.replay": "Repetir",
"looking.teamSP": "Fuerza Sendou de Equipo",
"looking.teamSP.calculated": "Fuerza Sendou de Equipo calculada",
@@ -112,6 +114,8 @@
"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": "",
"match.header": "Partido #{{number}}",

View File

@@ -8,6 +8,7 @@
"tabs.results": "",
"tabs.streams": "Streams ({{count}})",
"tabs.subs": "Subs ({{count}})",
"tabs.looking": "",
"pre.steps.header": "Completa estos pasos para jugar",
"pre.steps.name": "Nombre de equipo",
"pre.steps.roster": "Equipo lleno",

View File

@@ -220,5 +220,8 @@
"errors.alreadyFriends": "",
"errors.friendRequestExists": "",
"labels.type": "",
"labels.visibility": ""
"labels.visibility": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -94,6 +94,7 @@
"looking.groups.actions.removeManager": "",
"looking.groups.actions.kick": "",
"looking.groups.actions.leaveGroup": "",
"looking.groups.actions.stopLooking": "",
"looking.groups.actions.leaveQ": "",
"looking.groups.actions.goBack": "",
"looking.groups.adder.quickAdd": "",
@@ -103,6 +104,7 @@
"looking.groups.adder.select": "",
"looking.groups.editNote": "",
"looking.groups.addNote": "",
"looking.groups.stayAsSub": "",
"looking.replay": "",
"looking.teamSP": "",
"looking.teamSP.calculated": "",
@@ -112,6 +114,8 @@
"looking.rankCalculating": "",
"looking.allTiers": "",
"looking.joinQPrompt": "",
"looking.joinQPromptTeam": "",
"looking.joinQPromptMembers": "",
"looking.range.or": "",
"looking.range.or.explanation": "",
"match.header": "",

View File

@@ -8,6 +8,7 @@
"tabs.results": "",
"tabs.streams": "Diffusions ({{count}})",
"tabs.subs": "Remplaçants ({{count}})",
"tabs.looking": "",
"pre.steps.header": "Complétez ces étapes pour jouer",
"pre.steps.name": "Nom de l'équipe",
"pre.steps.roster": "Participants",

View File

@@ -220,5 +220,8 @@
"errors.alreadyFriends": "",
"errors.friendRequestExists": "",
"labels.type": "",
"labels.visibility": ""
"labels.visibility": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -94,6 +94,7 @@
"looking.groups.actions.removeManager": "Rétrograder",
"looking.groups.actions.kick": "Kick",
"looking.groups.actions.leaveGroup": "Quitter le groupe",
"looking.groups.actions.stopLooking": "",
"looking.groups.actions.leaveQ": "Quitter la queue",
"looking.groups.actions.goBack": "Retourner en arrière",
"looking.groups.adder.quickAdd": "Ajouter rapidement",
@@ -103,6 +104,7 @@
"looking.groups.adder.select": "Selectionner un utilisateur...",
"looking.groups.editNote": "Modifier le note",
"looking.groups.addNote": "Ajouter la note",
"looking.groups.stayAsSub": "",
"looking.replay": "Rejouer",
"looking.teamSP": "Team SP",
"looking.teamSP.calculated": "Team SP calculé",
@@ -112,6 +114,8 @@
"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": "",
"match.header": "Match #{{number}}",

View File

@@ -8,6 +8,7 @@
"tabs.results": "Resultats",
"tabs.streams": "Diffusions ({{count}})",
"tabs.subs": "Remplaçants ({{count}})",
"tabs.looking": "",
"pre.steps.header": "Complétez ces étapes pour jouer",
"pre.steps.name": "Nom de l'équipe",
"pre.steps.roster": "Participants",

View File

@@ -220,5 +220,8 @@
"errors.alreadyFriends": "",
"errors.friendRequestExists": "",
"labels.type": "",
"labels.visibility": ""
"labels.visibility": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -94,6 +94,7 @@
"looking.groups.actions.removeManager": "",
"looking.groups.actions.kick": "",
"looking.groups.actions.leaveGroup": "",
"looking.groups.actions.stopLooking": "",
"looking.groups.actions.leaveQ": "",
"looking.groups.actions.goBack": "",
"looking.groups.adder.quickAdd": "",
@@ -103,6 +104,7 @@
"looking.groups.adder.select": "",
"looking.groups.editNote": "",
"looking.groups.addNote": "",
"looking.groups.stayAsSub": "",
"looking.replay": "",
"looking.teamSP": "",
"looking.teamSP.calculated": "",
@@ -112,6 +114,8 @@
"looking.rankCalculating": "",
"looking.allTiers": "",
"looking.joinQPrompt": "",
"looking.joinQPromptTeam": "",
"looking.joinQPromptMembers": "",
"looking.range.or": "",
"looking.range.or.explanation": "",
"match.header": "",

View File

@@ -8,6 +8,7 @@
"tabs.results": "",
"tabs.streams": "שידורים חיים ({{count}})",
"tabs.subs": "מחליפים ({{count}})",
"tabs.looking": "",
"pre.steps.header": "השלימו את השלבים הבאים כדי לשחק",
"pre.steps.name": "שם קבוצה",
"pre.steps.roster": "צוות מלא",

View File

@@ -220,5 +220,8 @@
"errors.alreadyFriends": "",
"errors.friendRequestExists": "",
"labels.type": "",
"labels.visibility": ""
"labels.visibility": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -94,6 +94,7 @@
"looking.groups.actions.removeManager": "Rimuovi manager",
"looking.groups.actions.kick": "Caccia",
"looking.groups.actions.leaveGroup": "Lascia gruppo",
"looking.groups.actions.stopLooking": "",
"looking.groups.actions.leaveQ": "Lascia coda",
"looking.groups.actions.goBack": "Indietro",
"looking.groups.adder.quickAdd": "Aggiungi direttamente",
@@ -103,6 +104,7 @@
"looking.groups.adder.select": "Seleziona utente...",
"looking.groups.editNote": "Modifica nota",
"looking.groups.addNote": "Aggiungi nota",
"looking.groups.stayAsSub": "",
"looking.replay": "Replay",
"looking.teamSP": "SP del team",
"looking.teamSP.calculated": "SP del team calcolato",
@@ -112,6 +114,8 @@
"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": "",
"match.header": "Match #{{number}}",

View File

@@ -8,6 +8,7 @@
"tabs.results": "Resultati",
"tabs.streams": "Stream ({{count}})",
"tabs.subs": "Sub ({{count}})",
"tabs.looking": "",
"pre.steps.header": "Completa questi passaggi per giocare",
"pre.steps.name": "Nome team",
"pre.steps.roster": "Roster completo",

View File

@@ -220,5 +220,8 @@
"errors.alreadyFriends": "",
"errors.friendRequestExists": "",
"labels.type": "",
"labels.visibility": ""
"labels.visibility": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -94,6 +94,7 @@
"looking.groups.actions.removeManager": "マネージャーを外す",
"looking.groups.actions.kick": "キックする",
"looking.groups.actions.leaveGroup": "グループを出る",
"looking.groups.actions.stopLooking": "",
"looking.groups.actions.leaveQ": "列から出る",
"looking.groups.actions.goBack": "戻る",
"looking.groups.adder.quickAdd": "クイック追加",
@@ -103,6 +104,7 @@
"looking.groups.adder.select": "",
"looking.groups.editNote": "メモを編集する",
"looking.groups.addNote": "メモを追加する",
"looking.groups.stayAsSub": "",
"looking.replay": "リプレイ",
"looking.teamSP": "チーム SP",
"looking.teamSP.calculated": "チーム SP を計算しました。",
@@ -112,6 +114,8 @@
"looking.rankCalculating": "遊んだセットが{{count}}個以下です。ランクはまだ計算中です...",
"looking.allTiers": "全てのティア",
"looking.joinQPrompt": "グループを見つけるには列に入ってください",
"looking.joinQPromptTeam": "",
"looking.joinQPromptMembers": "",
"looking.range.or": "か",
"looking.range.or.explanation": "",
"match.header": "マッチ #{{number}}番",

View File

@@ -8,6 +8,7 @@
"tabs.results": "結果",
"tabs.streams": "配信 ({{count}})",
"tabs.subs": "サブ ({{count}})",
"tabs.looking": "",
"pre.steps.header": "トーナメント参加のために下記を入力してください",
"pre.steps.name": "チーム名",
"pre.steps.roster": "全プレイヤー",

View File

@@ -220,5 +220,8 @@
"errors.alreadyFriends": "",
"errors.friendRequestExists": "",
"labels.type": "",
"labels.visibility": ""
"labels.visibility": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -94,6 +94,7 @@
"looking.groups.actions.removeManager": "",
"looking.groups.actions.kick": "",
"looking.groups.actions.leaveGroup": "",
"looking.groups.actions.stopLooking": "",
"looking.groups.actions.leaveQ": "",
"looking.groups.actions.goBack": "",
"looking.groups.adder.quickAdd": "",
@@ -103,6 +104,7 @@
"looking.groups.adder.select": "",
"looking.groups.editNote": "",
"looking.groups.addNote": "",
"looking.groups.stayAsSub": "",
"looking.replay": "",
"looking.teamSP": "",
"looking.teamSP.calculated": "",
@@ -112,6 +114,8 @@
"looking.rankCalculating": "",
"looking.allTiers": "",
"looking.joinQPrompt": "",
"looking.joinQPromptTeam": "",
"looking.joinQPromptMembers": "",
"looking.range.or": "",
"looking.range.or.explanation": "",
"match.header": "",

View File

@@ -8,6 +8,7 @@
"tabs.results": "",
"tabs.streams": "",
"tabs.subs": "",
"tabs.looking": "",
"pre.steps.header": "",
"pre.steps.name": "",
"pre.steps.roster": "",

View File

@@ -220,5 +220,8 @@
"errors.alreadyFriends": "",
"errors.friendRequestExists": "",
"labels.type": "",
"labels.visibility": ""
"labels.visibility": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -94,6 +94,7 @@
"looking.groups.actions.removeManager": "",
"looking.groups.actions.kick": "",
"looking.groups.actions.leaveGroup": "",
"looking.groups.actions.stopLooking": "",
"looking.groups.actions.leaveQ": "",
"looking.groups.actions.goBack": "",
"looking.groups.adder.quickAdd": "",
@@ -103,6 +104,7 @@
"looking.groups.adder.select": "",
"looking.groups.editNote": "",
"looking.groups.addNote": "",
"looking.groups.stayAsSub": "",
"looking.replay": "",
"looking.teamSP": "",
"looking.teamSP.calculated": "",
@@ -112,6 +114,8 @@
"looking.rankCalculating": "",
"looking.allTiers": "",
"looking.joinQPrompt": "",
"looking.joinQPromptTeam": "",
"looking.joinQPromptMembers": "",
"looking.range.or": "",
"looking.range.or.explanation": "",
"match.header": "",

View File

@@ -8,6 +8,7 @@
"tabs.results": "",
"tabs.streams": "",
"tabs.subs": "",
"tabs.looking": "",
"pre.steps.header": "",
"pre.steps.name": "",
"pre.steps.roster": "",

View File

@@ -220,5 +220,8 @@
"errors.alreadyFriends": "",
"errors.friendRequestExists": "",
"labels.type": "",
"labels.visibility": ""
"labels.visibility": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -94,6 +94,7 @@
"looking.groups.actions.removeManager": "",
"looking.groups.actions.kick": "",
"looking.groups.actions.leaveGroup": "",
"looking.groups.actions.stopLooking": "",
"looking.groups.actions.leaveQ": "",
"looking.groups.actions.goBack": "",
"looking.groups.adder.quickAdd": "",
@@ -103,6 +104,7 @@
"looking.groups.adder.select": "",
"looking.groups.editNote": "",
"looking.groups.addNote": "",
"looking.groups.stayAsSub": "",
"looking.replay": "",
"looking.teamSP": "",
"looking.teamSP.calculated": "",
@@ -112,6 +114,8 @@
"looking.rankCalculating": "",
"looking.allTiers": "",
"looking.joinQPrompt": "",
"looking.joinQPromptTeam": "",
"looking.joinQPromptMembers": "",
"looking.range.or": "",
"looking.range.or.explanation": "",
"match.header": "",

View File

@@ -8,6 +8,7 @@
"tabs.results": "",
"tabs.streams": "",
"tabs.subs": "",
"tabs.looking": "",
"pre.steps.header": "",
"pre.steps.name": "",
"pre.steps.roster": "",

View File

@@ -220,5 +220,8 @@
"errors.alreadyFriends": "",
"errors.friendRequestExists": "",
"labels.type": "",
"labels.visibility": ""
"labels.visibility": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -94,6 +94,7 @@
"looking.groups.actions.removeManager": "Remover gerência",
"looking.groups.actions.kick": "Chutar (kick)",
"looking.groups.actions.leaveGroup": "Sair do grupo",
"looking.groups.actions.stopLooking": "",
"looking.groups.actions.leaveQ": "Sair da fila",
"looking.groups.actions.goBack": "Voltar",
"looking.groups.adder.quickAdd": "Adicionar rapidamente (quick add)",
@@ -103,6 +104,7 @@
"looking.groups.adder.select": "",
"looking.groups.editNote": "Editar nota",
"looking.groups.addNote": "Adicionar nota",
"looking.groups.stayAsSub": "",
"looking.replay": "Replay",
"looking.teamSP": "SP do time",
"looking.teamSP.calculated": "SP do time calculado",
@@ -112,6 +114,8 @@
"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": "",
"match.header": "Partida #{{number}}",

View File

@@ -8,6 +8,7 @@
"tabs.results": "",
"tabs.streams": "Transmissões ({{count}})",
"tabs.subs": "Inscritos ({{count}})",
"tabs.looking": "",
"pre.steps.header": "Complete esses passos para jogar",
"pre.steps.name": "Nome do time",
"pre.steps.roster": "Lista de membros completa",

View File

@@ -220,5 +220,8 @@
"errors.alreadyFriends": "",
"errors.friendRequestExists": "",
"labels.type": "",
"labels.visibility": ""
"labels.visibility": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -94,6 +94,7 @@
"looking.groups.actions.removeManager": "Удалить роль менеджера",
"looking.groups.actions.kick": "Выгнать",
"looking.groups.actions.leaveGroup": "Покинуть группу",
"looking.groups.actions.stopLooking": "",
"looking.groups.actions.leaveQ": "Покинуть очередь",
"looking.groups.actions.goBack": "Вернуться назад",
"looking.groups.adder.quickAdd": "Быстро добавить",
@@ -103,6 +104,7 @@
"looking.groups.adder.select": "Выбрать пользователя...",
"looking.groups.editNote": "Изменить заметку",
"looking.groups.addNote": "Добавить заметку",
"looking.groups.stayAsSub": "",
"looking.replay": "Повтор",
"looking.teamSP": "Командое SP",
"looking.teamSP.calculated": "Командое SP рассчитано",
@@ -112,6 +114,8 @@
"looking.rankCalculating": "Менее чем {{count}} матчей сыграно. Ранг ещё рассчитывается...",
"looking.allTiers": "Все ранги",
"looking.joinQPrompt": "Присоединитесь к очереди, чтобы найти группу",
"looking.joinQPromptTeam": "",
"looking.joinQPromptMembers": "",
"looking.range.or": "или",
"looking.range.or.explanation": "",
"match.header": "Матч #{{number}}",

View File

@@ -8,6 +8,7 @@
"tabs.results": "Результаты",
"tabs.streams": "Трансляции: ({{count}})",
"tabs.subs": "Запасные: ({{count}})",
"tabs.looking": "",
"pre.steps.header": "Завершите следующие шаги, чтобы закончить регистрацию",
"pre.steps.name": "Имя команды",
"pre.steps.roster": "Полный состав",

View File

@@ -220,5 +220,8 @@
"errors.alreadyFriends": "",
"errors.friendRequestExists": "",
"labels.type": "",
"labels.visibility": ""
"labels.visibility": "",
"labels.note": "",
"labels.stayAsSub": "",
"bottomTexts.stayAsSub": ""
}

View File

@@ -94,6 +94,7 @@
"looking.groups.actions.removeManager": "移除管理者权限",
"looking.groups.actions.kick": "踢出",
"looking.groups.actions.leaveGroup": "离开小队",
"looking.groups.actions.stopLooking": "",
"looking.groups.actions.leaveQ": "停止匹配",
"looking.groups.actions.goBack": "返回",
"looking.groups.adder.quickAdd": "快速添加",
@@ -103,6 +104,7 @@
"looking.groups.adder.select": "",
"looking.groups.editNote": "编辑笔记",
"looking.groups.addNote": "添加笔记",
"looking.groups.stayAsSub": "",
"looking.replay": "重赛",
"looking.teamSP": "小队SP",
"looking.teamSP.calculated": "小队SP已定分",
@@ -112,6 +114,8 @@
"looking.rankCalculating": "对战数少于 {{count}} 场。段位定分中...",
"looking.allTiers": "所有段位",
"looking.joinQPrompt": "参加匹配以寻找小队",
"looking.joinQPromptTeam": "",
"looking.joinQPromptMembers": "",
"looking.range.or": "或",
"looking.range.or.explanation": "",
"match.header": "对战 #{{number}}",

View File

@@ -8,6 +8,7 @@
"tabs.results": "",
"tabs.streams": "直播 ({{count}})",
"tabs.subs": "替补 ({{count}})",
"tabs.looking": "",
"pre.steps.header": "参加比赛需完成以下步骤",
"pre.steps.name": "队伍名称",
"pre.steps.roster": "完整阵容",

View File

@@ -0,0 +1,77 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/*sql*/ `alter table "TournamentTeam" add column "isLooking" integer not null default 0`,
).run();
db.prepare(
/*sql*/ `alter table "TournamentTeam" add column "isPlaceholder" integer not null default 0`,
).run();
db.prepare(
/*sql*/ `alter table "TournamentTeam" add column "lfgVisibility" text`,
).run();
db.prepare(
/*sql*/ `alter table "TournamentTeam" add column "lfgNote" text`,
).run();
db.prepare(
/*sql*/ `alter table "TournamentTeamMember" add column "role" text not null default 'REGULAR'`,
).run();
db.prepare(
/*sql*/ `alter table "TournamentTeamMember" add column "isStayAsSub" integer not null default 0`,
).run();
db.prepare(
/*sql*/ `alter table "TournamentTeamMember" add column "isLooking" integer not null default 0`,
).run();
db.prepare(
/*sql*/ `create index tournament_team_member_user_id_is_looking on "TournamentTeamMember"("userId", "isLooking")`,
).run();
db.prepare(
/*sql*/ `
create trigger sync_is_looking_on_team_update
after update of isLooking on "TournamentTeam"
begin
update "TournamentTeamMember"
set "isLooking" = NEW."isLooking"
where "tournamentTeamId" = NEW."id";
end
`,
).run();
db.prepare(
/*sql*/ `
create trigger sync_is_looking_on_member_insert
after insert on "TournamentTeamMember"
begin
update "TournamentTeamMember"
set "isLooking" = (select "isLooking" from "TournamentTeam" where "id" = NEW."tournamentTeamId")
where rowid = NEW.rowid;
end
`,
).run();
db.prepare(
/*sql*/ `update "TournamentTeamMember" set "role" = 'OWNER' where "isOwner" = 1`,
).run();
db.prepare(
/*sql*/ `
create table "TournamentLFGLike" (
"likerTeamId" integer not null,
"targetTeamId" integer not null,
"createdAt" integer default (strftime('%s', 'now')) not null,
foreign key ("likerTeamId") references "TournamentTeam"("id") on delete cascade,
foreign key ("targetTeamId") references "TournamentTeam"("id") on delete cascade,
unique("likerTeamId", "targetTeamId") on conflict rollback
) strict
`,
).run();
db.prepare(
/*sql*/ `create index tournament_lfg_like_liker_team_id on "TournamentLFGLike"("likerTeamId")`,
).run();
db.prepare(
/*sql*/ `create index tournament_lfg_like_target_team_id on "TournamentLFGLike"("targetTeamId")`,
).run();
})();
}