Selaa lähdekoodia

restart projeckt

Gogs 3 viikkoa sitten
vanhempi
sitoutus
b536e23c85

+ 40 - 0
app/components/Admin/CategoryNode/index.coffee

@@ -0,0 +1,40 @@
+# Добавление стилей компонента
+if globalThis.stylFns and globalThis.stylFns['app/components/Admin/CategoryNode/index.styl']
+  styleElement = document.createElement('style')
+  styleElement.type = 'text/css'
+  styleElement.textContent = globalThis.stylFns['app/components/Admin/CategoryNode/index.styl']
+  document.head.appendChild(styleElement)
+else
+  log '⚠️ Стили CategoryNode не найдены'
+
+module.exports = {
+  name: 'category-node'
+  props:
+    category:
+      type: Object
+      required: true
+    level:
+      type: Number
+      default: 0
+
+  data: ->
+    {
+      isExpanded: false
+      isDragging: false
+    }
+
+  computed:
+    hasChildren: ->
+      @category.children and @category.children.length > 0
+
+  methods:
+    toggleExpanded: ->
+      @isExpanded = not @isExpanded
+
+  mounted: ->
+    # Автоматически раскрываем первые два уровня
+    if @level < 2
+      @isExpanded = true
+
+  render: (new Function '_ctx', '_cache', globalThis.renderFns['app/components/Admin/CategoryNode/index.pug'])()
+}

+ 40 - 0
app/components/Admin/CategoryNode/index.pug

@@ -0,0 +1,40 @@
+div(
+  class="category-node"
+  :class="{ 'category-node--expanded': isExpanded, 'category-node--dragging': isDragging }"
+  :style="{ paddingLeft: (level * 20) + 'px' }"
+)
+  div(class="category-node__header")
+    button(
+      v-if="hasChildren"
+      @click="toggleExpanded"
+      class="category-node__toggle"
+      :title="isExpanded ? 'Свернуть' : 'Развернуть'"
+    ) {{ isExpanded ? '−' : '+' }}
+    span(v-else class="category-node__spacer") •
+    
+    span(class="category-node__name") {{ category.name }}
+    
+    span(class="category-node__badge") {{ category.children ? category.children.length : 0 }}
+    
+    div(class="category-node__actions")
+      ui-button(
+        @click="$emit('edit', category)"
+        size="small"
+        type="outline"
+      ) ✏️
+      ui-button(
+        @click="$emit('delete', category)"
+        size="small"
+        type="danger"
+      ) 🗑️
+  
+  div(v-if="isExpanded && hasChildren" class="category-node__children")
+    category-node(
+      v-for="child in category.children"
+      :key="child._id"
+      :category="child"
+      :level="level + 1"
+      @edit="$emit('edit', $event)"
+      @delete="$emit('delete', $event)"
+      @move="$emit('move', $event)"
+    )

+ 86 - 0
app/components/Admin/CategoryNode/index.styl

@@ -0,0 +1,86 @@
+.category-node
+  border: 1px solid var(--border-color)
+  border-radius: var(--border-radius)
+  margin-bottom: var(--spacing-xs)
+  transition: var(--transition-fast)
+
+.category-node__header
+  display: flex
+  align-items: center
+  gap: var(--spacing-sm)
+  padding: var(--spacing-md)
+  background: var(--color-light-10)
+  cursor: pointer
+  user-select: none
+  
+  .theme-dark &
+    background: var(--color-dark-50)
+  
+  .category-node--expanded &
+    border-bottom: 1px solid var(--border-color)
+
+.category-node__toggle
+  width: 24px
+  height: 24px
+  border: 1px solid var(--border-color)
+  border-radius: var(--border-radius-sm)
+  background: var(--color-white)
+  display: flex
+  align-items: center
+  justify-content: center
+  cursor: pointer
+  font-weight: var(--font-weight-bold)
+  
+  .theme-dark &
+    background: var(--color-dark)
+    color: var(--color-light)
+
+.category-node__spacer
+  width: 24px
+  text-align: center
+  color: var(--color-secondary)
+
+.category-node__name
+  flex: 1
+  font-weight: var(--font-weight-medium)
+
+.category-node__badge
+  background: var(--color-primary)
+  color: var(--color-white)
+  padding: 2px 8px
+  border-radius: 12px
+  font-size: var(--font-size-xs)
+  font-weight: var(--font-weight-bold)
+
+.category-node__actions
+  display: flex
+  gap: var(--spacing-xs)
+  opacity: 0
+  transition: var(--transition-fast)
+  
+  .category-node__header:hover &
+    opacity: 1
+
+.category-node__children
+  padding: var(--spacing-sm)
+  background: var(--color-white)
+  
+  .theme-dark &
+    background: var(--color-dark-20)
+
+// Анимации
+.category-node-enter-active,
+.category-node-leave-active
+  transition: all 0.3s ease
+
+.category-node-enter-from,
+.category-node-leave-to
+  opacity: 0
+  transform: translateX(-10px)
+
+@media (max-width: 768px)
+  .category-node__actions
+    opacity: 1
+  
+  .category-node__header
+    flex-wrap: wrap

+ 122 - 0
app/components/Admin/MediaUpload/index.coffee

@@ -0,0 +1,122 @@
+# Добавление стилей компонента
+if globalThis.stylFns and globalThis.stylFns['app/components/Admin/MediaUpload/index.styl']
+  styleElement = document.createElement('style')
+  styleElement.type = 'text/css'
+  styleElement.textContent = globalThis.stylFns['app/components/Admin/MediaUpload/index.styl']
+  document.head.appendChild(styleElement)
+else
+  log '⚠️ Стили MediaUpload не найдены'
+
+module.exports = 
+  name: 'media-upload'
+  props:
+    currentImage:
+      type: String
+      default: ''
+    previewAlt:
+      type: String
+      default: 'Превью изображения'
+    maxSize:
+      type: Number
+      default: 10485760 # 10MB
+
+  data: ->
+    {
+      isDragging: false
+      selectedFile: null
+      uploading: false
+      uploadProgress: 0
+    }
+
+  methods:
+    onDragOver: (event) ->
+      event.preventDefault()
+      @isDragging = true
+
+    onDragLeave: (event) ->
+      event.preventDefault()
+      @isDragging = false
+
+    onDrop: (event) ->
+      event.preventDefault()
+      @isDragging = false
+      files = Array.from(event.dataTransfer.files)
+      @handleFiles(files)
+
+    openFileDialog: ->
+      @$refs.fileInput?.click()
+
+    onFileSelect: (event) ->
+      files = Array.from(event.target.files)
+      @handleFiles(files)
+      # Сброс input для возможности выбора того же файла снова
+      event.target.value = ''
+
+    handleFiles: (files) ->
+      if files.length == 0
+        return
+
+      file = files[0]
+      
+      # Валидация типа файла
+      if not file.type.startsWith('image/')
+        @$emit('error', 'Выберите файл изображения')
+        return
+      
+      # Валидация размера
+      if file.size > @maxSize
+        @$emit('error', 'Файл слишком большой. Максимальный размер: ' + (@maxSize / 1048576) + 'MB')
+        return
+      
+      @selectedFile = file
+      
+      # Создание preview
+      if URL and URL.createObjectURL
+        previewUrl = URL.createObjectURL(file)
+        @$emit('image-selected', previewUrl)
+      
+      # Начало загрузки
+      @startUpload(file)
+
+    startUpload: (file) ->
+      @uploading = true
+      @uploadProgress = 0
+      
+      # Имитация загрузки файла
+      # В реальном приложении здесь будет загрузка на сервер
+      interval = setInterval (=>
+        @uploadProgress += 10
+        if @uploadProgress >= 100
+          clearInterval(interval)
+          @uploading = false
+          @$emit('upload-complete', {
+            file: file
+            url: URL.createObjectURL(file)
+            name: file.name
+            size: file.size
+            type: file.type
+          })
+      ), 100
+
+    removeImage: ->
+      @selectedFile = null
+      @uploadProgress = 0
+      @$emit('image-removed')
+      
+      if @currentImage and URL and URL.revokeObjectURL
+        URL.revokeObjectURL(@currentImage)
+
+    formatFileSize: (bytes) ->
+      if bytes == 0
+        return '0 Bytes'
+      k = 1024
+      sizes = ['Bytes', 'KB', 'MB', 'GB']
+      i = Math.floor(Math.log(bytes) / Math.log(k))
+      return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
+
+  beforeUnmount: ->
+    # Очистка URL объектов при уничтожении компонента
+    if @currentImage and URL and URL.revokeObjectURL
+      URL.revokeObjectURL(@currentImage)
+
+  render: (new Function '_ctx', '_cache', globalThis.renderFns['app/components/Admin/MediaUpload/index.pug'])()

+ 0 - 0
app/components/Admin/MediaUpload/index.pug


+ 219 - 0
app/components/Admin/MediaUpload/index.styl

@@ -0,0 +1,219 @@
+.media-upload
+  border: 2px dashed var(--border-color)
+  border-radius: var(--border-radius)
+  transition: var(--transition-normal)
+  overflow: hidden
+
+.media-upload__dropzone
+  position: relative
+  min-height: 200px
+  display: flex
+  align-items: center
+  justify-content: center
+  background: var(--color-light-10)
+  cursor: pointer
+  
+  .theme-dark &
+    background: var(--color-dark-50)
+  
+  &:hover
+    border-color: var(--color-primary-50)
+    background: var(--color-primary-10)
+  
+  &.media-upload__dropzone--dragging
+    border-color: var(--color-primary)
+    background: var(--color-primary-20)
+  
+  &.media-upload__dropzone--has-image
+    min-height: 300px
+    background: var(--color-white)
+    
+    .theme-dark &
+      background: var(--color-dark)
+
+.media-upload__preview
+  position: relative
+  width: 100%
+  height: 100%
+  display: flex
+  align-items: center
+  justify-content: center
+  padding: var(--spacing-md)
+
+.media-upload__image
+  max-width: 100%
+  max-height: 250px
+  object-fit: contain
+  border-radius: var(--border-radius-sm)
+
+.media-upload__preview-actions
+  position: absolute
+  top: var(--spacing-md)
+  right: var(--spacing-md)
+  display: flex
+  gap: var(--spacing-sm)
+  opacity: 0
+  transition: var(--transition-fast)
+  
+  .media-upload__preview:hover &
+    opacity: 1
+
+.media-upload__remove-btn,
+.media-upload__change-btn
+  width: 36px
+  height: 36px
+  border: none
+  border-radius: 50%
+  display: flex
+  align-items: center
+  justify-content: center
+  cursor: pointer
+  font-size: var(--font-size-lg)
+  transition: var(--transition-fast)
+
+.media-upload__remove-btn
+  background: var(--color-danger)
+  color: var(--color-white)
+  
+  &:hover
+    background: var(--color-danger-50)
+    transform: scale(1.1)
+
+.media-upload__change-btn
+  background: var(--color-primary)
+  color: var(--color-white)
+  
+  &:hover
+    background: var(--color-primary-50)
+    transform: scale(1.1)
+
+.media-upload__uploading
+  display: flex
+  flex-direction: column
+  align-items: center
+  gap: var(--spacing-md)
+  color: var(--color-secondary)
+
+.media-upload__spinner
+  width: 40px
+  height: 40px
+  border: 3px solid var(--color-primary-10)
+  border-top: 3px solid var(--color-primary)
+  border-radius: 50%
+  animation: media-upload-spin 1s linear infinite
+
+@keyframes media-upload-spin
+  0%
+    transform: rotate(0deg)
+  100%
+    transform: rotate(360deg)
+
+.media-upload__empty
+  display: flex
+  flex-direction: column
+  align-items: center
+  gap: var(--spacing-md)
+  padding: var(--spacing-xl)
+  text-align: center
+
+.media-upload__icon
+  font-size: 3rem
+  opacity: 0.7
+
+.media-upload__title
+  font-size: var(--font-size-lg)
+  font-weight: var(--font-weight-semibold)
+  color: var(--color-dark)
+  margin: 0
+  
+  .theme-dark &
+    color: var(--color-light)
+
+.media-upload__subtitle
+  color: var(--color-secondary)
+  margin: 0
+
+.media-upload__browse-btn
+  padding: var(--spacing-sm) var(--spacing-lg)
+  background: var(--color-primary)
+  color: var(--color-white)
+  border: none
+  border-radius: var(--border-radius)
+  cursor: pointer
+  font-weight: var(--font-weight-medium)
+  transition: var(--transition-fast)
+  
+  &:hover
+    background: var(--color-primary-50)
+    transform: translateY(-1px)
+
+.media-upload__hint
+  font-size: var(--font-size-sm)
+  color: var(--color-secondary)
+  margin: 0
+
+.media-upload__input
+  display: none
+
+.media-upload__file-info
+  padding: var(--spacing-md)
+  background: var(--color-light-10)
+  border-top: 1px solid var(--border-color)
+  
+  .theme-dark &
+    background: var(--color-dark-50)
+
+.media-upload__file-name
+  font-weight: var(--font-weight-medium)
+  margin-bottom: var(--spacing-xs)
+
+.media-upload__file-size
+  font-size: var(--font-size-sm)
+  color: var(--color-secondary)
+
+.media-upload__progress
+  padding: var(--spacing-md)
+  background: var(--color-light-10)
+  border-top: 1px solid var(--border-color)
+  
+  .theme-dark &
+    background: var(--color-dark-50)
+
+.media-upload__progress-bar
+  height: 6px
+  background: var(--color-light-50)
+  border-radius: 3px
+  overflow: hidden
+  margin-bottom: var(--spacing-sm)
+  
+  .theme-dark &
+    background: var(--color-dark-20)
+
+.media-upload__progress-fill
+  height: 100%
+  background: var(--color-primary)
+  transition: width 0.3s ease
+  border-radius: 3px
+
+.media-upload__progress-text
+  font-size: var(--font-size-sm)
+  color: var(--color-secondary)
+  text-align: center
+  display: block
+
+// Адаптивность
+@media (max-width: 768px)
+  .media-upload__dropzone
+    min-height: 150px
+    
+    &.media-upload__dropzone--has-image
+      min-height: 250px
+  
+  .media-upload__preview-actions
+    opacity: 1
+  
+  .media-upload__empty
+    padding: var(--spacing-lg)
+  
+  .media-upload__icon
+    font-size: 2rem

+ 183 - 9
app/pages/Admin/Categories/index.coffee

@@ -1,21 +1,195 @@
-# app/pages/Admin/Categories/index.coffee
+# Добавление стилей страницы
+if globalThis.stylFns and globalThis.stylFns['app/pages/Admin/Categories/index.styl']
+  styleElement = document.createElement('style')
+  styleElement.type = 'text/css'
+  styleElement.textContent = globalThis.stylFns['app/pages/Admin/Categories/index.styl']
+  document.head.appendChild(styleElement)
+else
+  log '⚠️ Стили страницы категорий не найдены'
 
+CategoryService = require 'app/services/CategoryService'
+CategoryNode = require 'app/components/Admin/CategoryNode/index.coffee'
+MediaUpload = require 'app/components/Admin/MediaUpload/index.coffee'
 
 module.exports = {
-  props:
-    domainSettings:
-      type: Object
-      default: -> {}
-    language:
-      type: String
-      default: 'ru'
+  components: {
+    'category-node': CategoryNode
+    'media-upload': MediaUpload
+  }
 
   data: ->
     {
-      pageTitle: 'Управление категориями'
+      loading: false
+      saving: false
+      categories: []
+      editingCategory: null
+      creatingNew: false
+      categoryForm: {
+        name: ''
+        slug: ''
+        parent: ''
+        order: 0
+        image: ''
+        description: ''
+      }
     }
 
+  computed:
+    hierarchicalCategories: ->
+      @buildHierarchy(@categories)
+    
+    flatCategories: ->
+      @flattenCategories(@hierarchicalCategories)
+
+  methods:
+    loadCategories: ->
+      @loading = true
+      try
+        @categories = await CategoryService.getAllCategories()
+        log '✅ Категории загружены: '+@categories.length
+      catch error
+        log '❌ Ошибка загрузки категорий: '+error.message
+        @$emit('show-notification', 'Ошибка загрузки категорий', 'error')
+      finally
+        @loading = false
+
+    buildHierarchy: (categories, parentId = null) ->
+      hierarchy = []
+      categories
+        .filter (cat) -> cat.parent == parentId
+        .sort (a, b) -> a.order - b.order
+        .forEach (category) =>
+          children = @buildHierarchy(categories, category._id)
+          if children.length > 0
+            category.children = children
+          hierarchy.push(category)
+      return hierarchy
+
+    flattenCategories: (hierarchical, level = 0) ->
+      flat = []
+      hierarchical.forEach (category) =>
+        flat.push({
+          ...category
+          level: level
+          name: '→ '.repeat(level) + category.name
+        })
+        if category.children
+          flat = flat.concat(@flattenCategories(category.children, level + 1))
+      return flat
+
+    createCategory: ->
+      @editingCategory = null
+      @creatingNew = true
+      @resetForm()
+
+    editCategory: (category) ->
+      @editingCategory = category
+      @creatingNew = false
+      @categoryForm = {
+        name: category.name
+        slug: category.slug
+        parent: category.parent || ''
+        order: category.order || 0
+        image: category.image || ''
+        description: category.description || ''
+      }
+
+    cancelEdit: ->
+      @editingCategory = null
+      @creatingNew = false
+      @resetForm()
+
+    resetForm: ->
+      @categoryForm = {
+        name: ''
+        slug: ''
+        parent: ''
+        order: 0
+        image: ''
+        description: ''
+      }
+
+    saveCategory: ->
+      if not @categoryForm.name.trim()
+        @$emit('show-notification', 'Введите название категории', 'error')
+        return
+
+      @saving = true
+      try
+        categoryData = {
+          ...@categoryForm
+          type: 'category'
+          domains: [window.location.hostname]
+          active: true
+        }
+
+        if @editingCategory
+          categoryData._id = @editingCategory._id
+          categoryData._rev = @editingCategory._rev
+        else
+          categoryData._id = 'category:'+Date.now()
+
+        if not categoryData.slug
+          categoryData.slug = @slugify(categoryData.name)
+
+        await CategoryService.saveCategory(categoryData)
+        
+        @$emit('show-notification', 
+          if @editingCategory then 'Категория обновлена' else 'Категория создана', 
+          'success'
+        )
+        
+        @cancelEdit()
+        @loadCategories()
+        
+      catch error
+        log '❌ Ошибка сохранения категории: '+error.message
+        @$emit('show-notification', 'Ошибка сохранения категории', 'error')
+      finally
+        @saving = false
+
+    deleteCategory: (category) ->
+      if not confirm('Удалить категорию "'+category.name+'"?')
+        return
+
+      try
+        await CategoryService.deleteCategory(category._id)
+        @$emit('show-notification', 'Категория удалена', 'success')
+        @loadCategories()
+      catch error
+        log '❌ Ошибка удаления категории: '+error.message
+        @$emit('show-notification', 'Ошибка удаления категории', 'error')
+
+    moveCategory: (categoryId, newParentId, newIndex) ->
+      log 'Перемещение категории: '+categoryId+' в '+newParentId+' на позицию '+newIndex
+      # Здесь будет логика перемещения категорий через drag&drop
+
+    onImageSelected: (imageUrl) ->
+      @categoryForm.image = imageUrl
+
+    onImageRemoved: ->
+      @categoryForm.image = ''
+
+    expandAll: ->
+      # Логика раскрытия всех узлов дерева
+      document.querySelectorAll('.category-node').forEach (node) ->
+        node.classList.add('expanded')
+
+    collapseAll: ->
+      # Логика сворачивания всех узлов дерева
+      document.querySelectorAll('.category-node').forEach (node) ->
+        node.classList.remove('expanded')
+
+    slugify: (text) ->
+      text.toString().toLowerCase()
+        .replace(/\s+/g, '-')
+        .replace(/[^\w\-]+/g, '')
+        .replace(/\-\-+/g, '-')
+        .replace(/^-+/, '')
+        .replace(/-+$/, '')
+
   mounted: ->
+    @loadCategories()
     log '📂 Страница управления категориями загружена'
 
   render: (new Function '_ctx', '_cache', globalThis.renderFns['app/pages/Admin/Categories/index.pug'])()

+ 89 - 13
app/pages/Admin/Categories/index.pug

@@ -1,13 +1,89 @@
-div(class="admin-categories")
-  h1 {{ pageTitle }}
-  p Страница управления категориями в разработке
-  div(class="page-actions")
-    ui-button(@click="$router.push('/admin/products')" type="outline") 🛍️ Управление товарамиdiv(class="admin-categories")
-  h1 {{ pageTitle }}
-  p Страница управления категориями в разработке
-  div(class="page-actions")
-    ui-button(@click="$router.push('/admin/products')" type="outline") 🛍️ Управление товарамиdiv(class="admin-categories")
-  h1 {{ pageTitle }}
-  p Страница управления категориями в разработке
-  div(class="page-actions")
-    ui-button(@click="$router.push('/admin/products')" type="outline") 🛍️ Управление товарами
+div(class="categories-page")
+  div(class="page-header")
+    h1 Управление категориями
+    div(class="page-actions")
+      ui-button(@click="createCategory" type="primary") ➕ Создать категорию
+      ui-button(@click="expandAll" type="outline") 📂 Развернуть все
+      ui-button(@click="collapseAll" type="outline") 📁 Свернуть все
+
+  div(class="categories-content")
+    div(class="categories-tree")
+      h2 Древовидная структура
+      div(v-if="loading" class="loading-state") Загрузка категорий...
+      div(v-else class="tree-container")
+        category-node(
+          v-for="category in hierarchicalCategories"
+          :key="category._id"
+          :category="category"
+          :level="0"
+          @edit="editCategory"
+          @delete="deleteCategory"
+          @move="moveCategory"
+        )
+    
+    div(class="category-editor")
+      h2 {{ editingCategory ? 'Редактирование категории' : 'Создание категории' }}
+      div(v-if="editingCategory || creatingNew" class="editor-form")
+        div(class="form-group")
+          label Название категории *
+          input(
+            type="text"
+            v-model="categoryForm.name"
+            placeholder="Введите название категории"
+            class="form-input"
+          )
+        
+        div(class="form-group")
+          label URL-адрес (slug)
+          input(
+            type="text"
+            v-model="categoryForm.slug"
+            placeholder="category-slug"
+            class="form-input"
+          )
+        
+        div(class="form-group")
+          label Родительская категория
+          select(v-model="categoryForm.parent" class="form-select")
+            option(value="") Без родительской категории
+            option(
+              v-for="cat in flatCategories"
+              :key="cat._id"
+              :value="cat._id"
+            ) {{ cat.name }}
+        
+        div(class="form-group")
+          label Порядок сортировки
+          input(
+            type="number"
+            v-model="categoryForm.order"
+            class="form-input"
+          )
+        
+        div(class="form-group")
+          label Изображение категории
+          media-upload(
+            :current-image="categoryForm.image"
+            @image-selected="onImageSelected"
+            @image-removed="onImageRemoved"
+          )
+        
+        div(class="form-group")
+          label Описание категории
+          textarea(
+            v-model="categoryForm.description"
+            placeholder="Описание категории для SEO"
+            class="form-textarea"
+            rows="4"
+          )
+        
+        div(class="form-actions")
+          ui-button(@click="cancelEdit" type="outline") Отмена
+          ui-button(
+            @click="saveCategory"
+            type="primary"
+            :loading="saving"
+          ) {{ editingCategory ? 'Сохранить' : 'Создать' }}
+      
+      div(v-else class="editor-placeholder")
+        p Выберите категорию для редактирования или создайте новую

+ 115 - 0
app/pages/Admin/Categories/index.styl

@@ -0,0 +1,115 @@
+.categories-page
+  display: grid
+  grid-template-columns: 1fr 400px
+  gap: var(--spacing-2xl)
+  max-width: 1400px
+  margin: 0 auto
+
+.page-header
+  grid-column: 1 / -1
+  display: flex
+  justify-content: space-between
+  align-items: center
+  margin-bottom: var(--spacing-2xl)
+
+.page-actions
+  display: flex
+  gap: var(--spacing-md)
+
+.categories-content
+  display: contents
+
+.categories-tree
+  background: var(--color-white)
+  padding: var(--spacing-xl)
+  border-radius: var(--border-radius)
+  box-shadow: var(--shadow-sm)
+  
+  .theme-dark &
+    background: var(--color-dark)
+
+.loading-state
+  text-align: center
+  padding: var(--spacing-2xl)
+  color: var(--color-secondary)
+
+.tree-container
+  space-y: var(--spacing-sm)
+
+.category-editor
+  background: var(--color-white)
+  padding: var(--spacing-xl)
+  border-radius: var(--border-radius)
+  box-shadow: var(--shadow-sm)
+  height: fit-content
+  position: sticky
+  top: var(--spacing-xl)
+  
+  .theme-dark &
+    background: var(--color-dark)
+
+.editor-form
+  space-y: var(--spacing-lg)
+
+.form-group
+  display: flex
+  flex-direction: column
+  gap: var(--spacing-sm)
+
+.form-group label
+  font-weight: var(--font-weight-medium)
+  color: var(--color-dark)
+  
+  .theme-dark &
+    color: var(--color-light)
+
+.form-input,
+.form-select,
+.form-textarea
+  padding: var(--spacing-md)
+  border: 1px solid var(--border-color)
+  border-radius: var(--border-radius)
+  font-family: var(--font-family)
+  font-size: var(--font-size-base)
+  
+  .theme-dark &
+    background: var(--color-dark-50)
+    color: var(--color-light)
+    border-color: var(--color-light-10)
+
+.form-textarea
+  resize: vertical
+  min-height: 100px
+
+.form-actions
+  display: flex
+  gap: var(--spacing-md)
+  justify-content: flex-end
+  margin-top: var(--spacing-xl)
+
+.editor-placeholder
+  text-align: center
+  padding: var(--spacing-2xl)
+  color: var(--color-secondary)
+
+// Адаптивность
+@media (max-width: 1024px)
+  .categories-page
+    grid-template-columns: 1fr
+    gap: var(--spacing-xl)
+  
+  .category-editor
+    position: static
+    order: -1
+
+@media (max-width: 768px)
+  .page-header
+    flex-direction: column
+    gap: var(--spacing-md)
+    align-items: stretch
+  
+  .page-actions
+    justify-content: stretch
+    
+    .btn
+      flex: 1

+ 118 - 9
app/pages/Admin/Media/index.coffee

@@ -1,21 +1,130 @@
-# app/pages/Admin/Media/index.coffee
+# Добавление стилей страницы
+if globalThis.stylFns and globalThis.stylFns['app/pages/Admin/Media/index.styl']
+  styleElement = document.createElement('style')
+  styleElement.type = 'text/css'
+  styleElement.textContent = globalThis.stylFns['app/pages/Admin/Media/index.styl']
+  document.head.appendChild(styleElement)
+else
+  log '⚠️ Стили медиа-менеджера не найдены'
 
+FileUpload = require 'app/components/Admin/FileUpload/index.coffee'
+MediaService = require 'app/services/MediaService'
 
 module.exports = {
-  props:
-    domainSettings:
-      type: Object
-      default: -> {}
-    language:
-      type: String
-      default: 'ru'
+  components: {
+    'file-upload': FileUpload
+  }
 
   data: ->
     {
-      pageTitle: 'Медиа-менеджер'
+      files: []
+      selectedFiles: []
+      uploading: false
+      uploadProgress: 0
+      filterType: ''
+      searchQuery: ''
+      loading: false
     }
 
+  computed:
+    filteredFiles: ->
+      files = @files
+      
+      if @filterType
+        files = files.filter (file) => file.type == @filterType
+      
+      if @searchQuery
+        query = @searchQuery.toLowerCase()
+        files = files.filter (file) => 
+          file.name.toLowerCase().includes(query)
+      
+      return files.sort (a, b) -> new Date(b.createdAt) - new Date(a.createdAt)
+
+  methods:
+    loadMediaFiles: ->
+      @loading = true
+      try
+        @files = await MediaService.getAllFiles()
+        log '✅ Медиа-файлы загружены: '+@files.length
+      catch error
+        log '❌ Ошибка загрузки медиа-файлов: '+error.message
+        @$emit('show-notification', 'Ошибка загрузки файлов', 'error')
+      finally
+        @loading = false
+
+    onFilesSelect: (files) ->
+      log 'Выбрано файлов для загрузки: '+files.length
+
+    uploadFiles: (files) ->
+      @uploading = true
+      @uploadProgress = 0
+      
+      try
+        for file, index in files
+          # Имитация загрузки с прогрессом
+          for i in [0..100] by 10
+            await new Promise (resolve) -> setTimeout(resolve, 50)
+            @uploadProgress = ((index * 100) + i) / files.length
+        
+        await MediaService.uploadFiles(files)
+        @$emit('show-notification', 'Файлы успешно загружены', 'success')
+        @loadMediaFiles()
+        
+      catch error
+        log '❌ Ошибка загрузки файлов: '+error.message
+        @$emit('show-notification', 'Ошибка загрузки файлов', 'error')
+      finally
+        @uploading = false
+        @uploadProgress = 0
+
+    getFileUrl: (file) ->
+      # Формирование URL для доступа к файлу в CouchDB
+      return '/d/braer_color_shop/'+file._id+'/'+file.name
+
+    toggleSelect: (fileId) ->
+      if @selectedFiles.includes(fileId)
+        @selectedFiles = @selectedFiles.filter (id) -> id != fileId
+      else
+        @selectedFiles.push(fileId)
+
+    deleteFile: (file) ->
+      if not confirm('Удалить файл "'+file.name+'"?')
+        return
+
+      try
+        await MediaService.deleteFile(file._id)
+        @$emit('show-notification', 'Файл удален', 'success')
+        @loadMediaFiles()
+      catch error
+        log '❌ Ошибка удаления файла: '+error.message
+        @$emit('show-notification', 'Ошибка удаления файла', 'error')
+
+    deleteSelected: ->
+      if not confirm('Удалить '+@selectedFiles.length+' выбранных файлов?')
+        return
+
+      try
+        await MediaService.deleteFiles(@selectedFiles)
+        @$emit('show-notification', 'Файлы удалены', 'success')
+        @selectedFiles = []
+        @loadMediaFiles()
+      catch error
+        log '❌ Ошибка удаления файлов: '+error.message
+        @$emit('show-notification', 'Ошибка удаления файлов', 'error')
+
+    formatFileSize: (bytes) ->
+      if bytes == 0
+        return '0 Bytes'
+      k = 1024
+      sizes = ['Bytes', 'KB', 'MB', 'GB']
+      i = Math.floor(Math.log(bytes) / Math.log(k))
+      return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
+
+    formatDate: (dateString) ->
+      return new Date(dateString).toLocaleDateString('ru-RU')
+
   mounted: ->
+    @loadMediaFiles()
     log '🖼️ Медиа-менеджер загружен'
 
   render: (new Function '_ctx', '_cache', globalThis.renderFns['app/pages/Admin/Media/index.pug'])()

+ 66 - 5
app/pages/Admin/Media/index.pug

@@ -1,5 +1,66 @@
-div(class="admin-media")
-  h1 {{ pageTitle }}
-  p Медиа-менеджер в разработке
-  div(class="page-actions")
-    ui-button(@click="$router.push('/admin/import')" type="outline") 📥 Импорт товаров
+div(class="media-page")
+  h1 Медиа-менеджер
+  
+  div(class="media-toolbar")
+    div(class="media-upload-area")
+      file-upload(
+        accept="image/*,.pdf,.doc,.docx"
+        :multiple="true"
+        @select="onFilesSelect"
+        @upload="uploadFiles"
+      )
+    
+    div(class="media-filters")
+      select(v-model="filterType" class="filter-select")
+        option(value="") Все типы
+        option(value="image") Изображения
+        option(value="document") Документы
+      input(
+        type="text"
+        v-model="searchQuery"
+        placeholder="Поиск по названию..."
+        class="search-input"
+      )
+
+  div(class="media-content")
+    div(v-if="uploading" class="upload-progress")
+      div(class="progress-bar")
+        div(
+          class="progress-fill"
+          :style="{ width: uploadProgress + '%' }"
+        )
+      span {{ uploadProgress }}%
+
+    div(class="media-grid")
+      div(
+        v-for="file in filteredFiles"
+        :key="file._id"
+        class="media-item"
+        :class="{ 'media-item--selected': selectedFiles.includes(file._id) }"
+        @click="toggleSelect(file._id)"
+      )
+        div(class="media-item__preview")
+          img(
+            v-if="file.type === 'image'"
+            :src="getFileUrl(file)"
+            :alt="file.name"
+            class="media-item__image"
+          )
+          div(v-else class="media-item__icon") 📄
+        
+        div(class="media-item__info")
+          div(class="media-item__name") {{ file.name }}
+          div(class="media-item__meta")
+            span {{ formatFileSize(file.size) }}
+            span {{ formatDate(file.createdAt) }}
+        
+        div(class="media-item__actions")
+          button(
+            @click.stop="deleteFile(file)"
+            class="media-item__delete"
+            title="Удалить файл"
+          ) ×
+
+  div(v-if="selectedFiles.length > 0" class="media-selection")
+    span Выбрано: {{ selectedFiles.length }}
+    ui-button(@click="deleteSelected" type="danger" size="small") Удалить выбранные

+ 175 - 0
app/pages/Admin/Media/index.styl

@@ -0,0 +1,175 @@
+.media-page
+  max-width: 1200px
+  margin: 0 auto
+
+.media-toolbar
+  display: flex
+  gap: var(--spacing-xl)
+  margin-bottom: var(--spacing-2xl)
+  align-items: flex-start
+
+.media-upload-area
+  flex: 1
+
+.media-filters
+  display: flex
+  gap: var(--spacing-md)
+  align-items: center
+
+.filter-select,
+.search-input
+  padding: var(--spacing-sm) var(--spacing-md)
+  border: 1px solid var(--border-color)
+  border-radius: var(--border-radius)
+  
+  .theme-dark &
+    background: var(--color-dark-50)
+    color: var(--color-light)
+    border-color: var(--color-light-10)
+
+.search-input
+  min-width: 200px
+
+.upload-progress
+  background: var(--color-light-10)
+  padding: var(--spacing-lg)
+  border-radius: var(--border-radius)
+  margin-bottom: var(--spacing-xl)
+  text-align: center
+  
+  .theme-dark &
+    background: var(--color-dark-50)
+
+.progress-bar
+  height: 8px
+  background: var(--color-light-50)
+  border-radius: 4px
+  overflow: hidden
+  margin-bottom: var(--spacing-sm)
+  
+  .theme-dark &
+    background: var(--color-dark-20)
+
+.progress-fill
+  height: 100%
+  background: var(--color-primary)
+  transition: width 0.3s ease
+  border-radius: 4px
+
+.media-grid
+  display: grid
+  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr))
+  gap: var(--spacing-lg)
+  margin-bottom: var(--spacing-2xl)
+
+.media-item
+  border: 2px solid transparent
+  border-radius: var(--border-radius)
+  padding: var(--spacing-md)
+  background: var(--color-white)
+  cursor: pointer
+  transition: var(--transition-fast)
+  
+  &:hover
+    border-color: var(--color-primary-20)
+    transform: translateY(-2px)
+    box-shadow: var(--shadow-md)
+  
+  .theme-dark &
+    background: var(--color-dark)
+  
+  .media-item--selected &
+    border-color: var(--color-primary)
+
+.media-item__preview
+  height: 120px
+  display: flex
+  align-items: center
+  justify-content: center
+  background: var(--color-light-10)
+  border-radius: var(--border-radius-sm)
+  margin-bottom: var(--spacing-md)
+  overflow: hidden
+  
+  .theme-dark &
+    background: var(--color-dark-50)
+
+.media-item__image
+  max-width: 100%
+  max-height: 100%
+  object-fit: cover
+
+.media-item__icon
+  font-size: 2rem
+  opacity: 0.7
+
+.media-item__info
+  text-align: center
+
+.media-item__name
+  font-weight: var(--font-weight-medium)
+  margin-bottom: var(--spacing-xs)
+  word-break: break-word
+
+.media-item__meta
+  display: flex
+  justify-content: space-between
+  font-size: var(--font-size-sm)
+  color: var(--color-secondary)
+
+.media-item__actions
+  position: absolute
+  top: var(--spacing-sm)
+  right: var(--spacing-sm)
+  opacity: 0
+  transition: var(--transition-fast)
+  
+  .media-item:hover &
+    opacity: 1
+
+.media-item__delete
+  width: 24px
+  height: 24px
+  border: none
+  background: var(--color-danger)
+  color: var(--color-white)
+  border-radius: 50%
+  cursor: pointer
+  display: flex
+  align-items: center
+  justify-content: center
+  font-size: var(--font-size-lg)
+
+.media-selection
+  position: fixed
+  bottom: var(--spacing-xl)
+  left: 50%
+  transform: translateX(-50%)
+  background: var(--color-white)
+  padding: var(--spacing-lg)
+  border-radius: var(--border-radius)
+  box-shadow: var(--shadow-lg)
+  display: flex
+  align-items: center
+  gap: var(--spacing-lg)
+  z-index: var(--z-sticky)
+  
+  .theme-dark &
+    background: var(--color-dark)
+    color: var(--color-light)
+
+@media (max-width: 768px)
+  .media-toolbar
+    flex-direction: column
+    gap: var(--spacing-md)
+  
+  .media-filters
+    width: 100%
+    justify-content: space-between
+  
+  .search-input
+    flex: 1
+  
+  .media-grid
+    grid-template-columns: repeat(auto-fill, minmax(150px, 1fr))
+    gap: var(--spacing-md)

+ 313 - 0
app/services/MediaService.coffee

@@ -0,0 +1,313 @@
+# app/services/MediaService.coffee
+{ DomainEntity } = require 'app/types/data'
+
+class MediaFile extends DomainEntity
+  constructor: ->
+    super()
+    @type = 'media_file'
+    @name = ''
+    @filename = ''
+    @size = 0
+    @mimeType = ''
+    @url = ''
+    @thumbnailUrl = ''
+    @description = ''
+    @tags = []
+    @attachedTo = [] # Массив объектов, к которым прикреплен файл
+
+class MediaService
+  constructor: ->
+    @pouchService = require 'app/utils/pouch'
+    @initialized = false
+
+  init: ->
+    return Promise.resolve() if @initialized
+    
+    try
+      await @pouchService.init()
+      @initialized = true
+      log '✅ MediaService инициализирован'
+      return Promise.resolve()
+    catch error
+      log '❌ Ошибка инициализации MediaService: '+error.message
+      return Promise.reject(error)
+
+  getAllFiles: (options = {}) ->
+    await @ensureInit()
+    
+    try
+      # Временная заглушка - возвращаем тестовые данные
+      # В реальном приложении здесь будет запрос к PouchDB
+      mockFiles = [
+        {
+          _id: 'media_file:1'
+          name: 'product-image-1.jpg'
+          filename: 'product-image-1.jpg'
+          size: 1024000
+          mimeType: 'image/jpeg'
+          type: 'media_file'
+          url: '/d/braer_color_shop/media_file:1/product-image-1.jpg'
+          thumbnailUrl: '/d/braer_color_shop/media_file:1/thumb-product-image-1.jpg'
+          createdAt: new Date().toISOString()
+          updatedAt: new Date().toISOString()
+          domains: [window.location.hostname]
+          active: true
+        }
+        {
+          _id: 'media_file:2'
+          name: 'category-banner.png'
+          filename: 'category-banner.png'
+          size: 2048000
+          mimeType: 'image/png'
+          type: 'media_file'
+          url: '/d/braer_color_shop/media_file:2/category-banner.png'
+          thumbnailUrl: '/d/braer_color_shop/media_file:2/thumb-category-banner.png'
+          createdAt: new Date(Date.now() - 86400000).toISOString()
+          updatedAt: new Date(Date.now() - 86400000).toISOString()
+          domains: [window.location.hostname]
+          active: true
+        }
+        {
+          _id: 'media_file:3'
+          name: 'product-specification.pdf'
+          filename: 'product-specification.pdf'
+          size: 512000
+          mimeType: 'application/pdf'
+          type: 'media_file'
+          url: '/d/braer_color_shop/media_file:3/product-specification.pdf'
+          createdAt: new Date(Date.now() - 172800000).toISOString()
+          updatedAt: new Date(Date.now() - 172800000).toISOString()
+          domains: [window.location.hostname]
+          active: true
+        }
+      ]
+
+      # Фильтрация по опциям
+      files = mockFiles
+      if options.type
+        files = files.filter (file) -> file.mimeType.startsWith(options.type)
+      
+      if options.search
+        query = options.search.toLowerCase()
+        files = files.filter (file) -> 
+          file.name.toLowerCase().includes(query) or
+          file.filename.toLowerCase().includes(query)
+      
+      log '✅ Медиа-файлы загружены: '+files.length
+      return files.map (file) -> new MediaFile(file)
+      
+    catch error
+      log '❌ Ошибка загрузки медиа-файлов: '+error.message
+      throw error
+
+  uploadFiles: (files) ->
+    await @ensureInit()
+    
+    try
+      log '🚀 Начало загрузки файлов: '+files.length
+      
+      uploadedFiles = []
+      
+      for file in files
+        # Создание документа медиа-файла
+        mediaFile = new MediaFile()
+        mediaFile._id = 'media_file:'+Date.now()+'_'+Math.random().toString(36).substr(2, 9)
+        mediaFile.name = file.name
+        mediaFile.filename = file.name
+        mediaFile.size = file.size
+        mediaFile.mimeType = file.type
+        mediaFile.domains = [window.location.hostname]
+        
+        # Определение типа файла
+        if file.type.startsWith('image/')
+          mediaFile.type = 'image'
+        else if file.type.startsWith('application/')
+          mediaFile.type = 'document'
+        else
+          mediaFile.type = 'other'
+        
+        # В реальном приложении здесь будет загрузка файла как attachment в PouchDB
+        # await @pouchService.putAttachment(mediaFile._id, file.name, file, file.type)
+        
+        # Сохранение документа
+        # await @pouchService.saveDocument(mediaFile)
+        
+        uploadedFiles.push(mediaFile)
+        log '✅ Файл загружен: '+file.name
+      
+      log '🎉 Все файлы успешно загружены: '+uploadedFiles.length
+      return uploadedFiles
+      
+    catch error
+      log '❌ Ошибка загрузки файлов: '+error.message
+      throw error
+
+  uploadFile: (file) ->
+    await @ensureInit()
+    
+    try
+      log '📤 Загрузка файла: '+file.name
+      
+      # Создание документа медиа-файла
+      mediaFile = new MediaFile()
+      mediaFile._id = 'media_file:'+Date.now()+'_'+Math.random().toString(36).substr(2, 9)
+      mediaFile.name = file.name
+      mediaFile.filename = file.name
+      mediaFile.size = file.size
+      mediaFile.mimeType = file.type
+      mediaFile.domains = [window.location.hostname]
+      
+      # Определение типа файла
+      if file.type.startsWith('image/')
+        mediaFile.type = 'image'
+        
+        # Создание thumbnail для изображений (в реальном приложении)
+        mediaFile.thumbnailUrl = '/d/braer_color_shop/'+mediaFile._id+'/thumb-'+file.name
+        
+      else if file.type.startsWith('application/')
+        mediaFile.type = 'document'
+      else
+        mediaFile.type = 'other'
+      
+      # В реальном приложении здесь будет:
+      # 1. Загрузка файла как attachment в PouchDB
+      # 2. Создание thumbnail для изображений
+      # 3. Сохранение документа медиа-файла
+      
+      # await @pouchService.putAttachment(mediaFile._id, file.name, file, file.type)
+      # await @pouchService.saveDocument(mediaFile)
+      
+      log '✅ Файл успешно загружен: '+file.name
+      return mediaFile
+      
+    catch error
+      log '❌ Ошибка загрузки файла: '+error.message
+      throw error
+
+  deleteFile: (fileId) ->
+    await @ensureInit()
+    
+    try
+      log '🗑️ Удаление файла: '+fileId
+      
+      # В реальном приложении здесь будет:
+      # 1. Получение документа
+      # 2. Удаление attachments
+      # 3. Удаление документа
+      
+      # const doc = await @pouchService.getDocument(fileId)
+      # await @pouchService.removeDocument(doc)
+      
+      log '✅ Файл удален: '+fileId
+      return true
+      
+    catch error
+      log '❌ Ошибка удаления файла: '+error.message
+      throw error
+
+  deleteFiles: (fileIds) ->
+    await @ensureInit()
+    
+    try
+      log '🗑️ Пакетное удаление файлов: '+fileIds.length
+      
+      results = []
+      for fileId in fileIds
+        try
+          # await @deleteFile(fileId)
+          results.push({ fileId: fileId, success: true })
+        catch error
+          results.push({ fileId: fileId, success: false, error: error.message })
+      
+      successCount = results.filter((r) -> r.success).length
+      log '✅ Удалено файлов: '+successCount+' из '+fileIds.length
+      return results
+      
+    catch error
+      log '❌ Ошибка пакетного удаления файлов: '+error.message
+      throw error
+
+  getFileUrl: (fileId, filename) ->
+    # Генерация URL для доступа к файлу в CouchDB
+    return '/d/braer_color_shop/'+fileId+'/'+filename
+
+  getThumbnailUrl: (fileId, filename) ->
+    # Генерация URL для thumbnail
+    return '/d/braer_color_shop/'+fileId+'/thumb-'+filename
+
+  attachFileToDocument: (fileId, targetDocId, targetType) ->
+    await @ensureInit()
+    
+    try
+      log '📎 Прикрепление файла '+fileId+' к документу '+targetDocId
+      
+      # Получение файла
+      # const file = await @pouchService.getDocument(fileId)
+      
+      # Обновление списка прикрепленных документов
+      # if not file.attachedTo
+      #   file.attachedTo = []
+      # 
+      # file.attachedTo.push({
+      #   docId: targetDocId
+      #   type: targetType
+      #   attachedAt: new Date().toISOString()
+      # })
+      # 
+      # await @pouchService.saveDocument(file)
+      
+      log '✅ Файл прикреплен к документу'
+      return true
+      
+    catch error
+      log '❌ Ошибка прикрепления файла: '+error.message
+      throw error
+
+  detachFileFromDocument: (fileId, targetDocId) ->
+    await @ensureInit()
+    
+    try
+      log '📎 Открепление файла '+fileId+' от документа '+targetDocId
+      
+      # Получение файла
+      # const file = await @pouchService.getDocument(fileId)
+      # 
+      # if file.attachedTo
+      #   file.attachedTo = file.attachedTo.filter (attachment) -> 
+      #     attachment.docId != targetDocId
+      # 
+      #   await @pouchService.saveDocument(file)
+      
+      log '✅ Файл откреплен от документа'
+      return true
+      
+    catch error
+      log '❌ Ошибка открепления файла: '+error.message
+      throw error
+
+  getFilesByDocument: (docId) ->
+    await @ensureInit()
+    
+    try
+      # В реальном приложении здесь будет запрос к PouchDB
+      # для поиска файлов, прикрепленных к указанному документу
+      
+      # Временная заглушка
+      allFiles = await @getAllFiles()
+      # Фильтрация файлов, которые должны быть прикреплены к документу
+      attachedFiles = allFiles.filter (file) ->
+        file.attachedTo and file.attachedTo.some (attachment) -> 
+          attachment.docId == docId
+      
+      log '✅ Загружены файлы документа '+docId+': '+attachedFiles.length
+      return attachedFiles
+      
+    catch error
+      log '❌ Ошибка загрузки файлов документа: '+error.message
+      throw error
+
+  ensureInit: ->
+    unless @initialized
+      throw new Error('MediaService не инициализирован. Вызовите init() сначала.')
+
+module.exports = new MediaService()