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"> <html lang="ru">
<head> <head>
<meta charset="UTF-8"> <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"> <link rel="icon" type="image/x-icon" href="/favicon.ico">
<title>TaskBoard</title> <title>TaskBoard</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">

View File

@@ -1,5 +1,6 @@
<template> <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"> <button class="project-btn" @click="dropdownOpen = !dropdownOpen">
<i data-lucide="folder" class="folder-icon"></i> <i data-lucide="folder" class="folder-icon"></i>
{{ store.currentProject?.name || 'Выберите проект' }} {{ store.currentProject?.name || 'Выберите проект' }}
@@ -17,12 +18,24 @@
</button> </button>
</div> </div>
</div> </div>
<!-- Mobile: полноэкранный селект -->
<MobileSelect
v-else
v-model="mobileProjectId"
:options="projectOptions"
title="Выберите проект"
placeholder="Проект"
variant="accent"
@update:model-value="handleMobileSelect"
/>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from 'vue' import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useProjectsStore } from '../stores/projects' import { useProjectsStore } from '../stores/projects'
import { useMobile } from '../composables/useMobile' import { useMobile } from '../composables/useMobile'
import MobileSelect from './ui/MobileSelect.vue'
const store = useProjectsStore() const store = useProjectsStore()
const { isMobile } = useMobile() const { isMobile } = useMobile()
@@ -30,6 +43,7 @@ const dropdownOpen = ref(false)
const emit = defineEmits(['change']) const emit = defineEmits(['change'])
// ==================== DESKTOP ====================
const handleSelect = async (projectId) => { const handleSelect = async (projectId) => {
dropdownOpen.value = false dropdownOpen.value = false
await store.selectProject(projectId) 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(() => { onMounted(() => {
document.addEventListener('click', closeDropdown) document.addEventListener('click', closeDropdown)
if (window.lucide) window.lucide.createIcons() if (window.lucide) window.lucide.createIcons()
@@ -137,15 +169,4 @@ onUnmounted(() => {
color: var(--accent); 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> </style>

View File

@@ -3,7 +3,6 @@
class="comment" class="comment"
:class="{ :class="{
'comment--own': isOwn, 'comment--own': isOwn,
'comment--editing': isEditing,
'comment--reply': level > 0 'comment--reply': level > 0
}" }"
:style="{ marginLeft: level * 24 + 'px' }" :style="{ marginLeft: level * 24 + 'px' }"
@@ -18,7 +17,7 @@
<span class="comment-author">{{ comment.author_name }}</span> <span class="comment-author">{{ comment.author_name }}</span>
<span class="comment-date">{{ formattedDate }}</span> <span class="comment-date">{{ formattedDate }}</span>
<div v-if="!isEditing" class="comment-actions"> <div class="comment-actions">
<IconButton <IconButton
icon="reply" icon="reply"
variant="ghost" variant="ghost"
@@ -46,56 +45,33 @@
</div> </div>
</div> </div>
<!-- Режим редактирования --> <!-- Текст комментария -->
<div v-if="isEditing" class="comment-edit-form"> <div class="comment-text" v-html="comment.text"></div>
<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 v-if="comment.file_img && comment.file_img.length > 0" class="comment-files">
<div class="comment-text" v-html="comment.text"></div> <div
v-for="(file, index) in comment.file_img"
<!-- Прикреплённые файлы --> :key="file.name + '-' + index"
<div v-if="comment.file_img && comment.file_img.length > 0" class="comment-files"> class="comment-file"
<div @click="!isArchiveFile(file) && openPreview(file)"
v-for="(file, index) in comment.file_img" >
:key="file.name + '-' + index" <template v-if="isArchiveFile(file)">
class="comment-file" <div class="comment-file-archive">
@click="!isArchiveFile(file) && openPreview(file)" <i data-lucide="archive"></i>
<span class="comment-file-ext">.{{ getFileExt(file) }}</span>
</div>
</template>
<img v-else :src="getFullUrl(file.url)" :alt="file.name">
<a
class="comment-file-download"
:href="getFullUrl(file.url)"
:download="file.name"
@click.stop
title="Скачать"
> >
<template v-if="isArchiveFile(file)"> <i data-lucide="download"></i>
<div class="comment-file-archive"> </a>
<i data-lucide="archive"></i>
<span class="comment-file-ext">.{{ getFileExt(file) }}</span>
</div>
</template>
<img v-else :src="getFullUrl(file.url)" :alt="file.name">
<a
class="comment-file-download"
:href="getFullUrl(file.url)"
:download="file.name"
@click.stop
title="Скачать"
>
<i data-lucide="download"></i>
</a>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -103,9 +79,8 @@
</template> </template>
<script setup> <script setup>
import { computed, ref, onMounted, onUpdated, watch, nextTick } from 'vue' import { computed, onMounted, onUpdated } from 'vue'
import IconButton from '../ui/IconButton.vue' import IconButton from '../ui/IconButton.vue'
import RichTextEditor from '../ui/RichTextEditor.vue'
import { serverSettings } from '../../api' import { serverSettings } from '../../api'
const props = defineProps({ const props = defineProps({
@@ -125,23 +100,13 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false default: false
}, },
isEditing: {
type: Boolean,
default: false
},
editText: {
type: String,
default: ''
},
getFullUrl: { getFullUrl: {
type: Function, type: Function,
required: true required: true
} }
}) })
const emit = defineEmits(['reply', 'edit', 'delete', 'cancel-edit', 'save-edit', 'update:editText', 'preview-file']) const emit = defineEmits(['reply', 'edit', 'delete', 'preview-file'])
const editEditor = ref(null)
const archiveExtensions = ['zip', 'rar'] const archiveExtensions = ['zip', 'rar']
@@ -182,14 +147,6 @@ const formattedDate = computed(() => {
return `${day} ${months[date.getMonth()]} ${time}` return `${day} ${months[date.getMonth()]} ${time}`
}) })
// Установка контента редактора при начале редактирования
watch(() => props.isEditing, async (newVal) => {
if (newVal) {
await nextTick()
editEditor.value?.setContent(props.editText)
}
})
const refreshIcons = () => { const refreshIcons = () => {
if (window.lucide) { if (window.lucide) {
window.lucide.createIcons() window.lucide.createIcons()
@@ -198,11 +155,6 @@ const refreshIcons = () => {
onMounted(refreshIcons) onMounted(refreshIcons)
onUpdated(refreshIcons) onUpdated(refreshIcons)
// Expose для установки контента редактора
defineExpose({
setEditContent: (text) => editEditor.value?.setContent(text)
})
</script> </script>
<style scoped> <style scoped>
@@ -227,11 +179,6 @@ defineExpose({
background: rgba(0, 212, 170, 0.08); background: rgba(0, 212, 170, 0.08);
} }
.comment--editing {
background: rgba(255, 255, 255, 0.05);
outline: 1px solid var(--accent);
}
.comment--reply { .comment--reply {
border-left: 2px solid rgba(255, 255, 255, 0.1); border-left: 2px solid rgba(255, 255, 255, 0.1);
padding-left: 12px; padding-left: 12px;
@@ -322,63 +269,17 @@ defineExpose({
opacity: 1; opacity: 1;
} }
/* Mobile: иконки видны всегда */
:global(body.is-mobile) .comment-actions {
opacity: 1;
}
.comment-btn-delete:hover { .comment-btn-delete:hover {
background: rgba(239, 68, 68, 0.15) !important; background: rgba(239, 68, 68, 0.15) !important;
border-color: rgba(239, 68, 68, 0.3) !important; border-color: rgba(239, 68, 68, 0.3) !important;
color: var(--red) !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 { .comment-files {
display: flex; display: flex;

View File

@@ -20,15 +20,10 @@
:level="comment.level" :level="comment.level"
:is-own="comment.id_accounts === currentUserId" :is-own="comment.id_accounts === currentUserId"
:can-edit="canEditComment(comment)" :can-edit="canEditComment(comment)"
:is-editing="editingCommentId === comment.id"
:edit-text="editingCommentText"
:get-full-url="getFullUrl" :get-full-url="getFullUrl"
@update:edit-text="editingCommentText = $event"
@reply="startReply" @reply="startReply"
@edit="startEditComment" @edit="startEditComment"
@delete="confirmDeleteComment" @delete="confirmDeleteComment"
@cancel-edit="cancelEditComment"
@save-edit="saveEditComment"
@preview-file="(file) => previewFile(file, comment)" @preview-file="(file) => previewFile(file, comment)"
/> />
</div> </div>
@@ -53,6 +48,155 @@
@confirm="deleteComment" @confirm="deleteComment"
@cancel="showDeleteDialog = false" @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> </div>
</template> </template>
@@ -61,7 +205,12 @@ import { ref, computed, watch, onMounted, onUpdated, onUnmounted, nextTick } fro
import CommentItem from './CommentItem.vue' import CommentItem from './CommentItem.vue'
import CommentForm from './CommentForm.vue' import CommentForm from './CommentForm.vue'
import ConfirmDialog from '../ConfirmDialog.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 { commentsApi, commentImageApi, getFullUrl } from '../../api'
import { useMobile } from '../../composables/useMobile'
const { isMobile } = useMobile()
const props = defineProps({ const props = defineProps({
taskId: { taskId: {
@@ -266,34 +415,166 @@ const canEditComment = (comment) => {
return comment.id_accounts === props.currentUserId || props.isProjectAdmin 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) => { const startEditComment = (comment) => {
editingCommentId.value = comment.id editingCommentId.value = comment.id
editingCommentText.value = comment.text 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 = () => { const cancelEditComment = () => {
editingCommentId.value = null editingCommentId.value = null
editingCommentText.value = '' editingCommentText.value = ''
editingComment.value = null
editExistingFiles.value = []
editNewFiles.value = []
filesToDelete.value = []
} }
const saveEditComment = async () => { const cancelEditPanel = () => {
if (!editingCommentText.value.trim() || !editingCommentId.value) return editPanelOpen.value = false
cancelEditComment()
}
try { const applyEditFormat = (command) => {
const result = await commentsApi.update(editingCommentId.value, editingCommentText.value) editEditorRef.value?.applyFormat(command)
if (result.success) { }
const index = comments.value.findIndex(c => c.id === editingCommentId.value)
if (index !== -1) { // File management
comments.value[index] = result.comment const markFileForDelete = (fileName) => {
} filesToDelete.value.push(fileName)
cancelEditComment() }
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() 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) { } catch (e) {
console.error('Ошибка обновления комментария:', e) console.error('Ошибка обновления комментария:', e)
} finally {
isSavingEdit.value = false
} }
} }
// Desktop inline edit (deprecated, kept for compatibility)
const saveEditComment = async () => {
await saveEditPanel()
}
// Удаление // Удаление
const confirmDeleteComment = (comment) => { const confirmDeleteComment = (comment) => {
commentToDelete.value = comment commentToDelete.value = comment
@@ -419,12 +700,10 @@ defineExpose({
} }
/* Mobile: убираем max-height, используем flex */ /* Mobile: убираем max-height, используем flex */
@media (max-width: 768px) { :global(body.is-mobile) .comments-list {
.comments-list { max-height: none;
max-height: none; min-height: 50px;
min-height: 50px; margin-bottom: 12px;
margin-bottom: 12px;
}
} }
.comments-loading, .comments-loading,
@@ -459,4 +738,314 @@ defineExpose({
transform: rotate(360deg); 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> </style>

View File

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

View File

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

View File

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