Compare commits
12 Commits
b2c3580e17
...
941bbbc12a
| Author | SHA1 | Date | |
|---|---|---|---|
| 941bbbc12a | |||
| 91f6f5366d | |||
| fe2b677bc3 | |||
| 39ed486173 | |||
| 4c07f8c4a1 | |||
| 39c72386fa | |||
| 754139b14a | |||
| b854a9922a | |||
| 6e57c372cf | |||
| 0dfd673ab3 | |||
| 65799180c5 | |||
| 08b84680a4 |
18
index.html
|
|
@ -3,16 +3,22 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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>
|
<title>Meatloaf Manipulator</title>
|
||||||
<meta name="theme-color" content="#4d4d4d" />
|
<meta name="theme-color" content="#4d4d4d" />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<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="192x192" href="/icon.192.png" />
|
||||||
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512.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-capable" content="yes" />
|
||||||
<meta name="mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<link rel="apple-touch-icon" href="/icon-192.png" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<style>html, body { height: 100%; margin: 0; } #root { height: 100%; }</style>
|
<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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 831 B |
|
Before Width: | Height: | Size: 856 B |
|
Before Width: | Height: | Size: 485 B |
|
Before Width: | Height: | Size: 437 B |
|
Before Width: | Height: | Size: 436 B |
|
Before Width: | Height: | Size: 475 B |
|
Before Width: | Height: | Size: 504 B |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 746 B |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 456 B |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 838 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 723 B |
|
|
@ -10,12 +10,14 @@
|
||||||
{
|
{
|
||||||
"src": "icon.192.png",
|
"src": "icon.192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png"
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "icon.512.png",
|
"src": "icon.512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
141
public/ws.html
|
|
@ -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>
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState } from '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 } from 'lucide-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 { Toaster } from 'sonner';
|
||||||
import StatusPage from './components/StatusPage';
|
import StatusPage from './components/StatusPage';
|
||||||
import DevicesPage from './components/DevicesPage';
|
import DevicesPage from './components/DevicesPage';
|
||||||
|
|
@ -14,6 +14,7 @@ import RealityOverridePage from './components/RealityOverridePage';
|
||||||
import RealityOverrideAdminPage from './components/RealityOverrideAdminPage';
|
import RealityOverrideAdminPage from './components/RealityOverrideAdminPage';
|
||||||
import logoSvg from '../imports/logo.svg';
|
import logoSvg from '../imports/logo.svg';
|
||||||
import { useSettings } from './settings';
|
import { useSettings } from './settings';
|
||||||
|
import { WsProvider } from './ws';
|
||||||
|
|
||||||
type Page = 'status' | 'devices' | 'iec' | 'network' | 'other' | 'general' | 'tools' | 'apps' | AppId;
|
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 [showSearch, setShowSearch] = useState(false);
|
||||||
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
||||||
const [devicesOpenId, setDevicesOpenId] = useState<string | null>(null);
|
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 = {
|
const pages = {
|
||||||
status: <StatusPage config={config} setConfig={setConfig} />,
|
status: <StatusPage config={config} setConfig={setConfig} />,
|
||||||
|
|
@ -184,14 +204,22 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<WsProvider>
|
||||||
<div className="size-full flex flex-col bg-neutral-50">
|
<div className="size-full flex flex-col bg-neutral-50">
|
||||||
<Toaster position="top-center" />
|
<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-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" />
|
<img src={logoSvg} alt="Meatloaf" className="h-full max-h-[56px] w-auto object-contain" />
|
||||||
</div>
|
</button>
|
||||||
<div className="flex items-center gap-3">
|
<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
|
<button
|
||||||
onClick={() => setShowSearch(true)}
|
onClick={() => setShowSearch(true)}
|
||||||
className="p-2 hover:bg-[#5e5e5e] rounded-lg"
|
className="p-2 hover:bg-[#5e5e5e] rounded-lg"
|
||||||
|
|
@ -268,7 +296,7 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
|
||||||
{pages[currentPage]}
|
{pages[currentPage]}
|
||||||
</main>
|
</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">
|
<div className="flex">
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage('status')}
|
onClick={() => setCurrentPage('status')}
|
||||||
|
|
@ -316,6 +344,7 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</WsProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { X, ChevronLeft, ChevronRight, Printer, HardDrive, Network, Box, FolderOpen, MoreVertical, Play, Pause, SkipForward, SkipBack, RotateCcw } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getFileContents, joinPath } from '../webdav';
|
import { fileExists, getFileContents, joinPath } from '../webdav';
|
||||||
import MediaBrowser from './MediaBrowser';
|
import MediaBrowser from './MediaBrowser';
|
||||||
import MediaSet from './MediaSet';
|
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.
|
// 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) {
|
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();
|
if (!deviceData.url) { setMediaSetFiles(null); return; }
|
||||||
return detected ? detected.files : null;
|
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 switchMedia = (file: string) => {
|
||||||
const path = getDevicePath();
|
const path = getDevicePath();
|
||||||
|
|
@ -166,11 +154,17 @@ export default function DeviceDetailOverlay({
|
||||||
try {
|
try {
|
||||||
const text = await (await getFileContents(selectedPath)).text();
|
const text = await (await getFileContents(selectedPath)).text();
|
||||||
const dir = selectedPath.split('/').slice(0, -1).join('/') || '/';
|
const dir = selectedPath.split('/').slice(0, -1).join('/') || '/';
|
||||||
const files = text.split('\n')
|
const candidates = text.split('\n')
|
||||||
.map(l => l.trim())
|
.map(l => l.trim())
|
||||||
.filter(l => l.length > 0 && !l.startsWith('#'))
|
.filter(l => l.length > 0 && !l.startsWith('#'))
|
||||||
.map(l => l.startsWith('/') ? l : joinPath(dir, l));
|
.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));
|
const newConfig = JSON.parse(JSON.stringify(config));
|
||||||
let dev = newConfig;
|
let dev = newConfig;
|
||||||
for (const k of devicePath) dev = dev[k];
|
for (const k of devicePath) dev = dev[k];
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||||
import { Printer, HardDrive, Network, Box, ChevronRight, RefreshCw } from 'lucide-react';
|
import { Printer, HardDrive, Network, Box, ChevronRight, RefreshCw } from 'lucide-react';
|
||||||
import DeviceDetailOverlay from './DeviceDetailOverlay';
|
import DeviceDetailOverlay from './DeviceDetailOverlay';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useWs } from '../ws';
|
||||||
|
|
||||||
interface Device {
|
interface Device {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -152,8 +153,11 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { send: wsSend } = useWs();
|
||||||
|
|
||||||
const rescanBus = async () => {
|
const rescanBus = async () => {
|
||||||
setIsScanning(true);
|
setIsScanning(true);
|
||||||
|
wsSend('iec scan');
|
||||||
toast.loading('Scanning IEC bus...');
|
toast.loading('Scanning IEC bus...');
|
||||||
|
|
||||||
// Simulate bus scan
|
// Simulate bus scan
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ import {
|
||||||
copyPath,
|
copyPath,
|
||||||
createFolder,
|
createFolder,
|
||||||
deletePath,
|
deletePath,
|
||||||
|
fileExists,
|
||||||
getFileContents,
|
getFileContents,
|
||||||
humanFileSize,
|
humanFileSize,
|
||||||
joinPath,
|
joinPath,
|
||||||
|
|
@ -268,6 +269,7 @@ function EntryIcon({ entry }: { entry: EntryInfo }) {
|
||||||
|
|
||||||
interface FolderManagementActions {
|
interface FolderManagementActions {
|
||||||
onMountFolder: () => void;
|
onMountFolder: () => void;
|
||||||
|
onConfigureFolder: () => void;
|
||||||
onNewFolder: () => void;
|
onNewFolder: () => void;
|
||||||
onNewFile: () => void;
|
onNewFile: () => void;
|
||||||
onUpload: () => 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">
|
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>
|
<HardDrive className="w-4 h-4 text-amber-600" /> <span>Mount Folder</span>
|
||||||
</button>
|
</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" />
|
<div className="border-t border-neutral-100" />
|
||||||
<button onClick={() => { onClose(); fm.onNewFolder(); }}
|
<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">
|
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);
|
const key = _cacheKey(viewEntry.path, viewEntry.size, viewEntry.lastModified?.toISOString() ?? null);
|
||||||
_sessionCache.set(key, bytes);
|
_sessionCache.set(key, bytes);
|
||||||
_lsSet(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}`);
|
toast.success(`Saved ${viewEntry.name}`);
|
||||||
void load(path);
|
void load(path);
|
||||||
};
|
};
|
||||||
|
|
@ -742,14 +760,23 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
|
||||||
try {
|
try {
|
||||||
const text = await (await getFileContents(mountEntry.path)).text();
|
const text = await (await getFileContents(mountEntry.path)).text();
|
||||||
const dir = splitPath(mountEntry.path).parent;
|
const dir = splitPath(mountEntry.path).parent;
|
||||||
const files = text.split('\n')
|
const candidates = text.split('\n')
|
||||||
.map(l => l.trim())
|
.map(l => l.trim())
|
||||||
.filter(l => l.length > 0 && !l.startsWith('#'))
|
.filter(l => l.length > 0 && !l.startsWith('#'))
|
||||||
.map(l => l.startsWith('/') ? l : joinPath(dir, l));
|
.map(l => l.startsWith('/') ? l : joinPath(dir, l));
|
||||||
if (files.length === 0) {
|
if (candidates.length === 0) {
|
||||||
toast.error(`${mountEntry.name}: swap list is empty`);
|
toast.error(`${mountEntry.name}: swap list is empty`);
|
||||||
return;
|
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.url = files[0];
|
||||||
dev.media_set = files;
|
dev.media_set = files;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
@ -763,8 +790,14 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
|
||||||
|
|
||||||
if (!dev.enabled) dev.enabled = 1;
|
if (!dev.enabled) dev.enabled = 1;
|
||||||
if (folderConfig?.['base_url']) {
|
if (folderConfig?.['base_url']) {
|
||||||
dev.base_url = folderConfig['base_url'];
|
const resolvedBase = (folderConfig['base_url'] === '.' ? path : folderConfig['base_url']).replace(/\/$/, '');
|
||||||
delete dev.url;
|
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;
|
if (folderConfig?.['cache'] === '.') dev.cache = path;
|
||||||
setConfig(newConfig);
|
setConfig(newConfig);
|
||||||
|
|
@ -815,6 +848,19 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
|
||||||
void load(path);
|
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 ───────────────────────────────────────────────────────────
|
// ── New folder ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const handleCreateFolder = async () => {
|
const handleCreateFolder = async () => {
|
||||||
|
|
@ -824,7 +870,7 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
|
||||||
await createFolder(joinPath(path, name), true);
|
await createFolder(joinPath(path, name), true);
|
||||||
toast.success(`Created folder "${name}"`);
|
toast.success(`Created folder "${name}"`);
|
||||||
setShowNewFolder(false); setNewFolderName('');
|
setShowNewFolder(false); setNewFolderName('');
|
||||||
void load(path);
|
void load(joinPath(path, name));
|
||||||
} catch (e: any) { toast.error(`Failed to create folder: ${e?.message ?? e}`); }
|
} 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)}
|
onDelete={e => void deleteEntry(e)}
|
||||||
folderManagement={folderActionOpen ? {
|
folderManagement={folderActionOpen ? {
|
||||||
onMountFolder: () => setMountEntry({ name: splitPath(path).name || '/', path, type: 'folder', size: 0, lastModified: null, contentType: null }),
|
onMountFolder: () => setMountEntry({ name: splitPath(path).name || '/', path, type: 'folder', size: 0, lastModified: null, contentType: null }),
|
||||||
|
onConfigureFolder: () => void handleConfigureFolder(),
|
||||||
onNewFolder: () => { setShowNewFolder(true); setShowNewFile(false); },
|
onNewFolder: () => { setShowNewFolder(true); setShowNewFile(false); },
|
||||||
onNewFile: () => { setShowNewFile(true); setShowNewFolder(false); },
|
onNewFile: () => { setShowNewFile(true); setShowNewFolder(false); },
|
||||||
onUpload: () => fileInputRef.current?.click(),
|
onUpload: () => fileInputRef.current?.click(),
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||||
import { Wifi, Trash2, Scan } from 'lucide-react';
|
import { Wifi, Trash2, Scan } from 'lucide-react';
|
||||||
import WiFiScanOverlay from './WiFiScanOverlay';
|
import WiFiScanOverlay from './WiFiScanOverlay';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useWs } from '../ws';
|
||||||
|
|
||||||
interface NetworkPageProps {
|
interface NetworkPageProps {
|
||||||
config: any;
|
config: any;
|
||||||
|
|
@ -9,6 +10,7 @@ interface NetworkPageProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NetworkPage({ config, setConfig }: NetworkPageProps) {
|
export default function NetworkPage({ config, setConfig }: NetworkPageProps) {
|
||||||
|
const { send: wsSend } = useWs();
|
||||||
const [showWiFiScan, setShowWiFiScan] = useState(false);
|
const [showWiFiScan, setShowWiFiScan] = useState(false);
|
||||||
|
|
||||||
const updateSetting = (path: string[], value: any) => {
|
const updateSetting = (path: string[], value: any) => {
|
||||||
|
|
@ -51,7 +53,7 @@ export default function NetworkPage({ config, setConfig }: NetworkPageProps) {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-sm text-neutral-500">Known WiFi Networks</h2>
|
<h2 className="text-sm text-neutral-500">Known WiFi Networks</h2>
|
||||||
<button
|
<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"
|
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" />
|
<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">
|
<div className="bg-white border border-neutral-200 rounded-lg divide-y divide-neutral-200">
|
||||||
{wifi.map((network: any, index: number) => (
|
{wifi.map((network: any, index: number) => (
|
||||||
<div key={index} className="p-4 flex items-center justify-between">
|
<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'}`} />
|
<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="flex-1 min-w-0">
|
||||||
<div className="font-medium truncate">{network.ssid}</div>
|
<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 className="text-xs text-green-600">Connected</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => removeWifiNetwork(index)}
|
onClick={() => removeWifiNetwork(index)}
|
||||||
className="p-2 text-red-600 hover:bg-red-50 rounded ml-2"
|
className="p-2 text-red-600 hover:bg-red-50 rounded ml-2"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
|
import { useWs } from '../ws';
|
||||||
import {
|
import {
|
||||||
ChevronLeft, Loader2, Wifi, WifiOff, Send,
|
ChevronLeft, Loader2, Radio, WifiOff, Send,
|
||||||
Image as ImageIcon, Film, Music,
|
Image as ImageIcon, Film, Music,
|
||||||
Crop, RotateCcw, Minimize2,
|
Crop, RotateCcw, Minimize2,
|
||||||
Scissors, FileOutput, LayoutGrid,
|
Scissors, FileOutput, LayoutGrid,
|
||||||
|
|
@ -89,37 +90,17 @@ const GROUP_HEAD: Record<string, string> = {
|
||||||
// ── Component ─────────────────────────────────────────────────────────────────
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function RealityOverrideAdminPage({ onBack }: { onBack: () => void }) {
|
export default function RealityOverrideAdminPage({ onBack }: { onBack: () => void }) {
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
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 [lastSent, setLastSent] = useState<string | null>(null);
|
||||||
const [flashId, setFlashId] = useState(0);
|
const [flashId, setFlashId] = useState(0);
|
||||||
const [freeform, setFreeform] = useState('');
|
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 ────────────────────────────────────────────────────────────
|
// ── Send command ────────────────────────────────────────────────────────────
|
||||||
const send = (payload: string) => {
|
const send = (payload: string) => {
|
||||||
if (wsRef.current?.readyState !== WebSocket.OPEN) return;
|
if (wsStatus !== 'connected') return;
|
||||||
wsRef.current.send(payload);
|
wsSend(payload);
|
||||||
setLastSent(payload);
|
setLastSent(payload);
|
||||||
setFlashId(n => n + 1);
|
setFlashId(n => n + 1);
|
||||||
clearTimeout(timerRef.current);
|
clearTimeout(timerRef.current);
|
||||||
|
|
@ -153,7 +134,7 @@ export default function RealityOverrideAdminPage({ onBack }: { onBack: () => voi
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
<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 === '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></>}
|
{wsStatus === 'disconnected' && <><WifiOff className="w-3.5 h-3.5 text-red-400" /><span className="text-red-400">Offline</span></>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
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 * as THREE from 'three';
|
||||||
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
|
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
|
||||||
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.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 containerRef = useRef<HTMLDivElement>(null);
|
||||||
const starCanvasRef = useRef<HTMLCanvasElement>(null);
|
const starCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const starsRef = useRef<Star[]>([]);
|
const starsRef = useRef<Star[]>([]);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
|
||||||
const msgIdRef = useRef(0);
|
const msgIdRef = useRef(0);
|
||||||
const fadeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
const fadeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
const pausedRef = useRef(localStorage.getItem('ro-bg') === 'off');
|
const pausedRef = useRef(localStorage.getItem('ro-bg') === 'off');
|
||||||
const lastTapRef = useRef(0);
|
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 [currentCmd, setCurrentCmd] = useState<Msg | null>(null);
|
||||||
const [history, setHistory] = useState<Msg[]>([]);
|
const [history, setHistory] = useState<Msg[]>([]);
|
||||||
const [bgVisible, setBgVisible] = useState(() => localStorage.getItem('ro-bg') !== 'off');
|
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');
|
localStorage.setItem('ro-bg', next ? 'on' : 'off');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchEnd = () => {
|
const handlePointerDown = (e: React.PointerEvent) => {
|
||||||
|
if (!e.isPrimary) return;
|
||||||
const now = Date.now();
|
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; }
|
else { lastTapRef.current = now; }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -288,39 +289,22 @@ export default function RealityOverridePage({ onBack }: { onBack: () => void })
|
||||||
return () => cancelAnimationFrame(animId);
|
return () => cancelAnimationFrame(animId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── WebSocket ───────────────────────────────────────────────────────────────
|
// ── WebSocket (shared connection via context) ───────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
return subscribe((data) => {
|
||||||
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;
|
|
||||||
const id = msgIdRef.current++;
|
const id = msgIdRef.current++;
|
||||||
const msg: Msg = { text: String(e.data), id };
|
const msg: Msg = { text: data, id };
|
||||||
setCurrentCmd(msg);
|
setCurrentCmd(msg);
|
||||||
setHistory(prev => [...prev.slice(-9), msg]);
|
setHistory(prev => [...prev.slice(-9), msg]);
|
||||||
clearTimeout(fadeTimer.current);
|
clearTimeout(fadeTimer.current);
|
||||||
fadeTimer.current = setTimeout(() => setCurrentCmd(null), 5000);
|
fadeTimer.current = setTimeout(() => setCurrentCmd(null), 5000);
|
||||||
};
|
});
|
||||||
ws.onclose = () => { if (!cancelled) { setWsStatus('disconnected'); setTimeout(connect, 3000); } };
|
}, [subscribe]);
|
||||||
ws.onerror = () => ws?.close();
|
|
||||||
};
|
|
||||||
|
|
||||||
connect();
|
|
||||||
return () => { cancelled = true; ws?.close(); };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative w-full h-full bg-black overflow-hidden"
|
className="relative w-full h-full bg-black overflow-hidden"
|
||||||
onDoubleClick={toggleBg}
|
onPointerDown={handlePointerDown}
|
||||||
onTouchEnd={handleTouchEnd}
|
|
||||||
>
|
>
|
||||||
|
|
||||||
{/* Star field — always visible beneath the vortex */}
|
{/* Star field — always visible beneath the vortex */}
|
||||||
|
|
@ -351,7 +335,7 @@ export default function RealityOverridePage({ onBack }: { onBack: () => void })
|
||||||
{/* WS status */}
|
{/* 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">
|
<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 === '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></>}
|
{wsStatus === 'disconnected' && <><WifiOff className="w-3.5 h-3.5 text-red-400" /><span className="text-red-400">Reconnecting…</span></>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect, useState } from 'react';
|
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 DeviceDetailOverlay from './DeviceDetailOverlay';
|
||||||
import MediaSet from './MediaSet';
|
import MediaSet from './MediaSet';
|
||||||
import { ImageWithFallback } from './figma/ImageWithFallback';
|
import { ImageWithFallback } from './figma/ImageWithFallback';
|
||||||
|
|
@ -13,6 +14,8 @@ interface StatusPageProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StatusPage({ config, setConfig }: StatusPageProps) {
|
export default function StatusPage({ config, setConfig }: StatusPageProps) {
|
||||||
|
const { status: wsStatus, send: wsSend } = useWs();
|
||||||
|
|
||||||
// Mock memory stats
|
// Mock memory stats
|
||||||
const memory = {
|
const memory = {
|
||||||
heap: { total: 4096, free: 1024 }, // in KB
|
heap: { total: 4096, free: 1024 }, // in KB
|
||||||
|
|
@ -310,6 +313,7 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
|
||||||
<button
|
<button
|
||||||
className="px-4 py-2 rounded bg-blue-600 text-white hover:bg-blue-700 transition"
|
className="px-4 py-2 rounded bg-blue-600 text-white hover:bg-blue-700 transition"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
wsSend(showResetModal === 'meatloaf' ? 'reset' : 'reset hard');
|
||||||
setResetStatus('in-progress');
|
setResetStatus('in-progress');
|
||||||
setTimeout(() => setResetStatus('done'), 2000);
|
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-sm text-neutral-700">192.168.1.100</div>
|
||||||
<div className="text-xs text-neutral-500 mt-1">MAC Address</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-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>
|
<div>
|
||||||
<div className="text-xs text-neutral-500">Uptime</div>
|
<div className="text-xs text-neutral-500">Uptime</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||||
import { X, Wifi, Lock, Signal } from 'lucide-react';
|
import { X, Wifi, Lock, Signal } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useWs } from '../ws';
|
||||||
|
|
||||||
interface WiFiNetwork {
|
interface WiFiNetwork {
|
||||||
ssid: string;
|
ssid: string;
|
||||||
|
|
@ -16,6 +17,7 @@ interface WiFiScanOverlayProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WiFiScanOverlay({ onClose, onNetworkAdded, existingNetworks }: WiFiScanOverlayProps) {
|
export default function WiFiScanOverlay({ onClose, onNetworkAdded, existingNetworks }: WiFiScanOverlayProps) {
|
||||||
|
const { send: wsSend } = useWs();
|
||||||
const [isScanning, setIsScanning] = useState(true);
|
const [isScanning, setIsScanning] = useState(true);
|
||||||
const [networks, setNetworks] = useState<WiFiNetwork[]>([]);
|
const [networks, setNetworks] = useState<WiFiNetwork[]>([]);
|
||||||
const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork | null>(null);
|
const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork | null>(null);
|
||||||
|
|
@ -53,6 +55,10 @@ export default function WiFiScanOverlay({ onClose, onNetworkAdded, existingNetwo
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsConnecting(true);
|
setIsConnecting(true);
|
||||||
|
const cmd = selectedNetwork.secured
|
||||||
|
? `connect ${selectedNetwork.ssid} ${password}`
|
||||||
|
: `connect ${selectedNetwork.ssid}`;
|
||||||
|
wsSend(cmd);
|
||||||
toast.loading('Connecting to network...');
|
toast.loading('Connecting to network...');
|
||||||
|
|
||||||
// Simulate connection
|
// Simulate connection
|
||||||
|
|
|
||||||
57
src/app/ws.tsx
Normal 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);
|
||||||