save/load projects and robots now stores on server

main
realrobotshk 2026-04-21 03:45:12 +00:00
parent 7c8cd918e8
commit 8d661307b9
7 changed files with 481 additions and 52 deletions

View File

@ -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>

View File

@ -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;
}

View File

@ -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) ───────────────────────────────

77
server/projects.js Normal file
View File

@ -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;

View File

@ -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;

View File

@ -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 = '';
}
}

View File

@ -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);
}