guandan.dev
guandan.dev
https://git.tonybtw.com/guandan.dev.git
git://git.tonybtw.com/guandan.dev.git
Ditched playerlog, added mobile friendly css, changed dev workflow to run air on 8081.
Diff
diff --git a/client/.env.development b/client/.env.development
index 495b484..fb92615 100644
--- a/client/.env.development
+++ b/client/.env.development
@@ -1 +1 @@
-VITE_WS_URL=ws://localhost:8080/ws
+VITE_WS_URL=ws://localhost:8081/ws
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 9d5a689..c33297f 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -59,7 +59,6 @@ export default function App() {
const [player_card_counts, set_player_card_counts] = useState([27, 27, 27, 27])
const [team_levels, set_team_levels] = useState<[number, number]>([0, 0])
const [error, set_error] = useState<string | null>(null)
- const [play_log, set_play_log] = useState<Array<{ seat: number; cards: Card[]; combo_type: string; is_pass: boolean }>>([])
const [players_map, set_players_map] = useState<Record<number, string>>({})
const [last_play_seat, set_last_play_seat] = useState<number | null>(null)
@@ -90,7 +89,6 @@ export default function App() {
set_combo_type('')
set_selected_ids(new Set())
set_player_card_counts([27, 27, 27, 27])
- set_play_log([])
})
const unsub_turn = on('turn', (msg: Message) => {
@@ -102,16 +100,6 @@ export default function App() {
const unsub_play_made = on('play_made', (msg: Message) => {
const payload = msg.payload as Play_Made_Payload
- set_play_log((prev) => {
- const next = [...prev, {
- seat: payload.seat,
- cards: payload.cards || [],
- combo_type: payload.combo_type || '',
- is_pass: payload.is_pass,
- }]
- return next.slice(-8)
- })
-
set_last_play_seat(payload.seat)
setTimeout(() => set_last_play_seat(null), 800)
@@ -239,7 +227,6 @@ export default function App() {
can_pass={can_pass}
player_card_counts={player_card_counts}
team_levels={team_levels}
- play_log={play_log}
players_map={players_map}
last_play_seat={last_play_seat}
/>
diff --git a/client/src/components/Card.tsx b/client/src/components/Card.tsx
index d416822..7c334c9 100644
--- a/client/src/components/Card.tsx
+++ b/client/src/components/Card.tsx
@@ -1,17 +1,26 @@
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'
+type Card_Size = 'small' | 'normal'
+
interface Card_Props {
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 },
}
-export function Card({ card, level, selected, on_click }: Card_Props) {
+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]
return (
<motion.div
@@ -23,8 +32,8 @@ export function Card({ card, level, selected, on_click }: Card_Props) {
whileHover={{ scale: 1.08 }}
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
style={{
- width: 70,
- height: 100,
+ width: cfg.width,
+ height: cfg.height,
backgroundColor: is_wild_card ? '#fff3cd' : '#fff',
border: is_wild_card ? '3px solid #ffc107' : '2px solid #333',
borderRadius: 8,
@@ -45,8 +54,8 @@ export function Card({ card, level, selected, on_click }: Card_Props) {
alignItems: 'center',
lineHeight: 1,
}}>
- <span style={{ fontSize: 16, fontWeight: 'bold' }}>{is_joker ? (card.Rank === Rank_Red_Joker ? 'R' : 'B') : get_rank_symbol(card.Rank)}</span>
- <span style={{ fontSize: 18 }}>{is_joker ? '🃏' : get_suit_symbol(card.Suit)}</span>
+ <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',
@@ -58,15 +67,15 @@ export function Card({ card, level, selected, on_click }: Card_Props) {
lineHeight: 1,
transform: 'rotate(180deg)',
}}>
- <span style={{ fontSize: 14 }}>{is_joker ? (card.Rank === Rank_Red_Joker ? 'R' : 'B') : get_rank_symbol(card.Rank)}</span>
- <span style={{ fontSize: 12 }}>{is_joker ? '🃏' : get_suit_symbol(card.Suit)}</span>
+ <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 ? 28 : 24,
+ fontSize: is_joker ? cfg.center_font + 4 : cfg.center_font,
}}>
{is_joker ? '🃏' : get_suit_symbol(card.Suit)}
</div>
@@ -74,12 +83,18 @@ export function Card({ card, level, selected, on_click }: Card_Props) {
)
}
-export function Card_Back() {
+interface Card_Back_Props {
+ size?: Card_Size
+}
+
+export function Card_Back({ size = 'normal' }: Card_Back_Props) {
+ const cfg = SIZE_CONFIG[size]
+
return (
<div
style={{
- width: 70,
- height: 100,
+ width: cfg.width,
+ height: cfg.height,
backgroundColor: '#1e3a5f',
border: '2px solid #0d1b2a',
borderRadius: 8,
@@ -89,7 +104,7 @@ export function Card_Back() {
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: 24 }}>🀄</div>
+ <div style={{ color: '#fff', fontSize: size === 'small' ? 20 : 24 }}>🀄</div>
</div>
)
}
diff --git a/client/src/components/Game.tsx b/client/src/components/Game.tsx
index 26d7dd1..c431c73 100644
--- a/client/src/components/Game.tsx
+++ b/client/src/components/Game.tsx
@@ -1,15 +1,9 @@
import { motion } from 'framer-motion'
-import { Card as Card_Type, Rank, get_rank_symbol, get_suit_symbol, is_red_suit, Suit_Joker, Rank_Red_Joker } from '../game/types'
+import { Card as Card_Type, Rank, get_rank_symbol } from '../game/types'
import { Hand } from './Hand'
import { Table } from './Table'
import { Card_Back } from './Card'
-
-interface Play_Log_Entry {
- seat: number
- cards: Card_Type[]
- combo_type: string
- is_pass: boolean
-}
+import { use_is_mobile } from '../hooks/use_is_mobile'
interface Game_Props {
hand: Card_Type[]
@@ -25,7 +19,6 @@ interface Game_Props {
can_pass: boolean
player_card_counts: number[]
team_levels: [number, number]
- play_log: Play_Log_Entry[]
players_map: Record<number, string>
last_play_seat: number | null
}
@@ -44,12 +37,93 @@ export function Game({
can_pass,
player_card_counts,
team_levels,
- play_log,
players_map,
last_play_seat,
}: Game_Props) {
const is_my_turn = current_turn === my_seat
const relative_positions = get_relative_positions(my_seat)
+ const is_mobile = use_is_mobile()
+
+ if (is_mobile) {
+ return (
+ <div style={mobile_styles.container}>
+ <div style={mobile_styles.info_bar}>
+ <div style={mobile_styles.level_badge}>
+ Lvl: {get_rank_symbol(level)}
+ </div>
+ <div style={mobile_styles.team_scores}>
+ <span style={{ color: '#2196f3' }}>T1: {get_rank_symbol(team_levels[0] as Rank)}</span>
+ <span style={{ marginLeft: 8, color: '#e91e63' }}>T2: {get_rank_symbol(team_levels[1] as Rank)}</span>
+ </div>
+ </div>
+
+ <Mobile_Opponent_Bar
+ positions={relative_positions}
+ player_card_counts={player_card_counts}
+ current_turn={current_turn}
+ last_play_seat={last_play_seat}
+ players_map={players_map}
+ />
+
+ <div style={mobile_styles.table_area}>
+ <Table cards={table_cards} level={level} combo_type={combo_type} last_play_seat={last_play_seat} />
+ </div>
+
+ <div style={mobile_styles.my_area}>
+ <Hand
+ cards={hand}
+ level={level}
+ selected_ids={selected_ids}
+ on_card_click={on_card_click}
+ />
+
+ <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 }}
+ animate={{ opacity: 1 }}
+ style={mobile_styles.turn_indicator}
+ >
+ Your turn!
+ </motion.div>
+ )}
+ {hand.length === 0 && (
+ <motion.div
+ initial={{ opacity: 0 }}
+ animate={{ opacity: 1 }}
+ style={{ ...mobile_styles.turn_indicator, backgroundColor: '#28a745' }}
+ >
+ You finished!
+ </motion.div>
+ )}
+ </div>
+ </div>
+ )
+ }
return (
<div style={styles.container}>
@@ -158,39 +232,50 @@ export function Game({
)}
</div>
</div>
-
- <div style={styles.play_log}>
- <div style={styles.play_log_title}>Play Log</div>
- {play_log.map((entry, i) => (
- <motion.div
- key={i}
- initial={{ opacity: 0, x: 20 }}
- animate={{ opacity: 1, x: 0 }}
- style={styles.play_log_entry}
- >
- <span style={{ color: entry.seat % 2 === 0 ? '#2196f3' : '#e91e63', fontWeight: 'bold' }}>
- {players_map[entry.seat] || `Seat ${entry.seat + 1}`}:
- </span>{' '}
- {entry.is_pass ? (
- <span style={{ color: '#888' }}>Pass</span>
- ) : (
- <span>
- {entry.cards.map((c, j) => (
- <span key={j} style={{ color: c.Suit === Suit_Joker ? (c.Rank === Rank_Red_Joker ? '#dc3545' : '#000') : is_red_suit(c.Suit) ? '#dc3545' : '#000' }}>
- {get_rank_symbol(c.Rank)}{get_suit_symbol(c.Suit)}{j < entry.cards.length - 1 ? ' ' : ''}
- </span>
- ))}
- {entry.combo_type && <span style={{ color: '#888', marginLeft: 4 }}>({entry.combo_type})</span>}
- </span>
- )}
- </motion.div>
- ))}
- </div>
</div>
</div>
)
}
+interface Mobile_Opponent_Bar_Props {
+ positions: { top: number; left: number; right: number }
+ player_card_counts: number[]
+ current_turn: number
+ last_play_seat: number | null
+ players_map: Record<number, string>
+}
+
+function Mobile_Opponent_Bar({ positions, player_card_counts, current_turn, last_play_seat, players_map }: Mobile_Opponent_Bar_Props) {
+ const opponents = [positions.left, positions.top, positions.right]
+
+ return (
+ <div style={mobile_styles.opponent_bar}>
+ {opponents.map((seat) => {
+ const is_turn = current_turn === seat
+ const just_played = last_play_seat === seat
+
+ return (
+ <div
+ key={seat}
+ style={{
+ ...mobile_styles.opponent_chip,
+ backgroundColor: just_played ? 'rgba(76, 175, 80, 0.3)' : is_turn ? 'rgba(255,193,7,0.3)' : 'rgba(255,255,255,0.1)',
+ borderColor: just_played ? '#4caf50' : is_turn ? '#ffc107' : 'transparent',
+ }}
+ >
+ <span style={{ color: seat % 2 === 0 ? '#2196f3' : '#e91e63', fontWeight: 'bold', fontSize: 12 }}>
+ {players_map[seat] || `P${seat + 1}`}
+ </span>
+ <span style={{ color: '#fff', fontSize: 11, marginLeft: 4 }}>
+ ({player_card_counts[seat]})
+ </span>
+ </div>
+ )
+ })}
+ </div>
+ )
+}
+
interface Opponent_Hand_Props {
count: number
is_turn: boolean
@@ -268,45 +353,52 @@ const styles: Record<string, React.CSSProperties> = {
flexDirection: 'column',
height: '100vh',
backgroundColor: '#0f3460',
+ overflow: 'hidden',
},
info_bar: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
- padding: '12px 24px',
+ padding: '8px 12px',
backgroundColor: '#16213e',
+ flexShrink: 0,
},
level_badge: {
- padding: '8px 16px',
+ padding: '6px 12px',
backgroundColor: '#ffc107',
color: '#000',
borderRadius: 8,
fontWeight: 'bold',
+ fontSize: 14,
},
team_scores: {
color: '#fff',
- fontSize: 14,
+ fontSize: 12,
},
game_area: {
flex: 1,
display: 'flex',
flexDirection: 'column',
- padding: 20,
+ padding: 8,
+ minHeight: 0,
},
opponent_top: {
display: 'flex',
justifyContent: 'center',
- marginBottom: 20,
+ marginBottom: 8,
+ flexShrink: 0,
},
middle_row: {
flex: 1,
display: 'flex',
alignItems: 'center',
+ minHeight: 0,
},
opponent_side: {
- width: 120,
+ width: 80,
display: 'flex',
justifyContent: 'center',
+ flexShrink: 0,
},
table_area: {
flex: 1,
@@ -314,61 +406,130 @@ const styles: Record<string, React.CSSProperties> = {
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.2)',
- borderRadius: 16,
- margin: '0 20px',
+ borderRadius: 12,
+ margin: '0 8px',
+ minHeight: 120,
},
my_area: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
- paddingTop: 20,
+ paddingTop: 8,
borderTop: '2px solid #333',
+ flexShrink: 0,
},
actions: {
display: 'flex',
- gap: 16,
- marginTop: 16,
+ gap: 12,
+ marginTop: 8,
},
action_button: {
- padding: '12px 32px',
- fontSize: 16,
+ padding: '10px 24px',
+ fontSize: 14,
border: 'none',
borderRadius: 8,
color: '#fff',
cursor: 'pointer',
},
turn_indicator: {
- marginTop: 12,
- padding: '8px 16px',
+ marginTop: 8,
+ padding: '6px 12px',
backgroundColor: '#ffc107',
color: '#000',
borderRadius: 8,
fontWeight: 'bold',
+ fontSize: 12,
},
main_layout: {
display: 'flex',
flex: 1,
overflow: 'hidden',
+ minHeight: 0,
},
- play_log: {
- width: 220,
+}
+
+const mobile_styles: Record<string, React.CSSProperties> = {
+ container: {
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100dvh',
+ backgroundColor: '#0f3460',
+ overflow: 'hidden',
+ },
+ info_bar: {
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ padding: '6px 10px',
backgroundColor: '#16213e',
- padding: 12,
- overflowY: 'auto',
- borderLeft: '2px solid #333',
+ flexShrink: 0,
},
- play_log_title: {
- color: '#fff',
- fontSize: 14,
+ level_badge: {
+ padding: '4px 8px',
+ backgroundColor: '#ffc107',
+ color: '#000',
+ borderRadius: 6,
fontWeight: 'bold',
- marginBottom: 12,
+ fontSize: 12,
+ },
+ team_scores: {
+ color: '#fff',
+ fontSize: 11,
+ },
+ opponent_bar: {
+ display: 'flex',
+ justifyContent: 'center',
+ gap: 8,
+ padding: '8px 4px',
+ backgroundColor: 'rgba(0,0,0,0.2)',
+ flexShrink: 0,
+ },
+ opponent_chip: {
+ display: 'flex',
+ alignItems: 'center',
+ padding: '6px 10px',
+ borderRadius: 16,
+ border: '2px solid transparent',
+ },
+ table_area: {
+ flex: 1,
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ backgroundColor: 'rgba(0,0,0,0.2)',
+ borderRadius: 8,
+ margin: 8,
+ minHeight: 100,
+ },
+ my_area: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ paddingTop: 4,
paddingBottom: 8,
- borderBottom: '1px solid #333',
+ borderTop: '2px solid #333',
+ flexShrink: 0,
},
- play_log_entry: {
- fontSize: 12,
+ actions: {
+ display: 'flex',
+ gap: 16,
+ marginTop: 4,
+ },
+ action_button: {
+ padding: '10px 28px',
+ fontSize: 14,
+ border: 'none',
+ borderRadius: 8,
color: '#fff',
- padding: '6px 0',
- borderBottom: '1px solid rgba(255,255,255,0.1)',
+ cursor: 'pointer',
+ },
+ turn_indicator: {
+ marginTop: 6,
+ padding: '4px 10px',
+ backgroundColor: '#ffc107',
+ color: '#000',
+ borderRadius: 6,
+ fontWeight: 'bold',
+ fontSize: 11,
},
}
diff --git a/client/src/components/Hand.tsx b/client/src/components/Hand.tsx
index 1a49a86..b0e1bd6 100644
--- a/client/src/components/Hand.tsx
+++ b/client/src/components/Hand.tsx
@@ -1,6 +1,7 @@
import { motion, AnimatePresence } from 'framer-motion'
import { Card as Card_Type, Rank } from '../game/types'
import { Card } from './Card'
+import { use_is_mobile } from '../hooks/use_is_mobile'
interface Hand_Props {
cards: Card_Type[]
@@ -10,8 +11,62 @@ interface Hand_Props {
}
export function Hand({ cards, level, selected_ids, on_card_click }: Hand_Props) {
- const card_width = 70
- const overlap = 35
+ const is_mobile = use_is_mobile()
+ const card_width = is_mobile ? 56 : 70
+ const card_height = is_mobile ? 80 : 100
+ const overlap = is_mobile ? 28 : 35
+
+ if (is_mobile) {
+ return (
+ <div
+ style={{
+ width: '100%',
+ overflowX: 'auto',
+ overflowY: 'hidden',
+ WebkitOverflowScrolling: 'touch',
+ padding: '8px 0',
+ minHeight: card_height + 30,
+ }}
+ >
+ <div
+ style={{
+ display: 'flex',
+ position: 'relative',
+ width: cards.length > 0 ? card_width + (cards.length - 1) * overlap + 16 : 0,
+ height: card_height + 20,
+ margin: '0 auto',
+ paddingLeft: 8,
+ paddingRight: 8,
+ }}
+ >
+ <AnimatePresence>
+ {cards.map((card, index) => (
+ <motion.div
+ key={card.Id}
+ initial={{ opacity: 0, y: 30, scale: 0.8 }}
+ animate={{ opacity: 1, y: 0, scale: 1 }}
+ exit={{ opacity: 0, y: -30, scale: 0.8 }}
+ transition={{ delay: index * 0.015 }}
+ style={{
+ position: 'absolute',
+ left: index * overlap,
+ zIndex: index,
+ }}
+ >
+ <Card
+ card={card}
+ level={level}
+ selected={selected_ids.has(card.Id)}
+ on_click={() => on_card_click(card.Id)}
+ size={is_mobile ? 'small' : 'normal'}
+ />
+ </motion.div>
+ ))}
+ </AnimatePresence>
+ </div>
+ </div>
+ )
+ }
return (
<div
@@ -27,7 +82,7 @@ export function Hand({ cards, level, selected_ids, on_card_click }: Hand_Props)
display: 'flex',
position: 'relative',
width: cards.length > 0 ? card_width + (cards.length - 1) * overlap : 0,
- height: 100,
+ height: card_height,
}}
>
<AnimatePresence>
diff --git a/client/src/components/Table.tsx b/client/src/components/Table.tsx
index fb73d02..f1165e5 100644
--- a/client/src/components/Table.tsx
+++ b/client/src/components/Table.tsx
@@ -1,5 +1,6 @@
import { Card as Card_Type, Rank } from '../game/types'
import { Card } from './Card'
+import { use_is_mobile } from '../hooks/use_is_mobile'
interface Table_Props {
cards: Card_Type[]
@@ -9,8 +10,9 @@ interface Table_Props {
}
export function Table({ cards, level, combo_type, last_play_seat }: Table_Props) {
- const card_width = 70
- const overlap = 40
+ const is_mobile = use_is_mobile()
+ const card_width = is_mobile ? 56 : 70
+ const overlap = is_mobile ? 32 : 40
const show_highlight = last_play_seat !== null && last_play_seat !== undefined && cards.length > 0
return (
@@ -20,8 +22,8 @@ export function Table({ cards, level, combo_type, last_play_seat }: Table_Props)
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
- minHeight: 180,
- padding: 20,
+ minHeight: is_mobile ? 100 : 180,
+ padding: is_mobile ? 8 : 20,
borderRadius: 12,
transition: 'background-color 0.2s ease, box-shadow 0.2s ease',
backgroundColor: show_highlight ? 'rgba(76, 175, 80, 0.15)' : 'transparent',
@@ -33,7 +35,7 @@ export function Table({ cards, level, combo_type, last_play_seat }: Table_Props)
display: 'flex',
position: 'relative',
width: cards.length > 0 ? card_width + (cards.length - 1) * overlap : 100,
- height: 100,
+ height: is_mobile ? 80 : 100,
justifyContent: 'center',
}}
>
@@ -52,6 +54,7 @@ export function Table({ cards, level, combo_type, last_play_seat }: Table_Props)
level={level}
selected={false}
on_click={() => {}}
+ size={is_mobile ? 'small' : 'normal'}
/>
</div>
))
@@ -62,7 +65,7 @@ export function Table({ cards, level, combo_type, last_play_seat }: Table_Props)
alignItems: 'center',
justifyContent: 'center',
color: '#666',
- fontSize: 14,
+ fontSize: is_mobile ? 12 : 14,
opacity: 0.5,
}}
>
@@ -73,12 +76,12 @@ export function Table({ cards, level, combo_type, last_play_seat }: Table_Props)
{combo_type && (
<div
style={{
- marginTop: 12,
- padding: '4px 12px',
+ marginTop: is_mobile ? 6 : 12,
+ padding: is_mobile ? '3px 8px' : '4px 12px',
backgroundColor: '#333',
color: '#fff',
borderRadius: 4,
- fontSize: 12,
+ fontSize: is_mobile ? 10 : 12,
textTransform: 'uppercase',
}}
>
diff --git a/client/src/hooks/use_is_mobile.ts b/client/src/hooks/use_is_mobile.ts
new file mode 100644
index 0000000..439b8e6
--- /dev/null
+++ b/client/src/hooks/use_is_mobile.ts
@@ -0,0 +1,20 @@
+import { useState, useEffect } from 'react'
+
+const MOBILE_BREAKPOINT = 768
+
+export function use_is_mobile(): boolean {
+ const [is_mobile, set_is_mobile] = useState(() =>
+ typeof window !== 'undefined' ? window.innerWidth < MOBILE_BREAKPOINT : false
+ )
+
+ useEffect(() => {
+ const handle_resize = () => {
+ set_is_mobile(window.innerWidth < MOBILE_BREAKPOINT)
+ }
+
+ window.addEventListener('resize', handle_resize)
+ return () => window.removeEventListener('resize', handle_resize)
+ }, [])
+
+ return is_mobile
+}
diff --git a/client/src/main.tsx b/client/src/main.tsx
index d18d7ec..3593812 100644
--- a/client/src/main.tsx
+++ b/client/src/main.tsx
@@ -9,13 +9,42 @@ style.textContent = `
padding: 0;
box-sizing: border-box;
}
+ html, body, #root {
+ height: 100%;
+ overflow: hidden;
+ }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #1a1a2e;
+ -webkit-tap-highlight-color: transparent;
+ -webkit-touch-callout: none;
+ touch-action: manipulation;
+ }
+ ::-webkit-scrollbar {
+ width: 4px;
+ height: 4px;
+ }
+ ::-webkit-scrollbar-track {
+ background: transparent;
+ }
+ ::-webkit-scrollbar-thumb {
+ background: rgba(255,255,255,0.2);
+ border-radius: 2px;
+ }
+ button {
+ -webkit-tap-highlight-color: transparent;
}
`
document.head.appendChild(style)
+const viewport = document.querySelector('meta[name="viewport"]')
+if (!viewport) {
+ const meta = document.createElement('meta')
+ meta.name = 'viewport'
+ meta.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover'
+ document.head.appendChild(meta)
+}
+
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
diff --git a/justfile b/justfile
index 834773a..999c87b 100644
--- a/justfile
+++ b/justfile
@@ -2,10 +2,14 @@ default:
@just --list
dev:
- just --parallel server client
+ #!/usr/bin/env bash
+ trap 'kill 0' EXIT
+ just server &
+ just client &
+ wait
server:
- cd server && air
+ cd server && PORT=8081 air
client:
cd client && npm run dev
diff --git a/mprocs.yaml b/mprocs.yaml
index fe1b418..a4c820f 100644
--- a/mprocs.yaml
+++ b/mprocs.yaml
@@ -2,6 +2,8 @@ procs:
server:
cmd: ["air"]
cwd: "server"
+ env:
+ PORT: "8081"
client:
cmd: ["npm", "run", "dev"]
cwd: "client"
diff --git a/server/main.go b/server/main.go
index e9a559c..d7560e1 100644
--- a/server/main.go
+++ b/server/main.go
@@ -4,9 +4,15 @@ import (
"guandanbtw/room"
"log"
"net/http"
+ "os"
)
func main() {
+ port := os.Getenv("PORT")
+ if port == "" {
+ port = "8080"
+ }
+
hub := room.New_Hub()
go hub.Run()
@@ -14,6 +20,6 @@ func main() {
http.Handle("/", http.FileServer(http.Dir("../client/dist")))
- log.Println("server starting on :8080")
- log.Fatal(http.ListenAndServe(":8080", nil))
+ log.Println("server starting on :" + port)
+ log.Fatal(http.ListenAndServe(":"+port, nil))
}