Initial React badminton team matching app

This commit is contained in:
2026-04-14 22:35:03 +08:00
commit 268e76bf0d
15 changed files with 3861 additions and 0 deletions

260
src/App.tsx Normal file
View File

@@ -0,0 +1,260 @@
import { useEffect, useState } from 'react'
import './App.css'
type Pair = {
id: number
playerA: string
playerB: string
}
type DrawResult = {
pairs: Pair[]
benchA: string[]
benchB: string[]
}
const STORAGE_KEYS = {
areaA: 'badminton-match-hub::area-a',
areaB: 'badminton-match-hub::area-b',
} as const
const defaultAreaA = ['小杰', '阿誠', '建宏', '柏宇', 'Eason']
const defaultAreaB = ['小安', '佩珊', '阿廷', 'Luna', 'Mina']
function App() {
const [areaAInput, setAreaAInput] = useState(() =>
loadRoster(STORAGE_KEYS.areaA, defaultAreaA),
)
const [areaBInput, setAreaBInput] = useState(() =>
loadRoster(STORAGE_KEYS.areaB, defaultAreaB),
)
const [result, setResult] = useState<DrawResult | null>(null)
const [error, setError] = useState('')
useEffect(() => {
window.localStorage.setItem(STORAGE_KEYS.areaA, areaAInput)
}, [areaAInput])
useEffect(() => {
window.localStorage.setItem(STORAGE_KEYS.areaB, areaBInput)
}, [areaBInput])
const parsedAreaA = parseRoster(areaAInput)
const parsedAreaB = parseRoster(areaBInput)
function generateMatches() {
if (parsedAreaA.length < 3 || parsedAreaB.length < 3) {
setResult(null)
setError('A 區與 B 區都至少要有 3 位不重複成員,才能產生三組配對。')
return
}
const shuffledA = shuffleList(parsedAreaA)
const shuffledB = shuffleList(parsedAreaB)
const selectedA = shuffledA.slice(0, 3)
const selectedB = shuffledB.slice(0, 3)
const pairs = selectedA.map((playerA, index) => ({
id: index + 1,
playerA,
playerB: selectedB[index],
}))
setResult({
pairs,
benchA: shuffledA.slice(3),
benchB: shuffledB.slice(3),
})
setError('')
}
function resetDemo() {
setAreaAInput(defaultAreaA.join('\n'))
setAreaBInput(defaultAreaB.join('\n'))
setResult(null)
setError('')
}
return (
<main className="app-shell">
<section className="hero-card">
<div className="hero-copy">
<p className="eyebrow">使 ReactVite TypeScript </p>
<h1></h1>
<p className="hero-text">
A B 3
3 A 1 B 1
</p>
<div className="hero-actions">
<button className="primary-button" type="button" onClick={generateMatches}>
</button>
<button className="ghost-button" type="button" onClick={resetDemo}>
</button>
</div>
</div>
<div className="hero-stats">
<article className="stat-card">
<span>A </span>
<strong>{parsedAreaA.length}</strong>
</article>
<article className="stat-card">
<span>B </span>
<strong>{parsedAreaB.length}</strong>
</article>
<article className="tip-card">
<h2></h2>
<p> 3 A 1 B 1 </p>
</article>
</div>
</section>
<section className="content-grid">
<article className="panel">
<div className="panel-heading">
<p className="eyebrow"></p>
<h2> A B </h2>
</div>
<div className="input-grid">
<label className="roster-field">
<span>A </span>
<textarea
value={areaAInput}
onChange={(event) => setAreaAInput(event.target.value)}
placeholder={'每行一位\n例如小杰'}
rows={10}
/>
</label>
<label className="roster-field">
<span>B </span>
<textarea
value={areaBInput}
onChange={(event) => setAreaBInput(event.target.value)}
placeholder={'每行一位\n例如小安'}
rows={10}
/>
</label>
</div>
<p className="helper-text">
</p>
{error ? <p className="error-banner">{error}</p> : null}
</article>
<article className="panel">
<div className="panel-heading">
<p className="eyebrow"></p>
<h2></h2>
</div>
{result ? (
<div className="pair-list">
{result.pairs.map((pair) => (
<article className="pair-card" key={pair.id}>
<div className="pair-index"> {pair.id} </div>
<div className="pair-names">
<div className="player-chip player-chip-a">
<span className="chip-label">A </span>
<strong>{pair.playerA}</strong>
</div>
<div className="pair-link">+</div>
<div className="player-chip player-chip-b">
<span className="chip-label">B </span>
<strong>{pair.playerB}</strong>
</div>
</div>
</article>
))}
</div>
) : (
<div className="empty-state">
<p></p>
</div>
)}
</article>
</section>
<section className="bench-grid">
<article className="panel">
<div className="panel-heading">
<p className="eyebrow"></p>
<h2>A </h2>
</div>
<RosterList
names={result?.benchA ?? parsedAreaA.slice(3)}
emptyText="目前沒有 A 區候補成員"
/>
</article>
<article className="panel">
<div className="panel-heading">
<p className="eyebrow"></p>
<h2>B </h2>
</div>
<RosterList
names={result?.benchB ?? parsedAreaB.slice(3)}
emptyText="目前沒有 B 區候補成員"
/>
</article>
</section>
</main>
)
}
type RosterListProps = {
names: string[]
emptyText: string
}
function RosterList({ names, emptyText }: RosterListProps) {
if (names.length === 0) {
return <p className="empty-inline">{emptyText}</p>
}
return (
<div className="name-list">
{names.map((name) => (
<span className="name-pill" key={name}>
{name}
</span>
))}
</div>
)
}
function loadRoster(storageKey: string, fallbackList: string[]) {
const storedValue = window.localStorage.getItem(storageKey)
return storedValue && storedValue.trim() ? storedValue : fallbackList.join('\n')
}
function parseRoster(input: string) {
const uniqueNames = new Set<string>()
input
.split(/[\n,,、]+/g)
.map((name) => name.trim())
.filter(Boolean)
.forEach((name) => uniqueNames.add(name))
return Array.from(uniqueNames)
}
function shuffleList(list: string[]) {
const next = [...list]
for (let index = next.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(Math.random() * (index + 1))
;[next[index], next[swapIndex]] = [next[swapIndex], next[index]]
}
return next
}
export default App