new file: main_dc/yalarba/easySite/easySite/app/components/BookingModal.vue

new file:   main_dc/yalarba/easySite/easySite/app/components/ImageGallery.vue
	new file:   main_dc/yalarba/easySite/easySite/app/components/layout/ObjectsNavigation.vue
	modified:   main_dc/yalarba/easySite/easySite/app/pages/objects/[id]/edit.vue
	modified:   main_dc/yalarba/easySite/easySite/app/pages/objects/[id]/index.vue
	modified:   main_dc/yalarba/easySite/easySite/app/pages/objects/create.vue
	modified:   main_dc/yalarba/easySite/easySite/app/pages/objects/index.vue
	modified:   main_dc/yalarba/easySite/easySite/app/pages/objects/my-objects.vue
upgrade UI/UX
This commit is contained in:
2025-11-07 14:25:38 +05:00
parent 1ba3f907ac
commit be08bfed7e
8 changed files with 2309 additions and 482 deletions
@@ -0,0 +1,31 @@
<template>
<div class="modal-overlay" @click="$emit('close')">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h2 class="modal-title">Бронирование</h2>
<button class="modal-close" @click="$emit('close')"></button>
</div>
<div class="modal-body">
<!-- Контент модального окна бронирования -->
<div class="booking-summary">
<h3>{{ object?.title }}</h3>
<p>{{ object?.city }}, {{ object?.address }}</p>
</div>
<!-- Форма бронирования -->
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
object: any
dates: any
guests: string
}>()
defineEmits<{
close: []
confirm: [bookingData: any]
}>()
</script>
@@ -0,0 +1,32 @@
<template>
<div class="gallery-overlay" @click="$emit('close')">
<div class="gallery-content" @click.stop>
<button class="gallery-close" @click="$emit('close')"></button>
<img :src="currentImage" :alt="`Image ${currentIndex + 1}`" class="gallery-image" />
<button class="gallery-nav prev" @click="prevImage"></button>
<button class="gallery-nav next" @click="nextImage"></button>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
images: string[]
initialIndex: number
}>()
const emit = defineEmits<{
close: []
}>()
const currentIndex = ref(props.initialIndex)
const currentImage = computed(() => props.images[currentIndex.value])
const nextImage = () => {
currentIndex.value = (currentIndex.value + 1) % props.images.length
}
const prevImage = () => {
currentIndex.value = (currentIndex.value - 1 + props.images.length) % props.images.length
}
</script>
@@ -0,0 +1,117 @@
<!-- components/layout/ObjectsNavigation.vue -->
<template>
<nav class="objects-navigation">
<div class="nav-container">
<!-- Основные ссылки -->
<div class="nav-links">
<NuxtLink to="/objects" class="nav-link" :class="{ active: $route.path === '/objects' }">
🏢 Все объекты
</NuxtLink>
<NuxtLink to="/objects/my-objects" class="nav-link"
:class="{ active: $route.path === '/objects/my-objects' }">
📝 Мои объекты
</NuxtLink>
<NuxtLink to="/objects/create" class="nav-link" :class="{ active: $route.path === '/objects/create' }">
Добавить объект
</NuxtLink>
</div>
<!-- Дополнительные действия -->
<div class="nav-actions">
<NuxtLink to="/" class="nav-action">
🏠 На главную
</NuxtLink>
</div>
</div>
</nav>
</template>
<script setup lang="ts">
</script>
<style scoped>
.objects-navigation {
background: var(--bg-primary);
border-bottom: 1px solid var(--border-light);
margin-bottom: 2rem;
}
.nav-container {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 0;
}
.nav-links {
display: flex;
gap: 1.5rem;
align-items: center;
}
.nav-link {
padding: 0.75rem 1.25rem;
text-decoration: none;
border-radius: var(--radius-lg);
font-weight: 500;
transition: all 0.3s ease;
border: 1px solid transparent;
color: var(--text-primary);
}
.nav-link:hover {
background: var(--bg-secondary);
color: var(--primary-600);
text-decoration: none;
}
.nav-link.active {
background: var(--primary-500);
color: var(--text-inverse);
border-color: var(--primary-500);
}
.nav-actions {
display: flex;
gap: 1rem;
}
.nav-action {
padding: 0.5rem 1rem;
text-decoration: none;
border-radius: var(--radius-md);
font-size: 0.9rem;
color: var(--text-secondary);
transition: all 0.3s ease;
}
.nav-action:hover {
color: var(--primary-500);
text-decoration: none;
}
/* Адаптивность */
@media (max-width: 768px) {
.nav-container {
flex-direction: column;
gap: 1rem;
padding: 0.75rem 0;
}
.nav-links {
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}
.nav-link {
padding: 0.5rem 1rem;
font-size: 0.9rem;
}
.nav-actions {
width: 100%;
justify-content: center;
}
}
</style>
@@ -1,43 +1,51 @@
<!-- pages/objects/[id]/edit.vue -->
<template>
<div class="min-h-screen bg-gray-50 py-8">
<div class="page-wrapper">
<Header />
<main class="edit-object-page">
<div class="container max-w-4xl">
<div class="mb-8">
<div class="flex items-center gap-4 mb-2">
<NuxtLink
:to="`/objects/${$route.params.id}`"
class="btn btn-outline btn-sm"
>
Назад
<!-- Заголовок -->
<div class="page-header">
<div class="header-content">
<div class="header-main">
<NuxtLink :to="`/objects/${$route.params.id}`" class="btn btn-outline btn-sm btn-with-icon">
Назад к объекту
</NuxtLink>
<h1 class="text-3xl font-bold">Редактировать объект</h1>
<h1 class="page-title">Редактировать объект</h1>
</div>
<p class="page-subtitle">Обновите информацию о вашем объекте</p>
</div>
<p class="text-gray-600">Обновите информацию о вашем объекте</p>
</div>
<div v-if="loading" class="text-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-500 mx-auto"></div>
<p class="mt-4 text-gray-600">Загрузка данных объекта...</p>
<!-- Загрузка -->
<div v-if="loading" class="loading-state">
<div class="loading-spinner"></div>
<p class="loading-text">Загрузка данных объекта...</p>
</div>
<ObjectForm
v-else-if="object"
:object="object"
:loading="updating"
@submit="handleSubmit"
@cancel="handleCancel"
/>
<!-- Форма -->
<ObjectForm v-else-if="object" :object="object" :loading="updating" @submit="handleSubmit"
@cancel="handleCancel" />
<div v-else class="text-center py-12">
<div class="text-gray-400 text-6xl mb-4"></div>
<h3 class="text-xl font-semibold mb-2">Объект не найден</h3>
<p class="text-gray-600 mb-6">Возможно, объект был удален или у вас нет к нему доступа</p>
<!-- Объект не найден -->
<div v-else class="error-state">
<div class="error-icon"></div>
<h3 class="error-title">Объект не найден</h3>
<p class="error-description">Возможно, объект был удален или у вас нет к нему доступа</p>
<div class="error-actions">
<NuxtLink to="/objects/my-objects" class="btn btn-primary">
Вернуться к моим объектам
</NuxtLink>
<NuxtLink to="/objects" class="btn btn-outline">
Все объекты
</NuxtLink>
</div>
</div>
</div>
</main>
<Footer />
</div>
</template>
<script setup lang="ts">
@@ -67,7 +75,7 @@ const mockObject: ObjectData = {
id: 1,
title: 'Гостевой дом "У озера"',
type: 'guest_house',
description: 'Уютный гостевой дом на берегу живописного озера в Карелии',
description: 'Уютный гостевой дом на берегу живописного озера в Карелии. Идеальное место для отдыха от городской суеты.',
city: 'Карелия',
address: 'ул. Озерная, 15',
price: 2800,
@@ -117,3 +125,148 @@ const handleCancel = () => {
navigateTo(`/objects/${objectId}`)
}
</script>
<style scoped>
.edit-object-page {
padding: var(--space-xl) 0;
min-height: calc(100vh - 160px);
background: var(--bg-secondary);
}
.page-header {
background: var(--bg-primary);
border-radius: var(--radius-lg);
padding: var(--space-xl);
margin-bottom: var(--space-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-light);
}
.header-content {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.header-main {
display: flex;
align-items: center;
gap: var(--space-lg);
}
.page-title {
font-family: var(--font-heading);
font-size: var(--text-3xl);
font-weight: var(--font-bold);
color: var(--text-primary);
margin: 0;
}
.page-subtitle {
font-size: var(--text-lg);
color: var(--text-secondary);
margin: 0;
}
.loading-state {
text-align: center;
padding: var(--space-2xl);
background: var(--bg-primary);
border-radius: var(--radius-lg);
border: 1px solid var(--border-light);
}
.loading-spinner {
width: 3rem;
height: 3rem;
border: 3px solid var(--border-light);
border-top: 3px solid var(--primary-500);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto var(--space-md);
}
.loading-text {
color: var(--text-secondary);
font-size: var(--text-lg);
}
.error-state {
text-align: center;
padding: var(--space-2xl);
background: var(--bg-primary);
border-radius: var(--radius-lg);
border: 1px solid var(--border-light);
}
.error-icon {
font-size: 4rem;
margin-bottom: var(--space-lg);
opacity: 0.7;
}
.error-title {
font-family: var(--font-heading);
font-size: var(--text-xl);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin-bottom: var(--space-sm);
}
.error-description {
color: var(--text-secondary);
margin-bottom: var(--space-lg);
max-width: 400px;
margin-left: auto;
margin-right: auto;
}
.error-actions {
display: flex;
gap: var(--space-sm);
justify-content: center;
flex-wrap: wrap;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Адаптивность */
@media (max-width: 768px) {
.header-main {
flex-direction: column;
align-items: flex-start;
gap: var(--space-md);
}
.page-title {
font-size: var(--text-2xl);
}
.error-actions {
flex-direction: column;
align-items: center;
}
.error-actions .btn {
min-width: 200px;
}
}
@media (max-width: 480px) {
.page-header {
padding: var(--space-lg);
}
.error-state {
padding: var(--space-xl);
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,14 +1,28 @@
<!-- pages/objects/create.vue -->
<template>
<div class="min-h-screen bg-gray-50 py-8">
<div class="page-wrapper">
<Header />
<main class="create-object-page">
<div class="container max-w-4xl">
<div class="mb-8">
<h1 class="text-3xl font-bold">Добавить новый объект</h1>
<p class="text-gray-600 mt-2">Заполните информацию о вашем объекте</p>
<!-- Заголовок -->
<div class="page-header">
<div class="header-content">
<div class="header-text">
<h1 class="page-title">Добавить новый объект</h1>
<p class="page-subtitle">Заполните информацию о вашем объекте</p>
</div>
<NuxtLink to="/objects/my-objects" class="btn btn-outline btn-with-icon">
Мои объекты
</NuxtLink>
</div>
</div>
<!-- Форма -->
<ObjectForm :loading="loading" @submit="handleSubmit" @cancel="handleCancel" />
</div>
</main>
<Footer />
</div>
</template>
@@ -46,3 +60,67 @@ const handleCancel = () => {
navigateTo('/objects/my-objects')
}
</script>
<style scoped>
.create-object-page {
padding: var(--space-xl) 0;
min-height: calc(100vh - 160px);
background: var(--bg-secondary);
}
.page-header {
background: var(--bg-primary);
border-radius: var(--radius-lg);
padding: var(--space-xl);
margin-bottom: var(--space-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-light);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-lg);
}
.header-text {
flex: 1;
}
.page-title {
font-family: var(--font-heading);
font-size: var(--text-3xl);
font-weight: var(--font-bold);
color: var(--text-primary);
margin-bottom: var(--space-xs);
}
.page-subtitle {
font-size: var(--text-lg);
color: var(--text-secondary);
}
/* Адаптивность */
@media (max-width: 768px) {
.header-content {
flex-direction: column;
align-items: stretch;
gap: var(--space-md);
}
.header-content .btn {
align-self: flex-start;
}
}
@media (max-width: 480px) {
.page-header {
padding: var(--space-lg);
}
.page-title {
font-size: var(--text-2xl);
}
}
</style>
@@ -1,103 +1,144 @@
<!-- pages/objects/index.vue -->
<template>
<div class="min-h-screen bg-gray-50 py-8">
<div class="page-wrapper">
<Header />
<main class="objects-page">
<div class="container">
<!-- Заголовок и кнопка добавления -->
<div class="flex justify-between items-center mb-8">
<div>
<h1 class="text-3xl font-bold">Все объекты</h1>
<p class="text-gray-600 mt-2">База объектов для путешествий</p>
<!-- Заголовок и действия -->
<div class="page-header">
<div class="header-content">
<div class="header-text">
<h1 class="page-title">Все объекты</h1>
<p class="page-subtitle">Найдено {{ filteredObjects.length }} объектов</p>
</div>
<NuxtLink to="/objects/create" class="btn btn-primary">
<div class="header-actions">
<button @click="showFilters = !showFilters" class="btn btn-outline btn-with-icon">
<span>🔍</span>
Фильтры
<span v-if="activeFiltersCount > 0" class="badge badge-primary">
{{ activeFiltersCount }}
</span>
</button>
<NuxtLink to="/objects/create" class="btn btn-primary btn-with-icon">
<span></span>
Добавить объект
</NuxtLink>
</div>
</div>
<!-- Фильтры поиска -->
<div class="search-filters mb-8">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<!-- Быстрые фильтры -->
<div class="quick-filters">
<button v-for="type in quickTypes" :key="type.value" @click="toggleQuickFilter(type.value)"
class="quick-filter" :class="{ active: filters.type === type.value }">
{{ type.icon }} {{ type.label }}
</button>
</div>
</div>
<!-- Расширенные фильтры -->
<div v-if="showFilters" class="search-filters card">
<div class="filter-grid">
<div class="form-group">
<label class="form-label">Тип объекта</label>
<select v-model="filters.type" class="form-select">
<option value="">Все типы</option>
<option value="hotel">Гостиница</option>
<option value="sanatorium">Санаторий</option>
<option value="tour">Тур</option>
<option value="restaurant">Ресторан</option>
<option value="hotel">🏨 Гостиница</option>
<option value="sanatorium">🏥 Санаторий</option>
<option value="tour">🧳 Тур</option>
<option value="restaurant">🍴 Ресторан</option>
<option value="guest_house">🏡 Гостевой дом</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Город</label>
<input
v-model="filters.city"
type="text"
class="form-input"
placeholder="Введите город"
>
<input v-model="filters.city" type="text" class="form-input" placeholder="Введите город">
</div>
<div class="form-group">
<label class="form-label">Цена до</label>
<input
v-model="filters.maxPrice"
type="number"
class="form-input"
placeholder="Макс. цена"
>
<input v-model="filters.maxPrice" type="number" class="form-input" placeholder="Макс. цена">
</div>
<div class="form-group">
<label class="form-label">Рейтинг от</label>
<select v-model="filters.minRating" class="form-select">
<option value="0">Любой рейтинг</option>
<option value="4"> 4.0+</option>
<option value="4.5"> 4.5+</option>
<option value="4.8"> 4.8+</option>
</select>
</div>
</div>
<div class="flex gap-4">
<div class="form-group flex-1">
<input
v-model="filters.search"
type="text"
class="form-input"
placeholder="Поиск по названию, городу или описанию..."
@keyup.enter="searchObjects"
>
</div>
<button class="btn btn-primary" @click="searchObjects">
Найти
<div class="filter-actions">
<button class="btn btn-primary" @click="applyFilters">
Применить фильтры
</button>
<button class="btn btn-outline" @click="resetSearch">
Сброс
<button class="btn btn-outline" @click="resetFilters">
Сбросить
</button>
</div>
</div>
<!-- Быстрые ссылки -->
<div class="flex gap-4 mb-6">
<NuxtLink to="/objects/my-objects" class="btn btn-outline">
📝 Мои объекты
</NuxtLink>
<NuxtLink to="/objects/create" class="btn btn-outline">
Добавить объект
</NuxtLink>
<!-- Управление видом -->
<div class="view-controls">
<div class="sort-controls">
<select v-model="sortBy" class="form-select">
<option value="title">По названию</option>
<option value="price">По цене</option>
<option value="rating">По рейтингу</option>
<option value="city">По городу</option>
</select>
<button @click="sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'" class="btn btn-outline btn-sm">
{{ sortOrder === 'asc' ? '↑' : '↓' }}
</button>
</div>
<!-- Результаты поиска -->
<div v-if="loading" class="text-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-500 mx-auto"></div>
<p class="mt-4 text-gray-600">Загрузка объектов...</p>
<div class="view-toggle">
<button @click="viewMode = 'grid'" class="btn btn-outline btn-sm"
:class="{ 'btn-primary': viewMode === 'grid' }">
</button>
<button @click="viewMode = 'list'" class="btn btn-outline btn-sm"
:class="{ 'btn-primary': viewMode === 'list' }">
</button>
</div>
</div>
<div v-else-if="objects.length === 0" class="text-center py-12">
<div class="text-gray-400 text-6xl mb-4">🏢</div>
<h3 class="text-xl font-semibold mb-2">Объекты не найдены</h3>
<p class="text-gray-600 mb-6">Попробуйте изменить поисковый запрос или добавьте первый объект</p>
<NuxtLink to="/objects/create" class="btn btn-primary">
Добавить первый объект
</NuxtLink>
<!-- Результаты -->
<div v-if="loading" class="loading-state">
<div class="loading-spinner"></div>
<p class="loading-text">Загрузка объектов...</p>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<ObjectCard
v-for="object in objects"
:key="object.id"
:object="object"
@click="navigateToObject(object.id)"
/>
<div v-else-if="filteredObjects.length === 0" class="empty-state">
<div class="empty-icon">🏢</div>
<h3 class="empty-title">Объекты не найдены</h3>
<p class="empty-description">Попробуйте изменить параметры поиска</p>
<button @click="resetFilters" class="btn btn-primary">
Сбросить фильтры
</button>
</div>
<!-- Сетка объектов -->
<div v-else class="objects-grid" :class="viewMode === 'grid' ? 'grid-view' : 'list-view'">
<ObjectCard v-for="object in paginatedObjects" :key="object.id" :object="object" :view-mode="viewMode"
@click="navigateToObject(object.id)" />
</div>
<!-- Пагинация -->
<div v-if="!loading && filteredObjects.length > 0" class="pagination">
<button v-for="page in totalPages" :key="page" @click="currentPage = page" class="pagination-btn"
:class="{ active: currentPage === page }">
{{ page }}
</button>
</div>
</div>
</main>
<Footer />
</div>
</template>
@@ -112,8 +153,40 @@ interface ObjectItem {
image: string
description: string
isActive: boolean
createdAt: string
}
// Навигация
useHead({
title: 'Все объекты - EasySite'
})
// Состояние
const objects = ref<ObjectItem[]>([])
const loading = ref(true)
const showFilters = ref(false)
const viewMode = ref<'grid' | 'list'>('grid')
const sortBy = ref<'title' | 'price' | 'rating' | 'city'>('title')
const sortOrder = ref<'asc' | 'desc'>('asc')
const currentPage = ref(1)
const itemsPerPage = 9
const filters = ref({
search: '',
type: '',
city: '',
maxPrice: null as number | null,
minRating: 0
})
// Быстрые фильтры
const quickTypes = [
{ value: 'hotel', label: 'Гостиницы', icon: '🏨' },
{ value: 'sanatorium', label: 'Санатории', icon: '🏥' },
{ value: 'tour', label: 'Туры', icon: '🧳' },
{ value: 'restaurant', label: 'Рестораны', icon: '🍴' }
]
// Мок-данные
const mockObjects: ObjectItem[] = [
{
@@ -124,49 +197,45 @@ const mockObjects: ObjectItem[] = [
price: 4500,
rating: 4.8,
image: '/images/hotels/edelweiss.jpg',
description: 'Комфортабельный отель в горах с видом на море',
isActive: true
description: 'Комфортабельный отель в горах с видом на море. Идеальное место для отдыха от городской суеты.',
isActive: true,
createdAt: '2024-01-15'
},
{
id: 2,
title: 'Тур по Алтаю',
type: 'tour',
city: 'Горно-Алтайск',
price: 12500,
rating: 4.9,
image: '/images/tours/altai.jpg',
description: '7-дневный тур по самым живописным местам Алтая',
isActive: true
},
{
id: 3,
title: 'Санаторий "Здоровье"',
type: 'sanatorium',
city: 'Кисловодск',
price: 3200,
rating: 4.6,
image: '/images/sanatoriums/health.jpg',
description: 'Лечебно-оздоровительный комплекс с минеральными водами',
isActive: true
description: 'Лечебно-оздоровительный комплекс с минеральными водами и современным оборудованием.',
isActive: true,
createdAt: '2024-01-10'
},
{
id: 3,
title: 'Тур по Золотому кольцу',
type: 'tour',
city: 'Москва',
price: 12500,
rating: 4.9,
image: '/images/tours/golden-ring.jpg',
description: '7-дневный тур по древним городам России с опытным гидом и комфортабельным транспортом.',
isActive: true,
createdAt: '2024-01-08'
}
]
const objects = ref<ObjectItem[]>(mockObjects)
const loading = ref(false)
const filters = ref({
search: '',
type: '',
city: '',
maxPrice: null as number | null
// Вычисляемые свойства
const activeFiltersCount = computed(() => {
return Object.values(filters.value).filter(val =>
val !== '' && val !== null && val !== 0
).length
})
const searchObjects = async () => {
loading.value = true
// Имитация загрузки
await new Promise(resolve => setTimeout(resolve, 500))
let filtered = [...mockObjects]
const filteredObjects = computed(() => {
let filtered = [...objects.value]
if (filters.value.search) {
const search = filters.value.search.toLowerCase()
@@ -191,21 +260,344 @@ const searchObjects = async () => {
filtered = filtered.filter(obj => obj.price <= filters.value.maxPrice!)
}
objects.value = filtered
loading.value = false
if (filters.value.minRating) {
filtered = filtered.filter(obj => obj.rating >= filters.value.minRating!)
}
return filtered
})
const sortedObjects = computed(() => {
const sorted = [...filteredObjects.value].sort((a, b) => {
let aVal = a[sortBy.value]
let bVal = b[sortBy.value]
if (sortBy.value === 'price' || sortBy.value === 'rating') {
return sortOrder.value === 'asc' ? aVal - bVal : bVal - aVal
}
aVal = String(aVal).toLowerCase()
bVal = String(bVal).toLowerCase()
if (sortOrder.value === 'asc') {
return aVal.localeCompare(bVal)
} else {
return bVal.localeCompare(aVal)
}
})
return sorted
})
const paginatedObjects = computed(() => {
const start = (currentPage.value - 1) * itemsPerPage
return sortedObjects.value.slice(start, start + itemsPerPage)
})
const totalPages = computed(() => {
return Math.ceil(filteredObjects.value.length / itemsPerPage)
})
// Методы
const toggleQuickFilter = (type: string) => {
filters.value.type = filters.value.type === type ? '' : type
applyFilters()
}
const resetSearch = async () => {
const applyFilters = () => {
currentPage.value = 1
}
const resetFilters = () => {
filters.value = {
search: '',
type: '',
city: '',
maxPrice: null
maxPrice: null,
minRating: 0
}
objects.value = mockObjects
currentPage.value = 1
}
const navigateToObject = (id: number) => {
navigateTo(`/objects/${id}`)
}
// Инициализация
onMounted(async () => {
loading.value = true
await new Promise(resolve => setTimeout(resolve, 800))
objects.value = mockObjects
loading.value = false
})
// Следим за изменениями фильтров
watch([filters, sortBy, sortOrder], () => {
applyFilters()
})
</script>
<style scoped>
.objects-page {
padding: var(--space-xl) 0;
min-height: calc(100vh - 160px);
background: var(--bg-secondary);
}
.page-header {
background: var(--bg-primary);
border-radius: var(--radius-lg);
padding: var(--space-xl);
margin-bottom: var(--space-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-light);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-lg);
margin-bottom: var(--space-lg);
}
.header-text {
flex: 1;
}
.page-title {
font-family: var(--font-heading);
font-size: var(--text-3xl);
font-weight: var(--font-bold);
color: var(--text-primary);
margin-bottom: var(--space-xs);
}
.page-subtitle {
font-size: var(--text-lg);
color: var(--text-secondary);
}
.header-actions {
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
}
.quick-filters {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
}
.quick-filter {
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
background: var(--bg-primary);
cursor: pointer;
transition: all 0.3s ease;
font-size: var(--text-sm);
font-weight: var(--font-medium);
}
.quick-filter:hover {
border-color: var(--primary-300);
background: var(--primary-50);
transform: translateY(-1px);
}
.quick-filter.active {
background: var(--primary-500);
color: var(--text-inverse);
border-color: var(--primary-500);
}
.filter-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: var(--space-lg);
margin-bottom: var(--space-lg);
}
.filter-actions {
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
}
.view-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-lg);
padding: var(--space-md);
background: var(--bg-primary);
border-radius: var(--radius-lg);
border: 1px solid var(--border-light);
}
.sort-controls {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.view-toggle {
display: flex;
gap: var(--space-xs);
}
.objects-grid.grid-view {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: var(--space-lg);
}
.objects-grid.list-view {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.loading-state {
text-align: center;
padding: var(--space-2xl);
}
.loading-spinner {
width: 3rem;
height: 3rem;
border: 3px solid var(--border-light);
border-top: 3px solid var(--primary-500);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto var(--space-md);
}
.loading-text {
color: var(--text-secondary);
font-size: var(--text-lg);
}
.empty-state {
text-align: center;
padding: var(--space-2xl);
background: var(--bg-primary);
border-radius: var(--radius-lg);
border: 1px solid var(--border-light);
}
.empty-icon {
font-size: 4rem;
margin-bottom: var(--space-lg);
opacity: 0.5;
}
.empty-title {
font-family: var(--font-heading);
font-size: var(--text-xl);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin-bottom: var(--space-sm);
}
.empty-description {
color: var(--text-secondary);
margin-bottom: var(--space-lg);
}
.pagination {
display: flex;
justify-content: center;
gap: var(--space-xs);
margin-top: var(--space-xl);
}
.pagination-btn {
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
transition: all 0.3s ease;
font-weight: var(--font-medium);
}
.pagination-btn:hover {
border-color: var(--primary-300);
background: var(--primary-50);
}
.pagination-btn.active {
background: var(--primary-500);
color: var(--text-inverse);
border-color: var(--primary-500);
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Адаптивность */
@media (max-width: 768px) {
.header-content {
flex-direction: column;
align-items: stretch;
}
.header-actions {
justify-content: stretch;
}
.header-actions .btn {
flex: 1;
min-width: auto;
}
.filter-grid {
grid-template-columns: 1fr;
}
.view-controls {
flex-direction: column;
gap: var(--space-md);
align-items: stretch;
}
.sort-controls {
justify-content: space-between;
}
.objects-grid.grid-view {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.page-header {
padding: var(--space-lg);
}
.page-title {
font-size: var(--text-2xl);
}
.quick-filters {
justify-content: center;
}
.quick-filter {
flex: 1;
min-width: 120px;
text-align: center;
}
}
</style>
@@ -1,19 +1,23 @@
<!-- pages/objects/my-objects.vue -->
<template>
<div class="min-h-screen bg-gray-50 py-8">
<div class="page-wrapper">
<Header />
<main class="my-objects-page">
<div class="container">
<!-- Заголовок -->
<div class="mb-8">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-3xl font-bold">Мои объекты</h1>
<p class="text-gray-600 mt-2">Управление вашими добавленными местами</p>
<div class="page-header">
<div class="header-content">
<div class="header-text">
<h1 class="page-title">Мои объекты</h1>
<p class="page-subtitle">Управление вашими добавленными местами</p>
</div>
<div class="flex gap-3">
<NuxtLink to="/objects" class="btn btn-outline">
🔍 Все объекты
<div class="header-actions">
<NuxtLink to="/objects" class="btn btn-outline btn-with-icon">
<span>🔍</span>
Все объекты
</NuxtLink>
<NuxtLink to="/objects/create" class="btn btn-primary">
<NuxtLink to="/objects/create" class="btn btn-primary btn-with-icon">
<span></span>
Добавить объект
</NuxtLink>
</div>
@@ -21,9 +25,8 @@
</div>
<!-- Фильтры -->
<div class="card mb-6">
<div class="card-body">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="search-filters card">
<div class="filter-grid">
<div class="form-group">
<label class="form-label">Статус</label>
<select v-model="filters.status" class="form-select">
@@ -36,22 +39,18 @@
<label class="form-label">Тип</label>
<select v-model="filters.type" class="form-select">
<option value="">Все типы</option>
<option value="hotel">Гостиница</option>
<option value="sanatorium">Санаторий</option>
<option value="tour">Тур</option>
<option value="hotel">🏨 Гостиница</option>
<option value="sanatorium">🏥 Санаторий</option>
<option value="tour">🧳 Тур</option>
<option value="guest_house">🏡 Гостевой дом</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Поиск</label>
<input
v-model="filters.search"
type="text"
class="form-input"
placeholder="Название или город"
>
<input v-model="filters.search" type="text" class="form-input" placeholder="Название или город">
</div>
</div>
<div class="flex gap-2 mt-4">
<div class="filter-actions">
<button class="btn btn-primary" @click="applyFilters">
Применить
</button>
@@ -60,72 +59,61 @@
</button>
</div>
</div>
</div>
<!-- Таблица объектов -->
<div class="card">
<div class="objects-table card">
<div class="card-header">
<h2 class="text-xl font-semibold">Список объектов</h2>
<h2 class="card-title">Список объектов</h2>
<p class="card-subtitle">Всего: {{ myObjects.length }} объектов</p>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<div class="table-container">
<table class="objects-table">
<thead>
<tr class="border-b border-gray-200">
<th class="text-left py-3 px-4">Название</th>
<th class="text-left py-3 px-4">Тип</th>
<th class="text-left py-3 px-4">Город</th>
<th class="text-left py-3 px-4">Цена</th>
<th class="text-left py-3 px-4">Статус</th>
<th class="text-left py-3 px-4">Действия</th>
<tr>
<th class="table-header">Название</th>
<th class="table-header">Тип</th>
<th class="table-header">Город</th>
<th class="table-header">Цена</th>
<th class="table-header">Статус</th>
<th class="table-header">Действия</th>
</tr>
</thead>
<tbody>
<tr
v-for="object in myObjects"
:key="object.id"
class="border-b border-gray-100 hover:bg-gray-50"
>
<td class="py-3 px-4">
<div class="font-medium">{{ object.title }}</div>
<div class="text-sm text-gray-500">{{ formatDate(object.createdAt) }}</div>
<tr v-for="object in myObjects" :key="object.id" class="table-row">
<td class="table-cell">
<div class="object-info">
<div class="object-title">{{ object.title }}</div>
<div class="object-date">{{ formatDate(object.createdAt) }}</div>
</div>
</td>
<td class="py-3 px-4">
<td class="table-cell">
<span class="badge badge-primary">
{{ getTypeLabel(object.type) }}
</span>
</td>
<td class="py-3 px-4">{{ object.city }}</td>
<td class="py-3 px-4 font-medium">
{{ formatPrice(object.price) }}
<td class="table-cell">{{ object.city }}</td>
<td class="table-cell">
<div class="object-price">{{ formatPrice(object.price) }}</div>
</td>
<td class="py-3 px-4">
<span
class="badge"
:class="object.isActive ? 'badge-success' : 'badge-secondary'"
>
<td class="table-cell">
<span class="status-badge" :class="object.isActive ? 'status-active' : 'status-inactive'">
{{ object.isActive ? 'Активен' : 'Неактивен' }}
</span>
</td>
<td class="py-3 px-4">
<div class="flex gap-2">
<NuxtLink
:to="`/objects/${object.id}`"
class="btn btn-outline btn-sm"
>
Просмотр
<td class="table-cell">
<div class="action-buttons">
<NuxtLink :to="`/objects/${object.id}`" class="btn btn-outline btn-sm btn-with-icon"
title="Просмотр">
👁
</NuxtLink>
<NuxtLink
:to="`/objects/${object.id}/edit`"
class="btn btn-outline btn-sm"
>
Редактировать
<NuxtLink :to="`/objects/${object.id}/edit`" class="btn btn-outline btn-sm btn-with-icon"
title="Редактировать">
</NuxtLink>
<button
@click="deleteObject(object.id)"
class="btn btn-outline btn-sm text-red-600 hover:bg-red-50"
>
Удалить
<button @click="deleteObject(object.id)" class="btn btn-outline btn-sm btn-with-icon delete-btn"
title="Удалить">
🗑
</button>
</div>
</td>
@@ -135,13 +123,10 @@
</div>
<!-- Пустой state -->
<div
v-if="!loading && myObjects.length === 0"
class="text-center py-12"
>
<div class="text-gray-400 text-6xl mb-4">🏢</div>
<h3 class="text-xl font-semibold mb-2">У вас пока нет объектов</h3>
<p class="text-gray-600 mb-6">Добавьте первый объект, чтобы начать работу</p>
<div v-if="!loading && myObjects.length === 0" class="empty-state">
<div class="empty-icon">🏢</div>
<h3 class="empty-title">У вас пока нет объектов</h3>
<p class="empty-description">Добавьте первый объект, чтобы начать работу</p>
<NuxtLink to="/objects/create" class="btn btn-primary">
Добавить первый объект
</NuxtLink>
@@ -149,15 +134,18 @@
</div>
<!-- Навигация -->
<div class="mt-8 flex justify-between">
<NuxtLink to="/objects" class="btn btn-outline">
<div class="page-navigation">
<NuxtLink to="/objects" class="btn btn-outline btn-with-icon">
Все объекты
</NuxtLink>
<NuxtLink to="/" class="btn btn-outline">
<NuxtLink to="/" class="btn btn-outline btn-with-icon">
🏠 На главную
</NuxtLink>
</div>
</div>
</main>
<Footer />
</div>
</template>
@@ -186,7 +174,7 @@ const mockMyObjects: MyObjectItem[] = [
{
id: 2,
title: 'Экскурсия по историческому центру',
type: 'excursion',
type: 'tour',
city: 'Санкт-Петербург',
price: 1500,
isActive: true,
@@ -247,7 +235,6 @@ const resetFilters = () => {
const deleteObject = async (id: number) => {
if (confirm('Вы уверены, что хотите удалить этот объект?')) {
// Имитация удаления
myObjects.value = myObjects.value.filter(obj => obj.id !== id)
}
}
@@ -275,3 +262,265 @@ const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('ru-RU')
}
</script>
<style scoped>
.my-objects-page {
padding: var(--space-xl) 0;
min-height: calc(100vh - 160px);
background: var(--bg-secondary);
}
.page-header {
background: var(--bg-primary);
border-radius: var(--radius-lg);
padding: var(--space-xl);
margin-bottom: var(--space-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-light);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-lg);
}
.header-text {
flex: 1;
}
.page-title {
font-family: var(--font-heading);
font-size: var(--text-3xl);
font-weight: var(--font-bold);
color: var(--text-primary);
margin-bottom: var(--space-xs);
}
.page-subtitle {
font-size: var(--text-lg);
color: var(--text-secondary);
}
.header-actions {
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
}
.filter-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-lg);
margin-bottom: var(--space-lg);
}
.filter-actions {
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
}
.objects-table {
width: 100%;
}
.card-header {
padding: var(--space-xl);
border-bottom: 1px solid var(--border-light);
background: var(--bg-primary);
}
.card-title {
font-family: var(--font-heading);
font-size: var(--text-xl);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin-bottom: var(--space-xs);
}
.card-subtitle {
color: var(--text-secondary);
font-size: var(--text-sm);
}
.table-container {
overflow-x: auto;
}
.objects-table {
width: 100%;
border-collapse: collapse;
}
.table-header {
padding: var(--space-md);
text-align: left;
font-weight: var(--font-semibold);
color: var(--text-secondary);
border-bottom: 1px solid var(--border-light);
background: var(--bg-secondary);
font-size: var(--text-sm);
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
}
.table-row {
border-bottom: 1px solid var(--border-light);
transition: background-color 0.3s ease;
}
.table-row:hover {
background: var(--bg-secondary);
}
.table-cell {
padding: var(--space-md);
vertical-align: middle;
}
.object-info {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.object-title {
font-weight: var(--font-semibold);
color: var(--text-primary);
}
.object-date {
font-size: var(--text-sm);
color: var(--text-tertiary);
}
.object-price {
font-weight: var(--font-semibold);
color: var(--primary-600);
font-size: var(--text-lg);
}
.status-badge {
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
font-weight: var(--font-medium);
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
}
.status-active {
background: var(--success-50);
color: var(--success-600);
}
.status-inactive {
background: var(--gray-100);
color: var(--gray-600);
}
.action-buttons {
display: flex;
gap: var(--space-xs);
}
.delete-btn:hover {
background: var(--error-50);
border-color: var(--error-300);
color: var(--error-600);
}
.empty-state {
text-align: center;
padding: var(--space-2xl);
}
.empty-icon {
font-size: 4rem;
margin-bottom: var(--space-lg);
opacity: 0.5;
}
.empty-title {
font-family: var(--font-heading);
font-size: var(--text-xl);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin-bottom: var(--space-sm);
}
.empty-description {
color: var(--text-secondary);
margin-bottom: var(--space-lg);
}
.page-navigation {
display: flex;
justify-content: space-between;
margin-top: var(--space-xl);
padding-top: var(--space-lg);
border-top: 1px solid var(--border-light);
}
/* Адаптивность */
@media (max-width: 768px) {
.header-content {
flex-direction: column;
align-items: stretch;
}
.header-actions {
justify-content: stretch;
}
.header-actions .btn {
flex: 1;
min-width: auto;
}
.filter-grid {
grid-template-columns: 1fr;
}
.table-container {
font-size: var(--text-sm);
}
.action-buttons {
flex-direction: column;
}
.page-navigation {
flex-direction: column;
gap: var(--space-sm);
}
.page-navigation .btn {
justify-content: center;
}
}
@media (max-width: 480px) {
.page-header {
padding: var(--space-lg);
}
.page-title {
font-size: var(--text-2xl);
}
.card-header {
padding: var(--space-lg);
}
.table-cell {
padding: var(--space-sm);
}
.objects-table {
font-size: var(--text-xs);
}
}
</style>