feat: make flat-stack-presejpacky standalone with Docker and Makefile
This commit is contained in:
10
drills/flat-stack-presejpacky/.dockerignore
Normal file
10
drills/flat-stack-presejpacky/.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
server.js
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
Makefile
|
||||||
|
Dockerfile
|
||||||
|
README.md
|
||||||
9
drills/flat-stack-presejpacky/.env.example
Normal file
9
drills/flat-stack-presejpacky/.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
||||||
|
# AI Studio automatically injects this at runtime from user secrets.
|
||||||
|
# Users configure this via the Secrets panel in the AI Studio UI.
|
||||||
|
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
||||||
|
|
||||||
|
# APP_URL: The URL where this applet is hosted.
|
||||||
|
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||||
|
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||||
|
APP_URL="MY_APP_URL"
|
||||||
8
drills/flat-stack-presejpacky/.gitignore
vendored
Normal file
8
drills/flat-stack-presejpacky/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
37
drills/flat-stack-presejpacky/Dockerfile
Normal file
37
drills/flat-stack-presejpacky/Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Stage 1: Build stage
|
||||||
|
FROM node:24-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files and install dependencies
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy the rest of the application files
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build frontend and server.ts
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Production stage
|
||||||
|
FROM node:24-alpine AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Set production environment
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
# Copy package files and install only production dependencies
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
# Copy built assets and compiled server from builder stage
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/server.js ./server.js
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Run the server
|
||||||
|
CMD ["node", "server.js"]
|
||||||
46
drills/flat-stack-presejpacky/Makefile
Normal file
46
drills/flat-stack-presejpacky/Makefile
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Variables
|
||||||
|
IMAGE_NAME = flat-stack-presejpacky
|
||||||
|
PORT = 3000
|
||||||
|
|
||||||
|
.PHONY: help install build dev start clean lint docker-build docker-run docker-stop
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "Available commands:"
|
||||||
|
@echo " make install - Install project dependencies"
|
||||||
|
@echo " make dev - Run the application locally in development mode (Vite)"
|
||||||
|
@echo " make build - Build the production assets"
|
||||||
|
@echo " make start - Run the production build locally using Express"
|
||||||
|
@echo " make clean - Remove build artifacts (dist/ and server.js)"
|
||||||
|
@echo " make lint - Run TypeScript type checks"
|
||||||
|
@echo " make docker-build - Build the Docker image"
|
||||||
|
@echo " make docker-run - Run the Docker image locally on port $(PORT)"
|
||||||
|
@echo " make docker-stop - Stop and remove the Docker container"
|
||||||
|
|
||||||
|
install:
|
||||||
|
docker run -it --rm -v $$(pwd):/app -v /app/node_modules -w /app node:24-alpine npm install
|
||||||
|
|
||||||
|
dev:
|
||||||
|
docker run -it --rm -p $(PORT):$(PORT) -v $$(pwd):/app -v /app/node_modules -w /app node:24-alpine sh -c "npm install && npm run dev"
|
||||||
|
|
||||||
|
build:
|
||||||
|
docker run -it --rm -v $$(pwd):/app -v /app/node_modules -w /app node:24-alpine sh -c "npm install && npm run build"
|
||||||
|
|
||||||
|
start:
|
||||||
|
docker run -it --rm -p $(PORT):$(PORT) -v $$(pwd):/app -v /app/node_modules -w /app node:24-alpine sh -c "npm install && npm run start"
|
||||||
|
|
||||||
|
clean:
|
||||||
|
docker run -it --rm -v $$(pwd):/app -w /app node:24-alpine npm run clean
|
||||||
|
|
||||||
|
lint:
|
||||||
|
docker run -it --rm -v $$(pwd):/app -v /app/node_modules -w /app node:24-alpine sh -c "npm install && npm run lint"
|
||||||
|
|
||||||
|
docker-build:
|
||||||
|
docker build -t $(IMAGE_NAME) .
|
||||||
|
|
||||||
|
docker-run:
|
||||||
|
docker run -d -p $(PORT):$(PORT) --name $(IMAGE_NAME) $(IMAGE_NAME)
|
||||||
|
@echo "Container is running at http://localhost:$(PORT)"
|
||||||
|
|
||||||
|
docker-stop:
|
||||||
|
docker stop $(IMAGE_NAME) || true
|
||||||
|
docker rm $(IMAGE_NAME) || true
|
||||||
20
drills/flat-stack-presejpacky/README.md
Normal file
20
drills/flat-stack-presejpacky/README.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<div align="center">
|
||||||
|
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
# Run and deploy your AI Studio app
|
||||||
|
|
||||||
|
This contains everything you need to run your app locally.
|
||||||
|
|
||||||
|
View your app in AI Studio: https://ai.studio/apps/6348f874-622e-4303-a154-d6e93300a7b9
|
||||||
|
|
||||||
|
## Run Locally
|
||||||
|
|
||||||
|
**Prerequisites:** Node.js
|
||||||
|
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
`npm install`
|
||||||
|
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||||
|
3. Run the app:
|
||||||
|
`npm run dev`
|
||||||
13
drills/flat-stack-presejpacky/index.html
Normal file
13
drills/flat-stack-presejpacky/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>My Google AI Studio App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
6
drills/flat-stack-presejpacky/metadata.json
Normal file
6
drills/flat-stack-presejpacky/metadata.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"description": "",
|
||||||
|
"requestFramePermissions": [],
|
||||||
|
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
|
||||||
|
}
|
||||||
4305
drills/flat-stack-presejpacky/package-lock.json
generated
Normal file
4305
drills/flat-stack-presejpacky/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
drills/flat-stack-presejpacky/package.json
Normal file
38
drills/flat-stack-presejpacky/package.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "react-example",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||||
|
"build": "vite build && esbuild server.ts --platform=node --format=esm --outfile=server.js",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"clean": "rm -rf dist server.js",
|
||||||
|
"lint": "tsc --noEmit",
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@google/genai": "^2.4.0",
|
||||||
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"lucide-react": "^0.546.0",
|
||||||
|
"motion": "^12.23.24",
|
||||||
|
"react": "^19.0.1",
|
||||||
|
"react-dom": "^19.0.1",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"vite": "^6.2.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^22.14.0",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"tailwindcss": "^4.1.14",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "~5.8.2",
|
||||||
|
"vite": "^6.2.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
drills/flat-stack-presejpacky/server.ts
Normal file
21
drills/flat-stack-presejpacky/server.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// Serve static files from the 'dist' directory
|
||||||
|
app.use(express.static(path.join(__dirname, 'dist')));
|
||||||
|
|
||||||
|
// Fallback to index.html for Single Page Application routing
|
||||||
|
app.get('*', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server is running on port ${PORT}`);
|
||||||
|
});
|
||||||
225
drills/flat-stack-presejpacky/src/App.tsx
Normal file
225
drills/flat-stack-presejpacky/src/App.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { FieldCanvas } from './components/FieldCanvas';
|
||||||
|
import { DrillTimeline } from './components/DrillTimeline';
|
||||||
|
import { STEPS, getPositionsForStep } from './lib/drill';
|
||||||
|
import { Play, Pause, RotateCcw, LayoutTemplate, Info, X, SkipBack, SkipForward } from 'lucide-react';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [cycle, setCycle] = useState(0);
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [showInfo, setShowInfo] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playing) return;
|
||||||
|
|
||||||
|
const currentDuration = STEPS[step].duration;
|
||||||
|
|
||||||
|
// Automatically transition to the next step
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (step === STEPS.length - 1) {
|
||||||
|
setStep(0);
|
||||||
|
setCycle(c => c + 1);
|
||||||
|
} else {
|
||||||
|
setStep(s => s + 1);
|
||||||
|
}
|
||||||
|
}, currentDuration);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [playing, step, cycle]);
|
||||||
|
|
||||||
|
// Derive all rendered positions mathematically from cycle and step
|
||||||
|
const { pos, discX, discY, markX, markY, markAngle } = getPositionsForStep(cycle, step);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen w-full bg-slate-900 text-slate-100 font-sans overflow-hidden">
|
||||||
|
|
||||||
|
{/* Header Navigation */}
|
||||||
|
<header className="flex items-center justify-between px-6 lg:px-8 py-4 bg-slate-800/50 border-b border-slate-700 shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-indigo-600 rounded-lg flex items-center justify-center shadow-lg shadow-indigo-500/20">
|
||||||
|
<LayoutTemplate className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold tracking-tight text-white flex items-center gap-2">
|
||||||
|
Half-Flat Break-Side Pivot
|
||||||
|
<button
|
||||||
|
onClick={() => setShowInfo(true)}
|
||||||
|
className="ml-2 p-1 text-slate-400 hover:text-indigo-400 hover:bg-slate-800 rounded-full transition-colors"
|
||||||
|
title="Drill Description"
|
||||||
|
>
|
||||||
|
<Info className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</h1>
|
||||||
|
<p className="text-[10px] sm:text-xs text-slate-400 uppercase tracking-widest leading-none mt-1">Tactical Drill Simulator</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Workspace */}
|
||||||
|
<main className="flex flex-1 overflow-hidden relative">
|
||||||
|
{/* Sidebar Controls */}
|
||||||
|
<aside className="w-80 border-r border-slate-700 bg-slate-800/30 p-6 flex-col gap-6 overflow-y-auto shrink-0 hidden lg:flex custom-scrollbar">
|
||||||
|
|
||||||
|
<section className="bg-slate-800/50 border border-slate-700/50 p-4 rounded-xl shadow-inner">
|
||||||
|
<h3 className="text-[10px] font-bold text-indigo-400 uppercase mb-2 tracking-widest">Current Action Focus</h3>
|
||||||
|
<h4 className="text-base font-semibold text-white mb-2">{STEPS[step].name}</h4>
|
||||||
|
<p className="text-sm text-slate-300 leading-relaxed">
|
||||||
|
{STEPS[step].description}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex-1 flex flex-col min-h-0">
|
||||||
|
<h3 className="text-xs font-bold text-slate-500 uppercase mb-4 flex justify-between items-center">
|
||||||
|
Active Rotation
|
||||||
|
<span className="px-2 py-0.5 bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 font-mono text-[10px] rounded">
|
||||||
|
CYCLE {cycle + 1}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<DrillTimeline currentStep={step} cycle={cycle} />
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Tactical Field Visualizer */}
|
||||||
|
<div className="flex-1 relative bg-slate-950 p-4 md:p-12 flex flex-col items-center justify-center overflow-hidden">
|
||||||
|
<div className="w-full flex-1 max-h-[800px] flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-[500px] aspect-[5/7] h-full max-h-full">
|
||||||
|
<FieldCanvas
|
||||||
|
pos={pos}
|
||||||
|
discX={discX}
|
||||||
|
discY={discY}
|
||||||
|
markX={markX}
|
||||||
|
markY={markY}
|
||||||
|
markAngle={markAngle}
|
||||||
|
stepDuration={STEPS[step].duration}
|
||||||
|
step={step}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overlay Legend */}
|
||||||
|
<div className="absolute bottom-6 md:bottom-12 right-6 md:right-12 bg-slate-800/80 backdrop-blur-md border border-slate-700 p-3 sm:p-4 rounded-xl flex gap-4 sm:gap-6 shadow-xl z-20">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 bg-blue-500 rounded-full shadow-[0_0_8px_rgba(59,130,246,0.5)]"></div>
|
||||||
|
<span className="text-[10px] uppercase font-bold text-slate-300 tracking-wider">Handler</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 bg-slate-400 rounded-full border border-slate-300"></div>
|
||||||
|
<span className="text-[10px] uppercase font-bold text-slate-300 tracking-wider">Cutter</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 bg-red-600 rounded-sm rotate-45"></div>
|
||||||
|
<span className="text-[10px] uppercase font-bold text-slate-300 tracking-wider">Defense</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Modal Overlay */}
|
||||||
|
{showInfo && (
|
||||||
|
<div className="absolute inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm">
|
||||||
|
<div className="bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl max-w-2xl w-full flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-slate-800 bg-slate-800/30">
|
||||||
|
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
|
<Info className="w-6 h-6 text-indigo-400" /> Drill Overview
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowInfo(false)}
|
||||||
|
className="p-2 bg-slate-800 hover:bg-slate-700 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-slate-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 overflow-y-auto max-h-[70vh] flex flex-col gap-4 text-slate-300">
|
||||||
|
<p className="text-base leading-relaxed">
|
||||||
|
The <strong className="text-white">Isolation Break Drill</strong> focuses on exploiting the defense using handler movement to open up the break side from a half-flatstack setup.
|
||||||
|
</p>
|
||||||
|
<div className="bg-slate-800/50 p-4 rounded-xl border border-slate-700">
|
||||||
|
<h3 className="font-semibold text-indigo-300 mb-3 uppercase tracking-wider text-xs">Sequence of Motions</h3>
|
||||||
|
<ol className="list-decimal list-outside ml-4 space-y-3 text-sm">
|
||||||
|
<li><strong className="text-slate-100">Initiation:</strong> The breakside handler drives hard to the open side, threatening a give-and-go cut. The mark tracks this movement.</li>
|
||||||
|
<li><strong className="text-slate-100">The Fake:</strong> The central handler (with the disc) fakes a throw to the open side, forcing the mark to bite and shift their weight fully into the open lane.</li>
|
||||||
|
<li><strong className="text-slate-100">The Pivot:</strong> Exploiting the mark's overcommitment, the central handler rapidly pivots back to the breakside, stepping around the off-balance defender.</li>
|
||||||
|
<li><strong className="text-slate-100">The Cut:</strong> Synchronized perfectly with the thrower's pivot, the cutter on the line (breakside) makes a hard cut parallel to the sideline towards the disc.</li>
|
||||||
|
<li><strong className="text-slate-100">The Throw:</strong> The handler delivers a breakside pass into space, allowing the cutter to catch it in stride and continue advancing upfield.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<div className="bg-emerald-900/20 border border-emerald-500/30 p-4 rounded-xl">
|
||||||
|
<p className="text-sm text-emerald-200 leading-relaxed">
|
||||||
|
<span className="font-bold text-emerald-400">Coaching Tip:</span> Ensure the line cutter doesn't start their cut too early. They must wait for the central handler's eyes to snap back to the break side before initiating the horizontal cut so they arrive exactly on time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Playback Control Bar */}
|
||||||
|
<footer className="h-20 lg:h-24 bg-slate-900 border-t border-slate-700 flex items-center px-6 lg:px-12 gap-6 lg:gap-8 shrink-0 z-10">
|
||||||
|
<div className="flex items-center gap-3 md:gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setPlaying(false);
|
||||||
|
if (step > 0) setStep(step - 1);
|
||||||
|
else if (cycle > 0) { setCycle(cycle - 1); setStep(STEPS.length - 1); }
|
||||||
|
}}
|
||||||
|
className="w-10 h-10 flex items-center justify-center bg-slate-800 rounded-full border border-slate-700 hover:text-indigo-400 transition-colors text-slate-400 hover:border-indigo-500/50"
|
||||||
|
title="Previous Step"
|
||||||
|
>
|
||||||
|
<SkipBack className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPlaying(!playing)}
|
||||||
|
className="w-14 h-14 flex items-center justify-center bg-indigo-600 rounded-full shadow-lg shadow-indigo-500/30 hover:scale-105 active:scale-95 transition-all text-white border border-indigo-400/50"
|
||||||
|
title={playing ? "Pause" : "Play"}
|
||||||
|
>
|
||||||
|
{playing ? <Pause className="w-6 h-6 fill-current" /> : <Play className="w-6 h-6 fill-current ml-1" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setPlaying(false);
|
||||||
|
if (step < STEPS.length - 1) setStep(step + 1);
|
||||||
|
else { setCycle(cycle + 1); setStep(0); }
|
||||||
|
}}
|
||||||
|
className="w-10 h-10 flex items-center justify-center bg-slate-800 rounded-full border border-slate-700 hover:text-indigo-400 transition-colors text-slate-400 hover:border-indigo-500/50"
|
||||||
|
title="Next Step"
|
||||||
|
>
|
||||||
|
<SkipForward className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setStep(0); setCycle(0); setPlaying(false); }}
|
||||||
|
className="w-10 h-10 hidden sm:flex items-center justify-center bg-slate-800 rounded-full border border-slate-700 hover:text-indigo-400 transition-colors text-slate-400 hover:border-indigo-500/50 ml-2"
|
||||||
|
title="Restart Drill"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col gap-2 mx-2">
|
||||||
|
<div className="flex justify-between text-[10px] font-mono text-slate-500 uppercase tracking-widest px-1">
|
||||||
|
<span>{String((step * 0.45).toFixed(2)).padStart(5, '0')}</span>
|
||||||
|
<span className="font-semibold text-indigo-400 text-xs hidden sm:inline">{STEPS[step]?.name}</span>
|
||||||
|
<span>{String(((STEPS.length - 1) * 0.45).toFixed(2)).padStart(5, '0')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative h-2 w-full bg-slate-800 rounded-full overflow-hidden border border-slate-700/50">
|
||||||
|
<div
|
||||||
|
className="absolute top-0 left-0 h-full bg-indigo-500 transition-all duration-300 ease-linear shadow-[0_0_10px_rgba(99,102,241,0.8)]"
|
||||||
|
style={{ width: `${Math.max(2, ((step) / Math.max(1, STEPS.length - 1)) * 100)}%` }}
|
||||||
|
></div>
|
||||||
|
<div className="absolute top-0 w-0.5 h-full bg-white shadow-[0_0_5px_white] transition-all duration-300 ease-linear" style={{ left: `calc(${Math.max(2, ((step) / Math.max(1, STEPS.length - 1)) * 100)}% - 2px)` }}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden md:flex items-center gap-6 text-slate-400">
|
||||||
|
<div className="flex flex-col items-end">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-tighter text-slate-500">Speed</span>
|
||||||
|
<span className="text-xs font-mono text-indigo-400 font-semibold">1.0x</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { STEPS } from '../lib/drill';
|
||||||
|
|
||||||
|
export function DrillTimeline({ currentStep }: { currentStep: number, cycle: number }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 overflow-y-auto custom-scrollbar flex-1 pr-1 pb-4">
|
||||||
|
{STEPS.map((s, i) => {
|
||||||
|
const isActive = i === currentStep;
|
||||||
|
const isPassed = i < currentStep;
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex flex-col p-3.5 bg-indigo-500/10 border border-indigo-500/30 rounded-lg shadow-sm gap-2 transition-all">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-indigo-300">{s.name}</span>
|
||||||
|
<span className="text-xs bg-indigo-500 text-white px-2.5 py-0.5 rounded shadow-sm shadow-indigo-500/50 font-medium tracking-wide">Active</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (isPassed) {
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex flex-col p-3.5 bg-slate-700/20 border border-slate-700 rounded-lg opacity-60 gap-1.5 transition-all">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-slate-300">{s.name}</span>
|
||||||
|
<span className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Done</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex flex-col p-3.5 bg-slate-800/30 border border-slate-700/50 rounded-lg opacity-40 gap-1.5 transition-all">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-slate-400">{s.name}</span>
|
||||||
|
<span className="text-[10px] text-slate-600 uppercase tracking-widest font-semibold">Next</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
drills/flat-stack-presejpacky/src/components/FieldCanvas.tsx
Normal file
116
drills/flat-stack-presejpacky/src/components/FieldCanvas.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { PlayerId } from '../types';
|
||||||
|
import { PLAYERS_UI, P_B, P_D } from '../lib/drill';
|
||||||
|
|
||||||
|
interface FieldCanvasProps {
|
||||||
|
pos: Record<PlayerId, { x: number, y: number }>;
|
||||||
|
discX: number;
|
||||||
|
discY: number;
|
||||||
|
markX: number;
|
||||||
|
markY: number;
|
||||||
|
markAngle?: number;
|
||||||
|
stepDuration: number;
|
||||||
|
step: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldCanvas({ pos, discX, discY, markX, markY, markAngle = 0, stepDuration, step }: FieldCanvasProps) {
|
||||||
|
const transition = { duration: stepDuration / 1000, ease: 'easeInOut' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full bg-emerald-900/50 rounded border-2 border-emerald-500/30 overflow-hidden shadow-[inset_0_0_40px_rgba(0,0,0,0.5)]">
|
||||||
|
{/* Field Markings HTML layer */}
|
||||||
|
<div className="absolute inset-0 grid grid-cols-4 divide-x divide-emerald-500/20 pointer-events-none">
|
||||||
|
<div></div><div></div><div></div><div></div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 flex flex-col justify-between py-[12%] px-0 pointer-events-none">
|
||||||
|
<div className="w-full h-[1px] bg-white/20 shadow-[0_0_2px_rgba(255,255,255,0.4)]"></div>
|
||||||
|
<div className="w-full h-[1px] bg-white/20 shadow-[0_0_2px_rgba(255,255,255,0.4)]"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SVG Animation layer */}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 500 700"
|
||||||
|
className="absolute inset-0 w-full h-full"
|
||||||
|
preserveAspectRatio="xMidYMid slice"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<filter id="dropshadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="8" stdDeviation="6" floodOpacity="0.4" floodColor="#020617" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Action Trajectory Paths */}
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
d={`M ${P_B.x} ${P_B.y} Q 250 350 350 300`}
|
||||||
|
stroke="#818cf8"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeDasharray="6 6"
|
||||||
|
fill="none"
|
||||||
|
className="transition-opacity duration-300"
|
||||||
|
opacity={step >= 1 && step <= 6 ? 0.8 : 0}
|
||||||
|
/>
|
||||||
|
{step >= 1 && step <= 6 && (
|
||||||
|
<circle cx="350" cy="300" r="4" fill="#818cf8" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<path
|
||||||
|
d={`M ${P_D.x} ${P_D.y} L 100 250`}
|
||||||
|
stroke="#fbbf24"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeDasharray="6 6"
|
||||||
|
fill="none"
|
||||||
|
className="transition-opacity duration-300"
|
||||||
|
opacity={step >= 3 && step <= 6 ? 0.8 : 0}
|
||||||
|
/>
|
||||||
|
{step >= 3 && step <= 6 && (
|
||||||
|
<circle cx="100" cy="250" r="4" fill="#fbbf24" />
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Players */}
|
||||||
|
{(Object.entries(pos) as [string, {x:number, y:number}][]).map(([idStr, coords]) => {
|
||||||
|
const id = parseInt(idStr) as PlayerId;
|
||||||
|
const ui = PLAYERS_UI[id];
|
||||||
|
|
||||||
|
// Determine styling based on position (handlers typically at bottom half)
|
||||||
|
const isHandler = coords.y > 400;
|
||||||
|
const fill = isHandler ? '#3b82f6' : '#94a3b8'; // Handler: blue, Cutter: slate
|
||||||
|
const labelPrefix = isHandler ? 'H' : 'C';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.g key={id} animate={{ x: coords.x, y: coords.y }} transition={transition} filter="url(#dropshadow)">
|
||||||
|
<circle cx="0" cy="0" r="16" fill={fill} stroke="#ffffff" strokeWidth="2.5" />
|
||||||
|
<text x="0" y="4" textAnchor="middle" fill="#ffffff" fontSize="11" fontWeight="bold" fontFamily="sans-serif">
|
||||||
|
{labelPrefix}{ui.label}
|
||||||
|
</text>
|
||||||
|
</motion.g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Mark (Defender) */}
|
||||||
|
<motion.g animate={{ x: markX, y: markY }} transition={transition} filter="url(#dropshadow)">
|
||||||
|
<motion.g animate={{ rotate: markAngle }} transition={transition}>
|
||||||
|
{/* Arms span */}
|
||||||
|
<rect x="-22" y="-2" width="44" height="4" rx="2" fill="#ef4444" stroke="#7f1d1d" strokeWidth="0.5" />
|
||||||
|
{/* Hands */}
|
||||||
|
<circle cx="-22" cy="0" r="3.5" fill="#fca5a5" />
|
||||||
|
<circle cx="22" cy="0" r="3.5" fill="#fca5a5" />
|
||||||
|
{/* Head */}
|
||||||
|
<circle cx="0" cy="0" r="7" fill="#b91c1c" stroke="#ffffff" strokeWidth="1.5" />
|
||||||
|
</motion.g>
|
||||||
|
<text x="0" y="16" textAnchor="middle" fill="#ef4444" fontSize="10" fontWeight="bold" fontFamily="sans-serif">M</text>
|
||||||
|
</motion.g>
|
||||||
|
|
||||||
|
{/* Disc */}
|
||||||
|
<motion.g animate={{ x: discX, y: discY }} transition={transition}>
|
||||||
|
<g filter="url(#dropshadow)">
|
||||||
|
<circle r="9" fill="#ffffff" stroke="#94a3b8" strokeWidth="1" />
|
||||||
|
<circle r="6" fill="none" stroke="#e2e8f0" strokeWidth="1" />
|
||||||
|
<line x1="-4" y1="0" x2="4" y2="0" stroke="#cbd5e1" strokeWidth="1.5" />
|
||||||
|
</g>
|
||||||
|
</motion.g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
drills/flat-stack-presejpacky/src/index.css
Normal file
1
drills/flat-stack-presejpacky/src/index.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
130
drills/flat-stack-presejpacky/src/lib/drill.ts
Normal file
130
drills/flat-stack-presejpacky/src/lib/drill.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { PlayerId, StepAction, PlayerRoles, Position } from '../types';
|
||||||
|
|
||||||
|
export const STEPS: StepAction[] = [
|
||||||
|
{ name: 'Setup', duration: 800, description: 'H2 (Central) holds disc. H1 (Breakside) prepares. C1 & C2 hold flatstack positions.' },
|
||||||
|
{ name: 'Initiate Cut', duration: 1000, description: 'H1 drives into the open side for a potential give-and-go. M (Mark) tracks the movement.' },
|
||||||
|
{ name: 'Fake Open Side', duration: 600, description: 'H2 fakes the pass to H1. M bites and shifts weight to block the open side throw.' },
|
||||||
|
{ name: 'Pivot & Break Cut', duration: 900, description: 'H2 quickly pivots to the breakside. C2 (Line Cutter) sees the pivot and cuts parallel to the sideline towards the disc.' },
|
||||||
|
{ name: 'Throw Break', duration: 500, description: 'H2 throws the break pass. C2 continues stride along the sideline to meet the disc.' },
|
||||||
|
{ name: 'Caught', duration: 600, description: 'C2 catches the disc in stride, gaining forward momentum.' },
|
||||||
|
{ name: 'Rotate', duration: 1400, description: 'H2 clears to breakside. H1 to C1. C1 to C2. C2 clears to queue. Queue enters as H2.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getCycleRoles = (cycle: number): PlayerRoles => {
|
||||||
|
const arr: PlayerId[] = [1, 2, 3, 4, 5];
|
||||||
|
const shifts = cycle % 5;
|
||||||
|
for (let i = 0; i < shifts; i++) {
|
||||||
|
const last = arr.pop();
|
||||||
|
if (last) arr.unshift(last);
|
||||||
|
}
|
||||||
|
return { A: arr[0], B: arr[1], C: arr[2], D: arr[3], E: arr[4] };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PLAYERS_UI: Record<PlayerId, { color: string; label: string }> = {
|
||||||
|
1: { color: '#3b82f6', label: '1' },
|
||||||
|
2: { color: '#eab308', label: '2' },
|
||||||
|
3: { color: '#a855f7', label: '3' },
|
||||||
|
4: { color: '#ec4899', label: '4' },
|
||||||
|
5: { color: '#f97316', label: '5' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nominal positions for roles
|
||||||
|
export const P_A: Position = { x: 250, y: 450 }; // Central
|
||||||
|
export const P_B: Position = { x: 100, y: 450 }; // Breakside Side
|
||||||
|
export const P_C: Position = { x: 250, y: 200 }; // Cutter Middle
|
||||||
|
export const P_D: Position = { x: 100, y: 200 }; // Cutter Line
|
||||||
|
export const P_E: Position = { x: 250, y: 620 }; // Queue
|
||||||
|
|
||||||
|
export const getPositionsForStep = (cycle: number, step: number) => {
|
||||||
|
const roles = getCycleRoles(cycle);
|
||||||
|
|
||||||
|
const pos = {
|
||||||
|
[roles.A]: { ...P_A },
|
||||||
|
[roles.B]: { ...P_B },
|
||||||
|
[roles.C]: { ...P_C },
|
||||||
|
[roles.D]: { ...P_D },
|
||||||
|
[roles.E]: { ...P_E },
|
||||||
|
} as Record<PlayerId, Position>;
|
||||||
|
|
||||||
|
const discOffset = { x: 10, y: -10 };
|
||||||
|
|
||||||
|
let discHolder: PlayerId | null = roles.A;
|
||||||
|
let discX = P_A.x + discOffset.x;
|
||||||
|
let discY = P_A.y + discOffset.y;
|
||||||
|
|
||||||
|
let markX = P_A.x - 18;
|
||||||
|
let markY = P_A.y - 18;
|
||||||
|
let markAngle = -15; // Blocks space between both cutters
|
||||||
|
|
||||||
|
// 1: Initiate Cut
|
||||||
|
if (step >= 1) {
|
||||||
|
pos[roles.B] = { x: 250, y: 350 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2: Fake Open Side
|
||||||
|
if (step >= 2) {
|
||||||
|
pos[roles.B] = { x: 350, y: 300 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === 2) {
|
||||||
|
discX = pos[roles.A].x + 35;
|
||||||
|
discY = pos[roles.A].y - 35;
|
||||||
|
// Mark bites slightly to challenge give and go cut
|
||||||
|
markX = pos[roles.A].x + 8;
|
||||||
|
markY = pos[roles.A].y - 15;
|
||||||
|
markAngle = 35; // Angles to block open side
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3: Pivot & Break Cut
|
||||||
|
if (step >= 3 && step <= 4) {
|
||||||
|
discX = pos[roles.A].x - 28;
|
||||||
|
discY = pos[roles.A].y;
|
||||||
|
// Mark tries to recover but leaves lane open to the cutter
|
||||||
|
markX = P_A.x - 5;
|
||||||
|
markY = P_A.y - 25;
|
||||||
|
markAngle = -5; // Not fully rotated back to breakside angle
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === 3) {
|
||||||
|
// Cutter starts cut, but doesn't reach the end yet
|
||||||
|
pos[roles.D] = { x: 100, y: 220 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step >= 4) {
|
||||||
|
// Cutter catches the disc further upfield, in stride
|
||||||
|
pos[roles.D] = { x: 100, y: 250 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4: Throw Break
|
||||||
|
if (step === 4) {
|
||||||
|
discHolder = null;
|
||||||
|
discX = pos[roles.D].x;
|
||||||
|
discY = pos[roles.D].y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5: Caught
|
||||||
|
if (step === 5) {
|
||||||
|
discHolder = roles.D;
|
||||||
|
discX = pos[roles.D].x + discOffset.x;
|
||||||
|
discY = pos[roles.D].y + discOffset.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6: Rotate
|
||||||
|
if (step === 6) {
|
||||||
|
pos[roles.A] = { ...P_B };
|
||||||
|
pos[roles.B] = { ...P_C };
|
||||||
|
pos[roles.C] = { ...P_D };
|
||||||
|
pos[roles.D] = { ...P_E };
|
||||||
|
pos[roles.E] = { ...P_A };
|
||||||
|
|
||||||
|
discHolder = roles.E;
|
||||||
|
discX = pos[roles.E].x + discOffset.x;
|
||||||
|
discY = pos[roles.E].y + discOffset.y;
|
||||||
|
|
||||||
|
markX = pos[roles.E].x - 18;
|
||||||
|
markY = pos[roles.E].y - 18;
|
||||||
|
markAngle = -15;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pos, discX, discY, markX, markY, markAngle };
|
||||||
|
};
|
||||||
6
drills/flat-stack-presejpacky/src/lib/utils.ts
Normal file
6
drills/flat-stack-presejpacky/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
10
drills/flat-stack-presejpacky/src/main.tsx
Normal file
10
drills/flat-stack-presejpacky/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import {StrictMode} from 'react';
|
||||||
|
import {createRoot} from 'react-dom/client';
|
||||||
|
import App from './App.tsx';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
20
drills/flat-stack-presejpacky/src/types.ts
Normal file
20
drills/flat-stack-presejpacky/src/types.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export type PlayerId = 1 | 2 | 3 | 4 | 5;
|
||||||
|
|
||||||
|
export type StepAction = {
|
||||||
|
name: string;
|
||||||
|
duration: number; // in ms
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlayerRoles = {
|
||||||
|
A: PlayerId;
|
||||||
|
B: PlayerId;
|
||||||
|
C: PlayerId;
|
||||||
|
D: PlayerId;
|
||||||
|
E: PlayerId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Position = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
26
drills/flat-stack-presejpacky/tsconfig.json
Normal file
26
drills/flat-stack-presejpacky/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"allowJs": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
22
drills/flat-stack-presejpacky/vite.config.ts
Normal file
22
drills/flat-stack-presejpacky/vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
import {defineConfig} from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig(() => {
|
||||||
|
return {
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, '.'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||||
|
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
||||||
|
hmr: process.env.DISABLE_HMR !== 'true',
|
||||||
|
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
|
||||||
|
watch: process.env.DISABLE_HMR === 'true' ? null : {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user