diff --git a/index.html b/index.html index 0f21dfa..f9465af 100644 --- a/index.html +++ b/index.html @@ -56,15 +56,18 @@ diff --git a/server/db.js b/server/db.js index 50f5fd0..cb39997 100644 --- a/server/db.js +++ b/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; +} diff --git a/server/index.js b/server/index.js index 0264f5f..81481a6 100644 --- a/server/index.js +++ b/server/index.js @@ -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) ─────────────────────────────── diff --git a/server/projects.js b/server/projects.js new file mode 100644 index 0000000..cf37784 --- /dev/null +++ b/server/projects.js @@ -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; diff --git a/src/style.css b/src/style.css index 9b45938..c1122e6 100644 --- a/src/style.css +++ b/src/style.css @@ -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; diff --git a/src/ui/projectsDialog.js b/src/ui/projectsDialog.js index fec9fff..0e1f577 100644 --- a/src/ui/projectsDialog.js +++ b/src/ui/projectsDialog.js @@ -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 = '
  • No saved projects
  • '; 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 = ''; + } } diff --git a/src/ui/robotPanel.js b/src/ui/robotPanel.js index a949111..a287575 100644 --- a/src/ui/robotPanel.js +++ b/src/ui/robotPanel.js @@ -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 }} 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 = '
  • No saved robot files
  • '; + 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); }