mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 09:54:36 -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/
|
||||
.env
|
||||
translation-progress.md
|
||||
**/*/__screenshots__
|
||||
|
||||
notes.md
|
||||
|
||||
|
|
@ -21,11 +20,17 @@ dump
|
|||
/scripts/output/*
|
||||
!/scripts/output/.gitkeep
|
||||
|
||||
/scripts/dicts/**/*.json
|
||||
/scripts/dicts/**/*
|
||||
/scripts/badge
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/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
|
||||
|
||||
notepad.txt
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@
|
|||
- 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`
|
||||
- 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
|
||||
|
||||
|
|
|
|||
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 { 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/normalize.css";
|
||||
import "~/styles/common.css";
|
||||
import "~/styles/utils.css";
|
||||
import "~/styles/flags.css";
|
||||
|
||||
document.documentElement.classList.add("dark");
|
||||
|
||||
i18next.use(initReactI18next).init({
|
||||
...config,
|
||||
|
|
|
|||
|
|
@ -24,9 +24,9 @@
|
|||
|
||||
.abilityButton {
|
||||
padding: var(--s-0-5);
|
||||
border-color: var(--abilities-button-bg);
|
||||
border: var(--border-style);
|
||||
border-radius: 50%;
|
||||
background-color: var(--abilities-button-bg);
|
||||
background-color: var(--color-bg-ability);
|
||||
}
|
||||
|
||||
.abilityButton.isDragging {
|
||||
|
|
|
|||
|
|
@ -2,20 +2,19 @@
|
|||
width: var(--ability-size);
|
||||
height: var(--ability-size);
|
||||
padding: 0;
|
||||
border: 2px solid var(--theme-transparent);
|
||||
border: var(--border-style-high);
|
||||
border-radius: 50%;
|
||||
border-right: 0;
|
||||
border-bottom: 0;
|
||||
background: var(--bg-ability);
|
||||
background: var(--color-bg-ability);
|
||||
background-size: 100%;
|
||||
box-shadow: 0 0 0 1px var(--bg-ability);
|
||||
transform: scale(1);
|
||||
transition: all 0.1s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.isDragTarget {
|
||||
background: var(--abilities-button-bg);
|
||||
background: var(--color-bg-ability);
|
||||
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 { Check, CircleAlert, OctagonAlert, TriangleAlert } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { AlertIcon } from "./icons/Alert";
|
||||
import { CheckmarkIcon } from "./icons/Checkmark";
|
||||
import { ErrorIcon } from "./icons/Error";
|
||||
import styles from "./Alert.module.css";
|
||||
|
||||
export type AlertVariation = "INFO" | "WARNING" | "ERROR" | "SUCCESS";
|
||||
|
||||
|
|
@ -22,11 +21,11 @@ export function Alert({
|
|||
}) {
|
||||
return (
|
||||
<div
|
||||
className={clsx("alert", alertClassName, {
|
||||
tiny,
|
||||
warning: variation === "WARNING",
|
||||
error: variation === "ERROR",
|
||||
success: variation === "SUCCESS",
|
||||
className={clsx(styles.alert, alertClassName, {
|
||||
[styles.tiny]: tiny,
|
||||
[styles.warning]: variation === "WARNING",
|
||||
[styles.error]: variation === "ERROR",
|
||||
[styles.success]: variation === "SUCCESS",
|
||||
})}
|
||||
>
|
||||
<Icon variation={variation} />{" "}
|
||||
|
|
@ -38,13 +37,13 @@ export function Alert({
|
|||
function Icon({ variation }: { variation: AlertVariation }) {
|
||||
switch (variation) {
|
||||
case "INFO":
|
||||
return <AlertIcon />;
|
||||
return <CircleAlert />;
|
||||
case "WARNING":
|
||||
return <AlertIcon />;
|
||||
return <TriangleAlert />;
|
||||
case "ERROR":
|
||||
return <ErrorIcon />;
|
||||
return <OctagonAlert />;
|
||||
case "SUCCESS":
|
||||
return <CheckmarkIcon />;
|
||||
return <Check />;
|
||||
default:
|
||||
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 * as React from "react";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { BLANK_IMAGE_URL, discordAvatarUrl } from "~/utils/urls";
|
||||
import styles from "./Avatar.module.css";
|
||||
|
||||
const dimensions = {
|
||||
xxxs: 16,
|
||||
xxxsm: 20,
|
||||
xxs: 24,
|
||||
xxsm: 32,
|
||||
xs: 36,
|
||||
sm: 44,
|
||||
xsm: 62,
|
||||
|
|
@ -15,49 +18,141 @@ const dimensions = {
|
|||
lg: 125,
|
||||
} 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({
|
||||
user,
|
||||
url,
|
||||
identiconInput,
|
||||
size = "sm",
|
||||
className,
|
||||
alt = "",
|
||||
...rest
|
||||
}: {
|
||||
user?: Pick<Tables["User"], "discordId" | "discordAvatar">;
|
||||
url?: string;
|
||||
url?: string | null;
|
||||
identiconInput?: string;
|
||||
className?: string;
|
||||
alt?: string;
|
||||
size: keyof typeof dimensions;
|
||||
} & React.ButtonHTMLAttributes<HTMLImageElement>) {
|
||||
const [isErrored, setIsErrored] = React.useState(false);
|
||||
// TODO: just show text... my profile?
|
||||
// TODO: also show this if discordAvatar is stale and 404's
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const isClient = useIsMounted();
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: every avatar error state is unique and we want to avoid using key on every avatar
|
||||
React.useEffect(() => {
|
||||
setIsErrored(false);
|
||||
}, [user?.discordAvatar]);
|
||||
const isIdenticon =
|
||||
!url && (!user?.discordAvatar || isErrored || identiconInput);
|
||||
|
||||
const src =
|
||||
url ??
|
||||
(user?.discordAvatar && !isErrored
|
||||
const identiconSource = identiconInput ?? user?.discordId ?? "unknown";
|
||||
|
||||
const src = url
|
||||
? url
|
||||
: user?.discordAvatar && !isErrored
|
||||
? discordAvatarUrl({
|
||||
discordAvatar: user.discordAvatar,
|
||||
discordId: user.discordId,
|
||||
size: size === "lg" || size === "xmd" ? "lg" : "sm",
|
||||
})
|
||||
: BLANK_IMAGE_URL); // avoid broken image placeholder
|
||||
: isClient
|
||||
? generateIdenticon(identiconSource, dimensions[size], 7)
|
||||
: BLANK_IMAGE_URL;
|
||||
|
||||
return (
|
||||
<img
|
||||
className={clsx("avatar", className)}
|
||||
src={src}
|
||||
alt={alt}
|
||||
title={alt ? alt : undefined}
|
||||
width={dimensions[size]}
|
||||
height={dimensions[size]}
|
||||
onError={() => setIsErrored(true)}
|
||||
{...rest}
|
||||
/>
|
||||
<div className={clsx(styles.avatarWrapper, className)}>
|
||||
<img
|
||||
className={clsx({
|
||||
[styles.identicon]: isIdenticon,
|
||||
[styles.loaded]: loaded,
|
||||
})}
|
||||
src={src}
|
||||
alt={alt}
|
||||
title={alt ? alt : undefined}
|
||||
width={dimensions[size]}
|
||||
height={dimensions[size]}
|
||||
onError={() => setIsErrored(true)}
|
||||
onLoad={() => setLoaded(true)}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,34 +3,28 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--s-3);
|
||||
border-radius: var(--rounded);
|
||||
background-color: var(--bg-lighter);
|
||||
border-radius: var(--radius-box);
|
||||
background-color: var(--color-bg-high);
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
.private {
|
||||
background-color: var(--bg-lighter-transparent);
|
||||
background-color: var(--color-bg-higher);
|
||||
}
|
||||
|
||||
.privateText {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
font-weight: var(--semi-bold);
|
||||
font-weight: var(--weight-semi);
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.privateIcon {
|
||||
width: 16px;
|
||||
margin-block-end: var(--s-1);
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
.title {
|
||||
overflow: hidden;
|
||||
height: 2.5rem;
|
||||
font-size: var(--fonts-sm);
|
||||
font-size: var(--font-sm);
|
||||
line-height: 1.25;
|
||||
word-wrap: break-all;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.topRow {
|
||||
|
|
@ -40,7 +34,7 @@
|
|||
|
||||
.dateAuthorRow {
|
||||
display: flex;
|
||||
font-size: var(--fonts-xxs);
|
||||
font-size: var(--font-2xs);
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
|
|
@ -63,7 +57,12 @@
|
|||
position: relative;
|
||||
padding: var(--s-0-5);
|
||||
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 {
|
||||
|
|
@ -74,9 +73,9 @@
|
|||
|
||||
.weaponText {
|
||||
padding-left: var(--s-1);
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-xxs);
|
||||
font-weight: var(--semi-bold);
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.weapons {
|
||||
|
|
@ -104,7 +103,7 @@
|
|||
|
||||
.gear {
|
||||
border-radius: 50%;
|
||||
background-color: var(--bg-darker-very-transparent);
|
||||
background-color: var(--color-bg);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
|
|
@ -113,14 +112,9 @@
|
|||
height: 100%;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.smallText {
|
||||
font-size: var(--fonts-xxs) !important;
|
||||
font-size: var(--font-2xs) !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import clsx from "clsx";
|
||||
import { Lock, MessageCircleMore, SquarePen, Trash } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import type { GearType, Tables, UserWithPlusTier } from "~/db/tables";
|
||||
|
|
@ -30,10 +31,6 @@ import { LinkButton, SendouButton } from "./elements/Button";
|
|||
import { SendouPopover } from "./elements/Popover";
|
||||
import { FormWithConfirm } from "./FormWithConfirm";
|
||||
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 {
|
||||
build: Pick<
|
||||
|
|
@ -119,11 +116,10 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
|
|||
<div>•</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="stack horizontal sm">
|
||||
<div className="stack horizontal sm items-center">
|
||||
{build.private ? (
|
||||
<div className={styles.privateText}>
|
||||
<LockIcon className={styles.privateIcon} />{" "}
|
||||
{t("common:build.private")}
|
||||
<Lock size={16} /> {t("common:build.private")}
|
||||
</div>
|
||||
) : null}
|
||||
<time
|
||||
|
|
@ -172,24 +168,30 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
|
|||
/>
|
||||
</div>
|
||||
<div className={styles.bottomRow}>
|
||||
<Link
|
||||
<LinkButton
|
||||
to={analyzerPage({
|
||||
weaponId: weapons[0].weaponSplId,
|
||||
abilities: abilities.flat(),
|
||||
})}
|
||||
shape="circle"
|
||||
variant="minimal"
|
||||
size="small"
|
||||
>
|
||||
<Image
|
||||
size={24}
|
||||
alt={t("common:pages.analyzer")}
|
||||
className={styles.icon}
|
||||
path={navIconUrl("analyzer")}
|
||||
/>
|
||||
</Link>
|
||||
</LinkButton>
|
||||
{description ? (
|
||||
<SendouPopover
|
||||
trigger={
|
||||
<SendouButton
|
||||
shape="circle"
|
||||
size="small"
|
||||
variant="minimal"
|
||||
icon={<SpeechBubbleIcon />}
|
||||
icon={<MessageCircleMore />}
|
||||
className={styles.smallText}
|
||||
/>
|
||||
}
|
||||
|
|
@ -200,14 +202,14 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
|
|||
{canEdit && (
|
||||
<>
|
||||
<LinkButton
|
||||
shape="circle"
|
||||
className={styles.smallText}
|
||||
variant="minimal"
|
||||
size="small"
|
||||
to={`new?buildId=${id}&userId=${user!.id}`}
|
||||
testId="edit-build"
|
||||
>
|
||||
<EditIcon className={styles.icon} />
|
||||
</LinkButton>
|
||||
icon={<SquarePen />}
|
||||
/>
|
||||
<FormWithConfirm
|
||||
dialogHeading={t("builds:deleteConfirm", { title })}
|
||||
fields={[
|
||||
|
|
@ -216,7 +218,9 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
|
|||
]}
|
||||
>
|
||||
<SendouButton
|
||||
icon={<TrashIcon className={styles.icon} />}
|
||||
shape="circle"
|
||||
size="small"
|
||||
icon={<Trash />}
|
||||
className={styles.smallText}
|
||||
variant="minimal-destructive"
|
||||
type="submit"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { RefreshCcw } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
isRouteErrorResponse,
|
||||
|
|
@ -14,7 +15,6 @@ import {
|
|||
} from "~/utils/urls";
|
||||
import { SendouButton } from "./elements/Button";
|
||||
import { Image } from "./Image";
|
||||
import { RefreshArrowsIcon } from "./icons/RefreshArrows";
|
||||
import { Main } from "./Main";
|
||||
|
||||
export function Catcher() {
|
||||
|
|
@ -165,7 +165,7 @@ function RefreshPageButton() {
|
|||
return (
|
||||
<SendouButton
|
||||
onPress={() => window.location.reload()}
|
||||
icon={<RefreshArrowsIcon />}
|
||||
icon={<RefreshCcw />}
|
||||
>
|
||||
Refresh page
|
||||
</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 { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import styles from "./Chart.module.css";
|
||||
|
||||
export default function Chart({
|
||||
options,
|
||||
|
|
@ -62,11 +63,11 @@ export default function Chart({
|
|||
);
|
||||
|
||||
if (!isMounted) {
|
||||
return <div className={clsx("chart__container", containerClassName)} />;
|
||||
return <div className={clsx(styles.container, containerClassName)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx("chart__container", containerClassName)}>
|
||||
<div className={clsx(styles.container, containerClassName)}>
|
||||
<ReactChart
|
||||
options={{
|
||||
data: options,
|
||||
|
|
@ -85,9 +86,9 @@ export default function Chart({
|
|||
secondaryAxes,
|
||||
dark: theme.htmlThemeClass === Theme.DARK,
|
||||
defaultColors: [
|
||||
"var(--theme)",
|
||||
"var(--theme-secondary)",
|
||||
"var(--theme-info)",
|
||||
"var(--color-text-accent)",
|
||||
"var(--color-accent)",
|
||||
"var(--color-info)",
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
|
@ -124,19 +125,19 @@ function ChartTooltip({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="chart__tooltip">
|
||||
<div className={styles.tooltip}>
|
||||
<h3 className="text-center text-md">
|
||||
{header()}
|
||||
{headerSuffix}
|
||||
</h3>
|
||||
{dataPoints.map((dataPoint, index) => {
|
||||
const color = dataPoint.style?.fill ?? "var(--theme)";
|
||||
const color = dataPoint.style?.fill ?? "var(--color-accent)";
|
||||
|
||||
return (
|
||||
<div key={index} className="stack horizontal items-center sm">
|
||||
<div
|
||||
className={clsx("chart__dot", {
|
||||
chart__dot__focused:
|
||||
className={clsx(styles.dot, {
|
||||
[styles.dotFocused]:
|
||||
focusedDatum?.seriesId === dataPoint.seriesId,
|
||||
})}
|
||||
style={{
|
||||
|
|
@ -144,10 +145,8 @@ function ChartTooltip({
|
|||
"--dot-color-outline": color.replace(")", "-transparent)"),
|
||||
}}
|
||||
/>
|
||||
<div className="chart__tooltip__label">
|
||||
{dataPoint.originalSeries.label}
|
||||
</div>
|
||||
<div className="chart__tooltip__value">
|
||||
<div>{dataPoint.originalSeries.label}</div>
|
||||
<div className={styles.tooltipValue}>
|
||||
{dataPoint.secondaryValue}
|
||||
{valueSuffix}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { Check, Clipboard } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCopyToClipboard } from "react-use";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import { CheckmarkIcon } from "~/components/icons/Checkmark";
|
||||
import { ClipboardIcon } from "~/components/icons/Clipboard";
|
||||
|
||||
interface CopyToClipboardPopoverProps {
|
||||
url: string;
|
||||
|
|
@ -36,7 +35,7 @@ export function CopyToClipboardPopover({
|
|||
size="miniscule"
|
||||
variant="minimal"
|
||||
onPress={() => copyToClipboard(url)}
|
||||
icon={copySuccess ? <CheckmarkIcon /> : <ClipboardIcon />}
|
||||
icon={copySuccess ? <Check /> : <Clipboard />}
|
||||
>
|
||||
{t("common:actions.copyToClipboard")}
|
||||
</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 styles from "./Divider.module.css";
|
||||
|
||||
export function Divider({
|
||||
children,
|
||||
|
|
@ -10,7 +11,11 @@ export function Divider({
|
|||
smallText?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx("divider", className, { "text-sm": smallText })}>
|
||||
<div
|
||||
className={clsx(styles.divider, className, {
|
||||
[styles.smallText]: smallText,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</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 { useActionData } from "react-router";
|
||||
import type { Namespace } from "~/modules/i18n/resources.server";
|
||||
import styles from "./FormErrors.module.css";
|
||||
|
||||
export function FormErrors({ namespace }: { namespace: Namespace }) {
|
||||
const { t } = useTranslation(["common", namespace]);
|
||||
|
|
@ -11,7 +12,7 @@ export function FormErrors({ namespace }: { namespace: Namespace }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="form-errors">
|
||||
<div className={styles.container}>
|
||||
<h4>{t("common:forms.errors.title")}:</h4>
|
||||
<ol>
|
||||
{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 type * as React from "react";
|
||||
import styles from "./FormMessage.module.css";
|
||||
|
||||
export function FormMessage({
|
||||
children,
|
||||
|
|
@ -18,8 +19,8 @@ export function FormMessage({
|
|||
<div
|
||||
id={id}
|
||||
className={clsx(
|
||||
{ "info-message": type === "info", "error-message": type === "error" },
|
||||
{ "no-margin": !spaced },
|
||||
{ [styles.info]: type === "info", [styles.error]: type === "error" },
|
||||
{ [styles.noMargin]: !spaced },
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export function FriendCodeInput({
|
|||
|
||||
return (
|
||||
<fetcher.Form method="post" action={SENDOUQ_PAGE}>
|
||||
<input type="hidden" name="revalidateRoot" value="true" />
|
||||
<div
|
||||
className={clsx("stack sm horizontal items-end", {
|
||||
"justify-center": friendCode,
|
||||
|
|
@ -34,8 +35,7 @@ export function FriendCodeInput({
|
|||
<Label htmlFor={id}>{t("common:fc.title")}</Label>
|
||||
<InfoPopover tiny>
|
||||
<div className="stack sm">
|
||||
<div>{t("common:fc.helpText")}</div>
|
||||
<div className="text-lighter text-xs font-bold">
|
||||
<div className="text-xs font-bold">
|
||||
{t("common:fc.whereToFind")}
|
||||
</div>
|
||||
<Image
|
||||
|
|
@ -62,7 +62,7 @@ export function FriendCodeInput({
|
|||
</div>
|
||||
{!friendCode ? (
|
||||
<SubmitButton _action="ADD_FRIEND_CODE" state={fetcher.state}>
|
||||
Save
|
||||
{t("common:actions.save")}
|
||||
</SubmitButton>
|
||||
) : null}
|
||||
</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={{
|
||||
placeholder: t("common:forms.gearSearch.search.placeholder"),
|
||||
}}
|
||||
className={styles.selectWidthWider}
|
||||
popoverClassName={styles.selectWidthWider}
|
||||
selectedKey={value}
|
||||
defaultSelectedKey={initialValue}
|
||||
onSelectionChange={(value) => onChange?.(value as any)}
|
||||
|
|
@ -57,7 +55,7 @@ export function GearSelect<Clearable extends boolean | undefined = undefined>({
|
|||
>
|
||||
{({ key, items: gear, brandId, idx }) => (
|
||||
<SendouSelectItemSection
|
||||
className={idx === 0 ? "pt-0-5-forced" : undefined}
|
||||
className={idx === 0 ? "pt-0-5" : undefined}
|
||||
heading={t(`game-misc:BRAND_${brandId}` as any)}
|
||||
headingImgPath={brandImageUrl(brandId)}
|
||||
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,
|
||||
tierImageUrl,
|
||||
} from "~/utils/urls";
|
||||
import styles from "./Image.module.css";
|
||||
|
||||
interface ImageProps {
|
||||
path: string;
|
||||
|
|
@ -229,14 +230,14 @@ export function TierImage({ tier, className, width = 200 }: TierImageProps) {
|
|||
const height = width * 0.8675;
|
||||
|
||||
return (
|
||||
<div className={clsx("tier__container", className)} style={{ width }}>
|
||||
<div className={clsx(styles.tierContainer, className)} style={{ width }}>
|
||||
<Image
|
||||
path={tierImageUrl(tier.name)}
|
||||
width={width}
|
||||
height={height}
|
||||
alt={title}
|
||||
title={title}
|
||||
containerClassName="tier__img"
|
||||
containerClassName={styles.tierImg}
|
||||
/>
|
||||
{tier.isPlus ? (
|
||||
<Image
|
||||
|
|
@ -245,7 +246,7 @@ export function TierImage({ tier, className, width = 200 }: TierImageProps) {
|
|||
height={height}
|
||||
alt={title}
|
||||
title={title}
|
||||
containerClassName="tier__img"
|
||||
containerClassName={styles.tierImg}
|
||||
/>
|
||||
) : null}
|
||||
</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 { Button } from "react-aria-components";
|
||||
import { SendouPopover } from "./elements/Popover";
|
||||
import styles from "./InfoPopover.module.css";
|
||||
|
||||
export function InfoPopover({
|
||||
children,
|
||||
|
|
@ -15,14 +16,9 @@ export function InfoPopover({
|
|||
<SendouPopover
|
||||
trigger={
|
||||
<Button
|
||||
className={clsx(
|
||||
"react-aria-Button",
|
||||
"info-popover__trigger",
|
||||
className,
|
||||
{
|
||||
"info-popover__trigger__tiny": tiny,
|
||||
},
|
||||
)}
|
||||
className={clsx(styles.trigger, className, {
|
||||
[styles.triggerTiny]: tiny,
|
||||
})}
|
||||
>
|
||||
?
|
||||
</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 styles from "./Input.module.css";
|
||||
|
||||
export function Input({
|
||||
name,
|
||||
|
|
@ -20,8 +21,10 @@ export function Input({
|
|||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
disableAutoComplete = false,
|
||||
readOnly,
|
||||
ref,
|
||||
}: {
|
||||
name?: string;
|
||||
id?: string;
|
||||
|
|
@ -42,17 +45,21 @@ export function Input({
|
|||
value?: string;
|
||||
placeholder?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
disableAutoComplete?: boolean;
|
||||
readOnly?: boolean;
|
||||
ref?: React.Ref<HTMLInputElement>;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={clsx("input-container", className, {
|
||||
"input__read-only": readOnly,
|
||||
className={clsx(styles.container, className, {
|
||||
[styles.readOnly]: readOnly,
|
||||
})}
|
||||
>
|
||||
{leftAddon ? <div className="input-addon">{leftAddon}</div> : null}
|
||||
{leftAddon ? <div className={styles.addon}>{leftAddon}</div> : null}
|
||||
<input
|
||||
ref={ref}
|
||||
className="in-container"
|
||||
name={name}
|
||||
id={id}
|
||||
minLength={minLength}
|
||||
|
|
@ -65,6 +72,7 @@ export function Input({
|
|||
data-testid={testId}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
aria-label={ariaLabel}
|
||||
required={required}
|
||||
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 styles from "./Label.module.css";
|
||||
|
||||
type LabelProps = Pick<
|
||||
React.DetailedHTMLProps<
|
||||
|
|
@ -27,12 +28,12 @@ export function Label({
|
|||
spaced = true,
|
||||
}: LabelProps) {
|
||||
return (
|
||||
<div className={clsx("label__container", className, { "mb-0": !spaced })}>
|
||||
<div className={clsx(styles.container, className, { "mb-0": !spaced })}>
|
||||
<label htmlFor={htmlFor} className={labelClassName}>
|
||||
{children} {required && <span className="text-error">*</span>}
|
||||
</label>
|
||||
{valueLimits ? (
|
||||
<div className={clsx("label__value", lengthWarning(valueLimits))}>
|
||||
<div className={clsx(styles.value, lengthWarning(valueLimits))}>
|
||||
{valueLimits.current}/{valueLimits.max}
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -41,8 +42,8 @@ export function Label({
|
|||
}
|
||||
|
||||
function lengthWarning(valueLimits: NonNullable<LabelProps["valueLimits"]>) {
|
||||
if (valueLimits.current > valueLimits.max) return "error";
|
||||
if (valueLimits.current / valueLimits.max >= 0.9) return "warning";
|
||||
if (valueLimits.current > valueLimits.max) return styles.valueError;
|
||||
if (valueLimits.current / valueLimits.max >= 0.9) return styles.valueWarning;
|
||||
|
||||
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 type * as React from "react";
|
||||
import { isRouteErrorResponse, useRouteError } from "react-router";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import styles from "./Main.module.css";
|
||||
|
||||
export const Main = ({
|
||||
children,
|
||||
|
|
@ -18,49 +17,40 @@ export const Main = ({
|
|||
bigger?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}) => {
|
||||
const error = useRouteError();
|
||||
const isMinorSupporter = useHasRole("MINOR_SUPPORT");
|
||||
const showLeaderboard =
|
||||
import.meta.env.VITE_PLAYWIRE_PUBLISHER_ID &&
|
||||
!isMinorSupporter &&
|
||||
!isRouteErrorResponse(error);
|
||||
|
||||
return (
|
||||
<div className="layout__main-container">
|
||||
<main
|
||||
className={
|
||||
classNameOverwrite
|
||||
? clsx(classNameOverwrite, {
|
||||
[containerClassName("narrow")]: halfWidth,
|
||||
"pt-8-forced": showLeaderboard,
|
||||
})
|
||||
: clsx(
|
||||
"layout__main",
|
||||
containerClassName("normal"),
|
||||
{
|
||||
[containerClassName("narrow")]: halfWidth,
|
||||
[containerClassName("wide")]: bigger,
|
||||
"pt-8-forced": showLeaderboard,
|
||||
},
|
||||
className,
|
||||
)
|
||||
}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
<main
|
||||
className={
|
||||
classNameOverwrite
|
||||
? clsx(classNameOverwrite, {
|
||||
[styles.narrow]: halfWidth,
|
||||
})
|
||||
: clsx(
|
||||
styles.main,
|
||||
styles.normal,
|
||||
{
|
||||
[styles.narrow]: halfWidth,
|
||||
[styles.wide]: bigger,
|
||||
},
|
||||
className,
|
||||
)
|
||||
}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export { styles as mainStyles };
|
||||
|
||||
export const containerClassName = (width: "narrow" | "normal" | "wide") => {
|
||||
if (width === "narrow") {
|
||||
return "half-width";
|
||||
return styles.narrow;
|
||||
}
|
||||
|
||||
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 {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
|
@ -23,23 +11,23 @@
|
|||
flex-grow: 1;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-radius: var(--rounded);
|
||||
background-color: var(--bg-lighter);
|
||||
font-size: var(--fonts-xs);
|
||||
font-weight: var(--semi-bold);
|
||||
border-radius: var(--radius-box);
|
||||
background-color: var(--color-bg-high);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
gap: var(--s-2);
|
||||
padding-block: var(--s-1-5);
|
||||
padding-inline: var(--s-3);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 640px) {
|
||||
@container (width >= 560px) {
|
||||
.stageNameRow {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.stageImage {
|
||||
border-radius: var(--rounded);
|
||||
border-radius: var(--radius-box);
|
||||
}
|
||||
|
||||
.modeButtonsContainer {
|
||||
|
|
@ -51,25 +39,29 @@
|
|||
|
||||
.modeButton {
|
||||
padding: var(--s-1);
|
||||
border: 2px solid var(--bg-darker);
|
||||
border-radius: var(--rounded-full);
|
||||
background-color: transparent;
|
||||
color: var(--theme);
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-accent);
|
||||
opacity: 1 !important;
|
||||
outline: initial;
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.modeButton.selected {
|
||||
border: 2px solid transparent;
|
||||
background-color: var(--bg-mode-active);
|
||||
background-color: var(--color-bg-higher);
|
||||
}
|
||||
|
||||
.modeButton.preselected {
|
||||
border: 2px solid var(--theme-info);
|
||||
background-color: var(--bg-mode-active);
|
||||
border: var(--border-style-accent);
|
||||
background-color: var(--color-bg-higher);
|
||||
}
|
||||
|
||||
.mode:not(.selected, .preselected) {
|
||||
filter: var(--inactive-image-filter);
|
||||
opacity: 0.6;
|
||||
filter: grayscale(100%) brightness(50%);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import clsx from "clsx";
|
||||
import { ArrowLeft, X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Image } from "~/components/Image";
|
||||
|
|
@ -12,8 +13,6 @@ import { split, startsWith } from "~/utils/strings";
|
|||
import { assertType } from "~/utils/types";
|
||||
import { modeImageUrl, stageImageUrl } from "~/utils/urls";
|
||||
import { SendouButton } from "./elements/Button";
|
||||
import { ArrowLongLeftIcon } from "./icons/ArrowLongLeft";
|
||||
import { CrossIcon } from "./icons/Cross";
|
||||
|
||||
import styles from "./MapPoolSelector.module.css";
|
||||
|
||||
|
|
@ -104,12 +103,10 @@ export function MapPoolSelector({
|
|||
)}
|
||||
<div className="stack md">
|
||||
{allowBulkEdit && (
|
||||
<div className={styles.templateSelection}>
|
||||
<MapPoolTemplateSelect
|
||||
value={template}
|
||||
handleChange={handleTemplateChange}
|
||||
/>
|
||||
</div>
|
||||
<MapPoolTemplateSelect
|
||||
value={template}
|
||||
handleChange={handleTemplateChange}
|
||||
/>
|
||||
)}
|
||||
{info}
|
||||
<MapPoolStages
|
||||
|
|
@ -252,7 +249,7 @@ export function MapPoolStages({
|
|||
return (
|
||||
<button
|
||||
key={mode}
|
||||
className={clsx(styles.modeButton, "outline-theme", {
|
||||
className={clsx(styles.modeButton, {
|
||||
[styles.selected]: selected,
|
||||
[styles.preselected]: preselected,
|
||||
invisible:
|
||||
|
|
@ -282,22 +279,20 @@ export function MapPoolStages({
|
|||
allowBulkEdit &&
|
||||
(mapPool.hasStage(stageId) ? (
|
||||
<SendouButton
|
||||
shape="circle"
|
||||
key="clear"
|
||||
onPress={() => handleStageClear(stageId)}
|
||||
icon={<CrossIcon title={t("common:actions.remove")} />}
|
||||
icon={<X />}
|
||||
variant="minimal"
|
||||
aria-label={t("common:actions.remove")}
|
||||
size="small"
|
||||
/>
|
||||
) : (
|
||||
<SendouButton
|
||||
shape="circle"
|
||||
key="select-all"
|
||||
onPress={() => handleStageAdd(stageId)}
|
||||
icon={
|
||||
<ArrowLongLeftIcon
|
||||
title={t("common:actions.selectAll")}
|
||||
/>
|
||||
}
|
||||
icon={<ArrowLeft />}
|
||||
variant="minimal"
|
||||
aria-label={t("common:actions.selectAll")}
|
||||
size="small"
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ export function Markdown({ children }: { children: string }) {
|
|||
.replace(/style\s*=\s*("[^"]*"|'[^']*')/gi, (_match, value) => {
|
||||
const sanitized = value.replace(CSS_URL_REGEX, "");
|
||||
return `style=${sanitized}`;
|
||||
});
|
||||
})
|
||||
.replace(/ +$/gm, "");
|
||||
|
||||
return (
|
||||
<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 { SendouButton } from "~/components/elements/Button";
|
||||
import { ArrowLeftIcon } from "~/components/icons/ArrowLeft";
|
||||
import { ArrowRightIcon } from "~/components/icons/ArrowRight";
|
||||
import { nullFilledArray } from "~/utils/arrays";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import styles from "./Pagination.module.css";
|
||||
|
||||
export function Pagination({
|
||||
currentPage,
|
||||
|
|
@ -17,39 +16,223 @@ export function Pagination({
|
|||
previousPage: () => void;
|
||||
setPage: (page: number) => void;
|
||||
}) {
|
||||
const pages = getPageNumbers(currentPage, pagesCount);
|
||||
const [jumpToIndex, setJumpToIndex] = React.useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<div className="pagination__container">
|
||||
<SendouButton
|
||||
icon={<ArrowLeftIcon />}
|
||||
variant="outlined"
|
||||
className="fix-rtl"
|
||||
isDisabled={currentPage === 1}
|
||||
onPress={previousPage}
|
||||
<div className={styles.container}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.arrow}
|
||||
disabled={currentPage === 1}
|
||||
onClick={previousPage}
|
||||
aria-label="Previous page"
|
||||
/>
|
||||
<div className="pagination__dots">
|
||||
{nullFilledArray(pagesCount).map((_, i) => (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: Biome v2 migration
|
||||
<div
|
||||
key={i}
|
||||
className={clsx("pagination__dot", {
|
||||
pagination__dot__active: i === currentPage - 1,
|
||||
})}
|
||||
onClick={() => setPage(i + 1)}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
{pages.map((page, index) =>
|
||||
page.value === "..." ? (
|
||||
<JumpToEllipsis
|
||||
key={`ellipsis-${index}`}
|
||||
isOpen={jumpToIndex === index}
|
||||
pagesCount={pagesCount}
|
||||
desktopOnly={page.desktopOnly}
|
||||
mobileOnly={page.mobileOnly}
|
||||
onOpen={() => setJumpToIndex(index)}
|
||||
onClose={() => setJumpToIndex(null)}
|
||||
onJump={(page) => {
|
||||
setPage(page);
|
||||
setJumpToIndex(null);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="pagination__page-count">
|
||||
{currentPage}/{pagesCount}
|
||||
</div>
|
||||
<SendouButton
|
||||
icon={<ArrowRightIcon />}
|
||||
variant="outlined"
|
||||
className="fix-rtl"
|
||||
isDisabled={currentPage === pagesCount}
|
||||
onPress={nextPage}
|
||||
) : (
|
||||
<PageButton
|
||||
key={page.value}
|
||||
page={page.value}
|
||||
currentPage={currentPage}
|
||||
desktopOnly={page.desktopOnly}
|
||||
setPage={setPage}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.arrow}
|
||||
disabled={currentPage === pagesCount}
|
||||
onClick={nextPage}
|
||||
aria-label="Next page"
|
||||
/>
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</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({
|
||||
value,
|
||||
isValid,
|
||||
|
|
@ -9,7 +11,7 @@ export function RequiredHiddenInput({
|
|||
}) {
|
||||
return (
|
||||
<input
|
||||
className="hidden-input-with-validation"
|
||||
className={styles.input}
|
||||
name={name}
|
||||
value={isValid ? value : []}
|
||||
// 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({
|
||||
title,
|
||||
children,
|
||||
|
|
@ -8,7 +10,7 @@ export function Section({
|
|||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="section">
|
||||
<section className={styles.section}>
|
||||
{title && <h2>{title}</h2>}
|
||||
<div className={className}>{children}</div>
|
||||
</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 {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
|
|
@ -9,7 +5,7 @@
|
|||
}
|
||||
|
||||
.stageImg {
|
||||
border-radius: var(--rounded-sm);
|
||||
border-radius: var(--radius-field);
|
||||
}
|
||||
|
||||
.stageLabel {
|
||||
|
|
|
|||
|
|
@ -48,8 +48,6 @@ export function StageSelect<Clearable extends boolean | undefined = undefined>({
|
|||
search={{
|
||||
placeholder: t("common:forms.stageSearch.search.placeholder"),
|
||||
}}
|
||||
className={styles.selectWidthWider}
|
||||
popoverClassName={styles.selectWidthWider}
|
||||
selectedKey={isControlled ? value : undefined}
|
||||
defaultSelectedKey={isControlled ? undefined : (initialValue as Key)}
|
||||
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 { LinkProps } from "react-router";
|
||||
import { NavLink } from "react-router";
|
||||
import styles from "./SubNav.module.css";
|
||||
|
||||
export function SubNav({
|
||||
children,
|
||||
|
|
@ -13,8 +14,8 @@ export function SubNav({
|
|||
return (
|
||||
<div>
|
||||
<nav
|
||||
className={clsx("sub-nav__container", {
|
||||
"sub-nav__container__secondary": secondary,
|
||||
className={clsx(styles.container, {
|
||||
[styles.secondary]: secondary,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
|
|
@ -41,8 +42,8 @@ export function SubNavLink({
|
|||
return (
|
||||
<NavLink
|
||||
className={(state) =>
|
||||
clsx("sub-nav__link__container", {
|
||||
active: controlled ? active : state.isActive,
|
||||
clsx(styles.linkContainer, {
|
||||
[styles.active]: controlled ? active : state.isActive,
|
||||
pending: state.isPending,
|
||||
})
|
||||
}
|
||||
|
|
@ -50,15 +51,15 @@ export function SubNavLink({
|
|||
{...props}
|
||||
>
|
||||
<div
|
||||
className={clsx("sub-nav__link", className, {
|
||||
"sub-nav__link__secondary": secondary,
|
||||
className={clsx(styles.link, className, {
|
||||
[styles.linkSecondary]: secondary,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
className={clsx("sub-nav__border-guy", {
|
||||
"sub-nav__border-guy__secondary": secondary,
|
||||
className={clsx(styles.borderGuy, {
|
||||
[styles.borderGuySecondary]: secondary,
|
||||
})}
|
||||
/>
|
||||
</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 }) {
|
||||
return (
|
||||
<div className="my-table__container">
|
||||
<table className="my-table">{children}</table>
|
||||
<div className={styles.container}>
|
||||
<table className={styles.table}>{children}</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
.pill {
|
||||
font-size: var(--fonts-xxs);
|
||||
font-weight: var(--bold);
|
||||
border-radius: var(--rounded-sm);
|
||||
padding: var(--s-0-5) var(--s-1-5);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
border-radius: var(--radius-selector);
|
||||
padding: 0 var(--s-1-5);
|
||||
height: var(--selector-size-sm);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 24px;
|
||||
min-width: 33px;
|
||||
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 { Check, Clipboard } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { Dialog, Popover } from "react-aria-components";
|
||||
|
|
@ -6,8 +7,8 @@ import { useTranslation } from "react-i18next";
|
|||
import { useCopyToClipboard } from "react-use";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { SendouButton } from "./elements/Button";
|
||||
import { CheckmarkIcon } from "./icons/Checkmark";
|
||||
import { ClipboardIcon } from "./icons/Clipboard";
|
||||
import popoverStyles from "./elements/Popover.module.css";
|
||||
import styles from "./TimePopover.module.css";
|
||||
|
||||
export default function TimePopover({
|
||||
time,
|
||||
|
|
@ -54,8 +55,9 @@ export default function TimePopover({
|
|||
ref={triggerRef}
|
||||
className={clsx(
|
||||
className,
|
||||
"clickable text-only-button",
|
||||
underline ? "dotted" : "",
|
||||
"clickable",
|
||||
styles.textOnlyButton,
|
||||
underline ? styles.dotted : "",
|
||||
)}
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
|
|
@ -65,11 +67,11 @@ export default function TimePopover({
|
|||
</button>
|
||||
<Popover
|
||||
isOpen={open}
|
||||
className={"sendou-popover-content"}
|
||||
className={popoverStyles.content}
|
||||
onOpenChange={setOpen}
|
||||
triggerRef={triggerRef}
|
||||
>
|
||||
<Dialog>
|
||||
<Dialog className={popoverStyles.dialog}>
|
||||
<div className="stack sm">
|
||||
<div className="text-center" suppressHydrationWarning>
|
||||
{formatTime(time, {
|
||||
|
|
@ -82,7 +84,7 @@ export default function TimePopover({
|
|||
size="miniscule"
|
||||
variant="minimal"
|
||||
onPress={() => copyToClipboard(`<t:${time.valueOf() / 1000}:F>`)}
|
||||
icon={copySuccess ? <CheckmarkIcon /> : <ClipboardIcon />}
|
||||
icon={copySuccess ? <Check /> : <Clipboard />}
|
||||
>
|
||||
{t("common:actions.copyTimestampForDiscord")}
|
||||
</SendouButton>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
.selectWidthWider {
|
||||
--select-width: 100%;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
|
|
|
|||
|
|
@ -115,8 +115,6 @@ export function WeaponSelect<
|
|||
search={{
|
||||
placeholder: t("common:forms.weaponSearch.search.placeholder"),
|
||||
}}
|
||||
className={styles.selectWidthWider}
|
||||
popoverClassName={styles.selectWidthWider}
|
||||
searchInputValue={filterValue}
|
||||
onSearchInputChange={setFilterValue}
|
||||
selectedKey={isControlled ? keyify(value) : undefined}
|
||||
|
|
@ -141,7 +139,7 @@ export function WeaponSelect<
|
|||
? specialWeaponImageUrl(TRIZOOKA_ID)
|
||||
: weaponCategoryUrl(name)
|
||||
}
|
||||
className={idx === 0 ? "pt-0-5-forced" : undefined}
|
||||
className={idx === 0 ? "pt-0-5" : undefined}
|
||||
key={key}
|
||||
>
|
||||
{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 { useTranslation } from "react-i18next";
|
||||
import { toastQueue } from "./elements/Toast";
|
||||
import styles from "./YouTubeEmbed.module.css";
|
||||
|
||||
export function YouTubeEmbed({
|
||||
id,
|
||||
|
|
@ -117,16 +118,16 @@ export function YouTubeEmbed({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="youtube__container--api">
|
||||
<div className={styles.containerApi}>
|
||||
<div ref={containerRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="youtube__container">
|
||||
<div className={styles.container}>
|
||||
<iframe
|
||||
className="youtube__iframe"
|
||||
className={styles.iframe}
|
||||
src={`https://www.youtube.com/embed/${id}?autoplay=${
|
||||
autoplay ? "1" : "0"
|
||||
}&controls=1&rel=0&modestbranding=1&start=${start ?? 0}`}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { SendouFieldError } from "~/components/elements/FieldError";
|
||||
import { SendouFieldMessage } from "~/components/elements/FieldMessage";
|
||||
|
||||
// TODO: deprecate in favor of FormMessage
|
||||
export function SendouBottomTexts({
|
||||
bottomText,
|
||||
errorText,
|
||||
|
|
|
|||
|
|
@ -3,24 +3,26 @@
|
|||
width: auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid var(--theme);
|
||||
border-radius: var(--rounded-sm);
|
||||
border: var(--border-style-accent);
|
||||
border-radius: var(--radius-field);
|
||||
appearance: none;
|
||||
background: var(--theme);
|
||||
color: var(--button-text);
|
||||
background: var(--color-text-accent);
|
||||
color: var(--color-text-inverse);
|
||||
cursor: pointer;
|
||||
font-size: var(--fonts-sm);
|
||||
font-weight: var(--bold);
|
||||
line-height: 1.2;
|
||||
outline-offset: 2px;
|
||||
padding-block: var(--s-1-5);
|
||||
padding-inline: var(--s-2-5);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-bold);
|
||||
padding: 0 var(--field-padding);
|
||||
user-select: none;
|
||||
outline-color: var(--color-text-accent);
|
||||
height: var(--field-size);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.button[data-focus-visible],
|
||||
.button:focus-visible {
|
||||
outline: 2px solid var(--theme);
|
||||
outline-style: solid;
|
||||
outline-width: 2px;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.button[data-pressed],
|
||||
|
|
@ -36,77 +38,94 @@
|
|||
}
|
||||
|
||||
.outlined {
|
||||
background-color: var(--theme-very-transparent);
|
||||
color: var(--theme);
|
||||
background-color: transparent;
|
||||
color: var(--color-text-accent);
|
||||
}
|
||||
|
||||
.outlinedSuccess {
|
||||
border-color: var(--theme-success);
|
||||
border-color: var(--color-success);
|
||||
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 {
|
||||
font-size: var(--fonts-xs);
|
||||
padding-block: var(--s-1);
|
||||
padding-inline: var(--s-2);
|
||||
font-size: var(--font-xs);
|
||||
height: var(--field-size-sm);
|
||||
}
|
||||
|
||||
.miniscule {
|
||||
font-size: var(--fonts-xxs);
|
||||
padding-block: var(--s-1);
|
||||
padding-inline: var(--s-2);
|
||||
font-size: var(--font-2xs);
|
||||
height: var(--field-size-xs);
|
||||
}
|
||||
|
||||
.big {
|
||||
font-size: var(--fonts-md);
|
||||
padding-block: var(--s-2-5);
|
||||
padding-inline: var(--s-6);
|
||||
font-size: var(--font-md);
|
||||
height: var(--field-size-lg);
|
||||
}
|
||||
|
||||
.square {
|
||||
aspect-ratio: 1 / 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.circle {
|
||||
border-radius: 50%;
|
||||
aspect-ratio: 1 / 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.minimal {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--theme);
|
||||
color: var(--color-text-accent);
|
||||
outline: initial;
|
||||
}
|
||||
|
||||
.minimal[data-focus-visible] {
|
||||
outline: 2px solid var(--theme);
|
||||
outline: var(--focus-ring);
|
||||
}
|
||||
|
||||
.minimalSuccess {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--theme-success);
|
||||
color: var(--color-success);
|
||||
outline-color: var(--color-success);
|
||||
}
|
||||
|
||||
.success {
|
||||
border-color: var(--theme-success);
|
||||
background-color: var(--theme-success);
|
||||
outline-color: var(--theme-success);
|
||||
border-color: var(--color-success);
|
||||
background-color: var(--color-success);
|
||||
outline-color: var(--color-success);
|
||||
}
|
||||
|
||||
.destructive {
|
||||
border-color: var(--theme-error);
|
||||
background-color: transparent;
|
||||
color: var(--theme-error);
|
||||
outline-color: var(--theme-error);
|
||||
border-color: var(--color-error);
|
||||
background-color: var(--color-error);
|
||||
color: var(--color-text-inverse);
|
||||
outline-color: var(--color-error);
|
||||
}
|
||||
|
||||
.minimalDestructive {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--theme-error);
|
||||
outline-color: var(--theme-error);
|
||||
color: var(--color-error);
|
||||
outline-color: var(--color-error);
|
||||
}
|
||||
|
||||
.buttonIcon {
|
||||
min-width: 1.25rem;
|
||||
max-width: 1.25rem;
|
||||
min-width: 20px;
|
||||
max-width: 20px;
|
||||
margin-inline-end: var(--s-1-5);
|
||||
}
|
||||
|
||||
|
|
@ -115,13 +134,19 @@
|
|||
}
|
||||
|
||||
.buttonIconSmall {
|
||||
min-width: 1rem;
|
||||
max-width: 1rem;
|
||||
min-width: 18px;
|
||||
max-width: 18px;
|
||||
margin-inline-end: var(--s-1);
|
||||
}
|
||||
|
||||
.buttonIconMiniscule {
|
||||
min-width: 0.857rem;
|
||||
max-width: 0.857rem;
|
||||
min-width: 14px;
|
||||
max-width: 14px;
|
||||
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 =
|
||||
| "primary"
|
||||
| "success"
|
||||
| "destructive"
|
||||
| "outlined"
|
||||
| "outlined-success"
|
||||
| "destructive"
|
||||
| "outlined-destructive"
|
||||
| "minimal"
|
||||
| "minimal-success"
|
||||
| "minimal-destructive";
|
||||
|
|
@ -24,6 +25,7 @@ export interface SendouButtonProps
|
|||
className?: string;
|
||||
variant?: ButtonVariant;
|
||||
size?: "miniscule" | "small" | "medium" | "big";
|
||||
shape?: "circle" | "square";
|
||||
icon?: JSX.Element;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
|
@ -32,6 +34,7 @@ export function SendouButton({
|
|||
children,
|
||||
variant,
|
||||
size,
|
||||
shape,
|
||||
className,
|
||||
icon,
|
||||
...rest
|
||||
|
|
@ -39,7 +42,7 @@ export function SendouButton({
|
|||
return (
|
||||
<ReactAriaButton
|
||||
{...rest}
|
||||
className={buttonClassName({ className, variant, size })}
|
||||
className={buttonClassName({ className, variant, size, shape })}
|
||||
>
|
||||
{icon &&
|
||||
React.cloneElement(icon, {
|
||||
|
|
@ -58,6 +61,7 @@ export interface LinkButtonProps {
|
|||
className?: string;
|
||||
variant?: SendouButtonProps["variant"];
|
||||
size?: SendouButtonProps["size"];
|
||||
shape?: SendouButtonProps["shape"];
|
||||
icon?: JSX.Element;
|
||||
children?: React.ReactNode;
|
||||
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
|
||||
|
|
@ -72,6 +76,7 @@ export function LinkButton({
|
|||
className,
|
||||
variant,
|
||||
size,
|
||||
shape,
|
||||
icon,
|
||||
children,
|
||||
onClick,
|
||||
|
|
@ -80,7 +85,7 @@ export function LinkButton({
|
|||
if (isExternal) {
|
||||
return (
|
||||
<a
|
||||
className={buttonClassName({ className, variant, size })}
|
||||
className={buttonClassName({ className, variant, size, shape })}
|
||||
href={to as string}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
|
|
@ -98,11 +103,12 @@ export function LinkButton({
|
|||
|
||||
return (
|
||||
<Link
|
||||
className={buttonClassName({ className, variant, size })}
|
||||
className={buttonClassName({ className, variant, size, shape })}
|
||||
to={to}
|
||||
data-testid={testId}
|
||||
prefetch={prefetch}
|
||||
preventScrollReset={preventScrollReset}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon &&
|
||||
React.cloneElement(icon, {
|
||||
|
|
@ -117,19 +123,22 @@ function buttonClassName({
|
|||
className,
|
||||
variant,
|
||||
size,
|
||||
}: Pick<SendouButtonProps, "className" | "variant" | "size">) {
|
||||
shape,
|
||||
}: Pick<SendouButtonProps, "className" | "variant" | "size" | "shape">) {
|
||||
const variantToClassname = (variant: ButtonVariant) => {
|
||||
switch (variant) {
|
||||
case "primary":
|
||||
return styles.primary;
|
||||
case "success":
|
||||
return styles.success;
|
||||
case "destructive":
|
||||
return styles.destructive;
|
||||
case "outlined":
|
||||
return styles.outlined;
|
||||
case "outlined-success":
|
||||
return styles.outlinedSuccess;
|
||||
case "destructive":
|
||||
return styles.destructive;
|
||||
case "outlined-destructive":
|
||||
return styles.outlinedDestructive;
|
||||
case "minimal":
|
||||
return styles.minimal;
|
||||
case "minimal-success":
|
||||
|
|
@ -150,6 +159,10 @@ function buttonClassName({
|
|||
[styles.big]: size === "big",
|
||||
[styles.miniscule]: size === "miniscule",
|
||||
},
|
||||
{
|
||||
[styles.circle]: shape === "circle",
|
||||
[styles.square]: shape === "square",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -162,5 +175,6 @@ function iconClassName(
|
|||
[styles.lonely]: !children,
|
||||
[styles.buttonIconSmall]: size === "small",
|
||||
[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 { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Calendar,
|
||||
CalendarCell,
|
||||
CalendarGrid,
|
||||
CalendarGridBody,
|
||||
CalendarGridHeader,
|
||||
CalendarHeaderCell,
|
||||
type CalendarProps,
|
||||
type DateValue,
|
||||
Heading,
|
||||
} from "react-aria-components";
|
||||
import { ArrowLeftIcon } from "~/components/icons/ArrowLeft";
|
||||
import { ArrowRightIcon } from "~/components/icons/ArrowRight";
|
||||
import styles from "./Calendar.module.css";
|
||||
|
||||
export interface SendouCalendarProps<T extends DateValue>
|
||||
extends CalendarProps<T> {
|
||||
|
|
@ -21,20 +24,33 @@ export function SendouCalendar<T extends DateValue>({
|
|||
...rest
|
||||
}: SendouCalendarProps<T>) {
|
||||
return (
|
||||
<Calendar className={clsx(className, "react-aria-Calendar")} {...rest}>
|
||||
<header>
|
||||
<Button slot="previous">
|
||||
<ArrowLeftIcon />
|
||||
<Calendar className={clsx(className, styles.root)} {...rest}>
|
||||
<header className={styles.header}>
|
||||
<Button slot="previous" className={styles.navButton}>
|
||||
<ChevronLeft className={styles.navIcon} />
|
||||
</Button>
|
||||
<Heading />
|
||||
<Button slot="next">
|
||||
<ArrowRightIcon />
|
||||
<Heading className={styles.heading} />
|
||||
<Button slot="next" className={styles.navButton}>
|
||||
<ChevronRight className={styles.navIcon} />
|
||||
</Button>
|
||||
</header>
|
||||
<CalendarGrid>
|
||||
{(date) => {
|
||||
return <CalendarCell date={date} data-testid="choose-date-button" />;
|
||||
}}
|
||||
<CalendarGrid className={styles.grid}>
|
||||
<CalendarGridHeader>
|
||||
{(day) => (
|
||||
<CalendarHeaderCell className={styles.headerCell}>
|
||||
{day}
|
||||
</CalendarHeaderCell>
|
||||
)}
|
||||
</CalendarGridHeader>
|
||||
<CalendarGridBody>
|
||||
{(date) => (
|
||||
<CalendarCell
|
||||
date={date}
|
||||
className={styles.cell}
|
||||
data-testid="choose-date-button"
|
||||
/>
|
||||
)}
|
||||
</CalendarGridBody>
|
||||
</CalendarGrid>
|
||||
</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 {
|
||||
Button,
|
||||
DateInput,
|
||||
|
|
@ -12,7 +13,7 @@ import {
|
|||
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
|
||||
import { SendouCalendar } from "~/components/elements/Calendar";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { CalendarIcon } from "../icons/Calendar";
|
||||
import styles from "./DatePicker.module.css";
|
||||
import { SendouLabel } from "./Label";
|
||||
|
||||
interface SendouDatePickerProps<T extends DateValue>
|
||||
|
|
@ -51,14 +52,17 @@ export function SendouDatePicker<T extends DateValue>({
|
|||
<ReactAriaDatePicker
|
||||
{...rest}
|
||||
validationBehavior="aria"
|
||||
aria-label={label}
|
||||
isInvalid={!!errorText}
|
||||
className={styles.root}
|
||||
>
|
||||
<SendouLabel required={isRequired}>{label}</SendouLabel>
|
||||
<Group className="react-aria-Group">
|
||||
<DateInput>{(segment) => <DateSegment segment={segment} />}</DateInput>
|
||||
<Button data-testid="open-calendar-button">
|
||||
<CalendarIcon />
|
||||
<Group className={styles.group}>
|
||||
<DateInput className={styles.dateInput}>
|
||||
{(segment) => (
|
||||
<DateSegment segment={segment} className={styles.segment} />
|
||||
)}
|
||||
</DateInput>
|
||||
<Button data-testid="open-calendar-button" className={styles.button}>
|
||||
<Calendar className={styles.icon} />
|
||||
</Button>
|
||||
</Group>
|
||||
<SendouBottomTexts
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
|
|
@ -11,7 +11,6 @@
|
|||
padding: 1rem;
|
||||
text-align: center;
|
||||
backdrop-filter: blur(10px); /* Adjust blur value as needed */
|
||||
padding-block: var(--s-32);
|
||||
}
|
||||
|
||||
.fullScreenOverlay {
|
||||
|
|
@ -35,10 +34,12 @@
|
|||
.modal {
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
overflow: hidden;
|
||||
max-height: 80dvh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
border-radius: 1rem;
|
||||
background-color: var(--bg-lighter-solid);
|
||||
border: 2.5px solid var(--border);
|
||||
border: 1px solid var(--color-border);
|
||||
background-color: var(--color-bg);
|
||||
padding: var(--s-6);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
|
|
@ -76,7 +77,7 @@
|
|||
}
|
||||
|
||||
.headingContainer {
|
||||
border-bottom: 2px solid var(--border);
|
||||
border-bottom: var(--border-style);
|
||||
padding-block-end: var(--s-2);
|
||||
margin-block-end: var(--s-4);
|
||||
display: flex;
|
||||
|
|
@ -90,5 +91,5 @@
|
|||
}
|
||||
|
||||
.heading {
|
||||
font-size: var(--fonts-lg);
|
||||
font-size: var(--font-lg);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import clsx from "clsx";
|
||||
import { X } from "lucide-react";
|
||||
import type { ModalOverlayProps } from "react-aria-components";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -10,7 +11,6 @@ import {
|
|||
import { useNavigate } from "react-router";
|
||||
import * as R from "remeda";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { CrossIcon } from "~/components/icons/Cross";
|
||||
import styles from "./Dialog.module.css";
|
||||
|
||||
interface SendouDialogProps extends ModalOverlayProps {
|
||||
|
|
@ -118,7 +118,7 @@ function DialogModal({
|
|||
{...overlayProps}
|
||||
>
|
||||
<Modal
|
||||
className={clsx(className, styles.modal, {
|
||||
className={clsx(className, styles.modal, "scrollbar", {
|
||||
[styles.fullScreenModal]: rest.isFullScreen,
|
||||
})}
|
||||
>
|
||||
|
|
@ -136,7 +136,8 @@ function DialogModal({
|
|||
) : null}
|
||||
{showCloseButton ? (
|
||||
<SendouButton
|
||||
icon={<CrossIcon />}
|
||||
icon={<X />}
|
||||
shape="circle"
|
||||
variant="minimal-destructive"
|
||||
className="ml-auto"
|
||||
slot="close"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { FieldError as ReactAriaFieldError } from "react-aria-components";
|
||||
import styles from "../FormMessage.module.css";
|
||||
|
||||
export function SendouFieldError({
|
||||
children,
|
||||
|
|
@ -8,7 +9,7 @@ export function SendouFieldError({
|
|||
id?: string;
|
||||
}) {
|
||||
return (
|
||||
<ReactAriaFieldError className="error-message" id={id}>
|
||||
<ReactAriaFieldError className={styles.error} id={id}>
|
||||
{children}
|
||||
</ReactAriaFieldError>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Text } from "react-aria-components";
|
||||
import styles from "../FormMessage.module.css";
|
||||
|
||||
export function SendouFieldMessage({
|
||||
children,
|
||||
|
|
@ -6,7 +7,7 @@ export function SendouFieldMessage({
|
|||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Text slot="description" className="info-message">
|
||||
<Text slot="description" className={styles.info}>
|
||||
{children}
|
||||
</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 styles from "./Label.module.css";
|
||||
|
||||
export function SendouLabel({
|
||||
children,
|
||||
|
|
@ -8,8 +9,8 @@ export function SendouLabel({
|
|||
required?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ReactAriaLabel>
|
||||
{children} {required && <span className="text-error">*</span>}
|
||||
<ReactAriaLabel className={styles.label}>
|
||||
{children} {required ? <span className="text-error">*</span> : null}
|
||||
</ReactAriaLabel>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,74 +1,71 @@
|
|||
.itemsContainer {
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
border-radius: var(--rounded);
|
||||
background-color: var(--bg-darker);
|
||||
.popover {
|
||||
border-radius: var(--radius-box);
|
||||
background-color: var(--color-bg-high);
|
||||
border: var(--border-style);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
align-items: flex-start;
|
||||
width: max-content;
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semi);
|
||||
padding: var(--s-2);
|
||||
}
|
||||
|
||||
.itemsContainerOpensLeft {
|
||||
.popoverOpensLeft {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.scrolling {
|
||||
max-height: 300px !important;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: var(--bold);
|
||||
font-size: var(--fonts-xs);
|
||||
color: var(--text);
|
||||
font-weight: var(--weight-bold);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
gap: var(--s-2);
|
||||
border-radius: var(--rounded-xs);
|
||||
padding: var(--s-1-5) var(--s-2-5);
|
||||
background-color: var(--bg-darker);
|
||||
border-radius: var(--radius-field);
|
||||
padding: var(--s-1-5) var(--s-3);
|
||||
background-color: var(--color-bg-high);
|
||||
width: 100%;
|
||||
border: 0;
|
||||
outline: none;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.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;
|
||||
transition: background-color 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item[data-focused] {
|
||||
background-color: var(--theme-very-transparent);
|
||||
background-color: var(--color-bg-higher);
|
||||
}
|
||||
|
||||
.itemDisabled {
|
||||
color: var(--text-lighter);
|
||||
color: var(--color-text-high);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.itemSelected {
|
||||
background-color: var(--theme-transparent);
|
||||
font-weight: var(--extra-bold);
|
||||
background-color: var(--color-text-accent);
|
||||
font-weight: var(--weight-extra);
|
||||
}
|
||||
|
||||
.itemActive {
|
||||
color: var(--theme);
|
||||
color: var(--color-text-accent);
|
||||
}
|
||||
|
||||
.itemDestructive {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.itemIcon {
|
||||
|
|
@ -76,6 +73,13 @@
|
|||
}
|
||||
|
||||
.itemImg {
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
min-width: 20px;
|
||||
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 {
|
||||
Header,
|
||||
Menu,
|
||||
MenuItem,
|
||||
type MenuItemProps,
|
||||
MenuTrigger,
|
||||
Popover,
|
||||
type PopoverProps,
|
||||
Section,
|
||||
} from "react-aria-components";
|
||||
import { Image } from "../Image";
|
||||
import styles from "./Menu.module.css";
|
||||
|
|
@ -14,6 +17,8 @@ interface SendouMenuProps {
|
|||
scrolling?: boolean;
|
||||
opensLeft?: boolean;
|
||||
children: React.ReactNode;
|
||||
popoverClassName?: string;
|
||||
placement?: PopoverProps["placement"];
|
||||
}
|
||||
|
||||
export function SendouMenu({
|
||||
|
|
@ -21,17 +26,19 @@ export function SendouMenu({
|
|||
trigger,
|
||||
opensLeft,
|
||||
scrolling,
|
||||
placement,
|
||||
}: SendouMenuProps) {
|
||||
return (
|
||||
<MenuTrigger>
|
||||
{trigger}
|
||||
<Popover
|
||||
className={clsx(styles.itemsContainer, {
|
||||
placement={placement}
|
||||
className={clsx(styles.popover, "scrollbar", {
|
||||
[styles.scrolling]: scrolling,
|
||||
[styles.itemsContainerOpensLeft]: !opensLeft,
|
||||
[styles.popoverOpensLeft]: !opensLeft,
|
||||
})}
|
||||
>
|
||||
<Menu>{children}</Menu>
|
||||
<Menu className={styles.itemsContainer}>{children}</Menu>
|
||||
</Popover>
|
||||
</MenuTrigger>
|
||||
);
|
||||
|
|
@ -41,6 +48,24 @@ export interface SendouMenuItemProps extends MenuItemProps {
|
|||
icon?: React.ReactNode;
|
||||
imagePath?: string;
|
||||
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) {
|
||||
|
|
@ -56,6 +81,7 @@ export function SendouMenuItem(props: SendouMenuItemProps) {
|
|||
[styles.itemSelected]: isSelected,
|
||||
[styles.itemDisabled]: isDisabled,
|
||||
[styles.itemActive]: props.isActive,
|
||||
[styles.itemDestructive]: props.isDestructive,
|
||||
})
|
||||
}
|
||||
>
|
||||
|
|
@ -68,8 +94,8 @@ export function SendouMenuItem(props: SendouMenuItemProps) {
|
|||
<Image
|
||||
path={props.imagePath}
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
width={20}
|
||||
height={20}
|
||||
className={styles.itemImg}
|
||||
/>
|
||||
) : 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,
|
||||
type PopoverProps,
|
||||
} 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).
|
||||
|
|
@ -35,14 +36,13 @@ export function SendouPopover({
|
|||
isOpen?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogTrigger isOpen={isOpen}>
|
||||
<DialogTrigger isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
{trigger}
|
||||
<Popover
|
||||
className={clsx("sendou-popover-content", popoverClassName)}
|
||||
className={clsx(styles.content, popoverClassName)}
|
||||
placement={placement}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<Dialog>{children}</Dialog>
|
||||
<Dialog className={styles.dialog}>{children}</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,60 +1,58 @@
|
|||
.button {
|
||||
height: 1rem;
|
||||
padding: var(--s-4) var(--s-3);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: var(--rounded-sm);
|
||||
accent-color: var(--theme-secondary);
|
||||
background-color: var(--bg-input);
|
||||
color: var(--text);
|
||||
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: var(--select-width);
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
|
||||
font-size: var(--fonts-xs);
|
||||
font-weight: var(--semi-bold);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
&[data-focus-visible],
|
||||
&[aria-expanded="true"] {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.button[data-focus-visible] {
|
||||
outline: 2px solid var(--theme);
|
||||
&[data-disabled] {
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.selectValue {
|
||||
max-width: calc(var(--select-width) - 55px);
|
||||
font-size: var(--font-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.selectValue[data-placeholder] {
|
||||
color: var(--text-lighter);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.icon {
|
||||
min-width: 18px;
|
||||
max-width: 18px;
|
||||
stroke-width: 2.5px;
|
||||
color: var(--text-lighter);
|
||||
}
|
||||
|
||||
.smallIcon {
|
||||
min-width: 16px;
|
||||
max-width: 16px;
|
||||
stroke-width: 2px;
|
||||
color: var(--text-lighter);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.popover {
|
||||
padding: var(--s-1);
|
||||
width: var(--trigger-width);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: var(--rounded);
|
||||
background-color: var(--bg-darker);
|
||||
max-height: 250px !important;
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-box);
|
||||
background-color: var(--color-bg);
|
||||
outline: none;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -63,35 +61,30 @@
|
|||
.listBox {
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
scrollbar-color: rgb(83 65 91) rgba(83 65 91 / 0.3);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.item {
|
||||
font-size: var(--fonts-xsm);
|
||||
font-weight: var(--semi-bold);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semi);
|
||||
padding: var(--s-1-5);
|
||||
border-radius: var(--rounded-sm);
|
||||
height: 33px;
|
||||
border-radius: var(--radius-field);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.item[data-disabled] {
|
||||
color: var(--text-lighter);
|
||||
font-style: italic;
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.itemFocused {
|
||||
background-color: var(--theme-transparent);
|
||||
color: var(--text);
|
||||
background-color: var(--color-bg-high);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.itemSelected {
|
||||
color: var(--theme);
|
||||
font-weight: var(--bold);
|
||||
color: var(--color-text-accent);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.searchField {
|
||||
|
|
@ -99,20 +92,20 @@
|
|||
display: flex;
|
||||
gap: var(--s-2);
|
||||
|
||||
border: 2px solid var(--border);
|
||||
border-radius: var(--rounded-sm);
|
||||
accent-color: var(--theme-secondary);
|
||||
background-color: var(--bg-input);
|
||||
color: var(--text);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-radius: 0;
|
||||
accent-color: var(--color-accent);
|
||||
color: var(--color-text);
|
||||
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);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
all: unset;
|
||||
font-size: var(--fonts-xxs);
|
||||
font-weight: var(--semi-bold);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
letter-spacing: 0.5px;
|
||||
flex: 1;
|
||||
}
|
||||
|
|
@ -122,7 +115,7 @@
|
|||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--text-lighter);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.searchClearButton {
|
||||
|
|
@ -141,20 +134,28 @@
|
|||
}
|
||||
|
||||
.noResults {
|
||||
font-size: var(--fonts-md);
|
||||
font-weight: var(--bold);
|
||||
font-size: var(--font-md);
|
||||
font-weight: var(--weight-bold);
|
||||
text-align: center;
|
||||
padding-block: var(--s-8);
|
||||
color: var(--text-lighter);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.select {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-bold);
|
||||
margin-block-end: var(--label-margin);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
position: absolute;
|
||||
bottom: -17px;
|
||||
bottom: -21px;
|
||||
right: 9px;
|
||||
}
|
||||
|
||||
|
|
@ -162,18 +163,18 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
font-weight: bold;
|
||||
color: var(--text-lighter);
|
||||
font-weight: var(--weight-extra);
|
||||
color: var(--color-text-high);
|
||||
text-transform: uppercase;
|
||||
font-size: var(--fonts-xxs);
|
||||
padding-block-start: var(--s-2-5);
|
||||
font-size: var(--font-2xs);
|
||||
padding-block-start: var(--s-3);
|
||||
padding-block-end: var(--s-1);
|
||||
padding-inline: var(--s-1-5);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.categoryDivider {
|
||||
background-color: var(--border);
|
||||
background-color: var(--color-border);
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
margin-block: var(--s-2);
|
||||
|
|
@ -181,7 +182,7 @@
|
|||
|
||||
.categoryHeading img {
|
||||
border-radius: 100%;
|
||||
background-color: var(--bg-lightest);
|
||||
background-color: var(--color-bg-higher);
|
||||
padding: var(--s-1);
|
||||
min-width: 28px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import clsx from "clsx";
|
||||
import { ChevronsUpDown, Search, X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import type {
|
||||
AutocompleteProps,
|
||||
|
|
@ -26,10 +27,7 @@ import {
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { ChevronUpDownIcon } from "~/components/icons/ChevronUpDown";
|
||||
import { Image } from "../Image";
|
||||
import { CrossIcon } from "../icons/Cross";
|
||||
import { SearchIcon } from "../icons/Search";
|
||||
import styles from "./Select.module.css";
|
||||
|
||||
export interface SendouSelectProps<T extends object>
|
||||
|
|
@ -101,11 +99,11 @@ export function SendouSelect<T extends object>({
|
|||
className={clsx(className, styles.select)}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
{label ? <Label>{label}</Label> : null}
|
||||
{label ? <Label className={styles.label}>{label}</Label> : null}
|
||||
<Button className={styles.button}>
|
||||
<SelectValue className={styles.selectValue} />
|
||||
<span aria-hidden="true">
|
||||
<ChevronUpDownIcon className={styles.icon} />
|
||||
<ChevronsUpDown className={styles.icon} />
|
||||
</span>
|
||||
</Button>
|
||||
{clearable ? <SelectClearButton /> : null}
|
||||
|
|
@ -122,20 +120,20 @@ export function SendouSelect<T extends object>({
|
|||
autoFocus
|
||||
className={styles.searchField}
|
||||
>
|
||||
<SearchIcon aria-hidden className={styles.smallIcon} />
|
||||
<Search aria-hidden className={styles.icon} />
|
||||
<Input
|
||||
placeholder={search.placeholder}
|
||||
className={clsx("plain", styles.searchInput)}
|
||||
className={clsx(styles.searchInput, "in-container")}
|
||||
/>
|
||||
<Button className={styles.searchClearButton}>
|
||||
<CrossIcon className={styles.smallIcon} />
|
||||
<X className={styles.icon} />
|
||||
</Button>
|
||||
</SearchField>
|
||||
) : null}
|
||||
<Virtualizer layout={ListLayout} layoutOptions={{ rowHeight: 33 }}>
|
||||
<ListBox
|
||||
items={items}
|
||||
className={styles.listBox}
|
||||
className={clsx(styles.listBox, "scrollbar")}
|
||||
renderEmptyState={() => (
|
||||
<div className={styles.noResults}>{t("common:noResults")}</div>
|
||||
)}
|
||||
|
|
@ -203,7 +201,7 @@ function SelectClearButton() {
|
|||
slot={null}
|
||||
variant="minimal-destructive"
|
||||
size="miniscule"
|
||||
icon={<CrossIcon />}
|
||||
icon={<X />}
|
||||
onPress={() => state?.setSelectedKey(null)}
|
||||
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 {
|
||||
Switch as ReactAriaSwitch,
|
||||
type SwitchProps as ReactAriaSwitchProps,
|
||||
} from "react-aria-components";
|
||||
import styles from "./Switch.module.css";
|
||||
|
||||
interface SendouSwitchProps extends ReactAriaSwitchProps {
|
||||
children?: React.ReactNode;
|
||||
size?: "small" | "medium";
|
||||
}
|
||||
|
||||
export function SendouSwitch({ children, size, ...rest }: SendouSwitchProps) {
|
||||
export function SendouSwitch({ children, ...rest }: SendouSwitchProps) {
|
||||
return (
|
||||
<ReactAriaSwitch
|
||||
{...rest}
|
||||
className={clsx("react-aria-Switch", { small: size === "small" })}
|
||||
>
|
||||
<div className="indicator" />
|
||||
<ReactAriaSwitch {...rest} className={styles.root}>
|
||||
<div className={styles.indicator} />
|
||||
{children}
|
||||
</ReactAriaSwitch>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,23 @@
|
|||
.tabListContainer {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.tabList {
|
||||
display: flex;
|
||||
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 {
|
||||
|
|
@ -23,38 +39,48 @@
|
|||
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;
|
||||
}
|
||||
|
||||
.disappearing .tabList:has(.tabButton:only-child) {
|
||||
.disappearing .tabList:has(> div:only-child) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tabContainer {
|
||||
min-width: fit-content;
|
||||
margin-bottom: -2px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.tabButton {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
font-size: var(--fonts-xs);
|
||||
font-size: var(--font-xs);
|
||||
border-radius: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-lighter);
|
||||
color: var(--color-text-high);
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.tabButton[data-selected] {
|
||||
border-color: var(--theme);
|
||||
color: var(--text);
|
||||
.tabContainer[data-selected] .tabButton {
|
||||
border-color: var(--color-text-accent);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.tabButton[data-focus-visible] {
|
||||
color: var(--theme) !important;
|
||||
.tabContainer[data-focus-visible] .tabButton {
|
||||
color: var(--color-text-accent) !important;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tabNumber {
|
||||
color: var(--theme);
|
||||
color: var(--color-text-accent);
|
||||
margin-inline-start: var(--s-2);
|
||||
}
|
||||
|
||||
|
|
@ -62,5 +88,5 @@
|
|||
position: sticky;
|
||||
top: 47px;
|
||||
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) {
|
||||
return (
|
||||
<Tab className={clsx(buttonStyles.button, styles.tabButton)} {...rest}>
|
||||
{icon}
|
||||
{children}
|
||||
{typeof number === "number" && number !== 0 && (
|
||||
<span className={styles.tabNumber}>{number}</span>
|
||||
)}
|
||||
<Tab className={styles.tabContainer} {...rest}>
|
||||
<div className={clsx(buttonStyles.button, styles.tabButton)}>
|
||||
{icon}
|
||||
{children}
|
||||
{typeof number === "number" && number !== 0 && (
|
||||
<span className={styles.tabNumber}>{number}</span>
|
||||
)}
|
||||
</div>
|
||||
</Tab>
|
||||
);
|
||||
}
|
||||
|
||||
interface SendouTabListProps<T extends object> extends TabListProps<T> {
|
||||
/** Should overflow-x: auto CSS rule be applied? Defaults to true */
|
||||
scrolling?: boolean;
|
||||
sticky?: boolean;
|
||||
/** Should tabs take 100% width with equal distribution? */
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export function SendouTabList<T extends object>({
|
||||
scrolling = true,
|
||||
sticky,
|
||||
fullWidth,
|
||||
...rest
|
||||
}: SendouTabListProps<T>) {
|
||||
return (
|
||||
<TabList
|
||||
className={clsx(styles.tabList, {
|
||||
"overflow-x-auto": scrolling,
|
||||
// invisible: cantSwitchTabs && !disappearing,
|
||||
// hidden: cantSwitchTabs && disappearing,
|
||||
[styles.sticky]: sticky,
|
||||
})}
|
||||
{...rest}
|
||||
/>
|
||||
<div className={clsx(styles.tabListContainer, "scrollbar")}>
|
||||
<TabList
|
||||
className={clsx(styles.tabList, {
|
||||
// invisible: cantSwitchTabs && !disappearing,
|
||||
// hidden: cantSwitchTabs && disappearing,
|
||||
[styles.sticky]: sticky,
|
||||
[styles.fullWidth]: fullWidth,
|
||||
})}
|
||||
{...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