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">
|
||||
<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>
|
||||
|
|
|
|||
|
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",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
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 { 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
const id = msgIdRef.current++;
|
||||
const msg: Msg = { text: String(e.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(); };
|
||||
}, []);
|
||||
return subscribe((data) => {
|
||||
const id = msgIdRef.current++;
|
||||
const msg: Msg = { text: data, id };
|
||||
setCurrentCmd(msg);
|
||||
setHistory(prev => [...prev.slice(-9), msg]);
|
||||
clearTimeout(fadeTimer.current);
|
||||
fadeTimer.current = setTimeout(() => setCurrentCmd(null), 5000);
|
||||
});
|
||||
}, [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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||