guandan.dev
guandan.dev
https://git.tonybtw.com/guandan.dev.git
git://git.tonybtw.com/guandan.dev.git
Fixed size for mobile
Diff
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 2824cb2..78356c3 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -11,11 +11,6 @@ import {
} from './game/types'
import {
find_same_rank,
- select_pair,
- select_triple,
- select_bomb,
- find_valid_plays,
- detect_combo,
} from './game/combos'
interface Deal_Cards_Payload {
@@ -199,43 +194,6 @@ export default function App() {
})
}, [])
- const handle_quick_select = useCallback((type: 'pair' | 'triple' | 'bomb' | 'clear') => {
- if (type === 'clear') {
- set_selected_ids(new Set())
- return
- }
-
- set_hand((current_hand) => {
- let cards: Card[] | null = null
- switch (type) {
- case 'pair':
- cards = select_pair(current_hand, level)
- break
- case 'triple':
- cards = select_triple(current_hand, level)
- break
- case 'bomb':
- cards = select_bomb(current_hand, level)
- break
- }
- if (cards) {
- set_selected_ids(new Set(cards.map(c => c.Id)))
- }
- return current_hand
- })
- }, [level])
-
- const handle_suggest = useCallback(() => {
- set_hand((current_hand) => {
- const table_combo = table_cards.length > 0 ? detect_combo(table_cards, level) : null
- const suggestions = find_valid_plays(current_hand, table_combo, level)
- if (suggestions.length > 0) {
- set_selected_ids(new Set(suggestions[0].map(c => c.Id)))
- }
- return current_hand
- })
- }, [table_cards, level])
-
const handle_play = useCallback(() => {
if (selected_ids.size === 0) return
@@ -282,8 +240,6 @@ export default function App() {
selected_ids={selected_ids}
on_card_click={handle_card_click}
on_select_same_rank={handle_select_same_rank}
- on_quick_select={handle_quick_select}
- on_suggest={handle_suggest}
on_play={handle_play}
on_pass={handle_pass}
table_cards={table_cards}
diff --git a/client/src/components/Card.tsx b/client/src/components/Card.tsx
index 7c334c9..afa4ce4 100644
--- a/client/src/components/Card.tsx
+++ b/client/src/components/Card.tsx
@@ -1,110 +1,142 @@
-import { motion } from 'framer-motion'
-import { Card as Card_Type, get_suit_symbol, get_rank_symbol, is_red_suit, is_wild, Rank, Rank_Red_Joker, Suit_Joker } from '../game/types'
+import {
+ Card as Card_Type, get_suit_symbol, get_rank_symbol, is_red_suit, is_wild, Rank, Rank_Red_Joker, Suit_Joker
+} from '../game/types'
type Card_Size = 'small' | 'normal'
interface Card_Props {
- card: Card_Type
- level: Rank
- selected: boolean
- on_click: () => void
- size?: Card_Size
+ card: Card_Type
+ level: Rank
+ selected: boolean
+ on_click: () => void
+ size?: Card_Size
}
const SIZE_CONFIG = {
- small: { width: 56, height: 80, rank_font: 14, suit_font: 16, center_font: 20, corner_rank: 12, corner_suit: 10 },
- normal: { width: 70, height: 100, rank_font: 16, suit_font: 18, center_font: 24, corner_rank: 14, corner_suit: 12 },
+ small: { width: 36, height: 50, rank_font: 13, suit_font: 11 },
+ normal: { width: 56, height: 78, rank_font: 20, suit_font: 18 },
}
export function Card({ card, level, selected, on_click, size = 'normal' }: Card_Props) {
- const is_joker = card.Suit === Suit_Joker
- const is_red = is_joker ? card.Rank === Rank_Red_Joker : is_red_suit(card.Suit)
- const is_wild_card = is_wild(card, level)
- const cfg = SIZE_CONFIG[size]
+ const is_joker = card.Suit === Suit_Joker
+ const is_red = is_joker ? card.Rank === Rank_Red_Joker : is_red_suit(card.Suit)
+ const is_wild_card = is_wild(card, level)
+ const cfg = SIZE_CONFIG[size]
- return (
- <motion.div
- onClick={on_click}
- animate={{
- y: selected ? -20 : 0,
- scale: selected ? 1.05 : 1,
- }}
- whileHover={{ scale: 1.08 }}
- transition={{ type: 'spring', stiffness: 400, damping: 25 }}
- style={{
- width: cfg.width,
- height: cfg.height,
- backgroundColor: is_wild_card ? '#fff3cd' : '#fff',
- border: is_wild_card ? '3px solid #ffc107' : '2px solid #333',
- borderRadius: 8,
- position: 'relative',
- cursor: 'pointer',
- userSelect: 'none',
- boxShadow: selected ? '0 8px 16px rgba(0,0,0,0.3)' : '0 2px 4px rgba(0,0,0,0.1)',
- color: is_red ? '#dc3545' : '#000',
- fontWeight: 'bold',
- }}
- >
- <div style={{
- position: 'absolute',
- top: 4,
- left: 6,
- display: 'flex',
- flexDirection: 'column',
- alignItems: 'center',
- lineHeight: 1,
- }}>
- <span style={{ fontSize: cfg.rank_font, fontWeight: 'bold' }}>{is_joker ? (card.Rank === Rank_Red_Joker ? 'R' : 'B') : get_rank_symbol(card.Rank)}</span>
- <span style={{ fontSize: cfg.suit_font }}>{is_joker ? '🃏' : get_suit_symbol(card.Suit)}</span>
- </div>
- <div style={{
- position: 'absolute',
- bottom: 4,
- right: 6,
- display: 'flex',
- flexDirection: 'column',
- alignItems: 'center',
- lineHeight: 1,
- transform: 'rotate(180deg)',
- }}>
- <span style={{ fontSize: cfg.corner_rank }}>{is_joker ? (card.Rank === Rank_Red_Joker ? 'R' : 'B') : get_rank_symbol(card.Rank)}</span>
- <span style={{ fontSize: cfg.corner_suit }}>{is_joker ? '🃏' : get_suit_symbol(card.Suit)}</span>
- </div>
- <div style={{
- position: 'absolute',
- top: '50%',
- left: '50%',
- transform: 'translate(-50%, -50%)',
- fontSize: is_joker ? cfg.center_font + 4 : cfg.center_font,
- }}>
- {is_joker ? '🃏' : get_suit_symbol(card.Suit)}
- </div>
- </motion.div>
- )
+ const rank_display = is_joker
+ ? (card.Rank === Rank_Red_Joker ? 'R' : 'B')
+ : get_rank_symbol(card.Rank)
+
+ const suit_display = is_joker ? '🃏' : get_suit_symbol(card.Suit)
+
+ return (
+ <div
+ onClick={on_click}
+ style={{
+ width: cfg.width,
+ height: cfg.height,
+ backgroundColor: is_wild_card ? '#fff3cd' : '#fff',
+ border: is_wild_card ? '2px solid #ffc107' : '1px solid #ccc',
+ borderRadius: 6,
+ position: 'relative',
+ cursor: 'pointer',
+ userSelect: 'none',
+ boxShadow: '0 1px 3px rgba(0,0,0,0.12)',
+ overflow: 'hidden',
+ }}
+ >
+ {/* Selection overlay */}
+ {selected && (
+ <div
+ style={{
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: 'rgba(156, 39, 176, 0.35)',
+ borderRadius: 5,
+ pointerEvents: 'none',
+ zIndex: 10,
+ }}
+ />
+ )}
+
+ {/* Rank and suit at TOP of card so it's visible when stacked */}
+ <div
+ style={{
+ position: 'absolute',
+ top: 4,
+ left: 0,
+ right: 0,
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 1,
+ }}
+ >
+ <span
+ style={{
+ fontSize: cfg.rank_font,
+ fontWeight: 'bold',
+ color: is_red ? '#dc3545' : '#000',
+ lineHeight: 1,
+ }}
+ >
+ {rank_display}
+ </span>
+ <span
+ style={{
+ fontSize: cfg.suit_font,
+ color: is_red ? '#dc3545' : '#000',
+ lineHeight: 1,
+ }}
+ >
+ {suit_display}
+ </span>
+ </div>
+
+ {/* Big suit in center */}
+ <div
+ style={{
+ position: 'absolute',
+ top: '55%',
+ left: '50%',
+ transform: 'translate(-50%, -50%)',
+ fontSize: size === 'small' ? 18 : 32,
+ color: is_red ? '#dc3545' : '#000',
+ opacity: 0.9,
+ }}
+ >
+ {suit_display}
+ </div>
+ </div>
+ )
}
interface Card_Back_Props {
- size?: Card_Size
+ size?: Card_Size
}
export function Card_Back({ size = 'normal' }: Card_Back_Props) {
- const cfg = SIZE_CONFIG[size]
+ const cfg = SIZE_CONFIG[size]
- return (
- <div
- style={{
- width: cfg.width,
- height: cfg.height,
- backgroundColor: '#1e3a5f',
- border: '2px solid #0d1b2a',
- borderRadius: 8,
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 5px, rgba(255,255,255,0.1) 5px, rgba(255,255,255,0.1) 10px)',
- }}
- >
- <div style={{ color: '#fff', fontSize: size === 'small' ? 20 : 24 }}>🀄</div>
- </div>
- )
+ return (
+ <div
+ style={{
+ width: cfg.width,
+ height: cfg.height,
+ backgroundColor: '#1e3a5f',
+ border: '1px solid #0d1b2a',
+ borderRadius: 6,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 5px, rgba(255,255,255,0.1) 5px, rgba(255,255,255,0.1) 10px)',
+ }}
+ >
+ <div style={{ color: '#fff', fontSize: size === 'small' ? 14 : 22 }}>🀄</div>
+ </div>
+ )
}
diff --git a/client/src/components/Game.tsx b/client/src/components/Game.tsx
index 462ca9f..f1ce23f 100644
--- a/client/src/components/Game.tsx
+++ b/client/src/components/Game.tsx
@@ -11,8 +11,6 @@ interface Game_Props {
selected_ids: Set<number>
on_card_click: (id: number) => void
on_select_same_rank: (rank: number) => void
- on_quick_select: (type: 'pair' | 'triple' | 'bomb' | 'clear') => void
- on_suggest: () => void
on_play: () => void
on_pass: () => void
table_cards: Card_Type[]
@@ -32,8 +30,6 @@ export function Game({
selected_ids,
on_card_click,
on_select_same_rank,
- on_quick_select,
- on_suggest,
on_play,
on_pass,
table_cards,
@@ -76,48 +72,19 @@ export function Game({
</div>
<div style={mobile_styles.my_area}>
- {/* Quick select buttons */}
- <div style={mobile_styles.quick_select_row}>
- <button onClick={() => on_quick_select('pair')} style={mobile_styles.quick_btn}>2x</button>
- <button onClick={() => on_quick_select('triple')} style={mobile_styles.quick_btn}>3x</button>
- <button onClick={() => on_quick_select('bomb')} style={mobile_styles.quick_btn_bomb}>Bomb</button>
- <button onClick={on_suggest} style={mobile_styles.quick_btn_suggest}>Hint</button>
- <button onClick={() => on_quick_select('clear')} style={mobile_styles.quick_btn_clear}>Clear</button>
- </div>
-
<Hand
cards={hand}
level={level}
selected_ids={selected_ids}
on_card_click={on_card_click}
+ on_toggle_selection={on_card_click}
on_select_same_rank={on_select_same_rank}
+ on_play={on_play}
+ on_pass={on_pass}
+ is_my_turn={is_my_turn}
+ can_pass={can_pass}
/>
- <div style={mobile_styles.actions}>
- <motion.button
- whileTap={{ scale: 0.95 }}
- onClick={on_play}
- disabled={!is_my_turn || selected_ids.size === 0 || hand.length === 0}
- style={{
- ...mobile_styles.action_button,
- backgroundColor: is_my_turn && selected_ids.size > 0 && hand.length > 0 ? '#28a745' : '#444',
- }}
- >
- Play
- </motion.button>
- <motion.button
- whileTap={{ scale: 0.95 }}
- onClick={on_pass}
- disabled={!is_my_turn || !can_pass || hand.length === 0}
- style={{
- ...mobile_styles.action_button,
- backgroundColor: is_my_turn && can_pass && hand.length > 0 ? '#dc3545' : '#444',
- }}
- >
- Pass
- </motion.button>
- </div>
-
{is_my_turn && hand.length > 0 && (
<motion.div
initial={{ opacity: 0 }}
@@ -172,7 +139,6 @@ export function Game({
is_turn={current_turn === relative_positions.left}
just_played={last_play_seat === relative_positions.left}
seat={relative_positions.left}
- vertical
name={players_map[relative_positions.left]}
/>
</div>
@@ -182,62 +148,30 @@ export function Game({
</div>
<div style={styles.opponent_side}>
- <Opponent_Hand
- count={player_card_counts[relative_positions.right]}
- is_turn={current_turn === relative_positions.right}
- just_played={last_play_seat === relative_positions.right}
- seat={relative_positions.right}
- vertical
- name={players_map[relative_positions.right]}
- />
- </div>
+ <Opponent_Hand
+ count={player_card_counts[relative_positions.right]}
+ is_turn={current_turn === relative_positions.right}
+ just_played={last_play_seat === relative_positions.right}
+ seat={relative_positions.right}
+ name={players_map[relative_positions.right]}
+ />
+ </div>
</div>
<div style={styles.my_area}>
- {/* Quick select buttons */}
- <div style={styles.quick_select_row}>
- <button onClick={() => on_quick_select('pair')} style={styles.quick_btn}>Pair</button>
- <button onClick={() => on_quick_select('triple')} style={styles.quick_btn}>Triple</button>
- <button onClick={() => on_quick_select('bomb')} style={styles.quick_btn_bomb}>Bomb</button>
- <button onClick={on_suggest} style={styles.quick_btn_suggest}>Suggest</button>
- <button onClick={() => on_quick_select('clear')} style={styles.quick_btn_clear}>Clear</button>
- </div>
-
<Hand
cards={hand}
level={level}
selected_ids={selected_ids}
on_card_click={on_card_click}
+ on_toggle_selection={on_card_click}
on_select_same_rank={on_select_same_rank}
+ on_play={on_play}
+ on_pass={on_pass}
+ is_my_turn={is_my_turn}
+ can_pass={can_pass}
/>
- <div style={styles.actions}>
- <motion.button
- whileHover={{ scale: 1.05 }}
- whileTap={{ scale: 0.95 }}
- onClick={on_play}
- disabled={!is_my_turn || selected_ids.size === 0 || hand.length === 0}
- style={{
- ...styles.action_button,
- backgroundColor: is_my_turn && selected_ids.size > 0 && hand.length > 0 ? '#28a745' : '#444',
- }}
- >
- Play
- </motion.button>
- <motion.button
- whileHover={{ scale: 1.05 }}
- whileTap={{ scale: 0.95 }}
- onClick={on_pass}
- disabled={!is_my_turn || !can_pass || hand.length === 0}
- style={{
- ...styles.action_button,
- backgroundColor: is_my_turn && can_pass && hand.length > 0 ? '#dc3545' : '#444',
- }}
- >
- Pass
- </motion.button>
- </div>
-
{is_my_turn && hand.length > 0 && (
<motion.div
initial={{ opacity: 0 }}
@@ -307,14 +241,10 @@ interface Opponent_Hand_Props {
is_turn: boolean
just_played?: boolean
seat: number
- vertical?: boolean
name?: string
}
-function Opponent_Hand({ count, is_turn, just_played, seat, vertical, name }: Opponent_Hand_Props) {
- const display_count = Math.min(count, 10)
- const overlap = vertical ? 15 : 20
-
+function Opponent_Hand({ count, is_turn, just_played, seat, name }: Opponent_Hand_Props) {
const get_highlight_style = () => {
if (just_played) return { backgroundColor: 'rgba(76, 175, 80, 0.3)', border: '2px solid #4caf50' }
if (is_turn) return { backgroundColor: 'rgba(255,193,7,0.2)', border: '2px solid #ffc107' }
@@ -325,41 +255,38 @@ function Opponent_Hand({ count, is_turn, just_played, seat, vertical, name }: Op
<div
style={{
display: 'flex',
- flexDirection: vertical ? 'column' : 'row',
+ flexDirection: 'column',
alignItems: 'center',
- gap: 8,
+ gap: 4,
padding: 8,
borderRadius: 8,
transition: 'all 0.2s ease',
...get_highlight_style(),
}}
>
- <div
- style={{
- display: 'flex',
- flexDirection: vertical ? 'column' : 'row',
- position: 'relative',
- width: vertical ? 50 : 50 + (display_count - 1) * overlap,
- height: vertical ? 70 + (display_count - 1) * overlap : 70,
- }}
- >
- {Array.from({ length: display_count }).map((_, i) => (
- <div
- key={i}
- style={{
- position: 'absolute',
- left: vertical ? 0 : i * overlap,
- top: vertical ? i * overlap : 0,
- transform: 'scale(0.7)',
- transformOrigin: 'top left',
- }}
- >
- <Card_Back />
- </div>
- ))}
+ <div style={{ position: 'relative' }}>
+ <Card_Back size="small" />
+ <div
+ style={{
+ position: 'absolute',
+ top: '50%',
+ left: '50%',
+ transform: 'translate(-50%, -50%)',
+ backgroundColor: 'rgba(0,0,0,0.7)',
+ color: '#fff',
+ fontWeight: 'bold',
+ fontSize: 16,
+ padding: '4px 8px',
+ borderRadius: 4,
+ minWidth: 24,
+ textAlign: 'center',
+ }}
+ >
+ {count}
+ </div>
</div>
- <div style={{ color: '#fff', fontSize: 12 }}>
- {name || `Seat ${seat + 1}`}: {count}
+ <div style={{ color: '#fff', fontSize: 11 }}>
+ {name || `P${seat + 1}`}
</div>
</div>
)
@@ -373,15 +300,6 @@ function get_relative_positions(my_seat: number) {
}
}
-const quick_btn_base: React.CSSProperties = {
- padding: '6px 12px',
- fontSize: 12,
- border: 'none',
- borderRadius: 6,
- cursor: 'pointer',
- fontWeight: 'bold',
-}
-
const styles: Record<string, React.CSSProperties> = {
container: {
display: 'flex',
@@ -406,15 +324,6 @@ const styles: Record<string, React.CSSProperties> = {
fontWeight: 'bold',
fontSize: 14,
},
- layout_toggle: {
- padding: '6px 12px',
- backgroundColor: '#4a5568',
- color: '#fff',
- border: 'none',
- borderRadius: 6,
- cursor: 'pointer',
- fontSize: 12,
- },
team_scores: {
color: '#fff',
fontSize: 12,
@@ -462,44 +371,6 @@ const styles: Record<string, React.CSSProperties> = {
borderTop: '2px solid #333',
flexShrink: 0,
},
- quick_select_row: {
- display: 'flex',
- gap: 8,
- marginBottom: 8,
- },
- quick_btn: {
- ...quick_btn_base,
- backgroundColor: '#2196f3',
- color: '#fff',
- },
- quick_btn_bomb: {
- ...quick_btn_base,
- backgroundColor: '#ff5722',
- color: '#fff',
- },
- quick_btn_suggest: {
- ...quick_btn_base,
- backgroundColor: '#9c27b0',
- color: '#fff',
- },
- quick_btn_clear: {
- ...quick_btn_base,
- backgroundColor: '#607d8b',
- color: '#fff',
- },
- actions: {
- display: 'flex',
- gap: 12,
- marginTop: 8,
- },
- action_button: {
- padding: '10px 24px',
- fontSize: 14,
- border: 'none',
- borderRadius: 8,
- color: '#fff',
- cursor: 'pointer',
- },
turn_indicator: {
marginTop: 8,
padding: '6px 12px',
@@ -517,15 +388,6 @@ const styles: Record<string, React.CSSProperties> = {
},
}
-const mobile_quick_btn_base: React.CSSProperties = {
- padding: '4px 8px',
- fontSize: 10,
- border: 'none',
- borderRadius: 4,
- cursor: 'pointer',
- fontWeight: 'bold',
-}
-
const mobile_styles: Record<string, React.CSSProperties> = {
container: {
display: 'flex',
@@ -550,15 +412,6 @@ const mobile_styles: Record<string, React.CSSProperties> = {
fontWeight: 'bold',
fontSize: 12,
},
- layout_toggle: {
- padding: '4px 8px',
- backgroundColor: '#4a5568',
- color: '#fff',
- border: 'none',
- borderRadius: 4,
- cursor: 'pointer',
- fontSize: 12,
- },
team_scores: {
color: '#fff',
fontSize: 11,
@@ -597,44 +450,6 @@ const mobile_styles: Record<string, React.CSSProperties> = {
borderTop: '2px solid #333',
flexShrink: 0,
},
- quick_select_row: {
- display: 'flex',
- gap: 6,
- marginBottom: 4,
- },
- quick_btn: {
- ...mobile_quick_btn_base,
- backgroundColor: '#2196f3',
- color: '#fff',
- },
- quick_btn_bomb: {
- ...mobile_quick_btn_base,
- backgroundColor: '#ff5722',
- color: '#fff',
- },
- quick_btn_suggest: {
- ...mobile_quick_btn_base,
- backgroundColor: '#9c27b0',
- color: '#fff',
- },
- quick_btn_clear: {
- ...mobile_quick_btn_base,
- backgroundColor: '#607d8b',
- color: '#fff',
- },
- actions: {
- display: 'flex',
- gap: 16,
- marginTop: 4,
- },
- action_button: {
- padding: '10px 28px',
- fontSize: 14,
- border: 'none',
- borderRadius: 8,
- color: '#fff',
- cursor: 'pointer',
- },
turn_indicator: {
marginTop: 6,
padding: '4px 10px',
diff --git a/client/src/components/Hand.tsx b/client/src/components/Hand.tsx
index dc40d2c..8bc6262 100644
--- a/client/src/components/Hand.tsx
+++ b/client/src/components/Hand.tsx
@@ -1,6 +1,6 @@
import { useRef, useCallback, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
-import { Card as Card_Type, Rank } from '../game/types'
+import { Card as Card_Type, Rank, Suit_Hearts, Suit_Diamonds, Suit_Clubs, Suit_Spades } from '../game/types'
import { Card } from './Card'
import { use_is_mobile } from '../hooks/use_is_mobile'
@@ -9,7 +9,12 @@ interface Hand_Props {
level: Rank
selected_ids: Set<number>
on_card_click: (id: number) => void
+ on_toggle_selection: (id: number) => void
on_select_same_rank: (rank: number) => void
+ on_play: () => void
+ on_pass: () => void
+ is_my_turn: boolean
+ can_pass: boolean
}
interface Column {
@@ -18,23 +23,21 @@ interface Column {
is_custom: boolean
}
-export function Hand({ cards, level, selected_ids, on_card_click, on_select_same_rank }: Hand_Props) {
+export function Hand({ cards, level, selected_ids, on_card_click, on_toggle_selection, on_select_same_rank, on_play, on_pass, is_my_turn, can_pass }: Hand_Props) {
const is_mobile = use_is_mobile()
const last_click = useRef<{ id: number; time: number } | null>(null)
const [custom_columns, set_custom_columns] = useState<Map<string, number[]>>(new Map())
- const [drag_card_id, set_drag_card_id] = useState<number | null>(null)
- const [drop_target, set_drop_target] = useState<string | null>(null)
- // Touch drag state
- const touch_start_pos = useRef<{ x: number; y: number } | null>(null)
- const is_touch_dragging = useRef(false)
- const column_refs = useRef<Map<string, HTMLDivElement>>(new Map())
- const new_pile_ref = useRef<HTMLDivElement>(null)
+ // Swipe-to-select state
+ const [is_swiping, set_is_swiping] = useState(false)
+ const swipe_start = useRef<{ x: number; y: number } | null>(null)
+ const swiped_cards = useRef<Set<number>>(new Set())
+ const card_refs = useRef<Map<number, HTMLDivElement>>(new Map())
const handle_card_click = useCallback((card: Card_Type) => {
- // Don't trigger click if we were dragging
- if (is_touch_dragging.current) {
- is_touch_dragging.current = false
+ // Don't trigger click if we were swiping
+ if (swiped_cards.current.size > 0) {
+ swiped_cards.current.clear()
return
}
@@ -57,14 +60,15 @@ export function Hand({ cards, level, selected_ids, on_card_click, on_select_same
// Filter to only cards that still exist in hand
const valid_card_ids = new Set(cards.map(c => c.Id))
- // Build columns: custom columns first, then auto-sorted remaining cards
+ // Build columns: auto-sorted cards first, then custom columns on right
const columns: Column[] = []
+ const custom_cols: Column[] = []
- // Add custom columns (filter out cards no longer in hand)
+ // Collect custom columns (filter out cards no longer in hand)
custom_columns.forEach((card_ids, col_id) => {
const valid_ids = card_ids.filter(id => valid_card_ids.has(id))
if (valid_ids.length > 0) {
- columns.push({ id: col_id, card_ids: valid_ids, is_custom: true })
+ custom_cols.push({ id: col_id, card_ids: valid_ids, is_custom: true })
}
})
@@ -97,52 +101,28 @@ export function Hand({ cards, level, selected_ids, on_card_click, on_select_same
})
})
+ // Add custom columns at the end (right side)
+ columns.push(...custom_cols)
+
// Card lookup
const card_by_id = new Map(cards.map(c => [c.Id, c]))
- const card_width = is_mobile ? 44 : 56
- const card_height = is_mobile ? 62 : 78
- const v_overlap = is_mobile ? 24 : 30
- const h_gap = is_mobile ? 3 : 4
- const selection_lift = 24
-
- // Find drop target from touch position
- const find_drop_target = (x: number, y: number): string | null => {
- // Check new pile zone
- if (new_pile_ref.current) {
- const rect = new_pile_ref.current.getBoundingClientRect()
- if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
- return 'new'
- }
- }
+ const card_width = is_mobile ? 36 : 56
+ const card_height = is_mobile ? 50 : 78
+ const v_overlap = is_mobile ? 18 : 30
+ const h_gap = is_mobile ? 2 : 3
- // Check custom columns
- for (const [col_id, el] of column_refs.current) {
- if (col_id.startsWith('custom-')) {
- const rect = el.getBoundingClientRect()
- if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
- return col_id
- }
- }
- }
-
- return null
- }
-
- // Commit the drop
- const commit_drop = (card_id: number, target: string | null) => {
- if (target === null) {
- set_drag_card_id(null)
- set_drop_target(null)
- return
- }
+ // Move selected cards to a new custom pile
+ const handle_create_pile = () => {
+ if (selected_ids.size === 0) return
+ const selected_array = Array.from(selected_ids)
set_custom_columns(prev => {
const next = new Map(prev)
- // Remove from any existing custom column
+ // Remove selected cards from any existing custom columns
next.forEach((ids, col_id) => {
- const filtered = ids.filter(id => id !== card_id)
+ const filtered = ids.filter(id => !selected_ids.has(id))
if (filtered.length === 0) {
next.delete(col_id)
} else {
@@ -150,73 +130,79 @@ export function Hand({ cards, level, selected_ids, on_card_click, on_select_same
}
})
- if (target === 'new') {
- const new_col_id = `custom-${Date.now()}`
- next.set(new_col_id, [card_id])
- } else if (target.startsWith('custom-')) {
- const existing = next.get(target) || []
- next.set(target, [...existing, card_id])
- }
+ // Create new column with selected cards
+ const new_col_id = `custom-${Date.now()}`
+ next.set(new_col_id, selected_array)
return next
})
-
- set_drag_card_id(null)
- set_drop_target(null)
}
- // Mouse drag handlers
- const handle_drag_start = (card_id: number) => {
- set_drag_card_id(card_id)
+ // Reset all custom arrangements
+ const handle_reset = () => {
+ set_custom_columns(new Map())
}
- const handle_drag_end = () => {
- if (drag_card_id !== null && drop_target !== null) {
- commit_drop(drag_card_id, drop_target)
- } else {
- set_drag_card_id(null)
- set_drop_target(null)
- }
- }
-
- const handle_drag_over_column = (col_id: string) => {
- set_drop_target(col_id)
+ // Select all cards of a given suit
+ const handle_select_suit = (suit: number) => {
+ const suit_cards = cards.filter(c => c.Suit === suit)
+ if (suit_cards.length === 0) return
+
+ // Toggle: if all are selected, deselect; otherwise select all
+ const all_selected = suit_cards.every(c => selected_ids.has(c.Id))
+ suit_cards.forEach(c => {
+ if (all_selected) {
+ on_card_click(c.Id) // deselect
+ } else if (!selected_ids.has(c.Id)) {
+ on_toggle_selection(c.Id) // select
+ }
+ })
}
- const handle_drag_leave = () => {
- set_drop_target(null)
+ // Swipe-to-select handlers
+ const find_card_at_point = (x: number, y: number): number | null => {
+ for (const [card_id, el] of card_refs.current) {
+ const rect = el.getBoundingClientRect()
+ if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
+ return card_id
+ }
+ }
+ return null
}
- // Touch handlers
- const handle_touch_start = (_card_id: number, e: React.TouchEvent) => {
- const touch = e.touches[0]
- touch_start_pos.current = { x: touch.clientX, y: touch.clientY }
- is_touch_dragging.current = false
+ const handle_swipe_start = (e: React.MouseEvent | React.TouchEvent) => {
+ const point = 'touches' in e ? e.touches[0] : e
+ swipe_start.current = { x: point.clientX, y: point.clientY }
+ swiped_cards.current.clear()
+ set_is_swiping(false)
}
- const handle_touch_move = (card_id: number, e: React.TouchEvent) => {
- if (!touch_start_pos.current) return
+ const handle_swipe_move = (e: React.MouseEvent | React.TouchEvent) => {
+ if (!swipe_start.current) return
- const touch = e.touches[0]
- const dx = Math.abs(touch.clientX - touch_start_pos.current.x)
- const dy = Math.abs(touch.clientY - touch_start_pos.current.y)
+ const point = 'touches' in e ? e.touches[0] : e
+ const dx = Math.abs(point.clientX - swipe_start.current.x)
+ const dy = Math.abs(point.clientY - swipe_start.current.y)
- // Start dragging if moved more than 10px
- if (dx > 10 || dy > 10) {
- is_touch_dragging.current = true
- set_drag_card_id(card_id)
+ // Start swiping if moved more than 5px
+ if (dx > 5 || dy > 5) {
+ set_is_swiping(true)
+ }
- const target = find_drop_target(touch.clientX, touch.clientY)
- set_drop_target(target)
+ if (is_swiping) {
+ const card_id = find_card_at_point(point.clientX, point.clientY)
+ if (card_id !== null && !swiped_cards.current.has(card_id)) {
+ swiped_cards.current.add(card_id)
+ on_toggle_selection(card_id)
+ }
}
}
- const handle_touch_end = (_card_id: number) => {
- if (is_touch_dragging.current && drag_card_id !== null) {
- commit_drop(drag_card_id, drop_target)
- }
- touch_start_pos.current = null
- // Don't reset is_touch_dragging here - let click handler check it
+ const handle_swipe_end = () => {
+ swipe_start.current = null
+ set_is_swiping(false)
+ // Don't clear swiped_cards here - let click handler check it
+ setTimeout(() => swiped_cards.current.clear(), 50)
}
// Double-tap on custom column to dissolve it
@@ -231,120 +217,198 @@ export function Hand({ cards, level, selected_ids, on_card_click, on_select_same
}
return (
- <div
- style={{
+ <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', width: '100%' }}>
+ {/* Cards area with swipe detection */}
+ <div
+ onMouseDown={handle_swipe_start}
+ onMouseMove={handle_swipe_move}
+ onMouseUp={handle_swipe_end}
+ onMouseLeave={handle_swipe_end}
+ onTouchStart={handle_swipe_start}
+ onTouchMove={handle_swipe_move}
+ onTouchEnd={handle_swipe_end}
+ style={{
+ display: 'flex',
+ justifyContent: 'center',
+ padding: is_mobile ? '4px 8px' : '8px 16px',
+ overflowX: 'auto',
+ overflowY: 'visible',
+ WebkitOverflowScrolling: 'touch',
+ width: '100%',
+ cursor: is_swiping ? 'crosshair' : 'default',
+ userSelect: 'none',
+ }}
+ >
+ <div style={{ display: 'flex', gap: h_gap, alignItems: 'flex-end' }}>
+ {columns.map((col, col_idx) => {
+ const col_cards = col.card_ids.map(id => card_by_id.get(id)!).filter(Boolean)
+ const col_height = card_height + (col_cards.length - 1) * v_overlap
+
+ return (
+ <div
+ key={col.id}
+ onDoubleClick={() => handle_column_double_click(col.id)}
+ style={{
+ position: 'relative',
+ width: card_width,
+ height: col_height,
+ borderRadius: 4,
+ flexShrink: 0,
+ }}
+ >
+ <AnimatePresence>
+ {col_cards.map((card, card_idx) => {
+ // First card (idx 0) = bottom position, FRONT (highest z-index)
+ // Last card = top position, BACK (lowest z-index, only top peeks out)
+ const from_bottom = card_idx
+
+ return (
+ <motion.div
+ key={card.Id}
+ ref={(el) => { if (el) card_refs.current.set(card.Id, el) }}
+ initial={{ opacity: 0, y: 20, scale: 0.8 }}
+ animate={{ opacity: 1, y: 0, scale: 1 }}
+ exit={{ opacity: 0, y: -20, scale: 0.8 }}
+ transition={{ delay: (col_idx * 0.3 + card_idx) * 0.01 }}
+ style={{
+ position: 'absolute',
+ bottom: from_bottom * v_overlap,
+ left: 0,
+ zIndex: col_cards.length - card_idx,
+ cursor: 'pointer',
+ touchAction: 'none',
+ }}
+ >
+ <Card
+ card={card}
+ level={level}
+ selected={selected_ids.has(card.Id)}
+ on_click={() => handle_card_click(card)}
+ size="small"
+ />
+ </motion.div>
+ )
+ })}
+ </AnimatePresence>
+ </div>
+ )
+ })}
+ </div>
+ </div>
+
+ {/* Suit filter buttons + action buttons */}
+ <div style={{
display: 'flex',
+ gap: is_mobile ? 6 : 10,
+ marginTop: is_mobile ? 4 : 8,
justifyContent: 'center',
- padding: is_mobile ? '4px 8px' : '8px 16px',
- paddingTop: selection_lift + (is_mobile ? 4 : 8),
- overflowX: 'auto',
- overflowY: 'visible',
- WebkitOverflowScrolling: 'touch',
- width: '100%',
- }}
- >
- <div style={{ display: 'flex', gap: h_gap, alignItems: 'flex-start' }}>
- {/* New pile drop zone - always visible */}
- <div
- ref={new_pile_ref}
- onDragOver={(e) => { e.preventDefault(); set_drop_target('new') }}
- onDragLeave={handle_drag_leave}
- onDrop={handle_drag_end}
+ alignItems: 'center',
+ flexWrap: 'wrap',
+ }}>
+ {/* Suit buttons */}
+ <div style={{ display: 'flex', gap: is_mobile ? 4 : 6 }}>
+ {[
+ { suit: Suit_Spades, symbol: '♠', color: '#000' },
+ { suit: Suit_Hearts, symbol: '♥', color: '#dc3545' },
+ { suit: Suit_Clubs, symbol: '♣', color: '#000' },
+ { suit: Suit_Diamonds, symbol: '♦', color: '#dc3545' },
+ ].map(({ suit, symbol, color }) => (
+ <button
+ key={suit}
+ onClick={() => handle_select_suit(suit)}
+ style={{
+ width: is_mobile ? 28 : 34,
+ height: is_mobile ? 28 : 34,
+ fontSize: is_mobile ? 16 : 20,
+ backgroundColor: '#fff',
+ color: color,
+ border: '1px solid #ccc',
+ borderRadius: 6,
+ cursor: 'pointer',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ }}
+ >
+ {symbol}
+ </button>
+ ))}
+ </div>
+
+ {/* Divider */}
+ <div style={{ width: 1, height: is_mobile ? 20 : 24, backgroundColor: '#555' }} />
+
+ {/* Action buttons */}
+ <button
+ onClick={handle_create_pile}
+ disabled={selected_ids.size === 0}
style={{
- width: card_width,
- height: card_height,
- border: `2px dashed ${drop_target === 'new' ? '#4caf50' : drag_card_id !== null ? '#888' : '#444'}`,
- borderRadius: 8,
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- color: drop_target === 'new' ? '#4caf50' : drag_card_id !== null ? '#888' : '#555',
- fontSize: is_mobile ? 18 : 24,
- backgroundColor: drop_target === 'new' ? 'rgba(76,175,80,0.15)' : 'transparent',
- opacity: drag_card_id !== null ? 1 : 0.4,
- transition: 'all 0.15s ease',
- flexShrink: 0,
+ padding: is_mobile ? '6px 10px' : '8px 14px',
+ fontSize: is_mobile ? 11 : 13,
+ backgroundColor: selected_ids.size > 0 ? '#9c27b0' : '#444',
+ color: '#fff',
+ border: 'none',
+ borderRadius: 6,
+ cursor: selected_ids.size > 0 ? 'pointer' : 'default',
+ opacity: selected_ids.size > 0 ? 1 : 0.5,
}}
>
- +
- </div>
+ New Pile
+ </button>
+ <button
+ onClick={handle_reset}
+ disabled={custom_columns.size === 0}
+ style={{
+ padding: is_mobile ? '6px 10px' : '8px 14px',
+ fontSize: is_mobile ? 11 : 13,
+ backgroundColor: custom_columns.size > 0 ? '#607d8b' : '#444',
+ color: '#fff',
+ border: 'none',
+ borderRadius: 6,
+ cursor: custom_columns.size > 0 ? 'pointer' : 'default',
+ opacity: custom_columns.size > 0 ? 1 : 0.5,
+ }}
+ >
+ Reset
+ </button>
- {columns.map((col, col_idx) => {
- const col_cards = col.card_ids.map(id => card_by_id.get(id)!).filter(Boolean)
- const col_height = card_height + (col_cards.length - 1) * v_overlap
-
- return (
- <div
- key={col.id}
- ref={(el) => { if (el) column_refs.current.set(col.id, el) }}
- onDragOver={(e) => { e.preventDefault(); if (col.is_custom) handle_drag_over_column(col.id) }}
- onDragLeave={handle_drag_leave}
- onDrop={handle_drag_end}
- onDoubleClick={() => handle_column_double_click(col.id)}
- style={{
- position: 'relative',
- width: card_width,
- height: col_height,
- borderLeft: col.is_custom ? '2px solid #9c27b0' : 'none',
- paddingLeft: col.is_custom ? 2 : 0,
- backgroundColor: drop_target === col.id ? 'rgba(156,39,176,0.15)' : 'transparent',
- borderRadius: 4,
- flexShrink: 0,
- }}
- >
- {col.is_custom && (
- <div
- style={{
- position: 'absolute',
- top: -14,
- left: 0,
- fontSize: 8,
- color: '#9c27b0',
- }}
- >
- ✦
- </div>
- )}
- <AnimatePresence>
- {col_cards.map((card, card_idx) => (
- <motion.div
- key={card.Id}
- draggable
- onDragStart={() => handle_drag_start(card.Id)}
- onDragEnd={handle_drag_end}
- onTouchStart={(e) => handle_touch_start(card.Id, e)}
- onTouchMove={(e) => handle_touch_move(card.Id, e)}
- onTouchEnd={() => handle_touch_end(card.Id)}
- initial={{ opacity: 0, x: 20, scale: 0.8 }}
- animate={{
- opacity: drag_card_id === card.Id ? 0.5 : 1,
- x: 0,
- scale: 1
- }}
- exit={{ opacity: 0, x: -20, scale: 0.8 }}
- transition={{ delay: (col_idx * 0.3 + card_idx) * 0.01 }}
- style={{
- position: 'absolute',
- top: card_idx * v_overlap,
- left: 0,
- zIndex: card_idx,
- cursor: 'grab',
- touchAction: 'none',
- }}
- >
- <Card
- card={card}
- level={level}
- selected={selected_ids.has(card.Id)}
- on_click={() => handle_card_click(card)}
- size="small"
- />
- </motion.div>
- ))}
- </AnimatePresence>
- </div>
- )
- })}
+ {/* Divider */}
+ <div style={{ width: 1, height: is_mobile ? 20 : 24, backgroundColor: '#555' }} />
+
+ {/* Play/Pass buttons */}
+ <button
+ onClick={on_pass}
+ disabled={!is_my_turn || !can_pass || cards.length === 0}
+ style={{
+ padding: is_mobile ? '6px 12px' : '8px 16px',
+ fontSize: is_mobile ? 12 : 14,
+ backgroundColor: is_my_turn && can_pass && cards.length > 0 ? '#dc3545' : '#444',
+ color: '#fff',
+ border: 'none',
+ borderRadius: 6,
+ cursor: is_my_turn && can_pass && cards.length > 0 ? 'pointer' : 'default',
+ opacity: is_my_turn && can_pass && cards.length > 0 ? 1 : 0.5,
+ }}
+ >
+ Pass
+ </button>
+ <button
+ onClick={on_play}
+ disabled={!is_my_turn || selected_ids.size === 0 || cards.length === 0}
+ style={{
+ padding: is_mobile ? '6px 16px' : '8px 24px',
+ fontSize: is_mobile ? 13 : 15,
+ fontWeight: 'bold',
+ backgroundColor: is_my_turn && selected_ids.size > 0 && cards.length > 0 ? '#28a745' : '#444',
+ color: '#fff',
+ border: 'none',
+ borderRadius: 6,
+ cursor: is_my_turn && selected_ids.size > 0 && cards.length > 0 ? 'pointer' : 'default',
+ opacity: is_my_turn && selected_ids.size > 0 && cards.length > 0 ? 1 : 0.5,
+ }}
+ >
+ Play
+ </button>
</div>
</div>
)
diff --git a/client/src/components/Lobby.tsx b/client/src/components/Lobby.tsx
index 5eeb87f..0ac05a8 100644
--- a/client/src/components/Lobby.tsx
+++ b/client/src/components/Lobby.tsx
@@ -3,269 +3,269 @@ import { motion } from 'framer-motion'
import { Player_Info } from '../game/types'
interface Lobby_Props {
- room_id: string | null
- players: Player_Info[]
- on_create_room: (name: string) => void
- on_join_room: (room_id: string, name: string) => void
- on_fill_bots: () => void
+ room_id: string | null
+ players: Player_Info[]
+ on_create_room: (name: string) => void
+ on_join_room: (room_id: string, name: string) => void
+ on_fill_bots: () => void
}
export function Lobby({ room_id, players, on_create_room, on_join_room, on_fill_bots }: Lobby_Props) {
- const [name, set_name] = useState('')
- const [join_code, set_join_code] = useState('')
- const [mode, set_mode] = useState<'select' | 'create' | 'join'>('select')
+ const [name, set_name] = useState('')
+ const [join_code, set_join_code] = useState('')
+ const [mode, set_mode] = useState<'select' | 'create' | 'join'>('select')
- const handle_create = () => {
- if (name.trim()) {
- on_create_room(name.trim())
+ const handle_create = () => {
+ if (name.trim()) {
+ on_create_room(name.trim())
+ }
}
- }
- const handle_join = () => {
- if (name.trim() && join_code.trim()) {
- on_join_room(join_code.trim(), name.trim())
+ const handle_join = () => {
+ if (name.trim() && join_code.trim()) {
+ on_join_room(join_code.trim(), name.trim())
+ }
}
- }
- if (room_id) {
- return (
- <div style={styles.container}>
- <motion.div
- initial={{ opacity: 0, y: 20 }}
- animate={{ opacity: 1, y: 0 }}
- style={styles.card}
- >
- <h2 style={styles.title}>Room: {room_id}</h2>
- <p style={styles.subtitle}>Waiting for players... ({players.length}/4)</p>
-
- <div style={styles.players_grid}>
- {[0, 1, 2, 3].map((seat) => {
- const player = players.find((p) => p.seat === seat)
- const team = seat % 2
- return (
+ if (room_id) {
+ return (
+ <div style={styles.container}>
<motion.div
- key={seat}
- initial={{ opacity: 0, scale: 0.8 }}
- animate={{ opacity: 1, scale: 1 }}
- transition={{ delay: seat * 0.1 }}
- style={{
- ...styles.player_slot,
- backgroundColor: team === 0 ? '#e3f2fd' : '#fce4ec',
- borderColor: team === 0 ? '#2196f3' : '#e91e63',
- }}
+ initial={{ opacity: 0, y: 20 }}
+ animate={{ opacity: 1, y: 0 }}
+ style={styles.card}
>
- {player ? (
- <>
- <div style={styles.player_name}>{player.name}</div>
- <div style={styles.player_team}>Team {team + 1}</div>
- </>
- ) : (
- <div style={styles.empty_slot}>Empty</div>
- )}
- </motion.div>
- )
- })}
- </div>
+ <h2 style={styles.title}>Room: {room_id}</h2>
+ <p style={styles.subtitle}>Waiting for players... ({players.length}/4)</p>
- <motion.button
- whileHover={{ scale: 1.05 }}
- whileTap={{ scale: 0.95 }}
- onClick={on_fill_bots}
- style={{ ...styles.button, backgroundColor: '#ff9800', marginBottom: 16 }}
- >
- Fill with Bots
- </motion.button>
+ <div style={styles.players_grid}>
+ {[0, 1, 2, 3].map((seat) => {
+ const player = players.find((p) => p.seat === seat)
+ const team = seat % 2
+ return (
+ <motion.div
+ key={seat}
+ initial={{ opacity: 0, scale: 0.8 }}
+ animate={{ opacity: 1, scale: 1 }}
+ transition={{ delay: seat * 0.1 }}
+ style={{
+ ...styles.player_slot,
+ backgroundColor: team === 0 ? '#e3f2fd' : '#fce4ec',
+ borderColor: team === 0 ? '#2196f3' : '#e91e63',
+ }}
+ >
+ {player ? (
+ <>
+ <div style={styles.player_name}>{player.name}</div>
+ <div style={styles.player_team}>Team {team + 1}</div>
+ </>
+ ) : (
+ <div style={styles.empty_slot}>Empty</div>
+ )}
+ </motion.div>
+ )
+ })}
+ </div>
- <p style={styles.hint}>Share room code with friends to join</p>
- </motion.div>
- </div>
- )
- }
+ <motion.button
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ onClick={on_fill_bots}
+ style={{ ...styles.button, backgroundColor: '#ff9800', marginBottom: 16 }}
+ >
+ Fill with Bots
+ </motion.button>
- return (
- <div style={styles.container}>
- <motion.div
- initial={{ opacity: 0, y: 20 }}
- animate={{ opacity: 1, y: 0 }}
- style={styles.card}
- >
- <h1 style={styles.logo}>掼蛋</h1>
- <h2 style={styles.title}>Guan Dan</h2>
+ <p style={styles.hint}>Share room code with friends to join</p>
+ </motion.div>
+ </div>
+ )
+ }
- {mode === 'select' && (
- <div style={styles.buttons}>
- <motion.button
- whileHover={{ scale: 1.05 }}
- whileTap={{ scale: 0.95 }}
- onClick={() => set_mode('create')}
- style={styles.button}
- >
- Create Room
- </motion.button>
- <motion.button
- whileHover={{ scale: 1.05 }}
- whileTap={{ scale: 0.95 }}
- onClick={() => set_mode('join')}
- style={{ ...styles.button, backgroundColor: '#28a745' }}
+ return (
+ <div style={styles.container}>
+ <motion.div
+ initial={{ opacity: 0, y: 20 }}
+ animate={{ opacity: 1, y: 0 }}
+ style={styles.card}
>
- Join Room
- </motion.button>
- </div>
- )}
+ <h1 style={styles.logo}>掼蛋</h1>
+ <h2 style={styles.title}>Guan Dan</h2>
- {mode === 'create' && (
- <div style={styles.form}>
- <input
- type="text"
- placeholder="Your name"
- value={name}
- onChange={(e) => set_name(e.target.value)}
- style={styles.input}
- />
- <div style={styles.buttons}>
- <motion.button
- whileHover={{ scale: 1.05 }}
- whileTap={{ scale: 0.95 }}
- onClick={handle_create}
- style={styles.button}
- >
- Create
- </motion.button>
- <motion.button
- whileHover={{ scale: 1.05 }}
- whileTap={{ scale: 0.95 }}
- onClick={() => set_mode('select')}
- style={{ ...styles.button, backgroundColor: '#6c757d' }}
- >
- Back
- </motion.button>
- </div>
- </div>
- )}
+ {mode === 'select' && (
+ <div style={styles.buttons}>
+ <motion.button
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ onClick={() => set_mode('create')}
+ style={styles.button}
+ >
+ Create Room
+ </motion.button>
+ <motion.button
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ onClick={() => set_mode('join')}
+ style={{ ...styles.button, backgroundColor: '#28a745' }}
+ >
+ Join Room
+ </motion.button>
+ </div>
+ )}
- {mode === 'join' && (
- <div style={styles.form}>
- <input
- type="text"
- placeholder="Your name"
- value={name}
- onChange={(e) => set_name(e.target.value)}
- style={styles.input}
- />
- <input
- type="text"
- placeholder="Room code"
- value={join_code}
- onChange={(e) => set_join_code(e.target.value)}
- style={styles.input}
- />
- <div style={styles.buttons}>
- <motion.button
- whileHover={{ scale: 1.05 }}
- whileTap={{ scale: 0.95 }}
- onClick={handle_join}
- style={{ ...styles.button, backgroundColor: '#28a745' }}
- >
- Join
- </motion.button>
- <motion.button
- whileHover={{ scale: 1.05 }}
- whileTap={{ scale: 0.95 }}
- onClick={() => set_mode('select')}
- style={{ ...styles.button, backgroundColor: '#6c757d' }}
- >
- Back
- </motion.button>
- </div>
- </div>
- )}
- </motion.div>
- </div>
- )
+ {mode === 'create' && (
+ <div style={styles.form}>
+ <input
+ type="text"
+ placeholder="Your name"
+ value={name}
+ onChange={(e) => set_name(e.target.value)}
+ style={styles.input}
+ />
+ <div style={styles.buttons}>
+ <motion.button
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ onClick={handle_create}
+ style={styles.button}
+ >
+ Create
+ </motion.button>
+ <motion.button
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ onClick={() => set_mode('select')}
+ style={{ ...styles.button, backgroundColor: '#6c757d' }}
+ >
+ Back
+ </motion.button>
+ </div>
+ </div>
+ )}
+
+ {mode === 'join' && (
+ <div style={styles.form}>
+ <input
+ type="text"
+ placeholder="Your name"
+ value={name}
+ onChange={(e) => set_name(e.target.value)}
+ style={styles.input}
+ />
+ <input
+ type="text"
+ placeholder="Room code"
+ value={join_code}
+ onChange={(e) => set_join_code(e.target.value)}
+ style={styles.input}
+ />
+ <div style={styles.buttons}>
+ <motion.button
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ onClick={handle_join}
+ style={{ ...styles.button, backgroundColor: '#28a745' }}
+ >
+ Join
+ </motion.button>
+ <motion.button
+ whileHover={{ scale: 1.05 }}
+ whileTap={{ scale: 0.95 }}
+ onClick={() => set_mode('select')}
+ style={{ ...styles.button, backgroundColor: '#6c757d' }}
+ >
+ Back
+ </motion.button>
+ </div>
+ </div>
+ )}
+ </motion.div>
+ </div>
+ )
}
const styles: Record<string, React.CSSProperties> = {
- container: {
- display: 'flex',
- justifyContent: 'center',
- alignItems: 'center',
- minHeight: '100vh',
- backgroundColor: '#1a1a2e',
- },
- card: {
- backgroundColor: '#16213e',
- padding: 40,
- borderRadius: 16,
- textAlign: 'center',
- boxShadow: '0 8px 32px rgba(0,0,0,0.3)',
- minWidth: 360,
- },
- logo: {
- fontSize: 64,
- margin: 0,
- color: '#fff',
- },
- title: {
- color: '#fff',
- marginTop: 8,
- marginBottom: 24,
- },
- subtitle: {
- color: '#aaa',
- marginBottom: 24,
- },
- buttons: {
- display: 'flex',
- gap: 12,
- justifyContent: 'center',
- },
- button: {
- padding: '12px 24px',
- fontSize: 16,
- border: 'none',
- borderRadius: 8,
- backgroundColor: '#007bff',
- color: '#fff',
- cursor: 'pointer',
- },
- form: {
- display: 'flex',
- flexDirection: 'column',
- gap: 12,
- },
- input: {
- padding: '12px 16px',
- fontSize: 16,
- border: '2px solid #333',
- borderRadius: 8,
- backgroundColor: '#0f3460',
- color: '#fff',
- outline: 'none',
- },
- players_grid: {
- display: 'grid',
- gridTemplateColumns: '1fr 1fr',
- gap: 12,
- marginBottom: 24,
- },
- player_slot: {
- padding: 16,
- borderRadius: 8,
- border: '2px solid',
- },
- player_name: {
- fontWeight: 'bold',
- color: '#333',
- },
- player_team: {
- fontSize: 12,
- color: '#666',
- },
- empty_slot: {
- color: '#999',
- },
- hint: {
- color: '#666',
- fontSize: 12,
- },
+ container: {
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ minHeight: '100vh',
+ backgroundColor: '#1a1a2e',
+ },
+ card: {
+ backgroundColor: '#16213e',
+ padding: 40,
+ borderRadius: 16,
+ textAlign: 'center',
+ boxShadow: '0 8px 32px rgba(0,0,0,0.3)',
+ minWidth: 360,
+ },
+ logo: {
+ fontSize: 64,
+ margin: 0,
+ color: '#fff',
+ },
+ title: {
+ color: '#fff',
+ marginTop: 8,
+ marginBottom: 24,
+ },
+ subtitle: {
+ color: '#aaa',
+ marginBottom: 24,
+ },
+ buttons: {
+ display: 'flex',
+ gap: 12,
+ justifyContent: 'center',
+ },
+ button: {
+ padding: '12px 24px',
+ fontSize: 16,
+ border: 'none',
+ borderRadius: 8,
+ backgroundColor: '#007bff',
+ color: '#fff',
+ cursor: 'pointer',
+ },
+ form: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 12,
+ },
+ input: {
+ padding: '12px 16px',
+ fontSize: 16,
+ border: '2px solid #333',
+ borderRadius: 8,
+ backgroundColor: '#0f3460',
+ color: '#fff',
+ outline: 'none',
+ },
+ players_grid: {
+ display: 'grid',
+ gridTemplateColumns: '1fr 1fr',
+ gap: 12,
+ marginBottom: 24,
+ },
+ player_slot: {
+ padding: 16,
+ borderRadius: 8,
+ border: '2px solid',
+ },
+ player_name: {
+ fontWeight: 'bold',
+ color: '#333',
+ },
+ player_team: {
+ fontSize: 12,
+ color: '#666',
+ },
+ empty_slot: {
+ color: '#999',
+ },
+ hint: {
+ color: '#666',
+ fontSize: 12,
+ },
}