• Час читання ~6 хв
  • 16.03.2023

Кілька тижнів тому я побудував канбан-дошку з використанням Laravel і InertiaJS для клієнта. Однією з вимог до дошки було дозволити користувачам перетягувати/кидати картки в межах одного стовпця та інших стовпців.

Ця вимога передбачала написання ефективного вихідного коду для збереження нової позиції картки після кожного перетягування на дошку.

Існує кілька способів обробки збереження цього повторного замовлення. Ось кілька варіантів, з якими я стикався.

  • Ця URL-адреса пояснює, як Trello обчислює положення кожної картки після успішного перетягування.

  • Алгоритм переупорядкування стискання може допомогти вирішити таку проблему. Ось більше пояснень про те, як працює цей алгоритм.

  • Під час кожної операції перетягування повторно впорядковуйте відповідні картки в стовпцях, надсилайте бекенд карток із новими значеннями позицій і пакетно зберігайте зміни.

Для цієї статті я вирішив використовувати варіант 3. Оскільки я буду використовувати InertiaJS (Vue JS) на передній частині, перерахунок нових позицій карт за допомогою JavaScript є легким.

Існує кілька способів боротьби з новою позицією карт на бекенді.

  • Одним із способів є підготовка інструкції SQL Update і використання DB::update() методу для виконання оновлення.
  • Ще один спосіб - використовувати функцію «Красномовство upsert() » для виконання оновлень. Незважаючи на те, що метод upsert() може виконувати як операції вставки, так і оновлення, у нашому випадку upsert() буде використовуватися лише для виконання оновлень. Оновлюємо позиції карток, що зберігаються в базі даних.

Давайте відразу перейдемо до вихідного коду програми та вивчимо реалізацію.

Передумови

  • Я створив нову програму Laravel 10, використовуючи опцію Docker. Однак ви можете вибрати будь-який інший метод створення нового порожнього додатка Laravel локально.

  • Я встановив стартовий комплект Laravel Breeze. Це необов'язково, але мені подобається, як цей пакет легко перебирає багато переглядів і сторінок :-) Встановивши Laravel Breeze, ми встановлюємо та налаштовуємо InertiaJs, а також

Tailwindcs. Макет

Почнемо з вивчення остаточного макета цієї програми.
Kanban board in Laravel

Дошка канбану складається з однієї або декількох колон.
Кожна колонка складається з однієї або декількох карток.

Користувач може:

  • Додати нові картки Редагувати наявні картки Видалити наявні картки Додати нові стовпці Видалити наявні стовпці
  • Перетягувати картки в межах одного стовпця Перетягувати картки
  • в кілька стовпців.

Я побудував інтерфейс таким чином, що плата не дозволяє прокручувати вертикально. Як тільки немає місця для відображення всіх карток у стовпці, список карток починає прокручуватися вертикально. Таким чином, кнопка Додати картку продовжує відображатися внизу стовпця.

Коли до дошки додано багато стовпців, і немає місця, щоб показати їх усі, дошка дозволяє горизонтальну прокрутку. Це також типова поведінка при перегляді даної плати на мобільному пристрої. За раз з'являється один стовпець. Потім необхідно прокрутити вправо, щоб побачити інші стовпці.

Я не буду витрачати більше часу на пояснення інтерфейсу користувача або того, як я створив цю плату за допомогою Tailwind CSS. Ви можете самостійно перевірити репозиторій для отримання більш детальної інформації. Ларавель Канбан.

Компоненти

Створення модульних додатків у Vue або InertiaJS - це те, чого я прагну досягти. Тому я розділив канбан-дошку на незалежні компоненти Vue. Давайте досліджувати їх разом.

Канбан-компонент

є основним компонентом програми. Він визначає власність, населену board Інерцією JS. Цей компонент підтримується разом із маршрутомboards.

Файл routes/web.php визначає всі маршрути для цієї програми.

Маршрут boards визначається наступним чином:

Route::get('/boards/{board}', [BoardController::class, 'show'])->name('boards');

Коли користувач відвідує /boards URL-адресу, метод, визначений на BoardController::class виконанні, show() виконується. Давайте відкриємо show() метод.

public function show(Request $request, Board $board)
{
    // Case when no board is passed over
    if (!$board->exists) {
        $board = $request->user()->boards()->first();
    }
    // eager load columns with their cards
    $board?->loadMissing(['columns.cards']);

    return inertia('Kanban', [
        'board' => $board ? BoardResource::make($board) : null,
    ]);
}

Метод отримує в якості вхідних даних неявно зв'язаний Board екземпляр моделі. Однак відкрити цей маршрут можна, не проходячи жодних дощок. У цьому випадку я вирішив завантажити першу плату в базу даних.

Потім я завантажую відсутні columns та зв'язки, Column визначені на моделях та cards Card моделях.

Нарешті, метод візуалізує компонент InertiaJS на /resources/js/Pages/Kanban.vue, передаючи йому board властивість, що представляє об'єкт API Resource, що обертається над Board моделлю.

Компонент визначає одну властивість:Він визначає обчислену властивість у обгортанні Vue над стовпцями, які належать дошці:Потім він циклічно переходить на стовпці, доступні на поточній дошці, і візуалізує Column компонент:Компонент Column Kanban випромінює дві основні події:

const props = defineProps({
  board: Object,
});

const columns = computed(() => props.board?.data?.columns);

<Column
   v-for="column in columns"
   :key="column.title"
   :column="column"
   @reorder-change="onReorderChange"
   @reorder-commit="onReorderCommit"
/>

reorder-change і .reorder-commit Ми повернемося до обох подій пізніше, коли обговоримо перетягування на дошці.

Нарешті, він присвячує стовпець для відображення кнопкиAdd column. Компонент CreateColumn займається створенням нової колонки на дошці.

<ColumnCreate :board="board.data" />

Далі ми розглянемо компонент

.ColumnCreate Компонент

ColumnCreate Цей компонент має два режими роботи. Спочатку він візуалізується як Button a з міткою Add another list. Користувач натискає цю кнопку, і з'являється aForm, щоб дозволити користувачеві вказати a name для нового стовпця.

  <div>
   <form
      v-if="isCreating"
      @keydown.esc="isCreating = false"
      @submit.prevent="onSubmit"
      class="p-3 bg-gray-200 rounded-md"
      >
      <input
         v-model="form.title"
         type="text"
         placeholder="Column name ..."
         ref="inputColumnNameRef"
         class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
         />
      <div class="mt-2 space-x-2">
         <button
            type="submit"
            class="inline-flex items-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
            >
         Add column
         </button>
         <button
            @click.prevent="isCreating = false"
            type="button"
            class="inline-flex items-center rounded-md border border-transparent px-4 py-2 text-sm font-medium text-gray-700 hover:text-black focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
            >
         Cancel
         </button>
      </div>
   </form>
   <button
      v-if="!isCreating"
      @click.prevent="showForm"
      type="button"
      class="flex items-center p-2 text-sm rounded-md font-medium bg-gray-200 text-gray-600 hover:text-black hover:bg-gray-300 w-full"
      >
      <PlusIcon class="w-5 h-5" />
      <span class="ml-1">Add another list</span>
   </button>
</div>

Я обробляю логіку показу/приховування форми за допомогою змінної isCreating

const showForm = async () => {
  isCreating.value = true;
  await nextTick(); // wait for form to be rendered
  inputColumnNameRef.value.focus();
};

Vue ref(). Метод showForm() запускається, коли користувач натискає кнопку, щоб додати новий стовпець.

Він повертає to true, чекаєisCreating, поки Vue JS виконає зміни DOM за допомогою методу nextTick() і встановлює фокус на введенні стовпцяname.

const onSubmit = () => {
    form.post(route('boards.columns.store', {
        board: props?.board
    }), {
        onSuccess: () => {
            form.reset();
            isCreating.value = false;
        },
    });
};

Відправка форми відправляє запит POST на маршрут.boards.columns.store Цей маршрут визначає routes/web.php наступним чином:

Route::post('/boards/{board}/columns', BoardColumnCreateController::class)
        ->name('boards.columns.store');

У BoardColumnCreateController::class Laravel це інвокований контролер, який обробляє створення нового стовпця в базі даних.

public function __invoke(StoreColumnRequest $request, Board $board): RedirectResponse
{
$board->columns()->save(Column::create($request->all()));
        return back();
}

Контролер спочатку перевіряє запит, використовуючи запит формиStoreColumnRequest::class. Потім він зберігає новий стовпець у базі даних і повертається до подання дошки.

Компонент стовпця Компонент

Column прив'язаний до одного стовпця на дошці. Він визначає дві властивості:Об'єкт

  • стовпця Board ID
  • Він визначає події,

які він може випромінювати до батьківського компонента.

const emit = defineEmits(['reorder-commit', 'reorder-change']);

Крім того, цей компонент визначає реактивне cards властивість за допомогою функції Vue JSref(). Потім він використовує цю властивість для відображення карток у Draggable списку.

<Draggable
   v-model="cards"
   group="cards"
   item-key="id"
   tag="ul"
   drag-class="drag"
   ghost-class="ghost"
   class="space-y-3"
   @change="onReorderCards"
   @end="onReorderEnds"
   >
   <template #item="{ element }">
      <li>
         <Card :card="element" />
      </li>
   </template>
</Draggable>

Я використовував відому бібліотеку пакетів Vue.Draggable Vue3.

КомпонентDraggable:Візуалізується як ul

  • зв'язаний з cards властивістю
  • Стріляє дві події:
  • change і
  • end Візуалізує кожну картку всередині <li> елемента за допомогою компонента

Card Ми повернемося пізніше, щоб далі обговорити обробники подій перетягування.

Щоб видалити стовпець, натисніть ... кнопку настройок у верхньому правому куті кожного стовпця. Я використовував спадний Menu компонент, запропонований бібліотекою пакетів @headless/vue, для створення спадного меню з потрібними параметрами. Я додав Delete a column опцію в цьому випадку.

! [Видалити стовпець] (https://user-images.githubusercontent.com/1163421/222558878-916be407-a2c4-4ca3-8f9c-d4d6c9aa16f8.png "Видалити стовпець")

Перш ніж видалити стовпець, потрібно спочатку перевірити цю операцію. Для цього я використовував Modal компонент, запропонований бібліотекою пакетів @headless/vue.

! [Підтвердження видалення колонки] (https://user-images.githubusercontent.com/1163421/222558897-46a9f689-7fd3-4877-a317-9edef07d016d.png "Підтвердження видалення колонки")

Коли видалення підтверджується, цей рядок коду відповідає за відправку запиту DELETE на бекенд Laravel для виконання видалення колонки.

outer.delete(route('columns.destroy', { column: props?.column?.id }));

Маршрут columns.destroy зіставляється з внутрішнім ColumnDestroyController::class файломroutes/web.php.

Route::delete('/columns/{column}', ColumnDestroyController::class)->name('columns.destroy');

У ColumnDestroyController::class контролері, що викликається, який видаляє відповідний стовпець і перезавантажує сторінку.

public function __invoke(Column $column): RedirectResponse
{
    $column->delete();
    return back();
}

Компонент Column також посилається на компонент, CardCreate який обробляє додавання нової картки до стовпця. Ми розглянемо це найближчим часом.

Компонент CardCreate

Цей компонент має два режими роботи. По-перше, він візуалізується як Button маркований Add card. Користувач натискає на неї, і з'являється aForm, що дозволяє користувачеві вказати content для нової картки.

Я використовував ту саму логіку, що й для створення нового стовпця.

Компонент картки

Компонент Card є простим. Має два режими роботи. Один для редагування вмісту, а інший для відображення вмісту картки всередині стовпця.

! [Інтерфейс картки] (https://user-images.githubusercontent.com/1163421/222906920-a07b8968-7ef2-4875-9de5-ac0022299ee2.png "КардУІ")

При наведенні курсору на картку з'являються дві іконки. Один для редагування вмісту картки, а інший для її видалення.

  • Редагування картки відбувається на місці за допомогою елементаForm.
  • Якщо натиснути на значок кошика, відкриється діалогове вікно "Модаль" із запитом підтвердити видалення.

Використовується ConfirmDialog компонент, Dialog який я побудував, загортаючи a DialogPanel з бібліотеки пакетів @headlessui/vue.

Компонент «Діалог» використовує слоти Vue JS для заголовка, тіла та дій діалогу.

Якщо ви хочете розширити цю плату Kanban іншим типом Modal, ви можете використовувати Dialog компонент і налаштувати інтерфейс користувача так, як ви хочете.

Я хотів би звернути вашу увагу на техніку або трюк, який я використовував, щоб гарантувати, що одна картка знаходиться в режимі редагування за раз. Це означає, що прямо зараз, якщо ви натиснете значок редагування, усі картки, перелічені в цьому стовпці, перебуватимуть у режимі редагування. Це те, чого ми хочемо уникнути. Як?

У вашому проекті, якщо ви використовуєте Pinia або Vuex, ви будете зберігати їх ідентифікатор картки ідентифікатор у магазині щоразу, коли користувач натискає, щоб відредагувати картку.

Однак в цьому випадку використання a Store - це надмірність. Ми збережемо ту саму концепцію, за винятком використання Vue JS ref() для створення композиційного, який пропонує спільне сховище даних для зберігання поточного ідентифікатор картки ідентифікатор редагованого.

Створіть нову папку за адресою resource/js/Composables/. Додайте новий складений файл useEditCard.js JavaScript з наступним вмістом.

import { ref } from 'vue';

export const useEditCard = ref({
  currentCard: null,
});

Складений експортує useEditCard реф-обгортання об'єкта з єдиною властивістю currentCard.

Тепер давайте повернемося до компонента Card і скористаємося цим складаним.

По-перше, почніть з імпорту складеного.

import { useEditCard } from '@/Composables/useEditCard';

Усередині обробника подій для показу форми редагування встановіть значення currentCard на складеному.

const showForm = async () => {

  useEditCard.value.currentCard = props?.card?.id;

  // …
};

Складова Card вже приймає об'єктcard. Розміщуємо id клік картки всередині useEditCard.value.currentCard помешкання.

Ця методика гарантує, що в режимі редагування завжди є одна карта.

Також важливо обробляти обробник скасування подій форми редагування. Вам слід скинути значення, currentCard щоб відобразити це.

const onCancel = () => {
  useEditCard.value.currentCard = null;
};

Ось і все для компонентаCard. Давайте перейдемо до фінальної розтяжки та обговоримо роботу з перетягуванням на бекенді.

Картки перетягування/перетягування У

In the section under Компонент стовпця Компонент, I’ve shown the markup required to allow dragging and dropping cards within the same column or across other columns.

<Draggable
   v-model="cards"
   group="cards"
   item-key="id"
   tag="ul"
   drag-class="drag"
   ghost-class="ghost"
   class="space-y-3"
   @change="onReorderCards"
   @end="onReorderEnds"
   >
   <template #item="{ element }">
      <li>
         <Card :card="element" />
      </li>
   </template>
</Draggable>

Реалізація перетягування/падіння у Vue 3 ніколи не була простішою за допомогою бібліотеки пакетів Vue Draggable.

Прив'язую Draggable компонент до cards змінної. Давайте перевіримо, де визначається ця змінна.

const cards = ref(props?.column?.cards);

Це ref() обгортання масиву карток на вхідній властивості column.

Ми також зачіпаємо дві події, що випромінюються компонентомDraggable: change і end.

Давайте дослідимо обробника onReorderCards() подій.

const onReorderCards = () => {
    const cloned = cloneDeep(cards?.value);

    const cardsWithOrder = [
        ...cloned?.map((card, index) => ({
            id: card.id,
            position: index * 1000 + 1000,
        })),
    ];
    emit('reorder-change', {
        id: props?.column?.id,
        cards: cardsWithOrder,
    });
};

Метод починається з клонування cards реактивної змінної. Таким чином, ми переконуємося, що не торкаємося оригінальних карток.

Код проходить через клоновану версію cards. Він відображає кожну картку в об'єкт з двома властивостями: id і position.

Пам'ятайте, що cards завжди тримайте останню re-ordered версію карток стовпців. Тому те, що робить наведений вище код, - це скидання position властивості для всіх карток і присвоєння їм послідовних позицій, що представляють фактичний порядок цих карток у стовпці.

Потім метод випромінює подію, що проходить як корисне навантаження на подіюreorder-change, об'єкт, що утримує стовпця ідентифікатор Нарешті і знову розташовані карти.

Цей метод виконується один раз для будь-якого стовпця, що бере участь в операції перетягування. Наприклад, якщо ви перетягуєте в межах одного стовпця, цей обробник запускається один раз. Але уявіть, що ви перетягуєте/перетягуєте картку з одного стовпця в інший. Потім цей обробник запускається один раз для кожного стовпця.

Давайте обговоримо onReorderEnds() хендлера заходу.

const onReorderEnds = () => {
  emit('reorder-commit');
};

Обробник подій просто виділяє іншу подію, reorder-commit подію. Компонент Draggable запускає подію, end коли операція перетягування/падіння закінчується. Отже, ми могли б використовувати цей обробник подій для збереження змін у базі даних. Давайте подивимося, як ми це робимо.

Тепер ми хочемо перевірити, як ми передаємо обидві події reorder-change і reorder-commit.

Повертаючись до компонентаKanban, ми бачили, як ми відтворюємо стовпці.

<Column
   v-for="column in columns"
   :key="column.title"
   :column="column"
   @reorder-change="onReorderChange"
   @reorder-commit="onReorderCommit"
/>

Зверніть увагу, як ми зачіпаємо обидві події. Давайте розглянемо обидва обробники подій по порядку.

Обробник onReorderchange() подій визначається наступним чином:Це columnsWithOrder Vue JSref(),

const onReorderChange = column => {
  columnsWithOrder.value?.push(column);
};

який обертає масив. Цей метод штовхає корисне навантаження події в цю реактивну змінну.

У разі перетягування/падіння в межах одного стовпця обробник подій запускається один раз. Хоча, у випадку перетягування/падіння картки через два стовпці, цей обробник подій працює двічі. Отже, в кінці операції columnsWithOrder буде проведено два елементи, по одному для кожної колони.

З іншого боку, обробник подій визначається наступним чином:

const onReorderCommit = () => {
  if (!columnsWithOrder?.value?.length) {
    return;
  }
  router.put(route('cards.reorder'), {
    columns: columnsWithOrder.value,
  });
};

Цей обробник onReorderCommit() подій виконує запит POST на маршрут.cards.reorder Корисне навантаження - це масив усіх стовпців, на які вплинула операція перетягування, включаючи картки кожного стовпця та нові позиції карток у стовпці.

Давайте перейдемо до коду на стороні сервера та переглянемо код, який фактично виконує оновлення бази даних.

Файл routes/web.php визначає cards.reorder маршрут наступним чином:

Route::put('/cards/reorder', CardsReorderUpdateController::class)->name('cards.reorder');

Маркери CardsReorderUpdateController::class оновлюють картки в базі даних.

public function __invoke(CardsReorderUpdateRequest $request): RedirectResponse
{
    $data = collect($request->columns)
                ->recursive() // make all nested arrays a collection
                ->map(function ($column) {
                    return $column['cards']->map(function ($card) use ($column) {
                        return ['id' => $card['id'], 'position' => $card['position'], 'column_id' => $column['id']];
                 });
            })
            ->flatten(1)
            ->toArray();

    // Batch
    Card::query()->upsert($data, ['id'], ['position', 'column_id']);

    return back();
}

Давайте крок за кроком розглянемо цей код.

Перш за все, ми використовуємо CardsReorderUpdateRequest::class [Запит форми] (https://laravel.com/docs/10.x/validation#form-request-validation).

Дослідимо rules() функцію об'єкта запиту форми.

public function rules(): array
{
    return [
        'columns.*.id' => ['integer', 'required', 'exists:\App\Models\Column,id'],
        'columns.*.cards' => ['required', 'array'],
        'columns.*.cards.*.id' => ['required', 'integer', 'exists:\App\Models\Card,id'],
        'columns.*.cards.*.position' => ['numeric', 'required'],
    ];
}

Laravel дозволяє нам перевіряти вкладені масиви за допомогою API перевірки.

У цьому випадку ми перевіряємо дворівневий вкладений масив, щоб переконатися, що стовпця ідентифікатор Нарешті всі вони існують у ідентифікатор картки ідентифікатор базі даних. Ви можете пропустити цю перевірку, але це гарантія того, що ніхто не зможе зламати вашу дошку. Тільки пам'ятайте, для кожного стовпця і карти в запиті корисного навантаження, буде запит бази даних на перевірку відповідних id існуючих в базі даних.

Перейдемо назад до контролера.

Ми починаємо з того, що загортаємо корисне навантаження масиву в колекцію Laravel.

$data = collect($request->columns)

Відразу після цього я перетворюю всі масиви PHP в колекції Laravel на всіх рівнях у вхідному корисному навантаженні, використовуючи користувальницький макрос Laravel на ім'я, який я recursive визначив всередині AppServiceProvider.

$data = collect($request->columns)->recursive()

Мені просто легше мати справу з колекціями, а не з масивами. Таким чином, я міг би використовувати функції в більш послідовному та ефективному коді.

Продовжимо!

Далі я перетворюю вхідні записи в набір об'єктів. Кожен об'єкт має такі властивості:

  • ідентифікатор картки ідентифікатор
  • позиції
  • стовпця ідентифікатор Нарешті
->map(function ($column) {
    return $column['cards']->map(function ($card) use ($column) {
            return ['id' => $card['id'], 'position' => $card['position'], 'column_id' => $column['id']];
        }
    );
})

, я об'єдную колекцію, щоб отримати листові вузли та перетворити на масив.

At the end, we will have an array of objects. Each object specifies the ідентифікатор картки ідентифікатор, the new card position, and the new стовпця ідентифікатор Нарешті in case the card was dragged/dropped to another column.

Нарешті, я використовую метод Laravel Eloquent upsert() для виконання операції пакетного оновлення.

Метод upserts() може виконувати вставки та оновлення одночасно. Однак у нашому випадку, оскільки ми маємо перевірку, щоб переконатися, що стовпці та картки дійсно існують у базі даних, вона завжди виконуватиме операцію оновлення. Ми хочемо оновити нові позиції та колонки картки.

За визначенням, метод приймає три параметри:

  • $values параметр утримує дані, upsert() що підлягають оновленню/вставці в базу даних$uniqueBy параметр для спроби отримати записи спочатку (якщо він існує)
  • $update параметр - це масив стовпців бази даних
  • для оновлення
Card::query()->upsert($data, ['id'], ['position', 'column_id']);

У нашому випадку $values параметр утримує масив об'єктів карток з їх новим положенням і новим стовпцем (при зміні).

The $unique parameter is the card id. Therefore, the upsert() checks if the record exists in the cards database table based on the ідентифікатор картки ідентифікатор.

Нарешті, $update параметр утримує масив стовпців для оновлення в базі даних. У нашому випадку ми хочемо оновити як стовпці, так і column_id стовпці position в cards таблиці бази даних.

Заява вище оновлює всі картки в корисному навантаженні за допомогою єдиної інструкції бази даних.

insert into `cards` (`column_id`, `created_at`, `id`, `position`, `updated_at`) values (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?) on duplicate key update `position` = values(`position`), `column_id` = values(`column_id`), `updated_at` = values(`updated_at`)",[/* … */]

This way, no matter how many cards are involved, there will be a single database operation to update the new position and стовпця ідентифікатор Нарештіs.

Посилання

Я хотів би віддати належне кільком ресурсам, якими я скористався під час написання цієї статті та створення її вихідного коду.

Висновок

Я залишив багато деталей, пов'язаних з Vue JS, без пояснень. Причиною цього є те, що я хотів зосередитися на функції перетягування карток і на тому, як ми ефективно оновлюємо записи в базі даних.

Якщо вам потрібно запитати про вихідний код, не соромтеся надсилати мені свої запитання та запити електронною поштою.

Ви можете витягнути репозиторій на GitHub Laravel Kanban і спробувати його самостійно локально.

Comments

No comments yet
Yurij Finiv

Yurij Finiv

Full stack

Про мене

Professional Fullstack Developer with extensive experience in website and desktop application development. Proficient in a wide range of tools and technologies, including Bootstrap, Tailwind, HTML5, CSS3, PUG, JavaScript, Alpine.js, jQuery, PHP, MODX, and Node.js. Skilled in website development using Symfony, MODX, and Laravel. Experience: Contributed to the development and translation of MODX3 i...

Про автора CrazyBoy49z
WORK EXPERIENCE
Контакти
Ukraine, Lutsk
+380979856297