Выбрать файл
+Существующие файлы на сервере и загрузки для текущего поля.
+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 @@ +
+ +