@floless/app 0.72.2 → 0.72.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/floless-server.cjs +89 -21
- package/dist/web/app.css +39 -1
- package/dist/web/index.html +4 -0
- package/dist/web/steel-editor.html +54 -4
- package/dist/web/workspaces.js +151 -2
- package/package.json +1 -1
package/dist/floless-server.cjs
CHANGED
|
@@ -53022,7 +53022,7 @@ function appVersion() {
|
|
|
53022
53022
|
return resolveVersion({
|
|
53023
53023
|
isSea: isSea2(),
|
|
53024
53024
|
sqVersionXml: readSqVersionXml(),
|
|
53025
|
-
define: true ? "0.72.
|
|
53025
|
+
define: true ? "0.72.3" : void 0,
|
|
53026
53026
|
pkgVersion: readPkgVersion()
|
|
53027
53027
|
});
|
|
53028
53028
|
}
|
|
@@ -53032,7 +53032,7 @@ function resolveChannel(s) {
|
|
|
53032
53032
|
return "dev";
|
|
53033
53033
|
}
|
|
53034
53034
|
function appChannel() {
|
|
53035
|
-
return resolveChannel({ isSea: isSea2(), define: true ? "0.72.
|
|
53035
|
+
return resolveChannel({ isSea: isSea2(), define: true ? "0.72.3" : void 0 });
|
|
53036
53036
|
}
|
|
53037
53037
|
|
|
53038
53038
|
// workflow-update.ts
|
|
@@ -53329,14 +53329,16 @@ function extractReportHtml(events) {
|
|
|
53329
53329
|
var import_node_child_process3 = require("node:child_process");
|
|
53330
53330
|
var import_node_fs12 = require("node:fs");
|
|
53331
53331
|
var import_node_path11 = require("node:path");
|
|
53332
|
-
var
|
|
53332
|
+
var OPEN_EXTS = /* @__PURE__ */ new Set([".xls", ".xlsx", ".html", ".htm", ".csv"]);
|
|
53333
|
+
var REVEAL_EXTS = /* @__PURE__ */ new Set([...OPEN_EXTS, ".ifc", ".nc1", ".dstv"]);
|
|
53333
53334
|
function hasOpenableExt(path) {
|
|
53334
|
-
return
|
|
53335
|
+
return OPEN_EXTS.has((0, import_node_path11.extname)(path).toLowerCase());
|
|
53335
53336
|
}
|
|
53336
|
-
function validateOpenable(path) {
|
|
53337
|
+
function validateOpenable(path, mode = "open") {
|
|
53337
53338
|
if (typeof path !== "string" || !path.trim()) return { ok: false, error: "path required" };
|
|
53338
53339
|
if (path.includes("%")) return { ok: false, error: "unsupported file path" };
|
|
53339
|
-
|
|
53340
|
+
const allowed = mode === "reveal" ? REVEAL_EXTS.has((0, import_node_path11.extname)(path).toLowerCase()) : hasOpenableExt(path);
|
|
53341
|
+
if (!allowed) return { ok: false, error: "unsupported file type" };
|
|
53340
53342
|
let isFile = false;
|
|
53341
53343
|
try {
|
|
53342
53344
|
isFile = (0, import_node_fs12.existsSync)(path) && (0, import_node_fs12.statSync)(path).isFile();
|
|
@@ -53973,6 +53975,9 @@ function slugify(name) {
|
|
|
53973
53975
|
function projectDir(id) {
|
|
53974
53976
|
return (0, import_node_path17.join)(DIR2, safeProjectId(id));
|
|
53975
53977
|
}
|
|
53978
|
+
function projectExportsDir(id) {
|
|
53979
|
+
return (0, import_node_path17.join)(projectDir(id), "exports");
|
|
53980
|
+
}
|
|
53976
53981
|
var metaPath = (id) => (0, import_node_path17.join)(projectDir(id), "project.json");
|
|
53977
53982
|
function readMeta(id) {
|
|
53978
53983
|
const p = metaPath(id);
|
|
@@ -54020,9 +54025,19 @@ function renameProject(id, name) {
|
|
|
54020
54025
|
writeMeta(next);
|
|
54021
54026
|
return next;
|
|
54022
54027
|
}
|
|
54023
|
-
function
|
|
54028
|
+
function markApproved(id) {
|
|
54024
54029
|
const meta = readMeta(safeProjectId(id));
|
|
54025
|
-
if (meta)
|
|
54030
|
+
if (!meta) throw new ProjectError(`unknown project: ${id}`);
|
|
54031
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
54032
|
+
const next = { ...meta, approvedAt: now, updatedAt: now };
|
|
54033
|
+
writeMeta(next);
|
|
54034
|
+
return next;
|
|
54035
|
+
}
|
|
54036
|
+
function clearApproval(id) {
|
|
54037
|
+
const meta = readMeta(safeProjectId(id));
|
|
54038
|
+
if (!meta) return;
|
|
54039
|
+
const { approvedAt: _drop, ...rest } = meta;
|
|
54040
|
+
writeMeta({ ...rest, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
54026
54041
|
}
|
|
54027
54042
|
function duplicateProject(id, name) {
|
|
54028
54043
|
const src = readMeta(safeProjectId(id));
|
|
@@ -63782,6 +63797,9 @@ async function startServer() {
|
|
|
63782
63797
|
if (err2 instanceof ContractError) {
|
|
63783
63798
|
return reply.status(400).send({ ok: false, error: err2.message });
|
|
63784
63799
|
}
|
|
63800
|
+
if (err2 instanceof ProjectError) {
|
|
63801
|
+
return reply.status(400).send({ ok: false, error: err2.message });
|
|
63802
|
+
}
|
|
63785
63803
|
if (err2 instanceof RoutineError) {
|
|
63786
63804
|
return reply.status(err2.status).send({ ok: false, error: err2.message });
|
|
63787
63805
|
}
|
|
@@ -64226,6 +64244,20 @@ async function startServer() {
|
|
|
64226
64244
|
if (p.app !== appId) return `project "${project}" belongs to app "${p.app}", not "${appId}"`;
|
|
64227
64245
|
return null;
|
|
64228
64246
|
}
|
|
64247
|
+
function projectExportFiles(appId) {
|
|
64248
|
+
return [
|
|
64249
|
+
{ kind: "bom-csv", filename: `${appId}-bom.csv` },
|
|
64250
|
+
{ kind: "bom-xlsx", filename: `${appId}-bom.xlsx` },
|
|
64251
|
+
{ kind: "ifc", filename: `${appId}.ifc` }
|
|
64252
|
+
];
|
|
64253
|
+
}
|
|
64254
|
+
function projectExportGate(appId, project) {
|
|
64255
|
+
const p = getProject(project);
|
|
64256
|
+
if (!p) return { status: 404, error: `unknown project: ${project}` };
|
|
64257
|
+
if (p.app !== appId) return { status: 404, error: `project "${project}" belongs to app "${p.app}", not "${appId}"` };
|
|
64258
|
+
if (!p.approvedAt) return { status: 409, error: "Approve the model before exporting \u2014 exports go out only after sign-off." };
|
|
64259
|
+
return null;
|
|
64260
|
+
}
|
|
64229
64261
|
app.get("/api/contract/:appId", async (req, reply) => {
|
|
64230
64262
|
const project = req.query.project || void 0;
|
|
64231
64263
|
if (project) {
|
|
@@ -64255,7 +64287,7 @@ async function startServer() {
|
|
|
64255
64287
|
if (e instanceof ContractError) return reply.status(400).send({ ok: false, error: e.message });
|
|
64256
64288
|
throw e;
|
|
64257
64289
|
}
|
|
64258
|
-
if (project)
|
|
64290
|
+
if (project) clearApproval(project);
|
|
64259
64291
|
broadcast({ type: "contract-changed", appId: req.params.appId, project });
|
|
64260
64292
|
return { ok: true };
|
|
64261
64293
|
}
|
|
@@ -64287,7 +64319,9 @@ async function startServer() {
|
|
|
64287
64319
|
});
|
|
64288
64320
|
}
|
|
64289
64321
|
broadcast({ type: "compiled", id: req.params.appId, lockPath: result.lockPath });
|
|
64290
|
-
|
|
64322
|
+
let approvedAt;
|
|
64323
|
+
if (project) approvedAt = markApproved(project).approvedAt;
|
|
64324
|
+
return { ok: true, result, approvedAt };
|
|
64291
64325
|
});
|
|
64292
64326
|
app.post(
|
|
64293
64327
|
"/api/contract/:appId/score",
|
|
@@ -64351,6 +64385,24 @@ async function startServer() {
|
|
|
64351
64385
|
}
|
|
64352
64386
|
return null;
|
|
64353
64387
|
}
|
|
64388
|
+
app.get("/api/contract/:appId/exports", async (req, reply) => {
|
|
64389
|
+
const project = req.query.project;
|
|
64390
|
+
if (!project) return reply.status(400).send({ ok: false, error: "project required" });
|
|
64391
|
+
const bad = projectAppMismatch(req.params.appId, project);
|
|
64392
|
+
if (bad) return reply.status(404).send({ ok: false, error: bad });
|
|
64393
|
+
const dir = projectExportsDir(project);
|
|
64394
|
+
const exports2 = projectExportFiles(req.params.appId).map(({ kind, filename }) => {
|
|
64395
|
+
const path = (0, import_node_path30.join)(dir, filename);
|
|
64396
|
+
let exportedAt = null;
|
|
64397
|
+
try {
|
|
64398
|
+
const st = (0, import_node_fs32.statSync)(path);
|
|
64399
|
+
if (st.isFile()) exportedAt = st.mtime.toISOString();
|
|
64400
|
+
} catch {
|
|
64401
|
+
}
|
|
64402
|
+
return { kind, filename, path, exportedAt };
|
|
64403
|
+
});
|
|
64404
|
+
return { ok: true, approvedAt: getProject(project)?.approvedAt ?? null, exports: exports2 };
|
|
64405
|
+
});
|
|
64354
64406
|
app.post("/api/contract/:appId/export-3d", async (req, reply) => {
|
|
64355
64407
|
const doc = readContract(req.params.appId);
|
|
64356
64408
|
if (doc == null) return reply.status(404).send({ ok: false, error: "no contract to export" });
|
|
@@ -64391,7 +64443,12 @@ async function startServer() {
|
|
|
64391
64443
|
return { ok: true, report: { ...extracted, interactive: true }, skipped };
|
|
64392
64444
|
});
|
|
64393
64445
|
app.post("/api/contract/:appId/export-ifc", async (req, reply) => {
|
|
64394
|
-
const
|
|
64446
|
+
const project = req.query.project || void 0;
|
|
64447
|
+
if (project) {
|
|
64448
|
+
const g = projectExportGate(req.params.appId, project);
|
|
64449
|
+
if (g) return reply.status(g.status).send({ ok: false, error: g.error });
|
|
64450
|
+
}
|
|
64451
|
+
const doc = project ? readContract(req.params.appId, project) : readContractForApp(req.params.appId);
|
|
64395
64452
|
if (doc == null) return reply.status(404).send({ ok: false, error: "no contract to export" });
|
|
64396
64453
|
const isVector = typeof doc === "object" && doc != null && doc.type === "drawing.vector/v1";
|
|
64397
64454
|
let scene;
|
|
@@ -64422,7 +64479,8 @@ async function startServer() {
|
|
|
64422
64479
|
}
|
|
64423
64480
|
const companionId = `${req.params.appId}-ifc`;
|
|
64424
64481
|
const filename = `${req.params.appId}.ifc`;
|
|
64425
|
-
const outPath = (0, import_node_path30.join)(appPath(companionId), filename);
|
|
64482
|
+
const outPath = project ? (0, import_node_path30.join)(projectExportsDir(project), filename) : (0, import_node_path30.join)(appPath(companionId), filename);
|
|
64483
|
+
if (project) (0, import_node_fs32.mkdirSync)((0, import_node_path30.dirname)(outPath), { recursive: true });
|
|
64426
64484
|
let flo;
|
|
64427
64485
|
try {
|
|
64428
64486
|
flo = writeIfcApp(appPath(companionId), companionId, scene, outPath, writeOpts);
|
|
@@ -64448,10 +64506,15 @@ async function startServer() {
|
|
|
64448
64506
|
if (!(0, import_node_fs32.existsSync)(outPath)) return reply.send({ ok: false, error: "the IFC export produced no file" });
|
|
64449
64507
|
const content = (0, import_node_fs32.readFileSync)(outPath, "utf8");
|
|
64450
64508
|
broadcast({ type: "apps-changed" });
|
|
64451
|
-
return { ok: true, filename, content, bytes: Buffer.byteLength(content), skipped, ...extrusionsSkipped ? { extrusionsSkipped } : {} };
|
|
64509
|
+
return { ok: true, filename, savedTo: outPath, content, bytes: Buffer.byteLength(content), skipped, ...extrusionsSkipped ? { extrusionsSkipped } : {} };
|
|
64452
64510
|
});
|
|
64453
64511
|
app.post("/api/contract/:appId/export-tekla", async (req, reply) => {
|
|
64454
|
-
const
|
|
64512
|
+
const project = req.query.project || void 0;
|
|
64513
|
+
if (project) {
|
|
64514
|
+
const g = projectExportGate(req.params.appId, project);
|
|
64515
|
+
if (g) return reply.status(g.status).send({ ok: false, error: g.error });
|
|
64516
|
+
}
|
|
64517
|
+
const doc = project ? readContract(req.params.appId, project) : readContract(req.params.appId);
|
|
64455
64518
|
if (doc == null) return reply.status(404).send({ ok: false, error: "no contract to bake" });
|
|
64456
64519
|
const v = validateSteelTakeoff(doc);
|
|
64457
64520
|
if (!v.valid) return reply.status(400).send({ ok: false, error: "stored contract is invalid \u2014 re-save it in the editor" });
|
|
@@ -64510,13 +64573,18 @@ async function startServer() {
|
|
|
64510
64573
|
});
|
|
64511
64574
|
app.post("/api/contract/:appId/export-bom/:format", async (req, reply) => {
|
|
64512
64575
|
const { appId, format } = req.params;
|
|
64576
|
+
const project = req.query.project || void 0;
|
|
64513
64577
|
if (format !== "csv" && format !== "xlsx") {
|
|
64514
64578
|
return reply.status(400).send({ ok: false, error: `unknown format "${format}" \u2014 use csv or xlsx` });
|
|
64515
64579
|
}
|
|
64516
64580
|
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(appId)) {
|
|
64517
64581
|
return reply.status(400).send({ ok: false, error: `invalid appId: ${appId}` });
|
|
64518
64582
|
}
|
|
64519
|
-
|
|
64583
|
+
if (project) {
|
|
64584
|
+
const g = projectExportGate(appId, project);
|
|
64585
|
+
if (g) return reply.status(g.status).send({ ok: false, error: g.error });
|
|
64586
|
+
}
|
|
64587
|
+
const doc = readContract(appId, project);
|
|
64520
64588
|
if (doc == null) return reply.status(404).send({ ok: false, error: "no contract to export" });
|
|
64521
64589
|
const v = validateSteelTakeoff(doc);
|
|
64522
64590
|
if (!v.valid) return reply.status(400).send({ ok: false, error: "stored contract is invalid \u2014 re-save it in the editor" });
|
|
@@ -64524,9 +64592,9 @@ async function startServer() {
|
|
|
64524
64592
|
if (bom.rows.filter((r) => r.Profile !== "TOTAL").length === 0) {
|
|
64525
64593
|
return reply.status(422).send({ ok: false, error: "no priced members to export \u2014 every member is an RFI (no AISC size / weight yet)" });
|
|
64526
64594
|
}
|
|
64527
|
-
const outPath = bomExportPath(appId, format);
|
|
64528
|
-
const dir = (0, import_node_path30.dirname)(outPath);
|
|
64529
64595
|
const filename = `${appId}-bom.${format}`;
|
|
64596
|
+
const outPath = project ? (0, import_node_path30.join)(projectExportsDir(project), filename) : bomExportPath(appId, format);
|
|
64597
|
+
const dir = (0, import_node_path30.dirname)(outPath);
|
|
64530
64598
|
try {
|
|
64531
64599
|
(0, import_node_fs32.mkdirSync)(dir, { recursive: true });
|
|
64532
64600
|
if (format === "csv") {
|
|
@@ -64916,16 +64984,16 @@ async function startServer() {
|
|
|
64916
64984
|
const running = cancelActiveRun();
|
|
64917
64985
|
return { ok: true, running };
|
|
64918
64986
|
});
|
|
64919
|
-
function fileVerb(launch) {
|
|
64987
|
+
function fileVerb(launch, mode) {
|
|
64920
64988
|
return async (req, reply) => {
|
|
64921
|
-
const check = validateOpenable(req.body?.path);
|
|
64989
|
+
const check = validateOpenable(req.body?.path, mode);
|
|
64922
64990
|
if (!check.ok) return reply.status(400).send({ ok: false, error: check.error });
|
|
64923
64991
|
launch(check.path);
|
|
64924
64992
|
return { ok: true };
|
|
64925
64993
|
};
|
|
64926
64994
|
}
|
|
64927
|
-
app.post("/api/open-file", fileVerb(launchFile));
|
|
64928
|
-
app.post("/api/reveal", fileVerb(revealFile));
|
|
64995
|
+
app.post("/api/open-file", fileVerb(launchFile, "open"));
|
|
64996
|
+
app.post("/api/reveal", fileVerb(revealFile, "reveal"));
|
|
64929
64997
|
app.post(
|
|
64930
64998
|
"/api/trigger-run",
|
|
64931
64999
|
async (req, reply) => {
|
package/dist/web/app.css
CHANGED
|
@@ -3312,7 +3312,7 @@ body {
|
|
|
3312
3312
|
/* ── Workspaces (mode shell) — see docs/superpowers/mockups/2026-07-03-workspaces-mockup.html ── */
|
|
3313
3313
|
/* The display rules below (display:flex etc.) would otherwise override the `hidden` attribute's
|
|
3314
3314
|
UA display:none — these guards keep hidden winning so nothing leaks across modes. */
|
|
3315
|
-
.ws-landing[hidden], .ws-project[hidden], .ws-spine[hidden], .ws-frame[hidden] { display:none !important; }
|
|
3315
|
+
.ws-landing[hidden], .ws-project[hidden], .ws-spine[hidden], .ws-frame[hidden], .ws-exports[hidden] { display:none !important; }
|
|
3316
3316
|
.mode-switch { display:inline-flex; border:1px solid var(--border-strong); border-radius:6px;
|
|
3317
3317
|
overflow:hidden; background:var(--surface-2); flex:none; }
|
|
3318
3318
|
.mode-switch button { background:transparent; border:none; border-radius:0; color:var(--text-muted);
|
|
@@ -3390,3 +3390,41 @@ body {
|
|
|
3390
3390
|
.tabs.ws-step-tabs { background:transparent; padding:0 6px; }
|
|
3391
3391
|
.tabs.ws-step-tabs button { flex:0 0 auto; padding:11px 16px; }
|
|
3392
3392
|
.ws-frame { flex:1; min-height:0; width:100%; border:none; background:var(--bg); }
|
|
3393
|
+
|
|
3394
|
+
/* ── Workspaces ▸ Exports step (shell-rendered pane; export cards over the project's OWN
|
|
3395
|
+
contract). Ports the mockup's .ecard grid into app tokens; all states baseline-faithful. */
|
|
3396
|
+
.ws-exports { flex:1; min-height:0; display:flex; flex-direction:column; }
|
|
3397
|
+
/* The Approve gate — a project's Model must be signed off before its exports go out. Not an
|
|
3398
|
+
error (accent-dim rail, never a danger color); the inline "Approve" deep-links the sign-off. */
|
|
3399
|
+
.ws-exports-gate { flex:none; margin:12px 20px 0; padding:9px 13px; display:flex; align-items:center; gap:6px;
|
|
3400
|
+
background:var(--surface-2); border:1px solid var(--border); border-left:3px solid var(--accent-dim);
|
|
3401
|
+
border-radius:6px; font-size:12px; color:var(--text-muted); }
|
|
3402
|
+
.ws-exports-gate button { background:none; border:none; padding:0; font:inherit; color:var(--accent); cursor:pointer; }
|
|
3403
|
+
.ws-exports-gate button:hover { color:var(--accent-bright); text-decoration:underline; }
|
|
3404
|
+
.exports-note { flex:none; padding:14px 20px 4px; color:var(--text-muted); font-size:12px; }
|
|
3405
|
+
.exports-note b { color:var(--text); font-weight:600; }
|
|
3406
|
+
.exports-note code { font-family:var(--mono); color:var(--accent-bright); font-size:11px; }
|
|
3407
|
+
.export-grid { flex:1; overflow-y:auto; padding:14px 20px 22px; display:grid; gap:14px;
|
|
3408
|
+
grid-template-columns:repeat(auto-fill,minmax(250px,1fr)); align-content:start;
|
|
3409
|
+
scrollbar-width:thin; scrollbar-color:var(--border-strong) transparent; }
|
|
3410
|
+
.ecard { background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:15px;
|
|
3411
|
+
display:flex; flex-direction:column; gap:10px; }
|
|
3412
|
+
.ecard .e-top { display:flex; align-items:center; gap:10px; }
|
|
3413
|
+
.ecard .e-ico { width:34px; height:34px; border-radius:7px; background:var(--surface-2);
|
|
3414
|
+
border:1px solid var(--border-strong); display:flex; align-items:center; justify-content:center; font-size:15px; }
|
|
3415
|
+
.ecard .e-name { font-size:13px; font-weight:600; color:var(--text); }
|
|
3416
|
+
.ecard .e-fmt { font-size:10.5px; color:var(--text-dim); font-family:var(--mono); }
|
|
3417
|
+
.ecard .e-meta { font-size:11px; color:var(--text-muted); display:flex; align-items:center;
|
|
3418
|
+
justify-content:space-between; gap:8px; min-height:16px; }
|
|
3419
|
+
.ecard .e-status { color:var(--text-dim); }
|
|
3420
|
+
.ecard .e-done .chk { color:var(--ok); } /* the ✓ */
|
|
3421
|
+
.ecard .e-done .t { color:var(--text-muted); font-family:var(--mono); } /* HH:MM — the scannable datum */
|
|
3422
|
+
.ecard .e-links { display:flex; align-items:center; gap:8px; flex:none; }
|
|
3423
|
+
.ecard .e-link { background:none; border:none; padding:0; font:inherit; font-size:11px; color:var(--text-muted); cursor:pointer; }
|
|
3424
|
+
.ecard .e-link:hover { color:var(--accent); }
|
|
3425
|
+
.ecard .e-link + .e-link { border-left:1px solid var(--border); padding-left:8px; } /* thin divider, keeps them tertiary */
|
|
3426
|
+
.ecard .e-act { margin-top:auto; }
|
|
3427
|
+
.ecard .e-act button { width:100%; padding:7px 10px; font-size:12px; border-radius:5px; font-family:var(--ui);
|
|
3428
|
+
background:var(--surface-2); border:1px solid var(--border-strong); color:var(--text-muted); cursor:pointer; transition:all .15s; }
|
|
3429
|
+
.ecard .e-act button:hover:not(:disabled) { color:var(--text); border-color:var(--accent-dim); background:var(--surface); }
|
|
3430
|
+
.ecard .e-act button:disabled { opacity:0.6; cursor:not-allowed; }
|
package/dist/web/index.html
CHANGED
|
@@ -167,11 +167,15 @@
|
|
|
167
167
|
<div class="tabs ws-step-tabs" id="ws-step-tabs" role="tablist" aria-label="Project steps">
|
|
168
168
|
<button type="button" data-step="drawings" role="tab" aria-selected="false">Drawings</button>
|
|
169
169
|
<button type="button" data-step="model" class="active" role="tab" aria-selected="true">Model</button>
|
|
170
|
+
<button type="button" data-step="exports" role="tab" aria-selected="false">Exports</button>
|
|
170
171
|
</div>
|
|
171
172
|
<!-- Two lazily-srced iframes so switching steps never reloads the editor's 3D state.
|
|
172
173
|
Same-origin like #contract-editor-frame (they call /api/contract directly). -->
|
|
173
174
|
<iframe id="ws-frame-model" class="ws-frame" title="Project model editor" hidden></iframe>
|
|
174
175
|
<iframe id="ws-frame-drawings" class="ws-frame" title="Project drawings & filter" hidden></iframe>
|
|
176
|
+
<!-- Exports = a SHELL-rendered pane (not an iframe): export cards over the project's own
|
|
177
|
+
contract. workspaces.js fills it on step-switch (renderExports). -->
|
|
178
|
+
<div class="ws-exports" id="ws-exports" hidden></div>
|
|
175
179
|
</div>
|
|
176
180
|
<div class="hint" id="canvas-hint">Click any node to inspect. Star ★ a node to save it as a reusable Template. Drag the background to pan — or press Home to fit.</div>
|
|
177
181
|
<div class="fav-bar" id="fav-bar">
|
|
@@ -349,6 +349,10 @@
|
|
|
349
349
|
#propPop .ppfoot .seg2{margin-top:0}
|
|
350
350
|
#propPop .ppfoot label{display:flex;align-items:center;gap:7px;color:var(--text);cursor:pointer;font-size:11px}
|
|
351
351
|
#propPop .ppfoot label input{margin:0;accent-color:var(--brand);cursor:pointer;flex:none;width:auto}
|
|
352
|
+
/* Connection read-only mode: no labeling, so hide the label footer + Clear-all; rows aren't clickable. */
|
|
353
|
+
#propPop.connmode .ppfoot{display:none}
|
|
354
|
+
#propPop.connmode #ppClear{display:none}
|
|
355
|
+
#propPop.connmode .pprow{cursor:default;padding-left:14px} /* no checkbox → nudge the name so it doesn't float where the box was */
|
|
352
356
|
/* thin cluster divider in the 3D toolbar */
|
|
353
357
|
#m3dBar .tb-sep{width:1px;height:18px;background:var(--line);flex:0 0 auto;align-self:center}
|
|
354
358
|
/* Themed tooltip — replaces native title= so no OS-default tooltip leaks the dark theme. */
|
|
@@ -2018,6 +2022,7 @@ function refreshPropLabels3d(){const V=window.Steel3DView;if(!V||!V.setPropLabel
|
|
|
2018
2022
|
|
|
2019
2023
|
// ---- The floating Properties popup ----
|
|
2020
2024
|
let propPopPinned=false;
|
|
2025
|
+
let propPopConn=null; // non-null = read-only Connection mode (a connection selected → connPropRows); null = the member label-picker mode
|
|
2021
2026
|
function propPopOpen(){const el=document.getElementById('propPop');return !!(el&&el.classList.contains('open'));}
|
|
2022
2027
|
function propPopEl(){let el=document.getElementById('propPop');if(el)return el;
|
|
2023
2028
|
el=document.createElement('div');el.id='propPop';el.setAttribute('role','dialog');el.setAttribute('aria-label','Member properties');
|
|
@@ -2032,8 +2037,8 @@ function propPopEl(){let el=document.getElementById('propPop');if(el)return el;
|
|
|
2032
2037
|
+`<label><input type=checkbox id=ppSel>Selected only</label></div>`;
|
|
2033
2038
|
document.body.appendChild(el);
|
|
2034
2039
|
const list=el.querySelector('#ppList');
|
|
2035
|
-
list.addEventListener('click',e=>{const row=e.target.closest('.pprow');if(!row||row.classList.contains('dis'))return;if(e.target.tagName==='INPUT')return;togglePropKey(row.dataset.k);});
|
|
2036
|
-
list.addEventListener('change',e=>{const cb=e.target;if(cb.tagName!=='INPUT')return;const row=cb.closest('.pprow');if(row)togglePropKey(row.dataset.k,cb.checked);});
|
|
2040
|
+
list.addEventListener('click',e=>{const row=e.target.closest('.pprow');if(!row||row.classList.contains('dis')||!row.dataset.k)return;if(e.target.tagName==='INPUT')return;togglePropKey(row.dataset.k);}); // conn-mode rows carry no data-k → not toggleable
|
|
2041
|
+
list.addEventListener('change',e=>{const cb=e.target;if(cb.tagName!=='INPUT')return;const row=cb.closest('.pprow');if(row&&row.dataset.k)togglePropKey(row.dataset.k,cb.checked);});
|
|
2037
2042
|
el.querySelector('#ppSearch').addEventListener('input',renderPropPop);
|
|
2038
2043
|
el.querySelector('#ppClear').onclick=()=>{C.prop_labels.props=[];refreshPropLabels();};
|
|
2039
2044
|
el.querySelector('#ppClose').onclick=()=>closePropPop(true);
|
|
@@ -2053,6 +2058,9 @@ function togglePropKey(k,on){const pl=C.prop_labels,has=pl.props.includes(k);if(
|
|
|
2053
2058
|
refreshPropLabels();}
|
|
2054
2059
|
// rebuild the popup contents against the current selection (chrome + rows), preserving row focus
|
|
2055
2060
|
function renderPropPop(){const el=document.getElementById('propPop');if(!el||!el.classList.contains('open'))return;
|
|
2061
|
+
if(propPopPinned){const cs=connSelInfo();if(cs)propPopConn=cs;else if(selArr().length)propPopConn=null;} // a PINNED popup follows the selection across member ↔ connection (an unpinned one re-asserts its mode on each right-click)
|
|
2062
|
+
el.classList.toggle('connmode',!!propPopConn);
|
|
2063
|
+
if(propPopConn){renderConnProps(el);return;} // read-only Connection view
|
|
2056
2064
|
const arr=selArr(),pl=C.prop_labels,q=(el.querySelector('#ppSearch').value||'').trim().toLowerCase();
|
|
2057
2065
|
el.querySelector('#ppTitle').textContent='Properties ('+arr.length+' selected)';
|
|
2058
2066
|
el.querySelector('#ppLabeled').textContent=pl.props.length?pl.props.length+' labeled':'';
|
|
@@ -2072,12 +2080,52 @@ function renderPropPop(){const el=document.getElementById('propPop');if(!el||!el
|
|
|
2072
2080
|
if(fk){const cb=el.querySelector('.pprow[data-k="'+fk+'"] input');if(cb)cb.focus();}}
|
|
2073
2081
|
function dockPropPop(){const el=propPopEl(),st=document.getElementById(view3d?'stage3d':'stage').getBoundingClientRect();
|
|
2074
2082
|
el.style.left=Math.max(4,st.right-el.offsetWidth-12)+'px';el.style.top=(st.top+12)+'px';}
|
|
2075
|
-
function openPropLabels(x,y){if(!selArr().length)return;const el=propPopEl();const sr=el.querySelector('#ppSearch');
|
|
2083
|
+
function openPropLabels(x,y){if(!selArr().length)return;propPopConn=null;const el=propPopEl();const sr=el.querySelector('#ppSearch');
|
|
2076
2084
|
sr.value=''; // clear any prior filter BEFORE rendering rows, so reopening never shows a filtered list under a blank search box
|
|
2077
2085
|
el.classList.add('open');renderPropPop();
|
|
2078
2086
|
if(propPopPinned){dockPropPop();}
|
|
2079
2087
|
else{const r=el.getBoundingClientRect();el.style.left=Math.max(4,Math.min(x,innerWidth-r.width-8))+'px';el.style.top=Math.max(4,Math.min(y,innerHeight-r.height-8))+'px';}
|
|
2080
2088
|
setTimeout(()=>sr.focus(),0);}
|
|
2089
|
+
// ---- Connection read-only Properties popup (right-click a selected connection). Reuses #propPop in
|
|
2090
|
+
// `connmode`: the connection's props as read-only name/value rows, label controls hidden. Labeling
|
|
2091
|
+
// connection props onto the model is a member-keyed pipeline → deferred; this is a VIEW, not a picker. ----
|
|
2092
|
+
function connPropRows(cs){
|
|
2093
|
+
const j=cs.joint,isBP=cs.kind==='base-plate',pp=j.params||{};
|
|
2094
|
+
const plate=(partsById||{})[cs.conn+':plate']||null;
|
|
2095
|
+
const dim=mm=>(mm==null?'—':fmtFtIn(Number(mm)/25.4));
|
|
2096
|
+
const cols=pp.boltCols||(isBP?2:1),rows=pp.boltRows||(isBP?2:3);
|
|
2097
|
+
return [
|
|
2098
|
+
{label:'Type', value:isBP?'Base plate':'Shear plate'},
|
|
2099
|
+
{label:'On member', value:cs.main},
|
|
2100
|
+
{label:'Plate width', value:plate?dim(plate.width):'—'},
|
|
2101
|
+
{label:'Plate height', value:plate?dim(plate.depth):'—'},
|
|
2102
|
+
{label:'Plate thickness', value:plate?dim(plate.thickness):dim(pp[isBP?'thickness':'plateThickness'])},
|
|
2103
|
+
{label:'Weld leg', value:dim(pp.weldLeg||(isBP?8:6))},
|
|
2104
|
+
{label:isBP?'Anchor grid':'Bolt grid', value:cols+' × '+rows},
|
|
2105
|
+
{label:isBP?'Anchor diameter':'Bolt diameter', value:dim(pp.boltDia||(isBP?24:20))},
|
|
2106
|
+
{label:isBP?'Anchor count':'Bolt count', value:String(cols*rows)},
|
|
2107
|
+
{label:'Parts', value:String(cs.childIds.length)},
|
|
2108
|
+
];}
|
|
2109
|
+
function openConnProps(x,y,cs){propPopConn=cs;const el=propPopEl();const sr=el.querySelector('#ppSearch');
|
|
2110
|
+
sr.value='';el.classList.add('open');renderPropPop();
|
|
2111
|
+
if(propPopPinned){dockPropPop();}
|
|
2112
|
+
else{const r=el.getBoundingClientRect();el.style.left=Math.max(4,Math.min(x,innerWidth-r.width-8))+'px';el.style.top=Math.max(4,Math.min(y,innerHeight-r.height-8))+'px';}
|
|
2113
|
+
setTimeout(()=>sr.focus(),0);}
|
|
2114
|
+
// render the connection read-view; re-derives the connection each call so it self-heals if the selection moves
|
|
2115
|
+
function renderConnProps(el){
|
|
2116
|
+
const cs=connSelInfo();
|
|
2117
|
+
if(!cs){ if(!propPopPinned){propPopConn=null;closePropPop(true);return;}
|
|
2118
|
+
el.querySelector('#ppTitle').textContent='Properties';el.querySelector('#ppLabeled').textContent='';
|
|
2119
|
+
el.querySelector('#ppScope').textContent='Right-click a connection';el.querySelector('#ppMeta').textContent='';
|
|
2120
|
+
el.querySelector('#ppList').innerHTML='<div class=ppempty>No connection selected.</div>';return; }
|
|
2121
|
+
propPopConn=cs;
|
|
2122
|
+
const q=(el.querySelector('#ppSearch').value||'').trim().toLowerCase();
|
|
2123
|
+
el.querySelector('#ppTitle').textContent='Properties · '+(cs.kind==='base-plate'?'Base plate':'Shear plate');
|
|
2124
|
+
el.querySelector('#ppLabeled').textContent='';
|
|
2125
|
+
el.querySelector('#ppScope').textContent='On '+cs.main;
|
|
2126
|
+
const all=connPropRows(cs),rows=all.filter(r=>!q||r.label.toLowerCase().includes(q));
|
|
2127
|
+
el.querySelector('#ppList').innerHTML=rows.length?rows.map(r=>`<div class=pprow><span class=pn>${esc(r.label)}</span><span class=pv>${esc(r.value)}</span></div>`).join(''):'<div class=ppempty>No properties match your search.</div>';
|
|
2128
|
+
el.querySelector('#ppMeta').textContent=rows.length+' of '+all.length+' shown';}
|
|
2081
2129
|
function closePropPop(force){const el=document.getElementById('propPop');if(!el)return;if(propPopPinned&&!force)return;el.classList.remove('open');
|
|
2082
2130
|
const c=document.getElementById(view3d?'stage3d':'stage');if(c)c.focus&&c.focus();}
|
|
2083
2131
|
document.addEventListener('pointerdown',e=>{if(propPopOpen()&&!propPopPinned&&!e.target.closest('#propPop'))closePropPop();},true);
|
|
@@ -2122,7 +2170,9 @@ document.getElementById('stage').addEventListener('contextmenu',e=>{e.preventDef
|
|
|
2122
2170
|
document.getElementById('stage3d').addEventListener('contextmenu',e=>{e.preventDefault();const V=window.Steel3DView;
|
|
2123
2171
|
if(V&&V.rightDragged&&V.rightDragged())return; // that right button was an orbit/pan, not a click — no menu
|
|
2124
2172
|
if(V&&V.dimToolOn&&V.dimToolOn()){openSnapMenu(e.clientX,e.clientY,true);return;} // dim tool armed → snap-override menu (unchanged)
|
|
2125
|
-
if(mode==='sel'&&!cmTool&&!picking
|
|
2173
|
+
if(mode==='sel'&&!cmTool&&!picking){const cs=connSelInfo();
|
|
2174
|
+
if(cs){openConnProps(e.clientX,e.clientY,cs);return;} // a connection selected → its read-only Properties popup
|
|
2175
|
+
if(selArr().length){openPropLabels(e.clientX,e.clientY);return;}} // members selected → the member label-picker popup
|
|
2126
2176
|
});
|
|
2127
2177
|
document.getElementById('snapStat').onclick=()=>{snapOnly=null;const V=window.Steel3DView;if(V&&V.setSnapOnly)V.setSnapOnly(null);updSnapStat();};
|
|
2128
2178
|
// --- Dimension tool: armed mode + 3-click placement (anchor, anchor, offset). Shares the editor's
|
package/dist/web/workspaces.js
CHANGED
|
@@ -30,10 +30,22 @@
|
|
|
30
30
|
const $status = document.getElementById('ws-status');
|
|
31
31
|
const $stepTabs = document.getElementById('ws-step-tabs');
|
|
32
32
|
const $projMenu = document.getElementById('ws-proj-menu');
|
|
33
|
+
const $wsExports = document.getElementById('ws-exports');
|
|
33
34
|
const frames = {
|
|
34
35
|
model: document.getElementById('ws-frame-model'),
|
|
35
36
|
drawings: document.getElementById('ws-frame-drawings'),
|
|
36
37
|
};
|
|
38
|
+
|
|
39
|
+
// The Exports step's cards. `file:true` = writes a file on disk (shown with a "✓ exported HH:MM"
|
|
40
|
+
// line + ⧉ Open / ▤ Reveal); `open:false` (IFC) = reveal-only (no OS default app for a .ifc).
|
|
41
|
+
// Tekla is an ACTION (host mutation, no file). `kind` matches the server's export route + listing.
|
|
42
|
+
const EXPORT_CARDS = [
|
|
43
|
+
{ kind: 'bom-csv', ico: '▤', name: 'Bill of materials', fmt: 'CSV', verb: 'Export CSV', file: true, open: true },
|
|
44
|
+
{ kind: 'bom-xlsx', ico: '▦', name: 'Bill of materials', fmt: 'XLSX', verb: 'Export Excel', file: true, open: true },
|
|
45
|
+
{ kind: 'ifc', ico: '◈', name: 'Model', fmt: 'IFC4', verb: 'Export IFC', file: true, open: false },
|
|
46
|
+
{ kind: 'tekla', ico: '◧', name: 'Tekla', fmt: '.ifc → Open API', verb: 'Send to Tekla', file: false },
|
|
47
|
+
];
|
|
48
|
+
let exportState = null; // kind → { path, exportedAt } after a successful list; null = unknown/failed
|
|
37
49
|
if (!$switch || !$landing || !$app) return; // markup absent — nothing to wire
|
|
38
50
|
|
|
39
51
|
// The picker menu is a `.menu` (display:none by default; `.menu.show` → display:block, which
|
|
@@ -185,6 +197,10 @@
|
|
|
185
197
|
f.hidden = !show;
|
|
186
198
|
if (show && !f.dataset.loaded) { f.src = f.dataset.want; f.dataset.loaded = '1'; }
|
|
187
199
|
}
|
|
200
|
+
// Exports is a shell-rendered pane (not an iframe): show/hide + (re)paint on open.
|
|
201
|
+
const showExports = s === 'exports';
|
|
202
|
+
$wsExports.hidden = !showExports;
|
|
203
|
+
if (showExports) renderExports();
|
|
188
204
|
}
|
|
189
205
|
$stepTabs.addEventListener('click', (e) => {
|
|
190
206
|
const b = e.target.closest('button[data-step]');
|
|
@@ -192,7 +208,10 @@
|
|
|
192
208
|
});
|
|
193
209
|
|
|
194
210
|
// ── Approve (per-project) ───────────────────────────────────────────────────
|
|
195
|
-
|
|
211
|
+
// Shared by the header ✓ Approve and the Exports-tab gate link ("Approve the model"), so the
|
|
212
|
+
// sign-off is one flow. On success it stamps current.approvedAt (server returns it) so the
|
|
213
|
+
// Exports gate unlocks without a reload.
|
|
214
|
+
async function approveProject() {
|
|
196
215
|
if (!current) return;
|
|
197
216
|
const btn = document.getElementById('ws-approve');
|
|
198
217
|
btn.disabled = true;
|
|
@@ -204,11 +223,141 @@
|
|
|
204
223
|
// fires. flushContract() PUTs the live contract and resolves; a save failure aborts the bake.
|
|
205
224
|
const win = frames.model && frames.model.contentWindow;
|
|
206
225
|
if (win && typeof win.flushContract === 'function') await win.flushContract();
|
|
207
|
-
await api(`/api/contract/${encodeURIComponent(current.app)}/approve?project=${encodeURIComponent(current.id)}`, { method: 'POST' });
|
|
226
|
+
const res = await api(`/api/contract/${encodeURIComponent(current.app)}/approve?project=${encodeURIComponent(current.id)}`, { method: 'POST' });
|
|
227
|
+
if (res && res.approvedAt) current.approvedAt = res.approvedAt; // unlock exports
|
|
208
228
|
showToast(`Approved — "${current.name}" is baked into ${current.app}`, 'ok');
|
|
229
|
+
if (!$wsExports.hidden) renderExports(); // reflect the unlocked gate immediately if it's open
|
|
209
230
|
} catch (err) {
|
|
210
231
|
showToast('Approve failed: ' + (err && err.message || err), 'warn');
|
|
211
232
|
} finally { btn.disabled = false; btn.textContent = prev; }
|
|
233
|
+
}
|
|
234
|
+
document.getElementById('ws-approve').addEventListener('click', approveProject);
|
|
235
|
+
|
|
236
|
+
// ── Exports step ──────────────────────────────────────────────────────────────
|
|
237
|
+
const hhmm = (iso) => {
|
|
238
|
+
const d = new Date(iso);
|
|
239
|
+
if (isNaN(d.getTime())) return '';
|
|
240
|
+
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// The POST export endpoint for a card kind, project-scoped so it reads THIS project's contract
|
|
244
|
+
// and writes into its own exports folder.
|
|
245
|
+
function exportUrl(kind) {
|
|
246
|
+
const qs = `?project=${encodeURIComponent(current.id)}`;
|
|
247
|
+
const base = `/api/contract/${encodeURIComponent(current.app)}`;
|
|
248
|
+
if (kind === 'bom-csv') return `${base}/export-bom/csv${qs}`;
|
|
249
|
+
if (kind === 'bom-xlsx') return `${base}/export-bom/xlsx${qs}`;
|
|
250
|
+
if (kind === 'ifc') return `${base}/export-ifc${qs}`;
|
|
251
|
+
return `${base}/export-tekla${qs}`; // tekla
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function cardHtml(c, approved) {
|
|
255
|
+
let meta;
|
|
256
|
+
if (!approved) {
|
|
257
|
+
meta = `<span class="e-status">Available after approval</span>`;
|
|
258
|
+
} else if (!c.file) {
|
|
259
|
+
meta = `<span class="e-status">Creates parts in the open Tekla model</span>`;
|
|
260
|
+
} else if (exportState == null) {
|
|
261
|
+
// The listing failed — say so honestly; never a fake "not exported yet" (silent-failure rule).
|
|
262
|
+
meta = `<span class="e-status">Status unavailable</span>`;
|
|
263
|
+
} else {
|
|
264
|
+
const st = exportState[c.kind];
|
|
265
|
+
if (st && st.exportedAt) {
|
|
266
|
+
const links = `<span class="e-links">` +
|
|
267
|
+
(c.open ? `<button type="button" class="e-link" data-openfile="${escapeAttr(c.kind)}" data-tip="Open in its default app">⧉ Open</button>` : '') +
|
|
268
|
+
`<button type="button" class="e-link" data-reveal="${escapeAttr(c.kind)}" data-tip="Show the file in its folder">▤ Reveal</button>` +
|
|
269
|
+
`</span>`;
|
|
270
|
+
meta = `<span class="e-done"><span class="chk">✓</span> exported <span class="t">${escapeHtml(hhmm(st.exportedAt))}</span></span>${links}`;
|
|
271
|
+
} else {
|
|
272
|
+
meta = `<span class="e-status">not exported yet</span>`;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const exported = approved && c.file && exportState && exportState[c.kind] && exportState[c.kind].exportedAt;
|
|
276
|
+
const label = exported ? 'Re-export' : c.verb;
|
|
277
|
+
const dis = approved ? '' : ' disabled data-tip="Approve the model first"';
|
|
278
|
+
return `<div class="ecard">` +
|
|
279
|
+
`<div class="e-top"><div class="e-ico">${escapeHtml(c.ico)}</div>` +
|
|
280
|
+
`<div><div class="e-name">${escapeHtml(c.name)}</div><div class="e-fmt">${escapeHtml(c.fmt)}</div></div></div>` +
|
|
281
|
+
`<div class="e-meta">${meta}</div>` +
|
|
282
|
+
`<div class="e-act"><button type="button" data-export="${escapeAttr(c.kind)}"${dis}>↗ ${escapeHtml(label)}</button></div>` +
|
|
283
|
+
`</div>`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function renderExports() {
|
|
287
|
+
if (!current) return;
|
|
288
|
+
const id = current.id, app = current.app;
|
|
289
|
+
$wsExports.innerHTML = '<div class="exports-note">Loading exports…</div>'; // avoid a blank flash
|
|
290
|
+
let state = null;
|
|
291
|
+
let approvedAt = current.approvedAt; // fallback if the fetch fails (best-known)
|
|
292
|
+
try {
|
|
293
|
+
const r = await api(`/api/contract/${encodeURIComponent(app)}/exports?project=${encodeURIComponent(id)}`);
|
|
294
|
+
state = {};
|
|
295
|
+
for (const e of r.exports || []) state[e.kind] = e;
|
|
296
|
+
// Authoritative gate state: an in-session edit clears approvedAt server-side, so trust the
|
|
297
|
+
// server, not the possibly-stale current.approvedAt — and re-sync current to match.
|
|
298
|
+
approvedAt = r.approvedAt || undefined;
|
|
299
|
+
current.approvedAt = approvedAt;
|
|
300
|
+
} catch (err) {
|
|
301
|
+
// Surface the load failure — don't let it read as "nothing exported yet" (a lie).
|
|
302
|
+
showToast('Couldn’t load export status: ' + (err && err.message || err), 'warn');
|
|
303
|
+
state = null;
|
|
304
|
+
}
|
|
305
|
+
// The user may have switched project/step during the await — don't paint stale.
|
|
306
|
+
if (!current || current.id !== id || $wsExports.hidden) return;
|
|
307
|
+
exportState = state;
|
|
308
|
+
const approved = !!approvedAt;
|
|
309
|
+
let html = '';
|
|
310
|
+
if (!approved) {
|
|
311
|
+
html += `<div class="ws-exports-gate">Exports go out after sign-off — ` +
|
|
312
|
+
`<button type="button" id="ws-exports-approve">Approve the model</button> to enable them.</div>`;
|
|
313
|
+
}
|
|
314
|
+
html += `<div class="exports-note"><b>Export at any stage</b> — polishing is optional. ` +
|
|
315
|
+
`Each writes to this project’s <code>exports</code> folder.</div>`;
|
|
316
|
+
html += '<div class="export-grid">' + EXPORT_CARDS.map((c) => cardHtml(c, approved)).join('') + '</div>';
|
|
317
|
+
$wsExports.innerHTML = html;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function doExport(kind) {
|
|
321
|
+
if (!current) return;
|
|
322
|
+
const btn = $wsExports.querySelector(`[data-export="${kind}"]`);
|
|
323
|
+
const prev = btn ? btn.textContent : '';
|
|
324
|
+
if (btn) { btn.disabled = true; btn.textContent = kind === 'tekla' ? 'Baking…' : 'Exporting…'; }
|
|
325
|
+
try {
|
|
326
|
+
const res = await api(exportUrl(kind), { method: 'POST' });
|
|
327
|
+
if (kind === 'tekla') {
|
|
328
|
+
const dropped = Array.isArray(res.skipped) ? res.skipped.length : 0;
|
|
329
|
+
showToast((res.message || 'Baked into the Tekla model.') + (dropped ? ` · ${dropped} RFI member(s) skipped` : ''), 'ok');
|
|
330
|
+
} else {
|
|
331
|
+
showToast(`Exported — ${res.filename || kind}`, 'ok');
|
|
332
|
+
return renderExports(); // repaint with the new "✓ exported HH:MM" + Open/Reveal
|
|
333
|
+
}
|
|
334
|
+
} catch (err) {
|
|
335
|
+
const needsTekla = err && err.body && err.body.needsTekla;
|
|
336
|
+
showToast(err && err.message ? err.message : String(err), needsTekla ? 'info' : 'warn');
|
|
337
|
+
} finally {
|
|
338
|
+
if (btn && btn.isConnected) { btn.disabled = false; btn.textContent = prev; }
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function fileAction(endpoint, kind, failMsg) {
|
|
343
|
+
const st = exportState && exportState[kind];
|
|
344
|
+
if (!st || !st.path) return; // button only shows when a path exists; belt-and-braces
|
|
345
|
+
try {
|
|
346
|
+
await api(endpoint, { method: 'POST', body: JSON.stringify({ path: st.path }) });
|
|
347
|
+
} catch (err) {
|
|
348
|
+
const detail = err && err.body && err.body.error;
|
|
349
|
+
showToast(detail ? `${failMsg} — ${detail}` : failMsg, 'warn');
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
$wsExports.addEventListener('click', (e) => {
|
|
354
|
+
if (e.target.closest('#ws-exports-approve')) { approveProject(); return; }
|
|
355
|
+
const exp = e.target.closest('[data-export]');
|
|
356
|
+
if (exp && !exp.disabled) { doExport(exp.dataset.export); return; }
|
|
357
|
+
const open = e.target.closest('[data-openfile]');
|
|
358
|
+
if (open) { fileAction('/api/open-file', open.dataset.openfile, 'Couldn’t open the file'); return; }
|
|
359
|
+
const rev = e.target.closest('[data-reveal]');
|
|
360
|
+
if (rev) { fileAction('/api/reveal', rev.dataset.reveal, 'Couldn’t reveal the file'); return; }
|
|
212
361
|
});
|
|
213
362
|
|
|
214
363
|
// ── project picker + lifecycle (full set lives HERE; ≡ carries nothing) ────
|