mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-26 20:43:36 -05:00
Merge branch 'sqified-tournaments' into css-rework-sidenav
This commit is contained in:
329
TOURNAMENT_LFG_PLAN.md
Normal file
329
TOURNAMENT_LFG_PLAN.md
Normal 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
166
TOURNAMENT_LFG_SPEC.md
Normal 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?
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
458
app/features/tournament-lfg/TournamentLFGRepository.server.ts
Normal file
458
app/features/tournament-lfg/TournamentLFGRepository.server.ts
Normal 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;
|
||||
}
|
||||
252
app/features/tournament-lfg/actions/to.$id.looking.server.ts
Normal file
252
app/features/tournament-lfg/actions/to.$id.looking.server.ts
Normal 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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
386
app/features/tournament-lfg/components/LFGGroupCard.tsx
Normal file
386
app/features/tournament-lfg/components/LFGGroupCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
228
app/features/tournament-lfg/loaders/to.$id.looking.server.ts
Normal file
228
app/features/tournament-lfg/loaders/to.$id.looking.server.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
479
app/features/tournament-lfg/routes/to.$id.looking.tsx
Normal file
479
app/features/tournament-lfg/routes/to.$id.looking.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
app/features/tournament-lfg/tournament-lfg-schemas.ts
Normal file
63
app/features/tournament-lfg/tournament-lfg-schemas.ts
Normal 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,
|
||||
}),
|
||||
]);
|
||||
18
app/features/tournament-lfg/tournament-lfg-utils.ts
Normal file
18
app/features/tournament-lfg/tournament-lfg-utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
.selectedWeapon {
|
||||
padding: var(--s-2);
|
||||
border-radius: 100%;
|
||||
background-color: var(--color-bg-high);
|
||||
}
|
||||
|
||||
.weaponPool {
|
||||
width: 20rem;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export const TOURNAMENT_SUB = {
|
||||
WEAPON_POOL_MAX_SIZE: 5,
|
||||
MESSAGE_MAX_LENGTH: 280,
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -149,6 +149,7 @@ export function create({
|
||||
tournamentTeamId: tournamentTeam.id,
|
||||
userId,
|
||||
isOwner: 1,
|
||||
role: "OWNER",
|
||||
inGameName: ownerInGameName,
|
||||
})
|
||||
.execute();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
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
12
app/utils/team-name.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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`;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -220,5 +220,8 @@
|
||||
"errors.alreadyFriends": "",
|
||||
"errors.friendRequestExists": "",
|
||||
"labels.type": "",
|
||||
"labels.visibility": ""
|
||||
"labels.visibility": "",
|
||||
"labels.note": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -220,5 +220,8 @@
|
||||
"errors.alreadyFriends": "",
|
||||
"errors.friendRequestExists": "",
|
||||
"labels.type": "",
|
||||
"labels.visibility": ""
|
||||
"labels.visibility": "",
|
||||
"labels.note": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -220,5 +220,8 @@
|
||||
"errors.alreadyFriends": "",
|
||||
"errors.friendRequestExists": "",
|
||||
"labels.type": "",
|
||||
"labels.visibility": ""
|
||||
"labels.visibility": "",
|
||||
"labels.note": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -220,5 +220,8 @@
|
||||
"errors.alreadyFriends": "",
|
||||
"errors.friendRequestExists": "",
|
||||
"labels.type": "",
|
||||
"labels.visibility": ""
|
||||
"labels.visibility": "",
|
||||
"labels.note": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -220,5 +220,8 @@
|
||||
"errors.alreadyFriends": "",
|
||||
"errors.friendRequestExists": "",
|
||||
"labels.type": "",
|
||||
"labels.visibility": ""
|
||||
"labels.visibility": "",
|
||||
"labels.note": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -220,5 +220,8 @@
|
||||
"errors.alreadyFriends": "",
|
||||
"errors.friendRequestExists": "",
|
||||
"labels.type": "",
|
||||
"labels.visibility": ""
|
||||
"labels.visibility": "",
|
||||
"labels.note": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -220,5 +220,8 @@
|
||||
"errors.alreadyFriends": "",
|
||||
"errors.friendRequestExists": "",
|
||||
"labels.type": "",
|
||||
"labels.visibility": ""
|
||||
"labels.visibility": "",
|
||||
"labels.note": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"tabs.results": "",
|
||||
"tabs.streams": "שידורים חיים ({{count}})",
|
||||
"tabs.subs": "מחליפים ({{count}})",
|
||||
"tabs.looking": "",
|
||||
"pre.steps.header": "השלימו את השלבים הבאים כדי לשחק",
|
||||
"pre.steps.name": "שם קבוצה",
|
||||
"pre.steps.roster": "צוות מלא",
|
||||
|
||||
@@ -220,5 +220,8 @@
|
||||
"errors.alreadyFriends": "",
|
||||
"errors.friendRequestExists": "",
|
||||
"labels.type": "",
|
||||
"labels.visibility": ""
|
||||
"labels.visibility": "",
|
||||
"labels.note": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -220,5 +220,8 @@
|
||||
"errors.alreadyFriends": "",
|
||||
"errors.friendRequestExists": "",
|
||||
"labels.type": "",
|
||||
"labels.visibility": ""
|
||||
"labels.visibility": "",
|
||||
"labels.note": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -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}}番",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"tabs.results": "結果",
|
||||
"tabs.streams": "配信 ({{count}})",
|
||||
"tabs.subs": "サブ ({{count}})",
|
||||
"tabs.looking": "",
|
||||
"pre.steps.header": "トーナメント参加のために下記を入力してください",
|
||||
"pre.steps.name": "チーム名",
|
||||
"pre.steps.roster": "全プレイヤー",
|
||||
|
||||
@@ -220,5 +220,8 @@
|
||||
"errors.alreadyFriends": "",
|
||||
"errors.friendRequestExists": "",
|
||||
"labels.type": "",
|
||||
"labels.visibility": ""
|
||||
"labels.visibility": "",
|
||||
"labels.note": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"tabs.results": "",
|
||||
"tabs.streams": "",
|
||||
"tabs.subs": "",
|
||||
"tabs.looking": "",
|
||||
"pre.steps.header": "",
|
||||
"pre.steps.name": "",
|
||||
"pre.steps.roster": "",
|
||||
|
||||
@@ -220,5 +220,8 @@
|
||||
"errors.alreadyFriends": "",
|
||||
"errors.friendRequestExists": "",
|
||||
"labels.type": "",
|
||||
"labels.visibility": ""
|
||||
"labels.visibility": "",
|
||||
"labels.note": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"tabs.results": "",
|
||||
"tabs.streams": "",
|
||||
"tabs.subs": "",
|
||||
"tabs.looking": "",
|
||||
"pre.steps.header": "",
|
||||
"pre.steps.name": "",
|
||||
"pre.steps.roster": "",
|
||||
|
||||
@@ -220,5 +220,8 @@
|
||||
"errors.alreadyFriends": "",
|
||||
"errors.friendRequestExists": "",
|
||||
"labels.type": "",
|
||||
"labels.visibility": ""
|
||||
"labels.visibility": "",
|
||||
"labels.note": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"tabs.results": "",
|
||||
"tabs.streams": "",
|
||||
"tabs.subs": "",
|
||||
"tabs.looking": "",
|
||||
"pre.steps.header": "",
|
||||
"pre.steps.name": "",
|
||||
"pre.steps.roster": "",
|
||||
|
||||
@@ -220,5 +220,8 @@
|
||||
"errors.alreadyFriends": "",
|
||||
"errors.friendRequestExists": "",
|
||||
"labels.type": "",
|
||||
"labels.visibility": ""
|
||||
"labels.visibility": "",
|
||||
"labels.note": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -220,5 +220,8 @@
|
||||
"errors.alreadyFriends": "",
|
||||
"errors.friendRequestExists": "",
|
||||
"labels.type": "",
|
||||
"labels.visibility": ""
|
||||
"labels.visibility": "",
|
||||
"labels.note": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"tabs.results": "Результаты",
|
||||
"tabs.streams": "Трансляции: ({{count}})",
|
||||
"tabs.subs": "Запасные: ({{count}})",
|
||||
"tabs.looking": "",
|
||||
"pre.steps.header": "Завершите следующие шаги, чтобы закончить регистрацию",
|
||||
"pre.steps.name": "Имя команды",
|
||||
"pre.steps.roster": "Полный состав",
|
||||
|
||||
@@ -220,5 +220,8 @@
|
||||
"errors.alreadyFriends": "",
|
||||
"errors.friendRequestExists": "",
|
||||
"labels.type": "",
|
||||
"labels.visibility": ""
|
||||
"labels.visibility": "",
|
||||
"labels.note": "",
|
||||
"labels.stayAsSub": "",
|
||||
"bottomTexts.stayAsSub": ""
|
||||
}
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"tabs.results": "",
|
||||
"tabs.streams": "直播 ({{count}})",
|
||||
"tabs.subs": "替补 ({{count}})",
|
||||
"tabs.looking": "",
|
||||
"pre.steps.header": "参加比赛需完成以下步骤",
|
||||
"pre.steps.name": "队伍名称",
|
||||
"pre.steps.roster": "完整阵容",
|
||||
|
||||
77
migrations/119-tournament-lfg.js
Normal file
77
migrations/119-tournament-lfg.js
Normal 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();
|
||||
})();
|
||||
}
|
||||
Reference in New Issue
Block a user