Initial commit: GSPro Remote MVP - Phase 1 complete

This commit is contained in:
Ryan Hill 2025-11-13 15:38:58 -06:00
commit 74ca4b38eb
50 changed files with 12818 additions and 0 deletions

182
frontend/index.html Normal file
View file

@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#2D5016" />
<meta name="description" content="Remote control application for GSPro golf simulator" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="GSPro Remote" />
<!-- PWA manifest -->
<link rel="manifest" href="/manifest.json" />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>GSPro Remote</title>
<style>
/* Prevent text selection and touch highlighting for better mobile UX */
body {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
overscroll-behavior: none;
}
/* Allow text selection in specific areas if needed */
.selectable {
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
}
/* Loading screen styles */
#loading-screen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 9999;
transition: opacity 0.3s ease-out;
}
#loading-screen.fade-out {
opacity: 0;
pointer-events: none;
}
.loading-content {
text-align: center;
color: white;
}
.loading-logo {
width: 120px;
height: 120px;
margin: 0 auto 2rem;
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
border-radius: 30px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 20px 40px rgba(34, 197, 94, 0.3);
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.loading-logo svg {
width: 60px;
height: 60px;
fill: white;
}
.loading-title {
font-size: 1.875rem;
font-weight: 700;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.loading-subtitle {
font-size: 1rem;
color: #94a3b8;
margin-bottom: 2rem;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid #1e293b;
border-top-color: #22c55e;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(0.95);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
</head>
<body class="bg-gray-900 text-white overflow-hidden">
<!-- Loading Screen -->
<div id="loading-screen">
<div class="loading-content">
<div class="loading-logo">
<!-- Golf flag icon -->
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M14.4 6L14 4H5v17h2v-7h5.6l.4 2h7V6h-5.6z"/>
</svg>
</div>
<h1 class="loading-title">GSPro Remote</h1>
<p class="loading-subtitle">Connecting to GSPro...</p>
<div class="loading-spinner"></div>
</div>
</div>
<!-- React Root -->
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script>
// Remove loading screen when React app is ready
window.addEventListener('app-ready', function() {
const loadingScreen = document.getElementById('loading-screen');
if (loadingScreen) {
loadingScreen.classList.add('fade-out');
setTimeout(() => {
loadingScreen.style.display = 'none';
}, 300);
}
});
// Prevent pull-to-refresh on mobile
document.addEventListener('touchmove', function(e) {
if (e.touches.length > 1) {
e.preventDefault();
}
}, { passive: false });
// Handle viewport height on mobile devices
function setViewportHeight() {
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
}
setViewportHeight();
window.addEventListener('resize', setViewportHeight);
window.addEventListener('orientationchange', setViewportHeight);
</script>
</body>
</html>

4813
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

36
frontend/package.json Normal file
View file

@ -0,0 +1,36 @@
{
"name": "gspro-remote-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"axios": "^1.6.2",
"clsx": "^2.0.0",
"react-icons": "^4.12.0",
"zustand": "^4.4.7",
"react-hot-toast": "^2.4.1"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"typescript": "^5.2.2",
"vite": "^5.0.8"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View file

@ -0,0 +1,60 @@
{
"name": "GSPro Remote",
"short_name": "GSPro Remote",
"description": "Remote control application for GSPro golf simulator",
"version": "0.1.0",
"manifest_version": 3,
"start_url": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0f172a",
"theme_color": "#2D5016",
"categories": ["sports", "games", "utilities"],
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"shortcuts": [
{
"name": "Quick Launch",
"short_name": "Launch",
"description": "Quick launch GSPro Remote",
"url": "/",
"icons": [{ "src": "/icon-192.png", "sizes": "192x192" }]
}
],
"screenshots": [
{
"src": "/screenshot1.png",
"type": "image/png",
"sizes": "1280x720",
"label": "Main control interface"
},
{
"src": "/screenshot2.png",
"type": "image/png",
"sizes": "1280x720",
"label": "Map view with streaming"
}
],
"related_applications": [],
"prefer_related_applications": false,
"scope": "/",
"launch_handler": {
"client_mode": "navigate-existing"
},
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
"edge_side_panel": {
"preferred_width": 480
}
}

131
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,131 @@
import React, { useEffect, useState } from 'react'
import { Toaster } from 'react-hot-toast'
import DynamicGolfUI from './pages/DynamicGolfUI'
import ConnectionStatus from './components/ConnectionStatus'
import ErrorBoundary from './components/ErrorBoundary'
import { useStore } from './stores/appStore'
import { checkBackendConnection } from './api/system'
function App() {
const [isConnected, setIsConnected] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const { setConnectionStatus } = useStore()
useEffect(() => {
// Check backend connection on mount
const checkConnection = async () => {
try {
const result = await checkBackendConnection()
setIsConnected(result)
setConnectionStatus(result)
} catch (error) {
console.error('Failed to connect to backend:', error)
setIsConnected(false)
setConnectionStatus(false)
} finally {
setIsLoading(false)
}
}
checkConnection()
// Set up periodic connection check
const interval = setInterval(checkConnection, 5000)
return () => clearInterval(interval)
}, [setConnectionStatus])
if (isLoading) {
return (
<div className="h-screen-safe flex items-center justify-center bg-gray-900">
<div className="text-center">
<div className="w-16 h-16 border-4 border-primary-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-400">Connecting to GSPro Remote...</p>
</div>
</div>
)
}
if (!isConnected) {
return (
<div className="h-screen-safe flex items-center justify-center bg-gray-900 p-4">
<div className="card max-w-md w-full">
<div className="card-body text-center">
<div className="w-20 h-20 bg-error-600 rounded-full flex items-center justify-center mx-auto mb-4">
<svg
className="w-10 h-10 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h2 className="text-2xl font-bold text-white mb-2">Connection Error</h2>
<p className="text-gray-400 mb-6">
Unable to connect to the GSPro Remote backend server.
</p>
<div className="bg-gray-800 rounded-lg p-4 mb-6 text-left">
<p className="text-sm text-gray-300 mb-2">Please ensure:</p>
<ul className="text-sm text-gray-400 space-y-1">
<li> The backend server is running</li>
<li> You're on the same network</li>
<li> Port 5005 is accessible</li>
</ul>
</div>
<button
onClick={() => window.location.reload()}
className="btn-primary w-full"
>
Retry Connection
</button>
</div>
</div>
</div>
)
}
return (
<ErrorBoundary>
<div className="h-screen-safe bg-gray-900 overflow-hidden">
{/* Connection Status Bar */}
<ConnectionStatus />
{/* Main UI */}
<DynamicGolfUI />
{/* Toast Notifications */}
<Toaster
position="top-right"
toastOptions={{
duration: 3000,
style: {
background: '#1f2937',
color: '#fff',
border: '1px solid #374151',
},
success: {
iconTheme: {
primary: '#22c55e',
secondary: '#fff',
},
},
error: {
iconTheme: {
primary: '#ef4444',
secondary: '#fff',
},
},
}}
/>
</div>
</ErrorBoundary>
)
}
export default App

308
frontend/src/api/client.ts Normal file
View file

@ -0,0 +1,308 @@
import axios, { AxiosInstance, AxiosError } from "axios";
import toast from "react-hot-toast";
// Determine API base URL based on current location
const getApiBaseUrl = () => {
// If we're running on localhost (development), use localhost backend
if (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") {
return "http://localhost:5005";
}
// If we're accessing from a network IP, use the same IP for backend
// This assumes backend is running on same machine as frontend
const protocol = window.location.protocol;
const hostname = window.location.hostname;
return `${protocol}//${hostname}:5005`;
};
const API_BASE_URL = import.meta.env.VITE_API_URL || getApiBaseUrl();
// Log the API URL for debugging
console.log("API Base URL:", API_BASE_URL);
// Create axios instance with default config
const apiClient: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
});
// Request interceptor
apiClient.interceptors.request.use(
(config) => {
// Add any auth tokens or request modifications here
return config;
},
(error) => {
return Promise.reject(error);
},
);
// Response interceptor
apiClient.interceptors.response.use(
(response) => {
return response;
},
(error: AxiosError) => {
// Handle common errors
if (error.code === "ECONNABORTED") {
toast.error("Request timeout - please try again");
} else if (error.code === "ERR_NETWORK") {
toast.error("Cannot connect to backend - check if server is running");
} else if (error.response) {
// Server responded with error
const message = (error.response.data as any)?.detail || "An error occurred";
if (error.response.status === 409) {
// GSPro not running
toast.error("GSPro is not running or window not found");
} else if (error.response.status >= 500) {
toast.error(`Server error: ${message}`);
}
} else if (error.request) {
// No response from server
console.error("Backend connection failed:", error.message);
toast.error("Cannot connect to backend server on " + API_BASE_URL);
}
return Promise.reject(error);
},
);
// Actions API
export const actionsAPI = {
sendKey: async (key: string, delay?: number) => {
const response = await apiClient.post("/api/actions/key", { key, delay });
return response.data;
},
sendKeyDown: async (key: string, duration?: number) => {
const response = await apiClient.post("/api/actions/keydown", { key, duration });
return response.data;
},
sendKeyUp: async (key: string) => {
const response = await apiClient.post("/api/actions/keyup", { key });
return response.data;
},
sendKeySequence: async (keys: string[], interval?: number) => {
const response = await apiClient.post("/api/actions/sequence", { keys, interval });
return response.data;
},
getShortcuts: async () => {
const response = await apiClient.get("/api/actions/shortcuts");
return response.data;
},
};
// Config API
export const configAPI = {
getConfig: async () => {
const response = await apiClient.get("/api/config");
return response.data;
},
updateConfig: async (config: any) => {
const response = await apiClient.put("/api/config", config);
return response.data;
},
saveConfig: async () => {
const response = await apiClient.post("/api/config/save");
return response.data;
},
resetConfig: async () => {
const response = await apiClient.post("/api/config/reset");
return response.data;
},
};
// Vision API
export const visionAPI = {
captureRegion: async (x: number, y: number, width: number, height: number) => {
const response = await apiClient.post("/api/vision/capture", {
x,
y,
width,
height,
format: "base64",
quality: 85,
});
return response.data;
},
getRegions: async () => {
const response = await apiClient.get("/api/vision/regions");
return response.data;
},
updateRegion: async (name: string, region: any) => {
const response = await apiClient.post(`/api/vision/regions/${name}`, region);
return response.data;
},
getStatus: async () => {
const response = await apiClient.get("/api/vision/status");
return response.data;
},
};
// System API
export const systemAPI = {
getHealth: async () => {
const response = await apiClient.get("/api/system/health");
return response.data;
},
getInfo: async () => {
const response = await apiClient.get("/api/system/info");
return response.data;
},
getGSProStatus: async () => {
const response = await apiClient.get("/api/system/gspro/status");
return response.data;
},
findGSProWindow: async () => {
const response = await apiClient.get("/api/system/gspro/find");
return response.data;
},
getMetrics: async () => {
const response = await apiClient.get("/api/system/metrics");
return response.data;
},
runDiagnostics: async () => {
const response = await apiClient.get("/api/system/diagnostics");
return response.data;
},
};
// WebSocket connection for streaming
export class StreamingClient {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private reconnectDelay = 1000;
constructor(
private onFrame: (data: any) => void,
private onError?: (error: any) => void,
private onConnect?: () => void,
private onDisconnect?: () => void,
) {}
connect(config: any = {}) {
// Use same logic for WebSocket URL
let wsUrl: string;
if (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") {
wsUrl = "ws://localhost:5005";
} else {
// Use the same hostname but with ws:// protocol
wsUrl = `ws://${window.location.hostname}:5005`;
}
console.log("WebSocket URL:", wsUrl);
this.ws = new WebSocket(`${wsUrl}/api/vision/ws/stream`);
this.ws.onopen = () => {
console.log("WebSocket connected");
this.reconnectAttempts = 0;
// Send configuration
this.ws?.send(
JSON.stringify({
action: "start",
config: {
fps: config.fps || 30,
quality: config.quality || 85,
resolution: config.resolution || "720p",
region_x: config.region_x || 0,
region_y: config.region_y || 0,
region_width: config.region_width || 640,
region_height: config.region_height || 480,
},
}),
);
this.onConnect?.();
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "frame") {
this.onFrame(data);
} else if (data.type === "error") {
console.error("Stream error:", data.message);
this.onError?.(data.message);
} else if (data.type === "config") {
console.log("Stream config:", data.data);
}
} catch (error) {
console.error("Failed to parse WebSocket message:", error);
}
};
this.ws.onerror = (error) => {
console.error("WebSocket error:", error);
this.onError?.(error);
};
this.ws.onclose = () => {
console.log("WebSocket disconnected");
this.onDisconnect?.();
// Attempt reconnection
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
console.log(`Reconnecting... (attempt ${this.reconnectAttempts})`);
setTimeout(() => this.connect(config), this.reconnectDelay * this.reconnectAttempts);
}
};
}
disconnect() {
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
updateConfig(config: any) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(
JSON.stringify({
action: "update",
config,
}),
);
}
}
}
// Export a function to manually check backend connection
export async function checkBackendConnection(): Promise<boolean> {
try {
await systemAPI.getHealth();
return true;
} catch (error) {
console.error("Backend connection check failed:", error);
return false;
}
}
// Export the current API base URL for debugging
export function getCurrentApiUrl(): string {
return API_BASE_URL;
}
export default apiClient;

123
frontend/src/api/system.ts Normal file
View file

@ -0,0 +1,123 @@
import { systemAPI } from './client'
export interface SystemHealth {
status: string
timestamp: string
uptime_seconds: number
services: {
api: string
gspro: string
mdns: string
}
}
export interface GSProStatus {
running: boolean
window_title: string
process: {
pid: number
name: string
cpu_percent: number
memory_mb: number
} | null
auto_focus: boolean
key_delay: number
}
let connectionCheckPromise: Promise<boolean> | null = null
export async function checkBackendConnection(): Promise<boolean> {
// Prevent multiple simultaneous connection checks
if (connectionCheckPromise) {
return connectionCheckPromise
}
connectionCheckPromise = systemAPI.getHealth()
.then(() => true)
.catch(() => false)
.finally(() => {
connectionCheckPromise = null
})
return connectionCheckPromise
}
export async function checkGSProStatus(): Promise<GSProStatus | null> {
try {
const status = await systemAPI.getGSProStatus()
return status
} catch (error) {
console.error('Failed to get GSPro status:', error)
return null
}
}
export async function getSystemHealth(): Promise<SystemHealth | null> {
try {
const health = await systemAPI.getHealth()
return health
} catch (error) {
console.error('Failed to get system health:', error)
return null
}
}
export async function findGSProWindows(): Promise<any[]> {
try {
const result = await systemAPI.findGSProWindow()
return result.windows || []
} catch (error) {
console.error('Failed to find GSPro windows:', error)
return []
}
}
export async function runSystemDiagnostics(): Promise<any> {
try {
const diagnostics = await systemAPI.runDiagnostics()
return diagnostics
} catch (error) {
console.error('Failed to run diagnostics:', error)
return null
}
}
export function formatUptime(seconds: number): string {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
if (hours > 0) {
return `${hours}h ${minutes}m`
} else if (minutes > 0) {
return `${minutes}m ${secs}s`
} else {
return `${secs}s`
}
}
export function getConnectionStatusColor(status: string): string {
switch (status) {
case 'connected':
return 'text-green-500'
case 'disconnected':
return 'text-red-500'
case 'connecting':
return 'text-yellow-500'
default:
return 'text-gray-500'
}
}
export function getConnectionStatusIcon(status: string): string {
switch (status) {
case 'connected':
return '●'
case 'disconnected':
return '○'
case 'connecting':
return '◐'
default:
return '◯'
}
}

View file

@ -0,0 +1,281 @@
import React, { useState, useCallback, useEffect } from 'react'
import { actionsAPI } from '../api/client'
import { useStore } from '../stores/appStore'
import toast from 'react-hot-toast'
interface AimPadProps {
size?: 'small' | 'medium' | 'large'
}
const AimPad: React.FC<AimPadProps> = ({ size = 'large' }) => {
const { settings, setAimDirection } = useStore()
const [activeDirection, setActiveDirection] = useState<string | null>(null)
const [isHolding, setIsHolding] = useState(false)
// Size configurations
const sizeClasses = {
small: 'w-32 h-32',
medium: 'w-48 h-48',
large: 'w-64 h-64',
}
const buttonSizeClasses = {
small: 'text-xl',
medium: 'text-2xl',
large: 'text-3xl',
}
// Handle key press/release
const handleKeyAction = useCallback(async (key: string, action: 'down' | 'up') => {
try {
if (action === 'down') {
await actionsAPI.sendKeyDown(key)
setIsHolding(true)
// Haptic feedback on mobile
if (settings.hapticFeedback && 'vibrate' in navigator) {
navigator.vibrate(10)
}
} else {
await actionsAPI.sendKeyUp(key)
setIsHolding(false)
}
} catch (error) {
console.error(`Failed to send key ${action}:`, error)
if (action === 'down') {
toast.error('Failed to send command')
}
}
}, [settings.hapticFeedback])
// Handle single press
const handlePress = useCallback(async (key: string, direction?: string) => {
try {
await actionsAPI.sendKey(key)
if (direction) {
// Flash the button
setActiveDirection(direction)
setTimeout(() => setActiveDirection(null), 150)
// Update aim direction in store
if (direction === 'up') setAimDirection({ x: 0, y: 1 })
else if (direction === 'down') setAimDirection({ x: 0, y: -1 })
else if (direction === 'left') setAimDirection({ x: -1, y: 0 })
else if (direction === 'right') setAimDirection({ x: 1, y: 0 })
else if (direction === 'center') setAimDirection({ x: 0, y: 0 })
}
// Haptic feedback
if (settings.hapticFeedback && 'vibrate' in navigator) {
navigator.vibrate(10)
}
} catch (error) {
console.error('Failed to send key:', error)
}
}, [settings.hapticFeedback, setAimDirection])
// Touch handlers for hold functionality
const handleTouchStart = (key: string, direction: string) => {
setActiveDirection(direction)
handleKeyAction(key, 'down')
}
const handleTouchEnd = (key: string) => {
setActiveDirection(null)
if (isHolding) {
handleKeyAction(key, 'up')
}
}
// Mouse handlers for desktop
const handleMouseDown = (key: string, direction: string) => {
setActiveDirection(direction)
handleKeyAction(key, 'down')
}
const handleMouseUp = (key: string) => {
setActiveDirection(null)
if (isHolding) {
handleKeyAction(key, 'up')
}
}
const handleMouseLeave = (key: string) => {
if (isHolding) {
setActiveDirection(null)
handleKeyAction(key, 'up')
}
}
// Cleanup on unmount
useEffect(() => {
return () => {
if (isHolding) {
// Clean up any held keys
['up', 'down', 'left', 'right'].forEach(key => {
actionsAPI.sendKeyUp(key).catch(() => {})
})
}
}
}, [isHolding])
return (
<div className="relative">
{/* D-Pad Container */}
<div className={`relative ${sizeClasses[size]} select-none`}>
{/* Background circle */}
<div className="absolute inset-0 bg-gray-800 rounded-full shadow-2xl"></div>
{/* Center lines */}
<div className="absolute inset-x-0 top-1/2 h-px bg-gray-700 -translate-y-1/2"></div>
<div className="absolute inset-y-0 left-1/2 w-px bg-gray-700 -translate-x-1/2"></div>
{/* Up Button */}
<button
className={`absolute top-0 left-1/2 -translate-x-1/2 w-1/3 h-1/3 flex items-center justify-center rounded-t-full transition-all ${
activeDirection === 'up'
? 'bg-primary-600 text-white scale-95'
: 'hover:bg-gray-700 text-gray-400 hover:text-white'
}`}
onMouseDown={() => handleMouseDown('up', 'up')}
onMouseUp={() => handleMouseUp('up')}
onMouseLeave={() => handleMouseLeave('up')}
onTouchStart={() => handleTouchStart('up', 'up')}
onTouchEnd={() => handleTouchEnd('up')}
aria-label="Aim Up"
>
<svg
className={`${buttonSizeClasses[size]} pointer-events-none`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
width="1em"
height="1em"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
</button>
{/* Down Button */}
<button
className={`absolute bottom-0 left-1/2 -translate-x-1/2 w-1/3 h-1/3 flex items-center justify-center rounded-b-full transition-all ${
activeDirection === 'down'
? 'bg-primary-600 text-white scale-95'
: 'hover:bg-gray-700 text-gray-400 hover:text-white'
}`}
onMouseDown={() => handleMouseDown('down', 'down')}
onMouseUp={() => handleMouseUp('down')}
onMouseLeave={() => handleMouseLeave('down')}
onTouchStart={() => handleTouchStart('down', 'down')}
onTouchEnd={() => handleTouchEnd('down')}
aria-label="Aim Down"
>
<svg
className={`${buttonSizeClasses[size]} pointer-events-none`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
width="1em"
height="1em"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Left Button */}
<button
className={`absolute left-0 top-1/2 -translate-y-1/2 w-1/3 h-1/3 flex items-center justify-center rounded-l-full transition-all ${
activeDirection === 'left'
? 'bg-primary-600 text-white scale-95'
: 'hover:bg-gray-700 text-gray-400 hover:text-white'
}`}
onMouseDown={() => handleMouseDown('left', 'left')}
onMouseUp={() => handleMouseUp('left')}
onMouseLeave={() => handleMouseLeave('left')}
onTouchStart={() => handleTouchStart('left', 'left')}
onTouchEnd={() => handleTouchEnd('left')}
aria-label="Aim Left"
>
<svg
className={`${buttonSizeClasses[size]} pointer-events-none`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
width="1em"
height="1em"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
{/* Right Button */}
<button
className={`absolute right-0 top-1/2 -translate-y-1/2 w-1/3 h-1/3 flex items-center justify-center rounded-r-full transition-all ${
activeDirection === 'right'
? 'bg-primary-600 text-white scale-95'
: 'hover:bg-gray-700 text-gray-400 hover:text-white'
}`}
onMouseDown={() => handleMouseDown('right', 'right')}
onMouseUp={() => handleMouseUp('right')}
onMouseLeave={() => handleMouseLeave('right')}
onTouchStart={() => handleTouchStart('right', 'right')}
onTouchEnd={() => handleTouchEnd('right')}
aria-label="Aim Right"
>
<svg
className={`${buttonSizeClasses[size]} pointer-events-none`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
width="1em"
height="1em"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
{/* Center Reset Button */}
<button
className={`absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-1/4 h-1/4 flex items-center justify-center rounded-full transition-all ${
activeDirection === 'center'
? 'bg-success-600 text-white scale-95'
: 'bg-gray-700 hover:bg-gray-600 text-gray-400 hover:text-white'
}`}
onClick={() => handlePress('a', 'center')}
aria-label="Reset Aim"
>
<svg
className={`${buttonSizeClasses[size]} pointer-events-none`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
width="1em"
height="1em"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
</div>
{/* Label */}
<div className="text-center mt-2">
<span className="text-sm text-gray-400 uppercase tracking-wider">Aim Control</span>
</div>
{/* Hold indicator */}
{isHolding && (
<div className="absolute -top-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-primary-600 text-white text-xs rounded animate-pulse">
Holding...
</div>
)}
</div>
)
}
export default AimPad

View file

@ -0,0 +1,168 @@
import React from 'react'
import { useStore } from '../stores/appStore'
import { actionsAPI } from '../api/client'
import toast from 'react-hot-toast'
interface ClubIndicatorProps {
compact?: boolean
}
const ClubIndicator: React.FC<ClubIndicatorProps> = ({ compact = false }) => {
const { club, nextClub, previousClub, settings } = useStore()
const handleClubChange = async (direction: 'up' | 'down') => {
try {
const key = direction === 'up' ? 'u' : 'k'
await actionsAPI.sendKey(key)
if (direction === 'up') {
previousClub()
} else {
nextClub()
}
// Haptic feedback
if (settings.hapticFeedback && 'vibrate' in navigator) {
navigator.vibrate(10)
}
} catch (error) {
toast.error('Failed to change club')
}
}
const getClubIcon = (clubName: string) => {
// Return appropriate icon based on club type
if (clubName.includes('Driver')) return '🏌️'
if (clubName.includes('Wood')) return '🪵'
if (clubName.includes('Hybrid')) return '⚡'
if (clubName.includes('Iron')) return '🔧'
if (clubName.includes('Wedge') || clubName.includes('W')) return '⛳'
if (clubName.includes('Putter')) return '🎯'
return '🏌️'
}
const getClubDistance = (clubName: string) => {
// Approximate distances for each club
const distances: { [key: string]: string } = {
'Driver': '250-280y',
'3 Wood': '215-240y',
'5 Wood': '200-220y',
'3 Hybrid': '190-210y',
'4 Iron': '180-200y',
'5 Iron': '170-190y',
'6 Iron': '160-180y',
'7 Iron': '150-170y',
'8 Iron': '140-160y',
'9 Iron': '130-150y',
'PW': '120-140y',
'GW': '100-120y',
'SW': '80-100y',
'LW': '60-80y',
'Putter': 'Green',
}
return distances[clubName] || '---'
}
if (compact) {
// Compact version for mobile
return (
<div className="bg-gray-800 rounded-lg p-3 shadow-lg">
<div className="flex items-center justify-between">
<button
onClick={() => handleClubChange('up')}
className="p-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
aria-label="Previous Club"
>
<svg className="w-4 h-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
</button>
<div className="flex-1 mx-3 text-center">
<div className="text-lg font-bold text-white">{club.current}</div>
<div className="text-xs text-gray-400">{getClubDistance(club.current)}</div>
</div>
<button
onClick={() => handleClubChange('down')}
className="p-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
aria-label="Next Club"
>
<svg className="w-4 h-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
</div>
)
}
// Full version for desktop/tablet
return (
<div className="bg-gray-800 rounded-xl shadow-2xl overflow-hidden h-full">
{/* Header */}
<div className="bg-gradient-to-r from-primary-600 to-primary-700 p-4">
<h3 className="text-white font-bold text-lg">Club Selection</h3>
</div>
{/* Current Club Display */}
<div className="p-6">
<div className="bg-gray-900 rounded-lg p-6 text-center">
<div className="text-4xl mb-2">{getClubIcon(club.current)}</div>
<div className="text-2xl font-bold text-white mb-1">{club.current}</div>
<div className="text-sm text-gray-400">{getClubDistance(club.current)}</div>
</div>
{/* Club Change Buttons */}
<div className="mt-6 space-y-3">
<button
onClick={() => handleClubChange('up')}
className="w-full flex items-center justify-center p-3 bg-gray-700 hover:bg-gray-600 rounded-lg transition-all group"
aria-label="Previous Club"
>
<svg className="w-5 h-5 text-gray-300 group-hover:text-white mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
<span className="text-gray-300 group-hover:text-white font-medium">Shorter Club</span>
</button>
<button
onClick={() => handleClubChange('down')}
className="w-full flex items-center justify-center p-3 bg-gray-700 hover:bg-gray-600 rounded-lg transition-all group"
aria-label="Next Club"
>
<svg className="w-5 h-5 text-gray-300 group-hover:text-white mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
<span className="text-gray-300 group-hover:text-white font-medium">Longer Club</span>
</button>
</div>
{/* Club List Preview */}
<div className="mt-6">
<div className="text-xs text-gray-500 uppercase tracking-wider mb-2">Club Bag</div>
<div className="space-y-1">
{[
club.index > 0 ? `${['Driver', '3 Wood', '5 Wood', '3 Hybrid', '4 Iron', '5 Iron', '6 Iron', '7 Iron', '8 Iron', '9 Iron', 'PW', 'GW', 'SW', 'LW', 'Putter'][club.index - 1]}` : '',
`${club.current}`,
club.index < 14 ? `${['Driver', '3 Wood', '5 Wood', '3 Hybrid', '4 Iron', '5 Iron', '6 Iron', '7 Iron', '8 Iron', '9 Iron', 'PW', 'GW', 'SW', 'LW', 'Putter'][club.index + 1]}` : '',
].filter(Boolean).map((text, idx) => (
<div
key={idx}
className={`text-sm px-2 py-1 rounded ${
text.startsWith('→')
? 'bg-primary-600 bg-opacity-20 text-primary-400 font-medium'
: 'text-gray-500'
}`}
>
{text}
</div>
))}
</div>
</div>
</div>
</div>
)
}
export default ClubIndicator

View file

@ -0,0 +1,125 @@
import React, { useEffect, useState } from 'react'
import { useStore } from '../stores/appStore'
import { getSystemHealth, formatUptime } from '../api/system'
const ConnectionStatus: React.FC = () => {
const { connectionStatus, gsproStatus } = useStore()
const [health, setHealth] = useState<any>(null)
const [isVisible, setIsVisible] = useState(true)
useEffect(() => {
const fetchHealth = async () => {
const healthData = await getSystemHealth()
if (healthData) {
setHealth(healthData)
}
}
fetchHealth()
const interval = setInterval(fetchHealth, 10000) // Update every 10 seconds
return () => clearInterval(interval)
}, [])
useEffect(() => {
// Auto-hide the status bar after 5 seconds if everything is connected
if (connectionStatus === 'connected' && gsproStatus === 'running') {
const timer = setTimeout(() => setIsVisible(false), 5000)
return () => clearTimeout(timer)
} else {
setIsVisible(true)
}
}, [connectionStatus, gsproStatus])
const getStatusColor = () => {
if (connectionStatus !== 'connected') return 'bg-red-600'
if (gsproStatus !== 'running') return 'bg-yellow-600'
return 'bg-green-600'
}
const getStatusText = () => {
if (connectionStatus !== 'connected') return 'Backend Disconnected'
if (gsproStatus !== 'running') return 'GSPro Not Running'
return 'Connected'
}
if (!isVisible && connectionStatus === 'connected' && gsproStatus === 'running') {
// Show a minimal indicator when hidden
return (
<div
className="fixed top-2 right-2 z-50"
onClick={() => setIsVisible(true)}
>
<div className="bg-gray-800 rounded-full p-2 cursor-pointer hover:bg-gray-700 transition-colors">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
</div>
</div>
)
}
return (
<div className={`fixed top-0 left-0 right-0 z-50 transition-transform duration-300 ${isVisible ? 'translate-y-0' : '-translate-y-full'}`}>
<div className={`${getStatusColor()} bg-opacity-90 backdrop-blur-sm`}>
<div className="container mx-auto px-4 py-2">
<div className="flex items-center justify-between text-white text-sm">
<div className="flex items-center space-x-4">
{/* Status indicator */}
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${connectionStatus === 'connected' ? 'bg-white animate-pulse' : 'bg-white opacity-50'}`}></div>
<span className="font-medium">{getStatusText()}</span>
</div>
{/* Backend status */}
{connectionStatus === 'connected' && (
<>
<div className="hidden sm:flex items-center space-x-1 text-xs opacity-80">
<span>Backend:</span>
<span className="font-mono">localhost:5005</span>
</div>
{/* Uptime */}
{health && (
<div className="hidden md:flex items-center space-x-1 text-xs opacity-80">
<span>Uptime:</span>
<span className="font-mono">{formatUptime(health.uptime_seconds)}</span>
</div>
)}
</>
)}
</div>
{/* Actions */}
<div className="flex items-center space-x-2">
{/* GSPro status */}
{connectionStatus === 'connected' && (
<div className={`flex items-center space-x-1 text-xs px-2 py-1 rounded ${
gsproStatus === 'running'
? 'bg-white bg-opacity-20'
: 'bg-yellow-500 bg-opacity-50'
}`}>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z"></path>
<path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z"></path>
</svg>
<span>{gsproStatus === 'running' ? 'GSPro Ready' : 'GSPro Not Found'}</span>
</div>
)}
{/* Close button for mobile */}
<button
onClick={() => setIsVisible(false)}
className="sm:hidden p-1 hover:bg-white hover:bg-opacity-20 rounded transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
)
}
export default ConnectionStatus

View file

@ -0,0 +1,106 @@
import React, { Component, ErrorInfo, ReactNode } from 'react'
interface Props {
children: ReactNode
}
interface State {
hasError: boolean
error: Error | null
errorInfo: ErrorInfo | null
}
class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null,
errorInfo: null,
}
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error, errorInfo: null }
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo)
this.setState({
error,
errorInfo,
})
}
private handleReset = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
})
window.location.reload()
}
public render() {
if (this.state.hasError) {
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center p-4">
<div className="bg-gray-800 rounded-lg shadow-xl p-6 max-w-lg w-full">
<div className="flex items-center mb-4">
<div className="bg-red-600 rounded-full p-3 mr-4">
<svg
className="w-6 h-6 text-white"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
</div>
<div>
<h2 className="text-xl font-semibold text-white">Something went wrong</h2>
<p className="text-gray-400">An unexpected error occurred</p>
</div>
</div>
{this.state.error && (
<div className="bg-gray-900 rounded p-3 mb-4">
<p className="text-sm font-mono text-red-400">{this.state.error.toString()}</p>
</div>
)}
{process.env.NODE_ENV === 'development' && this.state.errorInfo && (
<details className="mb-4">
<summary className="cursor-pointer text-sm text-gray-400 hover:text-gray-300">
Show details
</summary>
<pre className="mt-2 text-xs bg-gray-900 p-3 rounded overflow-auto max-h-64 text-gray-400">
{this.state.errorInfo.componentStack}
</pre>
</details>
)}
<div className="flex space-x-3">
<button
onClick={this.handleReset}
className="flex-1 bg-primary-600 hover:bg-primary-700 text-white font-medium py-2 px-4 rounded transition-colors"
>
Reload App
</button>
<button
onClick={() => window.location.href = '/'}
className="flex-1 bg-gray-700 hover:bg-gray-600 text-white font-medium py-2 px-4 rounded transition-colors"
>
Go Home
</button>
</div>
</div>
</div>
)
}
return this.props.children
}
}
export default ErrorBoundary

View file

@ -0,0 +1,284 @@
import React, { useEffect, useRef, useState } from 'react'
import { useStore } from '../stores/appStore'
import { StreamingClient } from '../api/client'
import { actionsAPI } from '../api/client'
import toast from 'react-hot-toast'
interface MapPanelProps {
compact?: boolean
}
const MapPanel: React.FC<MapPanelProps> = ({ compact = false }) => {
const { mapConfig, toggleMapExpanded, setMapStreaming, settings } = useStore()
const [isConnected, setIsConnected] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const canvasRef = useRef<HTMLCanvasElement>(null)
const streamClientRef = useRef<StreamingClient | null>(null)
const animationFrameRef = useRef<number>()
useEffect(() => {
// Initialize streaming client
streamClientRef.current = new StreamingClient(
(data) => {
// Handle incoming frame
if (data.data && canvasRef.current) {
const img = new Image()
img.onload = () => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
// Clear and draw new frame
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
}
img.src = `data:image/jpeg;base64,${data.data}`
}
},
(error) => {
console.error('Stream error:', error)
toast.error('Stream connection lost')
setIsConnected(false)
setMapStreaming(false)
},
() => {
console.log('Stream connected')
setIsConnected(true)
setMapStreaming(true)
setIsLoading(false)
},
() => {
console.log('Stream disconnected')
setIsConnected(false)
setMapStreaming(false)
}
)
return () => {
// Cleanup
streamClientRef.current?.disconnect()
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
}
}, [setMapStreaming])
const handleStartStream = () => {
setIsLoading(true)
const config = {
fps: settings.streamFPS,
quality: settings.streamQuality === 'high' ? 90 : settings.streamQuality === 'medium' ? 75 : 60,
resolution: settings.streamQuality === 'high' ? '1080p' : settings.streamQuality === 'medium' ? '720p' : '480p',
region_x: mapConfig.x,
region_y: mapConfig.y,
region_width: mapConfig.width,
region_height: mapConfig.height,
}
streamClientRef.current?.connect(config)
}
const handleStopStream = () => {
streamClientRef.current?.disconnect()
}
const handleToggleMap = async () => {
try {
await actionsAPI.sendKey('s')
toggleMapExpanded()
if (settings.hapticFeedback && 'vibrate' in navigator) {
navigator.vibrate(10)
}
} catch (error) {
toast.error('Failed to toggle map')
}
}
const handleZoom = async (direction: 'in' | 'out') => {
try {
const key = direction === 'in' ? 'q' : 'w'
await actionsAPI.sendKey(key)
if (settings.hapticFeedback && 'vibrate' in navigator) {
navigator.vibrate(10)
}
} catch (error) {
toast.error('Failed to zoom map')
}
}
if (compact) {
// Compact version for mobile
return (
<div className="bg-gray-800 rounded-lg p-3 shadow-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-300">Map</span>
<button
onClick={handleToggleMap}
className="text-xs text-primary-400 hover:text-primary-300"
>
{mapConfig.expanded ? 'Collapse' : 'Expand'}
</button>
</div>
<div className="relative bg-black rounded-lg overflow-hidden" style={{ height: '120px' }}>
{!isConnected ? (
<div className="absolute inset-0 flex items-center justify-center">
<button
onClick={handleStartStream}
disabled={isLoading}
className="px-3 py-1 bg-primary-600 hover:bg-primary-700 text-white text-sm rounded transition-colors"
>
{isLoading ? 'Connecting...' : 'Start Stream'}
</button>
</div>
) : (
<>
<canvas
ref={canvasRef}
className="w-full h-full"
width={320}
height={120}
/>
<button
onClick={handleStopStream}
className="absolute top-1 right-1 p-1 bg-gray-900 bg-opacity-50 rounded hover:bg-opacity-70 transition-colors"
>
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</>
)}
</div>
</div>
)
}
// Full version for desktop/tablet
return (
<div className={`bg-gray-800 rounded-xl shadow-2xl overflow-hidden ${mapConfig.expanded ? 'fixed inset-4 z-50' : 'h-full'}`}>
{/* Header */}
<div className="bg-gradient-to-r from-green-600 to-green-700 p-4 flex items-center justify-between">
<h3 className="text-white font-bold text-lg">Course Map</h3>
<div className="flex items-center space-x-2">
{isConnected && (
<>
<button
onClick={() => handleZoom('in')}
className="p-1 bg-white bg-opacity-20 hover:bg-opacity-30 rounded transition-colors"
aria-label="Zoom In"
>
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
</svg>
</button>
<button
onClick={() => handleZoom('out')}
className="p-1 bg-white bg-opacity-20 hover:bg-opacity-30 rounded transition-colors"
aria-label="Zoom Out"
>
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM13 10H7" />
</svg>
</button>
</>
)}
<button
onClick={handleToggleMap}
className="p-1 bg-white bg-opacity-20 hover:bg-opacity-30 rounded transition-colors"
aria-label={mapConfig.expanded ? 'Collapse Map' : 'Expand Map'}
>
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{mapConfig.expanded ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
)}
</svg>
</button>
</div>
</div>
{/* Map Display */}
<div className={`relative bg-black ${mapConfig.expanded ? 'h-[calc(100%-60px)]' : 'h-64'}`}>
{!isConnected ? (
<div className="absolute inset-0 flex flex-col items-center justify-center p-4">
<div className="text-center">
<svg className="w-16 h-16 text-gray-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
<h4 className="text-white font-medium mb-2">Map Stream</h4>
<p className="text-gray-400 text-sm mb-4">View the GSPro course map in real-time</p>
<button
onClick={handleStartStream}
disabled={isLoading}
className="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<span className="flex items-center">
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Connecting...
</span>
) : (
'Start Streaming'
)}
</button>
</div>
</div>
) : (
<>
<canvas
ref={canvasRef}
className="w-full h-full"
width={mapConfig.expanded ? 1280 : 640}
height={mapConfig.expanded ? 720 : 480}
/>
{/* Stream Controls Overlay */}
<div className="absolute top-2 right-2 flex items-center space-x-2">
<div className="px-2 py-1 bg-green-600 bg-opacity-75 rounded text-xs text-white flex items-center">
<div className="w-2 h-2 bg-white rounded-full animate-pulse mr-1"></div>
Live
</div>
<button
onClick={handleStopStream}
className="p-1 bg-gray-900 bg-opacity-50 hover:bg-opacity-70 rounded transition-colors"
aria-label="Stop Stream"
>
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Map Click Overlay (when expanded) */}
{mapConfig.expanded && (
<div className="absolute bottom-4 left-4 bg-gray-900 bg-opacity-75 rounded-lg p-2">
<p className="text-xs text-gray-300">Click on the map to set target location</p>
</div>
)}
</>
)}
</div>
{/* Stream Info */}
{isConnected && !mapConfig.expanded && (
<div className="p-3 border-t border-gray-700">
<div className="flex items-center justify-between text-xs text-gray-400">
<span>Quality: {settings.streamQuality}</span>
<span>FPS: {settings.streamFPS}</span>
<span>Resolution: {settings.streamQuality === 'high' ? '1080p' : settings.streamQuality === 'medium' ? '720p' : '480p'}</span>
</div>
</div>
)}
</div>
)
}
export default MapPanel

View file

@ -0,0 +1,205 @@
import React, { useState } from 'react'
import { actionsAPI } from '../api/client'
import toast from 'react-hot-toast'
import { useStore } from '../stores/appStore'
interface QuickActionsProps {
mobile?: boolean
}
const QuickActions: React.FC<QuickActionsProps> = ({ mobile = false }) => {
const [isOpen, setIsOpen] = useState(false)
const { settings } = useStore()
const actions = [
{
id: 'scorecard',
label: 'Scorecard',
icon: '📊',
key: 't',
description: 'Toggle scorecard',
},
{
id: 'rangefinder',
label: 'Range Finder',
icon: '🎯',
key: 'r',
description: 'Launch range finder',
},
{
id: 'heatmap',
label: 'Heat Map',
icon: '🗺️',
key: 'y',
description: 'Toggle heat map',
},
{
id: 'flyover',
label: 'Flyover',
icon: '🚁',
key: 'o',
description: 'Hole preview',
},
{
id: 'pin',
label: 'Pin Indicator',
icon: '🚩',
key: 'p',
description: 'Show/hide pin',
},
{
id: 'freelook',
label: 'Free Look',
icon: '👀',
key: 'f5',
description: 'Unlock camera',
},
{
id: 'aimpoint',
label: 'Aim Point',
icon: '🎪',
key: 'f3',
description: 'Toggle aim point',
},
{
id: 'tracerclear',
label: 'Clear Tracer',
icon: '💨',
key: 'f1',
description: 'Clear ball tracer',
},
]
const handleAction = async (action: typeof actions[0]) => {
try {
await actionsAPI.sendKey(action.key)
toast.success(action.description)
setIsOpen(false)
// Haptic feedback
if (settings.hapticFeedback && 'vibrate' in navigator) {
navigator.vibrate(10)
}
} catch (error) {
toast.error(`Failed to execute ${action.label}`)
}
}
const handleToggle = () => {
setIsOpen(!isOpen)
// Haptic feedback
if (settings.hapticFeedback && 'vibrate' in navigator) {
navigator.vibrate(10)
}
}
if (mobile) {
// Mobile version - bottom sheet style
return (
<>
{/* Floating Action Button */}
<button
onClick={handleToggle}
className={`fixed bottom-20 right-4 z-40 w-14 h-14 bg-primary-600 hover:bg-primary-700 text-white rounded-full shadow-lg transition-all transform ${
isOpen ? 'rotate-45 scale-110' : ''
}`}
aria-label="Quick Actions"
>
<svg className="w-8 h-8 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
{/* Bottom Sheet */}
<div
className={`fixed inset-x-0 bottom-0 z-30 bg-gray-800 rounded-t-2xl shadow-2xl transition-transform duration-300 ${
isOpen ? 'translate-y-0' : 'translate-y-full'
}`}
>
<div className="p-4">
<div className="w-12 h-1 bg-gray-600 rounded-full mx-auto mb-4"></div>
<h3 className="text-lg font-semibold text-white mb-3">Quick Actions</h3>
<div className="grid grid-cols-3 gap-3">
{actions.map((action) => (
<button
key={action.id}
onClick={() => handleAction(action)}
className="bg-gray-700 hover:bg-gray-600 p-3 rounded-lg text-center transition-colors"
>
<div className="text-2xl mb-1">{action.icon}</div>
<div className="text-xs text-gray-300">{action.label}</div>
</button>
))}
</div>
</div>
</div>
{/* Backdrop */}
{isOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-20"
onClick={() => setIsOpen(false)}
/>
)}
</>
)
}
// Desktop version - floating menu
return (
<div className="fixed bottom-8 right-8 z-40">
{/* Action Menu */}
<div
className={`absolute bottom-16 right-0 bg-gray-800 rounded-lg shadow-2xl overflow-hidden transition-all duration-300 ${
isOpen
? 'opacity-100 scale-100 translate-y-0'
: 'opacity-0 scale-95 translate-y-4 pointer-events-none'
}`}
>
<div className="p-2 max-h-96 overflow-y-auto">
<div className="space-y-1">
{actions.map((action) => (
<button
key={action.id}
onClick={() => handleAction(action)}
className="w-full flex items-center space-x-3 px-3 py-2 hover:bg-gray-700 rounded-lg transition-colors group"
>
<span className="text-xl">{action.icon}</span>
<div className="text-left flex-1">
<div className="text-sm font-medium text-white group-hover:text-primary-400 transition-colors">
{action.label}
</div>
<div className="text-xs text-gray-400">{action.description}</div>
</div>
<kbd className="hidden sm:inline-block px-2 py-1 text-xs bg-gray-900 rounded">
{action.key.toUpperCase()}
</kbd>
</button>
))}
</div>
</div>
</div>
{/* Floating Action Button */}
<button
onClick={handleToggle}
className={`relative w-14 h-14 bg-gradient-to-r from-primary-600 to-primary-700 hover:from-primary-700 hover:to-primary-800 text-white rounded-full shadow-lg transition-all transform hover:scale-110 ${
isOpen ? 'rotate-45' : ''
}`}
aria-label="Quick Actions"
>
<svg className="w-8 h-8 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{/* Pulse animation when closed */}
{!isOpen && (
<span className="absolute inset-0 rounded-full bg-primary-600 animate-ping opacity-30"></span>
)}
</button>
</div>
)
}
export default QuickActions

View file

@ -0,0 +1,130 @@
import React from 'react'
import { useStore } from '../stores/appStore'
import { actionsAPI } from '../api/client'
import toast from 'react-hot-toast'
interface ShotOptionsProps {
compact?: boolean
}
const ShotOptions: React.FC<ShotOptionsProps> = ({ compact = false }) => {
const { shotMode, setShotMode, settings } = useStore()
const shotTypes = [
{ id: 'normal', label: 'Normal', icon: '⚫', key: null },
{ id: 'punch', label: 'Punch', icon: '👊', key: "'" },
{ id: 'flop', label: 'Flop', icon: '🎯', key: "'" },
{ id: 'chip', label: 'Chip', icon: '⛳', key: "'" },
]
const handleShotChange = async (mode: 'normal' | 'punch' | 'flop' | 'chip') => {
try {
// Send key if needed (shot options typically cycle with apostrophe key)
if (mode !== 'normal' && mode !== shotMode) {
await actionsAPI.sendKey("'")
}
setShotMode(mode)
// Haptic feedback
if (settings.hapticFeedback && 'vibrate' in navigator) {
navigator.vibrate(10)
}
toast.success(`Shot mode: ${mode}`)
} catch (error) {
toast.error('Failed to change shot mode')
}
}
if (compact) {
// Compact horizontal layout for mobile
return (
<div className="bg-gray-800 rounded-lg p-3 shadow-lg">
<div className="text-xs text-gray-400 uppercase tracking-wider mb-2">Shot Type</div>
<div className="flex space-x-2">
{shotTypes.map((shot) => (
<button
key={shot.id}
onClick={() => handleShotChange(shot.id as any)}
className={`flex-1 px-2 py-2 rounded-lg text-sm font-medium transition-all ${
shotMode === shot.id
? 'bg-primary-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
>
<span className="block text-lg mb-1">{shot.icon}</span>
<span className="block text-xs">{shot.label}</span>
</button>
))}
</div>
</div>
)
}
// Full version for desktop/tablet
return (
<div className="bg-gray-800 rounded-xl shadow-lg p-4">
<h3 className="text-sm font-medium text-gray-400 uppercase tracking-wider mb-3">Shot Options</h3>
<div className="grid grid-cols-2 gap-2">
{shotTypes.map((shot) => (
<button
key={shot.id}
onClick={() => handleShotChange(shot.id as any)}
className={`relative p-3 rounded-lg transition-all transform hover:scale-105 ${
shotMode === shot.id
? 'bg-gradient-to-r from-primary-600 to-primary-700 text-white shadow-lg'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
>
<div className="flex flex-col items-center">
<span className="text-2xl mb-1">{shot.icon}</span>
<span className="text-sm font-medium">{shot.label}</span>
</div>
{shotMode === shot.id && (
<div className="absolute top-1 right-1">
<div className="w-2 h-2 bg-white rounded-full animate-pulse"></div>
</div>
)}
</button>
))}
</div>
{/* Additional shot controls */}
<div className="mt-4 pt-4 border-t border-gray-700">
<div className="grid grid-cols-2 gap-2">
<button
onClick={async () => {
try {
await actionsAPI.sendKey('u')
toast.success('Putt mode toggled')
} catch (error) {
toast.error('Failed to toggle putt mode')
}
}}
className="px-3 py-2 bg-gray-700 hover:bg-gray-600 text-gray-300 text-sm rounded-lg transition-colors"
>
🎯 Putt Toggle
</button>
<button
onClick={async () => {
try {
await actionsAPI.sendKey('g')
toast.success('Green grid toggled')
} catch (error) {
toast.error('Failed to toggle green grid')
}
}}
className="px-3 py-2 bg-gray-700 hover:bg-gray-600 text-gray-300 text-sm rounded-lg transition-colors"
>
📋 Green Grid
</button>
</div>
</div>
</div>
)
}
export default ShotOptions

View file

@ -0,0 +1,164 @@
import React, { useEffect, useState } from 'react'
import { systemAPI } from '../api/client'
import { useStore } from '../stores/appStore'
interface StatBarProps {
compact?: boolean
}
interface Stats {
windSpeed: number
windDirection: string
distance: number
elevation: number
lie: string
}
const StatBar: React.FC<StatBarProps> = ({ compact = false }) => {
const { club, shotMode } = useStore()
const [stats, setStats] = useState<Stats>({
windSpeed: 0,
windDirection: 'N',
distance: 0,
elevation: 0,
lie: 'Fairway',
})
useEffect(() => {
// In a real implementation, these would come from the backend
// For now, using placeholder values
setStats({
windSpeed: Math.floor(Math.random() * 20),
windDirection: ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'][Math.floor(Math.random() * 8)],
distance: 150,
elevation: Math.floor(Math.random() * 20) - 10,
lie: 'Fairway',
})
}, [club])
const getWindArrow = (direction: string) => {
const arrows: { [key: string]: string } = {
'N': '↑',
'NE': '↗',
'E': '→',
'SE': '↘',
'S': '↓',
'SW': '↙',
'W': '←',
'NW': '↖',
}
return arrows[direction] || '↑'
}
const getLieColor = (lie: string) => {
switch (lie.toLowerCase()) {
case 'fairway':
return 'text-green-400'
case 'rough':
return 'text-yellow-400'
case 'sand':
return 'text-orange-400'
case 'water':
return 'text-blue-400'
default:
return 'text-gray-400'
}
}
if (compact) {
// Compact version for mobile
return (
<div className="bg-gray-800 rounded-lg p-2 shadow-lg">
<div className="flex items-center justify-around text-xs">
<div className="flex items-center space-x-1">
<span className="text-gray-500">Wind:</span>
<span className="text-cyan-400 font-medium">{stats.windSpeed}mph</span>
<span className="text-cyan-400">{getWindArrow(stats.windDirection)}</span>
</div>
<div className="flex items-center space-x-1">
<span className="text-gray-500">Dist:</span>
<span className="text-white font-medium">{stats.distance}y</span>
</div>
<div className="flex items-center space-x-1">
<span className="text-gray-500">Elev:</span>
<span className={`font-medium ${stats.elevation > 0 ? 'text-red-400' : stats.elevation < 0 ? 'text-blue-400' : 'text-gray-400'}`}>
{stats.elevation > 0 ? '+' : ''}{stats.elevation}ft
</span>
</div>
<div className="flex items-center space-x-1">
<span className="text-gray-500">Lie:</span>
<span className={`font-medium ${getLieColor(stats.lie)}`}>{stats.lie}</span>
</div>
</div>
</div>
)
}
// Full version for desktop/tablet
return (
<div className="bg-gradient-to-r from-gray-800 to-gray-900 rounded-xl shadow-2xl p-4">
<div className="grid grid-cols-5 gap-4">
{/* Wind */}
<div className="bg-gray-900 bg-opacity-50 rounded-lg p-3 text-center">
<div className="text-xs text-gray-400 uppercase tracking-wider mb-1">Wind</div>
<div className="flex items-center justify-center space-x-2">
<span className="text-2xl text-cyan-400">{getWindArrow(stats.windDirection)}</span>
<div>
<div className="text-lg font-bold text-cyan-400">{stats.windSpeed}</div>
<div className="text-xs text-gray-500">mph</div>
</div>
</div>
</div>
{/* Distance to Pin */}
<div className="bg-gray-900 bg-opacity-50 rounded-lg p-3 text-center">
<div className="text-xs text-gray-400 uppercase tracking-wider mb-1">To Pin</div>
<div className="text-2xl font-bold text-white">{stats.distance}</div>
<div className="text-xs text-gray-500">yards</div>
</div>
{/* Elevation */}
<div className="bg-gray-900 bg-opacity-50 rounded-lg p-3 text-center">
<div className="text-xs text-gray-400 uppercase tracking-wider mb-1">Elevation</div>
<div className={`text-2xl font-bold ${stats.elevation > 0 ? 'text-red-400' : stats.elevation < 0 ? 'text-blue-400' : 'text-gray-400'}`}>
{stats.elevation > 0 ? '+' : ''}{stats.elevation}
</div>
<div className="text-xs text-gray-500">feet</div>
</div>
{/* Lie */}
<div className="bg-gray-900 bg-opacity-50 rounded-lg p-3 text-center">
<div className="text-xs text-gray-400 uppercase tracking-wider mb-1">Lie</div>
<div className={`text-lg font-bold ${getLieColor(stats.lie)}`}>{stats.lie}</div>
<div className="text-xs text-gray-500">{shotMode} shot</div>
</div>
{/* Club */}
<div className="bg-gray-900 bg-opacity-50 rounded-lg p-3 text-center">
<div className="text-xs text-gray-400 uppercase tracking-wider mb-1">Club</div>
<div className="text-lg font-bold text-primary-400">{club.current}</div>
<div className="text-xs text-gray-500">Selected</div>
</div>
</div>
{/* Additional info bar */}
<div className="mt-3 pt-3 border-t border-gray-700">
<div className="flex items-center justify-between text-xs text-gray-500">
<div className="flex items-center space-x-4">
<span>Hole: 1 Par 4</span>
<span>Stroke: 1</span>
</div>
<div className="flex items-center space-x-4">
<span>Round Time: 0:00</span>
<span>Score: E</span>
</div>
</div>
</div>
</div>
)
}
export default StatBar

View file

@ -0,0 +1,110 @@
import React from 'react'
import { actionsAPI } from '../api/client'
import { useStore } from '../stores/appStore'
import toast from 'react-hot-toast'
interface TeeControlsProps {
compact?: boolean
}
const TeeControls: React.FC<TeeControlsProps> = ({ compact = false }) => {
const { settings } = useStore()
const handleTeeMove = async (direction: 'left' | 'right') => {
try {
const key = direction === 'left' ? 'c' : 'v'
await actionsAPI.sendKey(key)
// Haptic feedback
if (settings.hapticFeedback && 'vibrate' in navigator) {
navigator.vibrate(10)
}
toast.success(`Tee moved ${direction}`)
} catch (error) {
toast.error('Failed to move tee')
}
}
if (compact) {
// Compact version for mobile
return (
<div className="bg-gray-800 rounded-lg p-3 shadow-lg">
<div className="text-xs text-gray-400 uppercase tracking-wider mb-2 text-center">Tee Box</div>
<div className="flex space-x-2">
<button
onClick={() => handleTeeMove('left')}
className="flex-1 px-3 py-2 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-lg transition-colors flex items-center justify-center"
aria-label="Move Tee Left"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<span className="text-sm">Left</span>
</button>
<div className="flex items-center justify-center px-3">
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
</div>
<button
onClick={() => handleTeeMove('right')}
className="flex-1 px-3 py-2 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-lg transition-colors flex items-center justify-center"
aria-label="Move Tee Right"
>
<span className="text-sm">Right</span>
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
)
}
// Full version for desktop/tablet
return (
<div className="bg-gray-800 rounded-xl shadow-lg p-4">
<h3 className="text-sm font-medium text-gray-400 uppercase tracking-wider mb-3 text-center">Tee Position</h3>
<div className="flex items-center space-x-3">
<button
onClick={() => handleTeeMove('left')}
className="flex-1 px-4 py-3 bg-gradient-to-r from-gray-700 to-gray-600 hover:from-gray-600 hover:to-gray-500 text-white rounded-lg transition-all transform hover:scale-105 flex items-center justify-center group"
aria-label="Move Tee Left"
>
<svg className="w-5 h-5 mr-2 group-hover:-translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
<span className="font-medium">Move Left</span>
</button>
<div className="relative">
<div className="w-16 h-16 bg-gradient-to-br from-green-500 to-green-600 rounded-full flex items-center justify-center shadow-lg">
<svg className="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM6.293 6.707a1 1 0 010-1.414l3-3a1 1 0 011.414 0l3 3a1 1 0 01-1.414 1.414L11 5.414V13a1 1 0 11-2 0V5.414L7.707 6.707a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
</div>
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-12 h-1 bg-green-700 rounded-full opacity-50"></div>
</div>
<button
onClick={() => handleTeeMove('right')}
className="flex-1 px-4 py-3 bg-gradient-to-r from-gray-700 to-gray-600 hover:from-gray-600 hover:to-gray-500 text-white rounded-lg transition-all transform hover:scale-105 flex items-center justify-center group"
aria-label="Move Tee Right"
>
<span className="font-medium">Move Right</span>
<svg className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
</div>
<div className="mt-3 text-center">
<p className="text-xs text-gray-500">Adjust your starting position on the tee box</p>
</div>
</div>
)
}
export default TeeControls

361
frontend/src/index.css Normal file
View file

@ -0,0 +1,361 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom global styles */
@layer base {
/* Set default font and colors */
html {
@apply font-sans antialiased;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Custom scrollbar styles */
::-webkit-scrollbar {
@apply w-2 h-2;
}
::-webkit-scrollbar-track {
@apply bg-gray-800 rounded-full;
}
::-webkit-scrollbar-thumb {
@apply bg-gray-600 rounded-full hover:bg-gray-500;
}
/* Firefox scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: #4b5563 #1f2937;
}
/* Viewport height fix for mobile */
.h-screen-safe {
height: 100vh;
height: calc(var(--vh, 1vh) * 100);
}
/* Prevent text selection on interactive elements */
button,
.btn,
.interactive {
@apply select-none;
-webkit-tap-highlight-color: transparent;
}
/* Focus styles */
.focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 focus:ring-offset-gray-900;
}
}
@layer components {
/* Button base styles */
.btn {
@apply inline-flex items-center justify-center px-4 py-2 font-medium rounded-lg transition-all duration-200 focus-ring disabled:opacity-50 disabled:cursor-not-allowed;
}
/* Button variants */
.btn-primary {
@apply btn bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800;
}
.btn-secondary {
@apply btn bg-secondary-600 text-white hover:bg-secondary-700 active:bg-secondary-800;
}
.btn-success {
@apply btn bg-success-600 text-white hover:bg-success-700 active:bg-success-800;
}
.btn-danger {
@apply btn bg-error-600 text-white hover:bg-error-700 active:bg-error-800;
}
.btn-ghost {
@apply btn bg-transparent hover:bg-gray-800 active:bg-gray-700;
}
.btn-outline {
@apply btn border-2 border-gray-600 hover:bg-gray-800 active:bg-gray-700;
}
/* Icon button */
.btn-icon {
@apply inline-flex items-center justify-center p-2 rounded-lg transition-all duration-200 focus-ring hover:bg-gray-800 active:bg-gray-700;
}
/* Card component */
.card {
@apply bg-gray-800 rounded-xl border border-gray-700 shadow-lg;
}
.card-body {
@apply p-4;
}
/* Input styles */
.input {
@apply block w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500;
}
/* Label styles */
.label {
@apply block text-sm font-medium text-gray-300 mb-1;
}
/* Directional pad button */
.dpad-btn {
@apply flex items-center justify-center bg-gray-700 hover:bg-gray-600 active:bg-primary-600 transition-all duration-150 select-none cursor-pointer;
}
/* Status indicator */
.status-dot {
@apply inline-block w-2 h-2 rounded-full;
}
.status-dot.online {
@apply bg-success-500 animate-pulse;
}
.status-dot.offline {
@apply bg-gray-500;
}
.status-dot.error {
@apply bg-error-500;
}
/* Golf-specific styles */
.golf-green {
@apply bg-gradient-to-br from-green-700 to-green-800;
}
.golf-fairway {
@apply bg-gradient-to-br from-green-600 to-green-700;
}
.golf-sand {
@apply bg-gradient-to-br from-yellow-600 to-yellow-700;
}
.golf-water {
@apply bg-gradient-to-br from-blue-600 to-blue-700;
}
/* Aim pad grid */
.aim-grid {
@apply grid grid-cols-3 gap-1 w-48 h-48;
}
/* Map panel */
.map-panel {
@apply relative bg-black rounded-lg overflow-hidden;
}
.map-panel.expanded {
@apply fixed inset-4 z-50 md:inset-8 lg:inset-16;
}
/* Stat display */
.stat-item {
@apply flex flex-col items-center p-2 bg-gray-800 rounded-lg;
}
.stat-value {
@apply text-xl font-bold text-primary-400;
}
.stat-label {
@apply text-xs text-gray-400 uppercase tracking-wider;
}
/* Toggle switch */
.toggle {
@apply relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 focus:ring-offset-gray-900;
}
.toggle.checked {
@apply bg-primary-600;
}
.toggle:not(.checked) {
@apply bg-gray-700;
}
.toggle-thumb {
@apply inline-block h-4 w-4 transform rounded-full bg-white transition-transform;
}
.toggle.checked .toggle-thumb {
@apply translate-x-6;
}
.toggle:not(.checked) .toggle-thumb {
@apply translate-x-1;
}
}
@layer utilities {
/* Touch action utilities */
.touch-none {
touch-action: none;
}
.touch-pan-x {
touch-action: pan-x;
}
.touch-pan-y {
touch-action: pan-y;
}
/* Prevent double-tap zoom on mobile */
.no-tap-zoom {
touch-action: manipulation;
}
/* Glass morphism effect */
.glass {
@apply bg-gray-900 bg-opacity-60 backdrop-blur-lg;
}
/* Gradient text */
.gradient-text {
@apply bg-gradient-to-r from-primary-400 to-success-400 bg-clip-text text-transparent;
}
/* Loading animation */
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
.shimmer {
animation: shimmer 2s linear infinite;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.1) 50%,
rgba(255, 255, 255, 0) 100%
);
background-size: 1000px 100%;
}
/* Fade animations */
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
.fade-out {
animation: fadeOut 0.3s ease-in-out;
}
/* Slide animations */
.slide-in-left {
animation: slideInLeft 0.3s ease-out;
}
.slide-in-right {
animation: slideInRight 0.3s ease-out;
}
.slide-in-up {
animation: slideInUp 0.3s ease-out;
}
.slide-in-down {
animation: slideInDown 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes slideInLeft {
from {
transform: translateX(-100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideInUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes slideInDown {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Responsive text utilities */
.text-responsive {
@apply text-sm sm:text-base lg:text-lg;
}
.heading-responsive {
@apply text-2xl sm:text-3xl lg:text-4xl;
}
/* Custom grid utilities */
.grid-center {
@apply grid place-items-center;
}
/* Aspect ratio utilities */
.aspect-map {
aspect-ratio: 4/3;
}
/* Overlay utilities */
.overlay {
@apply fixed inset-0 bg-black bg-opacity-50 z-40;
}
}

23
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,23 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
// Create root element
const rootElement = document.getElementById('root')
if (!rootElement) {
throw new Error('Failed to find the root element')
}
// Create React root
const root = ReactDOM.createRoot(rootElement)
// Render the app
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
)
// Signal that the app is ready (removes loading screen)
window.dispatchEvent(new Event('app-ready'))

View file

@ -0,0 +1,202 @@
import React, { useEffect, useState } from 'react'
import { useStore } from '../stores/appStore'
import AimPad from '../components/AimPad'
import ClubIndicator from '../components/ClubIndicator'
import MapPanel from '../components/MapPanel'
import ShotOptions from '../components/ShotOptions'
import TeeControls from '../components/TeeControls'
import StatBar from '../components/StatBar'
import QuickActions from '../components/QuickActions'
import { actionsAPI, systemAPI } from '../api/client'
import toast from 'react-hot-toast'
const DynamicGolfUI: React.FC = () => {
const {
club,
mapConfig,
shotMode,
mulliganActive,
setGSProStatus,
toggleMulligan,
toggleMapExpanded,
} = useStore()
const [isLandscape, setIsLandscape] = useState(
window.innerWidth > window.innerHeight
)
useEffect(() => {
// Check GSPro status
const checkGSPro = async () => {
try {
const status = await systemAPI.getGSProStatus()
setGSProStatus(status.running ? 'running' : 'not_running')
} catch (error) {
setGSProStatus('unknown')
}
}
checkGSPro()
const interval = setInterval(checkGSPro, 5000)
return () => clearInterval(interval)
}, [setGSProStatus])
useEffect(() => {
// Handle orientation change
const handleResize = () => {
setIsLandscape(window.innerWidth > window.innerHeight)
}
window.addEventListener('resize', handleResize)
window.addEventListener('orientationchange', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
window.removeEventListener('orientationchange', handleResize)
}
}, [])
const handleMulligan = async () => {
try {
await actionsAPI.sendKey('ctrl+m')
toggleMulligan()
toast.success(mulliganActive ? 'Mulligan disabled' : 'Mulligan enabled')
} catch (error) {
toast.error('Failed to toggle mulligan')
}
}
const handleReset = async () => {
try {
await actionsAPI.sendKey('a')
toast.success('Reset')
} catch (error) {
toast.error('Failed to reset')
}
}
// Render different layouts based on orientation
if (isLandscape) {
// Landscape layout (tablets, desktop)
return (
<div className="h-screen-safe bg-gradient-to-br from-gray-900 to-gray-800 overflow-hidden">
<div className="h-full flex flex-col p-4">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-4">
<h1 className="text-2xl font-bold text-white">GSPro Remote</h1>
<span className="text-sm text-gray-400">v0.1.0</span>
</div>
<div className="flex items-center space-x-2">
<button
onClick={handleMulligan}
className={`px-4 py-2 rounded-lg font-medium transition-all ${
mulliganActive
? 'bg-yellow-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
>
Mulligan
</button>
<button
onClick={handleReset}
className="px-4 py-2 bg-gray-700 text-gray-300 rounded-lg hover:bg-gray-600 transition-all"
>
Reset
</button>
</div>
</div>
{/* Main content */}
<div className="flex-1 flex gap-4">
{/* Left panel - Club */}
<div className="w-48">
<ClubIndicator />
</div>
{/* Center - Aim Pad */}
<div className="flex-1 flex flex-col items-center justify-center">
<AimPad />
<div className="mt-6">
<TeeControls />
</div>
</div>
{/* Right panel - Map */}
<div className="w-80">
<MapPanel />
<div className="mt-4">
<ShotOptions />
</div>
</div>
</div>
{/* Bottom bar */}
<div className="mt-4">
<StatBar />
</div>
</div>
{/* Quick actions floating button */}
<QuickActions />
</div>
)
} else {
// Portrait layout (phones)
return (
<div className="h-screen-safe bg-gradient-to-br from-gray-900 to-gray-800 overflow-hidden">
<div className="h-full flex flex-col p-3">
{/* Header - Compact */}
<div className="flex items-center justify-between mb-3">
<h1 className="text-lg font-bold text-white">GSPro Remote</h1>
<button
onClick={handleMulligan}
className={`px-3 py-1 rounded-lg text-sm font-medium transition-all ${
mulliganActive
? 'bg-yellow-600 text-white'
: 'bg-gray-700 text-gray-300'
}`}
>
Mulligan
</button>
</div>
{/* Main content area */}
<div className="flex-1 flex flex-col space-y-3">
{/* Top row - Club and Map */}
<div className="flex gap-3">
<div className="flex-1">
<ClubIndicator compact />
</div>
<div className="flex-1">
<MapPanel compact />
</div>
</div>
{/* Center - Aim Pad */}
<div className="flex-1 flex items-center justify-center">
<AimPad size="medium" />
</div>
{/* Bottom controls */}
<div className="space-y-3">
<TeeControls compact />
<ShotOptions compact />
</div>
</div>
{/* Bottom stats */}
<div className="mt-3">
<StatBar compact />
</div>
</div>
{/* Quick actions for mobile */}
<QuickActions mobile />
</div>
)
}
}
export default DynamicGolfUI

View file

@ -0,0 +1,229 @@
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
export interface ClubInfo {
current: string
index: number
}
export interface MapConfig {
expanded: boolean
zoom: number
x: number
y: number
width: number
height: number
}
export interface AppSettings {
hapticFeedback: boolean
soundEffects: boolean
streamQuality: 'low' | 'medium' | 'high'
streamFPS: number
showStats: boolean
compactMode: boolean
}
export interface AppState {
// Connection status
isConnected: boolean
connectionStatus: 'connected' | 'disconnected' | 'connecting'
gsproStatus: 'running' | 'not_running' | 'unknown'
// UI state
currentView: 'main' | 'settings' | 'advanced'
isLoading: boolean
// Golf state
club: ClubInfo
aimDirection: { x: number; y: number }
mulliganActive: boolean
shotMode: 'normal' | 'punch' | 'flop' | 'chip'
// Map state
mapConfig: MapConfig
isMapStreaming: boolean
// Settings
settings: AppSettings
// Actions
setConnectionStatus: (connected: boolean) => void
setGSProStatus: (status: 'running' | 'not_running' | 'unknown') => void
setCurrentView: (view: 'main' | 'settings' | 'advanced') => void
setLoading: (loading: boolean) => void
// Golf actions
setClub: (club: ClubInfo) => void
nextClub: () => void
previousClub: () => void
setAimDirection: (direction: { x: number; y: number }) => void
toggleMulligan: () => void
setShotMode: (mode: 'normal' | 'punch' | 'flop' | 'chip') => void
// Map actions
toggleMapExpanded: () => void
setMapConfig: (config: Partial<MapConfig>) => void
setMapStreaming: (streaming: boolean) => void
// Settings actions
updateSettings: (settings: Partial<AppSettings>) => void
resetSettings: () => void
}
const CLUBS = [
'Driver',
'3 Wood',
'5 Wood',
'3 Hybrid',
'4 Iron',
'5 Iron',
'6 Iron',
'7 Iron',
'8 Iron',
'9 Iron',
'PW',
'GW',
'SW',
'LW',
'Putter'
]
const DEFAULT_SETTINGS: AppSettings = {
hapticFeedback: true,
soundEffects: false,
streamQuality: 'medium',
streamFPS: 30,
showStats: true,
compactMode: false,
}
const DEFAULT_MAP_CONFIG: MapConfig = {
expanded: false,
zoom: 1,
x: 0,
y: 0,
width: 640,
height: 480,
}
export const useStore = create<AppState>()(
devtools(
persist(
(set, get) => ({
// Initial state
isConnected: false,
connectionStatus: 'disconnected',
gsproStatus: 'unknown',
currentView: 'main',
isLoading: false,
club: {
current: 'Driver',
index: 0,
},
aimDirection: { x: 0, y: 0 },
mulliganActive: false,
shotMode: 'normal',
mapConfig: DEFAULT_MAP_CONFIG,
isMapStreaming: false,
settings: DEFAULT_SETTINGS,
// Connection actions
setConnectionStatus: (connected) =>
set({
isConnected: connected,
connectionStatus: connected ? 'connected' : 'disconnected',
}),
setGSProStatus: (status) => set({ gsproStatus: status }),
// UI actions
setCurrentView: (view) => set({ currentView: view }),
setLoading: (loading) => set({ isLoading: loading }),
// Golf actions
setClub: (club) => set({ club }),
nextClub: () => {
const state = get()
const nextIndex = (state.club.index + 1) % CLUBS.length
set({
club: {
current: CLUBS[nextIndex],
index: nextIndex,
},
})
},
previousClub: () => {
const state = get()
const prevIndex = state.club.index === 0 ? CLUBS.length - 1 : state.club.index - 1
set({
club: {
current: CLUBS[prevIndex],
index: prevIndex,
},
})
},
setAimDirection: (direction) => set({ aimDirection: direction }),
toggleMulligan: () =>
set((state) => ({ mulliganActive: !state.mulliganActive })),
setShotMode: (mode) => set({ shotMode: mode }),
// Map actions
toggleMapExpanded: () =>
set((state) => ({
mapConfig: {
...state.mapConfig,
expanded: !state.mapConfig.expanded,
},
})),
setMapConfig: (config) =>
set((state) => ({
mapConfig: {
...state.mapConfig,
...config,
},
})),
setMapStreaming: (streaming) => set({ isMapStreaming: streaming }),
// Settings actions
updateSettings: (settings) =>
set((state) => ({
settings: {
...state.settings,
...settings,
},
})),
resetSettings: () => set({ settings: DEFAULT_SETTINGS }),
}),
{
name: 'gspro-remote-store',
partialize: (state) => ({
settings: state.settings,
mapConfig: state.mapConfig,
}),
}
)
)
)
// Selectors
export const selectIsConnected = (state: AppState) => state.isConnected
export const selectConnectionStatus = (state: AppState) => state.connectionStatus
export const selectGSProStatus = (state: AppState) => state.gsproStatus
export const selectCurrentView = (state: AppState) => state.currentView
export const selectClub = (state: AppState) => state.club
export const selectMapConfig = (state: AppState) => state.mapConfig
export const selectSettings = (state: AppState) => state.settings
export const selectIsMapExpanded = (state: AppState) => state.mapConfig.expanded
export const selectIsMapStreaming = (state: AppState) => state.isMapStreaming

131
frontend/tailwind.config.js Normal file
View file

@ -0,0 +1,131 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
},
secondary: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
},
success: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
},
warning: {
50: '#fefce8',
100: '#fef9c3',
200: '#fef08a',
300: '#fde047',
400: '#facc15',
500: '#eab308',
600: '#ca8a04',
700: '#a16207',
800: '#854d0e',
900: '#713f12',
},
error: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
},
golf: {
green: '#2D5016',
fairway: '#355E3B',
sand: '#C2B280',
water: '#4682B4',
rough: '#3A5F0B',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
mono: ['JetBrains Mono', 'Consolas', 'Monaco', 'Courier New', 'monospace'],
},
animation: {
'fade-in': 'fadeIn 0.3s ease-in-out',
'fade-out': 'fadeOut 0.3s ease-in-out',
'slide-in': 'slideIn 0.3s ease-out',
'slide-out': 'slideOut 0.3s ease-out',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'bounce-slow': 'bounce 2s infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
fadeOut: {
'0%': { opacity: '1' },
'100%': { opacity: '0' },
},
slideIn: {
'0%': { transform: 'translateX(-100%)' },
'100%': { transform: 'translateX(0)' },
},
slideOut: {
'0%': { transform: 'translateX(0)' },
'100%': { transform: 'translateX(100%)' },
},
},
boxShadow: {
'golf': '0 4px 6px -1px rgba(45, 80, 22, 0.1), 0 2px 4px -1px rgba(45, 80, 22, 0.06)',
'golf-lg': '0 10px 15px -3px rgba(45, 80, 22, 0.1), 0 4px 6px -2px rgba(45, 80, 22, 0.05)',
},
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
'golf-pattern': "url('data:image/svg+xml,%3Csvg width=\"40\" height=\"40\" viewBox=\"0 0 40 40\" xmlns=\"http://www.w3.org/2000/svg\"%3E%3Cg fill=\"%232D5016\" fill-opacity=\"0.03\"%3E%3Cpath d=\"M0 40L40 0H20L0 20M40 40V20L20 40\"%3E%3C/path%3E%3C/g%3E%3C/svg%3E')",
},
spacing: {
'18': '4.5rem',
'88': '22rem',
'120': '30rem',
'128': '32rem',
'144': '36rem',
},
screens: {
'xs': '480px',
'3xl': '1920px',
},
},
},
plugins: [],
}

38
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,38 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@pages/*": ["src/pages/*"],
"@hooks/*": ["src/hooks/*"],
"@utils/*": ["src/utils/*"],
"@api/*": ["src/api/*"],
"@stores/*": ["src/stores/*"],
"@types/*": ["src/types/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

47
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,47 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@components': path.resolve(__dirname, './src/components'),
'@pages': path.resolve(__dirname, './src/pages'),
'@hooks': path.resolve(__dirname, './src/hooks'),
'@utils': path.resolve(__dirname, './src/utils'),
'@api': path.resolve(__dirname, './src/api'),
'@stores': path.resolve(__dirname, './src/stores'),
'@types': path.resolve(__dirname, './src/types'),
},
},
server: {
port: 5173,
host: true,
proxy: {
'/api': {
target: 'http://localhost:5005',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:5005',
ws: true,
},
},
},
build: {
outDir: '../backend/ui',
emptyOutDir: true,
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
'react-vendor': ['react', 'react-dom'],
'ui-vendor': ['react-icons', 'clsx', 'react-hot-toast'],
},
},
},
},
})