Compare commits

..

12 Commits

Author SHA1 Message Date
941bbbc12a feat: integrate WebSocket functionality into DevicesPage, NetworkPage, StatusPage, and WiFiScanOverlay components 2026-06-09 00:49:30 -04:00
91f6f5366d feat(ws): implement WebSocket context provider and update components to use it 2026-06-09 00:25:27 -04:00
fe2b677bc3 fix(RealityOverridePage): update touch handling to pointer events and adjust double-tap timing 2026-06-09 00:01:26 -04:00
39ed486173 Remove unused device images: sx.png, tape.png, and vic20.png from assets 2026-06-09 00:01:10 -04:00
4c07f8c4a1 fix(MediaManager, DeviceDetailOverlay): improve file existence checks and handle missing files in swap lists 2026-06-08 20:42:21 -04:00
39c72386fa fix(MediaManager): resolve base_url handling and improve path resolution logic 2026-06-08 20:03:19 -04:00
754139b14a fix(App): change header logo to button for better navigation 2026-06-08 19:47:49 -04:00
b854a9922a feat(App): implement fullscreen toggle functionality with event listeners 2026-06-08 19:46:59 -04:00
6e57c372cf feat: enhance PWA support with updated meta tags and icon paths 2026-06-08 19:28:29 -04:00
0dfd673ab3 fix(MediaManager): update base_url assignment to handle current directory case 2026-06-08 19:24:31 -04:00
65799180c5 feat(MediaManager): add folder configuration functionality and update .config handling 2026-06-08 19:22:56 -04:00
08b84680a4 fix(MediaManager): update folder creation path in load function 2026-06-08 19:13:43 -04:00
37 changed files with 250 additions and 258 deletions

View File

@ -3,16 +3,22 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no" />
<title>Meatloaf Manipulator</title>
<meta name="theme-color" content="#4d4d4d" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/icon.192.png" />
<link rel="icon" type="image/png" sizes="512x512" href="/icon.512.png" />
<!-- PWA / add-to-home-screen -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<style>html, body { height: 100%; margin: 0; } #root { height: 100%; }</style>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Meatloaf" />
<link rel="apple-touch-icon" href="/icon.192.png" />
<style>
html, body { height: 100%; margin: 0; overscroll-behavior: none; }
#root { height: 100%; }
</style>
</head>
<body>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 831 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 856 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 485 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 437 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 475 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 504 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 746 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 456 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 838 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 723 B

View File

@ -10,12 +10,14 @@
{
"src": "icon.192.png",
"sizes": "192x192",
"type": "image/png"
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icon.512.png",
"sizes": "512x512",
"type": "image/png"
"type": "image/png",
"purpose": "any maskable"
}
]
}

View File

@ -1,141 +0,0 @@
<!DOCTYPE HTML>
<html>
<head>
<title>ESP32 Web Server</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:,">
<style>
html {
font-family: Arial, Helvetica, sans-serif;
text-align: center;
}
h1 {
font-size: 1.8rem;
color: white;
}
h2 {
font-size: 1.5rem;
font-weight: bold;
color: #07156d;
}
.card {
background-color: #F8F7F9;
;
box-shadow: 2px 2px 12px 1px rgba(140, 140, 140, .5);
padding-top: 10px;
padding-bottom: 20px;
}
.card input {
width: 80%;
height: 2em;
font-size: 24px;
text-align: center;
}
.topnav {
overflow: hidden;
background-color: #4d4d4d;
}
.topnav img {
height: 100px;
}
body {
margin: 0;
}
.content {
padding: 30px;
max-width: 600px;
margin: 0 auto;
}
.button {
padding: 15px 50px;
font-size: 24px;
text-align: center;
outline: none;
color: #fff;
background-color: #0ffa6d; //green
border: #0ffa6d;
border-radius: 5px;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
.button:active {
background-color: #fa0f0f;
transform: translateY(2px);
}
.state {
font-size: 1.5rem;
color: #120707;
font-weight: bold;
}
</style>
<title>Meatloaf WebSocket Test</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:,">
</head>
<body>
<div class="topnav">
<img src="assets/logo.svg" />
<h1>WebSocket Test</h1>
</div>
<div class="content">
<div class="card">
<h2>COMMAND</h2>
<input id="command" type="text"/>
<p><button id="button" class="button">Execute</button></p>
</div>
</div>
</div>
<script>
var gateway = `ws://${window.location.hostname}/ws`;
var websocket;
window.addEventListener('load', onLoad);
function initWebSocket() {
console.log('Trying to open a WebSocket connection...');
websocket = new WebSocket(gateway);
websocket.onopen = onOpen;
websocket.onclose = onClose;
websocket.onmessage = onMessage; // <-- add this line
}
function onOpen(event) {
console.log('Connection opened');
}
function onClose(event) {
console.log('Connection closed');
setTimeout(initWebSocket, 2000);
}
function onMessage(event) {
var state;
console.log(event.data);
}
function onLoad(event) {
initWebSocket();
initButton();
}
function initButton() {
document.getElementById('button').addEventListener('click', execute);
}
function execute() {
var command = document.getElementById('command').value;
console.log(command);
websocket.send(command);
}
</script>
</body>
</html>

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import { Cpu, Settings, Wifi, Network, HardDrive, Activity, MoreHorizontal, Search, Wrench, User, LogOut, Bell, FileText, AppWindow, Folder, Edit, Eye, Database, Upload, Download, Code2, LayoutList, Image, ChevronLeft, Loader2, Check, AlertCircle, RefreshCw, Terminal, Link, Printer } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Cpu, Settings, Wifi, Network, HardDrive, Activity, MoreHorizontal, Search, Wrench, User, LogOut, Bell, FileText, AppWindow, Folder, Edit, Eye, Database, Upload, Download, Code2, LayoutList, Image, ChevronLeft, Loader2, Check, AlertCircle, RefreshCw, Terminal, Link, Printer, Maximize2, Minimize2 } from 'lucide-react';
import { Toaster } from 'sonner';
import StatusPage from './components/StatusPage';
import DevicesPage from './components/DevicesPage';
@ -14,6 +14,7 @@ import RealityOverridePage from './components/RealityOverridePage';
import RealityOverrideAdminPage from './components/RealityOverrideAdminPage';
import logoSvg from '../imports/logo.svg';
import { useSettings } from './settings';
import { WsProvider } from './ws';
type Page = 'status' | 'devices' | 'iec' | 'network' | 'other' | 'general' | 'tools' | 'apps' | AppId;
@ -47,6 +48,25 @@ export default function App() {
const [showSearch, setShowSearch] = useState(false);
const [showProfileMenu, setShowProfileMenu] = useState(false);
const [devicesOpenId, setDevicesOpenId] = useState<string | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onChange);
document.addEventListener('webkitfullscreenchange', onChange);
return () => {
document.removeEventListener('fullscreenchange', onChange);
document.removeEventListener('webkitfullscreenchange', onChange);
};
}, []);
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
(document.documentElement.requestFullscreen?.() ?? (document.documentElement as any).webkitRequestFullscreen?.());
} else {
(document.exitFullscreen?.() ?? (document as any).webkitExitFullscreen?.());
}
};
const pages = {
status: <StatusPage config={config} setConfig={setConfig} />,
@ -184,14 +204,22 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
}
return (
<WsProvider>
<div className="size-full flex flex-col bg-neutral-50">
<Toaster position="top-center" />
<header className="bg-[#4d4d4d] px-0 py-0 flex-shrink-0">
<header className="bg-[#4d4d4d] px-0 py-0 flex-shrink-0" style={{ paddingTop: 'env(safe-area-inset-top)' }}>
<div className="flex items-stretch justify-between min-h-[56px]">
<div className="flex items-center h-full">
<button onClick={() => setCurrentPage('status')} className="flex items-center h-full">
<img src={logoSvg} alt="Meatloaf" className="h-full max-h-[56px] w-auto object-contain" />
</div>
</button>
<div className="flex items-center gap-3">
<button
onClick={toggleFullscreen}
className="p-2 hover:bg-[#5e5e5e] rounded-lg"
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
>
{isFullscreen ? <Minimize2 className="w-5 h-5 text-white" /> : <Maximize2 className="w-5 h-5 text-white" />}
</button>
<button
onClick={() => setShowSearch(true)}
className="p-2 hover:bg-[#5e5e5e] rounded-lg"
@ -268,7 +296,7 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
{pages[currentPage]}
</main>
<nav className="bg-[#4d4d4d] flex-shrink-0">
<nav className="bg-[#4d4d4d] flex-shrink-0" style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
<div className="flex">
<button
onClick={() => setCurrentPage('status')}
@ -316,6 +344,7 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
/>
)}
</div>
</WsProvider>
);
}

View File

@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { X, ChevronLeft, ChevronRight, Printer, HardDrive, Network, Box, FolderOpen, MoreVertical, Play, Pause, SkipForward, SkipBack, RotateCcw } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { toast } from 'sonner';
import { getFileContents, joinPath } from '../webdav';
import { fileExists, getFileContents, joinPath } from '../webdav';
import MediaBrowser from './MediaBrowser';
import MediaSet from './MediaSet';
@ -122,38 +122,26 @@ export default function DeviceDetailOverlay({
}
};
// Detect if URL is part of a media set (e.g., disk1.d64, disk2.d64)
const detectMediaSet = () => {
if (!deviceData.url) return null;
const match = deviceData.url.match(/^(.+?)(\d+)(\.[^.]+)$/);
if (!match) return null;
const [, prefix, num, ext] = match;
const currentNum = parseInt(num);
// Generate potential media set
const mediaSet = [];
for (let i = 1; i <= 10; i++) {
mediaSet.push(`${prefix}${i}${ext}`);
}
return {
prefix,
extension: ext,
currentIndex: currentNum - 1,
files: mediaSet
};
};
// Prefer an explicit .lst-derived mediaSet stored in config; fall back to pattern detection.
const mediaSetFiles: string[] | null = (() => {
const [mediaSetFiles, setMediaSetFiles] = useState<string[] | null>(null);
useEffect(() => {
if (Array.isArray(deviceData.media_set) && deviceData.media_set.length > 0) {
return deviceData.media_set as string[];
setMediaSetFiles(deviceData.media_set as string[]);
return;
}
const detected = detectMediaSet();
return detected ? detected.files : null;
})();
if (!deviceData.url) { setMediaSetFiles(null); return; }
const match = (deviceData.url as string).match(/^(.+?)(\d+)(\.[^.]+)$/);
if (!match) { setMediaSetFiles(null); return; }
const [, prefix, , ext] = match;
const candidates: string[] = [];
for (let i = 1; i <= 10; i++) candidates.push(`${prefix}${i}${ext}`);
let cancelled = false;
Promise.all(candidates.map(f => fileExists(f).catch(() => false))).then(flags => {
if (!cancelled) setMediaSetFiles(candidates.filter((_, i) => flags[i]));
});
return () => { cancelled = true; };
}, [deviceData.url, deviceData.media_set]);
const switchMedia = (file: string) => {
const path = getDevicePath();
@ -166,11 +154,17 @@ export default function DeviceDetailOverlay({
try {
const text = await (await getFileContents(selectedPath)).text();
const dir = selectedPath.split('/').slice(0, -1).join('/') || '/';
const files = text.split('\n')
const candidates = text.split('\n')
.map(l => l.trim())
.filter(l => l.length > 0 && !l.startsWith('#'))
.map(l => l.startsWith('/') ? l : joinPath(dir, l));
if (files.length === 0) { toast.error('Swap list is empty'); return; }
if (candidates.length === 0) { toast.error('Swap list is empty'); return; }
const existsArr = await Promise.all(candidates.map(f => fileExists(f).catch(() => false)));
const files = candidates.filter((_, i) => existsArr[i]);
if (files.length === 0) { toast.error('No files in swap list exist on device'); return; }
if (files.length < candidates.length) {
toast.warning(`${candidates.length - files.length} missing file(s) skipped from swap list`);
}
const newConfig = JSON.parse(JSON.stringify(config));
let dev = newConfig;
for (const k of devicePath) dev = dev[k];

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { Printer, HardDrive, Network, Box, ChevronRight, RefreshCw } from 'lucide-react';
import DeviceDetailOverlay from './DeviceDetailOverlay';
import { toast } from 'sonner';
import { useWs } from '../ws';
interface Device {
id: string;
@ -152,8 +153,11 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
}
};
const { send: wsSend } = useWs();
const rescanBus = async () => {
setIsScanning(true);
wsSend('iec scan');
toast.loading('Scanning IEC bus...');
// Simulate bus scan

View File

@ -52,6 +52,7 @@ import {
copyPath,
createFolder,
deletePath,
fileExists,
getFileContents,
humanFileSize,
joinPath,
@ -268,6 +269,7 @@ function EntryIcon({ entry }: { entry: EntryInfo }) {
interface FolderManagementActions {
onMountFolder: () => void;
onConfigureFolder: () => void;
onNewFolder: () => void;
onNewFile: () => void;
onUpload: () => void;
@ -315,6 +317,10 @@ function ActionsModal({ entry, onClose, onOpen, onMount, onDownload, onRename, o
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3">
<HardDrive className="w-4 h-4 text-amber-600" /> <span>Mount Folder</span>
</button>
<button onClick={() => { onClose(); fm.onConfigureFolder(); }}
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3">
<SlidersHorizontal className="w-4 h-4 text-violet-600" /> <span>Configure Folder</span>
</button>
<div className="border-t border-neutral-100" />
<button onClick={() => { onClose(); fm.onNewFolder(); }}
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
@ -659,6 +665,18 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
const key = _cacheKey(viewEntry.path, viewEntry.size, viewEntry.lastModified?.toISOString() ?? null);
_sessionCache.set(key, bytes);
_lsSet(key, bytes);
// If this is the folder .config, update folderConfig immediately so base_url etc. take effect
if (viewEntry.name === '.config' && typeof content === 'string') {
const cfg: Record<string, string> = {};
for (const line of content.split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq >= 0) cfg[t.slice(0, eq).trim()] = t.slice(eq + 1).trim();
}
setFolderConfig(Object.keys(cfg).length ? cfg : null);
closeViewer();
}
toast.success(`Saved ${viewEntry.name}`);
void load(path);
};
@ -742,14 +760,23 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
try {
const text = await (await getFileContents(mountEntry.path)).text();
const dir = splitPath(mountEntry.path).parent;
const files = text.split('\n')
const candidates = text.split('\n')
.map(l => l.trim())
.filter(l => l.length > 0 && !l.startsWith('#'))
.map(l => l.startsWith('/') ? l : joinPath(dir, l));
if (files.length === 0) {
if (candidates.length === 0) {
toast.error(`${mountEntry.name}: swap list is empty`);
return;
}
const exists = await Promise.all(candidates.map(f => fileExists(f).catch(() => false)));
const files = candidates.filter((_, i) => exists[i]);
if (files.length === 0) {
toast.error(`${mountEntry.name}: no files in swap list exist on device`);
return;
}
if (files.length < candidates.length) {
toast.warning(`${candidates.length - files.length} missing file(s) skipped from swap list`);
}
dev.url = files[0];
dev.media_set = files;
} catch (e: any) {
@ -763,8 +790,14 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
if (!dev.enabled) dev.enabled = 1;
if (folderConfig?.['base_url']) {
dev.base_url = folderConfig['base_url'];
delete dev.url;
const resolvedBase = (folderConfig['base_url'] === '.' ? path : folderConfig['base_url']).replace(/\/$/, '');
if (mountEntry.path.startsWith(resolvedBase + '/') || mountEntry.path === resolvedBase) {
dev.base_url = resolvedBase;
dev.url = mountEntry.path.slice(resolvedBase.length) || '/';
} else {
delete dev.base_url;
// dev.url already set to mountEntry.path above
}
}
if (folderConfig?.['cache'] === '.') dev.cache = path;
setConfig(newConfig);
@ -815,6 +848,19 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
void load(path);
};
// ── Configure folder ─────────────────────────────────────────────────────
const handleConfigureFolder = async () => {
const configPath = joinPath(path, '.config');
try {
if (!await fileExists(configPath)) {
await putFileContents(configPath, '');
}
} catch { /* open anyway */ }
const entry: EntryInfo = { name: '.config', path: configPath, type: 'file', size: 0, lastModified: null, contentType: null };
void openEntry(entry, 'config');
};
// ── New folder ───────────────────────────────────────────────────────────
const handleCreateFolder = async () => {
@ -824,7 +870,7 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
await createFolder(joinPath(path, name), true);
toast.success(`Created folder "${name}"`);
setShowNewFolder(false); setNewFolderName('');
void load(path);
void load(joinPath(path, name));
} catch (e: any) { toast.error(`Failed to create folder: ${e?.message ?? e}`); }
};
@ -1201,6 +1247,7 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
onDelete={e => void deleteEntry(e)}
folderManagement={folderActionOpen ? {
onMountFolder: () => setMountEntry({ name: splitPath(path).name || '/', path, type: 'folder', size: 0, lastModified: null, contentType: null }),
onConfigureFolder: () => void handleConfigureFolder(),
onNewFolder: () => { setShowNewFolder(true); setShowNewFile(false); },
onNewFile: () => { setShowNewFile(true); setShowNewFolder(false); },
onUpload: () => fileInputRef.current?.click(),

View File

@ -2,6 +2,7 @@ import { useState } from 'react';
import { Wifi, Trash2, Scan } from 'lucide-react';
import WiFiScanOverlay from './WiFiScanOverlay';
import { toast } from 'sonner';
import { useWs } from '../ws';
interface NetworkPageProps {
config: any;
@ -9,6 +10,7 @@ interface NetworkPageProps {
}
export default function NetworkPage({ config, setConfig }: NetworkPageProps) {
const { send: wsSend } = useWs();
const [showWiFiScan, setShowWiFiScan] = useState(false);
const updateSetting = (path: string[], value: any) => {
@ -51,7 +53,7 @@ export default function NetworkPage({ config, setConfig }: NetworkPageProps) {
<div className="flex items-center justify-between">
<h2 className="text-sm text-neutral-500">Known WiFi Networks</h2>
<button
onClick={() => setShowWiFiScan(true)}
onClick={() => { wsSend('scan'); setShowWiFiScan(true); }}
className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg"
>
<Scan className="w-4 h-4" />
@ -62,7 +64,20 @@ export default function NetworkPage({ config, setConfig }: NetworkPageProps) {
<div className="bg-white border border-neutral-200 rounded-lg divide-y divide-neutral-200">
{wifi.map((network: any, index: number) => (
<div key={index} className="p-4 flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
<button
className="flex items-center gap-3 flex-1 min-w-0 text-left hover:bg-neutral-50 -m-1 p-1 rounded"
onClick={() => {
const cmd = network.passphrase
? `connect ${network.ssid} ${network.passphrase}`
: `connect ${network.ssid}`;
wsSend(cmd);
toast.info(`Connecting to ${network.ssid}`);
const updated = wifi
.map((n: any, i: number) => ({ ...n, enabled: i === index ? 1 : 0 }))
.sort((a: any, b: any) => b.enabled - a.enabled);
updateSetting(['wifi'], updated);
}}
>
<Wifi className={`w-5 h-5 flex-shrink-0 ${network.enabled ? 'text-blue-600' : 'text-neutral-400'}`} />
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{network.ssid}</div>
@ -70,7 +85,7 @@ export default function NetworkPage({ config, setConfig }: NetworkPageProps) {
<div className="text-xs text-green-600">Connected</div>
)}
</div>
</div>
</button>
<button
onClick={() => removeWifiNetwork(index)}
className="p-2 text-red-600 hover:bg-red-50 rounded ml-2"

View File

@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { useRef, useState } from 'react';
import { useWs } from '../ws';
import {
ChevronLeft, Loader2, Wifi, WifiOff, Send,
ChevronLeft, Loader2, Radio, WifiOff, Send,
Image as ImageIcon, Film, Music,
Crop, RotateCcw, Minimize2,
Scissors, FileOutput, LayoutGrid,
@ -89,37 +90,17 @@ const GROUP_HEAD: Record<string, string> = {
// ── Component ─────────────────────────────────────────────────────────────────
export default function RealityOverrideAdminPage({ onBack }: { onBack: () => void }) {
const wsRef = useRef<WebSocket | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
const { status: wsStatus, send: wsSend } = useWs();
const [lastSent, setLastSent] = useState<string | null>(null);
const [flashId, setFlashId] = useState(0);
const [freeform, setFreeform] = useState('');
// ── WebSocket ───────────────────────────────────────────────────────────────
useEffect(() => {
let cancelled = false;
let ws: WebSocket | null = null;
const connect = () => {
if (cancelled) return;
ws = new WebSocket(`ws://${window.location.hostname}/ws`);
wsRef.current = ws;
setWsStatus('connecting');
ws.onopen = () => { if (!cancelled) setWsStatus('connected'); };
ws.onclose = () => { if (!cancelled) { setWsStatus('disconnected'); setTimeout(connect, 3000); } };
ws.onerror = () => ws?.close();
};
connect();
return () => { cancelled = true; ws?.close(); };
}, []);
// ── Send command ────────────────────────────────────────────────────────────
const send = (payload: string) => {
if (wsRef.current?.readyState !== WebSocket.OPEN) return;
wsRef.current.send(payload);
if (wsStatus !== 'connected') return;
wsSend(payload);
setLastSent(payload);
setFlashId(n => n + 1);
clearTimeout(timerRef.current);
@ -153,7 +134,7 @@ export default function RealityOverrideAdminPage({ onBack }: { onBack: () => voi
</span>
<div className="flex items-center gap-1.5 text-xs">
{wsStatus === 'connecting' && <><Loader2 className="w-3.5 h-3.5 text-yellow-400 animate-spin" /><span className="text-yellow-400">Connecting</span></>}
{wsStatus === 'connected' && <><Wifi className="w-3.5 h-3.5 text-green-400" /><span className="text-green-400">Connected</span></>}
{wsStatus === 'connected' && <><Radio className="w-3.5 h-3.5 text-green-400" /><span className="text-green-400">Connected</span></>}
{wsStatus === 'disconnected' && <><WifiOff className="w-3.5 h-3.5 text-red-400" /><span className="text-red-400">Offline</span></>}
</div>
</div>

View File

@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { ChevronLeft, Loader2, Wifi, WifiOff } from 'lucide-react';
import { ChevronLeft, Loader2, Radio, WifiOff } from 'lucide-react';
import { useWs } from '../ws';
import * as THREE from 'three';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
@ -97,13 +98,12 @@ export default function RealityOverridePage({ onBack }: { onBack: () => void })
const containerRef = useRef<HTMLDivElement>(null);
const starCanvasRef = useRef<HTMLCanvasElement>(null);
const starsRef = useRef<Star[]>([]);
const wsRef = useRef<WebSocket | null>(null);
const msgIdRef = useRef(0);
const fadeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const pausedRef = useRef(localStorage.getItem('ro-bg') === 'off');
const lastTapRef = useRef(0);
const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
const { status: wsStatus, subscribe } = useWs();
const [currentCmd, setCurrentCmd] = useState<Msg | null>(null);
const [history, setHistory] = useState<Msg[]>([]);
const [bgVisible, setBgVisible] = useState(() => localStorage.getItem('ro-bg') !== 'off');
@ -115,9 +115,10 @@ export default function RealityOverridePage({ onBack }: { onBack: () => void })
localStorage.setItem('ro-bg', next ? 'on' : 'off');
};
const handleTouchEnd = () => {
const handlePointerDown = (e: React.PointerEvent) => {
if (!e.isPrimary) return;
const now = Date.now();
if (now - lastTapRef.current < 300) { toggleBg(); lastTapRef.current = 0; }
if (now - lastTapRef.current < 400) { toggleBg(); lastTapRef.current = 0; }
else { lastTapRef.current = now; }
};
@ -288,39 +289,22 @@ export default function RealityOverridePage({ onBack }: { onBack: () => void })
return () => cancelAnimationFrame(animId);
}, []);
// ── WebSocket ───────────────────────────────────────────────────────────────
// ── WebSocket (shared connection via context) ───────────────────────────────
useEffect(() => {
let cancelled = false;
let ws: WebSocket | null = null;
const connect = () => {
if (cancelled) return;
ws = new WebSocket(`ws://${window.location.hostname}/ws`);
wsRef.current = ws;
setWsStatus('connecting');
ws.onopen = () => { if (!cancelled) setWsStatus('connected'); };
ws.onmessage = (e) => {
if (cancelled) return;
return subscribe((data) => {
const id = msgIdRef.current++;
const msg: Msg = { text: String(e.data), id };
const msg: Msg = { text: data, id };
setCurrentCmd(msg);
setHistory(prev => [...prev.slice(-9), msg]);
clearTimeout(fadeTimer.current);
fadeTimer.current = setTimeout(() => setCurrentCmd(null), 5000);
};
ws.onclose = () => { if (!cancelled) { setWsStatus('disconnected'); setTimeout(connect, 3000); } };
ws.onerror = () => ws?.close();
};
connect();
return () => { cancelled = true; ws?.close(); };
}, []);
});
}, [subscribe]);
return (
<div
className="relative w-full h-full bg-black overflow-hidden"
onDoubleClick={toggleBg}
onTouchEnd={handleTouchEnd}
onPointerDown={handlePointerDown}
>
{/* Star field — always visible beneath the vortex */}
@ -351,7 +335,7 @@ export default function RealityOverridePage({ onBack }: { onBack: () => void })
{/* WS status */}
<div className="absolute top-3 right-3 z-10 flex items-center gap-1.5 text-xs px-2 py-1 rounded bg-black/40 border border-white/10">
{wsStatus === 'connecting' && <><Loader2 className="w-3.5 h-3.5 text-yellow-400 animate-spin" /><span className="text-yellow-400">Connecting</span></>}
{wsStatus === 'connected' && <><Wifi className="w-3.5 h-3.5 text-green-400" /><span className="text-green-400">Connected</span></>}
{wsStatus === 'connected' && <><Radio className="w-3.5 h-3.5 text-green-400" /><span className="text-green-400">Connected</span></>}
{wsStatus === 'disconnected' && <><WifiOff className="w-3.5 h-3.5 text-red-400" /><span className="text-red-400">Reconnecting</span></>}
</div>

View File

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { HardDrive, Activity, Wifi, Signal, Clock, RefreshCw, FolderOpen, Map, Loader2 } from 'lucide-react';
import { HardDrive, Activity, Wifi, Radio, Signal, Clock, RefreshCw, FolderOpen, Map, Loader2 } from 'lucide-react';
import { useWs } from '../ws';
import DeviceDetailOverlay from './DeviceDetailOverlay';
import MediaSet from './MediaSet';
import { ImageWithFallback } from './figma/ImageWithFallback';
@ -13,6 +14,8 @@ interface StatusPageProps {
}
export default function StatusPage({ config, setConfig }: StatusPageProps) {
const { status: wsStatus, send: wsSend } = useWs();
// Mock memory stats
const memory = {
heap: { total: 4096, free: 1024 }, // in KB
@ -310,6 +313,7 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
<button
className="px-4 py-2 rounded bg-blue-600 text-white hover:bg-blue-700 transition"
onClick={() => {
wsSend(showResetModal === 'meatloaf' ? 'reset' : 'reset hard');
setResetStatus('in-progress');
setTimeout(() => setResetStatus('done'), 2000);
}}
@ -444,6 +448,12 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
<div className="text-sm text-neutral-700">192.168.1.100</div>
<div className="text-xs text-neutral-500 mt-1">MAC Address</div>
<div className="text-sm text-neutral-700">AA:BB:CC:DD:EE:FF</div>
<div className="text-xs text-neutral-500 mt-1">WebSocket</div>
<div className="flex items-center gap-1 text-sm">
{wsStatus === 'connecting' && <><Loader2 className="w-3 h-3 text-yellow-500 animate-spin" /><span className="text-yellow-600">Connecting</span></>}
{wsStatus === 'connected' && <><Radio className="w-3 h-3 text-green-600" /><span>Connected</span></>}
{wsStatus === 'disconnected' && <><Radio className="w-3 h-3 text-red-500" /><span className="text-red-600">Disconnected</span></>}
</div>
</div>
<div>
<div className="text-xs text-neutral-500">Uptime</div>

View File

@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { X, Wifi, Lock, Signal } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { toast } from 'sonner';
import { useWs } from '../ws';
interface WiFiNetwork {
ssid: string;
@ -16,6 +17,7 @@ interface WiFiScanOverlayProps {
}
export default function WiFiScanOverlay({ onClose, onNetworkAdded, existingNetworks }: WiFiScanOverlayProps) {
const { send: wsSend } = useWs();
const [isScanning, setIsScanning] = useState(true);
const [networks, setNetworks] = useState<WiFiNetwork[]>([]);
const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork | null>(null);
@ -53,6 +55,10 @@ export default function WiFiScanOverlay({ onClose, onNetworkAdded, existingNetwo
}
setIsConnecting(true);
const cmd = selectedNetwork.secured
? `connect ${selectedNetwork.ssid} ${password}`
: `connect ${selectedNetwork.ssid}`;
wsSend(cmd);
toast.loading('Connecting to network...');
// Simulate connection

57
src/app/ws.tsx Normal file
View File

@ -0,0 +1,57 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
export type WsStatus = 'connecting' | 'connected' | 'disconnected';
interface WsContextValue {
status: WsStatus;
send: (msg: string) => void;
subscribe: (handler: (msg: string) => void) => () => void;
}
const WsContext = createContext<WsContextValue>({
status: 'disconnected',
send: () => {},
subscribe: () => () => {},
});
export function WsProvider({ children }: { children: React.ReactNode }) {
const wsRef = useRef<WebSocket | null>(null);
const listeners = useRef<Set<(msg: string) => void>>(new Set());
const [status, setStatus] = useState<WsStatus>('connecting');
useEffect(() => {
let cancelled = false;
let ws: WebSocket | null = null;
const connect = () => {
if (cancelled) return;
ws = new WebSocket(`ws://${window.location.hostname}/ws`);
wsRef.current = ws;
setStatus('connecting');
ws.onopen = () => { if (!cancelled) setStatus('connected'); };
ws.onmessage = (e) => { if (!cancelled) listeners.current.forEach(h => h(String(e.data))); };
ws.onclose = () => { if (!cancelled) { setStatus('disconnected'); setTimeout(connect, 3000); } };
ws.onerror = () => ws?.close();
};
connect();
return () => { cancelled = true; ws?.close(); };
}, []);
const send = useCallback((msg: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(msg);
}, []);
const subscribe = useCallback((handler: (msg: string) => void) => {
listeners.current.add(handler);
return () => { listeners.current.delete(handler); };
}, []);
return (
<WsContext.Provider value={{ status, send, subscribe }}>
{children}
</WsContext.Provider>
);
}
export const useWs = () => useContext(WsContext);