Compare commits
No commits in common. "5a17c0a2e0aa0a5cc7bb3bc531d799e2657b1cef" and "63d2ff9f698914e1b03bb6a0e6a027e26be1b4a9" have entirely different histories.
5a17c0a2e0
...
63d2ff9f69
|
|
@ -31,7 +31,7 @@ src/
|
||||||
ToolsPage.tsx # Tools
|
ToolsPage.tsx # Tools
|
||||||
SearchOverlay.tsx
|
SearchOverlay.tsx
|
||||||
WiFiScanOverlay.tsx
|
WiFiScanOverlay.tsx
|
||||||
MediaBrowser.tsx # WebDAV file browser (file click = select, kebab menu)
|
FileBrowser.tsx # WebDAV file browser (file click = select, kebab menu)
|
||||||
figma/ # Figma-generated components
|
figma/ # Figma-generated components
|
||||||
ui/ # shadcn/Radix UI wrappers
|
ui/ # shadcn/Radix UI wrappers
|
||||||
vendor/
|
vendor/
|
||||||
|
|
@ -93,7 +93,7 @@ All individual app pages are currently stubs (`AppPage` component with "coming s
|
||||||
11. **WebDAV client** — replaced `webdav@5` npm package with vendored `webdav-component` (browser-native `fetch` + `DOMParser`, no external deps). All pages import from `webdav.ts` abstraction layer.
|
11. **WebDAV client** — replaced `webdav@5` npm package with vendored `webdav-component` (browser-native `fetch` + `DOMParser`, no external deps). All pages import from `webdav.ts` abstraction layer.
|
||||||
12. **WebDAV path fixes** — `webdav.ts`: always `decodeURIComponent` paths; use `entry.uri` (not broken `entry.path`) for servers returning relative hrefs
|
12. **WebDAV path fixes** — `webdav.ts`: always `decodeURIComponent` paths; use `entry.uri` (not broken `entry.path`) for servers returning relative hrefs
|
||||||
13. **`webdav3.py` server fixes** — `displayname` now returns leaf name only (not full path); PROPFIND depth-1 guard prevents crash when called on a file
|
13. **`webdav3.py` server fixes** — `displayname` now returns leaf name only (not full path); PROPFIND depth-1 guard prevents crash when called on a file
|
||||||
14. **MediaBrowser redesign** — file click = `onSelect` + close; folder click = navigate; per-row kebab (`MoreVert`) opens a Dialog with contextual actions; permanent "Select Folder" button in footer; no mode-toggle buttons
|
14. **FileBrowser redesign** — file click = `onSelect` + close; folder click = navigate; per-row kebab (`MoreVert`) opens a Dialog with contextual actions; permanent "Select Folder" button in footer; no mode-toggle buttons
|
||||||
15. **Settings persistence** — `settings.ts` + `useSettings()` hook: loads `/.sys/config.json` via WebDAV on mount, auto-saves 3 s after last change, exposes `saveStatus` / `pendingCount` / `flushNow`; `beforeunload` flushes via `fetch keepalive`
|
15. **Settings persistence** — `settings.ts` + `useSettings()` hook: loads `/.sys/config.json` via WebDAV on mount, auto-saves 3 s after last change, exposes `saveStatus` / `pendingCount` / `flushNow`; `beforeunload` flushes via `fetch keepalive`
|
||||||
16. **Save-status badge** — `SaveStatusBadge` in `App.tsx` header shows: idle (hidden), loading spinner, amber "N unsaved + Save button", saving spinner, saved checkmark, red error + retry
|
16. **Save-status badge** — `SaveStatusBadge` in `App.tsx` header shows: idle (hidden), loading spinner, amber "N unsaved + Save button", saving spinner, saved checkmark, red error + retry
|
||||||
|
|
||||||
|
|
|
||||||
|
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 |
|
Before Width: | Height: | Size: 3.4 KiB |
|
|
@ -1 +0,0 @@
|
||||||
<svg width="3in" height="3in" viewBox="0 0 629.007 629.007" xmlns="http://www.w3.org/2000/svg"><path fill="#4d4d4d" stroke="#4d4d4d" stroke-width="27.974" stroke-linecap="round" stroke-linejoin="round" d="M14.318 13.749h600.666v601.095H14.318z"/><g stroke-width=".114"><path d="M518.37 460.295l.012 74.528h74.515v-74.3z" fill="#fa574a" stroke="#fa574a" stroke-width=".46976664"/><path d="M518.369 366.903l.012 74.527h74.516v-74.3z" fill="#fc9149" stroke="#fc9149" stroke-width=".46976664"/><path d="M518.369 275.903l.012 74.527h74.516v-74.3z" fill="#ede24c" stroke="#ede24c" stroke-width=".46976664"/><path d="M518.369 184.903l.012 74.528h74.516v-74.3z" fill="#91cb41" stroke="#91cb41" stroke-width=".46976664"/><path d="M518.369 93.864l.012 74.528h74.516v-74.3z" fill="#76c4f2" stroke="#76c4f2" stroke-width=".46976664"/></g><g fill="#fffffb" stroke="#fff" stroke-width=".265"><path d="M37.092 95.066l206.612 206.612.336 233.152-207.62-.336zM496.418 95.068L289.805 301.68l-.336 233.153 207.621-.336z" stroke-width="1.0920014"/></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
|
@ -1 +0,0 @@
|
||||||
<svg width="5in" height="2in" viewBox="0 0 1329.716 530.585" xmlns="http://www.w3.org/2000/svg"><path fill="#4d4d4d" stroke="#4d4d4d" stroke-width="27.195" stroke-linecap="round" stroke-linejoin="round" d="M13.598 13.597h1302.52v503.39H13.599z"/><g aria-label="eatloaf" style="line-height:1.25;-inkscape-font-specification:'Microgramma D Extended'" font-size="45.303" font-family="Microgramma D Extended" fill="#fff" stroke-width=".607"><path d="M611.141 459.333c-1.312 7.808-5.248 9.296-26.43 9.296-11.996 0-18.932-1.116-22.493-3.719-3.187-2.417-4.124-5.392-4.124-14.316h86.6c.187-3.718.187-7.808.187-8.924 0-19.893-2.999-29.747-10.871-36.254-8.06-6.507-18.558-8.367-48.549-8.367-28.867 0-39.926 1.86-48.548 7.81-9.373 6.506-11.997 15.244-11.997 38.856 0 22.497 2.624 31.235 11.247 37.742 8.06 5.95 20.244 8.18 45.924 8.18 47.424 0 59.608-5.949 62.045-30.304zm-53.047-26.773c.375-12.27 5.623-15.06 27.93-15.06 13.12 0 19.869 1.302 22.868 4.463 2.062 2.045 2.437 3.718 2.624 10.597zM691.18 426.983c.938-7.995 3.374-8.739 27.18-8.739 20.057 0 22.681 1.86 22.681 15.618v4.648h-1.5c-5.248-5.578-9.747-6.88-25.117-6.88-24.368 0-32.99.558-41.05 2.79-12.935 3.346-18.933 12.084-18.933 27.887 0 20.823 9.185 27.33 38.614 27.33 21.556 0 29.991-.743 35.427-2.974 5.998-2.417 9.56-5.578 12.372-11.341h1.312v12.084h31.866V434.42c0-15.989-1.875-22.682-7.498-28.631-7.498-7.437-14.246-8.739-43.3-8.739-26.618 0-41.988 1.674-49.674 5.206-10.309 5.02-14.62 11.9-15.557 24.728zm22.119 23.24c14.058 0 19.307.185 21.369.93 3.749 1.3 5.81 3.903 5.81 7.436 0 7.808-6.56 10.04-29.99 10.04-17.808 0-22.869-2.046-22.869-9.11 0-7.251 5.436-9.297 25.68-9.297zM798.4 379.201v20.265h-17.433v23.612h17.432v25.657c0 7.251 0 11.713.188 13.2.375 10.04 2.249 14.874 6.935 19.522 6.56 6.321 15.183 8.18 36.365 8.18 22.868 0 32.99-2.788 39.738-11.155 5.061-6.507 6.373-11.899 6.748-28.631h-30.366c0 9.11-.187 9.853-1.312 11.898-1.5 3.161-5.249 4.463-11.997 4.463-6.373 0-9.747-1.302-11.434-4.277-1.5-2.23-1.687-3.532-1.687-13.2v-25.657h52.485v-23.612h-52.485v-20.265zM896.996 363.398v124.008h33.177V363.398zM1005.527 397.235c-30.18 0-43.113 2.045-51.36 7.995-8.81 6.321-11.435 14.688-11.435 35.325 0 25.47 2.062 33.651 10.31 40.344 8.435 6.88 19.494 8.738 52.672 8.738 32.803 0 43.863-1.859 52.298-8.738 8.06-6.693 10.31-14.873 10.31-39.043 0-22.124-2.438-30.119-11.435-36.626-8.248-5.95-20.994-7.995-51.36-7.995zm.187 24.728c9.372 0 17.245.93 21.369 2.603 6.186 2.417 8.06 6.693 8.06 18.592 0 18.591-4.311 21.752-29.429 21.752-10.684 0-18.182-.93-22.306-2.789-5.998-2.417-7.498-6.32-7.498-18.034 0-18.778 4.499-22.124 29.804-22.124zM1114.62 426.983c.937-7.995 3.374-8.739 27.18-8.739 20.057 0 22.68 1.86 22.68 15.618v4.648h-1.499c-5.248-5.578-9.747-6.88-25.118-6.88-24.367 0-32.99.558-41.05 2.79-12.934 3.346-18.932 12.084-18.932 27.887 0 20.823 9.185 27.33 38.614 27.33 21.556 0 29.991-.743 35.427-2.974 5.998-2.417 9.56-5.578 12.371-11.341h1.312v12.084h31.866V434.42c0-15.989-1.874-22.682-7.498-28.631-7.497-7.437-14.245-8.739-43.3-8.739-26.617 0-41.987 1.674-49.673 5.206-10.31 5.02-14.62 11.9-15.558 24.728zm22.119 23.24c14.058 0 19.307.185 21.369.93 3.749 1.3 5.81 3.903 5.81 7.436 0 7.808-6.56 10.04-29.99 10.04-17.808 0-22.87-2.046-22.87-9.11 0-7.251 5.437-9.297 25.68-9.297zM1204.407 423.078h17.057v64.328h33.178v-64.328h32.616v-23.612h-32.616c.375-13.758 3.562-16.175 21.181-16.175 2.062 0 2.625 0 8.81.186v-21.752c-7.123-.372-10.497-.558-16.682-.558-22.119 0-30.554 1.86-37.677 8.366-6.748 6.136-8.248 11.155-8.81 29.933h-17.057z"/></g><g stroke-width=".268"><path d="M525.47 295.584l.12 40.101h761.114v-39.98z" fill="#fa574a" stroke="#fa574a" stroke-width="1.10436368"/><path d="M525.47 233.772l.12 40.102h761.114v-39.98z" fill="#fc9149" stroke="#fc9149" stroke-width="1.10436368"/><path d="M525.47 171.96l.12 40.102h761.114v-39.98z" fill="#ede24c" stroke="#ede24c" stroke-width="1.10436368"/><path d="M525.47 110.15l.12 40.1h761.114v-39.979z" fill="#91cb41" stroke="#91cb41" stroke-width="1.10436368"/><path d="M525.47 48.214l.12 40.102h761.114v-39.98z" fill="#76c4f2" stroke="#76c4f2" stroke-width="1.10436368"/></g><g fill="#fffffb" stroke="#fff" stroke-width=".265"><path d="M43.875 49.099L250.488 255.71l.336 233.152-207.62-.335zM503.202 49.1L296.59 255.714l-.336 233.153 207.621-.336z" stroke-width="1.0920014"/></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.2 KiB |
|
|
@ -1,19 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
|
||||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
|
||||||
<specVersion>
|
|
||||||
<major>1</major>
|
|
||||||
<minor>0</minor>
|
|
||||||
</specVersion>
|
|
||||||
<device>
|
|
||||||
<deviceType>urn:schemas-upnp-org:device:ManageableDevice:1</deviceType>
|
|
||||||
<UDN>uuid:SSDP-DISABLED</UDN>
|
|
||||||
<friendlyName>Meatloaf (Multi-device emulator)</friendlyName>
|
|
||||||
<manufacturer>Meatloaf</manufacturer>
|
|
||||||
<manufacturerURL>https://meatloaf.cc/</manufacturerURL>
|
|
||||||
<modelName>ML CBM 1.6.1</modelName>
|
|
||||||
<modelNumber>ML-CBM-1.6.1</modelNumber>
|
|
||||||
<modelURL>https://github.com/idolpx/meatloaf</modelURL>
|
|
||||||
<serialNumber>MLCBM3C214F90</serialNumber>
|
|
||||||
<presentationURL>http://192.168.1.185</presentationURL>
|
|
||||||
</device>
|
|
||||||
</root>
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Commodore 64 Boot Screen</title>
|
|
||||||
<link rel="stylesheet" href="error/styles.css">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="boot-screen">
|
|
||||||
<h1>**** MEATLOAF 64 BASIC V2 ****</h1>
|
|
||||||
<h1>64K RAM SYSTEM 38911 BASIC BYTES FREE</h1>
|
|
||||||
<p>?SERVER 404 ERROR</p>
|
|
||||||
<p>READY.</p>
|
|
||||||
<p>USE JOYSTICK IN PORT 2 TO SELECT</p>
|
|
||||||
<div class="help-actions">
|
|
||||||
<a href="javascript:location.reload();">RELOAD PAGE</a>
|
|
||||||
<a href="javascript:history.back();">BACK TO PREVIOUS</a>
|
|
||||||
<a href="/">HOME PAGE</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Commodore 64 Boot Screen</title>
|
|
||||||
<link rel="stylesheet" href="error/styles.css">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="boot-screen">
|
|
||||||
<h1>**** MEATLOAF 64 BASIC V2 ****</h1>
|
|
||||||
<h1>64K RAM SYSTEM 38911 BASIC BYTES FREE</h1>
|
|
||||||
<p>?SERVER 405 ERROR</p>
|
|
||||||
<p>READY.</p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
Before Width: | Height: | Size: 7.2 KiB |
|
|
@ -1,37 +0,0 @@
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
background-color: #6C5EB5; /* dark blue */
|
|
||||||
font-family: 'Press Start 2P', cursive;
|
|
||||||
color: #6C5EB5; /* light blue */
|
|
||||||
}
|
|
||||||
|
|
||||||
.boot-screen {
|
|
||||||
text-align: center;
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
border: 5px solid #6C5EB5; /* light blue */
|
|
||||||
padding: 20px;
|
|
||||||
background-color: #352879; /* black */
|
|
||||||
width: 80%;
|
|
||||||
height: 80%;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 24px;
|
|
||||||
letter-spacing: 4px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
font-size: 24px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-actions {
|
|
||||||
font-size: 24px;
|
|
||||||
text-align: center;
|
|
||||||
color: #FFFFFF; /* light blue */
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 169 B |
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 { 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 } 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';
|
||||||
|
|
@ -9,7 +9,7 @@ import IECPage from './components/IECPage';
|
||||||
import OtherPage from './components/OtherPage';
|
import OtherPage from './components/OtherPage';
|
||||||
import ToolsPage from './components/ToolsPage';
|
import ToolsPage from './components/ToolsPage';
|
||||||
import SearchOverlay from './components/SearchOverlay';
|
import SearchOverlay from './components/SearchOverlay';
|
||||||
import MediaManager from './components/MediaManager';
|
import FileManager from './components/FileManager';
|
||||||
import logoSvg from '../imports/logo.svg';
|
import logoSvg from '../imports/logo.svg';
|
||||||
import { useSettings } from './settings';
|
import { useSettings } from './settings';
|
||||||
|
|
||||||
|
|
@ -17,7 +17,6 @@ type Page = 'status' | 'devices' | 'iec' | 'network' | 'other' | 'general' | 'to
|
||||||
|
|
||||||
type AppId =
|
type AppId =
|
||||||
| 'file-manager'
|
| 'file-manager'
|
||||||
| 'print-manager'
|
|
||||||
| 'serial-console'
|
| 'serial-console'
|
||||||
| 'directory-editor'
|
| 'directory-editor'
|
||||||
| 'sector-editor'
|
| 'sector-editor'
|
||||||
|
|
@ -60,8 +59,7 @@ export default function App() {
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold mb-4 text-blue-700">Management</h2>
|
<h2 className="text-lg font-semibold mb-4 text-blue-700">Management</h2>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
<AppCard icon={<Folder className="w-7 h-7" />} label="Media Manager" onClick={() => setCurrentPage('file-manager')} />
|
<AppCard icon={<Folder className="w-7 h-7" />} label="File Manager" onClick={() => setCurrentPage('file-manager')} />
|
||||||
<AppCard icon={<Printer className="w-7 h-7" />} label="Print Manager" onClick={() => setCurrentPage('print-manager')} />
|
|
||||||
<AppCard icon={<Terminal className="w-7 h-7" />} label="Serial Console" onClick={() => setCurrentPage('serial-console')} />
|
<AppCard icon={<Terminal className="w-7 h-7" />} label="Serial Console" onClick={() => setCurrentPage('serial-console')} />
|
||||||
<AppCard icon={<Link className="w-7 h-7" />} label="Short Codes" onClick={() => setCurrentPage('serial-console')} />
|
<AppCard icon={<Link className="w-7 h-7" />} label="Short Codes" onClick={() => setCurrentPage('serial-console')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -70,11 +68,11 @@ export default function App() {
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold mb-4 text-blue-700">Disk</h2>
|
<h2 className="text-lg font-semibold mb-4 text-blue-700">Disk</h2>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
<AppCard icon={<Database className="w-7 h-7" />} label="RAM/ROM Explorer" onClick={() => setCurrentPage('ramrom-explorer')} />
|
|
||||||
<AppCard icon={<LayoutList className="w-7 h-7" />} label="BAM Editor" onClick={() => setCurrentPage('bam-editor')} />
|
|
||||||
<AppCard icon={<Folder className="w-7 h-7" />} label="Directory Editor" onClick={() => setCurrentPage('directory-editor')} />
|
<AppCard icon={<Folder className="w-7 h-7" />} label="Directory Editor" onClick={() => setCurrentPage('directory-editor')} />
|
||||||
<AppCard icon={<Edit className="w-7 h-7" />} label="Sector Editor" onClick={() => setCurrentPage('sector-editor')} />
|
<AppCard icon={<Edit className="w-7 h-7" />} label="Sector Editor" onClick={() => setCurrentPage('sector-editor')} />
|
||||||
|
<AppCard icon={<LayoutList className="w-7 h-7" />} label="BAM Editor" onClick={() => setCurrentPage('bam-editor')} />
|
||||||
<AppCard icon={<Eye className="w-7 h-7" />} label="Disk Visualizer" onClick={() => setCurrentPage('disk-visualizer')} />
|
<AppCard icon={<Eye className="w-7 h-7" />} label="Disk Visualizer" onClick={() => setCurrentPage('disk-visualizer')} />
|
||||||
|
<AppCard icon={<Database className="w-7 h-7" />} label="RAM/ROM Explorer" onClick={() => setCurrentPage('ramrom-explorer')} />
|
||||||
<AppCard icon={<Download className="w-7 h-7" />} label="Dump Disk Image" onClick={() => setCurrentPage('dump-disk-image')} />
|
<AppCard icon={<Download className="w-7 h-7" />} label="Dump Disk Image" onClick={() => setCurrentPage('dump-disk-image')} />
|
||||||
<AppCard icon={<Upload className="w-7 h-7" />} label="Write Disk Image" onClick={() => setCurrentPage('write-disk-image')} />
|
<AppCard icon={<Upload className="w-7 h-7" />} label="Write Disk Image" onClick={() => setCurrentPage('write-disk-image')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -111,19 +109,12 @@ export default function App() {
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
// Individual app pages
|
// Individual app pages
|
||||||
'file-manager': <MediaManager
|
'file-manager': <FileManager
|
||||||
onBack={() => setCurrentPage('apps')}
|
onBack={() => setCurrentPage('apps')}
|
||||||
config={config}
|
config={config}
|
||||||
setConfig={setConfig}
|
setConfig={setConfig}
|
||||||
onNavigateToDevice={(id) => { setCurrentPage('devices'); setDevicesOpenId(id); }}
|
onNavigateToDevice={(id) => { setCurrentPage('devices'); setDevicesOpenId(id); }}
|
||||||
/>,
|
/>,
|
||||||
'print-manager': <MediaManager
|
|
||||||
title="Print Manager"
|
|
||||||
rootPath="/sd/.print"
|
|
||||||
onBack={() => setCurrentPage('apps')}
|
|
||||||
config={config}
|
|
||||||
setConfig={setConfig}
|
|
||||||
/>,
|
|
||||||
'serial-console': <AppPage title="Serial Console" onBack={() => setCurrentPage('apps')} />,
|
'serial-console': <AppPage title="Serial Console" onBack={() => setCurrentPage('apps')} />,
|
||||||
'directory-editor': <AppPage title="Directory Editor" onBack={() => setCurrentPage('apps')} />,
|
'directory-editor': <AppPage title="Directory Editor" onBack={() => setCurrentPage('apps')} />,
|
||||||
'sector-editor': <AppPage title="Sector Editor" onBack={() => setCurrentPage('apps')} />,
|
'sector-editor': <AppPage title="Sector Editor" onBack={() => setCurrentPage('apps')} />,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { X, ChevronLeft, ChevronRight, Printer, HardDrive, Network, Box, FolderO
|
||||||
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 { getFileContents, joinPath } from '../webdav';
|
||||||
import MediaBrowser from './MediaBrowser';
|
import FileBrowser from './FileBrowser';
|
||||||
import MediaSet from './MediaSet';
|
import MediaSet from './MediaSet';
|
||||||
|
|
||||||
interface Device {
|
interface Device {
|
||||||
|
|
@ -37,7 +37,7 @@ export default function DeviceDetailOverlay({
|
||||||
}: DeviceDetailOverlayProps) {
|
}: DeviceDetailOverlayProps) {
|
||||||
const [touchStart, setTouchStart] = useState(0);
|
const [touchStart, setTouchStart] = useState(0);
|
||||||
const [touchEnd, setTouchEnd] = useState(0);
|
const [touchEnd, setTouchEnd] = useState(0);
|
||||||
const [showMediaBrowser, setShowMediaBrowser] = useState(false);
|
const [showFileBrowser, setShowFileBrowser] = useState(false);
|
||||||
const [showCommandMenu, setShowCommandMenu] = useState(false);
|
const [showCommandMenu, setShowCommandMenu] = useState(false);
|
||||||
|
|
||||||
const minSwipeDistance = 50;
|
const minSwipeDistance = 50;
|
||||||
|
|
@ -332,7 +332,7 @@ export default function DeviceDetailOverlay({
|
||||||
className="flex-1 px-3 py-2 border border-neutral-300 rounded-lg"
|
className="flex-1 px-3 py-2 border border-neutral-300 rounded-lg"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowMediaBrowser(true)}
|
onClick={() => setShowFileBrowser(true)}
|
||||||
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
|
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
|
||||||
>
|
>
|
||||||
<FolderOpen className="w-5 h-5" />
|
<FolderOpen className="w-5 h-5" />
|
||||||
|
|
@ -395,11 +395,11 @@ export default function DeviceDetailOverlay({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showMediaBrowser && (
|
{showFileBrowser && (
|
||||||
<MediaBrowser
|
<FileBrowser
|
||||||
currentPath={deviceData.url || '/'}
|
currentPath={deviceData.url || '/'}
|
||||||
onSelect={(path) => { void handleFileSelect(path); setShowMediaBrowser(false); }}
|
onSelect={(path) => { void handleFileSelect(path); setShowFileBrowser(false); }}
|
||||||
onClose={() => setShowMediaBrowser(false)}
|
onClose={() => setShowFileBrowser(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
|
||||||
|
|
@ -50,13 +50,13 @@ function EntryIcon({ entry }: { entry: EntryInfo }) {
|
||||||
return <File className="w-5 h-5 text-neutral-400 flex-shrink-0" />;
|
return <File className="w-5 h-5 text-neutral-400 flex-shrink-0" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MediaBrowserProps {
|
interface FileBrowserProps {
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
onSelect: (path: string) => void;
|
onSelect: (path: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MediaBrowser({ currentPath, onSelect, onClose }: MediaBrowserProps) {
|
export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrowserProps) {
|
||||||
// Resolve the initial path: if `currentPath` is itself a file, jump
|
// Resolve the initial path: if `currentPath` is itself a file, jump
|
||||||
// to its parent so we never try to list a file as if it were a folder.
|
// to its parent so we never try to list a file as if it were a folder.
|
||||||
const [path, setPath] = useState<string | null>(null);
|
const [path, setPath] = useState<string | null>(null);
|
||||||
|
|
@ -1,20 +1,17 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AlignLeft,
|
AlignLeft,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Braces,
|
Braces,
|
||||||
Check,
|
Check,
|
||||||
CheckSquare,
|
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ClipboardPaste,
|
|
||||||
Code2,
|
Code2,
|
||||||
Copy,
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
File,
|
File,
|
||||||
FilePlus,
|
|
||||||
FileText,
|
FileText,
|
||||||
Folder,
|
Folder,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
|
|
@ -23,7 +20,6 @@ import {
|
||||||
Home,
|
Home,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
Menu,
|
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Move,
|
Move,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
|
@ -247,154 +243,10 @@ function EntryIcon({ entry }: { entry: EntryInfo }) {
|
||||||
return <File className="w-5 h-5 text-neutral-400 flex-shrink-0" />;
|
return <File className="w-5 h-5 text-neutral-400 flex-shrink-0" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── ActionsModal ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface FolderManagementActions {
|
|
||||||
onNewFolder: () => void;
|
|
||||||
onNewFile: () => void;
|
|
||||||
onUpload: () => void;
|
|
||||||
clipboard: Clipboard | null;
|
|
||||||
onPaste: () => void;
|
|
||||||
selectedCount: number;
|
|
||||||
totalCount: number;
|
|
||||||
onSelectAll: () => void;
|
|
||||||
isRoot: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActionsModalProps {
|
|
||||||
entry: EntryInfo | null;
|
|
||||||
onClose: () => void;
|
|
||||||
onOpen: (entry: EntryInfo, mode?: ViewMode) => void;
|
|
||||||
onMount: (entry: EntryInfo) => void;
|
|
||||||
onDownload: (entry: EntryInfo) => void;
|
|
||||||
onRename: (entry: EntryInfo) => void;
|
|
||||||
onCopy: (entry: EntryInfo) => void;
|
|
||||||
onCut: (entry: EntryInfo) => void;
|
|
||||||
onDelete: (entry: EntryInfo) => void;
|
|
||||||
folderManagement?: FolderManagementActions;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActionsModal({ entry, onClose, onOpen, onMount, onDownload, onRename, onCopy, onCut, onDelete, folderManagement }: ActionsModalProps) {
|
|
||||||
const isFolder = entry?.type === 'folder';
|
|
||||||
const fm = folderManagement;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={entry !== null} onOpenChange={open => !open && onClose()}>
|
|
||||||
<DialogContent className="max-w-sm">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="truncate">{entry?.name || '/'}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{isFolder ? 'Folder' : humanFileSize(entry?.size ?? 0)}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{entry && (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
|
|
||||||
{/* Folder management items — current-folder context (header Actions) */}
|
|
||||||
{fm && (
|
|
||||||
<>
|
|
||||||
<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">
|
|
||||||
<FolderPlus className="w-4 h-4 text-neutral-500" /> <span>New Folder</span>
|
|
||||||
</button>
|
|
||||||
<button onClick={() => { onClose(); fm.onNewFile(); }}
|
|
||||||
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
|
|
||||||
<FilePlus className="w-4 h-4 text-neutral-500" /> <span>New File</span>
|
|
||||||
</button>
|
|
||||||
<button onClick={() => { onClose(); fm.onUpload(); }}
|
|
||||||
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
|
|
||||||
<Upload className="w-4 h-4 text-neutral-500" /> <span>Upload Files</span>
|
|
||||||
</button>
|
|
||||||
{fm.clipboard && (
|
|
||||||
<button onClick={() => { onClose(); fm.onPaste(); }}
|
|
||||||
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
|
|
||||||
<ClipboardPaste className="w-4 h-4 text-neutral-500" />
|
|
||||||
<span>{fm.clipboard.op === 'copy' ? 'Copy' : 'Move'} {fm.clipboard.paths.length} item{fm.clipboard.paths.length !== 1 ? 's' : ''} here</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button onClick={() => { onClose(); fm.onSelectAll(); }}
|
|
||||||
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
|
|
||||||
<CheckSquare className="w-4 h-4 text-neutral-500" />
|
|
||||||
<span>{fm.selectedCount === fm.totalCount && fm.totalCount > 0 ? 'Deselect All' : 'Select All'}</span>
|
|
||||||
</button>
|
|
||||||
{!fm.isRoot && <div className="border-t border-neutral-100" />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Open folder — list item only (not current-folder context) */}
|
|
||||||
{isFolder && !fm && (
|
|
||||||
<button onClick={() => { onClose(); onOpen(entry); }}
|
|
||||||
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">
|
|
||||||
<Folder className="w-4 h-4 text-blue-600" /> <span>Open folder</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* File actions */}
|
|
||||||
{!isFolder && (
|
|
||||||
<>
|
|
||||||
<button onClick={() => { onClose(); onMount(entry); }}
|
|
||||||
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 on virtual drive</span>
|
|
||||||
</button>
|
|
||||||
<button onClick={() => { onClose(); onOpen(entry); }}
|
|
||||||
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
|
|
||||||
<Eye className="w-4 h-4 text-blue-600" />
|
|
||||||
<span className="flex-1">Open / View</span>
|
|
||||||
<span className="text-xs text-neutral-400">{VIEWER_LABEL[defaultViewMode(entry)]}</span>
|
|
||||||
</button>
|
|
||||||
{availableViewers(entry).filter(m => m !== defaultViewMode(entry)).map(mode => (
|
|
||||||
<button key={mode} onClick={() => { onClose(); onOpen(entry, mode); }}
|
|
||||||
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
|
|
||||||
<ViewerModeIcon mode={mode} className="w-4 h-4 text-neutral-500" />
|
|
||||||
<span>Open as {VIEWER_LABEL[mode]}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<button onClick={() => { onClose(); onDownload(entry); }}
|
|
||||||
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
|
|
||||||
<Download className="w-4 h-4" /> <span>Download</span>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Rename / Copy / Move / Delete */}
|
|
||||||
{(!fm || !fm.isRoot) && (
|
|
||||||
<>
|
|
||||||
<div className="border-t border-neutral-100" />
|
|
||||||
<button onClick={() => { onClose(); onRename(entry); }}
|
|
||||||
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
|
|
||||||
<Pencil className="w-4 h-4" /> <span>Rename</span>
|
|
||||||
</button>
|
|
||||||
{!fm && (
|
|
||||||
<>
|
|
||||||
<button onClick={() => { onClose(); onCopy(entry); }}
|
|
||||||
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
|
|
||||||
<Copy className="w-4 h-4" /> <span>Copy</span>
|
|
||||||
</button>
|
|
||||||
<button onClick={() => { onClose(); onCut(entry); }}
|
|
||||||
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
|
|
||||||
<Move className="w-4 h-4" /> <span>Move (Cut)</span>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<button onClick={() => { onClose(); onDelete(entry); }}
|
|
||||||
className="w-full text-left px-4 py-3 rounded border border-red-200 hover:bg-red-50 text-red-700 inline-flex items-center gap-3">
|
|
||||||
<Trash2 className="w-4 h-4" /> <span>Delete</span>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface MediaManagerProps {
|
interface FileManagerProps {
|
||||||
initialPath?: string;
|
initialPath?: string;
|
||||||
rootPath?: string;
|
|
||||||
title?: string;
|
|
||||||
config?: any;
|
config?: any;
|
||||||
setConfig?: (c: any) => void;
|
setConfig?: (c: any) => void;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
|
|
@ -451,9 +303,8 @@ async function _getEntryBytes(entry: EntryInfo): Promise<Uint8Array> {
|
||||||
|
|
||||||
const FM_PATH_KEY = 'fileManager.path';
|
const FM_PATH_KEY = 'fileManager.path';
|
||||||
|
|
||||||
export default function MediaManager({ initialPath = '/', rootPath, title, config, setConfig, onBack, onNavigateToDevice }: MediaManagerProps) {
|
export default function FileManager({ initialPath = '/', config, setConfig, onBack, onNavigateToDevice }: FileManagerProps) {
|
||||||
const pathKey = rootPath ? `fileManager.path:${rootPath}` : FM_PATH_KEY;
|
const [path, setPath] = useState(() => normalizePath(localStorage.getItem(FM_PATH_KEY) || initialPath));
|
||||||
const [path, setPath] = useState(() => normalizePath(localStorage.getItem(pathKey) || rootPath || initialPath));
|
|
||||||
const [entries, setEntries] = useState<EntryInfo[]>([]);
|
const [entries, setEntries] = useState<EntryInfo[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -463,10 +314,7 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
|
||||||
const [sortAsc, setSortAsc] = useState(() => localStorage.getItem('fileManager.sortAsc') !== 'false');
|
const [sortAsc, setSortAsc] = useState(() => localStorage.getItem('fileManager.sortAsc') !== 'false');
|
||||||
const [clipboard, setClipboard] = useState<Clipboard | null>(null);
|
const [clipboard, setClipboard] = useState<Clipboard | null>(null);
|
||||||
const [actionEntry, setActionEntry] = useState<EntryInfo | null>(null);
|
const [actionEntry, setActionEntry] = useState<EntryInfo | null>(null);
|
||||||
const [folderActionOpen, setFolderActionOpen] = useState(false);
|
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const [showNewFile, setShowNewFile] = useState(false);
|
|
||||||
const [newFileName, setNewFileName] = useState('');
|
|
||||||
|
|
||||||
// Viewer
|
// Viewer
|
||||||
const [viewEntry, setViewEntry] = useState<EntryInfo | null>(null);
|
const [viewEntry, setViewEntry] = useState<EntryInfo | null>(null);
|
||||||
|
|
@ -509,13 +357,12 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
|
||||||
useEffect(() => { localStorage.setItem('fileManager.sortAsc', String(sortAsc)); }, [sortAsc]);
|
useEffect(() => { localStorage.setItem('fileManager.sortAsc', String(sortAsc)); }, [sortAsc]);
|
||||||
|
|
||||||
const navigateTo = (p: string) => {
|
const navigateTo = (p: string) => {
|
||||||
let norm = normalizePath(p);
|
const norm = normalizePath(p);
|
||||||
if (rootPath && !norm.startsWith(rootPath)) norm = rootPath;
|
localStorage.setItem(FM_PATH_KEY, norm);
|
||||||
localStorage.setItem(pathKey, norm);
|
|
||||||
setPath(norm);
|
setPath(norm);
|
||||||
setFilter('');
|
setFilter('');
|
||||||
};
|
};
|
||||||
const navigateUp = () => { if (path !== (rootPath ?? '/')) navigateTo(splitPath(path).parent); };
|
const navigateUp = () => { if (path !== '/') navigateTo(splitPath(path).parent); };
|
||||||
|
|
||||||
// ── Sort + filter ────────────────────────────────────────────────────────
|
// ── Sort + filter ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -663,7 +510,6 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
|
||||||
setRenameEntry(entry);
|
setRenameEntry(entry);
|
||||||
setRenameName(entry.name);
|
setRenameName(entry.name);
|
||||||
setActionEntry(null);
|
setActionEntry(null);
|
||||||
setFolderActionOpen(false);
|
|
||||||
setTimeout(() => renameInputRef.current?.focus(), 50);
|
setTimeout(() => renameInputRef.current?.focus(), 50);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -776,19 +622,6 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
|
||||||
} catch (e: any) { toast.error(`Failed to create folder: ${e?.message ?? e}`); }
|
} catch (e: any) { toast.error(`Failed to create folder: ${e?.message ?? e}`); }
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── New file ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const handleCreateFile = async () => {
|
|
||||||
const name = newFileName.trim();
|
|
||||||
if (!name) return;
|
|
||||||
try {
|
|
||||||
await putFileContents(joinPath(path, name), new Uint8Array(0));
|
|
||||||
toast.success(`Created "${name}"`);
|
|
||||||
setShowNewFile(false); setNewFileName('');
|
|
||||||
void load(path);
|
|
||||||
} catch (e: any) { toast.error(`Failed to create file: ${e?.message ?? e}`); }
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Upload ───────────────────────────────────────────────────────────────
|
// ── Upload ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const handleUpload = async (file: File) => {
|
const handleUpload = async (file: File) => {
|
||||||
|
|
@ -823,23 +656,6 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
|
||||||
const pathParts = path.split('/').filter(Boolean);
|
const pathParts = path.split('/').filter(Boolean);
|
||||||
const selCount = selected.size;
|
const selCount = selected.size;
|
||||||
|
|
||||||
const deviceBaseUrl = useMemo(() => {
|
|
||||||
if (!config?.iec?.devices) return null;
|
|
||||||
const groups = ['drive', 'meatloaf', 'printer', 'network', 'other'];
|
|
||||||
for (const t of groups) {
|
|
||||||
for (const dev of Object.values(config.iec.devices[t] ?? {}) as any[]) {
|
|
||||||
if (dev?.base_url && (path.startsWith(dev.base_url) || dev.base_url === rootPath))
|
|
||||||
return dev.base_url as string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const t of groups) {
|
|
||||||
for (const dev of Object.values(config.iec.devices[t] ?? {}) as any[]) {
|
|
||||||
if (dev?.base_url) return dev.base_url as string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [config, path, rootPath]);
|
|
||||||
|
|
||||||
// ── Render ───────────────────────────────────────────────────────────────
|
// ── Render ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -858,28 +674,22 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
|
||||||
<ChevronLeft className="w-5 h-5" />
|
<ChevronLeft className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<h2 className="font-semibold flex-1 text-sm">{title ?? 'Media Manager'}</h2>
|
<h2 className="font-semibold flex-1 text-sm">File Manager</h2>
|
||||||
<button onClick={() => void load(path)} className="p-1.5 rounded hover:bg-neutral-100" title="Refresh">
|
<button onClick={() => void load(path)} className="p-1.5 rounded hover:bg-neutral-100" title="Refresh">
|
||||||
<RefreshCw className="w-4 h-4" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => { setShowNewFile(v => !v); setShowNewFolder(false); }} className="p-1.5 rounded hover:bg-neutral-100" title="New File">
|
<button onClick={() => setShowNewFolder(v => !v)} className="p-1.5 rounded hover:bg-neutral-100" title="New Folder">
|
||||||
<FilePlus className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button onClick={() => { setShowNewFolder(v => !v); setShowNewFile(false); }} className="p-1.5 rounded hover:bg-neutral-100" title="New Folder">
|
|
||||||
<FolderPlus className="w-4 h-4" />
|
<FolderPlus className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => fileInputRef.current?.click()} className="p-1.5 rounded hover:bg-neutral-100" title="Upload">
|
<button onClick={() => fileInputRef.current?.click()} className="p-1.5 rounded hover:bg-neutral-100" title="Upload">
|
||||||
<Upload className="w-4 h-4" />
|
<Upload className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setFolderActionOpen(true)} className="p-1.5 rounded hover:bg-neutral-100" title="Actions">
|
|
||||||
<MoreVertical className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={onPickFiles} />
|
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={onPickFiles} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<div className="flex items-center gap-1 text-sm text-neutral-600 overflow-x-auto">
|
<div className="flex items-center gap-1 text-sm text-neutral-600 overflow-x-auto">
|
||||||
<button onClick={() => navigateTo(rootPath ?? '/')} className="p-1 rounded hover:bg-neutral-100 flex-shrink-0" title="Root">
|
<button onClick={() => navigateTo('/')} className="p-1 rounded hover:bg-neutral-100 flex-shrink-0" title="Root">
|
||||||
<Home className="w-4 h-4" />
|
<Home className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
{pathParts.map((part, i) => (
|
{pathParts.map((part, i) => (
|
||||||
|
|
@ -894,28 +704,7 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{deviceBaseUrl && (
|
|
||||||
<div className="text-xs text-neutral-400 mt-0.5 truncate">Base: {deviceBaseUrl}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showNewFile && (
|
|
||||||
<div className="mt-2 flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
value={newFileName}
|
|
||||||
onChange={e => setNewFileName(e.target.value)}
|
|
||||||
onKeyDown={e => {
|
|
||||||
if (e.key === 'Enter') void handleCreateFile();
|
|
||||||
if (e.key === 'Escape') { setShowNewFile(false); setNewFileName(''); }
|
|
||||||
}}
|
|
||||||
placeholder="New file name"
|
|
||||||
className="flex-1 px-2 py-1 text-sm border border-neutral-300 rounded"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<button onClick={() => void handleCreateFile()} className="px-3 py-1 text-sm bg-blue-600 text-white rounded">
|
|
||||||
Create
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showNewFolder && (
|
{showNewFolder && (
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
|
|
@ -1128,31 +917,105 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Actions modal (per-entry + current-folder context) ── */}
|
{/* ── Per-entry action menu ── */}
|
||||||
<ActionsModal
|
<Dialog open={actionEntry !== null} onOpenChange={open => !open && setActionEntry(null)}>
|
||||||
entry={folderActionOpen
|
<DialogContent className="max-w-sm">
|
||||||
? { name: splitPath(path).name || '/', path, type: 'folder', size: 0, lastModified: null, contentType: null }
|
<DialogHeader>
|
||||||
: actionEntry}
|
<DialogTitle className="truncate">{actionEntry?.name}</DialogTitle>
|
||||||
onClose={() => { setActionEntry(null); setFolderActionOpen(false); }}
|
<DialogDescription>
|
||||||
onOpen={(e, mode) => void openEntry(e, mode)}
|
{actionEntry?.type === 'folder' ? 'Folder' : humanFileSize(actionEntry?.size ?? 0)}
|
||||||
onMount={e => setMountEntry(e)}
|
</DialogDescription>
|
||||||
onDownload={e => void downloadEntry(e)}
|
</DialogHeader>
|
||||||
onRename={e => startRename(e)}
|
<div className="flex flex-col gap-2">
|
||||||
onCopy={e => cutOrCopyEntry(e, 'copy')}
|
{/* Folder: open */}
|
||||||
onCut={e => cutOrCopyEntry(e, 'move')}
|
{actionEntry?.type === 'folder' && (
|
||||||
onDelete={e => void deleteEntry(e)}
|
<button
|
||||||
folderManagement={folderActionOpen ? {
|
onClick={() => { const e = actionEntry; setActionEntry(null); if (e) void openEntry(e); }}
|
||||||
onNewFolder: () => { setShowNewFolder(true); setShowNewFile(false); },
|
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"
|
||||||
onNewFile: () => { setShowNewFile(true); setShowNewFolder(false); },
|
>
|
||||||
onUpload: () => fileInputRef.current?.click(),
|
<Folder className="w-4 h-4 text-blue-600" />
|
||||||
clipboard,
|
<span>Open folder</span>
|
||||||
onPaste: () => void paste(),
|
</button>
|
||||||
selectedCount: selected.size,
|
)}
|
||||||
totalCount: visible.length,
|
|
||||||
onSelectAll: selectAll,
|
{/* File: mount (primary default) */}
|
||||||
isRoot: path === (rootPath ?? '/'),
|
{actionEntry?.type === 'file' && (
|
||||||
} : undefined}
|
<button
|
||||||
/>
|
onClick={() => { const e = actionEntry; setActionEntry(null); if (e) setMountEntry(e); }}
|
||||||
|
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 on virtual drive</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* File: open/view */}
|
||||||
|
{actionEntry?.type === 'file' && (
|
||||||
|
<button
|
||||||
|
onClick={() => { const e = actionEntry; setActionEntry(null); if (e) void openEntry(e); }}
|
||||||
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4 text-blue-600" />
|
||||||
|
<span className="flex-1">Open / View</span>
|
||||||
|
<span className="text-xs text-neutral-400">{VIEWER_LABEL[defaultViewMode(actionEntry)]}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Alternate viewers */}
|
||||||
|
{actionEntry?.type === 'file' && availableViewers(actionEntry)
|
||||||
|
.filter(m => m !== defaultViewMode(actionEntry))
|
||||||
|
.map(mode => {
|
||||||
|
const entry = actionEntry;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
onClick={() => { setActionEntry(null); void openEntry(entry, mode); }}
|
||||||
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<ViewerModeIcon mode={mode} className="w-4 h-4 text-neutral-500" />
|
||||||
|
<span>Open as {VIEWER_LABEL[mode]}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{actionEntry?.type === 'file' && (
|
||||||
|
<button
|
||||||
|
onClick={() => { const e = actionEntry; setActionEntry(null); if (e) void downloadEntry(e); }}
|
||||||
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" /> <span>Download</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border-t border-neutral-100" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => { if (actionEntry) startRename(actionEntry); }}
|
||||||
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" /> <span>Rename</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { if (actionEntry) cutOrCopyEntry(actionEntry, 'copy'); }}
|
||||||
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" /> <span>Copy</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { if (actionEntry) cutOrCopyEntry(actionEntry, 'move'); }}
|
||||||
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Move className="w-4 h-4" /> <span>Move (Cut)</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { const e = actionEntry; setActionEntry(null); if (e) void deleteEntry(e); }}
|
||||||
|
className="w-full text-left px-4 py-3 rounded border border-red-200 hover:bg-red-50 text-red-700 inline-flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" /> <span>Delete</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* ── File viewer overlay ── */}
|
{/* ── File viewer overlay ── */}
|
||||||
{viewEntry && (
|
{viewEntry && (
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { FolderOpen } from 'lucide-react';
|
import { FolderOpen } from 'lucide-react';
|
||||||
import MediaBrowser from './MediaBrowser';
|
import FileBrowser from './FileBrowser';
|
||||||
|
|
||||||
interface IECPageProps {
|
interface IECPageProps {
|
||||||
config: any;
|
config: any;
|
||||||
|
|
@ -8,7 +8,7 @@ interface IECPageProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function IECPage({ config, setConfig }: IECPageProps) {
|
export default function IECPage({ config, setConfig }: IECPageProps) {
|
||||||
const [showMediaBrowser, setShowMediaBrowser] = useState(false);
|
const [showFileBrowser, setShowFileBrowser] = useState(false);
|
||||||
const updateSetting = (path: string[], value: any) => {
|
const updateSetting = (path: string[], value: any) => {
|
||||||
const newConfig = JSON.parse(JSON.stringify(config));
|
const newConfig = JSON.parse(JSON.stringify(config));
|
||||||
let current = newConfig;
|
let current = newConfig;
|
||||||
|
|
@ -70,7 +70,7 @@ export default function IECPage({ config, setConfig }: IECPageProps) {
|
||||||
className="flex-1 px-3 py-2 border border-neutral-300 rounded-lg"
|
className="flex-1 px-3 py-2 border border-neutral-300 rounded-lg"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowMediaBrowser(true)}
|
onClick={() => setShowFileBrowser(true)}
|
||||||
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
|
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
|
||||||
title="Browse files"
|
title="Browse files"
|
||||||
>
|
>
|
||||||
|
|
@ -79,14 +79,14 @@ export default function IECPage({ config, setConfig }: IECPageProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showMediaBrowser && (
|
{showFileBrowser && (
|
||||||
<MediaBrowser
|
<FileBrowser
|
||||||
currentPath={iec.boot_disk || '/'}
|
currentPath={iec.boot_disk || '/'}
|
||||||
onSelect={(path) => {
|
onSelect={(path) => {
|
||||||
updateSetting(['iec', 'boot_disk'], path);
|
updateSetting(['iec', 'boot_disk'], path);
|
||||||
setShowMediaBrowser(false);
|
setShowFileBrowser(false);
|
||||||
}}
|
}}
|
||||||
onClose={() => setShowMediaBrowser(false)}
|
onClose={() => setShowFileBrowser(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ function figmaAssetResolver() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: process.env.BASE_PATH || '/',
|
base: process.env.BASE_PATH || '/config/',
|
||||||
plugins: [
|
plugins: [
|
||||||
figmaAssetResolver(),
|
figmaAssetResolver(),
|
||||||
// The React and Tailwind plugins are both required for Make, even if
|
// The React and Tailwind plugins are both required for Make, even if
|
||||||
|
|
|
||||||