feat: Implement recipe selection with backend integration
Backend changes (C#): - Add SelectRecipe method to MachineBridge for recipe selection - Add currentRecipeId tracking in MainForm - Implement SELECT_RECIPE handler in WebSocketServer Frontend changes (React/TypeScript): - Add selectRecipe method to communication layer - Update handleSelectRecipe to call backend and handle response - Recipe selection updates ModelInfoPanel automatically - Add error handling and logging for recipe operations Layout improvements: - Add Layout component with persistent Header and Footer - Create separate IOMonitorPage for full-screen I/O monitoring - Add dynamic IO tab switcher in Header (Inputs/Outputs) - Ensure consistent UI across all pages 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -41,9 +41,25 @@ namespace HMIWeb
|
|||||||
_host.HandleCommand(command);
|
_host.HandleCommand(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void LoadRecipe(string recipeId)
|
public string SelectRecipe(string recipeId)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[C#] Loading Recipe: {recipeId}");
|
Console.WriteLine($"[C#] Selecting Recipe: {recipeId}");
|
||||||
|
|
||||||
|
// In real app, load recipe settings here
|
||||||
|
// For now, just return success
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Simulate recipe loading logic
|
||||||
|
_host.SetCurrentRecipe(recipeId);
|
||||||
|
|
||||||
|
var response = new { success = true, message = "Recipe selected successfully", recipeId = recipeId };
|
||||||
|
return JsonConvert.SerializeObject(response);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var response = new { success = false, message = ex.Message };
|
||||||
|
return JsonConvert.SerializeObject(response);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetConfig()
|
public string GetConfig()
|
||||||
@@ -93,5 +109,49 @@ namespace HMIWeb
|
|||||||
Console.WriteLine($"[Backend] Data: {configJson}");
|
Console.WriteLine($"[Backend] Data: {configJson}");
|
||||||
// In a real app, we would save this to a file or database
|
// In a real app, we would save this to a file or database
|
||||||
}
|
}
|
||||||
|
public string GetIOList()
|
||||||
|
{
|
||||||
|
var ioList = new System.Collections.Generic.List<object>();
|
||||||
|
|
||||||
|
// Outputs (0-31)
|
||||||
|
for (int i = 0; i < 32; i++)
|
||||||
|
{
|
||||||
|
string name = $"DOUT_{i:D2}";
|
||||||
|
if (i == 0) name = "Tower Lamp Red";
|
||||||
|
if (i == 1) name = "Tower Lamp Yel";
|
||||||
|
if (i == 2) name = "Tower Lamp Grn";
|
||||||
|
|
||||||
|
ioList.Add(new { id = i, name = name, type = "output", state = false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inputs (0-31)
|
||||||
|
for (int i = 0; i < 32; i++)
|
||||||
|
{
|
||||||
|
string name = $"DIN_{i:D2}";
|
||||||
|
bool initialState = false;
|
||||||
|
if (i == 0) name = "Front Door Sensor";
|
||||||
|
if (i == 1) name = "Right Door Sensor";
|
||||||
|
if (i == 2) name = "Left Door Sensor";
|
||||||
|
if (i == 3) name = "Back Door Sensor";
|
||||||
|
if (i == 4) { name = "Main Air Pressure"; initialState = true; }
|
||||||
|
if (i == 5) { name = "Vacuum Generator"; initialState = true; }
|
||||||
|
if (i == 6) { name = "Emergency Stop Loop"; initialState = true; }
|
||||||
|
|
||||||
|
ioList.Add(new { id = i, name = name, type = "input", state = initialState });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Newtonsoft.Json.JsonConvert.SerializeObject(ioList);
|
||||||
|
}
|
||||||
|
public string GetRecipeList()
|
||||||
|
{
|
||||||
|
var recipes = new System.Collections.Generic.List<object>();
|
||||||
|
recipes.Add(new { id = "1", name = "Wafer_Proc_300_Au", lastModified = "2023-10-25" });
|
||||||
|
recipes.Add(new { id = "2", name = "Wafer_Insp_200_Adv", lastModified = "2023-10-26" });
|
||||||
|
recipes.Add(new { id = "3", name = "Glass_Gen5_Bonding", lastModified = "2023-10-27" });
|
||||||
|
recipes.Add(new { id = "4", name = "Solar_Cell_Cut_A", lastModified = "2023-11-01" });
|
||||||
|
recipes.Add(new { id = "5", name = "LED_Mount_HighSpeed", lastModified = "2023-11-15" });
|
||||||
|
|
||||||
|
return Newtonsoft.Json.JsonConvert.SerializeObject(recipes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ namespace HMIWeb
|
|||||||
private bool[] inputs = new bool[32];
|
private bool[] inputs = new bool[32];
|
||||||
private bool[] outputs = new bool[32];
|
private bool[] outputs = new bool[32];
|
||||||
private string systemState = "IDLE";
|
private string systemState = "IDLE";
|
||||||
|
private string currentRecipeId = "1"; // Default recipe
|
||||||
public MainForm()
|
public MainForm()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
@@ -77,7 +78,7 @@ namespace HMIWeb
|
|||||||
webView.CoreWebView2.AddHostObjectToScript("machine", new MachineBridge(this));
|
webView.CoreWebView2.AddHostObjectToScript("machine", new MachineBridge(this));
|
||||||
|
|
||||||
// 3. Load UI
|
// 3. Load UI
|
||||||
//webView.Source = new Uri("http://hmi.local/index.html");
|
webView.Source = new Uri("http://hmi.local/index.html");
|
||||||
|
|
||||||
// Disable default browser keys (F5, F12 etc) if needed for kiosk mode
|
// Disable default browser keys (F5, F12 etc) if needed for kiosk mode
|
||||||
webView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false;
|
webView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false;
|
||||||
@@ -153,6 +154,18 @@ namespace HMIWeb
|
|||||||
systemState = (cmd == "START") ? "RUNNING" : (cmd == "STOP") ? "PAUSED" : "IDLE";
|
systemState = (cmd == "START") ? "RUNNING" : (cmd == "STOP") ? "PAUSED" : "IDLE";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetCurrentRecipe(string recipeId)
|
||||||
|
{
|
||||||
|
currentRecipeId = recipeId;
|
||||||
|
Console.WriteLine($"[MainForm] Current recipe set to: {recipeId}");
|
||||||
|
// In real app, load recipe parameters here
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetCurrentRecipe()
|
||||||
|
{
|
||||||
|
return currentRecipeId;
|
||||||
|
}
|
||||||
|
|
||||||
private double Lerp(double a, double b, double t) => a + (b - a) * t;
|
private double Lerp(double a, double b, double t) => a + (b - a) * t;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ namespace HMIWeb
|
|||||||
dynamic json = Newtonsoft.Json.JsonConvert.DeserializeObject(msg);
|
dynamic json = Newtonsoft.Json.JsonConvert.DeserializeObject(msg);
|
||||||
string type = json.type;
|
string type = json.type;
|
||||||
|
|
||||||
|
Console.WriteLine( $"HandleMessage:{type}" );
|
||||||
if (type == "GET_CONFIG")
|
if (type == "GET_CONFIG")
|
||||||
{
|
{
|
||||||
// Simulate Delay for Loading Screen Test
|
// Simulate Delay for Loading Screen Test
|
||||||
@@ -131,6 +132,20 @@ namespace HMIWeb
|
|||||||
var response = new { type = "CONFIG_DATA", data = Newtonsoft.Json.JsonConvert.DeserializeObject(configJson) };
|
var response = new { type = "CONFIG_DATA", data = Newtonsoft.Json.JsonConvert.DeserializeObject(configJson) };
|
||||||
await Send(socket, Newtonsoft.Json.JsonConvert.SerializeObject(response));
|
await Send(socket, Newtonsoft.Json.JsonConvert.SerializeObject(response));
|
||||||
}
|
}
|
||||||
|
else if (type == "GET_IO_LIST")
|
||||||
|
{
|
||||||
|
var bridge = new MachineBridge(_mainForm);
|
||||||
|
string ioJson = bridge.GetIOList();
|
||||||
|
var response = new { type = "IO_LIST_DATA", data = Newtonsoft.Json.JsonConvert.DeserializeObject(ioJson) };
|
||||||
|
await Send(socket, Newtonsoft.Json.JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
else if (type == "GET_RECIPE_LIST")
|
||||||
|
{
|
||||||
|
var bridge = new MachineBridge(_mainForm);
|
||||||
|
string recipeJson = bridge.GetRecipeList();
|
||||||
|
var response = new { type = "RECIPE_LIST_DATA", data = Newtonsoft.Json.JsonConvert.DeserializeObject(recipeJson) };
|
||||||
|
await Send(socket, Newtonsoft.Json.JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
else if (type == "SAVE_CONFIG")
|
else if (type == "SAVE_CONFIG")
|
||||||
{
|
{
|
||||||
string configJson = Newtonsoft.Json.JsonConvert.SerializeObject(json.data);
|
string configJson = Newtonsoft.Json.JsonConvert.SerializeObject(json.data);
|
||||||
@@ -154,6 +169,14 @@ namespace HMIWeb
|
|||||||
bool state = json.state;
|
bool state = json.state;
|
||||||
_mainForm.Invoke(new Action(() => _mainForm.SetOutput(id, state)));
|
_mainForm.Invoke(new Action(() => _mainForm.SetOutput(id, state)));
|
||||||
}
|
}
|
||||||
|
else if (type == "SELECT_RECIPE")
|
||||||
|
{
|
||||||
|
string recipeId = json.recipeId;
|
||||||
|
var bridge = new MachineBridge(_mainForm);
|
||||||
|
string resultJson = bridge.SelectRecipe(recipeId);
|
||||||
|
var response = new { type = "RECIPE_SELECTED", data = Newtonsoft.Json.JsonConvert.DeserializeObject(resultJson) };
|
||||||
|
await Send(socket, Newtonsoft.Json.JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
343
frontend/App.tsx
343
frontend/App.tsx
@@ -1,26 +1,13 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import {
|
import { HashRouter, Routes, Route } from 'react-router-dom';
|
||||||
Activity, Settings, Move, Camera, Play, Square, RotateCw,
|
import { Layout } from './components/layout/Layout';
|
||||||
Cpu, AlertTriangle, Siren, Terminal, Layers
|
import { HomePage } from './pages/HomePage';
|
||||||
} from 'lucide-react';
|
import { IOMonitorPage } from './pages/IOMonitorPage';
|
||||||
import { Machine3D } from './components/Machine3D';
|
|
||||||
import { SettingsModal } from './components/SettingsModal';
|
|
||||||
import { RecipePanel } from './components/RecipePanel';
|
|
||||||
import { IOPanel } from './components/IOPanel';
|
|
||||||
import { MotionPanel } from './components/MotionPanel';
|
|
||||||
import { CameraPanel } from './components/CameraPanel';
|
|
||||||
import { CyberPanel } from './components/common/CyberPanel';
|
|
||||||
import { TechButton } from './components/common/TechButton';
|
|
||||||
import { SystemState, Recipe, IOPoint, LogEntry, RobotTarget, ConfigItem } from './types';
|
import { SystemState, Recipe, IOPoint, LogEntry, RobotTarget, ConfigItem } from './types';
|
||||||
import { comms } from './communication';
|
import { comms } from './communication';
|
||||||
|
|
||||||
// --- MOCK DATA ---
|
// --- MOCK DATA ---
|
||||||
const MOCK_RECIPES: Recipe[] = [
|
|
||||||
{ id: '1', name: 'Wafer_Proc_300_Au', lastModified: '2023-10-25' },
|
|
||||||
{ id: '2', name: 'Wafer_Insp_200_Adv', lastModified: '2023-10-26' },
|
|
||||||
{ id: '3', name: 'Glass_Gen5_Bonding', lastModified: '2023-10-27' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const INITIAL_IO: IOPoint[] = [
|
const INITIAL_IO: IOPoint[] = [
|
||||||
...Array.from({ length: 32 }, (_, i) => {
|
...Array.from({ length: 32 }, (_, i) => {
|
||||||
@@ -47,34 +34,35 @@ const INITIAL_IO: IOPoint[] = [
|
|||||||
// --- MAIN APP ---
|
// --- MAIN APP ---
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [activeTab, setActiveTab] = useState<'recipe' | 'io' | 'motion' | 'camera' | 'setting' | null>(null);
|
const [activeTab, setActiveTab] = useState<'recipe' | 'motion' | 'camera' | 'setting' | null>(null);
|
||||||
|
const [activeIOTab, setActiveIOTab] = useState<'in' | 'out'>('in');
|
||||||
const [systemState, setSystemState] = useState<SystemState>(SystemState.IDLE);
|
const [systemState, setSystemState] = useState<SystemState>(SystemState.IDLE);
|
||||||
const [currentRecipe, setCurrentRecipe] = useState<Recipe>(MOCK_RECIPES[0]);
|
const [recipes, setRecipes] = useState<Recipe[]>([]);
|
||||||
|
const [currentRecipe, setCurrentRecipe] = useState<Recipe | null>(null);
|
||||||
const [robotTarget, setRobotTarget] = useState<RobotTarget>({ x: 0, y: 0, z: 0 });
|
const [robotTarget, setRobotTarget] = useState<RobotTarget>({ x: 0, y: 0, z: 0 });
|
||||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
const [ioPoints, setIoPoints] = useState<IOPoint[]>(INITIAL_IO);
|
const [ioPoints, setIoPoints] = useState<IOPoint[]>([]);
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
const [config, setConfig] = useState<ConfigItem[] | null>(null);
|
const [config, setConfig] = useState<ConfigItem[] | null>(null);
|
||||||
const [isConfigRefreshing, setIsConfigRefreshing] = useState(false);
|
const [isConfigRefreshing, setIsConfigRefreshing] = useState(false);
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const videoRef = useRef<any>(null);
|
const [isHostConnected, setIsHostConnected] = useState(false);
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
// Check if running in WebView2 context
|
|
||||||
const isWebView = typeof window !== 'undefined' && !!window.chrome?.webview;
|
|
||||||
|
|
||||||
// -- COMMUNICATION LAYER --
|
// -- COMMUNICATION LAYER --
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Subscribe to unified communication layer
|
|
||||||
const unsubscribe = comms.subscribe((msg: any) => {
|
const unsubscribe = comms.subscribe((msg: any) => {
|
||||||
if (!msg) return;
|
if (!msg) return;
|
||||||
|
|
||||||
|
if (msg.type === 'CONNECTION_STATE') {
|
||||||
|
setIsHostConnected(msg.connected);
|
||||||
|
addLog(msg.connected ? "HOST CONNECTED" : "HOST DISCONNECTED", msg.connected ? "info" : "warning");
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.type === 'STATUS_UPDATE') {
|
if (msg.type === 'STATUS_UPDATE') {
|
||||||
// Update Motion State
|
|
||||||
if (msg.position) {
|
if (msg.position) {
|
||||||
setRobotTarget({ x: msg.position.x, y: msg.position.y, z: msg.position.z });
|
setRobotTarget({ x: msg.position.x, y: msg.position.y, z: msg.position.z });
|
||||||
}
|
}
|
||||||
// Update IO State (Merge with existing names/configs)
|
|
||||||
if (msg.ioState) {
|
if (msg.ioState) {
|
||||||
setIoPoints(prev => {
|
setIoPoints(prev => {
|
||||||
const newIO = [...prev];
|
const newIO = [...prev];
|
||||||
@@ -85,7 +73,6 @@ export default function App() {
|
|||||||
return newIO;
|
return newIO;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Update System State
|
|
||||||
if (msg.sysState) {
|
if (msg.sysState) {
|
||||||
setSystemState(msg.sysState as SystemState);
|
setSystemState(msg.sysState as SystemState);
|
||||||
}
|
}
|
||||||
@@ -93,8 +80,8 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
addLog("COMMUNICATION CHANNEL OPEN", "info");
|
addLog("COMMUNICATION CHANNEL OPEN", "info");
|
||||||
|
setIsHostConnected(comms.getConnectionState());
|
||||||
|
|
||||||
// Timer for Clock
|
|
||||||
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
|
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
@@ -105,17 +92,29 @@ export default function App() {
|
|||||||
// -- INITIALIZATION --
|
// -- INITIALIZATION --
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initSystem = async () => {
|
const initSystem = async () => {
|
||||||
// Just start up without fetching config
|
|
||||||
addLog("SYSTEM STARTED", "info");
|
addLog("SYSTEM STARTED", "info");
|
||||||
|
try {
|
||||||
|
const ioStr = await comms.getIOList();
|
||||||
|
const ioData = JSON.parse(ioStr);
|
||||||
|
setIoPoints(ioData);
|
||||||
|
addLog("IO LIST LOADED", "info");
|
||||||
|
|
||||||
|
const recipeStr = await comms.getRecipeList();
|
||||||
|
const recipeData = JSON.parse(recipeStr);
|
||||||
|
setRecipes(recipeData);
|
||||||
|
if (recipeData.length > 0) setCurrentRecipe(recipeData[0]);
|
||||||
|
addLog("RECIPE LIST LOADED", "info");
|
||||||
|
} catch (e) {
|
||||||
|
addLog("FAILED TO LOAD SYSTEM DATA", "error");
|
||||||
|
// Fallback to empty or keep initial if needed
|
||||||
|
}
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
initSystem();
|
initSystem();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// -- ON-DEMAND CONFIG FETCH --
|
// -- CONFIG FETCHING (for settings modal) --
|
||||||
useEffect(() => {
|
const fetchConfig = React.useCallback(async () => {
|
||||||
if (activeTab === 'setting') {
|
|
||||||
const fetchConfig = async () => {
|
|
||||||
setIsConfigRefreshing(true);
|
setIsConfigRefreshing(true);
|
||||||
try {
|
try {
|
||||||
const configStr = await comms.getConfig();
|
const configStr = await comms.getConfig();
|
||||||
@@ -125,10 +124,7 @@ export default function App() {
|
|||||||
addLog("CONFIG REFRESH FAILED", "error");
|
addLog("CONFIG REFRESH FAILED", "error");
|
||||||
}
|
}
|
||||||
setIsConfigRefreshing(false);
|
setIsConfigRefreshing(false);
|
||||||
};
|
}, []);
|
||||||
fetchConfig();
|
|
||||||
}
|
|
||||||
}, [activeTab]);
|
|
||||||
|
|
||||||
const addLog = (msg: string, type: 'info' | 'warning' | 'error' = 'info') => {
|
const addLog = (msg: string, type: 'info' | 'warning' | 'error' = 'info') => {
|
||||||
setLogs(prev => [{ id: Date.now() + Math.random(), timestamp: new Date().toLocaleTimeString(), message: msg, type }, ...prev].slice(0, 50));
|
setLogs(prev => [{ id: Date.now() + Math.random(), timestamp: new Date().toLocaleTimeString(), message: msg, type }, ...prev].slice(0, 50));
|
||||||
@@ -146,8 +142,6 @@ export default function App() {
|
|||||||
|
|
||||||
// -- COMMAND HANDLERS --
|
// -- COMMAND HANDLERS --
|
||||||
|
|
||||||
// -- COMMAND HANDLERS --
|
|
||||||
|
|
||||||
const handleControl = async (action: 'start' | 'stop' | 'reset') => {
|
const handleControl = async (action: 'start' | 'stop' | 'reset') => {
|
||||||
if (isEmergencyStop && action === 'start') return addLog('EMERGENCY STOP ACTIVE', 'error');
|
if (isEmergencyStop && action === 'start') return addLog('EMERGENCY STOP ACTIVE', 'error');
|
||||||
|
|
||||||
@@ -160,7 +154,6 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleIO = async (id: number, type: 'input' | 'output', forceState?: boolean) => {
|
const toggleIO = async (id: number, type: 'input' | 'output', forceState?: boolean) => {
|
||||||
// Only allow output toggling
|
|
||||||
if (type === 'output') {
|
if (type === 'output') {
|
||||||
const current = ioPoints.find(p => p.id === id && p.type === type)?.state;
|
const current = ioPoints.find(p => p.id === id && p.type === type)?.state;
|
||||||
const nextState = forceState !== undefined ? forceState : !current;
|
const nextState = forceState !== undefined ? forceState : !current;
|
||||||
@@ -174,215 +167,87 @@ export default function App() {
|
|||||||
addLog(`CMD MOVE ${axis}: ${value}`, 'info');
|
addLog(`CMD MOVE ${axis}: ${value}`, 'info');
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab === 'camera' && navigator.mediaDevices?.getUserMedia) {
|
|
||||||
navigator.mediaDevices.getUserMedia({ video: true }).then(s => { if (videoRef.current) videoRef.current.srcObject = s });
|
|
||||||
}
|
|
||||||
}, [activeTab]);
|
|
||||||
|
|
||||||
const handleSaveConfig = async (newConfig: ConfigItem[]) => {
|
const handleSaveConfig = async (newConfig: ConfigItem[]) => {
|
||||||
try {
|
try {
|
||||||
await comms.saveConfig(JSON.stringify(newConfig));
|
await comms.saveConfig(JSON.stringify(newConfig));
|
||||||
setConfig(newConfig);
|
setConfig(newConfig);
|
||||||
addLog("CONFIGURATION SAVED", "success");
|
addLog("CONFIGURATION SAVED", "info");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
addLog("FAILED TO SAVE CONFIG", "error");
|
addLog("FAILED TO SAVE CONFIG", "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSelectRecipe = async (r: Recipe) => {
|
||||||
|
try {
|
||||||
|
addLog(`LOADING: ${r.name}`, 'info');
|
||||||
|
const result = await comms.selectRecipe(r.id);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setCurrentRecipe(r);
|
||||||
|
addLog(`RECIPE LOADED: ${r.name}`, 'info');
|
||||||
|
} else {
|
||||||
|
addLog(`RECIPE LOAD FAILED: ${result.message}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
addLog(`RECIPE LOAD ERROR: ${error.message || 'Unknown error'}`, 'error');
|
||||||
|
console.error('Recipe selection error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-screen h-screen bg-slate-950 text-slate-100 overflow-hidden font-sans">
|
<HashRouter>
|
||||||
|
<Layout
|
||||||
{/* Animated Nebula Background */}
|
currentTime={currentTime}
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-slate-950 via-[#050a15] to-[#0a0f20] animate-gradient bg-[length:400%_400%] z-0"></div>
|
isHostConnected={isHostConnected}
|
||||||
<div className="absolute inset-0 grid-bg opacity-30 z-0"></div>
|
robotTarget={robotTarget}
|
||||||
<div className="absolute inset-0 scanlines z-50 pointer-events-none"></div>
|
onTabChange={(tab) => {
|
||||||
|
setActiveTab(tab);
|
||||||
{/* LOADING OVERLAY */}
|
if (tab === null) setActiveIOTab('in'); // Reset IO tab when closing
|
||||||
{isLoading && (
|
}}
|
||||||
<div className="absolute inset-0 z-[100] bg-black flex flex-col items-center justify-center gap-6">
|
activeTab={activeTab}
|
||||||
<div className="relative">
|
isLoading={isLoading}
|
||||||
<div className="w-24 h-24 border-4 border-neon-blue/30 rounded-full animate-spin"></div>
|
|
||||||
<div className="absolute inset-0 border-t-4 border-neon-blue rounded-full animate-spin"></div>
|
|
||||||
<Cpu className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-neon-blue w-10 h-10 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<h2 className="text-2xl font-tech font-bold text-white tracking-widest mb-2">SYSTEM INITIALIZING</h2>
|
|
||||||
<p className="font-mono text-neon-blue text-sm animate-pulse">ESTABLISHING CONNECTION...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* HEADER */}
|
|
||||||
<header className="absolute top-0 left-0 right-0 h-20 px-6 flex items-center justify-between z-40 bg-gradient-to-b from-black/80 to-transparent pointer-events-none">
|
|
||||||
<div className="flex items-center gap-4 pointer-events-auto">
|
|
||||||
<div className="w-12 h-12 border-2 border-neon-blue flex items-center justify-center rounded shadow-glow-blue bg-black/50 backdrop-blur">
|
|
||||||
<Cpu className="text-neon-blue w-8 h-8 animate-pulse-slow" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-tech font-bold text-white tracking-widest uppercase italic text-shadow-glow-blue">
|
|
||||||
EQUI-HANDLER <span className="text-neon-blue text-sm not-italic">PRO</span>
|
|
||||||
</h1>
|
|
||||||
<div className="flex gap-2 text-[10px] text-neon-blue/70 font-mono">
|
|
||||||
<span>SYS.VER 4.2.0</span>
|
|
||||||
<span>|</span>
|
|
||||||
<span className={isWebView ? "text-neon-green" : "text-amber-500"}>
|
|
||||||
LINK: {isWebView ? "NATIVE" : "SIMULATION"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Top Navigation */}
|
|
||||||
<div className="flex items-center gap-2 pointer-events-auto bg-black/40 backdrop-blur-md p-1 rounded-full border border-white/10">
|
|
||||||
{[
|
|
||||||
{ id: 'recipe', icon: Layers, label: 'RECIPE' },
|
|
||||||
{ id: 'io', icon: Activity, label: 'I/O MONITOR' },
|
|
||||||
{ id: 'motion', icon: Move, label: 'MOTION' },
|
|
||||||
{ id: 'camera', icon: Camera, label: 'VISION' },
|
|
||||||
{ id: 'setting', icon: Settings, label: 'CONFIG' }
|
|
||||||
].map(item => (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
onClick={() => setActiveTab(activeTab === item.id ? null : item.id as any)}
|
|
||||||
className={`
|
|
||||||
flex items-center gap-2 px-6 py-2 rounded-full font-tech font-bold text-sm transition-all border border-transparent
|
|
||||||
${activeTab === item.id
|
|
||||||
? 'bg-neon-blue/10 text-neon-blue border-neon-blue shadow-glow-blue'
|
|
||||||
: 'text-slate-400 hover:text-white hover:bg-white/5'}
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
<item.icon className="w-4 h-4" /> {item.label}
|
<Routes>
|
||||||
</button>
|
<Route
|
||||||
))}
|
path="/"
|
||||||
</div>
|
element={
|
||||||
|
<HomePage
|
||||||
<div className="text-right pointer-events-auto">
|
systemState={systemState}
|
||||||
<div className="text-2xl font-mono font-bold text-white text-shadow-glow-blue">
|
currentRecipe={currentRecipe || { id: '0', name: 'No Recipe', lastModified: '-' }}
|
||||||
{currentTime.toLocaleTimeString('en-GB')}
|
recipes={recipes}
|
||||||
</div>
|
robotTarget={robotTarget}
|
||||||
<div className="text-xs font-tech text-slate-400 tracking-[0.3em]">
|
logs={logs}
|
||||||
{currentTime.toLocaleDateString().toUpperCase()}
|
ioPoints={ioPoints}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* MAIN CONTENT */}
|
|
||||||
<main className="absolute inset-0 pt-24 pb-12 px-6 flex gap-6 z-10">
|
|
||||||
|
|
||||||
{/* 3D Canvas (Background Layer) */}
|
|
||||||
<div className="absolute inset-0 z-0">
|
|
||||||
<Machine3D target={robotTarget} ioState={ioPoints} doorStates={doorStates} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Center Alarms */}
|
|
||||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 pointer-events-none flex flex-col items-center gap-4">
|
|
||||||
{isEmergencyStop && (
|
|
||||||
<div className="bg-red-600/90 text-white p-8 border-4 border-red-500 shadow-glow-red flex items-center gap-6 animate-pulse">
|
|
||||||
<Siren className="w-16 h-16 animate-spin" />
|
|
||||||
<div>
|
|
||||||
<h1 className="text-5xl font-tech font-bold tracking-widest">EMERGENCY STOP</h1>
|
|
||||||
<p className="text-center font-mono text-lg">SYSTEM HALTED - RELEASE TO RESET</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isLowPressure && !isEmergencyStop && (
|
|
||||||
<div className="bg-amber-500/80 text-black px-8 py-4 rounded font-bold text-2xl tracking-widest flex items-center gap-4 shadow-glow-red animate-bounce">
|
|
||||||
<AlertTriangle className="w-8 h-8" /> LOW AIR PRESSURE WARNING
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Floating Panel (Left) */}
|
|
||||||
{activeTab && activeTab !== 'setting' && (
|
|
||||||
<div className="w-[450px] z-20 animate-in slide-in-from-left-20 duration-500 fade-in">
|
|
||||||
<CyberPanel className="h-full flex flex-col">
|
|
||||||
{activeTab === 'recipe' && <RecipePanel recipes={MOCK_RECIPES} currentRecipe={currentRecipe} onSelectRecipe={(r) => { setCurrentRecipe(r); addLog(`LOADED: ${r.name}`) }} />}
|
|
||||||
{activeTab === 'io' && <IOPanel ioPoints={ioPoints} onToggle={toggleIO} />}
|
|
||||||
{activeTab === 'motion' && <MotionPanel robotTarget={robotTarget} onMove={moveAxis} />}
|
|
||||||
{activeTab === 'camera' && <CameraPanel videoRef={videoRef} />}
|
|
||||||
</CyberPanel>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Settings Modal */}
|
|
||||||
<SettingsModal
|
|
||||||
isOpen={activeTab === 'setting'}
|
|
||||||
onClose={() => setActiveTab(null)}
|
|
||||||
config={config}
|
config={config}
|
||||||
isRefreshing={isConfigRefreshing}
|
isConfigRefreshing={isConfigRefreshing}
|
||||||
onSave={handleSaveConfig}
|
doorStates={doorStates}
|
||||||
|
isLowPressure={isLowPressure}
|
||||||
|
isEmergencyStop={isEmergencyStop}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onSelectRecipe={handleSelectRecipe}
|
||||||
|
onMove={moveAxis}
|
||||||
|
onControl={handleControl}
|
||||||
|
onSaveConfig={handleSaveConfig}
|
||||||
|
onFetchConfig={fetchConfig}
|
||||||
|
onCloseTab={() => setActiveTab(null)}
|
||||||
|
videoRef={videoRef}
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
{/* Right Sidebar (Dashboard) */}
|
/>
|
||||||
<div className="w-80 ml-auto z-20 flex flex-col gap-4">
|
<Route
|
||||||
<CyberPanel className="flex-none">
|
path="/io-monitor"
|
||||||
<div className="mb-2 text-xs text-neon-blue font-bold tracking-widest uppercase">System Status</div>
|
element={
|
||||||
<div className={`text-3xl font-tech font-bold mb-4 ${systemState === SystemState.RUNNING ? 'text-neon-green text-shadow-glow-green' : 'text-slate-400'}`}>
|
<IOMonitorPage
|
||||||
{systemState}
|
ioPoints={ioPoints}
|
||||||
</div>
|
onToggle={toggleIO}
|
||||||
<div className="space-y-3">
|
activeIOTab={activeIOTab}
|
||||||
<TechButton
|
onIOTabChange={setActiveIOTab}
|
||||||
variant="green"
|
/>
|
||||||
className="w-full flex items-center justify-center gap-2"
|
}
|
||||||
active={systemState === SystemState.RUNNING}
|
/>
|
||||||
onClick={() => handleControl('start')}
|
</Routes>
|
||||||
>
|
</Layout>
|
||||||
<Play className="w-4 h-4" /> START AUTO
|
</HashRouter>
|
||||||
</TechButton>
|
|
||||||
<TechButton
|
|
||||||
variant="amber"
|
|
||||||
className="w-full flex items-center justify-center gap-2"
|
|
||||||
active={systemState === SystemState.PAUSED}
|
|
||||||
onClick={() => handleControl('stop')}
|
|
||||||
>
|
|
||||||
<Square className="w-4 h-4 fill-current" /> STOP / PAUSE
|
|
||||||
</TechButton>
|
|
||||||
<TechButton
|
|
||||||
className="w-full flex items-center justify-center gap-2"
|
|
||||||
onClick={() => handleControl('reset')}
|
|
||||||
>
|
|
||||||
<RotateCw className="w-4 h-4" /> SYSTEM RESET
|
|
||||||
</TechButton>
|
|
||||||
</div>
|
|
||||||
</CyberPanel>
|
|
||||||
|
|
||||||
<CyberPanel className="flex-1 flex flex-col min-h-0">
|
|
||||||
<div className="mb-2 flex items-center justify-between text-xs text-neon-blue font-bold tracking-widest uppercase border-b border-white/10 pb-2">
|
|
||||||
<span>Event Log</span>
|
|
||||||
<Terminal className="w-3 h-3" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-y-auto font-mono text-[10px] space-y-1 pr-1 custom-scrollbar">
|
|
||||||
{logs.map(log => (
|
|
||||||
<div key={log.id} className={`flex gap-2 ${log.type === 'error' ? 'text-red-500' : log.type === 'warning' ? 'text-amber-400' : 'text-slate-400'}`}>
|
|
||||||
<span className="opacity-50">[{log.timestamp}]</span>
|
|
||||||
<span>{log.message}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CyberPanel>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* FOOTER STATUS */}
|
|
||||||
<footer className="absolute bottom-0 left-0 right-0 h-10 bg-black/80 border-t border-neon-blue/30 flex items-center px-6 justify-between z-40 backdrop-blur text-xs font-mono text-slate-400">
|
|
||||||
<div className="flex gap-6">
|
|
||||||
{['PLC', 'MOTION', 'VISION', 'LIGHT'].map(hw => (
|
|
||||||
<div key={hw} className="flex items-center gap-2">
|
|
||||||
<div className="w-2 h-2 bg-neon-green rounded-full shadow-[0_0_5px_#0aff00]"></div>
|
|
||||||
<span className="font-bold text-slate-300">{hw}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-8 text-neon-blue">
|
|
||||||
<span>POS.X: {robotTarget.x.toFixed(3)}</span>
|
|
||||||
<span>POS.Y: {robotTarget.y.toFixed(3)}</span>
|
|
||||||
<span>POS.Z: {robotTarget.z.toFixed(3)}</span>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class CommunicationLayer {
|
|||||||
constructor() {
|
constructor() {
|
||||||
if (isWebView) {
|
if (isWebView) {
|
||||||
console.log("[COMM] Running in WebView2 Mode");
|
console.log("[COMM] Running in WebView2 Mode");
|
||||||
|
this.isConnected = true; // WebView2 is always connected
|
||||||
window.chrome.webview.addEventListener('message', (event: any) => {
|
window.chrome.webview.addEventListener('message', (event: any) => {
|
||||||
this.notifyListeners(event.data);
|
this.notifyListeners(event.data);
|
||||||
});
|
});
|
||||||
@@ -27,6 +28,7 @@ class CommunicationLayer {
|
|||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
console.log("[COMM] WebSocket Connected");
|
console.log("[COMM] WebSocket Connected");
|
||||||
this.isConnected = true;
|
this.isConnected = true;
|
||||||
|
this.notifyListeners({ type: 'CONNECTION_STATE', connected: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
this.ws.onmessage = (event) => {
|
||||||
@@ -41,6 +43,7 @@ class CommunicationLayer {
|
|||||||
this.ws.onclose = () => {
|
this.ws.onclose = () => {
|
||||||
console.log("[COMM] WebSocket Closed. Reconnecting...");
|
console.log("[COMM] WebSocket Closed. Reconnecting...");
|
||||||
this.isConnected = false;
|
this.isConnected = false;
|
||||||
|
this.notifyListeners({ type: 'CONNECTION_STATE', connected: false });
|
||||||
setTimeout(() => this.connectWebSocket(), 2000);
|
setTimeout(() => this.connectWebSocket(), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,6 +63,10 @@ class CommunicationLayer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getConnectionState(): boolean {
|
||||||
|
return this.isConnected;
|
||||||
|
}
|
||||||
|
|
||||||
// --- API Methods ---
|
// --- API Methods ---
|
||||||
|
|
||||||
public async getConfig(): Promise<string> {
|
public async getConfig(): Promise<string> {
|
||||||
@@ -95,6 +102,64 @@ class CommunicationLayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getIOList(): Promise<string> {
|
||||||
|
if (isWebView) {
|
||||||
|
return await window.chrome.webview.hostObjects.machine.GetIOList();
|
||||||
|
} else {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!this.isConnected) reject("WebSocket connection timeout");
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
this.listeners = this.listeners.filter(cb => cb !== handler);
|
||||||
|
reject("IO fetch timeout");
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
const handler = (data: any) => {
|
||||||
|
if (data.type === 'IO_LIST_DATA') {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
this.listeners = this.listeners.filter(cb => cb !== handler);
|
||||||
|
resolve(JSON.stringify(data.data));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.listeners.push(handler);
|
||||||
|
this.ws?.send(JSON.stringify({ type: 'GET_IO_LIST' }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRecipeList(): Promise<string> {
|
||||||
|
if (isWebView) {
|
||||||
|
return await window.chrome.webview.hostObjects.machine.GetRecipeList();
|
||||||
|
} else {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!this.isConnected) reject("WebSocket connection timeout");
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
this.listeners = this.listeners.filter(cb => cb !== handler);
|
||||||
|
reject("Recipe fetch timeout");
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
const handler = (data: any) => {
|
||||||
|
if (data.type === 'RECIPE_LIST_DATA') {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
this.listeners = this.listeners.filter(cb => cb !== handler);
|
||||||
|
resolve(JSON.stringify(data.data));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.listeners.push(handler);
|
||||||
|
this.ws?.send(JSON.stringify({ type: 'GET_RECIPE_LIST' }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async saveConfig(configJson: string): Promise<void> {
|
public async saveConfig(configJson: string): Promise<void> {
|
||||||
if (isWebView) {
|
if (isWebView) {
|
||||||
await window.chrome.webview.hostObjects.machine.SaveConfig(configJson);
|
await window.chrome.webview.hostObjects.machine.SaveConfig(configJson);
|
||||||
@@ -126,6 +191,36 @@ class CommunicationLayer {
|
|||||||
this.ws?.send(JSON.stringify({ type: 'SET_IO', id, state }));
|
this.ws?.send(JSON.stringify({ type: 'SET_IO', id, state }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async selectRecipe(recipeId: string): Promise<{ success: boolean; message: string; recipeId?: string }> {
|
||||||
|
if (isWebView) {
|
||||||
|
const resultJson = await window.chrome.webview.hostObjects.machine.SelectRecipe(recipeId);
|
||||||
|
return JSON.parse(resultJson);
|
||||||
|
} else {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!this.isConnected) reject({ success: false, message: "WebSocket connection timeout" });
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
this.listeners = this.listeners.filter(cb => cb !== handler);
|
||||||
|
reject({ success: false, message: "Recipe selection timeout" });
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
const handler = (data: any) => {
|
||||||
|
if (data.type === 'RECIPE_SELECTED') {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
this.listeners = this.listeners.filter(cb => cb !== handler);
|
||||||
|
resolve(data.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.listeners.push(handler);
|
||||||
|
this.ws?.send(JSON.stringify({ type: 'SELECT_RECIPE', recipeId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const comms = new CommunicationLayer();
|
export const comms = new CommunicationLayer();
|
||||||
|
|||||||
58
frontend/components/ModelInfoPanel.tsx
Normal file
58
frontend/components/ModelInfoPanel.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Cpu, Activity } from 'lucide-react';
|
||||||
|
import { Recipe } from '../types';
|
||||||
|
import { CyberPanel } from './common/CyberPanel';
|
||||||
|
|
||||||
|
interface ModelInfoPanelProps {
|
||||||
|
currentRecipe: Recipe;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModelInfoPanel: React.FC<ModelInfoPanelProps> = ({ currentRecipe }) => {
|
||||||
|
return (
|
||||||
|
<CyberPanel className="flex-none">
|
||||||
|
<div className="mb-3 flex items-center justify-between text-xs text-neon-blue font-bold tracking-widest uppercase border-b border-white/10 pb-2">
|
||||||
|
<span>Model Information</span>
|
||||||
|
<Box className="w-3 h-3" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-slate-500 font-mono mb-1">SELECTED MODEL</div>
|
||||||
|
<div className="text-xl font-bold text-white tracking-wide truncate flex items-center gap-2">
|
||||||
|
<Cpu className="w-4 h-4 text-neon-blue" />
|
||||||
|
{currentRecipe.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="bg-white/5 rounded p-2 border border-white/5">
|
||||||
|
<div className="text-[9px] text-slate-500 font-mono uppercase">Model ID</div>
|
||||||
|
<div className="text-sm font-mono text-neon-blue">{currentRecipe.id}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/5 rounded p-2 border border-white/5">
|
||||||
|
<div className="text-[9px] text-slate-500 font-mono uppercase">Last Mod</div>
|
||||||
|
<div className="text-sm font-mono text-slate-300">{currentRecipe.lastModified}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-[10px] font-mono text-slate-400">
|
||||||
|
<span>TARGET CYCLE</span>
|
||||||
|
<span className="text-neon-green">12.5s</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1 bg-slate-800 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full w-[85%] bg-neon-green/50"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between text-[10px] font-mono text-slate-400 mt-2">
|
||||||
|
<span>EST. YIELD</span>
|
||||||
|
<span className="text-neon-blue">99.8%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1 bg-slate-800 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full w-[99%] bg-neon-blue/50"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CyberPanel>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,40 +1,84 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { FileText, CheckCircle2, Layers } from 'lucide-react';
|
import { Layers, Check, Settings, X } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Recipe } from '../types';
|
import { Recipe } from '../types';
|
||||||
import { PanelHeader } from './common/PanelHeader';
|
|
||||||
import { TechButton } from './common/TechButton';
|
import { TechButton } from './common/TechButton';
|
||||||
|
|
||||||
interface RecipePanelProps {
|
interface RecipePanelProps {
|
||||||
recipes: Recipe[];
|
recipes: Recipe[];
|
||||||
currentRecipe: Recipe;
|
currentRecipe: Recipe;
|
||||||
onSelectRecipe: (r: Recipe) => void;
|
onSelectRecipe: (r: Recipe) => void;
|
||||||
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RecipePanel: React.FC<RecipePanelProps> = ({ recipes, currentRecipe, onSelectRecipe }) => (
|
export const RecipePanel: React.FC<RecipePanelProps> = ({ recipes, currentRecipe, onSelectRecipe, onClose }) => {
|
||||||
<div className="h-full flex flex-col">
|
const navigate = useNavigate();
|
||||||
<PanelHeader title="Recipe Select" icon={FileText} />
|
const [selectedId, setSelectedId] = useState<string>(currentRecipe.id);
|
||||||
<div className="space-y-2 flex-1 overflow-y-auto custom-scrollbar pr-2">
|
|
||||||
{recipes.map(r => (
|
const handleConfirm = () => {
|
||||||
|
const selected = recipes.find(r => r.id === selectedId);
|
||||||
|
if (selected) {
|
||||||
|
onSelectRecipe(selected);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
|
||||||
|
<div className="w-[500px] bg-slate-950/90 border border-neon-blue/30 rounded-lg shadow-2xl flex flex-col overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="h-12 bg-white/5 flex items-center justify-between px-4 border-b border-white/10">
|
||||||
|
<div className="flex items-center gap-2 text-neon-blue font-tech font-bold tracking-wider">
|
||||||
|
<Layers className="w-4 h-4" /> RECIPE SELECTION
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List (Max height for ~5 items) */}
|
||||||
|
<div className="p-4 max-h-[320px] overflow-y-auto custom-scrollbar">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{recipes.map(recipe => (
|
||||||
<div
|
<div
|
||||||
key={r.id}
|
key={recipe.id}
|
||||||
onClick={() => onSelectRecipe(r)}
|
onClick={() => setSelectedId(recipe.id)}
|
||||||
className={`
|
className={`
|
||||||
p-3 cursor-pointer flex justify-between items-center transition-all border-l-4
|
p-3 rounded border cursor-pointer transition-all flex items-center justify-between
|
||||||
${currentRecipe.id === r.id
|
${selectedId === recipe.id
|
||||||
? 'bg-white/10 border-neon-blue text-white shadow-[inset_0_0_20px_rgba(0,243,255,0.1)]'
|
? 'bg-neon-blue/20 border-neon-blue text-white shadow-[0_0_10px_rgba(0,243,255,0.2)]'
|
||||||
: 'bg-black/20 border-transparent text-slate-500 hover:bg-white/5 hover:text-slate-300'}
|
: 'bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:border-white/20'}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-tech font-bold text-sm tracking-wide">{r.name}</div>
|
<div className="font-bold tracking-wide">{recipe.name}</div>
|
||||||
<div className="text-[10px] font-mono opacity-60">{r.lastModified}</div>
|
<div className="text-[10px] font-mono opacity-70">ID: {recipe.id} | MOD: {recipe.lastModified}</div>
|
||||||
</div>
|
</div>
|
||||||
{currentRecipe.id === r.id && <CheckCircle2 className="w-4 h-4 text-neon-blue" />}
|
{selectedId === recipe.id && <Check className="w-4 h-4 text-neon-blue" />}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<TechButton className="w-full mt-4 flex items-center justify-center gap-2">
|
</div>
|
||||||
<Layers className="w-4 h-4" /> New Recipe
|
|
||||||
|
{/* Footer Actions */}
|
||||||
|
<div className="p-4 border-t border-white/10 flex justify-between gap-4 bg-black/20">
|
||||||
|
<TechButton
|
||||||
|
variant="default"
|
||||||
|
className="flex items-center gap-2 px-4"
|
||||||
|
onClick={() => navigate('/recipe')}
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" /> MANAGEMENT
|
||||||
|
</TechButton>
|
||||||
|
|
||||||
|
<TechButton
|
||||||
|
variant="blue"
|
||||||
|
className="flex items-center gap-2 px-8"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
>
|
||||||
|
SELECT
|
||||||
</TechButton>
|
</TechButton>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ interface TechButtonProps {
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
variant?: 'blue' | 'red' | 'amber' | 'green';
|
variant?: 'blue' | 'red' | 'amber' | 'green' | 'default' | 'danger';
|
||||||
className?: string;
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TechButton: React.FC<TechButtonProps> = ({
|
export const TechButton: React.FC<TechButtonProps> = ({
|
||||||
@@ -13,26 +15,39 @@ export const TechButton: React.FC<TechButtonProps> = ({
|
|||||||
onClick,
|
onClick,
|
||||||
active = false,
|
active = false,
|
||||||
variant = 'blue',
|
variant = 'blue',
|
||||||
className = ''
|
className = '',
|
||||||
|
disabled = false,
|
||||||
|
title
|
||||||
}) => {
|
}) => {
|
||||||
const colors = {
|
const colors = {
|
||||||
blue: 'from-blue-600 to-cyan-600 hover:shadow-glow-blue border-cyan-400/30',
|
blue: 'from-blue-600 to-cyan-600 hover:shadow-glow-blue border-cyan-400/30',
|
||||||
red: 'from-red-600 to-pink-600 hover:shadow-glow-red border-red-400/30',
|
red: 'from-red-600 to-pink-600 hover:shadow-glow-red border-red-400/30',
|
||||||
amber: 'from-amber-500 to-orange-600 hover:shadow-orange-500/50 border-orange-400/30',
|
amber: 'from-amber-500 to-orange-600 hover:shadow-orange-500/50 border-orange-400/30',
|
||||||
green: 'from-emerald-500 to-green-600 hover:shadow-green-500/50 border-green-400/30'
|
green: 'from-emerald-500 to-green-600 hover:shadow-green-500/50 border-green-400/30',
|
||||||
|
default: 'from-slate-600 to-slate-500 hover:shadow-slate-500/50 border-slate-400/30',
|
||||||
|
danger: 'from-red-600 to-pink-600 hover:shadow-glow-red border-red-400/30'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const variantKey = variant === 'danger' ? 'red' : (variant === 'default' ? 'blue' : variant);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
title={title}
|
||||||
className={`
|
className={`
|
||||||
relative px-4 py-2 font-tech font-bold tracking-wider uppercase transition-all duration-300
|
relative px-4 py-2 font-tech font-bold tracking-wider uppercase transition-all duration-300
|
||||||
clip-tech border-b-2 border-r-2
|
clip-tech border-b-2 border-r-2
|
||||||
${active ? `bg-gradient-to-r ${colors[variant]} text-white` : 'bg-slate-800/50 text-slate-400 hover:text-white hover:bg-slate-700/50 border-slate-600'}
|
${disabled
|
||||||
|
? 'opacity-50 cursor-not-allowed bg-slate-900 text-slate-600 border-slate-800'
|
||||||
|
: (active
|
||||||
|
? `bg-gradient-to-r ${colors[variantKey]} text-white`
|
||||||
|
: 'bg-slate-800/50 text-slate-400 hover:text-white hover:bg-slate-700/50 border-slate-600')
|
||||||
|
}
|
||||||
${className}
|
${className}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{active && <div className="absolute inset-0 bg-white/20 animate-pulse pointer-events-none"></div>}
|
{active && !disabled && <div className="absolute inset-0 bg-white/20 animate-pulse pointer-events-none"></div>}
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
31
frontend/components/layout/Footer.tsx
Normal file
31
frontend/components/layout/Footer.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { RobotTarget } from '../../types';
|
||||||
|
|
||||||
|
interface FooterProps {
|
||||||
|
isHostConnected: boolean;
|
||||||
|
robotTarget: RobotTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Footer: React.FC<FooterProps> = ({ isHostConnected, robotTarget }) => {
|
||||||
|
return (
|
||||||
|
<footer className="absolute bottom-0 left-0 right-0 h-10 bg-black/80 border-t border-neon-blue/30 flex items-center px-6 justify-between z-40 backdrop-blur text-xs font-mono text-slate-400">
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{['PLC', 'MOTION', 'VISION', 'LIGHT'].map(hw => (
|
||||||
|
<div key={hw} className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 bg-neon-green rounded-full shadow-[0_0_5px_#0aff00]"></div>
|
||||||
|
<span className="font-bold text-slate-300">{hw}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`w-2 h-2 rounded-full transition-all ${isHostConnected ? 'bg-neon-green shadow-[0_0_5px_#0aff00]' : 'bg-red-500 shadow-[0_0_5px_#ff0000] animate-pulse'}`}></div>
|
||||||
|
<span className={`font-bold ${isHostConnected ? 'text-slate-300' : 'text-red-400'}`}>HOST</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-8 text-neon-blue">
|
||||||
|
<span>POS.X: {robotTarget.x.toFixed(3)}</span>
|
||||||
|
<span>POS.Y: {robotTarget.y.toFixed(3)}</span>
|
||||||
|
<span>POS.Z: {robotTarget.z.toFixed(3)}</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
100
frontend/components/layout/Header.tsx
Normal file
100
frontend/components/layout/Header.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { Activity, Settings, Move, Camera, Layers, Cpu } from 'lucide-react';
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
currentTime: Date;
|
||||||
|
onTabChange: (tab: 'recipe' | 'motion' | 'camera' | 'setting' | null) => void;
|
||||||
|
activeTab: 'recipe' | 'motion' | 'camera' | 'setting' | null;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Header: React.FC<HeaderProps> = ({ currentTime, onTabChange, activeTab }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const isWebView = typeof window !== 'undefined' && !!window.chrome?.webview;
|
||||||
|
const isIOPage = location.pathname === '/io-monitor';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="absolute top-0 left-0 right-0 h-20 px-6 flex items-center justify-between z-40 bg-gradient-to-b from-black/80 to-transparent pointer-events-none">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-4 pointer-events-auto cursor-pointer group"
|
||||||
|
onClick={() => {
|
||||||
|
navigate('/');
|
||||||
|
onTabChange(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 border-2 border-neon-blue flex items-center justify-center rounded shadow-glow-blue bg-black/50 backdrop-blur group-hover:bg-neon-blue/10 transition-colors">
|
||||||
|
<Cpu className="text-neon-blue w-8 h-8 animate-pulse-slow" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-tech font-bold text-white tracking-widest uppercase italic text-shadow-glow-blue group-hover:text-neon-blue transition-colors">
|
||||||
|
EQUI-HANDLER <span className="text-neon-blue text-sm not-italic">PRO</span>
|
||||||
|
</h1>
|
||||||
|
<div className="flex gap-2 text-[10px] text-neon-blue/70 font-mono">
|
||||||
|
<span>SYS.VER 4.2.0</span>
|
||||||
|
<span>|</span>
|
||||||
|
<span className={isWebView ? "text-neon-green" : "text-amber-500"}>
|
||||||
|
LINK: {isWebView ? "NATIVE" : "SIMULATION"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Navigation */}
|
||||||
|
<div className="flex items-center gap-2 pointer-events-auto">
|
||||||
|
{/* IO Tab Switcher (only on IO page) */}
|
||||||
|
|
||||||
|
|
||||||
|
<div className="bg-black/40 backdrop-blur-md p-1 rounded-full border border-white/10 flex gap-1">
|
||||||
|
{[
|
||||||
|
{ id: 'recipe', icon: Layers, label: 'RECIPE', path: '/' },
|
||||||
|
{ id: 'io', icon: Activity, label: 'I/O MONITOR', path: '/io-monitor' },
|
||||||
|
{ id: 'motion', icon: Move, label: 'MOTION', path: '/' },
|
||||||
|
{ id: 'camera', icon: Camera, label: 'VISION', path: '/' },
|
||||||
|
{ id: 'setting', icon: Settings, label: 'CONFIG', path: '/' }
|
||||||
|
].map(item => {
|
||||||
|
const isActive = item.id === 'io'
|
||||||
|
? location.pathname === '/io-monitor'
|
||||||
|
: activeTab === item.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => {
|
||||||
|
if (item.id === 'io') {
|
||||||
|
navigate('/io-monitor');
|
||||||
|
onTabChange(null);
|
||||||
|
} else {
|
||||||
|
if (location.pathname !== '/') {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
onTabChange(activeTab === item.id ? null : item.id as any);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-2 px-6 py-2 rounded-full font-tech font-bold text-sm transition-all border border-transparent
|
||||||
|
${isActive
|
||||||
|
? 'bg-neon-blue/10 text-neon-blue border-neon-blue shadow-glow-blue'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-white/5'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<item.icon className="w-4 h-4" /> {item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right pointer-events-auto">
|
||||||
|
<div className="text-2xl font-mono font-bold text-white text-shadow-glow-blue">
|
||||||
|
{currentTime.toLocaleTimeString('en-GB')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-tech text-slate-400 tracking-[0.3em]">
|
||||||
|
{currentTime.toLocaleDateString().toUpperCase()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
61
frontend/components/layout/Layout.tsx
Normal file
61
frontend/components/layout/Layout.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Header } from './Header';
|
||||||
|
import { Footer } from './Footer';
|
||||||
|
import { RobotTarget } from '../../types';
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
currentTime: Date;
|
||||||
|
isHostConnected: boolean;
|
||||||
|
robotTarget: RobotTarget;
|
||||||
|
onTabChange: (tab: 'recipe' | 'motion' | 'camera' | 'setting' | null) => void;
|
||||||
|
activeTab: 'recipe' | 'motion' | 'camera' | 'setting' | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Layout: React.FC<LayoutProps> = ({
|
||||||
|
children,
|
||||||
|
currentTime,
|
||||||
|
isHostConnected,
|
||||||
|
robotTarget,
|
||||||
|
onTabChange,
|
||||||
|
activeTab,
|
||||||
|
isLoading
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="relative w-screen h-screen bg-slate-950 text-slate-100 overflow-hidden font-sans">
|
||||||
|
{/* Animated Nebula Background */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-slate-950 via-[#050a15] to-[#0a0f20] animate-gradient bg-[length:400%_400%] z-0"></div>
|
||||||
|
<div className="absolute inset-0 grid-bg opacity-30 z-0"></div>
|
||||||
|
<div className="absolute inset-0 scanlines z-50 pointer-events-none"></div>
|
||||||
|
|
||||||
|
{/* LOADING OVERLAY */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="absolute inset-0 z-[100] bg-black flex flex-col items-center justify-center gap-6">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-24 h-24 border-4 border-neon-blue/30 rounded-full animate-spin"></div>
|
||||||
|
<div className="absolute inset-0 border-t-4 border-neon-blue rounded-full animate-spin"></div>
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-neon-blue text-2xl font-tech font-bold">●</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-2xl font-tech font-bold text-white tracking-widest mb-2">SYSTEM INITIALIZING</h2>
|
||||||
|
<p className="font-mono text-neon-blue text-sm animate-pulse">ESTABLISHING CONNECTION...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Header
|
||||||
|
currentTime={currentTime}
|
||||||
|
onTabChange={onTabChange}
|
||||||
|
activeTab={activeTab}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<div className="absolute inset-0 pt-20 pb-10 z-10">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Footer isHostConnected={isHostConnected} robotTarget={robotTarget} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
54
frontend/package-lock.json
generated
54
frontend/package-lock.json
generated
@@ -16,6 +16,7 @@
|
|||||||
"lucide-react": "^0.303.0",
|
"lucide-react": "^0.303.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^7.9.6",
|
||||||
"tailwind-merge": "^2.2.0",
|
"tailwind-merge": "^2.2.0",
|
||||||
"three": "^0.160.0"
|
"three": "^0.160.0"
|
||||||
},
|
},
|
||||||
@@ -1800,6 +1801,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-env": {
|
"node_modules/cross-env": {
|
||||||
"version": "7.0.3",
|
"version": "7.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
|
||||||
@@ -2801,6 +2811,44 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-router": {
|
||||||
|
"version": "7.9.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz",
|
||||||
|
"integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.1",
|
||||||
|
"set-cookie-parser": "^2.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router-dom": {
|
||||||
|
"version": "7.9.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.6.tgz",
|
||||||
|
"integrity": "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-router": "7.9.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-use-measure": {
|
"node_modules/react-use-measure": {
|
||||||
"version": "2.1.7",
|
"version": "2.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
|
||||||
@@ -2965,6 +3013,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-cookie-parser": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"lucide-react": "^0.303.0",
|
"lucide-react": "^0.303.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^7.9.6",
|
||||||
"tailwind-merge": "^2.2.0",
|
"tailwind-merge": "^2.2.0",
|
||||||
"three": "^0.160.0"
|
"three": "^0.160.0"
|
||||||
},
|
},
|
||||||
|
|||||||
174
frontend/pages/HomePage.tsx
Normal file
174
frontend/pages/HomePage.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Play, Square, RotateCw, AlertTriangle, Siren, Terminal } from 'lucide-react';
|
||||||
|
import { Machine3D } from '../components/Machine3D';
|
||||||
|
import { SettingsModal } from '../components/SettingsModal';
|
||||||
|
import { RecipePanel } from '../components/RecipePanel';
|
||||||
|
import { MotionPanel } from '../components/MotionPanel';
|
||||||
|
import { CameraPanel } from '../components/CameraPanel';
|
||||||
|
import { CyberPanel } from '../components/common/CyberPanel';
|
||||||
|
import { TechButton } from '../components/common/TechButton';
|
||||||
|
import { ModelInfoPanel } from '../components/ModelInfoPanel';
|
||||||
|
import { SystemState, Recipe, IOPoint, LogEntry, RobotTarget, ConfigItem } from '../types';
|
||||||
|
|
||||||
|
interface HomePageProps {
|
||||||
|
systemState: SystemState;
|
||||||
|
currentRecipe: Recipe;
|
||||||
|
recipes: Recipe[];
|
||||||
|
robotTarget: RobotTarget;
|
||||||
|
logs: LogEntry[];
|
||||||
|
ioPoints: IOPoint[];
|
||||||
|
config: ConfigItem[] | null;
|
||||||
|
isConfigRefreshing: boolean;
|
||||||
|
doorStates: { front: boolean; right: boolean; left: boolean; back: boolean };
|
||||||
|
isLowPressure: boolean;
|
||||||
|
isEmergencyStop: boolean;
|
||||||
|
activeTab: 'recipe' | 'motion' | 'camera' | 'setting' | null;
|
||||||
|
onSelectRecipe: (r: Recipe) => void;
|
||||||
|
onMove: (axis: 'X' | 'Y' | 'Z', val: number) => void;
|
||||||
|
onControl: (action: 'start' | 'stop' | 'reset') => void;
|
||||||
|
onSaveConfig: (config: ConfigItem[]) => void;
|
||||||
|
onFetchConfig: () => void;
|
||||||
|
onCloseTab: () => void;
|
||||||
|
videoRef: React.RefObject<HTMLVideoElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HomePage: React.FC<HomePageProps> = ({
|
||||||
|
systemState,
|
||||||
|
currentRecipe,
|
||||||
|
recipes,
|
||||||
|
robotTarget,
|
||||||
|
logs,
|
||||||
|
ioPoints,
|
||||||
|
config,
|
||||||
|
isConfigRefreshing,
|
||||||
|
doorStates,
|
||||||
|
isLowPressure,
|
||||||
|
isEmergencyStop,
|
||||||
|
activeTab,
|
||||||
|
onSelectRecipe,
|
||||||
|
onMove,
|
||||||
|
onControl,
|
||||||
|
onSaveConfig,
|
||||||
|
onFetchConfig,
|
||||||
|
onCloseTab,
|
||||||
|
videoRef
|
||||||
|
}) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'camera' && navigator.mediaDevices?.getUserMedia) {
|
||||||
|
navigator.mediaDevices.getUserMedia({ video: true }).then(s => { if (videoRef.current) videoRef.current.srcObject = s });
|
||||||
|
}
|
||||||
|
}, [activeTab, videoRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'setting') {
|
||||||
|
onFetchConfig();
|
||||||
|
}
|
||||||
|
}, [activeTab, onFetchConfig]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="relative w-full h-full flex gap-6 px-6">
|
||||||
|
{/* 3D Canvas (Background Layer) */}
|
||||||
|
<div className="absolute inset-0 z-0">
|
||||||
|
<Machine3D target={robotTarget} ioState={ioPoints} doorStates={doorStates} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center Alarms */}
|
||||||
|
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 pointer-events-none flex flex-col items-center gap-4">
|
||||||
|
{isEmergencyStop && (
|
||||||
|
<div className="bg-red-600/90 text-white p-8 border-4 border-red-500 shadow-glow-red flex items-center gap-6 animate-pulse">
|
||||||
|
<Siren className="w-16 h-16 animate-spin" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-5xl font-tech font-bold tracking-widest">EMERGENCY STOP</h1>
|
||||||
|
<p className="text-center font-mono text-lg">SYSTEM HALTED - RELEASE TO RESET</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isLowPressure && !isEmergencyStop && (
|
||||||
|
<div className="bg-amber-500/80 text-black px-8 py-4 rounded font-bold text-2xl tracking-widest flex items-center gap-4 shadow-glow-red animate-bounce">
|
||||||
|
<AlertTriangle className="w-8 h-8" /> LOW AIR PRESSURE WARNING
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Panel (Left) */}
|
||||||
|
{activeTab && activeTab !== 'setting' && activeTab !== 'recipe' && (
|
||||||
|
<div className="w-[450px] z-20 animate-in slide-in-from-left-20 duration-500 fade-in">
|
||||||
|
<CyberPanel className="h-full flex flex-col">
|
||||||
|
{activeTab === 'motion' && <MotionPanel robotTarget={robotTarget} onMove={onMove} />}
|
||||||
|
{activeTab === 'camera' && <CameraPanel videoRef={videoRef} />}
|
||||||
|
</CyberPanel>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recipe Selection Modal */}
|
||||||
|
{activeTab === 'recipe' && (
|
||||||
|
<RecipePanel
|
||||||
|
recipes={recipes}
|
||||||
|
currentRecipe={currentRecipe}
|
||||||
|
onSelectRecipe={onSelectRecipe}
|
||||||
|
onClose={onCloseTab}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Settings Modal */}
|
||||||
|
<SettingsModal
|
||||||
|
isOpen={activeTab === 'setting'}
|
||||||
|
onClose={onCloseTab}
|
||||||
|
config={config}
|
||||||
|
isRefreshing={isConfigRefreshing}
|
||||||
|
onSave={onSaveConfig}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Right Sidebar (Dashboard) */}
|
||||||
|
<div className="w-80 ml-auto z-20 flex flex-col gap-4">
|
||||||
|
<ModelInfoPanel currentRecipe={currentRecipe} />
|
||||||
|
|
||||||
|
<CyberPanel className="flex-none">
|
||||||
|
<div className="mb-2 text-xs text-neon-blue font-bold tracking-widest uppercase">System Status</div>
|
||||||
|
<div className={`text-3xl font-tech font-bold mb-4 ${systemState === SystemState.RUNNING ? 'text-neon-green text-shadow-glow-green' : 'text-slate-400'}`}>
|
||||||
|
{systemState}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<TechButton
|
||||||
|
variant="green"
|
||||||
|
className="w-full flex items-center justify-center gap-2"
|
||||||
|
active={systemState === SystemState.RUNNING}
|
||||||
|
onClick={() => onControl('start')}
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4" /> START AUTO
|
||||||
|
</TechButton>
|
||||||
|
<TechButton
|
||||||
|
variant="amber"
|
||||||
|
className="w-full flex items-center justify-center gap-2"
|
||||||
|
active={systemState === SystemState.PAUSED}
|
||||||
|
onClick={() => onControl('stop')}
|
||||||
|
>
|
||||||
|
<Square className="w-4 h-4 fill-current" /> STOP / PAUSE
|
||||||
|
</TechButton>
|
||||||
|
<TechButton
|
||||||
|
className="w-full flex items-center justify-center gap-2"
|
||||||
|
onClick={() => onControl('reset')}
|
||||||
|
>
|
||||||
|
<RotateCw className="w-4 h-4" /> SYSTEM RESET
|
||||||
|
</TechButton>
|
||||||
|
</div>
|
||||||
|
</CyberPanel>
|
||||||
|
|
||||||
|
<CyberPanel className="flex-1 flex flex-col min-h-0">
|
||||||
|
<div className="mb-2 flex items-center justify-between text-xs text-neon-blue font-bold tracking-widest uppercase border-b border-white/10 pb-2">
|
||||||
|
<span>Event Log</span>
|
||||||
|
<Terminal className="w-3 h-3" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto font-mono text-[10px] space-y-1 pr-1 custom-scrollbar">
|
||||||
|
{logs.map(log => (
|
||||||
|
<div key={log.id} className={`flex gap-2 ${log.type === 'error' ? 'text-red-500' : log.type === 'warning' ? 'text-amber-400' : 'text-slate-400'}`}>
|
||||||
|
<span className="opacity-50">[{log.timestamp}]</span>
|
||||||
|
<span>{log.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CyberPanel>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
83
frontend/pages/IOMonitorPage.tsx
Normal file
83
frontend/pages/IOMonitorPage.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { IOPoint } from '../types';
|
||||||
|
|
||||||
|
interface IOMonitorPageProps {
|
||||||
|
ioPoints: IOPoint[];
|
||||||
|
onToggle: (id: number, type: 'input' | 'output') => void;
|
||||||
|
activeIOTab: 'in' | 'out';
|
||||||
|
onIOTabChange: (tab: 'in' | 'out') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IOMonitorPage: React.FC<IOMonitorPageProps> = ({ ioPoints, onToggle, activeIOTab, onIOTabChange }) => {
|
||||||
|
const points = ioPoints.filter(p => p.type === (activeIOTab === 'in' ? 'input' : 'output'));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="relative w-full h-full px-6 pt-6 pb-20">
|
||||||
|
<div className="glass-holo p-6 h-full flex flex-col gap-4">
|
||||||
|
|
||||||
|
{/* Local Header / Controls */}
|
||||||
|
<div className="flex items-center justify-between shrink-0">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<h2 className="text-2xl font-tech font-bold text-white tracking-wider">
|
||||||
|
SYSTEM I/O MONITOR
|
||||||
|
</h2>
|
||||||
|
<div className="h-6 w-px bg-white/20"></div>
|
||||||
|
<div className="text-sm font-mono text-neon-blue">
|
||||||
|
TOTAL POINTS: {ioPoints.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-black/40 backdrop-blur-md p-1 rounded-full border border-white/10 flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => onIOTabChange('in')}
|
||||||
|
className={`px-6 py-2 rounded-full font-tech font-bold text-sm transition-all ${activeIOTab === 'in' ? 'bg-neon-yellow/20 text-neon-yellow border border-neon-yellow shadow-[0_0_15px_rgba(255,230,0,0.3)]' : 'text-slate-400 hover:text-white hover:bg-white/5'}`}
|
||||||
|
>
|
||||||
|
INPUTS ({ioPoints.filter(p => p.type === 'input').length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onIOTabChange('out')}
|
||||||
|
className={`px-6 py-2 rounded-full font-tech font-bold text-sm transition-all ${activeIOTab === 'out' ? 'bg-neon-green/20 text-neon-green border border-neon-green shadow-[0_0_15px_rgba(10,255,0,0.3)]' : 'text-slate-400 hover:text-white hover:bg-white/5'}`}
|
||||||
|
>
|
||||||
|
OUTPUTS ({ioPoints.filter(p => p.type === 'output').length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-950/40 backdrop-blur-md flex-1 overflow-y-auto custom-scrollbar rounded-lg border border-white/5">
|
||||||
|
{/* Grid Layout - More columns for full screen */}
|
||||||
|
{/* Grid Layout - 2 Columns for list view */}
|
||||||
|
<div className="grid grid-cols-2 gap-x-8 gap-y-2 p-4">
|
||||||
|
{points.map(p => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => onToggle(p.id, p.type)}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-4 px-4 py-3 cursor-pointer transition-all border
|
||||||
|
clip-tech-sm group hover:translate-x-1
|
||||||
|
${p.state
|
||||||
|
? (p.type === 'output'
|
||||||
|
? 'bg-neon-green/10 border-neon-green text-neon-green shadow-[0_0_15px_rgba(10,255,0,0.2)]'
|
||||||
|
: 'bg-neon-yellow/10 border-neon-yellow text-neon-yellow shadow-[0_0_15px_rgba(255,230,0,0.2)]')
|
||||||
|
: 'bg-slate-900/40 border-slate-800 text-slate-500 hover:border-slate-600 hover:bg-slate-800/40'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* LED Indicator */}
|
||||||
|
<div className={`w-3 h-3 rounded-full shrink-0 transition-all ${p.state ? (p.type === 'output' ? 'bg-neon-green shadow-[0_0_8px_#0aff00]' : 'bg-neon-yellow shadow-[0_0_8px_#ffe600]') : 'bg-slate-800 border border-slate-700'}`}></div>
|
||||||
|
|
||||||
|
{/* ID Badge */}
|
||||||
|
<div className={`w-12 font-mono font-bold text-lg ${p.state ? 'text-white' : 'text-slate-600'}`}>
|
||||||
|
{p.type === 'input' ? 'I' : 'Q'}{p.id.toString().padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<div className={`flex-1 font-bold uppercase tracking-wide truncate ${p.state ? 'text-white' : 'text-slate-500'} group-hover:text-white transition-colors`}>
|
||||||
|
{p.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
173
frontend/pages/RecipePage.tsx
Normal file
173
frontend/pages/RecipePage.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Layers, Plus, Trash2, Copy, Save, ArrowLeft, FileText, Calendar } from 'lucide-react';
|
||||||
|
import { Recipe } from '../types';
|
||||||
|
import { comms } from '../communication';
|
||||||
|
import { TechButton } from '../components/common/TechButton';
|
||||||
|
|
||||||
|
export const RecipePage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [recipes, setRecipes] = useState<Recipe[]>([]);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [editedRecipe, setEditedRecipe] = useState<Recipe | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadRecipes = async () => {
|
||||||
|
try {
|
||||||
|
const recipeStr = await comms.getRecipeList();
|
||||||
|
const recipeData = JSON.parse(recipeStr);
|
||||||
|
setRecipes(recipeData);
|
||||||
|
if (recipeData.length > 0) {
|
||||||
|
setSelectedId(recipeData[0].id);
|
||||||
|
setEditedRecipe(recipeData[0]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load recipes", e);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
loadRecipes();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelect = (r: Recipe) => {
|
||||||
|
setSelectedId(r.id);
|
||||||
|
setEditedRecipe({ ...r });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
// Mock Save Logic
|
||||||
|
console.log("Saving recipe:", editedRecipe);
|
||||||
|
// In real app: await comms.saveRecipe(editedRecipe);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full p-6 flex flex-col gap-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between shrink-0">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="p-2 rounded-full hover:bg-white/10 text-slate-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-tech font-bold text-white tracking-wider flex items-center gap-3">
|
||||||
|
<Layers className="text-neon-blue" /> RECIPE MANAGEMENT
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-mono text-slate-400">
|
||||||
|
TOTAL: <span className="text-neon-blue">{recipes.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex gap-6 min-h-0">
|
||||||
|
{/* Left: Recipe List */}
|
||||||
|
<div className="w-80 flex flex-col gap-4">
|
||||||
|
<div className="bg-slate-950/40 backdrop-blur-md border border-white/10 rounded-lg flex-1 overflow-hidden flex flex-col">
|
||||||
|
<div className="p-3 border-b border-white/10 bg-white/5 font-bold text-sm tracking-wider text-slate-300">
|
||||||
|
RECIPE LIST
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-2 space-y-2 custom-scrollbar">
|
||||||
|
{recipes.map(r => (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
onClick={() => handleSelect(r)}
|
||||||
|
className={`
|
||||||
|
p-3 rounded border cursor-pointer transition-all group
|
||||||
|
${selectedId === r.id
|
||||||
|
? 'bg-neon-blue/20 border-neon-blue text-white shadow-glow-blue'
|
||||||
|
: 'bg-white/5 border-white/5 text-slate-400 hover:bg-white/10 hover:border-white/20'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="font-bold text-sm mb-1">{r.name}</div>
|
||||||
|
<div className="text-[10px] font-mono opacity-60 flex items-center gap-2">
|
||||||
|
<Calendar className="w-3 h-3" /> {r.lastModified}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List Actions */}
|
||||||
|
<div className="p-3 border-t border-white/10 bg-black/20 grid grid-cols-3 gap-2">
|
||||||
|
<TechButton variant="default" className="flex justify-center" title="Add New">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</TechButton>
|
||||||
|
<TechButton variant="default" className="flex justify-center" title="Copy Selected">
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</TechButton>
|
||||||
|
<TechButton variant="danger" className="flex justify-center" title="Delete Selected">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</TechButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Editor */}
|
||||||
|
<div className="flex-1 bg-slate-950/40 backdrop-blur-md border border-white/10 rounded-lg flex flex-col overflow-hidden">
|
||||||
|
<div className="p-3 border-b border-white/10 bg-white/5 font-bold text-sm tracking-wider text-slate-300 flex justify-between items-center">
|
||||||
|
<span>RECIPE EDITOR</span>
|
||||||
|
{editedRecipe && <span className="text-neon-blue font-mono">{editedRecipe.id}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 p-6 overflow-y-auto custom-scrollbar">
|
||||||
|
{editedRecipe ? (
|
||||||
|
<div className="max-w-2xl space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Recipe Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editedRecipe.name}
|
||||||
|
onChange={(e) => setEditedRecipe({ ...editedRecipe, name: e.target.value })}
|
||||||
|
className="w-full bg-black/40 border border-white/10 rounded px-4 py-3 text-white focus:border-neon-blue focus:outline-none transition-colors font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Description</label>
|
||||||
|
<textarea
|
||||||
|
className="w-full h-32 bg-black/40 border border-white/10 rounded px-4 py-3 text-white focus:border-neon-blue focus:outline-none transition-colors font-mono resize-none"
|
||||||
|
placeholder="Enter recipe description..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Process Time (sec)</label>
|
||||||
|
<input type="number" className="w-full bg-black/40 border border-white/10 rounded px-4 py-3 text-white focus:border-neon-blue focus:outline-none transition-colors font-mono" defaultValue="120" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Temperature (°C)</label>
|
||||||
|
<input type="number" className="w-full bg-black/40 border border-white/10 rounded px-4 py-3 text-white focus:border-neon-blue focus:outline-none transition-colors font-mono" defaultValue="24.5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Placeholder for more parameters */}
|
||||||
|
<div className="p-4 border border-dashed border-white/10 rounded bg-white/5 text-center text-slate-500 text-sm">
|
||||||
|
Additional process parameters would go here...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-slate-500">
|
||||||
|
<FileText className="w-16 h-16 mb-4 opacity-20" />
|
||||||
|
<p>Select a recipe to edit</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor Actions */}
|
||||||
|
<div className="p-4 border-t border-white/10 bg-black/20 flex justify-end">
|
||||||
|
<TechButton
|
||||||
|
variant="green"
|
||||||
|
className="flex items-center gap-2 px-8"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!editedRecipe}
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" /> SAVE CHANGES
|
||||||
|
</TechButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -61,6 +61,8 @@ declare global {
|
|||||||
SystemControl(command: string): Promise<void>;
|
SystemControl(command: string): Promise<void>;
|
||||||
LoadRecipe(recipeId: string): Promise<void>;
|
LoadRecipe(recipeId: string): Promise<void>;
|
||||||
GetConfig(): Promise<string>;
|
GetConfig(): Promise<string>;
|
||||||
|
GetIOList(): Promise<string>;
|
||||||
|
GetRecipeList(): Promise<string>;
|
||||||
SaveConfig(configJson: string): Promise<void>;
|
SaveConfig(configJson: string): Promise<void>;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user