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 = '