Rust + Axum: Добавляем поддержку мобильных устройств в Kanban-доску (тач-перетаскивание и адаптивный дизайн)

Rust + Axum: Добавляем поддержку мобильных устройств в Kanban-доску (тач-перетаскивание и адаптивный дизайн)

Kanban доска на мобильном устройстве

Продолжаем работать над Kanban-доской, созданной в предыдущей статье. В этот раз добавим поддержку мобильных устройств: адаптивный дизайн и тач-перетаскивание задач с помощью библиотеки SortableJS.

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

Цель

Сделать Kanban-доску адаптивной под мобильные устройства и добавить поддержку тач-перетаскивания задач.

Что будем использовать

  • SortableJS — библиотека для перетаскивания с поддержкой тача
  • CSS Media Queries — адаптивный дизайн
  • Axum — как и раньше, отдаёт статику

Подключаем SortableJS

1. Скачиваем библиотеку

cd /srv/apps/kanban/kanban-board
wget https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js -O static/Sortable.min.js

2. Проверяем, что файл появился

ls -la static/Sortable.min.js

Обновляем шаблоны

1. templates/base.html

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Kanban Board</title>
    <link rel="stylesheet" href="/static/style.css">
</head>
<body>
    <div class="container">
        {% block content %}{% endblock %}
    </div>
    <script src="/static/Sortable.min.js"></script>
    <script src="/static/script.js"></script>
</body>
</html>

Обновляем JavaScript

1. static/script.js

Замените всё содержимое:

let tasks = [];

async function loadTasks() {
    const response = await fetch('/api/tasks');
    tasks = await response.json();
    renderTasks();
}

function renderTasks() {
    const columns = { todo: [], in_progress: [], done: [] };
    tasks.forEach(task => {
        columns[task.status].push(task);
    });

    ['todo', 'in_progress', 'done'].forEach(status => {
        const container = document.getElementById(status);
        container.innerHTML = '';
        columns[status].forEach(task => {
            const taskEl = document.createElement('div');
            taskEl.className = 'task';
            taskEl.dataset.id = task.id;
            taskEl.dataset.status = task.status;

            taskEl.innerHTML = `
                <div class="task-header">
                    <div class="task-title">${task.title}</div>
                    <button class="delete-btn" onclick="deleteTask('${task.id}')">✕</button>
                </div>
                <div class="task-desc">${task.description || ''}</div>
            `;
            container.appendChild(taskEl);
        });

        // Инициализация SortableJS
        new Sortable(container, {
            group: 'tasks',
            animation: 150,
            onEnd: function (evt) {
                const id = evt.item.dataset.id;
                const newStatus = evt.to.id;
                updateTaskStatus(id, newStatus);
            }
        });
    });
}

async function addTask() {
    const title = document.getElementById('newTaskTitle').value;
    const desc = document.getElementById('newTaskDesc').value;
    if (!title) return;

    const response = await fetch('/api/tasks', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ title, description: desc || null })
    });

    if (response.ok) {
        document.getElementById('newTaskTitle').value = '';
        document.getElementById('newTaskDesc').value = '';
        loadTasks();
    }
}

async function deleteTask(id) {
    if (!confirm('Вы уверены, что хотите удалить задачу?')) return;

    const response = await fetch(`/api/tasks/${id}`, {
        method: 'DELETE'
    });

    if (response.ok) {
        loadTasks();
    }
}

async function updateTaskStatus(id, status) {
    const response = await fetch(`/api/tasks/${id}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ status: status })
    });

    if (response.ok) {
        loadTasks();
    }
}

loadTasks();

Обновляем CSS для адаптивности

1. static/style.css

Замените всё содержимое:

:root {
    --bg-dark: #0f172a;
    --bg-card: #1e293b;
    --text-light: #f1f5f9;
    --accent-blue: #3b82f6;
    --accent-green: #10b981;
    --accent-red: #ef4444;
}

body {
    margin: 0;
    font-family: system-ui, sans-serif;
    background-color: var(--bg-dark);
    color: var(--text-light);
}

.container {
    padding: 20px;
}

.board h1 {
    text-align: center;
    margin-bottom: 30px;
    color: #93c5fd;
}

.add-task {
    display: flex;
    flex-direction: column;
    gap: 10px;
    margin-bottom: 30px;
    max-width: 500px;
    margin-left: auto;
    margin-right: auto;
}

.add-task input, .add-task textarea {
    padding: 10px;
    border-radius: 6px;
    border: 1px solid #475569;
    background-color: var(--bg-card);
    color: var(--text-light);
}

.add-task button {
    padding: 10px 15px;
    background-color: var(--accent-blue);
    color: white;
    border: none;
    border-radius: 6px;
    cursor: pointer;
}

.columns {
    display: flex;
    gap: 20px;
    justify-content: center;
    flex-wrap: wrap;
}

.column {
    background-color: #334155;
    border-radius: 8px;
    width: 300px;
    padding: 15px;
    min-height: 500px;
}

.column h2 {
    margin-top: 0;
    text-align: center;
    color: #c7d2fe;
}

.tasks {
    min-height: 450px;
    padding: 10px;
}

.task {
    background-color: var(--bg-card);
    border-radius: 6px;
    padding: 12px;
    margin-bottom: 10px;
    cursor: grab;
    box-shadow: 0 4px 6px rgba(0,0,0,0.1);
    border-left: 4px solid var(--accent-blue);
    position: relative;
}

.task[data-status="in_progress"] {
    border-left-color: var(--accent-green);
}

.task[data-status="done"] {
    border-left-color: var(--accent-red);
}

.task:hover {
    opacity: 0.9;
}

.task-header {
    display: flex;
    justify-content: space-between;
    align-items: flex-start;
    margin-bottom: 5px;
}

.task-title {
    font-weight: bold;
    margin-bottom: 0;
    flex-grow: 1;
}

.delete-btn {
    background: none;
    border: none;
    color: #94a3b8;
    cursor: pointer;
    font-size: 16px;
    padding: 0;
    margin-left: 8px;
}

.delete-btn:hover {
    color: var(--accent-red);
}

.task-desc {
    font-size: 0.9em;
    color: #cbd5e1;
}

/* Адаптив для мобильных */
@media (max-width: 768px) {
    .columns {
        flex-direction: column;
        align-items: center;
    }

    .column {
        width: 100%;
        max-width: 300px;
    }

    .task {
        padding: 10px;
    }

    .task-header {
        flex-direction: column;
        align-items: flex-start;
    }

    .delete-btn {
        margin-top: 5px;
        margin-left: 0;
    }
}

Пересборка и запуск

1. Войдите под пользователем kanban

sudo -u kanban -H bash
cd /srv/apps/kanban/kanban-board

2. Убедитесь, что cargo доступен

export PATH="$HOME/.cargo/bin:$PATH"
which cargo

3. Пересоберите проект

cargo build --release

4. Выйдите из сессии

exit

5. Перезапустите сервис

sudo systemctl restart kanban-board

6. Проверьте статус

sudo systemctl status kanban-board

Проверка на мобильном устройстве

Откройте http://192.168.1.xxx:8082:

  • Колонки располагаются вертикально на маленьком экране
  • Можно перетаскивать задачи пальцем
  • Все задачи видны в каждой колонке
  • Кнопка удаления работает

Готово!

Теперь ваша Kanban-доска:

  • Адаптивна под мобильные устройства
  • Поддерживает тач-перетаскивание
  • Совместима с десктопом
  • Всё работает как раньше
Приятного использования на телефоне!