2025-05-21 22:13:09 +07:00
|
|
|
|
<template>
|
|
|
|
|
<div id="app-container">
|
2025-05-23 18:42:29 +07:00
|
|
|
|
<!-- Основная область с содержимым -->
|
|
|
|
|
<main class="app-content">
|
|
|
|
|
<router-view v-slot="{ Component }">
|
|
|
|
|
<transition name="page-transition" mode="out-in">
|
|
|
|
|
<component :is="Component" />
|
|
|
|
|
</transition>
|
|
|
|
|
</router-view>
|
2025-05-21 22:13:09 +07:00
|
|
|
|
</main>
|
|
|
|
|
|
2025-05-26 01:28:53 +07:00
|
|
|
|
<!-- Мобильная навигация внизу экрана -->
|
|
|
|
|
<nav v-if="authState" class="mobile-nav">
|
|
|
|
|
<!-- Вкладки для администратора -->
|
|
|
|
|
<template v-if="currentUserIsAdmin">
|
|
|
|
|
<router-link to="/admin/users" class="nav-item" active-class="active">
|
|
|
|
|
<div class="nav-icon">
|
|
|
|
|
<i class="bi-people"></i>
|
|
|
|
|
</div>
|
|
|
|
|
<span class="nav-text">Пользователи</span>
|
|
|
|
|
</router-link>
|
|
|
|
|
<router-link to="/admin/conversations" class="nav-item" active-class="active">
|
|
|
|
|
<div class="nav-icon">
|
|
|
|
|
<i class="bi-chat-square-dots"></i>
|
|
|
|
|
</div>
|
|
|
|
|
<span class="nav-text">Диалоги</span>
|
|
|
|
|
</router-link>
|
2025-05-26 01:32:02 +07:00
|
|
|
|
<router-link to="/admin/reports" class="nav-item" active-class="active">
|
|
|
|
|
<div class="nav-icon">
|
|
|
|
|
<i class="bi-exclamation-octagon"></i>
|
|
|
|
|
</div>
|
|
|
|
|
<span class="nav-text">Жалобы</span>
|
|
|
|
|
</router-link>
|
2025-05-26 01:28:53 +07:00
|
|
|
|
<router-link to="/admin/statistics" class="nav-item" active-class="active">
|
|
|
|
|
<div class="nav-icon">
|
|
|
|
|
<i class="bi-bar-chart-line"></i>
|
|
|
|
|
</div>
|
|
|
|
|
<span class="nav-text">Статистика</span>
|
|
|
|
|
</router-link>
|
|
|
|
|
</template>
|
|
|
|
|
<!-- Вкладки для обычного пользователя (не показываются в админ-панели, если isAdminRoute true) -->
|
|
|
|
|
<template v-else-if="!isAdminRoute">
|
|
|
|
|
<router-link to="/swipe" class="nav-item" active-class="active">
|
|
|
|
|
<div class="nav-icon">
|
|
|
|
|
<i class="bi-search"></i>
|
|
|
|
|
</div>
|
|
|
|
|
<span class="nav-text">Поиск</span>
|
|
|
|
|
</router-link>
|
|
|
|
|
<router-link to="/chats" class="nav-item" active-class="active">
|
|
|
|
|
<div class="nav-icon">
|
|
|
|
|
<i class="bi-chat-dots"></i>
|
|
|
|
|
<span v-if="totalUnread > 0" class="unread-badge">
|
|
|
|
|
{{ totalUnread < 100 ? totalUnread : '99+' }}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<span class="nav-text">Чаты</span>
|
|
|
|
|
</router-link>
|
|
|
|
|
<router-link to="/profile" class="nav-item" active-class="active">
|
|
|
|
|
<div class="nav-icon">
|
|
|
|
|
<i class="bi-person"></i>
|
|
|
|
|
</div>
|
|
|
|
|
<span class="nav-text">Профиль</span>
|
|
|
|
|
</router-link>
|
|
|
|
|
</template>
|
2025-05-23 18:42:29 +07:00
|
|
|
|
</nav>
|
2025-05-26 16:41:09 +07:00
|
|
|
|
|
|
|
|
|
<!-- Контейнер для уведомлений -->
|
|
|
|
|
<div class="toast-list">
|
|
|
|
|
<Toast
|
|
|
|
|
v-for="toast in toasts"
|
|
|
|
|
:key="toast.id"
|
|
|
|
|
:message="toast.message"
|
|
|
|
|
:type="toast.type"
|
|
|
|
|
:duration="toast.duration"
|
|
|
|
|
@close="removeToast(toast.id)"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2025-05-21 22:13:09 +07:00
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup>
|
2025-05-23 18:42:29 +07:00
|
|
|
|
import { useAuth } from '@/auth';
|
2025-05-23 18:47:16 +07:00
|
|
|
|
import { ref, watch, onMounted, computed, onUnmounted } from 'vue';
|
2025-05-24 23:52:52 +07:00
|
|
|
|
import { useRoute } from 'vue-router';
|
2025-05-23 18:42:29 +07:00
|
|
|
|
import api from '@/services/api';
|
|
|
|
|
import { getSocket, connectSocket } from '@/services/socketService';
|
2025-05-26 16:41:09 +07:00
|
|
|
|
import Toast from '@/components/Toast.vue';
|
|
|
|
|
import toastService from '@/services/toastService';
|
2025-05-21 22:13:09 +07:00
|
|
|
|
|
|
|
|
|
const { user, isAuthenticated, logout, fetchUser } = useAuth();
|
2025-05-24 23:52:52 +07:00
|
|
|
|
const route = useRoute();
|
2025-05-21 22:13:09 +07:00
|
|
|
|
|
2025-05-26 16:41:09 +07:00
|
|
|
|
// Импортируем уведомления из сервиса
|
|
|
|
|
const toasts = toastService.toasts;
|
|
|
|
|
const removeToast = toastService.remove;
|
|
|
|
|
|
2025-05-21 22:13:09 +07:00
|
|
|
|
const userData = ref(null);
|
|
|
|
|
const authState = ref(false);
|
2025-05-23 18:42:29 +07:00
|
|
|
|
const conversations = ref([]);
|
|
|
|
|
const totalUnread = computed(() => {
|
|
|
|
|
return conversations.value.reduce((sum, conv) => sum + (conv.unreadCount || 0), 0);
|
|
|
|
|
});
|
|
|
|
|
|
2025-05-26 01:28:53 +07:00
|
|
|
|
const currentUserIsAdmin = computed(() => {
|
|
|
|
|
return !!user.value && !!user.value.isAdmin;
|
|
|
|
|
});
|
|
|
|
|
|
2025-05-26 00:30:10 +07:00
|
|
|
|
const isAdminRoute = computed(() => {
|
|
|
|
|
return route.path.startsWith('/admin');
|
|
|
|
|
});
|
|
|
|
|
|
2025-05-23 18:42:29 +07:00
|
|
|
|
let socket = null;
|
|
|
|
|
|
|
|
|
|
const fetchConversations = async () => {
|
2025-05-26 01:28:53 +07:00
|
|
|
|
if (!isAuthenticated.value || currentUserIsAdmin.value) return;
|
2025-05-23 18:42:29 +07:00
|
|
|
|
try {
|
2025-05-23 18:47:16 +07:00
|
|
|
|
const response = await api.getUserConversations();
|
2025-05-23 18:42:29 +07:00
|
|
|
|
conversations.value = response.data;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[App] Ошибка при загрузке диалогов:', err);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleNewMessage = (message) => {
|
2025-05-26 01:28:53 +07:00
|
|
|
|
if (currentUserIsAdmin.value || !user.value) return;
|
2025-05-25 00:02:25 +07:00
|
|
|
|
const senderId = typeof message.sender === 'object' ? message.sender._id : message.sender;
|
2025-05-26 01:28:53 +07:00
|
|
|
|
if (senderId !== user.value._id) {
|
2025-05-25 00:05:52 +07:00
|
|
|
|
setTimeout(() => {
|
|
|
|
|
const conversation = conversations.value.find(c => c._id === message.conversationId);
|
|
|
|
|
if (conversation) {
|
|
|
|
|
const currentRoute = route.path;
|
|
|
|
|
const isInThisChat = currentRoute === `/chat/${message.conversationId}`;
|
|
|
|
|
if (!isInThisChat) {
|
|
|
|
|
conversation.unreadCount = (conversation.unreadCount || 0) + 1;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
fetchConversations();
|
|
|
|
|
}
|
2025-05-26 01:28:53 +07:00
|
|
|
|
}, 100);
|
2025-05-23 18:42:29 +07:00
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleMessagesRead = ({ conversationId, readerId }) => {
|
2025-05-26 01:28:53 +07:00
|
|
|
|
if (currentUserIsAdmin.value || !user.value) return;
|
|
|
|
|
if (readerId === user.value._id) {
|
2025-05-23 18:42:29 +07:00
|
|
|
|
const conversation = conversations.value.find(c => c._id === conversationId);
|
|
|
|
|
if (conversation) {
|
|
|
|
|
conversation.unreadCount = 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
2025-05-21 22:13:09 +07:00
|
|
|
|
|
2025-05-26 01:28:53 +07:00
|
|
|
|
watch(() => isAuthenticated.value, (newAuthStatus, oldAuthStatus) => {
|
|
|
|
|
authState.value = newAuthStatus;
|
|
|
|
|
if (newAuthStatus) {
|
|
|
|
|
console.log('[App.vue watch isAuthenticated] Became authenticated.');
|
|
|
|
|
// Initial setup for authenticated users is handled by onMounted or watch(user.value)
|
|
|
|
|
} else if (oldAuthStatus && !newAuthStatus) {
|
|
|
|
|
// User is now unauthenticated (logged out).
|
|
|
|
|
console.log('[App.vue watch isAuthenticated] Became unauthenticated. Cleaning up.');
|
|
|
|
|
conversations.value = [];
|
|
|
|
|
if (socket) {
|
|
|
|
|
socket.off('getMessage', handleNewMessage);
|
|
|
|
|
socket.off('messagesRead', handleMessagesRead);
|
|
|
|
|
// Use userData.value for _id as user.value is null on logout
|
|
|
|
|
// userData.value holds the *previous* user data here
|
|
|
|
|
if (userData.value?._id && !(userData.value?.isAdmin)) {
|
|
|
|
|
socket.emit('leaveNotificationRoom', userData.value._id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
userData.value = null; // Clear local userData as well
|
2025-05-23 18:42:29 +07:00
|
|
|
|
}
|
2025-05-21 22:13:09 +07:00
|
|
|
|
});
|
|
|
|
|
|
2025-05-26 01:28:53 +07:00
|
|
|
|
watch(() => user.value, (newUser, oldUser) => {
|
|
|
|
|
userData.value = newUser;
|
|
|
|
|
if (isAuthenticated.value) {
|
|
|
|
|
console.log('[App.vue watch user.value] User data changed and authenticated. Initializing/Re-initializing user state.');
|
|
|
|
|
initializeUserState();
|
|
|
|
|
} else if (oldUser && !newUser) {
|
|
|
|
|
console.log('[App.vue watch user.value] User logged out (user.value became null).');
|
|
|
|
|
// Cleanup is primarily handled by watch(isAuthenticated.value)
|
|
|
|
|
}
|
|
|
|
|
}, { deep: true });
|
2025-05-21 22:13:09 +07:00
|
|
|
|
|
2025-05-24 23:52:52 +07:00
|
|
|
|
watch(() => route.path, (newPath, oldPath) => {
|
2025-05-26 01:28:53 +07:00
|
|
|
|
if (isAuthenticated.value && !currentUserIsAdmin.value) {
|
|
|
|
|
if (newPath.startsWith('/chat/') || oldPath?.startsWith('/chat/')) {
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
fetchConversations();
|
|
|
|
|
}, 500);
|
|
|
|
|
}
|
2025-05-24 23:52:52 +07:00
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-05-23 18:42:29 +07:00
|
|
|
|
const setupSocketConnection = () => {
|
2025-05-26 01:28:53 +07:00
|
|
|
|
if (currentUserIsAdmin.value || !user.value?._id) return;
|
2025-05-23 18:42:29 +07:00
|
|
|
|
socket = getSocket();
|
|
|
|
|
if (!socket) {
|
|
|
|
|
socket = connectSocket();
|
|
|
|
|
}
|
|
|
|
|
if (socket) {
|
2025-05-26 01:28:53 +07:00
|
|
|
|
socket.off('getMessage', handleNewMessage);
|
|
|
|
|
socket.off('messagesRead', handleMessagesRead);
|
2025-05-23 18:42:29 +07:00
|
|
|
|
socket.on('getMessage', handleNewMessage);
|
|
|
|
|
socket.on('messagesRead', handleMessagesRead);
|
2025-05-26 01:28:53 +07:00
|
|
|
|
socket.emit('joinNotificationRoom', user.value._id);
|
|
|
|
|
console.log('[App.vue setupSocketConnection] Socket connection configured for user:', user.value._id);
|
2025-05-23 18:42:29 +07:00
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-05-26 01:28:53 +07:00
|
|
|
|
const initializeUserState = () => {
|
|
|
|
|
if (isAuthenticated.value) {
|
|
|
|
|
if (!currentUserIsAdmin.value) {
|
|
|
|
|
console.log('[App.vue initializeUserState] Initializing for regular user.');
|
|
|
|
|
fetchConversations();
|
|
|
|
|
setupSocketConnection();
|
|
|
|
|
} else {
|
|
|
|
|
console.log('[App.vue initializeUserState] User is Admin. Clearing any regular user socket listeners.');
|
|
|
|
|
if (socket) { // Ensure any previous user socket listeners are off for admin
|
|
|
|
|
socket.off('getMessage', handleNewMessage);
|
|
|
|
|
socket.off('messagesRead', handleMessagesRead);
|
2025-05-25 23:41:35 +07:00
|
|
|
|
}
|
|
|
|
|
}
|
2025-05-26 01:28:53 +07:00
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
authState.value = isAuthenticated.value;
|
|
|
|
|
userData.value = user.value;
|
|
|
|
|
|
|
|
|
|
if (localStorage.getItem('userToken') && !user.value) {
|
|
|
|
|
console.log('[App.vue onMounted] Token found, user data missing. Fetching user...');
|
|
|
|
|
await fetchUser();
|
|
|
|
|
console.log('[App.vue onMounted] User fetched. isAuthenticated:', isAuthenticated.value, 'isAdmin:', currentUserIsAdmin.value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
authState.value = isAuthenticated.value;
|
|
|
|
|
userData.value = user.value;
|
|
|
|
|
|
|
|
|
|
if (isAuthenticated.value) {
|
|
|
|
|
if (currentUserIsAdmin.value) {
|
|
|
|
|
console.log('[App.vue onMounted] Authenticated user is Admin.');
|
|
|
|
|
initializeUserState(); // Handles admin case (clears user listeners)
|
2025-05-25 23:41:35 +07:00
|
|
|
|
} else {
|
2025-05-26 01:28:53 +07:00
|
|
|
|
console.log('[App.vue onMounted] Authenticated user is a regular user. Initializing user state.');
|
|
|
|
|
initializeUserState();
|
2025-05-25 23:41:35 +07:00
|
|
|
|
}
|
2025-05-26 01:28:53 +07:00
|
|
|
|
} else {
|
|
|
|
|
console.log('[App.vue onMounted] User is not authenticated.');
|
2025-05-21 22:13:09 +07:00
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-05-23 18:42:29 +07:00
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
if (socket) {
|
|
|
|
|
socket.off('getMessage', handleNewMessage);
|
|
|
|
|
socket.off('messagesRead', handleMessagesRead);
|
2025-05-26 01:28:53 +07:00
|
|
|
|
if (user.value?._id && !currentUserIsAdmin.value) {
|
2025-05-23 18:42:29 +07:00
|
|
|
|
socket.emit('leaveNotificationRoom', user.value._id);
|
2025-05-26 01:28:53 +07:00
|
|
|
|
console.log('[App.vue onUnmounted] Left notification room for user:', user.value._id);
|
2025-05-23 18:42:29 +07:00
|
|
|
|
}
|
2025-05-21 22:13:09 +07:00
|
|
|
|
}
|
2025-05-23 18:42:29 +07:00
|
|
|
|
});
|
2025-05-21 22:13:09 +07:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
/* Глобальные стили */
|
2025-05-23 18:42:29 +07:00
|
|
|
|
* {
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
margin: 0;
|
|
|
|
|
padding: 0;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-21 22:13:09 +07:00
|
|
|
|
html, body {
|
|
|
|
|
height: 100%;
|
2025-05-23 18:42:29 +07:00
|
|
|
|
width: 100%;
|
|
|
|
|
overflow-x: hidden;
|
|
|
|
|
-webkit-tap-highlight-color: transparent; /* Убирает фон при касании на мобильных */
|
2025-05-21 22:13:09 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
body {
|
|
|
|
|
margin: 0;
|
2025-05-23 18:42:29 +07:00
|
|
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
2025-05-21 22:13:09 +07:00
|
|
|
|
color: #212529;
|
2025-05-23 18:42:29 +07:00
|
|
|
|
background-color: #f8f9fa;
|
|
|
|
|
overscroll-behavior: none; /* Предотвращает "bounce" эффект на iOS */
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
will-change: transform; /* Ускорение для анимаций */
|
2025-05-21 22:13:09 +07:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-26 16:41:09 +07:00
|
|
|
|
/* Контейнер для уведомлений */
|
|
|
|
|
.toast-list {
|
|
|
|
|
position: fixed;
|
|
|
|
|
top: 20px;
|
|
|
|
|
right: 20px;
|
|
|
|
|
z-index: 2000;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.toast-list > * {
|
|
|
|
|
pointer-events: auto;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-21 22:13:09 +07:00
|
|
|
|
#app-container {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
2025-05-23 18:42:29 +07:00
|
|
|
|
height: 100vh;
|
|
|
|
|
width: 100%;
|
|
|
|
|
position: relative;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
background-color: #f8f9fa;
|
2025-05-21 22:13:09 +07:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-23 18:42:29 +07:00
|
|
|
|
/* Основная область содержимого */
|
|
|
|
|
.app-content {
|
|
|
|
|
flex: 1;
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
overflow-x: hidden;
|
|
|
|
|
-webkit-overflow-scrolling: touch; /* Плавная прокрутка на iOS */
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: calc(100% - var(--nav-height, 60px)); /* Высота за вычетом навбара */
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 0;
|
|
|
|
|
left: 0;
|
|
|
|
|
right: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Анимация переходов между страницами */
|
|
|
|
|
.page-transition-enter-active,
|
|
|
|
|
.page-transition-leave-active {
|
|
|
|
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.page-transition-enter-from,
|
|
|
|
|
.page-transition-leave-to {
|
|
|
|
|
opacity: 0;
|
|
|
|
|
transform: translateY(10px);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Мобильная навигация */
|
|
|
|
|
.mobile-nav {
|
|
|
|
|
display: flex;
|
|
|
|
|
position: fixed;
|
|
|
|
|
bottom: 0;
|
|
|
|
|
left: 0;
|
|
|
|
|
right: 0;
|
|
|
|
|
height: var(--nav-height, 60px);
|
|
|
|
|
background: white;
|
|
|
|
|
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
|
|
|
|
|
z-index: 1000;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.nav-item {
|
|
|
|
|
flex: 1;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
align-items: center;
|
2025-05-23 20:37:35 +07:00
|
|
|
|
justify-content: center;
|
2025-05-23 18:42:29 +07:00
|
|
|
|
text-decoration: none;
|
|
|
|
|
color: #6c757d;
|
|
|
|
|
transition: all 0.2s ease;
|
2025-05-23 20:37:35 +07:00
|
|
|
|
/* Удаляем отступы, они будут применляться по-другому */
|
|
|
|
|
padding: 0;
|
|
|
|
|
position: relative;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.nav-icon {
|
2025-05-23 18:42:29 +07:00
|
|
|
|
position: relative;
|
2025-05-23 20:37:35 +07:00
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
margin-bottom: 3px;
|
|
|
|
|
height: 28px;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-24 23:46:45 +07:00
|
|
|
|
/* Стили для индикатора непрочитанных сообщений в навигации */
|
|
|
|
|
.nav-icon .unread-badge {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: -8px;
|
|
|
|
|
right: -8px;
|
|
|
|
|
background: linear-gradient(135deg, #ff6b6b, #ff5252);
|
|
|
|
|
color: white;
|
|
|
|
|
font-size: 0.65rem;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
min-width: 18px;
|
|
|
|
|
height: 18px;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
padding: 0 5px;
|
|
|
|
|
box-shadow: 0 2px 8px rgba(255, 107, 107, 0.4);
|
|
|
|
|
border: 2px solid white;
|
|
|
|
|
animation: pulse-notification 2s ease-in-out infinite;
|
|
|
|
|
z-index: 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Анимация пульсации для привлечения внимания */
|
|
|
|
|
@keyframes pulse-notification {
|
|
|
|
|
0%, 100% {
|
|
|
|
|
transform: scale(1);
|
|
|
|
|
box-shadow: 0 2px 8px rgba(255, 107, 107, 0.4);
|
|
|
|
|
}
|
|
|
|
|
50% {
|
|
|
|
|
transform: scale(1.1);
|
|
|
|
|
box-shadow: 0 3px 12px rgba(255, 107, 107, 0.6);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-23 20:37:35 +07:00
|
|
|
|
/* Оборачиваем текст для лучшего управления */
|
|
|
|
|
.nav-text {
|
|
|
|
|
font-size: 0.7rem;
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
/* Критическое изменение: добавляем отступ чтобы поднять текст выше home indicator */
|
|
|
|
|
transform: translateY(-10px);
|
|
|
|
|
display: block;
|
2025-05-23 18:42:29 +07:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-24 23:46:45 +07:00
|
|
|
|
/* Активная вкладка */
|
|
|
|
|
.nav-item.active {
|
|
|
|
|
color: #667eea;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.nav-item.active .nav-icon i {
|
|
|
|
|
color: #667eea;
|
|
|
|
|
transform: translateY(-2px);
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-23 18:42:29 +07:00
|
|
|
|
/* CSS переменные для динамической настройки */
|
|
|
|
|
:root {
|
|
|
|
|
--nav-height: 60px;
|
|
|
|
|
--header-height: 56px;
|
2025-05-23 20:33:46 +07:00
|
|
|
|
--safe-area-bottom: 0px;
|
2025-05-23 18:42:29 +07:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-23 20:37:35 +07:00
|
|
|
|
/* Обновленная поддержка для устройств с вырезом (iPhone X и новее) */
|
2025-05-23 20:31:21 +07:00
|
|
|
|
@supports (padding: env(safe-area-inset-bottom)) {
|
|
|
|
|
:root {
|
|
|
|
|
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-23 18:42:29 +07:00
|
|
|
|
.mobile-nav {
|
2025-05-23 20:31:21 +07:00
|
|
|
|
height: calc(var(--nav-height) + var(--safe-area-bottom));
|
2025-05-23 20:37:35 +07:00
|
|
|
|
padding-bottom: var(--safe-area-bottom);
|
2025-05-23 20:28:18 +07:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-23 20:33:46 +07:00
|
|
|
|
.nav-item {
|
2025-05-23 20:37:35 +07:00
|
|
|
|
/* Элемент занимает всю высоту панели, включая safe area */
|
|
|
|
|
height: 100%;
|
|
|
|
|
/* Добавляем флекс для размещения иконки и текста */
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
2025-05-23 20:33:46 +07:00
|
|
|
|
justify-content: flex-start;
|
2025-05-23 20:37:35 +07:00
|
|
|
|
padding-top: 8px;
|
2025-05-23 20:31:21 +07:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-26 01:28:53 +07:00
|
|
|
|
.app-content { /* Это правило изменяет высоту .app-content, если есть safe-area */
|
|
|
|
|
height: calc(100% - (var(--nav-height) + var(--safe-area-bottom)));
|
2025-05-23 17:13:32 +07:00
|
|
|
|
}
|
2025-05-23 18:42:29 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Адаптивные настройки для различных устройств */
|
|
|
|
|
@media (max-width: 576px) {
|
|
|
|
|
:root {
|
|
|
|
|
--nav-height: 56px;
|
2025-05-23 17:13:32 +07:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-23 18:42:29 +07:00
|
|
|
|
.nav-item i {
|
|
|
|
|
font-size: 1.3rem;
|
2025-05-23 17:13:32 +07:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-23 18:42:29 +07:00
|
|
|
|
/* Маленькие телефоны */
|
2025-05-23 17:13:32 +07:00
|
|
|
|
@media (max-width: 375px) {
|
2025-05-23 18:42:29 +07:00
|
|
|
|
.nav-item span {
|
|
|
|
|
font-size: 0.65rem;
|
2025-05-23 17:13:32 +07:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-23 18:42:29 +07:00
|
|
|
|
/* Ландшафтная ориентация на мобильных */
|
2025-05-23 17:13:32 +07:00
|
|
|
|
@media (max-height: 450px) and (orientation: landscape) {
|
2025-05-23 18:42:29 +07:00
|
|
|
|
:root {
|
|
|
|
|
--nav-height: 50px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.nav-item {
|
|
|
|
|
flex-direction: row;
|
|
|
|
|
gap: 5px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.nav-icon {
|
|
|
|
|
margin-bottom: 0;
|
2025-05-23 17:13:32 +07:00
|
|
|
|
}
|
|
|
|
|
}
|
2025-05-21 22:13:09 +07:00
|
|
|
|
</style>
|