1
0

Управление проектами

Добавил возможность удаления, создание и редактирование проектов.
This commit is contained in:
2026-01-18 10:19:34 +07:00
parent 15725ae90a
commit 250eac70a7
11 changed files with 2273 additions and 16 deletions

View File

@@ -64,6 +64,77 @@ export const projectsApi = {
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'get_project_data', id_project })
}),
// Создание проекта
create: (name) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'create', name })
}),
// Обновление проекта
update: (id, name) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'update', id, name })
}),
// Удаление проекта
delete: (id) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete', id })
}),
// Обновление порядка проектов
updateOrder: (ids) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'update_order', ids })
}),
// ==================== КОЛОНКИ ====================
// Добавление колонки
addColumn: (project_id, name, color) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'add_column', project_id, name, color })
}),
// Обновление колонки
updateColumn: (id, name, color) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'update_column', id, name, color })
}),
// Получение количества задач в колонке
getColumnTasksCount: (id) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'get_column_tasks_count', id })
}),
// Удаление колонки
deleteColumn: (id) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete_column', id })
}),
// Обновление порядка колонок
updateColumnsOrder: (project_id, ids) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'update_columns_order', project_id, ids })
}),
// Установка финальной колонки
setReadyColumn: (project_id, column_id) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'set_ready_column', project_id, column_id })
})
}

View File

@@ -1,6 +1,16 @@
<template>
<div class="filters">
<ProjectSelector @change="$emit('project-change')" />
<ProjectSelector
@change="$emit('project-change')"
@edit="$emit('edit-project', $event)"
/>
<button
class="add-project-btn"
title="Создать проект"
@click="$emit('create-project')"
>
<i data-lucide="plus"></i>
</button>
<div class="filter-divider"></div>
<button
class="filter-tag"
@@ -22,6 +32,7 @@
</template>
<script setup>
import { onMounted, onUpdated } from 'vue'
import ProjectSelector from './ProjectSelector.vue'
import { useProjectsStore } from '../stores/projects'
@@ -34,7 +45,39 @@ defineProps({
}
})
defineEmits(['update:modelValue', 'project-change'])
defineEmits(['update:modelValue', 'project-change', 'create-project', 'edit-project'])
const refreshIcons = () => {
if (window.lucide) window.lucide.createIcons()
}
onMounted(refreshIcons)
onUpdated(refreshIcons)
</script>
<!-- Стили определены в PageLayout.vue через :deep(.filters) -->
<style scoped>
.add-project-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: none;
border: none;
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
padding: 0;
margin-left: 6px;
}
.add-project-btn:hover {
color: var(--accent);
}
.add-project-btn i {
width: 14px;
height: 14px;
}
</style>

View File

@@ -0,0 +1,856 @@
<template>
<SlidePanel
:show="show"
@close="tryClose"
>
<template #header>
<h2>{{ isNew ? 'Новый проект' : 'Настройки проекта' }}</h2>
</template>
<template #default>
<!-- Название проекта -->
<div class="form-section">
<label class="form-label">Название проекта</label>
<TextInput
v-model="form.name"
placeholder="Введите название"
/>
</div>
<!-- Колонки -->
<div class="form-section">
<label class="form-label">Колонки</label>
<div class="columns-list" ref="columnsListRef">
<div
v-for="(column, index) in form.columns"
:key="column.id || column.tempId"
class="column-item"
:class="{
'is-ready': isReadyColumn(column),
'is-dragging': !isMobile && !isLastColumn(index) && dragIndex === index,
'drag-over-top': !isMobile && dragOverIndex === index && dragPosition === 'top',
'drag-over-bottom': !isMobile && !isLastColumn(index) && dragOverIndex === index && dragPosition === 'bottom'
}"
:draggable="!isMobile && !isLastColumn(index)"
@dragstart="!isMobile && !isLastColumn(index) && handleColumnDragStart($event, index)"
@dragend="!isMobile && handleColumnDragEnd()"
@dragover.prevent="!isMobile && handleColumnDragOver($event, index)"
@dragleave="!isMobile && handleColumnDragLeave()"
@drop.prevent="!isMobile && handleColumnDrop(index)"
>
<!-- Desktop: drag handle (не показываем для последней колонки и если только 2 колонки) -->
<div v-if="!isMobile && !isLastColumn(index) && canReorderColumns" class="column-drag-handle">
<i data-lucide="grip-vertical"></i>
</div>
<!-- Mobile: up/down arrows (скрываем если обе кнопки были бы неактивны) -->
<div v-else-if="isMobile && !isLastColumn(index) && (index > 0 || index < form.columns.length - 2)" class="column-move-buttons">
<button
class="move-btn"
:disabled="index === 0"
@click="moveColumnUp(index)"
title="Вверх"
>
<i data-lucide="chevron-up"></i>
</button>
<button
class="move-btn"
:disabled="index === form.columns.length - 2"
@click="moveColumnDown(index)"
title="Вниз"
>
<i data-lucide="chevron-down"></i>
</button>
</div>
<ColorPicker v-model="column.color" />
<input
type="text"
class="column-name"
:ref="el => columnInputRefs[index] = el"
v-model="column.name_columns"
placeholder="Название колонки"
/>
<!-- Иконка редактирования -->
<button
class="column-edit-btn"
title="Редактировать название"
@click="focusColumnName(index)"
>
<i data-lucide="pencil"></i>
</button>
<!-- Маркер финальной колонки (только отображение) -->
<span
v-if="isLastColumn(index)"
class="column-ready-marker"
title="Финальная колонка (Готово)"
>
<i data-lucide="check-circle"></i>
</span>
<!-- Кнопка удаления: скрыта для последней колонки и если только 2 колонки -->
<button
v-if="!isLastColumn(index) && form.columns.length > 2"
class="column-delete-btn"
title="Удалить колонку"
@click="confirmDeleteColumn(index)"
>
<i data-lucide="trash-2"></i>
</button>
</div>
</div>
<button class="add-column-btn" @click="addColumn">
<i data-lucide="plus"></i>
Добавить колонку
</button>
</div>
</template>
<template #footer>
<div class="footer-left">
<IconButton
v-if="!isNew"
icon="trash-2"
variant="danger"
title="Удалить проект"
@click="handleDeleteProject"
/>
</div>
<ActionButtons
:save-text="isNew ? 'Создать' : 'Сохранить'"
:loading="isSaving"
:disabled="!canSave"
@save="handleSave"
@cancel="tryClose"
/>
</template>
</SlidePanel>
<!-- Диалог удаления проекта -->
<ConfirmDialog
:show="showDeleteProjectDialog"
type="deleteProject"
:action="confirmDeleteProject"
@confirm="showDeleteProjectDialog = false"
@cancel="showDeleteProjectDialog = false"
/>
<!-- Диалог удаления колонки -->
<ConfirmDialog
:show="showDeleteColumnDialog"
type="deleteColumn"
:message="deleteColumnMessage"
:action="confirmDeleteColumnAction"
@confirm="showDeleteColumnDialog = false"
@cancel="showDeleteColumnDialog = false"
/>
<!-- Диалог несохранённых изменений -->
<ConfirmDialog
:show="showUnsavedDialog"
type="unsavedChanges"
@confirm="confirmSave"
@cancel="showUnsavedDialog = false"
@discard="confirmDiscard"
/>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import SlidePanel from './ui/SlidePanel.vue'
import TextInput from './ui/TextInput.vue'
import ColorPicker from './ui/ColorPicker.vue'
import IconButton from './ui/IconButton.vue'
import ActionButtons from './ui/ActionButtons.vue'
import ConfirmDialog from './ConfirmDialog.vue'
import { useLucideIcons } from '../composables/useLucideIcons'
import { useMobile } from '../composables/useMobile'
import { useProjectsStore } from '../stores/projects'
const { refreshIcons } = useLucideIcons()
const { isMobile } = useMobile()
const store = useProjectsStore()
const props = defineProps({
show: Boolean,
project: Object // null = создание, object = редактирование
})
const emit = defineEmits(['close', 'saved'])
// State
const isNew = computed(() => !props.project)
const isSaving = ref(false)
// Form data
const form = ref({
name: '',
columns: []
})
// Initial form for change detection
const initialForm = ref(null)
// Dialogs
const showDeleteProjectDialog = ref(false)
const showDeleteColumnDialog = ref(false)
const showUnsavedDialog = ref(false)
const columnToDelete = ref(null)
const deleteColumnMessage = ref('')
// Drag state
const dragIndex = ref(null)
const dragOverIndex = ref(null)
const dragPosition = ref(null) // 'top' | 'bottom'
const columnsListRef = ref(null)
const columnInputRefs = ref([])
// Temp ID counter for new columns
let tempIdCounter = 0
// Can save
const canSave = computed(() => {
return form.value.name?.trim() && form.value.columns.length > 0
})
// Можно ли сортировать колонки (больше 2 колонок = минимум 2 перемещаемые + ready)
const canReorderColumns = computed(() => {
return form.value.columns.length > 2
})
// Has changes
const hasChanges = computed(() => {
if (!initialForm.value) return false
return JSON.stringify(form.value) !== JSON.stringify(initialForm.value)
})
// ==================== COLUMNS MANAGEMENT ====================
// Фокус на поле названия колонки
const focusColumnName = (index) => {
const input = columnInputRefs.value[index]
if (input) {
input.focus()
// Ставим курсор в конец текста
const len = input.value.length
input.setSelectionRange(len, len)
}
}
// Проверка является ли колонка последней (финальной)
const isLastColumn = (index) => {
return index === form.value.columns.length - 1
}
// Для совместимости со стилями (is-ready класс)
const isReadyColumn = (column) => {
const index = form.value.columns.findIndex(c =>
(c.id && c.id === column.id) || (c.tempId && c.tempId === column.tempId)
)
return isLastColumn(index)
}
// Цвета для новых колонок
const columnColors = [
'#ef4444', '#f97316', '#f59e0b', '#eab308', '#84cc16',
'#22c55e', '#10b981', '#14b8a6', '#06b6d4', '#0ea5e9',
'#3b82f6', '#6366f1', '#8b5cf6', '#a855f7', '#d946ef',
'#ec4899', '#f43f5e'
]
const getRandomColor = () => {
// Получаем цвета уже используемых колонок
const usedColors = form.value.columns.map(c => c.color)
// Фильтруем доступные цвета
const availableColors = columnColors.filter(c => !usedColors.includes(c))
// Если все цвета использованы — берём из полного списка
const colorPool = availableColors.length > 0 ? availableColors : columnColors
return colorPool[Math.floor(Math.random() * colorPool.length)]
}
const addColumn = () => {
// Находим индекс финальной колонки
const readyIndex = form.value.columns.findIndex(col => isReadyColumn(col))
const newColumn = {
tempId: `new-${++tempIdCounter}`,
name_columns: '',
color: getRandomColor(),
id_order: 0
}
// Вставляем перед финальной колонкой, или в конец если финальная не найдена
if (readyIndex !== -1) {
form.value.columns.splice(readyIndex, 0, newColumn)
} else {
form.value.columns.push(newColumn)
}
// Обновляем id_order у всех колонок
form.value.columns.forEach((col, idx) => {
col.id_order = idx + 1
})
nextTick(refreshIcons)
}
const confirmDeleteColumn = async (index) => {
const column = form.value.columns[index]
columnToDelete.value = index
// Для существующих колонок проверяем количество задач
if (column.id) {
const count = await store.getColumnTasksCount(column.id)
if (count > 0) {
deleteColumnMessage.value = `В колонке ${count} задач.<br>Все они будут удалены.`
} else {
deleteColumnMessage.value = 'Колонка будет удалена.'
}
} else {
deleteColumnMessage.value = 'Колонка будет удалена.'
}
showDeleteColumnDialog.value = true
}
const confirmDeleteColumnAction = async () => {
const index = columnToDelete.value
const column = form.value.columns[index]
// Если колонка уже существует на сервере — удаляем через API
if (column.id) {
const result = await store.deleteColumn(column.id)
if (!result.success) {
throw new Error(result.errors?.column || 'Ошибка удаления')
}
}
// Удаляем из формы
form.value.columns.splice(index, 1)
columnToDelete.value = null
}
// ==================== MOBILE: MOVE COLUMNS ====================
const moveColumnUp = async (index) => {
if (index <= 0) return
const columns = form.value.columns
;[columns[index - 1], columns[index]] = [columns[index], columns[index - 1]]
// Update id_order
columns.forEach((col, idx) => {
col.id_order = idx + 1
})
// Save to server if editing
if (!isNew.value) {
const ids = columns.filter(c => c.id).map(c => c.id)
if (ids.length > 0) {
await store.reorderColumns(ids)
}
}
nextTick(refreshIcons)
}
const moveColumnDown = async (index) => {
// Нельзя перемещать последнюю колонку и нельзя менять местами с последней
const lastIndex = form.value.columns.length - 1
if (index >= lastIndex - 1) return
const columns = form.value.columns
;[columns[index], columns[index + 1]] = [columns[index + 1], columns[index]]
// Update id_order
columns.forEach((col, idx) => {
col.id_order = idx + 1
})
// Save to server if editing
if (!isNew.value) {
const ids = columns.filter(c => c.id).map(c => c.id)
if (ids.length > 0) {
await store.reorderColumns(ids)
}
}
nextTick(refreshIcons)
}
// ==================== DRAG AND DROP COLUMNS (Desktop) ====================
const handleColumnDragStart = (e, index) => {
dragIndex.value = index
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', index.toString())
}
const handleColumnDragEnd = () => {
dragIndex.value = null
dragOverIndex.value = null
dragPosition.value = null
}
const handleColumnDragOver = (e, index) => {
if (dragIndex.value === null || dragIndex.value === index) return
// Нельзя перетаскивать НА последнюю колонку (она зафиксирована)
const lastIndex = form.value.columns.length - 1
if (index === lastIndex) return
const rect = e.currentTarget.getBoundingClientRect()
const midY = rect.top + rect.height / 2
dragOverIndex.value = index
// Для предпоследней колонки: нельзя drop "bottom" (это поставит после последней)
if (index === lastIndex - 1) {
dragPosition.value = 'top'
} else {
dragPosition.value = e.clientY < midY ? 'top' : 'bottom'
}
}
const handleColumnDragLeave = () => {
// Don't clear immediately to avoid flickering
}
const handleColumnDrop = async (targetIndex) => {
if (dragIndex.value === null || dragIndex.value === targetIndex) {
handleColumnDragEnd()
return
}
const lastIndex = form.value.columns.length - 1
// Нельзя drop на последнюю колонку
if (targetIndex === lastIndex) {
handleColumnDragEnd()
return
}
const sourceIndex = dragIndex.value
let insertIndex = targetIndex
// Adjust insert index based on drag position
if (dragPosition.value === 'bottom') {
insertIndex = targetIndex + 1
}
// If moving down, adjust for removal
if (sourceIndex < insertIndex) {
insertIndex--
}
// Не даём вставить на позицию последней колонки (перед ready)
const maxInsertIndex = lastIndex - 1
if (insertIndex > maxInsertIndex) {
insertIndex = maxInsertIndex
}
// Reorder array
const [movedColumn] = form.value.columns.splice(sourceIndex, 1)
form.value.columns.splice(insertIndex, 0, movedColumn)
// Update id_order
form.value.columns.forEach((col, idx) => {
col.id_order = idx + 1
})
// If editing existing project — save order to server
if (!isNew.value) {
const ids = form.value.columns.filter(c => c.id).map(c => c.id)
if (ids.length > 0) {
await store.reorderColumns(ids)
}
}
handleColumnDragEnd()
}
// ==================== SAVE / CLOSE ====================
const handleSave = async () => {
if (!canSave.value || isSaving.value) return
isSaving.value = true
try {
if (isNew.value) {
// Создание проекта
const result = await store.createProject(form.value.name)
if (result.success) {
// Обновляем колонки если есть изменения
// Дефолтные колонки уже созданы сервером
// Здесь можно добавить логику для кастомизации колонок при создании
emit('saved', result.id)
emit('close')
}
} else {
// Редактирование проекта
// Обновляем название если изменилось
if (form.value.name !== initialForm.value.name) {
await store.updateProject(props.project.id, form.value.name)
}
// Обрабатываем новые колонки
for (const column of form.value.columns) {
if (column.tempId && !column.id) {
// Новая колонка
const result = await store.addColumn(column.name_columns, column.color)
if (result.success) {
column.id = result.id
delete column.tempId
}
} else if (column.id) {
// Проверяем изменения в существующей колонке
const original = initialForm.value.columns.find(c => c.id === column.id)
if (original) {
const nameChanged = column.name_columns !== original.name_columns
const colorChanged = column.color !== original.color
if (nameChanged || colorChanged) {
await store.updateColumn(column.id, column.name_columns, column.color)
}
}
}
}
// Сохраняем порядок колонок
const ids = form.value.columns.filter(c => c.id).map(c => c.id)
if (ids.length > 0) {
await store.reorderColumns(ids)
}
emit('saved', props.project.id)
emit('close')
}
} finally {
isSaving.value = false
}
}
const tryClose = () => {
if (hasChanges.value) {
showUnsavedDialog.value = true
} else {
emit('close')
}
}
const confirmSave = () => {
showUnsavedDialog.value = false
handleSave()
}
const confirmDiscard = () => {
showUnsavedDialog.value = false
emit('close')
}
// ==================== DELETE PROJECT ====================
const handleDeleteProject = () => {
showDeleteProjectDialog.value = true
}
const confirmDeleteProject = async () => {
if (!props.project?.id) {
throw new Error('Проект не выбран')
}
const result = await store.deleteProject(props.project.id)
if (!result.success) {
throw new Error(result.errors?.access || 'Ошибка удаления')
}
emit('close')
}
// ==================== WATCH ====================
watch(() => props.show, async (newVal) => {
if (newVal) {
isSaving.value = false
tempIdCounter = 0
if (props.project) {
// Редактирование — загружаем данные
form.value = {
name: props.project.name,
columns: JSON.parse(JSON.stringify(store.columns))
}
} else {
// Создание — дефолтные значения (последняя колонка = финальная)
form.value = {
name: '',
columns: [
{ tempId: 'new-1', name_columns: 'К выполнению', color: '#6366f1', id_order: 1 },
{ tempId: 'new-2', name_columns: 'Готово', color: '#22c55e', id_order: 2 }
]
}
tempIdCounter = 2
}
// Сохраняем начальное состояние
await nextTick()
initialForm.value = JSON.parse(JSON.stringify(form.value))
refreshIcons()
}
})
// Refresh icons when columns change
watch(() => form.value.columns.length, () => {
nextTick(refreshIcons)
})
</script>
<style scoped>
.form-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.form-label {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* ==================== COLUMNS LIST ==================== */
.columns-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.column-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px;
transition: all 0.15s;
}
.column-item:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
.column-item.is-ready {
border-color: var(--green);
background: rgba(34, 197, 94, 0.05);
}
.column-item.is-dragging {
opacity: 0.5;
}
.column-item.drag-over-top {
border-top: 2px solid var(--accent);
padding-top: 6px;
}
.column-item.drag-over-bottom {
border-bottom: 2px solid var(--accent);
padding-bottom: 6px;
}
.column-drag-handle {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: var(--text-muted);
cursor: grab;
flex-shrink: 0;
}
.column-drag-handle:active {
cursor: grabbing;
}
.column-drag-handle i {
width: 16px;
height: 16px;
}
/* Mobile: up/down buttons */
.column-move-buttons {
display: flex;
flex-direction: column;
gap: 2px;
flex-shrink: 0;
}
.move-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 18px;
background: rgba(255, 255, 255, 0.06);
border: none;
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
}
.move-btn i {
width: 14px;
height: 14px;
}
.move-btn:not(:disabled):hover,
.move-btn:not(:disabled):active {
background: rgba(255, 255, 255, 0.12);
color: var(--accent);
}
.move-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.column-name {
flex: 1;
min-width: 0;
background: transparent;
border: none;
color: var(--text-primary);
font-family: inherit;
font-size: 14px;
outline: none;
padding: 8px;
}
.column-name::placeholder {
color: var(--text-muted);
}
.column-name:focus {
background: rgba(255, 255, 255, 0.04);
border-radius: 4px;
}
.column-edit-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: none;
border: none;
border-radius: 6px;
color: var(--text-muted);
opacity: 0.5;
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.column-edit-btn i {
width: 14px;
height: 14px;
}
.column-edit-btn:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.06);
color: var(--accent);
}
/* Маркер финальной колонки (не кликабельный) */
.column-ready-marker {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: var(--green);
flex-shrink: 0;
}
.column-ready-marker i {
width: 16px;
height: 16px;
}
.column-delete-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: none;
border: none;
border-radius: 6px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.column-delete-btn i {
width: 16px;
height: 16px;
}
.column-delete-btn:hover {
background: rgba(239, 68, 68, 0.1);
color: var(--red);
}
/* ==================== ADD COLUMN BUTTON ==================== */
.add-column-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 12px;
background: rgba(255, 255, 255, 0.03);
border: 1px dashed rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: var(--text-muted);
font-family: inherit;
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.add-column-btn:hover {
background: rgba(255, 255, 255, 0.06);
border-color: var(--accent);
color: var(--accent);
}
.add-column-btn i {
width: 16px;
height: 16px;
}
/* ==================== FOOTER ==================== */
.footer-left {
display: flex;
gap: 8px;
}
</style>

View File

@@ -7,15 +7,41 @@
<i data-lucide="chevron-down" class="chevron" :class="{ open: dropdownOpen }"></i>
</button>
<div class="project-dropdown" v-if="dropdownOpen">
<button
v-for="project in store.projects"
<div
v-for="(project, index) in store.projects"
:key="project.id"
class="project-option"
:class="{ active: store.currentProjectId === project.id }"
@click="handleSelect(project.id)"
class="project-option-wrapper"
:class="{
'is-dragging': dragIndex === index,
'drag-over-top': dragOverIndex === index && dragPosition === 'top',
'drag-over-bottom': dragOverIndex === index && dragPosition === 'bottom'
}"
draggable="true"
@dragstart="handleDragStart($event, index)"
@dragend="handleDragEnd"
@dragover.prevent="handleDragOver($event, index)"
@dragleave="handleDragLeave"
@drop.prevent="handleDrop(index)"
>
{{ project.name }}
</button>
<div class="project-drag-handle">
<i data-lucide="grip-vertical"></i>
</div>
<button
class="project-option"
:class="{ active: store.currentProjectId === project.id }"
@click="handleSelect(project.id)"
>
{{ project.name }}
</button>
<button
v-if="project.id_admin"
class="project-edit-btn"
title="Настройки проекта"
@click.stop="handleEdit(project)"
>
<i data-lucide="settings"></i>
</button>
</div>
</div>
</div>
@@ -32,7 +58,7 @@
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useProjectsStore } from '../stores/projects'
import { useMobile } from '../composables/useMobile'
import MobileSelect from './ui/MobileSelect.vue'
@@ -41,7 +67,72 @@ const store = useProjectsStore()
const { isMobile } = useMobile()
const dropdownOpen = ref(false)
const emit = defineEmits(['change'])
const emit = defineEmits(['change', 'edit'])
// ==================== DRAG AND DROP ====================
const dragIndex = ref(null)
const dragOverIndex = ref(null)
const dragPosition = ref(null) // 'top' | 'bottom'
const handleDragStart = (e, index) => {
dragIndex.value = index
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', index.toString())
}
const handleDragEnd = () => {
dragIndex.value = null
dragOverIndex.value = null
dragPosition.value = null
}
const handleDragOver = (e, index) => {
if (dragIndex.value === null || dragIndex.value === index) return
const rect = e.currentTarget.getBoundingClientRect()
const midY = rect.top + rect.height / 2
dragOverIndex.value = index
dragPosition.value = e.clientY < midY ? 'top' : 'bottom'
}
const handleDragLeave = () => {
// Don't clear immediately to avoid flickering
}
const handleDrop = async (targetIndex) => {
if (dragIndex.value === null || dragIndex.value === targetIndex) {
handleDragEnd()
return
}
const sourceIndex = dragIndex.value
let insertIndex = targetIndex
// Adjust insert index based on drag position
if (dragPosition.value === 'bottom') {
insertIndex = targetIndex + 1
}
// If moving down, adjust for removal
if (sourceIndex < insertIndex) {
insertIndex--
}
// Build new order array
const projects = [...store.projects]
const [movedProject] = projects.splice(sourceIndex, 1)
projects.splice(insertIndex, 0, movedProject)
// Get IDs in new order
const ids = projects.map(p => p.id)
// Update store
await store.reorderProjects(ids)
handleDragEnd()
nextTick(refreshIcons)
}
// ==================== DESKTOP ====================
const handleSelect = async (projectId) => {
@@ -50,6 +141,11 @@ const handleSelect = async (projectId) => {
emit('change', projectId)
}
const handleEdit = (project) => {
dropdownOpen.value = false
emit('edit', project)
}
// Закрытие дропдауна при клике вне
const closeDropdown = (e) => {
if (!e.target.closest('.project-select')) {
@@ -75,14 +171,23 @@ const handleMobileSelect = async (projectId) => {
}
// ==================== LIFECYCLE ====================
const refreshIcons = () => {
if (window.lucide) window.lucide.createIcons()
}
onMounted(() => {
document.addEventListener('click', closeDropdown)
if (window.lucide) window.lucide.createIcons()
refreshIcons()
})
onUnmounted(() => {
document.removeEventListener('click', closeDropdown)
})
// Refresh icons when dropdown opens
watch(dropdownOpen, (open) => {
if (open) nextTick(refreshIcons)
})
</script>
<style scoped>
@@ -169,4 +274,91 @@ onUnmounted(() => {
color: var(--accent);
}
/* ==================== DRAG AND DROP ==================== */
.project-option-wrapper {
display: flex;
align-items: center;
gap: 4px;
border-radius: 6px;
transition: all 0.15s;
}
.project-option-wrapper:hover {
background: rgba(255, 255, 255, 0.03);
}
.project-option-wrapper.is-dragging {
opacity: 0.5;
}
.project-option-wrapper.drag-over-top {
border-top: 2px solid var(--accent);
}
.project-option-wrapper.drag-over-bottom {
border-bottom: 2px solid var(--accent);
}
.project-drag-handle {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
color: var(--text-muted);
cursor: grab;
flex-shrink: 0;
margin-left: 2px;
opacity: 0.5;
transition: opacity 0.15s;
}
.project-drag-handle:hover {
opacity: 1;
}
.project-drag-handle:active {
cursor: grabbing;
}
.project-drag-handle i {
width: 12px;
height: 12px;
}
.project-option-wrapper .project-option {
flex: 1;
min-width: 0;
text-align: left;
}
.project-edit-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
background: none;
border: none;
border-radius: 6px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
margin-right: 2px;
opacity: 0.5;
}
.project-edit-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
opacity: 1;
}
.project-edit-btn i {
width: 13px;
height: 13px;
}
</style>

View File

@@ -0,0 +1,448 @@
<template>
<div class="color-picker" ref="pickerRef">
<!-- Триггер - текущий цвет -->
<button
class="color-trigger"
ref="triggerRef"
:style="{ backgroundColor: modelValue }"
@click="togglePicker"
:title="'Выбрать цвет'"
>
<span class="color-check" v-if="modelValue">
<i data-lucide="check"></i>
</span>
</button>
<!-- Desktop: dropdown палитра (teleport чтобы не обрезалось) -->
<Teleport to="body">
<Transition v-if="!isMobile" name="dropdown">
<div
v-if="isOpen"
class="color-dropdown"
ref="dropdownRef"
:style="dropdownStyle"
>
<div class="color-grid">
<button
v-for="color in colors"
:key="color"
class="color-option"
:class="{ selected: modelValue === color }"
:style="{ backgroundColor: color }"
@click="selectColor(color)"
>
<i v-if="modelValue === color" data-lucide="check"></i>
</button>
</div>
</div>
</Transition>
</Teleport>
<!-- Mobile: bottom sheet -->
<Teleport to="body">
<Transition name="mobile-picker">
<div v-if="isMobile && isOpen" class="mobile-overlay" @click.self="closePicker">
<div class="mobile-sheet">
<div class="sheet-header">
<span class="sheet-title">Выберите цвет</span>
<button class="sheet-close" @click="closePicker">
<i data-lucide="x"></i>
</button>
</div>
<div class="sheet-body">
<div class="color-grid mobile">
<button
v-for="color in colors"
:key="color"
class="color-option"
:class="{ selected: modelValue === color }"
:style="{ backgroundColor: color }"
@click="selectColor(color)"
>
<i v-if="modelValue === color" data-lucide="check"></i>
</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useMobile } from '../../composables/useMobile'
import { useLucideIcons } from '../../composables/useLucideIcons'
const { isMobile } = useMobile()
const { refreshIcons } = useLucideIcons()
const props = defineProps({
modelValue: {
type: String,
default: '#6366f1'
}
})
const emit = defineEmits(['update:modelValue'])
// Предустановленные цвета
const colors = [
// Основные
'#ef4444', // red
'#f97316', // orange
'#f59e0b', // amber
'#eab308', // yellow
'#84cc16', // lime
'#22c55e', // green
'#10b981', // emerald
'#14b8a6', // teal
'#06b6d4', // cyan
'#0ea5e9', // sky
'#3b82f6', // blue
'#6366f1', // indigo
'#8b5cf6', // violet
'#a855f7', // purple
'#d946ef', // fuchsia
'#ec4899', // pink
'#f43f5e', // rose
// Нейтральные
'#64748b', // slate
'#6b7280', // gray
'#78716c', // stone
]
const pickerRef = ref(null)
const triggerRef = ref(null)
const dropdownRef = ref(null)
const isOpen = ref(false)
const dropdownPosition = ref({ top: 0, left: 0 })
// Вычисляем позицию dropdown относительно триггера
const updateDropdownPosition = () => {
if (triggerRef.value) {
const rect = triggerRef.value.getBoundingClientRect()
const dropdownWidth = 220
const dropdownHeight = 320
// Левый край dropdown начинается от левого края триггера
let top = rect.bottom + 8
let left = rect.left
// Проверяем не выходит ли за правый край
if (left + dropdownWidth > window.innerWidth - 16) {
left = window.innerWidth - dropdownWidth - 16
}
// Проверяем не выходит ли за нижний край — открываем вверх
if (top + dropdownHeight > window.innerHeight - 16) {
top = rect.top - dropdownHeight - 8
}
dropdownPosition.value = { top, left }
}
}
const dropdownStyle = computed(() => ({
position: 'fixed',
top: `${dropdownPosition.value.top}px`,
left: `${dropdownPosition.value.left}px`,
zIndex: 2000
}))
const togglePicker = () => {
isOpen.value = !isOpen.value
if (isOpen.value) {
updateDropdownPosition()
nextTick(refreshIcons)
}
}
const closePicker = () => {
isOpen.value = false
}
const selectColor = (color) => {
emit('update:modelValue', color)
// Закрываем dropdown после выбора цвета из палитры
closePicker()
nextTick(refreshIcons)
}
const handleClickOutside = (e) => {
// Проверяем что клик не на триггере и не на dropdown
const isClickOnTrigger = pickerRef.value && pickerRef.value.contains(e.target)
const isClickOnDropdown = dropdownRef.value && dropdownRef.value.contains(e.target)
if (!isClickOnTrigger && !isClickOnDropdown) {
isOpen.value = false
}
}
// Обновляем позицию при скролле/ресайзе
watch(isOpen, (val) => {
if (val && !isMobile.value) {
window.addEventListener('scroll', updateDropdownPosition, true)
window.addEventListener('resize', updateDropdownPosition)
} else {
window.removeEventListener('scroll', updateDropdownPosition, true)
window.removeEventListener('resize', updateDropdownPosition)
}
})
onMounted(() => {
document.addEventListener('click', handleClickOutside)
refreshIcons()
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
window.removeEventListener('scroll', updateDropdownPosition, true)
window.removeEventListener('resize', updateDropdownPosition)
})
</script>
<style scoped>
.color-picker {
position: relative;
}
.color-trigger {
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
}
.color-trigger:hover {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.color-check {
display: none;
}
/* ========== DROPDOWN (Desktop) ========== */
.color-dropdown {
background: #1e1e24;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
padding: 12px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);
min-width: 220px;
}
.color-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
}
.color-option {
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
}
.color-option:hover {
transform: scale(1.15);
z-index: 1;
}
.color-option.selected {
box-shadow: 0 0 0 2px var(--bg-body), 0 0 0 4px currentColor;
}
.color-option i {
width: 16px;
height: 16px;
color: #fff;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
}
/* Кастомный цвет */
.custom-color {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.custom-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
color: var(--text-secondary);
font-size: 13px;
}
.native-picker {
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: 8px;
cursor: pointer;
background: transparent;
}
.native-picker::-webkit-color-swatch-wrapper {
padding: 2px;
}
.native-picker::-webkit-color-swatch {
border-radius: 6px;
border: none;
}
/* Dropdown transition */
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.15s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-8px);
}
/* ========== MOBILE: Bottom Sheet ========== */
.mobile-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 2000;
display: flex;
align-items: flex-end;
justify-content: center;
}
.mobile-sheet {
width: 100%;
max-height: 70vh;
background: var(--bg-body);
border-radius: 20px 20px 0 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.sheet-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.sheet-close {
width: 32px;
height: 32px;
background: rgba(255, 255, 255, 0.06);
border: none;
border-radius: 8px;
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.sheet-close i {
width: 18px;
height: 18px;
}
.sheet-body {
padding: 20px;
padding-bottom: calc(20px + env(safe-area-inset-bottom, 0px));
overflow-y: auto;
}
.color-grid.mobile {
display: grid;
grid-template-columns: repeat(5, 48px);
gap: 10px;
justify-content: center;
}
.color-grid.mobile .color-option {
width: 48px;
height: 48px;
border-radius: 10px;
}
.color-grid.mobile .color-option i {
width: 20px;
height: 20px;
}
.custom-color.mobile {
margin-top: 20px;
padding-top: 20px;
}
.custom-color.mobile .custom-label {
padding: 12px 16px;
background: rgba(255, 255, 255, 0.04);
border-radius: 12px;
font-size: 15px;
}
.custom-preview {
width: 28px;
height: 28px;
border-radius: 8px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
}
.custom-color.mobile .native-picker {
width: 40px;
height: 40px;
margin-left: auto;
}
/* Mobile transition */
.mobile-picker-enter-active,
.mobile-picker-leave-active {
transition: opacity 0.2s ease;
}
.mobile-picker-enter-active .mobile-sheet,
.mobile-picker-leave-active .mobile-sheet {
transition: transform 0.25s ease;
}
.mobile-picker-enter-from,
.mobile-picker-leave-to {
opacity: 0;
}
.mobile-picker-enter-from .mobile-sheet,
.mobile-picker-leave-to .mobile-sheet {
transform: translateY(100%);
}
</style>

View File

@@ -368,7 +368,7 @@ onUnmounted(() => {
.panel.mobile .panel-body {
padding: 16px;
min-height: 0;
gap: 0;
gap: 20px;
overflow-y: auto;
}

View File

@@ -50,5 +50,29 @@ export const DIALOGS = {
confirmText: 'Сохранить',
showDiscard: true,
variant: 'default'
},
// Удаление проекта
deleteProject: {
title: 'Удалить проект?',
message: 'Все задачи, колонки и комментарии<br>будут удалены навсегда.',
confirmText: 'Удалить',
variant: 'danger'
},
// Удаление колонки
deleteColumn: {
title: 'Удалить колонку?',
message: 'Колонка и все задачи в ней<br>будут удалены навсегда.',
confirmText: 'Удалить',
variant: 'danger'
},
// Удаление колонки с задачами (динамический message)
deleteColumnWithTasks: {
title: 'Удалить колонку?',
message: '', // Будет задан динамически
confirmText: 'Удалить',
variant: 'danger'
}
}

View File

@@ -226,6 +226,156 @@ export const useProjectsStore = defineStore('projects', () => {
localStorage.removeItem('currentProjectName')
}
// ==================== CRUD ПРОЕКТОВ ====================
// Создание проекта
const createProject = async (name) => {
const result = await projectsApi.create(name)
if (result.success) {
// Добавляем проект в список
projects.value.push({
id: result.id,
name,
id_order: projects.value.length + 1,
id_ready: result.id_ready,
id_admin: true // Создатель = админ
})
// Переключаемся на новый проект
await selectProject(result.id)
}
return result
}
// Обновление проекта
const updateProject = async (id, name) => {
const result = await projectsApi.update(id, name)
if (result.success) {
const project = projects.value.find(p => p.id === id)
if (project) {
project.name = name
// Обновляем localStorage если это текущий проект
if (id === currentProjectId.value) {
localStorage.setItem('currentProjectName', name)
}
}
}
return result
}
// Удаление проекта
const deleteProject = async (id) => {
const result = await projectsApi.delete(id)
if (result.success) {
const index = projects.value.findIndex(p => p.id === id)
if (index !== -1) {
projects.value.splice(index, 1)
}
// Если удалили текущий проект — переключаемся на первый
if (id === currentProjectId.value) {
if (projects.value.length > 0) {
await selectProject(projects.value[0].id)
} else {
currentProjectId.value = null
columns.value = []
departments.value = []
cards.value = []
localStorage.removeItem('currentProjectId')
localStorage.removeItem('currentProjectName')
}
}
}
return result
}
// Обновление порядка проектов
const reorderProjects = async (ids) => {
// Оптимистичное обновление
const reordered = ids.map((id, index) => {
const project = projects.value.find(p => p.id === id)
return { ...project, id_order: index + 1 }
})
projects.value = reordered
// Отправляем на сервер
await projectsApi.updateOrder(ids)
}
// ==================== CRUD КОЛОНОК ====================
// Добавление колонки
const addColumn = async (name, color = '#6366f1') => {
if (!currentProjectId.value) return { success: false }
const result = await projectsApi.addColumn(currentProjectId.value, name, color)
if (result.success) {
columns.value.push(result.column)
}
return result
}
// Обновление колонки
const updateColumn = async (id, name, color) => {
const result = await projectsApi.updateColumn(id, name, color)
if (result.success) {
const column = columns.value.find(c => c.id === id)
if (column) {
if (name !== null && name !== undefined) column.name_columns = name
if (color !== null && color !== undefined) column.color = color
}
}
return result
}
// Получение количества задач в колонке
const getColumnTasksCount = async (id) => {
const result = await projectsApi.getColumnTasksCount(id)
return result.success ? result.count : 0
}
// Удаление колонки
const deleteColumn = async (id) => {
const result = await projectsApi.deleteColumn(id)
if (result.success) {
const index = columns.value.findIndex(c => c.id === id)
if (index !== -1) {
columns.value.splice(index, 1)
}
// Удаляем карточки этой колонки из локального состояния
cards.value = cards.value.filter(c => c.column_id !== id)
}
return result
}
// Обновление порядка колонок
const reorderColumns = async (ids) => {
if (!currentProjectId.value) return
// Оптимистичное обновление
const reordered = ids.map((id, index) => {
const column = columns.value.find(c => c.id === id)
return { ...column, id_order: index + 1 }
})
columns.value = reordered
// Отправляем на сервер
await projectsApi.updateColumnsOrder(currentProjectId.value, ids)
}
// Установка финальной колонки
const setReadyColumn = async (columnId) => {
if (!currentProjectId.value) return { success: false }
const result = await projectsApi.setReadyColumn(currentProjectId.value, columnId)
if (result.success) {
// Обновляем id_ready в проекте
const project = projects.value.find(p => p.id === currentProjectId.value)
if (project) {
project.id_ready = columnId
}
}
return result
}
return {
// Состояние
projects,
@@ -254,6 +404,18 @@ export const useProjectsStore = defineStore('projects', () => {
fetchCards,
fetchArchivedCards,
clearCards,
reset
reset,
// CRUD проектов
createProject,
updateProject,
deleteProject,
reorderProjects,
// CRUD колонок
addColumn,
updateColumn,
getColumnTasksCount,
deleteColumn,
reorderColumns,
setReadyColumn
}
})

View File

@@ -7,12 +7,35 @@
<DepartmentTags
v-model="activeDepartment"
@project-change="onProjectChange"
@create-project="openCreateProjectPanel"
@edit-project="openEditProjectPanel"
/>
</template>
<!-- Мобильный: Проект + Отделы -->
<template #mobile-filters>
<ProjectSelector @change="onProjectChange" />
<ProjectSelector
@change="onProjectChange"
@edit="openEditProjectPanel"
/>
<!-- Кнопки управления проектами (мобильные) -->
<div class="mobile-project-actions">
<button
v-if="store.isProjectAdmin"
class="mobile-project-btn"
title="Настройки проекта"
@click="openEditProjectPanel(store.currentProject)"
>
<i data-lucide="settings"></i>
</button>
<button
class="mobile-project-btn"
title="Создать проект"
@click="openCreateProjectPanel"
>
<i data-lucide="plus"></i>
</button>
</div>
<MobileSelect
v-model="activeDepartment"
:options="departmentOptions"
@@ -69,6 +92,13 @@
@close="closePanel"
@delete="handleDeleteTask"
/>
<ProjectPanel
:show="projectPanelOpen"
:project="editingProject"
@close="closeProjectPanel"
@saved="onProjectSaved"
/>
</template>
</PageLayout>
</template>
@@ -79,6 +109,7 @@ import PageLayout from '../components/PageLayout.vue'
import Header from '../components/Header.vue'
import Board from '../components/Board.vue'
import TaskPanel from '../components/TaskPanel'
import ProjectPanel from '../components/ProjectPanel.vue'
import DepartmentTags from '../components/DepartmentTags.vue'
import ProjectSelector from '../components/ProjectSelector.vue'
import MobileSelect from '../components/ui/MobileSelect.vue'
@@ -172,6 +203,33 @@ const handleArchiveTask = async (cardId) => {
closePanel()
}
// ==================== ПАНЕЛЬ ПРОЕКТА ====================
const projectPanelOpen = ref(false)
const editingProject = ref(null)
const openCreateProjectPanel = () => {
editingProject.value = null
projectPanelOpen.value = true
}
const openEditProjectPanel = (project) => {
editingProject.value = project
projectPanelOpen.value = true
}
const closeProjectPanel = () => {
projectPanelOpen.value = false
editingProject.value = null
}
const onProjectSaved = async (projectId) => {
// Перезагружаем данные если изменился текущий проект
if (projectId === store.currentProjectId) {
await store.fetchProjectData()
await fetchCards()
}
}
// ==================== АВТООБНОВЛЕНИЕ (POLLING) ====================
const REFRESH_INTERVAL = (window.APP_CONFIG?.IDLE_REFRESH_SECONDS ?? 30) * 1000
let pollTimer = null
@@ -231,4 +289,36 @@ onUnmounted(() => {
.main.mobile {
padding: 0;
}
/* Мобильные кнопки управления проектами */
.mobile-project-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.mobile-project-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: none;
border: none;
border-radius: 6px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
}
.mobile-project-btn:active {
color: var(--accent);
background: rgba(255, 255, 255, 0.06);
}
.mobile-project-btn i {
width: 18px;
height: 18px;
}
</style>