From d70f6e08863bd45be3e5b39cc007f213b75e522b Mon Sep 17 00:00:00 2001 From: CODEX Date: Sat, 20 Jun 2026 17:21:07 +0300 Subject: [PATCH] Align BIM comments with OPS detail card --- frontend/dcViewer.css | 574 +++++++++++++++++++++++++++++++++--------- frontend/dcViewer.js | 220 ++++++++++++++-- frontend/index.html | 153 ++++++++--- server/index.js | 45 +++- 4 files changed, 800 insertions(+), 192 deletions(-) diff --git a/frontend/dcViewer.css b/frontend/dcViewer.css index a346e73..5abf746 100644 --- a/frontend/dcViewer.css +++ b/frontend/dcViewer.css @@ -1582,24 +1582,29 @@ header { .comment-card-modal { position: fixed; - --env-control-h: 46px; - --env-button-radius: 17px; - --env-glass-control-bg: var(--nodedc-glass-control-bg); - --env-glass-control-hover: var(--nodedc-glass-control-hover); - --env-glass-control-shadow: var(--nodedc-glass-control-shadow); - top: 96px; - left: calc(50vw - 230px); + --env-control-h: 42px; + --ops-detail-bg: rgba(18, 18, 20, 0.96); + --ops-detail-surface: rgba(28, 28, 31, 0.76); + --ops-detail-surface-strong: rgba(32, 32, 35, 0.92); + --ops-detail-border: rgba(255, 255, 255, 0.055); + --ops-detail-muted: rgba(229, 231, 235, 0.54); + top: 34px; + left: calc(50vw - 440px); display: grid; grid-template-rows: auto minmax(0, 1fr) auto; - width: min(520px, calc(100vw - 36px)); - max-height: min(760px, calc(100vh - 72px)); - background: color-mix(in srgb, var(--modal-bg) var(--modal-bg-alpha, 100%), transparent); - border: 1px solid var(--modal-border); - border-radius: var(--modal-radius, 16px); - box-shadow: 0 22px 60px rgba(0, 0, 0, 0.5); - z-index: 30050; + width: min(880px, calc(100vw - 48px)); + max-height: min(820px, calc(100vh - 68px)); overflow: hidden; - backdrop-filter: blur(18px); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 30px; + background: + radial-gradient(circle at 80% 20%, rgba(130, 58, 120, 0.13), transparent 34%), + linear-gradient(180deg, rgba(255, 255, 255, 0.018), rgba(255, 255, 255, 0.006)), + var(--ops-detail-bg); + box-shadow: 0 28px 80px rgba(0, 0, 0, 0.64); + z-index: 30050; + backdrop-filter: blur(30px) saturate(1.08); + -webkit-backdrop-filter: blur(30px) saturate(1.08); } .comment-card-modal.hidden { @@ -1607,14 +1612,11 @@ header { } .comment-card-modal__header { - display: grid; - grid-template-columns: minmax(0, 1fr) var(--env-control-h); + display: flex; align-items: center; - gap: 10px; - min-height: calc(var(--env-control-h) + 20px); - padding: 12px 12px 8px 16px; - background: color-mix(in srgb, var(--modal-bg) var(--modal-bg-alpha, 100%), transparent); - border-radius: var(--modal-radius, 16px) var(--modal-radius, 16px) 0 0; + justify-content: space-between; + min-height: 64px; + padding: 20px 28px 8px; cursor: grab; user-select: none; } @@ -1623,172 +1625,471 @@ header { cursor: grabbing; } -.comment-card-modal__titles { - display: grid; - gap: 3px; - min-width: 0; +.ops-detail-nav, +.ops-detail-actions, +.ops-detail-section-actions, +.ops-activity-composer__toolbar { + display: flex; + align-items: center; + gap: 8px; } -.comment-card-modal__title { - color: var(--text); - font-size: 15px; - font-weight: 800; - line-height: 1.2; +.ops-detail-actions { + gap: 10px; +} + +.ops-detail-icon-btn, +.ops-detail-mini-btn, +.ops-activity-send { + display: inline-grid; + place-items: center; + width: 42px; + height: 42px; + padding: 0; + border: 0; + border-radius: 18px; + background: rgba(255, 255, 255, 0.038); + color: rgba(245, 245, 250, 0.78); + cursor: pointer; + outline: none; + transition: background 130ms ease, color 130ms ease, transform 120ms ease; +} + +.ops-detail-mini-btn { + width: 36px; + height: 36px; + border-radius: 13px; +} + +.ops-detail-icon-btn:hover, +.ops-detail-mini-btn:hover, +.ops-activity-send:hover { + background: rgba(255, 255, 255, 0.07); + color: rgba(255, 255, 255, 0.95); +} + +.ops-detail-icon-btn:active, +.ops-detail-mini-btn:active, +.ops-activity-send:active { + transform: translateY(1px); +} + +.ops-detail-icon-btn svg, +.ops-detail-mini-btn svg, +.ops-activity-send svg, +.ops-detail-pill svg, +.ops-detail-meta-row svg, +.ops-detail-dropzone__icon svg { + display: block; + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.comment-card-modal__body { + display: grid; + gap: 24px; + min-height: 0; + overflow: auto; + padding: 30px 32px 24px; +} + +.ops-detail-hero { + display: grid; + gap: 12px; } .comment-card-modal__subtitle { min-width: 0; overflow: hidden; text-overflow: ellipsis; - color: var(--muted); - font-size: 11px; + color: rgba(229, 231, 235, 0.68); + font-size: 12px; font-weight: 700; line-height: 1.2; + letter-spacing: 0; white-space: nowrap; } -.comment-card-modal__body { - display: grid; - gap: 12px; - min-height: 0; - overflow: auto; - padding: 12px 14px 14px; -} - -.comment-field { - display: grid; - gap: 6px; -} - -.comment-field span, -.comment-thread__title, -.comment-attachments__head span { - color: rgba(245, 245, 250, 0.84); - font-size: 12px; - font-weight: 800; - line-height: 1; -} - -.comment-field input, -.comment-field textarea { +.ops-detail-title-input, +.ops-detail-description-input { width: 100%; - border: 1px solid var(--modal-border); - border-radius: var(--modal-radius, 10px); - background: var(--modal-element); - color: var(--text); + min-width: 0; + border: 0; + background: transparent; + color: rgba(245, 245, 250, 0.94); font: inherit; - font-size: 13px; outline: none; - padding: 11px 12px; - resize: vertical; + resize: none; } -.comment-field textarea { - min-height: 86px; - line-height: 1.42; +.ops-detail-title-input { + min-height: 36px; + font-size: 21px; + font-weight: 760; + line-height: 1.25; } -.comment-field input:focus, -.comment-field textarea:focus { - border-color: var(--modal-element-outline); - box-shadow: 0 0 0 2px rgba(255, 234, 0, 0.08); +.ops-detail-title-input::placeholder { + color: rgba(245, 245, 250, 0.52); } +.ops-detail-description-input { + min-height: 30px; + color: rgba(245, 245, 250, 0.72); + font-size: 16px; + line-height: 1.45; +} + +.ops-detail-description-input::placeholder { + color: rgba(245, 245, 250, 0.4); +} + +.ops-detail-meta-row { + display: flex; + align-items: center; + gap: 7px; + min-height: 22px; + margin-top: 18px; + color: var(--ops-detail-muted); + font-size: 12px; + font-weight: 650; +} + +.ops-detail-meta-row svg { + width: 15px; + height: 15px; +} + +.ops-detail-pills { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 10px; + margin-top: 28px; +} + +.ops-detail-pill { + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 38px; + padding: 0 18px; + border: 0; + border-radius: 999px; + background: rgba(255, 255, 255, 0.034); + color: rgba(245, 245, 250, 0.78); + cursor: pointer; + font: inherit; + font-size: 14px; + font-weight: 720; +} + +.ops-detail-pill:hover { + background: rgba(255, 255, 255, 0.065); + color: rgba(255, 255, 255, 0.95); +} + +.ops-comment-subitems, .comment-attachments, .comment-thread { + display: grid; + gap: 12px; +} + +.ops-comment-subitems.hidden { + display: none; +} + +.ops-detail-section-title, +.comment-thread__title { + color: rgba(245, 245, 250, 0.92); + font-size: 19px; + font-weight: 760; + line-height: 1.2; +} + +.ops-comment-subitems__list { display: grid; gap: 8px; - border: 1px solid var(--modal-border); - border-radius: var(--modal-radius, 10px); - background: color-mix(in srgb, var(--modal-focus) var(--modal-focus-alpha, 100%), transparent); - padding: 10px; } -.comment-attachments--reply { - border-style: dashed; -} - -.comment-attachments__head { - display: flex; +.ops-comment-subitem { + display: grid; + grid-template-columns: 20px minmax(0, 1fr) 28px; align-items: center; - justify-content: space-between; - gap: 8px; + gap: 10px; + min-height: 42px; + padding: 8px 10px; + border: 1px solid var(--ops-detail-border); + border-radius: 15px; + background: rgba(255, 255, 255, 0.026); +} + +.ops-comment-subitem input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent); +} + +.ops-comment-subitem input[type="text"] { + min-width: 0; + border: 0; + background: transparent; + color: rgba(245, 245, 250, 0.86); + font: inherit; + font-size: 13px; + outline: none; +} + +.ops-comment-subitem button { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border: 0; + border-radius: 10px; + background: transparent; + color: rgba(245, 245, 250, 0.48); + cursor: pointer; + font-size: 18px; + line-height: 1; +} + +.ops-comment-subitem button:hover { + background: rgba(255, 255, 255, 0.06); + color: rgba(245, 245, 250, 0.9); +} + +.ops-detail-dropzone { + grid-template-columns: 38px minmax(0, 1fr) auto; + align-items: center; + min-height: 76px; + padding: 16px 18px; + border: 0; + border-radius: 21px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.046), rgba(255, 255, 255, 0.024)); + transition: background 140ms ease, box-shadow 140ms ease; +} + +.ops-detail-dropzone.drag-over { + background: color-mix(in srgb, var(--accent) 18%, rgba(255, 255, 255, 0.04)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 44%, transparent); +} + +.ops-detail-dropzone__icon { + color: var(--accent); +} + +.ops-detail-dropzone__copy { + display: grid; + gap: 5px; + min-width: 0; +} + +.ops-detail-dropzone__copy strong { + color: rgba(245, 245, 250, 0.92); + font-size: 15px; + font-weight: 760; + line-height: 1.2; +} + +.ops-detail-dropzone__copy span { + color: rgba(245, 245, 250, 0.52); + font-size: 13px; + line-height: 1.2; } .comment-attach-button { - min-height: 28px; + min-height: 34px; border: 0; - border-radius: 9px; - background: var(--modal-element); - color: rgba(245, 245, 250, 0.82); + border-radius: 13px; + background: transparent; + color: var(--accent); cursor: pointer; font: inherit; - font-size: 11px; + font-size: 13px; font-weight: 800; - padding: 0 10px; + padding: 0 14px; } .comment-attach-button:hover { - background: var(--env-glass-control-hover); - color: #fff; + background: rgba(255, 255, 255, 0.055); } .comment-attachment-grid { + grid-column: 1 / -1; display: grid; - grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); - gap: 8px; + grid-template-columns: repeat(auto-fill, minmax(142px, 1fr)); + gap: 9px; +} + +.comment-attachment-grid--reply { + grid-column: auto; } .comment-attachment-thumb { position: relative; - min-height: 70px; + min-height: 86px; overflow: hidden; - border: 1px solid var(--modal-border); - border-radius: 9px; - background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--ops-detail-border); + border-radius: 14px; + background: rgba(255, 255, 255, 0.04); } .comment-attachment-thumb img { display: block; width: 100%; - height: 82px; + height: 104px; object-fit: cover; } +.comment-attachment-file { + display: grid; + grid-template-columns: 32px minmax(0, 1fr); + align-items: center; + gap: 9px; + min-height: 86px; + padding: 12px; + color: rgba(245, 245, 250, 0.82); +} + +.comment-attachment-file svg { + display: block; + width: 26px; + height: 26px; + fill: none; + stroke: currentColor; + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; + opacity: 0.72; +} + +.comment-attachment-file__meta { + display: grid; + gap: 4px; + min-width: 0; +} + +.comment-attachment-file__meta strong, +.comment-attachment-file__meta span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comment-attachment-file__meta strong { + font-size: 12px; + font-weight: 760; +} + +.comment-attachment-file__meta span { + color: rgba(245, 245, 250, 0.48); + font-size: 11px; +} + .comment-attachment-thumb button { position: absolute; - top: 4px; - right: 4px; + top: 6px; + right: 6px; display: grid; place-items: center; - width: 22px; - height: 22px; + width: 24px; + height: 24px; border: 0; - border-radius: 8px; - background: rgba(12, 12, 14, 0.76); + border-radius: 9px; + background: rgba(12, 12, 14, 0.78); color: #fff; cursor: pointer; font-weight: 900; line-height: 1; } -.comment-thread__list { +.ops-detail-activity { + gap: 14px; +} + +.ops-detail-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.ops-activity-composer { display: grid; gap: 8px; + padding: 16px; + border: 1px solid var(--ops-detail-border); + border-radius: 24px; + background: rgba(22, 23, 25, 0.78); +} + +.ops-activity-composer > span { + color: rgba(245, 245, 250, 0.9); + font-size: 15px; + font-weight: 760; +} + +.ops-activity-composer textarea { + width: 100%; + min-height: 118px; + border: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.045); + background: transparent; + color: rgba(245, 245, 250, 0.84); + font: inherit; + font-size: 14px; + line-height: 1.45; + outline: none; + resize: vertical; +} + +.ops-activity-composer textarea::placeholder { + color: rgba(245, 245, 250, 0.34); +} + +.ops-activity-composer__toolbar { + justify-content: flex-start; +} + +.ops-activity-send { + margin-left: auto; + background: rgba(255, 255, 255, 0.16); + color: rgba(245, 245, 250, 0.74); +} + +.ops-activity-send:disabled, +.comment-card-modal__footer button:disabled { + cursor: default; + opacity: 0.55; + transform: none; +} + +.comment-thread__list { + display: grid; + gap: 10px; } .comment-thread-empty { - color: var(--muted); - font-size: 12px; + color: var(--ops-detail-muted); + font-size: 13px; line-height: 1.4; } .comment-thread-message { display: grid; - gap: 6px; - border-radius: 10px; - background: rgba(255, 255, 255, 0.045); - padding: 9px; + gap: 7px; + border: 1px solid var(--ops-detail-border); + border-radius: 16px; + background: rgba(255, 255, 255, 0.026); + padding: 11px 12px; } .comment-thread-message__head { @@ -1797,8 +2098,8 @@ header { align-items: baseline; justify-content: space-between; gap: 8px; - color: rgba(245, 245, 250, 0.56); - font-size: 10px; + color: rgba(245, 245, 250, 0.5); + font-size: 11px; font-weight: 700; } @@ -1811,32 +2112,59 @@ header { } .comment-thread-message__body { - color: rgba(245, 245, 250, 0.78); - font-size: 12px; - line-height: 1.42; + color: rgba(245, 245, 250, 0.76); + font-size: 13px; + line-height: 1.45; white-space: pre-wrap; } .comment-card-modal__footer { position: sticky; bottom: 0; - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; - padding: 12px 14px; - background: color-mix(in srgb, var(--modal-bg) var(--modal-bg-alpha, 100%), transparent); - border-top: 1px solid var(--modal-border); + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 14px 32px 18px; + background: var(--ops-detail-bg); + border-top: 1px solid rgba(255, 255, 255, 0.04); } .comment-card-modal__footer .primary-btn, .comment-card-modal__footer .secondary-btn { - width: 100%; + min-width: 148px; } .comment-card-modal .error-text { min-height: 16px; color: var(--error); font-size: 12px; + font-weight: 700; +} + +@media (max-width: 720px) { + .comment-card-modal { + top: 14px; + left: 12px; + width: calc(100vw - 24px); + max-height: calc(100vh - 28px); + border-radius: 24px; + } + + .comment-card-modal__header, + .comment-card-modal__body, + .comment-card-modal__footer { + padding-left: 18px; + padding-right: 18px; + } + + .ops-detail-dropzone { + grid-template-columns: 30px minmax(0, 1fr); + } + + .ops-detail-dropzone .comment-attach-button { + grid-column: 2; + justify-self: start; + } } .settings-panel { diff --git a/frontend/dcViewer.js b/frontend/dcViewer.js index 7892e7b..3a64a24 100644 --- a/frontend/dcViewer.js +++ b/frontend/dcViewer.js @@ -57,11 +57,16 @@ const commentModal = document.getElementById("commentModal"); const commentModalHeader = document.getElementById("commentModalHeader"); const commentModalClose = document.getElementById("commentModalClose"); const commentModalSubtitle = document.getElementById("commentModalSubtitle"); +const commentModalEditedMeta = document.getElementById("commentModalEditedMeta"); const commentTitleInput = document.getElementById("commentTitleInput"); const commentBodyInput = document.getElementById("commentBodyInput"); +const commentDropzone = document.getElementById("commentDropzone"); const commentAttachButton = document.getElementById("commentAttachButton"); const commentAttachmentInput = document.getElementById("commentAttachmentInput"); const commentAttachmentGrid = document.getElementById("commentAttachmentGrid"); +const commentSubitemsSection = document.getElementById("commentSubitemsSection"); +const commentSubitemsList = document.getElementById("commentSubitemsList"); +const commentAddSubitemButton = document.getElementById("commentAddSubitemButton"); const commentThreadList = document.getElementById("commentThreadList"); const commentReplyInput = document.getElementById("commentReplyInput"); const commentReplyAttachButton = document.getElementById("commentReplyAttachButton"); @@ -70,6 +75,7 @@ const commentReplyAttachmentGrid = document.getElementById("commentReplyAttachme const commentModalError = document.getElementById("commentModalError"); const commentCancelButton = document.getElementById("commentCancelButton"); const commentApplyButton = document.getElementById("commentApplyButton"); +const commentApplyInlineButton = document.getElementById("commentApplyInlineButton"); const treeContainer = document.getElementById("treeContainer"); const displayModeControl = document.getElementById("displayModeControl"); const displayModeSelectHost = document.getElementById("displayModeSelect"); @@ -297,6 +303,7 @@ let activeCommentId = null; let hoverCommentId = null; let commentDraftAttachments = []; let commentReplyAttachments = []; +let commentDraftSubitems = []; let commentTargetRaf = 0; let commentLongPressTimer = null; let commentLongPressStart = null; @@ -890,8 +897,16 @@ const openCommentContextMenuFromEvent = (event) => { return true; }; -const readImageFiles = async (fileList) => { - const files = Array.from(fileList || []).filter((file) => file?.type?.startsWith("image/")).slice(0, 8); +const COMMENT_FILE_LIMIT_BYTES = 50 * 1024 * 1024; + +const readAttachmentFiles = async (fileList) => { + const files = Array.from(fileList || []) + .filter((file) => file && file.size <= COMMENT_FILE_LIMIT_BYTES) + .slice(0, 8); + const skipped = Array.from(fileList || []).filter((file) => file && file.size > COMMENT_FILE_LIMIT_BYTES).length; + if (skipped && commentModalError) { + commentModalError.textContent = `Файлы больше 50 МБ не добавлены: ${skipped}`; + } const readOne = (file) => new Promise((resolve) => { const reader = new FileReader(); reader.onload = () => resolve({ @@ -908,21 +923,51 @@ const readImageFiles = async (fileList) => { return items.filter(Boolean); }; +const formatFileSize = (value) => { + const size = Number(value); + if (!Number.isFinite(size) || size <= 0) return ""; + if (size >= 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(size >= 10 * 1024 * 1024 ? 0 : 1)} МБ`; + if (size >= 1024) return `${Math.round(size / 1024)} КБ`; + return `${Math.round(size)} Б`; +}; + const renderAttachmentGrid = (grid, attachments, onRemove) => { if (!grid) return; grid.replaceChildren(); (attachments || []).forEach((attachment, index) => { const thumb = document.createElement("div"); thumb.className = "comment-attachment-thumb"; - const img = document.createElement("img"); - img.src = attachment.dataUrl; - img.alt = attachment.name || "attachment"; + const isImage = String(attachment.type || "").startsWith("image/") || /^data:image\//i.test(String(attachment.dataUrl || "")); + if (isImage) { + const img = document.createElement("img"); + img.src = attachment.dataUrl; + img.alt = attachment.name || "attachment"; + thumb.appendChild(img); + } else { + const file = document.createElement("div"); + file.className = "comment-attachment-file"; + file.innerHTML = ` + + `; + const meta = document.createElement("div"); + meta.className = "comment-attachment-file__meta"; + const name = document.createElement("strong"); + name.textContent = attachment.name || "Файл"; + const size = document.createElement("span"); + size.textContent = formatFileSize(attachment.size) || attachment.type || "Файл"; + meta.append(name, size); + file.appendChild(meta); + thumb.appendChild(file); + } const remove = document.createElement("button"); remove.type = "button"; - remove.setAttribute("aria-label", "Удалить картинку"); + remove.setAttribute("aria-label", "Удалить файл"); remove.textContent = "×"; remove.addEventListener("click", () => onRemove?.(index)); - thumb.append(img, remove); + thumb.appendChild(remove); grid.appendChild(thumb); }); }; @@ -934,10 +979,31 @@ const renderThreadAttachments = (container, attachments) => { attachments.forEach((attachment) => { const thumb = document.createElement("div"); thumb.className = "comment-attachment-thumb"; - const img = document.createElement("img"); - img.src = attachment.dataUrl; - img.alt = attachment.name || "attachment"; - thumb.appendChild(img); + const isImage = String(attachment.type || "").startsWith("image/") || /^data:image\//i.test(String(attachment.dataUrl || "")); + if (isImage) { + const img = document.createElement("img"); + img.src = attachment.dataUrl; + img.alt = attachment.name || "attachment"; + thumb.appendChild(img); + } else { + const file = document.createElement("div"); + file.className = "comment-attachment-file"; + file.innerHTML = ` + + `; + const meta = document.createElement("div"); + meta.className = "comment-attachment-file__meta"; + const name = document.createElement("strong"); + name.textContent = attachment.name || "Файл"; + const size = document.createElement("span"); + size.textContent = formatFileSize(attachment.size) || attachment.type || "Файл"; + meta.append(name, size); + file.appendChild(meta); + thumb.appendChild(file); + } grid.appendChild(thumb); }); container.appendChild(grid); @@ -976,7 +1042,9 @@ const renderCommentThread = (comment) => { const resetCommentModalAttachments = () => { commentDraftAttachments = []; commentReplyAttachments = []; + commentDraftSubitems = []; renderModalAttachmentInputs(); + renderCommentSubitems(); }; const renderModalAttachmentInputs = () => { @@ -990,6 +1058,40 @@ const renderModalAttachmentInputs = () => { }); }; +const renderCommentSubitems = () => { + if (!commentSubitemsList || !commentSubitemsSection) return; + commentSubitemsList.replaceChildren(); + const subitems = Array.isArray(commentDraftSubitems) ? commentDraftSubitems : []; + commentSubitemsSection.classList.toggle("hidden", !subitems.length); + subitems.forEach((subitem, index) => { + const row = document.createElement("label"); + row.className = "ops-comment-subitem"; + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = !!subitem.done; + checkbox.addEventListener("change", () => { + commentDraftSubitems[index] = { ...commentDraftSubitems[index], done: checkbox.checked }; + }); + const input = document.createElement("input"); + input.type = "text"; + input.value = subitem.title || ""; + input.placeholder = "Новый подэлемент"; + input.addEventListener("input", () => { + commentDraftSubitems[index] = { ...commentDraftSubitems[index], title: input.value }; + }); + const remove = document.createElement("button"); + remove.type = "button"; + remove.setAttribute("aria-label", "Удалить подэлемент"); + remove.textContent = "×"; + remove.addEventListener("click", () => { + commentDraftSubitems.splice(index, 1); + renderCommentSubitems(); + }); + row.append(checkbox, input, remove); + commentSubitemsList.appendChild(row); + }); +}; + const openCommentModalForTarget = () => { if (!commentContextTarget || !commentModal) return; commentsModeActive = true; @@ -1001,8 +1103,13 @@ const openCommentModalForTarget = () => { commentBodyInput && (commentBodyInput.value = ""); commentReplyInput && (commentReplyInput.value = ""); if (commentModalSubtitle) { + commentModalSubtitle.textContent = "BIM-COMMENT"; + } + if (commentModalEditedMeta) { const [x, y, z] = commentContextTarget.worldPos || []; - commentModalSubtitle.textContent = Number.isFinite(x) ? `Точка: ${x.toFixed(2)}, ${y.toFixed(2)}, ${z.toFixed(2)}` : "Новый таргет"; + commentModalEditedMeta.textContent = Number.isFinite(x) + ? `Последнее редактирование: новая точка ${x.toFixed(2)}, ${y.toFixed(2)}, ${z.toFixed(2)}` + : "Последнее редактирование: новый комментарий"; } if (commentModalError) commentModalError.textContent = ""; resetCommentModalAttachments(); @@ -1024,10 +1131,17 @@ const openCommentModalForComment = (commentId) => { commentBodyInput && (commentBodyInput.value = comment.body || ""); commentReplyInput && (commentReplyInput.value = ""); if (commentModalSubtitle) { - commentModalSubtitle.textContent = `Создан: ${formatDateTime(comment.createdAt)} · Изменён: ${formatDateTime(comment.updatedAt)}`; + commentModalSubtitle.textContent = comment.id || "BIM-COMMENT"; + } + if (commentModalEditedMeta) { + commentModalEditedMeta.textContent = `Последнее редактирование: ${formatDateTime(comment.updatedAt || comment.createdAt)}`; } if (commentModalError) commentModalError.textContent = ""; resetCommentModalAttachments(); + commentDraftSubitems = Array.isArray(comment.subitems) + ? comment.subitems.map((item) => ({ ...item })) + : []; + renderCommentSubitems(); renderCommentThread(comment); commentModalBackdrop?.classList.remove("hidden"); commentModal.classList.remove("hidden"); @@ -1049,39 +1163,58 @@ const closeCommentModal = () => { renderCommentTargets(); }; -const buildCommentPayload = () => ({ - ...(commentContextTarget || {}), - title: commentTitleInput?.value?.trim() || "", - body: commentBodyInput?.value?.trim() || "", - attachments: commentDraftAttachments, -}); +const normalizedSubitemsDraft = () => ( + (commentDraftSubitems || []) + .map((item) => ({ + id: item.id || `sub_${Date.now()}_${Math.random().toString(36).slice(2)}`, + title: String(item.title || "").trim(), + done: !!item.done, + })) + .filter((item) => item.title) +); + +const buildCommentPayload = ({ title, body, replyBody } = {}) => { + const finalBody = body || replyBody || ""; + return { + ...(commentContextTarget || {}), + title: title || (finalBody ? finalBody.slice(0, 80) : "Комментарий"), + body: finalBody, + attachments: [...commentDraftAttachments, ...commentReplyAttachments].slice(0, 8), + subitems: normalizedSubitemsDraft(), + }; +}; const applyCommentModal = async () => { if (isGuestMode()) return; const title = commentTitleInput?.value?.trim() || ""; const body = commentBodyInput?.value?.trim() || ""; const replyBody = commentReplyInput?.value?.trim() || ""; - if (!title && !body && commentModalMode === "create") { - if (commentModalError) commentModalError.textContent = "Заполните тему или описание"; + if (!title && !body && !replyBody && !commentDraftAttachments.length && !commentReplyAttachments.length && !commentDraftSubitems.length && commentModalMode === "create") { + if (commentModalError) commentModalError.textContent = "Добавьте заголовок, описание, комментарий или файл"; return; } if (commentApplyButton) { commentApplyButton.disabled = true; commentApplyButton.textContent = "Сохранение..."; } + if (commentApplyInlineButton) { + commentApplyInlineButton.disabled = true; + } try { let payload = null; if (commentModalMode === "create") { payload = await fetchJSON(COMMENTS_API, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(buildCommentPayload()), + body: JSON.stringify(buildCommentPayload({ title, body, replyBody })), }); } else if (commentModalCommentId) { const original = commentRecords.find((item) => item.id === commentModalCommentId); + const subitems = normalizedSubitemsDraft(); const patchPayload = {}; if (!original || title !== (original.title || "")) patchPayload.title = title; if (!original || body !== (original.body || "")) patchPayload.body = body; + if (JSON.stringify(subitems) !== JSON.stringify(original?.subitems || [])) patchPayload.subitems = subitems; if (replyBody || commentReplyAttachments.length) { patchPayload.message = { body: replyBody, @@ -1117,6 +1250,9 @@ const applyCommentModal = async () => { commentApplyButton.disabled = false; commentApplyButton.textContent = "Применить"; } + if (commentApplyInlineButton) { + commentApplyInlineButton.disabled = false; + } } }; @@ -4941,23 +5077,59 @@ const loadProjectSnapshot = async (project) => { commentModalClose?.addEventListener("click", closeCommentModal); commentCancelButton?.addEventListener("click", closeCommentModal); commentApplyButton?.addEventListener("click", applyCommentModal); + commentApplyInlineButton?.addEventListener("click", applyCommentModal); commentModalBackdrop?.addEventListener("click", (event) => { if (event.target === commentModalBackdrop) closeCommentModal(); }); commentAttachButton?.addEventListener("click", () => commentAttachmentInput?.click()); commentReplyAttachButton?.addEventListener("click", () => commentReplyAttachmentInput?.click()); + commentAddSubitemButton?.addEventListener("click", () => { + commentDraftSubitems.push({ + id: `sub_${Date.now()}_${Math.random().toString(36).slice(2)}`, + title: "", + done: false, + }); + renderCommentSubitems(); + const inputs = commentSubitemsList?.querySelectorAll?.(".ops-comment-subitem input[type='text']"); + inputs?.[inputs.length - 1]?.focus?.(); + }); commentAttachmentInput?.addEventListener("change", async (event) => { - const files = await readImageFiles(event.target.files); + const files = await readAttachmentFiles(event.target.files); commentDraftAttachments = [...commentDraftAttachments, ...files].slice(0, 8); event.target.value = ""; renderModalAttachmentInputs(); }); commentReplyAttachmentInput?.addEventListener("change", async (event) => { - const files = await readImageFiles(event.target.files); + const files = await readAttachmentFiles(event.target.files); commentReplyAttachments = [...commentReplyAttachments, ...files].slice(0, 8); event.target.value = ""; renderModalAttachmentInputs(); }); + if (commentDropzone) { + commentDropzone.addEventListener("click", (event) => { + if (event.target?.closest?.("button,input,.comment-attachment-thumb")) return; + commentAttachmentInput?.click(); + }); + ["dragenter", "dragover"].forEach((type) => { + commentDropzone.addEventListener(type, (event) => { + event.preventDefault(); + event.stopPropagation(); + commentDropzone.classList.add("drag-over"); + }); + }); + ["dragleave", "drop"].forEach((type) => { + commentDropzone.addEventListener(type, (event) => { + event.preventDefault(); + event.stopPropagation(); + commentDropzone.classList.remove("drag-over"); + }); + }); + commentDropzone.addEventListener("drop", async (event) => { + const files = await readAttachmentFiles(event.dataTransfer?.files); + commentDraftAttachments = [...commentDraftAttachments, ...files].slice(0, 8); + renderModalAttachmentInputs(); + }); + } if (!isGuestMode()) { loadProjectsList().catch(() => { templatesMenu?.setData({ templates: templateFallbacks, projects: [], error: "API недоступно" }); diff --git a/frontend/index.html b/frontend/index.html index d6e32ae..824e848 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -134,7 +134,7 @@ - +
@@ -293,51 +293,126 @@ -