Импортируем категории товаров

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

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

Помимо этого, категории еще могут иметь картинку, но тут мы просто используем компонент upload-file, который написали вчера для юзеров.

Модель Language

Первым делом создаём модель для работы с языками - это нужно для создания связи с другими моделями.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;

/**php
 * @property string $lang
 * @property string $title
 * @property bool $active
 * @property int $rank
 *
 * @property-read CategoryTranslation[] $categoriesTranslations
 * @property-read Category[] $categories
 */
class Language extends Model
{
    public $timestamps = false;
    protected $primaryKey = 'lang';
    protected $keyType = 'string';
    protected $fillable = ['code', 'title', 'active', 'rank'];
    protected $casts = [
        'active' => 'boolean',
    ];

    public function categoriesTranslations(): HasMany
    {
        return $this->hasMany(CategoryTranslation::class, 'lang');
    }

    public function categories(): HasManyThrough
    {
        return $this->hasManyThrough(
            Category::class,
            CategoryTranslation::class,
            'lang',
            'id',
            'lang',
            'category_id'
        );
    }
}

Обратите внимание на связь с категориями через модель CategoryTranslation. Через неё Eloquent сделает присоединение таблицы CategoryTranslation и получит все категории на нужном языке. Не знаю, где это может пригодиться, но пусть будет.

Модель CategoryTranslation

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

/**
 * @property int $category_id
 * @property string $lang
 * @property string $title
 * @property string $description
 *
 * @property-read Category $category
 * @property-read Language $language
 */
class CategoryTranslation extends Model
{
    use Traits\CompositeKey;

    public $timestamps = false;
    protected $primaryKey = ['category_id', 'lang'];
    protected $fillable = ['category_id', 'lang', 'title', 'description'];

    public function category(): BelongsTo
    {
        return $this->belongsTo(Category::class);
    }

    public function language(): BelongsTo
    {
        return $this->belongsTo(Language::class, 'lang');
    }
}

Эта модель использует ещё один новый трейт CompositeKey, потому что, что Eloquent не поддерживает составные ключи по умолчанию - и нам надо переопределить некоторые методы модели для правильной работы.

Сам код трейта я приводить не буду, посмотрите в исходниках, там ничего интересного. Когда-то давно я столкнулся с этой проблемой и нашёл решение, теперь таскаю этот трейт из проекта в проект, по необходимости.

Еще можно обратить внимание на связь language(), где явно указано по какой колонке привязывать записи. Для метода category() это не требуется, потому что Eloquent автоматически будет смотреть на колонку category_id, исходя из имени связи. А вот для языка у нас не lang_id, а просто lang - поэтому её и нужно указать.

Модель Category

Эта модель у нас уже есть, нужно добавить новые свойства:

/**
 * @property int $id
 * @property int $parent_id
 * @property ?int $file_id
 * @property string $alias
 * @property string $uri
 * @property bool $active
 * @property int $rank
 * @property int $remote_id
 * @property Carbon $created_at
 * @property Carbon $updated_at
 *
 * @property-read Category $parent
 * @property-read CategoryTranslation[] $translations
 * @property-read File $file
 * @property-read Category[] $children
 * @property-read Product[] $products
 */

Из-за того, что теперь категории могут быть вложены друг в друга, у нас появилась колонка uri, которая будет хранить полный адрес категории. Мне не хочется следить за её обновлением самостоятельно, так что пропишу функцию на событие сохранения модели:

    protected static function booted(): void
    {
        static::saving(static function (self $model) {
            $uri = [$model->alias];
            if ($model->parent) {
                array_unshift($uri, $model->parent->uri);
            }
            $model->uri = implode('/', $uri);
        });
    }

Здесь мы генерируем uri модели, складывая её alias с родительским uri.

Осталось прописать связи с другими моделями:

    public function parent(): BelongsTo
    {
        return $this->belongsTo(__CLASS__);
    }

    public function translations(): HasMany
    {
        return $this->hasMany(CategoryTranslation::class);
    }

    public function file(): BelongsTo
    {
        return $this->belongsTo(File::class);
    }

    public function children(): HasMany
    {
        return $this->hasMany(__CLASS__, 'parent_id');
    }

    public function products(): HasMany
    {
        return $this->hasMany(Product::class);
    }

Связи parent и children ссылаются сами на себя, то есть на саму таблицу категорий, так что мы используем встроенную в PHP константу __CLASS__.

Засеиваем языки

Создаём новый сид в core/db/seeds/Languages.php, так как мы планируем на них везде ссылаться.

<?php

use App\Models\Language;
use Phinx\Seed\AbstractSeed;

class Languages extends AbstractSeed
{
    protected array $data = [
        'ru' => [
            'title' => 'Русский',
            'rank' => 0,
        ],
        'en' => [
            'title' => 'English',
            'rank' => 1,
        ],
    ];


    public function run(): void
    {
        foreach ($this->data as $id => $data) {
            /** @var Language $record */
            if (!$record = Language::query()->find($id)) {
                $record = new Language();
                $record->lang = $id;
            }
            $record->fill($data);
            $record->save();
        }
    }
}

Можно сразу же его и запустить: composer db:seed-one Languages

Импортируем категории из miniShop2

Общая логика здесь та же, что и при импорте пользователей, с одним отличием: нам нужно присоединить еще и переводы. К счастью, в исходном проекте на MODX использовалось дополнение Lingua, которое хранит тексты в отдельной таблице. Вот её и будем подключать.

Там тексты на немецком и француском, но мы сделаем вид, что это русский и английский!

Во время запуска сида я столкнулся с проблемой проверки уникальности индекса таблицы category_translations - она ругалась, что нельзя создать 2 записи на 1 языке с одинаковым title. Причём, если title пустой - это всё равно считается за значение. Так как не у всех категорий есть перевод на обоих языках, мне вылетало вот такое

Пришлось откатить миграции до юзеров, сделать $table->string('title')->nullable(); в миграции категорий и накатить снова. Null не считается значением, и не влияет на уникальность индекса.

Теперь можно и написать сам сидер в core/db/seeds/Categories.php.

<?php

use App\Models\Category;
use App\Models\File;
use Phinx\Seed\AbstractSeed;
use Slim\Psr7\UploadedFile;
use Vesp\Services\Eloquent;

class Categories extends AbstractSeed
{
    public function getDependencies(): array
    {
        return ['Languages'];
    }

    public function run(): void
    {
        $tvImageId = 1;

        // Выбираем категории вместе с переводами и картинками
        $pdo = (new Eloquent())->getDatabaseManager()->getPdo();
        $stmt = $pdo->query(
            "SELECT `r`.*, `tv`.`value` AS `image`, `ln`.`pagetitle` as `pagetitle_fr`, `ln`.`content` as `content_fr`
            FROM `modx_site_content` `r`
            LEFT JOIN `modx_site_tmplvar_contentvalues` `tv` ON `tmplvarid` = '$tvImageId' AND `contentid` = `r`.`id`
            LEFT JOIN `modx_lingua_site_content` `ln` ON `resource_id` = `r`.`id`
            WHERE `class_key` = 'msCategory' ORDER BY `parent`,`r`.`id`"
        );

        $parents = [];
        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
            // Проверяем наличие категории по remote_id
            if (!$category = Category::query()->where('remote_id', $row['id'])->first()) {
                $category = new Category();
                $category->remote_id = $row['id'];
            }

            // Замолняем основные поля
            $category->alias = trim($row['alias']);
            $category->uri = preg_replace('#^shop/#', '', trim($row['uri'], '/'));
            $category->active = empty($row['deleted']) && !empty($row['published']);
            $category->rank = $row['menuindex'];
            $category->created_at = $row['createdon'];
            $category->updated_at = $row['editedon'] ?: null;
            // Заменяем id родителя, если есть
            $category->parent_id = $parents[$row['parent']] ?? null;

            // Если у категории есть картинка - загружаем плейсхолдер
            if (!empty($row['image'])) {
                $filename = tempnam(getenv('UPLOAD_DIR'), 'img_');
                file_put_contents($filename, file_get_contents('https://i.pravatar.cc/500'));
                $data = new UploadedFile($filename, basename($filename), mime_content_type($filename));
                if (!$file = $category->file) {
                    $file = new File();
                }
                $file->uploadFile($data);
                $category->update(['file_id' => $file->id]);
                unlink($filename);
            }
            // --

            // Сохраняем категорию
            $category->save();
            // И сразу создаём переводы через связь модели
            $category->translations()->updateOrCreate(['lang' => 'ru'], [
                'title' => trim($row['pagetitle']) ?: null,
                'description' => trim($row['content']) ?: null,
            ]);
            $category->translations()->updateOrCreate(['lang' => 'en'], [
                'title' => trim($row['pagetitle_fr']) ?: null,
                'description' => trim($row['content_fr']) ?: null,
            ]);

            // Обязательно сохраняем соответствие старого и нового id для
            // добавления дочерних категорий
            $parents[$category->remote_id] = $category->id;
        }
    }
}

Небольшая хитрость здесь в том, чтобы выбирать исходные категории по возрастанию родителя (колонка parent), чтобы мы сначала создали корневые категории, а потом добавили в них дочерние.

id из MODX и id в Vesp сопоставляются через массив $parents, в который мы закидываем новые записи по мере создания категорий.

В проекте на MODX картинка категории хранится в TV, поэтому я подключаю таблицу modx_site_tmplvar_contentvalues, но настоящих файлов у нас нет, так что я создаю случайные плейсхолдеры. Оставил просто для примера импорта из TV.

После запуска скрипта вижу новые записи в БД:

Плагин для вывода текста в админке

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

Я предпочитаю создавать плагин с утилитами в admin/plugins/utils.js для всякого такого:

// Ищет требуемый ключ в переданном объете и возвращает строку,
// если она не пустая
function translate(translations, field, lang = 'ru') {
  const data = translations.filter((i) => i.lang === lang)
  if (data.length > 0 && data[0][field] && data[0][field].length > 0) {
    return data[0][field]
  }
  return null
}

// Плагин Nuxt должен возвращать вот такой объект
export default ({app}, inject) => {
  // Регистрируем глобальную функцию приложения 
  inject('translate', (translations, field = 'title') => {
    // Пробуем вывести строку для требуемого языка
    let text = translate(translations, field, app.i18n.locale)
    if (!text && app.i18n.locale !== app.i18n.defaultLocale) {
      //  Или строку языка по умолчанию
      text = translate(translations, field, app.i18n.defaultLocale)
    }
    // Если переводов нет - надо уведомить разработчика, чтобы добавил
    if (!text) {
      console.info(`Could not find translation for ${field} in ${JSON.stringify(translations)}`)
    }
    return text
  })
}

Побробнее про регистрацию плагинов Nuxt 2 можно почитать в документации.

А мы просто добавляем его в nuxt.config.js:

Config.plugins = ['@/plugins/utils.js']

После этого можно везде использовать функцию $translate().

Дорабатываем таблицу категорий

Сначала нужно подготовить бэкенд. Я создаю новый трейт для контроллеров Controllers/Traits/TranslateModelController.php:

<?php

namespace App\Controllers\Traits;

use Illuminate\Database\Eloquent\Model;

trait TranslateModelController
{
    protected function afterSave(Model $record): Model
    {
        if ($translations = $this->getProperty('translations')) {
            foreach ($translations as $data) {
                $record->translations()->updateOrCreate(['lang' => $data['lang']], $data);
            }
        }

        return $record;
    }
}

Трейт очень простой - обрабатывает присланные тексты после сохранения модели.

Дальше крутим Controllers/Admin/Categories.php.

class Categories extends ModelController
{
    // Наши новые трейты для переводов и загрузки файла
    use FileModelController;
    use TranslateModelController;
    //...
    protected $attachments = ['file'];

    // Подключаем нужные модели при выводе для редактирования
    protected function beforeGet(Builder $c): Builder
    {
        $c->with('file:id,updated_at');
        $c->with('translations');

        return $c;
    }

    protected function beforeCount(Builder $c): Builder
    {
        // Поиск категории теперь проводится по таблице текстов
        if ($query = trim($this->getProperty('query', ''))) {
            $c->whereHas('translations', static function (Builder $c) use ($query) {
                $c->where('title', 'LIKE', "%$query%");
                $c->orWhere('description', 'LIKE', "%$query%");
            });
        }

        // Возможность прятать некоторые категории,
        // пригодится для автодополнения
        if ($exclude = $this->getProperty('exclude')) {
            if (is_array($exclude)) {
                $c->whereNotIn('id', $exclude);
            } else {
                $c->where('id', '!=', $exclude);
            }
        }

        return $c;
    }

    // Нужные связи для вывода таблицы категорий
    protected function afterCount(Builder $c): Builder
    {
        $c->with('translations:category_id,lang,title');
        $c->with('parent:id', 'parent.translations:category_id,lang,title');
        $c->with('file:id,updated_at');
        $c->withCount('children');
        $c->withCount('products');

        return $c;
    }

    protected function beforeSave(Model $record): ?ResponseInterface
    {
        /** @var Category $record */
        if (!$record->parent_id) {
            // Фронт может прислать 0 или пустую строку в качестве id родителя
            // Это вызовет ошибку внешнего ключа, так что форсируем null
            $record->parent_id = null;
        }

        // Обрабатываем файлы методом из трейта
        if ($error = $this->processFiles($record)) {
            return $error;
        }

        // Уникальность alias теперь проверяем с учётом родителя
        $c = Category::query()->where('alias', $record->alias);
        if ($record->exists) {
            $c->where('id', '!=', $record->id);
        }
        if ($record->parent_id) {
            $c->where('parent_id', $record->parent_id);
        }
        if ($c->count()) {
            return $this->failure('errors.category.alias_exists');
        }

        return null;
    }

Далее нужно обновить и таблицу категорий admin/pages/categories.vue. В самой таблице у нас добавляется специальное оформление ячеек:

<vesp-table ....>
      <template #cell(title)="{item}">
        {{ $translate(item.translations) }}
        <div class="small text-muted">{{ item.uri }}</div>
      </template>
      <template #cell(parent)="{item}">
        <template v-if="item.parent">
          {{ $translate(item.parent.translations) }}
        </template>
      </template>
      <template #cell(file)="{value}">
        <b-img
          v-if="value.id"
          :key="value.id"
          :src="$image(value, {w: 150, h: 75, fit: 'crop'})"
          :srcset="$image(value, {w: 300, h: 150, fit: 'crop'}) + ' 2x'"
          width="150"
          height="75"
          alt=""
          class="rounded"
        />
      </template>
</vesp-table>

А в js коде добавляем только новые колонки:

<script>
export default {
  //...
  computed: {
    // ...
    fields() {
      return [
        {key: 'id', label: this.$t('components.table.columns.id'), sortable: true},
        {key: 'file', label: this.$t('models.category.file')},
        {key: 'title', label: this.$t('models.category.title'), sortable: true},
        {key: 'parent', label: this.$t('models.category.parent')},
        {key: 'products_count', label: this.$t('models.product.title_many')},
      ]
    },
  }
}
</script>

В итоге у нас получается вот такая магия - имена категорий меняются при смене локализации админки:

Языком по умолчанию сейчас является английский, через это на видео некоторые строки выходят пыстыми - ведь что на английском они не заполнены. Меняем порядок языков и умолчание в nuxt.config.js

Config.i18n.defaultLocale = 'ru'
Config.i18n.locales = [
  {code: 'ru', title: 'Русский'},
  {code: 'en', title: 'English'},
]

Форма редактирования категории

Вот здесь может быть немного сложно для понимания.

Дело в том, что для удобной работы с переводами, мы добавим переключение языков прямо в форму. Но нужно сделать это универсально, ведь у нас еще будут как минимум товары с такой же функцией. Да и в другие проекты потом можно утащить, что тоже неплохо.

Поэтому нам понадобятся:

  • Новый контроллер Controllers/Admin/Languages.php для получения списка языков системы
  • Новый маршрут для него в core/routes.php
  • Результат загрузки мы будем хранить в store приложения. Для этого добавим новый ключ в state, новый метод в mutations и заведём action для запроса в API.
  • Всякие нужные методы для работы формы сгруппируем в один общий mixin для добавления формам. Это как трейт в PHP, только для Vue.
  • Активный язык будем отображать для каждого поля ввода с помощью стилей CSS.
  • Ну а сам язык формы будем переключать через отдельный компонент lang-tabs.vue

Контроллер с маршрутом описывать не буду, там ничего нового.

А вот хранилище (admin/store/index.js) мы меняем вот так:

export const state = () => ({
  // ...
  languages: [],
})

export const mutations = {
  // ...
  languages(state, payload) {
    state.languages = payload
  },
}

export const actions = {
  async languages({commit, state}) {
    if (!state.languages.length) {
      try {
        const {data} = await this.$axios.get('admin/languages', {params: {combo: true}})
        commit('languages', data.rows)
      } catch (e) {}
    }
    return state.languages
  },
}

Вновь созданный action мы будем вызывать как this.$store.dispatch('languages') и в ответ получим языки системы. Загружать он их будет только один раз, а потом сохранит в this.$store.state.languages и будет выдавать уже оттуда. Можно сказать, это такое кэширование.

Именно на этой логике и основана наша примесь в admin/mixins/translations.js:

export default {
  // Работаем до рендера компонента на странице
  async beforeMount() {
    try {
      const translations = []
      // Поулучаем языки
      const languages = await this.$store.dispatch('languages')
      // Проверяем значения для языков в моделе и создаем пустые переводы, если надо
      languages.forEach((v) => {
        const idx = this.record.translations.findIndex((i) => i.lang === v.lang)
        translations.push(idx > -1 ? {...this.record.translations[idx]} : {...this.translationFields, lang: v.lang})
      })
      // Выставляем переводы модели в форму, получая ключи из translationFields
      this.$set(this.record, 'translations', translations)
    } catch (e) {
      console.error(e)
    }
  },
  data() {
    return {
      // Здесь каждый компонент должен указать, какие поля у него переводимые 
      // например {title: '', description: ''}
      translationFields: {},
    }
  },
  directives: {
    // См. ниже
    langIcon: {
      update(el, {value}) {
        if (!el.classList.contains('form-group')) {
          el = el.querySelector('.form-group')
        }
        if (!el) {
          return
        }

        el.classList.forEach((cls) => {
          if (cls.startsWith('icon-')) {
            el.classList.remove(cls)
          }
        })
        el.classList.add('icon-' + value)
      },
    },
  },
}

Сам по себе этот mixin, как и трейт в PHP, работать не будет - нам нужно импортировать его в форму категории.

import Translations from '@/mixins/translations.js'

export default {
  // ...  
  mixins: [Translations],
  data() {
    return {
      lang: this.$i18n.locale || 'ru',
      translationFields: {title: '', description: ''},
    }
  },
  // ...  
}

При вызозе формы он:

  1. Добавит метод beforeMount(), который пробежится по указанным translationFields, проверит их ключи в переводах редактируемой модели и добавит путые, если нужно. Это чтобы избежать ошибок при создании новой категории, у которой еще не может быть переводов, и добавлении новых языков.
  2. Добавит директиву langIcon, котрая будет прикручивать специальный CSS класс для поля ввода, чтобы там появлялся флажочек.

Директива указывается непосредственно элементам формы, и передаёт их, элементы, в метод update(), где мы на чистом javascript можем сделать с ним всё, что угодно. Например, добавлять класс icon-lang-ru для отрисовки российского флага.

По наличию этих флажочков пользователь админки поймёт, что это специальные переводимые поля формы.

Теперь можно изменить шаблон формы категории:

<template>
<!-- -->
        <b-form-group v-lang-icon="lang" :label="$t('models.category.title')">
          <b-form-input
            v-for="(translation, idx) in record.translations"
            v-show="translation.lang === lang"
            :key="idx"
            v-model.trim="translation.title"
            autofocus
            required
          />
        </b-form-group>
<!-- -->
</template>

Итак, v-lang-icon="lang" - это и есть наша директива, которая срабатывает на элемент формы и получает текущий активный язык. На этот момент уже отработал метод beforeMount() и в нашей моделе обязательно есть значения для каждого языка внутри record.translations - этот массив мы и перебираем для вывода стольких полей ввода.

Но видеть мы будем только то поле, которое соответствует активному языку, потому что v-show="translation.lang === lang". Все эти поля будут обязательны для ввода, а значение будет сохраняться в соответствующий ключ record.translations.

На сервер у нас должен улетать примерно такой массив:

{
  "id": 20,
  // ...
  "file": null,
  "translations": [
    {
      "category_id": 20,
      "lang": "ru",
      "title": "Дочерняя Категория",
      "description": "Описание дочерней категории"
    },
    {
      "category_id": 20,
      "lang": "en",
      "title": "Children Category",
      "description": "Children Category Description"
    }
  ]
}

Там его поймает и обработает наш трейт TranslateModelController.

Последний штрих - компонент для переключения активного lang, пусть он будет в admin/components/lang-tabs.vue:

<template>
  <b-tabs v-model="tab" :pills="pills" :small="small" :align="align" :active-nav-item-class="activeClass">
    <b-tab v-for="(language, idx) in languages" :key="idx">
      <template #title>
        <div class="d-flex align-items-center">
          <b-img :src="require('~/assets/images/icon-lang-' + language.lang + '.svg')" width="20" height="20" />
          <div class="ml-2">{{ language.title }}</div>
        </div>
      </template>
    </b-tab>
  </b-tabs>
</template>

<script>
export default {
  name: 'LangTabs',
  // ...
  data() {
    // Активная вкладка при загрузке зависит от текущего языка админки
    let tab = 0
    const languages = this.$store.state.languages
    if (this.value && languages.length) {
      tab = languages.findIndex((i) => i.lang === this.value)
    }
    return {tab}
  },
  computed: {
    languages() {
      return this.$store.state.languages
    },
  },
  watch: {
    // Меняем v-model при переключении вкладки
    tab(newValue) {
      if (this.languages[newValue]) {
        this.myValue = this.languages[newValue].lang
      }
    },
  },
}
</script>

Этот компонент тоже нужно импортировать в форму и вызвать в любом удобном месте. У меня он на самом верху:

<template>
  <div>
    <lang-tabs v-model="lang" />

    <hr />

    <b-row class="mt-4">
<!-- ... -->

Итоговый результат:

Заключение

Прекрасно понимаю, что вся эта магия Vue с хранилищами, примесями и директивами сбивает с толку, но для этого у нас и предусмотрены комментарии, чтобы вы задавали вопросы.

Весь смысл в том, чтобы вынести общие и повторяющиеся функции отдельно, а потом использовать их там, где нужно. Как видите, загрузку файла я уже не рассматриваю, она работает ровно так же, как и загрузка аватарки у юзеров. На следующим уроке по работе с товарами я не буду рассказывать про работу с переводами - потому что мы просто используем общие компоненты, написанные сегодня.

Итоговый коммит со всеми изменениями.

← Предыдущая заметка
Импортируем пользователей
Следующая заметка →
Импортируем товары
Комментарии (11)
bezumkinВасилий Наумкин
16.08.2023 13:28

Так здесь дело не про отношения, а про составной первичный ключ.

У обычный моделей есть id, по которому модель можно выбрать и обновить/удалить. У CategoryTranslation есть category_id и lang, и только их сочетание уникально. То есть, у этой модели всегда нужно указывать 2 колонки для работы, вместо 1й.

Именно для того, чтобы такая модель нормально находилась, сохранялась и удалялась и нужен наш trait.

Если я чего-то не понимаю - приведи пример, пожалйста, как это можно сделать, наследуя класс Pivot. Как минимум там в модели должен указываться $primaryKey в виде массива колонок, в документации я такого не вижу.

bezumkinВасилий Наумкин
16.08.2023 14:24

Выглядит любопытно, надо будет поэксперементировать на досуге, спасибо.

Только непонятно, а как составить ключ из 3х колонок? У меня и такие модели есть. Полагаю, что никак.

bezumkinВасилий Наумкин
21.08.2023 12:37

Сорян, что-то я уже второй раз затупил с этим дампом.

Держи правильный ms2.sql.zip (2.79 Mb), в заметке про импорт юзеров тоже поменял.

bezumkinВасилий Наумкин
24.11.2023 10:22

но пока для меня разрабатывать на Vesp магазин с переносом данных из Modx неподъемно

Думаю, для многих так. Зато можно почитать и узнать всякое новое. Понять, что за пределами известного тебе есть еще много интересного, и стремиться к этому.

закинуть это все на рабочий сервер

Да просто не дошли руки положить этот файл в общий репозиторий, но он должен подойти без проблем.

Я сам постоянно работаю с Vesp и предумываю что-то новое. Когда выпущу Орбиту, будет большое обновление Vesp, включая обновлённый конфиг Docker.

bezumkinВасилий Наумкин
25.11.2023 08:30

Vesp тянет 2 зависимости: vesp-frontent для фронта и vesp-core для бэкенда.

Их можно обновлять, но это не даст каких-то преимуществ само по себе. Просто ты сможешь использовать каие-то новые функции в своём приложении (если они были добавлены).

Обычно там просто исправление ошибок, которых сейчас очень мало, буквально раз-два в год. Текущий Vesp очень стабилен.

bezumkin
Василий Наумкин
01.03.2024 04:30
С PWA пока не разбирался, мне кажется это не особо популярная штука. Если надо просто иконки добавит...
bezumkin
Василий Наумкин
22.02.2024 09:23
На здоровье! Держи лайк =)
inetlover
Александр Наумов
27.01.2024 00:06
Василий, спасибо! Извини, тупанул.
bezumkin
Василий Наумкин
22.01.2024 04:43
Давай-давай!
bezumkin
Василий Наумкин
24.12.2023 11:26
Спасибо!
bezumkin
Василий Наумкин
27.11.2023 02:43
Ура!
bezumkin
Василий Наумкин
25.11.2023 08:30
Vesp тянет 2 зависимости: vesp-frontent для фронта и vesp-core для бэкенда. Их можно обновлять, но э...
bezumkin
Василий Наумкин
22.11.2023 08:09
Отлично, поздравляю!
bezumkin
Василий Наумкин
04.11.2023 10:31
На здоровье!
bezumkin
Василий Наумкин
30.10.2023 01:21
Спасибо!