1
0

Мобильная версия

1. Адаптация и разработка мобильного варианта.
This commit is contained in:
2026-01-15 09:33:16 +07:00
parent 901604afd4
commit 5018a2d123
23 changed files with 2787 additions and 171 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"> <meta name="viewport" content="width=device-width, initial-scale=1.0, 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

@@ -3,6 +3,9 @@ window.APP_CONFIG = {
API_BASE: 'http://192.168.1.6', API_BASE: 'http://192.168.1.6',
// Интервал автообновления данных (в секундах) // Интервал автообновления данных (в секундах)
IDLE_REFRESH_SECONDS: 1 IDLE_REFRESH_SECONDS: 1,
// Брейкпоинт для мобильной версии (px)
MOBILE_BREAKPOINT: 768
} }

View File

@@ -2,6 +2,21 @@
<router-view /> <router-view />
</template> </template>
<script setup>
import { onMounted, onUnmounted } from 'vue'
import { initMobileClass } from './composables/useMobile'
let cleanup = null
onMounted(() => {
cleanup = initMobileClass()
})
onUnmounted(() => {
if (cleanup) cleanup()
})
</script>
<style> <style>
/* Сброс стилей */ /* Сброс стилей */
* { * {
@@ -38,4 +53,34 @@ body {
color: var(--text-primary); color: var(--text-primary);
min-height: 100vh; min-height: 100vh;
} }
/* Мобильный режим — фиксируем body */
body.is-mobile {
position: fixed;
width: 100%;
height: 100%;
height: 100dvh; /* Динамическая высота для iOS */
overflow: hidden;
touch-action: none;
}
body.is-mobile #app {
height: 100%;
height: 100dvh;
overflow: hidden;
}
/* Safe area для iPhone (notch и home indicator) */
:root {
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
}
/* Блокировка скролла когда панель открыта */
body.panel-open {
overflow: hidden !important;
position: fixed !important;
width: 100% !important;
height: 100% !important;
touch-action: none !important;
}
</style> </style>

View File

@@ -1,75 +1,140 @@
<template> <template>
<div <div
class="archive-card" class="archive-card"
:class="{ 'has-label-color': cardLabelColor }" :class="{ 'has-label-color': cardLabelColor, mobile: isMobile }"
:style="cardLabelColor ? { '--label-bg': cardLabelColor } : {}" :style="cardLabelColor ? { '--label-bg': cardLabelColor } : {}"
@click="$emit('click')" @click="$emit('click')"
> >
<!-- Аватарка --> <!-- Desktop версия -->
<div class="card-assignee" v-if="card.assignee"> <template v-if="!isMobile">
<img <!-- Аватарка -->
v-if="isAvatarUrl(card.assignee)" <div class="card-assignee" v-if="card.assignee">
:src="getFullUrl(card.assignee)" <img
alt="avatar" v-if="isAvatarUrl(card.assignee)"
class="assignee-img" :src="getFullUrl(card.assignee)"
/> alt="avatar"
<span v-else class="assignee-initials">{{ card.assignee }}</span> class="assignee-img"
</div> />
<span v-else class="assignee-initials">{{ card.assignee }}</span>
<!-- Контент -->
<div class="card-main">
<div class="card-content">
<h3 class="card-title">
<span v-if="cardLabel" class="label-icon" :title="cardLabel.name_labels">{{ cardLabel.icon }}</span>
{{ card.title }}
</h3>
<p v-if="card.description" class="card-description">{{ card.description }}</p>
</div> </div>
</div>
<!-- Центральная часть: мета-информация --> <!-- Контент -->
<div class="card-meta"> <div class="card-main">
<span <div class="card-content">
v-if="cardDepartment" <h3 class="card-title">
class="department-tag" <span v-if="cardLabel" class="label-icon" :title="cardLabel.name_labels">{{ cardLabel.icon }}</span>
:style="{ color: cardDepartment.color }" {{ card.title }}
> </h3>
{{ cardDepartment.name_departments }} <p v-if="card.description" class="card-description">{{ card.description }}</p>
</span> </div>
<span v-if="card.files && card.files.length" class="files-badge" :title="card.files.length + ' файлов'"> </div>
<i data-lucide="image"></i>
</span>
</div>
<!-- Даты: создано и выполнено --> <!-- Центральная часть: мета-информация -->
<div class="card-dates"> <div class="card-meta">
<span class="date-created">{{ formatDateFull(card.dateCreate) }}</span> <span
<span class="date-closed">{{ formatDateFull(card.dateClosed) }}</span> v-if="cardDepartment"
</div> class="department-tag"
:style="{ color: cardDepartment.color }"
>
{{ cardDepartment.name_departments }}
</span>
<span v-if="card.files && card.files.length" class="files-badge" :title="card.files.length + ' файлов'">
<i data-lucide="image"></i>
</span>
</div>
<!-- Кнопки действий (всегда видны) --> <!-- Даты: создано и выполнено -->
<div class="card-actions"> <div class="card-dates">
<button <span class="date-created">{{ formatDateFull(card.dateCreate) }}</span>
class="btn-action btn-restore" <span class="date-closed">{{ formatDateFull(card.dateClosed) }}</span>
@click.stop="$emit('restore', card.id)" </div>
title="Вернуть из архива"
> <!-- Кнопки действий (всегда видны) -->
<i data-lucide="archive-restore"></i> <div class="card-actions">
</button> <button
<button class="btn-action btn-restore"
class="btn-action btn-delete" @click.stop="$emit('restore', card.id)"
@click.stop="$emit('delete', card.id)" title="Вернуть из архива"
title="Удалить навсегда" >
> <i data-lucide="archive-restore"></i>
<i data-lucide="trash-2"></i> </button>
</button> <button
</div> class="btn-action btn-delete"
@click.stop="$emit('delete', card.id)"
title="Удалить навсегда"
>
<i data-lucide="trash-2"></i>
</button>
</div>
</template>
<!-- Mobile версия (как Card.vue) -->
<template v-else>
<div class="card-header">
<div class="tag-group">
<span v-if="cardLabel" class="label-icon">{{ cardLabel.icon }}</span>
<span
v-if="cardDepartment"
class="tag"
:style="{ color: cardDepartment.color }"
>
{{ cardDepartment.name_departments }}
</span>
</div>
<div class="header-right">
<button
class="btn-action btn-restore"
@click.stop="$emit('restore', card.id)"
title="Вернуть из архива"
>
<i data-lucide="archive-restore"></i>
</button>
<button
class="btn-action btn-delete"
@click.stop="$emit('delete', card.id)"
title="Удалить навсегда"
>
<i data-lucide="trash-2"></i>
</button>
<span v-if="card.files && card.files.length" class="files-indicator">
<i data-lucide="image-plus"></i>
</span>
<div v-if="card.assignee" class="assignee">
<img
v-if="isAvatarUrl(card.assignee)"
:src="getFullUrl(card.assignee)"
alt="avatar"
class="assignee-img"
/>
<span v-else>{{ card.assignee }}</span>
</div>
</div>
</div>
<h3 class="mobile-title">{{ card.title }}</h3>
<p v-if="card.description" class="mobile-description">
{{ card.description }}
</p>
<div class="card-footer">
<span class="date-create">
Создано: {{ formatDateWithYear(card.dateCreate) }}
</span>
<span v-if="card.dateClosed" class="date-closed">
Закрыто: {{ closedDateText }}
</span>
</div>
</template>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, onMounted, onUpdated } from 'vue' import { computed, onMounted, onUpdated } from 'vue'
import { getFullUrl } from '../api' import { getFullUrl } from '../api'
import { useMobile } from '../composables/useMobile'
const { isMobile } = useMobile()
const props = defineProps({ const props = defineProps({
card: Object, card: Object,
@@ -127,9 +192,34 @@ const formatDateFull = (dateStr) => {
const minutes = date.getMinutes().toString().padStart(2, '0') const minutes = date.getMinutes().toString().padStart(2, '0')
return `${day} ${months[date.getMonth()]} ${year}, ${hours}:${minutes}` return `${day} ${months[date.getMonth()]} ${year}, ${hours}:${minutes}`
} }
const formatDateWithYear = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
const day = date.getDate()
const months = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
return `${day} ${months[date.getMonth()]} ${date.getFullYear()}`
}
// Форматирование даты закрытия (относительный формат)
const closedDateText = computed(() => {
if (!props.card.dateClosed) return ''
const closed = new Date(props.card.dateClosed)
const today = new Date()
today.setHours(0, 0, 0, 0)
closed.setHours(0, 0, 0, 0)
const daysAgo = Math.round((today - closed) / (1000 * 60 * 60 * 24))
if (daysAgo === 0) return 'Сегодня'
if (daysAgo === 1) return 'Вчера'
if (daysAgo >= 2 && daysAgo <= 4) return `${daysAgo} дня назад`
if (daysAgo >= 5 && daysAgo <= 14) return `${daysAgo} дней назад`
return formatDateWithYear(props.card.dateClosed)
})
</script> </script>
<style scoped> <style scoped>
/* ========== DESKTOP STYLES ========== */
.archive-card { .archive-card {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -307,4 +397,111 @@ const formatDateFull = (dateStr) => {
background: var(--red, #ff4757); background: var(--red, #ff4757);
color: #fff; color: #fff;
} }
/* ========== MOBILE STYLES ========== */
.archive-card.mobile {
flex-direction: column;
align-items: stretch;
gap: 0;
padding: 14px 16px;
border-radius: 12px;
border-left: none;
}
.archive-card.mobile.has-label-color {
background: color-mix(in srgb, var(--label-bg) 15%, var(--bg-card));
border-left: 3px solid var(--label-bg);
}
.archive-card.mobile .card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.archive-card.mobile .tag-group {
display: flex;
align-items: center;
gap: 6px;
}
.archive-card.mobile .tag {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.archive-card.mobile .header-right {
display: flex;
align-items: center;
gap: 8px;
}
.archive-card.mobile .btn-action {
width: 24px;
height: 24px;
}
.archive-card.mobile .btn-action i {
width: 14px;
height: 14px;
}
.archive-card.mobile .files-indicator {
display: flex;
align-items: center;
color: var(--text-muted);
}
.archive-card.mobile .files-indicator i {
width: 14px;
height: 14px;
}
.archive-card.mobile .assignee {
width: 24px;
height: 24px;
background: var(--blue);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 8px;
font-weight: 600;
color: white;
overflow: hidden;
}
.archive-card.mobile .mobile-title {
font-size: 14px;
font-weight: 500;
line-height: 1.5;
color: var(--text-primary);
margin: 0;
}
.archive-card.mobile .mobile-description {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
margin-top: 8px;
}
.archive-card.mobile .card-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.archive-card.mobile .date-create {
font-size: 11px;
color: var(--text-muted);
opacity: 0.7;
}
</style> </style>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="board"> <div class="board" :class="{ mobile: isMobile }">
<div class="columns"> <div class="columns" ref="columnsRef" @scroll="onColumnsScroll">
<Column <Column
v-for="column in filteredColumns" v-for="column in filteredColumns"
:key="column.id" :key="column.id"
@@ -14,6 +14,21 @@
@archive-task="archiveTask" @archive-task="archiveTask"
/> />
</div> </div>
<!-- Мобильный индикатор снизу (над навигацией) -->
<div v-if="isMobile" class="mobile-column-footer">
<div class="current-column-title">{{ currentColumnTitle }}</div>
<div class="column-indicators">
<button
v-for="(column, index) in filteredColumns"
:key="column.id"
class="indicator-dot"
:class="{ active: currentColumnIndex === index }"
:style="{ '--dot-color': column.color }"
@click="scrollToColumn(index)"
></button>
</div>
</div>
</div> </div>
</template> </template>
@@ -21,6 +36,35 @@
import { ref, computed, onMounted, onUpdated, watch } from 'vue' import { ref, computed, onMounted, onUpdated, watch } from 'vue'
import Column from './Column.vue' import Column from './Column.vue'
import { cardsApi } from '../api' import { cardsApi } from '../api'
import { useMobile } from '../composables/useMobile'
const { isMobile } = useMobile()
// Мобильный свайп
const columnsRef = ref(null)
const currentColumnIndex = ref(0)
const onColumnsScroll = () => {
if (!columnsRef.value || !isMobile.value) return
const scrollLeft = columnsRef.value.scrollLeft
const columnWidth = columnsRef.value.scrollWidth / filteredColumns.value.length
currentColumnIndex.value = Math.round(scrollLeft / columnWidth)
}
const scrollToColumn = (index) => {
if (!columnsRef.value) return
const columnWidth = columnsRef.value.scrollWidth / filteredColumns.value.length
columnsRef.value.scrollTo({
left: index * columnWidth,
behavior: 'smooth'
})
}
// Название текущей колонки для мобильного индикатора
const currentColumnTitle = computed(() => {
const col = filteredColumns.value[currentColumnIndex.value]
return col ? col.title : ''
})
const props = defineProps({ const props = defineProps({
activeDepartment: Number, activeDepartment: Number,
@@ -277,4 +321,76 @@ defineExpose({ saveTask, deleteTask, archiveTask })
align-items: flex-start; align-items: flex-start;
} }
/* ========== MOBILE: колонки со свайпом ========== */
.board.mobile {
min-width: auto;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.board.mobile .columns {
flex: 1;
display: flex;
gap: 0;
padding: 0;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
/* Отключаем вертикальный скролл на уровне этого элемента */
overscroll-behavior: contain;
touch-action: pan-x;
}
.board.mobile .columns::-webkit-scrollbar {
display: none;
}
/* Мобильный футер с индикатором колонок */
.mobile-column-footer {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: var(--bg-body);
flex-shrink: 0;
min-height: 60px;
}
.current-column-title {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
text-align: center;
}
.column-indicators {
display: flex;
justify-content: center;
gap: 8px;
}
.indicator-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
border: none;
padding: 0;
cursor: pointer;
transition: all 0.3s ease;
}
.indicator-dot.active {
width: 28px;
border-radius: 5px;
background: var(--dot-color, var(--accent));
}
</style> </style>

View File

@@ -22,6 +22,7 @@
<button <button
v-if="canArchive" v-if="canArchive"
class="btn-archive-card" class="btn-archive-card"
:class="{ 'always-visible': isMobile }"
@click.stop="handleArchive" @click.stop="handleArchive"
title="В архив" title="В архив"
> >
@@ -65,6 +66,9 @@
<script setup> <script setup>
import { ref, computed, onMounted, onUpdated } from 'vue' import { ref, computed, onMounted, onUpdated } from 'vue'
import { getFullUrl } from '../api' import { getFullUrl } from '../api'
import { useMobile } from '../composables/useMobile'
const { isMobile } = useMobile()
const props = defineProps({ const props = defineProps({
card: Object, card: Object,
@@ -350,4 +354,9 @@ const handleArchive = () => {
background: var(--orange); background: var(--orange);
color: #000; color: #000;
} }
/* Mobile: всегда показываем кнопку архивирования */
.btn-archive-card.always-visible {
display: flex;
}
</style> </style>

View File

@@ -5,7 +5,7 @@
@dragenter.prevent="handleDragEnter" @dragenter.prevent="handleDragEnter"
@dragleave="handleDragLeave" @dragleave="handleDragLeave"
@drop="handleDrop" @drop="handleDrop"
:class="{ 'drag-over': isDragOver }" :class="{ 'drag-over': isDragOver, mobile: isMobile }"
ref="columnRef" ref="columnRef"
> >
<div class="column-header"> <div class="column-header">
@@ -45,6 +45,9 @@
<script setup> <script setup>
import { ref, onMounted, onUpdated } from 'vue' import { ref, onMounted, onUpdated } from 'vue'
import Card from './Card.vue' import Card from './Card.vue'
import { useMobile } from '../composables/useMobile'
const { isMobile } = useMobile()
const props = defineProps({ const props = defineProps({
column: Object, column: Object,
@@ -142,6 +145,23 @@ const handleDrop = (e) => {
max-height: calc(100vh - 140px); max-height: calc(100vh - 140px);
} }
/* ========== MOBILE ========== */
.column.mobile {
width: 100vw;
min-width: 100vw;
height: 100%;
max-height: none;
padding: 0 16px;
scroll-snap-align: start;
scroll-snap-stop: always;
flex-shrink: 0;
}
.column.mobile .cards {
max-height: calc(100vh - 320px);
overflow-y: auto;
}
.column.drag-over .cards { .column.drag-over .cards {
background: var(--accent-soft); background: var(--accent-soft);
border-radius: 12px; border-radius: 12px;

View File

@@ -1,15 +1,16 @@
<template> <template>
<div class="datepicker" ref="datepickerRef"> <div class="datepicker" ref="datepickerRef">
<div class="datepicker-trigger" @click="toggleCalendar"> <div class="datepicker-trigger" :class="{ mobile: isMobile }" @click="toggleCalendar">
<i data-lucide="calendar"></i> <i data-lucide="calendar"></i>
<span v-if="modelValue">{{ formatDisplayDate(modelValue) }}</span> <span v-if="modelValue" class="date-text">{{ formatCompactDate(modelValue) }}</span>
<span v-else class="placeholder">Выберите дату</span> <span v-else class="placeholder">Выберите дату</span>
<button v-if="modelValue" class="clear-btn" @click.stop="clearDate"> <button v-if="modelValue" class="clear-btn" @click.stop="clearDate">
<i data-lucide="x"></i> <i data-lucide="x"></i>
</button> </button>
</div> </div>
<Transition name="dropdown"> <!-- Desktop: dropdown calendar -->
<Transition v-if="!isMobile" name="dropdown">
<div v-if="isOpen" class="calendar" :class="{ 'open-up': openUp }" ref="calendarRef"> <div v-if="isOpen" class="calendar" :class="{ 'open-up': openUp }" ref="calendarRef">
<div class="calendar-header"> <div class="calendar-header">
<button class="nav-btn" @click="prevMonth"> <button class="nav-btn" @click="prevMonth">
@@ -40,14 +41,65 @@
{{ day.day }} {{ day.day }}
</button> </button>
</div> </div>
</div> </div>
</Transition> </Transition>
<!-- Mobile: fullscreen overlay -->
<Teleport to="body">
<Transition name="mobile-calendar">
<div v-if="isMobile && isOpen" class="mobile-calendar-overlay">
<div class="mobile-calendar-panel">
<div class="mobile-calendar-body">
<div class="calendar-nav">
<button class="nav-btn" @click="prevMonth">
<i data-lucide="chevron-left"></i>
</button>
<span class="current-month">{{ monthNames[currentMonth] }} {{ currentYear }}</span>
<button class="nav-btn" @click="nextMonth">
<i data-lucide="chevron-right"></i>
</button>
</div>
<div class="calendar-weekdays">
<span v-for="day in weekdays" :key="day">{{ day }}</span>
</div>
<div class="calendar-days">
<button
v-for="day in calendarDays"
:key="day.key"
class="day"
:class="{
'other-month': !day.currentMonth,
'today': day.isToday,
'selected': day.isSelected
}"
@click="selectDate(day)"
>
{{ day.day }}
</button>
</div>
</div>
<div class="mobile-calendar-footer">
<button class="btn-today" @click="selectToday">Сегодня</button>
<button v-if="modelValue" class="btn-clear" @click="clearDate">Очистить</button>
<button class="btn-close-footer" @click="closeCalendar">
<i data-lucide="x"></i>
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue' import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useMobile } from '../composables/useMobile'
const { isMobile } = useMobile()
const props = defineProps({ const props = defineProps({
modelValue: String modelValue: String
@@ -68,6 +120,19 @@ const monthNames = [
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь' 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
] ]
const monthNamesShort = [
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
]
const formatCompactDate = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
const day = date.getDate()
const monthShort = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
return `${day} ${monthShort[date.getMonth()]} ${date.getFullYear()}`
}
const formatDisplayDate = (dateStr) => { const formatDisplayDate = (dateStr) => {
if (!dateStr) return '' if (!dateStr) return ''
const date = new Date(dateStr) const date = new Date(dateStr)
@@ -77,6 +142,10 @@ const formatDisplayDate = (dateStr) => {
return `${day} ${month} ${year}` return `${day} ${month} ${year}`
} }
const closeCalendar = () => {
isOpen.value = false
}
const calendarDays = computed(() => { const calendarDays = computed(() => {
const days = [] const days = []
const firstDay = new Date(currentYear.value, currentMonth.value, 1) const firstDay = new Date(currentYear.value, currentMonth.value, 1)
@@ -434,4 +503,205 @@ watch(isOpen, () => {
.calendar.open-up.dropdown-leave-to { .calendar.open-up.dropdown-leave-to {
transform: translateY(8px); transform: translateY(8px);
} }
/* ========== MOBILE: Компактный триггер ========== */
.datepicker-trigger.mobile {
padding: 0 12px;
height: 48px;
font-size: 13px;
}
.datepicker-trigger.mobile i:first-child {
width: 16px;
height: 16px;
}
.date-text {
flex: 1;
}
/* ========== MOBILE: Fullscreen Calendar ========== */
.mobile-calendar-overlay {
position: fixed;
inset: 0;
background: #18181b;
z-index: 2000;
display: flex;
flex-direction: column;
touch-action: none;
}
.mobile-calendar-panel {
width: 100%;
height: 100%;
background: #18181b;
display: flex;
flex-direction: column;
overflow: hidden;
}
.mobile-calendar-body {
flex: 1;
padding: 20px 16px;
display: flex;
flex-direction: column;
}
.calendar-nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.calendar-nav .current-month {
font-size: 18px;
font-weight: 600;
}
.calendar-nav .nav-btn {
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.06);
border-radius: 10px;
}
.calendar-nav .nav-btn i {
width: 20px;
height: 20px;
}
.mobile-calendar-body .calendar-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 8px;
}
.mobile-calendar-body .calendar-weekdays span {
text-align: center;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
padding: 8px;
}
.mobile-calendar-body .calendar-days {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 6px;
}
.mobile-calendar-body .day {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: 10px;
font-size: 15px;
color: var(--text-primary);
cursor: pointer;
transition: all 0.15s;
}
.mobile-calendar-body .day:active {
background: rgba(255, 255, 255, 0.1);
}
.mobile-calendar-body .day.other-month {
color: var(--text-muted);
opacity: 0.4;
}
.mobile-calendar-body .day.today {
border: 2px solid var(--accent);
color: var(--accent);
}
.mobile-calendar-body .day.selected {
background: var(--accent);
color: #000;
font-weight: 600;
}
.mobile-calendar-footer {
display: flex;
gap: 12px;
padding: 16px;
padding-bottom: calc(16px + var(--safe-area-bottom, 0px));
border-top: 1px solid rgba(255, 255, 255, 0.06);
margin-top: auto;
}
.btn-today {
flex: 1;
padding: 14px;
background: var(--accent);
border: none;
border-radius: 10px;
color: #000;
font-family: inherit;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn-today:active {
filter: brightness(0.9);
}
.btn-clear {
padding: 14px 20px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: var(--text-secondary);
font-family: inherit;
font-size: 15px;
cursor: pointer;
transition: all 0.15s;
}
.btn-clear:active {
background: rgba(255, 255, 255, 0.1);
}
.btn-close-footer {
width: 48px;
height: 48px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.btn-close-footer i {
width: 20px;
height: 20px;
}
.btn-close-footer:active {
background: rgba(255, 255, 255, 0.1);
}
/* Mobile calendar transition */
.mobile-calendar-enter-active,
.mobile-calendar-leave-active {
transition: opacity 0.2s ease;
}
.mobile-calendar-enter-from,
.mobile-calendar-leave-to {
opacity: 0;
}
</style> </style>

View File

@@ -0,0 +1,169 @@
<template>
<div class="department-select" :class="{ mobile: isMobile }" @click.stop>
<button class="department-btn" @click="dropdownOpen = !dropdownOpen">
<i data-lucide="filter" class="filter-icon"></i>
{{ currentLabel }}
<i data-lucide="chevron-down" class="chevron" :class="{ open: dropdownOpen }"></i>
</button>
<div class="department-dropdown" v-if="dropdownOpen">
<button
class="department-option"
:class="{ active: modelValue === null }"
@click="handleSelect(null)"
>
Все отделы
</button>
<button
v-for="dept in departments"
:key="dept.id"
class="department-option"
:class="{ active: modelValue === dept.id }"
@click="handleSelect(dept.id)"
>
{{ dept.name_departments }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useMobile } from '../composables/useMobile'
const props = defineProps({
modelValue: {
type: Number,
default: null
},
departments: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['update:modelValue'])
const { isMobile } = useMobile()
const dropdownOpen = ref(false)
const currentLabel = computed(() => {
if (props.modelValue === null) return 'Все отделы'
const dept = props.departments.find(d => d.id === props.modelValue)
return dept?.name_departments || 'Все отделы'
})
const handleSelect = (id) => {
dropdownOpen.value = false
emit('update:modelValue', id)
}
// Закрытие при клике вне
const closeDropdown = (e) => {
if (!e.target.closest('.department-select')) {
dropdownOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', closeDropdown)
if (window.lucide) window.lucide.createIcons()
})
onUnmounted(() => {
document.removeEventListener('click', closeDropdown)
})
</script>
<style scoped>
.department-select {
position: relative;
}
.department-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: var(--bg-card);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: var(--text-secondary);
font-family: inherit;
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.department-btn:hover {
background: var(--bg-card-hover);
color: var(--text-primary);
}
.department-btn .filter-icon {
width: 14px;
height: 14px;
opacity: 0.7;
}
.department-btn .chevron {
width: 12px;
height: 12px;
opacity: 0.5;
transition: transform 0.2s;
margin-left: 4px;
}
.department-btn .chevron.open {
transform: rotate(180deg);
}
.department-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 180px;
background: var(--bg-secondary);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 6px;
z-index: 200;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.department-option {
display: block;
width: 100%;
padding: 10px 12px;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-secondary);
font-family: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
transition: all 0.15s;
}
.department-option:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--text-primary);
}
.department-option.active {
background: var(--accent-soft);
color: var(--accent);
}
/* ========== MOBILE ========== */
.department-select.mobile .department-dropdown {
position: fixed;
top: auto;
left: 16px;
right: 16px;
bottom: 80px;
min-width: auto;
max-height: 50vh;
overflow-y: auto;
}
</style>

View File

@@ -1,20 +1,33 @@
<template> <template>
<header class="header"> <header class="header" :class="{ mobile: isMobile }">
<div class="header-left"> <!-- Десктоп версия -->
<div class="title-row"> <template v-if="!isMobile">
<h1 class="page-title">{{ title }}</h1> <div class="header-left">
<!-- Слот для фильтров (на одной строке с заголовком) --> <div class="title-row">
<slot name="filters"></slot> <h1 class="page-title">{{ title }}</h1>
<slot name="filters"></slot>
</div>
<p v-if="subtitle" class="page-subtitle">{{ subtitle }}</p>
</div> </div>
<p v-if="subtitle" class="page-subtitle">{{ subtitle }}</p> <div class="header-right">
</div> <slot name="stats"></slot>
<div class="header-right"> <button class="logout-btn" @click="logout" title="Выйти">
<!-- Слот для статистики и прочего --> <i data-lucide="log-out"></i>
<slot name="stats"></slot> </button>
<button class="logout-btn" @click="logout" title="Выйти"> </div>
<i data-lucide="log-out"></i> </template>
</button>
</div> <!-- Мобильная версия -->
<template v-else>
<!-- Компактная строка: заголовок слева + иконки-кнопки -->
<div class="mobile-header-row">
<h1 v-if="!$slots['mobile-filters']" class="mobile-title">{{ title }}</h1>
<slot name="mobile-filters"></slot>
<button class="mobile-logout-btn" @click="logout" title="Выйти">
<i data-lucide="log-out"></i>
</button>
</div>
</template>
</header> </header>
</template> </template>
@@ -22,6 +35,9 @@
import { onMounted, onUpdated } from 'vue' import { onMounted, onUpdated } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { authApi } from '../api' import { authApi } from '../api'
import { useMobile } from '../composables/useMobile'
const { isMobile } = useMobile()
defineProps({ defineProps({
title: { title: {
@@ -66,12 +82,15 @@ onUpdated(refreshIcons)
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
min-width: 0;
flex: 1;
} }
.title-row { .title-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 24px; gap: 24px;
min-width: 0;
} }
.page-title { .page-title {
@@ -92,6 +111,7 @@ onUpdated(refreshIcons)
display: flex; display: flex;
align-items: center; align-items: center;
gap: 20px; gap: 20px;
flex-shrink: 0;
} }
.logout-btn { .logout-btn {
@@ -118,4 +138,53 @@ onUpdated(refreshIcons)
width: 18px; width: 18px;
height: 18px; height: 18px;
} }
/* ========== MOBILE ========== */
.header.mobile {
padding: 10px 16px;
flex-direction: row;
border-bottom: none;
}
.mobile-header-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.mobile-title {
font-size: 18px;
font-weight: 600;
color: var(--text);
margin: 0;
white-space: nowrap;
}
.mobile-logout-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
margin-left: auto;
}
.mobile-logout-btn:hover,
.mobile-logout-btn:active {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.3);
color: #ef4444;
}
.mobile-logout-btn i {
width: 18px;
height: 18px;
}
</style> </style>

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="project-select" v-if="store.currentProject" @click.stop> <div class="project-select" :class="{ mobile: isMobile }" v-if="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 || 'Выберите проект' }}
@@ -22,8 +22,10 @@
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { useProjectsStore } from '../stores/projects' import { useProjectsStore } from '../stores/projects'
import { useMobile } from '../composables/useMobile'
const store = useProjectsStore() const store = useProjectsStore()
const { isMobile } = useMobile()
const dropdownOpen = ref(false) const dropdownOpen = ref(false)
const emit = defineEmits(['change']) const emit = defineEmits(['change'])
@@ -134,4 +136,16 @@ onUnmounted(() => {
background: var(--accent-soft); background: var(--accent-soft);
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

@@ -1,5 +1,5 @@
<template> <template>
<aside class="sidebar"> <aside class="sidebar" :class="{ mobile: isMobile }">
<!-- Логотип --> <!-- Логотип -->
<div class="sidebar-logo"> <div class="sidebar-logo">
<i data-lucide="layout-grid"></i> <i data-lucide="layout-grid"></i>
@@ -22,6 +22,9 @@
<script setup> <script setup>
import { onMounted, onUpdated } from 'vue' import { onMounted, onUpdated } from 'vue'
import { useMobile } from '../composables/useMobile'
const { isMobile } = useMobile()
const refreshIcons = () => { const refreshIcons = () => {
if (window.lucide) { if (window.lucide) {
@@ -98,4 +101,42 @@ onUpdated(refreshIcons)
width: 22px; width: 22px;
height: 22px; height: 22px;
} }
/* ========== MOBILE: Нижняя навигация ========== */
.sidebar.mobile {
top: auto;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: calc(64px + var(--safe-area-bottom, 0px));
flex-direction: row;
justify-content: center;
align-items: flex-start;
padding: 8px 0;
padding-bottom: calc(8px + var(--safe-area-bottom, 0px));
border-right: none;
border-top: 1px solid rgba(255, 255, 255, 0.08);
background: var(--bg-body);
backdrop-filter: blur(10px);
}
.sidebar.mobile .sidebar-logo {
display: none;
}
.sidebar.mobile .sidebar-nav {
flex-direction: row;
gap: 32px;
}
.sidebar.mobile .nav-item {
width: 48px;
height: 48px;
}
.sidebar.mobile .nav-item i {
width: 24px;
height: 24px;
}
</style> </style>

View File

@@ -1,41 +1,82 @@
<template> <template>
<div class="comment-form"> <div class="comment-form">
<!-- Индикатор "ответ на" --> <!-- Desktop: Inline форма -->
<div v-if="replyingTo" class="reply-indicator"> <template v-if="!isMobile">
<i data-lucide="corner-down-right"></i> <!-- Индикатор "ответ на" -->
<span>Ответ для <strong>{{ replyingTo.author_name }}</strong></span> <div v-if="replyingTo" class="reply-indicator">
<button class="reply-cancel" @click="$emit('cancel-reply')" title="Отменить"> <i data-lucide="corner-down-right"></i>
<i data-lucide="x"></i> <span>Ответ для <strong>{{ replyingTo.author_name }}</strong></span>
</button> <button class="reply-cancel" @click="$emit('cancel-reply')" title="Отменить">
</div> <i data-lucide="x"></i>
</button>
</div>
<FormField :label="replyingTo ? 'Ваш ответ' : 'Новый комментарий'"> <FormField :label="replyingTo ? 'Ваш ответ' : 'Новый комментарий'">
<template #actions> <template #actions>
<div class="format-buttons"> <div class="format-buttons">
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('bold')" title="Жирный"> <button type="button" class="format-btn" @mousedown.prevent="applyFormat('bold')" title="Жирный">
<i data-lucide="bold"></i> <i data-lucide="bold"></i>
</button> </button>
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('italic')" title="Курсив"> <button type="button" class="format-btn" @mousedown.prevent="applyFormat('italic')" title="Курсив">
<i data-lucide="italic"></i> <i data-lucide="italic"></i>
</button> </button>
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('underline')" title="Подчёркивание"> <button type="button" class="format-btn" @mousedown.prevent="applyFormat('underline')" title="Подчёркивание">
<i data-lucide="underline"></i> <i data-lucide="underline"></i>
</button> </button>
<button type="button" class="format-btn" @mousedown.prevent="triggerFileInput" title="Прикрепить файл"> <button type="button" class="format-btn" @mousedown.prevent="triggerFileInput" title="Прикрепить файл">
<i data-lucide="paperclip"></i> <i data-lucide="paperclip"></i>
</button>
</div>
</template>
<RichTextEditor
:modelValue="modelValue"
@update:modelValue="$emit('update:modelValue', $event)"
:placeholder="replyingTo ? 'Напишите ответ...' : 'Напишите комментарий...'"
:show-toolbar="false"
ref="editorRef"
/>
</FormField>
<!-- Превью прикреплённых файлов -->
<div v-if="files.length > 0" class="attached-files">
<div
v-for="(file, index) in files"
:key="file.name + '-' + index"
class="attached-file"
>
<div class="attached-file-icon">
<i v-if="isArchive(file)" data-lucide="archive"></i>
<i v-else data-lucide="image"></i>
</div>
<span class="attached-file-name" :title="file.name">{{ file.name }}</span>
<button class="attached-file-remove" @click="removeFile(index)" title="Удалить">
<i data-lucide="x"></i>
</button> </button>
</div> </div>
</template> </div>
<RichTextEditor
:modelValue="modelValue"
@update:modelValue="$emit('update:modelValue', $event)"
:placeholder="replyingTo ? 'Напишите ответ...' : 'Напишите комментарий...'"
:show-toolbar="false"
ref="editorRef"
/>
</FormField>
<!-- Скрытый input для файлов --> <button
class="btn-send-comment"
@click="$emit('send')"
:disabled="!canSend || isSending"
>
<span v-if="isSending" class="btn-loader"></span>
<template v-else>
<i data-lucide="send"></i>
Отправить
</template>
</button>
</template>
<!-- Mobile: Кнопка открытия формы -->
<template v-else>
<button class="btn-open-form" @click="openMobileForm">
<i data-lucide="message-square-plus"></i>
{{ replyingTo ? 'Написать ответ' : 'Написать комментарий' }}
</button>
</template>
<!-- Скрытый input для файлов (общий) -->
<input <input
type="file" type="file"
ref="fileInputRef" ref="fileInputRef"
@@ -45,42 +86,94 @@
style="display: none" style="display: none"
> >
<!-- Превью прикреплённых файлов --> <!-- Mobile: Fullscreen форма -->
<div v-if="files.length > 0" class="attached-files"> <Teleport to="body">
<div <Transition name="mobile-form">
v-for="(file, index) in files" <div v-if="isMobile && mobileFormOpen" class="mobile-form-overlay">
:key="file.name + '-' + index" <div class="mobile-form-panel">
class="attached-file" <div class="mobile-form-header">
> <button class="btn-close" @click="closeMobileForm">
<div class="attached-file-icon"> <i data-lucide="x"></i>
<i v-if="isArchive(file)" data-lucide="archive"></i> </button>
<i v-else data-lucide="image"></i> <h3 class="panel-title">{{ replyingTo ? 'Ответ' : 'Новый комментарий' }}</h3>
</div> <div class="header-spacer"></div>
<span class="attached-file-name" :title="file.name">{{ file.name }}</span> </div>
<button class="attached-file-remove" @click="removeFile(index)" title="Удалить">
<i data-lucide="x"></i>
</button>
</div>
</div>
<button <!-- Индикатор "ответ на" -->
class="btn-send-comment" <div v-if="replyingTo" class="mobile-reply-indicator">
@click="$emit('send')" <i data-lucide="corner-down-right"></i>
:disabled="!canSend || isSending" <span>Ответ для <strong>{{ replyingTo.author_name }}</strong></span>
> </div>
<span v-if="isSending" class="btn-loader"></span>
<template v-else> <div class="mobile-form-body">
<i data-lucide="send"></i> <RichTextEditor
Отправить :modelValue="modelValue"
</template> @update:modelValue="$emit('update:modelValue', $event)"
</button> :placeholder="replyingTo ? 'Напишите ответ...' : 'Напишите комментарий...'"
:show-toolbar="false"
ref="mobileEditorRef"
class="mobile-editor"
/>
<!-- Превью прикреплённых файлов -->
<div v-if="files.length > 0" class="attached-files mobile">
<div
v-for="(file, index) in files"
:key="file.name + '-' + index"
class="attached-file"
>
<div class="attached-file-icon">
<i v-if="isArchive(file)" data-lucide="archive"></i>
<i v-else data-lucide="image"></i>
</div>
<span class="attached-file-name" :title="file.name">{{ file.name }}</span>
<button class="attached-file-remove" @click="removeFile(index)" title="Удалить">
<i data-lucide="x"></i>
</button>
</div>
</div>
</div>
<div class="mobile-form-footer">
<div class="mobile-format-buttons">
<button class="btn-format" @click="applyMobileFormat('bold')" title="Жирный">
<i data-lucide="bold"></i>
</button>
<button class="btn-format" @click="applyMobileFormat('italic')" title="Курсив">
<i data-lucide="italic"></i>
</button>
<button class="btn-format" @click="applyMobileFormat('underline')" title="Подчёркивание">
<i data-lucide="underline"></i>
</button>
<button class="btn-format" @click="triggerFileInput" title="Прикрепить файл">
<i data-lucide="paperclip"></i>
</button>
</div>
<button
class="btn-send"
@click="handleMobileSend"
:disabled="!canSend || isSending"
>
<span v-if="isSending" class="btn-loader"></span>
<template v-else>
<i data-lucide="send"></i>
</template>
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, onUpdated } from 'vue' import { ref, computed, onMounted, onUpdated, watch, nextTick } from 'vue'
import FormField from '../ui/FormField.vue' import FormField from '../ui/FormField.vue'
import RichTextEditor from '../ui/RichTextEditor.vue' import RichTextEditor from '../ui/RichTextEditor.vue'
import { useMobile } from '../../composables/useMobile'
const { isMobile } = useMobile()
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@@ -97,11 +190,47 @@ const props = defineProps({
} }
}) })
defineEmits(['update:modelValue', 'send', 'cancel-reply']) const emit = defineEmits(['update:modelValue', 'send', 'cancel-reply'])
const editorRef = ref(null) const editorRef = ref(null)
const mobileEditorRef = ref(null)
const fileInputRef = ref(null) const fileInputRef = ref(null)
const files = ref([]) const files = ref([])
const mobileFormOpen = ref(false)
// Открытие мобильной формы
const openMobileForm = async () => {
mobileFormOpen.value = true
await nextTick()
refreshIcons()
}
// Закрытие мобильной формы
const closeMobileForm = () => {
mobileFormOpen.value = false
emit('cancel-reply')
}
// Отправка из мобильной формы
const handleMobileSend = () => {
emit('send')
// Форма закроется после успешной отправки через watch
}
// Закрытие формы когда isSending становится false после отправки
watch(() => props.isSending, (newVal, oldVal) => {
if (oldVal === true && newVal === false && mobileFormOpen.value) {
// Отправка завершена, закрываем форму
closeMobileForm()
}
})
// Открытие формы при выборе ответа
watch(() => props.replyingTo, (newVal) => {
if (newVal && isMobile.value) {
openMobileForm()
}
})
const allowedExtensions = ['png', 'jpg', 'jpeg', 'zip', 'rar'] const allowedExtensions = ['png', 'jpg', 'jpeg', 'zip', 'rar']
const archiveExtensions = ['zip', 'rar'] const archiveExtensions = ['zip', 'rar']
@@ -197,6 +326,10 @@ const applyFormat = (command) => {
editorRef.value?.applyFormat(command) editorRef.value?.applyFormat(command)
} }
const applyMobileFormat = (command) => {
mobileEditorRef.value?.applyFormat(command)
}
const refreshIcons = () => { const refreshIcons = () => {
if (window.lucide) { if (window.lucide) {
window.lucide.createIcons() window.lucide.createIcons()
@@ -221,6 +354,7 @@ defineExpose({
.comment-form { .comment-form {
border-top: 1px solid rgba(255, 255, 255, 0.06); border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-top: 16px; padding-top: 16px;
padding-bottom: calc(var(--safe-area-bottom, 0px));
margin-top: auto; margin-top: auto;
} }
@@ -420,4 +554,215 @@ defineExpose({
width: 12px; width: 12px;
height: 12px; height: 12px;
} }
/* ========== MOBILE: Кнопка открытия формы ========== */
.btn-open-form {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 14px;
background: var(--accent);
border: none;
border-radius: 10px;
color: #000;
font-family: inherit;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn-open-form i {
width: 18px;
height: 18px;
}
.btn-open-form:active {
filter: brightness(0.9);
}
/* ========== MOBILE: Fullscreen Form ========== */
.mobile-form-overlay {
position: fixed;
inset: 0;
background: #18181b;
z-index: 2000;
display: flex;
flex-direction: column;
touch-action: none;
}
.mobile-form-panel {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.mobile-form-header {
display: flex;
align-items: center;
padding: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
}
.mobile-form-header .panel-title {
flex: 1;
text-align: center;
font-size: 18px;
font-weight: 500;
margin: 0;
}
.mobile-form-header .btn-close {
width: 36px;
height: 36px;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
flex-shrink: 0;
}
.mobile-form-header .btn-close i {
width: 20px;
height: 20px;
}
.mobile-form-header .header-spacer {
width: 36px;
flex-shrink: 0;
}
.mobile-reply-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: rgba(0, 212, 170, 0.08);
font-size: 14px;
color: var(--text-secondary);
}
.mobile-reply-indicator i {
width: 16px;
height: 16px;
color: var(--accent);
}
.mobile-reply-indicator strong {
color: var(--text-primary);
}
.mobile-form-body {
flex: 1;
padding: 16px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.mobile-editor {
flex: 1;
min-height: 150px;
}
.mobile-editor :deep(.editor-content) {
min-height: 150px;
}
.attached-files.mobile {
margin-top: auto;
}
.mobile-form-footer {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
padding-bottom: calc(12px + var(--safe-area-bottom, 0px));
border-top: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
}
.mobile-format-buttons {
display: flex;
gap: 6px;
}
.btn-format {
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
}
.btn-format i {
width: 16px;
height: 16px;
}
.btn-format:active {
background: var(--accent);
border-color: var(--accent);
color: #000;
}
.btn-send {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px;
background: var(--accent);
border: none;
border-radius: 10px;
color: #000;
font-family: inherit;
font-size: 15px;
font-weight: 500;
cursor: pointer;
}
.btn-send i {
width: 18px;
height: 18px;
}
.btn-send:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-send:not(:disabled):active {
filter: brightness(0.9);
}
/* Mobile form transition */
.mobile-form-enter-active,
.mobile-form-leave-active {
transition: opacity 0.2s ease;
}
.mobile-form-enter-from,
.mobile-form-leave-to {
opacity: 0;
}
</style> </style>

View File

@@ -402,6 +402,7 @@ defineExpose({
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; /* Важно для flex overflow */
} }
.comments-list { .comments-list {
@@ -413,10 +414,19 @@ defineExpose({
padding: 2px; /* Для outline при редактировании */ padding: 2px; /* Для outline при редактировании */
padding-right: 6px; padding-right: 6px;
margin-bottom: 16px; margin-bottom: 16px;
min-height: 200px; min-height: 100px;
max-height: calc(100vh - 400px); max-height: calc(100vh - 400px);
} }
/* Mobile: убираем max-height, используем flex */
@media (max-width: 768px) {
.comments-list {
max-height: none;
min-height: 50px;
margin-bottom: 12px;
}
}
.comments-loading, .comments-loading,
.comments-empty { .comments-empty {
display: flex; display: flex;

View File

@@ -51,7 +51,7 @@
/> />
</FormField> </FormField>
<div class="field-row"> <div class="field-row" :class="{ mobile: isMobile }">
<FormField label="Срок выполнения"> <FormField label="Срок выполнения">
<DatePicker v-model="form.dueDate" /> <DatePicker v-model="form.dueDate" />
</FormField> </FormField>
@@ -106,6 +106,9 @@ import FileUploader from '../ui/FileUploader.vue'
import DatePicker from '../DatePicker.vue' import DatePicker from '../DatePicker.vue'
import ConfirmDialog from '../ConfirmDialog.vue' import ConfirmDialog from '../ConfirmDialog.vue'
import { getFullUrl } from '../../api' import { getFullUrl } from '../../api'
import { useMobile } from '../../composables/useMobile'
const { isMobile } = useMobile()
const props = defineProps({ const props = defineProps({
card: { card: {
@@ -370,6 +373,11 @@ defineExpose({
flex: 1; flex: 1;
} }
.field-row.mobile {
flex-direction: column;
gap: 16px;
}
.format-buttons { .format-buttons {
display: flex; display: flex;
gap: 3px; gap: 3px;

View File

@@ -4,10 +4,12 @@
@close="tryClose" @close="tryClose"
> >
<template #header> <template #header>
<h2>{{ panelTitle }}</h2> <div class="header-title-block">
<span v-if="!isNew && card?.dateCreate" class="header-date"> <h2>{{ panelTitle }}</h2>
Создано: {{ formatDate(card.dateCreate) }} <span v-if="!isNew && card?.dateCreate" class="header-date">
</span> Создано: {{ formatDate(card.dateCreate) }}
</span>
</div>
<!-- Вкладки (только для существующих задач) --> <!-- Вкладки (только для существующих задач) -->
<TabsPanel <TabsPanel
@@ -43,7 +45,8 @@
/> />
</template> </template>
<template #footer> <!-- Footer: скрываем на вкладке комментариев -->
<template #footer v-if="activeTab !== 'comments'">
<div class="footer-left"> <div class="footer-left">
<IconButton <IconButton
v-if="!isNew" v-if="!isNew"
@@ -146,6 +149,9 @@ import ConfirmDialog from '../ConfirmDialog.vue'
import TaskEditTab from './TaskEditTab.vue' import TaskEditTab from './TaskEditTab.vue'
import TaskCommentsTab from './TaskCommentsTab.vue' import TaskCommentsTab from './TaskCommentsTab.vue'
import { taskImageApi, commentImageApi, getFullUrl } from '../../api' import { taskImageApi, commentImageApi, getFullUrl } from '../../api'
import { useMobile } from '../../composables/useMobile'
const { isMobile } = useMobile()
const props = defineProps({ const props = defineProps({
show: Boolean, show: Boolean,
@@ -402,7 +408,7 @@ watch(() => props.show, async (newVal) => {
await nextTick() await nextTick()
editTabRef.value?.saveInitialForm() editTabRef.value?.saveInitialForm()
editTabRef.value?.focusTitle() // Не фокусируем поле автоматически, чтобы не открывалась клавиатура
refreshIcons() refreshIcons()
} }
}) })
@@ -412,13 +418,22 @@ onUpdated(refreshIcons)
</script> </script>
<style scoped> <style scoped>
.header-title-block {
display: flex;
flex-direction: column;
gap: 4px;
}
.header-title-block h2 {
margin: 0;
}
.header-date { .header-date {
font-size: 13px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
} }
.header-tabs { .header-tabs {
margin-left: auto;
margin-right: 12px; margin-right: 12px;
} }

View File

@@ -0,0 +1,337 @@
<template>
<!-- Кнопка-триггер -->
<button class="mobile-select-btn" :class="[variant, { compact }]" @click="open = true">
<i v-if="icon" :data-lucide="icon" class="btn-icon"></i>
<span v-if="!compact" class="btn-label">{{ displayValue }}</span>
<i data-lucide="chevron-down" class="btn-arrow"></i>
</button>
<!-- Fullscreen панель выбора -->
<Teleport to="body">
<Transition name="slide-up">
<div v-if="open" class="mobile-select-overlay" @click.self="open = false">
<div class="mobile-select-panel">
<!-- Header панели -->
<div class="panel-header">
<button class="panel-back" @click="open = false">
<i data-lucide="arrow-left"></i>
</button>
<h3 class="panel-title">{{ title }}</h3>
</div>
<!-- Список опций -->
<div class="panel-options">
<button
v-for="option in options"
:key="option.id"
class="option-item"
:class="{ active: modelValue === option.id }"
@click="selectOption(option.id)"
>
<span class="option-label">{{ option.label }}</span>
<i v-if="modelValue === option.id" data-lucide="check" class="option-check"></i>
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUpdated } from 'vue'
const props = defineProps({
modelValue: {
type: [Number, String, null],
default: null
},
options: {
type: Array,
required: true
// [{ id: 1, label: 'Option 1' }, ...]
},
title: {
type: String,
default: 'Выберите'
},
placeholder: {
type: String,
default: 'Выберите...'
},
icon: {
type: String,
default: null
},
variant: {
type: String,
default: 'default' // 'default' | 'accent'
},
compact: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue'])
const open = ref(false)
const displayValue = computed(() => {
if (props.modelValue === null || props.modelValue === undefined) {
return props.placeholder
}
const option = props.options.find(o => o.id === props.modelValue)
return option?.label || props.placeholder
})
const selectOption = (id) => {
emit('update:modelValue', id)
open.value = false
}
// Refresh icons
const refreshIcons = () => {
if (window.lucide) {
setTimeout(() => window.lucide.createIcons(), 10)
}
}
watch(open, (val) => {
if (val) {
document.body.style.overflow = 'hidden'
refreshIcons()
} else {
document.body.style.overflow = ''
}
})
onMounted(refreshIcons)
onUpdated(refreshIcons)
</script>
<style scoped>
/* Кнопка триггер */
.mobile-select-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
min-height: 40px;
background: var(--bg-card);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
color: var(--text-secondary);
font-family: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
@media (hover: hover) {
.mobile-select-btn:hover {
background: var(--bg-card-hover);
color: var(--text-primary);
}
}
.mobile-select-btn:active {
background: var(--bg-card-hover);
color: var(--text-primary);
}
/* Акцентная версия (для проекта) */
.mobile-select-btn.accent {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
@media (hover: hover) {
.mobile-select-btn.accent:hover {
background: var(--accent);
color: #000;
}
}
.mobile-select-btn.accent:active {
background: var(--accent);
color: #000;
}
.mobile-select-btn.accent .btn-icon,
.mobile-select-btn.accent .btn-arrow {
opacity: 1;
}
/* Компактный режим (только иконка) */
.mobile-select-btn.compact {
width: 40px;
height: 40px;
padding: 0;
justify-content: center;
border-radius: 10px;
}
.mobile-select-btn.compact .btn-icon {
width: 18px;
height: 18px;
margin: 0;
}
.mobile-select-btn.compact .btn-arrow {
display: none;
}
.btn-icon {
width: 14px;
height: 14px;
opacity: 0.7;
}
.btn-label {
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-arrow {
width: 12px;
height: 12px;
opacity: 0.4;
margin-left: 2px;
}
/* Overlay */
.mobile-select-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 1000;
display: flex;
align-items: flex-start;
}
/* Панель */
.mobile-select-panel {
width: 100%;
max-height: 80vh;
background: var(--bg-body);
border-radius: 0 0 20px 20px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.panel-back {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-radius: 8px;
transition: all 0.15s;
}
.panel-back:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary);
}
.panel-back i {
width: 20px;
height: 20px;
}
.panel-title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
/* Опции */
.panel-options {
flex: 1;
overflow-y: auto;
padding: 8px;
padding-bottom: 16px;
}
.option-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 16px;
background: none;
border: none;
border-radius: 12px;
color: var(--text-secondary);
font-family: inherit;
font-size: 16px;
text-align: left;
cursor: pointer;
transition: all 0.15s;
}
@media (hover: hover) {
.option-item:hover {
background: rgba(255, 255, 255, 0.04);
}
}
.option-item:active {
background: rgba(255, 255, 255, 0.08);
}
.option-item.active {
background: var(--accent-soft);
color: var(--accent);
}
.option-label {
flex: 1;
}
.option-check {
width: 20px;
height: 20px;
color: var(--accent);
}
/* Анимация */
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.3s ease;
}
.slide-up-enter-active .mobile-select-panel,
.slide-up-leave-active .mobile-select-panel {
transition: transform 0.3s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
opacity: 0;
}
.slide-up-enter-from .mobile-select-panel,
.slide-up-leave-to .mobile-select-panel {
transform: translateY(-100%);
}
</style>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="select-dropdown" ref="dropdownRef"> <div class="select-dropdown" ref="dropdownRef">
<!-- Триггер --> <!-- Триггер -->
<button class="dropdown-trigger" @click="toggleDropdown" :disabled="disabled"> <button class="dropdown-trigger" :class="{ mobile: isMobile }" @click="toggleDropdown" :disabled="disabled">
<slot name="selected" :selected="selectedOption"> <slot name="selected" :selected="selectedOption">
<img <img
v-if="selectedOption?.avatar" v-if="selectedOption?.avatar"
@@ -15,8 +15,8 @@
<i data-lucide="chevron-down" class="dropdown-arrow"></i> <i data-lucide="chevron-down" class="dropdown-arrow"></i>
</button> </button>
<!-- Выпадающий список --> <!-- Desktop: выпадающий список -->
<Transition name="dropdown"> <Transition v-if="!isMobile" name="dropdown">
<div v-if="isOpen" class="dropdown-menu" :class="{ 'open-up': openUp }"> <div v-if="isOpen" class="dropdown-menu" :class="{ 'open-up': openUp }">
<input <input
v-if="searchable" v-if="searchable"
@@ -68,11 +68,86 @@
</div> </div>
</div> </div>
</Transition> </Transition>
<!-- Mobile: fullscreen панель -->
<Teleport to="body">
<Transition name="mobile-select">
<div v-if="isMobile && isOpen" class="mobile-select-overlay" @click.self="closeDropdown">
<div
class="mobile-select-panel"
:class="{ dragging: isDragging }"
:style="panelDragStyle"
@click.stop
ref="mobilePanelRef"
>
<div
class="mobile-select-handle"
@touchstart="onDragStart"
@touchmove="onDragMove"
@touchend="onDragEnd"
></div>
<div v-if="searchable" class="mobile-select-search" @click.stop>
<i data-lucide="search"></i>
<input
type="text"
v-model="searchQuery"
:placeholder="searchPlaceholder"
ref="mobileSearchRef"
>
</div>
<div class="mobile-select-list">
<!-- Опция "не выбрано" -->
<button
v-if="allowEmpty"
class="mobile-select-item"
:class="{ active: !modelValue }"
@click="selectOption(null)"
>
<span class="no-selection-icon"></span>
<span class="item-label">{{ emptyLabel }}</span>
<i v-if="!modelValue" data-lucide="check" class="check-icon"></i>
</button>
<!-- Опции -->
<button
v-for="option in filteredOptions"
:key="option.value"
class="mobile-select-item"
:class="{ active: modelValue === option.value }"
@click="selectOption(option.value)"
>
<img
v-if="option.avatar"
:src="option.avatar"
:alt="option.label"
class="option-avatar"
>
<div class="option-content">
<span class="option-label">{{ option.label }}</span>
<span v-if="option.subtitle" class="option-subtitle">{{ option.subtitle }}</span>
</div>
<i v-if="modelValue === option.value" data-lucide="check" class="check-icon"></i>
</button>
<!-- Пустой результат поиска -->
<div v-if="searchable && filteredOptions.length === 0" class="mobile-select-empty">
Ничего не найдено
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, watch, onMounted, onUnmounted, onUpdated, nextTick } from 'vue' import { ref, computed, onMounted, onUnmounted, onUpdated, nextTick } from 'vue'
import { useMobile } from '../../composables/useMobile'
const { isMobile } = useMobile()
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@@ -114,11 +189,64 @@ const emit = defineEmits(['update:modelValue', 'change'])
const dropdownRef = ref(null) const dropdownRef = ref(null)
const searchInputRef = ref(null) const searchInputRef = ref(null)
const mobileSearchRef = ref(null)
const mobilePanelRef = ref(null)
const listRef = ref(null) const listRef = ref(null)
const isOpen = ref(false) const isOpen = ref(false)
const openUp = ref(false) const openUp = ref(false)
const searchQuery = ref('') const searchQuery = ref('')
// Drag to dismiss
const isDragging = ref(false)
const dragStartY = ref(0)
const dragCurrentY = ref(0)
const panelHeight = ref(0)
const panelDragStyle = computed(() => {
if (!isDragging.value) return {}
const deltaY = dragCurrentY.value - dragStartY.value
if (deltaY < 0) return {} // Не двигать вверх
return {
transform: `translateY(${deltaY}px)`,
transition: 'none'
}
})
const onDragStart = (e) => {
isDragging.value = true
dragStartY.value = e.touches[0].clientY
dragCurrentY.value = e.touches[0].clientY
if (mobilePanelRef.value) {
panelHeight.value = mobilePanelRef.value.offsetHeight
}
}
const onDragMove = (e) => {
if (!isDragging.value) return
dragCurrentY.value = e.touches[0].clientY
}
const onDragEnd = () => {
if (!isDragging.value) return
const deltaY = dragCurrentY.value - dragStartY.value
const threshold = panelHeight.value * 0.3 // 30% от высоты панели
if (deltaY > threshold) {
// Закрыть панель
closeDropdown()
}
isDragging.value = false
dragStartY.value = 0
dragCurrentY.value = 0
}
const closeDropdown = () => {
isOpen.value = false
searchQuery.value = ''
}
// Выбранная опция // Выбранная опция
const selectedOption = computed(() => { const selectedOption = computed(() => {
if (!props.modelValue) return null if (!props.modelValue) return null
@@ -165,8 +293,9 @@ const selectOption = (value) => {
searchQuery.value = '' searchQuery.value = ''
} }
// Закрытие при клике вне // Закрытие при клике вне (только для десктопа)
const handleClickOutside = (e) => { const handleClickOutside = (e) => {
if (isMobile.value) return // На мобильных закрытие только по кнопке
if (dropdownRef.value && !dropdownRef.value.contains(e.target)) { if (dropdownRef.value && !dropdownRef.value.contains(e.target)) {
isOpen.value = false isOpen.value = false
searchQuery.value = '' searchQuery.value = ''
@@ -416,4 +545,194 @@ onUpdated(refreshIcons)
.dropdown-menu.open-up.dropdown-leave-to { .dropdown-menu.open-up.dropdown-leave-to {
transform: translateY(8px); transform: translateY(8px);
} }
/* ========== MOBILE: Fullscreen Select ========== */
.dropdown-trigger.mobile {
height: 48px;
font-size: 13px;
}
.mobile-select-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 2000;
display: flex;
flex-direction: column;
justify-content: flex-end;
touch-action: none;
}
.mobile-select-panel {
width: 100%;
max-height: 80vh;
background: #18181b;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.mobile-select-handle {
width: 100%;
padding: 12px 0;
display: flex;
justify-content: center;
flex-shrink: 0;
cursor: grab;
touch-action: none;
}
.mobile-select-handle::after {
content: '';
width: 36px;
height: 4px;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
}
.mobile-select-panel.dragging .mobile-select-handle {
cursor: grabbing;
}
.mobile-select-search {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
}
.mobile-select-search i {
width: 18px;
height: 18px;
color: var(--text-muted);
flex-shrink: 0;
}
.mobile-select-search input {
flex: 1;
background: none;
border: none;
color: var(--text-primary);
font-family: inherit;
font-size: 16px;
outline: none;
}
.mobile-select-search input::placeholder {
color: var(--text-muted);
}
.mobile-select-list {
flex: 1;
overflow-y: auto;
padding: 8px 0;
-webkit-overflow-scrolling: touch;
}
.mobile-select-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 14px 16px;
background: none;
border: none;
color: var(--text-secondary);
font-family: inherit;
font-size: 16px;
text-align: left;
cursor: pointer;
transition: all 0.15s;
}
.mobile-select-item:active {
background: rgba(255, 255, 255, 0.06);
}
.mobile-select-item.active {
color: var(--accent);
}
.mobile-select-item .option-avatar {
width: 40px;
height: 40px;
border-radius: 10px;
}
.mobile-select-item .option-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.mobile-select-item .option-label {
font-weight: 500;
}
.mobile-select-item .option-subtitle {
font-size: 13px;
color: var(--text-muted);
}
.mobile-select-item.active .option-subtitle {
color: var(--accent);
opacity: 0.7;
}
.mobile-select-item .no-selection-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.08);
border-radius: 10px;
font-size: 16px;
flex-shrink: 0;
}
.mobile-select-item .item-label {
flex: 1;
}
.mobile-select-item .check-icon {
width: 20px;
height: 20px;
color: var(--accent);
flex-shrink: 0;
}
.mobile-select-empty {
padding: 40px 16px;
text-align: center;
color: var(--text-muted);
font-size: 15px;
}
/* Mobile select transition */
.mobile-select-enter-active,
.mobile-select-leave-active {
transition: opacity 0.25s ease;
}
.mobile-select-enter-active .mobile-select-panel,
.mobile-select-leave-active .mobile-select-panel {
transition: transform 0.25s ease;
}
.mobile-select-enter-from,
.mobile-select-leave-to {
opacity: 0;
}
.mobile-select-enter-from .mobile-select-panel,
.mobile-select-leave-to .mobile-select-panel {
transform: translateY(100%);
}
</style> </style>

View File

@@ -8,6 +8,7 @@
> >
<div <div
class="panel" class="panel"
:class="{ mobile: isMobile }"
:style="{ width: panelWidth + 'px' }" :style="{ width: panelWidth + 'px' }"
ref="panelRef" ref="panelRef"
@mousedown="overlayMouseDownTarget = false" @mousedown="overlayMouseDownTarget = false"
@@ -41,6 +42,9 @@
<script setup> <script setup>
import { ref, onMounted, onUnmounted, onUpdated, watch } from 'vue' import { ref, onMounted, onUnmounted, onUpdated, watch } from 'vue'
import { useMobile } from '../../composables/useMobile'
const { isMobile } = useMobile()
const props = defineProps({ const props = defineProps({
show: { show: {
@@ -142,10 +146,22 @@ const refreshIcons = () => {
} }
} }
// Блокировка скролла body при открытии панели
const lockBodyScroll = () => {
document.body.classList.add('panel-open')
}
const unlockBodyScroll = () => {
document.body.classList.remove('panel-open')
}
// Восстановление ширины при открытии (из localStorage или дефолтная) // Восстановление ширины при открытии (из localStorage или дефолтная)
watch(() => props.show, (newVal) => { watch(() => props.show, (newVal) => {
if (newVal) { if (newVal) {
panelWidth.value = getSavedWidth() panelWidth.value = getSavedWidth()
lockBodyScroll()
} else {
unlockBodyScroll()
} }
}) })
@@ -155,6 +171,7 @@ onUpdated(refreshIcons)
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('mousemove', onResize) document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize) document.removeEventListener('mouseup', stopResize)
unlockBodyScroll()
}) })
</script> </script>
@@ -164,6 +181,9 @@ onUnmounted(() => {
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
z-index: 1000; z-index: 1000;
/* Блокируем горизонтальные свайпы на мобильных */
touch-action: pan-y pinch-zoom;
overscroll-behavior: contain;
} }
.panel { .panel {
@@ -172,6 +192,7 @@ onUnmounted(() => {
right: 0; right: 0;
max-width: 100%; max-width: 100%;
height: 100vh; height: 100vh;
height: 100dvh; /* Динамическая высота для iOS */
background: #18181b; background: #18181b;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -205,9 +226,10 @@ onUnmounted(() => {
.header-content { .header-content {
display: flex; display: flex;
align-items: baseline; align-items: center;
gap: 16px; gap: 16px;
flex: 1; flex: 1;
min-width: 0;
} }
.header-content :deep(h2) { .header-content :deep(h2) {
@@ -216,11 +238,21 @@ onUnmounted(() => {
margin: 0; margin: 0;
} }
.header-content :deep(.header-title-block) {
display: flex;
flex-direction: column;
gap: 2px;
}
.header-content :deep(.header-date) { .header-content :deep(.header-date) {
font-size: 13px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
} }
.header-content :deep(.header-tabs) {
margin-left: auto;
}
.btn-close { .btn-close {
background: none; background: none;
border: none; border: none;
@@ -299,4 +331,38 @@ onUnmounted(() => {
.panel-leave-to .panel { .panel-leave-to .panel {
transform: translateX(100%); transform: translateX(100%);
} }
/* ========== MOBILE: Fullscreen ========== */
.panel.mobile {
width: 100% !important;
max-width: 100%;
border-radius: 0;
/* Блокируем горизонтальные свайпы, чтобы не срабатывала навигация браузера */
touch-action: pan-y pinch-zoom;
overscroll-behavior: contain;
}
.panel.mobile .resize-handle {
display: none;
}
.panel.mobile .panel-header {
padding: 16px;
}
.panel.mobile .panel-body {
padding: 16px;
padding-bottom: calc(16px + var(--safe-area-bottom, 0px));
min-height: 0; /* Важно для flex overflow */
gap: 16px;
}
.panel.mobile .panel-footer {
padding: 16px;
padding-bottom: calc(16px + var(--safe-area-bottom, 0px));
}
.panel.mobile .header-content :deep(h2) {
font-size: 18px;
}
</style> </style>

View File

@@ -0,0 +1,40 @@
import { ref, onMounted, onUnmounted } from 'vue'
// Брейкпоинт для мобильных устройств (берётся из config.js)
export const MOBILE_BREAKPOINT = window.APP_CONFIG?.MOBILE_BREAKPOINT ?? 768
// Composable для определения мобильного устройства
export function useMobile() {
const isMobile = ref(false)
const checkMobile = () => {
isMobile.value = window.innerWidth <= MOBILE_BREAKPOINT
}
onMounted(() => {
checkMobile()
window.addEventListener('resize', checkMobile)
})
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
return { isMobile, MOBILE_BREAKPOINT }
}
// Функция для добавления класса на body (вызывать в App.vue)
export function initMobileClass() {
const updateBodyClass = () => {
if (window.innerWidth <= MOBILE_BREAKPOINT) {
document.body.classList.add('is-mobile')
} else {
document.body.classList.remove('is-mobile')
}
}
updateBodyClass()
window.addEventListener('resize', updateBodyClass)
return () => window.removeEventListener('resize', updateBodyClass)
}

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="app"> <div class="app" :class="{ mobile: isMobile }">
<!-- Боковая панель навигации --> <!-- Боковая панель навигации -->
<Sidebar /> <Sidebar />
@@ -33,6 +33,27 @@
</button> </button>
</div> </div>
</template> </template>
<!-- Мобильные фильтры -->
<template #mobile-filters>
<MobileSelect
v-model="mobileProjectId"
:options="projectOptions"
title="Выберите проект"
placeholder="Проект"
variant="accent"
@update:model-value="onMobileProjectChange"
/>
<MobileSelect
v-model="activeDepartment"
:options="departmentOptions"
title="Фильтр по отделам"
placeholder="Все отделы"
icon="filter"
compact
/>
</template>
<template #stats> <template #stats>
<div class="header-stats"> <div class="header-stats">
<div class="stat"> <div class="stat">
@@ -45,6 +66,15 @@
<!-- Список архивных задач --> <!-- Список архивных задач -->
<main class="main"> <main class="main">
<!-- Мобильный заголовок над карточками -->
<div v-if="isMobile" class="mobile-archive-header">
<div class="archive-title-row">
<span class="archive-dot"></span>
<span class="archive-title">В архиве</span>
<span class="archive-count">{{ filteredCards.length }}</span>
</div>
</div>
<div class="archive-list"> <div class="archive-list">
<ArchiveCard <ArchiveCard
v-for="card in filteredCards" v-for="card in filteredCards"
@@ -73,6 +103,7 @@
</main> </main>
</div> </div>
<!-- Панель редактирования задачи --> <!-- Панель редактирования задачи -->
<TaskPanel <TaskPanel
:show="panelOpen" :show="panelOpen"
@@ -110,12 +141,45 @@ import ArchiveCard from '../components/ArchiveCard.vue'
import TaskPanel from '../components/TaskPanel' import TaskPanel from '../components/TaskPanel'
import ConfirmDialog from '../components/ConfirmDialog.vue' import ConfirmDialog from '../components/ConfirmDialog.vue'
import ProjectSelector from '../components/ProjectSelector.vue' import ProjectSelector from '../components/ProjectSelector.vue'
import MobileSelect from '../components/ui/MobileSelect.vue'
import { useProjectsStore } from '../stores/projects' import { useProjectsStore } from '../stores/projects'
import { cardsApi } from '../api' import { cardsApi } from '../api'
import { useMobile } from '../composables/useMobile'
const { isMobile } = useMobile()
// ==================== STORE ==================== // ==================== STORE ====================
const store = useProjectsStore() 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 [
{ 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([])
const loading = ref(true) const loading = ref(true)
@@ -437,4 +501,92 @@ onMounted(async () => {
from { transform: rotate(0deg); } from { transform: rotate(0deg); }
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
/* ========== MOBILE ========== */
.app.mobile {
flex-direction: column;
height: 100vh;
height: 100dvh;
overflow: hidden;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.app.mobile .main-wrapper {
margin-left: 0;
flex: 1;
min-height: 0;
padding-bottom: calc(64px + var(--safe-area-bottom, 0px));
overflow: hidden;
display: flex;
flex-direction: column;
}
.app.mobile .main {
flex: 1;
min-height: 0;
padding: 0 16px 16px;
overflow: hidden;
}
.app.mobile .archive-list {
/* 60px header + 40px title + 64px nav + safe-area */
max-height: calc(100dvh - 60px - 40px - 64px - var(--safe-area-bottom, 0px));
overflow-y: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
padding-bottom: 16px;
}
.app.mobile .archive-list::-webkit-scrollbar {
display: none;
}
.app.mobile .archive-list {
max-width: none;
gap: 12px;
}
.app.mobile .empty-state {
padding: 40px 20px;
}
.app.mobile .empty-state i {
width: 40px;
height: 40px;
}
/* Мобильный заголовок над карточками */
.mobile-archive-header {
padding: 12px 0 16px 4px;
}
.archive-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.archive-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--orange, #ff9f43);
}
.archive-title {
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
}
.archive-count {
font-size: 14px;
font-weight: 500;
color: var(--text-muted);
}
</style> </style>

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="app"> <div class="app" :class="{ mobile: isMobile }">
<!-- Боковая панель навигации --> <!-- Боковая панель навигации -->
<Sidebar /> <Sidebar />
@@ -7,14 +7,11 @@
<div class="main-wrapper"> <div class="main-wrapper">
<!-- Шапка с заголовком, фильтрами и статистикой --> <!-- Шапка с заголовком, фильтрами и статистикой -->
<Header title="Доска задач"> <Header title="Доска задач">
<!-- Десктоп: фильтры в одну строку -->
<template #filters> <template #filters>
<div class="filters"> <div class="filters">
<!-- Выбор проекта -->
<ProjectSelector @change="onProjectChange" /> <ProjectSelector @change="onProjectChange" />
<div class="filter-divider"></div> <div class="filter-divider"></div>
<!-- Фильтр по отделам -->
<button <button
class="filter-tag" class="filter-tag"
:class="{ active: activeDepartment === null }" :class="{ active: activeDepartment === null }"
@@ -33,6 +30,28 @@
</button> </button>
</div> </div>
</template> </template>
<!-- Мобильный: Проект (текст) + Отделы (иконка) -->
<template #mobile-filters>
<MobileSelect
v-model="mobileProjectId"
:options="projectOptions"
title="Выберите проект"
placeholder="Проект"
variant="accent"
@update:model-value="onMobileProjectChange"
/>
<MobileSelect
v-model="activeDepartment"
:options="departmentOptions"
title="Фильтр по отделам"
placeholder="Все отделы"
icon="filter"
compact
/>
</template>
<!-- Десктоп: статистика -->
<template #stats> <template #stats>
<div class="header-stats"> <div class="header-stats">
<div class="stat"> <div class="stat">
@@ -90,18 +109,44 @@
</template> </template>
<script setup> <script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue' import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import Sidebar from '../components/Sidebar.vue' import Sidebar from '../components/Sidebar.vue'
import Header from '../components/Header.vue' import Header from '../components/Header.vue'
import Board from '../components/Board.vue' import Board from '../components/Board.vue'
import TaskPanel from '../components/TaskPanel' import TaskPanel from '../components/TaskPanel'
import ProjectSelector from '../components/ProjectSelector.vue' import ProjectSelector from '../components/ProjectSelector.vue'
import MobileSelect from '../components/ui/MobileSelect.vue'
import { useProjectsStore } from '../stores/projects' import { useProjectsStore } from '../stores/projects'
import { cardsApi } from '../api' import { cardsApi } from '../api'
import { useMobile } from '../composables/useMobile'
const { isMobile } = useMobile()
// ==================== STORE ==================== // ==================== STORE ====================
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(() => [
{ 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([]) const cards = ref([])
@@ -225,6 +270,21 @@ onUnmounted(() => {
background: var(--bg-main); background: var(--bg-main);
} }
/* ========== MOBILE ========== */
.app.mobile {
height: 100vh;
height: 100dvh; /* Динамическая высота для iOS */
overflow: hidden;
}
.app.mobile .main-wrapper {
margin-left: 0;
padding-bottom: calc(64px + var(--safe-area-bottom, 0px)); /* место для нижней навигации + safe area */
height: 100vh;
height: 100dvh; /* Динамическая высота для iOS */
overflow: hidden;
}
/* Контейнер фильтров */ /* Контейнер фильтров */
.filters { .filters {
display: flex; display: flex;
@@ -233,12 +293,26 @@ onUnmounted(() => {
flex-wrap: wrap; flex-wrap: wrap;
} }
/* На мобильных — горизонтальный скролл */
.app.mobile .filters {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
}
.app.mobile .filters::-webkit-scrollbar {
display: none;
}
/* Разделитель между проектом и отделами */ /* Разделитель между проектом и отделами */
.filter-divider { .filter-divider {
width: 1px; width: 1px;
height: 24px; height: 24px;
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
margin: 0 8px; margin: 0 8px;
flex-shrink: 0;
} }
/* Кнопка фильтра */ /* Кнопка фильтра */
@@ -255,6 +329,8 @@ onUnmounted(() => {
letter-spacing: 0.3px; letter-spacing: 0.3px;
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
flex-shrink: 0;
white-space: nowrap;
} }
.filter-tag:hover { .filter-tag:hover {
@@ -302,6 +378,11 @@ onUnmounted(() => {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
} }
/* ========== MOBILE: статистика ========== */
.app.mobile .header-stats {
display: none;
}
/* Основная область с доской */ /* Основная область с доской */
.main { .main {
flex: 1; flex: 1;
@@ -331,4 +412,14 @@ onUnmounted(() => {
.main::-webkit-scrollbar-thumb:hover { .main::-webkit-scrollbar-thumb:hover {
background: rgba(0, 212, 170, 0.6); background: rgba(0, 212, 170, 0.6);
} }
/* ========== MOBILE: доска ========== */
.app.mobile .main {
flex: 1;
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0; /* Важно для flex children с overflow */
}
</style> </style>

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="app"> <div class="app" :class="{ mobile: isMobile }">
<Sidebar /> <Sidebar />
<div class="main-wrapper"> <div class="main-wrapper">
@@ -11,7 +11,8 @@
<span>Загрузка...</span> <span>Загрузка...</span>
</div> </div>
<div v-else class="team-grid"> <!-- Desktop: grid -->
<div v-else-if="!isMobile" class="team-grid">
<div <div
v-for="user in users" v-for="user in users"
:key="user.id" :key="user.id"
@@ -37,8 +38,53 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Mobile: swipe cards -->
<template v-else>
<div class="mobile-cards" ref="mobileCardsRef" @scroll="onCardsScroll">
<div
v-for="user in users"
:key="user.id"
class="mobile-card"
>
<div class="mobile-avatar">
<img :src="getFullUrl(user.avatar_url)" :alt="user.name">
</div>
<div class="mobile-info">
<h2 class="mobile-name">{{ user.name }}</h2>
<span class="mobile-username">@{{ user.username }}</span>
<span v-if="user.department" class="mobile-department">{{ user.department }}</span>
</div>
<a
:href="'https://t.me/' + user.telegram.replace('@', '')"
target="_blank"
class="mobile-telegram"
>
<i data-lucide="send"></i>
Написать в Telegram
</a>
</div>
</div>
</template>
</main> </main>
</div> </div>
<!-- Фиксированные индикаторы над навигацией -->
<div v-if="isMobile && !loading && users.length > 0" class="mobile-team-footer">
<div class="team-indicators">
<button
v-for="(user, index) in users"
:key="user.id"
class="indicator-dot"
:class="{ active: currentUserIndex === index }"
@click="scrollToUser(index)"
>
<img :src="getFullUrl(user.avatar_url)" :alt="user.name">
</button>
</div>
</div>
</div> </div>
</template> </template>
@@ -47,9 +93,14 @@ import { ref, onMounted, onUpdated } from 'vue'
import Sidebar from '../components/Sidebar.vue' import Sidebar from '../components/Sidebar.vue'
import Header from '../components/Header.vue' import Header from '../components/Header.vue'
import { usersApi, getFullUrl } from '../api' import { usersApi, getFullUrl } from '../api'
import { useMobile } from '../composables/useMobile'
const { isMobile } = useMobile()
const users = ref([]) const users = ref([])
const loading = ref(true) const loading = ref(true)
const mobileCardsRef = ref(null)
const currentUserIndex = ref(0)
const fetchUsers = async () => { const fetchUsers = async () => {
try { try {
@@ -64,6 +115,24 @@ const fetchUsers = async () => {
} }
} }
const onCardsScroll = () => {
if (!mobileCardsRef.value) return
const container = mobileCardsRef.value
const cardWidth = container.offsetWidth
const scrollLeft = container.scrollLeft
currentUserIndex.value = Math.round(scrollLeft / cardWidth)
}
const scrollToUser = (index) => {
if (!mobileCardsRef.value) return
const container = mobileCardsRef.value
const cardWidth = container.offsetWidth
container.scrollTo({
left: index * cardWidth,
behavior: 'smooth'
})
}
const refreshIcons = () => { const refreshIcons = () => {
if (window.lucide) { if (window.lucide) {
window.lucide.createIcons() window.lucide.createIcons()
@@ -90,11 +159,32 @@ onUpdated(refreshIcons)
margin-left: 64px; margin-left: 64px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0;
max-height: 100vh;
} }
.main { .main {
flex: 1; flex: 1;
padding: 0 20px 20px; padding: 0 20px 20px;
overflow-y: auto;
min-height: 0;
}
.main::-webkit-scrollbar {
width: 6px;
}
.main::-webkit-scrollbar-track {
background: transparent;
}
.main::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
}
.main::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
} }
.loading { .loading {
@@ -220,4 +310,194 @@ onUpdated(refreshIcons)
width: 14px; width: 14px;
height: 14px; height: 14px;
} }
/* Mobile styles */
.app.mobile {
flex-direction: column;
height: 100vh;
height: 100dvh;
overflow: hidden;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.app.mobile .main-wrapper {
margin-left: 0;
flex: 1;
min-height: 0;
max-height: none;
overflow: hidden;
display: flex;
flex-direction: column;
}
.app.mobile .main {
flex: 1;
min-height: 0;
padding: 0;
overflow: hidden;
}
.app.mobile .loading {
padding: 40px;
}
/* Mobile swipe cards */
.mobile-cards {
width: 100%;
/* Высота = экран - header(60px) - индикаторы(70px) - навигация(64px) - safe-area */
height: calc(100dvh - 60px - 70px - 64px - var(--safe-area-bottom, 0px));
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
}
.mobile-cards::-webkit-scrollbar {
display: none;
}
.mobile-card {
flex: 0 0 100%;
width: 100%;
scroll-snap-align: start;
scroll-snap-stop: always;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
padding-top: 0;
gap: 20px;
box-sizing: border-box;
}
.mobile-avatar {
width: 160px;
height: 160px;
border-radius: 50%;
overflow: hidden;
background: rgba(255, 255, 255, 0.1);
box-shadow:
0 0 0 4px rgba(0, 212, 170, 0.3),
0 20px 60px rgba(0, 0, 0, 0.4);
}
.mobile-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.mobile-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
}
.mobile-name {
font-size: 28px;
font-weight: 700;
color: var(--text);
margin: 0;
}
.mobile-username {
font-size: 16px;
color: var(--text-muted);
}
.mobile-department {
padding: 6px 16px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--accent);
background: var(--accent-soft);
border-radius: 20px;
margin-top: 4px;
}
.mobile-telegram {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 14px 28px;
background: linear-gradient(135deg, #0088cc 0%, #00a0dc 100%);
color: white;
border-radius: 30px;
font-size: 16px;
font-weight: 600;
text-decoration: none;
transition: all 0.2s ease;
box-shadow: 0 8px 24px rgba(0, 136, 204, 0.3);
margin-top: 8px;
}
.mobile-telegram:active {
transform: scale(0.96);
}
.mobile-telegram i {
width: 20px;
height: 20px;
}
/* Mobile team footer - фиксированный над навигацией */
.mobile-team-footer {
position: fixed;
left: 0;
right: 0;
bottom: calc(64px + var(--safe-area-bottom, 0px));
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: var(--bg);
z-index: 100;
}
.team-indicators {
display: flex;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
}
.team-indicators .indicator-dot {
width: 36px;
height: 36px;
border-radius: 50%;
overflow: hidden;
background: rgba(255, 255, 255, 0.1);
border: 2px solid transparent;
padding: 0;
cursor: pointer;
transition: all 0.2s ease;
opacity: 0.5;
}
.team-indicators .indicator-dot img {
width: 100%;
height: 100%;
object-fit: cover;
}
.team-indicators .indicator-dot.active {
border-color: var(--accent);
opacity: 1;
transform: scale(1.15);
box-shadow: 0 4px 12px rgba(0, 212, 170, 0.3);
}
</style> </style>