Compare commits

..

6 Commits

Author SHA1 Message Date
backuppc
a40cecc846 perf: WebView2 HostObject 프록시 캐싱으로 성능 개선
- communication.ts에서 machine 프록시를 모듈 로드 시 한 번만 초기화
- 10개 API 메서드에서 매번 hostObjects.machine 접근하던 오버헤드 제거

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 15:58:42 +09:00
362263ab05 refactor: Decentralize data fetching and add axis initialization
Refactor data fetching architecture from centralized App state to
component-local data management for improved maintainability and
data freshness guarantees.

Changes:
- SettingsModal: Fetch config data on modal open
- RecipePanel: Fetch recipe list on panel open
- IOMonitorPage: Fetch IO list on page mount with real-time updates
- Remove unnecessary props drilling through component hierarchy
- Simplify App.tsx by removing centralized config/recipes state

New feature:
- Add InitializeModal for sequential axis initialization (X, Y, Z)
- Each axis initializes with 3-second staggered start
- Progress bar animation for each axis
- Auto-close on completion

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 01:35:32 +09:00
27cc2507cf feat: Implement recipe copy and delete functionality
Backend changes (C#):
- Add CopyRecipe method to MachineBridge
  - Generates new GUID for copied recipe
  - Returns new recipe with current timestamp
- Add DeleteRecipe method to MachineBridge
  - Prevents deletion of currently selected recipe
  - Returns success/failure status
- Add COPY_RECIPE and DELETE_RECIPE handlers in WebSocketServer

Frontend changes (React/TypeScript):
- Add CopyRecipe and DeleteRecipe to Window interface types
- Add copyRecipe and deleteRecipe methods to communication layer
- Implement handleCopy in RecipePage
  - Prompts user for new name
  - Adds copied recipe to list
  - Selects newly copied recipe
- Implement handleDelete in RecipePage
  - Shows confirmation dialog
  - Removes recipe from list
  - Selects next available recipe
- Connect Copy and Delete buttons with handlers
- Disable buttons when no recipe is selected

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 23:53:35 +09:00
8c1a87ded0 feat: Add recipe management page routing
- Import RecipePage component in App.tsx
- Register /recipe route in HashRouter
- RecipePage now accessible from RecipePanel MANAGEMENT button

Features available on /recipe page:
- Recipe list with selection
- Recipe editor with name, description, process time, temperature
- Back button to return to main page
- Add/Copy/Delete buttons (UI only, backend TBD)
- Save changes button (mock implementation)

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 23:27:02 +09:00
83a9a63de4 fix: Add TypeScript type definitions for SelectRecipe
- Update Window interface in types.ts to include SelectRecipe method
- Change chrome and webview to optional properties (chrome?, webview?)
- Add non-null assertions (!) to all window.chrome.webview accesses
- Remove LoadRecipe (replaced with SelectRecipe)
- Fix TypeScript strict null checking errors

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 23:04:37 +09:00
82cf4b8fd0 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>
2025-11-24 22:42:00 +09:00
20 changed files with 1849 additions and 355 deletions

View File

@@ -2,6 +2,9 @@
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Windows.Forms; using System.Windows.Forms;
using Newtonsoft.Json; using Newtonsoft.Json;
using System.IO;
using System.Linq;
using System.Collections.Generic;
namespace HMIWeb namespace HMIWeb
{ {
@@ -13,9 +16,83 @@ namespace HMIWeb
// Reference to the main form to update logic // Reference to the main form to update logic
private MainForm _host; private MainForm _host;
// Data folder paths
private readonly string _dataFolder;
private readonly string _settingsPath;
private readonly string _recipeFolder;
public MachineBridge(MainForm host) public MachineBridge(MainForm host)
{ {
_host = host; _host = host;
// Initialize data folder paths
_dataFolder = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data");
_settingsPath = Path.Combine(_dataFolder, "settings.json");
_recipeFolder = Path.Combine(_dataFolder, "recipe");
// Ensure folders exist
EnsureDataFoldersExist();
}
private void EnsureDataFoldersExist()
{
try
{
if (!Directory.Exists(_dataFolder))
Directory.CreateDirectory(_dataFolder);
if (!Directory.Exists(_recipeFolder))
Directory.CreateDirectory(_recipeFolder);
// Initialize default settings if not exists
if (!File.Exists(_settingsPath))
InitializeDefaultSettings();
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] Failed to create data folders: {ex.Message}");
}
}
private void InitializeDefaultSettings()
{
var defaultSettings = new List<object>
{
new { Key = "Site Name", Value = "Smart Factory A-1", Group = "System Information", Type = "String", Description = "The display name of the factory site." },
new { Key = "Line ID", Value = "L-2024-001", Group = "System Information", Type = "String", Description = "Unique identifier for this production line." },
new { Key = "API Endpoint", Value = "https://api.factory.local/v1", Group = "Network Configuration", Type = "String", Description = "Base URL for the backend API." },
new { Key = "Support Contact", Value = "010-1234-5678", Group = "System Information", Type = "String", Description = "Emergency contact number for maintenance." },
new { Key = "Debug Mode", Value = "false", Group = "System Information", Type = "Boolean", Description = "Enable detailed logging for debugging." },
new { Key = "Max Speed", Value = "1500", Group = "Motion Control", Type = "Number", Description = "Maximum velocity in mm/s." },
new { Key = "Acceleration", Value = "500", Group = "Motion Control", Type = "Number", Description = "Acceleration ramp in mm/s²." }
};
for (int i = 1; i <= 5; i++)
{
defaultSettings.Add(new
{
Key = $"Sensor_{i}_Threshold",
Value = (i * 10).ToString(),
Group = "Sensor Calibration",
Type = "Number",
Description = $"Trigger threshold for Sensor {i}."
});
}
for (int i = 1; i <= 3; i++)
{
defaultSettings.Add(new
{
Key = $"Safety_Zone_{i}",
Value = "true",
Group = "Safety Settings",
Type = "Boolean",
Description = $"Enable monitoring for Safety Zone {i}."
});
}
File.WriteAllText(_settingsPath, JsonConvert.SerializeObject(defaultSettings, Formatting.Indented));
Console.WriteLine("[INFO] Default settings.json created");
} }
// --- Methods called from React (App.tsx) --- // --- Methods called from React (App.tsx) ---
@@ -41,57 +118,295 @@ 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}");
try
{
string recipePath = Path.Combine(_recipeFolder, $"{recipeId}.json");
if (!File.Exists(recipePath))
{
var response = new { success = false, message = "Recipe not found" };
return JsonConvert.SerializeObject(response);
}
// Load recipe data from file
string json = File.ReadAllText(recipePath);
var recipeData = JsonConvert.DeserializeObject<dynamic>(json);
// Set as current recipe
_host.SetCurrentRecipe(recipeId);
Console.WriteLine($"[INFO] Recipe {recipeId} selected successfully");
var response2 = new { success = true, message = "Recipe selected successfully", recipeId = recipeId, recipe = recipeData };
return JsonConvert.SerializeObject(response2);
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] Failed to select recipe: {ex.Message}");
var response = new { success = false, message = ex.Message };
return JsonConvert.SerializeObject(response);
}
} }
public string GetConfig() public string GetConfig()
{ {
// Generate 20 Mock Settings try
var settings = new System.Collections.Generic.List<object>();
// Core Settings
settings.Add(new { Key = "Site Name", Value = "Smart Factory A-1", Group = "System Information", Type = "String", Description = "The display name of the factory site." });
settings.Add(new { Key = "Line ID", Value = "L-2024-001", Group = "System Information", Type = "String", Description = "Unique identifier for this production line." });
settings.Add(new { Key = "API Endpoint", Value = "https://api.factory.local/v1", Group = "Network Configuration", Type = "String", Description = "Base URL for the backend API." });
settings.Add(new { Key = "Support Contact", Value = "010-1234-5678", Group = "System Information", Type = "String", Description = "Emergency contact number for maintenance." });
settings.Add(new { Key = "Debug Mode", Value = "false", Group = "System Information", Type = "Boolean", Description = "Enable detailed logging for debugging." });
settings.Add(new { Key = "Max Speed", Value = "1500", Group = "Motion Control", Type = "Number", Description = "Maximum velocity in mm/s." });
settings.Add(new { Key = "Acceleration", Value = "500", Group = "Motion Control", Type = "Number", Description = "Acceleration ramp in mm/s²." });
// Generated Settings
for (int i = 1; i <= 5; i++)
{ {
settings.Add(new { if (!File.Exists(_settingsPath))
Key = $"Sensor_{i}_Threshold", {
Value = (i * 10).ToString(), InitializeDefaultSettings();
Group = "Sensor Calibration", }
Type = "Number",
Description = $"Trigger threshold for Sensor {i}."
});
}
for (int i = 1; i <= 3; i++) string json = File.ReadAllText(_settingsPath);
Console.WriteLine($"[INFO] Loaded settings from {_settingsPath}");
return json;
}
catch (Exception ex)
{ {
settings.Add(new { Console.WriteLine($"[ERROR] Failed to load settings: {ex.Message}");
Key = $"Safety_Zone_{i}", return "[]";
Value = "true",
Group = "Safety Settings",
Type = "Boolean",
Description = $"Enable monitoring for Safety Zone {i}."
});
} }
Console.WriteLine("get config (20 items)");
return Newtonsoft.Json.JsonConvert.SerializeObject(settings);
} }
public void SaveConfig(string configJson) public void SaveConfig(string configJson)
{ {
Console.WriteLine($"[Backend] SAVE CONFIG REQUEST RECEIVED"); try
Console.WriteLine($"[Backend] Data: {configJson}"); {
// In a real app, we would save this to a file or database Console.WriteLine($"[Backend] SAVE CONFIG REQUEST RECEIVED");
// Validate JSON format
var settings = JsonConvert.DeserializeObject(configJson);
// Save to file with formatting
File.WriteAllText(_settingsPath, JsonConvert.SerializeObject(settings, Formatting.Indented));
Console.WriteLine($"[INFO] Settings saved successfully to {_settingsPath}");
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] Failed to save settings: {ex.Message}");
}
}
public string GetIOList()
{
var ioList = new System.Collections.Generic.List<object>();
Console.WriteLine("GetIOList");
// 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()
{
try
{
var recipes = new List<object>();
if (!Directory.Exists(_recipeFolder))
{
Directory.CreateDirectory(_recipeFolder);
return JsonConvert.SerializeObject(recipes);
}
var jsonFiles = Directory.GetFiles(_recipeFolder, "*.json");
foreach (var filePath in jsonFiles)
{
try
{
string fileName = Path.GetFileNameWithoutExtension(filePath);
string json = File.ReadAllText(filePath);
var recipeData = JsonConvert.DeserializeObject<dynamic>(json);
var lastModified = File.GetLastWriteTime(filePath).ToString("yyyy-MM-dd");
recipes.Add(new
{
id = fileName,
name = recipeData?.name ?? fileName,
lastModified = lastModified
});
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] Failed to read recipe {filePath}: {ex.Message}");
}
}
Console.WriteLine($"[INFO] Loaded {recipes.Count} recipes from {_recipeFolder}");
return JsonConvert.SerializeObject(recipes);
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] Failed to get recipe list: {ex.Message}");
return "[]";
}
}
public string GetRecipe(string recipeId)
{
try
{
string recipePath = Path.Combine(_recipeFolder, $"{recipeId}.json");
if (!File.Exists(recipePath))
{
var response = new { success = false, message = "Recipe not found" };
return JsonConvert.SerializeObject(response);
}
string json = File.ReadAllText(recipePath);
Console.WriteLine($"[INFO] Loaded recipe {recipeId}");
return json;
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] Failed to get recipe {recipeId}: {ex.Message}");
var response = new { success = false, message = ex.Message };
return JsonConvert.SerializeObject(response);
}
}
public string SaveRecipe(string recipeId, string recipeData)
{
try
{
string recipePath = Path.Combine(_recipeFolder, $"{recipeId}.json");
// Validate JSON format
var recipe = JsonConvert.DeserializeObject(recipeData);
// Save to file with formatting
File.WriteAllText(recipePath, JsonConvert.SerializeObject(recipe, Formatting.Indented));
Console.WriteLine($"[INFO] Recipe {recipeId} saved successfully to {recipePath}");
var response = new { success = true, message = "Recipe saved successfully", recipeId = recipeId };
return JsonConvert.SerializeObject(response);
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] Failed to save recipe {recipeId}: {ex.Message}");
var response = new { success = false, message = ex.Message };
return JsonConvert.SerializeObject(response);
}
}
public string CopyRecipe(string recipeId, string newName)
{
Console.WriteLine($"[C#] Copying Recipe: {recipeId} as {newName}");
try
{
string sourcePath = Path.Combine(_recipeFolder, $"{recipeId}.json");
if (!File.Exists(sourcePath))
{
var response = new { success = false, message = "Source recipe not found" };
return JsonConvert.SerializeObject(response);
}
// Generate new ID
string newId = Guid.NewGuid().ToString().Substring(0, 8);
string destPath = Path.Combine(_recipeFolder, $"{newId}.json");
// Read source recipe
string json = File.ReadAllText(sourcePath);
var recipeData = JsonConvert.DeserializeObject<dynamic>(json);
// Update name in recipe data
recipeData.name = newName;
// Save to new file
File.WriteAllText(destPath, JsonConvert.SerializeObject(recipeData, Formatting.Indented));
string timestamp = DateTime.Now.ToString("yyyy-MM-dd");
Console.WriteLine($"[INFO] Recipe copied from {recipeId} to {newId}");
var response2 = new {
success = true,
message = "Recipe copied successfully",
newRecipe = new {
id = newId,
name = newName,
lastModified = timestamp
}
};
return JsonConvert.SerializeObject(response2);
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] Failed to copy recipe: {ex.Message}");
var response = new { success = false, message = ex.Message };
return JsonConvert.SerializeObject(response);
}
}
public string DeleteRecipe(string recipeId)
{
Console.WriteLine($"[C#] Deleting Recipe: {recipeId}");
try
{
// Check if recipe is in use
if (recipeId == _host.GetCurrentRecipe())
{
var response1 = new { success = false, message = "Cannot delete currently selected recipe" };
return JsonConvert.SerializeObject(response1);
}
string recipePath = Path.Combine(_recipeFolder, $"{recipeId}.json");
if (!File.Exists(recipePath))
{
var response = new { success = false, message = "Recipe not found" };
return JsonConvert.SerializeObject(response);
}
// Delete the file
File.Delete(recipePath);
Console.WriteLine($"[INFO] Recipe {recipeId} deleted successfully");
var response2 = new { success = true, message = "Recipe deleted successfully", recipeId = recipeId };
return JsonConvert.SerializeObject(response2);
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] Failed to delete recipe: {ex.Message}");
var response = new { success = false, message = ex.Message };
return JsonConvert.SerializeObject(response);
}
} }
} }
} }

View File

@@ -32,10 +32,12 @@
// //
// MainForm // MainForm
// //
this.AutoScaleDimensions = new System.Drawing.SizeF(8F, 15F); this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 12F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(800, 450); this.ClientSize = new System.Drawing.Size(1284, 961);
this.Margin = new System.Windows.Forms.Padding(3, 2, 3, 2);
this.Name = "MainForm"; this.Name = "MainForm";
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen;
this.Text = "Form1"; this.Text = "Form1";
this.Load += new System.EventHandler(this.MainForm_Load); this.Load += new System.EventHandler(this.MainForm_Load);
this.ResumeLayout(false); this.ResumeLayout(false);

View File

@@ -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;
} }
} }

View File

@@ -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,31 @@ 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));
}
else if (type == "COPY_RECIPE")
{
string recipeId = json.recipeId;
string newName = json.newName;
var bridge = new MachineBridge(_mainForm);
string resultJson = bridge.CopyRecipe(recipeId, newName);
var response = new { type = "RECIPE_COPIED", data = Newtonsoft.Json.JsonConvert.DeserializeObject(resultJson) };
await Send(socket, Newtonsoft.Json.JsonConvert.SerializeObject(response));
}
else if (type == "DELETE_RECIPE")
{
string recipeId = json.recipeId;
var bridge = new MachineBridge(_mainForm);
string resultJson = bridge.DeleteRecipe(recipeId);
var response = new { type = "RECIPE_DELETED", data = Newtonsoft.Json.JsonConvert.DeserializeObject(resultJson) };
await Send(socket, Newtonsoft.Json.JsonConvert.SerializeObject(response));
}
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@@ -1,26 +1,14 @@
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 { RecipePage } from './pages/RecipePage';
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 +35,31 @@ 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' | 'initialize' | null>(null);
const [systemState, setSystemState] = useState<SystemState>(SystemState.IDLE); const [systemState, setSystemState] = useState<SystemState>(SystemState.IDLE);
const [currentRecipe, setCurrentRecipe] = useState<Recipe>(MOCK_RECIPES[0]); const [currentRecipe, setCurrentRecipe] = useState<Recipe>({ id: '0', name: 'No Recipe', lastModified: '-' });
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 [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 +70,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 +77,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,31 +89,21 @@ 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");
// Initial IO data will be loaded by HomePage when it mounts
try {
const ioStr = await comms.getIOList();
const ioData = JSON.parse(ioStr);
setIoPoints(ioData);
addLog("IO LIST LOADED", "info");
} catch (e) {
addLog("FAILED TO LOAD IO DATA", "error");
}
setIsLoading(false); setIsLoading(false);
}; };
initSystem(); initSystem();
}, []); }, []);
// -- ON-DEMAND CONFIG FETCH --
useEffect(() => {
if (activeTab === 'setting') {
const fetchConfig = async () => {
setIsConfigRefreshing(true);
try {
const configStr = await comms.getConfig();
setConfig(JSON.parse(configStr));
addLog("CONFIG REFRESHED", "info");
} catch (e) {
addLog("CONFIG REFRESH FAILED", "error");
}
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 +120,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 +132,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 +145,80 @@ 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); addLog("CONFIGURATION SAVED", "info");
addLog("CONFIGURATION SAVED", "success");
} 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={setActiveTab}
activeTab={activeTab}
{/* LOADING OVERLAY */} isLoading={isLoading}
{isLoading && ( >
<div className="absolute inset-0 z-[100] bg-black flex flex-col items-center justify-center gap-6"> <Routes>
<div className="relative"> <Route
<div className="w-24 h-24 border-4 border-neon-blue/30 rounded-full animate-spin"></div> path="/"
<div className="absolute inset-0 border-t-4 border-neon-blue rounded-full animate-spin"></div> element={
<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" /> <HomePage
</div> systemState={systemState}
<div className="text-center"> currentRecipe={currentRecipe}
<h2 className="text-2xl font-tech font-bold text-white tracking-widest mb-2">SYSTEM INITIALIZING</h2> robotTarget={robotTarget}
<p className="font-mono text-neon-blue text-sm animate-pulse">ESTABLISHING CONNECTION...</p> logs={logs}
</div> ioPoints={ioPoints}
</div> doorStates={doorStates}
)} isLowPressure={isLowPressure}
isEmergencyStop={isEmergencyStop}
{/* HEADER */} activeTab={activeTab}
<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"> onSelectRecipe={handleSelectRecipe}
<div className="flex items-center gap-4 pointer-events-auto"> onMove={moveAxis}
<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"> onControl={handleControl}
<Cpu className="text-neon-blue w-8 h-8 animate-pulse-slow" /> onSaveConfig={handleSaveConfig}
</div> onCloseTab={() => setActiveTab(null)}
<div> videoRef={videoRef}
<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"> <Route
<span>SYS.VER 4.2.0</span> path="/io-monitor"
<span>|</span> element={
<span className={isWebView ? "text-neon-green" : "text-amber-500"}> <IOMonitorPage
LINK: {isWebView ? "NATIVE" : "SIMULATION"} onToggle={toggleIO}
</span> />
</div> }
</div> />
</div> <Route
path="/recipe"
{/* Top Navigation */} element={<RecipePage />}
<div className="flex items-center gap-2 pointer-events-auto bg-black/40 backdrop-blur-md p-1 rounded-full border border-white/10"> />
{[ </Routes>
{ id: 'recipe', icon: Layers, label: 'RECIPE' }, </Layout>
{ id: 'io', icon: Activity, label: 'I/O MONITOR' }, </HashRouter>
{ 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}
</button>
))}
</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>
{/* 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}
isRefreshing={isConfigRefreshing}
onSave={handleSaveConfig}
/>
{/* Right Sidebar (Dashboard) */}
<div className="w-80 ml-auto z-20 flex flex-col gap-4">
<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={() => handleControl('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={() => 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>
); );
} }

View File

@@ -2,6 +2,9 @@
// Check if running in WebView2 // Check if running in WebView2
const isWebView = typeof window !== 'undefined' && !!window.chrome?.webview; const isWebView = typeof window !== 'undefined' && !!window.chrome?.webview;
// 비동기 프록시 캐싱 (한 번만 초기화) - 매번 접근 시 오버헤드 제거
const machine = isWebView ? window.chrome!.webview!.hostObjects.machine : null;
type MessageCallback = (data: any) => void; type MessageCallback = (data: any) => void;
class CommunicationLayer { class CommunicationLayer {
@@ -12,7 +15,8 @@ class CommunicationLayer {
constructor() { constructor() {
if (isWebView) { if (isWebView) {
console.log("[COMM] Running in WebView2 Mode"); console.log("[COMM] Running in WebView2 Mode");
window.chrome.webview.addEventListener('message', (event: any) => { this.isConnected = true; // WebView2 is always connected
window.chrome!.webview!.addEventListener('message', (event: any) => {
this.notifyListeners(event.data); this.notifyListeners(event.data);
}); });
} else { } else {
@@ -27,6 +31,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 +46,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,11 +66,15 @@ class CommunicationLayer {
}; };
} }
public getConnectionState(): boolean {
return this.isConnected;
}
// --- API Methods --- // --- API Methods ---
public async getConfig(): Promise<string> { public async getConfig(): Promise<string> {
if (isWebView) { if (isWebView && machine) {
return await window.chrome.webview.hostObjects.machine.GetConfig(); return await machine.GetConfig();
} else { } else {
// WebSocket Request/Response Pattern // WebSocket Request/Response Pattern
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -95,37 +105,185 @@ class CommunicationLayer {
} }
} }
public async getIOList(): Promise<string> {
if (isWebView && machine) {
return await 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 && machine) {
return await 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 && machine) {
await window.chrome.webview.hostObjects.machine.SaveConfig(configJson); await machine.SaveConfig(configJson);
} else { } else {
this.ws?.send(JSON.stringify({ type: 'SAVE_CONFIG', data: JSON.parse(configJson) })); this.ws?.send(JSON.stringify({ type: 'SAVE_CONFIG', data: JSON.parse(configJson) }));
} }
} }
public async sendControl(command: string) { public async sendControl(command: string) {
if (isWebView) { if (isWebView && machine) {
await window.chrome.webview.hostObjects.machine.SystemControl(command); await machine.SystemControl(command);
} else { } else {
this.ws?.send(JSON.stringify({ type: 'CONTROL', command })); this.ws?.send(JSON.stringify({ type: 'CONTROL', command }));
} }
} }
public async moveAxis(axis: string, value: number) { public async moveAxis(axis: string, value: number) {
if (isWebView) { if (isWebView && machine) {
await window.chrome.webview.hostObjects.machine.MoveAxis(axis, value); await machine.MoveAxis(axis, value);
} else { } else {
this.ws?.send(JSON.stringify({ type: 'MOVE', axis, value })); this.ws?.send(JSON.stringify({ type: 'MOVE', axis, value }));
} }
} }
public async setIO(id: number, state: boolean) { public async setIO(id: number, state: boolean) {
if (isWebView) { if (isWebView && machine) {
await window.chrome.webview.hostObjects.machine.SetIO(id, false, state); await machine.SetIO(id, false, state);
} else { } else {
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 && machine) {
const resultJson = await 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 }));
});
}
}
public async copyRecipe(recipeId: string, newName: string): Promise<{ success: boolean; message: string; newRecipe?: any }> {
if (isWebView && machine) {
const resultJson = await machine.CopyRecipe(recipeId, newName);
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 copy timeout" });
}, 10000);
const handler = (data: any) => {
if (data.type === 'RECIPE_COPIED') {
clearTimeout(timeoutId);
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve(data.data);
}
};
this.listeners.push(handler);
this.ws?.send(JSON.stringify({ type: 'COPY_RECIPE', recipeId, newName }));
});
}
}
public async deleteRecipe(recipeId: string): Promise<{ success: boolean; message: string; recipeId?: string }> {
if (isWebView && machine) {
const resultJson = await machine.DeleteRecipe(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 delete timeout" });
}, 10000);
const handler = (data: any) => {
if (data.type === 'RECIPE_DELETED') {
clearTimeout(timeoutId);
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve(data.data);
}
};
this.listeners.push(handler);
this.ws?.send(JSON.stringify({ type: 'DELETE_RECIPE', recipeId }));
});
}
}
} }
export const comms = new CommunicationLayer(); export const comms = new CommunicationLayer();

View File

@@ -0,0 +1,181 @@
import React, { useState, useEffect } from 'react';
import { Target, CheckCircle2, Loader2 } from 'lucide-react';
import { TechButton } from './common/TechButton';
interface InitializeModalProps {
isOpen: boolean;
onClose: () => void;
}
type AxisStatus = 'pending' | 'initializing' | 'completed';
interface AxisState {
name: string;
status: AxisStatus;
progress: number;
}
export const InitializeModal: React.FC<InitializeModalProps> = ({ isOpen, onClose }) => {
const [axes, setAxes] = useState<AxisState[]>([
{ name: 'X-Axis', status: 'pending', progress: 0 },
{ name: 'Y-Axis', status: 'pending', progress: 0 },
{ name: 'Z-Axis', status: 'pending', progress: 0 },
]);
const [isInitializing, setIsInitializing] = useState(false);
// Reset state when modal closes
useEffect(() => {
if (!isOpen) {
setAxes([
{ name: 'X-Axis', status: 'pending', progress: 0 },
{ name: 'Y-Axis', status: 'pending', progress: 0 },
{ name: 'Z-Axis', status: 'pending', progress: 0 },
]);
setIsInitializing(false);
}
}, [isOpen]);
const startInitialization = () => {
setIsInitializing(true);
// Initialize each axis with 3 second delay between them
axes.forEach((axis, index) => {
const delay = index * 3000; // 0s, 3s, 6s
// Start initialization after delay
setTimeout(() => {
setAxes(prev => {
const next = [...prev];
next[index] = { ...next[index], status: 'initializing', progress: 0 };
return next;
});
// Progress animation (3 seconds)
const startTime = Date.now();
const duration = 3000;
const interval = setInterval(() => {
const elapsed = Date.now() - startTime;
const progress = Math.min((elapsed / duration) * 100, 100);
setAxes(prev => {
const next = [...prev];
next[index] = { ...next[index], progress };
return next;
});
if (progress >= 100) {
clearInterval(interval);
setAxes(prev => {
const next = [...prev];
next[index] = { ...next[index], status: 'completed', progress: 100 };
return next;
});
// Check if all axes are completed
setAxes(prev => {
if (prev.every(a => a.status === 'completed')) {
// Auto close after 500ms
setTimeout(() => {
onClose();
}, 500);
}
return prev;
});
}
}, 50);
}, delay);
});
};
if (!isOpen) return null;
const allCompleted = axes.every(a => a.status === 'completed');
return (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-[600px] glass-holo p-8 border border-neon-blue shadow-glow-blue relative flex flex-col">
<button
onClick={onClose}
disabled={isInitializing}
className="absolute top-4 right-4 text-slate-400 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed"
>
</button>
<h2 className="text-2xl font-tech font-bold text-neon-blue mb-8 border-b border-white/10 pb-4 flex items-center gap-3">
<Target className="animate-pulse" /> AXIS INITIALIZATION
</h2>
<div className="space-y-6 mb-8">
{axes.map((axis, index) => (
<div key={axis.name} className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{axis.status === 'completed' ? (
<CheckCircle2 className="w-5 h-5 text-neon-green" />
) : axis.status === 'initializing' ? (
<Loader2 className="w-5 h-5 text-neon-blue animate-spin" />
) : (
<div className="w-5 h-5 border-2 border-slate-600 rounded-full" />
)}
<span className="font-tech font-bold text-lg text-white tracking-wider">
{axis.name}
</span>
</div>
<span
className={`font-mono text-sm font-bold ${
axis.status === 'completed'
? 'text-neon-green'
: axis.status === 'initializing'
? 'text-neon-blue'
: 'text-slate-500'
}`}
>
{axis.status === 'completed'
? 'COMPLETED'
: axis.status === 'initializing'
? `${Math.round(axis.progress)}%`
: 'WAITING'}
</span>
</div>
{/* Progress Bar */}
<div className="h-3 bg-black/50 border border-slate-700 overflow-hidden">
<div
className={`h-full transition-all duration-100 ${
axis.status === 'completed'
? 'bg-neon-green shadow-[0_0_10px_rgba(10,255,0,0.5)]'
: 'bg-neon-blue shadow-[0_0_10px_rgba(0,243,255,0.5)]'
}`}
style={{ width: `${axis.progress}%` }}
/>
</div>
</div>
))}
</div>
{allCompleted && (
<div className="mb-6 p-4 bg-neon-green/10 border border-neon-green rounded text-center">
<span className="font-tech font-bold text-neon-green tracking-wider">
ALL AXES INITIALIZED SUCCESSFULLY
</span>
</div>
)}
<div className="flex justify-end gap-4">
<TechButton onClick={onClose} disabled={isInitializing}>
CANCEL
</TechButton>
<TechButton
variant="blue"
active
onClick={startInitialization}
disabled={isInitializing || allCompleted}
>
{isInitializing ? 'INITIALIZING...' : 'START INITIALIZATION'}
</TechButton>
</div>
</div>
</div>
);
};

View 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>
);
};

View File

@@ -1,40 +1,119 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { FileText, CheckCircle2, Layers } from 'lucide-react'; import { Layers, Check, Settings, X, RotateCw } 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';
import { comms } from '../communication';
interface RecipePanelProps { interface RecipePanelProps {
recipes: Recipe[]; isOpen: boolean;
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> = ({ isOpen, currentRecipe, onSelectRecipe, onClose }) => {
<div className="h-full flex flex-col"> const navigate = useNavigate();
<PanelHeader title="Recipe Select" icon={FileText} /> const [recipes, setRecipes] = useState<Recipe[]>([]);
<div className="space-y-2 flex-1 overflow-y-auto custom-scrollbar pr-2"> const [isLoading, setIsLoading] = useState(false);
{recipes.map(r => ( const [selectedId, setSelectedId] = useState<string>(currentRecipe.id);
<div
key={r.id} // Fetch recipes when panel opens
onClick={() => onSelectRecipe(r)} useEffect(() => {
className={` if (isOpen) {
p-3 cursor-pointer flex justify-between items-center transition-all border-l-4 const fetchRecipes = async () => {
${currentRecipe.id === r.id setIsLoading(true);
? 'bg-white/10 border-neon-blue text-white shadow-[inset_0_0_20px_rgba(0,243,255,0.1)]' try {
: 'bg-black/20 border-transparent text-slate-500 hover:bg-white/5 hover:text-slate-300'} const recipeStr = await comms.getRecipeList();
`} const recipeData: Recipe[] = JSON.parse(recipeStr);
> setRecipes(recipeData);
<div> } catch (e) {
<div className="font-tech font-bold text-sm tracking-wide">{r.name}</div> console.error('Failed to fetch recipes:', e);
<div className="text-[10px] font-mono opacity-60">{r.lastModified}</div> }
setIsLoading(false);
};
fetchRecipes();
}
}, [isOpen]);
// Update selected ID when currentRecipe changes
useEffect(() => {
setSelectedId(currentRecipe.id);
}, [currentRecipe.id]);
const handleConfirm = () => {
const selected = recipes.find(r => r.id === selectedId);
if (selected) {
onSelectRecipe(selected);
onClose();
}
};
if (!isOpen) return null;
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> </div>
{currentRecipe.id === r.id && <CheckCircle2 className="w-4 h-4 text-neon-blue" />} <button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
<X className="w-5 h-5" />
</button>
</div> </div>
))}
{/* List (Max height for ~5 items) */}
<div className="p-4 max-h-[320px] overflow-y-auto custom-scrollbar">
{isLoading ? (
<div className="h-64 flex flex-col items-center justify-center gap-4 animate-pulse">
<RotateCw className="w-12 h-12 text-neon-blue animate-spin" />
<div className="text-lg font-tech text-neon-blue tracking-widest">LOADING RECIPES...</div>
</div>
) : (
<div className="space-y-2">
{recipes.map(recipe => (
<div
key={recipe.id}
onClick={() => setSelectedId(recipe.id)}
className={`
p-3 rounded border cursor-pointer transition-all flex items-center justify-between
${selectedId === recipe.id
? 'bg-neon-blue/20 border-neon-blue text-white shadow-[0_0_10px_rgba(0,243,255,0.2)]'
: 'bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:border-white/20'}
`}
>
<div>
<div className="font-bold tracking-wide">{recipe.name}</div>
<div className="text-[10px] font-mono opacity-70">ID: {recipe.id} | MOD: {recipe.lastModified}</div>
</div>
{selectedId === recipe.id && <Check className="w-4 h-4 text-neon-blue" />}
</div>
))}
</div>
)}
</div>
{/* 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>
</div>
</div>
</div> </div>
<TechButton className="w-full mt-4 flex items-center justify-center gap-2"> );
<Layers className="w-4 h-4" /> New Recipe };
</TechButton>
</div>
);

View File

@@ -1,12 +1,11 @@
import React from 'react'; import React from 'react';
import { Settings, RotateCw, ChevronDown, ChevronRight } from 'lucide-react'; import { Settings, RotateCw, ChevronDown, ChevronRight } from 'lucide-react';
import { ConfigItem } from '../types'; import { ConfigItem } from '../types';
import { comms } from '../communication';
interface SettingsModalProps { interface SettingsModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
config: ConfigItem[] | null;
isRefreshing: boolean;
onSave: (config: ConfigItem[]) => void; onSave: (config: ConfigItem[]) => void;
} }
@@ -34,18 +33,31 @@ const TechButton = ({ children, onClick, active = false, variant = 'blue', class
); );
}; };
export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, config, isRefreshing, onSave }) => { export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, onSave }) => {
const [localConfig, setLocalConfig] = React.useState<ConfigItem[]>([]); const [localConfig, setLocalConfig] = React.useState<ConfigItem[]>([]);
const [expandedGroups, setExpandedGroups] = React.useState<Set<string>>(new Set()); const [expandedGroups, setExpandedGroups] = React.useState<Set<string>>(new Set());
const [isRefreshing, setIsRefreshing] = React.useState(false);
// Fetch config data when modal opens
React.useEffect(() => { React.useEffect(() => {
if (config) { if (isOpen) {
setLocalConfig(config); const fetchConfig = async () => {
// Auto-expand all groups initially setIsRefreshing(true);
const groups = new Set(config.map(c => c.Group)); try {
setExpandedGroups(groups); const configStr = await comms.getConfig();
const config: ConfigItem[] = JSON.parse(configStr);
setLocalConfig(config);
// Auto-expand all groups initially
const groups = new Set<string>(config.map(c => c.Group));
setExpandedGroups(groups);
} catch (e) {
console.error('Failed to fetch config:', e);
}
setIsRefreshing(false);
};
fetchConfig();
} }
}, [config]); }, [isOpen]);
const handleChange = (idx: number, newValue: string) => { const handleChange = (idx: number, newValue: string) => {
setLocalConfig(prev => { setLocalConfig(prev => {

View File

@@ -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>
); );

View 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>
);
};

View File

@@ -0,0 +1,101 @@
import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Activity, Settings, Move, Camera, Layers, Cpu, Target } from 'lucide-react';
interface HeaderProps {
currentTime: Date;
onTabChange: (tab: 'recipe' | 'motion' | 'camera' | 'setting' | 'initialize' | null) => void;
activeTab: 'recipe' | 'motion' | 'camera' | 'setting' | 'initialize' | 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: '/' },
{ id: 'initialize', icon: Target, label: 'INITIALIZE', 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>
);
};

View 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' | 'initialize' | null) => void;
activeTab: 'recipe' | 'motion' | 'camera' | 'setting' | 'initialize' | 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>
);
};

View File

@@ -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",

View File

@@ -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"
}, },

163
frontend/pages/HomePage.tsx Normal file
View File

@@ -0,0 +1,163 @@
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 { InitializeModal } from '../components/InitializeModal';
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;
robotTarget: RobotTarget;
logs: LogEntry[];
ioPoints: IOPoint[];
doorStates: { front: boolean; right: boolean; left: boolean; back: boolean };
isLowPressure: boolean;
isEmergencyStop: boolean;
activeTab: 'recipe' | 'motion' | 'camera' | 'setting' | 'initialize' | null;
onSelectRecipe: (r: Recipe) => void;
onMove: (axis: 'X' | 'Y' | 'Z', val: number) => void;
onControl: (action: 'start' | 'stop' | 'reset') => void;
onSaveConfig: (config: ConfigItem[]) => void;
onCloseTab: () => void;
videoRef: React.RefObject<HTMLVideoElement>;
}
export const HomePage: React.FC<HomePageProps> = ({
systemState,
currentRecipe,
robotTarget,
logs,
ioPoints,
doorStates,
isLowPressure,
isEmergencyStop,
activeTab,
onSelectRecipe,
onMove,
onControl,
onSaveConfig,
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]);
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 */}
<RecipePanel
isOpen={activeTab === 'recipe'}
currentRecipe={currentRecipe}
onSelectRecipe={onSelectRecipe}
onClose={onCloseTab}
/>
{/* Settings Modal */}
<SettingsModal
isOpen={activeTab === 'setting'}
onClose={onCloseTab}
onSave={onSaveConfig}
/>
{/* Initialize Modal */}
<InitializeModal
isOpen={activeTab === 'initialize'}
onClose={onCloseTab}
/>
{/* 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>
);
};

View File

@@ -0,0 +1,125 @@
import React, { useState, useEffect } from 'react';
import { RotateCw } from 'lucide-react';
import { IOPoint } from '../types';
import { comms } from '../communication';
interface IOMonitorPageProps {
onToggle: (id: number, type: 'input' | 'output') => void;
}
export const IOMonitorPage: React.FC<IOMonitorPageProps> = ({ onToggle }) => {
const [ioPoints, setIoPoints] = useState<IOPoint[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [activeIOTab, setActiveIOTab] = useState<'in' | 'out'>('in');
// Fetch initial IO list when page mounts
useEffect(() => {
const fetchIOList = async () => {
setIsLoading(true);
try {
const ioStr = await comms.getIOList();
const ioData: IOPoint[] = JSON.parse(ioStr);
setIoPoints(ioData);
} catch (e) {
console.error('Failed to fetch IO list:', e);
}
setIsLoading(false);
};
fetchIOList();
// Subscribe to real-time IO updates
const unsubscribe = comms.subscribe((msg: any) => {
if (msg?.type === 'STATUS_UPDATE' && msg.ioState) {
setIoPoints(prev => {
const newIO = [...prev];
msg.ioState.forEach((update: { id: number, type: string, state: boolean }) => {
const idx = newIO.findIndex(p => p.id === update.id && p.type === update.type);
if (idx >= 0) newIO[idx] = { ...newIO[idx], state: update.state };
});
return newIO;
});
}
});
return () => {
unsubscribe();
};
}, []);
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={() => setActiveIOTab('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={() => setActiveIOTab('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">
{isLoading ? (
<div className="h-full flex flex-col items-center justify-center gap-4 animate-pulse">
<RotateCw className="w-16 h-16 text-neon-blue animate-spin" />
<div className="text-xl font-tech text-neon-blue tracking-widest">LOADING IO POINTS...</div>
</div>
) : (
<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>
);
};

View File

@@ -0,0 +1,245 @@
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);
};
const handleCopy = async () => {
if (!selectedId) return;
const selectedRecipe = recipes.find(r => r.id === selectedId);
if (!selectedRecipe) return;
const newName = prompt(`Copy "${selectedRecipe.name}" as:`, `${selectedRecipe.name}_Copy`);
if (!newName || newName.trim() === '') return;
try {
const result = await comms.copyRecipe(selectedId, newName.trim());
if (result.success && result.newRecipe) {
const newRecipeList = [...recipes, result.newRecipe];
setRecipes(newRecipeList);
setSelectedId(result.newRecipe.id);
setEditedRecipe(result.newRecipe);
console.log("Recipe copied successfully:", result.newRecipe);
} else {
alert(`Failed to copy recipe: ${result.message}`);
}
} catch (error: any) {
alert(`Error copying recipe: ${error.message || 'Unknown error'}`);
console.error('Recipe copy error:', error);
}
};
const handleDelete = async () => {
if (!selectedId) return;
const selectedRecipe = recipes.find(r => r.id === selectedId);
if (!selectedRecipe) return;
const confirmed = window.confirm(`Are you sure you want to delete "${selectedRecipe.name}"?`);
if (!confirmed) return;
try {
const result = await comms.deleteRecipe(selectedId);
if (result.success) {
const newRecipeList = recipes.filter(r => r.id !== selectedId);
setRecipes(newRecipeList);
// Select first recipe or clear selection
if (newRecipeList.length > 0) {
setSelectedId(newRecipeList[0].id);
setEditedRecipe(newRecipeList[0]);
} else {
setSelectedId(null);
setEditedRecipe(null);
}
console.log("Recipe deleted successfully");
} else {
alert(`Failed to delete recipe: ${result.message}`);
}
} catch (error: any) {
alert(`Error deleting recipe: ${error.message || 'Unknown error'}`);
console.error('Recipe delete error:', error);
}
};
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"
onClick={handleCopy}
disabled={!selectedId}
>
<Copy className="w-4 h-4" />
</TechButton>
<TechButton
variant="danger"
className="flex justify-center"
title="Delete Selected"
onClick={handleDelete}
disabled={!selectedId}
>
<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>
);
};

View File

@@ -52,15 +52,19 @@ export interface ConfigItem {
declare global { declare global {
interface Window { interface Window {
chrome: { chrome?: {
webview: { webview?: {
hostObjects: { hostObjects: {
machine: { machine: {
MoveAxis(axis: string, value: number): Promise<void>; MoveAxis(axis: string, value: number): Promise<void>;
SetIO(id: number, isInput: boolean, state: boolean): Promise<void>; SetIO(id: number, isInput: boolean, state: boolean): Promise<void>;
SystemControl(command: string): Promise<void>; SystemControl(command: string): Promise<void>;
LoadRecipe(recipeId: string): Promise<void>; SelectRecipe(recipeId: string): Promise<string>;
CopyRecipe(recipeId: string, newName: string): Promise<string>;
DeleteRecipe(recipeId: string): Promise<string>;
GetConfig(): Promise<string>; GetConfig(): Promise<string>;
GetIOList(): Promise<string>;
GetRecipeList(): Promise<string>;
SaveConfig(configJson: string): Promise<void>; SaveConfig(configJson: string): Promise<void>;
} }
}; };