Мобильная версия
1. Адаптация и разработка мобильного варианта.
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<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 версия -->
|
||||||
|
<template v-if="!isMobile">
|
||||||
<!-- Аватарка -->
|
<!-- Аватарка -->
|
||||||
<div class="card-assignee" v-if="card.assignee">
|
<div class="card-assignee" v-if="card.assignee">
|
||||||
<img
|
<img
|
||||||
@@ -64,12 +66,75 @@
|
|||||||
<i data-lucide="trash-2"></i>
|
<i data-lucide="trash-2"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
169
front_vue/src/components/DepartmentSelector.vue
Normal file
169
front_vue/src/components/DepartmentSelector.vue
Normal 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>
|
||||||
@@ -1,20 +1,33 @@
|
|||||||
<template>
|
<template>
|
||||||
<header class="header">
|
<header class="header" :class="{ mobile: isMobile }">
|
||||||
|
<!-- Десктоп версия -->
|
||||||
|
<template v-if="!isMobile">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<div class="title-row">
|
<div class="title-row">
|
||||||
<h1 class="page-title">{{ title }}</h1>
|
<h1 class="page-title">{{ title }}</h1>
|
||||||
<!-- Слот для фильтров (на одной строке с заголовком) -->
|
|
||||||
<slot name="filters"></slot>
|
<slot name="filters"></slot>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="subtitle" class="page-subtitle">{{ subtitle }}</p>
|
<p v-if="subtitle" class="page-subtitle">{{ subtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<!-- Слот для статистики и прочего -->
|
|
||||||
<slot name="stats"></slot>
|
<slot name="stats"></slot>
|
||||||
<button class="logout-btn" @click="logout" title="Выйти">
|
<button class="logout-btn" @click="logout" title="Выйти">
|
||||||
<i data-lucide="log-out"></i>
|
<i data-lucide="log-out"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Мобильная версия -->
|
||||||
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="comment-form">
|
<div class="comment-form">
|
||||||
|
<!-- Desktop: Inline форма -->
|
||||||
|
<template v-if="!isMobile">
|
||||||
<!-- Индикатор "ответ на" -->
|
<!-- Индикатор "ответ на" -->
|
||||||
<div v-if="replyingTo" class="reply-indicator">
|
<div v-if="replyingTo" class="reply-indicator">
|
||||||
<i data-lucide="corner-down-right"></i>
|
<i data-lucide="corner-down-right"></i>
|
||||||
@@ -35,16 +37,6 @@
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<!-- Скрытый input для файлов -->
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
ref="fileInputRef"
|
|
||||||
accept=".png,.jpg,.jpeg,.zip,.rar,image/png,image/jpeg,application/zip,application/x-rar-compressed"
|
|
||||||
multiple
|
|
||||||
@change="handleFileSelect"
|
|
||||||
style="display: none"
|
|
||||||
>
|
|
||||||
|
|
||||||
<!-- Превью прикреплённых файлов -->
|
<!-- Превью прикреплённых файлов -->
|
||||||
<div v-if="files.length > 0" class="attached-files">
|
<div v-if="files.length > 0" class="attached-files">
|
||||||
<div
|
<div
|
||||||
@@ -74,13 +66,114 @@
|
|||||||
Отправить
|
Отправить
|
||||||
</template>
|
</template>
|
||||||
</button>
|
</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
|
||||||
|
type="file"
|
||||||
|
ref="fileInputRef"
|
||||||
|
accept=".png,.jpg,.jpeg,.zip,.rar,image/png,image/jpeg,application/zip,application/x-rar-compressed"
|
||||||
|
multiple
|
||||||
|
@change="handleFileSelect"
|
||||||
|
style="display: none"
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- Mobile: Fullscreen форма -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="mobile-form">
|
||||||
|
<div v-if="isMobile && mobileFormOpen" class="mobile-form-overlay">
|
||||||
|
<div class="mobile-form-panel">
|
||||||
|
<div class="mobile-form-header">
|
||||||
|
<button class="btn-close" @click="closeMobileForm">
|
||||||
|
<i data-lucide="x"></i>
|
||||||
|
</button>
|
||||||
|
<h3 class="panel-title">{{ replyingTo ? 'Ответ' : 'Новый комментарий' }}</h3>
|
||||||
|
<div class="header-spacer"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Индикатор "ответ на" -->
|
||||||
|
<div v-if="replyingTo" class="mobile-reply-indicator">
|
||||||
|
<i data-lucide="corner-down-right"></i>
|
||||||
|
<span>Ответ для <strong>{{ replyingTo.author_name }}</strong></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-form-body">
|
||||||
|
<RichTextEditor
|
||||||
|
:modelValue="modelValue"
|
||||||
|
@update:modelValue="$emit('update:modelValue', $event)"
|
||||||
|
: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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -4,10 +4,12 @@
|
|||||||
@close="tryClose"
|
@close="tryClose"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
|
<div class="header-title-block">
|
||||||
<h2>{{ panelTitle }}</h2>
|
<h2>{{ panelTitle }}</h2>
|
||||||
<span v-if="!isNew && card?.dateCreate" class="header-date">
|
<span v-if="!isNew && card?.dateCreate" class="header-date">
|
||||||
Создано: {{ formatDate(card.dateCreate) }}
|
Создано: {{ formatDate(card.dateCreate) }}
|
||||||
</span>
|
</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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
337
front_vue/src/components/ui/MobileSelect.vue
Normal file
337
front_vue/src/components/ui/MobileSelect.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
40
front_vue/src/composables/useMobile.js
Normal file
40
front_vue/src/composables/useMobile.js
Normal 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)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user