save/load projects and robots now stores on server
parent
7c8cd918e8
commit
8d661307b9
20
index.html
20
index.html
|
|
@ -56,15 +56,18 @@
|
|||
<div id="robot-panel" class="ide-panel hidden">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Robot</span>
|
||||
<button type="button" id="robot-panel-done" class="cust-done-btn" title="Close">Done</button>
|
||||
</div>
|
||||
<div class="panel-body robot-panel-body">
|
||||
<div class="robot-toolbar">
|
||||
<button type="button" id="robot-new" class="robot-tb-btn">New</button>
|
||||
<button type="button" id="robot-open" class="robot-tb-btn">Open…</button>
|
||||
<button type="button" id="robot-save" class="robot-tb-btn">Save…</button>
|
||||
<button type="button" id="robot-open" class="robot-tb-btn">Open</button>
|
||||
<button type="button" id="robot-save" class="robot-tb-btn">Save</button>
|
||||
<button type="button" id="robot-apply" class="robot-tb-btn robot-tb-primary">Apply</button>
|
||||
</div>
|
||||
<ul id="robot-file-list" class="projects-list"></ul>
|
||||
<div class="projects-name-row">
|
||||
<input type="text" id="robot-save-name" placeholder="Robot file name..." />
|
||||
</div>
|
||||
<button type="button" id="robot-import-ws" class="robot-full-btn">Import from workspace</button>
|
||||
<div id="robot-device-note" class="robot-note"></div>
|
||||
<div class="robot-add-row">
|
||||
|
|
@ -137,11 +140,18 @@
|
|||
<input type="text" id="browser-save-name" placeholder="Project name..." />
|
||||
<button id="browser-save-btn">Save</button>
|
||||
</div>
|
||||
<div class="projects-btn-row">
|
||||
<div class="projects-btn-row projects-btn-row-primary">
|
||||
<button id="browser-load-btn" disabled>Load</button>
|
||||
<button id="browser-download-btn" disabled>Download to computer</button>
|
||||
<button id="browser-delete-btn" class="btn-danger" disabled>Delete</button>
|
||||
</div>
|
||||
<div class="projects-btn-row projects-btn-row-secondary">
|
||||
<button id="browser-move-cloud-btn" disabled>Move to cloud</button>
|
||||
<button id="browser-download-btn" disabled>Download to computer</button>
|
||||
</div>
|
||||
<div class="projects-btn-row projects-btn-row-secondary">
|
||||
<button id="browser-upload-btn">Upload from computer</button>
|
||||
</div>
|
||||
<input type="file" id="browser-upload-input" accept=".blk,.json,application/json" class="robot-file-input-hidden" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
45
server/db.js
45
server/db.js
|
|
@ -38,6 +38,18 @@ export async function bootstrap() {
|
|||
CONSTRAINT fk_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`);
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id INT NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
state_json LONGTEXT NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uniq_user_project (user_id, name),
|
||||
INDEX idx_projects_user (user_id),
|
||||
CONSTRAINT fk_projects_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`);
|
||||
}
|
||||
|
||||
export async function findUserByUsername(username) {
|
||||
|
|
@ -90,3 +102,36 @@ export async function deleteSession(token) {
|
|||
export async function purgeExpiredSessions() {
|
||||
await getPool().query('DELETE FROM sessions WHERE expires_at <= NOW()');
|
||||
}
|
||||
|
||||
export async function listProjectsByUser(userId) {
|
||||
const [rows] = await getPool().query(
|
||||
'SELECT name, state_json, updated_at FROM projects WHERE user_id = ? ORDER BY name ASC',
|
||||
[userId],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function upsertProject({ userId, name, stateJson }) {
|
||||
await getPool().query(
|
||||
`INSERT INTO projects (user_id, name, state_json)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE state_json = VALUES(state_json), updated_at = CURRENT_TIMESTAMP`,
|
||||
[userId, name, stateJson],
|
||||
);
|
||||
}
|
||||
|
||||
export async function getProjectByName({ userId, name }) {
|
||||
const [rows] = await getPool().query(
|
||||
'SELECT name, state_json, updated_at FROM projects WHERE user_id = ? AND name = ? LIMIT 1',
|
||||
[userId, name],
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function deleteProjectByName({ userId, name }) {
|
||||
const [result] = await getPool().query(
|
||||
'DELETE FROM projects WHERE user_id = ? AND name = ?',
|
||||
[userId, name],
|
||||
);
|
||||
return result.affectedRows > 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { join, resolve, dirname } from 'node:path';
|
|||
import { fileURLToPath } from 'node:url';
|
||||
import { tmpdir } from 'node:os';
|
||||
import authRouter from './auth.js';
|
||||
import projectsRouter from './projects.js';
|
||||
import { bootstrap, purgeExpiredSessions } from './db.js';
|
||||
import { attachWsServer } from './ws.js';
|
||||
|
||||
|
|
@ -19,6 +20,7 @@ app.use(express.json({ limit: '2mb' }));
|
|||
// ─── Auth ────────────────────────────────────────────────
|
||||
|
||||
app.use('/api/auth', authRouter);
|
||||
app.use('/api/projects', projectsRouter);
|
||||
|
||||
// ─── Helpers (Arduino CLI) ───────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
import { Router } from 'express';
|
||||
import { requireAuth } from './auth.js';
|
||||
import {
|
||||
listProjectsByUser,
|
||||
upsertProject,
|
||||
getProjectByName,
|
||||
deleteProjectByName,
|
||||
} from './db.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
function isValidName(name) {
|
||||
return typeof name === 'string' && name.trim().length > 0 && name.trim().length <= 128;
|
||||
}
|
||||
|
||||
router.get('/', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const rows = await listProjectsByUser(req.user.id);
|
||||
const projects = rows.map((row) => ({
|
||||
name: row.name,
|
||||
state: JSON.parse(row.state_json),
|
||||
updatedAt: row.updated_at,
|
||||
}));
|
||||
res.json({ projects });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', requireAuth, async (req, res) => {
|
||||
const { name, state } = req.body || {};
|
||||
if (!isValidName(name)) return res.status(400).json({ error: 'Invalid project name' });
|
||||
if (!state || typeof state !== 'object') return res.status(400).json({ error: 'Invalid project state' });
|
||||
|
||||
try {
|
||||
await upsertProject({
|
||||
userId: req.user.id,
|
||||
name: name.trim(),
|
||||
stateJson: JSON.stringify(state),
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:name', requireAuth, async (req, res) => {
|
||||
const name = decodeURIComponent(req.params.name || '');
|
||||
if (!isValidName(name)) return res.status(400).json({ error: 'Invalid project name' });
|
||||
|
||||
try {
|
||||
const row = await getProjectByName({ userId: req.user.id, name: name.trim() });
|
||||
if (!row) return res.status(404).json({ error: 'Project not found' });
|
||||
res.json({
|
||||
name: row.name,
|
||||
state: JSON.parse(row.state_json),
|
||||
updatedAt: row.updated_at,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:name', requireAuth, async (req, res) => {
|
||||
const name = decodeURIComponent(req.params.name || '');
|
||||
if (!isValidName(name)) return res.status(400).json({ error: 'Invalid project name' });
|
||||
|
||||
try {
|
||||
const ok = await deleteProjectByName({ userId: req.user.id, name: name.trim() });
|
||||
if (!ok) return res.status(404).json({ error: 'Project not found' });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -535,6 +535,18 @@ html, body {
|
|||
gap: 4px;
|
||||
}
|
||||
|
||||
.projects-btn-row-primary #browser-load-btn {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.projects-btn-row-primary #browser-delete-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.projects-btn-row-secondary button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.projects-actions button {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
|
|
@ -558,8 +570,15 @@ html, body {
|
|||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.projects-actions button.btn-danger {
|
||||
background: rgba(243, 139, 168, 0.2);
|
||||
color: var(--red);
|
||||
border-color: rgba(243, 139, 168, 0.7);
|
||||
}
|
||||
|
||||
.projects-actions button.btn-danger:hover:not(:disabled) {
|
||||
background: var(--red);
|
||||
color: var(--bg-toolbar);
|
||||
border-color: var(--red);
|
||||
}
|
||||
|
||||
|
|
@ -589,6 +608,11 @@ html, body {
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
#robot-file-list {
|
||||
min-height: 72px;
|
||||
max-height: 140px;
|
||||
}
|
||||
|
||||
.robot-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import * as Blockly from 'blockly';
|
||||
import { getToken, onAuthChange } from '../auth/client.js';
|
||||
|
||||
const BROWSER_STORAGE_KEY = 'esp32block_projects';
|
||||
|
||||
|
|
@ -9,9 +10,12 @@ let checkConnected = null;
|
|||
|
||||
let browserList;
|
||||
let browserNameInput;
|
||||
let browserSaveBtn, browserLoadBtn, browserDownloadBtn, browserDeleteBtn;
|
||||
let browserSaveBtn, browserLoadBtn, browserMoveCloudBtn, browserDownloadBtn, browserDeleteBtn;
|
||||
let browserUploadBtn, browserUploadInput;
|
||||
|
||||
let browserSelected = null;
|
||||
let selectedEntry = null;
|
||||
let cloudProjects = [];
|
||||
|
||||
export function initProjectsDialog(deps) {
|
||||
workspace = deps.workspace;
|
||||
|
|
@ -23,19 +27,28 @@ export function initProjectsDialog(deps) {
|
|||
browserNameInput = document.getElementById('browser-save-name');
|
||||
browserSaveBtn = document.getElementById('browser-save-btn');
|
||||
browserLoadBtn = document.getElementById('browser-load-btn');
|
||||
browserMoveCloudBtn = document.getElementById('browser-move-cloud-btn');
|
||||
browserDownloadBtn = document.getElementById('browser-download-btn');
|
||||
browserDeleteBtn = document.getElementById('browser-delete-btn');
|
||||
browserUploadBtn = document.getElementById('browser-upload-btn');
|
||||
browserUploadInput = document.getElementById('browser-upload-input');
|
||||
|
||||
browserSaveBtn.addEventListener('click', saveBrowser);
|
||||
browserLoadBtn.addEventListener('click', loadBrowser);
|
||||
browserMoveCloudBtn.addEventListener('click', moveBrowserToCloud);
|
||||
browserDownloadBtn.addEventListener('click', downloadBrowser);
|
||||
browserDeleteBtn.addEventListener('click', deleteBrowser);
|
||||
browserUploadBtn.addEventListener('click', openUploadPicker);
|
||||
browserUploadInput.addEventListener('change', uploadFromComputer);
|
||||
|
||||
refreshBrowserList();
|
||||
onAuthChange(() => {
|
||||
refreshBrowserList().catch(() => { /* ignore */ });
|
||||
});
|
||||
refreshBrowserList().catch(() => { /* ignore */ });
|
||||
}
|
||||
|
||||
export function refreshAll() {
|
||||
refreshBrowserList();
|
||||
refreshBrowserList().catch(() => { /* ignore */ });
|
||||
}
|
||||
|
||||
export async function saveCurrentWorkspaceToDevice(preferredName = 'main') {
|
||||
|
|
@ -70,80 +83,197 @@ function setBrowserProjects(projects) {
|
|||
localStorage.setItem(BROWSER_STORAGE_KEY, JSON.stringify(projects));
|
||||
}
|
||||
|
||||
function refreshBrowserList() {
|
||||
async function fetchCloudProjects() {
|
||||
const token = getToken();
|
||||
if (!token) return [];
|
||||
const res = await fetch(apiUrl('/api/projects'), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to fetch cloud projects (HTTP ${res.status})`);
|
||||
const data = await res.json();
|
||||
return Array.isArray(data?.projects) ? data.projects : [];
|
||||
}
|
||||
|
||||
function apiUrl(path) {
|
||||
const base = import.meta.env.BASE_URL || '/';
|
||||
const p = path.startsWith('/') ? path.slice(1) : path;
|
||||
return (base.endsWith('/') ? base : base + '/') + p;
|
||||
}
|
||||
|
||||
function displayName(entry) {
|
||||
const tag = entry.source === 'cloud' ? '[cloud]' : '[browser]';
|
||||
return `${entry.name} ${tag}`;
|
||||
}
|
||||
|
||||
async function refreshBrowserList() {
|
||||
const loggedIn = !!getToken();
|
||||
const projects = getBrowserProjects();
|
||||
const names = Object.keys(projects).sort();
|
||||
const names = Object.keys(projects).sort((a, b) => a.localeCompare(b));
|
||||
try {
|
||||
cloudProjects = await fetchCloudProjects();
|
||||
} catch {
|
||||
cloudProjects = [];
|
||||
}
|
||||
|
||||
browserList.innerHTML = '';
|
||||
browserSelected = null;
|
||||
browserSelected = null; // legacy selection key
|
||||
selectedEntry = null;
|
||||
updateBrowserButtons();
|
||||
|
||||
if (names.length === 0) {
|
||||
const entries = loggedIn
|
||||
? [
|
||||
...names.map((name) => ({ source: 'browser', name })),
|
||||
...cloudProjects.map((p) => ({ source: 'cloud', name: p.name })),
|
||||
]
|
||||
: names.map((name) => ({ source: 'browser', name }));
|
||||
|
||||
entries.sort((a, b) => {
|
||||
const byName = a.name.localeCompare(b.name);
|
||||
if (byName !== 0) return byName;
|
||||
return a.source.localeCompare(b.source);
|
||||
});
|
||||
|
||||
if (entries.length === 0) {
|
||||
browserList.innerHTML = '<li class="empty-msg">No saved projects</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
for (const name of names) {
|
||||
for (const entry of entries) {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = name;
|
||||
li.addEventListener('click', () => selectBrowserItem(name, li));
|
||||
li.addEventListener('dblclick', () => { selectBrowserItem(name, li); loadBrowser(); });
|
||||
li.textContent = displayName(entry);
|
||||
li.addEventListener('click', () => selectBrowserItem(entry, li));
|
||||
li.addEventListener('dblclick', () => { selectBrowserItem(entry, li); loadBrowser(); });
|
||||
browserList.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
function selectBrowserItem(name, li) {
|
||||
function selectBrowserItem(entry, li) {
|
||||
browserList.querySelectorAll('li').forEach(el => el.classList.remove('selected'));
|
||||
li.classList.add('selected');
|
||||
browserSelected = name;
|
||||
browserNameInput.value = name;
|
||||
selectedEntry = entry;
|
||||
browserSelected = entry.name;
|
||||
browserNameInput.value = entry.name;
|
||||
updateBrowserButtons();
|
||||
}
|
||||
|
||||
function updateBrowserButtons() {
|
||||
browserLoadBtn.disabled = !browserSelected;
|
||||
browserDownloadBtn.disabled = !browserSelected;
|
||||
browserDeleteBtn.disabled = !browserSelected;
|
||||
const hasSelection = !!selectedEntry;
|
||||
const canMoveToCloud = !!selectedEntry && selectedEntry.source === 'browser' && !!getToken();
|
||||
browserLoadBtn.disabled = !hasSelection;
|
||||
browserMoveCloudBtn.disabled = !canMoveToCloud;
|
||||
browserDownloadBtn.disabled = !hasSelection;
|
||||
browserDeleteBtn.disabled = !hasSelection;
|
||||
}
|
||||
|
||||
function saveBrowser() {
|
||||
async function saveCloud(name, state) {
|
||||
const token = getToken();
|
||||
if (!token) return false;
|
||||
const res = await fetch(apiUrl('/api/projects'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ name, state }),
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
async function saveBrowser() {
|
||||
const name = browserNameInput.value.trim();
|
||||
if (!name) return;
|
||||
const projects = getBrowserProjects();
|
||||
projects[name] = Blockly.serialization.workspaces.save(workspace);
|
||||
setBrowserProjects(projects);
|
||||
const state = Blockly.serialization.workspaces.save(workspace);
|
||||
if (getToken()) {
|
||||
await saveCloud(name, state).catch(() => { /* ignore */ });
|
||||
} else {
|
||||
const projects = getBrowserProjects();
|
||||
projects[name] = state;
|
||||
setBrowserProjects(projects);
|
||||
}
|
||||
browserNameInput.value = '';
|
||||
refreshBrowserList();
|
||||
await refreshBrowserList();
|
||||
}
|
||||
|
||||
function loadBrowser() {
|
||||
if (!browserSelected) return;
|
||||
async function loadBrowser() {
|
||||
if (!selectedEntry) return;
|
||||
if (selectedEntry.source === 'cloud') {
|
||||
const cloud = cloudProjects.find((p) => p.name === selectedEntry.name);
|
||||
if (!cloud?.state) return;
|
||||
Blockly.serialization.workspaces.load(cloud.state, workspace);
|
||||
return;
|
||||
}
|
||||
const projects = getBrowserProjects();
|
||||
const state = projects[browserSelected];
|
||||
const state = projects[selectedEntry.name];
|
||||
if (!state) return;
|
||||
Blockly.serialization.workspaces.load(state, workspace);
|
||||
}
|
||||
|
||||
function deleteBrowser() {
|
||||
if (!browserSelected) return;
|
||||
async function deleteCloud(name) {
|
||||
const token = getToken();
|
||||
if (!token) return false;
|
||||
const res = await fetch(apiUrl(`/api/projects/${encodeURIComponent(name)}`), {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
async function deleteBrowser() {
|
||||
if (!selectedEntry) return;
|
||||
if (selectedEntry.source === 'cloud') {
|
||||
await deleteCloud(selectedEntry.name).catch(() => { /* ignore */ });
|
||||
} else {
|
||||
const projects = getBrowserProjects();
|
||||
delete projects[selectedEntry.name];
|
||||
setBrowserProjects(projects);
|
||||
}
|
||||
await refreshBrowserList();
|
||||
}
|
||||
|
||||
async function moveBrowserToCloud() {
|
||||
if (!selectedEntry || selectedEntry.source !== 'browser' || !getToken()) return;
|
||||
const projects = getBrowserProjects();
|
||||
delete projects[browserSelected];
|
||||
const state = projects[selectedEntry.name];
|
||||
if (!state) return;
|
||||
const ok = await saveCloud(selectedEntry.name, state).catch(() => false);
|
||||
if (!ok) return;
|
||||
delete projects[selectedEntry.name];
|
||||
setBrowserProjects(projects);
|
||||
refreshBrowserList();
|
||||
await refreshBrowserList();
|
||||
}
|
||||
|
||||
function downloadBrowser() {
|
||||
if (!browserSelected) return;
|
||||
const projects = getBrowserProjects();
|
||||
const state = projects[browserSelected];
|
||||
if (!selectedEntry) return;
|
||||
const state = selectedEntry.source === 'cloud'
|
||||
? cloudProjects.find((p) => p.name === selectedEntry.name)?.state
|
||||
: getBrowserProjects()[selectedEntry.name];
|
||||
if (!state) return;
|
||||
const json = JSON.stringify(state, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const url = `data:application/json;charset=utf-8,${encodeURIComponent(json)}`;
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${browserSelected}.blk`;
|
||||
a.download = `${selectedEntry.name}.blk`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function openUploadPicker() {
|
||||
if (!browserUploadInput) return;
|
||||
browserUploadInput.click();
|
||||
}
|
||||
|
||||
async function uploadFromComputer(event) {
|
||||
const file = event?.target?.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const raw = await file.text();
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return;
|
||||
Blockly.serialization.workspaces.load(parsed, workspace);
|
||||
} catch {
|
||||
// ignore invalid file contents
|
||||
} finally {
|
||||
browserUploadInput.value = '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { ROBOT_TEMPLATE_LIST, getTemplate, defaultFieldsFor } from '../robot/rob
|
|||
import { createEmptyRobotFile, parseRobotFileText } from '../robot/robotFile.js';
|
||||
import { applyRobotToWorkspace } from '../robot/applyRobotToWorkspace.js';
|
||||
import { importRobotFromWorkspace } from '../robot/robotImport.js';
|
||||
import { getToken, onAuthChange } from '../auth/client.js';
|
||||
|
||||
/** @typedef {{ kind: string, fields: Record<string, string> }} RobotComponentRow */
|
||||
|
||||
|
|
@ -16,12 +17,18 @@ let robotDocument = createEmptyRobotFile('esp32s3');
|
|||
let selectedIndex = -1;
|
||||
|
||||
let listEl;
|
||||
let fileListEl;
|
||||
let fileNameInputEl;
|
||||
let editorEl;
|
||||
let statusEl;
|
||||
let deviceNoteEl;
|
||||
let addSelectEl;
|
||||
let fileInputEl;
|
||||
let panelEl;
|
||||
let selectedFileEntry = null;
|
||||
|
||||
const BROWSER_STORAGE_KEY = 'esp32block_robot_files';
|
||||
const CLOUD_PREFIX = 'robot::';
|
||||
|
||||
function setStatus(msg, isError = false) {
|
||||
if (!statusEl) return;
|
||||
|
|
@ -41,6 +48,103 @@ function updateDeviceNote() {
|
|||
}
|
||||
}
|
||||
|
||||
function apiUrl(path) {
|
||||
const base = import.meta.env.BASE_URL || '/';
|
||||
const p = path.startsWith('/') ? path.slice(1) : path;
|
||||
return (base.endsWith('/') ? base : base + '/') + p;
|
||||
}
|
||||
|
||||
function getBrowserFiles() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(BROWSER_STORAGE_KEY) || '{}');
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function setBrowserFiles(files) {
|
||||
localStorage.setItem(BROWSER_STORAGE_KEY, JSON.stringify(files));
|
||||
}
|
||||
|
||||
async function fetchCloudFiles() {
|
||||
const token = getToken();
|
||||
if (!token) return [];
|
||||
const res = await fetch(apiUrl('/api/projects'), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
const projects = Array.isArray(data?.projects) ? data.projects : [];
|
||||
return projects
|
||||
.filter((p) => typeof p?.name === 'string' && p.name.startsWith(CLOUD_PREFIX))
|
||||
.map((p) => ({ name: p.name.slice(CLOUD_PREFIX.length), state: p.state }));
|
||||
}
|
||||
|
||||
async function saveCloudFile(name, state) {
|
||||
const token = getToken();
|
||||
if (!token) return false;
|
||||
const res = await fetch(apiUrl('/api/projects'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ name: `${CLOUD_PREFIX}${name}`, state }),
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
function toDisplayLabel(entry) {
|
||||
const tag = entry.source === 'cloud' ? '[cloud]' : '[browser]';
|
||||
return `${entry.name} ${tag}`;
|
||||
}
|
||||
|
||||
async function refreshFileList() {
|
||||
if (!fileListEl) return;
|
||||
const browser = getBrowserFiles();
|
||||
const browserEntries = Object.keys(browser).sort((a, b) => a.localeCompare(b)).map((name) => ({
|
||||
source: 'browser',
|
||||
name,
|
||||
state: browser[name],
|
||||
}));
|
||||
let cloudEntries = [];
|
||||
if (getToken()) {
|
||||
cloudEntries = await fetchCloudFiles();
|
||||
cloudEntries = cloudEntries.map((f) => ({ source: 'cloud', name: f.name, state: f.state }));
|
||||
}
|
||||
|
||||
const entries = [...browserEntries, ...cloudEntries].sort((a, b) => {
|
||||
const nameCmp = a.name.localeCompare(b.name);
|
||||
if (nameCmp !== 0) return nameCmp;
|
||||
return a.source.localeCompare(b.source);
|
||||
});
|
||||
|
||||
fileListEl.innerHTML = '';
|
||||
selectedFileEntry = null;
|
||||
if (entries.length === 0) {
|
||||
fileListEl.innerHTML = '<li class="empty-msg">No saved robot files</li>';
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = toDisplayLabel(entry);
|
||||
li.addEventListener('click', () => {
|
||||
fileListEl.querySelectorAll('li').forEach((el) => el.classList.remove('selected'));
|
||||
li.classList.add('selected');
|
||||
selectedFileEntry = entry;
|
||||
if (fileNameInputEl) fileNameInputEl.value = entry.name;
|
||||
});
|
||||
li.addEventListener('dblclick', () => {
|
||||
fileListEl.querySelectorAll('li').forEach((el) => el.classList.remove('selected'));
|
||||
li.classList.add('selected');
|
||||
selectedFileEntry = entry;
|
||||
if (fileNameInputEl) fileNameInputEl.value = entry.name;
|
||||
openSelectedFile();
|
||||
});
|
||||
fileListEl.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAddSelect() {
|
||||
if (!addSelectEl) return;
|
||||
addSelectEl.innerHTML = '';
|
||||
|
|
@ -183,19 +287,45 @@ function addComponent(kind) {
|
|||
function newDocument() {
|
||||
robotDocument = createEmptyRobotFile(getDeviceId());
|
||||
selectedIndex = robotDocument.components.length ? 0 : -1;
|
||||
selectedFileEntry = null;
|
||||
if (fileNameInputEl) fileNameInputEl.value = '';
|
||||
setStatus('New robot file.');
|
||||
renderAll();
|
||||
}
|
||||
|
||||
function downloadJson() {
|
||||
const text = JSON.stringify(robotDocument, null, 2);
|
||||
const blob = new Blob([text], { type: 'application/json' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'robot.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
setStatus('Download started.');
|
||||
function openSelectedFile() {
|
||||
if (!selectedFileEntry?.state) return;
|
||||
try {
|
||||
robotDocument = parseRobotFileText(JSON.stringify(selectedFileEntry.state));
|
||||
selectedIndex = robotDocument.components.length ? 0 : -1;
|
||||
setStatus(`Loaded ${selectedFileEntry.name}`);
|
||||
renderAll();
|
||||
} catch (e) {
|
||||
setStatus(e instanceof Error ? e.message : 'Could not open file', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCurrentFile() {
|
||||
const name = fileNameInputEl?.value?.trim();
|
||||
if (!name) {
|
||||
setStatus('Please enter a file name first.', true);
|
||||
return;
|
||||
}
|
||||
const state = JSON.parse(JSON.stringify(robotDocument));
|
||||
if (getToken()) {
|
||||
const ok = await saveCloudFile(name, state).catch(() => false);
|
||||
if (!ok) {
|
||||
setStatus('Failed to save cloud file.', true);
|
||||
return;
|
||||
}
|
||||
setStatus(`Saved ${name} [cloud]`);
|
||||
} else {
|
||||
const browser = getBrowserFiles();
|
||||
browser[name] = state;
|
||||
setBrowserFiles(browser);
|
||||
setStatus(`Saved ${name} [browser]`);
|
||||
}
|
||||
await refreshFileList();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -207,6 +337,8 @@ export function initRobotPanel(deps) {
|
|||
|
||||
panelEl = document.getElementById('robot-panel');
|
||||
listEl = document.getElementById('robot-component-list');
|
||||
fileListEl = document.getElementById('robot-file-list');
|
||||
fileNameInputEl = document.getElementById('robot-save-name');
|
||||
editorEl = document.getElementById('robot-editor');
|
||||
statusEl = document.getElementById('robot-status');
|
||||
deviceNoteEl = document.getElementById('robot-device-note');
|
||||
|
|
@ -225,10 +357,14 @@ export function initRobotPanel(deps) {
|
|||
robotDocument = createEmptyRobotFile(getDeviceId());
|
||||
renderAddSelect();
|
||||
renderAll();
|
||||
refreshFileList().catch(() => { /* ignore */ });
|
||||
onAuthChange(() => {
|
||||
refreshFileList().catch(() => { /* ignore */ });
|
||||
});
|
||||
|
||||
btnNew?.addEventListener('click', () => newDocument());
|
||||
|
||||
btnOpen?.addEventListener('click', () => fileInputEl?.click());
|
||||
btnOpen?.addEventListener('click', () => openSelectedFile());
|
||||
|
||||
fileInputEl?.addEventListener('change', () => {
|
||||
const f = fileInputEl.files?.[0];
|
||||
|
|
@ -249,7 +385,11 @@ export function initRobotPanel(deps) {
|
|||
reader.readAsText(f);
|
||||
});
|
||||
|
||||
btnSave?.addEventListener('click', () => downloadJson());
|
||||
btnSave?.addEventListener('click', () => {
|
||||
saveCurrentFile().catch(() => {
|
||||
setStatus('Save failed.', true);
|
||||
});
|
||||
});
|
||||
|
||||
btnApply?.addEventListener('click', () => {
|
||||
if (!workspace) return;
|
||||
|
|
@ -274,6 +414,7 @@ export function initRobotPanel(deps) {
|
|||
selectedIndex = 0;
|
||||
setStatus(`Imported ${rows.length} component(s).`);
|
||||
renderAll();
|
||||
refreshFileList().catch(() => { /* ignore */ });
|
||||
} catch (e) {
|
||||
setStatus(e instanceof Error ? e.message : 'Import failed', true);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue