feat: implement ofxpimapper-full-stack
- Unified setup script with OpenFrameworks and ofxPiMapper installation. - Integrated C++ source modification for 'Highlight Surface' OSC listener. - Full-featured 5-tab React/Tailwind web UI (Dashboard, Mapping, Media, Network, System). - WebSocket-based real-time vertex/surface manipulation and throttled log streaming. - Media Vault with ffprobe validation (H.264) and Mapping Manifest persistence. - Hybrid Networking UI with WiFi scanning, connection, and AP configuration. - System control endpoints for Reboot, Shutdown, and Mapping Save.
This commit is contained in:
@ -5,9 +5,11 @@ import { io } from 'socket.io-client';
|
||||
const socket = io('http://localhost:3000');
|
||||
|
||||
function App() {
|
||||
const [activeTab, setActiveTab] = useState('Dashboard');
|
||||
const [surfaces, setSurfaces] = useState([
|
||||
{
|
||||
type: 'quad',
|
||||
name: 'Screen 1',
|
||||
selected: true,
|
||||
vertices: [
|
||||
{ x: 0.1, y: 0.1 },
|
||||
@ -17,57 +19,123 @@ function App() {
|
||||
]
|
||||
}
|
||||
]);
|
||||
const [dragging, setDragging] = useState(null);
|
||||
const [dragging, setDragging] = useState(null); // { type: 'vertex'|'surface', sIndex, vIndex, startPos }
|
||||
const [stats, setStats] = useState({ cpu: '0', mem: { used: '0', total: '0' }, temp: '0' });
|
||||
const canvasRef = useRef(null);
|
||||
const [mapperStatus, setMapperStatus] = useState('unknown');
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [mediaFiles, setMediaFiles] = useState([]);
|
||||
const [manifest, setManifest] = useState({ surfaces: [] });
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(async () => {
|
||||
// Stats Polling
|
||||
const statsInterval = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch('http://localhost:3000/system/stats');
|
||||
const data = await res.json();
|
||||
setStats(data);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch stats');
|
||||
}
|
||||
} catch (e) {}
|
||||
}, 2000);
|
||||
return () => clearInterval(interval);
|
||||
|
||||
// Initial Data Fetch
|
||||
fetch('http://localhost:3000/mapper/status').then(r => r.json()).then(d => setMapperStatus(d.status));
|
||||
fetch('http://localhost:3000/media').then(r => r.json()).then(d => setMediaFiles(d.files || []));
|
||||
fetch('http://localhost:3000/manifest').then(r => r.json()).then(d => setManifest(d));
|
||||
|
||||
// Socket Listeners
|
||||
socket.on('mapper:status', (data) => setMapperStatus(data.status));
|
||||
socket.on('mapper:logs', (data) => {
|
||||
setLogs(prev => [...prev, ...data.logs].slice(-50));
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearInterval(statsInterval);
|
||||
socket.off('mapper:status');
|
||||
socket.off('mapper:logs');
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchMedia = async () => {
|
||||
const res = await fetch('http://localhost:3000/media');
|
||||
const data = await res.json();
|
||||
setMediaFiles(data.files || []);
|
||||
};
|
||||
|
||||
const handleFileUpload = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
await fetch('http://localhost:3000/media/upload', { method: 'POST', body: formData });
|
||||
fetchMedia();
|
||||
};
|
||||
|
||||
const deleteMedia = async (filename) => {
|
||||
await fetch(`http://localhost:3000/media/${filename}`, { method: 'DELETE' });
|
||||
fetchMedia();
|
||||
};
|
||||
|
||||
const assignMedia = async (filename) => {
|
||||
const selectedIndex = surfaces.findIndex(s => s.selected);
|
||||
if (selectedIndex === -1) return alert('Please select a surface first');
|
||||
const surface = surfaces[selectedIndex];
|
||||
|
||||
await fetch('http://localhost:3000/media/assign', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ surfaceIndex: selectedIndex, filename, surfaceName: surface.name })
|
||||
});
|
||||
|
||||
const res = await fetch('http://localhost:3000/manifest');
|
||||
const data = await res.json();
|
||||
setManifest(data);
|
||||
};
|
||||
|
||||
const handleVertexMouseDown = (e, sIndex, vIndex) => {
|
||||
e.stopPropagation();
|
||||
setDragging({ sIndex, vIndex });
|
||||
setDragging({ type: 'vertex', sIndex, vIndex });
|
||||
};
|
||||
|
||||
const handleSurfaceMoveStart = (e, sIndex) => {
|
||||
e.stopPropagation();
|
||||
const svg = e.currentTarget.closest('svg');
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const y = (e.clientY - rect.top) / rect.height;
|
||||
setDragging({ type: 'surface', sIndex, startPos: { x, y } });
|
||||
};
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (!dragging) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
|
||||
const svg = e.currentTarget.querySelector('svg');
|
||||
if (!svg) return;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const y = (e.clientY - rect.top) / rect.height;
|
||||
|
||||
const newSurfaces = [...surfaces];
|
||||
newSurfaces[dragging.sIndex].vertices[dragging.vIndex] = { x, y };
|
||||
const s = newSurfaces[dragging.sIndex];
|
||||
|
||||
if (dragging.type === 'vertex') {
|
||||
const boundedX = Math.max(0, Math.min(1, x));
|
||||
const boundedY = Math.max(0, Math.min(1, y));
|
||||
s.vertices[dragging.vIndex] = { x: boundedX, y: boundedY };
|
||||
socket.emit('vertex:move', { surfaceIndex: dragging.sIndex, vertexIndex: dragging.vIndex, x: boundedX, y: boundedY });
|
||||
} else if (dragging.type === 'surface') {
|
||||
const dx = x - dragging.startPos.x;
|
||||
const dy = y - dragging.startPos.y;
|
||||
s.vertices = s.vertices.map(v => ({ x: v.x + dx, y: v.y + dy }));
|
||||
setDragging({ ...dragging, startPos: { x, y } });
|
||||
s.vertices.forEach((v, vi) => {
|
||||
socket.emit('vertex:move', { surfaceIndex: dragging.sIndex, vertexIndex: vi, x: v.x, y: v.y });
|
||||
});
|
||||
}
|
||||
setSurfaces(newSurfaces);
|
||||
|
||||
// Task 3.4: Send to backend
|
||||
socket.emit('vertex:move', {
|
||||
surfaceIndex: dragging.sIndex,
|
||||
vertexIndex: dragging.vIndex,
|
||||
x,
|
||||
y
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setDragging(null);
|
||||
};
|
||||
const handleMouseUp = () => setDragging(null);
|
||||
|
||||
const handleSurfaceSelect = (sIndex) => {
|
||||
const newSurfaces = surfaces.map((s, i) => ({
|
||||
...s,
|
||||
selected: i === sIndex
|
||||
}));
|
||||
const newSurfaces = surfaces.map((s, i) => ({ ...s, selected: i === sIndex }));
|
||||
setSurfaces(newSurfaces);
|
||||
socket.emit('surface:select', { surfaceIndex: sIndex });
|
||||
};
|
||||
@ -75,6 +143,7 @@ function App() {
|
||||
const addSurface = (type) => {
|
||||
const newSurface = {
|
||||
type,
|
||||
name: `New ${type}`,
|
||||
selected: true,
|
||||
vertices: type === 'quad'
|
||||
? [{ x: 0.4, y: 0.4 }, { x: 0.6, y: 0.4 }, { x: 0.6, y: 0.6 }, { x: 0.4, y: 0.6 }]
|
||||
@ -84,92 +153,306 @@ function App() {
|
||||
socket.emit('surface:add', { type });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white p-8">
|
||||
<header className="mb-8 flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold tracking-tighter text-blue-500">MPVJ <span className="text-white font-normal">Headless Control Center</span></h1>
|
||||
const deleteSurface = (sIndex) => {
|
||||
const newSurfaces = surfaces.filter((_, i) => i !== sIndex);
|
||||
setSurfaces(newSurfaces);
|
||||
socket.emit('surface:delete', { surfaceIndex: sIndex });
|
||||
};
|
||||
|
||||
const renameSurface = (sIndex) => {
|
||||
const newName = prompt('Enter new name for surface:', surfaces[sIndex].name || `Surface ${sIndex}`);
|
||||
if (newName) {
|
||||
const newSurfaces = [...surfaces];
|
||||
newSurfaces[sIndex].name = newName;
|
||||
setSurfaces(newSurfaces);
|
||||
}
|
||||
};
|
||||
|
||||
const highlightSurface = (sIndex) => {
|
||||
socket.emit('surface:highlight', { surfaceIndex: sIndex });
|
||||
};
|
||||
|
||||
const renderDashboard = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-gray-900 p-6 rounded-xl border border-gray-800 text-center">
|
||||
<div className="text-3xl font-mono text-emerald-500">{stats.cpu}%</div>
|
||||
<div className="text-xs uppercase text-gray-500 mt-2 font-bold tracking-widest">CPU LOAD</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 p-6 rounded-xl border border-gray-800 text-center">
|
||||
<div className="text-3xl font-mono text-blue-500">{stats.mem.used}MB</div>
|
||||
<div className="text-xs uppercase text-gray-500 mt-2 font-bold tracking-widest">RAM USAGE</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 p-6 rounded-xl border border-gray-800 text-center">
|
||||
<div className="text-3xl font-mono text-orange-500">{stats.temp}°C</div>
|
||||
<div className="text-xs uppercase text-gray-500 mt-2 font-bold tracking-widest">CPU TEMP</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 p-6 rounded-xl border border-gray-800 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-3 h-3 rounded-full ${mapperStatus === 'running' ? 'bg-emerald-500 animate-pulse' : 'bg-red-500'}`}></div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-white">ofxPiMapper</div>
|
||||
<div className="text-xs text-gray-500 uppercase font-bold tracking-wider">{mapperStatus}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => fetch('http://localhost:3000/mapper/start', { method: 'POST' })} className="bg-emerald-600 hover:bg-emerald-500 px-4 py-2 rounded-md text-xs font-bold uppercase transition-colors">Start</button>
|
||||
<button onClick={() => fetch('http://localhost:3000/mapper/stop', { method: 'POST' })} className="bg-red-600 hover:bg-red-500 px-4 py-2 rounded-md text-xs font-bold uppercase transition-colors">Stop</button>
|
||||
<button onClick={() => fetch('http://localhost:3000/mapper/restart', { method: 'POST' })} className="bg-orange-600 hover:bg-orange-500 px-4 py-2 rounded-md text-xs font-bold uppercase transition-colors">Restart</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderMapping = () => (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
<div className="relative bg-gray-950 rounded-lg overflow-hidden aspect-video border border-gray-800"
|
||||
onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp}>
|
||||
<VirtualCanvas
|
||||
surfaces={surfaces}
|
||||
onVertexMove={handleVertexMouseDown}
|
||||
onSurfaceSelect={handleSurfaceSelect}
|
||||
onSurfaceMoveStart={handleSurfaceMoveStart}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button onClick={() => addSurface('quad')} className="bg-blue-600 hover:bg-blue-500 px-4 py-2 rounded-md text-sm font-medium transition-colors">+ Add Quad</button>
|
||||
<button onClick={() => addSurface('triangle')} className="bg-emerald-600 hover:bg-emerald-500 px-4 py-2 rounded-md text-sm font-medium transition-colors">+ Add Triangle</button>
|
||||
<button onClick={() => addSurface('quad')} className="bg-blue-600 hover:bg-blue-500 px-6 py-3 rounded-lg text-sm font-bold transition-all shadow-lg">+ Add Quad</button>
|
||||
<button onClick={() => addSurface('triangle')} className="bg-emerald-600 hover:bg-emerald-500 px-6 py-3 rounded-lg text-sm font-bold transition-all shadow-lg">+ Add Triangle</button>
|
||||
<button onClick={() => fetch('http://localhost:3000/mapper/save', { method: 'POST' })} className="bg-purple-600 hover:bg-purple-500 px-6 py-3 rounded-lg text-sm font-bold transition-all ml-auto flex items-center gap-2">
|
||||
💾 Save Mapping
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-[10px] font-bold uppercase tracking-widest text-gray-500">Surface Hierarchy</h3>
|
||||
<div className="space-y-2 max-h-[500px] overflow-y-auto pr-2 custom-scrollbar">
|
||||
{surfaces.map((s, i) => (
|
||||
<div key={i} className={`p-4 rounded-xl border transition-all cursor-pointer ${s.selected ? 'bg-blue-600/10 border-blue-500' : 'bg-gray-900 border-gray-800 hover:border-gray-700'}`}
|
||||
onClick={() => handleSurfaceSelect(i)}>
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-xs">{s.name || `Surface ${i}`}</span>
|
||||
<span className="text-[9px] uppercase tracking-widest text-gray-500 font-mono">{s.type}</span>
|
||||
</div>
|
||||
<button onClick={(e) => { e.stopPropagation(); deleteSurface(i); }} className="text-gray-600 hover:text-red-500">✕</button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={(e) => { e.stopPropagation(); highlightSurface(i); }} className="flex-1 bg-gray-800 hover:bg-gray-700 py-1 rounded text-[9px] uppercase font-bold tracking-widest">Highlight</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); renameSurface(i); }} className="flex-1 bg-gray-800 hover:bg-gray-700 py-1 rounded text-[9px] uppercase font-bold tracking-widest text-blue-400">Rename</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderMedia = () => (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-6 space-y-4">
|
||||
<h2 className="text-xl font-bold">Media Vault</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{mediaFiles.map((f, i) => (
|
||||
<div key={i} className="bg-gray-800 p-4 rounded-lg flex flex-col items-center gap-3 border border-transparent hover:border-blue-500 transition-all cursor-pointer group">
|
||||
<div className="w-full aspect-video bg-black/50 rounded flex items-center justify-center text-gray-600">
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
|
||||
</div>
|
||||
<span className="text-[10px] font-medium truncate w-full text-center">{f}</span>
|
||||
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => assignMedia(f)} className="text-[9px] bg-blue-600 px-2 py-1 rounded font-bold uppercase">Assign</button>
|
||||
<button onClick={() => deleteMedia(f)} className="text-[9px] bg-red-600 px-2 py-1 rounded font-bold uppercase">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<label className="border-2 border-dashed border-gray-700 hover:border-blue-500/50 hover:bg-blue-500/5 aspect-video rounded-lg flex flex-col items-center justify-center cursor-pointer transition-all group">
|
||||
<svg className="w-8 h-8 text-gray-600 group-hover:text-blue-500 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" /></svg>
|
||||
<span className="text-[10px] text-gray-500 group-hover:text-blue-500 font-bold uppercase">Upload</span>
|
||||
<input type="file" className="hidden" onChange={handleFileUpload} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-6">
|
||||
<h2 className="text-xl font-bold mb-4">Live Assignment</h2>
|
||||
<div className="space-y-4">
|
||||
{manifest.surfaces.length === 0 ? (
|
||||
<div className="text-sm text-gray-500 italic">No media assigned yet. Select a surface in Mapping tab and click Assign here.</div>
|
||||
) : (
|
||||
manifest.surfaces.map((s, i) => (
|
||||
<div key={i} className="flex justify-between items-center p-3 bg-black/30 border border-gray-800 rounded-lg">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-bold">{s.name}</span>
|
||||
<span className="text-[10px] text-gray-500 font-mono">{s.source}</span>
|
||||
</div>
|
||||
<div className="text-[9px] font-bold text-blue-500 uppercase tracking-widest bg-blue-500/10 px-2 py-1 rounded">Active</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const [networks, setNetworks] = useState([]);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
|
||||
const scanWiFi = async () => {
|
||||
setIsScanning(true);
|
||||
try {
|
||||
const res = await fetch('http://localhost:3000/network/scan');
|
||||
const data = await res.json();
|
||||
setNetworks(data.networks || []);
|
||||
} catch (e) {}
|
||||
setIsScanning(false);
|
||||
};
|
||||
|
||||
const connectToWiFi = async (ssid) => {
|
||||
const password = prompt(`Enter password for ${ssid}:`);
|
||||
if (password === null) return;
|
||||
await fetch('http://localhost:3000/network/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ssid, password })
|
||||
});
|
||||
alert(`Connecting to ${ssid}...`);
|
||||
};
|
||||
|
||||
const [apSsid, setApSsid] = useState('MPVJ-AP');
|
||||
const [apPass, setApPass] = useState('');
|
||||
const [isRebooting, setIsRebooting] = useState(false);
|
||||
|
||||
const updateAP = async () => {
|
||||
if (apPass.length > 0 && apPass.length < 8) return alert('Password must be at least 8 characters');
|
||||
if (!confirm('The system will reboot to apply changes. Continue?')) return;
|
||||
|
||||
setIsRebooting(true);
|
||||
await fetch('http://localhost:3000/network/ap/update', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ssid: apSsid, password: apPass })
|
||||
});
|
||||
};
|
||||
|
||||
if (isRebooting) {
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white flex flex-col items-center justify-center space-y-6">
|
||||
<div className="w-16 h-16 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold mb-2">System Rebooting...</h1>
|
||||
<p className="text-gray-500 max-w-xs">Applying new network settings. Please reconnect to the new WiFi SSID in about 60 seconds.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderNetwork = () => (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div className="bg-gray-900 p-6 rounded-xl border border-gray-800">
|
||||
<h2 className="text-xl font-bold mb-4 flex justify-between items-center">
|
||||
<span>WiFi Client (Venue)</span>
|
||||
<button onClick={scanWiFi} className={`text-[10px] bg-gray-800 hover:bg-gray-700 px-3 py-1 rounded uppercase font-bold tracking-widest transition-all ${isScanning ? 'animate-pulse' : ''}`}>
|
||||
{isScanning ? 'Scanning...' : 'Scan Networks'}
|
||||
</button>
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{networks.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-600 italic text-sm">No networks found. Click Scan to start.</div>
|
||||
) : (
|
||||
networks.map((n, i) => (
|
||||
<div key={i} className="flex justify-between items-center p-4 bg-black/30 border border-gray-800 rounded-lg hover:border-gray-600 cursor-pointer group transition-all"
|
||||
onClick={() => connectToWiFi(n.ssid)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7a1 1 0 011.414-1.414L10 14.586l6.293-6.293a1 1 0 011.414 0z" clipRule="evenodd" /></svg>
|
||||
<span className="text-sm font-medium">{n.ssid}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`text-xs font-mono font-bold ${n.strength > 70 ? 'text-emerald-500' : 'text-blue-500'}`}>{n.strength}%</span>
|
||||
<button className="text-[9px] bg-blue-600 hover:bg-blue-500 px-3 py-1 rounded font-bold uppercase opacity-0 group-hover:opacity-100 transition-all">Connect</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 p-6 rounded-xl border border-gray-800">
|
||||
<h2 className="text-xl font-bold mb-4">AP Configuration</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-gray-500 uppercase block mb-1">SSID</label>
|
||||
<input type="text" className="w-full bg-black border border-gray-800 rounded px-3 py-2 text-sm focus:border-blue-500 outline-none text-white" value={apSsid} onChange={e => setApSsid(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-gray-500 uppercase block mb-1">Password</label>
|
||||
<input type="password" className="w-full bg-black border border-gray-800 rounded px-3 py-2 text-sm focus:border-blue-500 outline-none text-white" placeholder="Min 8 chars" value={apPass} onChange={e => setApPass(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={updateAP} className="w-full bg-blue-600 hover:bg-blue-500 py-3 rounded-lg font-bold uppercase text-[10px] tracking-widest transition-all">Update & Reboot</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSystem = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<button onClick={() => fetch('http://localhost:3000/system/reboot', { method: 'POST' })} className="bg-orange-600/5 hover:bg-orange-600/10 border border-orange-600/20 p-8 rounded-2xl flex flex-col items-center gap-4 transition-all group">
|
||||
<div className="p-4 bg-orange-600/20 rounded-full group-hover:scale-110 transition-transform">
|
||||
<svg className="w-8 h-8 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
|
||||
</div>
|
||||
<span className="text-sm font-bold uppercase tracking-widest text-orange-500">System Reboot</span>
|
||||
</button>
|
||||
<button onClick={() => fetch('http://localhost:3000/system/shutdown', { method: 'POST' })} className="bg-red-600/5 hover:bg-red-600/10 border border-red-600/20 p-8 rounded-2xl flex flex-col items-center gap-4 transition-all group">
|
||||
<div className="p-4 bg-red-600/20 rounded-full group-hover:scale-110 transition-transform">
|
||||
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M18.364 5.636l-3.536 3.536m0 5.656L18.364 18.364M5.636 18.364l3.536-3.536m0-5.656L5.636 5.636M10.586 7.586a2 2 0 112.828 2.828l-2.828-2.828zm2.828 6l2.828 2.828a2 2 0 11-2.828-2.828l2.828-2.828z" /></svg>
|
||||
</div>
|
||||
<span className="text-sm font-bold uppercase tracking-widest text-red-500">System Shutdown</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden flex flex-col h-[400px]">
|
||||
<div className="bg-gray-800 px-4 py-2 flex justify-between items-center border-b border-gray-700">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400">ofxPiMapper Live Logs</span>
|
||||
<button onClick={() => setLogs([])} className="text-[9px] text-gray-500 hover:text-white uppercase font-bold">Clear</button>
|
||||
</div>
|
||||
<div className="p-4 font-mono text-[10px] overflow-y-auto space-y-1 bg-black/50 flex-1 custom-scrollbar">
|
||||
{logs.map((log, i) => <div key={i} className="text-gray-400"><span className="text-blue-500/50">[{new Date().toLocaleTimeString()}]</span> {log}</div>)}
|
||||
{logs.length === 0 && <div className="text-gray-600 italic">No logs received yet...</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white flex flex-col font-sans">
|
||||
<header className="border-b border-gray-800 p-4 flex flex-col sm:flex-row justify-between items-center bg-gray-950/80 backdrop-blur-md sticky top-0 z-50 gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-blue-600 p-1.5 rounded-lg shadow-lg shadow-blue-900/40">
|
||||
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V7z" clipRule="evenodd" /></svg>
|
||||
</div>
|
||||
<h1 className="text-lg font-black tracking-tighter uppercase">Modern PocketVJ</h1>
|
||||
</div>
|
||||
<div className="flex bg-gray-900 p-1 rounded-xl border border-gray-800 shadow-inner">
|
||||
{['Dashboard', 'Mapping', 'Media', 'Network', 'System'].map(tab => (
|
||||
<button key={tab} onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-2 rounded-lg text-[10px] font-bold uppercase tracking-widest transition-all ${activeTab === tab ? 'bg-gray-800 text-white shadow-md' : 'text-gray-500 hover:text-gray-300'}`}>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div
|
||||
className="w-full relative select-none"
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
<VirtualCanvas
|
||||
surfaces={surfaces}
|
||||
onVertexMove={handleVertexMouseDown}
|
||||
onSurfaceSelect={handleSurfaceSelect}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-gray-900/50 p-4 rounded-lg border border-gray-800">
|
||||
<h2 className="text-xs font-bold uppercase tracking-widest text-gray-500 mb-2">Instructions</h2>
|
||||
<p className="text-sm text-gray-400">Drag vertices to adjust mapping. Tapping a surface selects it. Changes are reflected in real-time on the projection.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="space-y-8">
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-6">
|
||||
<h2 className="text-lg font-bold mb-4">System Health</h2>
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div className="space-y-1">
|
||||
<div className="text-2xl font-mono text-emerald-500">{stats.cpu}%</div>
|
||||
<div className="text-[10px] uppercase text-gray-500">CPU</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-2xl font-mono text-blue-500">{stats.mem.used}MB</div>
|
||||
<div className="text-[10px] uppercase text-gray-500">RAM</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-2xl font-mono text-orange-500">{stats.temp}°C</div>
|
||||
<div className="text-[10px] uppercase text-gray-500">TEMP</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-6">
|
||||
<h2 className="text-lg font-bold mb-4">Media Library</h2>
|
||||
<div className="space-y-2">
|
||||
<div className="bg-gray-800 p-3 rounded-lg flex justify-between items-center text-sm border border-transparent hover:border-blue-500 cursor-pointer">
|
||||
<span>landscape-test.mp4</span>
|
||||
<span className="text-[10px] bg-gray-700 px-2 py-0.5 rounded uppercase">Assign</span>
|
||||
</div>
|
||||
<button className="w-full border-2 border-dashed border-gray-700 hover:border-blue-500/50 hover:bg-blue-500/5 py-4 rounded-lg transition-all text-sm text-gray-500 font-medium">
|
||||
Upload New File
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-6">
|
||||
<h2 className="text-lg font-bold mb-4">Networking</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => fetch('http://localhost:3000/network/ap', { method: 'POST' })} className="flex-1 bg-gray-800 hover:bg-gray-700 p-2 rounded text-xs font-bold uppercase">AP Mode</button>
|
||||
<button onClick={() => fetch('http://localhost:3000/network/client', { method: 'POST' })} className="flex-1 bg-gray-800 hover:bg-gray-700 p-2 rounded text-xs font-bold uppercase">Client Mode</button>
|
||||
</div>
|
||||
<div className="bg-black/30 rounded-lg p-3">
|
||||
<h3 className="text-[10px] font-bold text-gray-500 uppercase mb-2">Available Networks</h3>
|
||||
<div className="space-y-2 max-h-32 overflow-y-auto">
|
||||
<div className="flex justify-between items-center text-xs p-2 bg-gray-800/50 rounded hover:bg-gray-800 cursor-pointer transition-colors">
|
||||
<span>Venue-WiFi</span>
|
||||
<span className="text-emerald-500 font-mono">60%</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs p-2 bg-gray-800/50 rounded hover:bg-gray-800 cursor-pointer transition-colors">
|
||||
<span>Artist-Hotspot</span>
|
||||
<span className="text-blue-500 font-mono">85%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 p-6 max-w-7xl mx-auto w-full">
|
||||
{activeTab === 'Dashboard' && renderDashboard()}
|
||||
{activeTab === 'Mapping' && renderMapping()}
|
||||
{activeTab === 'Media' && renderMedia()}
|
||||
{activeTab === 'Network' && renderNetwork()}
|
||||
{activeTab === 'System' && renderSystem()}
|
||||
</main>
|
||||
|
||||
<footer className="p-4 border-t border-gray-800 bg-gray-950/50 text-center text-[9px] text-gray-600 font-bold tracking-[0.3em] uppercase">
|
||||
Pi 3B Appliance // mpvj.local // eth0: dhcp // wlan0: 192.168.4.1
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,31 +1,40 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const VirtualCanvas = ({ surfaces, onVertexMove, onSurfaceSelect }) => {
|
||||
const VirtualCanvas = ({ surfaces, onVertexMove, onSurfaceSelect, onSurfaceMoveStart }) => {
|
||||
return (
|
||||
<div className="relative w-full aspect-video bg-gray-900 border-2 border-gray-700 rounded-lg overflow-hidden cursor-crosshair">
|
||||
<div className="relative w-full h-full bg-gray-950 border-2 border-gray-800 rounded-lg overflow-hidden">
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
className="w-full h-full"
|
||||
>
|
||||
{surfaces.map((surface, sIndex) => (
|
||||
<g key={sIndex} onClick={() => onSurfaceSelect(sIndex)}>
|
||||
<g key={sIndex} onClick={(e) => { e.stopPropagation(); onSurfaceSelect(sIndex); }}>
|
||||
<polygon
|
||||
points={surface.vertices.map(v => `${v.x * 100},${v.y * 100}`).join(' ')}
|
||||
className={`${
|
||||
surface.selected ? 'fill-blue-500/50 stroke-blue-400' : 'fill-white/20 stroke-white/50'
|
||||
} stroke-1 transition-colors duration-200`}
|
||||
surface.selected ? 'fill-blue-500/40 stroke-blue-400' : 'fill-white/10 stroke-white/30'
|
||||
} stroke-1 cursor-grab active:cursor-grabbing transition-colors duration-200`}
|
||||
onMouseDown={(e) => surface.selected && onSurfaceMoveStart(e, sIndex)}
|
||||
/>
|
||||
{surface.selected && surface.vertices.map((v, vIndex) => (
|
||||
<circle
|
||||
key={vIndex}
|
||||
cx={v.x * 100}
|
||||
cy={v.y * 100}
|
||||
r="1.5"
|
||||
className="fill-blue-400 stroke-white stroke-[0.2] hover:fill-blue-200 cursor-move"
|
||||
r="1.2"
|
||||
className="fill-blue-400 stroke-white stroke-[0.2] hover:fill-white cursor-move"
|
||||
onMouseDown={(e) => onVertexMove(e, sIndex, vIndex)}
|
||||
/>
|
||||
))}
|
||||
{/* Label */}
|
||||
<text
|
||||
x={surface.vertices[0].x * 100}
|
||||
y={surface.vertices[0].y * 100 - 2}
|
||||
className="fill-white font-bold text-[3px] select-none pointer-events-none"
|
||||
>
|
||||
{surface.name || `Surface ${sIndex}`}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
Reference in New Issue
Block a user