import { useRef, useCallback, useState } from 'react' import { motion, AnimatePresence } from 'framer-motion' 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' interface Hand_Props { cards: Card_Type[] level: Rank selected_ids: Set 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 { id: string card_ids: number[] is_custom: boolean } 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>(new Map()) // 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>(new Set()) const card_refs = useRef>(new Map()) const handle_card_click = useCallback((card: Card_Type) => { // Don't trigger click if we were swiping if (swiped_cards.current.size > 0) { swiped_cards.current.clear() return } const now = Date.now() const last = last_click.current if (last && last.id === card.Id && now - last.time < 300) { on_select_same_rank(card.Rank) last_click.current = null } else { on_card_click(card.Id) last_click.current = { id: card.Id, time: now } } }, [on_card_click, on_select_same_rank]) // Get all card IDs in custom columns const cards_in_custom = new Set() custom_columns.forEach(ids => ids.forEach(id => cards_in_custom.add(id))) // Filter to only cards that still exist in hand const valid_card_ids = new Set(cards.map(c => c.Id)) // Build columns: auto-sorted cards first, then custom columns on right const columns: Column[] = [] const custom_cols: Column[] = [] // 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) { custom_cols.push({ id: col_id, card_ids: valid_ids, is_custom: true }) } }) // Auto-group remaining cards by rank const remaining_cards = cards.filter(c => !cards_in_custom.has(c.Id)) const by_rank = new Map() remaining_cards.forEach(card => { const arr = by_rank.get(card.Rank) || [] arr.push(card) by_rank.set(card.Rank, arr) }) // Sort ranks high to low const rank_order = (rank: number): number => { if (rank === 14) return 1000 if (rank === 13) return 999 if (rank === level) return 998 if (rank === 0) return 15 if (rank === 12) return 14 return rank + 2 } const sorted_ranks = Array.from(by_rank.keys()).sort((a, b) => rank_order(b) - rank_order(a)) sorted_ranks.forEach(rank => { const rank_cards = by_rank.get(rank)! columns.push({ id: `rank-${rank}`, card_ids: rank_cards.map(c => c.Id), is_custom: false }) }) // 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 ? 48 : 60 const card_height = is_mobile ? 67 : 84 const v_overlap = is_mobile ? 18 : 28 const h_gap = is_mobile ? 3 : 4 // 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 selected cards from any existing custom columns next.forEach((ids, col_id) => { const filtered = ids.filter(id => !selected_ids.has(id)) if (filtered.length === 0) { next.delete(col_id) } else { next.set(col_id, filtered) } }) // Create new column with selected cards const new_col_id = `custom-${Date.now()}` next.set(new_col_id, selected_array) return next }) } // Reset all custom arrangements const handle_reset = () => { set_custom_columns(new Map()) } // 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 } }) } // 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 } 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_swipe_move = (e: React.MouseEvent | React.TouchEvent) => { if (!swipe_start.current) return 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 swiping if moved more than 5px if (dx > 5 || dy > 5) { set_is_swiping(true) } 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_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 const handle_column_double_click = (col_id: string) => { if (col_id.startsWith('custom-')) { set_custom_columns(prev => { const next = new Map(prev) next.delete(col_id) return next }) } } return (
{/* Cards area with swipe detection */}
{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 (
handle_column_double_click(col.id)} style={{ position: 'relative', width: card_width, height: col_height, borderRadius: 4, flexShrink: 0, }} > {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 ( { 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', }} > handle_card_click(card)} size={is_mobile ? "small" : "normal"} /> ) })}
) })}
{/* Suit filter buttons + action buttons */}
{/* Turn indicator - mobile only, in the row */} {is_mobile && is_my_turn && cards.length > 0 && (
Your turn
)} {/* Suit buttons */}
{[ { 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 }) => ( ))}
{/* Divider */}
{/* Action buttons */} {/* Divider */}
{/* Play/Pass buttons */}
) }