mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Design refresh + a bunch of stuff (#2864)
Co-authored-by: hfcRed <hfcred@gmx.net>
This commit is contained in:
parent
fcc5c5f273
commit
fef1ffc955
|
|
@ -1,6 +0,0 @@
|
||||||
beans:
|
|
||||||
path: .beans
|
|
||||||
prefix: sendou.ink-2-
|
|
||||||
id_length: 4
|
|
||||||
default_status: todo
|
|
||||||
default_type: task
|
|
||||||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -6,7 +6,6 @@ node_modules
|
||||||
.react-router/
|
.react-router/
|
||||||
.env
|
.env
|
||||||
translation-progress.md
|
translation-progress.md
|
||||||
**/*/__screenshots__
|
|
||||||
|
|
||||||
notes.md
|
notes.md
|
||||||
|
|
||||||
|
|
@ -21,11 +20,17 @@ dump
|
||||||
/scripts/output/*
|
/scripts/output/*
|
||||||
!/scripts/output/.gitkeep
|
!/scripts/output/.gitkeep
|
||||||
|
|
||||||
/scripts/dicts/**/*.json
|
/scripts/dicts/**/*
|
||||||
/scripts/badge
|
/scripts/badge
|
||||||
/test-results/
|
/test-results/
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
|
/.vitest-attachments
|
||||||
|
.vitest-attachments/
|
||||||
|
|
||||||
|
# Vitest auto-captured failure screenshots (numbered, without browser info)
|
||||||
|
# Real baselines have pattern: *-chromium-darwin.png
|
||||||
|
**/__screenshots__/**/*-[0-9].png
|
||||||
.e2e-minio-started
|
.e2e-minio-started
|
||||||
|
|
||||||
notepad.txt
|
notepad.txt
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,8 @@
|
||||||
- use CSS modules
|
- use CSS modules
|
||||||
- one file containing React code should have a matching CSS module file e.g. `Component.tsx` should have a file with the same root name i.e. `Component.module.css`
|
- one file containing React code should have a matching CSS module file e.g. `Component.tsx` should have a file with the same root name i.e. `Component.module.css`
|
||||||
- clsx library is used for conditional class names
|
- clsx library is used for conditional class names
|
||||||
- prefer using [CSS variables](../app/styles/vars.css) for theming
|
- prefer using [CSS variables](./app/styles/vars.css) for theming
|
||||||
|
- for simple styling, prefer [utility classes](./app/styles/utils.css) over creating a new class
|
||||||
|
|
||||||
## SQL
|
## SQL
|
||||||
|
|
||||||
|
|
|
||||||
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?
|
||||||
|
|
@ -3,13 +3,13 @@ import { initReactI18next } from "react-i18next";
|
||||||
import { config } from "~/modules/i18n/config";
|
import { config } from "~/modules/i18n/config";
|
||||||
import { resources } from "~/modules/i18n/resources.browser";
|
import { resources } from "~/modules/i18n/resources.browser";
|
||||||
|
|
||||||
import "~/styles/common.css";
|
|
||||||
import "~/styles/elements.css";
|
|
||||||
import "~/styles/flags.css";
|
|
||||||
import "~/styles/layout.css";
|
|
||||||
import "~/styles/reset.css";
|
|
||||||
import "~/styles/utils.css";
|
|
||||||
import "~/styles/vars.css";
|
import "~/styles/vars.css";
|
||||||
|
import "~/styles/normalize.css";
|
||||||
|
import "~/styles/common.css";
|
||||||
|
import "~/styles/utils.css";
|
||||||
|
import "~/styles/flags.css";
|
||||||
|
|
||||||
|
document.documentElement.classList.add("dark");
|
||||||
|
|
||||||
i18next.use(initReactI18next).init({
|
i18next.use(initReactI18next).init({
|
||||||
...config,
|
...config,
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,9 @@
|
||||||
|
|
||||||
.abilityButton {
|
.abilityButton {
|
||||||
padding: var(--s-0-5);
|
padding: var(--s-0-5);
|
||||||
border-color: var(--abilities-button-bg);
|
border: var(--border-style);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: var(--abilities-button-bg);
|
background-color: var(--color-bg-ability);
|
||||||
}
|
}
|
||||||
|
|
||||||
.abilityButton.isDragging {
|
.abilityButton.isDragging {
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,19 @@
|
||||||
width: var(--ability-size);
|
width: var(--ability-size);
|
||||||
height: var(--ability-size);
|
height: var(--ability-size);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 2px solid var(--theme-transparent);
|
border: var(--border-style-high);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
background: var(--bg-ability);
|
background: var(--color-bg-ability);
|
||||||
background-size: 100%;
|
background-size: 100%;
|
||||||
box-shadow: 0 0 0 1px var(--bg-ability);
|
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
transition: all 0.1s ease;
|
transition: all 0.1s ease;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.isDragTarget {
|
.isDragTarget {
|
||||||
background: var(--abilities-button-bg);
|
background: var(--color-bg-ability);
|
||||||
transform: scale(1.15);
|
transform: scale(1.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
.addNewButton {
|
|
||||||
--picture-size: 18px;
|
|
||||||
--icon-size: 14px;
|
|
||||||
--button-height: 28px;
|
|
||||||
--border-width: 2px;
|
|
||||||
--inner-border-radius: 6px;
|
|
||||||
|
|
||||||
padding-block: 0 !important;
|
|
||||||
padding-inline: 0 !important;
|
|
||||||
background-color: transparent !important;
|
|
||||||
height: var(--button-height);
|
|
||||||
}
|
|
||||||
|
|
||||||
.iconsContainer {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--s-0-5);
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background-color: var(--theme-very-transparent);
|
|
||||||
height: calc(var(--button-height) - var(--border-width) * 2);
|
|
||||||
border-radius: var(--inner-border-radius) 0 0 var(--inner-border-radius);
|
|
||||||
padding-inline: var(--s-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.iconsContainer > svg {
|
|
||||||
max-width: var(--icon-size);
|
|
||||||
max-height: var(--icon-size);
|
|
||||||
min-width: var(--icon-size);
|
|
||||||
min-height: var(--icon-size);
|
|
||||||
color: var(--theme);
|
|
||||||
stroke-width: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.iconsContainer > picture {
|
|
||||||
max-width: var(--picture-size);
|
|
||||||
max-height: var(--picture-size);
|
|
||||||
min-width: var(--picture-size);
|
|
||||||
min-height: var(--picture-size);
|
|
||||||
}
|
|
||||||
|
|
||||||
.textContainer {
|
|
||||||
padding-inline: var(--s-1-5);
|
|
||||||
background-color: var(--theme) !important;
|
|
||||||
height: calc(var(--button-height) - var(--border-width) * 2);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
border-radius: 0 var(--inner-border-radius) var(--inner-border-radius) 0;
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import { LinkButton } from "~/components/elements/Button";
|
|
||||||
import { Image } from "~/components/Image";
|
|
||||||
import { PlusIcon } from "~/components/icons/Plus";
|
|
||||||
import { navIconUrl } from "~/utils/urls";
|
|
||||||
|
|
||||||
import styles from "./AddNewButton.module.css";
|
|
||||||
|
|
||||||
interface AddNewButtonProps {
|
|
||||||
to: string;
|
|
||||||
navIcon: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AddNewButton({ to, navIcon }: AddNewButtonProps) {
|
|
||||||
return (
|
|
||||||
<LinkButton to={to} size="small" className={styles.addNewButton}>
|
|
||||||
<span className={styles.iconsContainer}>
|
|
||||||
<PlusIcon />
|
|
||||||
<Image path={navIconUrl(navIcon)} size={18} alt="" />
|
|
||||||
</span>
|
|
||||||
<span className={styles.textContainer}>New</span>
|
|
||||||
</LinkButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
43
app/components/Alert.module.css
Normal file
43
app/components/Alert.module.css
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
.alert {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-box);
|
||||||
|
background-color: var(--color-info-low);
|
||||||
|
color: var(--color-info-high);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: var(--weight-semi);
|
||||||
|
gap: var(--s-2);
|
||||||
|
margin-inline: auto;
|
||||||
|
padding-block: var(--s-1-5);
|
||||||
|
padding-inline: var(--s-3) var(--s-4);
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
& > svg {
|
||||||
|
height: 1.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiny {
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
|
||||||
|
& > svg {
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
background-color: var(--color-warning-low);
|
||||||
|
color: var(--color-warning-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background-color: var(--color-error-low);
|
||||||
|
color: var(--color-error-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
background-color: var(--color-success-low);
|
||||||
|
color: var(--color-success-high);
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { Check, CircleAlert, OctagonAlert, TriangleAlert } from "lucide-react";
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
import { assertUnreachable } from "~/utils/types";
|
import { assertUnreachable } from "~/utils/types";
|
||||||
import { AlertIcon } from "./icons/Alert";
|
import styles from "./Alert.module.css";
|
||||||
import { CheckmarkIcon } from "./icons/Checkmark";
|
|
||||||
import { ErrorIcon } from "./icons/Error";
|
|
||||||
|
|
||||||
export type AlertVariation = "INFO" | "WARNING" | "ERROR" | "SUCCESS";
|
export type AlertVariation = "INFO" | "WARNING" | "ERROR" | "SUCCESS";
|
||||||
|
|
||||||
|
|
@ -22,11 +21,11 @@ export function Alert({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx("alert", alertClassName, {
|
className={clsx(styles.alert, alertClassName, {
|
||||||
tiny,
|
[styles.tiny]: tiny,
|
||||||
warning: variation === "WARNING",
|
[styles.warning]: variation === "WARNING",
|
||||||
error: variation === "ERROR",
|
[styles.error]: variation === "ERROR",
|
||||||
success: variation === "SUCCESS",
|
[styles.success]: variation === "SUCCESS",
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Icon variation={variation} />{" "}
|
<Icon variation={variation} />{" "}
|
||||||
|
|
@ -38,13 +37,13 @@ export function Alert({
|
||||||
function Icon({ variation }: { variation: AlertVariation }) {
|
function Icon({ variation }: { variation: AlertVariation }) {
|
||||||
switch (variation) {
|
switch (variation) {
|
||||||
case "INFO":
|
case "INFO":
|
||||||
return <AlertIcon />;
|
return <CircleAlert />;
|
||||||
case "WARNING":
|
case "WARNING":
|
||||||
return <AlertIcon />;
|
return <TriangleAlert />;
|
||||||
case "ERROR":
|
case "ERROR":
|
||||||
return <ErrorIcon />;
|
return <OctagonAlert />;
|
||||||
case "SUCCESS":
|
case "SUCCESS":
|
||||||
return <CheckmarkIcon />;
|
return <Check />;
|
||||||
default:
|
default:
|
||||||
assertUnreachable(variation);
|
assertUnreachable(variation);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
app/components/Avatar.module.css
Normal file
21
app/components/Avatar.module.css
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
.identicon {
|
||||||
|
filter: blur(99px);
|
||||||
|
transition: filter 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loaded {
|
||||||
|
filter: blur(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarWrapper {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: fit-content;
|
||||||
|
height: fit-content;
|
||||||
|
background-color: var(--color-bg-higher);
|
||||||
|
border-radius: var(--radius-avatar);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarWrapper img {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import type { Tables } from "~/db/tables";
|
import type { Tables } from "~/db/tables";
|
||||||
|
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||||
import { BLANK_IMAGE_URL, discordAvatarUrl } from "~/utils/urls";
|
import { BLANK_IMAGE_URL, discordAvatarUrl } from "~/utils/urls";
|
||||||
|
import styles from "./Avatar.module.css";
|
||||||
|
|
||||||
const dimensions = {
|
const dimensions = {
|
||||||
xxxs: 16,
|
xxxs: 16,
|
||||||
xxxsm: 20,
|
xxxsm: 20,
|
||||||
xxs: 24,
|
xxs: 24,
|
||||||
|
xxsm: 32,
|
||||||
xs: 36,
|
xs: 36,
|
||||||
sm: 44,
|
sm: 44,
|
||||||
xsm: 62,
|
xsm: 62,
|
||||||
|
|
@ -15,49 +18,141 @@ const dimensions = {
|
||||||
lg: 125,
|
lg: 125,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const identiconCache = new Map<string, string>();
|
||||||
|
|
||||||
|
function hashString(str: string) {
|
||||||
|
let hash = 5381;
|
||||||
|
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateColors(hash: number) {
|
||||||
|
const hue = hash % 360;
|
||||||
|
const saturation = 65 + ((hash >>> 8) % 20);
|
||||||
|
const lightness = 50 + ((hash >>> 16) % 20);
|
||||||
|
|
||||||
|
return {
|
||||||
|
background: `hsl(${hue}, ${saturation - 50}%, ${lightness - 40}%)`,
|
||||||
|
foreground: `hsl(${hue}, ${saturation}%, ${lightness}%)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateIdenticon(input: string, size = 128, gridSize = 5) {
|
||||||
|
const cacheKey = `${input}-${size}-${gridSize}`;
|
||||||
|
const cached = identiconCache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = size * dpr;
|
||||||
|
canvas.height = size * dpr;
|
||||||
|
canvas.style.width = `${size}px`;
|
||||||
|
canvas.style.height = `${size}px`;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
|
||||||
|
const insetRatio = 1 / Math.sqrt(2);
|
||||||
|
const cellSize = Math.floor((size * insetRatio) / gridSize);
|
||||||
|
const actualSize = cellSize * gridSize;
|
||||||
|
const offset = Math.floor((size - actualSize) / 2);
|
||||||
|
const halfGrid = Math.ceil(gridSize / 2);
|
||||||
|
|
||||||
|
const patternHash = hashString(input);
|
||||||
|
const colorHash = hashString(input.split("").reverse().join(""));
|
||||||
|
|
||||||
|
const colors = generateColors(colorHash);
|
||||||
|
ctx.fillStyle = colors.background;
|
||||||
|
ctx.fillRect(0, 0, size, size);
|
||||||
|
ctx.fillStyle = colors.foreground;
|
||||||
|
|
||||||
|
const path = new Path2D();
|
||||||
|
|
||||||
|
for (let row = 0; row < gridSize; row++) {
|
||||||
|
for (let col = 0; col < halfGrid; col++) {
|
||||||
|
const bitIndex = row * halfGrid + col;
|
||||||
|
const shouldFill = (patternHash >>> bitIndex) & 1;
|
||||||
|
|
||||||
|
if (shouldFill) {
|
||||||
|
const x = offset + col * cellSize;
|
||||||
|
const y = offset + row * cellSize;
|
||||||
|
|
||||||
|
path.rect(x, y, cellSize, cellSize);
|
||||||
|
|
||||||
|
const mirrorCol = gridSize - 1 - col;
|
||||||
|
if (col !== mirrorCol) {
|
||||||
|
const mirrorX = offset + mirrorCol * cellSize;
|
||||||
|
path.rect(mirrorX, y, cellSize, cellSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fill(path);
|
||||||
|
|
||||||
|
const dataUrl = canvas.toDataURL();
|
||||||
|
identiconCache.set(cacheKey, dataUrl);
|
||||||
|
return dataUrl;
|
||||||
|
}
|
||||||
|
|
||||||
export function Avatar({
|
export function Avatar({
|
||||||
user,
|
user,
|
||||||
url,
|
url,
|
||||||
|
identiconInput,
|
||||||
size = "sm",
|
size = "sm",
|
||||||
className,
|
className,
|
||||||
alt = "",
|
alt = "",
|
||||||
...rest
|
...rest
|
||||||
}: {
|
}: {
|
||||||
user?: Pick<Tables["User"], "discordId" | "discordAvatar">;
|
user?: Pick<Tables["User"], "discordId" | "discordAvatar">;
|
||||||
url?: string;
|
url?: string | null;
|
||||||
|
identiconInput?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
size: keyof typeof dimensions;
|
size: keyof typeof dimensions;
|
||||||
} & React.ButtonHTMLAttributes<HTMLImageElement>) {
|
} & React.ButtonHTMLAttributes<HTMLImageElement>) {
|
||||||
const [isErrored, setIsErrored] = React.useState(false);
|
const [isErrored, setIsErrored] = React.useState(false);
|
||||||
// TODO: just show text... my profile?
|
const [loaded, setLoaded] = React.useState(false);
|
||||||
// TODO: also show this if discordAvatar is stale and 404's
|
const isClient = useIsMounted();
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: every avatar error state is unique and we want to avoid using key on every avatar
|
const isIdenticon =
|
||||||
React.useEffect(() => {
|
!url && (!user?.discordAvatar || isErrored || identiconInput);
|
||||||
setIsErrored(false);
|
|
||||||
}, [user?.discordAvatar]);
|
|
||||||
|
|
||||||
const src =
|
const identiconSource = identiconInput ?? user?.discordId ?? "unknown";
|
||||||
url ??
|
|
||||||
(user?.discordAvatar && !isErrored
|
const src = url
|
||||||
|
? url
|
||||||
|
: user?.discordAvatar && !isErrored
|
||||||
? discordAvatarUrl({
|
? discordAvatarUrl({
|
||||||
discordAvatar: user.discordAvatar,
|
discordAvatar: user.discordAvatar,
|
||||||
discordId: user.discordId,
|
discordId: user.discordId,
|
||||||
size: size === "lg" || size === "xmd" ? "lg" : "sm",
|
size: size === "lg" || size === "xmd" ? "lg" : "sm",
|
||||||
})
|
})
|
||||||
: BLANK_IMAGE_URL); // avoid broken image placeholder
|
: isClient
|
||||||
|
? generateIdenticon(identiconSource, dimensions[size], 7)
|
||||||
|
: BLANK_IMAGE_URL;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<div className={clsx(styles.avatarWrapper, className)}>
|
||||||
className={clsx("avatar", className)}
|
<img
|
||||||
src={src}
|
className={clsx({
|
||||||
alt={alt}
|
[styles.identicon]: isIdenticon,
|
||||||
title={alt ? alt : undefined}
|
[styles.loaded]: loaded,
|
||||||
width={dimensions[size]}
|
})}
|
||||||
height={dimensions[size]}
|
src={src}
|
||||||
onError={() => setIsErrored(true)}
|
alt={alt}
|
||||||
{...rest}
|
title={alt ? alt : undefined}
|
||||||
/>
|
width={dimensions[size]}
|
||||||
|
height={dimensions[size]}
|
||||||
|
onError={() => setIsErrored(true)}
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,34 +3,28 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: var(--s-3);
|
padding: var(--s-3);
|
||||||
border-radius: var(--rounded);
|
border-radius: var(--radius-box);
|
||||||
background-color: var(--bg-lighter);
|
background-color: var(--color-bg-high);
|
||||||
gap: var(--s-3);
|
gap: var(--s-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.private {
|
.private {
|
||||||
background-color: var(--bg-lighter-transparent);
|
background-color: var(--color-bg-higher);
|
||||||
}
|
}
|
||||||
|
|
||||||
.privateText {
|
.privateText {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-weight: var(--semi-bold);
|
font-weight: var(--weight-semi);
|
||||||
gap: var(--s-1);
|
gap: var(--s-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.privateIcon {
|
|
||||||
width: 16px;
|
|
||||||
margin-block-end: var(--s-1);
|
|
||||||
stroke-width: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
font-size: var(--fonts-sm);
|
font-size: var(--font-sm);
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
word-wrap: break-all;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topRow {
|
.topRow {
|
||||||
|
|
@ -40,7 +34,7 @@
|
||||||
|
|
||||||
.dateAuthorRow {
|
.dateAuthorRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
font-size: var(--fonts-xxs);
|
font-size: var(--font-2xs);
|
||||||
gap: var(--s-1);
|
gap: var(--s-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,7 +57,12 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: var(--s-0-5);
|
padding: var(--s-0-5);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: var(--bg-darker-very-transparent);
|
background-color: var(--color-bg);
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
outline: var(--focus-ring);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.top500 {
|
.top500 {
|
||||||
|
|
@ -74,9 +73,9 @@
|
||||||
|
|
||||||
.weaponText {
|
.weaponText {
|
||||||
padding-left: var(--s-1);
|
padding-left: var(--s-1);
|
||||||
color: var(--text-lighter);
|
color: var(--color-text-high);
|
||||||
font-size: var(--fonts-xxs);
|
font-size: var(--font-2xs);
|
||||||
font-weight: var(--semi-bold);
|
font-weight: var(--weight-semi);
|
||||||
}
|
}
|
||||||
|
|
||||||
.weapons {
|
.weapons {
|
||||||
|
|
@ -104,7 +103,7 @@
|
||||||
|
|
||||||
.gear {
|
.gear {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: var(--bg-darker-very-transparent);
|
background-color: var(--color-bg);
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,14 +112,9 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: var(--s-4);
|
gap: var(--s-2);
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
width: 1.2rem;
|
|
||||||
height: 1.2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.smallText {
|
.smallText {
|
||||||
font-size: var(--fonts-xxs) !important;
|
font-size: var(--font-2xs) !important;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { Lock, MessageCircleMore, SquarePen, Trash } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import type { GearType, Tables, UserWithPlusTier } from "~/db/tables";
|
import type { GearType, Tables, UserWithPlusTier } from "~/db/tables";
|
||||||
|
|
@ -30,10 +31,6 @@ import { LinkButton, SendouButton } from "./elements/Button";
|
||||||
import { SendouPopover } from "./elements/Popover";
|
import { SendouPopover } from "./elements/Popover";
|
||||||
import { FormWithConfirm } from "./FormWithConfirm";
|
import { FormWithConfirm } from "./FormWithConfirm";
|
||||||
import { Image } from "./Image";
|
import { Image } from "./Image";
|
||||||
import { EditIcon } from "./icons/Edit";
|
|
||||||
import { LockIcon } from "./icons/Lock";
|
|
||||||
import { SpeechBubbleIcon } from "./icons/SpeechBubble";
|
|
||||||
import { TrashIcon } from "./icons/Trash";
|
|
||||||
|
|
||||||
interface BuildProps {
|
interface BuildProps {
|
||||||
build: Pick<
|
build: Pick<
|
||||||
|
|
@ -119,11 +116,10 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
|
||||||
<div>•</div>
|
<div>•</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="stack horizontal sm">
|
<div className="stack horizontal sm items-center">
|
||||||
{build.private ? (
|
{build.private ? (
|
||||||
<div className={styles.privateText}>
|
<div className={styles.privateText}>
|
||||||
<LockIcon className={styles.privateIcon} />{" "}
|
<Lock size={16} /> {t("common:build.private")}
|
||||||
{t("common:build.private")}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<time
|
<time
|
||||||
|
|
@ -172,24 +168,30 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.bottomRow}>
|
<div className={styles.bottomRow}>
|
||||||
<Link
|
<LinkButton
|
||||||
to={analyzerPage({
|
to={analyzerPage({
|
||||||
weaponId: weapons[0].weaponSplId,
|
weaponId: weapons[0].weaponSplId,
|
||||||
abilities: abilities.flat(),
|
abilities: abilities.flat(),
|
||||||
})}
|
})}
|
||||||
|
shape="circle"
|
||||||
|
variant="minimal"
|
||||||
|
size="small"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
|
size={24}
|
||||||
alt={t("common:pages.analyzer")}
|
alt={t("common:pages.analyzer")}
|
||||||
className={styles.icon}
|
className={styles.icon}
|
||||||
path={navIconUrl("analyzer")}
|
path={navIconUrl("analyzer")}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</LinkButton>
|
||||||
{description ? (
|
{description ? (
|
||||||
<SendouPopover
|
<SendouPopover
|
||||||
trigger={
|
trigger={
|
||||||
<SendouButton
|
<SendouButton
|
||||||
|
shape="circle"
|
||||||
|
size="small"
|
||||||
variant="minimal"
|
variant="minimal"
|
||||||
icon={<SpeechBubbleIcon />}
|
icon={<MessageCircleMore />}
|
||||||
className={styles.smallText}
|
className={styles.smallText}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
@ -200,14 +202,14 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<>
|
<>
|
||||||
<LinkButton
|
<LinkButton
|
||||||
|
shape="circle"
|
||||||
className={styles.smallText}
|
className={styles.smallText}
|
||||||
variant="minimal"
|
variant="minimal"
|
||||||
size="small"
|
size="small"
|
||||||
to={`new?buildId=${id}&userId=${user!.id}`}
|
to={`new?buildId=${id}&userId=${user!.id}`}
|
||||||
testId="edit-build"
|
testId="edit-build"
|
||||||
>
|
icon={<SquarePen />}
|
||||||
<EditIcon className={styles.icon} />
|
/>
|
||||||
</LinkButton>
|
|
||||||
<FormWithConfirm
|
<FormWithConfirm
|
||||||
dialogHeading={t("builds:deleteConfirm", { title })}
|
dialogHeading={t("builds:deleteConfirm", { title })}
|
||||||
fields={[
|
fields={[
|
||||||
|
|
@ -216,7 +218,9 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<SendouButton
|
<SendouButton
|
||||||
icon={<TrashIcon className={styles.icon} />}
|
shape="circle"
|
||||||
|
size="small"
|
||||||
|
icon={<Trash />}
|
||||||
className={styles.smallText}
|
className={styles.smallText}
|
||||||
variant="minimal-destructive"
|
variant="minimal-destructive"
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { RefreshCcw } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {
|
import {
|
||||||
isRouteErrorResponse,
|
isRouteErrorResponse,
|
||||||
|
|
@ -14,7 +15,6 @@ import {
|
||||||
} from "~/utils/urls";
|
} from "~/utils/urls";
|
||||||
import { SendouButton } from "./elements/Button";
|
import { SendouButton } from "./elements/Button";
|
||||||
import { Image } from "./Image";
|
import { Image } from "./Image";
|
||||||
import { RefreshArrowsIcon } from "./icons/RefreshArrows";
|
|
||||||
import { Main } from "./Main";
|
import { Main } from "./Main";
|
||||||
|
|
||||||
export function Catcher() {
|
export function Catcher() {
|
||||||
|
|
@ -165,7 +165,7 @@ function RefreshPageButton() {
|
||||||
return (
|
return (
|
||||||
<SendouButton
|
<SendouButton
|
||||||
onPress={() => window.location.reload()}
|
onPress={() => window.location.reload()}
|
||||||
icon={<RefreshArrowsIcon />}
|
icon={<RefreshCcw />}
|
||||||
>
|
>
|
||||||
Refresh page
|
Refresh page
|
||||||
</SendouButton>
|
</SendouButton>
|
||||||
|
|
|
||||||
34
app/components/Chart.module.css
Normal file
34
app/components/Chart.module.css
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
.container {
|
||||||
|
height: var(--chart-height, 175px);
|
||||||
|
width: var(--chart-width);
|
||||||
|
background-color: var(--chart-bg, var(--color-bg-high));
|
||||||
|
border-radius: var(--radius-box);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip {
|
||||||
|
border: var(--border-style);
|
||||||
|
border-radius: var(--radius-box);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
padding: var(--s-1) var(--s-2);
|
||||||
|
font-weight: var(--weight-semi);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltipValue {
|
||||||
|
margin-inline-start: auto;
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
background-color: var(--dot-color);
|
||||||
|
border-radius: 100%;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dotFocused {
|
||||||
|
outline: 3px solid var(--dot-color-outline);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||||
import { Theme, useTheme } from "~/features/theme/core/provider";
|
import { Theme, useTheme } from "~/features/theme/core/provider";
|
||||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||||
|
import styles from "./Chart.module.css";
|
||||||
|
|
||||||
export default function Chart({
|
export default function Chart({
|
||||||
options,
|
options,
|
||||||
|
|
@ -62,11 +63,11 @@ export default function Chart({
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isMounted) {
|
if (!isMounted) {
|
||||||
return <div className={clsx("chart__container", containerClassName)} />;
|
return <div className={clsx(styles.container, containerClassName)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("chart__container", containerClassName)}>
|
<div className={clsx(styles.container, containerClassName)}>
|
||||||
<ReactChart
|
<ReactChart
|
||||||
options={{
|
options={{
|
||||||
data: options,
|
data: options,
|
||||||
|
|
@ -85,9 +86,9 @@ export default function Chart({
|
||||||
secondaryAxes,
|
secondaryAxes,
|
||||||
dark: theme.htmlThemeClass === Theme.DARK,
|
dark: theme.htmlThemeClass === Theme.DARK,
|
||||||
defaultColors: [
|
defaultColors: [
|
||||||
"var(--theme)",
|
"var(--color-text-accent)",
|
||||||
"var(--theme-secondary)",
|
"var(--color-accent)",
|
||||||
"var(--theme-info)",
|
"var(--color-info)",
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -124,19 +125,19 @@ function ChartTooltip({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chart__tooltip">
|
<div className={styles.tooltip}>
|
||||||
<h3 className="text-center text-md">
|
<h3 className="text-center text-md">
|
||||||
{header()}
|
{header()}
|
||||||
{headerSuffix}
|
{headerSuffix}
|
||||||
</h3>
|
</h3>
|
||||||
{dataPoints.map((dataPoint, index) => {
|
{dataPoints.map((dataPoint, index) => {
|
||||||
const color = dataPoint.style?.fill ?? "var(--theme)";
|
const color = dataPoint.style?.fill ?? "var(--color-accent)";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index} className="stack horizontal items-center sm">
|
<div key={index} className="stack horizontal items-center sm">
|
||||||
<div
|
<div
|
||||||
className={clsx("chart__dot", {
|
className={clsx(styles.dot, {
|
||||||
chart__dot__focused:
|
[styles.dotFocused]:
|
||||||
focusedDatum?.seriesId === dataPoint.seriesId,
|
focusedDatum?.seriesId === dataPoint.seriesId,
|
||||||
})}
|
})}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -144,10 +145,8 @@ function ChartTooltip({
|
||||||
"--dot-color-outline": color.replace(")", "-transparent)"),
|
"--dot-color-outline": color.replace(")", "-transparent)"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="chart__tooltip__label">
|
<div>{dataPoint.originalSeries.label}</div>
|
||||||
{dataPoint.originalSeries.label}
|
<div className={styles.tooltipValue}>
|
||||||
</div>
|
|
||||||
<div className="chart__tooltip__value">
|
|
||||||
{dataPoint.secondaryValue}
|
{dataPoint.secondaryValue}
|
||||||
{valueSuffix}
|
{valueSuffix}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
|
import { Check, Clipboard } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useCopyToClipboard } from "react-use";
|
import { useCopyToClipboard } from "react-use";
|
||||||
import { SendouButton } from "~/components/elements/Button";
|
import { SendouButton } from "~/components/elements/Button";
|
||||||
import { SendouPopover } from "~/components/elements/Popover";
|
import { SendouPopover } from "~/components/elements/Popover";
|
||||||
import { CheckmarkIcon } from "~/components/icons/Checkmark";
|
|
||||||
import { ClipboardIcon } from "~/components/icons/Clipboard";
|
|
||||||
|
|
||||||
interface CopyToClipboardPopoverProps {
|
interface CopyToClipboardPopoverProps {
|
||||||
url: string;
|
url: string;
|
||||||
|
|
@ -36,7 +35,7 @@ export function CopyToClipboardPopover({
|
||||||
size="miniscule"
|
size="miniscule"
|
||||||
variant="minimal"
|
variant="minimal"
|
||||||
onPress={() => copyToClipboard(url)}
|
onPress={() => copyToClipboard(url)}
|
||||||
icon={copySuccess ? <CheckmarkIcon /> : <ClipboardIcon />}
|
icon={copySuccess ? <Check /> : <Clipboard />}
|
||||||
>
|
>
|
||||||
{t("common:actions.copyToClipboard")}
|
{t("common:actions.copyToClipboard")}
|
||||||
</SendouButton>
|
</SendouButton>
|
||||||
|
|
|
||||||
103
app/components/CustomThemeSelector.module.css
Normal file
103
app/components/CustomThemeSelector.module.css
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
.customThemeSelector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s-4);
|
||||||
|
padding: var(--s-4);
|
||||||
|
border: var(--border-style);
|
||||||
|
border-radius: var(--radius-field);
|
||||||
|
background-color: var(--color-bg-medium);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customThemeSelectorSupporter {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customThemeSelectorNoSupporter {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10;
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customThemeSelectorInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s-2);
|
||||||
|
margin-bottom: var(--s-4);
|
||||||
|
text-align: center;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
padding: var(--s-2) var(--s-4);
|
||||||
|
border-block: var(--border-style);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customThemeSelectorActions {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--s-2);
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeSliders {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeSliderRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 40% auto;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
gap: var(--s-1) var(--s-2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hueSlider::-webkit-slider-runnable-track {
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
oklch(65% 0.15 0),
|
||||||
|
oklch(65% 0.15 60),
|
||||||
|
oklch(65% 0.15 120),
|
||||||
|
oklch(65% 0.15 180),
|
||||||
|
oklch(65% 0.15 240),
|
||||||
|
oklch(65% 0.15 300),
|
||||||
|
oklch(65% 0.15 360)
|
||||||
|
) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hueSlider::-moz-range-track {
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
oklch(65% 0.15 0),
|
||||||
|
oklch(65% 0.15 60),
|
||||||
|
oklch(65% 0.15 120),
|
||||||
|
oklch(65% 0.15 180),
|
||||||
|
oklch(65% 0.15 240),
|
||||||
|
oklch(65% 0.15 300),
|
||||||
|
oklch(65% 0.15 360)
|
||||||
|
) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatColorPreview {
|
||||||
|
color: oklch(from var(--color-text-accent) l c var(--_chat-h));
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeShare {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeShareActions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s-2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeShareInput input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
506
app/components/CustomThemeSelector.tsx
Normal file
506
app/components/CustomThemeSelector.tsx
Normal file
|
|
@ -0,0 +1,506 @@
|
||||||
|
import { Check, Clipboard, PencilLine } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useCopyToClipboard } from "react-use";
|
||||||
|
import {
|
||||||
|
CUSTOM_THEME_VARS,
|
||||||
|
type CustomTheme,
|
||||||
|
type CustomThemeVar,
|
||||||
|
} from "~/db/tables";
|
||||||
|
import {
|
||||||
|
ACCENT_CHROMA_MULTIPLIERS,
|
||||||
|
BASE_CHROMA_MULTIPLIERS,
|
||||||
|
clampThemeToGamut,
|
||||||
|
type ThemeInput,
|
||||||
|
} from "~/utils/oklch-gamut";
|
||||||
|
import { THEME_INPUT_LIMITS, themeInputSchema } from "~/utils/zod";
|
||||||
|
import styles from "./CustomThemeSelector.module.css";
|
||||||
|
import { Divider } from "./Divider";
|
||||||
|
import { LinkButton, SendouButton } from "./elements/Button";
|
||||||
|
import { SendouSwitch } from "./elements/Switch";
|
||||||
|
import { Label } from "./Label";
|
||||||
|
|
||||||
|
const COLOR_SLIDERS = [
|
||||||
|
{
|
||||||
|
id: "base-hue",
|
||||||
|
inputKey: "baseHue",
|
||||||
|
min: THEME_INPUT_LIMITS.BASE_HUE_MIN,
|
||||||
|
max: THEME_INPUT_LIMITS.BASE_HUE_MAX,
|
||||||
|
step: 1,
|
||||||
|
labelKey: "baseHue",
|
||||||
|
isHue: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "base-chroma",
|
||||||
|
inputKey: "baseChroma",
|
||||||
|
min: THEME_INPUT_LIMITS.BASE_CHROMA_MIN,
|
||||||
|
max: THEME_INPUT_LIMITS.BASE_CHROMA_MAX,
|
||||||
|
step: 0.001,
|
||||||
|
labelKey: "baseChroma",
|
||||||
|
isHue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "accent-hue",
|
||||||
|
inputKey: "accentHue",
|
||||||
|
min: THEME_INPUT_LIMITS.ACCENT_HUE_MIN,
|
||||||
|
max: THEME_INPUT_LIMITS.ACCENT_HUE_MAX,
|
||||||
|
step: 1,
|
||||||
|
labelKey: "accentHue",
|
||||||
|
isHue: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "accent-chroma",
|
||||||
|
inputKey: "accentChroma",
|
||||||
|
min: THEME_INPUT_LIMITS.ACCENT_CHROMA_MIN,
|
||||||
|
max: THEME_INPUT_LIMITS.ACCENT_CHROMA_MAX,
|
||||||
|
step: 0.01,
|
||||||
|
labelKey: "accentChroma",
|
||||||
|
isHue: false,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const RADIUS_SLIDERS = [
|
||||||
|
{
|
||||||
|
id: "radius-box",
|
||||||
|
inputKey: "radiusBox",
|
||||||
|
min: THEME_INPUT_LIMITS.RADIUS_MIN,
|
||||||
|
max: THEME_INPUT_LIMITS.RADIUS_MAX,
|
||||||
|
step: THEME_INPUT_LIMITS.RADIUS_STEP,
|
||||||
|
labelKey: "boxes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "radius-field",
|
||||||
|
inputKey: "radiusField",
|
||||||
|
min: THEME_INPUT_LIMITS.RADIUS_MIN,
|
||||||
|
max: THEME_INPUT_LIMITS.RADIUS_MAX,
|
||||||
|
step: THEME_INPUT_LIMITS.RADIUS_STEP,
|
||||||
|
labelKey: "fields",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "radius-selector",
|
||||||
|
inputKey: "radiusSelector",
|
||||||
|
min: THEME_INPUT_LIMITS.RADIUS_MIN,
|
||||||
|
max: THEME_INPUT_LIMITS.RADIUS_MAX,
|
||||||
|
step: THEME_INPUT_LIMITS.RADIUS_STEP,
|
||||||
|
labelKey: "selectors",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const BORDER_SLIDERS = [
|
||||||
|
{
|
||||||
|
id: "border-width",
|
||||||
|
inputKey: "borderWidth",
|
||||||
|
min: THEME_INPUT_LIMITS.BORDER_WIDTH_MIN,
|
||||||
|
max: THEME_INPUT_LIMITS.BORDER_WIDTH_MAX,
|
||||||
|
step: THEME_INPUT_LIMITS.BORDER_WIDTH_STEP,
|
||||||
|
labelKey: "borderWidth",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SIZE_SLIDERS = [
|
||||||
|
{
|
||||||
|
id: "size-field",
|
||||||
|
inputKey: "sizeField",
|
||||||
|
min: THEME_INPUT_LIMITS.SIZE_MIN,
|
||||||
|
max: THEME_INPUT_LIMITS.SIZE_MAX,
|
||||||
|
step: THEME_INPUT_LIMITS.SIZE_STEP,
|
||||||
|
labelKey: "fields",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "size-selector",
|
||||||
|
inputKey: "sizeSelector",
|
||||||
|
min: THEME_INPUT_LIMITS.SIZE_MIN,
|
||||||
|
max: THEME_INPUT_LIMITS.SIZE_MAX,
|
||||||
|
step: THEME_INPUT_LIMITS.SIZE_STEP,
|
||||||
|
labelKey: "selectors",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "size-spacing",
|
||||||
|
inputKey: "sizeSpacing",
|
||||||
|
min: THEME_INPUT_LIMITS.SIZE_MIN,
|
||||||
|
max: THEME_INPUT_LIMITS.SIZE_MAX,
|
||||||
|
step: THEME_INPUT_LIMITS.SIZE_STEP,
|
||||||
|
labelKey: "spacings",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type ThemeInputKey =
|
||||||
|
| (typeof COLOR_SLIDERS)[number]["inputKey"]
|
||||||
|
| (typeof RADIUS_SLIDERS)[number]["inputKey"]
|
||||||
|
| (typeof BORDER_SLIDERS)[number]["inputKey"]
|
||||||
|
| (typeof SIZE_SLIDERS)[number]["inputKey"]
|
||||||
|
| "chatHue";
|
||||||
|
|
||||||
|
const THEME_STRING_KEYS: readonly ThemeInputKey[] = [
|
||||||
|
...COLOR_SLIDERS.map((s) => s.inputKey),
|
||||||
|
...RADIUS_SLIDERS.map((s) => s.inputKey),
|
||||||
|
...BORDER_SLIDERS.map((s) => s.inputKey),
|
||||||
|
...SIZE_SLIDERS.map((s) => s.inputKey),
|
||||||
|
"chatHue",
|
||||||
|
];
|
||||||
|
|
||||||
|
function themeInputToString(input: ThemeInput): string {
|
||||||
|
return THEME_STRING_KEYS.map((key) => {
|
||||||
|
const value = input[key];
|
||||||
|
return value === null ? "_" : String(value);
|
||||||
|
}).join(";");
|
||||||
|
}
|
||||||
|
|
||||||
|
function themeInputFromString(str: string): ThemeInput | null {
|
||||||
|
const parts = str.split(";");
|
||||||
|
if (parts.length !== THEME_STRING_KEYS.length) return null;
|
||||||
|
|
||||||
|
const raw: Record<string, number | null> = {};
|
||||||
|
for (let i = 0; i < THEME_STRING_KEYS.length; i++) {
|
||||||
|
const key = THEME_STRING_KEYS[i];
|
||||||
|
const part = parts[i].trim();
|
||||||
|
|
||||||
|
if (key === "chatHue" && part === "_") {
|
||||||
|
raw[key] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = Number(part);
|
||||||
|
if (Number.isNaN(num)) return null;
|
||||||
|
raw[key] = num;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = themeInputSchema.safeParse(raw);
|
||||||
|
return parsed.success ? parsed.data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_THEME_INPUT: ThemeInput = {
|
||||||
|
baseHue: 268,
|
||||||
|
baseChroma: 0.05,
|
||||||
|
accentHue: 253,
|
||||||
|
accentChroma: 0.24,
|
||||||
|
chatHue: null,
|
||||||
|
radiusBox: 3,
|
||||||
|
radiusField: 2,
|
||||||
|
radiusSelector: 2,
|
||||||
|
borderWidth: 2,
|
||||||
|
sizeField: 1,
|
||||||
|
sizeSelector: 1,
|
||||||
|
sizeSpacing: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
function themeInputFromCustomTheme(customTheme: CustomTheme): ThemeInput {
|
||||||
|
return {
|
||||||
|
baseHue: customTheme["--_base-h"] ?? DEFAULT_THEME_INPUT.baseHue,
|
||||||
|
baseChroma:
|
||||||
|
(customTheme["--_base-c-2"] ?? 0) / BASE_CHROMA_MULTIPLIERS[2] ||
|
||||||
|
DEFAULT_THEME_INPUT.baseChroma,
|
||||||
|
accentHue: customTheme["--_acc-h"] ?? DEFAULT_THEME_INPUT.accentHue,
|
||||||
|
accentChroma:
|
||||||
|
(customTheme["--_acc-c-2"] ?? 0) / ACCENT_CHROMA_MULTIPLIERS[2] ||
|
||||||
|
DEFAULT_THEME_INPUT.accentChroma,
|
||||||
|
chatHue: customTheme["--_chat-h"],
|
||||||
|
radiusBox: customTheme["--_radius-box"] ?? DEFAULT_THEME_INPUT.radiusBox,
|
||||||
|
radiusField:
|
||||||
|
customTheme["--_radius-field"] ?? DEFAULT_THEME_INPUT.radiusField,
|
||||||
|
radiusSelector:
|
||||||
|
customTheme["--_radius-selector"] ?? DEFAULT_THEME_INPUT.radiusSelector,
|
||||||
|
borderWidth:
|
||||||
|
customTheme["--_border-width"] ?? DEFAULT_THEME_INPUT.borderWidth,
|
||||||
|
sizeField: customTheme["--_size-field"] ?? DEFAULT_THEME_INPUT.sizeField,
|
||||||
|
sizeSelector:
|
||||||
|
customTheme["--_size-selector"] ?? DEFAULT_THEME_INPUT.sizeSelector,
|
||||||
|
sizeSpacing:
|
||||||
|
customTheme["--_size-spacing"] ?? DEFAULT_THEME_INPUT.sizeSpacing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyThemeInput(input: ThemeInput) {
|
||||||
|
const clampedTheme = clampThemeToGamut(input);
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(clampedTheme)) {
|
||||||
|
document.documentElement.style.setProperty(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThemeSlider({
|
||||||
|
id,
|
||||||
|
inputKey,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step,
|
||||||
|
label,
|
||||||
|
isHue,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
inputKey: ThemeInputKey;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step: number;
|
||||||
|
label: string;
|
||||||
|
isHue?: boolean;
|
||||||
|
value: number;
|
||||||
|
onChange: (inputKey: ThemeInputKey, value: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={styles.themeSliderRow}>
|
||||||
|
<Label htmlFor={id}>{label}</Label>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(inputKey, Number(e.target.value))}
|
||||||
|
className={isHue ? styles.hueSlider : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomThemeSelector({
|
||||||
|
initialTheme,
|
||||||
|
isSupporter,
|
||||||
|
isPersonalTheme,
|
||||||
|
onSave,
|
||||||
|
onReset,
|
||||||
|
hidePatreonInfo,
|
||||||
|
}: {
|
||||||
|
initialTheme: CustomTheme | null | undefined;
|
||||||
|
isSupporter: boolean;
|
||||||
|
isPersonalTheme: boolean;
|
||||||
|
onSave: (themeInput: ThemeInput) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
hidePatreonInfo?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation(["common"]);
|
||||||
|
|
||||||
|
const initialThemeInput = initialTheme
|
||||||
|
? themeInputFromCustomTheme(initialTheme)
|
||||||
|
: DEFAULT_THEME_INPUT;
|
||||||
|
|
||||||
|
const [themeInput, setThemeInput] =
|
||||||
|
React.useState<ThemeInput>(initialThemeInput);
|
||||||
|
|
||||||
|
const handleSliderChange = (inputKey: ThemeInputKey, value: number) => {
|
||||||
|
const updatedInput = { ...themeInput, [inputKey]: value };
|
||||||
|
setThemeInput(updatedInput);
|
||||||
|
applyThemeInput(updatedInput);
|
||||||
|
};
|
||||||
|
|
||||||
|
const chatHueEnabled = themeInput.chatHue !== null;
|
||||||
|
|
||||||
|
const handleChatHueToggle = (isSelected: boolean) => {
|
||||||
|
const updatedInput = {
|
||||||
|
...themeInput,
|
||||||
|
chatHue: isSelected ? 0 : null,
|
||||||
|
};
|
||||||
|
setThemeInput(updatedInput);
|
||||||
|
applyThemeInput(updatedInput);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onSave(themeInput);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setThemeInput(DEFAULT_THEME_INPUT);
|
||||||
|
CUSTOM_THEME_VARS.forEach((varDef: CustomThemeVar) => {
|
||||||
|
document.documentElement.style.removeProperty(varDef);
|
||||||
|
});
|
||||||
|
onReset();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.customThemeSelector}>
|
||||||
|
{hidePatreonInfo ? null : (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isSupporter
|
||||||
|
? styles.customThemeSelectorSupporter
|
||||||
|
: styles.customThemeSelectorNoSupporter
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.customThemeSelectorInfo}>
|
||||||
|
<p>{t("common:settings.customTheme.patreonText")}</p>
|
||||||
|
<LinkButton
|
||||||
|
to="https://www.patreon.com/sendou"
|
||||||
|
isExternal
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{t("common:settings.customTheme.joinPatreon")}
|
||||||
|
</LinkButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Divider smallText>{t("common:settings.customTheme.colors")}</Divider>
|
||||||
|
<div className={styles.themeSliders}>
|
||||||
|
{COLOR_SLIDERS.map((slider) => (
|
||||||
|
<ThemeSlider
|
||||||
|
key={slider.id}
|
||||||
|
id={slider.id}
|
||||||
|
inputKey={slider.inputKey}
|
||||||
|
min={slider.min}
|
||||||
|
max={slider.max}
|
||||||
|
step={slider.step}
|
||||||
|
label={t(`common:settings.customTheme.${slider.labelKey}`)}
|
||||||
|
isHue={slider.isHue}
|
||||||
|
value={themeInput[slider.inputKey]}
|
||||||
|
onChange={handleSliderChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{isPersonalTheme ? (
|
||||||
|
<div className="mt-2">
|
||||||
|
<SendouSwitch
|
||||||
|
isSelected={chatHueEnabled}
|
||||||
|
onChange={handleChatHueToggle}
|
||||||
|
>
|
||||||
|
{t("common:settings.customTheme.chatHueToggle")}
|
||||||
|
</SendouSwitch>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{chatHueEnabled && isPersonalTheme ? (
|
||||||
|
<div className={styles.chatColorPreview}>
|
||||||
|
<ThemeSlider
|
||||||
|
id="chat-hue"
|
||||||
|
inputKey="chatHue"
|
||||||
|
min={THEME_INPUT_LIMITS.BASE_HUE_MIN}
|
||||||
|
max={THEME_INPUT_LIMITS.BASE_HUE_MAX}
|
||||||
|
step={1}
|
||||||
|
label={t("common:settings.customTheme.chatHue")}
|
||||||
|
isHue
|
||||||
|
value={themeInput.chatHue ?? 0}
|
||||||
|
onChange={handleSliderChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Divider smallText>{t("common:settings.customTheme.radius")}</Divider>
|
||||||
|
<div className={styles.themeSliders}>
|
||||||
|
{RADIUS_SLIDERS.map((slider) => (
|
||||||
|
<ThemeSlider
|
||||||
|
key={slider.id}
|
||||||
|
id={slider.id}
|
||||||
|
inputKey={slider.inputKey}
|
||||||
|
min={slider.min}
|
||||||
|
max={slider.max}
|
||||||
|
step={slider.step}
|
||||||
|
label={t(`common:settings.customTheme.${slider.labelKey}`)}
|
||||||
|
value={themeInput[slider.inputKey]}
|
||||||
|
onChange={handleSliderChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{isPersonalTheme ? (
|
||||||
|
<>
|
||||||
|
<Divider smallText>{t("common:settings.customTheme.sizes")}</Divider>
|
||||||
|
<div className={styles.themeSliders}>
|
||||||
|
{SIZE_SLIDERS.map((slider) => (
|
||||||
|
<ThemeSlider
|
||||||
|
key={slider.id}
|
||||||
|
id={slider.id}
|
||||||
|
inputKey={slider.inputKey}
|
||||||
|
min={slider.min}
|
||||||
|
max={slider.max}
|
||||||
|
step={slider.step}
|
||||||
|
label={t(`common:settings.customTheme.${slider.labelKey}`)}
|
||||||
|
value={themeInput[slider.inputKey]}
|
||||||
|
onChange={handleSliderChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Divider smallText>
|
||||||
|
{t("common:settings.customTheme.borders")}
|
||||||
|
</Divider>
|
||||||
|
<div className={styles.themeSliders}>
|
||||||
|
{BORDER_SLIDERS.map((slider) => (
|
||||||
|
<ThemeSlider
|
||||||
|
key={slider.id}
|
||||||
|
id={slider.id}
|
||||||
|
inputKey={slider.inputKey}
|
||||||
|
min={slider.min}
|
||||||
|
max={slider.max}
|
||||||
|
step={slider.step}
|
||||||
|
label={t(`common:settings.customTheme.${slider.labelKey}`)}
|
||||||
|
value={themeInput[slider.inputKey]}
|
||||||
|
onChange={handleSliderChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<Divider />
|
||||||
|
<ThemeShareInput
|
||||||
|
themeInput={themeInput}
|
||||||
|
onImport={(imported) => {
|
||||||
|
setThemeInput(imported);
|
||||||
|
applyThemeInput(imported);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className={styles.customThemeSelectorActions}>
|
||||||
|
<SendouButton isDisabled={!isSupporter} onPress={handleSave}>
|
||||||
|
{t("common:actions.save")}
|
||||||
|
</SendouButton>
|
||||||
|
<SendouButton
|
||||||
|
isDisabled={!isSupporter}
|
||||||
|
variant="destructive"
|
||||||
|
onPress={handleReset}
|
||||||
|
>
|
||||||
|
{t("common:actions.reset")}
|
||||||
|
</SendouButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThemeShareInput({
|
||||||
|
themeInput,
|
||||||
|
onImport,
|
||||||
|
}: {
|
||||||
|
themeInput: ThemeInput;
|
||||||
|
onImport: (input: ThemeInput) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation(["common"]);
|
||||||
|
const [state, copyToClipboard] = useCopyToClipboard();
|
||||||
|
const [copySuccess, setCopySuccess] = React.useState(false);
|
||||||
|
|
||||||
|
const themeString = themeInputToString(themeInput);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!state.value) return;
|
||||||
|
|
||||||
|
setCopySuccess(true);
|
||||||
|
const timeout = setTimeout(() => setCopySuccess(false), 2000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
const handlePaste = async () => {
|
||||||
|
const text = await navigator.clipboard.readText();
|
||||||
|
const parsed = themeInputFromString(text);
|
||||||
|
if (parsed) onImport(parsed);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.themeShare}>
|
||||||
|
<Label htmlFor="theme-input">
|
||||||
|
{t("common:settings.customTheme.shareCode")}
|
||||||
|
</Label>
|
||||||
|
<div className={styles.themeShareActions}>
|
||||||
|
<input id="theme-input" type="text" value={themeString} readOnly />
|
||||||
|
<SendouButton
|
||||||
|
shape="square"
|
||||||
|
variant="outlined"
|
||||||
|
icon={copySuccess ? <Check /> : <Clipboard />}
|
||||||
|
onPress={() => copyToClipboard(themeString)}
|
||||||
|
aria-label={t("common:settings.customTheme.copy")}
|
||||||
|
/>
|
||||||
|
<SendouButton
|
||||||
|
shape="square"
|
||||||
|
variant="outlined"
|
||||||
|
icon={<PencilLine />}
|
||||||
|
onPress={handlePaste}
|
||||||
|
aria-label={t("common:settings.customTheme.paste")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,357 +0,0 @@
|
||||||
import clsx from "clsx";
|
|
||||||
import * as React from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useDebounce } from "react-use";
|
|
||||||
import { CUSTOM_CSS_VAR_COLORS } from "~/features/user-page/user-page-constants";
|
|
||||||
import { SendouButton } from "./elements/Button";
|
|
||||||
import { InfoPopover } from "./InfoPopover";
|
|
||||||
import { AlertIcon } from "./icons/Alert";
|
|
||||||
import { CheckmarkIcon } from "./icons/Checkmark";
|
|
||||||
import { Label } from "./Label";
|
|
||||||
|
|
||||||
type CustomColorsRecord = Partial<
|
|
||||||
Record<(typeof CUSTOM_CSS_VAR_COLORS)[number], string>
|
|
||||||
>;
|
|
||||||
|
|
||||||
type ContrastCombination = [
|
|
||||||
Exclude<(typeof CUSTOM_CSS_VAR_COLORS)[number], "bg-lightest">,
|
|
||||||
Exclude<(typeof CUSTOM_CSS_VAR_COLORS)[number], "bg-lightest">,
|
|
||||||
];
|
|
||||||
|
|
||||||
type ContrastArray = {
|
|
||||||
colors: ContrastCombination;
|
|
||||||
contrast: {
|
|
||||||
AA: { failed: boolean; ratio: string };
|
|
||||||
AAA: { failed: boolean; ratio: string };
|
|
||||||
};
|
|
||||||
}[];
|
|
||||||
|
|
||||||
export function CustomizedColorsInput({
|
|
||||||
initialColors,
|
|
||||||
value: controlledValue,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
initialColors?: Record<string, string> | null;
|
|
||||||
value?: Record<string, string> | null;
|
|
||||||
onChange?: (value: Record<string, string> | null) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [colors, setColors] = React.useState<CustomColorsRecord>(
|
|
||||||
controlledValue ?? initialColors ?? {},
|
|
||||||
);
|
|
||||||
|
|
||||||
const [defaultColors, setDefaultColors] = React.useState<
|
|
||||||
Record<string, string>[]
|
|
||||||
>([]);
|
|
||||||
const [contrasts, setContrast] = React.useState<ContrastArray>([]);
|
|
||||||
|
|
||||||
const updateColors = (newColors: CustomColorsRecord) => {
|
|
||||||
setColors(newColors);
|
|
||||||
if (onChange) {
|
|
||||||
const filtered = colorsWithDefaultsFilteredOut(newColors, defaultColors);
|
|
||||||
const hasValues = Object.keys(filtered).length > 0;
|
|
||||||
onChange(hasValues ? (filtered as Record<string, string>) : null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useDebounce(
|
|
||||||
() => {
|
|
||||||
for (const color in colors) {
|
|
||||||
const value =
|
|
||||||
colors[color as (typeof CUSTOM_CSS_VAR_COLORS)[number]] ?? "";
|
|
||||||
document.documentElement.style.setProperty(`--preview-${color}`, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
setContrast(handleContrast(defaultColors, colors));
|
|
||||||
},
|
|
||||||
100,
|
|
||||||
[colors],
|
|
||||||
);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const colors = CUSTOM_CSS_VAR_COLORS.map((color) => {
|
|
||||||
return {
|
|
||||||
[color]: getComputedStyle(document.documentElement).getPropertyValue(
|
|
||||||
`--${color}`,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setDefaultColors(colors);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.documentElement.removeAttribute("style");
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<details className="w-full">
|
|
||||||
<summary className="colors__summary">
|
|
||||||
<div>
|
|
||||||
<span>{t("custom.colors.title")}</span>
|
|
||||||
</div>
|
|
||||||
</summary>
|
|
||||||
<div>
|
|
||||||
<Label>{t("custom.colors.title")}</Label>
|
|
||||||
{!onChange ? (
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="css"
|
|
||||||
value={JSON.stringify(
|
|
||||||
colorsWithDefaultsFilteredOut(colors, defaultColors),
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<div className="colors__container colors__grid">
|
|
||||||
{CUSTOM_CSS_VAR_COLORS.filter(
|
|
||||||
(cssVar) => cssVar !== "bg-lightest",
|
|
||||||
).map((cssVar) => {
|
|
||||||
return (
|
|
||||||
<React.Fragment key={cssVar}>
|
|
||||||
<div>{t(`custom.colors.${cssVar}`)}</div>
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
className="plain"
|
|
||||||
value={colors[cssVar] ?? "#000000"}
|
|
||||||
onChange={(e) => {
|
|
||||||
const extras: Record<string, string> = {};
|
|
||||||
if (cssVar === "bg-lighter") {
|
|
||||||
extras["bg-lightest"] = `${e.target.value}80`;
|
|
||||||
}
|
|
||||||
updateColors({
|
|
||||||
...colors,
|
|
||||||
...extras,
|
|
||||||
[cssVar]: e.target.value,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
data-testid={`color-input-${cssVar}`}
|
|
||||||
/>
|
|
||||||
<SendouButton
|
|
||||||
size="small"
|
|
||||||
variant="minimal-destructive"
|
|
||||||
onPress={() => {
|
|
||||||
const newColors: Record<string, string | undefined> = {
|
|
||||||
...colors,
|
|
||||||
};
|
|
||||||
if (cssVar === "bg-lighter") {
|
|
||||||
newColors["bg-lightest"] = defaultColors.find(
|
|
||||||
(color) => color["bg-lightest"],
|
|
||||||
)?.["bg-lightest"];
|
|
||||||
}
|
|
||||||
updateColors({
|
|
||||||
...newColors,
|
|
||||||
[cssVar]: defaultColors.find((color) => color[cssVar])?.[
|
|
||||||
cssVar
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("actions.reset")}
|
|
||||||
</SendouButton>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<Label labelClassName="stack horizontal sm items-center">
|
|
||||||
{t("custom.colors.contrast.title")}
|
|
||||||
<InfoPopover tiny>
|
|
||||||
<div className="colors__description">
|
|
||||||
{t("custom.colors.contrast.description")}
|
|
||||||
</div>
|
|
||||||
</InfoPopover>
|
|
||||||
</Label>
|
|
||||||
<table className="colors__container colors__table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{t("custom.colors.contrast.first-color")}</th>
|
|
||||||
<th>{t("custom.colors.contrast.second-color")}</th>
|
|
||||||
<th>AA</th>
|
|
||||||
<th>AAA</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{contrasts.map((contrast) => {
|
|
||||||
return (
|
|
||||||
<tr key={contrast.colors.join("-")}>
|
|
||||||
<td>{t(`custom.colors.${contrast.colors[0]}`)}</td>
|
|
||||||
<td>{t(`custom.colors.${contrast.colors[1]}`)}</td>
|
|
||||||
<td
|
|
||||||
className={clsx(
|
|
||||||
"colors__contrast",
|
|
||||||
contrast.contrast.AA.failed ? "fail" : "success",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{contrast.contrast.AA.failed ? (
|
|
||||||
<AlertIcon />
|
|
||||||
) : (
|
|
||||||
<CheckmarkIcon />
|
|
||||||
)}
|
|
||||||
{contrast.contrast.AA.ratio}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className={clsx(
|
|
||||||
"colors__contrast",
|
|
||||||
contrast.contrast.AAA.failed ? "fail" : "success",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{contrast.contrast.AAA.failed ? (
|
|
||||||
<AlertIcon />
|
|
||||||
) : (
|
|
||||||
<CheckmarkIcon />
|
|
||||||
)}
|
|
||||||
{contrast.contrast.AAA.ratio}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function colorsWithDefaultsFilteredOut(
|
|
||||||
colors: CustomColorsRecord,
|
|
||||||
defaultColors: Record<string, string>[],
|
|
||||||
): CustomColorsRecord {
|
|
||||||
const colorsWithoutDefaults: CustomColorsRecord = {};
|
|
||||||
for (const color in colors) {
|
|
||||||
if (
|
|
||||||
colors[color as (typeof CUSTOM_CSS_VAR_COLORS)[number]] !==
|
|
||||||
defaultColors.find((c) => c[color])?.[color]
|
|
||||||
) {
|
|
||||||
colorsWithoutDefaults[color as keyof CustomColorsRecord] =
|
|
||||||
colors[color as (typeof CUSTOM_CSS_VAR_COLORS)[number]];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return colorsWithoutDefaults;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleContrast(
|
|
||||||
defaultColors: Record<string, string>[],
|
|
||||||
colors: CustomColorsRecord,
|
|
||||||
) {
|
|
||||||
/*
|
|
||||||
Excluded because bg-lightest is not visible to the user,
|
|
||||||
tho these should be checked as well:
|
|
||||||
["bg-lightest", "text"],
|
|
||||||
["bg-lightest", "theme-secondary"],
|
|
||||||
*/
|
|
||||||
const combinations: ContrastCombination[] = [
|
|
||||||
["bg", "text"],
|
|
||||||
["bg", "text-lighter"],
|
|
||||||
["bg-darker", "text"],
|
|
||||||
["bg-darker", "theme"],
|
|
||||||
["bg-lighter", "text-lighter"],
|
|
||||||
["bg-lighter", "theme"],
|
|
||||||
["bg-lighter", "theme-secondary"],
|
|
||||||
];
|
|
||||||
|
|
||||||
const results: ContrastArray = [];
|
|
||||||
|
|
||||||
for (const [A, B] of combinations) {
|
|
||||||
const valueA =
|
|
||||||
colors[A as (typeof CUSTOM_CSS_VAR_COLORS)[number]] ?? undefined;
|
|
||||||
const valueB =
|
|
||||||
colors[B as (typeof CUSTOM_CSS_VAR_COLORS)[number]] ?? undefined;
|
|
||||||
|
|
||||||
const colorA = valueA ?? defaultColors.find((color) => color[A])?.[A];
|
|
||||||
const colorB = valueB ?? defaultColors.find((color) => color[B])?.[B];
|
|
||||||
|
|
||||||
if (!colorA || !colorB) continue;
|
|
||||||
|
|
||||||
const parsedA = colorA.includes("rgb") ? parseCSSVar(colorA) : colorA;
|
|
||||||
const parsedB = colorB.includes("rgb") ? parseCSSVar(colorB) : colorB;
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
colors: [A, B],
|
|
||||||
contrast: checkContrast(parsedA, parsedB),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCSSVar(cssVar: string) {
|
|
||||||
const regex = /rgb\((\d+)\s+(\d+)\s+(\d+)(?:\s+\/\s+(\d+%?))?\)/;
|
|
||||||
const match = cssVar.match(regex);
|
|
||||||
|
|
||||||
if (!match) {
|
|
||||||
return "#000000";
|
|
||||||
}
|
|
||||||
|
|
||||||
const r = Number.parseInt(match[1], 10);
|
|
||||||
const g = Number.parseInt(match[2], 10);
|
|
||||||
const b = Number.parseInt(match[3], 10);
|
|
||||||
|
|
||||||
let alpha = 255;
|
|
||||||
if (match[4]) {
|
|
||||||
const percentage = Number.parseInt(match[4], 10);
|
|
||||||
alpha = Math.round((percentage / 100) * 255);
|
|
||||||
}
|
|
||||||
|
|
||||||
const toHex = (value: number) => {
|
|
||||||
const hex = value.toString(16);
|
|
||||||
return hex.length === 1 ? `0${hex}` : hex;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (match[4]) {
|
|
||||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(alpha)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkContrast(colorA: string, colorB: string) {
|
|
||||||
const rgb1 = hexToRgb(colorA);
|
|
||||||
const rgb2 = hexToRgb(colorB);
|
|
||||||
|
|
||||||
const luminanceA = calculateLuminance(rgb1);
|
|
||||||
const luminanceB = calculateLuminance(rgb2);
|
|
||||||
|
|
||||||
const light = Math.max(luminanceA, luminanceB);
|
|
||||||
const dark = Math.min(luminanceA, luminanceB);
|
|
||||||
const ratio = (light + 0.05) / (dark + 0.05);
|
|
||||||
|
|
||||||
return {
|
|
||||||
AA: {
|
|
||||||
failed: ratio < 4.5,
|
|
||||||
ratio: ratio.toFixed(1),
|
|
||||||
},
|
|
||||||
AAA: {
|
|
||||||
failed: ratio < 7,
|
|
||||||
ratio: ratio.toFixed(1),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function hexToRgb(hex: string) {
|
|
||||||
const noHash = hex.replace(/^#/, "");
|
|
||||||
|
|
||||||
const r = Number.parseInt(noHash.substring(0, 2), 16);
|
|
||||||
const g = Number.parseInt(noHash.substring(2, 4), 16);
|
|
||||||
const b = Number.parseInt(noHash.substring(4, 6), 16);
|
|
||||||
|
|
||||||
if (noHash.length === 8) {
|
|
||||||
const alpha = Number.parseInt(noHash.substring(6, 8), 16) / 255;
|
|
||||||
return [
|
|
||||||
Math.round(r * alpha),
|
|
||||||
Math.round(g * alpha),
|
|
||||||
Math.round(b * alpha),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [r, g, b];
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateLuminance(rgb: number[]) {
|
|
||||||
const [r, g, b] = rgb.map((value) => {
|
|
||||||
const normalized = value / 255;
|
|
||||||
|
|
||||||
return normalized <= 0.03928
|
|
||||||
? normalized / 12.92
|
|
||||||
: ((normalized + 0.055) / 1.055) ** 2.4;
|
|
||||||
});
|
|
||||||
|
|
||||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
||||||
}
|
|
||||||
28
app/components/Divider.module.css
Normal file
28
app/components/Divider.module.css
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
.divider {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--color-text-accent);
|
||||||
|
font-size: var(--font-lg);
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 1rem;
|
||||||
|
border-bottom: 2px solid var(--color-bg-high);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:empty)::before {
|
||||||
|
margin-right: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:empty)::after {
|
||||||
|
margin-left: 0.25em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.smallText {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import styles from "./Divider.module.css";
|
||||||
|
|
||||||
export function Divider({
|
export function Divider({
|
||||||
children,
|
children,
|
||||||
|
|
@ -10,7 +11,11 @@ export function Divider({
|
||||||
smallText?: boolean;
|
smallText?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={clsx("divider", className, { "text-sm": smallText })}>
|
<div
|
||||||
|
className={clsx(styles.divider, className, {
|
||||||
|
[styles.smallText]: smallText,
|
||||||
|
})}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
8
app/components/EventsList.module.css
Normal file
8
app/components/EventsList.module.css
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
.dayHeader {
|
||||||
|
padding: var(--s-2) var(--s-2) var(--s-1);
|
||||||
|
font-size: var(--font-2xs);
|
||||||
|
font-weight: var(--weight-bold);
|
||||||
|
color: var(--color-text-high);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
101
app/components/EventsList.tsx
Normal file
101
app/components/EventsList.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { isToday, isTomorrow } from "date-fns";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { SidebarEvent } from "~/features/sidebar/core/sidebar.server";
|
||||||
|
import styles from "./EventsList.module.css";
|
||||||
|
import { ListLink } from "./SideNav";
|
||||||
|
|
||||||
|
export function EventsList({
|
||||||
|
events,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
events: SidebarEvent[];
|
||||||
|
onClick?: () => void;
|
||||||
|
}) {
|
||||||
|
const { t, i18n } = useTranslation(["front"]);
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-lighter text-sm p-2">
|
||||||
|
{t("front:sideNav.noEvents")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDayKey = (timestamp: number) => {
|
||||||
|
const date = new Date(timestamp * 1000);
|
||||||
|
return date.toDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDayHeader = (date: Date) => {
|
||||||
|
if (isToday(date)) {
|
||||||
|
const rtf = new Intl.RelativeTimeFormat(i18n.language, {
|
||||||
|
numeric: "auto",
|
||||||
|
});
|
||||||
|
const str = rtf.format(0, "day");
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||||
|
}
|
||||||
|
if (isTomorrow(date)) {
|
||||||
|
const rtf = new Intl.RelativeTimeFormat(i18n.language, {
|
||||||
|
numeric: "auto",
|
||||||
|
});
|
||||||
|
const str = rtf.format(1, "day");
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString(i18n.language, {
|
||||||
|
weekday: "long",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (date: Date) => {
|
||||||
|
return date.toLocaleTimeString(i18n.language, {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupedEvents = events.reduce<Record<string, typeof events>>(
|
||||||
|
(acc, event) => {
|
||||||
|
const key = getDayKey(event.startTime);
|
||||||
|
if (!acc[key]) {
|
||||||
|
acc[key] = [];
|
||||||
|
}
|
||||||
|
acc[key].push(event);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const dayKeys = Object.keys(groupedEvents);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{dayKeys.map((dayKey) => {
|
||||||
|
const dayEvents = groupedEvents[dayKey];
|
||||||
|
const firstDate = new Date(dayEvents[0].startTime * 1000);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={dayKey}>
|
||||||
|
<div className={styles.dayHeader}>{formatDayHeader(firstDate)}</div>
|
||||||
|
{dayEvents.map((event) => (
|
||||||
|
<ListLink
|
||||||
|
key={`${event.type}-${event.id}`}
|
||||||
|
to={event.url}
|
||||||
|
imageUrl={event.logoUrl ?? undefined}
|
||||||
|
subtitle={formatTime(new Date(event.startTime * 1000))}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{event.scrimStatus === "booked"
|
||||||
|
? t("front:sideNav.scrimVs", { opponent: event.name })
|
||||||
|
: event.scrimStatus === "looking"
|
||||||
|
? t("front:sideNav.lookingForScrim")
|
||||||
|
: event.name}
|
||||||
|
</ListLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
app/components/FormErrors.module.css
Normal file
7
app/components/FormErrors.module.css
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
.container {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
|
||||||
|
& > h4 {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useActionData } from "react-router";
|
import { useActionData } from "react-router";
|
||||||
import type { Namespace } from "~/modules/i18n/resources.server";
|
import type { Namespace } from "~/modules/i18n/resources.server";
|
||||||
|
import styles from "./FormErrors.module.css";
|
||||||
|
|
||||||
export function FormErrors({ namespace }: { namespace: Namespace }) {
|
export function FormErrors({ namespace }: { namespace: Namespace }) {
|
||||||
const { t } = useTranslation(["common", namespace]);
|
const { t } = useTranslation(["common", namespace]);
|
||||||
|
|
@ -11,7 +12,7 @@ export function FormErrors({ namespace }: { namespace: Namespace }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="form-errors">
|
<div className={styles.container}>
|
||||||
<h4>{t("common:forms.errors.title")}:</h4>
|
<h4>{t("common:forms.errors.title")}:</h4>
|
||||||
<ol>
|
<ol>
|
||||||
{actionData.errors.map((error) => (
|
{actionData.errors.map((error) => (
|
||||||
|
|
|
||||||
17
app/components/FormMessage.module.css
Normal file
17
app/components/FormMessage.module.css
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
.error {
|
||||||
|
display: block;
|
||||||
|
color: var(--color-error);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
margin-block-start: var(--label-margin);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
display: block;
|
||||||
|
color: var(--color-text-high);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
margin-block-start: var(--label-margin);
|
||||||
|
}
|
||||||
|
|
||||||
|
.noMargin {
|
||||||
|
margin-block-start: 0;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
|
import styles from "./FormMessage.module.css";
|
||||||
|
|
||||||
export function FormMessage({
|
export function FormMessage({
|
||||||
children,
|
children,
|
||||||
|
|
@ -18,8 +19,8 @@ export function FormMessage({
|
||||||
<div
|
<div
|
||||||
id={id}
|
id={id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
{ "info-message": type === "info", "error-message": type === "error" },
|
{ [styles.info]: type === "info", [styles.error]: type === "error" },
|
||||||
{ "no-margin": !spaced },
|
{ [styles.noMargin]: !spaced },
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export function FriendCodeInput({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fetcher.Form method="post" action={SENDOUQ_PAGE}>
|
<fetcher.Form method="post" action={SENDOUQ_PAGE}>
|
||||||
|
<input type="hidden" name="revalidateRoot" value="true" />
|
||||||
<div
|
<div
|
||||||
className={clsx("stack sm horizontal items-end", {
|
className={clsx("stack sm horizontal items-end", {
|
||||||
"justify-center": friendCode,
|
"justify-center": friendCode,
|
||||||
|
|
@ -34,8 +35,7 @@ export function FriendCodeInput({
|
||||||
<Label htmlFor={id}>{t("common:fc.title")}</Label>
|
<Label htmlFor={id}>{t("common:fc.title")}</Label>
|
||||||
<InfoPopover tiny>
|
<InfoPopover tiny>
|
||||||
<div className="stack sm">
|
<div className="stack sm">
|
||||||
<div>{t("common:fc.helpText")}</div>
|
<div className="text-xs font-bold">
|
||||||
<div className="text-lighter text-xs font-bold">
|
|
||||||
{t("common:fc.whereToFind")}
|
{t("common:fc.whereToFind")}
|
||||||
</div>
|
</div>
|
||||||
<Image
|
<Image
|
||||||
|
|
@ -62,7 +62,7 @@ export function FriendCodeInput({
|
||||||
</div>
|
</div>
|
||||||
{!friendCode ? (
|
{!friendCode ? (
|
||||||
<SubmitButton _action="ADD_FRIEND_CODE" state={fetcher.state}>
|
<SubmitButton _action="ADD_FRIEND_CODE" state={fetcher.state}>
|
||||||
Save
|
{t("common:actions.save")}
|
||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
33
app/components/FriendCodePopover.tsx
Normal file
33
app/components/FriendCodePopover.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { SendouButton } from "~/components/elements/Button";
|
||||||
|
import { SendouDialog } from "~/components/elements/Dialog";
|
||||||
|
import { FormMessage } from "~/components/FormMessage";
|
||||||
|
import { FriendCodeInput } from "~/components/FriendCodeInput";
|
||||||
|
import { useUser } from "~/features/auth/core/user";
|
||||||
|
|
||||||
|
export function FriendCodePopover({ size }: { size?: "small" }) {
|
||||||
|
const { t } = useTranslation(["common"]);
|
||||||
|
const user = useUser();
|
||||||
|
const friendCode = user?.friendCode;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SendouDialog
|
||||||
|
key={String(Boolean(friendCode))}
|
||||||
|
isDismissable
|
||||||
|
heading={t("common:fc.title")}
|
||||||
|
trigger={
|
||||||
|
<SendouButton variant="outlined" size={size}>
|
||||||
|
{friendCode ? `SW-${friendCode}` : t("common:fc.set")}
|
||||||
|
</SendouButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="stack md">
|
||||||
|
<FriendCodeInput friendCode={friendCode} />
|
||||||
|
<FormMessage type="info">{t("common:fc.altingWarning")}</FormMessage>
|
||||||
|
{friendCode ? (
|
||||||
|
<FormMessage type="info">{t("common:fc.changeHelp")}</FormMessage>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</SendouDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -47,8 +47,6 @@ export function GearSelect<Clearable extends boolean | undefined = undefined>({
|
||||||
search={{
|
search={{
|
||||||
placeholder: t("common:forms.gearSearch.search.placeholder"),
|
placeholder: t("common:forms.gearSearch.search.placeholder"),
|
||||||
}}
|
}}
|
||||||
className={styles.selectWidthWider}
|
|
||||||
popoverClassName={styles.selectWidthWider}
|
|
||||||
selectedKey={value}
|
selectedKey={value}
|
||||||
defaultSelectedKey={initialValue}
|
defaultSelectedKey={initialValue}
|
||||||
onSelectionChange={(value) => onChange?.(value as any)}
|
onSelectionChange={(value) => onChange?.(value as any)}
|
||||||
|
|
@ -57,7 +55,7 @@ export function GearSelect<Clearable extends boolean | undefined = undefined>({
|
||||||
>
|
>
|
||||||
{({ key, items: gear, brandId, idx }) => (
|
{({ key, items: gear, brandId, idx }) => (
|
||||||
<SendouSelectItemSection
|
<SendouSelectItemSection
|
||||||
className={idx === 0 ? "pt-0-5-forced" : undefined}
|
className={idx === 0 ? "pt-0-5" : undefined}
|
||||||
heading={t(`game-misc:BRAND_${brandId}` as any)}
|
heading={t(`game-misc:BRAND_${brandId}` as any)}
|
||||||
headingImgPath={brandImageUrl(brandId)}
|
headingImgPath={brandImageUrl(brandId)}
|
||||||
key={key}
|
key={key}
|
||||||
|
|
|
||||||
8
app/components/Image.module.css
Normal file
8
app/components/Image.module.css
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
.tierContainer {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tierImg {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
TIER_PLUS_URL,
|
TIER_PLUS_URL,
|
||||||
tierImageUrl,
|
tierImageUrl,
|
||||||
} from "~/utils/urls";
|
} from "~/utils/urls";
|
||||||
|
import styles from "./Image.module.css";
|
||||||
|
|
||||||
interface ImageProps {
|
interface ImageProps {
|
||||||
path: string;
|
path: string;
|
||||||
|
|
@ -229,14 +230,14 @@ export function TierImage({ tier, className, width = 200 }: TierImageProps) {
|
||||||
const height = width * 0.8675;
|
const height = width * 0.8675;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("tier__container", className)} style={{ width }}>
|
<div className={clsx(styles.tierContainer, className)} style={{ width }}>
|
||||||
<Image
|
<Image
|
||||||
path={tierImageUrl(tier.name)}
|
path={tierImageUrl(tier.name)}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
alt={title}
|
alt={title}
|
||||||
title={title}
|
title={title}
|
||||||
containerClassName="tier__img"
|
containerClassName={styles.tierImg}
|
||||||
/>
|
/>
|
||||||
{tier.isPlus ? (
|
{tier.isPlus ? (
|
||||||
<Image
|
<Image
|
||||||
|
|
@ -245,7 +246,7 @@ export function TierImage({ tier, className, width = 200 }: TierImageProps) {
|
||||||
height={height}
|
height={height}
|
||||||
alt={title}
|
alt={title}
|
||||||
title={title}
|
title={title}
|
||||||
containerClassName="tier__img"
|
containerClassName={styles.tierImg}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
24
app/components/InfoPopover.module.css
Normal file
24
app/components/InfoPopover.module.css
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
.trigger {
|
||||||
|
border: var(--border-style-high);
|
||||||
|
border-radius: 100%;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-md);
|
||||||
|
padding: var(--s-0-5);
|
||||||
|
width: var(--selector-size);
|
||||||
|
height: var(--selector-size);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: var(--focus-ring);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.triggerTiny {
|
||||||
|
width: var(--selector-size-sm);
|
||||||
|
height: var(--selector-size-sm);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Button } from "react-aria-components";
|
import { Button } from "react-aria-components";
|
||||||
import { SendouPopover } from "./elements/Popover";
|
import { SendouPopover } from "./elements/Popover";
|
||||||
|
import styles from "./InfoPopover.module.css";
|
||||||
|
|
||||||
export function InfoPopover({
|
export function InfoPopover({
|
||||||
children,
|
children,
|
||||||
|
|
@ -15,14 +16,9 @@ export function InfoPopover({
|
||||||
<SendouPopover
|
<SendouPopover
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button
|
||||||
className={clsx(
|
className={clsx(styles.trigger, className, {
|
||||||
"react-aria-Button",
|
[styles.triggerTiny]: tiny,
|
||||||
"info-popover__trigger",
|
})}
|
||||||
className,
|
|
||||||
{
|
|
||||||
"info-popover__trigger__tiny": tiny,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
?
|
?
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
60
app/components/Input.module.css
Normal file
60
app/components/Input.module.css
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
outline: none;
|
||||||
|
border: var(--border-style);
|
||||||
|
border-radius: var(--radius-field);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
height: var(--field-size);
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
& svg {
|
||||||
|
color: var(--color-text-high);
|
||||||
|
height: calc(var(--field-size) / 2);
|
||||||
|
margin: auto;
|
||||||
|
margin-right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(input:user-invalid) {
|
||||||
|
outline: var(--focus-ring-error);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
outline: var(--focus-ring);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
border-radius: var(--radius-field);
|
||||||
|
padding: 0 var(--field-padding);
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
background-color: inherit;
|
||||||
|
color: inherit;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--color-text-high);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.readOnly {
|
||||||
|
pointer-events: none;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addon {
|
||||||
|
display: grid;
|
||||||
|
border-radius: var(--radius-field) 0 0 var(--radius-field);
|
||||||
|
background-color: var(--color-bg-high);
|
||||||
|
color: var(--color-text-high);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
font-weight: var(--weight-semi);
|
||||||
|
padding-inline: var(--s-2);
|
||||||
|
place-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import styles from "./Input.module.css";
|
||||||
|
|
||||||
export function Input({
|
export function Input({
|
||||||
name,
|
name,
|
||||||
|
|
@ -20,8 +21,10 @@ export function Input({
|
||||||
value,
|
value,
|
||||||
placeholder,
|
placeholder,
|
||||||
onChange,
|
onChange,
|
||||||
|
onKeyDown,
|
||||||
disableAutoComplete = false,
|
disableAutoComplete = false,
|
||||||
readOnly,
|
readOnly,
|
||||||
|
ref,
|
||||||
}: {
|
}: {
|
||||||
name?: string;
|
name?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|
@ -42,17 +45,21 @@ export function Input({
|
||||||
value?: string;
|
value?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||||
disableAutoComplete?: boolean;
|
disableAutoComplete?: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
ref?: React.Ref<HTMLInputElement>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx("input-container", className, {
|
className={clsx(styles.container, className, {
|
||||||
"input__read-only": readOnly,
|
[styles.readOnly]: readOnly,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{leftAddon ? <div className="input-addon">{leftAddon}</div> : null}
|
{leftAddon ? <div className={styles.addon}>{leftAddon}</div> : null}
|
||||||
<input
|
<input
|
||||||
|
ref={ref}
|
||||||
|
className="in-container"
|
||||||
name={name}
|
name={name}
|
||||||
id={id}
|
id={id}
|
||||||
minLength={minLength}
|
minLength={minLength}
|
||||||
|
|
@ -65,6 +72,7 @@ export function Input({
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
required={required}
|
required={required}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
|
|
||||||
28
app/components/Label.module.css
Normal file
28
app/components/Label.module.css
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--s-2);
|
||||||
|
margin-block-end: var(--label-margin);
|
||||||
|
|
||||||
|
.mb-0 {
|
||||||
|
margin-block-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > label {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
color: var(--color-text-high);
|
||||||
|
font-size: var(--font-2xs);
|
||||||
|
margin-block-start: -5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.valueWarning {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.valueError {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import styles from "./Label.module.css";
|
||||||
|
|
||||||
type LabelProps = Pick<
|
type LabelProps = Pick<
|
||||||
React.DetailedHTMLProps<
|
React.DetailedHTMLProps<
|
||||||
|
|
@ -27,12 +28,12 @@ export function Label({
|
||||||
spaced = true,
|
spaced = true,
|
||||||
}: LabelProps) {
|
}: LabelProps) {
|
||||||
return (
|
return (
|
||||||
<div className={clsx("label__container", className, { "mb-0": !spaced })}>
|
<div className={clsx(styles.container, className, { "mb-0": !spaced })}>
|
||||||
<label htmlFor={htmlFor} className={labelClassName}>
|
<label htmlFor={htmlFor} className={labelClassName}>
|
||||||
{children} {required && <span className="text-error">*</span>}
|
{children} {required && <span className="text-error">*</span>}
|
||||||
</label>
|
</label>
|
||||||
{valueLimits ? (
|
{valueLimits ? (
|
||||||
<div className={clsx("label__value", lengthWarning(valueLimits))}>
|
<div className={clsx(styles.value, lengthWarning(valueLimits))}>
|
||||||
{valueLimits.current}/{valueLimits.max}
|
{valueLimits.current}/{valueLimits.max}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -41,8 +42,8 @@ export function Label({
|
||||||
}
|
}
|
||||||
|
|
||||||
function lengthWarning(valueLimits: NonNullable<LabelProps["valueLimits"]>) {
|
function lengthWarning(valueLimits: NonNullable<LabelProps["valueLimits"]>) {
|
||||||
if (valueLimits.current > valueLimits.max) return "error";
|
if (valueLimits.current > valueLimits.max) return styles.valueError;
|
||||||
if (valueLimits.current / valueLimits.max >= 0.9) return "warning";
|
if (valueLimits.current / valueLimits.max >= 0.9) return styles.valueWarning;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
app/components/Main.module.css
Normal file
24
app/components/Main.module.css
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
.main {
|
||||||
|
container-type: inline-size;
|
||||||
|
padding: var(--layout-main-padding);
|
||||||
|
margin-bottom: var(--s-32);
|
||||||
|
min-height: calc(100dvh - var(--layout-nav-height));
|
||||||
|
}
|
||||||
|
|
||||||
|
.normal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 48rem;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrow {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 24rem;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wide {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 72rem;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
import { isRouteErrorResponse, useRouteError } from "react-router";
|
import styles from "./Main.module.css";
|
||||||
import { useHasRole } from "~/modules/permissions/hooks";
|
|
||||||
|
|
||||||
export const Main = ({
|
export const Main = ({
|
||||||
children,
|
children,
|
||||||
|
|
@ -18,49 +17,40 @@ export const Main = ({
|
||||||
bigger?: boolean;
|
bigger?: boolean;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
}) => {
|
}) => {
|
||||||
const error = useRouteError();
|
|
||||||
const isMinorSupporter = useHasRole("MINOR_SUPPORT");
|
|
||||||
const showLeaderboard =
|
|
||||||
import.meta.env.VITE_PLAYWIRE_PUBLISHER_ID &&
|
|
||||||
!isMinorSupporter &&
|
|
||||||
!isRouteErrorResponse(error);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="layout__main-container">
|
<main
|
||||||
<main
|
className={
|
||||||
className={
|
classNameOverwrite
|
||||||
classNameOverwrite
|
? clsx(classNameOverwrite, {
|
||||||
? clsx(classNameOverwrite, {
|
[styles.narrow]: halfWidth,
|
||||||
[containerClassName("narrow")]: halfWidth,
|
})
|
||||||
"pt-8-forced": showLeaderboard,
|
: clsx(
|
||||||
})
|
styles.main,
|
||||||
: clsx(
|
styles.normal,
|
||||||
"layout__main",
|
{
|
||||||
containerClassName("normal"),
|
[styles.narrow]: halfWidth,
|
||||||
{
|
[styles.wide]: bigger,
|
||||||
[containerClassName("narrow")]: halfWidth,
|
},
|
||||||
[containerClassName("wide")]: bigger,
|
className,
|
||||||
"pt-8-forced": showLeaderboard,
|
)
|
||||||
},
|
}
|
||||||
className,
|
style={style}
|
||||||
)
|
>
|
||||||
}
|
{children}
|
||||||
style={style}
|
</main>
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { styles as mainStyles };
|
||||||
|
|
||||||
export const containerClassName = (width: "narrow" | "normal" | "wide") => {
|
export const containerClassName = (width: "narrow" | "normal" | "wide") => {
|
||||||
if (width === "narrow") {
|
if (width === "narrow") {
|
||||||
return "half-width";
|
return styles.narrow;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (width === "wide") {
|
if (width === "wide") {
|
||||||
return "bigger";
|
return styles.wide;
|
||||||
}
|
}
|
||||||
|
|
||||||
return "main";
|
return styles.normal;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,3 @@
|
||||||
.templateSelection {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--s-2);
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (min-width: 640px) {
|
|
||||||
.templateSelection {
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.stageRow {
|
.stageRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -23,23 +11,23 @@
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
border-radius: var(--rounded);
|
border-radius: var(--radius-box);
|
||||||
background-color: var(--bg-lighter);
|
background-color: var(--color-bg-high);
|
||||||
font-size: var(--fonts-xs);
|
font-size: var(--font-xs);
|
||||||
font-weight: var(--semi-bold);
|
font-weight: var(--weight-semi);
|
||||||
gap: var(--s-2);
|
gap: var(--s-2);
|
||||||
padding-block: var(--s-1-5);
|
padding-block: var(--s-1-5);
|
||||||
padding-inline: var(--s-3);
|
padding-inline: var(--s-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 640px) {
|
@container (width >= 560px) {
|
||||||
.stageNameRow {
|
.stageNameRow {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.stageImage {
|
.stageImage {
|
||||||
border-radius: var(--rounded);
|
border-radius: var(--radius-box);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modeButtonsContainer {
|
.modeButtonsContainer {
|
||||||
|
|
@ -51,25 +39,29 @@
|
||||||
|
|
||||||
.modeButton {
|
.modeButton {
|
||||||
padding: var(--s-1);
|
padding: var(--s-1);
|
||||||
border: 2px solid var(--bg-darker);
|
border: var(--border-style);
|
||||||
border-radius: var(--rounded-full);
|
border-radius: var(--radius-full);
|
||||||
background-color: transparent;
|
background-color: var(--color-bg);
|
||||||
color: var(--theme);
|
color: var(--color-accent);
|
||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
outline: initial;
|
outline: initial;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: var(--focus-ring);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.modeButton.selected {
|
.modeButton.selected {
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
background-color: var(--bg-mode-active);
|
background-color: var(--color-bg-higher);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modeButton.preselected {
|
.modeButton.preselected {
|
||||||
border: 2px solid var(--theme-info);
|
border: var(--border-style-accent);
|
||||||
background-color: var(--bg-mode-active);
|
background-color: var(--color-bg-higher);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode:not(.selected, .preselected) {
|
.mode:not(.selected, .preselected) {
|
||||||
filter: var(--inactive-image-filter);
|
filter: grayscale(100%) brightness(50%);
|
||||||
opacity: 0.6;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { ArrowLeft, X } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Image } from "~/components/Image";
|
import { Image } from "~/components/Image";
|
||||||
|
|
@ -12,8 +13,6 @@ import { split, startsWith } from "~/utils/strings";
|
||||||
import { assertType } from "~/utils/types";
|
import { assertType } from "~/utils/types";
|
||||||
import { modeImageUrl, stageImageUrl } from "~/utils/urls";
|
import { modeImageUrl, stageImageUrl } from "~/utils/urls";
|
||||||
import { SendouButton } from "./elements/Button";
|
import { SendouButton } from "./elements/Button";
|
||||||
import { ArrowLongLeftIcon } from "./icons/ArrowLongLeft";
|
|
||||||
import { CrossIcon } from "./icons/Cross";
|
|
||||||
|
|
||||||
import styles from "./MapPoolSelector.module.css";
|
import styles from "./MapPoolSelector.module.css";
|
||||||
|
|
||||||
|
|
@ -104,12 +103,10 @@ export function MapPoolSelector({
|
||||||
)}
|
)}
|
||||||
<div className="stack md">
|
<div className="stack md">
|
||||||
{allowBulkEdit && (
|
{allowBulkEdit && (
|
||||||
<div className={styles.templateSelection}>
|
<MapPoolTemplateSelect
|
||||||
<MapPoolTemplateSelect
|
value={template}
|
||||||
value={template}
|
handleChange={handleTemplateChange}
|
||||||
handleChange={handleTemplateChange}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{info}
|
{info}
|
||||||
<MapPoolStages
|
<MapPoolStages
|
||||||
|
|
@ -252,7 +249,7 @@ export function MapPoolStages({
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={mode}
|
key={mode}
|
||||||
className={clsx(styles.modeButton, "outline-theme", {
|
className={clsx(styles.modeButton, {
|
||||||
[styles.selected]: selected,
|
[styles.selected]: selected,
|
||||||
[styles.preselected]: preselected,
|
[styles.preselected]: preselected,
|
||||||
invisible:
|
invisible:
|
||||||
|
|
@ -282,22 +279,20 @@ export function MapPoolStages({
|
||||||
allowBulkEdit &&
|
allowBulkEdit &&
|
||||||
(mapPool.hasStage(stageId) ? (
|
(mapPool.hasStage(stageId) ? (
|
||||||
<SendouButton
|
<SendouButton
|
||||||
|
shape="circle"
|
||||||
key="clear"
|
key="clear"
|
||||||
onPress={() => handleStageClear(stageId)}
|
onPress={() => handleStageClear(stageId)}
|
||||||
icon={<CrossIcon title={t("common:actions.remove")} />}
|
icon={<X />}
|
||||||
variant="minimal"
|
variant="minimal"
|
||||||
aria-label={t("common:actions.remove")}
|
aria-label={t("common:actions.remove")}
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SendouButton
|
<SendouButton
|
||||||
|
shape="circle"
|
||||||
key="select-all"
|
key="select-all"
|
||||||
onPress={() => handleStageAdd(stageId)}
|
onPress={() => handleStageAdd(stageId)}
|
||||||
icon={
|
icon={<ArrowLeft />}
|
||||||
<ArrowLongLeftIcon
|
|
||||||
title={t("common:actions.selectAll")}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
variant="minimal"
|
variant="minimal"
|
||||||
aria-label={t("common:actions.selectAll")}
|
aria-label={t("common:actions.selectAll")}
|
||||||
size="small"
|
size="small"
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@ export function Markdown({ children }: { children: string }) {
|
||||||
.replace(/style\s*=\s*("[^"]*"|'[^']*')/gi, (_match, value) => {
|
.replace(/style\s*=\s*("[^"]*"|'[^']*')/gi, (_match, value) => {
|
||||||
const sanitized = value.replace(CSS_URL_REGEX, "");
|
const sanitized = value.replace(CSS_URL_REGEX, "");
|
||||||
return `style=${sanitized}`;
|
return `style=${sanitized}`;
|
||||||
});
|
})
|
||||||
|
.replace(/ +$/gm, "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MarkdownToJsx
|
<MarkdownToJsx
|
||||||
|
|
|
||||||
391
app/components/MobileNav.module.css
Normal file
391
app/components/MobileNav.module.css
Normal file
|
|
@ -0,0 +1,391 @@
|
||||||
|
.mobileNav {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 19;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 600px) {
|
||||||
|
.mobileNav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
height: var(--layout-nav-height);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border-top: 1.5px solid var(--color-border);
|
||||||
|
padding: 0 var(--s-4);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (display-mode: standalone) {
|
||||||
|
.tabBar {
|
||||||
|
height: calc(var(--layout-nav-height) + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-field);
|
||||||
|
color: var(--color-text-high);
|
||||||
|
font-size: var(--font-2xs);
|
||||||
|
font-weight: var(--weight-semi);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.15s;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: var(--focus-ring);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover,
|
||||||
|
.tab[data-active="true"] {
|
||||||
|
color: var(--color-text-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabIcon {
|
||||||
|
position: relative;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabIcon svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideNavEmpty {
|
||||||
|
padding: var(--s-4);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-high);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelOverlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
bottom: var(--layout-nav-height);
|
||||||
|
z-index: 18;
|
||||||
|
background-color: rgba(0, 0, 0, 0.25);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelOverlay[data-entering] {
|
||||||
|
animation: fade-in 200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 85%;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border-radius: var(--radius-box) var(--radius-box) 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel[data-entering] {
|
||||||
|
animation: slide-up 200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-up {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelHeader {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-2);
|
||||||
|
padding-inline: var(--s-4);
|
||||||
|
background-color: var(--color-bg-high);
|
||||||
|
border-bottom: 1.5px solid var(--color-border);
|
||||||
|
z-index: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--color-text-high);
|
||||||
|
min-height: var(--layout-nav-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelTitle {
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
font-weight: var(--weight-bold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelContent {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--s-2);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelDialog {
|
||||||
|
outline: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuOverlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 20;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuOverlay[data-entering] {
|
||||||
|
animation: fade-in 200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-2);
|
||||||
|
padding-inline: var(--s-4);
|
||||||
|
background-color: var(--color-bg-high);
|
||||||
|
border-bottom: 1.5px solid var(--color-border);
|
||||||
|
border-top: 1.5px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--color-text-high);
|
||||||
|
min-height: var(--layout-nav-height);
|
||||||
|
|
||||||
|
&:has(+ nav) {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuHeaderActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-2);
|
||||||
|
margin-inline-start: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelCloseButton {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-error);
|
||||||
|
padding: 0;
|
||||||
|
height: var(--field-size);
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border-radius: var(--radius-field);
|
||||||
|
margin-inline-start: auto;
|
||||||
|
}
|
||||||
|
.panelCloseButton:hover {
|
||||||
|
background-color: var(--color-bg-higher);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelIconContainer {
|
||||||
|
border-radius: var(--radius-field);
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamsList {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--s-4);
|
||||||
|
|
||||||
|
& a:not(:first-child) {
|
||||||
|
padding-block: var(--s-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
& a:first-child {
|
||||||
|
padding-block-start: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navGrid {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: var(--s-3);
|
||||||
|
padding: var(--s-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-1);
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
font-weight: var(--weight-semi);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem:hover {
|
||||||
|
color: var(--color-text-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItemImage {
|
||||||
|
width: var(--field-size-lg);
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border-radius: var(--radius-field);
|
||||||
|
background-color: var(--color-bg-higher);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youPanelUserRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--s-1) var(--s-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.youPanelUser {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-3);
|
||||||
|
padding: var(--s-2);
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
border-radius: var(--radius-field);
|
||||||
|
}
|
||||||
|
|
||||||
|
.youPanelUser:hover {
|
||||||
|
background-color: var(--color-bg-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
.youPanelUsername {
|
||||||
|
font-size: var(--font-md);
|
||||||
|
font-weight: var(--weight-bold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.youPanelSettingsButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-text-high);
|
||||||
|
border-radius: var(--radius-field);
|
||||||
|
height: var(--field-size);
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youPanelSettingsButton:hover {
|
||||||
|
background-color: var(--color-bg-high);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.youPanelSettingsButton svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelSectionLink {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
width: fit-content;
|
||||||
|
margin-top: auto;
|
||||||
|
margin-inline: auto;
|
||||||
|
margin-bottom: var(--s-4);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
color: var(--color-text-high);
|
||||||
|
text-decoration: none;
|
||||||
|
height: var(--field-size);
|
||||||
|
padding: 0 var(--s-4);
|
||||||
|
background-color: var(--color-bg-high);
|
||||||
|
border-radius: var(--radius-selector);
|
||||||
|
|
||||||
|
& svg {
|
||||||
|
stroke-width: 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelSectionLink:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: var(--color-bg-higher);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBadge {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: -8px;
|
||||||
|
font-size: var(--font-2xs);
|
||||||
|
font-weight: var(--weight-bold);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
background-color: var(--color-text-accent);
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 var(--s-0-5);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
pointer-events: none;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noAnimation[data-entering] {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghostTabBar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: calc(0px - var(--layout-nav-height));
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: var(--layout-nav-height);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (display-mode: standalone) {
|
||||||
|
.ghostTabBar {
|
||||||
|
height: calc(var(--layout-nav-height) + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghostTab {
|
||||||
|
height: 100%;
|
||||||
|
flex: 1;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
630
app/components/MobileNav.tsx
Normal file
630
app/components/MobileNav.tsx
Normal file
|
|
@ -0,0 +1,630 @@
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
ChevronRight,
|
||||||
|
Heart,
|
||||||
|
LogIn,
|
||||||
|
Menu,
|
||||||
|
MessageSquare,
|
||||||
|
Settings,
|
||||||
|
Tv,
|
||||||
|
User,
|
||||||
|
Users,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { Dialog, Modal, ModalOverlay } from "react-aria-components";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { useUser } from "~/features/auth/core/user";
|
||||||
|
import { useChatContext } from "~/features/chat/useChatContext";
|
||||||
|
import { FriendMenu } from "~/features/friends/components/FriendMenu";
|
||||||
|
import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants";
|
||||||
|
import { useLayoutSize } from "~/hooks/useMainContentWidth";
|
||||||
|
import type { RootLoaderData } from "~/root";
|
||||||
|
import {
|
||||||
|
EVENTS_PAGE,
|
||||||
|
FRIENDS_PAGE,
|
||||||
|
navIconUrl,
|
||||||
|
SETTINGS_PAGE,
|
||||||
|
SUPPORT_PAGE,
|
||||||
|
userPage,
|
||||||
|
} from "~/utils/urls";
|
||||||
|
import { Avatar } from "./Avatar";
|
||||||
|
import { EventsList } from "./EventsList";
|
||||||
|
import { LinkButton } from "./elements/Button";
|
||||||
|
import { Image } from "./Image";
|
||||||
|
import { ChatSidebar } from "./layout/ChatSidebar";
|
||||||
|
import { LogInButtonContainer } from "./layout/LogInButtonContainer";
|
||||||
|
import {
|
||||||
|
NotificationContent,
|
||||||
|
useNotifications,
|
||||||
|
} from "./layout/NotificationPopover";
|
||||||
|
import { navItems } from "./layout/nav-items";
|
||||||
|
import styles from "./MobileNav.module.css";
|
||||||
|
import { NotificationDot } from "./NotificationDot";
|
||||||
|
import { StreamListItems } from "./StreamListItems";
|
||||||
|
|
||||||
|
type SidebarData = RootLoaderData["sidebar"] | undefined;
|
||||||
|
type PanelType = "closed" | "menu" | "friends" | "tourneys" | "chat" | "you";
|
||||||
|
|
||||||
|
export function MobileNav({ sidebarData }: { sidebarData: SidebarData }) {
|
||||||
|
const [activePanel, setActivePanel] = React.useState<PanelType>("closed");
|
||||||
|
const previousPanelRef = React.useRef<PanelType>("closed");
|
||||||
|
const user = useUser();
|
||||||
|
const { unseenIds } = useNotifications();
|
||||||
|
const chatContext = useChatContext();
|
||||||
|
const layoutSize = useLayoutSize();
|
||||||
|
|
||||||
|
const hasUnseenNotifications = unseenIds.length > 0;
|
||||||
|
const hasFriendInSendouQ =
|
||||||
|
sidebarData?.friends.some((f) => f.subtitle === SENDOUQ_ACTIVITY_LABEL) ??
|
||||||
|
false;
|
||||||
|
|
||||||
|
const skipAnimation = previousPanelRef.current !== "closed";
|
||||||
|
|
||||||
|
const closePanel = () => {
|
||||||
|
previousPanelRef.current = activePanel;
|
||||||
|
setActivePanel("closed");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTabPress = (panel: PanelType) => {
|
||||||
|
if (activePanel === panel) {
|
||||||
|
if (panel === "chat") {
|
||||||
|
chatContext?.setChatOpen(false);
|
||||||
|
}
|
||||||
|
previousPanelRef.current = activePanel;
|
||||||
|
setActivePanel("closed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activePanel === "chat") {
|
||||||
|
chatContext?.setChatOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (panel === "chat") {
|
||||||
|
chatContext?.setChatOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
previousPanelRef.current = activePanel;
|
||||||
|
setActivePanel(panel);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeChatPanel = () => {
|
||||||
|
chatContext?.setChatOpen(false);
|
||||||
|
closePanel();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.mobileNav}>
|
||||||
|
{activePanel === "menu" ? (
|
||||||
|
<MenuOverlay
|
||||||
|
streams={sidebarData?.streams ?? []}
|
||||||
|
savedTournamentIds={sidebarData?.savedTournamentIds}
|
||||||
|
onClose={closePanel}
|
||||||
|
onTabPress={handleTabPress}
|
||||||
|
isLoggedIn={Boolean(user)}
|
||||||
|
skipAnimation={skipAnimation}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activePanel === "friends" ? (
|
||||||
|
<FriendsPanel
|
||||||
|
friends={sidebarData?.friends ?? []}
|
||||||
|
onClose={closePanel}
|
||||||
|
onTabPress={handleTabPress}
|
||||||
|
isLoggedIn={Boolean(user)}
|
||||||
|
skipAnimation={skipAnimation}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activePanel === "tourneys" ? (
|
||||||
|
<TourneysPanel
|
||||||
|
events={sidebarData?.events ?? []}
|
||||||
|
onClose={closePanel}
|
||||||
|
onTabPress={handleTabPress}
|
||||||
|
isLoggedIn={Boolean(user)}
|
||||||
|
skipAnimation={skipAnimation}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activePanel === "you" ? (
|
||||||
|
<YouPanel
|
||||||
|
onClose={closePanel}
|
||||||
|
onTabPress={handleTabPress}
|
||||||
|
isLoggedIn={Boolean(user)}
|
||||||
|
skipAnimation={skipAnimation}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{chatContext?.chatOpen && layoutSize === "mobile" ? (
|
||||||
|
<ChatPanel
|
||||||
|
onClose={closeChatPanel}
|
||||||
|
onTabPress={handleTabPress}
|
||||||
|
isLoggedIn={Boolean(user)}
|
||||||
|
skipAnimation={skipAnimation}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<MobileTabBar
|
||||||
|
activePanel={activePanel}
|
||||||
|
onTabPress={handleTabPress}
|
||||||
|
isLoggedIn={Boolean(user)}
|
||||||
|
hasUnseenNotifications={hasUnseenNotifications}
|
||||||
|
hasFriendInSendouQ={hasFriendInSendouQ}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileTabBar({
|
||||||
|
activePanel,
|
||||||
|
onTabPress,
|
||||||
|
isLoggedIn,
|
||||||
|
hasUnseenNotifications,
|
||||||
|
hasFriendInSendouQ,
|
||||||
|
}: {
|
||||||
|
activePanel: PanelType;
|
||||||
|
onTabPress: (panel: PanelType) => void;
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
hasUnseenNotifications: boolean;
|
||||||
|
hasFriendInSendouQ: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation(["front", "common"]);
|
||||||
|
const chatContext = useChatContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className={styles.tabBar}>
|
||||||
|
<MobileTab
|
||||||
|
icon={<Menu />}
|
||||||
|
label={t("front:mobileNav.menu")}
|
||||||
|
isActive={activePanel === "menu"}
|
||||||
|
onPress={() => onTabPress("menu")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoggedIn ? (
|
||||||
|
<>
|
||||||
|
<MobileTab
|
||||||
|
icon={<Users />}
|
||||||
|
label={t("front:mobileNav.friends")}
|
||||||
|
isActive={activePanel === "friends"}
|
||||||
|
onPress={() => onTabPress("friends")}
|
||||||
|
showNotificationDot={hasFriendInSendouQ}
|
||||||
|
/>
|
||||||
|
<MobileTab
|
||||||
|
icon={<Calendar />}
|
||||||
|
label={t("front:sideNav.myCalendar")}
|
||||||
|
isActive={activePanel === "tourneys"}
|
||||||
|
onPress={() => onTabPress("tourneys")}
|
||||||
|
/>
|
||||||
|
<MobileTab
|
||||||
|
icon={<MessageSquare />}
|
||||||
|
label={t("front:mobileNav.chat")}
|
||||||
|
isActive={activePanel === "chat"}
|
||||||
|
onPress={() => onTabPress("chat")}
|
||||||
|
unreadCount={chatContext?.totalUnreadCount}
|
||||||
|
/>
|
||||||
|
<MobileTab
|
||||||
|
icon={<User />}
|
||||||
|
label={t("front:mobileNav.you")}
|
||||||
|
isActive={activePanel === "you"}
|
||||||
|
onPress={() => onTabPress("you")}
|
||||||
|
showNotificationDot={hasUnseenNotifications}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<LogInButtonContainer>
|
||||||
|
<button type="submit" className={styles.tab}>
|
||||||
|
<span className={styles.tabIcon}>
|
||||||
|
<LogIn />
|
||||||
|
</span>
|
||||||
|
<span>{t("front:mobileNav.login")}</span>
|
||||||
|
</button>
|
||||||
|
</LogInButtonContainer>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileTab({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
isActive,
|
||||||
|
onPress,
|
||||||
|
showNotificationDot,
|
||||||
|
unreadCount,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
isActive: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
showNotificationDot?: boolean;
|
||||||
|
unreadCount?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.tab}
|
||||||
|
data-active={isActive}
|
||||||
|
onClick={onPress}
|
||||||
|
>
|
||||||
|
<span className={styles.tabIcon}>
|
||||||
|
{icon}
|
||||||
|
{showNotificationDot ? <NotificationDot /> : null}
|
||||||
|
{unreadCount ? (
|
||||||
|
<span className={styles.tabBadge}>{unreadCount}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobilePanel({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
onTabPress,
|
||||||
|
isLoggedIn,
|
||||||
|
skipAnimation,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
onTabPress: (panel: PanelType) => void;
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
skipAnimation: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ModalOverlay
|
||||||
|
className={clsx(styles.panelOverlay, skipAnimation && styles.noAnimation)}
|
||||||
|
isOpen
|
||||||
|
isDismissable={false}
|
||||||
|
>
|
||||||
|
<Modal
|
||||||
|
className={clsx(styles.panel, skipAnimation && styles.noAnimation)}
|
||||||
|
>
|
||||||
|
<Dialog className={styles.panelDialog}>
|
||||||
|
<header className={styles.panelHeader}>
|
||||||
|
<div className={styles.panelIconContainer}>{icon}</div>
|
||||||
|
<h2 className={styles.panelTitle}>{title}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.panelCloseButton}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className={clsx(styles.panelContent, "scrollbar")}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<GhostTabBar onTabPress={onTabPress} isLoggedIn={isLoggedIn} />
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
|
</ModalOverlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuOverlay({
|
||||||
|
streams,
|
||||||
|
savedTournamentIds,
|
||||||
|
onClose,
|
||||||
|
onTabPress,
|
||||||
|
isLoggedIn,
|
||||||
|
skipAnimation,
|
||||||
|
}: {
|
||||||
|
streams: NonNullable<SidebarData>["streams"];
|
||||||
|
savedTournamentIds?: number[];
|
||||||
|
onClose: () => void;
|
||||||
|
onTabPress: (panel: PanelType) => void;
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
skipAnimation: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation(["front", "common"]);
|
||||||
|
const user = useUser();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalOverlay
|
||||||
|
className={clsx(styles.panelOverlay, skipAnimation && styles.noAnimation)}
|
||||||
|
isOpen
|
||||||
|
isDismissable={false}
|
||||||
|
>
|
||||||
|
<Modal
|
||||||
|
className={clsx(
|
||||||
|
styles.menuOverlay,
|
||||||
|
"scrollbar",
|
||||||
|
skipAnimation && styles.noAnimation,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Dialog className={styles.panelDialog}>
|
||||||
|
<header className={styles.menuHeader}>
|
||||||
|
<div className={styles.panelIconContainer}>
|
||||||
|
<Menu size={18} />
|
||||||
|
</div>
|
||||||
|
<h2 className={styles.panelTitle}>{t("front:mobileNav.menu")}</h2>
|
||||||
|
<div className={styles.menuHeaderActions}>
|
||||||
|
{!user?.roles.includes("MINOR_SUPPORT") ? (
|
||||||
|
<LinkButton
|
||||||
|
to={SUPPORT_PAGE}
|
||||||
|
size="small"
|
||||||
|
icon={<Heart />}
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
{t("common:pages.support")}
|
||||||
|
</LinkButton>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.panelCloseButton}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav aria-label={t("front:mobileNav.menu")}>
|
||||||
|
<ul className={styles.navGrid}>
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<li key={item.name}>
|
||||||
|
<Link
|
||||||
|
to={`/${item.url}`}
|
||||||
|
className={styles.navItem}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div className={styles.navItemImage}>
|
||||||
|
<Image
|
||||||
|
path={navIconUrl(item.name)}
|
||||||
|
height={32}
|
||||||
|
width={32}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span>{t(`common:pages.${item.name}` as any)}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<header className={styles.menuHeader}>
|
||||||
|
<div className={styles.panelIconContainer}>
|
||||||
|
<Tv size={18} />
|
||||||
|
</div>
|
||||||
|
<h3 className={styles.panelTitle}>
|
||||||
|
{t("front:sideNav.streams")}
|
||||||
|
</h3>
|
||||||
|
</header>
|
||||||
|
{streams.length === 0 ? (
|
||||||
|
<div className={styles.sideNavEmpty}>
|
||||||
|
{t("front:sideNav.noStreams")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<ul className={styles.streamsList}>
|
||||||
|
<StreamListItems
|
||||||
|
streams={streams}
|
||||||
|
onClick={onClose}
|
||||||
|
isLoggedIn={Boolean(user)}
|
||||||
|
savedTournamentIds={savedTournamentIds}
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<GhostTabBar onTabPress={onTabPress} isLoggedIn={isLoggedIn} />
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
|
</ModalOverlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FriendsPanel({
|
||||||
|
friends,
|
||||||
|
onClose,
|
||||||
|
onTabPress,
|
||||||
|
isLoggedIn,
|
||||||
|
skipAnimation,
|
||||||
|
}: {
|
||||||
|
friends: NonNullable<SidebarData>["friends"];
|
||||||
|
onClose: () => void;
|
||||||
|
onTabPress: (panel: PanelType) => void;
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
skipAnimation: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation(["front", "common"]);
|
||||||
|
const user = useUser();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobilePanel
|
||||||
|
title={t("front:sideNav.friends")}
|
||||||
|
icon={<Users size={18} />}
|
||||||
|
onClose={onClose}
|
||||||
|
onTabPress={onTabPress}
|
||||||
|
isLoggedIn={isLoggedIn}
|
||||||
|
skipAnimation={skipAnimation}
|
||||||
|
>
|
||||||
|
{friends.length > 0 ? (
|
||||||
|
friends.map((friend) => (
|
||||||
|
<FriendMenu key={friend.id} {...friend} onNavigate={onClose} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className={styles.sideNavEmpty}>
|
||||||
|
{user
|
||||||
|
? t("front:sideNav.friends.noFriends")
|
||||||
|
: t("front:sideNav.friends.notLoggedIn")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
to={FRIENDS_PAGE}
|
||||||
|
className={styles.panelSectionLink}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{t("common:actions.viewAll")}
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
</Link>
|
||||||
|
</MobilePanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TourneysPanel({
|
||||||
|
events,
|
||||||
|
onClose,
|
||||||
|
onTabPress,
|
||||||
|
isLoggedIn,
|
||||||
|
skipAnimation,
|
||||||
|
}: {
|
||||||
|
events: NonNullable<SidebarData>["events"];
|
||||||
|
onClose: () => void;
|
||||||
|
onTabPress: (panel: PanelType) => void;
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
skipAnimation: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation(["front", "common"]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobilePanel
|
||||||
|
title={t("front:sideNav.myCalendar")}
|
||||||
|
icon={<Calendar size={18} />}
|
||||||
|
onClose={onClose}
|
||||||
|
onTabPress={onTabPress}
|
||||||
|
isLoggedIn={isLoggedIn}
|
||||||
|
skipAnimation={skipAnimation}
|
||||||
|
>
|
||||||
|
<EventsList events={events} onClick={onClose} />
|
||||||
|
<Link
|
||||||
|
to={EVENTS_PAGE}
|
||||||
|
className={styles.panelSectionLink}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{t("common:actions.viewAll")}
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
</Link>
|
||||||
|
</MobilePanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function YouPanel({
|
||||||
|
onClose,
|
||||||
|
onTabPress,
|
||||||
|
isLoggedIn,
|
||||||
|
skipAnimation,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onTabPress: (panel: PanelType) => void;
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
skipAnimation: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation(["front", "common"]);
|
||||||
|
const user = useUser();
|
||||||
|
const { notifications, unseenIds } = useNotifications();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobilePanel
|
||||||
|
title={t("front:mobileNav.you")}
|
||||||
|
icon={<User size={18} />}
|
||||||
|
onClose={onClose}
|
||||||
|
onTabPress={onTabPress}
|
||||||
|
isLoggedIn={isLoggedIn}
|
||||||
|
skipAnimation={skipAnimation}
|
||||||
|
>
|
||||||
|
<div className={styles.youPanelUserRow}>
|
||||||
|
<Link
|
||||||
|
to={userPage(user)}
|
||||||
|
className={styles.youPanelUser}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<Avatar user={user} size="sm" />
|
||||||
|
<span className={styles.youPanelUsername}>{user.username}</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to={SETTINGS_PAGE}
|
||||||
|
className={styles.youPanelSettingsButton}
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t("common:pages.settings")}
|
||||||
|
>
|
||||||
|
<Settings size={18} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{notifications ? (
|
||||||
|
<NotificationContent
|
||||||
|
notifications={notifications}
|
||||||
|
unseenIds={unseenIds}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</MobilePanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatPanel({
|
||||||
|
onClose,
|
||||||
|
onTabPress,
|
||||||
|
isLoggedIn,
|
||||||
|
skipAnimation,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onTabPress: (panel: PanelType) => void;
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
skipAnimation: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ModalOverlay
|
||||||
|
className={clsx(styles.panelOverlay, skipAnimation && styles.noAnimation)}
|
||||||
|
isOpen
|
||||||
|
isDismissable={false}
|
||||||
|
>
|
||||||
|
<Modal
|
||||||
|
className={clsx(
|
||||||
|
styles.panel,
|
||||||
|
"scrollbar",
|
||||||
|
skipAnimation && styles.noAnimation,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Dialog className={styles.panelDialog}>
|
||||||
|
<ChatSidebar onClose={onClose} />
|
||||||
|
<GhostTabBar onTabPress={onTabPress} isLoggedIn={isLoggedIn} />
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
|
</ModalOverlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOGGED_IN_TABS: PanelType[] = [
|
||||||
|
"menu",
|
||||||
|
"friends",
|
||||||
|
"tourneys",
|
||||||
|
"chat",
|
||||||
|
"you",
|
||||||
|
];
|
||||||
|
const LOGGED_OUT_TABS: PanelType[] = ["menu"];
|
||||||
|
|
||||||
|
function GhostTabBar({
|
||||||
|
onTabPress,
|
||||||
|
isLoggedIn,
|
||||||
|
}: {
|
||||||
|
onTabPress: (panel: PanelType) => void;
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
}) {
|
||||||
|
const tabs = isLoggedIn ? LOGGED_IN_TABS : LOGGED_OUT_TABS;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.ghostTabBar} aria-hidden="true">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
type="button"
|
||||||
|
className={styles.ghostTab}
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={() => onTabPress(tab)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
app/components/NotificationDot.module.css
Normal file
33
app/components/NotificationDot.module.css
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
.dot {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--dot-top, -2px);
|
||||||
|
right: var(--dot-right, -2px);
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background-color: var(--color-text-accent);
|
||||||
|
border-radius: 100%;
|
||||||
|
outline: 2px solid var(--color-bg);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--color-text-accent);
|
||||||
|
box-shadow: 0 0 0 var(--color-text-accent);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
from {
|
||||||
|
box-shadow: 0 0 0 0 var(--color-text-accent);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 10px rgba(255, 255, 255, 0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/components/NotificationDot.tsx
Normal file
10
app/components/NotificationDot.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import clsx from "clsx";
|
||||||
|
import styles from "./NotificationDot.module.css";
|
||||||
|
|
||||||
|
export function NotificationDot({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<span className={clsx(styles.dot, className)}>
|
||||||
|
<span className={styles.pulse} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
app/components/Pagination.module.css
Normal file
117
app/components/Pagination.module.css
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--s-1);
|
||||||
|
background-color: var(--color-bg-high);
|
||||||
|
padding: var(--s-2) var(--s-3);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
width: fit-content;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow:hover:not(:disabled) {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
padding: 0 var(--s-1);
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: var(--weight-semi);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page:hover:not(.pageActive) {
|
||||||
|
background-color: var(--color-bg-higher);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageActive {
|
||||||
|
background-color: var(--color-text);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ellipsis {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 1.5rem;
|
||||||
|
height: 2rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ellipsisButton {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ellipsisButton:hover {
|
||||||
|
background-color: var(--color-bg-higher);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jumpToForm {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jumpToInput {
|
||||||
|
max-width: 2.5rem;
|
||||||
|
max-height: 1.75rem;
|
||||||
|
padding: 0 var(--s-1);
|
||||||
|
border: 2px solid var(--color-primary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: var(--weight-semi);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jumpToInput:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (width < 580px) {
|
||||||
|
.desktopOnly {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (width >= 580px) {
|
||||||
|
.mobileOnly {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { SendouButton } from "~/components/elements/Button";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { ArrowLeftIcon } from "~/components/icons/ArrowLeft";
|
import * as React from "react";
|
||||||
import { ArrowRightIcon } from "~/components/icons/ArrowRight";
|
import styles from "./Pagination.module.css";
|
||||||
import { nullFilledArray } from "~/utils/arrays";
|
|
||||||
|
|
||||||
export function Pagination({
|
export function Pagination({
|
||||||
currentPage,
|
currentPage,
|
||||||
|
|
@ -17,39 +16,223 @@ export function Pagination({
|
||||||
previousPage: () => void;
|
previousPage: () => void;
|
||||||
setPage: (page: number) => void;
|
setPage: (page: number) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const pages = getPageNumbers(currentPage, pagesCount);
|
||||||
|
const [jumpToIndex, setJumpToIndex] = React.useState<number | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pagination__container">
|
<div className={styles.container}>
|
||||||
<SendouButton
|
<button
|
||||||
icon={<ArrowLeftIcon />}
|
type="button"
|
||||||
variant="outlined"
|
className={styles.arrow}
|
||||||
className="fix-rtl"
|
disabled={currentPage === 1}
|
||||||
isDisabled={currentPage === 1}
|
onClick={previousPage}
|
||||||
onPress={previousPage}
|
|
||||||
aria-label="Previous page"
|
aria-label="Previous page"
|
||||||
/>
|
>
|
||||||
<div className="pagination__dots">
|
<ChevronLeft size={18} />
|
||||||
{nullFilledArray(pagesCount).map((_, i) => (
|
</button>
|
||||||
// biome-ignore lint/a11y/noStaticElementInteractions: Biome v2 migration
|
{pages.map((page, index) =>
|
||||||
<div
|
page.value === "..." ? (
|
||||||
key={i}
|
<JumpToEllipsis
|
||||||
className={clsx("pagination__dot", {
|
key={`ellipsis-${index}`}
|
||||||
pagination__dot__active: i === currentPage - 1,
|
isOpen={jumpToIndex === index}
|
||||||
})}
|
pagesCount={pagesCount}
|
||||||
onClick={() => setPage(i + 1)}
|
desktopOnly={page.desktopOnly}
|
||||||
|
mobileOnly={page.mobileOnly}
|
||||||
|
onOpen={() => setJumpToIndex(index)}
|
||||||
|
onClose={() => setJumpToIndex(null)}
|
||||||
|
onJump={(page) => {
|
||||||
|
setPage(page);
|
||||||
|
setJumpToIndex(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
) : (
|
||||||
</div>
|
<PageButton
|
||||||
<div className="pagination__page-count">
|
key={page.value}
|
||||||
{currentPage}/{pagesCount}
|
page={page.value}
|
||||||
</div>
|
currentPage={currentPage}
|
||||||
<SendouButton
|
desktopOnly={page.desktopOnly}
|
||||||
icon={<ArrowRightIcon />}
|
setPage={setPage}
|
||||||
variant="outlined"
|
/>
|
||||||
className="fix-rtl"
|
),
|
||||||
isDisabled={currentPage === pagesCount}
|
)}
|
||||||
onPress={nextPage}
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.arrow}
|
||||||
|
disabled={currentPage === pagesCount}
|
||||||
|
onClick={nextPage}
|
||||||
aria-label="Next page"
|
aria-label="Next page"
|
||||||
/>
|
>
|
||||||
|
<ChevronRight size={18} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function JumpToEllipsis({
|
||||||
|
isOpen,
|
||||||
|
pagesCount,
|
||||||
|
desktopOnly,
|
||||||
|
mobileOnly,
|
||||||
|
onOpen,
|
||||||
|
onClose,
|
||||||
|
onJump,
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
pagesCount: number;
|
||||||
|
desktopOnly?: boolean;
|
||||||
|
mobileOnly?: boolean;
|
||||||
|
onOpen: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onJump: (page: number) => void;
|
||||||
|
}) {
|
||||||
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const value = formData.get("page") as string;
|
||||||
|
if (!value) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageNumber = Number(value);
|
||||||
|
if (Number.isNaN(pageNumber) || pageNumber < 1 || pageNumber > pagesCount) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onJump(pageNumber);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className={clsx(styles.jumpToForm, {
|
||||||
|
[styles.desktopOnly]: desktopOnly,
|
||||||
|
[styles.mobileOnly]: mobileOnly,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
// biome-ignore lint/a11y/noAutofocus: valid use case
|
||||||
|
autoFocus
|
||||||
|
name="page"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className={styles.jumpToInput}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
aria-label={`Jump to page (1-${pagesCount})`}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={clsx(styles.ellipsis, styles.ellipsisButton, {
|
||||||
|
[styles.desktopOnly]: desktopOnly,
|
||||||
|
[styles.mobileOnly]: mobileOnly,
|
||||||
|
})}
|
||||||
|
onClick={onOpen}
|
||||||
|
aria-label="Jump to page"
|
||||||
|
>
|
||||||
|
...
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageButton({
|
||||||
|
page,
|
||||||
|
currentPage,
|
||||||
|
desktopOnly,
|
||||||
|
setPage,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
currentPage: number;
|
||||||
|
desktopOnly?: boolean;
|
||||||
|
setPage: (page: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={clsx(styles.page, {
|
||||||
|
[styles.pageActive]: page === currentPage,
|
||||||
|
[styles.desktopOnly]: desktopOnly,
|
||||||
|
})}
|
||||||
|
onClick={() => setPage(page)}
|
||||||
|
aria-current={page === currentPage ? "page" : undefined}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageItem = {
|
||||||
|
value: number | "...";
|
||||||
|
desktopOnly?: boolean;
|
||||||
|
mobileOnly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getPageNumbers(currentPage: number, pagesCount: number): PageItem[] {
|
||||||
|
if (pagesCount <= 5) {
|
||||||
|
return Array.from({ length: pagesCount }, (_, i) => ({ value: i + 1 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pagesCount <= 9) {
|
||||||
|
return Array.from({ length: pagesCount }, (_, i) => ({
|
||||||
|
value: i + 1,
|
||||||
|
desktopOnly: i >= 2 && i < pagesCount - 2,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const mobileStart = Math.max(2, currentPage - 1);
|
||||||
|
const mobileEnd = Math.min(pagesCount - 1, currentPage + 1);
|
||||||
|
const desktopStart = Math.max(2, currentPage - 2);
|
||||||
|
const desktopEnd = Math.min(pagesCount - 1, currentPage + 2);
|
||||||
|
|
||||||
|
const isMobileVisible = (page: number) =>
|
||||||
|
page >= mobileStart && page <= mobileEnd;
|
||||||
|
const isDesktopVisible = (page: number) =>
|
||||||
|
page >= desktopStart && page <= desktopEnd;
|
||||||
|
|
||||||
|
const pages: PageItem[] = [];
|
||||||
|
|
||||||
|
pages.push({ value: 1 });
|
||||||
|
|
||||||
|
const needsDesktopEllipsisStart = desktopStart > 2;
|
||||||
|
const needsMobileEllipsisStart = mobileStart > 2;
|
||||||
|
|
||||||
|
if (needsDesktopEllipsisStart && needsMobileEllipsisStart) {
|
||||||
|
pages.push({ value: "..." });
|
||||||
|
} else if (needsMobileEllipsisStart) {
|
||||||
|
pages.push({ value: "...", mobileOnly: true });
|
||||||
|
} else if (needsDesktopEllipsisStart) {
|
||||||
|
pages.push({ value: "...", desktopOnly: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 2; i <= pagesCount - 1; i++) {
|
||||||
|
if (isDesktopVisible(i)) {
|
||||||
|
pages.push({ value: i, desktopOnly: !isMobileVisible(i) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsDesktopEllipsisEnd = desktopEnd < pagesCount - 1;
|
||||||
|
const needsMobileEllipsisEnd = mobileEnd < pagesCount - 1;
|
||||||
|
|
||||||
|
if (needsDesktopEllipsisEnd && needsMobileEllipsisEnd) {
|
||||||
|
pages.push({ value: "..." });
|
||||||
|
} else if (needsMobileEllipsisEnd) {
|
||||||
|
pages.push({ value: "...", mobileOnly: true });
|
||||||
|
} else if (needsDesktopEllipsisEnd) {
|
||||||
|
pages.push({ value: "...", desktopOnly: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
pages.push({ value: pagesCount });
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
|
||||||
8
app/components/RequiredHiddenInput.module.css
Normal file
8
app/components/RequiredHiddenInput.module.css
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
.input {
|
||||||
|
position: absolute;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border: none;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import styles from "./RequiredHiddenInput.module.css";
|
||||||
|
|
||||||
export function RequiredHiddenInput({
|
export function RequiredHiddenInput({
|
||||||
value,
|
value,
|
||||||
isValid,
|
isValid,
|
||||||
|
|
@ -9,7 +11,7 @@ export function RequiredHiddenInput({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
className="hidden-input-with-validation"
|
className={styles.input}
|
||||||
name={name}
|
name={name}
|
||||||
value={isValid ? value : []}
|
value={isValid ? value : []}
|
||||||
// empty onChange is because otherwise it will give a React error in console
|
// empty onChange is because otherwise it will give a React error in console
|
||||||
|
|
|
||||||
12
app/components/Section.module.css
Normal file
12
app/components/Section.module.css
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
.section {
|
||||||
|
& > div {
|
||||||
|
padding: var(--s-2);
|
||||||
|
border-radius: var(--radius-box);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
& > h2 {
|
||||||
|
color: var(--color-text-high);
|
||||||
|
font-size: var(--font-md);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import styles from "./Section.module.css";
|
||||||
|
|
||||||
export function Section({
|
export function Section({
|
||||||
title,
|
title,
|
||||||
children,
|
children,
|
||||||
|
|
@ -8,7 +10,7 @@ export function Section({
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="section">
|
<section className={styles.section}>
|
||||||
{title && <h2>{title}</h2>}
|
{title && <h2>{title}</h2>}
|
||||||
<div className={className}>{children}</div>
|
<div className={className}>{children}</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
299
app/components/SideNav.module.css
Normal file
299
app/components/SideNav.module.css
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
.sideNav {
|
||||||
|
background-color: var(--color-bg-nav);
|
||||||
|
min-width: var(--layout-sidenav-width);
|
||||||
|
max-width: var(--layout-sidenav-width);
|
||||||
|
border-right: 1.5px solid var(--color-border);
|
||||||
|
overflow: hidden;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100dvh;
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
@media screen and (min-width: 1000px) {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideNavCollapsed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideNavTop {
|
||||||
|
height: var(--layout-nav-height);
|
||||||
|
background-color: var(--color-bg-nav);
|
||||||
|
border-bottom: 1.5px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-inline: var(--s-2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideNavTopCentered {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideNavInner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s-2);
|
||||||
|
padding: var(--s-1-5);
|
||||||
|
padding-block-end: var(--s-2);
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideNavFooter {
|
||||||
|
height: var(--layout-nav-height);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-1);
|
||||||
|
padding: var(--s-1-5) var(--s-3);
|
||||||
|
background-color: var(--color-bg-nav);
|
||||||
|
border-top: 1.5px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideNavFooterUser {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-2);
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
font-weight: var(--weight-semi);
|
||||||
|
border-radius: var(--radius-field);
|
||||||
|
padding: var(--s-1);
|
||||||
|
margin-left: calc(-1 * var(--s-1));
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideNavFooterUser:hover {
|
||||||
|
background-color: var(--color-bg-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideNavFooterUsername {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideNavFooterActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-0-5);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideNavFooterButton {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background-color: transparent;
|
||||||
|
width: var(--field-size-sm);
|
||||||
|
height: var(--field-size-sm);
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-field);
|
||||||
|
color: var(--color-text-high);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideNavFooterButton:hover {
|
||||||
|
background-color: var(--color-bg-high);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideNavFooterButton:focus-visible {
|
||||||
|
outline: var(--focus-ring);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideNavFooterButton svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideNavFooterNotification {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideNavFooterUnseenDot {
|
||||||
|
--dot-top: 2px;
|
||||||
|
--dot-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideNavHeader {
|
||||||
|
color: var(--color-text-high);
|
||||||
|
padding: var(--s-1-5) var(--s-2);
|
||||||
|
margin-inline: calc(-1 * var(--s-1-5));
|
||||||
|
background-color: var(--color-bg-high);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-2);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
border-top: 1.5px solid var(--color-border);
|
||||||
|
border-bottom: 1.5px solid var(--color-border);
|
||||||
|
height: var(--layout-nav-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideNavHeader:first-child {
|
||||||
|
margin-block-start: calc(-1 * var(--s-1-5));
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideNavHeader h2 {
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
font-weight: var(--weight-bold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideNavHeaderAction {
|
||||||
|
margin-inline-start: auto;
|
||||||
|
font-size: var(--font-2xs);
|
||||||
|
font-weight: var(--weight-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideNavHeaderAction a {
|
||||||
|
color: var(--color-text-high);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideNavHeaderAction a:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideNavHeaderClose {
|
||||||
|
margin-inline-start: auto;
|
||||||
|
margin-inline-end: var(--s-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideNavHeader svg {
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconContainer {
|
||||||
|
background-color: var(--color-bg-high);
|
||||||
|
border-radius: var(--radius-field);
|
||||||
|
padding: var(--s-1-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.listLink {
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: var(--s-1) var(--s-2);
|
||||||
|
border-radius: var(--radius-field);
|
||||||
|
transition:
|
||||||
|
background-color 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-2);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
&:not(:has(.listLinkSubtitle)) {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
background-color: var(--color-bg-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[aria-current="page"] {
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: var(--color-bg-higher);
|
||||||
|
font-weight: var(--weight-bold);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.listButton {
|
||||||
|
composes: listLink;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: var(--focus-ring);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.listLinkImageContainer {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listLinkImage {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--radius-avatar);
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listLinkOverlayIcon {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -2px;
|
||||||
|
right: -2px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: var(--radius-field);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listLinkContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
gap: var(--s-0-5);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listLinkTitle {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:has(+ .listLinkSubtitle) {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.listLinkSubtitleRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
color: var(--color-text-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
.listLinkSubtitle {
|
||||||
|
font-size: var(--font-2xs);
|
||||||
|
color: var(--color-text-high);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listLinkBadge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: var(--font-2xs);
|
||||||
|
font-weight: var(--weight-semi);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
background-color: var(--color-text-accent);
|
||||||
|
padding: 0 var(--s-1);
|
||||||
|
border-radius: var(--radius-selector);
|
||||||
|
height: var(--selector-size-xs);
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listLinkBadgeWarning {
|
||||||
|
background-color: var(--color-text-second);
|
||||||
|
}
|
||||||
214
app/components/SideNav.tsx
Normal file
214
app/components/SideNav.tsx
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import type * as React from "react";
|
||||||
|
import { Button } from "react-aria-components";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { SendouButton } from "~/components/elements/Button";
|
||||||
|
import type { Tables } from "~/db/tables";
|
||||||
|
import { Avatar } from "./Avatar";
|
||||||
|
import styles from "./SideNav.module.css";
|
||||||
|
|
||||||
|
export function SideNav({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
footer,
|
||||||
|
top,
|
||||||
|
topCentered,
|
||||||
|
collapsed,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
top?: React.ReactNode;
|
||||||
|
topCentered?: boolean;
|
||||||
|
collapsed?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
className={clsx(styles.sideNav, className, {
|
||||||
|
[styles.sideNavCollapsed]: collapsed,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clsx(styles.sideNavTop, {
|
||||||
|
[styles.sideNavTopCentered]: topCentered,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{top}
|
||||||
|
</div>
|
||||||
|
<div className={clsx(styles.sideNavInner, "scrollbar")}>{children}</div>
|
||||||
|
{footer}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SideNavHeader({
|
||||||
|
children,
|
||||||
|
icon,
|
||||||
|
showClose,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
showClose?: boolean;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<header className={styles.sideNavHeader}>
|
||||||
|
{icon ? <div className={styles.iconContainer}>{icon}</div> : null}
|
||||||
|
<h2>{children}</h2>
|
||||||
|
{action ? (
|
||||||
|
<span className={styles.sideNavHeaderAction}>{action}</span>
|
||||||
|
) : null}
|
||||||
|
{showClose ? (
|
||||||
|
<SendouButton
|
||||||
|
icon={<X />}
|
||||||
|
variant="minimal"
|
||||||
|
slot="close"
|
||||||
|
className={styles.sideNavHeaderClose}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListItemContent({
|
||||||
|
children,
|
||||||
|
user,
|
||||||
|
imageUrl,
|
||||||
|
overlayIconUrl,
|
||||||
|
subtitle,
|
||||||
|
badge,
|
||||||
|
badgeVariant,
|
||||||
|
suppressSubtitleHydrationWarning,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
user?: Pick<Tables["User"], "discordId" | "discordAvatar">;
|
||||||
|
imageUrl?: string;
|
||||||
|
overlayIconUrl?: string;
|
||||||
|
subtitle?: React.ReactNode;
|
||||||
|
badge?: React.ReactNode;
|
||||||
|
badgeVariant?: "default" | "warning";
|
||||||
|
suppressSubtitleHydrationWarning?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{user ? (
|
||||||
|
<Avatar user={user} size="xxsm" />
|
||||||
|
) : imageUrl ? (
|
||||||
|
<div className={styles.listLinkImageContainer}>
|
||||||
|
<img src={imageUrl} alt="" className={styles.listLinkImage} />
|
||||||
|
{overlayIconUrl ? (
|
||||||
|
<img
|
||||||
|
src={overlayIconUrl}
|
||||||
|
alt=""
|
||||||
|
className={styles.listLinkOverlayIcon}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.listLinkContent}>
|
||||||
|
<span className={styles.listLinkTitle}>{children}</span>
|
||||||
|
{subtitle || badge ? (
|
||||||
|
<div className={styles.listLinkSubtitleRow}>
|
||||||
|
{subtitle ? (
|
||||||
|
<span
|
||||||
|
className={styles.listLinkSubtitle}
|
||||||
|
suppressHydrationWarning={suppressSubtitleHydrationWarning}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{typeof badge === "string" ? (
|
||||||
|
<span
|
||||||
|
className={clsx(styles.listLinkBadge, {
|
||||||
|
[styles.listLinkBadgeWarning]: badgeVariant === "warning",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
badge
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListLink({
|
||||||
|
children,
|
||||||
|
to,
|
||||||
|
onClick,
|
||||||
|
isActive,
|
||||||
|
imageUrl,
|
||||||
|
overlayIconUrl,
|
||||||
|
user,
|
||||||
|
subtitle,
|
||||||
|
badge,
|
||||||
|
badgeVariant,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
to: string;
|
||||||
|
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||||
|
isActive?: boolean;
|
||||||
|
imageUrl?: string;
|
||||||
|
overlayIconUrl?: string;
|
||||||
|
user?: Pick<Tables["User"], "discordId" | "discordAvatar">;
|
||||||
|
subtitle?: React.ReactNode;
|
||||||
|
badge?: React.ReactNode;
|
||||||
|
badgeVariant?: "default" | "warning";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
className={styles.listLink}
|
||||||
|
onClick={onClick}
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
>
|
||||||
|
<ListItemContent
|
||||||
|
user={user}
|
||||||
|
imageUrl={imageUrl}
|
||||||
|
overlayIconUrl={overlayIconUrl}
|
||||||
|
subtitle={subtitle}
|
||||||
|
badge={badge}
|
||||||
|
badgeVariant={badgeVariant}
|
||||||
|
suppressSubtitleHydrationWarning
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ListItemContent>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListButton({
|
||||||
|
children,
|
||||||
|
user,
|
||||||
|
subtitle,
|
||||||
|
badge,
|
||||||
|
badgeVariant,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
user?: Pick<Tables["User"], "discordId" | "discordAvatar">;
|
||||||
|
subtitle?: string | null;
|
||||||
|
badge?: string | null;
|
||||||
|
badgeVariant?: "default" | "warning";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button className={styles.listButton}>
|
||||||
|
<ListItemContent
|
||||||
|
user={user}
|
||||||
|
subtitle={subtitle}
|
||||||
|
badge={badge}
|
||||||
|
badgeVariant={badgeVariant}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ListItemContent>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SideNavFooter({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div className={styles.sideNavFooter}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,3 @@
|
||||||
.selectWidthWider {
|
|
||||||
--select-width: 250px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item {
|
.item {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--s-2);
|
gap: var(--s-2);
|
||||||
|
|
@ -9,7 +5,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.stageImg {
|
.stageImg {
|
||||||
border-radius: var(--rounded-sm);
|
border-radius: var(--radius-field);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stageLabel {
|
.stageLabel {
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,6 @@ export function StageSelect<Clearable extends boolean | undefined = undefined>({
|
||||||
search={{
|
search={{
|
||||||
placeholder: t("common:forms.stageSearch.search.placeholder"),
|
placeholder: t("common:forms.stageSearch.search.placeholder"),
|
||||||
}}
|
}}
|
||||||
className={styles.selectWidthWider}
|
|
||||||
popoverClassName={styles.selectWidthWider}
|
|
||||||
selectedKey={isControlled ? value : undefined}
|
selectedKey={isControlled ? value : undefined}
|
||||||
defaultSelectedKey={isControlled ? undefined : (initialValue as Key)}
|
defaultSelectedKey={isControlled ? undefined : (initialValue as Key)}
|
||||||
onSelectionChange={handleOnChange}
|
onSelectionChange={handleOnChange}
|
||||||
|
|
|
||||||
58
app/components/StreamListItems.module.css
Normal file
58
app/components/StreamListItems.module.css
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
.xpSubtitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xpIcon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tierBadge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcomingDivider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-2);
|
||||||
|
padding: var(--s-1) var(--s-2);
|
||||||
|
font-size: var(--font-3xs);
|
||||||
|
color: var(--color-text-high);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgeRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-1);
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveIconButton {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-high);
|
||||||
|
padding: 0;
|
||||||
|
height: var(--selector-size-sm);
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border-radius: var(--radius-selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveIconButton:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: var(--color-bg-higher);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcomingDivider::before,
|
||||||
|
.upcomingDivider::after {
|
||||||
|
content: "";
|
||||||
|
flex: 1;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
181
app/components/StreamListItems.tsx
Normal file
181
app/components/StreamListItems.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
import { isToday, isTomorrow } from "date-fns";
|
||||||
|
import { Bookmark, BookmarkCheck } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useFetcher } from "react-router";
|
||||||
|
import type { SidebarStream } from "~/features/core/streams/streams.server";
|
||||||
|
import type { LanguageCode } from "~/modules/i18n/config";
|
||||||
|
import { databaseTimestampToDate, formatDistanceToNow } from "~/utils/dates";
|
||||||
|
import { navIconUrl, tournamentRegisterPage } from "~/utils/urls";
|
||||||
|
import { Image } from "./Image";
|
||||||
|
import { ListLink } from "./SideNav";
|
||||||
|
import styles from "./StreamListItems.module.css";
|
||||||
|
import { TierPill } from "./TierPill";
|
||||||
|
|
||||||
|
export function StreamListItems({
|
||||||
|
streams,
|
||||||
|
onClick,
|
||||||
|
isLoggedIn,
|
||||||
|
savedTournamentIds,
|
||||||
|
}: {
|
||||||
|
streams: SidebarStream[];
|
||||||
|
onClick?: () => void;
|
||||||
|
isLoggedIn?: boolean;
|
||||||
|
savedTournamentIds?: number[];
|
||||||
|
}) {
|
||||||
|
const { t, i18n } = useTranslation(["front"]);
|
||||||
|
|
||||||
|
const formatRelativeDate = (timestamp: number) => {
|
||||||
|
const date = new Date(timestamp * 1000);
|
||||||
|
const timeStr = date.toLocaleTimeString(i18n.language, {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isToday(date)) {
|
||||||
|
const rtf = new Intl.RelativeTimeFormat(i18n.language, {
|
||||||
|
numeric: "auto",
|
||||||
|
});
|
||||||
|
const dayStr = rtf.format(0, "day");
|
||||||
|
return `${dayStr.charAt(0).toUpperCase() + dayStr.slice(1)}, ${timeStr}`;
|
||||||
|
}
|
||||||
|
if (isTomorrow(date)) {
|
||||||
|
const rtf = new Intl.RelativeTimeFormat(i18n.language, {
|
||||||
|
numeric: "auto",
|
||||||
|
});
|
||||||
|
const dayStr = rtf.format(1, "day");
|
||||||
|
return `${dayStr.charAt(0).toUpperCase() + dayStr.slice(1)}, ${timeStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleDateString(i18n.language, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{streams.map((stream, i) => {
|
||||||
|
const startsAtDate = databaseTimestampToDate(stream.startsAt);
|
||||||
|
const isUpcoming = startsAtDate.getTime() > Date.now();
|
||||||
|
const prevStream = streams.at(i - 1);
|
||||||
|
const prevIsLive =
|
||||||
|
prevStream &&
|
||||||
|
databaseTimestampToDate(prevStream.startsAt).getTime() <= Date.now();
|
||||||
|
const showUpcomingDivider = isUpcoming && prevIsLive;
|
||||||
|
const tournamentId = stream.id.startsWith("upcoming-")
|
||||||
|
? Number(stream.id.replace("upcoming-", ""))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={stream.id}>
|
||||||
|
{showUpcomingDivider ? (
|
||||||
|
<div className={styles.upcomingDivider}>
|
||||||
|
{t("front:sideNav.streams.upcoming")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<ListLink
|
||||||
|
to={stream.url}
|
||||||
|
imageUrl={stream.imageUrl}
|
||||||
|
overlayIconUrl={stream.overlayIconUrl}
|
||||||
|
subtitle={
|
||||||
|
stream.peakXp ? (
|
||||||
|
<span className={styles.xpSubtitle}>
|
||||||
|
<Image
|
||||||
|
path={navIconUrl("xsearch")}
|
||||||
|
alt=""
|
||||||
|
className={styles.xpIcon}
|
||||||
|
/>
|
||||||
|
{stream.peakXp}
|
||||||
|
</span>
|
||||||
|
) : stream.subtitle ? (
|
||||||
|
stream.subtitle
|
||||||
|
) : isUpcoming ? (
|
||||||
|
formatRelativeDate(stream.startsAt)
|
||||||
|
) : (
|
||||||
|
formatDistanceToNow(startsAtDate, {
|
||||||
|
addSuffix: true,
|
||||||
|
language: i18n.language as LanguageCode,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
badge={
|
||||||
|
!isUpcoming ? (
|
||||||
|
"LIVE"
|
||||||
|
) : (
|
||||||
|
<div className={styles.badgeRow}>
|
||||||
|
{isLoggedIn && tournamentId !== null ? (
|
||||||
|
<SaveTournamentStreamButton
|
||||||
|
tournamentId={tournamentId}
|
||||||
|
isSaved={
|
||||||
|
savedTournamentIds?.includes(tournamentId) ?? false
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{streamTierBadge(stream)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{stream.name}
|
||||||
|
</ListLink>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SaveTournamentStreamButton({
|
||||||
|
tournamentId,
|
||||||
|
isSaved,
|
||||||
|
}: {
|
||||||
|
tournamentId: number;
|
||||||
|
isSaved: boolean;
|
||||||
|
}) {
|
||||||
|
const fetcher = useFetcher();
|
||||||
|
|
||||||
|
const optimisticSaved =
|
||||||
|
fetcher.formData?.get("_action") === "SAVE_TOURNAMENT"
|
||||||
|
? true
|
||||||
|
: fetcher.formData?.get("_action") === "UNSAVE_TOURNAMENT"
|
||||||
|
? false
|
||||||
|
: isSaved;
|
||||||
|
|
||||||
|
const Icon = optimisticSaved ? BookmarkCheck : Bookmark;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fetcher.Form
|
||||||
|
method="post"
|
||||||
|
action={tournamentRegisterPage(tournamentId)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="_action"
|
||||||
|
value={optimisticSaved ? "UNSAVE_TOURNAMENT" : "SAVE_TOURNAMENT"}
|
||||||
|
/>
|
||||||
|
<input type="hidden" name="revalidateRoot" value="true" />
|
||||||
|
<button type="submit" className={styles.saveIconButton} title="Save">
|
||||||
|
<Icon size={14} />
|
||||||
|
</button>
|
||||||
|
</fetcher.Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamTierBadge(stream: SidebarStream): React.ReactNode {
|
||||||
|
const tier = stream.tier ?? stream.tentativeTier;
|
||||||
|
if (!tier) return undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.tierBadge}>
|
||||||
|
<TierPill
|
||||||
|
tier={tier}
|
||||||
|
isTentative={!stream.tier && !!stream.tentativeTier}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
app/components/SubNav.module.css
Normal file
90
app/components/SubNav.module.css
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--s-4);
|
||||||
|
margin-block-end: var(--s-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
gap: var(--s-1);
|
||||||
|
margin-block-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkContainer {
|
||||||
|
display: flex;
|
||||||
|
max-width: 110px;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--color-text);
|
||||||
|
gap: var(--s-1-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkContainer.active {
|
||||||
|
color: var(--color-text-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary .linkContainer {
|
||||||
|
max-width: none;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary .linkContainer.active {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 var(--s-4);
|
||||||
|
height: var(--field-size-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-field);
|
||||||
|
font-weight: var(--weight-semi);
|
||||||
|
background-color: var(--color-bg-high);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkSecondary {
|
||||||
|
height: var(--selector-size-sm);
|
||||||
|
border-radius: var(--radius-selector);
|
||||||
|
padding: 0 var(--s-2);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
color: var(--color-text-high);
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary .linkContainer.active .linkSecondary {
|
||||||
|
background-color: var(--color-bg-higher);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container.compact .link {
|
||||||
|
padding: var(--s-1) var(--s-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.borderGuy {
|
||||||
|
width: 78%;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: var(--radius-box);
|
||||||
|
background-color: var(--color-bg-higher);
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.borderGuySecondary {
|
||||||
|
height: 2.5px;
|
||||||
|
background-color: var(--color-bg-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkContainer.active > .borderGuy {
|
||||||
|
visibility: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary .borderGuy {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import clsx from "clsx";
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
import type { LinkProps } from "react-router";
|
import type { LinkProps } from "react-router";
|
||||||
import { NavLink } from "react-router";
|
import { NavLink } from "react-router";
|
||||||
|
import styles from "./SubNav.module.css";
|
||||||
|
|
||||||
export function SubNav({
|
export function SubNav({
|
||||||
children,
|
children,
|
||||||
|
|
@ -13,8 +14,8 @@ export function SubNav({
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<nav
|
<nav
|
||||||
className={clsx("sub-nav__container", {
|
className={clsx(styles.container, {
|
||||||
"sub-nav__container__secondary": secondary,
|
[styles.secondary]: secondary,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
@ -41,8 +42,8 @@ export function SubNavLink({
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
className={(state) =>
|
className={(state) =>
|
||||||
clsx("sub-nav__link__container", {
|
clsx(styles.linkContainer, {
|
||||||
active: controlled ? active : state.isActive,
|
[styles.active]: controlled ? active : state.isActive,
|
||||||
pending: state.isPending,
|
pending: state.isPending,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -50,15 +51,15 @@ export function SubNavLink({
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={clsx("sub-nav__link", className, {
|
className={clsx(styles.link, className, {
|
||||||
"sub-nav__link__secondary": secondary,
|
[styles.linkSecondary]: secondary,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={clsx("sub-nav__border-guy", {
|
className={clsx(styles.borderGuy, {
|
||||||
"sub-nav__border-guy__secondary": secondary,
|
[styles.borderGuySecondary]: secondary,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
|
||||||
38
app/components/Table.module.css
Normal file
38
app/components/Table.module.css
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
text-align: left;
|
||||||
|
border-color: var(--color-border);
|
||||||
|
|
||||||
|
& > thead {
|
||||||
|
font-size: var(--font-2xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
& tbody tr:hover {
|
||||||
|
background-color: var(--color-bg-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
& > thead > tr > th {
|
||||||
|
padding: var(--s-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
& > tbody > tr > td {
|
||||||
|
padding: var(--s-2) var(--s-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
& tr:first-child td {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
& td {
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
import styles from "./Table.module.css";
|
||||||
|
|
||||||
export function Table({ children }: { children: React.ReactNode }) {
|
export function Table({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="my-table__container">
|
<div className={styles.container}>
|
||||||
<table className="my-table">{children}</table>
|
<table className={styles.table}>{children}</table>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
.pill {
|
.pill {
|
||||||
font-size: var(--fonts-xxs);
|
font-size: var(--font-2xs);
|
||||||
font-weight: var(--bold);
|
font-weight: var(--weight-bold);
|
||||||
border-radius: var(--rounded-sm);
|
border-radius: var(--radius-selector);
|
||||||
padding: var(--s-0-5) var(--s-1-5);
|
padding: 0 var(--s-1-5);
|
||||||
|
height: var(--selector-size-sm);
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
min-width: 24px;
|
min-width: 33px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
14
app/components/TimePopover.module.css
Normal file
14
app/components/TimePopover.module.css
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
.textOnlyButton {
|
||||||
|
cursor: pointer;
|
||||||
|
border: 0;
|
||||||
|
background-color: inherit;
|
||||||
|
color: inherit;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dotted {
|
||||||
|
text-decoration-style: dotted;
|
||||||
|
text-decoration-line: underline;
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { Check, Clipboard } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { Dialog, Popover } from "react-aria-components";
|
import { Dialog, Popover } from "react-aria-components";
|
||||||
|
|
@ -6,8 +7,8 @@ import { useTranslation } from "react-i18next";
|
||||||
import { useCopyToClipboard } from "react-use";
|
import { useCopyToClipboard } from "react-use";
|
||||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||||
import { SendouButton } from "./elements/Button";
|
import { SendouButton } from "./elements/Button";
|
||||||
import { CheckmarkIcon } from "./icons/Checkmark";
|
import popoverStyles from "./elements/Popover.module.css";
|
||||||
import { ClipboardIcon } from "./icons/Clipboard";
|
import styles from "./TimePopover.module.css";
|
||||||
|
|
||||||
export default function TimePopover({
|
export default function TimePopover({
|
||||||
time,
|
time,
|
||||||
|
|
@ -54,8 +55,9 @@ export default function TimePopover({
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
className,
|
className,
|
||||||
"clickable text-only-button",
|
"clickable",
|
||||||
underline ? "dotted" : "",
|
styles.textOnlyButton,
|
||||||
|
underline ? styles.dotted : "",
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
|
|
@ -65,11 +67,11 @@ export default function TimePopover({
|
||||||
</button>
|
</button>
|
||||||
<Popover
|
<Popover
|
||||||
isOpen={open}
|
isOpen={open}
|
||||||
className={"sendou-popover-content"}
|
className={popoverStyles.content}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
triggerRef={triggerRef}
|
triggerRef={triggerRef}
|
||||||
>
|
>
|
||||||
<Dialog>
|
<Dialog className={popoverStyles.dialog}>
|
||||||
<div className="stack sm">
|
<div className="stack sm">
|
||||||
<div className="text-center" suppressHydrationWarning>
|
<div className="text-center" suppressHydrationWarning>
|
||||||
{formatTime(time, {
|
{formatTime(time, {
|
||||||
|
|
@ -82,7 +84,7 @@ export default function TimePopover({
|
||||||
size="miniscule"
|
size="miniscule"
|
||||||
variant="minimal"
|
variant="minimal"
|
||||||
onPress={() => copyToClipboard(`<t:${time.valueOf() / 1000}:F>`)}
|
onPress={() => copyToClipboard(`<t:${time.valueOf() / 1000}:F>`)}
|
||||||
icon={copySuccess ? <CheckmarkIcon /> : <ClipboardIcon />}
|
icon={copySuccess ? <Check /> : <Clipboard />}
|
||||||
>
|
>
|
||||||
{t("common:actions.copyTimestampForDiscord")}
|
{t("common:actions.copyTimestampForDiscord")}
|
||||||
</SendouButton>
|
</SendouButton>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,3 @@
|
||||||
.selectWidthWider {
|
|
||||||
--select-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item {
|
.item {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--s-2);
|
gap: var(--s-2);
|
||||||
|
|
|
||||||
|
|
@ -115,8 +115,6 @@ export function WeaponSelect<
|
||||||
search={{
|
search={{
|
||||||
placeholder: t("common:forms.weaponSearch.search.placeholder"),
|
placeholder: t("common:forms.weaponSearch.search.placeholder"),
|
||||||
}}
|
}}
|
||||||
className={styles.selectWidthWider}
|
|
||||||
popoverClassName={styles.selectWidthWider}
|
|
||||||
searchInputValue={filterValue}
|
searchInputValue={filterValue}
|
||||||
onSearchInputChange={setFilterValue}
|
onSearchInputChange={setFilterValue}
|
||||||
selectedKey={isControlled ? keyify(value) : undefined}
|
selectedKey={isControlled ? keyify(value) : undefined}
|
||||||
|
|
@ -141,7 +139,7 @@ export function WeaponSelect<
|
||||||
? specialWeaponImageUrl(TRIZOOKA_ID)
|
? specialWeaponImageUrl(TRIZOOKA_ID)
|
||||||
: weaponCategoryUrl(name)
|
: weaponCategoryUrl(name)
|
||||||
}
|
}
|
||||||
className={idx === 0 ? "pt-0-5-forced" : undefined}
|
className={idx === 0 ? "pt-0-5" : undefined}
|
||||||
key={key}
|
key={key}
|
||||||
>
|
>
|
||||||
{weapons.map(({ weapon, name }) => (
|
{weapons.map(({ weapon, name }) => (
|
||||||
|
|
|
||||||
18
app/components/YouTubeEmbed.module.css
Normal file
18
app/components/YouTubeEmbed.module.css
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 0;
|
||||||
|
padding-bottom: 56.25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.containerApi {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iframe {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toastQueue } from "./elements/Toast";
|
import { toastQueue } from "./elements/Toast";
|
||||||
|
import styles from "./YouTubeEmbed.module.css";
|
||||||
|
|
||||||
export function YouTubeEmbed({
|
export function YouTubeEmbed({
|
||||||
id,
|
id,
|
||||||
|
|
@ -117,16 +118,16 @@ export function YouTubeEmbed({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="youtube__container--api">
|
<div className={styles.containerApi}>
|
||||||
<div ref={containerRef} />
|
<div ref={containerRef} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="youtube__container">
|
<div className={styles.container}>
|
||||||
<iframe
|
<iframe
|
||||||
className="youtube__iframe"
|
className={styles.iframe}
|
||||||
src={`https://www.youtube.com/embed/${id}?autoplay=${
|
src={`https://www.youtube.com/embed/${id}?autoplay=${
|
||||||
autoplay ? "1" : "0"
|
autoplay ? "1" : "0"
|
||||||
}&controls=1&rel=0&modestbranding=1&start=${start ?? 0}`}
|
}&controls=1&rel=0&modestbranding=1&start=${start ?? 0}`}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { SendouFieldError } from "~/components/elements/FieldError";
|
import { SendouFieldError } from "~/components/elements/FieldError";
|
||||||
import { SendouFieldMessage } from "~/components/elements/FieldMessage";
|
import { SendouFieldMessage } from "~/components/elements/FieldMessage";
|
||||||
|
|
||||||
|
// TODO: deprecate in favor of FormMessage
|
||||||
export function SendouBottomTexts({
|
export function SendouBottomTexts({
|
||||||
bottomText,
|
bottomText,
|
||||||
errorText,
|
errorText,
|
||||||
|
|
|
||||||
|
|
@ -3,24 +3,26 @@
|
||||||
width: auto;
|
width: auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: 2px solid var(--theme);
|
border: var(--border-style-accent);
|
||||||
border-radius: var(--rounded-sm);
|
border-radius: var(--radius-field);
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background: var(--theme);
|
background: var(--color-text-accent);
|
||||||
color: var(--button-text);
|
color: var(--color-text-inverse);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: var(--fonts-sm);
|
font-size: var(--font-sm);
|
||||||
font-weight: var(--bold);
|
font-weight: var(--weight-bold);
|
||||||
line-height: 1.2;
|
padding: 0 var(--field-padding);
|
||||||
outline-offset: 2px;
|
|
||||||
padding-block: var(--s-1-5);
|
|
||||||
padding-inline: var(--s-2-5);
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
outline-color: var(--color-text-accent);
|
||||||
|
height: var(--field-size);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button[data-focus-visible],
|
.button[data-focus-visible],
|
||||||
.button:focus-visible {
|
.button:focus-visible {
|
||||||
outline: 2px solid var(--theme);
|
outline-style: solid;
|
||||||
|
outline-width: 2px;
|
||||||
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button[data-pressed],
|
.button[data-pressed],
|
||||||
|
|
@ -36,77 +38,94 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.outlined {
|
.outlined {
|
||||||
background-color: var(--theme-very-transparent);
|
background-color: transparent;
|
||||||
color: var(--theme);
|
color: var(--color-text-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.outlinedSuccess {
|
.outlinedSuccess {
|
||||||
border-color: var(--theme-success);
|
border-color: var(--color-success);
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: var(--theme-success);
|
color: var(--color-success);
|
||||||
|
outline-color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outlinedDestructive {
|
||||||
|
border-color: var(--color-error);
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--color-error);
|
||||||
|
outline-color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.small {
|
.small {
|
||||||
font-size: var(--fonts-xs);
|
font-size: var(--font-xs);
|
||||||
padding-block: var(--s-1);
|
height: var(--field-size-sm);
|
||||||
padding-inline: var(--s-2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.miniscule {
|
.miniscule {
|
||||||
font-size: var(--fonts-xxs);
|
font-size: var(--font-2xs);
|
||||||
padding-block: var(--s-1);
|
height: var(--field-size-xs);
|
||||||
padding-inline: var(--s-2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.big {
|
.big {
|
||||||
font-size: var(--fonts-md);
|
font-size: var(--font-md);
|
||||||
padding-block: var(--s-2-5);
|
height: var(--field-size-lg);
|
||||||
padding-inline: var(--s-6);
|
}
|
||||||
|
|
||||||
|
.square {
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle {
|
||||||
|
border-radius: 50%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.minimal {
|
.minimal {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: var(--theme);
|
color: var(--color-text-accent);
|
||||||
outline: initial;
|
outline: initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
.minimal[data-focus-visible] {
|
.minimal[data-focus-visible] {
|
||||||
outline: 2px solid var(--theme);
|
outline: var(--focus-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
.minimalSuccess {
|
.minimalSuccess {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: var(--theme-success);
|
color: var(--color-success);
|
||||||
|
outline-color: var(--color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.success {
|
.success {
|
||||||
border-color: var(--theme-success);
|
border-color: var(--color-success);
|
||||||
background-color: var(--theme-success);
|
background-color: var(--color-success);
|
||||||
outline-color: var(--theme-success);
|
outline-color: var(--color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.destructive {
|
.destructive {
|
||||||
border-color: var(--theme-error);
|
border-color: var(--color-error);
|
||||||
background-color: transparent;
|
background-color: var(--color-error);
|
||||||
color: var(--theme-error);
|
color: var(--color-text-inverse);
|
||||||
outline-color: var(--theme-error);
|
outline-color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.minimalDestructive {
|
.minimalDestructive {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: var(--theme-error);
|
color: var(--color-error);
|
||||||
outline-color: var(--theme-error);
|
outline-color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.buttonIcon {
|
.buttonIcon {
|
||||||
min-width: 1.25rem;
|
min-width: 20px;
|
||||||
max-width: 1.25rem;
|
max-width: 20px;
|
||||||
margin-inline-end: var(--s-1-5);
|
margin-inline-end: var(--s-1-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,13 +134,19 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.buttonIconSmall {
|
.buttonIconSmall {
|
||||||
min-width: 1rem;
|
min-width: 18px;
|
||||||
max-width: 1rem;
|
max-width: 18px;
|
||||||
margin-inline-end: var(--s-1);
|
margin-inline-end: var(--s-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.buttonIconMiniscule {
|
.buttonIconMiniscule {
|
||||||
min-width: 0.857rem;
|
min-width: 14px;
|
||||||
max-width: 0.857rem;
|
max-width: 14px;
|
||||||
margin-inline-end: var(--s-1);
|
margin-inline-end: var(--s-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.buttonIconBig {
|
||||||
|
min-width: 28px;
|
||||||
|
max-width: 28px;
|
||||||
|
margin-inline-end: var(--s-2);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,10 @@ import styles from "./Button.module.css";
|
||||||
type ButtonVariant =
|
type ButtonVariant =
|
||||||
| "primary"
|
| "primary"
|
||||||
| "success"
|
| "success"
|
||||||
|
| "destructive"
|
||||||
| "outlined"
|
| "outlined"
|
||||||
| "outlined-success"
|
| "outlined-success"
|
||||||
| "destructive"
|
| "outlined-destructive"
|
||||||
| "minimal"
|
| "minimal"
|
||||||
| "minimal-success"
|
| "minimal-success"
|
||||||
| "minimal-destructive";
|
| "minimal-destructive";
|
||||||
|
|
@ -24,6 +25,7 @@ export interface SendouButtonProps
|
||||||
className?: string;
|
className?: string;
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
size?: "miniscule" | "small" | "medium" | "big";
|
size?: "miniscule" | "small" | "medium" | "big";
|
||||||
|
shape?: "circle" | "square";
|
||||||
icon?: JSX.Element;
|
icon?: JSX.Element;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
@ -32,6 +34,7 @@ export function SendouButton({
|
||||||
children,
|
children,
|
||||||
variant,
|
variant,
|
||||||
size,
|
size,
|
||||||
|
shape,
|
||||||
className,
|
className,
|
||||||
icon,
|
icon,
|
||||||
...rest
|
...rest
|
||||||
|
|
@ -39,7 +42,7 @@ export function SendouButton({
|
||||||
return (
|
return (
|
||||||
<ReactAriaButton
|
<ReactAriaButton
|
||||||
{...rest}
|
{...rest}
|
||||||
className={buttonClassName({ className, variant, size })}
|
className={buttonClassName({ className, variant, size, shape })}
|
||||||
>
|
>
|
||||||
{icon &&
|
{icon &&
|
||||||
React.cloneElement(icon, {
|
React.cloneElement(icon, {
|
||||||
|
|
@ -58,6 +61,7 @@ export interface LinkButtonProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
variant?: SendouButtonProps["variant"];
|
variant?: SendouButtonProps["variant"];
|
||||||
size?: SendouButtonProps["size"];
|
size?: SendouButtonProps["size"];
|
||||||
|
shape?: SendouButtonProps["shape"];
|
||||||
icon?: JSX.Element;
|
icon?: JSX.Element;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
|
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
|
||||||
|
|
@ -72,6 +76,7 @@ export function LinkButton({
|
||||||
className,
|
className,
|
||||||
variant,
|
variant,
|
||||||
size,
|
size,
|
||||||
|
shape,
|
||||||
icon,
|
icon,
|
||||||
children,
|
children,
|
||||||
onClick,
|
onClick,
|
||||||
|
|
@ -80,7 +85,7 @@ export function LinkButton({
|
||||||
if (isExternal) {
|
if (isExternal) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
className={buttonClassName({ className, variant, size })}
|
className={buttonClassName({ className, variant, size, shape })}
|
||||||
href={to as string}
|
href={to as string}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
|
|
@ -98,11 +103,12 @@ export function LinkButton({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
className={buttonClassName({ className, variant, size })}
|
className={buttonClassName({ className, variant, size, shape })}
|
||||||
to={to}
|
to={to}
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
prefetch={prefetch}
|
prefetch={prefetch}
|
||||||
preventScrollReset={preventScrollReset}
|
preventScrollReset={preventScrollReset}
|
||||||
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{icon &&
|
{icon &&
|
||||||
React.cloneElement(icon, {
|
React.cloneElement(icon, {
|
||||||
|
|
@ -117,19 +123,22 @@ function buttonClassName({
|
||||||
className,
|
className,
|
||||||
variant,
|
variant,
|
||||||
size,
|
size,
|
||||||
}: Pick<SendouButtonProps, "className" | "variant" | "size">) {
|
shape,
|
||||||
|
}: Pick<SendouButtonProps, "className" | "variant" | "size" | "shape">) {
|
||||||
const variantToClassname = (variant: ButtonVariant) => {
|
const variantToClassname = (variant: ButtonVariant) => {
|
||||||
switch (variant) {
|
switch (variant) {
|
||||||
case "primary":
|
case "primary":
|
||||||
return styles.primary;
|
return styles.primary;
|
||||||
case "success":
|
case "success":
|
||||||
return styles.success;
|
return styles.success;
|
||||||
|
case "destructive":
|
||||||
|
return styles.destructive;
|
||||||
case "outlined":
|
case "outlined":
|
||||||
return styles.outlined;
|
return styles.outlined;
|
||||||
case "outlined-success":
|
case "outlined-success":
|
||||||
return styles.outlinedSuccess;
|
return styles.outlinedSuccess;
|
||||||
case "destructive":
|
case "outlined-destructive":
|
||||||
return styles.destructive;
|
return styles.outlinedDestructive;
|
||||||
case "minimal":
|
case "minimal":
|
||||||
return styles.minimal;
|
return styles.minimal;
|
||||||
case "minimal-success":
|
case "minimal-success":
|
||||||
|
|
@ -150,6 +159,10 @@ function buttonClassName({
|
||||||
[styles.big]: size === "big",
|
[styles.big]: size === "big",
|
||||||
[styles.miniscule]: size === "miniscule",
|
[styles.miniscule]: size === "miniscule",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
[styles.circle]: shape === "circle",
|
||||||
|
[styles.square]: shape === "square",
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,5 +175,6 @@ function iconClassName(
|
||||||
[styles.lonely]: !children,
|
[styles.lonely]: !children,
|
||||||
[styles.buttonIconSmall]: size === "small",
|
[styles.buttonIconSmall]: size === "small",
|
||||||
[styles.buttonIconMiniscule]: size === "miniscule",
|
[styles.buttonIconMiniscule]: size === "miniscule",
|
||||||
|
[styles.buttonIconBig]: size === "big",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
80
app/components/elements/Calendar.module.css
Normal file
80
app/components/elements/Calendar.module.css
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
.root {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border-radius: var(--radius-box);
|
||||||
|
padding: var(--s-4);
|
||||||
|
border: var(--border-style);
|
||||||
|
max-width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s-2);
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-block-end: var(--s-1);
|
||||||
|
min-width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
font-size: var(--font-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navButton {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--color-text-accent);
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 100%;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: var(--focus-ring);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navIcon {
|
||||||
|
width: 27.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerCell {
|
||||||
|
color: var(--color-text-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
padding: var(--s-1-5);
|
||||||
|
width: 35px;
|
||||||
|
height: 35px;
|
||||||
|
border-radius: var(--radius-field);
|
||||||
|
outline-color: var(--color-accent);
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: var(--focus-ring);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell[data-outside-month] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell[data-disabled] {
|
||||||
|
opacity: 0.5;
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell[data-selected] {
|
||||||
|
background-color: var(--color-bg-high);
|
||||||
|
color: var(--color-text-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell:hover {
|
||||||
|
background-color: var(--color-bg-high);
|
||||||
|
outline: initial;
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Calendar,
|
Calendar,
|
||||||
CalendarCell,
|
CalendarCell,
|
||||||
CalendarGrid,
|
CalendarGrid,
|
||||||
|
CalendarGridBody,
|
||||||
|
CalendarGridHeader,
|
||||||
|
CalendarHeaderCell,
|
||||||
type CalendarProps,
|
type CalendarProps,
|
||||||
type DateValue,
|
type DateValue,
|
||||||
Heading,
|
Heading,
|
||||||
} from "react-aria-components";
|
} from "react-aria-components";
|
||||||
import { ArrowLeftIcon } from "~/components/icons/ArrowLeft";
|
import styles from "./Calendar.module.css";
|
||||||
import { ArrowRightIcon } from "~/components/icons/ArrowRight";
|
|
||||||
|
|
||||||
export interface SendouCalendarProps<T extends DateValue>
|
export interface SendouCalendarProps<T extends DateValue>
|
||||||
extends CalendarProps<T> {
|
extends CalendarProps<T> {
|
||||||
|
|
@ -21,20 +24,33 @@ export function SendouCalendar<T extends DateValue>({
|
||||||
...rest
|
...rest
|
||||||
}: SendouCalendarProps<T>) {
|
}: SendouCalendarProps<T>) {
|
||||||
return (
|
return (
|
||||||
<Calendar className={clsx(className, "react-aria-Calendar")} {...rest}>
|
<Calendar className={clsx(className, styles.root)} {...rest}>
|
||||||
<header>
|
<header className={styles.header}>
|
||||||
<Button slot="previous">
|
<Button slot="previous" className={styles.navButton}>
|
||||||
<ArrowLeftIcon />
|
<ChevronLeft className={styles.navIcon} />
|
||||||
</Button>
|
</Button>
|
||||||
<Heading />
|
<Heading className={styles.heading} />
|
||||||
<Button slot="next">
|
<Button slot="next" className={styles.navButton}>
|
||||||
<ArrowRightIcon />
|
<ChevronRight className={styles.navIcon} />
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
<CalendarGrid>
|
<CalendarGrid className={styles.grid}>
|
||||||
{(date) => {
|
<CalendarGridHeader>
|
||||||
return <CalendarCell date={date} data-testid="choose-date-button" />;
|
{(day) => (
|
||||||
}}
|
<CalendarHeaderCell className={styles.headerCell}>
|
||||||
|
{day}
|
||||||
|
</CalendarHeaderCell>
|
||||||
|
)}
|
||||||
|
</CalendarGridHeader>
|
||||||
|
<CalendarGridBody>
|
||||||
|
{(date) => (
|
||||||
|
<CalendarCell
|
||||||
|
date={date}
|
||||||
|
className={styles.cell}
|
||||||
|
data-testid="choose-date-button"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CalendarGridBody>
|
||||||
</CalendarGrid>
|
</CalendarGrid>
|
||||||
</Calendar>
|
</Calendar>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
47
app/components/elements/ChipRadio.module.css
Normal file
47
app/components/elements/ChipRadio.module.css
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
.group {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s-1);
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group[data-orientation="vertical"] {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
font-weight: var(--weight-semi);
|
||||||
|
padding: 0 var(--s-2);
|
||||||
|
height: var(--selector-size);
|
||||||
|
border-radius: var(--radius-selector);
|
||||||
|
background-color: var(--color-bg-higher);
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio:focus-visible + .label {
|
||||||
|
outline: var(--focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio:checked + .label {
|
||||||
|
background-color: var(--color-accent-high);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
}
|
||||||
58
app/components/elements/ChipRadio.tsx
Normal file
58
app/components/elements/ChipRadio.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import styles from "./ChipRadio.module.css";
|
||||||
|
|
||||||
|
interface SendouChipRadioGroupProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SendouChipRadioProps {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SendouChipRadioGroup({
|
||||||
|
children,
|
||||||
|
orientation = "horizontal",
|
||||||
|
className,
|
||||||
|
}: SendouChipRadioGroupProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className ? `${styles.group} ${className}` : styles.group}
|
||||||
|
data-orientation={orientation}
|
||||||
|
role="radiogroup"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SendouChipRadio({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
children,
|
||||||
|
}: SendouChipRadioProps) {
|
||||||
|
const id = `chip-radio-${name}-${value}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => onChange(value)}
|
||||||
|
className={styles.radio}
|
||||||
|
/>
|
||||||
|
<label htmlFor={id} className={styles.label}>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
app/components/elements/DatePicker.module.css
Normal file
48
app/components/elements/DatePicker.module.css
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
.group {
|
||||||
|
height: var(--field-size);
|
||||||
|
padding: 0 var(--field-padding);
|
||||||
|
border: var(--border-style);
|
||||||
|
border-radius: var(--radius-field);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
outline: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s-1-5);
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root[data-open] .group {
|
||||||
|
border-color: transparent;
|
||||||
|
outline: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dateInput {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment:not([data-type="literal"]) {
|
||||||
|
padding: 0 var(--s-0-5);
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment:focus-visible {
|
||||||
|
background-color: var(--color-bg-high);
|
||||||
|
color: var(--color-text-accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 0;
|
||||||
|
margin-inline-start: auto;
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: var(--color-text-high);
|
||||||
|
width: var(--field-size-icon);
|
||||||
|
height: var(--field-size-icon);
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { Calendar } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
DateInput,
|
DateInput,
|
||||||
|
|
@ -12,7 +13,7 @@ import {
|
||||||
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
|
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
|
||||||
import { SendouCalendar } from "~/components/elements/Calendar";
|
import { SendouCalendar } from "~/components/elements/Calendar";
|
||||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||||
import { CalendarIcon } from "../icons/Calendar";
|
import styles from "./DatePicker.module.css";
|
||||||
import { SendouLabel } from "./Label";
|
import { SendouLabel } from "./Label";
|
||||||
|
|
||||||
interface SendouDatePickerProps<T extends DateValue>
|
interface SendouDatePickerProps<T extends DateValue>
|
||||||
|
|
@ -51,14 +52,17 @@ export function SendouDatePicker<T extends DateValue>({
|
||||||
<ReactAriaDatePicker
|
<ReactAriaDatePicker
|
||||||
{...rest}
|
{...rest}
|
||||||
validationBehavior="aria"
|
validationBehavior="aria"
|
||||||
aria-label={label}
|
className={styles.root}
|
||||||
isInvalid={!!errorText}
|
|
||||||
>
|
>
|
||||||
<SendouLabel required={isRequired}>{label}</SendouLabel>
|
<SendouLabel required={isRequired}>{label}</SendouLabel>
|
||||||
<Group className="react-aria-Group">
|
<Group className={styles.group}>
|
||||||
<DateInput>{(segment) => <DateSegment segment={segment} />}</DateInput>
|
<DateInput className={styles.dateInput}>
|
||||||
<Button data-testid="open-calendar-button">
|
{(segment) => (
|
||||||
<CalendarIcon />
|
<DateSegment segment={segment} className={styles.segment} />
|
||||||
|
)}
|
||||||
|
</DateInput>
|
||||||
|
<Button data-testid="open-calendar-button" className={styles.button}>
|
||||||
|
<Calendar className={styles.icon} />
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
<SendouBottomTexts
|
<SendouBottomTexts
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
overflow-y: auto;
|
overflow: hidden;
|
||||||
background-color: rgba(0, 0, 0, 0.25);
|
background-color: rgba(0, 0, 0, 0.25);
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
|
|
@ -11,7 +11,6 @@
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
backdrop-filter: blur(10px); /* Adjust blur value as needed */
|
backdrop-filter: blur(10px); /* Adjust blur value as needed */
|
||||||
padding-block: var(--s-32);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fullScreenOverlay {
|
.fullScreenOverlay {
|
||||||
|
|
@ -35,10 +34,12 @@
|
||||||
.modal {
|
.modal {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 28rem;
|
max-width: 28rem;
|
||||||
overflow: hidden;
|
max-height: 80dvh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
background-color: var(--bg-lighter-solid);
|
border: 1px solid var(--color-border);
|
||||||
border: 2.5px solid var(--border);
|
background-color: var(--color-bg);
|
||||||
padding: var(--s-6);
|
padding: var(--s-6);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
|
@ -76,7 +77,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.headingContainer {
|
.headingContainer {
|
||||||
border-bottom: 2px solid var(--border);
|
border-bottom: var(--border-style);
|
||||||
padding-block-end: var(--s-2);
|
padding-block-end: var(--s-2);
|
||||||
margin-block-end: var(--s-4);
|
margin-block-end: var(--s-4);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -90,5 +91,5 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.heading {
|
.heading {
|
||||||
font-size: var(--fonts-lg);
|
font-size: var(--font-lg);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { X } from "lucide-react";
|
||||||
import type { ModalOverlayProps } from "react-aria-components";
|
import type { ModalOverlayProps } from "react-aria-components";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -10,7 +11,6 @@ import {
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import * as R from "remeda";
|
import * as R from "remeda";
|
||||||
import { SendouButton } from "~/components/elements/Button";
|
import { SendouButton } from "~/components/elements/Button";
|
||||||
import { CrossIcon } from "~/components/icons/Cross";
|
|
||||||
import styles from "./Dialog.module.css";
|
import styles from "./Dialog.module.css";
|
||||||
|
|
||||||
interface SendouDialogProps extends ModalOverlayProps {
|
interface SendouDialogProps extends ModalOverlayProps {
|
||||||
|
|
@ -118,7 +118,7 @@ function DialogModal({
|
||||||
{...overlayProps}
|
{...overlayProps}
|
||||||
>
|
>
|
||||||
<Modal
|
<Modal
|
||||||
className={clsx(className, styles.modal, {
|
className={clsx(className, styles.modal, "scrollbar", {
|
||||||
[styles.fullScreenModal]: rest.isFullScreen,
|
[styles.fullScreenModal]: rest.isFullScreen,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
|
@ -136,7 +136,8 @@ function DialogModal({
|
||||||
) : null}
|
) : null}
|
||||||
{showCloseButton ? (
|
{showCloseButton ? (
|
||||||
<SendouButton
|
<SendouButton
|
||||||
icon={<CrossIcon />}
|
icon={<X />}
|
||||||
|
shape="circle"
|
||||||
variant="minimal-destructive"
|
variant="minimal-destructive"
|
||||||
className="ml-auto"
|
className="ml-auto"
|
||||||
slot="close"
|
slot="close"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { FieldError as ReactAriaFieldError } from "react-aria-components";
|
import { FieldError as ReactAriaFieldError } from "react-aria-components";
|
||||||
|
import styles from "../FormMessage.module.css";
|
||||||
|
|
||||||
export function SendouFieldError({
|
export function SendouFieldError({
|
||||||
children,
|
children,
|
||||||
|
|
@ -8,7 +9,7 @@ export function SendouFieldError({
|
||||||
id?: string;
|
id?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ReactAriaFieldError className="error-message" id={id}>
|
<ReactAriaFieldError className={styles.error} id={id}>
|
||||||
{children}
|
{children}
|
||||||
</ReactAriaFieldError>
|
</ReactAriaFieldError>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Text } from "react-aria-components";
|
import { Text } from "react-aria-components";
|
||||||
|
import styles from "../FormMessage.module.css";
|
||||||
|
|
||||||
export function SendouFieldMessage({
|
export function SendouFieldMessage({
|
||||||
children,
|
children,
|
||||||
|
|
@ -6,7 +7,7 @@ export function SendouFieldMessage({
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Text slot="description" className="info-message">
|
<Text slot="description" className={styles.info}>
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
6
app/components/elements/Label.module.css
Normal file
6
app/components/elements/Label.module.css
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
.label {
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
font-weight: var(--weight-bold);
|
||||||
|
margin-block-end: var(--label-margin);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Label as ReactAriaLabel } from "react-aria-components";
|
import { Label as ReactAriaLabel } from "react-aria-components";
|
||||||
|
import styles from "./Label.module.css";
|
||||||
|
|
||||||
export function SendouLabel({
|
export function SendouLabel({
|
||||||
children,
|
children,
|
||||||
|
|
@ -8,8 +9,8 @@ export function SendouLabel({
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ReactAriaLabel>
|
<ReactAriaLabel className={styles.label}>
|
||||||
{children} {required && <span className="text-error">*</span>}
|
{children} {required ? <span className="text-error">*</span> : null}
|
||||||
</ReactAriaLabel>
|
</ReactAriaLabel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,74 +1,71 @@
|
||||||
.itemsContainer {
|
.popover {
|
||||||
position: absolute;
|
border-radius: var(--radius-box);
|
||||||
top: 32px;
|
background-color: var(--color-bg-high);
|
||||||
border-radius: var(--rounded);
|
|
||||||
background-color: var(--bg-darker);
|
|
||||||
border: var(--border-style);
|
border: var(--border-style);
|
||||||
z-index: 10;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
align-items: flex-start;
|
|
||||||
width: max-content;
|
width: max-content;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: var(--weight-semi);
|
||||||
|
padding: var(--s-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemsContainerOpensLeft {
|
.popoverOpensLeft {
|
||||||
right: 0;
|
right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrolling {
|
.scrolling {
|
||||||
max-height: 300px !important;
|
max-height: 300px !important;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-color: rgb(83 65 91) transparent;
|
}
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-gutter: stable;
|
.itemsContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s-0-5);
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.item {
|
.item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-weight: var(--bold);
|
font-weight: var(--weight-bold);
|
||||||
font-size: var(--fonts-xs);
|
font-size: var(--font-xs);
|
||||||
color: var(--text);
|
color: var(--color-text);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
gap: var(--s-2);
|
gap: var(--s-2);
|
||||||
border-radius: var(--rounded-xs);
|
border-radius: var(--radius-field);
|
||||||
padding: var(--s-1-5) var(--s-2-5);
|
padding: var(--s-1-5) var(--s-3);
|
||||||
background-color: var(--bg-darker);
|
background-color: var(--color-bg-high);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 0;
|
border: 0;
|
||||||
outline: none;
|
outline: none;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
transition: background-color 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
.item:first-child {
|
|
||||||
border-radius: 14.5px 14.5px var(--rounded-xs) var(--rounded-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item:last-child {
|
|
||||||
border-radius: var(--rounded-xs) var(--rounded-xs) 14.5px 14.5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.item[data-focused] {
|
.item[data-focused] {
|
||||||
background-color: var(--theme-very-transparent);
|
background-color: var(--color-bg-higher);
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemDisabled {
|
.itemDisabled {
|
||||||
color: var(--text-lighter);
|
color: var(--color-text-high);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemSelected {
|
.itemSelected {
|
||||||
background-color: var(--theme-transparent);
|
background-color: var(--color-text-accent);
|
||||||
font-weight: var(--extra-bold);
|
font-weight: var(--weight-extra);
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemActive {
|
.itemActive {
|
||||||
color: var(--theme);
|
color: var(--color-text-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemDestructive {
|
||||||
|
color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemIcon {
|
.itemIcon {
|
||||||
|
|
@ -76,6 +73,13 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemImg {
|
.itemImg {
|
||||||
min-width: 24px;
|
min-width: 20px;
|
||||||
min-height: 24px;
|
min-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuHeader {
|
||||||
|
font-size: var(--font-2xs);
|
||||||
|
color: var(--color-text-high);
|
||||||
|
padding: var(--s-1) var(--s-3);
|
||||||
|
font-weight: var(--weight-semi);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
|
Header,
|
||||||
Menu,
|
Menu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
type MenuItemProps,
|
type MenuItemProps,
|
||||||
MenuTrigger,
|
MenuTrigger,
|
||||||
Popover,
|
Popover,
|
||||||
|
type PopoverProps,
|
||||||
|
Section,
|
||||||
} from "react-aria-components";
|
} from "react-aria-components";
|
||||||
import { Image } from "../Image";
|
import { Image } from "../Image";
|
||||||
import styles from "./Menu.module.css";
|
import styles from "./Menu.module.css";
|
||||||
|
|
@ -14,6 +17,8 @@ interface SendouMenuProps {
|
||||||
scrolling?: boolean;
|
scrolling?: boolean;
|
||||||
opensLeft?: boolean;
|
opensLeft?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
popoverClassName?: string;
|
||||||
|
placement?: PopoverProps["placement"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SendouMenu({
|
export function SendouMenu({
|
||||||
|
|
@ -21,17 +26,19 @@ export function SendouMenu({
|
||||||
trigger,
|
trigger,
|
||||||
opensLeft,
|
opensLeft,
|
||||||
scrolling,
|
scrolling,
|
||||||
|
placement,
|
||||||
}: SendouMenuProps) {
|
}: SendouMenuProps) {
|
||||||
return (
|
return (
|
||||||
<MenuTrigger>
|
<MenuTrigger>
|
||||||
{trigger}
|
{trigger}
|
||||||
<Popover
|
<Popover
|
||||||
className={clsx(styles.itemsContainer, {
|
placement={placement}
|
||||||
|
className={clsx(styles.popover, "scrollbar", {
|
||||||
[styles.scrolling]: scrolling,
|
[styles.scrolling]: scrolling,
|
||||||
[styles.itemsContainerOpensLeft]: !opensLeft,
|
[styles.popoverOpensLeft]: !opensLeft,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Menu>{children}</Menu>
|
<Menu className={styles.itemsContainer}>{children}</Menu>
|
||||||
</Popover>
|
</Popover>
|
||||||
</MenuTrigger>
|
</MenuTrigger>
|
||||||
);
|
);
|
||||||
|
|
@ -41,6 +48,24 @@ export interface SendouMenuItemProps extends MenuItemProps {
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
imagePath?: string;
|
imagePath?: string;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
isDestructive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SendouMenuSection({
|
||||||
|
children,
|
||||||
|
headerText,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
headerText?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Section>
|
||||||
|
{headerText ? (
|
||||||
|
<Header className={styles.menuHeader}>{headerText}</Header>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SendouMenuItem(props: SendouMenuItemProps) {
|
export function SendouMenuItem(props: SendouMenuItemProps) {
|
||||||
|
|
@ -56,6 +81,7 @@ export function SendouMenuItem(props: SendouMenuItemProps) {
|
||||||
[styles.itemSelected]: isSelected,
|
[styles.itemSelected]: isSelected,
|
||||||
[styles.itemDisabled]: isDisabled,
|
[styles.itemDisabled]: isDisabled,
|
||||||
[styles.itemActive]: props.isActive,
|
[styles.itemActive]: props.isActive,
|
||||||
|
[styles.itemDestructive]: props.isDestructive,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -68,8 +94,8 @@ export function SendouMenuItem(props: SendouMenuItemProps) {
|
||||||
<Image
|
<Image
|
||||||
path={props.imagePath}
|
path={props.imagePath}
|
||||||
alt=""
|
alt=""
|
||||||
width={24}
|
width={20}
|
||||||
height={24}
|
height={20}
|
||||||
className={styles.itemImg}
|
className={styles.itemImg}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
14
app/components/elements/Popover.module.css
Normal file
14
app/components/elements/Popover.module.css
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
.content {
|
||||||
|
max-width: 20rem;
|
||||||
|
padding: var(--s-2);
|
||||||
|
border: var(--border-style);
|
||||||
|
border-radius: var(--radius-box);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: var(--weight-semi);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
Popover,
|
Popover,
|
||||||
type PopoverProps,
|
type PopoverProps,
|
||||||
} from "react-aria-components";
|
} from "react-aria-components";
|
||||||
|
import styles from "./Popover.module.css";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A reusable popover component that wraps around a trigger element (SendouButton or Button from React Aria Components library).
|
* A reusable popover component that wraps around a trigger element (SendouButton or Button from React Aria Components library).
|
||||||
|
|
@ -35,14 +36,13 @@ export function SendouPopover({
|
||||||
isOpen?: boolean;
|
isOpen?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DialogTrigger isOpen={isOpen}>
|
<DialogTrigger isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||||
{trigger}
|
{trigger}
|
||||||
<Popover
|
<Popover
|
||||||
className={clsx("sendou-popover-content", popoverClassName)}
|
className={clsx(styles.content, popoverClassName)}
|
||||||
placement={placement}
|
placement={placement}
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
>
|
>
|
||||||
<Dialog>{children}</Dialog>
|
<Dialog className={styles.dialog}>{children}</Dialog>
|
||||||
</Popover>
|
</Popover>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,58 @@
|
||||||
.button {
|
.button {
|
||||||
height: 1rem;
|
height: var(--field-size);
|
||||||
padding: var(--s-4) var(--s-3);
|
padding: 0 var(--field-padding);
|
||||||
border: 2px solid var(--border);
|
border: var(--border-style);
|
||||||
border-radius: var(--rounded-sm);
|
border-radius: var(--radius-field);
|
||||||
accent-color: var(--theme-secondary);
|
background-color: var(--color-bg);
|
||||||
background-color: var(--bg-input);
|
|
||||||
color: var(--text);
|
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--s-1-5);
|
gap: var(--s-1-5);
|
||||||
width: var(--select-width);
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
font-size: var(--fonts-xs);
|
&[data-focus-visible],
|
||||||
font-weight: var(--semi-bold);
|
&[aria-expanded="true"] {
|
||||||
letter-spacing: 0.5px;
|
outline: var(--focus-ring);
|
||||||
}
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.button[data-focus-visible] {
|
&[data-disabled] {
|
||||||
outline: 2px solid var(--theme);
|
pointer-events: none;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.selectValue {
|
.selectValue {
|
||||||
max-width: calc(var(--select-width) - 55px);
|
font-size: var(--font-sm);
|
||||||
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
min-width: 0;
|
||||||
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.selectValue[data-placeholder] {
|
.selectValue[data-placeholder] {
|
||||||
color: var(--text-lighter);
|
color: var(--color-text-high);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
min-width: 18px;
|
min-width: 18px;
|
||||||
max-width: 18px;
|
max-width: 18px;
|
||||||
stroke-width: 2.5px;
|
color: var(--color-text-high);
|
||||||
color: var(--text-lighter);
|
|
||||||
}
|
|
||||||
|
|
||||||
.smallIcon {
|
|
||||||
min-width: 16px;
|
|
||||||
max-width: 16px;
|
|
||||||
stroke-width: 2px;
|
|
||||||
color: var(--text-lighter);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.popover {
|
.popover {
|
||||||
padding: var(--s-1);
|
padding: var(--s-1);
|
||||||
width: var(--trigger-width);
|
width: var(--trigger-width);
|
||||||
border: 2px solid var(--border);
|
border: var(--border-style);
|
||||||
border-radius: var(--rounded);
|
border-radius: var(--radius-box);
|
||||||
background-color: var(--bg-darker);
|
background-color: var(--color-bg);
|
||||||
max-height: 250px !important;
|
outline: none;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -63,35 +61,30 @@
|
||||||
.listBox {
|
.listBox {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
scrollbar-color: rgb(83 65 91) rgba(83 65 91 / 0.3);
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-gutter: stable;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.item {
|
.item {
|
||||||
font-size: var(--fonts-xsm);
|
font-size: var(--font-sm);
|
||||||
font-weight: var(--semi-bold);
|
font-weight: var(--weight-semi);
|
||||||
padding: var(--s-1-5);
|
padding: var(--s-1-5);
|
||||||
border-radius: var(--rounded-sm);
|
border-radius: var(--radius-field);
|
||||||
height: 33px;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item[data-disabled] {
|
.item[data-disabled] {
|
||||||
color: var(--text-lighter);
|
color: var(--color-text-high);
|
||||||
font-style: italic;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemFocused {
|
.itemFocused {
|
||||||
background-color: var(--theme-transparent);
|
background-color: var(--color-bg-high);
|
||||||
color: var(--text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemSelected {
|
.itemSelected {
|
||||||
color: var(--theme);
|
color: var(--color-text-accent);
|
||||||
font-weight: var(--bold);
|
font-weight: var(--weight-bold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchField {
|
.searchField {
|
||||||
|
|
@ -99,20 +92,20 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--s-2);
|
gap: var(--s-2);
|
||||||
|
|
||||||
border: 2px solid var(--border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
border-radius: var(--rounded-sm);
|
border-radius: 0;
|
||||||
accent-color: var(--theme-secondary);
|
accent-color: var(--color-accent);
|
||||||
background-color: var(--bg-input);
|
color: var(--color-text);
|
||||||
color: var(--text);
|
|
||||||
outline: none;
|
outline: none;
|
||||||
padding: var(--s-1-5) var(--s-2);
|
padding: var(--s-1-5) var(--s-1-5) calc(var(--s-0-5) + var(--s-2))
|
||||||
|
var(--s-1-5);
|
||||||
margin-block-end: var(--s-1-5);
|
margin-block-end: var(--s-1-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchInput {
|
.searchInput {
|
||||||
all: unset;
|
all: unset;
|
||||||
font-size: var(--fonts-xxs);
|
font-size: var(--font-xs);
|
||||||
font-weight: var(--semi-bold);
|
font-weight: var(--weight-semi);
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
@ -122,7 +115,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchInput::placeholder {
|
.searchInput::placeholder {
|
||||||
color: var(--text-lighter);
|
color: var(--color-text-high);
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchClearButton {
|
.searchClearButton {
|
||||||
|
|
@ -141,20 +134,28 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.noResults {
|
.noResults {
|
||||||
font-size: var(--fonts-md);
|
font-size: var(--font-md);
|
||||||
font-weight: var(--bold);
|
font-weight: var(--weight-bold);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding-block: var(--s-8);
|
padding-block: var(--s-8);
|
||||||
color: var(--text-lighter);
|
color: var(--color-text-high);
|
||||||
}
|
}
|
||||||
|
|
||||||
.select {
|
.select {
|
||||||
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
font-weight: var(--weight-bold);
|
||||||
|
margin-block-end: var(--label-margin);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.clearButton {
|
.clearButton {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: -17px;
|
bottom: -21px;
|
||||||
right: 9px;
|
right: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,18 +163,18 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--s-2);
|
gap: var(--s-2);
|
||||||
font-weight: bold;
|
font-weight: var(--weight-extra);
|
||||||
color: var(--text-lighter);
|
color: var(--color-text-high);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: var(--fonts-xxs);
|
font-size: var(--font-2xs);
|
||||||
padding-block-start: var(--s-2-5);
|
padding-block-start: var(--s-3);
|
||||||
padding-block-end: var(--s-1);
|
padding-block-end: var(--s-1);
|
||||||
padding-inline: var(--s-1-5);
|
padding-inline: var(--s-1-5);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.categoryDivider {
|
.categoryDivider {
|
||||||
background-color: var(--border);
|
background-color: var(--color-border);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
margin-block: var(--s-2);
|
margin-block: var(--s-2);
|
||||||
|
|
@ -181,7 +182,7 @@
|
||||||
|
|
||||||
.categoryHeading img {
|
.categoryHeading img {
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
background-color: var(--bg-lightest);
|
background-color: var(--color-bg-higher);
|
||||||
padding: var(--s-1);
|
padding: var(--s-1);
|
||||||
min-width: 28px;
|
min-width: 28px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { ChevronsUpDown, Search, X } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import type {
|
import type {
|
||||||
AutocompleteProps,
|
AutocompleteProps,
|
||||||
|
|
@ -26,10 +27,7 @@ import {
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
|
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
|
||||||
import { SendouButton } from "~/components/elements/Button";
|
import { SendouButton } from "~/components/elements/Button";
|
||||||
import { ChevronUpDownIcon } from "~/components/icons/ChevronUpDown";
|
|
||||||
import { Image } from "../Image";
|
import { Image } from "../Image";
|
||||||
import { CrossIcon } from "../icons/Cross";
|
|
||||||
import { SearchIcon } from "../icons/Search";
|
|
||||||
import styles from "./Select.module.css";
|
import styles from "./Select.module.css";
|
||||||
|
|
||||||
export interface SendouSelectProps<T extends object>
|
export interface SendouSelectProps<T extends object>
|
||||||
|
|
@ -101,11 +99,11 @@ export function SendouSelect<T extends object>({
|
||||||
className={clsx(className, styles.select)}
|
className={clsx(className, styles.select)}
|
||||||
onOpenChange={handleOpenChange}
|
onOpenChange={handleOpenChange}
|
||||||
>
|
>
|
||||||
{label ? <Label>{label}</Label> : null}
|
{label ? <Label className={styles.label}>{label}</Label> : null}
|
||||||
<Button className={styles.button}>
|
<Button className={styles.button}>
|
||||||
<SelectValue className={styles.selectValue} />
|
<SelectValue className={styles.selectValue} />
|
||||||
<span aria-hidden="true">
|
<span aria-hidden="true">
|
||||||
<ChevronUpDownIcon className={styles.icon} />
|
<ChevronsUpDown className={styles.icon} />
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
{clearable ? <SelectClearButton /> : null}
|
{clearable ? <SelectClearButton /> : null}
|
||||||
|
|
@ -122,20 +120,20 @@ export function SendouSelect<T extends object>({
|
||||||
autoFocus
|
autoFocus
|
||||||
className={styles.searchField}
|
className={styles.searchField}
|
||||||
>
|
>
|
||||||
<SearchIcon aria-hidden className={styles.smallIcon} />
|
<Search aria-hidden className={styles.icon} />
|
||||||
<Input
|
<Input
|
||||||
placeholder={search.placeholder}
|
placeholder={search.placeholder}
|
||||||
className={clsx("plain", styles.searchInput)}
|
className={clsx(styles.searchInput, "in-container")}
|
||||||
/>
|
/>
|
||||||
<Button className={styles.searchClearButton}>
|
<Button className={styles.searchClearButton}>
|
||||||
<CrossIcon className={styles.smallIcon} />
|
<X className={styles.icon} />
|
||||||
</Button>
|
</Button>
|
||||||
</SearchField>
|
</SearchField>
|
||||||
) : null}
|
) : null}
|
||||||
<Virtualizer layout={ListLayout} layoutOptions={{ rowHeight: 33 }}>
|
<Virtualizer layout={ListLayout} layoutOptions={{ rowHeight: 33 }}>
|
||||||
<ListBox
|
<ListBox
|
||||||
items={items}
|
items={items}
|
||||||
className={styles.listBox}
|
className={clsx(styles.listBox, "scrollbar")}
|
||||||
renderEmptyState={() => (
|
renderEmptyState={() => (
|
||||||
<div className={styles.noResults}>{t("common:noResults")}</div>
|
<div className={styles.noResults}>{t("common:noResults")}</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -203,7 +201,7 @@ function SelectClearButton() {
|
||||||
slot={null}
|
slot={null}
|
||||||
variant="minimal-destructive"
|
variant="minimal-destructive"
|
||||||
size="miniscule"
|
size="miniscule"
|
||||||
icon={<CrossIcon />}
|
icon={<X />}
|
||||||
onPress={() => state?.setSelectedKey(null)}
|
onPress={() => state?.setSelectedKey(null)}
|
||||||
className={styles.clearButton}
|
className={styles.clearButton}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
60
app/components/elements/Switch.module.css
Normal file
60
app/components/elements/Switch.module.css
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
.root {
|
||||||
|
--height: var(--selector-size-sm);
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.571rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
forced-color-adjust: none;
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
font-weight: var(--weight-bold);
|
||||||
|
margin-block-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator {
|
||||||
|
width: calc(var(--height) * 1.75);
|
||||||
|
height: var(--height);
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: var(--border-style);
|
||||||
|
border-radius: calc(var(--radius-selector) * 2);
|
||||||
|
padding: 2px;
|
||||||
|
display: inline-grid;
|
||||||
|
justify-items: center;
|
||||||
|
grid-template-columns: 0fr 1fr 1fr;
|
||||||
|
transition: grid-template-columns 200ms;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
content: "";
|
||||||
|
height: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: var(--radius-selector);
|
||||||
|
grid-row-start: 1;
|
||||||
|
grid-column-start: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.root[data-selected] .indicator {
|
||||||
|
background: var(--color-text-accent);
|
||||||
|
border-color: var(--color-text-accent);
|
||||||
|
grid-template-columns: 1fr 1fr 0fr;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
background: var(--color-text-inverse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.root[data-focus-visible] .indicator {
|
||||||
|
outline: var(--focus-ring);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root[data-disabled] .indicator {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root[data-disabled] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
@ -1,21 +1,17 @@
|
||||||
import clsx from "clsx";
|
|
||||||
import {
|
import {
|
||||||
Switch as ReactAriaSwitch,
|
Switch as ReactAriaSwitch,
|
||||||
type SwitchProps as ReactAriaSwitchProps,
|
type SwitchProps as ReactAriaSwitchProps,
|
||||||
} from "react-aria-components";
|
} from "react-aria-components";
|
||||||
|
import styles from "./Switch.module.css";
|
||||||
|
|
||||||
interface SendouSwitchProps extends ReactAriaSwitchProps {
|
interface SendouSwitchProps extends ReactAriaSwitchProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
size?: "small" | "medium";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SendouSwitch({ children, size, ...rest }: SendouSwitchProps) {
|
export function SendouSwitch({ children, ...rest }: SendouSwitchProps) {
|
||||||
return (
|
return (
|
||||||
<ReactAriaSwitch
|
<ReactAriaSwitch {...rest} className={styles.root}>
|
||||||
{...rest}
|
<div className={styles.indicator} />
|
||||||
className={clsx("react-aria-Switch", { small: size === "small" })}
|
|
||||||
>
|
|
||||||
<div className="indicator" />
|
|
||||||
{children}
|
{children}
|
||||||
</ReactAriaSwitch>
|
</ReactAriaSwitch>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,23 @@
|
||||||
|
.tabListContainer {
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.tabList {
|
.tabList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
border-bottom: 2px solid var(--border);
|
border-bottom: 2px solid var(--color-border);
|
||||||
|
min-width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullWidth {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullWidth .tabContainer {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabList svg {
|
.tabList svg {
|
||||||
|
|
@ -23,38 +39,48 @@
|
||||||
padding-block-start: var(--s-4);
|
padding-block-start: var(--s-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.disappearing:has(.tabList .tabButton:only-child).padded .tabPanel {
|
.disappearing:has(.tabList > div:only-child).padded .tabPanel {
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.disappearing .tabList:has(.tabButton:only-child) {
|
.disappearing .tabList:has(> div:only-child) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tabContainer {
|
||||||
|
min-width: fit-content;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: var(--focus-ring);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.tabButton {
|
.tabButton {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: var(--fonts-xs);
|
font-size: var(--font-xs);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
color: var(--text-lighter);
|
color: var(--color-text-high);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
transform: none !important;
|
transform: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabButton[data-selected] {
|
.tabContainer[data-selected] .tabButton {
|
||||||
border-color: var(--theme);
|
border-color: var(--color-text-accent);
|
||||||
color: var(--text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabButton[data-focus-visible] {
|
.tabContainer[data-focus-visible] .tabButton {
|
||||||
color: var(--theme) !important;
|
color: var(--color-text-accent) !important;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabNumber {
|
.tabNumber {
|
||||||
color: var(--theme);
|
color: var(--color-text-accent);
|
||||||
margin-inline-start: var(--s-2);
|
margin-inline-start: var(--s-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,5 +88,5 @@
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 47px;
|
top: 47px;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
background-color: var(--bg);
|
background-color: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,37 +73,41 @@ interface SendouTabProps extends TabProps {
|
||||||
|
|
||||||
export function SendouTab({ icon, children, number, ...rest }: SendouTabProps) {
|
export function SendouTab({ icon, children, number, ...rest }: SendouTabProps) {
|
||||||
return (
|
return (
|
||||||
<Tab className={clsx(buttonStyles.button, styles.tabButton)} {...rest}>
|
<Tab className={styles.tabContainer} {...rest}>
|
||||||
{icon}
|
<div className={clsx(buttonStyles.button, styles.tabButton)}>
|
||||||
{children}
|
{icon}
|
||||||
{typeof number === "number" && number !== 0 && (
|
{children}
|
||||||
<span className={styles.tabNumber}>{number}</span>
|
{typeof number === "number" && number !== 0 && (
|
||||||
)}
|
<span className={styles.tabNumber}>{number}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Tab>
|
</Tab>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SendouTabListProps<T extends object> extends TabListProps<T> {
|
interface SendouTabListProps<T extends object> extends TabListProps<T> {
|
||||||
/** Should overflow-x: auto CSS rule be applied? Defaults to true */
|
|
||||||
scrolling?: boolean;
|
|
||||||
sticky?: boolean;
|
sticky?: boolean;
|
||||||
|
/** Should tabs take 100% width with equal distribution? */
|
||||||
|
fullWidth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SendouTabList<T extends object>({
|
export function SendouTabList<T extends object>({
|
||||||
scrolling = true,
|
|
||||||
sticky,
|
sticky,
|
||||||
|
fullWidth,
|
||||||
...rest
|
...rest
|
||||||
}: SendouTabListProps<T>) {
|
}: SendouTabListProps<T>) {
|
||||||
return (
|
return (
|
||||||
<TabList
|
<div className={clsx(styles.tabListContainer, "scrollbar")}>
|
||||||
className={clsx(styles.tabList, {
|
<TabList
|
||||||
"overflow-x-auto": scrolling,
|
className={clsx(styles.tabList, {
|
||||||
// invisible: cantSwitchTabs && !disappearing,
|
// invisible: cantSwitchTabs && !disappearing,
|
||||||
// hidden: cantSwitchTabs && disappearing,
|
// hidden: cantSwitchTabs && disappearing,
|
||||||
[styles.sticky]: sticky,
|
[styles.sticky]: sticky,
|
||||||
})}
|
[styles.fullWidth]: fullWidth,
|
||||||
{...rest}
|
})}
|
||||||
/>
|
{...rest}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
43
app/components/elements/Toast.browser.test.tsx
Normal file
43
app/components/elements/Toast.browser.test.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { render } from "vitest-browser-react";
|
||||||
|
import { SendouToastRegion, toastQueue } from "./Toast";
|
||||||
|
|
||||||
|
function Wrapper() {
|
||||||
|
return <SendouToastRegion />;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Toast", () => {
|
||||||
|
test("renders success toast", async () => {
|
||||||
|
const screen = await render(<Wrapper />);
|
||||||
|
|
||||||
|
toastQueue.add(
|
||||||
|
{ message: "Operation completed", variant: "success" },
|
||||||
|
{ timeout: 5000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect.element(screen.getByText("Operation completed")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders error toast", async () => {
|
||||||
|
const screen = await render(<Wrapper />);
|
||||||
|
|
||||||
|
toastQueue.add({ message: "Something went wrong", variant: "error" });
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.element(screen.getByText("Something went wrong"))
|
||||||
|
.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dismisses toast when close button is clicked", async () => {
|
||||||
|
const screen = await render(<Wrapper />);
|
||||||
|
|
||||||
|
toastQueue.add({ message: "Dismiss me", variant: "info" }, { timeout: 0 });
|
||||||
|
|
||||||
|
const toast = screen.getByRole("alertdialog", { name: "Dismiss me" });
|
||||||
|
await expect.element(toast).toBeVisible();
|
||||||
|
|
||||||
|
await screen.getByLabelText("Close").first().click();
|
||||||
|
|
||||||
|
await expect.element(toast).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user