[FIX] OneCAD web: Odoo Documents integration sources (services_odoo.rs, odoo glue, build script); wasm32 picker freeze fixed with web_time::Instant
This commit is contained in:
1 parent
4e0601611c
commit
ac05e26934
9 files changed
+3028
No files matched your search
@@ -0,0 +1,719 @@
|
||||
// OneBSpace (OneCAD) — odoo host bridge port
|
||||
/**
|
||||
* (C) OneBSpace — https://1bspace.com — Proprietary.
|
||||
* OneCAD odoo/explorer.js — the unified Documents explorer dialogs.
|
||||
* ----------------------------------------------------------------------
|
||||
* Vanilla-JS (ES module, zero deps) port of the family gold-standard
|
||||
* explorer (one-photo src/odoo-host/explorer.js — same layout, same
|
||||
* interactions, same last-folder memory), restyled with an injected dark
|
||||
* CAD palette (no CSS imports — one <style> tag) so it reads native over
|
||||
* the egui canvas. Four dialogs, one explorer:
|
||||
*
|
||||
* save — "Save to Documents": folder tree + name input (.solidcraft chip)
|
||||
* open — "Open from Documents": folder tree + files filtered to .solidcraft
|
||||
* asset — "Import asset": file picker -> Assets folder (+ retarget)
|
||||
* export — "Export": name input -> Exports folder
|
||||
*
|
||||
* left — My Drive folder tree (recursive, expandable, usage footer)
|
||||
* right — breadcrumb + folder/file rows (icon, name, size)
|
||||
* footer — Cancel left, primary action right, Esc / backdrop cancels
|
||||
*
|
||||
* Everything runs through the bridge (odoo/bridge.js — compiled from
|
||||
* bridge.ts): listFolder / createFolder / folders / usage. No framework.
|
||||
*
|
||||
* @returns per mode (resolve value, null = cancelled):
|
||||
* save -> {name, folderId}
|
||||
* open -> {recordId, recordName}
|
||||
* asset -> {file, folderId, name}
|
||||
* export -> {name, folderId}
|
||||
*/
|
||||
import { bridge } from "./bridge.js?v=20.0.1.0.6";
|
||||
|
||||
const STYLE_ID = "oca-explorer-style";
|
||||
|
||||
const MODES = {
|
||||
save: {
|
||||
heading: "Save to Documents",
|
||||
description: "Choose a folder in your Odoo drive for this .solidcraft document.",
|
||||
action: "Save",
|
||||
prefer: "root",
|
||||
lastKey: "one-cad:documents:last-folder:save",
|
||||
},
|
||||
open: {
|
||||
heading: "Open from Documents",
|
||||
description: "Browse your Odoo Documents drive and pick a .solidcraft document.",
|
||||
action: "Open",
|
||||
prefer: "root",
|
||||
lastKey: "one-cad:documents:last-folder:open",
|
||||
extensions: new Set(["solidcraft", "solvecraft"]),
|
||||
},
|
||||
asset: {
|
||||
heading: "Import asset",
|
||||
description: "Pick a CAD asset from your computer to copy into the Assets folder.",
|
||||
action: "Import",
|
||||
prefer: "assets",
|
||||
lastKey: "one-cad:documents:last-folder:asset",
|
||||
extensions: new Set([
|
||||
"step", "stp", "iges", "igs", "brep", "stl", "obj", "3mf",
|
||||
"dxf", "svg", "gltf", "glb", "ply", "urdf",
|
||||
]),
|
||||
},
|
||||
export: {
|
||||
heading: "Export",
|
||||
description: "Name the export — it lands in your Documents Exports folder.",
|
||||
action: "Export",
|
||||
prefer: "exports",
|
||||
lastKey: "one-cad:documents:last-folder:export",
|
||||
},
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Small SVG icon set (lucide-style, stroke = currentColor) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function svg(paths, size) {
|
||||
return (
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
|
||||
'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" ' +
|
||||
'style="width:' + (size || 16) + 'px;height:' + (size || 16) + 'px;flex:none">' +
|
||||
paths + "</svg>"
|
||||
);
|
||||
}
|
||||
|
||||
const ICONS = {
|
||||
folder: svg('<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>'),
|
||||
lock: svg('<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>', 11),
|
||||
chevronRight: svg('<path d="m9 18 6-6-6-6"/>', 12),
|
||||
hardDrive: svg('<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>'),
|
||||
file: svg('<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>'),
|
||||
cube: svg('<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>'),
|
||||
save: svg('<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 1-1 1H7"/>'),
|
||||
folderPlus: svg('<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/><path d="M12 10v6"/><path d="M9 13h6"/>'),
|
||||
upload: svg('<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>'),
|
||||
open: svg('<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/><path d="m2 21 5.1-5.1a2 2 0 0 1 2.8 0l1.1 1.1a2 2 0 0 0 2.8 0l4.2-4.2a2 2 0 0 1 2.8 0L22 14"/>'),
|
||||
download: svg('<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>'),
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Injected dark CAD styles (the only CSS this module ever adds) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
const STYLE_CSS = [
|
||||
":root{--oca-bg:#1b1e24;--oca-panel:#23272f;--oca-panel2:#2a2f39;--oca-hairline:#333945;",
|
||||
"--oca-text:#e8eaed;--oca-dim:#9aa3ad;--oca-accent:#4f8cff;--oca-accent-press:#3f76e0;",
|
||||
"--oca-danger:#e5534b;--oca-ok:#3fb26f}",
|
||||
".oca-explorer-root{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;",
|
||||
"font:13px/1.45 'Segoe UI',system-ui,-apple-system,sans-serif;color:var(--oca-text)}",
|
||||
".oca-backdrop{position:absolute;inset:0;background:rgba(8,10,14,.62)}",
|
||||
".oca-dialog{position:relative;width:min(860px,calc(100vw - 48px));height:min(560px,calc(100vh - 64px));",
|
||||
"background:var(--oca-bg);border:1px solid var(--oca-hairline);border-radius:10px;",
|
||||
"box-shadow:0 24px 64px rgba(0,0,0,.55);display:flex;flex-direction:column;overflow:hidden}",
|
||||
".oca-header{padding:14px 18px 10px;border-bottom:1px solid var(--oca-hairline);background:var(--oca-panel)}",
|
||||
".oca-title{font-size:15px;font-weight:600}",
|
||||
".oca-desc{margin-top:2px;color:var(--oca-dim);font-size:12px}",
|
||||
".oca-desc-error{color:var(--oca-danger)}",
|
||||
".oca-body{flex:1;display:flex;min-height:0}",
|
||||
".oca-aside{width:250px;flex:none;display:flex;flex-direction:column;border-right:1px solid var(--oca-hairline);background:var(--oca-panel)}",
|
||||
".oca-tree-box{flex:1;overflow:auto;padding:8px 6px}",
|
||||
".oca-tree-label{margin:2px 8px 6px;color:var(--oca-dim);font-size:11px;text-transform:uppercase;letter-spacing:.06em}",
|
||||
".oca-tree-error{padding:8px}",
|
||||
".oca-tree-row{display:flex;align-items:center;gap:6px;padding:5px 8px;border-radius:6px;cursor:pointer;white-space:nowrap}",
|
||||
".oca-tree-row:hover{background:var(--oca-panel2)}",
|
||||
".oca-tree-row.oca-active{background:var(--oca-panel2);color:#fff}",
|
||||
".oca-tree-toggle{width:14px;height:14px;flex:none;color:var(--oca-dim);display:inline-flex;transition:transform .12s}",
|
||||
".oca-tree-toggle.oca-open{transform:rotate(90deg)}",
|
||||
".oca-tree-icon{display:inline-flex;color:var(--oca-accent)}",
|
||||
".oca-tree-name{overflow:hidden;text-overflow:ellipsis}",
|
||||
".oca-usage{padding:8px 14px;border-top:1px solid var(--oca-hairline);color:var(--oca-dim);font-size:11px}",
|
||||
".oca-main{flex:1;display:flex;flex-direction:column;min-width:0}",
|
||||
".oca-crumbs{display:flex;align-items:center;gap:2px;padding:8px 12px;border-bottom:1px solid var(--oca-hairline);overflow:hidden}",
|
||||
".oca-crumb{display:inline-flex;align-items:center;gap:5px;background:none;border:0;color:var(--oca-dim);",
|
||||
"font:inherit;padding:4px 7px;border-radius:6px;cursor:pointer}",
|
||||
".oca-crumb:hover:not(:disabled){background:var(--oca-panel2);color:var(--oca-text)}",
|
||||
".oca-crumb:disabled{opacity:.45;cursor:default}",
|
||||
".oca-crumb-current{color:var(--oca-text)}",
|
||||
".oca-crumb-sep{display:inline-flex;color:var(--oca-dim)}",
|
||||
".oca-files{flex:1;overflow:auto;padding:6px 8px}",
|
||||
".oca-file-row{display:flex;align-items:center;gap:10px;width:100%;text-align:left;background:none;border:0;",
|
||||
"color:inherit;font:inherit;padding:7px 9px;border-radius:6px;cursor:pointer}",
|
||||
".oca-file-row:hover{background:var(--oca-panel2)}",
|
||||
".oca-file-row.oca-selected{background:rgba(79,140,255,.18);outline:1px solid var(--oca-accent)}",
|
||||
".oca-file-icon{display:inline-flex;color:var(--oca-dim)}",
|
||||
".oca-file-icon.oca-icon-owned{color:var(--oca-accent)}",
|
||||
".oca-file-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
||||
".oca-file-size{color:var(--oca-dim);font-size:11px;flex:none}",
|
||||
".oca-empty{color:var(--oca-dim);padding:18px 10px;text-align:center}",
|
||||
".oca-savebar{border-top:1px solid var(--oca-hairline);padding:10px 14px;background:var(--oca-panel)}",
|
||||
".oca-name-row{display:flex;align-items:flex-end;gap:10px}",
|
||||
".oca-name-wrap{flex:1;min-width:0}",
|
||||
".oca-name-label{display:block;color:var(--oca-dim);font-size:11px;margin-bottom:4px}",
|
||||
".oca-name-field{display:flex;align-items:center;background:var(--oca-bg);border:1px solid var(--oca-hairline);border-radius:6px;padding:0 8px}",
|
||||
".oca-name-field:focus-within{border-color:var(--oca-accent)}",
|
||||
".oca-name-input{flex:1;min-width:0;background:none;border:0;outline:0;color:var(--oca-text);font:inherit;padding:7px 0}",
|
||||
".oca-suffix{color:var(--oca-dim);font-size:12px;user-select:none}",
|
||||
".oca-create-row{display:flex;gap:8px;margin-top:8px}",
|
||||
".oca-newfolder-input{flex:1;background:var(--oca-bg);border:1px solid var(--oca-hairline);border-radius:6px;",
|
||||
"color:var(--oca-text);font:inherit;padding:6px 9px;outline:0}",
|
||||
".oca-newfolder-input:focus{border-color:var(--oca-accent)}",
|
||||
".oca-picker{display:flex;flex-direction:column;gap:8px}",
|
||||
".oca-pick-btn{display:inline-flex;align-items:center;gap:8px}",
|
||||
".oca-picked{color:var(--oca-dim);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
||||
".oca-footer{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;",
|
||||
"border-top:1px solid var(--oca-hairline);background:var(--oca-panel)}",
|
||||
".oca-btn{display:inline-flex;align-items:center;gap:6px;font:inherit;padding:7px 14px;border-radius:6px;",
|
||||
"border:1px solid var(--oca-hairline);background:var(--oca-panel2);color:var(--oca-text);cursor:pointer}",
|
||||
".oca-btn:hover:not(:disabled){border-color:var(--oca-dim)}",
|
||||
".oca-btn:disabled{opacity:.45;cursor:default}",
|
||||
".oca-btn-accent{background:var(--oca-accent);border-color:var(--oca-accent);color:#fff}",
|
||||
".oca-btn-accent:hover:not(:disabled){background:var(--oca-accent-press)}",
|
||||
".oca-btn-ghost{background:none;border-color:transparent;color:var(--oca-dim)}",
|
||||
".oca-btn-ghost:hover:not(:disabled){color:var(--oca-text)}",
|
||||
".oca-btn-icon{display:inline-flex}",
|
||||
].join("\n");
|
||||
|
||||
function ensureStyle() {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = STYLE_CSS;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function h(tag, attrs) {
|
||||
const el = document.createElement(tag);
|
||||
if (attrs) {
|
||||
for (const key in attrs) {
|
||||
if (!Object.prototype.hasOwnProperty.call(attrs, key)) continue;
|
||||
const value = attrs[key];
|
||||
if (key === "class") el.className = value;
|
||||
else if (key === "html") el.innerHTML = value;
|
||||
else if (key === "text") el.textContent = value;
|
||||
else if (key.slice(0, 2) === "on" && typeof value === "function")
|
||||
el.addEventListener(key.slice(2), value);
|
||||
else if (value !== null && value !== undefined) el.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
for (let i = 2; i < arguments.length; i++) {
|
||||
const child = arguments[i];
|
||||
if (child === null || child === undefined) continue;
|
||||
el.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function formatSize(size) {
|
||||
size = Number(size) || 0;
|
||||
if (size < 1024) return size + " B";
|
||||
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + " KB";
|
||||
if (size < 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(1) + " MB";
|
||||
return (size / (1024 * 1024 * 1024)).toFixed(2) + " GB";
|
||||
}
|
||||
|
||||
function extensionOf(name) {
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot <= 0 ? "" : name.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
function fileIconFor(file, extensions) {
|
||||
const ext = extensionOf(file.name || "");
|
||||
if (extensions && extensions.has(ext)) {
|
||||
return '<span class="oca-file-icon oca-icon-owned">' + ICONS.cube + "</span>";
|
||||
}
|
||||
return '<span class="oca-file-icon">' + ICONS.file + "</span>";
|
||||
}
|
||||
|
||||
function rememberLastFolder(folderId, key) {
|
||||
try {
|
||||
window.localStorage.setItem(key, String(folderId));
|
||||
} catch (e) {
|
||||
/* private mode — memory only */
|
||||
}
|
||||
}
|
||||
|
||||
function restoreLastFolder(key) {
|
||||
try {
|
||||
const id = Number(window.localStorage.getItem(key));
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* The explorer */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Open one Documents explorer dialog.
|
||||
*
|
||||
* @param {object} opts
|
||||
* mode "save" | "open" | "asset" | "export"
|
||||
* defaultName save/export mode: suggested file name ("part.solidcraft")
|
||||
* extensions open/asset mode: restrict file rows to these extensions
|
||||
* @returns {Promise<{name?, folderId?, recordId?, recordName?, file?} | null>}
|
||||
* null = cancelled (Esc, backdrop, Cancel button).
|
||||
*/
|
||||
export function openDocumentsExplorer(opts) {
|
||||
opts = opts || {};
|
||||
const mode = MODES[opts.mode] ? opts.mode : "save";
|
||||
const config = MODES[mode];
|
||||
const extensions = opts.extensions && opts.extensions.length
|
||||
? new Set(opts.extensions.map((ext) => String(ext).toLowerCase().replace(/^\./, "")))
|
||||
: config.extensions || null;
|
||||
|
||||
/* Standalone dev mode: no host, no dialog — the caller treats null as
|
||||
* "nothing chosen", so the app keeps running untouched. */
|
||||
if (typeof bridge.isHostAvailable === "function" && !bridge.isHostAvailable()) {
|
||||
console.info("[onecad-odoo] explorer: no Odoo host (standalone) — dialog unavailable");
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
ensureStyle();
|
||||
|
||||
/* Save/export name split: stem in the field, extension as the chip. */
|
||||
const wantsName = mode === "save" || mode === "export";
|
||||
const defaultExtension = mode === "save" ? ".solidcraft" : "";
|
||||
let defaultName = typeof opts.defaultName === "string" && opts.defaultName.trim() !== ""
|
||||
? opts.defaultName.trim()
|
||||
: mode === "save"
|
||||
? "untitled.solidcraft"
|
||||
: "export";
|
||||
let stem = defaultName;
|
||||
let suffix = "";
|
||||
const dotIdx = defaultName.lastIndexOf(".");
|
||||
if (dotIdx > 0) {
|
||||
stem = defaultName.slice(0, dotIdx);
|
||||
suffix = defaultName.slice(dotIdx);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const settle = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
document.removeEventListener("keydown", onDocKeyDown, true);
|
||||
if (root && root.parentNode) root.parentNode.removeChild(root);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
/* ---------------- state ---------------- */
|
||||
let tree = null;
|
||||
let treeError = null;
|
||||
let appTree = {};
|
||||
let appRootId = null;
|
||||
let currentFolderId = null;
|
||||
let folderFolders = [];
|
||||
let folderFiles = [];
|
||||
let loadingFiles = false;
|
||||
let selectedFileId = null;
|
||||
let pickedFile = null;
|
||||
let createBusy = false;
|
||||
let usage = null;
|
||||
const expandedSet = new Set();
|
||||
|
||||
/* ---------------- chrome ---------------- */
|
||||
const backdrop = h("div", { class: "oca-backdrop" });
|
||||
const dialog = h("div", { class: "oca-dialog", role: "dialog", "aria-modal": "true" });
|
||||
const root = h("div", { class: "oca-explorer-root" }, backdrop, dialog);
|
||||
|
||||
const descEl = h("div", { class: "oca-desc" }, config.description);
|
||||
const header = h("div", { class: "oca-header" },
|
||||
h("div", { class: "oca-title" }, config.heading), descEl);
|
||||
|
||||
const treeLabel = h("p", { class: "oca-tree-label" }, "Loading drive…");
|
||||
const treeBox = h("div", { class: "oca-tree-box" }, treeLabel);
|
||||
const usageEl = h("div", { class: "oca-usage", style: "display:none" });
|
||||
const aside = h("div", { class: "oca-aside" }, treeBox, usageEl);
|
||||
|
||||
const crumbs = h("div", { class: "oca-crumbs" });
|
||||
const fileList = h("div", { class: "oca-files" });
|
||||
const main = h("div", { class: "oca-main" }, crumbs, fileList);
|
||||
const body = h("div", { class: "oca-body" }, aside, main);
|
||||
|
||||
/* name row (save + export) */
|
||||
const nameInput = h("input", {
|
||||
class: "oca-name-input",
|
||||
id: "onecad-explorer-name",
|
||||
type: "text",
|
||||
value: stem,
|
||||
placeholder: mode === "save" ? "Untitled" : "export",
|
||||
spellcheck: "false",
|
||||
});
|
||||
const suffixChip = h("span", { class: "oca-suffix" }, suffix);
|
||||
const nameField = h("div", { class: "oca-name-field" }, nameInput, suffixChip);
|
||||
const nameRow = h("div", { class: "oca-name-row" },
|
||||
h("div", { class: "oca-name-wrap" },
|
||||
h("label", { class: "oca-name-label", for: "onecad-explorer-name" }, "File name"),
|
||||
nameField));
|
||||
|
||||
/* new-folder row (save + export; asset keeps it too — retargeting) */
|
||||
const newNameInput = h("input", {
|
||||
class: "oca-newfolder-input",
|
||||
type: "text",
|
||||
placeholder: "Folder name…",
|
||||
spellcheck: "false",
|
||||
});
|
||||
const createBtn = h("button", { class: "oca-btn oca-btn-accent", type: "button" }, "Create");
|
||||
const cancelCreateBtn = h("button", { class: "oca-btn oca-btn-ghost", type: "button" }, "Cancel");
|
||||
const createRow = h("div", { class: "oca-create-row", style: "display:none" }, newNameInput, createBtn, cancelCreateBtn);
|
||||
const newFolderBtn = h("button", { class: "oca-btn", type: "button" },
|
||||
h("span", { class: "oca-btn-icon", html: ICONS.folderPlus }), "New folder");
|
||||
|
||||
/* asset file picker */
|
||||
const fileInput = h("input", {
|
||||
type: "file",
|
||||
style: "display:none",
|
||||
...(extensions ? { accept: Array.from(extensions).map((ext) => "." + ext).join(",") } : {}),
|
||||
});
|
||||
const pickedLabel = h("div", { class: "oca-picked", text: "No file chosen yet." });
|
||||
const pickBtn = h("button", { class: "oca-btn oca-btn-accent", type: "button" },
|
||||
h("span", { class: "oca-btn-icon", html: ICONS.upload }), "Choose file…");
|
||||
const picker = h("div", { class: "oca-picker" }, pickBtn, pickedLabel, fileInput);
|
||||
|
||||
const savebar = h("div", { class: "oca-savebar" });
|
||||
if (wantsName) savebar.appendChild(nameRow);
|
||||
if (mode === "asset") savebar.appendChild(picker);
|
||||
savebar.appendChild(newFolderBtn);
|
||||
savebar.appendChild(createRow);
|
||||
|
||||
/* footer */
|
||||
const cancelBtn = h("button", { class: "oca-btn oca-btn-ghost", type: "button" }, "Cancel");
|
||||
const actionIcon = mode === "save" ? ICONS.save : mode === "open" ? ICONS.open : mode === "asset" ? ICONS.upload : ICONS.download;
|
||||
const actionBtn = h("button", { class: "oca-btn oca-btn-accent", type: "button" },
|
||||
h("span", { class: "oca-btn-icon", html: actionIcon }), config.action);
|
||||
const footer = h("div", { class: "oca-footer" }, cancelBtn, actionBtn);
|
||||
|
||||
dialog.appendChild(header);
|
||||
dialog.appendChild(body);
|
||||
if (mode !== "open") dialog.appendChild(savebar);
|
||||
dialog.appendChild(footer);
|
||||
|
||||
const redraw = () => {
|
||||
if (mode === "save") {
|
||||
actionBtn.disabled = !nameInput.value.trim() || currentFolderId === null;
|
||||
} else if (mode === "open") {
|
||||
actionBtn.disabled = selectedFileId === null;
|
||||
} else if (mode === "asset") {
|
||||
actionBtn.disabled = !pickedFile || currentFolderId === null;
|
||||
} else {
|
||||
actionBtn.disabled = !nameInput.value.trim() || currentFolderId === null;
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------------- tree rendering ---------------- */
|
||||
function renderTree() {
|
||||
treeBox.innerHTML = "";
|
||||
if (tree === null && !treeError) {
|
||||
treeBox.appendChild(h("p", { class: "oca-tree-label" }, "Loading drive…"));
|
||||
return;
|
||||
}
|
||||
if (treeError) {
|
||||
const retry = h("button", { class: "oca-btn", type: "button" }, "Retry");
|
||||
retry.addEventListener("click", () => boot());
|
||||
treeBox.appendChild(h("div", { class: "oca-tree-error" },
|
||||
h("p", { class: "oca-tree-label" }, treeError), retry));
|
||||
return;
|
||||
}
|
||||
treeBox.appendChild(h("p", { class: "oca-tree-label" }, "My Drive"));
|
||||
const childrenOf = new Map();
|
||||
for (const folder of tree) {
|
||||
const key = folder.parentId === null ? "root" : String(folder.parentId);
|
||||
if (!childrenOf.has(key)) childrenOf.set(key, []);
|
||||
childrenOf.get(key).push(folder);
|
||||
}
|
||||
const renderNodes = (parentId, depth) => {
|
||||
const key = parentId === null ? "root" : String(parentId);
|
||||
const children = childrenOf.get(key) || [];
|
||||
for (const folder of children) {
|
||||
const hasChildren = (childrenOf.get(String(folder.id)) || []).length > 0;
|
||||
const isOpen = expandedSet.has(folder.id);
|
||||
const row = h("div", {
|
||||
class: "oca-tree-row" + (folder.id === currentFolderId ? " oca-active" : ""),
|
||||
style: "padding-left:" + (8 + depth * 14) + "px",
|
||||
});
|
||||
const toggle = h("span", { class: "oca-tree-toggle" });
|
||||
if (hasChildren) {
|
||||
toggle.innerHTML = ICONS.chevronRight;
|
||||
toggle.classList.add("oca-has-children");
|
||||
if (isOpen) toggle.classList.add("oca-open");
|
||||
}
|
||||
toggle.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (expandedSet.has(folder.id)) expandedSet.delete(folder.id);
|
||||
else expandedSet.add(folder.id);
|
||||
renderTree();
|
||||
});
|
||||
row.appendChild(toggle);
|
||||
row.appendChild(h("span", { class: "oca-tree-icon", html: ICONS.folder }));
|
||||
row.appendChild(h("span", { class: "oca-tree-name" }, folder.name));
|
||||
row.addEventListener("click", () => enterFolder(folder.id));
|
||||
treeBox.appendChild(row);
|
||||
if (hasChildren && isOpen) renderNodes(folder.id, depth + 1);
|
||||
}
|
||||
};
|
||||
renderNodes(null, 0);
|
||||
usageEl.style.display = usage ? "" : "none";
|
||||
if (usage) {
|
||||
usageEl.textContent = usage.documentCount + " file" + (usage.documentCount === 1 ? "" : "s") +
|
||||
" · " + formatSize(usage.bytesUsed) + " used";
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- breadcrumb ---------------- */
|
||||
function renderCrumbs() {
|
||||
crumbs.innerHTML = "";
|
||||
const driveBtn = h("button", { class: "oca-crumb oca-crumb-root", type: "button" });
|
||||
driveBtn.appendChild(h("span", { class: "oca-btn-icon", html: ICONS.hardDrive }));
|
||||
driveBtn.appendChild(document.createTextNode("My Drive"));
|
||||
driveBtn.disabled = appRootId === null;
|
||||
driveBtn.addEventListener("click", () => appRootId !== null && enterFolder(appRootId));
|
||||
crumbs.appendChild(driveBtn);
|
||||
const path = [];
|
||||
let cursor = currentFolderId;
|
||||
const byId = new Map(tree ? tree.map((row) => [row.id, row]) : []);
|
||||
while (cursor !== null && byId.has(cursor)) {
|
||||
path.unshift(byId.get(cursor));
|
||||
cursor = byId.get(cursor).parentId;
|
||||
}
|
||||
for (const folder of path) {
|
||||
crumbs.appendChild(h("span", { class: "oca-crumb-sep", html: ICONS.chevronRight }));
|
||||
const crumb = h("button", {
|
||||
class: "oca-crumb" + (folder.id === currentFolderId ? " oca-crumb-current" : ""),
|
||||
type: "button",
|
||||
}, folder.name);
|
||||
crumb.addEventListener("click", () => enterFolder(folder.id));
|
||||
crumbs.appendChild(crumb);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- files ---------------- */
|
||||
function renderFiles() {
|
||||
fileList.innerHTML = "";
|
||||
if (loadingFiles) {
|
||||
fileList.appendChild(h("p", { class: "oca-empty" }, "Loading…"));
|
||||
return;
|
||||
}
|
||||
/* Desktop file-manager behavior: subfolders first (click = enter),
|
||||
* then files — open mode filtered to the requested extensions. */
|
||||
for (const folder of folderFolders) {
|
||||
const row = h("button", { class: "oca-file-row", type: "button" });
|
||||
row.insertAdjacentHTML("beforeend", '<span class="oca-file-icon oca-icon-owned">' + ICONS.folder + "</span>");
|
||||
row.appendChild(h("span", { class: "oca-file-name" }, folder.name));
|
||||
if (folder.system) {
|
||||
row.insertAdjacentHTML("beforeend",
|
||||
'<span class="oca-file-size" title="Protected OneBSpace folder">' + ICONS.lock + "</span>");
|
||||
}
|
||||
row.appendChild(h("span", { class: "oca-file-size" }, "Folder"));
|
||||
row.addEventListener("click", () => enterFolder(folder.id));
|
||||
fileList.appendChild(row);
|
||||
}
|
||||
const visibleFiles = extensions
|
||||
? folderFiles.filter((file) => extensions.has(extensionOf(file.name || "")))
|
||||
: folderFiles;
|
||||
if (folderFolders.length === 0 && visibleFiles.length === 0) {
|
||||
fileList.appendChild(h("p", { class: "oca-empty" },
|
||||
mode === "save" || mode === "export" ? "This folder is empty." : "No compatible files in this folder."));
|
||||
return;
|
||||
}
|
||||
if (visibleFiles.length === 0) {
|
||||
fileList.appendChild(h("p", { class: "oca-empty" }, "No compatible files here yet."));
|
||||
return;
|
||||
}
|
||||
for (const file of visibleFiles) {
|
||||
const row = h("button", {
|
||||
class: "oca-file-row" + (selectedFileId === file.id ? " oca-selected" : ""),
|
||||
type: "button",
|
||||
});
|
||||
row.insertAdjacentHTML("beforeend", fileIconFor(file, extensions));
|
||||
row.appendChild(h("span", { class: "oca-file-name" }, file.name));
|
||||
row.appendChild(h("span", { class: "oca-file-size" }, formatSize(file.size || 0)));
|
||||
row.addEventListener("click", () => {
|
||||
selectedFileId = file.id;
|
||||
renderFiles();
|
||||
redraw();
|
||||
});
|
||||
row.addEventListener("dblclick", () => {
|
||||
if (mode === "open") settle({ recordId: file.id, recordName: file.name });
|
||||
});
|
||||
fileList.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- actions ---------------- */
|
||||
function enterFolder(folderId) {
|
||||
currentFolderId = folderId;
|
||||
selectedFileId = null;
|
||||
rememberLastFolder(folderId, config.lastKey);
|
||||
loadFiles(folderId);
|
||||
renderTree();
|
||||
renderCrumbs();
|
||||
redraw();
|
||||
}
|
||||
|
||||
function loadFiles(folderId) {
|
||||
loadingFiles = true;
|
||||
renderFiles();
|
||||
bridge
|
||||
.listFolder(folderId)
|
||||
.then((listing) => {
|
||||
folderFiles = listing.files || [];
|
||||
folderFolders = listing.folders || [];
|
||||
loadingFiles = false;
|
||||
renderFiles();
|
||||
})
|
||||
.catch((error) => {
|
||||
folderFiles = [];
|
||||
folderFolders = [];
|
||||
loadingFiles = false;
|
||||
renderFiles();
|
||||
flashError("Could not list this folder — " + (error && error.message ? error.message : "unknown error"));
|
||||
});
|
||||
}
|
||||
|
||||
function onCreateFolder() {
|
||||
const trimmed = newNameInput.value.trim();
|
||||
if (!trimmed || currentFolderId === null || createBusy) return;
|
||||
createBusy = true;
|
||||
createBtn.disabled = true;
|
||||
bridge
|
||||
.createFolder({ name: trimmed, parentId: currentFolderId })
|
||||
.then((created) => {
|
||||
tree = (tree || []).concat([{ id: created.id, name: trimmed, parentId: currentFolderId }]);
|
||||
expandedSet.add(currentFolderId);
|
||||
expandedSet.add(created.id);
|
||||
newNameInput.value = "";
|
||||
createRow.style.display = "none";
|
||||
newFolderBtn.style.display = "";
|
||||
enterFolder(created.id);
|
||||
})
|
||||
.catch((error) => {
|
||||
flashError("Could not create folder — " + (error && error.message ? error.message : "unknown error"));
|
||||
})
|
||||
.finally(() => {
|
||||
createBusy = false;
|
||||
createBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function confirmAction() {
|
||||
if (mode === "save") {
|
||||
const name = nameInput.value.trim();
|
||||
if (!name || currentFolderId === null) return;
|
||||
settle({ name, folderId: currentFolderId });
|
||||
} else if (mode === "open") {
|
||||
const file = folderFiles.find((candidate) => candidate.id === selectedFileId);
|
||||
if (file) settle({ recordId: file.id, recordName: file.name });
|
||||
} else if (mode === "asset") {
|
||||
if (pickedFile && currentFolderId !== null) {
|
||||
settle({ file: pickedFile, folderId: currentFolderId, name: pickedFile.name });
|
||||
}
|
||||
} else {
|
||||
const name = nameInput.value.trim();
|
||||
if (!name || currentFolderId === null) return;
|
||||
settle({ name, folderId: currentFolderId });
|
||||
}
|
||||
}
|
||||
|
||||
pickBtn.addEventListener("click", () => fileInput.click());
|
||||
fileInput.addEventListener("change", () => {
|
||||
pickedFile = fileInput.files && fileInput.files[0] ? fileInput.files[0] : null;
|
||||
pickedLabel.textContent = pickedFile
|
||||
? pickedFile.name + " · " + formatSize(pickedFile.size)
|
||||
: "No file chosen yet.";
|
||||
redraw();
|
||||
});
|
||||
|
||||
newFolderBtn.addEventListener("click", () => {
|
||||
newFolderBtn.style.display = "none";
|
||||
createRow.style.display = "";
|
||||
newNameInput.focus();
|
||||
});
|
||||
cancelCreateBtn.addEventListener("click", () => {
|
||||
newNameInput.value = "";
|
||||
createRow.style.display = "none";
|
||||
newFolderBtn.style.display = "";
|
||||
});
|
||||
createBtn.addEventListener("click", onCreateFolder);
|
||||
newNameInput.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter") onCreateFolder();
|
||||
});
|
||||
nameInput.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter") confirmAction();
|
||||
});
|
||||
nameInput.addEventListener("input", redraw);
|
||||
cancelBtn.addEventListener("click", () => settle(null));
|
||||
actionBtn.addEventListener("click", confirmAction);
|
||||
backdrop.addEventListener("mousedown", () => settle(null));
|
||||
dialog.addEventListener("mousedown", (ev) => ev.stopPropagation());
|
||||
|
||||
function onDocKeyDown(ev) {
|
||||
if (ev.key !== "Escape") return;
|
||||
ev.stopPropagation();
|
||||
settle(null);
|
||||
}
|
||||
document.addEventListener("keydown", onDocKeyDown, true);
|
||||
|
||||
let errorTimer = null;
|
||||
function flashError(message) {
|
||||
descEl.textContent = message;
|
||||
descEl.classList.add("oca-desc-error");
|
||||
if (errorTimer) clearTimeout(errorTimer);
|
||||
errorTimer = setTimeout(() => {
|
||||
descEl.textContent = config.description;
|
||||
descEl.classList.remove("oca-desc-error");
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/* ---------------- boot ---------------- */
|
||||
function boot() {
|
||||
tree = null;
|
||||
treeError = null;
|
||||
renderTree();
|
||||
void (async () => {
|
||||
try {
|
||||
const folders = await bridge.folders();
|
||||
tree = folders.tree || [];
|
||||
appTree = folders.app || {};
|
||||
appRootId = typeof appTree.root === "number" ? appTree.root : null;
|
||||
const preferred = typeof appTree[config.prefer] === "number"
|
||||
? appTree[config.prefer]
|
||||
: appTree.root;
|
||||
const restored = restoreLastFolder(config.lastKey);
|
||||
const known = new Set(tree.map((row) => row.id));
|
||||
const target = restored && known.has(restored) ? restored : preferred;
|
||||
currentFolderId = target;
|
||||
const byId = new Map(tree.map((row) => [row.id, row]));
|
||||
let cursor = target;
|
||||
while (cursor !== null) {
|
||||
expandedSet.add(cursor);
|
||||
cursor = byId.has(cursor) ? byId.get(cursor).parentId : null;
|
||||
}
|
||||
renderTree();
|
||||
renderCrumbs();
|
||||
loadFiles(target);
|
||||
bridge
|
||||
.usage()
|
||||
.then((info) => {
|
||||
usage = info;
|
||||
renderTree();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
} catch (error) {
|
||||
treeError = error && error.message ? error.message : "Could not load the drive";
|
||||
renderTree();
|
||||
}
|
||||
redraw();
|
||||
if (wantsName) {
|
||||
window.setTimeout(() => {
|
||||
nameInput.focus();
|
||||
nameInput.select();
|
||||
}, 50);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
document.body.appendChild(root);
|
||||
boot();
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user