import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import './index.css';
import logo from './assets/logo.png';
interface QueuedOutput {
id: string;
target: string;
content: string;
status: 'pending' | 'approved' | 'rejected';
metadata: string | null;
comment: string | null;
created_at: string;
}
const App: React.FC = () => {
const { t, i18n } = useTranslation();
const [items, setItems] = useState<QueuedOutput[]>([]);
const [loading, setLoading] = useState(true);
const [currentView, setCurrentView] = useState<'pending' | 'settings'>('pending');
const [sidebarWidth, setSidebarWidth] = useState(280);
const [masterWidth, setMasterWidth] = useState(320);
const [resizingType, setResizingType] = useState<'sidebar' | 'master' | null>(null);
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
// Auto-select first item
useEffect(() => {
if (items.length > 0 && !selectedItemId) {
setSelectedItemId(items[0].id);
}
}, [items]);
useEffect(() => {
document.documentElement.style.setProperty('--dynamic-sidebar-width', `${sidebarWidth}px`);
}, [sidebarWidth]);
useEffect(() => {
document.documentElement.style.setProperty('--dynamic-master-width', `${masterWidth}px`);
}, [masterWidth]);
const handleLanguageChange = (lang: string) => {
i18n.changeLanguage(lang);
};
const startResizing = (type: 'sidebar' | 'master') => (e: React.MouseEvent) => {
setResizingType(type);
e.preventDefault();
};
const stopResizing = () => {
setResizingType(null);
};
const resize = (e: MouseEvent) => {
if (resizingType === 'sidebar') {
const newWidth = e.clientX - 64; // 64px is the activity bar width
if (newWidth > 150 && newWidth < 600) {
setSidebarWidth(newWidth);
}
} else if (resizingType === 'master') {
const sidebarTotal = 64 + sidebarWidth;
const newWidth = e.clientX - sidebarTotal;
if (newWidth > 200 && newWidth < 800) {
setMasterWidth(newWidth);
}
}
};
useEffect(() => {
if (resizingType) {
window.addEventListener('mousemove', resize);
window.addEventListener('mouseup', stopResizing);
} else {
window.removeEventListener('mousemove', resize);
window.removeEventListener('mouseup', stopResizing);
}
return () => {
window.removeEventListener('mousemove', resize);
window.removeEventListener('mouseup', stopResizing);
};
}, [resizingType, sidebarWidth]); // need sidebarWidth for master resizing calc
useEffect(() => {
document.documentElement.style.setProperty('--dynamic-sidebar-width', `${sidebarWidth}px`);
}, [sidebarWidth]);
useEffect(() => {
document.documentElement.style.setProperty('--dynamic-master-width', `${masterWidth}px`);
}, [masterWidth]);
const fetchItems = async (showLoading = true) => {
try {
if (showLoading) setLoading(true);
const response = await fetch('http://localhost:3001/api/pending');
const data = await response.json();
setItems(data);
} catch (err) {
console.error('Failed to fetch items:', err);
} finally {
if (showLoading) setLoading(false);
}
};
useEffect(() => {
let isMounted = true;
const initialFetch = async () => {
await fetchItems(true);
};
initialFetch();
// SSE Setup
const eventSource = new EventSource('http://localhost:3001/api/sse');
eventSource.onmessage = (event) => {
console.log('SSE Event received:', event.data);
if (isMounted) {
fetchItems(false);
}
};
eventSource.onerror = (err) => {
console.error('SSE Error:', err);
eventSource.close();
};
return () => {
isMounted = false;
eventSource.close();
};
}, []);
const handleApprove = async (id: string) => {
try {
await fetch(`http://localhost:3001/api/approve/${id}`, { method: 'POST' });
fetchItems(false);
} catch (err) {
alert('Failed to approve');
}
};
const handleReject = async (id: string) => {
const comment = prompt('Enter rejection feedback:');
if (comment === null) return;
try {
await fetch(`http://localhost:3001/api/reject/${id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ comment }),
});
fetchItems(false);
} catch (err) {
alert('Failed to reject');
}
};
const mcpConfigSnippet = {
"mcpServers": {
"exolauncher": {
"type": "sse",
"url": "http://localhost:3001/mcp/sse"
}
}
};
useEffect(() => {
document.documentElement.style.setProperty('--dynamic-sidebar-width', `${sidebarWidth}px`);
}, [sidebarWidth]);
return (
<div className="app-container">
{/* Sidebar (Dual Pane) */}
<aside className="sidebar">
{/* Activity Bar (Icons Only) */}
<div className="activity-bar">
<div className="logo-mini">
<img src={logo} alt="Logo" />
</div>
<button
className={`activity-item ${currentView === 'pending' ? 'active' : ''}`}
onClick={() => setCurrentView('pending')}
title="Pending Queue"
>
<svg className="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"></polyline>
<path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path>
</svg>
</button>
<button
className={`activity-item ${currentView === 'settings' ? 'active' : ''}`}
onClick={() => setCurrentView('settings')}
title="MCP Settings"
>
<svg className="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
</button>
</div>
{/* Side Panel (Details) */}
<div className="side-panel">
<div className="panel-header">
<h3>{currentView === 'pending' ? t('common.dashboard') : t('common.configuration')}</h3>
</div>
<nav className="panel-nav">
{currentView === 'pending' ? (
<>
<div className="panel-label">{t('sidebar.queues')}</div>
<button className="panel-item active">{t('sidebar.pending')}</button>
<button className="panel-item">{t('sidebar.history')}</button>
</>
) : (
<>
<div className="panel-label">{t('sidebar.settings')}</div>
<button className="panel-item active">{t('sidebar.mcp_servers')}</button>
<button className="panel-item">{t('sidebar.network')}</button>
</>
)}
</nav>
<div className="sidebar-footer">
{t('common.version', { version: '0.0.0' })}
</div>
</div>
{/* Resizer Handle */}
<div className="resizer" onMouseDown={startResizing('sidebar')} />
</aside>
<div className="main-content">
<header className="main-header">
<h1>{currentView === 'pending' ? t('views.pending_approvals') : t('views.mcp_settings')}</h1>
<div className="status-badge">{t('common.status')}</div>
</header>
<main className="content">
{currentView === 'pending' ? (
loading ? (
<div className="loading">{t('common.loading')}</div>
) : (
<div className="split-view">
<div className="master-list">
{items.length === 0 ? (
<div className="empty-list-message">
<p>{t('views.empty')}</p>
</div>
) : (
items.map((item) => (
<div
key={item.id}
className={`list-item ${selectedItemId === item.id ? 'active' : ''}`}
onClick={() => setSelectedItemId(item.id)}
>
<div className="item-meta">
<span className="item-target">{item.target}</span>
<span className="item-time">{new Date(item.created_at).toLocaleTimeString(i18n.language === 'ja' ? 'ja-JP' : 'en-US', { hour: '2-digit', minute: '2-digit' })}</span>
</div>
<div className="item-snippet">{item.content.substring(0, 40)}...</div>
</div>
))
)}
</div>
{/* Master-Detail Resizer */}
<div className="resizer-v" onMouseDown={startResizing('master')} />
<div className="detail-pane">
{items.length > 0 && items.find(i => i.id === selectedItemId) ? (
(() => {
const selectedItem = items.find(i => i.id === selectedItemId)!;
return (
<div className="preview-container">
<div className="preview-header">
<div className="header-info">
<h2>{selectedItem.target}</h2>
<span className="full-timestamp">{new Date(selectedItem.created_at).toLocaleString()}</span>
</div>
<div className="header-actions">
<button className="btn-reject" onClick={() => handleReject(selectedItem.id)}>{t('views.reject')}</button>
<button className="btn-approve" onClick={() => handleApprove(selectedItem.id)}>{t('views.approve')}</button>
</div>
</div>
<div className="preview-body">
<pre className="full-content">{selectedItem.content}</pre>
{selectedItem.metadata && (
<div className="detail-metadata">
<h4>Metadata</h4>
<code>{selectedItem.metadata}</code>
</div>
)}
</div>
</div>
);
})()
) : (
<div className="no-selection">
<div className="empty-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1-8.313-12.454z"></path>
<path d="M12 10l2 4l4 2l-4 2l-2 4l-2-4l-4-2l4-2z"></path>
</svg>
</div>
<p>{items.length === 0 ? '' : 'Select an item to preview'}</p>
</div>
)}
</div>
</div>
)
) : (
<div className="settings-view">
<div className="settings-card">
<h3>{t('sidebar.mcp_servers')}</h3>
<p>{t('views.mcp_config_help')}</p>
<div className="code-block">
<pre>{JSON.stringify(mcpConfigSnippet, null, 2)}</pre>
<button className="copy-btn" onClick={() => navigator.clipboard.writeText(JSON.stringify(mcpConfigSnippet, null, 2))}>
{t('views.copy_json')}
</button>
</div>
<div className="info-box">
<strong>{t('views.note')}:</strong> {t('views.sse_note')}
</div>
</div>
<div className="settings-card">
<h3>{t('settings.language')}</h3>
<div className="select-wrapper">
<select
className="lang-select"
title={t('settings.language')}
value={i18n.language}
onChange={(e) => handleLanguageChange(e.target.value)}
>
<option value="en">{t('settings.en')}</option>
<option value="ja">{t('settings.ja')}</option>
</select>
</div>
</div>
</div>
)}
</main>
</div>
</div>
);
};
export default App;