diff --git a/admin/admin.css b/admin/admin.css index de808ed..c5f109a 100644 --- a/admin/admin.css +++ b/admin/admin.css @@ -708,6 +708,17 @@ body[data-file-picker-active="true"] .editor { background: rgba(255, 255, 255, 0.13); } +.trash-can-icon { + display: block; + width: 1rem; + height: 1rem; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.65; +} + .danger-action-btn { background: transparent; color: rgba(255, 255, 255, 0.82); @@ -1281,6 +1292,12 @@ body[data-file-picker-active="true"] .editor { opacity: 0.78; } +.collection-icon-btn .trash-can-icon { + width: 0.95rem; + height: 0.95rem; + opacity: 0.82; +} + .collection-drag-handle:active { cursor: grabbing; } @@ -1754,6 +1771,640 @@ textarea { color: rgba(8, 8, 10, 1); } +.media-modal-layer { + z-index: 230; +} + +.media-modal { + width: min(82rem, calc(100vw - 2.8rem)); + height: min(52rem, calc(100vh - 2.8rem)); + grid-template-rows: auto auto minmax(0, 1fr) auto; +} + +.media-modal-head { + padding-bottom: 0.85rem; +} + +.media-modal-actions { + position: absolute; + top: 1.05rem; + right: 1.05rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.media-site-size { + display: inline-flex; + min-height: 2.8rem; + align-items: center; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: var(--launcher-radius-circle); + background: rgba(255, 255, 255, 0.045); + color: var(--text-muted); + padding: 0 0.9rem; + font-size: 0.68rem; + font-weight: 850; + letter-spacing: 0.04em; + text-transform: uppercase; + white-space: nowrap; +} + +.media-header-action { + position: static; +} + +.media-header-action svg, +.media-delete-btn svg { + width: 1.05rem; + height: 1.05rem; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.65; +} + +.media-trash-toggle[data-active="true"] { + border-color: rgba(255, 255, 255, 0.92); + background: rgba(255, 255, 255, 0.92); + color: rgba(8, 8, 10, 0.96); +} + +.media-toolbar { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: minmax(15rem, 1fr) auto auto auto; + gap: 0.7rem; + align-items: end; + padding: 0 1.25rem 1rem; +} + +.media-search { + display: grid; + gap: 0.38rem; + color: var(--text-muted); + font-size: 0.68rem; + font-weight: 850; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.media-search input { + min-height: var(--nodedc-shell-control-height); + border-radius: var(--launcher-radius-circle); + padding-inline: 1rem; +} + +.media-filter-row { + display: inline-flex; + max-width: 24rem; + align-items: center; + gap: 0.25rem; + overflow-x: auto; + border-radius: var(--launcher-radius-circle); + background: rgba(255, 255, 255, 0.055); + padding: 0.24rem; + scrollbar-width: none; +} + +.media-filter-row::-webkit-scrollbar { + display: none; +} + +.media-filter-button { + display: inline-flex; + min-height: 2.3rem; + align-items: center; + justify-content: center; + border-radius: var(--launcher-radius-circle); + background: transparent; + color: var(--text-muted); + padding: 0 0.78rem; + font-size: 0.72rem; + font-weight: 850; + white-space: nowrap; +} + +.media-filter-button:disabled { + opacity: 0.45; + pointer-events: none; +} + +.media-filter-button:hover { + background: rgba(255, 255, 255, 0.07); + color: var(--text-primary); +} + +.media-filter-button[data-active="true"] { + background: rgba(255, 255, 255, 0.92); + color: rgba(8, 8, 10, 0.96); +} + +.media-upload-btn { + white-space: nowrap; +} + +.media-browser { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(19rem, 0.32fr); + gap: 1rem; + min-height: 0; + padding: 0 1.25rem 1rem; +} + +.media-browser[data-drag-active="true"]::after { + position: absolute; + inset: 0 1.25rem 1rem; + display: grid; + place-items: center; + border: 1px dashed rgba(255, 255, 255, 0.38); + border-radius: var(--launcher-radius-card); + background: rgba(8, 8, 10, 0.72); + color: var(--text-primary); + content: "Отпустите файл, чтобы загрузить"; + font-size: 1rem; + font-weight: 850; + pointer-events: none; +} + +.media-main, +.media-preview-panel { + min-width: 0; + min-height: 0; + border-radius: var(--launcher-radius-card); + background: rgba(255, 255, 255, 0.04); +} + +.media-main { + position: relative; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; +} + +.media-breadcrumbs { + display: flex; + min-width: 0; + align-items: center; + gap: 0.25rem; + overflow-x: auto; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + padding: 0.62rem 0.78rem; + scrollbar-width: none; +} + +.media-breadcrumbs::-webkit-scrollbar { + display: none; +} + +.media-breadcrumb-button { + display: inline-flex; + min-height: 2rem; + align-items: center; + border-radius: var(--launcher-radius-circle); + background: rgba(255, 255, 255, 0.06); + color: var(--text-muted); + padding: 0 0.68rem; + font-size: 0.7rem; + font-weight: 850; + white-space: nowrap; +} + +.media-breadcrumb-button::after { + margin-left: 0.5rem; + color: rgba(255, 255, 255, 0.28); + content: "›"; +} + +.media-breadcrumb-button[data-active="true"] { + background: rgba(255, 255, 255, 0.92); + color: rgba(8, 8, 10, 0.96); +} + +.media-breadcrumb-button[data-active="true"]::after { + content: ""; + margin: 0; +} + +.media-columns { + display: flex; + min-height: 0; + overflow: auto; + padding: 0.72rem; + scroll-padding-inline: 0.72rem; +} + +.media-column { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + width: clamp(14rem, 24vw, 19rem); + min-width: clamp(14rem, 24vw, 19rem); + min-height: 0; + overflow: hidden; + border-right: 1px solid rgba(255, 255, 255, 0.06); + padding-right: 0.62rem; + margin-right: 0.62rem; +} + +.media-column:last-child { + border-right: 0; + margin-right: 0; +} + +.media-column-head { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 0.65rem; + padding: 0.18rem 0.1rem 0.58rem; +} + +.media-column-title { + overflow: hidden; + color: var(--text-primary); + font-size: 0.78rem; + font-weight: 850; + text-overflow: ellipsis; + white-space: nowrap; +} + +.media-column-count { + display: inline-grid; + min-width: 1.65rem; + height: 1.65rem; + place-items: center; + border-radius: 999px; + background: rgba(255, 255, 255, 0.07); + color: var(--text-muted); + font-size: 0.68rem; + font-weight: 850; +} + +.media-column-list { + display: grid; + align-content: start; + gap: 0.22rem; + min-height: 0; + overflow: auto; + padding-right: 0.12rem; + padding-bottom: 0.45rem; + scroll-padding-block: 0.5rem; + scrollbar-width: thin; +} + +.media-column-list[data-drop-active="true"] { + border-radius: var(--launcher-radius-control); + outline: 1px solid rgba(255, 255, 255, 0.34); + outline-offset: -1px; +} + +.media-column-empty { + color: var(--text-muted); + font-size: 0.76rem; + font-weight: 720; + padding: 0.8rem 0.25rem; +} + +.media-empty { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: var(--text-muted); + font-size: 0.92rem; + font-weight: 720; +} + +.media-entry { + display: grid; + grid-template-columns: 3rem minmax(0, 1fr) auto auto; + min-width: 0; + min-height: 3.35rem; + align-items: center; + gap: 0.58rem; + border: 0; + border-radius: var(--launcher-radius-control); + background: rgba(255, 255, 255, 0.038); + padding: 0.28rem 0.5rem 0.28rem 0.28rem; + color: var(--text-secondary); + text-align: left; +} + +.media-entry:hover { + background: rgba(255, 255, 255, 0.075); + color: var(--text-primary); +} + +.media-entry[data-active="true"] { + background: rgba(255, 255, 255, 0.14); + color: var(--text-primary); +} + +.media-entry[data-dragging="true"] { + opacity: 0.48; +} + +.media-entry[data-drop-active="true"] { + background: rgba(255, 255, 255, 0.16); + outline: 1px solid rgba(255, 255, 255, 0.38); + outline-offset: -1px; +} + +.media-entry-icon { + display: grid; + width: 3rem; + height: 3rem; + place-items: center; + border-radius: 0.82rem; + background: rgba(255, 255, 255, 0.07); + color: rgba(255, 255, 255, 0.52); + font-size: 0.64rem; + font-weight: 850; + overflow: hidden; +} + +.media-entry[data-type="directory"] .media-entry-icon { + background: rgba(255, 255, 255, 0.12); + color: rgba(255, 255, 255, 0.76); +} + +.media-entry-icon.is-preview { + background: rgba(255, 255, 255, 0.1); +} + +.media-entry-icon.is-preview img, +.media-entry-icon.is-preview video { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.media-entry-copy { + display: grid; + min-width: 0; + gap: 0.1rem; +} + +.media-entry-name, +.media-entry-meta { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.media-entry-name { + color: var(--text-primary); + font-size: 0.78rem; + font-weight: 820; +} + +.media-entry-meta { + color: var(--text-muted); + font-size: 0.64rem; + font-weight: 680; +} + +.media-entry-status { + position: static; + flex: 0 0 auto; +} + +.media-entry-arrow { + color: var(--text-muted); + font-size: 1.25rem; + line-height: 1; +} + +.media-preview img, +.media-preview video { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +.media-preview video { + object-fit: cover; +} + +.media-status-dot, +.media-usage-dot { + display: block; + width: 0.58rem; + height: 0.58rem; + border-radius: 50%; + background: rgba(255, 255, 255, 0.36); + box-shadow: 0 0 0 0.22rem rgba(255, 255, 255, 0.06); +} + +.media-status-dot { + position: absolute; + top: 0.48rem; + right: 0.48rem; +} + +.media-status-dot.media-entry-status { + position: static; + top: auto; + right: auto; +} + +.media-status-dot[data-status="rendered"], +.media-usage-pill[data-status="rendered"] .media-usage-dot { + background: #58d77a; + box-shadow: 0 0 0 0.22rem rgba(88, 215, 122, 0.16); +} + +.media-status-dot[data-status="referenced"], +.media-usage-pill[data-status="referenced"] .media-usage-dot { + background: #ffd36a; + box-shadow: 0 0 0 0.22rem rgba(255, 211, 106, 0.16); +} + +.media-status-dot[data-status="unused"], +.media-usage-pill[data-status="unused"] .media-usage-dot { + background: rgba(255, 255, 255, 0.34); +} + +.media-status-dot[data-status="trash"], +.media-usage-pill[data-status="trash"] .media-usage-dot { + background: #8aa4ff; + box-shadow: 0 0 0 0.22rem rgba(138, 164, 255, 0.16); +} + +.media-preview-name, +.media-preview-path, +.media-preview-meta { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.media-preview-panel { + position: relative; + display: grid; + grid-template-rows: auto auto auto 1fr; + align-content: stretch; + gap: 0.9rem; + padding: 0.8rem; + padding-bottom: calc(var(--nodedc-shell-control-height) + 1rem); +} + +.media-preview { + display: grid; + aspect-ratio: 1.25; + width: 100%; + place-items: center; + overflow: hidden; + border-radius: var(--launcher-radius-control); + background: rgba(255, 255, 255, 0.05); + color: var(--text-muted); + font-size: 0.86rem; + font-weight: 850; +} + +.media-preview-copy { + display: grid; + gap: 0.22rem; + min-width: 0; +} + +.media-preview-name { + color: var(--text-primary); + font-size: 0.92rem; + font-weight: 850; +} + +.media-preview-path, +.media-preview-meta { + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 680; +} + +.media-usage-pill { + display: inline-flex; + width: fit-content; + max-width: 100%; + align-items: center; + gap: 0.48rem; + border-radius: var(--launcher-radius-circle); + background: rgba(255, 255, 255, 0.07); + color: var(--text-secondary); + padding: 0.48rem 0.68rem; + font-size: 0.72rem; + font-weight: 850; +} + +.media-preview-actions { + display: flex; + align-items: end; + gap: 0.55rem; + align-self: end; + flex-wrap: wrap; + padding-right: calc(var(--admin-control-ring) + 0.65rem); +} + +.media-delete-btn { + position: absolute; + right: 0.8rem; + bottom: 0.8rem; + display: inline-grid; + width: var(--admin-control-ring); + min-width: var(--admin-control-ring); + height: var(--admin-control-ring); + place-items: center; + border: 1px solid rgba(255, 255, 255, 0.22); + border-radius: var(--launcher-radius-circle); + background: transparent; + color: rgba(255, 255, 255, 0.8); + padding: 0; + line-height: 1; +} + +.media-delete-btn:hover { + border-color: rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.07); + color: var(--text-primary); +} + +.media-delete-btn:disabled { + display: none; +} + +.media-context-menu { + position: fixed; + z-index: 320; + display: grid; + min-width: 12rem; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: var(--launcher-radius-control); + background: rgba(34, 34, 38, 0.94); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.42); + padding: 0.35rem; + backdrop-filter: blur(22px) saturate(1.15); + -webkit-backdrop-filter: blur(22px) saturate(1.15); +} + +.media-context-menu.hidden { + display: none; +} + +.media-context-menu button { + display: flex; + min-height: 2.25rem; + align-items: center; + border-radius: calc(var(--launcher-radius-control) - 0.18rem); + background: transparent; + color: var(--text-secondary); + padding: 0 0.72rem; + font-size: 0.76rem; + font-weight: 780; + text-align: left; +} + +.media-context-menu button:hover { + background: rgba(255, 255, 255, 0.09); + color: var(--text-primary); +} + +.media-context-menu button:disabled { + opacity: 0.38; + pointer-events: none; +} + +.media-action-modal-layer { + z-index: 300; +} + +.media-action-modal { + width: min(28rem, calc(100vw - 2.8rem)); +} + +.media-action-head { + padding-right: 1.25rem; +} + +.media-action-body { + padding: 0 1.25rem 1.25rem; +} + +.media-action-foot { + justify-content: flex-end; +} + +.media-modal-foot { + justify-content: space-between; +} + @media (max-width: 1100px) { body { overflow: auto; @@ -1808,6 +2459,29 @@ textarea { .template-list { grid-template-columns: 1fr; } + + .media-toolbar { + grid-template-columns: 1fr; + align-items: stretch; + } + + .media-filter-row { + max-width: 100%; + } + + .media-browser { + grid-template-columns: 1fr; + } + + .media-preview-panel { + grid-template-columns: 9rem minmax(0, 1fr); + grid-template-rows: auto auto; + } + + .media-preview-actions, + .media-usage-pill { + grid-column: 2; + } } @media (max-width: 720px) { @@ -1875,6 +2549,26 @@ textarea { .service-media-preview { display: none; } + + .media-modal { + width: 100vw; + height: 100vh; + max-height: 100vh; + border-radius: 0; + } + + .media-columns { + padding: 0.55rem; + } + + .media-preview-panel { + grid-template-columns: 1fr; + } + + .media-preview-actions, + .media-usage-pill { + grid-column: auto; + } } @media (max-width: 480px) { diff --git a/admin/admin.js b/admin/admin.js index 4464e1f..19fbd2b 100644 --- a/admin/admin.js +++ b/admin/admin.js @@ -1,3 +1,39 @@ +const ADMIN_LAYOUT_STORAGE_KEY = "nodedc-admin-layout-v1"; + +function readAdminLayoutState() { + try { + return JSON.parse(localStorage.getItem(ADMIN_LAYOUT_STORAGE_KEY) || "{}"); + } catch { + return {}; + } +} + +function writeAdminLayoutState(nextLayout) { + try { + localStorage.setItem(ADMIN_LAYOUT_STORAGE_KEY, JSON.stringify(nextLayout)); + } catch { + // Storage can be unavailable in private or restricted browser contexts. + } +} + +function updateAdminLayoutState(patch) { + writeAdminLayoutState({ + ...readAdminLayoutState(), + ...patch, + }); +} + +function readCollectionOpenState() { + const collections = readAdminLayoutState().collections || {}; + return new Map( + Object.entries(collections).map(([key, value]) => [key, new Set(Array.isArray(value) ? value : [])]), + ); +} + +function readLastMediaDirectory() { + return String(readAdminLayoutState().lastMediaDirectory || "").replace(/^\/+/, ""); +} + const state = { page: null, templates: [], @@ -10,7 +46,24 @@ const state = { mediaUploadActive: false, filePickerResetTimer: null, deleteConfirmResolve: null, - collectionOpenState: new Map(), + mediaActionResolve: null, + collectionOpenState: readCollectionOpenState(), + mediaLibrary: { + columns: [], + context: null, + contextMenu: null, + trashMode: false, + previousPath: "", + selectedPath: "", + selectedEntry: null, + selectedUrl: "", + root: "", + kind: "all", + query: "", + loaded: false, + dragEntryPath: "", + lastPath: readLastMediaDirectory(), + }, }; const SECTIONS_REGION_ID = "sections"; @@ -50,6 +103,37 @@ const el = { deleteModalBody: document.querySelector("#delete-modal-body"), deleteModalCancel: document.querySelector("#delete-modal-cancel"), deleteModalConfirm: document.querySelector("#delete-modal-confirm"), + mediaModal: document.querySelector("#media-modal"), + mediaModalTitle: document.querySelector("#media-modal-title"), + mediaModalSubtitle: document.querySelector("#media-modal-subtitle"), + mediaSiteSize: document.querySelector("#media-site-size"), + mediaModalClose: document.querySelector("#media-modal-close"), + mediaModalCancel: document.querySelector("#media-modal-cancel"), + mediaTrashToggle: document.querySelector("#media-trash-toggle"), + mediaSearch: document.querySelector("#media-search"), + mediaKindFilters: document.querySelector("#media-kind-filters"), + mediaRootFilters: document.querySelector("#media-root-filters"), + mediaUpload: document.querySelector("#media-upload"), + mediaRefresh: document.querySelector("#media-refresh"), + mediaDropZone: document.querySelector("#media-drop-zone"), + mediaBreadcrumbs: document.querySelector("#media-breadcrumbs"), + mediaList: document.querySelector("#media-list"), + mediaEmpty: document.querySelector("#media-empty"), + mediaPreview: document.querySelector("#media-preview"), + mediaPreviewName: document.querySelector("#media-preview-name"), + mediaPreviewPath: document.querySelector("#media-preview-path"), + mediaPreviewMeta: document.querySelector("#media-preview-meta"), + mediaPreviewUsage: document.querySelector("#media-preview-usage"), + mediaSelect: document.querySelector("#media-select"), + mediaDelete: document.querySelector("#media-delete"), + mediaContextMenu: document.querySelector("#media-context-menu"), + mediaActionModal: document.querySelector("#media-action-modal"), + mediaActionTitle: document.querySelector("#media-action-title"), + mediaActionBody: document.querySelector("#media-action-body"), + mediaActionLabel: document.querySelector("#media-action-label"), + mediaActionInput: document.querySelector("#media-action-input"), + mediaActionCancel: document.querySelector("#media-action-cancel"), + mediaActionConfirm: document.querySelector("#media-action-confirm"), }; const STATIC_FALLBACK = { @@ -180,10 +264,10 @@ const STATIC_FALLBACK = { href: "javascript:void(0)", linkTarget: "", targetId: "", - iconSrc: "./assets/webflow/images/69ad964d951f9d17cc5a919e_logo-app.svg", - iconAlt: "Иконка приложения", + iconSrc: "", + iconAlt: "", className: "dock-icon ssscript", - iconClass: "logo", + iconClass: "", dividerBefore: false, }, [ @@ -245,6 +329,23 @@ const GROUP_COPY = { }; const MEDIA_ACCEPT = "image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv,.glb,.gltf,.svg,.avif,.webp,.woff,.woff2"; +const MEDIA_DRAG_MIME = "application/x-nodedc-media-entry"; +const MEDIA_KIND_FILTERS = [ + ["all", "Все"], + ["image", "Изображения"], + ["video", "Видео"], + ["model", "3D"], +]; +const MEDIA_ROOT_FILTERS = [ + ["", "Проект"], + ["assets", "Assets"], + ["assets/uploads", "Uploads"], + ["assets/media", "Media"], + ["assets/webflow/images", "Webflow"], + ["assets/custom", "Custom"], +]; +const FINDER_TRASH_ICON_SVG = + ''; const mediaFilePicker = createMediaFilePicker(); const collectionItemRuntimeKeys = new WeakMap(); let collectionItemRuntimeKeyCounter = 0; @@ -454,16 +555,18 @@ function normalizeDock(dock, assets = {}) { } else { dock.items = dock.items.map((item, index) => { const fallback = fallbackItems[index] || fallbackItems[0]; + const hasOwn = (key) => Object.prototype.hasOwnProperty.call(item, key); + return { enabled: item.enabled !== false, title: item.title || fallback.title || `Элемент ${index + 1}`, href: item.href ?? fallback.href ?? "", linkTarget: item.linkTarget ?? fallback.linkTarget ?? "", targetId: item.targetId ?? fallback.targetId ?? "", - iconSrc: item.iconSrc || fallback.iconSrc, - iconAlt: item.iconAlt || item.alt || fallback.iconAlt, + iconSrc: hasOwn("iconSrc") ? item.iconSrc : fallback.iconSrc, + iconAlt: hasOwn("iconAlt") ? item.iconAlt : item.alt || fallback.iconAlt, className: item.className || fallback.className || "dock-icon ssscript", - iconClass: item.iconClass || fallback.iconClass || "logo", + iconClass: hasOwn("iconClass") ? item.iconClass : fallback.iconClass || "logo", dividerBefore: Boolean(item.dividerBefore), }; }); @@ -613,9 +716,56 @@ function mediaKindFromValue(value) { if (!value) return null; if (/\.(mp4|webm|mov|m4v|avi|mkv)(\?.*)?$/i.test(value)) return "video"; if (/\.(png|jpe?g|gif|svg|avif|webp)(\?.*)?$/i.test(value)) return "image"; + if (/\.(glb|gltf)(\?.*)?$/i.test(value)) return "model"; + if (/\.(woff2?|ttf|otf)(\?.*)?$/i.test(value)) return "font"; return null; } +function formatFileSize(bytes) { + const value = Number(bytes); + if (!Number.isFinite(value) || value < 0) return ""; + if (value < 1024) return `${value} B`; + const units = ["KB", "MB", "GB"]; + let size = value / 1024; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex += 1; + } + + return `${size >= 10 ? size.toFixed(0) : size.toFixed(1)} ${units[unitIndex]}`; +} + +function formatFileDate(ms) { + const value = Number(ms); + if (!Number.isFinite(value)) return ""; + return new Intl.DateTimeFormat("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +function mediaUsageStatus(file) { + return file?.usage?.status || "unused"; +} + +function mediaUsageLabel(file) { + return file?.usage?.label || "Не используется"; +} + +function mediaFileMeta(file) { + return [file.kind, file.extension, formatFileSize(file.size)].filter(Boolean).join(" · "); +} + +function selectedMediaFile() { + const entry = state.mediaLibrary.selectedEntry; + return entry?.type === "file" ? entry : null; +} + function shouldWarnAboutSolidLogoBackground(editableField) { const path = String(editableField?.path || "").toLowerCase(); const label = String(editableField?.label || "").toLowerCase(); @@ -1032,6 +1182,36 @@ function requestDeleteConfirmation({ }); } +function closeMediaActionModal(value = null) { + const resolve = state.mediaActionResolve; + + state.mediaActionResolve = null; + el.mediaActionModal.classList.add("hidden"); + if (resolve) resolve(value); +} + +function requestMediaActionInput({ + title = "Действие", + body = "Введите имя.", + label = "Имя", + value = "", + confirmLabel = "Подтвердить", +} = {}) { + if (state.mediaActionResolve) closeMediaActionModal(null); + + return new Promise((resolve) => { + state.mediaActionResolve = resolve; + el.mediaActionTitle.textContent = title; + el.mediaActionBody.textContent = body; + el.mediaActionLabel.textContent = label; + el.mediaActionConfirm.textContent = confirmLabel; + el.mediaActionInput.value = value; + el.mediaActionModal.classList.remove("hidden"); + el.mediaActionInput.focus(); + el.mediaActionInput.select(); + }); +} + function addBlockFromTemplate(templateKey) { const template = blockTemplates().find((candidate) => candidate.key === templateKey); if (!template) return; @@ -1257,7 +1437,7 @@ function updateMediaFieldValue({ block, editableField, hiddenInput, urlInput, fi markDirty(); } -async function uploadMediaFile({ block, editableField, hiddenInput, urlInput, fileName, preview, hint, error, setSource, file }) { +async function uploadMediaFile({ block, editableField, hiddenInput, urlInput, fileName, preview, hint, error, setSource, afterUpload, file }) { if (!file) return; state.mediaUploadActive = true; @@ -1298,10 +1478,13 @@ async function uploadMediaFile({ block, editableField, hiddenInput, urlInput, fi }); setSource("file"); markDirty(`Файл сохранён: ${payload.url}. Нажмите «Сохранить модель», чтобы записать путь в content/pages/home.json.`); + if (typeof afterUpload === "function") await afterUpload(payload); + return payload; } catch (uploadError) { error.textContent = uploadError.message; fileName.textContent = truncateText(fileNameFromUrl(hiddenInput.value), 28); setStatus(`Ошибка загрузки: ${uploadError.message}`); + return null; } finally { state.mediaUploadActive = false; mediaFilePicker.value = ""; @@ -1355,6 +1538,1248 @@ function openMediaFilePicker(context) { } } +function renderMediaThumb(target, file, { large = false } = {}) { + target.innerHTML = ""; + target.classList.remove("is-image", "is-video"); + + if (!file) { + target.textContent = "FILE"; + return; + } + + if (file.kind === "image") { + const image = document.createElement("img"); + target.classList.add("is-image"); + image.src = adminPreviewUrl(file.url); + image.alt = file.name || ""; + image.loading = "lazy"; + image.decoding = "async"; + target.append(image); + return; + } + + if (file.kind === "video") { + const video = document.createElement("video"); + target.classList.add("is-video"); + video.src = adminPreviewUrl(file.url); + video.muted = true; + video.loop = true; + video.playsInline = true; + video.preload = "metadata"; + if (large) video.controls = true; + target.append(video); + return; + } + + const label = file.kind === "model" ? "3D" : file.kind === "font" ? "FONT" : file.extension?.toUpperCase() || "FILE"; + target.textContent = label; +} + +function renderMediaFilterButtons() { + el.mediaKindFilters.innerHTML = ""; + el.mediaRootFilters.innerHTML = ""; + const activeRoot = activeMediaRootFilter(); + + for (const [kind, label] of MEDIA_KIND_FILTERS) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "media-filter-button"; + button.dataset.active = String(state.mediaLibrary.kind === kind); + button.disabled = state.mediaLibrary.trashMode; + button.textContent = label; + button.addEventListener("click", () => { + state.mediaLibrary.kind = kind; + renderMediaLibrary(); + }); + el.mediaKindFilters.append(button); + } + + for (const [rootPath, label] of MEDIA_ROOT_FILTERS) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "media-filter-button"; + button.dataset.active = String(activeRoot === rootPath); + button.disabled = state.mediaLibrary.trashMode; + button.textContent = label; + button.title = rootPath || "Корень проекта NODEDC_SITE"; + button.addEventListener("click", async () => { + state.mediaLibrary.root = rootPath; + state.mediaLibrary.selectedEntry = null; + state.mediaLibrary.selectedUrl = ""; + await loadMediaColumns(rootPath).catch((error) => setStatus(`Ошибка медиатеки: ${error.message}`)); + }); + el.mediaRootFilters.append(button); + } +} + +function mediaPathParts(path) { + return String(path || "") + .split("/") + .filter(Boolean); +} + +function isSameOrChildPath(path, rootPath) { + if (!rootPath) return !path; + return path === rootPath || path.startsWith(`${rootPath}/`); +} + +function activeMediaRootFilter() { + const selectedPath = state.mediaLibrary.selectedPath || state.mediaLibrary.root || ""; + let activeRoot = ""; + + for (const [rootPath] of MEDIA_ROOT_FILTERS) { + if (rootPath && isSameOrChildPath(selectedPath, rootPath)) { + activeRoot = rootPath; + } + } + + return activeRoot; +} + +function mediaPathChain(path) { + const parts = mediaPathParts(path); + const chain = [""]; + let current = ""; + + for (const part of parts) { + current = current ? `${current}/${part}` : part; + chain.push(current); + } + + return chain; +} + +function parentMediaPath(path) { + const parts = mediaPathParts(path); + parts.pop(); + return parts.join("/"); +} + +function rememberMediaDirectory(path) { + const nextPath = String(path || "").replace(/^\/+/, ""); + if (nextPath === "__trash__") return; + + state.mediaLibrary.lastPath = nextPath; + updateAdminLayoutState({ lastMediaDirectory: nextPath }); +} + +function pathFromUrl(url) { + let value = String(url || "").split("?")[0].split("#")[0]; + if (value.startsWith("./")) value = value.slice(2); + + try { + value = decodeURIComponent(value); + } catch { + // Keep raw value for malformed escape sequences. + } + + return value; +} + +function mediaReplacementVariants(pathOrUrl) { + const path = pathFromUrl(pathOrUrl); + if (!path) return []; + + const encodedPath = path + .split("/") + .map((part) => encodeURIComponent(part)) + .join("/"); + const variants = [path, `./${path}`, `/${path}`, encodedPath, `./${encodedPath}`, `/${encodedPath}`]; + + return [...new Set(variants)].sort((left, right) => right.length - left.length); +} + +function replaceMediaReferencesInString(value, replacements) { + let nextValue = value; + + for (const replacement of replacements) { + const fromVariants = mediaReplacementVariants(replacement.fromUrl || replacement.from); + const toPath = pathFromUrl(replacement.toUrl || replacement.to); + if (!fromVariants.length || !toPath) continue; + + const encodedToPath = toPath + .split("/") + .map((part) => encodeURIComponent(part)) + .join("/"); + + for (const fromVariant of fromVariants) { + const hasDotPrefix = fromVariant.startsWith("./"); + const hasRootPrefix = fromVariant.startsWith("/"); + const toVariant = `${hasDotPrefix ? "./" : hasRootPrefix ? "/" : ""}${fromVariant.includes("%") ? encodedToPath : toPath}`; + nextValue = nextValue.split(fromVariant).join(toVariant); + } + } + + return nextValue; +} + +function replaceMediaReferencesInValue(value, replacements) { + if (typeof value === "string") { + return replaceMediaReferencesInString(value, replacements); + } + + if (Array.isArray(value)) { + let changed = false; + const nextValue = value.map((item) => { + const nextItem = replaceMediaReferencesInValue(item, replacements); + changed ||= nextItem !== item; + return nextItem; + }); + return changed ? nextValue : value; + } + + if (value && typeof value === "object") { + let changed = false; + const nextValue = { ...value }; + + for (const [key, item] of Object.entries(value)) { + const nextItem = replaceMediaReferencesInValue(item, replacements); + if (nextItem !== item) { + nextValue[key] = nextItem; + changed = true; + } + } + + return changed ? nextValue : value; + } + + return value; +} + +async function browseMediaPath(path) { + const response = await fetch(`/api/media/browse?path=${encodeURIComponent(path || "")}`); + const payload = await response.json().catch(() => ({ ok: false, error: `Сервер вернул ${response.status}` })); + + if (!response.ok || !payload.ok) { + throw new Error(payload.error || "Не удалось открыть папку"); + } + + return payload; +} + +async function refreshMediaSiteSize() { + if (!el.mediaSiteSize) return; + + el.mediaSiteSize.textContent = "Сайт: считаем..."; + const response = await fetch("/api/media/site-size"); + const payload = await response.json().catch(() => ({ ok: false, error: `Сервер вернул ${response.status}` })); + + if (!response.ok || !payload.ok) { + el.mediaSiteSize.textContent = "Сайт: --"; + throw new Error(payload.error || "Не удалось посчитать размер сайта"); + } + + el.mediaSiteSize.textContent = `Сайт: ${payload.label}`; + el.mediaSiteSize.title = `${Number(payload.bytes || 0).toLocaleString("ru-RU")} байт`; +} + +async function postMediaAction(endpoint, payload = {}) { + const response = await fetch(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + const result = await response.json().catch(() => ({ ok: false, error: `Сервер вернул ${response.status}` })); + + if (!response.ok || !result.ok) { + throw new Error(result.error || "Операция не выполнена"); + } + + return result; +} + +function mediaDragHasEntry(event) { + return Array.from(event.dataTransfer?.types || []).includes(MEDIA_DRAG_MIME); +} + +function mediaDragPayload(event) { + const rawPayload = event.dataTransfer?.getData(MEDIA_DRAG_MIME); + if (!rawPayload) return null; + + try { + return JSON.parse(rawPayload); + } catch { + return null; + } +} + +function syncMediaFieldValueFromServer(context, value) { + if (!context || !value) return; + + const { block, editableField, hiddenInput, urlInput, fileName, preview, hint } = context; + hiddenInput.value = value; + urlInput.value = value; + fileName.textContent = truncateText(fileNameFromUrl(value), 28); + fileName.title = fileNameFromUrl(value); + setByPath(block, editableField.path, value); + renderMediaPreview(preview, value, hint, editableField); + + if (activeBlock() === block) { + el.json.value = JSON.stringify(block, null, 2); + } + + context.setSource("file"); +} + +function applyMediaReferenceReplacements(replacements = []) { + const context = state.mediaLibrary.context; + if (!Array.isArray(replacements) || !replacements.length) return false; + let changed = false; + const contextPath = context?.hiddenInput ? pathFromUrl(context.hiddenInput.value || "") : ""; + + const nextPage = replaceMediaReferencesInValue(state.page, replacements); + if (nextPage !== state.page) { + state.page = nextPage; + changed = true; + } + + document.querySelectorAll("input.service-media-hidden-input").forEach((input) => { + const nextValue = replaceMediaReferencesInString(input.value || "", replacements); + if (nextValue === input.value) return; + + input.value = nextValue; + const field = input.closest(".service-media-field"); + const fileName = field?.querySelector(".service-media-file-name"); + const urlInput = field?.querySelector(".service-media-url-input"); + if (urlInput) urlInput.value = nextValue; + if (fileName) { + fileName.textContent = truncateText(fileNameFromUrl(nextValue), 28); + fileName.title = fileNameFromUrl(nextValue); + } + changed = true; + }); + + if (activeBlock()) { + el.json.value = JSON.stringify(activeBlock(), null, 2); + } + + if (!context?.hiddenInput) return changed; + + const match = replacements.find((replacement) => pathFromUrl(replacement.fromUrl || replacement.from || "") === contextPath); + if (!match?.toUrl) return changed; + + syncMediaFieldValueFromServer(context, match.toUrl); + return true; +} + +function canDropMediaEntryOnTarget(entry, targetPath) { + if (!entry || state.mediaLibrary.trashMode) return false; + if (!entry.path) return false; + + const normalizedTarget = String(targetPath || "").replace(/^\/+/, ""); + const sourceParent = parentMediaPath(entry.path); + + if (entry.type === "directory" && (normalizedTarget === entry.path || normalizedTarget.startsWith(`${entry.path}/`))) { + return false; + } + + return normalizedTarget !== sourceParent; +} + +function setMediaDropActive(node, active) { + if (!node) return; + if (active) { + node.dataset.dropActive = "true"; + return; + } + + delete node.dataset.dropActive; +} + +function handleMediaDragOver(event, targetPath) { + if (!mediaDragHasEntry(event)) return; + const entry = mediaDragPayload(event) || findEntryByPath(state.mediaLibrary.dragEntryPath); + + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = canDropMediaEntryOnTarget(entry, targetPath) ? "move" : "none"; + setMediaDropActive(event.currentTarget, event.dataTransfer.dropEffect === "move"); +} + +function handleMediaDragLeave(event) { + if (event.currentTarget.contains(event.relatedTarget)) return; + setMediaDropActive(event.currentTarget, false); +} + +async function moveMediaEntryToPath(entry, targetPath) { + if (!canDropMediaEntryOnTarget(entry, targetPath)) return; + + const payload = await postMediaAction("/api/media/move", { path: entry.path, targetPath }); + applyMediaReferenceReplacements(payload.replacements); + + const nextParentPath = parentMediaPath(payload.path); + await loadMediaColumns(nextParentPath, { selectedUrl: payload.url || "" }); + + const movedEntry = findEntryByPath(payload.path); + if (movedEntry) { + state.mediaLibrary.selectedPath = nextParentPath; + state.mediaLibrary.selectedEntry = movedEntry; + state.mediaLibrary.selectedUrl = movedEntry.type === "file" ? movedEntry.url || "" : ""; + renderMediaLibrary(); + } + + const updatedCount = Array.isArray(payload.updatedFiles) ? payload.updatedFiles.length : 0; + setStatus(`Перенесено: ${payload.path}${updatedCount ? `. Ссылки обновлены: ${updatedCount}` : ""}.`); +} + +function handleMediaDrop(event, targetPath) { + if (!mediaDragHasEntry(event)) return; + const entry = mediaDragPayload(event) || findEntryByPath(state.mediaLibrary.dragEntryPath); + + event.preventDefault(); + event.stopPropagation(); + setMediaDropActive(event.currentTarget, false); + if (!canDropMediaEntryOnTarget(entry, targetPath)) return; + + moveMediaEntryToPath(entry, targetPath).catch((error) => setStatus(`Ошибка переноса: ${error.message}`)); +} + +async function browseMediaTrash() { + const response = await fetch("/api/media/trash"); + const payload = await response.json().catch(() => ({ ok: false, error: `Сервер вернул ${response.status}` })); + + if (!response.ok || !payload.ok) { + throw new Error(payload.error || "Не удалось открыть удалённые"); + } + + return { + ok: true, + path: "__trash__", + breadcrumbs: [{ name: "Удалённые", path: "__trash__" }], + entries: payload.files || [], + }; +} + +function entryMatchesMediaFilters(entry) { + const query = state.mediaLibrary.query.trim().toLowerCase(); + + if (query) { + const matchesQuery = [entry.name, entry.path, entry.url, mediaUsageLabel(entry)] + .filter(Boolean) + .some((value) => String(value).toLowerCase().includes(query)); + if (!matchesQuery) return false; + } + + if (entry.type === "directory") return true; + if (state.mediaLibrary.kind === "all") return true; + return entry.kind === state.mediaLibrary.kind; +} + +function findEntryByUrl(url) { + for (const column of state.mediaLibrary.columns) { + const match = column.entries.find((entry) => entry.url === url); + if (match) return match; + } + + return null; +} + +function findEntryByPath(path) { + if (!path) return null; + + for (const column of state.mediaLibrary.columns) { + const match = column.entries.find((entry) => entry.path === path); + if (match) return match; + } + + return null; +} + +function canDeleteMediaEntry(entry) { + if (!entry || state.mediaLibrary.trashMode) return false; + if (entry.type === "directory") return entry.path?.startsWith("assets/") && !entry.path.split("/").includes(".trash"); + + const isCurrentFieldValue = state.mediaLibrary.context?.hiddenInput?.value === entry.url; + return entry.type === "file" && entry.writable && mediaUsageStatus(entry) === "unused" && !isCurrentFieldValue; +} + +function selectMediaEntry(entry, columnPath = state.mediaLibrary.selectedPath) { + if (!entry) return; + + if (entry.type === "file") { + state.mediaLibrary.selectedPath = columnPath; + state.mediaLibrary.selectedUrl = entry.url || ""; + } else { + state.mediaLibrary.selectedUrl = ""; + } + + state.mediaLibrary.selectedEntry = entry; + renderMediaLibrary(); +} + +function closeMediaContextMenu() { + el.mediaContextMenu.classList.add("hidden"); + el.mediaContextMenu.innerHTML = ""; + state.mediaLibrary.contextMenu = null; +} + +function appendMediaContextAction(label, action, { disabled = false } = {}) { + const button = document.createElement("button"); + + button.type = "button"; + button.setAttribute("role", "menuitem"); + button.textContent = label; + button.disabled = disabled; + button.addEventListener("click", () => { + closeMediaContextMenu(); + action(); + }); + el.mediaContextMenu.append(button); +} + +function openMediaContextMenu(event, { entry = null, columnPath = state.mediaLibrary.selectedPath } = {}) { + event.preventDefault(); + closeMediaContextMenu(); + state.mediaLibrary.contextMenu = { entry, columnPath }; + + if (entry) { + selectMediaEntry(entry, columnPath); + } + + if (state.mediaLibrary.trashMode) { + if (entry?.type === "file") { + appendMediaContextAction("Восстановить", () => restoreSelectedMediaFile().catch((error) => setStatus(`Ошибка восстановления: ${error.message}`))); + appendMediaContextAction("Удалить навсегда", () => deleteSelectedTrashFile().catch((error) => setStatus(`Ошибка удаления из корзины: ${error.message}`))); + } + } else if (entry?.type === "file" || entry?.type === "directory") { + appendMediaContextAction("Дублировать", () => duplicateMediaEntry(entry).catch((error) => setStatus(`Ошибка дублирования: ${error.message}`))); + appendMediaContextAction("Переименовать", () => renameMediaEntry(entry).catch((error) => setStatus(`Ошибка переименования: ${error.message}`))); + appendMediaContextAction("Удалить", () => deleteSelectedMediaEntry().catch((error) => setStatus(`Ошибка удаления: ${error.message}`)), { + disabled: !canDeleteMediaEntry(entry), + }); + } else { + appendMediaContextAction("Создать папку", () => createMediaFolderAt(columnPath).catch((error) => setStatus(`Ошибка создания папки: ${error.message}`))); + } + + if (!el.mediaContextMenu.children.length) return; + + el.mediaContextMenu.classList.remove("hidden"); + const rect = el.mediaContextMenu.getBoundingClientRect(); + const x = Math.min(event.clientX, window.innerWidth - rect.width - 12); + const y = Math.min(event.clientY, window.innerHeight - rect.height - 12); + el.mediaContextMenu.style.left = `${Math.max(12, x)}px`; + el.mediaContextMenu.style.top = `${Math.max(12, y)}px`; +} + +function scrollMediaBrowserToSelection() { + window.requestAnimationFrame(() => { + const columns = [...el.mediaList.querySelectorAll(".media-column")]; + const targetColumn = columns.at(-1); + const exactEntry = el.mediaList.querySelector('.media-entry[data-exact-active="true"]'); + const activeEntry = exactEntry || el.mediaList.querySelector('.media-entry[data-active="true"]'); + + if (targetColumn) { + const padding = 14; + const maxScrollLeft = Math.max(0, el.mediaList.scrollWidth - el.mediaList.clientWidth); + const targetRight = targetColumn.offsetLeft + targetColumn.offsetWidth + padding; + const targetLeft = targetColumn.offsetLeft - padding; + let nextScrollLeft = el.mediaList.scrollLeft; + + if (targetRight > nextScrollLeft + el.mediaList.clientWidth) { + nextScrollLeft = targetRight - el.mediaList.clientWidth; + } + + if (targetLeft < nextScrollLeft) { + nextScrollLeft = targetLeft; + } + + el.mediaList.scrollLeft = Math.min(Math.max(nextScrollLeft, 0), maxScrollLeft); + } + + if (activeEntry) { + const list = activeEntry.closest(".media-column-list"); + if (!list) return; + + const padding = 8; + const nextSlotHeight = activeEntry.offsetHeight + 4; + const targetBottom = activeEntry.offsetTop + activeEntry.offsetHeight + nextSlotHeight + padding; + const targetTop = activeEntry.offsetTop - padding; + const maxScrollTop = Math.max(0, list.scrollHeight - list.clientHeight); + let nextScrollTop = list.scrollTop; + + if (targetBottom > nextScrollTop + list.clientHeight) { + nextScrollTop = targetBottom - list.clientHeight; + } + + if (targetTop < nextScrollTop) { + nextScrollTop = targetTop; + } + + list.scrollTop = Math.min(Math.max(nextScrollTop, 0), maxScrollTop); + } + }); +} + +async function loadMediaColumns(targetPath, { selectedUrl = state.mediaLibrary.selectedUrl } = {}) { + const path = String(targetPath || "").replace(/^\/+/, ""); + const chain = mediaPathChain(path); + const columns = []; + + state.mediaLibrary.trashMode = false; + setStatus("Открываем файловую структуру..."); + + for (const columnPath of chain) { + columns.push(await browseMediaPath(columnPath)); + } + + state.mediaLibrary.columns = columns; + state.mediaLibrary.selectedPath = path; + state.mediaLibrary.loaded = true; + state.mediaLibrary.selectedUrl = selectedUrl || ""; + state.mediaLibrary.selectedEntry = selectedUrl ? findEntryByUrl(selectedUrl) : findEntryByPath(path); + rememberMediaDirectory(path); + renderMediaLibrary(); + setStatus(`Файловая структура: ${path || "корень проекта"}.`); +} + +async function loadMediaTrash({ selectedTrashPath = "" } = {}) { + setStatus("Открываем удалённые файлы..."); + const column = await browseMediaTrash(); + + state.mediaLibrary.trashMode = true; + state.mediaLibrary.columns = [column]; + state.mediaLibrary.selectedPath = "__trash__"; + state.mediaLibrary.selectedUrl = ""; + state.mediaLibrary.loaded = true; + state.mediaLibrary.selectedEntry = selectedTrashPath ? findEntryByPath(selectedTrashPath) : column.entries[0] || null; + renderMediaLibrary(); + setStatus(`Удалённые файлы: ${column.entries.length}.`); +} + +function renderMediaBreadcrumbs() { + el.mediaBreadcrumbs.innerHTML = ""; + const breadcrumbs = state.mediaLibrary.columns.at(-1)?.breadcrumbs || [{ name: "NODEDC_SITE", path: "" }]; + + breadcrumbs.forEach((crumb, index) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "media-breadcrumb-button"; + button.textContent = crumb.name; + button.dataset.active = String(index === breadcrumbs.length - 1); + button.addEventListener("click", () => { + state.mediaLibrary.root = crumb.path; + state.mediaLibrary.selectedEntry = null; + state.mediaLibrary.selectedUrl = ""; + loadMediaColumns(crumb.path).catch((error) => setStatus(`Ошибка медиатеки: ${error.message}`)); + }); + el.mediaBreadcrumbs.append(button); + }); +} + +function renderMediaEntry(entry, columnPath) { + const button = document.createElement("button"); + const icon = document.createElement("span"); + const copy = document.createElement("span"); + const name = document.createElement("span"); + const meta = document.createElement("span"); + const statusDot = document.createElement("span"); + const arrow = document.createElement("span"); + const isExactSelected = state.mediaLibrary.selectedEntry?.path === entry.path; + const isSelected = + isExactSelected || + (entry.type === "directory" && isSameOrChildPath(state.mediaLibrary.selectedPath, entry.path)); + + button.type = "button"; + button.className = "media-entry"; + button.dataset.type = entry.type; + button.dataset.path = entry.path; + button.dataset.active = String(isSelected); + button.dataset.exactActive = String(isExactSelected); + button.title = entry.url || entry.path; + button.draggable = !state.mediaLibrary.trashMode; + + icon.className = "media-entry-icon"; + if (entry.type === "file" && entry.url && entry.kind === "image") { + const image = document.createElement("img"); + icon.classList.add("is-preview"); + image.src = adminPreviewUrl(entry.url); + image.alt = ""; + image.loading = "lazy"; + image.decoding = "async"; + icon.append(image); + } else if (entry.type === "file" && entry.url && entry.kind === "video") { + const video = document.createElement("video"); + icon.classList.add("is-preview"); + video.src = adminPreviewUrl(entry.url); + video.muted = true; + video.playsInline = true; + video.preload = "metadata"; + icon.append(video); + } else { + icon.textContent = + entry.type === "directory" + ? "DIR" + : entry.kind === "model" + ? "3D" + : entry.kind === "font" + ? "Aa" + : entry.extension?.toUpperCase() || "FILE"; + } + + copy.className = "media-entry-copy"; + name.className = "media-entry-name"; + name.textContent = entry.name; + meta.className = "media-entry-meta"; + meta.textContent = + entry.type === "directory" + ? entry.path || "Корень проекта" + : [mediaUsageLabel(entry), entry.extension, formatFileSize(entry.size)].filter(Boolean).join(" · "); + copy.append(name, meta); + + statusDot.className = "media-status-dot media-entry-status"; + statusDot.dataset.status = mediaUsageStatus(entry); + statusDot.title = mediaUsageLabel(entry); + + arrow.className = "media-entry-arrow"; + arrow.textContent = entry.type === "directory" ? "›" : ""; + + button.append(icon, copy); + if (entry.type === "file") button.append(statusDot); + button.append(arrow); + + button.addEventListener("click", async () => { + if (entry.type === "directory") { + state.mediaLibrary.root = entry.path; + state.mediaLibrary.selectedEntry = entry; + state.mediaLibrary.selectedUrl = ""; + await loadMediaColumns(entry.path); + return; + } + + selectMediaEntry(entry, columnPath); + }); + button.addEventListener("contextmenu", (event) => { + event.stopPropagation(); + openMediaContextMenu(event, { entry, columnPath }); + }); + button.addEventListener("dblclick", () => { + if (entry.type === "file" && entry.selectable) chooseSelectedMediaFile(); + }); + button.addEventListener("dragstart", (event) => { + if (state.mediaLibrary.trashMode) { + event.preventDefault(); + return; + } + + const payload = JSON.stringify({ path: entry.path, type: entry.type }); + state.mediaLibrary.dragEntryPath = entry.path; + button.dataset.dragging = "true"; + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData(MEDIA_DRAG_MIME, payload); + event.dataTransfer.setData("text/plain", entry.path); + }); + button.addEventListener("dragend", () => { + state.mediaLibrary.dragEntryPath = ""; + delete button.dataset.dragging; + el.mediaList.querySelectorAll("[data-drop-active]").forEach((node) => setMediaDropActive(node, false)); + }); + + if (entry.type === "directory") { + button.addEventListener("dragover", (event) => handleMediaDragOver(event, entry.path)); + button.addEventListener("dragleave", handleMediaDragLeave); + button.addEventListener("drop", (event) => handleMediaDrop(event, entry.path)); + } + + return button; +} + +function renderMediaColumn(column) { + const columnNode = document.createElement("section"); + const head = document.createElement("div"); + const title = document.createElement("div"); + const count = document.createElement("div"); + const list = document.createElement("div"); + const entries = (column.entries || []).filter(entryMatchesMediaFilters); + + columnNode.className = "media-column"; + head.className = "media-column-head"; + title.className = "media-column-title"; + title.textContent = column.path ? mediaPathParts(column.path).at(-1) : "NODEDC_SITE"; + count.className = "media-column-count"; + count.textContent = `${entries.length}`; + head.append(title, count); + + list.className = "media-column-list"; + list.dataset.path = column.path || ""; + list.addEventListener("dragover", (event) => handleMediaDragOver(event, column.path || "")); + list.addEventListener("dragleave", handleMediaDragLeave); + list.addEventListener("drop", (event) => handleMediaDrop(event, column.path || "")); + list.addEventListener("contextmenu", (event) => { + if (event.target.closest(".media-entry")) return; + openMediaContextMenu(event, { columnPath: column.path || "" }); + }); + for (const entry of entries) { + list.append(renderMediaEntry(entry, column.path)); + } + + if (!entries.length) { + const empty = document.createElement("div"); + empty.className = "media-column-empty"; + empty.textContent = "Пусто"; + list.append(empty); + } + + columnNode.append(head, list); + return columnNode; +} + +function mediaColumnByPath(path) { + return state.mediaLibrary.columns.find((column) => column.path === path) || null; +} + +function adjacentMediaFileUrl(file) { + const selectedPath = state.mediaLibrary.selectedPath || file?.directory || parentMediaPath(pathFromUrl(file?.url || "")); + const column = mediaColumnByPath(selectedPath); + if (!column) return ""; + + const visibleFiles = column.entries.filter(entryMatchesMediaFilters).filter((entry) => entry.type === "file"); + let index = visibleFiles.findIndex((entry) => entry.path === file.path); + let adjacent = index >= 0 ? visibleFiles[index + 1] || visibleFiles[index - 1] : null; + + if (!adjacent) { + const files = column.entries.filter((entry) => entry.type === "file"); + index = files.findIndex((entry) => entry.path === file.path); + adjacent = index >= 0 ? files[index + 1] || files[index - 1] : null; + } + + return adjacent?.url || ""; +} + +function adjacentMediaEntryPath(entry) { + if (!entry?.path) return ""; + + const column = mediaColumnForEntry(entry) || mediaColumnByPath(parentMediaPath(entry.path)); + if (!column) return ""; + + const entries = visibleMediaEntries(column); + let index = entries.findIndex((candidate) => candidate.path === entry.path); + let adjacent = index >= 0 ? entries[index + 1] || entries[index - 1] : null; + + if (!adjacent) { + index = column.entries.findIndex((candidate) => candidate.path === entry.path); + adjacent = index >= 0 ? column.entries[index + 1] || column.entries[index - 1] : null; + } + + return adjacent?.path || ""; +} + +function visibleMediaEntries(column) { + return (column?.entries || []).filter(entryMatchesMediaFilters); +} + +function mediaColumnForEntry(entry) { + if (!entry) return null; + return state.mediaLibrary.columns.find((column) => column.entries.some((candidate) => candidate.path === entry.path)) || null; +} + +function activeMediaColumn() { + return ( + mediaColumnForEntry(state.mediaLibrary.selectedEntry) || + state.mediaLibrary.columns.find((column) => column.path === state.mediaLibrary.selectedPath) || + state.mediaLibrary.columns.at(-1) || + null + ); +} + +async function moveMediaSelection(delta) { + const column = activeMediaColumn(); + const entries = visibleMediaEntries(column); + if (!column || !entries.length) return; + + const currentIndex = entries.findIndex((entry) => entry.path === state.mediaLibrary.selectedEntry?.path); + const fallbackIndex = delta > 0 ? 0 : entries.length - 1; + const nextIndex = currentIndex < 0 ? fallbackIndex : Math.min(Math.max(currentIndex + delta, 0), entries.length - 1); + const nextEntry = entries[nextIndex]; + + if (nextEntry.type === "directory" && !state.mediaLibrary.trashMode) { + state.mediaLibrary.root = nextEntry.path; + state.mediaLibrary.selectedUrl = ""; + await loadMediaColumns(nextEntry.path, { selectedUrl: "" }); + + const selectedDirectory = findEntryByPath(nextEntry.path); + if (selectedDirectory) { + state.mediaLibrary.selectedPath = column.path; + state.mediaLibrary.selectedEntry = selectedDirectory; + state.mediaLibrary.selectedUrl = ""; + renderMediaLibrary(); + } + return; + } + + selectMediaEntry(nextEntry, column.path); +} + +async function openSelectedMediaDirectory() { + const entry = state.mediaLibrary.selectedEntry; + if (!entry || entry.type !== "directory" || state.mediaLibrary.trashMode) return; + + state.mediaLibrary.root = entry.path; + state.mediaLibrary.selectedEntry = entry; + state.mediaLibrary.selectedUrl = ""; + await loadMediaColumns(entry.path, { selectedUrl: "" }); + + const openedColumn = mediaColumnByPath(entry.path); + const firstEntry = visibleMediaEntries(openedColumn)[0]; + if (firstEntry) { + state.mediaLibrary.selectedPath = entry.path; + state.mediaLibrary.selectedEntry = firstEntry; + state.mediaLibrary.selectedUrl = firstEntry.type === "file" ? firstEntry.url || "" : ""; + renderMediaLibrary(); + } +} + +async function moveMediaHierarchyBack() { + if (state.mediaLibrary.trashMode) return; + const path = state.mediaLibrary.selectedPath || state.mediaLibrary.selectedEntry?.path || ""; + if (!path) return; + + const parentPath = parentMediaPath(path); + state.mediaLibrary.selectedUrl = ""; + await loadMediaColumns(parentPath, { selectedUrl: "" }); + + const closedDirectory = findEntryByPath(path); + if (closedDirectory) { + state.mediaLibrary.selectedPath = parentPath; + state.mediaLibrary.selectedEntry = closedDirectory; + state.mediaLibrary.selectedUrl = ""; + renderMediaLibrary(); + } +} + +function isTypingTarget(target) { + return Boolean(target?.closest?.("input, textarea, select, [contenteditable='true']")); +} + +function renderMediaPreviewPanel() { + const file = selectedMediaFile(); + const entry = state.mediaLibrary.selectedEntry; + const usageText = el.mediaPreviewUsage.lastElementChild; + + renderMediaThumb(el.mediaPreview, file, { large: true }); + el.mediaSelect.textContent = state.mediaLibrary.trashMode ? "Восстановить" : "Выбрать"; + el.mediaSelect.disabled = !file; + el.mediaDelete.disabled = true; + + if (!entry) { + el.mediaPreviewName.textContent = "Выберите файл или папку"; + el.mediaPreviewPath.textContent = state.mediaLibrary.selectedPath || "NODEDC_SITE"; + el.mediaPreviewMeta.textContent = "Column view проекта"; + el.mediaPreviewUsage.dataset.status = "idle"; + if (usageText) usageText.textContent = "Нет выбора"; + return; + } + + if (entry.type === "directory") { + const canDeleteDirectory = canDeleteMediaEntry(entry); + el.mediaPreview.innerHTML = "DIR"; + el.mediaPreviewName.textContent = entry.name; + el.mediaPreviewPath.textContent = entry.path || "NODEDC_SITE"; + el.mediaPreviewPath.title = entry.path || "NODEDC_SITE"; + el.mediaPreviewMeta.textContent = "Папка проекта"; + el.mediaPreviewUsage.dataset.status = "idle"; + if (usageText) usageText.textContent = "Папка"; + el.mediaDelete.disabled = !canDeleteDirectory; + el.mediaDelete.title = canDeleteDirectory ? "Перенести папку в assets/.trash" : "Удаление доступно только для папок внутри assets"; + return; + } + + if (state.mediaLibrary.trashMode) { + el.mediaSelect.disabled = !entry.restorable; + el.mediaPreviewName.textContent = entry.originalName || entry.name; + el.mediaPreviewPath.textContent = entry.originalPath || entry.path; + el.mediaPreviewPath.title = entry.originalPath || entry.path; + el.mediaPreviewMeta.textContent = [mediaFileMeta(entry), formatFileDate(entry.mtimeMs)].filter(Boolean).join(" · "); + el.mediaPreviewUsage.dataset.status = "trash"; + if (usageText) usageText.textContent = "Удалён"; + el.mediaDelete.disabled = !entry.trashPath; + el.mediaDelete.title = "Удалить выбранный файл из корзины навсегда"; + return; + } + + const canDelete = canDeleteMediaEntry(entry); + + el.mediaSelect.disabled = !entry.selectable; + el.mediaPreviewName.textContent = entry.name; + el.mediaPreviewPath.textContent = entry.url || entry.path; + el.mediaPreviewPath.title = entry.url || entry.path; + el.mediaPreviewMeta.textContent = [mediaFileMeta(entry), formatFileDate(entry.mtimeMs)].filter(Boolean).join(" · "); + el.mediaPreviewUsage.dataset.status = mediaUsageStatus(entry); + if (usageText) usageText.textContent = entry.selectable ? mediaUsageLabel(entry) : "Не публичный asset"; + el.mediaDelete.disabled = !canDelete; + el.mediaDelete.title = canDelete + ? "Перенести unused-файл в assets/.trash" + : "Удаление доступно только для unused-файлов из assets"; +} + +function renderMediaLibrary() { + el.mediaTrashToggle.dataset.active = String(state.mediaLibrary.trashMode); + el.mediaUpload.textContent = state.mediaLibrary.trashMode ? "Очистить корзину" : "+ Загрузить"; + renderMediaFilterButtons(); + renderMediaBreadcrumbs(); + el.mediaList.innerHTML = ""; + el.mediaList.classList.add("media-columns"); + + const columns = state.mediaLibrary.columns || []; + el.mediaEmpty.classList.toggle("hidden", columns.length > 0); + for (const column of columns) { + el.mediaList.append(renderMediaColumn(column)); + } + + renderMediaPreviewPanel(); + scrollMediaBrowserToSelection(); +} + +async function refreshMediaLibrary() { + await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`)); + + if (state.mediaLibrary.trashMode) { + await loadMediaTrash({ selectedTrashPath: state.mediaLibrary.selectedEntry?.trashPath || "" }); + return; + } + + await loadMediaColumns(state.mediaLibrary.selectedPath || state.mediaLibrary.root || ""); +} + +async function toggleMediaTrash() { + if (state.mediaLibrary.trashMode) { + await loadMediaColumns(state.mediaLibrary.previousPath || state.mediaLibrary.root || ""); + return; + } + + state.mediaLibrary.previousPath = state.mediaLibrary.selectedPath || state.mediaLibrary.root || ""; + state.mediaLibrary.selectedEntry = null; + state.mediaLibrary.selectedUrl = ""; + await loadMediaTrash(); +} + +async function openMediaLibrary(context) { + const currentValuePath = pathFromUrl(context.hiddenInput.value || ""); + const suggestedPath = `assets/uploads/${uploadBucketForField(context.block, context.editableField)}`; + const lastPath = state.mediaLibrary.lastPath || readLastMediaDirectory(); + const startPath = lastPath || (currentValuePath ? parentMediaPath(currentValuePath) : suggestedPath); + + state.mediaLibrary.context = context; + state.mediaLibrary.trashMode = false; + state.mediaLibrary.previousPath = ""; + state.mediaLibrary.selectedUrl = context.hiddenInput.value || ""; + state.mediaLibrary.selectedEntry = null; + state.mediaLibrary.selectedPath = startPath; + state.mediaLibrary.query = ""; + state.mediaLibrary.root = startPath; + state.mediaLibrary.kind = "all"; + el.mediaSearch.value = ""; + el.mediaModalTitle.textContent = context.editableField.label || "Выбрать файл"; + el.mediaModalSubtitle.textContent = `${context.editableField.path} -> ${suggestedPath}`; + el.mediaModal.classList.remove("hidden"); + el.mediaSearch.focus(); + refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`)); + + try { + await loadMediaColumns(startPath, { selectedUrl: context.hiddenInput.value || "" }); + } catch (error) { + setStatus(`Ошибка медиатеки: ${error.message}`); + renderMediaLibrary(); + } +} + +function closeMediaLibrary() { + closeMediaContextMenu(); + el.mediaModal.classList.add("hidden"); + delete el.mediaDropZone.dataset.dragActive; + state.mediaLibrary.context = null; + state.mediaLibrary.trashMode = false; + state.mediaLibrary.selectedUrl = ""; + state.mediaLibrary.selectedEntry = null; + recoverEditorSurface(); +} + +function chooseSelectedMediaFile() { + const file = selectedMediaFile(); + const context = state.mediaLibrary.context; + if (!file || !context) return; + + if (state.mediaLibrary.trashMode) { + restoreSelectedMediaFile().catch((error) => setStatus(`Ошибка восстановления: ${error.message}`)); + return; + } + + updateMediaFieldValue({ + ...context, + value: file.url, + }); + rememberMediaDirectory(file.directory || parentMediaPath(pathFromUrl(file.url))); + context.setSource("file"); + closeMediaLibrary(); + markDirty(`Выбран файл: ${file.url}. Нажмите «Сохранить модель», чтобы записать путь.`); +} + +async function restoreSelectedMediaFile() { + const file = selectedMediaFile(); + if (!file?.trashPath) return; + + const payload = await postMediaAction("/api/media/restore", { trashPath: file.trashPath }); + await loadMediaTrash(); + await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`)); + setStatus(`Файл восстановлен: ${payload.path}.`); +} + +async function clearMediaTrash() { + const confirmed = await requestDeleteConfirmation({ + title: "Очистить корзину?", + body: "Все файлы из удалённых будут удалены окончательно.", + }); + if (!confirmed) return; + + await postMediaAction("/api/media/trash/clear"); + await loadMediaTrash(); + await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`)); + setStatus("Корзина очищена."); +} + +async function createMediaFolderAt(path) { + if (state.mediaLibrary.trashMode) return; + + const folderName = await requestMediaActionInput({ + title: "Создать папку", + body: `Новая папка будет создана в ${path || "корне проекта"}.`, + label: "Название папки", + value: "new-folder", + confirmLabel: "Создать", + }); + + if (!folderName) return; + + const payload = await postMediaAction("/api/media/folder", { path, name: folderName }); + await loadMediaColumns(path); + state.mediaLibrary.selectedEntry = findEntryByPath(payload.path); + state.mediaLibrary.selectedUrl = ""; + renderMediaLibrary(); + setStatus(`Папка создана: ${payload.path}.`); +} + +async function renameMediaEntry(entry) { + if (!entry || state.mediaLibrary.trashMode) return; + + const nextName = await requestMediaActionInput({ + title: "Переименовать", + body: entry.path, + label: "Новое имя", + value: entry.name, + confirmLabel: "Переименовать", + }); + + if (!nextName || nextName === entry.name) return; + + const payload = await postMediaAction("/api/media/rename", { path: entry.path, name: nextName }); + applyMediaReferenceReplacements(payload.replacements); + await loadMediaColumns(parentMediaPath(payload.path), { selectedUrl: payload.url || "" }); + const renamedEntry = findEntryByPath(payload.path); + if (renamedEntry) { + state.mediaLibrary.selectedPath = parentMediaPath(payload.path); + state.mediaLibrary.selectedEntry = renamedEntry; + state.mediaLibrary.selectedUrl = renamedEntry.type === "file" ? renamedEntry.url || "" : ""; + renderMediaLibrary(); + } + const updatedCount = Array.isArray(payload.updatedFiles) ? payload.updatedFiles.length : 0; + setStatus(`Переименовано: ${payload.path}${updatedCount ? `. Ссылки обновлены: ${updatedCount}` : ""}.`); +} + +async function duplicateMediaEntry(entry) { + if (!entry || state.mediaLibrary.trashMode) return; + + const payload = await postMediaAction("/api/media/duplicate", { path: entry.path }); + await loadMediaColumns(parentMediaPath(payload.path), { selectedUrl: payload.url || "" }); + await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`)); + if (!payload.url) { + state.mediaLibrary.selectedEntry = findEntryByPath(payload.path); + state.mediaLibrary.selectedUrl = ""; + renderMediaLibrary(); + } + setStatus(`${payload.type === "directory" ? "Папка" : "Файл"} продублирован: ${payload.path}.`); +} + +async function uploadIntoMediaLibrary(file) { + const context = state.mediaLibrary.context; + if (!context || !file) return; + + await uploadMediaFile({ + ...context, + file, + afterUpload: async (payload) => { + state.mediaLibrary.selectedUrl = payload.url; + const uploadedPath = pathFromUrl(payload.url); + await loadMediaColumns(parentMediaPath(uploadedPath), { selectedUrl: payload.url }); + await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`)); + }, + }); +} + +function openMediaLibraryUploader() { + const context = state.mediaLibrary.context; + if (!context) return; + + openMediaFilePicker({ + ...context, + afterUpload: async (payload) => { + state.mediaLibrary.selectedUrl = payload.url; + const uploadedPath = pathFromUrl(payload.url); + await loadMediaColumns(parentMediaPath(uploadedPath), { selectedUrl: payload.url }); + await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`)); + }, + }); +} + +async function deleteSelectedTrashFile() { + const file = selectedMediaFile(); + if (!file?.trashPath) return; + + const nextSelectedPath = adjacentMediaEntryPath(file); + const confirmed = await requestDeleteConfirmation({ + title: "Удалить из корзины навсегда?", + body: "Этот файл будет удалён окончательно. Восстановить его через админку уже не получится.", + }); + if (!confirmed) return; + + await postMediaAction("/api/media/trash/delete", { trashPath: file.trashPath }); + await loadMediaTrash({ selectedTrashPath: nextSelectedPath }); + setStatus(`Файл удалён из корзины навсегда: ${file.originalPath || file.path}.`); +} + +async function deleteSelectedMediaEntry() { + const entry = state.mediaLibrary.selectedEntry; + if (!entry || el.mediaDelete.disabled) return; + + if (state.mediaLibrary.trashMode) { + await deleteSelectedTrashFile(); + return; + } + + const parentPath = entry.type === "file" ? state.mediaLibrary.selectedPath || parentMediaPath(pathFromUrl(entry.url)) : parentMediaPath(entry.path); + const nextSelectedPath = adjacentMediaEntryPath(entry); + const confirmed = await requestDeleteConfirmation({ + title: entry.type === "directory" ? "Удалить папку?" : "Удалить unused-файл?", + body: + entry.type === "directory" + ? "Папка будет перенесена в assets/.trash. Если внутри есть файлы, которые используются в модели или текущей верстке, сервер остановит удаление." + : "Файл будет перенесён в assets/.trash. Файлы, которые используются в модели или текущей верстке, сервер не удаляет.", + }); + if (!confirmed) return; + + const payload = await postMediaAction("/api/media/delete-entry", { path: entry.path }); + await loadMediaColumns(parentPath, { selectedUrl: "" }); + await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`)); + + const nextEntry = nextSelectedPath ? findEntryByPath(nextSelectedPath) : null; + if (nextEntry) { + state.mediaLibrary.selectedPath = parentPath; + state.mediaLibrary.selectedEntry = nextEntry; + state.mediaLibrary.selectedUrl = nextEntry.type === "file" ? nextEntry.url || "" : ""; + renderMediaLibrary(); + } + + setStatus(`${payload.type === "directory" ? "Папка" : "Файл"} перенесён в ${payload.trashPath}.`); +} + function renderMediaField({ block, editableField, grid }) { const value = getByPath(block, editableField.path) ?? ""; const container = document.createElement("div"); @@ -1455,12 +2880,8 @@ function renderMediaField({ block, editableField, grid }) { }); }); - fileButton.addEventListener("pointerdown", () => { - setFilePickerActive(true); - scheduleFilePickerRecovery(); - }); fileButton.addEventListener("click", () => { - openMediaFilePicker({ + openMediaLibrary({ block, editableField, hiddenInput, @@ -1470,7 +2891,7 @@ function renderMediaField({ block, editableField, grid }) { hint, error, setSource, - }); + }).catch((errorMessage) => setStatus(`Ошибка медиатеки: ${errorMessage.message}`)); }); setSource(source); @@ -1493,7 +2914,15 @@ function collectionItemTitle(item, index) { } function collectionItemTemplate(editableField) { - if (editableField.itemTemplate) return clone(editableField.itemTemplate); + if (editableField.itemTemplate) { + const item = clone(editableField.itemTemplate); + if (editableField.path === "content.dock.items") { + item.iconSrc = ""; + item.iconAlt = ""; + item.iconClass = ""; + } + return item; + } const item = {}; for (const itemField of editableField.itemFields ?? []) { @@ -1528,10 +2957,27 @@ function collectionInstanceKey(block, editableField) { return `${block?.id || "static-elements"}::${editableField.path}`; } +function stableCollectionPart(value) { + return String(value || "") + .normalize("NFKD") + .replace(/[^\w.-]+/g, "-") + .replace(/^-+|-+$/g, "") + .toLowerCase() + .slice(0, 80); +} + function collectionItemKey(item, index) { if (item && typeof item === "object") { if (item.id) return `id:${item.id}`; + const stableIdentity = item.targetId || item.href || ""; + const stableIdentityPart = stableCollectionPart(stableIdentity); + if (stableIdentityPart) return `item:${stableIdentityPart}`; + + const stableTitle = item.title || item.name || item.label || item.question || item.windowTitle || item.adminLabel || ""; + const stableTitlePart = stableCollectionPart(stableTitle); + if (stableTitlePart) return `item:${stableTitlePart}`; + let runtimeKey = collectionItemRuntimeKeys.get(item); if (!runtimeKey) { collectionItemRuntimeKeyCounter += 1; @@ -1545,6 +2991,16 @@ function collectionItemKey(item, index) { return `value:${index}:${String(item)}`; } +function persistCollectionOpenState() { + const collections = {}; + + for (const [key, openKeys] of state.collectionOpenState.entries()) { + collections[key] = [...openKeys]; + } + + updateAdminLayoutState({ collections }); +} + function collectionOpenKeys(block, editableField, items) { const key = collectionInstanceKey(block, editableField); let openKeys = state.collectionOpenState.get(key); @@ -1555,6 +3011,7 @@ function collectionOpenKeys(block, editableField, items) { openKeys.add(collectionItemKey(items[0], 0)); } state.collectionOpenState.set(key, openKeys); + persistCollectionOpenState(); } return openKeys; @@ -1961,6 +3418,7 @@ function renderCollectionField({ block, editableField, grid }) { const nextItem = prepareCollectionItemForInsert(items, collectionItemTemplate(editableField)); items.push(nextItem); openKeys.add(collectionItemKey(nextItem, items.length - 1)); + persistCollectionOpenState(); rerender(`Добавлен элемент в «${editableField.label || editableField.path}».`); }); head.append(title, addButton); @@ -1988,6 +3446,7 @@ function renderCollectionField({ block, editableField, grid }) { details.addEventListener("toggle", () => { if (details.open) openKeys.add(itemKey); else openKeys.delete(itemKey); + persistCollectionOpenState(); }); summary.className = "collection-summary"; summaryTitle.className = "collection-summary-title"; @@ -2033,25 +3492,29 @@ function renderCollectionField({ block, editableField, grid }) { if (index === 0) return; const [moved] = items.splice(index, 1); items.splice(index - 1, 0, moved); + persistCollectionOpenState(); rerender("Порядок элементов коллекции изменён локально."); }], ["↓", "Опустить", () => { if (index >= items.length - 1) return; const [moved] = items.splice(index, 1); items.splice(index + 1, 0, moved); + persistCollectionOpenState(); rerender("Порядок элементов коллекции изменён локально."); }], ["⧉", "Дублировать", () => { const nextItem = prepareCollectionItemForInsert(items, item); items.splice(index + 1, 0, nextItem); if (details.open) openKeys.add(collectionItemKey(nextItem, index + 1)); + persistCollectionOpenState(); rerender("Элемент коллекции продублирован локально."); }], - ["⌫", "Удалить", async () => { + [FINDER_TRASH_ICON_SVG, "Удалить", async () => { const confirmed = await requestDeleteConfirmation(); if (!confirmed) return; openKeys.delete(itemKey); items.splice(index, 1); + persistCollectionOpenState(); rerender("Элемент коллекции удалён локально."); }], ].forEach(([symbol, titleText, handler]) => { @@ -2060,7 +3523,11 @@ function renderCollectionField({ block, editableField, grid }) { button.type = "button"; button.title = titleText; button.setAttribute("aria-label", titleText); - button.textContent = symbol; + if (titleText === "Удалить") { + button.innerHTML = symbol; + } else { + button.textContent = symbol; + } button.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); @@ -2378,15 +3845,134 @@ el.deleteModalConfirm.addEventListener("click", () => closeDeleteModal(true)); el.deleteModal.addEventListener("click", (event) => { if (event.target === el.deleteModal) closeDeleteModal(false); }); -document.addEventListener("keydown", (event) => { - if (event.key !== "Escape") return; - - if (!el.deleteModal.classList.contains("hidden")) { - closeDeleteModal(false); +el.mediaActionCancel.addEventListener("click", () => closeMediaActionModal(null)); +el.mediaActionConfirm.addEventListener("click", () => closeMediaActionModal(el.mediaActionInput.value.trim())); +el.mediaActionModal.addEventListener("click", (event) => { + if (event.target === el.mediaActionModal) closeMediaActionModal(null); +}); +el.mediaModalClose.addEventListener("click", closeMediaLibrary); +el.mediaModalCancel.addEventListener("click", closeMediaLibrary); +el.mediaModal.addEventListener("click", (event) => { + if (event.target === el.mediaModal) closeMediaLibrary(); +}); +el.mediaSearch.addEventListener("input", () => { + state.mediaLibrary.query = el.mediaSearch.value; + renderMediaLibrary(); +}); +el.mediaRefresh.addEventListener("click", () => refreshMediaLibrary().catch((error) => setStatus(`Ошибка медиатеки: ${error.message}`))); +el.mediaUpload.addEventListener("click", () => { + if (state.mediaLibrary.trashMode) { + clearMediaTrash().catch((error) => setStatus(`Ошибка очистки корзины: ${error.message}`)); return; } - if (!el.templateModal.classList.contains("hidden")) { + openMediaLibraryUploader(); +}); +el.mediaSelect.addEventListener("click", chooseSelectedMediaFile); +el.mediaDelete.addEventListener("click", () => deleteSelectedMediaEntry().catch((error) => setStatus(`Ошибка удаления: ${error.message}`))); +el.mediaTrashToggle.addEventListener("click", () => toggleMediaTrash().catch((error) => setStatus(`Ошибка корзины: ${error.message}`))); +el.mediaDropZone.addEventListener("dragover", (event) => { + if (!state.mediaLibrary.context) return; + if (mediaDragHasEntry(event)) return; + event.preventDefault(); + el.mediaDropZone.dataset.dragActive = "true"; +}); +el.mediaDropZone.addEventListener("dragleave", (event) => { + if (el.mediaDropZone.contains(event.relatedTarget)) return; + delete el.mediaDropZone.dataset.dragActive; +}); +el.mediaDropZone.addEventListener("drop", (event) => { + if (!state.mediaLibrary.context) return; + if (mediaDragHasEntry(event)) return; + event.preventDefault(); + delete el.mediaDropZone.dataset.dragActive; + const file = event.dataTransfer?.files?.[0]; + uploadIntoMediaLibrary(file).catch((error) => setStatus(`Ошибка загрузки: ${error.message}`)); +}); +document.addEventListener("click", (event) => { + if (el.mediaContextMenu.classList.contains("hidden")) return; + if (el.mediaContextMenu.contains(event.target)) return; + closeMediaContextMenu(); +}); +document.addEventListener("contextmenu", (event) => { + if (el.mediaModal.classList.contains("hidden")) return; + if (el.mediaModal.contains(event.target)) return; + closeMediaContextMenu(); +}); +document.addEventListener("keydown", (event) => { + if (!el.deleteModal.classList.contains("hidden")) { + if (event.key === "Enter") { + event.preventDefault(); + closeDeleteModal(true); + } + if (event.key === "Escape") { + event.preventDefault(); + closeDeleteModal(false); + } + return; + } + + if (!el.mediaActionModal.classList.contains("hidden")) { + if (event.key === "Enter") { + event.preventDefault(); + closeMediaActionModal(el.mediaActionInput.value.trim()); + } + if (event.key === "Escape") { + event.preventDefault(); + closeMediaActionModal(null); + } + return; + } + + if (!el.mediaModal.classList.contains("hidden")) { + if (!el.mediaContextMenu.classList.contains("hidden") && event.key === "Escape") { + event.preventDefault(); + closeMediaContextMenu(); + return; + } + + if (isTypingTarget(event.target)) return; + + if (event.key === "ArrowDown") { + event.preventDefault(); + moveMediaSelection(1).catch((error) => setStatus(`Ошибка навигации: ${error.message}`)); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + moveMediaSelection(-1).catch((error) => setStatus(`Ошибка навигации: ${error.message}`)); + return; + } + + if (event.key === "ArrowRight") { + event.preventDefault(); + openSelectedMediaDirectory().catch((error) => setStatus(`Ошибка навигации: ${error.message}`)); + return; + } + + if (event.key === "ArrowLeft") { + event.preventDefault(); + moveMediaHierarchyBack().catch((error) => setStatus(`Ошибка навигации: ${error.message}`)); + return; + } + + if (event.key === "Delete" || event.key === "Backspace") { + if (state.mediaLibrary.trashMode ? selectedMediaFile()?.trashPath : canDeleteMediaEntry(state.mediaLibrary.selectedEntry)) { + event.preventDefault(); + deleteSelectedMediaEntry().catch((error) => setStatus(`Ошибка удаления: ${error.message}`)); + } + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + closeMediaLibrary(); + } + return; + } + + if (event.key === "Escape" && !el.templateModal.classList.contains("hidden")) { closeTemplateModal(); } }); diff --git a/admin/index.html b/admin/index.html index 5059f0e..7e7ffd3 100644 --- a/admin/index.html +++ b/admin/index.html @@ -145,7 +145,13 @@ @@ -205,6 +211,98 @@ + + + diff --git a/assets/custom/css/app.css b/assets/custom/css/app.css index e93dddc..5415318 100644 --- a/assets/custom/css/app.css +++ b/assets/custom/css/app.css @@ -4,3 +4,4 @@ .project-composition .project-composition-copy{position:relative;z-index:2}.project-composition .project-composition-copy-bottom{margin-top:2.5rem}.project-composition .project-composition-copy-bottom .main-h{margin-bottom:0}@media screen and (max-width:767px){.project-composition .project-composition-hgroup{flex-flow:row;gap:1.25rem}.project-composition .project-composition-hgroup.flip{flex-flow:row-reverse}.project-composition .project-composition-hgroup .main-h.h2{font-size:2.35rem}.project-composition .project-composition-hgroup .h-txt{max-width:22ch}.project-composition .project-composition-copy-bottom{margin-top:2rem}}@media screen and (max-width:479px){.project-composition .project-composition-copy{width:86%}.project-composition .project-composition-hgroup{gap:1rem}.project-composition .project-composition-hgroup .main-h.h2{font-size:2rem}.project-composition .project-composition-hgroup .h-txt{max-width:17ch;font-size:.86rem;line-height:1.28em}} .pricing-table-section .pricing-table-shell{padding-top:12svh;padding-bottom:12svh}@media screen and (max-width:767px){.pricing-table-section .pricing-table-shell{padding-top:8svh;padding-bottom:8svh}} [data-module=notification-w][data-notification-stack-ready]{height:auto;max-height:calc(100svh - 3.2rem);overflow:visible;scrollbar-width:none;overscroll-behavior:contain}[data-module=notification-w][data-notification-stack-ready]::-webkit-scrollbar{display:none}[data-module=notification-w][data-notification-stack-ready]:not(:hover):not([data-notification-expanded=true]){height:var(--notification-collapsed-height,6.6rem);max-height:var(--notification-collapsed-height,6.6rem)}[data-module=notification-w][data-notification-stack-ready]:is(:hover,[data-notification-expanded=true]){overflow-x:visible;overflow-y:auto;padding-bottom:.2rem}[data-module=notification-w][data-notification-stack-ready]>[data-module=notification]{position:relative;z-index:var(--notification-z,1);transform:translateY(var(--notification-stack-y,0));transition:transform .3s cubic-bezier(.22,1,.36,1)}[data-module=notification-w][data-notification-stack-ready]:is(:hover,[data-notification-expanded=true])>[data-module=notification]{transform:translateY(0)} +.dock-icon.ssscript{overflow:hidden;background-color:transparent;background-image:none}.dock-icon.ssscript>img{display:block;width:100%;height:100%;max-width:none;object-fit:cover;border-radius:inherit} diff --git a/assets/custom/css/out.css b/assets/custom/css/out.css index 1e8684b..b6cec2b 100644 --- a/assets/custom/css/out.css +++ b/assets/custom/css/out.css @@ -4,3 +4,4 @@ .project-composition .project-composition-copy{position:relative;z-index:2}.project-composition .project-composition-copy-bottom{margin-top:2.5rem}.project-composition .project-composition-copy-bottom .main-h{margin-bottom:0}@media screen and (max-width:767px){.project-composition .project-composition-hgroup{flex-flow:row;gap:1.25rem}.project-composition .project-composition-hgroup.flip{flex-flow:row-reverse}.project-composition .project-composition-hgroup .main-h.h2{font-size:2.35rem}.project-composition .project-composition-hgroup .h-txt{max-width:22ch}.project-composition .project-composition-copy-bottom{margin-top:2rem}}@media screen and (max-width:479px){.project-composition .project-composition-copy{width:86%}.project-composition .project-composition-hgroup{gap:1rem}.project-composition .project-composition-hgroup .main-h.h2{font-size:2rem}.project-composition .project-composition-hgroup .h-txt{max-width:17ch;font-size:.86rem;line-height:1.28em}} .pricing-table-section .pricing-table-shell{padding-top:12svh;padding-bottom:12svh}@media screen and (max-width:767px){.pricing-table-section .pricing-table-shell{padding-top:8svh;padding-bottom:8svh}} [data-module=notification-w][data-notification-stack-ready]{height:auto;max-height:calc(100svh - 3.2rem);overflow:visible;scrollbar-width:none;overscroll-behavior:contain}[data-module=notification-w][data-notification-stack-ready]::-webkit-scrollbar{display:none}[data-module=notification-w][data-notification-stack-ready]:not(:hover):not([data-notification-expanded=true]){height:var(--notification-collapsed-height,6.6rem);max-height:var(--notification-collapsed-height,6.6rem)}[data-module=notification-w][data-notification-stack-ready]:is(:hover,[data-notification-expanded=true]){overflow-x:visible;overflow-y:auto;padding-bottom:.2rem}[data-module=notification-w][data-notification-stack-ready]>[data-module=notification]{position:relative;z-index:var(--notification-z,1);transform:translateY(var(--notification-stack-y,0));transition:transform .3s cubic-bezier(.22,1,.36,1)}[data-module=notification-w][data-notification-stack-ready]:is(:hover,[data-notification-expanded=true])>[data-module=notification]{transform:translateY(0)} +.dock-icon.ssscript{overflow:hidden;background-color:transparent;background-image:none}.dock-icon.ssscript>img{display:block;width:100%;height:100%;max-width:none;object-fit:cover;border-radius:inherit} diff --git a/assets/custom/logo.png b/assets/custom/logo.png deleted file mode 100644 index b054495..0000000 Binary files a/assets/custom/logo.png and /dev/null differ diff --git a/assets/custom/logo_1.png b/assets/custom/logo_1.png deleted file mode 100644 index 3439d18..0000000 Binary files a/assets/custom/logo_1.png and /dev/null differ diff --git a/assets/media/account.mp4 b/assets/media/account.mp4 deleted file mode 100644 index 5e1490e..0000000 Binary files a/assets/media/account.mp4 and /dev/null differ diff --git a/assets/media/ai-dock-mode.mp4 b/assets/media/ai-dock-mode.mp4 deleted file mode 100644 index d8ae48a..0000000 Binary files a/assets/media/ai-dock-mode.mp4 and /dev/null differ diff --git a/assets/media/code-edits.mp4 b/assets/media/code-edits.mp4 deleted file mode 100644 index ab54234..0000000 Binary files a/assets/media/code-edits.mp4 and /dev/null differ diff --git a/assets/media/dev-server.mp4 b/assets/media/dev-server.mp4 deleted file mode 100644 index a2d0a80..0000000 Binary files a/assets/media/dev-server.mp4 and /dev/null differ diff --git a/assets/media/project-flow.mp4 b/assets/media/project-flow.mp4 deleted file mode 100644 index 1be6816..0000000 Binary files a/assets/media/project-flow.mp4 and /dev/null differ diff --git a/assets/media/publish-flow_1.mp4 b/assets/media/publish-flow_1.mp4 deleted file mode 100644 index 913d157..0000000 Binary files a/assets/media/publish-flow_1.mp4 and /dev/null differ diff --git a/assets/media/various-modes.mp4 b/assets/media/various-modes.mp4 deleted file mode 100644 index e53c34d..0000000 Binary files a/assets/media/various-modes.mp4 and /dev/null differ diff --git a/assets/media/webflow-mcp.mp4 b/assets/media/webflow-mcp.mp4 deleted file mode 100644 index d729436..0000000 Binary files a/assets/media/webflow-mcp.mp4 and /dev/null differ diff --git a/assets/uploads/images/static-elements/2025-05-14-23.46.35-mqvznn10.jpg b/assets/uploads/images/Logo/2025-05-14-23.46.35-mqvznn10.jpg similarity index 100% rename from assets/uploads/images/static-elements/2025-05-14-23.46.35-mqvznn10.jpg rename to assets/uploads/images/Logo/2025-05-14-23.46.35-mqvznn10.jpg diff --git a/assets/uploads/images/static-elements/dclogo_white_alpha-mqus5if2.png b/assets/uploads/images/Logo/dclogo_white_alpha-mqus5if2.png similarity index 100% rename from assets/uploads/images/static-elements/dclogo_white_alpha-mqus5if2.png rename to assets/uploads/images/Logo/dclogo_white_alpha-mqus5if2.png diff --git a/assets/uploads/images/footer/dclogo_white_alpha-mquumh7v.png b/assets/uploads/images/footer/dclogo_white_alpha-mquumh7v.png deleted file mode 100644 index e265457..0000000 Binary files a/assets/uploads/images/footer/dclogo_white_alpha-mquumh7v.png and /dev/null differ diff --git a/assets/uploads/images/static-elements/2025-05-14-23.46.35-mqupw9qd.jpg b/assets/uploads/images/static-elements/2025-05-14-23.46.35-mqupw9qd.jpg deleted file mode 100644 index 4c283bb..0000000 Binary files a/assets/uploads/images/static-elements/2025-05-14-23.46.35-mqupw9qd.jpg and /dev/null differ diff --git a/assets/uploads/images/static-elements/dclogo_darck-mquq1l13.png b/assets/uploads/images/static-elements/dclogo_darck-mquq1l13.png deleted file mode 100644 index ffef888..0000000 Binary files a/assets/uploads/images/static-elements/dclogo_darck-mquq1l13.png and /dev/null differ diff --git a/assets/uploads/images/static-elements/dclogo_darck-mquq2kmb.png b/assets/uploads/images/static-elements/dclogo_darck-mquq2kmb.png deleted file mode 100644 index ffef888..0000000 Binary files a/assets/uploads/images/static-elements/dclogo_darck-mquq2kmb.png and /dev/null differ diff --git a/assets/uploads/images/static-elements/dclogo_darck-mqur0ayc.png b/assets/uploads/images/static-elements/dclogo_darck-mqur0ayc.png deleted file mode 100644 index ffef888..0000000 Binary files a/assets/uploads/images/static-elements/dclogo_darck-mqur0ayc.png and /dev/null differ diff --git a/assets/uploads/images/static-elements/dclogo_darck-mqurxviw.png b/assets/uploads/images/static-elements/dclogo_darck-mqurxviw.png deleted file mode 100644 index ffef888..0000000 Binary files a/assets/uploads/images/static-elements/dclogo_darck-mqurxviw.png and /dev/null differ diff --git a/assets/uploads/images/static-elements/dclogo_darck-mqvzlug7.png b/assets/uploads/images/static-elements/dclogo_darck-mqvzlug7.png deleted file mode 100644 index ffef888..0000000 Binary files a/assets/uploads/images/static-elements/dclogo_darck-mqvzlug7.png and /dev/null differ diff --git a/assets/uploads/images/static-elements/dcnt_dark-mqupt7tb.png b/assets/uploads/images/static-elements/dcnt_dark-mqupt7tb.png deleted file mode 100644 index f6624aa..0000000 Binary files a/assets/uploads/images/static-elements/dcnt_dark-mqupt7tb.png and /dev/null differ diff --git a/assets/uploads/images/static-elements/dcnt_dark-mqupuge5.png b/assets/uploads/images/static-elements/dcnt_dark-mqupuge5.png deleted file mode 100644 index f6624aa..0000000 Binary files a/assets/uploads/images/static-elements/dcnt_dark-mqupuge5.png and /dev/null differ diff --git a/assets/uploads/images/static-elements/dcnt_dark-mqupvf12.png b/assets/uploads/images/static-elements/dcnt_dark-mqupvf12.png deleted file mode 100644 index f6624aa..0000000 Binary files a/assets/uploads/images/static-elements/dcnt_dark-mqupvf12.png and /dev/null differ diff --git a/assets/uploads/images/static-elements/icon_bim-mqwedhun.png b/assets/uploads/images/static-elements/icon_bim-mqwedhun.png new file mode 100644 index 0000000..4e3def0 Binary files /dev/null and b/assets/uploads/images/static-elements/icon_bim-mqwedhun.png differ diff --git a/assets/uploads/images/static-elements/icon_engine-mqwd2fut.png b/assets/uploads/images/static-elements/icon_engine-mqwd2fut.png new file mode 100644 index 0000000..8561963 Binary files /dev/null and b/assets/uploads/images/static-elements/icon_engine-mqwd2fut.png differ diff --git a/assets/uploads/images/static-elements/icon_hub-mqwd0sn0.png b/assets/uploads/images/static-elements/icon_hub-mqwd0sn0.png new file mode 100644 index 0000000..31af8cf Binary files /dev/null and b/assets/uploads/images/static-elements/icon_hub-mqwd0sn0.png differ diff --git a/assets/uploads/images/static-elements/icon_ops-mqwdtmpk.png b/assets/uploads/images/static-elements/icon_ops-mqwdtmpk.png new file mode 100644 index 0000000..78906dd Binary files /dev/null and b/assets/uploads/images/static-elements/icon_ops-mqwdtmpk.png differ diff --git a/assets/uploads/images/waitlist-files/dclogo_darck-mqvf3vwm.png b/assets/uploads/images/waitlist-files/dclogo_darck-mqvf3vwm.png deleted file mode 100644 index ffef888..0000000 Binary files a/assets/uploads/images/waitlist-files/dclogo_darck-mqvf3vwm.png and /dev/null differ diff --git a/assets/uploads/media/ai-modes/ndc_ops_blur-mqv6m5i3.mp4 b/assets/uploads/media/ai-modes/ndc_ops_blur-mqv6m5i3.mp4 deleted file mode 100644 index c43b78e..0000000 Binary files a/assets/uploads/media/ai-modes/ndc_ops_blur-mqv6m5i3.mp4 and /dev/null differ diff --git a/assets/uploads/media/ai-modes/ndc_ops_blur-mqv6qi8l.mp4 b/assets/uploads/media/ai-modes/ndc_ops_blur-mqv6qi8l.mp4 deleted file mode 100644 index c43b78e..0000000 Binary files a/assets/uploads/media/ai-modes/ndc_ops_blur-mqv6qi8l.mp4 and /dev/null differ diff --git a/assets/uploads/media/component-library-copy/engine_coding_agent-mqv94pol.mov b/assets/uploads/media/component-library-copy/engine_coding_agent-mqv94pol.mov deleted file mode 100644 index e1a4356..0000000 Binary files a/assets/uploads/media/component-library-copy/engine_coding_agent-mqv94pol.mov and /dev/null differ diff --git a/assets/uploads/media/component-library-copy/engine_coding_workflow-mqv94k3x.mov b/assets/uploads/media/component-library-copy/engine_coding_workflow-mqv94k3x.mov deleted file mode 100644 index b268196..0000000 Binary files a/assets/uploads/media/component-library-copy/engine_coding_workflow-mqv94k3x.mov and /dev/null differ diff --git a/assets/uploads/media/component-library-copy/engine_monitor-mqv94vo6.mov b/assets/uploads/media/component-library-copy/engine_monitor-mqv94vo6.mov deleted file mode 100644 index 67eae80..0000000 Binary files a/assets/uploads/media/component-library-copy/engine_monitor-mqv94vo6.mov and /dev/null differ diff --git a/assets/uploads/media/feature-video-copy-copy/ndc_ops_blur-mqv99k3r.mp4 b/assets/uploads/media/feature-video-copy-copy/ndc_ops_blur-mqv99k3r.mp4 deleted file mode 100644 index c43b78e..0000000 Binary files a/assets/uploads/media/feature-video-copy-copy/ndc_ops_blur-mqv99k3r.mp4 and /dev/null differ diff --git a/assets/uploads/media/feature-video-copy/ndc_engine_base-mqv82irv.mp4 b/assets/uploads/media/feature-video-copy/ndc_engine_base-mqv82irv.mp4 deleted file mode 100644 index ea1b068..0000000 Binary files a/assets/uploads/media/feature-video-copy/ndc_engine_base-mqv82irv.mp4 and /dev/null differ diff --git a/assets/uploads/media/feature-video/ndc_hub_base-mqv741eh.mp4 b/assets/uploads/media/feature-video/ndc_hub_base-mqv741eh.mp4 deleted file mode 100644 index ca9cb95..0000000 Binary files a/assets/uploads/media/feature-video/ndc_hub_base-mqv741eh.mp4 and /dev/null differ diff --git a/assets/webflow/images/69d2db06ed4081cf9bdacdc4_federic0.avif b/assets/webflow/images/69d2db06ed4081cf9bdacdc4_federic0.avif deleted file mode 100644 index 9b0eea9..0000000 Binary files a/assets/webflow/images/69d2db06ed4081cf9bdacdc4_federic0.avif and /dev/null differ diff --git a/content/pages/home.json b/content/pages/home.json index 0e77486..0f17250 100644 --- a/content/pages/home.json +++ b/content/pages/home.json @@ -83,16 +83,52 @@ "items": [ { "enabled": true, - "title": "NODE.DC", - "href": "https://id.nodedc.ru/", + "title": "HUB", + "href": "https://hub.nodedc.ru/", "linkTarget": "", "targetId": "app-data", - "iconSrc": "./assets/uploads/images/static-elements/dclogo_white_alpha-mqus5if2.png", + "iconSrc": "./assets/uploads/images/static-elements/icon_hub-mqwd0sn0.png", "iconAlt": "Иконка NODE.DC", "className": "dock-icon ssscript", "iconClass": "logo", "dividerBefore": false }, + { + "enabled": true, + "title": "ENGINE", + "href": "https://engine.nodedc.ru/", + "linkTarget": "", + "targetId": "", + "iconSrc": "./assets/uploads/images/static-elements/icon_engine-mqwd2fut.png", + "iconAlt": "Иконка приложения", + "className": "dock-icon ssscript", + "iconClass": "logo", + "dividerBefore": false + }, + { + "enabled": true, + "title": "OPS", + "href": "https://ops.nodedc.ru/", + "linkTarget": "", + "targetId": "", + "iconSrc": "./assets/uploads/images/static-elements/icon_ops-mqwdtmpk.png", + "iconAlt": "Иконка приложения", + "className": "dock-icon ssscript", + "iconClass": "logo", + "dividerBefore": false + }, + { + "enabled": true, + "title": "BIM", + "href": "https://bim.nodedc.tech/", + "linkTarget": "", + "targetId": "", + "iconSrc": "./assets/uploads/images/static-elements/icon_bim-mqwedhun.png", + "iconAlt": "Иконка приложения", + "className": "dock-icon ssscript", + "iconClass": "logo", + "dividerBefore": false + }, { "enabled": true, "title": "Корзина", @@ -109,9 +145,9 @@ }, "assets": { "webglModelSrc": "./assets/custom/icon.glb", - "logoPng": "./assets/uploads/images/static-elements/2025-05-14-23.46.35-mqvznn10.jpg", + "logoPng": "./assets/uploads/images/Logo/2025-05-14-23.46.35-mqvznn10.jpg", "logoPngAlt": "Логотип NODE.DC", - "appIconSrc": "./assets/uploads/images/static-elements/dclogo_white_alpha-mqus5if2.png", + "appIconSrc": "./assets/uploads/images/Logo/dclogo_white_alpha-mqus5if2.png", "appIconAlt": "Иконка NODE.DC", "appWordmarkSrc": "./assets/webflow/images/69ad96a28b06b7552a339137_logtype.svg?v=node-dc", "appWordmarkAlt": "Логотип NODE.DC" @@ -226,10 +262,10 @@ "href": "javascript:void(0)", "linkTarget": "", "targetId": "", - "iconSrc": "./assets/webflow/images/69ad964d951f9d17cc5a919e_logo-app.svg", - "iconAlt": "Иконка приложения", + "iconSrc": "", + "iconAlt": "", "className": "dock-icon ssscript", - "iconClass": "logo", + "iconClass": "", "dividerBefore": false }, "itemFields": [ @@ -406,7 +442,7 @@ "legalHtml": "Политика конфиденциальностиПолитика cookie
© — Все права защищены
2026", "accessCard": { "enabled": true, - "iconSrc": "./assets/uploads/images/hero-waitlist/dclogo_white_alpha-mquue8ar.png", + "iconSrc": "./assets/uploads/images/Logo/dclogo_white_alpha-mqus5if2.png", "iconAlt": "Иконка NODE.DC", "wordmarkSrc": "./assets/webflow/images/69ad96a28b06b7552a339137_logtype.svg?v=node-dc", "wordmarkAlt": "Логотип NODE.DC", @@ -784,7 +820,7 @@ "anchor": "features", "content": { "windowTitle": "NDC_HUB.mp4", - "videoSrc": "./assets/uploads/media/feature-video/ndc_hub_base-mqv741eh.mp4", + "videoSrc": "./assets/uploads/media/ai-modes/ndc_hub_base-mqv6lm07.mp4", "heading": { "muted": "HUB", "main": "точка входа в платформу " @@ -1066,7 +1102,7 @@ "anchor": "features", "content": { "windowTitle": "ENGINE_HUB.mp4", - "videoSrc": "./assets/uploads/media/feature-video-copy/ndc_engine_base-mqv82irv.mp4", + "videoSrc": "./assets/uploads/media/ai-modes/ndc_engine_base-mqv6lvyr.mp4", "heading": { "muted": "ENGINE", "main": "Слой разработки" @@ -1350,7 +1386,7 @@ "anchor": "features", "content": { "windowTitle": "OPS_HUB.mp4", - "videoSrc": "./assets/uploads/media/feature-video-copy-copy/ndc_ops_blur-mqv99k3r.mp4", + "videoSrc": "./assets/uploads/media/ai-modes/ndc_ops_blur-mqv6ss6k.mp4", "heading": { "muted": "OPS", "main": "Операционный слой" @@ -3193,7 +3229,7 @@ "content": { "target": "demo-video", "to": "features", - "iconSrc": "./assets/webflow/images/69aab66f030013f2c7177d78_mov-file.avif", + "iconSrc": "./assets/uploads/media/ai-modes/ndc_hub_base-mqv6lm07.mp4", "iconAlt": "Иконка MOV-файла.", "label": "демо-видео.txt" }, @@ -3245,7 +3281,7 @@ "enabled": true, "content": { "footer": { - "appIconSrc": "./assets/uploads/images/footer/dclogo_white_alpha-mquumh7v.png", + "appIconSrc": "./assets/uploads/images/Logo/dclogo_white_alpha-mqus5if2.png", "appIconAlt": "Иконка NODE.DC", "wordmarkSrc": "./assets/webflow/images/69ad96a28b06b7552a339137_logtype.svg?v=node-dc", "wordmarkAlt": "Логотип NODE.DC", diff --git a/index.html b/index.html index 5601b0a..fb7e5a2 100644 --- a/index.html +++ b/index.html @@ -67,7 +67,7 @@ - + +
Логотип NODE.DC
Selfhost платформа

Запросить DEMO

NODE.DC - платформа для задач, процессов, данных, агентов и инженерных модулей.

Заявка принята. Спасибо.

Мы скоро свяжемся.

Подать заявку?

Или оформить ранний Founder-доступ

Оформить Founder-доступ 350€
До 200 мест

Не получилось

Что-то пошло не так.
Попробуйте ещё раз чуть позже.

Иконка TXT-файла
Hub Access

Единая точка входа: каталог приложений, рабочие пространства, роли, группы и права доступа.

Иконка TXT-файла
Engine Workflows

Нодовые workflow, обработчики данных, интеграционная логика и сборка прикладных контуров.

Иконка TXT-файла
Ops Cards

Операционные карточки, статусы, согласования, отчеты агентов и результаты выполнения задач.

Иконка TXT-файла
1C Assistant

Связка с 1C, разбор документов, поиск данных, сценарии согласований и передача результата в Ops.

Иконка TXT-файла
Tender Agent

Тендерные подборки, проверка требований, документы, дедлайны и агентные отчеты по процедурам.

Иконка TXT-файла
Digital Twin

Цифровые модели объектов, версии, проектный контекст, инженерные данные и связанные рабочие процессы.

Иконка TXT-файла
Model Viewer

Просмотр BIM, STEP, STL, 3DGS, облаков точек и инженерных материалов внутри единого контура.

Иконка TXT-файла
Engineering Core

Обработка технических форматов, связка моделей с задачами, версиями, отчетами и согласованиями.

Иконка TXT-файла
MCP Gateway

Контекстный шлюз для агентов: проектные данные, инструменты, кодовая база и внешние сервисы.

Иконка TXT-файла
API Contracts

Контракты API, схемы данных, webhooks, события, интеграции и контроль совместимости между модулями.

Иконка TXT-файла
Kubernetes

Оркестрация сервисов, изоляция окружений, scaling policies, probes, secrets и управляемый rollout.

Иконка TXT-файла
Cloudflare Edge

CDN, WAF, R2-хранилище, edge routing, cache policies и защита публичного периметра.

Иконка TXT-файла
Zero Trust

Доступ по принципу least privilege: SSO, MFA, device posture, audit trail и сегментация контуров.

Иконка TXT-файла
Security Matrix

Ролевая матрица, группы, политики доступа, ограничения функций и контроль административных действий.

Иконка TXT-файла
RBAC Policies

Права на приложения, рабочие области, действия агентов, документы, отчеты и инженерные данные.

Иконка TXT-файла
Audit Log

Журнал действий пользователей, агентов и сервисов: изменения, статусы, запросы, публикации и доступы.

Иконка TXT-файла
Secrets Vault

Хранение токенов, ключей API, webhook-секретов и конфигураций интеграций в защищенном контуре.

Иконка TXT-файла
Postgres Cluster

Основная транзакционная база для проектов, ролей, задач, статусов, событий и конфигураций платформы.

Иконка TXT-файла
Redis Queue

Очереди фоновых задач, статусы выполнения, debounce событий, кэширование и быстрые агентные операции.

Иконка TXT-файла
Vector Index

Индексация проектных материалов, документации, отчетов и контекста для ассистента и поиска.

Иконка TXT-файла
Local Models

Контур локальных моделей для приватных сценариев, инженерных данных и корпоративных ограничений.

Иконка TXT-файла
Model Routing

Маршрутизация задач между облачными и локальными моделями по типу запроса, правам и стоимости.

Иконка TXT-файла
Agent Runtime

Запуск ассистентов, выполнение действий, контроль инструментов, журналирование и передача результата в Ops.

Иконка TXT-файла
Observability

Метрики, логи, трассировка, health checks, алерты и диагностика сервисов платформы.

Иконка TXT-файла
Backups

Резервные копии данных, конфигураций, файлов, моделей, журналов и сценариев восстановления.

Иконка TXT-файла
CI CD

Сборка, проверки, preview-окружения, controlled rollout, rollback и публикация клиентских модулей.

Иконка TXT-файла
Terraform

Описание инфраструктуры кодом: окружения, сети, хранилища, политики, сервисы и переменные.

Иконка TXT-файла
Object Storage

Хранилище файлов, изображений, моделей, отчетов, txt-материалов и артефактов рабочих процессов.

Иконка TXT-файла
BIM Pipeline

Загрузка BIM-моделей, версии, разметка, связи с задачами и публикация в рабочие пространства.

Иконка TXT-файла
STEP Import

Импорт STEP-моделей, технические материалы, карточки проверки и привязка к инженерным задачам.

Иконка TXT-файла
STL Review

Проверка STL-геометрии, заметки, версии, визуальный контроль и подготовка производственных материалов.

Иконка TXT-файла
3DGS Assets

Gaussian Splatting сцены, визуальные реконструкции объектов и связь с проектным контекстом.

Иконка TXT-файла
LAS LAZ Clouds

Облака точек, съемка, координатные данные, версии и инженерная привязка к задачам.

Иконка TXT-файла
Webhooks

Событийные сценарии: входящие запросы, уведомления, автоматические действия и передача статусов.

Иконка TXT-файла
Docs Portal

Документация, регламенты, инструкции, технические заметки и материалы для команд внедрения.

Иконка TXT-файла
Stack Map

Карта платформенного стека: Hub, Engine, Ops, агенты, данные, интеграции, безопасность и инфраструктура.

Иконка TXT-файла
Hub Access
.txt
Иконка TXT-файла
Engine Workflows
.txt
Иконка TXT-файла
Ops Cards
.txt
Иконка TXT-файла
1C Assistant
.txt
Иконка TXT-файла
Tender Agent
.txt
Иконка TXT-файла
Digital Twin
.txt
Иконка TXT-файла
Model Viewer
.txt
Иконка TXT-файла
Engineering Core
.txt
Иконка TXT-файла
MCP Gateway
.txt
Иконка TXT-файла
API Contracts
.txt
Иконка TXT-файла
Kubernetes
.txt
Иконка TXT-файла
Cloudflare Edge
.txt
Иконка TXT-файла
Zero Trust
.txt
Иконка TXT-файла
Security Matrix
.txt
Иконка TXT-файла
RBAC Policies
.txt
Иконка TXT-файла
Audit Log
.txt
Иконка TXT-файла
Secrets Vault
.txt
Иконка TXT-файла
Postgres Cluster
.txt
Иконка TXT-файла
Redis Queue
.txt
Иконка TXT-файла
Vector Index
.txt
Иконка TXT-файла
Local Models
.txt
Иконка TXT-файла
Model Routing
.txt
Иконка TXT-файла
Agent Runtime
.txt
Иконка TXT-файла
Observability
.txt
Иконка TXT-файла
Backups
.txt
Иконка TXT-файла
CI CD
.txt
Иконка TXT-файла
Terraform
.txt
Иконка TXT-файла
Object Storage
.txt
Иконка TXT-файла
BIM Pipeline
.txt
Иконка TXT-файла
STEP Import
.txt
Иконка TXT-файла
STL Review
.txt
Иконка TXT-файла
3DGS Assets
.txt
Иконка TXT-файла
LAS LAZ Clouds
.txt
Иконка TXT-файла
Webhooks
.txt
Иконка TXT-файла
Docs Portal
.txt
Иконка TXT-файла
Stack Map
.txt
РАНЕЕ — МАЙ 2026
Engineering Core выделен в отдельный модуль
Единый контур инженерных моделей, версий, согласований и рабочих материалов.
РАНЕЕ ИЮНЬ 2026
Knowledge Graph добавлен в состав экосистемы
Связи между задачами, агентами, кодовой базой, документами и инженерными сущностями собраны в общем графе.
СЕЙЧАС
Открыт alpha-доступ к Procurement Intelligence
Агентный контур для закупок, сопоставления условий и контроля поставок.
ДАЛЕЕ СЕНТЯБРЬ 2026
Field Ops включён в план релиза
Мобильный операционный модуль для выездных команд, инспекций, фотофиксации и полевых отчётов.
Белая иконка корзины.
Сб, 10 июня
09:41
diff --git a/tools/admin-server.mjs b/tools/admin-server.mjs index c6e9486..a0decf3 100644 --- a/tools/admin-server.mjs +++ b/tools/admin-server.mjs @@ -1,7 +1,7 @@ import { createServer } from "node:http"; import { createReadStream } from "node:fs"; -import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; -import { extname, join, normalize } from "node:path"; +import { copyFile, cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; +import { extname, join, normalize, relative } from "node:path"; import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; import { dirname } from "node:path"; @@ -11,7 +11,88 @@ const port = Number(process.env.PORT || 8090); const host = process.env.HOST || "127.0.0.1"; const pagePath = join(repoRoot, "content", "pages", "home.json"); const blockTemplatesPath = join(repoRoot, "content", "block-templates", "home.json"); +const assetRoot = join(repoRoot, "assets"); +const assetTrashRoot = join(assetRoot, ".trash"); const uploadRoot = join(repoRoot, "assets", "uploads"); +const legacyUploadTrashRoot = join(uploadRoot, ".trash"); +const trashStores = [ + { + id: "assets", + rootPath: assetTrashRoot, + restoreBasePath: assetRoot, + urlPrefix: "./assets/.trash", + displayPrefix: "assets/.trash", + }, + { + id: "uploads-legacy", + rootPath: legacyUploadTrashRoot, + restoreBasePath: uploadRoot, + urlPrefix: "./assets/uploads/.trash", + displayPrefix: "assets/uploads/.trash", + }, +]; +const mediaRoots = [ + { + id: "uploads", + label: "Uploads", + rootPath: uploadRoot, + urlPrefix: "./assets/uploads", + writable: true, + }, + { + id: "media", + label: "Legacy media", + rootPath: join(repoRoot, "assets", "media"), + urlPrefix: "./assets/media", + writable: true, + }, + { + id: "webflow-images", + label: "Webflow images", + rootPath: join(repoRoot, "assets", "webflow", "images"), + urlPrefix: "./assets/webflow/images", + writable: true, + }, + { + id: "custom-assets", + label: "Custom assets", + rootPath: join(repoRoot, "assets", "custom"), + urlPrefix: "./assets/custom", + writable: true, + }, +]; +const MEDIA_EXTENSIONS = new Set([ + ".avif", + ".gif", + ".glb", + ".gltf", + ".jpeg", + ".jpg", + ".m4v", + ".mov", + ".mp4", + ".png", + ".svg", + ".webm", + ".webp", + ".woff", + ".woff2", +]); +const BROWSER_EXCLUDED_DIRS = new Set([".git", ".trash", "node_modules"]); +const REFERENCE_TEXT_EXTENSIONS = new Set([ + ".css", + ".html", + ".js", + ".json", + ".jsx", + ".md", + ".mjs", + ".svg", + ".ts", + ".tsx", + ".txt", + ".xml", +]); const MIME = { ".html": "text/html; charset=utf-8", @@ -220,6 +301,970 @@ async function saveUploadedAsset(payload) { }); } +function normalizeAssetRef(value) { + let url = String(value || "").trim(); + if (!url) return null; + + url = url.split("#")[0].split("?")[0].replace(/^\/+/, ""); + if (url.startsWith("./")) url = url.slice(2); + + try { + url = decodeURIComponent(url); + } catch { + // Keep the raw path when it contains a malformed escape sequence. + } + + if (!url.startsWith("assets/")) return null; + const normalized = normalize(url).replace(/\\/g, "/"); + if (!normalized.startsWith("assets/") || normalized.includes("../")) return null; + return normalized; +} + +function collectAssetRefsFromString(value, refs) { + for (const part of String(value || "").split(",")) { + const token = part.trim().split(/\s+/)[0]; + const ref = normalizeAssetRef(token); + if (ref) refs.add(ref); + } +} + +function collectAssetRefsFromValue(value, refs = new Set()) { + if (Array.isArray(value)) { + value.forEach((item) => collectAssetRefsFromValue(item, refs)); + return refs; + } + + if (value && typeof value === "object") { + Object.values(value).forEach((item) => collectAssetRefsFromValue(item, refs)); + return refs; + } + + if (typeof value === "string") { + collectAssetRefsFromString(value, refs); + } + + return refs; +} + +function collectAssetRefsFromText(text) { + const refs = new Set(); + const matches = String(text || "").matchAll(/(?:\.\/)?assets\/[^\s"'<>),]+/g); + + for (const match of matches) { + collectAssetRefsFromString(match[0], refs); + } + + return refs; +} + +async function buildMediaUsage() { + const [pageSource, indexSource] = await Promise.all([ + readFile(pagePath, "utf8").catch(() => "{}"), + readFile(join(repoRoot, "index.html"), "utf8").catch(() => ""), + ]); + const pageRefs = collectAssetRefsFromValue(JSON.parse(pageSource)); + const renderedRefs = collectAssetRefsFromText(indexSource); + + return { pageRefs, renderedRefs }; +} + +function assetReferenceReplacementPairs(fromRef, toRef) { + const encodedFrom = encodeUrlPath(fromRef); + const encodedTo = encodeUrlPath(toRef); + const rawPairs = [ + [`./${encodedFrom}`, `./${encodedTo}`], + [`./${fromRef}`, `./${toRef}`], + [`/${encodedFrom}`, `/${encodedTo}`], + [`/${fromRef}`, `/${toRef}`], + [encodedFrom, encodedTo], + [fromRef, toRef], + ]; + const seen = new Set(); + + return rawPairs + .filter(([from]) => { + if (seen.has(from)) return false; + seen.add(from); + return true; + }) + .sort((left, right) => right[0].length - left[0].length); +} + +function replaceAssetReferencesInString(value, replacements) { + let nextValue = value; + + for (const replacement of replacements) { + for (const [fromVariant, toVariant] of assetReferenceReplacementPairs(replacement.from, replacement.to)) { + nextValue = nextValue.split(fromVariant).join(toVariant); + } + } + + return nextValue; +} + +function shouldScanReferenceFile(relativePath) { + const segments = pathSegments(relativePath); + if (segments.some((segment) => BROWSER_EXCLUDED_DIRS.has(segment))) return false; + return REFERENCE_TEXT_EXTENSIONS.has(extname(relativePath).toLowerCase()); +} + +async function applyAssetReferenceReplacements(replacements) { + const normalizedReplacements = replacements + .map(({ from, to }) => ({ from: normalizeAssetRef(from), to: normalizeAssetRef(to) })) + .filter((replacement) => replacement.from && replacement.to && replacement.from !== replacement.to); + + if (!normalizedReplacements.length) return []; + + const updatedFiles = []; + + async function walk(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + + for (const entry of entries) { + if (BROWSER_EXCLUDED_DIRS.has(entry.name)) continue; + + const absolutePath = join(directory, entry.name); + const relativePath = relative(repoRoot, absolutePath).replace(/\\/g, "/"); + + if (entry.isDirectory()) { + await walk(absolutePath); + continue; + } + + if (!entry.isFile() || !shouldScanReferenceFile(relativePath)) continue; + + const source = await readFile(absolutePath, "utf8").catch(() => null); + if (source === null) continue; + + const nextSource = replaceAssetReferencesInString(source, normalizedReplacements); + if (nextSource === source) continue; + + await writeFile(absolutePath, nextSource); + updatedFiles.push(relativePath); + } + } + + await walk(repoRoot); + return updatedFiles.sort((left, right) => left.localeCompare(right, "ru", { numeric: true, sensitivity: "base" })); +} + +async function assetReferenceReplacementsForMove(relativePath, nextRelativePath, absolutePath, entryStat) { + const sourceRef = normalizeAssetRef(relativePath); + const targetRef = normalizeAssetRef(nextRelativePath); + + if (!sourceRef) return []; + if (!targetRef) { + throw new Error("Медиа из assets можно переносить только внутри assets, чтобы публичные ссылки оставались рабочими"); + } + + if (entryStat.isFile()) { + return [{ from: sourceRef, to: targetRef }]; + } + + if (!entryStat.isDirectory()) return []; + + const replacements = []; + + async function walk(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + + for (const entry of entries) { + if (BROWSER_EXCLUDED_DIRS.has(entry.name)) continue; + + const entryAbsolutePath = join(directory, entry.name); + if (entry.isDirectory()) { + await walk(entryAbsolutePath); + continue; + } + + if (!entry.isFile()) continue; + + const entryRelativePath = relative(repoRoot, entryAbsolutePath).replace(/\\/g, "/"); + const entryRef = normalizeAssetRef(entryRelativePath); + if (!entryRef) continue; + + const relativeSuffix = entryRelativePath.slice(relativePath.length).replace(/^\/+/, ""); + const nextEntryRef = normalizeAssetRef(relativeSuffix ? `${nextRelativePath}/${relativeSuffix}` : nextRelativePath); + if (nextEntryRef) replacements.push({ from: entryRef, to: nextEntryRef }); + } + } + + await walk(absolutePath); + return replacements; +} + +function serializedAssetReplacements(replacements) { + return replacements.map(({ from, to }) => ({ + from, + to, + fromUrl: `./${encodeUrlPath(from)}`, + toUrl: `./${encodeUrlPath(to)}`, + })); +} + +function mediaKindFromExtension(extension) { + if ([".avif", ".gif", ".jpeg", ".jpg", ".png", ".svg", ".webp"].includes(extension)) return "image"; + if ([".m4v", ".mov", ".mp4", ".webm"].includes(extension)) return "video"; + if ([".glb", ".gltf"].includes(extension)) return "model"; + if ([".woff", ".woff2"].includes(extension)) return "font"; + return "other"; +} + +function encodeUrlPath(value) { + return value + .split("/") + .map((part) => encodeURIComponent(part)) + .join("/") + .replaceAll("%2F", "/"); +} + +function buildMediaUrl(root, relativePath) { + return `${root.urlPrefix}/${encodeUrlPath(relativePath)}`; +} + +async function scanMediaRoot(root) { + const files = []; + + async function walk(directory) { + let entries = []; + + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + const filePath = join(directory, entry.name); + const relativePath = relative(root.rootPath, filePath).replace(/\\/g, "/"); + + if (!relativePath || relativePath.split("/").includes(".trash")) continue; + + if (entry.isDirectory()) { + await walk(filePath); + continue; + } + + if (!entry.isFile()) continue; + + const extension = extname(entry.name).toLowerCase(); + if (!MEDIA_EXTENSIONS.has(extension)) continue; + + const fileStat = await stat(filePath); + const url = buildMediaUrl(root, relativePath); + const normalizedRef = normalizeAssetRef(url); + + files.push({ + id: `${root.id}:${relativePath}`, + rootId: root.id, + rootLabel: root.label, + writable: root.writable, + name: entry.name, + directory: dirname(relativePath) === "." ? "" : dirname(relativePath).replace(/\\/g, "/"), + relativePath, + url, + normalizedRef, + extension: extension.slice(1), + kind: mediaKindFromExtension(extension), + size: fileStat.size, + mtimeMs: fileStat.mtimeMs, + }); + } + } + + await walk(root.rootPath); + return files; +} + +async function listMediaAssets() { + const [usage, rootFiles] = await Promise.all([buildMediaUsage(), Promise.all(mediaRoots.map(scanMediaRoot))]); + const files = rootFiles + .flat() + .map((file) => { + const rendered = usage.renderedRefs.has(file.normalizedRef); + const referenced = usage.pageRefs.has(file.normalizedRef); + const status = rendered ? "rendered" : referenced ? "referenced" : "unused"; + + return { + ...file, + usage: { + status, + referenced, + rendered, + label: status === "rendered" ? "В верстке" : status === "referenced" ? "В модели" : "Не используется", + }, + }; + }) + .sort((left, right) => right.mtimeMs - left.mtimeMs); + + return { + ok: true, + roots: mediaRoots.map(({ id, label, writable, urlPrefix }) => ({ id, label, writable, urlPrefix })), + files, + }; +} + +function safeBrowserRelativePath(value) { + let rawValue = String(value || "").trim().replace(/^\/+/, ""); + + try { + rawValue = decodeURIComponent(rawValue); + } catch { + // Keep the raw value when the browser sends a malformed escape sequence. + } + + rawValue = rawValue.replace(/\\/g, "/"); + const normalized = normalize(rawValue).replace(/\\/g, "/"); + + if (!normalized || normalized === ".") return ""; + if (normalized.startsWith("../") || normalized === ".." || normalized.includes("/../")) { + throw new Error("Некорректный путь"); + } + + return normalized; +} + +function resolveBrowserDirectory(value) { + const relativePath = safeBrowserRelativePath(value); + const absolutePath = join(repoRoot, relativePath); + const pathFromRoot = relative(repoRoot, absolutePath); + + if (pathFromRoot.startsWith("..") || pathFromRoot === "..") { + throw new Error("Путь вне проекта запрещён"); + } + + return { relativePath, absolutePath }; +} + +function pathSegments(value) { + return String(value || "") + .split("/") + .filter(Boolean); +} + +function ensureNoManagedDirectory(relativePath) { + const blockedSegment = pathSegments(relativePath).find((segment) => BROWSER_EXCLUDED_DIRS.has(segment)); + if (blockedSegment) { + throw new Error(`Операции внутри ${blockedSegment} запрещены`); + } +} + +function formatProjectSize(bytes) { + const value = Number(bytes); + if (!Number.isFinite(value) || value <= 0) return "0 МБ"; + + const gib = 1024 ** 3; + const mib = 1024 ** 2; + + if (value < gib) { + const mb = value / mib; + return `${mb >= 10 ? mb.toFixed(0) : mb.toFixed(1)} МБ`; + } + + const gb = value / gib; + return `${gb >= 10 ? gb.toFixed(0) : gb.toFixed(1)} ГБ`; +} + +async function calculateProjectSize() { + async function walk(directory) { + let total = 0; + const entries = await readdir(directory, { withFileTypes: true }); + + for (const entry of entries) { + if (BROWSER_EXCLUDED_DIRS.has(entry.name)) continue; + + const absolutePath = join(directory, entry.name); + if (entry.isSymbolicLink()) continue; + + if (entry.isDirectory()) { + total += await walk(absolutePath); + continue; + } + + if (!entry.isFile()) continue; + total += (await stat(absolutePath)).size; + } + + return total; + } + + const bytes = await walk(repoRoot); + return { + ok: true, + bytes, + label: formatProjectSize(bytes), + }; +} + +function resolveBrowserEntry(value) { + const relativePath = safeBrowserRelativePath(value); + if (!relativePath) { + throw new Error("Некорректный путь"); + } + + ensureNoManagedDirectory(relativePath); + + const absolutePath = join(repoRoot, relativePath); + const pathFromRoot = relative(repoRoot, absolutePath); + + if (pathFromRoot.startsWith("..") || pathFromRoot === "..") { + throw new Error("Путь вне проекта запрещён"); + } + + return { relativePath, absolutePath }; +} + +function safeEntryName(value) { + const name = String(value || "").trim(); + + if (!name || name === "." || name === "..") { + throw new Error("Введите корректное имя"); + } + + if (/[\\/]/.test(name) || BROWSER_EXCLUDED_DIRS.has(name)) { + throw new Error("Имя содержит запрещённые символы"); + } + + return name; +} + +async function ensurePathAvailable(absolutePath) { + try { + await stat(absolutePath); + } catch (error) { + if (error.code === "ENOENT") return; + throw error; + } + + throw new Error("Файл или папка с таким именем уже существует"); +} + +async function availableSiblingPath(directory, fileName) { + const extension = extname(fileName); + const baseName = fileName.slice(0, extension ? -extension.length : undefined); + let candidate = join(directory, fileName); + let index = 2; + + while (true) { + try { + await stat(candidate); + } catch (error) { + if (error.code === "ENOENT") return candidate; + throw error; + } + + candidate = join(directory, `${baseName}-${index}${extension}`); + index += 1; + } +} + +async function assertAssetFileCanMove(relativePath) { + const normalizedRef = normalizeAssetRef(relativePath); + if (!normalizedRef) return; + + const usage = await buildMediaUsage(); + if (usage.pageRefs.has(normalizedRef) || usage.renderedRefs.has(normalizedRef)) { + throw new Error("Файл используется в модели или текущей верстке"); + } +} + +function browserBreadcrumbs(relativePath) { + const breadcrumbs = [{ name: "NODEDC_SITE", path: "" }]; + const parts = relativePath.split("/").filter(Boolean); + let current = ""; + + for (const part of parts) { + current = current ? `${current}/${part}` : part; + breadcrumbs.push({ name: part, path: current }); + } + + return breadcrumbs; +} + +function publicUrlForBrowserPath(relativePath) { + if (!relativePath.startsWith("assets/")) return ""; + return `./${encodeUrlPath(relativePath)}`; +} + +async function browseProjectDirectory(pathValue) { + const { relativePath, absolutePath } = resolveBrowserDirectory(pathValue); + const usage = await buildMediaUsage(); + let entries = []; + + try { + const currentStat = await stat(absolutePath); + if (!currentStat.isDirectory()) { + throw new Error("Путь не является директорией"); + } + + const rawEntries = await readdir(absolutePath, { withFileTypes: true }); + + entries = await Promise.all( + rawEntries + .filter((entry) => !BROWSER_EXCLUDED_DIRS.has(entry.name)) + .map(async (entry) => { + const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name; + const entryAbsolutePath = join(absolutePath, entry.name); + const entryStat = await stat(entryAbsolutePath); + const isDirectory = entry.isDirectory(); + const extension = isDirectory ? "" : extname(entry.name).toLowerCase(); + const kind = isDirectory ? "folder" : mediaKindFromExtension(extension); + const url = isDirectory ? "" : publicUrlForBrowserPath(entryRelativePath); + const normalizedRef = url ? normalizeAssetRef(url) : null; + const rendered = normalizedRef ? usage.renderedRefs.has(normalizedRef) : false; + const referenced = normalizedRef ? usage.pageRefs.has(normalizedRef) : false; + const status = rendered ? "rendered" : referenced ? "referenced" : normalizedRef ? "unused" : "local"; + + return { + id: entryRelativePath || entry.name, + name: entry.name, + path: entryRelativePath, + directory: relativePath, + type: isDirectory ? "directory" : "file", + kind, + extension: extension ? extension.slice(1) : "", + size: isDirectory ? 0 : entryStat.size, + mtimeMs: entryStat.mtimeMs, + url, + selectable: Boolean(url), + writable: Boolean(normalizedRef && !normalizedRef.split("/").includes(".trash")), + usage: { + status, + referenced, + rendered, + label: + status === "rendered" + ? "В верстке" + : status === "referenced" + ? "В модели" + : status === "unused" + ? "Не используется" + : "Локальный файл", + }, + }; + }), + ); + } catch (error) { + if (error.code !== "ENOENT") throw error; + entries = []; + } + + entries.sort((left, right) => { + if (left.type !== right.type) return left.type === "directory" ? -1 : 1; + return left.name.localeCompare(right.name, "ru", { numeric: true, sensitivity: "base" }); + }); + + return { + ok: true, + path: relativePath, + breadcrumbs: browserBreadcrumbs(relativePath), + entries, + }; +} + +function resolveDeletableAssetFile(url) { + const normalizedRef = normalizeAssetRef(url); + if (!normalizedRef || !normalizedRef.startsWith("assets/") || normalizedRef.split("/").includes(".trash")) { + throw new Error("Удалять можно только файлы из assets"); + } + + const filePath = join(repoRoot, normalizedRef); + const pathFromAssets = relative(assetRoot, filePath); + if (pathFromAssets.startsWith("..") || pathFromAssets === "..") { + throw new Error("Некорректный путь файла"); + } + + return { normalizedRef, filePath }; +} + +function resolveDeletableAssetEntry(pathValue) { + const relativePath = safeBrowserRelativePath(pathValue); + if (!relativePath || relativePath === "assets" || !relativePath.startsWith("assets/") || pathSegments(relativePath).includes(".trash")) { + throw new Error("Удалять можно только файлы и папки внутри assets"); + } + + ensureNoManagedDirectory(relativePath); + + const absolutePath = join(repoRoot, relativePath); + const pathFromAssets = relative(assetRoot, absolutePath); + if (pathFromAssets.startsWith("..") || pathFromAssets === "..") { + throw new Error("Некорректный путь файла"); + } + + return { relativePath, absolutePath }; +} + +async function assertDirectoryCanDelete(directoryPath) { + const usage = await buildMediaUsage(); + + async function walk(currentPath) { + const entries = await readdir(currentPath, { withFileTypes: true }); + + for (const entry of entries) { + if (BROWSER_EXCLUDED_DIRS.has(entry.name)) continue; + + const entryPath = join(currentPath, entry.name); + if (entry.isDirectory()) { + await walk(entryPath); + continue; + } + + if (!entry.isFile()) continue; + + const entryRelativePath = relative(repoRoot, entryPath).replace(/\\/g, "/"); + const normalizedRef = normalizeAssetRef(entryRelativePath); + if (normalizedRef && (usage.pageRefs.has(normalizedRef) || usage.renderedRefs.has(normalizedRef))) { + throw new Error("В папке есть файлы, которые используются в модели или текущей верстке"); + } + } + } + + await walk(directoryPath); +} + +async function softDeleteMediaAsset(url) { + const { normalizedRef, filePath } = resolveDeletableAssetFile(url); + const usage = await buildMediaUsage(); + + if (usage.pageRefs.has(normalizedRef) || usage.renderedRefs.has(normalizedRef)) { + throw new Error("Файл используется в модели или текущей верстке"); + } + + const fileStat = await stat(filePath); + if (!fileStat.isFile()) { + throw new Error("Удалять можно только файлы"); + } + + const assetRelative = relative(assetRoot, filePath).replace(/\\/g, "/"); + const parsedExtension = extname(assetRelative); + const basePath = assetRelative.slice(0, parsedExtension ? -parsedExtension.length : undefined); + const trashPath = join(assetTrashRoot, `${basePath}-${Date.now().toString(36)}${parsedExtension}`); + + await mkdir(dirname(trashPath), { recursive: true }); + await rename(filePath, trashPath); + + return { + ok: true, + deleted: `./assets/${assetRelative}`, + trashPath: `assets/.trash/${relative(assetTrashRoot, trashPath).replace(/\\/g, "/")}`, + }; +} + +async function softDeleteProjectEntry(pathValue) { + const { relativePath, absolutePath } = resolveDeletableAssetEntry(pathValue); + const entryStat = await stat(absolutePath); + + if (entryStat.isFile()) { + await assertAssetFileCanMove(relativePath); + } else if (entryStat.isDirectory()) { + await assertDirectoryCanDelete(absolutePath); + } else { + throw new Error("Удалять можно только файл или папку"); + } + + const assetRelative = relative(assetRoot, absolutePath).replace(/\\/g, "/"); + const parsedExtension = entryStat.isFile() ? extname(assetRelative) : ""; + const basePath = assetRelative.slice(0, parsedExtension ? -parsedExtension.length : undefined); + const trashPath = join(assetTrashRoot, `${basePath}-${Date.now().toString(36)}${parsedExtension}`); + + await mkdir(dirname(trashPath), { recursive: true }); + await rename(absolutePath, trashPath); + + return { + ok: true, + deleted: relativePath, + type: entryStat.isDirectory() ? "directory" : "file", + trashPath: `assets/.trash/${relative(assetTrashRoot, trashPath).replace(/\\/g, "/")}`, + }; +} + +function originalNameFromTrashName(fileName) { + const extension = extname(fileName); + const baseName = fileName.slice(0, extension ? -extension.length : undefined); + return `${baseName.replace(/-[a-z0-9]{6,}$/i, "")}${extension}`; +} + +function trashOriginalRelativePath(store, trashRelativePath) { + const directory = dirname(trashRelativePath); + const restoredName = originalNameFromTrashName(trashRelativePath.split("/").pop() || "asset"); + const restoreRelative = directory === "." ? restoredName : `${directory}/${restoredName}`; + return relative(repoRoot, join(store.restoreBasePath, restoreRelative)).replace(/\\/g, "/"); +} + +async function scanTrashStore(store) { + const files = []; + + async function walk(directory) { + let entries = []; + + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + const absolutePath = join(directory, entry.name); + const trashRelativePath = relative(store.rootPath, absolutePath).replace(/\\/g, "/"); + + if (entry.isDirectory()) { + await walk(absolutePath); + continue; + } + + if (!entry.isFile()) continue; + + const extension = extname(entry.name).toLowerCase(); + const fileStat = await stat(absolutePath); + const url = `${store.urlPrefix}/${encodeUrlPath(trashRelativePath)}`; + const originalPath = trashOriginalRelativePath(store, trashRelativePath); + + files.push({ + id: `${store.id}:${trashRelativePath}`, + storeId: store.id, + name: entry.name, + originalName: originalNameFromTrashName(entry.name), + path: `${store.displayPrefix}/${trashRelativePath}`, + trashPath: `${store.displayPrefix}/${trashRelativePath}`, + originalPath, + directory: dirname(trashRelativePath) === "." ? "" : dirname(trashRelativePath).replace(/\\/g, "/"), + type: "file", + kind: mediaKindFromExtension(extension), + extension: extension ? extension.slice(1) : "", + size: fileStat.size, + mtimeMs: fileStat.mtimeMs, + url, + selectable: true, + writable: true, + restorable: true, + usage: { + status: "trash", + referenced: false, + rendered: false, + label: "Удалён", + }, + }); + } + } + + await walk(store.rootPath); + return files; +} + +async function listTrashAssets() { + const files = (await Promise.all(trashStores.map(scanTrashStore))) + .flat() + .sort((left, right) => right.mtimeMs - left.mtimeMs); + + return { + ok: true, + files, + }; +} + +function resolveTrashFile(trashPathValue) { + const relativePath = safeBrowserRelativePath(trashPathValue); + const store = trashStores.find( + (candidate) => relativePath === candidate.displayPrefix || relativePath.startsWith(`${candidate.displayPrefix}/`), + ); + + if (!store) { + throw new Error("Файл не найден в корзине"); + } + + const trashRelativePath = relativePath.slice(store.displayPrefix.length).replace(/^\/+/, ""); + if (!trashRelativePath || pathSegments(trashRelativePath).includes(".trash")) { + throw new Error("Некорректный путь корзины"); + } + + const absolutePath = join(store.rootPath, trashRelativePath); + const pathFromStore = relative(store.rootPath, absolutePath); + if (pathFromStore.startsWith("..") || pathFromStore === "..") { + throw new Error("Некорректный путь корзины"); + } + + return { store, trashRelativePath, absolutePath }; +} + +async function restoreTrashAsset(trashPathValue) { + const { store, trashRelativePath, absolutePath } = resolveTrashFile(trashPathValue); + const fileStat = await stat(absolutePath); + if (!fileStat.isFile()) { + throw new Error("Восстановить можно только файл"); + } + + const originalRelativePath = trashOriginalRelativePath(store, trashRelativePath); + const targetPath = await availableSiblingPath( + dirname(join(repoRoot, originalRelativePath)), + originalRelativePath.split("/").pop() || "asset", + ); + + await mkdir(dirname(targetPath), { recursive: true }); + await rename(absolutePath, targetPath); + + const restoredPath = relative(repoRoot, targetPath).replace(/\\/g, "/"); + return { + ok: true, + path: restoredPath, + url: publicUrlForBrowserPath(restoredPath), + }; +} + +async function pruneEmptyTrashParents(startPath, rootPath) { + let currentPath = dirname(startPath); + + while (currentPath !== rootPath && currentPath.startsWith(rootPath)) { + try { + const entries = await readdir(currentPath); + if (entries.length) return; + await rm(currentPath, { recursive: false, force: true }); + } catch { + return; + } + + currentPath = dirname(currentPath); + } +} + +async function deleteTrashAsset(trashPathValue) { + const { store, absolutePath } = resolveTrashFile(trashPathValue); + const fileStat = await stat(absolutePath); + if (!fileStat.isFile()) { + throw new Error("Окончательно удалить можно только файл"); + } + + await rm(absolutePath, { force: true }); + await pruneEmptyTrashParents(absolutePath, store.rootPath); + return { ok: true }; +} + +async function clearTrashAssets() { + await Promise.all(trashStores.map((store) => rm(store.rootPath, { recursive: true, force: true }))); + return { ok: true }; +} + +async function createProjectFolder(payload) { + const { relativePath, absolutePath } = resolveBrowserDirectory(payload?.path || ""); + ensureNoManagedDirectory(relativePath); + + const folderName = safeEntryName(payload?.name); + const targetPath = join(absolutePath, folderName); + const targetRelativePath = relativePath ? `${relativePath}/${folderName}` : folderName; + + ensureNoManagedDirectory(targetRelativePath); + await ensurePathAvailable(targetPath); + await mkdir(targetPath); + + return { + ok: true, + path: targetRelativePath, + }; +} + +async function renameProjectEntry(payload) { + const { relativePath, absolutePath } = resolveBrowserEntry(payload?.path || ""); + const entryStat = await stat(absolutePath); + const nextName = safeEntryName(payload?.name); + const parentPath = dirname(relativePath) === "." ? "" : dirname(relativePath).replace(/\\/g, "/"); + const nextRelativePath = parentPath ? `${parentPath}/${nextName}` : nextName; + const nextAbsolutePath = join(repoRoot, nextRelativePath); + + ensureNoManagedDirectory(nextRelativePath); + await ensurePathAvailable(nextAbsolutePath); + + const replacements = await assetReferenceReplacementsForMove(relativePath, nextRelativePath, absolutePath, entryStat); + await rename(absolutePath, nextAbsolutePath); + const updatedFiles = await applyAssetReferenceReplacements(replacements); + + return { + ok: true, + path: nextRelativePath, + type: entryStat.isDirectory() ? "directory" : "file", + url: publicUrlForBrowserPath(nextRelativePath), + replacements: serializedAssetReplacements(replacements), + updatedFiles, + }; +} + +async function moveProjectEntry(payload) { + const { relativePath, absolutePath } = resolveBrowserEntry(payload?.path || ""); + const { relativePath: targetRelativePath, absolutePath: targetAbsolutePath } = resolveBrowserDirectory(payload?.targetPath || ""); + const entryStat = await stat(absolutePath); + const targetStat = await stat(targetAbsolutePath); + + if (!entryStat.isFile() && !entryStat.isDirectory()) { + throw new Error("Переносить можно только файл или папку"); + } + + if (!targetStat.isDirectory()) { + throw new Error("Целевая директория не найдена"); + } + + ensureNoManagedDirectory(targetRelativePath); + + if (entryStat.isDirectory() && (targetRelativePath === relativePath || targetRelativePath.startsWith(`${relativePath}/`))) { + throw new Error("Нельзя перенести папку внутрь самой себя"); + } + + const entryName = relativePath.split("/").pop() || ""; + const nextRelativePath = targetRelativePath ? `${targetRelativePath}/${entryName}` : entryName; + const nextAbsolutePath = join(repoRoot, nextRelativePath); + + if (nextRelativePath === relativePath) { + return { + ok: true, + path: relativePath, + type: entryStat.isDirectory() ? "directory" : "file", + url: publicUrlForBrowserPath(relativePath), + replacements: [], + updatedFiles: [], + }; + } + + ensureNoManagedDirectory(nextRelativePath); + await ensurePathAvailable(nextAbsolutePath); + + const replacements = await assetReferenceReplacementsForMove(relativePath, nextRelativePath, absolutePath, entryStat); + await rename(absolutePath, nextAbsolutePath); + const updatedFiles = await applyAssetReferenceReplacements(replacements); + + return { + ok: true, + path: nextRelativePath, + type: entryStat.isDirectory() ? "directory" : "file", + url: publicUrlForBrowserPath(nextRelativePath), + replacements: serializedAssetReplacements(replacements), + updatedFiles, + }; +} + +async function duplicateProjectFile(payload) { + const { relativePath, absolutePath } = resolveBrowserEntry(payload?.path || ""); + const entryStat = await stat(absolutePath); + if (!entryStat.isFile() && !entryStat.isDirectory()) { + throw new Error("Дублировать можно только файл или папку"); + } + + const extension = entryStat.isFile() ? extname(relativePath) : ""; + const directory = dirname(absolutePath); + const baseName = relativePath.split("/").pop()?.slice(0, extension ? -extension.length : undefined) || "asset"; + const targetPath = await availableSiblingPath(directory, `${baseName}-copy${extension}`); + + if (entryStat.isDirectory()) { + await cp(absolutePath, targetPath, { recursive: true, errorOnExist: true }); + } else { + await copyFile(absolutePath, targetPath); + } + + const duplicatedPath = relative(repoRoot, targetPath).replace(/\\/g, "/"); + return { + ok: true, + path: duplicatedPath, + type: entryStat.isDirectory() ? "directory" : "file", + url: publicUrlForBrowserPath(duplicatedPath), + }; +} + function isMultipartRequest(req) { return String(req.headers["content-type"] || "").toLowerCase().startsWith("multipart/form-data"); } @@ -404,6 +1449,26 @@ const server = createServer(async (req, res) => { return; } + if (req.method === "GET" && url.pathname === "/api/media/assets") { + sendJson(res, 200, await listMediaAssets()); + return; + } + + if (req.method === "GET" && url.pathname === "/api/media/browse") { + sendJson(res, 200, await browseProjectDirectory(url.searchParams.get("path") || "")); + return; + } + + if (req.method === "GET" && url.pathname === "/api/media/site-size") { + sendJson(res, 200, await calculateProjectSize()); + return; + } + + if (req.method === "GET" && url.pathname === "/api/media/trash") { + sendJson(res, 200, await listTrashAssets()); + return; + } + if (req.method === "PUT" && url.pathname === "/api/page/home") { const body = await readBody(req); const parsed = JSON.parse(body); @@ -426,6 +1491,59 @@ const server = createServer(async (req, res) => { return; } + if (req.method === "POST" && url.pathname === "/api/media/delete") { + const payload = JSON.parse(await readBody(req)); + sendJson(res, 200, await softDeleteMediaAsset(payload.url)); + return; + } + + if (req.method === "POST" && url.pathname === "/api/media/delete-entry") { + const payload = JSON.parse(await readBody(req)); + sendJson(res, 200, await softDeleteProjectEntry(payload.path)); + return; + } + + if (req.method === "POST" && url.pathname === "/api/media/restore") { + const payload = JSON.parse(await readBody(req)); + sendJson(res, 200, await restoreTrashAsset(payload.trashPath)); + return; + } + + if (req.method === "POST" && url.pathname === "/api/media/trash/delete") { + const payload = JSON.parse(await readBody(req)); + sendJson(res, 200, await deleteTrashAsset(payload.trashPath)); + return; + } + + if (req.method === "POST" && url.pathname === "/api/media/trash/clear") { + sendJson(res, 200, await clearTrashAssets()); + return; + } + + if (req.method === "POST" && url.pathname === "/api/media/folder") { + const payload = JSON.parse(await readBody(req)); + sendJson(res, 200, await createProjectFolder(payload)); + return; + } + + if (req.method === "POST" && url.pathname === "/api/media/rename") { + const payload = JSON.parse(await readBody(req)); + sendJson(res, 200, await renameProjectEntry(payload)); + return; + } + + if (req.method === "POST" && url.pathname === "/api/media/move") { + const payload = JSON.parse(await readBody(req)); + sendJson(res, 200, await moveProjectEntry(payload)); + return; + } + + if (req.method === "POST" && url.pathname === "/api/media/duplicate") { + const payload = JSON.parse(await readBody(req)); + sendJson(res, 200, await duplicateProjectFile(payload)); + return; + } + if (req.method === "POST" && url.pathname === "/api/extract/home") { const result = await runNodeScript("extract-home-blocks.mjs"); sendJson(res, 200, { ok: true, ...result });