feat: Implement recipe selection with backend integration
Backend changes (C#): - Add SelectRecipe method to MachineBridge for recipe selection - Add currentRecipeId tracking in MainForm - Implement SELECT_RECIPE handler in WebSocketServer Frontend changes (React/TypeScript): - Add selectRecipe method to communication layer - Update handleSelectRecipe to call backend and handle response - Recipe selection updates ModelInfoPanel automatically - Add error handling and logging for recipe operations Layout improvements: - Add Layout component with persistent Header and Footer - Create separate IOMonitorPage for full-screen I/O monitoring - Add dynamic IO tab switcher in Header (Inputs/Outputs) - Ensure consistent UI across all pages 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
365
frontend/App.tsx
365
frontend/App.tsx
@@ -1,26 +1,13 @@
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Activity, Settings, Move, Camera, Play, Square, RotateCw,
|
||||
Cpu, AlertTriangle, Siren, Terminal, Layers
|
||||
} from 'lucide-react';
|
||||
import { Machine3D } from './components/Machine3D';
|
||||
import { SettingsModal } from './components/SettingsModal';
|
||||
import { RecipePanel } from './components/RecipePanel';
|
||||
import { IOPanel } from './components/IOPanel';
|
||||
import { MotionPanel } from './components/MotionPanel';
|
||||
import { CameraPanel } from './components/CameraPanel';
|
||||
import { CyberPanel } from './components/common/CyberPanel';
|
||||
import { TechButton } from './components/common/TechButton';
|
||||
import { HashRouter, Routes, Route } from 'react-router-dom';
|
||||
import { Layout } from './components/layout/Layout';
|
||||
import { HomePage } from './pages/HomePage';
|
||||
import { IOMonitorPage } from './pages/IOMonitorPage';
|
||||
import { SystemState, Recipe, IOPoint, LogEntry, RobotTarget, ConfigItem } from './types';
|
||||
import { comms } from './communication';
|
||||
|
||||
// --- 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[] = [
|
||||
...Array.from({ length: 32 }, (_, i) => {
|
||||
@@ -47,34 +34,35 @@ const INITIAL_IO: IOPoint[] = [
|
||||
// --- MAIN APP ---
|
||||
|
||||
export default function App() {
|
||||
const [activeTab, setActiveTab] = useState<'recipe' | 'io' | 'motion' | 'camera' | 'setting' | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'recipe' | 'motion' | 'camera' | 'setting' | null>(null);
|
||||
const [activeIOTab, setActiveIOTab] = useState<'in' | 'out'>('in');
|
||||
const [systemState, setSystemState] = useState<SystemState>(SystemState.IDLE);
|
||||
const [currentRecipe, setCurrentRecipe] = useState<Recipe>(MOCK_RECIPES[0]);
|
||||
const [recipes, setRecipes] = useState<Recipe[]>([]);
|
||||
const [currentRecipe, setCurrentRecipe] = useState<Recipe | null>(null);
|
||||
const [robotTarget, setRobotTarget] = useState<RobotTarget>({ x: 0, y: 0, z: 0 });
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [ioPoints, setIoPoints] = useState<IOPoint[]>(INITIAL_IO);
|
||||
const [ioPoints, setIoPoints] = useState<IOPoint[]>([]);
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [config, setConfig] = useState<ConfigItem[] | null>(null);
|
||||
const [isConfigRefreshing, setIsConfigRefreshing] = useState(false);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const videoRef = useRef<any>(null);
|
||||
|
||||
// Check if running in WebView2 context
|
||||
const isWebView = typeof window !== 'undefined' && !!window.chrome?.webview;
|
||||
const [isHostConnected, setIsHostConnected] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// -- COMMUNICATION LAYER --
|
||||
useEffect(() => {
|
||||
// Subscribe to unified communication layer
|
||||
const unsubscribe = comms.subscribe((msg: any) => {
|
||||
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') {
|
||||
// Update Motion State
|
||||
if (msg.position) {
|
||||
setRobotTarget({ x: msg.position.x, y: msg.position.y, z: msg.position.z });
|
||||
}
|
||||
// Update IO State (Merge with existing names/configs)
|
||||
if (msg.ioState) {
|
||||
setIoPoints(prev => {
|
||||
const newIO = [...prev];
|
||||
@@ -85,7 +73,6 @@ export default function App() {
|
||||
return newIO;
|
||||
});
|
||||
}
|
||||
// Update System State
|
||||
if (msg.sysState) {
|
||||
setSystemState(msg.sysState as SystemState);
|
||||
}
|
||||
@@ -93,8 +80,8 @@ export default function App() {
|
||||
});
|
||||
|
||||
addLog("COMMUNICATION CHANNEL OPEN", "info");
|
||||
setIsHostConnected(comms.getConnectionState());
|
||||
|
||||
// Timer for Clock
|
||||
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
@@ -105,30 +92,39 @@ export default function App() {
|
||||
// -- INITIALIZATION --
|
||||
useEffect(() => {
|
||||
const initSystem = async () => {
|
||||
// Just start up without fetching config
|
||||
addLog("SYSTEM STARTED", "info");
|
||||
try {
|
||||
const ioStr = await comms.getIOList();
|
||||
const ioData = JSON.parse(ioStr);
|
||||
setIoPoints(ioData);
|
||||
addLog("IO LIST LOADED", "info");
|
||||
|
||||
const recipeStr = await comms.getRecipeList();
|
||||
const recipeData = JSON.parse(recipeStr);
|
||||
setRecipes(recipeData);
|
||||
if (recipeData.length > 0) setCurrentRecipe(recipeData[0]);
|
||||
addLog("RECIPE LIST LOADED", "info");
|
||||
} catch (e) {
|
||||
addLog("FAILED TO LOAD SYSTEM DATA", "error");
|
||||
// Fallback to empty or keep initial if needed
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
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();
|
||||
// -- CONFIG FETCHING (for settings modal) --
|
||||
const fetchConfig = React.useCallback(async () => {
|
||||
setIsConfigRefreshing(true);
|
||||
try {
|
||||
const configStr = await comms.getConfig();
|
||||
setConfig(JSON.parse(configStr));
|
||||
addLog("CONFIG REFRESHED", "info");
|
||||
} catch (e) {
|
||||
addLog("CONFIG REFRESH FAILED", "error");
|
||||
}
|
||||
}, [activeTab]);
|
||||
setIsConfigRefreshing(false);
|
||||
}, []);
|
||||
|
||||
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));
|
||||
@@ -146,8 +142,6 @@ export default function App() {
|
||||
|
||||
// -- COMMAND HANDLERS --
|
||||
|
||||
// -- COMMAND HANDLERS --
|
||||
|
||||
const handleControl = async (action: 'start' | 'stop' | 'reset') => {
|
||||
if (isEmergencyStop && action === 'start') return addLog('EMERGENCY STOP ACTIVE', 'error');
|
||||
|
||||
@@ -160,7 +154,6 @@ export default function App() {
|
||||
};
|
||||
|
||||
const toggleIO = async (id: number, type: 'input' | 'output', forceState?: boolean) => {
|
||||
// Only allow output toggling
|
||||
if (type === 'output') {
|
||||
const current = ioPoints.find(p => p.id === id && p.type === type)?.state;
|
||||
const nextState = forceState !== undefined ? forceState : !current;
|
||||
@@ -174,215 +167,87 @@ export default function App() {
|
||||
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[]) => {
|
||||
try {
|
||||
await comms.saveConfig(JSON.stringify(newConfig));
|
||||
setConfig(newConfig);
|
||||
addLog("CONFIGURATION SAVED", "success");
|
||||
addLog("CONFIGURATION SAVED", "info");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
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 (
|
||||
<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>
|
||||
<Cpu className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-neon-blue w-10 h-10 animate-pulse" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-tech font-bold text-white tracking-widest mb-2">SYSTEM INITIALIZING</h2>
|
||||
<p className="font-mono text-neon-blue text-sm animate-pulse">ESTABLISHING CONNECTION...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HEADER */}
|
||||
<header className="absolute top-0 left-0 right-0 h-20 px-6 flex items-center justify-between z-40 bg-gradient-to-b from-black/80 to-transparent pointer-events-none">
|
||||
<div className="flex items-center gap-4 pointer-events-auto">
|
||||
<div className="w-12 h-12 border-2 border-neon-blue flex items-center justify-center rounded shadow-glow-blue bg-black/50 backdrop-blur">
|
||||
<Cpu className="text-neon-blue w-8 h-8 animate-pulse-slow" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-tech font-bold text-white tracking-widest uppercase italic text-shadow-glow-blue">
|
||||
EQUI-HANDLER <span className="text-neon-blue text-sm not-italic">PRO</span>
|
||||
</h1>
|
||||
<div className="flex gap-2 text-[10px] text-neon-blue/70 font-mono">
|
||||
<span>SYS.VER 4.2.0</span>
|
||||
<span>|</span>
|
||||
<span className={isWebView ? "text-neon-green" : "text-amber-500"}>
|
||||
LINK: {isWebView ? "NATIVE" : "SIMULATION"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Navigation */}
|
||||
<div className="flex items-center gap-2 pointer-events-auto bg-black/40 backdrop-blur-md p-1 rounded-full border border-white/10">
|
||||
{[
|
||||
{ id: 'recipe', icon: Layers, label: 'RECIPE' },
|
||||
{ id: 'io', icon: Activity, label: 'I/O MONITOR' },
|
||||
{ id: 'motion', icon: Move, label: 'MOTION' },
|
||||
{ id: 'camera', icon: Camera, label: 'VISION' },
|
||||
{ id: 'setting', icon: Settings, label: 'CONFIG' }
|
||||
].map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveTab(activeTab === item.id ? null : item.id as any)}
|
||||
className={`
|
||||
flex items-center gap-2 px-6 py-2 rounded-full font-tech font-bold text-sm transition-all border border-transparent
|
||||
${activeTab === item.id
|
||||
? 'bg-neon-blue/10 text-neon-blue border-neon-blue shadow-glow-blue'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'}
|
||||
`}
|
||||
>
|
||||
<item.icon className="w-4 h-4" /> {item.label}
|
||||
</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>
|
||||
<HashRouter>
|
||||
<Layout
|
||||
currentTime={currentTime}
|
||||
isHostConnected={isHostConnected}
|
||||
robotTarget={robotTarget}
|
||||
onTabChange={(tab) => {
|
||||
setActiveTab(tab);
|
||||
if (tab === null) setActiveIOTab('in'); // Reset IO tab when closing
|
||||
}}
|
||||
activeTab={activeTab}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<HomePage
|
||||
systemState={systemState}
|
||||
currentRecipe={currentRecipe || { id: '0', name: 'No Recipe', lastModified: '-' }}
|
||||
recipes={recipes}
|
||||
robotTarget={robotTarget}
|
||||
logs={logs}
|
||||
ioPoints={ioPoints}
|
||||
config={config}
|
||||
isConfigRefreshing={isConfigRefreshing}
|
||||
doorStates={doorStates}
|
||||
isLowPressure={isLowPressure}
|
||||
isEmergencyStop={isEmergencyStop}
|
||||
activeTab={activeTab}
|
||||
onSelectRecipe={handleSelectRecipe}
|
||||
onMove={moveAxis}
|
||||
onControl={handleControl}
|
||||
onSaveConfig={handleSaveConfig}
|
||||
onFetchConfig={fetchConfig}
|
||||
onCloseTab={() => setActiveTab(null)}
|
||||
videoRef={videoRef}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/io-monitor"
|
||||
element={
|
||||
<IOMonitorPage
|
||||
ioPoints={ioPoints}
|
||||
onToggle={toggleIO}
|
||||
activeIOTab={activeIOTab}
|
||||
onIOTabChange={setActiveIOTab}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Layout>
|
||||
</HashRouter>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user