feat: Add vision controls, function menu, and custom alert dialogs

- Add Vision menu with Camera (QRCode) and Barcode (Keyence) controls
- Add Function menu with Manage, Log Viewer, and folder navigation
- Add quick action buttons (Manual, Light, Print, Cancel) to header
- Replace browser alert() with custom AlertDialog component
- Add MachineBridge methods for vision, lighting, folders, and manual operations
- Add WebSocketServer handlers for all new commands
- Add communication layer methods for frontend-backend integration

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-25 23:39:38 +09:00
parent 6219c4c60e
commit 8bbd76e670
13 changed files with 1398 additions and 112 deletions

View File

@@ -6,6 +6,7 @@ import { IOMonitorPage } from './pages/IOMonitorPage';
import { RecipePage } from './pages/RecipePage';
import { SystemState, Recipe, IOPoint, LogEntry, RobotTarget, ConfigItem } from './types';
import { comms } from './communication';
import { AlertProvider } from './contexts/AlertContext';
// --- MOCK DATA ---
@@ -187,52 +188,55 @@ export default function App() {
};
return (
<HashRouter>
<Layout
currentTime={currentTime}
isHostConnected={isHostConnected}
robotTarget={robotTarget}
onTabChange={setActiveTab}
activeTab={activeTab}
isLoading={isLoading}
>
<Routes>
<Route
path="/"
element={
<HomePage
systemState={systemState}
currentRecipe={currentRecipe}
robotTarget={robotTarget}
logs={logs}
ioPoints={ioPoints}
doorStates={doorStates}
isLowPressure={isLowPressure}
isEmergencyStop={isEmergencyStop}
activeTab={activeTab}
onSelectRecipe={handleSelectRecipe}
onMove={moveAxis}
onControl={handleControl}
onSaveConfig={handleSaveConfig}
onCloseTab={() => setActiveTab(null)}
videoRef={videoRef}
/>
}
/>
<Route
path="/io-monitor"
element={
<IOMonitorPage
onToggle={toggleIO}
/>
}
/>
<Route
path="/recipe"
element={<RecipePage />}
/>
</Routes>
</Layout>
</HashRouter>
<AlertProvider>
<HashRouter>
<Layout
currentTime={currentTime}
isHostConnected={isHostConnected}
robotTarget={robotTarget}
onTabChange={setActiveTab}
activeTab={activeTab}
isLoading={isLoading}
>
<Routes>
<Route
path="/"
element={
<HomePage
systemState={systemState}
currentRecipe={currentRecipe}
robotTarget={robotTarget}
logs={logs}
ioPoints={ioPoints}
doorStates={doorStates}
isLowPressure={isLowPressure}
isEmergencyStop={isEmergencyStop}
isHostConnected={isHostConnected}
activeTab={activeTab}
onSelectRecipe={handleSelectRecipe}
onMove={moveAxis}
onControl={handleControl}
onSaveConfig={handleSaveConfig}
onCloseTab={() => setActiveTab(null)}
videoRef={videoRef}
/>
}
/>
<Route
path="/io-monitor"
element={
<IOMonitorPage
onToggle={toggleIO}
/>
}
/>
<Route
path="/recipe"
element={<RecipePage />}
/>
</Routes>
</Layout>
</HashRouter>
</AlertProvider>
);
}

View File

@@ -431,6 +431,259 @@ class CommunicationLayer {
});
}
}
// ===== VISION CONTROL METHODS =====
private async sendVisionCommand(command: string, responseType: string): Promise<{ success: boolean; message: string }> {
if (isWebView && machine) {
// WebView2 mode - direct call to C# methods
return { success: false, message: 'Vision commands not yet implemented in WebView2 mode' };
} 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: "Vision command timeout" });
}, 10000);
const handler = (data: any) => {
if (data.type === responseType) {
clearTimeout(timeoutId);
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve(data.data);
}
};
this.listeners.push(handler);
this.ws?.send(JSON.stringify({ type: command }));
});
}
}
public async cameraConnect(): Promise<{ success: boolean; message: string }> {
return this.sendVisionCommand('CAMERA_CONNECT', 'CAMERA_RESULT');
}
public async cameraDisconnect(): Promise<{ success: boolean; message: string }> {
return this.sendVisionCommand('CAMERA_DISCONNECT', 'CAMERA_RESULT');
}
public async cameraGetImage(): Promise<{ success: boolean; message: string }> {
return this.sendVisionCommand('CAMERA_GET_IMAGE', 'CAMERA_RESULT');
}
public async cameraLiveView(): Promise<{ success: boolean; message: string }> {
return this.sendVisionCommand('CAMERA_LIVE_VIEW', 'CAMERA_RESULT');
}
public async cameraReadTest(): Promise<{ success: boolean; message: string }> {
return this.sendVisionCommand('CAMERA_READ_TEST', 'CAMERA_RESULT');
}
public async keyenceTriggerOn(): Promise<{ success: boolean; message: string }> {
return this.sendVisionCommand('KEYENCE_TRIGGER_ON', 'KEYENCE_RESULT');
}
public async keyenceTriggerOff(): Promise<{ success: boolean; message: string }> {
return this.sendVisionCommand('KEYENCE_TRIGGER_OFF', 'KEYENCE_RESULT');
}
public async keyenceGetImage(): Promise<{ success: boolean; message: string }> {
return this.sendVisionCommand('KEYENCE_GET_IMAGE', 'KEYENCE_RESULT');
}
public async keyenceSaveImage(): Promise<{ success: boolean; message: string }> {
return this.sendVisionCommand('KEYENCE_SAVE_IMAGE', 'KEYENCE_RESULT');
}
// Light, Manual Print, Cancel Job commands
public async toggleLight(): Promise<{ success: boolean; message: string }> {
if (isWebView && machine) {
return { success: false, message: 'Light control not yet implemented in WebView2 mode' };
} else {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.listeners = this.listeners.filter(cb => cb !== handler);
reject(new Error('Light toggle timeout'));
}, 5000);
const handler = (data: any) => {
if (data.type === 'LIGHT_RESULT') {
clearTimeout(timeoutId);
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve(data.data);
}
};
this.listeners.push(handler);
this.ws?.send(JSON.stringify({ type: 'TOGGLE_LIGHT' }));
});
}
}
public async openManualPrint(): Promise<{ success: boolean; message: string }> {
if (isWebView && machine) {
return { success: false, message: 'Manual print not yet implemented in WebView2 mode' };
} else {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.listeners = this.listeners.filter(cb => cb !== handler);
reject(new Error('Manual print timeout'));
}, 5000);
const handler = (data: any) => {
if (data.type === 'MANUAL_PRINT_RESULT') {
clearTimeout(timeoutId);
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve(data.data);
}
};
this.listeners.push(handler);
this.ws?.send(JSON.stringify({ type: 'OPEN_MANUAL_PRINT' }));
});
}
}
public async cancelJob(): Promise<{ success: boolean; message: string }> {
if (isWebView && machine) {
return { success: false, message: 'Cancel job not yet implemented in WebView2 mode' };
} else {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.listeners = this.listeners.filter(cb => cb !== handler);
reject(new Error('Cancel job timeout'));
}, 5000);
const handler = (data: any) => {
if (data.type === 'CANCEL_JOB_RESULT') {
clearTimeout(timeoutId);
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve(data.data);
}
};
this.listeners.push(handler);
this.ws?.send(JSON.stringify({ type: 'CANCEL_JOB' }));
});
}
}
public async openManage(): Promise<{ success: boolean; message: string }> {
if (isWebView && machine) {
return { success: false, message: 'Manage not yet implemented in WebView2 mode' };
} else {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.listeners = this.listeners.filter(cb => cb !== handler);
reject(new Error('Open manage timeout'));
}, 5000);
const handler = (data: any) => {
if (data.type === 'MANAGE_RESULT') {
clearTimeout(timeoutId);
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve(data.data);
}
};
this.listeners.push(handler);
this.ws?.send(JSON.stringify({ type: 'OPEN_MANAGE' }));
});
}
}
public async openManual(): Promise<{ success: boolean; message: string }> {
if (isWebView && machine) {
return { success: false, message: 'Manual not yet implemented in WebView2 mode' };
} else {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.listeners = this.listeners.filter(cb => cb !== handler);
reject(new Error('Open manual timeout'));
}, 5000);
const handler = (data: any) => {
if (data.type === 'MANUAL_RESULT') {
clearTimeout(timeoutId);
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve(data.data);
}
};
this.listeners.push(handler);
this.ws?.send(JSON.stringify({ type: 'OPEN_MANUAL' }));
});
}
}
public async openLogViewer(): Promise<{ success: boolean; message: string }> {
if (isWebView && machine) {
return { success: false, message: 'Log viewer not yet implemented in WebView2 mode' };
} else {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.listeners = this.listeners.filter(cb => cb !== handler);
reject(new Error('Open log viewer timeout'));
}, 5000);
const handler = (data: any) => {
if (data.type === 'LOG_VIEWER_RESULT') {
clearTimeout(timeoutId);
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve(data.data);
}
};
this.listeners.push(handler);
this.ws?.send(JSON.stringify({ type: 'OPEN_LOG_VIEWER' }));
});
}
}
private async openFolder(command: string): Promise<{ success: boolean; message: string }> {
if (isWebView && machine) {
return { success: false, message: 'Folder open not yet implemented in WebView2 mode' };
} else {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.listeners = this.listeners.filter(cb => cb !== handler);
reject(new Error('Open folder timeout'));
}, 5000);
const handler = (data: any) => {
if (data.type === 'FOLDER_RESULT') {
clearTimeout(timeoutId);
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve(data.data);
}
};
this.listeners.push(handler);
this.ws?.send(JSON.stringify({ type: command }));
});
}
}
public async openProgramFolder(): Promise<{ success: boolean; message: string }> {
return this.openFolder('OPEN_PROGRAM_FOLDER');
}
public async openLogFolder(): Promise<{ success: boolean; message: string }> {
return this.openFolder('OPEN_LOG_FOLDER');
}
public async openScreenshotFolder(): Promise<{ success: boolean; message: string }> {
return this.openFolder('OPEN_SCREENSHOT_FOLDER');
}
public async openSavedDataFolder(): Promise<{ success: boolean; message: string }> {
return this.openFolder('OPEN_SAVED_DATA_FOLDER');
}
}
export const comms = new CommunicationLayer();

View File

@@ -0,0 +1,80 @@
import React from 'react';
import { AlertTriangle, CheckCircle, Info, XCircle } from 'lucide-react';
interface AlertDialogProps {
isOpen: boolean;
type: 'success' | 'error' | 'warning' | 'info';
title: string;
message: string;
onClose: () => void;
}
export const AlertDialog: React.FC<AlertDialogProps> = ({ isOpen, type, title, message, onClose }) => {
if (!isOpen) return null;
const getIcon = () => {
switch (type) {
case 'success':
return <CheckCircle className="w-12 h-12 text-green-500" />;
case 'error':
return <XCircle className="w-12 h-12 text-red-500" />;
case 'warning':
return <AlertTriangle className="w-12 h-12 text-amber-500" />;
case 'info':
return <Info className="w-12 h-12 text-blue-500" />;
}
};
const getBorderColor = () => {
switch (type) {
case 'success':
return 'border-green-500';
case 'error':
return 'border-red-500';
case 'warning':
return 'border-amber-500';
case 'info':
return 'border-blue-500';
}
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/80 backdrop-blur-sm"
onClick={onClose}
/>
{/* Dialog */}
<div
className={`relative bg-black/95 backdrop-blur-md border-2 ${getBorderColor()} rounded-lg shadow-2xl max-w-md w-full mx-4 animate-fadeIn`}
>
{/* Icon and Title */}
<div className="flex items-center gap-4 p-6 border-b border-white/10">
{getIcon()}
<h2 className="text-xl font-tech font-bold text-white uppercase tracking-wider">
{title}
</h2>
</div>
{/* Message */}
<div className="p-6">
<p className="text-slate-300 font-mono text-sm whitespace-pre-wrap leading-relaxed">
{message}
</p>
</div>
{/* Button */}
<div className="flex justify-end p-6 border-t border-white/10">
<button
onClick={onClose}
className="px-6 py-2 bg-neon-blue/20 hover:bg-neon-blue/30 border border-neon-blue text-neon-blue font-tech font-bold rounded transition-colors shadow-glow-blue"
>
OK
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,140 @@
import React, { useState, useRef, useEffect } from 'react';
import { Package, X, ChevronRight } from 'lucide-react';
import { comms } from '../communication';
import { useAlert } from '../contexts/AlertContext';
interface FunctionMenuProps {
isOpen: boolean;
onClose: () => void;
}
export const FunctionMenu: React.FC<FunctionMenuProps> = ({ isOpen, onClose }) => {
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const { showAlert } = useAlert();
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen, onClose]);
if (!isOpen) return null;
const handleCommand = async (commandFn: () => Promise<{ success: boolean; message: string }>, actionName: string) => {
try {
const result = await commandFn();
if (result.success) {
console.log(`[FunctionMenu] ${actionName}: ${result.message}`);
} else {
console.error(`[FunctionMenu] ${actionName} failed: ${result.message}`);
showAlert({
type: 'error',
title: `${actionName} Failed`,
message: result.message
});
}
} catch (error: any) {
console.error(`[FunctionMenu] ${actionName} error:`, error);
showAlert({
type: 'error',
title: `${actionName} Error`,
message: error.message || 'Unknown error'
});
}
};
return (
<div
ref={menuRef}
className="absolute top-20 left-1/2 -translate-x-1/2 z-50 bg-black/95 backdrop-blur-md border border-neon-blue/50 rounded-lg shadow-glow-blue min-w-[300px]"
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-white/10">
<div className="flex items-center gap-3">
<Package className="w-5 h-5 text-neon-blue" />
<h3 className="font-tech font-bold text-lg text-white">FUNCTION</h3>
</div>
<button
onClick={onClose}
className="text-slate-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Menu Items */}
<div className="p-2">
{/* Manage */}
<button
onClick={() => handleCommand(() => comms.openManage(), 'Manage')}
className="w-full flex items-center justify-between px-4 py-3 text-left text-slate-300 hover:bg-neon-blue/10 hover:text-neon-blue rounded transition-colors font-tech"
>
<span>Manage</span>
</button>
{/* Log Viewer */}
<button
onClick={() => handleCommand(() => comms.openLogViewer(), 'Log Viewer')}
className="w-full flex items-center justify-between px-4 py-3 text-left text-slate-300 hover:bg-neon-blue/10 hover:text-neon-blue rounded transition-colors font-tech"
>
<span>Log Viewer</span>
</button>
{/* Open Folder */}
<div className="relative">
<button
onMouseEnter={() => setActiveSubmenu('folder')}
className="w-full flex items-center justify-between px-4 py-3 text-left text-slate-300 hover:bg-neon-blue/10 hover:text-neon-blue rounded transition-colors font-tech"
>
<span>Open Folder</span>
<ChevronRight className="w-4 h-4" />
</button>
{/* Folder Submenu */}
{activeSubmenu === 'folder' && (
<div
className="absolute left-full top-0 ml-1 bg-black/95 backdrop-blur-md border border-neon-blue/50 rounded-lg shadow-glow-blue min-w-[200px] p-2"
onMouseLeave={() => setActiveSubmenu(null)}
>
<button
onClick={() => handleCommand(() => comms.openProgramFolder(), 'Program Folder')}
className="w-full px-4 py-2 text-left text-slate-300 hover:bg-neon-blue/10 hover:text-neon-blue rounded transition-colors text-sm"
>
Program
</button>
<button
onClick={() => handleCommand(() => comms.openLogFolder(), 'Log Folder')}
className="w-full px-4 py-2 text-left text-slate-300 hover:bg-neon-blue/10 hover:text-neon-blue rounded transition-colors text-sm"
>
Log
</button>
<button
onClick={() => handleCommand(() => comms.openScreenshotFolder(), 'Screenshot Folder')}
className="w-full px-4 py-2 text-left text-slate-300 hover:bg-neon-blue/10 hover:text-neon-blue rounded transition-colors text-sm"
>
Screenshot
</button>
<button
onClick={() => handleCommand(() => comms.openSavedDataFolder(), 'Saved Data Folder')}
className="w-full px-4 py-2 text-left text-slate-300 hover:bg-neon-blue/10 hover:text-neon-blue rounded transition-colors text-sm"
>
Saved Data
</button>
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -1,5 +1,7 @@
import React, { useState, useRef, useEffect } from 'react';
import { Camera, ChevronRight, X } from 'lucide-react';
import { comms } from '../communication';
import { useAlert } from '../contexts/AlertContext';
interface VisionMenuProps {
isOpen: boolean;
@@ -9,6 +11,7 @@ interface VisionMenuProps {
export const VisionMenu: React.FC<VisionMenuProps> = ({ isOpen, onClose }) => {
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const { showAlert } = useAlert();
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -28,14 +31,27 @@ export const VisionMenu: React.FC<VisionMenuProps> = ({ isOpen, onClose }) => {
if (!isOpen) return null;
const handleQRCodeCamera = () => {
console.log('[VisionMenu] Camera (QRCode) clicked');
// TODO: Implement QR Code camera functionality
};
const handleKeyenceBarcode = () => {
console.log('[VisionMenu] Barcode (Keyence) clicked');
// TODO: Implement Keyence barcode functionality
const handleVisionCommand = async (commandFn: () => Promise<{ success: boolean; message: string }>, actionName: string) => {
try {
const result = await commandFn();
if (result.success) {
console.log(`[VisionMenu] ${actionName}: ${result.message}`);
} else {
console.error(`[VisionMenu] ${actionName} failed: ${result.message}`);
showAlert({
type: 'error',
title: `${actionName} Failed`,
message: result.message
});
}
} catch (error: any) {
console.error(`[VisionMenu] ${actionName} error:`, error);
showAlert({
type: 'error',
title: `${actionName} Error`,
message: error.message || 'Unknown error'
});
}
};
return (
@@ -63,7 +79,6 @@ export const VisionMenu: React.FC<VisionMenuProps> = ({ isOpen, onClose }) => {
<div className="relative">
<button
onMouseEnter={() => setActiveSubmenu('qrcode')}
onClick={handleQRCodeCamera}
className="w-full flex items-center justify-between px-4 py-3 text-left text-slate-300 hover:bg-neon-blue/10 hover:text-neon-blue rounded transition-colors font-tech"
>
<span>Camera (QRCode)</span>
@@ -77,26 +92,26 @@ export const VisionMenu: React.FC<VisionMenuProps> = ({ isOpen, onClose }) => {
onMouseLeave={() => setActiveSubmenu(null)}
>
<button
onClick={() => console.log('[VisionMenu] QRCode - Connect')}
onClick={() => handleVisionCommand(() => comms.cameraConnect(), 'Camera Connect')}
className="w-full px-4 py-2 text-left text-slate-300 hover:bg-neon-blue/10 hover:text-neon-blue rounded transition-colors text-sm"
>
Connect
</button>
<div className="h-px bg-white/10 my-1" />
<button
onClick={() => console.log('[VisionMenu] QRCode - Get Image')}
onClick={() => handleVisionCommand(() => comms.cameraGetImage(), 'Camera Get Image')}
className="w-full px-4 py-2 text-left text-slate-300 hover:bg-neon-blue/10 hover:text-neon-blue rounded transition-colors text-sm"
>
Get Image
</button>
<button
onClick={() => console.log('[VisionMenu] QRCode - Live View')}
onClick={() => handleVisionCommand(() => comms.cameraLiveView(), 'Camera Live View')}
className="w-full px-4 py-2 text-left text-slate-300 hover:bg-neon-blue/10 hover:text-neon-blue rounded transition-colors text-sm"
>
Live View
</button>
<button
onClick={() => console.log('[VisionMenu] QRCode - Read Test')}
onClick={() => handleVisionCommand(() => comms.cameraReadTest(), 'Camera Read Test')}
className="w-full px-4 py-2 text-left text-slate-300 hover:bg-neon-blue/10 hover:text-neon-blue rounded transition-colors text-sm"
>
Read Test
@@ -109,7 +124,6 @@ export const VisionMenu: React.FC<VisionMenuProps> = ({ isOpen, onClose }) => {
<div className="relative">
<button
onMouseEnter={() => setActiveSubmenu('keyence')}
onClick={handleKeyenceBarcode}
className="w-full flex items-center justify-between px-4 py-3 text-left text-slate-300 hover:bg-neon-blue/10 hover:text-neon-blue rounded transition-colors font-tech"
>
<span>Barcode (Keyence)</span>
@@ -123,26 +137,26 @@ export const VisionMenu: React.FC<VisionMenuProps> = ({ isOpen, onClose }) => {
onMouseLeave={() => setActiveSubmenu(null)}
>
<button
onClick={() => console.log('[VisionMenu] Keyence - Get Image')}
onClick={() => handleVisionCommand(() => comms.keyenceGetImage(), 'Keyence Get Image')}
className="w-full px-4 py-2 text-left text-slate-300 hover:bg-neon-blue/10 hover:text-neon-blue rounded transition-colors text-sm"
>
Get Image
</button>
<div className="h-px bg-white/10 my-1" />
<button
onClick={() => console.log('[VisionMenu] Keyence - Trigger On')}
onClick={() => handleVisionCommand(() => comms.keyenceTriggerOn(), 'Keyence Trigger On')}
className="w-full px-4 py-2 text-left text-slate-300 hover:bg-neon-blue/10 hover:text-neon-blue rounded transition-colors text-sm"
>
Trigger On
</button>
<button
onClick={() => console.log('[VisionMenu] Keyence - Trigger Off')}
onClick={() => handleVisionCommand(() => comms.keyenceTriggerOff(), 'Keyence Trigger Off')}
className="w-full px-4 py-2 text-left text-slate-300 hover:bg-neon-blue/10 hover:text-neon-blue rounded transition-colors text-sm"
>
Trigger Off
</button>
<button
onClick={() => console.log('[VisionMenu] Keyence - Save Image')}
onClick={() => handleVisionCommand(() => comms.keyenceSaveImage(), 'Keyence Save Image')}
className="w-full px-4 py-2 text-left text-slate-300 hover:bg-neon-blue/10 hover:text-neon-blue rounded transition-colors text-sm"
>
Save Image

View File

@@ -1,7 +1,10 @@
import React, { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Activity, Settings, Move, Camera, Layers, Cpu, Target } from 'lucide-react';
import { Activity, Settings, Move, Camera, Layers, Cpu, Target, Lightbulb, Printer, XCircle, Package, BookOpen } from 'lucide-react';
import { VisionMenu } from '../VisionMenu';
import { FunctionMenu } from '../FunctionMenu';
import { comms } from '../../communication';
import { useAlert } from '../../contexts/AlertContext';
interface HeaderProps {
currentTime: Date;
@@ -14,13 +17,40 @@ export const Header: React.FC<HeaderProps> = ({ currentTime, onTabChange, active
const navigate = useNavigate();
const location = useLocation();
const [showVisionMenu, setShowVisionMenu] = useState(false);
const [showFunctionMenu, setShowFunctionMenu] = useState(false);
const { showAlert } = useAlert();
const isWebView = typeof window !== 'undefined' && !!window.chrome?.webview;
const isIOPage = location.pathname === '/io-monitor';
const handleCommand = async (commandFn: () => Promise<{ success: boolean; message: string }>, actionName: string) => {
try {
const result = await commandFn();
if (result.success) {
console.log(`[Header] ${actionName}: ${result.message}`);
// Show success message if needed
} else {
console.error(`[Header] ${actionName} failed: ${result.message}`);
showAlert({
type: 'error',
title: `${actionName} Failed`,
message: result.message
});
}
} catch (error: any) {
console.error(`[Header] ${actionName} error:`, error);
showAlert({
type: 'error',
title: `${actionName} Error`,
message: error.message || 'Unknown error'
});
}
};
return (
<>
<VisionMenu isOpen={showVisionMenu} onClose={() => setShowVisionMenu(false)} />
<FunctionMenu isOpen={showFunctionMenu} onClose={() => setShowFunctionMenu(false)} />
<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"
@@ -48,15 +78,50 @@ export const Header: React.FC<HeaderProps> = ({ currentTime, onTabChange, active
{/* Top Navigation */}
<div className="flex items-center gap-2 pointer-events-auto">
{/* IO Tab Switcher (only on IO page) */}
{/* Quick Action Buttons */}
<div className="bg-black/40 backdrop-blur-md p-1 rounded-2xl border border-white/10 flex gap-1 mr-2">
<button
onClick={() => handleCommand(() => comms.openManual(), 'Manual')}
className="flex flex-col items-center justify-center gap-1 px-3 py-2 rounded-xl font-tech font-bold text-[10px] transition-all border border-transparent min-w-[70px] text-slate-400 hover:text-blue-400 hover:bg-white/5"
title="Open Manual"
>
<BookOpen className="w-5 h-5" />
<span className="leading-tight">MANUAL</span>
</button>
<button
onClick={() => handleCommand(() => comms.toggleLight(), 'Toggle Light')}
className="flex flex-col items-center justify-center gap-1 px-3 py-2 rounded-xl font-tech font-bold text-[10px] transition-all border border-transparent min-w-[70px] text-slate-400 hover:text-amber-400 hover:bg-white/5"
title="Toggle Room Light"
>
<Lightbulb className="w-5 h-5" />
<span className="leading-tight">LIGHT</span>
</button>
<button
onClick={() => handleCommand(() => comms.openManualPrint(), 'Manual Print')}
className="flex flex-col items-center justify-center gap-1 px-3 py-2 rounded-xl font-tech font-bold text-[10px] transition-all border border-transparent min-w-[70px] text-slate-400 hover:text-cyan-400 hover:bg-white/5"
title="Open Manual Print"
>
<Printer className="w-5 h-5" />
<span className="leading-tight">PRINT</span>
</button>
<button
onClick={() => handleCommand(() => comms.cancelJob(), 'Cancel Job')}
className="flex flex-col items-center justify-center gap-1 px-3 py-2 rounded-xl font-tech font-bold text-[10px] transition-all border border-transparent min-w-[70px] text-slate-400 hover:text-red-400 hover:bg-white/5"
title="Cancel Current Job"
>
<XCircle className="w-5 h-5" />
<span className="leading-tight">CANCEL</span>
</button>
</div>
{/* Main Navigation */}
<div className="bg-black/40 backdrop-blur-md p-1 rounded-2xl 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: 'function', icon: Package, label: 'FUNCTION', path: '/' },
{ id: 'setting', icon: Settings, label: 'CONFIG', path: '/' },
{ id: 'initialize', icon: Target, label: 'INITIALIZE', path: '/' }
].map(item => {
@@ -72,20 +137,35 @@ export const Header: React.FC<HeaderProps> = ({ currentTime, onTabChange, active
navigate('/io-monitor');
onTabChange(null);
setShowVisionMenu(false);
setShowFunctionMenu(false);
} else if (item.id === 'camera') {
setShowVisionMenu(!showVisionMenu);
onTabChange(null);
// Camera button does nothing on click
} else if (item.id === 'function') {
// Function button does nothing on click
} else {
if (location.pathname !== '/') {
navigate('/');
}
setShowVisionMenu(false);
setShowFunctionMenu(false);
onTabChange(activeTab === item.id ? null : item.id as any);
}
}}
onMouseEnter={() => {
if (item.id === 'camera') {
setShowVisionMenu(true);
setShowFunctionMenu(false);
} else if (item.id === 'function') {
setShowFunctionMenu(true);
setShowVisionMenu(false);
} else {
setShowVisionMenu(false);
setShowFunctionMenu(false);
}
}}
className={`
flex flex-col items-center justify-center gap-1 px-3 py-2 rounded-xl font-tech font-bold text-[10px] transition-all border border-transparent min-w-[70px]
${isActive
${isActive || (item.id === 'camera' && showVisionMenu) || (item.id === 'function' && showFunctionMenu)
? 'bg-neon-blue/10 text-neon-blue border-neon-blue shadow-glow-blue'
: 'text-slate-400 hover:text-white hover:bg-white/5'}
`}

View File

@@ -0,0 +1,118 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
interface AlertConfig {
type: 'success' | 'error' | 'warning' | 'info';
title: string;
message: string;
}
interface AlertContextType {
showAlert: (config: AlertConfig) => void;
}
const AlertContext = createContext<AlertContextType | undefined>(undefined);
export const useAlert = () => {
const context = useContext(AlertContext);
if (!context) {
throw new Error('useAlert must be used within AlertProvider');
}
return context;
};
interface AlertProviderProps {
children: ReactNode;
}
export const AlertProvider: React.FC<AlertProviderProps> = ({ children }) => {
const [alertConfig, setAlertConfig] = useState<(AlertConfig & { isOpen: boolean }) | null>(null);
const showAlert = (config: AlertConfig) => {
setAlertConfig({ ...config, isOpen: true });
};
const closeAlert = () => {
setAlertConfig(null);
};
return (
<AlertContext.Provider value={{ showAlert }}>
{children}
{alertConfig && (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/80 backdrop-blur-sm"
onClick={closeAlert}
/>
{/* Dialog */}
<div
className={`relative bg-black/95 backdrop-blur-md border-2 ${getBorderColor(alertConfig.type)} rounded-lg shadow-2xl max-w-md w-full mx-4 animate-fadeIn`}
>
{/* Icon and Title */}
<div className="flex items-center gap-4 p-6 border-b border-white/10">
{getIcon(alertConfig.type)}
<h2 className="text-xl font-tech font-bold text-white uppercase tracking-wider">
{alertConfig.title}
</h2>
</div>
{/* Message */}
<div className="p-6 max-w-full overflow-hidden">
<p className="text-slate-300 font-mono text-sm whitespace-pre-wrap leading-relaxed break-words max-w-full">
{alertConfig.message}
</p>
</div>
{/* Button */}
<div className="flex justify-end p-6 border-t border-white/10">
<button
onClick={closeAlert}
className="px-6 py-2 bg-neon-blue/20 hover:bg-neon-blue/30 border border-neon-blue text-neon-blue font-tech font-bold rounded transition-colors shadow-glow-blue"
>
OK
</button>
</div>
</div>
</div>
)}
</AlertContext.Provider>
);
};
const getIcon = (type: 'success' | 'error' | 'warning' | 'info') => {
const icons = {
success: (
<svg className="w-12 h-12 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
error: (
<svg className="w-12 h-12 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
warning: (
<svg className="w-12 h-12 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
),
info: (
<svg className="w-12 h-12 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
};
return icons[type];
};
const getBorderColor = (type: 'success' | 'error' | 'warning' | 'info') => {
const colors = {
success: 'border-green-500',
error: 'border-red-500',
warning: 'border-amber-500',
info: 'border-blue-500',
};
return colors[type];
};

View File

@@ -21,6 +21,7 @@ interface HomePageProps {
doorStates: { front: boolean; right: boolean; left: boolean; back: boolean };
isLowPressure: boolean;
isEmergencyStop: boolean;
isHostConnected: boolean;
activeTab: 'recipe' | 'motion' | 'camera' | 'setting' | 'initialize' | null;
onSelectRecipe: (r: Recipe) => void;
onMove: (axis: 'X' | 'Y' | 'Z', val: number) => void;
@@ -39,6 +40,7 @@ export const HomePage: React.FC<HomePageProps> = ({
doorStates,
isLowPressure,
isEmergencyStop,
isHostConnected,
activeTab,
onSelectRecipe,
onMove,
@@ -56,6 +58,16 @@ export const HomePage: React.FC<HomePageProps> = ({
{/* 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">
{!isHostConnected && !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">
<AlertTriangle className="w-16 h-16" />
<div>
<h1 className="text-4xl font-tech font-bold tracking-widest">HOST DISCONNECTED</h1>
<p className="text-center font-mono text-base">WAITING FOR CONNECTION...</p>
<p className="text-center font-mono text-sm mt-2 text-red-200">PLEASE CHECK THE HANDLER PROGRAM</p>
</div>
</div>
)}
{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" />
@@ -65,7 +77,7 @@ export const HomePage: React.FC<HomePageProps> = ({
</div>
</div>
)}
{isLowPressure && !isEmergencyStop && (
{isLowPressure && !isEmergencyStop && isHostConnected && (
<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>

View File

@@ -37,6 +37,7 @@ export default {
'gradient': 'gradient 15s ease infinite',
'scan': 'scan 4s linear infinite',
'spin-slow': 'spin 10s linear infinite',
'fadeIn': 'fadeIn 0.2s ease-out',
},
keyframes: {
glow: {
@@ -51,6 +52,10 @@ export default {
scan: {
'0%': { transform: 'translateY(-100%)' },
'100%': { transform: 'translateY(100%)' },
},
fadeIn: {
'0%': { opacity: '0', transform: 'scale(0.95)' },
'100%': { opacity: '1', transform: 'scale(1)' },
}
}
}