Integrate BIM viewer with NodeDC auth and sharing
This commit is contained in:
parent
ef5b0a42a6
commit
4638acc04b
|
|
@ -4,3 +4,5 @@ node_modules
|
|||
__pycache__/
|
||||
*.pyc
|
||||
server/data/uploads/
|
||||
server/data/shares/
|
||||
server/data/models/
|
||||
|
|
|
|||
|
|
@ -3,8 +3,15 @@ services:
|
|||
image: node:22-alpine
|
||||
working_dir: /beam/server
|
||||
command: node index.js
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PORT: "8080"
|
||||
NODEDC_BIM_AUTH_REQUIRED: "${NODEDC_BIM_AUTH_REQUIRED:-0}"
|
||||
NODEDC_BIM_PUBLIC_URL: "${NODEDC_BIM_PUBLIC_URL:-http://localhost:8080}"
|
||||
NODEDC_LAUNCHER_BASE_URL: "${NODEDC_LAUNCHER_BASE_URL:-http://localhost:5173}"
|
||||
NODEDC_LAUNCHER_INTERNAL_URL: "${NODEDC_LAUNCHER_INTERNAL_URL:-http://host.docker.internal:5173}"
|
||||
NODEDC_INTERNAL_ACCESS_TOKEN: "${NODEDC_INTERNAL_ACCESS_TOKEN:-}"
|
||||
NODEDC_BIM_ALLOWED_ORIGINS: "${NODEDC_BIM_ALLOWED_ORIGINS:-http://localhost:5173,http://localhost:8090}"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
|
|
@ -16,8 +23,13 @@ services:
|
|||
image: nodedc/bim-converter:local
|
||||
container_name: NodeDcBimConverter
|
||||
platform: linux/amd64
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODEDC_BIM_CONVERTER_UPLOADS_DIR: /beam/uploads
|
||||
NODEDC_BIM_CONVERTER_INTERVAL_SECONDS: "10"
|
||||
NODEDC_BIM_CONVERTER_MAX_ATTEMPTS: "3"
|
||||
NODEDC_BIM_CONVERTER_TIMEOUT_SECONDS: "300"
|
||||
NODEDC_BIM_CONVERTER_XCAF_TIMEOUT_SECONDS: "1200"
|
||||
NODEDC_BIM_CONVERTER_CADQUERY_TIMEOUT_SECONDS: "1800"
|
||||
volumes:
|
||||
- ./server/data/uploads:/beam/uploads
|
||||
|
|
|
|||
|
|
@ -1203,19 +1203,11 @@ header {
|
|||
}
|
||||
|
||||
.save-project-modal .modal-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--modal-border);
|
||||
background: var(--modal-focus);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.save-project-modal .modal-close:hover {
|
||||
background: var(--modal-border);
|
||||
background: var(--env-glass-control-hover);
|
||||
}
|
||||
|
||||
.save-project-modal .input-label {
|
||||
|
|
@ -1239,6 +1231,11 @@ header {
|
|||
box-shadow: 0 0 0 2px rgba(255, 234, 0, 0.08);
|
||||
}
|
||||
|
||||
.save-project-modal input[readonly] {
|
||||
cursor: text;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.save-project-modal .input-hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
|
|
@ -1889,9 +1886,21 @@ header {
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--tb-icon-pad, 10px);
|
||||
line-height: 1;
|
||||
transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.tb-btn svg {
|
||||
display: block;
|
||||
width: var(--tb-svg-size, 24px);
|
||||
height: var(--tb-svg-size, 24px);
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.tb-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--toolbar-outline, rgba(76, 224, 210, 0.7));
|
||||
|
|
@ -1902,6 +1911,11 @@ header {
|
|||
background: var(--toolbar-bg-active);
|
||||
}
|
||||
|
||||
body[data-viewer-mode="guest"] .bottom-toolbar,
|
||||
body[data-viewer-mode="guest"] .inspector-restore-tray {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.templates-panel {
|
||||
position: fixed;
|
||||
left: var(--templates-left, 72px);
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ const bottomToolbar = document.getElementById("bottomToolbar");
|
|||
const measureButton = document.querySelector('[data-action="measure"]');
|
||||
const settingsButton = document.querySelector('[data-action="settings"]');
|
||||
const templatesButton = document.querySelector('[data-action="templates"]');
|
||||
const shareButton = document.querySelector('[data-action="share"]');
|
||||
const projectionToggle = document.querySelector('[data-action="projection"]');
|
||||
const treeContainer = document.getElementById("treeContainer");
|
||||
const displayModeControl = document.getElementById("displayModeControl");
|
||||
|
|
@ -70,6 +71,13 @@ const deleteProjectClose = document.getElementById("deleteProjectClose");
|
|||
const deleteProjectCancel = document.getElementById("deleteProjectCancel");
|
||||
const deleteProjectConfirm = document.getElementById("deleteProjectConfirm");
|
||||
const deleteProjectError = document.getElementById("deleteProjectError");
|
||||
const shareModalBackdrop = document.getElementById("shareModalBackdrop");
|
||||
const shareModal = document.getElementById("shareModal");
|
||||
const shareModalClose = document.getElementById("shareModalClose");
|
||||
const shareModalCancel = document.getElementById("shareModalCancel");
|
||||
const shareLinkInput = document.getElementById("shareLinkInput");
|
||||
const shareCopyButton = document.getElementById("shareCopyButton");
|
||||
const shareError = document.getElementById("shareError");
|
||||
const projectNameSection = document.getElementById("projectNameSection");
|
||||
const projectNameInput = document.getElementById("projectNameInput");
|
||||
const projectNameError = document.getElementById("projectNameError");
|
||||
|
|
@ -110,6 +118,8 @@ const applyPanelOffsets = () => {
|
|||
|
||||
const API_BASE = "/api/projects";
|
||||
const MODEL_SETTINGS_API = "/api/model-settings";
|
||||
const AUTH_SESSION_API = "/api/auth/session";
|
||||
const SHARES_API = "/api/shares";
|
||||
|
||||
const acceptByType = {
|
||||
xkt: ".xkt",
|
||||
|
|
@ -212,6 +222,9 @@ let treeView = null;
|
|||
let currentBlobUrl = null;
|
||||
let modelCounter = 0;
|
||||
const activeModels = [];
|
||||
let viewerMode = "standard";
|
||||
let runtimeSessionInfo = { authRequired: false, authenticated: false, canShare: true, user: null };
|
||||
let startupSharePayload = null;
|
||||
const memoryStore = new Map(); // url -> ArrayBuffer
|
||||
const blobUrls = [];
|
||||
let gizmo = null;
|
||||
|
|
@ -421,6 +434,138 @@ const getPrimaryModelEntry = () => (
|
|||
|
||||
const hasModelSettingsTarget = () => !!getModelSettingsSrc(getPrimaryModelEntry());
|
||||
|
||||
const isGuestMode = () => viewerMode === "guest";
|
||||
|
||||
const getShareTokenFromPath = () => {
|
||||
const match = window.location.pathname.match(/^\/share\/([^/?#]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
};
|
||||
|
||||
const applyRuntimeMode = () => {
|
||||
document.body.dataset.viewerMode = viewerMode;
|
||||
if (bottomToolbar) {
|
||||
bottomToolbar.classList.toggle("hidden", isGuestMode());
|
||||
}
|
||||
if (shareButton) {
|
||||
const canShowShare = !isGuestMode() && runtimeSessionInfo.canShare && activeModels.length > 0;
|
||||
shareButton.classList.toggle("hidden", !canShowShare);
|
||||
shareButton.disabled = !canShowShare;
|
||||
}
|
||||
if (dropZone) {
|
||||
dropZone.style.pointerEvents = isGuestMode() ? "none" : "";
|
||||
}
|
||||
};
|
||||
|
||||
const initializeRuntimeMode = async () => {
|
||||
const shareToken = getShareTokenFromPath();
|
||||
if (shareToken) {
|
||||
viewerMode = "guest";
|
||||
try {
|
||||
const response = await fetch(`${SHARES_API}/${encodeURIComponent(shareToken)}`, {
|
||||
credentials: "include",
|
||||
headers: { "Accept": "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Share link failed: HTTP ${response.status}`);
|
||||
}
|
||||
startupSharePayload = await response.json();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showError("Ссылка на модель недоступна");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(AUTH_SESSION_API, {
|
||||
credentials: "include",
|
||||
headers: { "Accept": "application/json" },
|
||||
});
|
||||
if (response.ok) {
|
||||
runtimeSessionInfo = await response.json();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("BIM session status unavailable", err);
|
||||
}
|
||||
|
||||
applyRuntimeMode();
|
||||
};
|
||||
|
||||
const getShareableModelPayload = () => {
|
||||
const entry = getPrimaryModelEntry();
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
const src = normalizeModelSettingsSrc(entry.meta?.artifactSrc || entry.src || entry.meta?.src);
|
||||
const settingsSrc = getModelSettingsSrc(entry);
|
||||
if (!src) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
assetId: entry.meta?.assetId || entry.meta?.conversion?.assetId || null,
|
||||
name: entry.name || entry.meta?.label || src.split("/").pop() || "model",
|
||||
settingsSrc: settingsSrc || src,
|
||||
src,
|
||||
type: entry.type || normalizeType(null, src),
|
||||
versionId: entry.meta?.versionId || entry.meta?.conversion?.versionId || null,
|
||||
};
|
||||
};
|
||||
|
||||
const closeShareModal = () => {
|
||||
shareModal?.classList.add("hidden");
|
||||
shareModalBackdrop?.classList.add("hidden");
|
||||
if (shareError) shareError.textContent = "";
|
||||
};
|
||||
|
||||
const openShareModal = async () => {
|
||||
if (isGuestMode()) return;
|
||||
const payload = getShareableModelPayload();
|
||||
if (!payload) {
|
||||
showError("Ссылку можно создать только для модели из BIM-хранилища");
|
||||
return;
|
||||
}
|
||||
shareModal?.classList.remove("hidden");
|
||||
shareModalBackdrop?.classList.remove("hidden");
|
||||
if (shareLinkInput) shareLinkInput.value = "Генерируем ссылку...";
|
||||
if (shareCopyButton) shareCopyButton.disabled = true;
|
||||
if (shareError) shareError.textContent = "";
|
||||
|
||||
try {
|
||||
const response = await fetch(SHARES_API, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok || !data?.url) {
|
||||
throw new Error(data?.error || `Share create failed: HTTP ${response.status}`);
|
||||
}
|
||||
if (shareLinkInput) {
|
||||
shareLinkInput.value = data.url;
|
||||
shareLinkInput.select();
|
||||
}
|
||||
if (shareCopyButton) shareCopyButton.disabled = false;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (shareLinkInput) shareLinkInput.value = "";
|
||||
if (shareError) shareError.textContent = err?.message || "Не удалось создать ссылку";
|
||||
}
|
||||
};
|
||||
|
||||
const copyShareLink = async () => {
|
||||
const value = shareLinkInput?.value || "";
|
||||
if (!value) return;
|
||||
try {
|
||||
await navigator.clipboard?.writeText(value);
|
||||
} catch (_) {
|
||||
shareLinkInput?.select();
|
||||
document.execCommand?.("copy");
|
||||
}
|
||||
};
|
||||
|
||||
const getDisplayModePreset = (mode) => {
|
||||
switch (mode) {
|
||||
case "clay":
|
||||
|
|
@ -1091,27 +1236,28 @@ const updatePanelsVisibility = () => {
|
|||
if (treeContainer) treeContainer.classList.toggle("hidden", !hasModels);
|
||||
const showActions = hasModels || !!currentProjectMeta || !!lastAttemptedProjectId;
|
||||
if (saveProjectSection) {
|
||||
saveProjectSection.classList.toggle("hidden", !showActions);
|
||||
saveProjectSection.classList.toggle("hidden", isGuestMode() || !showActions);
|
||||
}
|
||||
if (projectNameSection) {
|
||||
projectNameSection.classList.toggle("hidden", !(currentProjectMeta || lastAttemptedProjectId));
|
||||
projectNameSection.classList.toggle("hidden", isGuestMode() || !(currentProjectMeta || lastAttemptedProjectId));
|
||||
}
|
||||
if (saveProjectButton) {
|
||||
saveProjectButton.disabled = !hasModels;
|
||||
saveProjectButton.disabled = isGuestMode() || !hasModels;
|
||||
}
|
||||
if (saveModelSettingsButton) {
|
||||
saveModelSettingsButton.disabled = !hasModels || !hasModelSettingsTarget();
|
||||
saveModelSettingsButton.disabled = isGuestMode() || !hasModels || !hasModelSettingsTarget();
|
||||
}
|
||||
if (deleteProjectButton) {
|
||||
deleteProjectButton.disabled = !(currentProjectMeta || lastAttemptedProjectId);
|
||||
deleteProjectButton.disabled = isGuestMode() || !(currentProjectMeta || lastAttemptedProjectId);
|
||||
}
|
||||
if (projectNameInput) {
|
||||
const hasProject = !!(currentProjectMeta || lastAttemptedProjectId);
|
||||
const hasProject = !isGuestMode() && !!(currentProjectMeta || lastAttemptedProjectId);
|
||||
projectNameInput.disabled = !hasProject;
|
||||
if (!hasProject) {
|
||||
projectNameInput.value = "";
|
||||
}
|
||||
}
|
||||
applyRuntimeMode();
|
||||
};
|
||||
|
||||
const setInspectorCollapsed = (collapsed) => {
|
||||
|
|
@ -2289,6 +2435,7 @@ const findProjectMetaById = (id) => {
|
|||
};
|
||||
|
||||
const submitProjectRename = async () => {
|
||||
if (isGuestMode()) return;
|
||||
if (!projectNameInput || projectNameInput.disabled) return;
|
||||
const newName = (projectNameInput.value || "").trim();
|
||||
const targetId = currentProjectMeta?.id || lastAttemptedProjectId;
|
||||
|
|
@ -2581,6 +2728,10 @@ const fetchModelSettings = async (src) => {
|
|||
};
|
||||
|
||||
const saveCurrentModelSettings = async () => {
|
||||
if (isGuestMode()) {
|
||||
setStatus("Гостевой просмотр не сохраняет изменения");
|
||||
return;
|
||||
}
|
||||
const entry = getPrimaryModelEntry();
|
||||
const settingsSrc = getModelSettingsSrc(entry);
|
||||
if (!entry || !settingsSrc) {
|
||||
|
|
@ -2697,6 +2848,7 @@ const initViewer = async () => {
|
|||
preloadHidden.remove();
|
||||
}
|
||||
document.body.style.opacity = "1";
|
||||
await initializeRuntimeMode();
|
||||
|
||||
const sdk = await import(SDK_URL);
|
||||
sdkRef = sdk;
|
||||
|
|
@ -3239,22 +3391,25 @@ const initViewer = async () => {
|
|||
|
||||
const loadStartupModelFromQuery = async () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const src = params.get("url") || params.get("src");
|
||||
const sharedViewer = startupSharePayload?.viewer || null;
|
||||
const src = sharedViewer?.src || params.get("url") || params.get("src");
|
||||
reportViewerEvent("startup-query", {
|
||||
search: window.location.search,
|
||||
mode: viewerMode,
|
||||
hasShare: !!sharedViewer,
|
||||
hasUrl: params.has("url"),
|
||||
hasSrc: params.has("src"),
|
||||
src,
|
||||
typeParam: params.get("type"),
|
||||
nameParam: params.get("name"),
|
||||
typeParam: sharedViewer?.type || params.get("type"),
|
||||
nameParam: sharedViewer?.name || params.get("name"),
|
||||
});
|
||||
if (!src) {
|
||||
reportViewerEvent("startup-no-src", { search: window.location.search }, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const name = params.get("name") || src.split("/").pop() || "model";
|
||||
const type = normalizeType(params.get("type"), src) || normalizeType(guessTypeFromName(name));
|
||||
const name = sharedViewer?.name || params.get("name") || src.split("/").pop() || "model";
|
||||
const type = normalizeType(sharedViewer?.type || params.get("type"), src) || normalizeType(guessTypeFromName(name));
|
||||
if (!type) {
|
||||
reportViewerEvent("startup-type-error", { src, name, typeParam: params.get("type") }, "error");
|
||||
showError("Не удалось определить формат модели из URL");
|
||||
|
|
@ -3265,7 +3420,7 @@ const initViewer = async () => {
|
|||
setStatus(`Подготовка: ${name}...`);
|
||||
setLoading(true);
|
||||
|
||||
let settingsSrc = params.get("settingsSrc") || params.get("sourceSrc") || params.get("originalSrc") || src;
|
||||
let settingsSrc = sharedViewer?.settingsSrc || params.get("settingsSrc") || params.get("sourceSrc") || params.get("originalSrc") || src;
|
||||
const applyQueryViewerOverrides = () => {
|
||||
if (params.has("displayMode")) {
|
||||
activeDisplayMode = params.get("displayMode") || "source";
|
||||
|
|
@ -3287,9 +3442,12 @@ const initViewer = async () => {
|
|||
|
||||
const startupMeta = {
|
||||
label: name,
|
||||
source: "query",
|
||||
source: sharedViewer ? "share" : "query",
|
||||
settingsSrc: normalizeModelSettingsSrc(settingsSrc) || normalizeModelSettingsSrc(src),
|
||||
artifactSrc: normalizeModelSettingsSrc(src),
|
||||
assetId: sharedViewer?.assetId || null,
|
||||
versionId: sharedViewer?.versionId || null,
|
||||
shareToken: startupSharePayload?.share?.token || getShareTokenFromPath(),
|
||||
viewerSettings: null,
|
||||
};
|
||||
|
||||
|
|
@ -3463,6 +3621,7 @@ const loadProjectSnapshot = async (project) => {
|
|||
};
|
||||
|
||||
const openSaveModal = () => {
|
||||
if (isGuestMode()) return;
|
||||
if (!activeModels.length && !currentProjectMeta) return;
|
||||
const defaultName = currentProjectMeta?.name || "Новый проект";
|
||||
if (saveProjectNameInput) {
|
||||
|
|
@ -3492,6 +3651,7 @@ const loadProjectSnapshot = async (project) => {
|
|||
};
|
||||
|
||||
const openDeleteModal = () => {
|
||||
if (isGuestMode()) return;
|
||||
if (!currentProjectMeta && lastAttemptedProjectId) {
|
||||
const meta = findProjectMetaById(lastAttemptedProjectId) || { id: lastAttemptedProjectId, name: "", type: "project" };
|
||||
currentProjectMeta = meta;
|
||||
|
|
@ -3503,6 +3663,7 @@ const loadProjectSnapshot = async (project) => {
|
|||
};
|
||||
|
||||
const submitDeleteProject = async () => {
|
||||
if (isGuestMode()) return;
|
||||
const targetId = currentProjectMeta?.id || lastAttemptedProjectId;
|
||||
if (!targetId) return;
|
||||
if (deleteProjectError) deleteProjectError.textContent = "";
|
||||
|
|
@ -3537,6 +3698,7 @@ const loadProjectSnapshot = async (project) => {
|
|||
};
|
||||
|
||||
const submitSaveProject = async (mode = "update") => {
|
||||
if (isGuestMode()) return;
|
||||
if (!saveProjectNameInput) return;
|
||||
const trimmedName = saveProjectNameInput.value.trim();
|
||||
if (!trimmedName) {
|
||||
|
|
@ -3706,11 +3868,17 @@ const loadProjectSnapshot = async (project) => {
|
|||
}
|
||||
inspectorCollapseButton?.addEventListener("click", () => setInspectorCollapsed(true));
|
||||
inspectorRestoreButton?.addEventListener("click", () => setInspectorCollapsed(false));
|
||||
loadProjectsList().catch(() => {
|
||||
templatesMenu?.setData({ templates: templateFallbacks, projects: [], error: "API недоступно" });
|
||||
});
|
||||
if (!isGuestMode()) {
|
||||
loadProjectsList().catch(() => {
|
||||
templatesMenu?.setData({ templates: templateFallbacks, projects: [], error: "API недоступно" });
|
||||
});
|
||||
}
|
||||
|
||||
const handleFile = (file) => {
|
||||
if (isGuestMode()) {
|
||||
setStatus("Гостевой просмотр не принимает загрузку файлов");
|
||||
return;
|
||||
}
|
||||
const detected = guessTypeFromName(file.name);
|
||||
if (!detected) {
|
||||
showError("Формат файла не поддерживается. Выберите .xkt, .glb, .gltf, .bim, .las, .laz, .obj, .stl");
|
||||
|
|
@ -3744,6 +3912,7 @@ const loadProjectSnapshot = async (project) => {
|
|||
if (viewerWrapper) {
|
||||
["dragenter", "dragover"].forEach((evt) => {
|
||||
viewerWrapper.addEventListener(evt, (e) => {
|
||||
if (isGuestMode()) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dropZone.classList.add("active");
|
||||
|
|
@ -3752,6 +3921,7 @@ const loadProjectSnapshot = async (project) => {
|
|||
|
||||
["dragleave", "drop"].forEach((evt) => {
|
||||
viewerWrapper.addEventListener(evt, (e) => {
|
||||
if (isGuestMode()) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dropZone.classList.remove("active");
|
||||
|
|
@ -3759,6 +3929,7 @@ const loadProjectSnapshot = async (project) => {
|
|||
});
|
||||
|
||||
viewerWrapper.addEventListener("drop", (e) => {
|
||||
if (isGuestMode()) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
|
|
@ -3862,10 +4033,24 @@ const loadProjectSnapshot = async (project) => {
|
|||
closeDeleteModal();
|
||||
}
|
||||
});
|
||||
shareModalCancel?.addEventListener("click", closeShareModal);
|
||||
shareModalClose?.addEventListener("click", closeShareModal);
|
||||
shareCopyButton?.addEventListener("click", copyShareLink);
|
||||
shareModalBackdrop?.addEventListener("click", (e) => {
|
||||
if (e.target === shareModalBackdrop) {
|
||||
closeShareModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Escape") return;
|
||||
if (e.target && ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.tagName)) return;
|
||||
if (shareModal && !shareModal.classList.contains("hidden")) {
|
||||
closeShareModal();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (deleteProjectModal && !deleteProjectModal.classList.contains("hidden")) {
|
||||
closeDeleteModal();
|
||||
e.preventDefault();
|
||||
|
|
@ -4106,6 +4291,7 @@ const loadProjectSnapshot = async (project) => {
|
|||
bottomToolbar.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest(".tb-btn");
|
||||
if (!btn) return;
|
||||
if (isGuestMode()) return;
|
||||
const action = btn.dataset.action;
|
||||
if (action === "settings") {
|
||||
if (btn.classList.contains("active")) {
|
||||
|
|
@ -4124,6 +4310,8 @@ const loadProjectSnapshot = async (project) => {
|
|||
setMeasureActive(!measureActive);
|
||||
} else if (action === "projection") {
|
||||
toggleProjection();
|
||||
} else if (action === "share") {
|
||||
openShareModal();
|
||||
} else if (action === "add") {
|
||||
fileInput.click();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@
|
|||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="./dcViewer.css?v=16">
|
||||
<link rel="stylesheet" href="./dcViewer.css?v=17">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
|
|
@ -220,7 +220,11 @@
|
|||
<div id="saveProjectModal" class="save-project-modal hidden">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Сохранить проект</div>
|
||||
<button class="modal-close" id="saveProjectClose" aria-label="Закрыть">×</button>
|
||||
<button class="modal-close inspector-collapse-btn" id="saveProjectClose" aria-label="Закрыть">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
||||
<path d="M4.25 4.25 11.75 11.75M11.75 4.25 4.25 11.75" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<label for="saveProjectName" class="input-label">Имя проекта</label>
|
||||
<input type="text" id="saveProjectName" placeholder="Новый проект" autocomplete="off">
|
||||
|
|
@ -236,7 +240,11 @@
|
|||
<div id="deleteProjectModal" class="save-project-modal hidden">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Удалить проект</div>
|
||||
<button class="modal-close" id="deleteProjectClose" aria-label="Закрыть">×</button>
|
||||
<button class="modal-close inspector-collapse-btn" id="deleteProjectClose" aria-label="Закрыть">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
||||
<path d="M4.25 4.25 11.75 11.75M11.75 4.25 4.25 11.75" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="input-hint">Вы уверены, что хотите удалить проект?</div>
|
||||
<div class="modal-actions">
|
||||
|
|
@ -245,18 +253,64 @@
|
|||
</div>
|
||||
<div class="error-text" id="deleteProjectError"></div>
|
||||
</div>
|
||||
<div id="shareModalBackdrop" class="modal-backdrop hidden"></div>
|
||||
<div id="shareModal" class="save-project-modal hidden">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Ссылка на модель</div>
|
||||
<button class="modal-close inspector-collapse-btn" id="shareModalClose" aria-label="Закрыть">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
||||
<path d="M4.25 4.25 11.75 11.75M11.75 4.25 4.25 11.75" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<input type="text" id="shareLinkInput" readonly autocomplete="off">
|
||||
<div class="modal-actions">
|
||||
<button class="secondary-btn" id="shareModalCancel" type="button">Закрыть</button>
|
||||
<button class="primary-btn" id="shareCopyButton" type="button" disabled>Скопировать</button>
|
||||
</div>
|
||||
<div class="error-text" id="shareError"></div>
|
||||
</div>
|
||||
|
||||
<div id="bottomToolbar" class="bottom-toolbar">
|
||||
<button class="tb-btn" data-action="settings" title="Настройки">⚙</button>
|
||||
<button class="tb-btn" data-action="templates" title="Примеры">📂</button>
|
||||
<button class="tb-btn" data-action="measure" title="Линейка">📏</button>
|
||||
<button class="tb-btn" data-action="settings" title="Настройки" aria-label="Настройки">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5Z"></path>
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.37a1.7 1.7 0 0 0-1 .56V20a2 2 0 0 1-4 0v-.08a1.7 1.7 0 0 0-1-.56 1.7 1.7 0 0 0-1.88.34l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.63 15a1.7 1.7 0 0 0-.56-1H4a2 2 0 0 1 0-4h.08a1.7 1.7 0 0 0 .56-1 1.7 1.7 0 0 0-.34-1.88l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.63a1.7 1.7 0 0 0 1-.56V4a2 2 0 0 1 4 0v.08a1.7 1.7 0 0 0 1 .56 1.7 1.7 0 0 0 1.88-.34l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.37 9c.18.38.38.73.56 1H20a2 2 0 0 1 0 4h-.08c-.18.27-.37.62-.52 1Z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="tb-btn" data-action="templates" title="Примеры" aria-label="Примеры">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M3.5 6.5h6l2 2h9v8.75a2.25 2.25 0 0 1-2.25 2.25H5.75A2.25 2.25 0 0 1 3.5 17.25V6.5Z"></path>
|
||||
<path d="M3.5 10h17"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="tb-btn" data-action="measure" title="Линейка" aria-label="Линейка">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4.5 15.5 15.5 4.5l4 4-11 11-4-4Z"></path>
|
||||
<path d="m8 12 2 2"></path>
|
||||
<path d="m10.5 9.5 1.5 1.5"></path>
|
||||
<path d="m13 7 2 2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="tb-btn" data-action="projection" title="Перспектива / аксонометрия">A</button>
|
||||
<button class="tb-btn" data-action="add" title="Добавить файл">+</button>
|
||||
<button class="tb-btn" data-action="share" title="Поделиться моделью" aria-label="Поделиться моделью">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 14.5V4.75"></path>
|
||||
<path d="m8.25 8.5 3.75-3.75 3.75 3.75"></path>
|
||||
<path d="M7.25 11.25H6.5a2 2 0 0 0-2 2v4.25a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2v-4.25a2 2 0 0 0-2-2h-.75"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="tb-btn" data-action="add" title="Добавить файл" aria-label="Добавить файл">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 5v14"></path>
|
||||
<path d="M5 12h14"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input id="fileInput" class="hidden" type="file"
|
||||
accept=".xkt,.glb,.gltf,.bim,.las,.laz,.obj,.stl">
|
||||
|
||||
<script type="module" src="./dcViewer.js?v=21"></script>
|
||||
<script type="module" src="./dcViewer.js?v=22"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -9,13 +9,17 @@
|
|||
|
||||
Статика: раздаётся из `../frontend/dist`, при отсутствии сборки — из `../frontend`.
|
||||
|
||||
Хранилище: `server/data/projects/index.json` (список) + `server/data/projects/proj_<id>.json` (полный проект). При старте создаются при необходимости.
|
||||
Хранилище: `server/data/projects/index.json` (список) + `server/data/projects/proj_<id>.json` (полный проект).
|
||||
Upload-артефакты лежат в `server/data/uploads`, публичные share-ссылки — в `server/data/shares/index.json`,
|
||||
а пользовательские ссылки/ref-count моделей — в `server/data/models/registry.json`. При старте директории и индексы создаются при необходимости.
|
||||
|
||||
API:
|
||||
- `GET /api/projects[?type=project|template]` — укороченный список из `index.json`.
|
||||
- `GET /api/projects/:id` — полный JSON проекта.
|
||||
- `POST /api/projects` — создать проект, генерируется `id`, `createdAt`, `updatedAt`, сохраняется файл и обновляется индекс.
|
||||
- `POST /api/uploads?filename=<name>&projectId=<id>` — сохранить оригинальный файл модели в `server/data/uploads`.
|
||||
- `DELETE /api/uploads/asset` — снять ссылку текущего пользователя на модель и физически удалить asset только если больше нет user/share refs.
|
||||
- `DELETE /api/uploads/version` — удалить версию, если на неё нет чужих refs или публичных share-ссылок.
|
||||
- `GET /api/conversions/status?src=<uploads/...>` — получить статус подготовки viewer-артефакта.
|
||||
Для `.step/.stp` ответ помечается `conversion.status=conversion_required`: оригинал уже доступен для скачивания,
|
||||
а preview должен появиться после работы `NodeDcBimConverter`, который готовит GLB и metadata/tree.
|
||||
|
|
@ -25,3 +29,15 @@ API:
|
|||
- `docker compose -f docker-compose.beam.yml up nodedc-bim-converter`
|
||||
|
||||
Поле `ownerId` пока всегда `null`, но оставлено для будущих пользователей/шаринга.
|
||||
|
||||
NODE.DC auth/share:
|
||||
- `NODEDC_BIM_AUTH_REQUIRED=1` включает вход через Launcher handoff.
|
||||
- `NODEDC_INTERNAL_ACCESS_TOKEN` должен совпадать с Launcher internal token.
|
||||
- `NODEDC_LAUNCHER_BASE_URL` — публичный Launcher URL для редиректа на login/launch.
|
||||
- `NODEDC_LAUNCHER_INTERNAL_URL` — внутренний URL Launcher для `/api/internal/handoff/consume`.
|
||||
- `NODEDC_BIM_PUBLIC_URL` — публичный URL viewer-а для share-ссылок, на проде `https://bim.nodedc.ru`.
|
||||
- `NODEDC_BIM_DATA_ROOT` — опциональный корень файлового storage вместо `server/data`.
|
||||
- `POST /api/shares` создаёт публичную read-only ссылку на upload artifact; `/share/<token>` открывает viewer в guest mode.
|
||||
- `GET /api/models` возвращает личные активные модели текущего пользователя.
|
||||
- `POST /api/models/refs` сохраняет существующий upload artifact в личные модели текущего пользователя.
|
||||
- `DELETE /api/models/refs` снимает личную ссылку и удаляет физические файлы только при отсутствии других refs/share.
|
||||
|
|
|
|||
2034
server/index.js
2034
server/index.js
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue