Design refresh + a bunch of stuff (#2864)

Co-authored-by: hfcRed <hfcred@gmx.net>
This commit is contained in:
Kalle 2026-03-19 17:51:42 +02:00 committed by GitHub
parent fcc5c5f273
commit fef1ffc955
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
830 changed files with 35854 additions and 16836 deletions

View File

@ -1,6 +0,0 @@
beans:
path: .beans
prefix: sendou.ink-2-
id_length: 4
default_status: todo
default_type: task

9
.gitignore vendored
View File

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

View File

@ -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
View File

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

166
TOURNAMENT_LFG_SPEC.md Normal file
View File

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

View File

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

View File

@ -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 {

View File

@ -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);
}

View File

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

View File

@ -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>
);
}

View 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);
}

View File

@ -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);
}

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

View File

@ -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>
);
}

View File

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

View File

@ -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"

View File

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

View 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);
}

View File

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

View File

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

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

View 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>
);
}

View File

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

View 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);
}

View File

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

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

View 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>
);
})}
</>
);
}

View File

@ -0,0 +1,7 @@
.container {
font-size: var(--font-sm);
& > h4 {
color: var(--color-error);
}
}

View File

@ -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) => (

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

View File

@ -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,
)}
>

View File

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

View 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>
);
}

View File

@ -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}

View File

@ -0,0 +1,8 @@
.tierContainer {
display: grid;
}
.tierImg {
grid-column: 1;
grid-row: 1;
}

View File

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

View 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);
}

View File

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

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

View File

@ -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}

View 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);
}

View File

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

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

View File

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

View File

@ -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%);
}

View File

@ -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"

View File

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

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

View 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>
);
}

View 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);
}
}

View 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>
);
}

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

View File

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

View File

@ -0,0 +1,8 @@
.input {
position: absolute;
width: 0;
height: 0;
border: none;
opacity: 0;
pointer-events: none;
}

View File

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

View 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);
}
}

View File

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

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

View File

@ -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 {

View File

@ -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}

View 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);
}

View 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>
);
}

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

View File

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

View 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);
}
}

View File

@ -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>
);
}

View File

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

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

View File

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

View File

@ -1,7 +1,3 @@
.selectWidthWider {
--select-width: 100%;
}
.item {
display: flex;
gap: var(--s-2);

View File

@ -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 }) => (

View 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%;
}

View File

@ -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}`}

View File

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

View File

@ -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);
}

View File

@ -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",
});
}

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

View File

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

View 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);
}

View 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>
</>
);
}

View 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);
}

View File

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

View File

@ -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);
}

View File

@ -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"

View File

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

View File

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

View File

@ -0,0 +1,6 @@
.label {
font-size: var(--font-xs);
font-weight: var(--weight-bold);
margin-block-end: var(--label-margin);
display: block;
}

View File

@ -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>
);
}

View File

@ -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);
}

View File

@ -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}

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

View File

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

View File

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

View File

@ -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}
>

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

View File

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

View File

@ -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);
}

View File

@ -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>
);
}

View 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