1
0

Правки фронта

Дополнительные адаптации для мобилки
This commit is contained in:
2026-01-15 10:52:25 +07:00
parent 5018a2d123
commit 6f35b84725
7 changed files with 693 additions and 231 deletions

View File

@@ -2,7 +2,7 @@
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<title>TaskBoard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">

View File

@@ -1,5 +1,6 @@
<template>
<div class="project-select" :class="{ mobile: isMobile }" v-if="store.currentProject" @click.stop>
<!-- Desktop: обычный dropdown -->
<div v-if="!isMobile" class="project-select" v-show="store.currentProject" @click.stop>
<button class="project-btn" @click="dropdownOpen = !dropdownOpen">
<i data-lucide="folder" class="folder-icon"></i>
{{ store.currentProject?.name || 'Выберите проект' }}
@@ -17,12 +18,24 @@
</button>
</div>
</div>
<!-- Mobile: полноэкранный селект -->
<MobileSelect
v-else
v-model="mobileProjectId"
:options="projectOptions"
title="Выберите проект"
placeholder="Проект"
variant="accent"
@update:model-value="handleMobileSelect"
/>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useProjectsStore } from '../stores/projects'
import { useMobile } from '../composables/useMobile'
import MobileSelect from './ui/MobileSelect.vue'
const store = useProjectsStore()
const { isMobile } = useMobile()
@@ -30,6 +43,7 @@ const dropdownOpen = ref(false)
const emit = defineEmits(['change'])
// ==================== DESKTOP ====================
const handleSelect = async (projectId) => {
dropdownOpen.value = false
await store.selectProject(projectId)
@@ -43,6 +57,24 @@ const closeDropdown = (e) => {
}
}
// ==================== MOBILE ====================
const mobileProjectId = ref(store.currentProjectId)
const projectOptions = computed(() =>
store.projects.map(p => ({ id: p.id, label: p.name }))
)
// Синхронизируем с текущим проектом
watch(() => store.currentProjectId, (id) => {
mobileProjectId.value = id
}, { immediate: true })
const handleMobileSelect = async (projectId) => {
await store.selectProject(projectId)
emit('change', projectId)
}
// ==================== LIFECYCLE ====================
onMounted(() => {
document.addEventListener('click', closeDropdown)
if (window.lucide) window.lucide.createIcons()
@@ -137,15 +169,4 @@ onUnmounted(() => {
color: var(--accent);
}
/* ========== MOBILE ========== */
.project-select.mobile .project-dropdown {
position: fixed;
top: auto;
left: 16px;
right: 16px;
bottom: 80px;
min-width: auto;
max-height: 60vh;
overflow-y: auto;
}
</style>

View File

@@ -3,7 +3,6 @@
class="comment"
:class="{
'comment--own': isOwn,
'comment--editing': isEditing,
'comment--reply': level > 0
}"
:style="{ marginLeft: level * 24 + 'px' }"
@@ -18,7 +17,7 @@
<span class="comment-author">{{ comment.author_name }}</span>
<span class="comment-date">{{ formattedDate }}</span>
<div v-if="!isEditing" class="comment-actions">
<div class="comment-actions">
<IconButton
icon="reply"
variant="ghost"
@@ -46,29 +45,7 @@
</div>
</div>
<!-- Режим редактирования -->
<div v-if="isEditing" class="comment-edit-form">
<RichTextEditor
:modelValue="editText"
@update:modelValue="$emit('update:editText', $event)"
placeholder="Редактирование комментария..."
:show-toolbar="false"
ref="editEditor"
/>
<div class="comment-edit-actions">
<button class="btn-comment-cancel" @click="$emit('cancel-edit')">Отмена</button>
<button
class="btn-comment-save"
@click="$emit('save-edit')"
:disabled="!editText.trim()"
>
Сохранить
</button>
</div>
</div>
<!-- Обычный вид -->
<div v-else>
<!-- Текст комментария -->
<div class="comment-text" v-html="comment.text"></div>
<!-- Прикреплённые файлы -->
@@ -99,13 +76,11 @@
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, onMounted, onUpdated, watch, nextTick } from 'vue'
import { computed, onMounted, onUpdated } from 'vue'
import IconButton from '../ui/IconButton.vue'
import RichTextEditor from '../ui/RichTextEditor.vue'
import { serverSettings } from '../../api'
const props = defineProps({
@@ -125,23 +100,13 @@ const props = defineProps({
type: Boolean,
default: false
},
isEditing: {
type: Boolean,
default: false
},
editText: {
type: String,
default: ''
},
getFullUrl: {
type: Function,
required: true
}
})
const emit = defineEmits(['reply', 'edit', 'delete', 'cancel-edit', 'save-edit', 'update:editText', 'preview-file'])
const editEditor = ref(null)
const emit = defineEmits(['reply', 'edit', 'delete', 'preview-file'])
const archiveExtensions = ['zip', 'rar']
@@ -182,14 +147,6 @@ const formattedDate = computed(() => {
return `${day} ${months[date.getMonth()]} ${time}`
})
// Установка контента редактора при начале редактирования
watch(() => props.isEditing, async (newVal) => {
if (newVal) {
await nextTick()
editEditor.value?.setContent(props.editText)
}
})
const refreshIcons = () => {
if (window.lucide) {
window.lucide.createIcons()
@@ -198,11 +155,6 @@ const refreshIcons = () => {
onMounted(refreshIcons)
onUpdated(refreshIcons)
// Expose для установки контента редактора
defineExpose({
setEditContent: (text) => editEditor.value?.setContent(text)
})
</script>
<style scoped>
@@ -227,11 +179,6 @@ defineExpose({
background: rgba(0, 212, 170, 0.08);
}
.comment--editing {
background: rgba(255, 255, 255, 0.05);
outline: 1px solid var(--accent);
}
.comment--reply {
border-left: 2px solid rgba(255, 255, 255, 0.1);
padding-left: 12px;
@@ -322,63 +269,17 @@ defineExpose({
opacity: 1;
}
/* Mobile: иконки видны всегда */
:global(body.is-mobile) .comment-actions {
opacity: 1;
}
.comment-btn-delete:hover {
background: rgba(239, 68, 68, 0.15) !important;
border-color: rgba(239, 68, 68, 0.3) !important;
color: var(--red) !important;
}
/* Редактирование комментария */
.comment-edit-form {
margin-top: 8px;
}
.comment-edit-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 8px;
}
.btn-comment-cancel {
padding: 8px 14px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
color: var(--text-secondary);
font-family: inherit;
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.btn-comment-cancel:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary);
}
.btn-comment-save {
padding: 8px 14px;
background: var(--accent);
border: none;
border-radius: 6px;
color: #000;
font-family: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn-comment-save:hover {
filter: brightness(1.1);
}
.btn-comment-save:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Прикреплённые файлы в комментарии */
.comment-files {
display: flex;

View File

@@ -20,15 +20,10 @@
:level="comment.level"
:is-own="comment.id_accounts === currentUserId"
:can-edit="canEditComment(comment)"
:is-editing="editingCommentId === comment.id"
:edit-text="editingCommentText"
:get-full-url="getFullUrl"
@update:edit-text="editingCommentText = $event"
@reply="startReply"
@edit="startEditComment"
@delete="confirmDeleteComment"
@cancel-edit="cancelEditComment"
@save-edit="saveEditComment"
@preview-file="(file) => previewFile(file, comment)"
/>
</div>
@@ -53,6 +48,155 @@
@confirm="deleteComment"
@cancel="showDeleteDialog = false"
/>
<!-- Панель редактирования комментария -->
<SlidePanel
:show="editPanelOpen"
:width="500"
:min-width="400"
:max-width="700"
@close="cancelEditPanel"
>
<template #header>
<div class="edit-panel-header">
<div v-if="editingComment" class="edit-author-avatar">
<img v-if="editingComment.author_avatar" :src="getFullUrl(editingComment.author_avatar)" alt="">
<i v-else data-lucide="user"></i>
</div>
<div class="edit-header-text">
<h2>Редактирование</h2>
<span v-if="editingComment" class="edit-author-name">{{ editingComment.author_name }}</span>
</div>
</div>
</template>
<div class="edit-panel-content">
<!-- Редактор текста -->
<div class="edit-form-section">
<div class="edit-form-label">
<span>Текст комментария</span>
<div class="format-buttons">
<button type="button" class="format-btn" @mousedown.prevent="applyEditFormat('bold')" title="Жирный">
<i data-lucide="bold"></i>
</button>
<button type="button" class="format-btn" @mousedown.prevent="applyEditFormat('italic')" title="Курсив">
<i data-lucide="italic"></i>
</button>
<button type="button" class="format-btn" @mousedown.prevent="applyEditFormat('underline')" title="Подчёркивание">
<i data-lucide="underline"></i>
</button>
</div>
</div>
<RichTextEditor
:modelValue="editingCommentText"
@update:modelValue="editingCommentText = $event"
placeholder="Текст комментария..."
:show-toolbar="false"
ref="editEditorRef"
class="edit-editor"
/>
</div>
<!-- Существующие файлы -->
<div v-if="editExistingFiles.length > 0" class="edit-form-section">
<div class="edit-form-label">
<span>Прикреплённые файлы</span>
</div>
<div class="edit-files-list">
<div
v-for="(file, index) in editExistingFiles"
:key="'existing-' + file.name + '-' + index"
class="edit-file-item"
:class="{ 'file-deleted': filesToDelete.includes(file.name) }"
>
<div class="edit-file-preview">
<img v-if="isImageFile(file)" :src="getFullUrl(file.url)" :alt="file.name">
<div v-else class="edit-file-icon">
<i data-lucide="archive"></i>
<span>.{{ getFileExt(file) }}</span>
</div>
</div>
<span class="edit-file-name" :title="file.name">{{ file.name }}</span>
<button
v-if="!filesToDelete.includes(file.name)"
class="edit-file-delete"
@click="markFileForDelete(file.name)"
title="Удалить файл"
>
<i data-lucide="trash-2"></i>
</button>
<button
v-else
class="edit-file-restore"
@click="unmarkFileForDelete(file.name)"
title="Восстановить файл"
>
<i data-lucide="undo-2"></i>
</button>
</div>
</div>
</div>
<!-- Новые файлы -->
<div class="edit-form-section">
<div class="edit-form-label">
<span>Добавить файлы</span>
</div>
<div v-if="editNewFiles.length > 0" class="edit-files-list">
<div
v-for="(file, index) in editNewFiles"
:key="'new-' + file.name + '-' + index"
class="edit-file-item new-file"
>
<div class="edit-file-preview">
<img v-if="file.preview" :src="file.preview" :alt="file.name">
<div v-else class="edit-file-icon">
<i data-lucide="archive"></i>
<span>.{{ getFileExtFromName(file.name) }}</span>
</div>
</div>
<span class="edit-file-name" :title="file.name">{{ file.name }}</span>
<button
class="edit-file-delete"
@click="removeNewFile(index)"
title="Удалить"
>
<i data-lucide="x"></i>
</button>
</div>
</div>
<button class="btn-add-file" @click="triggerEditFileInput">
<i data-lucide="paperclip"></i>
Прикрепить файл
</button>
<input
type="file"
ref="editFileInputRef"
accept=".png,.jpg,.jpeg,.zip,.rar,image/png,image/jpeg,application/zip,application/x-rar-compressed"
multiple
@change="handleEditFileSelect"
style="display: none"
>
</div>
</div>
<template #footer>
<button class="btn-cancel" @click="cancelEditPanel">
Отмена
</button>
<button
class="btn-save-edit"
@click="saveEditPanel"
:disabled="!editingCommentText.trim() || isSavingEdit"
>
<span v-if="isSavingEdit" class="btn-loader"></span>
<template v-else>
<i data-lucide="check"></i>
Сохранить
</template>
</button>
</template>
</SlidePanel>
</div>
</template>
@@ -61,7 +205,12 @@ import { ref, computed, watch, onMounted, onUpdated, onUnmounted, nextTick } fro
import CommentItem from './CommentItem.vue'
import CommentForm from './CommentForm.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import SlidePanel from '../ui/SlidePanel.vue'
import RichTextEditor from '../ui/RichTextEditor.vue'
import { commentsApi, commentImageApi, getFullUrl } from '../../api'
import { useMobile } from '../../composables/useMobile'
const { isMobile } = useMobile()
const props = defineProps({
taskId: {
@@ -266,34 +415,166 @@ const canEditComment = (comment) => {
return comment.id_accounts === props.currentUserId || props.isProjectAdmin
}
// Edit panel state
const editPanelOpen = ref(false)
const editEditorRef = ref(null)
const editingComment = ref(null)
const isSavingEdit = ref(false)
// Files management
const editExistingFiles = ref([])
const editNewFiles = ref([])
const filesToDelete = ref([])
const editFileInputRef = ref(null)
const allowedExtensions = ['png', 'jpg', 'jpeg', 'zip', 'rar']
const archiveExtensions = ['zip', 'rar']
const maxFileSize = 10 * 1024 * 1024 // 10 MB
const getFileExt = (file) => file.name?.split('.').pop()?.toLowerCase() || ''
const getFileExtFromName = (name) => name?.split('.').pop()?.toLowerCase() || ''
const isImageFile = (file) => !archiveExtensions.includes(getFileExt(file))
const isAllowedFile = (file) => allowedExtensions.includes(getFileExtFromName(file.name))
const startEditComment = (comment) => {
editingCommentId.value = comment.id
editingCommentText.value = comment.text
editingComment.value = comment
editExistingFiles.value = [...(comment.file_img || [])]
editNewFiles.value = []
filesToDelete.value = []
editPanelOpen.value = true
nextTick(() => {
editEditorRef.value?.setContent(comment.text)
refreshIcons()
})
}
const cancelEditComment = () => {
editingCommentId.value = null
editingCommentText.value = ''
editingComment.value = null
editExistingFiles.value = []
editNewFiles.value = []
filesToDelete.value = []
}
const saveEditComment = async () => {
if (!editingCommentText.value.trim() || !editingCommentId.value) return
try {
const result = await commentsApi.update(editingCommentId.value, editingCommentText.value)
if (result.success) {
const index = comments.value.findIndex(c => c.id === editingCommentId.value)
if (index !== -1) {
comments.value[index] = result.comment
}
const cancelEditPanel = () => {
editPanelOpen.value = false
cancelEditComment()
}
const applyEditFormat = (command) => {
editEditorRef.value?.applyFormat(command)
}
// File management
const markFileForDelete = (fileName) => {
filesToDelete.value.push(fileName)
}
const unmarkFileForDelete = (fileName) => {
filesToDelete.value = filesToDelete.value.filter(f => f !== fileName)
}
const triggerEditFileInput = () => {
editFileInputRef.value?.click()
}
const handleEditFileSelect = (event) => {
const selectedFiles = event.target.files
if (selectedFiles) {
processEditFiles(Array.from(selectedFiles))
}
event.target.value = ''
}
const processEditFiles = (fileList) => {
for (const file of fileList) {
if (!isAllowedFile(file)) {
console.warn(`Файл "${file.name}" не поддерживается.`)
continue
}
if (file.size > maxFileSize) {
console.warn(`Файл "${file.name}" слишком большой.`)
continue
}
const isDuplicate = editNewFiles.value.some(f => f.name === file.name && f.size === file.size)
if (isDuplicate) continue
const reader = new FileReader()
reader.onload = (e) => {
const isImage = ['png', 'jpg', 'jpeg'].includes(getFileExtFromName(file.name))
editNewFiles.value.push({
file: file,
name: file.name,
size: file.size,
data: e.target.result,
preview: isImage ? e.target.result : null
})
refreshIcons()
}
reader.readAsDataURL(file)
}
}
const removeNewFile = (index) => {
editNewFiles.value.splice(index, 1)
}
const saveEditPanel = async () => {
if (!editingCommentText.value.trim() || !editingCommentId.value) return
isSavingEdit.value = true
try {
// 1. Обновляем текст комментария
const result = await commentsApi.update(editingCommentId.value, editingCommentText.value)
if (!result.success) throw new Error('Ошибка обновления текста')
// 2. Удаляем отмеченные файлы
if (filesToDelete.value.length > 0) {
await commentImageApi.delete(editingCommentId.value, filesToDelete.value)
}
// 3. Загружаем новые файлы
const uploadedFiles = []
for (const file of editNewFiles.value) {
try {
const uploadResult = await commentImageApi.upload(editingCommentId.value, file.data, file.name)
if (uploadResult.success) {
uploadedFiles.push(uploadResult.file)
}
} catch (e) {
console.error('Ошибка загрузки файла:', e)
}
}
// 4. Обновляем комментарий в списке
const index = comments.value.findIndex(c => c.id === editingCommentId.value)
if (index !== -1) {
const updatedComment = result.comment
// Обновляем file_img: убираем удалённые, добавляем новые
const remainingFiles = (editExistingFiles.value || []).filter(f => !filesToDelete.value.includes(f.name))
updatedComment.file_img = [...remainingFiles, ...uploadedFiles]
comments.value[index] = updatedComment
}
editPanelOpen.value = false
cancelEditComment()
refreshIcons()
} catch (e) {
console.error('Ошибка обновления комментария:', e)
} finally {
isSavingEdit.value = false
}
}
// Desktop inline edit (deprecated, kept for compatibility)
const saveEditComment = async () => {
await saveEditPanel()
}
// Удаление
const confirmDeleteComment = (comment) => {
commentToDelete.value = comment
@@ -419,13 +700,11 @@ defineExpose({
}
/* Mobile: убираем max-height, используем flex */
@media (max-width: 768px) {
.comments-list {
:global(body.is-mobile) .comments-list {
max-height: none;
min-height: 50px;
margin-bottom: 12px;
}
}
.comments-loading,
.comments-empty {
@@ -459,4 +738,314 @@ defineExpose({
transform: rotate(360deg);
}
}
/* ========== Edit Panel Styles ========== */
.edit-panel-header {
display: flex;
align-items: center;
gap: 12px;
}
.edit-panel-header h2 {
margin: 0;
font-size: 18px;
font-weight: 500;
}
.edit-author-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.edit-author-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.edit-author-avatar i {
width: 20px;
height: 20px;
color: var(--text-muted);
}
.edit-header-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.edit-author-name {
font-size: 13px;
color: var(--text-muted);
}
.edit-panel-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.edit-form-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.edit-form-label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.format-buttons {
display: flex;
gap: 4px;
}
.format-btn {
width: 26px;
height: 26px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 5px;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.format-btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
color: var(--text-primary);
}
.format-btn:active {
background: var(--accent);
border-color: var(--accent);
color: #000;
}
.format-btn i {
width: 12px;
height: 12px;
}
.edit-editor {
min-height: 120px;
}
.edit-editor :deep(.editor-content) {
min-height: 120px;
}
/* Files list */
.edit-files-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.edit-file-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
transition: all 0.15s;
}
.edit-file-item.new-file {
background: rgba(0, 212, 170, 0.05);
border-color: rgba(0, 212, 170, 0.2);
}
.edit-file-item.file-deleted {
opacity: 0.5;
text-decoration: line-through;
background: rgba(239, 68, 68, 0.05);
border-color: rgba(239, 68, 68, 0.2);
}
.edit-file-preview {
width: 48px;
height: 48px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.05);
overflow: hidden;
flex-shrink: 0;
}
.edit-file-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.edit-file-icon {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.edit-file-icon i {
width: 20px;
height: 20px;
}
.edit-file-icon span {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
margin-top: 2px;
}
.edit-file-name {
flex: 1;
font-size: 13px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.edit-file-delete,
.edit-file-restore {
width: 32px;
height: 32px;
background: none;
border: 1px solid transparent;
border-radius: 6px;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
flex-shrink: 0;
}
.edit-file-delete:hover {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.3);
color: #ef4444;
}
.edit-file-restore:hover {
background: rgba(0, 212, 170, 0.15);
border-color: rgba(0, 212, 170, 0.3);
color: var(--accent);
}
.edit-file-delete i,
.edit-file-restore i {
width: 16px;
height: 16px;
}
.btn-add-file {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px dashed rgba(255, 255, 255, 0.15);
border-radius: 8px;
color: var(--text-secondary);
font-family: inherit;
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.btn-add-file:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.25);
color: var(--text-primary);
}
.btn-add-file i {
width: 16px;
height: 16px;
}
/* Footer buttons */
.btn-cancel {
padding: 12px 20px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: var(--text-secondary);
font-family: inherit;
font-size: 14px;
cursor: pointer;
transition: all 0.15s;
}
.btn-cancel:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary);
}
.btn-save-edit {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 24px;
background: var(--accent);
border: none;
border-radius: 8px;
color: #000;
font-family: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn-save-edit:hover {
filter: brightness(1.1);
}
.btn-save-edit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-save-edit i {
width: 16px;
height: 16px;
}
.btn-loader {
width: 16px;
height: 16px;
border: 2px solid rgba(0, 0, 0, 0.2);
border-top-color: #000;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
</style>

View File

@@ -311,15 +311,15 @@ onUnmounted(() => {
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
/* Transition */
/* Transition — плавное появление */
.panel-enter-active,
.panel-leave-active {
transition: all 0.3s ease;
transition: opacity 0.2s ease;
}
.panel-enter-active .panel,
.panel-leave-active .panel {
transition: transform 0.3s ease;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.panel-enter-from,
@@ -329,7 +329,8 @@ onUnmounted(() => {
.panel-enter-from .panel,
.panel-leave-to .panel {
transform: translateX(100%);
opacity: 0;
transform: scale(0.98);
}
/* ========== MOBILE: Fullscreen ========== */

View File

@@ -36,14 +36,7 @@
<!-- Мобильные фильтры -->
<template #mobile-filters>
<MobileSelect
v-model="mobileProjectId"
:options="projectOptions"
title="Выберите проект"
placeholder="Проект"
variant="accent"
@update:model-value="onMobileProjectChange"
/>
<ProjectSelector @change="onProjectChange" />
<MobileSelect
v-model="activeDepartment"
:options="departmentOptions"
@@ -152,33 +145,13 @@ const { isMobile } = useMobile()
const store = useProjectsStore()
// ==================== MOBILE ====================
const mobileProjectId = computed({
get: () => store.currentProjectId,
set: () => {}
})
const projectOptions = computed(() => {
return store.projects.map(p => ({
id: p.id,
label: p.name
}))
})
const departmentOptions = computed(() => {
return [
const departmentOptions = computed(() => [
{ id: null, label: 'Все отделы' },
...store.departments.map(d => ({
id: d.id,
label: d.name_departments
}))
]
})
const onMobileProjectChange = async (projectId) => {
await store.setCurrentProject(projectId)
activeDepartment.value = null
await fetchCards()
}
])
// ==================== КАРТОЧКИ ====================
const cards = ref([])

View File

@@ -31,16 +31,9 @@
</div>
</template>
<!-- Мобильный: Проект (текст) + Отделы (иконка) -->
<!-- Мобильный: Проект + Отделы -->
<template #mobile-filters>
<MobileSelect
v-model="mobileProjectId"
:options="projectOptions"
title="Выберите проект"
placeholder="Проект"
variant="accent"
@update:model-value="onMobileProjectChange"
/>
<ProjectSelector @change="onProjectChange" />
<MobileSelect
v-model="activeDepartment"
:options="departmentOptions"
@@ -126,27 +119,11 @@ const { isMobile } = useMobile()
const store = useProjectsStore()
// ==================== МОБИЛЬНЫЕ СЕЛЕКТОРЫ ====================
const mobileProjectId = ref(null)
const projectOptions = computed(() =>
store.projects.map(p => ({ id: p.id, label: p.name }))
)
const departmentOptions = computed(() => [
{ id: null, label: 'Все отделы' },
...store.departments.map(d => ({ id: d.id, label: d.name_departments }))
])
const onMobileProjectChange = async (projectId) => {
await store.selectProject(projectId)
await onProjectChange()
}
// Синхронизируем с текущим проектом
watch(() => store.currentProjectId, (id) => {
mobileProjectId.value = id
}, { immediate: true })
// ==================== КАРТОЧКИ ====================
const cards = ref([])