init
This commit is contained in:
206
src/views/admin/ReportsView.vue
Normal file
206
src/views/admin/ReportsView.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useUiStore } from '@/stores/ui.store';
|
||||
import { apiClient } from '@/api/client';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
|
||||
import EmptyState from '@/components/common/EmptyState.vue';
|
||||
|
||||
interface Report {
|
||||
id: string;
|
||||
sourceProfileId: string;
|
||||
entityId: string;
|
||||
entityType: 'profile' | 'message';
|
||||
description?: string;
|
||||
createdAt: string;
|
||||
resolved?: boolean;
|
||||
reporterName?: string;
|
||||
}
|
||||
|
||||
const uiStore = useUiStore();
|
||||
const reports = ref<Report[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.api.reportsControllerGetAll() as unknown as Report[];
|
||||
reports.value = res;
|
||||
} catch {
|
||||
uiStore.addToast('Не удалось загрузить жалобы', 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function banUser(userId: string) {
|
||||
try {
|
||||
await apiClient.api.usersControllerBan(userId);
|
||||
uiStore.addToast('Пользователь заблокирован', 'success');
|
||||
} catch {
|
||||
uiStore.addToast('Не удалось заблокировать пользователя', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('ru', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="reports-admin">
|
||||
<header class="reports-admin__header">
|
||||
<h1 class="reports-admin__title">Жалобы</h1>
|
||||
<span class="meta">{{ reports.length }} всего</span>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="reports-admin__loading">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-else-if="reports.length === 0"
|
||||
title="Нет жалоб"
|
||||
description="Жалобы от пользователей появятся здесь"
|
||||
icon="default"
|
||||
/>
|
||||
|
||||
<div v-else class="reports-admin__table-wrap">
|
||||
<table class="reports-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Дата</th>
|
||||
<th>Тип</th>
|
||||
<th>Объект</th>
|
||||
<th>Описание</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="report in reports" :key="report.id" :class="{ 'reports-table__row--resolved': report.resolved }">
|
||||
<td class="reports-table__date meta">{{ formatDate(report.createdAt) }}</td>
|
||||
<td>
|
||||
<span class="reports-table__type" :class="`reports-table__type--${report.entityType}`">
|
||||
{{ report.entityType === 'profile' ? 'Профиль' : 'Сообщение' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="reports-table__entity">
|
||||
<RouterLink
|
||||
v-if="report.entityType === 'profile'"
|
||||
:to="`/profile/${report.entityId}`"
|
||||
class="reports-table__link"
|
||||
>Открыть</RouterLink>
|
||||
<span v-else class="meta">{{ report.entityId.slice(0, 8) }}…</span>
|
||||
</td>
|
||||
<td class="reports-table__desc">{{ report.description ?? '—' }}</td>
|
||||
<td>
|
||||
<AppButton
|
||||
v-if="report.entityType === 'profile'"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
@click="banUser(report.sourceProfileId)"
|
||||
>Заблокировать</AppButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.reports-admin {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-cream);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__table-wrap {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.reports-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8125rem;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-muted);
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
color: var(--color-cream);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
tr:last-child td { border-bottom: none; }
|
||||
|
||||
&__row--resolved td { opacity: 0.5; }
|
||||
|
||||
&__date { color: var(--color-muted) !important; font-variant-numeric: tabular-nums; }
|
||||
|
||||
&__type {
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
|
||||
&--profile { background: var(--color-signal-bg); color: var(--color-signal); }
|
||||
&--message { background: rgba(240, 235, 224, 0.06); color: var(--color-muted); }
|
||||
}
|
||||
|
||||
&__link {
|
||||
color: var(--color-cream);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-color: var(--color-border);
|
||||
transition: color var(--transition-fast);
|
||||
&:hover { color: var(--color-signal); }
|
||||
}
|
||||
|
||||
&__desc {
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-muted) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
174
src/views/auth/LoginView.vue
Normal file
174
src/views/auth/LoginView.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, helpers } from '@vuelidate/validators';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { useUiStore } from '@/stores/ui.store';
|
||||
import AppInput from '@/components/common/AppInput.vue';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
const uiStore = useUiStore();
|
||||
|
||||
const form = reactive({ phone: '', password: '' });
|
||||
const loading = ref(false);
|
||||
|
||||
const rules = {
|
||||
phone: { required: helpers.withMessage('Введите номер телефона', required) },
|
||||
password: { required: helpers.withMessage('Введите пароль', required) },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(rules, form);
|
||||
|
||||
async function submit() {
|
||||
const valid = await v$.value.$validate();
|
||||
if (!valid) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
await authStore.login({ phone: form.phone, password: form.password });
|
||||
const redirect = (route.query.redirect as string) || '/feed';
|
||||
router.replace(redirect);
|
||||
} catch (err: unknown) {
|
||||
const message = (err as { response?: { data?: { message?: string } } })
|
||||
?.response?.data?.message ?? 'Неверный телефон или пароль';
|
||||
uiStore.addToast(message, 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="auth-page__grain" aria-hidden="true" />
|
||||
|
||||
<div class="auth-card">
|
||||
<div class="auth-card__wordmark">Daiting</div>
|
||||
<h1 class="auth-card__heading">С возвращением</h1>
|
||||
<p class="auth-card__sub">Войдите, чтобы продолжить</p>
|
||||
|
||||
<form class="auth-form" @submit.prevent="submit" novalidate>
|
||||
<AppInput
|
||||
v-model="form.phone"
|
||||
label="Телефон"
|
||||
placeholder="+7 999 000 00 00"
|
||||
type="tel"
|
||||
name="phone"
|
||||
autocomplete="tel"
|
||||
required
|
||||
:error="v$.phone.$errors.map(e => e.$message as string)"
|
||||
@blur="v$.phone.$touch()"
|
||||
/>
|
||||
<AppInput
|
||||
v-model="form.password"
|
||||
label="Пароль"
|
||||
placeholder="••••••••"
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
:error="v$.password.$errors.map(e => e.$message as string)"
|
||||
@blur="v$.password.$touch()"
|
||||
/>
|
||||
<AppButton type="submit" :loading="loading" full-width size="lg" class="auth-form__submit">
|
||||
Войти
|
||||
</AppButton>
|
||||
</form>
|
||||
|
||||
<p class="auth-card__footer">
|
||||
Нет аккаунта?
|
||||
<RouterLink to="/register" class="auth-card__link">Зарегистрироваться</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.auth-page {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-base);
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&__grain {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url('@/assets/grain.svg');
|
||||
background-repeat: repeat;
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Warm radial vignette
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse 80% 70% at 50% 0%, rgba(196, 92, 58, 0.06) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
animation: fade-up var(--transition-base) both;
|
||||
|
||||
&__wordmark {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-signal);
|
||||
margin-bottom: 32px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
&__heading {
|
||||
font-family: var(--font-display);
|
||||
font-size: 2.25rem;
|
||||
color: var(--color-cream);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-muted);
|
||||
margin: 0 0 40px;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
margin-top: 24px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__link {
|
||||
color: var(--color-cream);
|
||||
transition: color var(--transition-fast);
|
||||
|
||||
&:hover { color: var(--color-signal); }
|
||||
}
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
|
||||
&__submit {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
191
src/views/auth/RegisterView.vue
Normal file
191
src/views/auth/RegisterView.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength, helpers } from '@vuelidate/validators';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { useUiStore } from '@/stores/ui.store';
|
||||
import AppInput from '@/components/common/AppInput.vue';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const uiStore = useUiStore();
|
||||
|
||||
const form = reactive({ phone: '', password: '', confirmPassword: '' });
|
||||
const loading = ref(false);
|
||||
|
||||
const phoneRegex = helpers.regex(/^\+?[0-9\s\-()]{7,20}$/);
|
||||
|
||||
const rules = {
|
||||
phone: {
|
||||
required: helpers.withMessage('Введите номер телефона', required),
|
||||
format: helpers.withMessage('Введите корректный номер', phoneRegex),
|
||||
},
|
||||
password: {
|
||||
required: helpers.withMessage('Введите пароль', required),
|
||||
minLen: helpers.withMessage('Минимум 8 символов', minLength(8)),
|
||||
},
|
||||
confirmPassword: {
|
||||
required: helpers.withMessage('Подтвердите пароль', required),
|
||||
match: helpers.withMessage('Пароли не совпадают', () => form.password === form.confirmPassword),
|
||||
},
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(rules, form);
|
||||
|
||||
async function submit() {
|
||||
const valid = await v$.value.$validate();
|
||||
if (!valid) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
await authStore.register({ phone: form.phone, password: form.password });
|
||||
router.replace('/setup');
|
||||
} catch (err: unknown) {
|
||||
const message = (err as { response?: { data?: { message?: string } } })
|
||||
?.response?.data?.message ?? 'Ошибка регистрации. Попробуйте ещё раз.';
|
||||
uiStore.addToast(message, 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="auth-page__grain" aria-hidden="true" />
|
||||
|
||||
<div class="auth-card">
|
||||
<div class="auth-card__wordmark">Daiting</div>
|
||||
<h1 class="auth-card__heading">Создать аккаунт</h1>
|
||||
<p class="auth-card__sub">Начните своё путешествие</p>
|
||||
|
||||
<form class="auth-form" @submit.prevent="submit" novalidate>
|
||||
<AppInput
|
||||
v-model="form.phone"
|
||||
label="Телефон"
|
||||
placeholder="+7 999 000 00 00"
|
||||
type="tel"
|
||||
name="phone"
|
||||
autocomplete="tel"
|
||||
required
|
||||
:error="v$.phone.$errors.map(e => e.$message as string)"
|
||||
@blur="v$.phone.$touch()"
|
||||
/>
|
||||
<AppInput
|
||||
v-model="form.password"
|
||||
label="Пароль"
|
||||
placeholder="Минимум 8 символов"
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
:error="v$.password.$errors.map(e => e.$message as string)"
|
||||
@blur="v$.password.$touch()"
|
||||
/>
|
||||
<AppInput
|
||||
v-model="form.confirmPassword"
|
||||
label="Подтверждение пароля"
|
||||
placeholder="Повторите пароль"
|
||||
type="password"
|
||||
name="confirm-password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
:error="v$.confirmPassword.$errors.map(e => e.$message as string)"
|
||||
@blur="v$.confirmPassword.$touch()"
|
||||
/>
|
||||
<AppButton type="submit" :loading="loading" full-width size="lg" class="auth-form__submit">
|
||||
Зарегистрироваться
|
||||
</AppButton>
|
||||
</form>
|
||||
|
||||
<p class="auth-card__footer">
|
||||
Уже есть аккаунт?
|
||||
<RouterLink to="/login" class="auth-card__link">Войти</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.auth-page {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-base);
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&__grain {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url('@/assets/grain.svg');
|
||||
background-repeat: repeat;
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse 80% 70% at 50% 0%, rgba(196, 92, 58, 0.06) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
animation: fade-up var(--transition-base) both;
|
||||
|
||||
&__wordmark {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-signal);
|
||||
margin-bottom: 32px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
&__heading {
|
||||
font-family: var(--font-display);
|
||||
font-size: 2.25rem;
|
||||
color: var(--color-cream);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-muted);
|
||||
margin: 0 0 40px;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
margin-top: 24px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__link {
|
||||
color: var(--color-cream);
|
||||
transition: color var(--transition-fast);
|
||||
&:hover { color: var(--color-signal); }
|
||||
}
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
|
||||
&__submit { margin-top: 8px; }
|
||||
}
|
||||
</style>
|
||||
285
src/views/chat/ChatRoomView.vue
Normal file
285
src/views/chat/ChatRoomView.vue
Normal file
@@ -0,0 +1,285 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, nextTick, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { useChatStore } from '@/stores/chat.store';
|
||||
import { useUiStore } from '@/stores/ui.store';
|
||||
import { apiClient } from '@/api/client';
|
||||
import type { ChatMessage } from '@/stores/chat.store';
|
||||
import ChatBubble from '@/components/chat/ChatBubble.vue';
|
||||
import ChatInput from '@/components/chat/ChatInput.vue';
|
||||
import AppModal from '@/components/common/AppModal.vue';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
const chatStore = useChatStore();
|
||||
const uiStore = useUiStore();
|
||||
|
||||
const chatId = route.params.chatId as string;
|
||||
const profileId = computed(() => authStore.activeProfile?.id ?? '');
|
||||
const messagesEnd = ref<HTMLElement | null>(null);
|
||||
const confirmClose = ref(false);
|
||||
|
||||
const chat = computed(() => chatStore.chats.find((c) => c.id === chatId));
|
||||
const isLocked = computed(() => chat.value && !chat.value.isActive);
|
||||
|
||||
// Group messages by date
|
||||
const groupedMessages = computed(() => {
|
||||
const groups: Array<{ date: string; messages: ChatMessage[] }> = [];
|
||||
let lastDate = '';
|
||||
for (const msg of chatStore.messages) {
|
||||
const d = new Date(msg.createdAt).toLocaleDateString('ru', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||
if (d !== lastDate) {
|
||||
groups.push({ date: d, messages: [] });
|
||||
lastDate = d;
|
||||
}
|
||||
groups[groups.length - 1].messages.push(msg);
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
async function scrollToBottom() {
|
||||
await nextTick();
|
||||
messagesEnd.value?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!profileId.value) return;
|
||||
await chatStore.fetchMessages(chatId, profileId.value);
|
||||
await scrollToBottom();
|
||||
chatStore.startPolling(chatId, profileId.value);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
chatStore.stopPolling();
|
||||
});
|
||||
|
||||
async function send(text: string, mediaUrl?: string, mediaType?: 'photo' | 'voice' | 'video') {
|
||||
if (!profileId.value) return;
|
||||
try {
|
||||
await chatStore.sendMessage(chatId, profileId.value, { text, mediaUrl, mediaType });
|
||||
await scrollToBottom();
|
||||
} catch {
|
||||
uiStore.addToast('Не удалось отправить сообщение', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function doCloseChat() {
|
||||
if (!profileId.value) return;
|
||||
try {
|
||||
await chatStore.closeChat(chatId, profileId.value);
|
||||
confirmClose.value = false;
|
||||
history.back();
|
||||
} catch {
|
||||
uiStore.addToast('Не удалось закрыть чат', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-room">
|
||||
<!-- Header -->
|
||||
<header class="chat-room__header">
|
||||
<button class="chat-room__back" @click="history.back()" aria-label="Назад">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="20" height="20">
|
||||
<path d="M19 12H5M12 5l-7 7 7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="chat-room__partner">
|
||||
<img
|
||||
v-if="chat?.partner.avatarUrl"
|
||||
:src="chat.partner.avatarUrl"
|
||||
class="chat-room__avatar"
|
||||
:alt="chat.partner.name"
|
||||
/>
|
||||
<div v-else class="chat-room__avatar chat-room__avatar--placeholder" />
|
||||
<span class="chat-room__name">{{ chat?.partner.name ?? 'Чат' }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="chat-room__close-btn"
|
||||
@click="confirmClose = true"
|
||||
aria-label="Закрыть чат"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="18" height="18">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Locked overlay -->
|
||||
<div v-if="isLocked" class="chat-room__locked" role="alert">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="24" height="24">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
<span>Чат заблокирован. Закройте другой активный чат, чтобы продолжить общение.</span>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div v-if="chatStore.loading" class="chat-room__loading">
|
||||
<LoadingSpinner size="md" />
|
||||
</div>
|
||||
|
||||
<div v-else class="chat-room__messages" :class="{ 'chat-room__messages--blurred': isLocked }">
|
||||
<div
|
||||
v-for="group in groupedMessages"
|
||||
:key="group.date"
|
||||
class="chat-room__group"
|
||||
>
|
||||
<div class="chat-room__date-sep">
|
||||
<span>{{ group.date }}</span>
|
||||
</div>
|
||||
<ChatBubble
|
||||
v-for="msg in group.messages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
:is-mine="msg.senderId === authStore.activeProfile?.id"
|
||||
/>
|
||||
</div>
|
||||
<div ref="messagesEnd" />
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<ChatInput v-if="!isLocked" @send="send" />
|
||||
|
||||
<!-- Close chat confirm -->
|
||||
<AppModal
|
||||
:open="confirmClose"
|
||||
title="Закрыть чат"
|
||||
size="sm"
|
||||
@close="confirmClose = false"
|
||||
>
|
||||
<p style="font-family: var(--font-mono); font-size: 0.875rem; color: var(--color-muted); margin: 0">
|
||||
Вы уверены? Переписка будет удалена и восстановить её нельзя.
|
||||
</p>
|
||||
<template #footer>
|
||||
<AppButton variant="ghost" @click="confirmClose = false">Отмена</AppButton>
|
||||
<AppButton variant="danger" @click="doCloseChat">Закрыть чат</AppButton>
|
||||
</template>
|
||||
</AppModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-room {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__back {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
&:hover { color: var(--color-cream); }
|
||||
}
|
||||
|
||||
&__partner {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--placeholder {
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-cream);
|
||||
}
|
||||
|
||||
&__close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-muted);
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
transition: color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
&:hover { color: var(--color-signal); }
|
||||
}
|
||||
|
||||
&__locked {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
background: rgba(196, 92, 58, 0.1);
|
||||
border-bottom: 1px solid rgba(196, 92, 58, 0.3);
|
||||
color: var(--color-signal);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 0;
|
||||
|
||||
&--blurred {
|
||||
filter: blur(4px);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__group { margin-bottom: 16px; }
|
||||
|
||||
&__date-sep {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 16px;
|
||||
|
||||
span {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.625rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-muted);
|
||||
background: var(--color-base);
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
239
src/views/chat/ChatsListView.vue
Normal file
239
src/views/chat/ChatsListView.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { useChatStore } from '@/stores/chat.store';
|
||||
import { useUiStore } from '@/stores/ui.store';
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
|
||||
import EmptyState from '@/components/common/EmptyState.vue';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const chatStore = useChatStore();
|
||||
const uiStore = useUiStore();
|
||||
const loading = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
const profileId = authStore.activeProfile?.id;
|
||||
if (!profileId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
await chatStore.fetchChats(profileId);
|
||||
} catch {
|
||||
uiStore.addToast('Не удалось загрузить чаты', 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function formatTime(dateStr: string) {
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const isToday = d.toDateString() === now.toDateString();
|
||||
if (isToday) return d.toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' });
|
||||
return d.toLocaleDateString('ru', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chats-list">
|
||||
<header class="chats-list__header">
|
||||
<h1 class="chats-list__title">Чаты</h1>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="chats-list__loading">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-else-if="chatStore.chats.length === 0"
|
||||
title="Нет активных чатов"
|
||||
description="Найдите совпадения и начните общение"
|
||||
icon="chat"
|
||||
/>
|
||||
|
||||
<ul v-else class="chats-list__list" role="list">
|
||||
<li v-for="chat in chatStore.chats" :key="chat.id">
|
||||
<RouterLink
|
||||
:to="`/chats/${chat.id}`"
|
||||
class="chat-item"
|
||||
:class="{ 'chat-item--inactive': !chat.isActive }"
|
||||
>
|
||||
<div class="chat-item__avatar-wrap">
|
||||
<img
|
||||
v-if="chat.partner.avatarUrl"
|
||||
:src="chat.partner.avatarUrl"
|
||||
:alt="chat.partner.name"
|
||||
class="chat-item__avatar"
|
||||
/>
|
||||
<div v-else class="chat-item__avatar chat-item__avatar--placeholder">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" width="24" height="24" opacity="0.3">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Lock icon for inactive chats -->
|
||||
<div v-if="!chat.isActive" class="chat-item__lock" aria-label="Чат неактивен">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12">
|
||||
<rect x="3" y="7" width="10" height="8" rx="1"/>
|
||||
<path d="M5 7V5a3 3 0 0 1 6 0v2"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-item__content">
|
||||
<div class="chat-item__top">
|
||||
<span class="chat-item__name">{{ chat.partner.name }}</span>
|
||||
<span v-if="chat.lastMessage" class="chat-item__time meta">
|
||||
{{ formatTime(chat.lastMessage.createdAt) }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="chat.lastMessage" class="chat-item__preview">
|
||||
{{ chat.lastMessage.text || (chat.lastMessage.mediaType === 'photo' ? '📷 Фото' : chat.lastMessage.mediaType === 'voice' ? '🎤 Голосовое' : '🎬 Видео') }}
|
||||
</p>
|
||||
<p v-else class="chat-item__preview chat-item__preview--empty">Начните переписку</p>
|
||||
</div>
|
||||
|
||||
<div v-if="chat.unreadCount > 0" class="chat-item__badge">{{ chat.unreadCount }}</div>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chats-list {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__header {
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-cream);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 24px;
|
||||
text-decoration: none;
|
||||
transition: background var(--transition-fast);
|
||||
|
||||
&:hover { background: rgba(240, 235, 224, 0.03); }
|
||||
|
||||
&--inactive {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&__avatar-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
|
||||
&--placeholder {
|
||||
background: var(--color-surface-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__lock {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-surface-3);
|
||||
border: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-cream);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__time {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
&__preview {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin: 0;
|
||||
|
||||
&--empty { font-style: italic; }
|
||||
}
|
||||
|
||||
&__badge {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-signal);
|
||||
color: white;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
257
src/views/dates/DatesView.vue
Normal file
257
src/views/dates/DatesView.vue
Normal file
@@ -0,0 +1,257 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { useUiStore } from '@/stores/ui.store';
|
||||
import { apiClient } from '@/api/client';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
import AppModal from '@/components/common/AppModal.vue';
|
||||
import EmptyState from '@/components/common/EmptyState.vue';
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
|
||||
|
||||
interface DateStatus { id: string; name: string; }
|
||||
interface DateItem {
|
||||
id: string;
|
||||
profileId: string;
|
||||
partnerProfileId: string;
|
||||
partnerName?: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
time: string;
|
||||
statusId: string;
|
||||
statusName?: string;
|
||||
isIncoming: boolean;
|
||||
}
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const uiStore = useUiStore();
|
||||
|
||||
const dates = ref<DateItem[]>([]);
|
||||
const statuses = ref<DateStatus[]>([]);
|
||||
const loading = ref(false);
|
||||
const actionLoading = ref<string | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
const profileId = authStore.activeProfile?.id;
|
||||
if (!profileId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const [datesRes, statusesRes] = await Promise.all([
|
||||
apiClient.api.datesControllerGetDates({ profileId }) as unknown as DateItem[],
|
||||
apiClient.api.datesControllerGetStatuses() as unknown as DateStatus[],
|
||||
]);
|
||||
dates.value = datesRes;
|
||||
statuses.value = statusesRes;
|
||||
} catch {
|
||||
uiStore.addToast('Не удалось загрузить встречи', 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function statusLabel(statusId: string) {
|
||||
return statuses.value.find((s) => s.id === statusId)?.name ?? statusId;
|
||||
}
|
||||
|
||||
function statusColor(statusId: string) {
|
||||
const name = statusLabel(statusId).toLowerCase();
|
||||
if (name.includes('ожид') || name.includes('pending')) return 'pending';
|
||||
if (name.includes('приня') || name.includes('accept')) return 'accepted';
|
||||
if (name.includes('отклон') || name.includes('declin')) return 'declined';
|
||||
if (name.includes('заверш') || name.includes('complet')) return 'completed';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
async function updateStatus(dateId: string, statusId: string) {
|
||||
const profileId = authStore.activeProfile?.id;
|
||||
if (!profileId) return;
|
||||
actionLoading.value = `${dateId}-${statusId}`;
|
||||
try {
|
||||
await apiClient.api.datesControllerUpdateStatus({ id: dateId, profileId }, { statusId });
|
||||
const idx = dates.value.findIndex((d) => d.id === dateId);
|
||||
if (idx !== -1) dates.value[idx].statusId = statusId;
|
||||
uiStore.addToast('Статус обновлён', 'success');
|
||||
} catch {
|
||||
uiStore.addToast('Не удалось обновить статус', 'error');
|
||||
} finally {
|
||||
actionLoading.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string) {
|
||||
return new Date(iso).toLocaleString('ru', {
|
||||
day: 'numeric', month: 'long', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
// Pending = first two status ids roughly; accept/decline/complete = 3+
|
||||
const pendingStatusId = computed(() => statuses.value[0]?.id ?? '');
|
||||
const acceptedStatusId = computed(() => statuses.value[1]?.id ?? '');
|
||||
const declinedStatusId = computed(() => statuses.value[2]?.id ?? '');
|
||||
const completedStatusId = computed(() => statuses.value[3]?.id ?? '');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dates-view">
|
||||
<header class="dates-view__header">
|
||||
<h1 class="dates-view__title">Встречи</h1>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="dates-view__loading">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-else-if="dates.length === 0"
|
||||
title="Нет предстоящих встреч"
|
||||
description="Предложите встречу из профиля совпадения"
|
||||
icon="calendar"
|
||||
/>
|
||||
|
||||
<div v-else class="dates-list">
|
||||
<article v-for="date in dates" :key="date.id" class="date-card">
|
||||
<div class="date-card__header">
|
||||
<div>
|
||||
<h3 class="date-card__partner">{{ date.partnerName ?? 'Партнёр' }}</h3>
|
||||
<time class="date-card__time meta" :datetime="date.time">{{ formatDateTime(date.time) }}</time>
|
||||
</div>
|
||||
<span
|
||||
class="date-card__status"
|
||||
:class="`date-card__status--${statusColor(date.statusId)}`"
|
||||
>{{ statusLabel(date.statusId) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="date-card__location meta">
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14">
|
||||
<path d="M10 11a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/>
|
||||
<path d="M17 10c0 6-7 10-7 10S3 16 3 10a7 7 0 1 1 14 0z"/>
|
||||
</svg>
|
||||
{{ date.lat.toFixed(4) }}, {{ date.lng.toFixed(4) }}
|
||||
</div>
|
||||
|
||||
<!-- Actions for incoming proposals -->
|
||||
<div v-if="date.isIncoming && statusLabel(date.statusId).toLowerCase().includes('ожид')" class="date-card__actions">
|
||||
<AppButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
:loading="actionLoading === `${date.id}-${acceptedStatusId}`"
|
||||
@click="updateStatus(date.id, acceptedStatusId)"
|
||||
>Принять</AppButton>
|
||||
<AppButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:loading="actionLoading === `${date.id}-${declinedStatusId}`"
|
||||
@click="updateStatus(date.id, declinedStatusId)"
|
||||
>Отклонить</AppButton>
|
||||
</div>
|
||||
|
||||
<!-- Mark as complete -->
|
||||
<div v-else-if="statusLabel(date.statusId).toLowerCase().includes('приня')" class="date-card__actions">
|
||||
<AppButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:loading="actionLoading === `${date.id}-${completedStatusId}`"
|
||||
@click="updateStatus(date.id, completedStatusId)"
|
||||
>Встреча состоялась</AppButton>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dates-view {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__header {
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-cream);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.dates-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.date-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
transition: border-color var(--transition-fast);
|
||||
|
||||
&:hover { border-color: var(--color-border-strong); }
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__partner {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-cream);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
&__time { color: var(--color-muted); }
|
||||
|
||||
&__status {
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--pending { background: rgba(200, 160, 60, 0.15); color: #c89c3c; }
|
||||
&--accepted { background: rgba(80, 180, 80, 0.15); color: #50b450; }
|
||||
&--declined { background: var(--color-signal-bg); color: var(--color-signal); }
|
||||
&--completed { background: rgba(240, 235, 224, 0.06); color: var(--color-muted); }
|
||||
&--default { background: rgba(240, 235, 224, 0.06); color: var(--color-muted); }
|
||||
}
|
||||
|
||||
&__location {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--color-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
251
src/views/feed/FeedView.vue
Normal file
251
src/views/feed/FeedView.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useFeedStore } from '@/stores/feed.store';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import FeedCardStack from '@/components/feed/FeedCardStack.vue';
|
||||
import FeedFilters from '@/components/feed/FeedFilters.vue';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
|
||||
const feedStore = useFeedStore();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const filtersOpen = ref(false);
|
||||
const viewMode = ref<'stack' | 'scroll'>('stack');
|
||||
|
||||
onMounted(() => {
|
||||
const profileId = authStore.activeProfile?.id;
|
||||
if (profileId && feedStore.cards.length === 0) {
|
||||
feedStore.fetchNextPage(profileId);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="feed-view">
|
||||
<!-- Header bar -->
|
||||
<header class="feed-header">
|
||||
<h1 class="feed-header__title">Лента</h1>
|
||||
<div class="feed-header__actions">
|
||||
<!-- View mode toggle -->
|
||||
<div class="feed-header__toggle" role="group" aria-label="Режим просмотра">
|
||||
<button
|
||||
class="feed-header__toggle-btn"
|
||||
:class="{ 'feed-header__toggle-btn--active': viewMode === 'stack' }"
|
||||
@click="viewMode = 'stack'"
|
||||
aria-label="Карточки"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16">
|
||||
<rect x="2" y="2" width="16" height="16" rx="2"/>
|
||||
<rect x="5" y="5" width="10" height="10" rx="1" stroke-dasharray="2 1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="feed-header__toggle-btn"
|
||||
:class="{ 'feed-header__toggle-btn--active': viewMode === 'scroll' }"
|
||||
@click="viewMode = 'scroll'"
|
||||
aria-label="Лента"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16">
|
||||
<path d="M2 5h16M2 10h16M2 15h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AppButton variant="secondary" size="sm" @click="filtersOpen = true">
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14">
|
||||
<path d="M3 5h14M6 10h8M9 15h2"/>
|
||||
</svg>
|
||||
Фильтры
|
||||
</AppButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Search paused banner -->
|
||||
<div v-if="feedStore.searchPaused" class="feed-paused" role="alert">
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16">
|
||||
<path d="M10 9v4m0 4h.01M8.29 3.86L1.82 17a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||
</svg>
|
||||
Поиск приостановлен: достигнут лимит совпадений
|
||||
</div>
|
||||
|
||||
<!-- Card stack mode -->
|
||||
<div v-if="viewMode === 'stack'" class="feed-view__stack">
|
||||
<FeedCardStack />
|
||||
</div>
|
||||
|
||||
<!-- Scroll mode — grid of cards -->
|
||||
<div v-else class="feed-view__scroll">
|
||||
<div class="feed-grid">
|
||||
<article
|
||||
v-for="profile in feedStore.cards"
|
||||
:key="profile.id"
|
||||
class="feed-grid__item"
|
||||
>
|
||||
<RouterLink :to="`/profile/${profile.id}`" class="feed-grid__link">
|
||||
<img
|
||||
v-if="profile.avatarUrl"
|
||||
:src="profile.avatarUrl"
|
||||
:alt="profile.name"
|
||||
class="feed-grid__img"
|
||||
/>
|
||||
<div v-else class="feed-grid__no-img" />
|
||||
<div class="feed-grid__overlay">
|
||||
<span class="feed-grid__name">{{ profile.name }}</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-if="feedStore.loading" class="feed-view__load-more">
|
||||
<span class="meta">Загрузка...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FeedFilters :open="filtersOpen" @close="filtersOpen = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.feed-view {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__stack {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
&__load-more {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.feed-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
|
||||
&__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-cream);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__toggle {
|
||||
display: flex;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
|
||||
&-btn {
|
||||
width: 36px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-muted);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
|
||||
&--active {
|
||||
background: var(--color-surface-3);
|
||||
color: var(--color-cream);
|
||||
}
|
||||
|
||||
&:hover:not(.feed-header__toggle-btn--active) {
|
||||
color: var(--color-cream);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.feed-paused {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
background: var(--color-signal-bg);
|
||||
border-bottom: 1px solid rgba(196, 92, 58, 0.3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-signal);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feed-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
|
||||
&__item {
|
||||
aspect-ratio: 3/4;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
|
||||
&__link {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover .feed-grid__overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&__no-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--color-surface-3);
|
||||
}
|
||||
|
||||
&__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(0deg, rgba(13,13,13,0.8) 0%, transparent 50%);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1rem;
|
||||
color: var(--color-cream);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
211
src/views/matches/MatchesView.vue
Normal file
211
src/views/matches/MatchesView.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { useChatStore } from '@/stores/chat.store';
|
||||
import { useUiStore } from '@/stores/ui.store';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { useRouter } from 'vue-router';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
import EmptyState from '@/components/common/EmptyState.vue';
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
|
||||
|
||||
interface Match {
|
||||
id: string;
|
||||
profileId: string;
|
||||
partnerProfile: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
cityName?: string;
|
||||
age?: number;
|
||||
};
|
||||
createdAt: string;
|
||||
hasChat: boolean;
|
||||
}
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const chatStore = useChatStore();
|
||||
const uiStore = useUiStore();
|
||||
const router = useRouter();
|
||||
|
||||
const matches = ref<Match[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
const profileId = authStore.activeProfile?.id;
|
||||
if (!profileId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.api.likesControllerGetMyMatches({ profileId }) as unknown as Match[];
|
||||
matches.value = res;
|
||||
} catch {
|
||||
uiStore.addToast('Не удалось загрузить совпадения', 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function openChat(match: Match) {
|
||||
const profileId = authStore.activeProfile?.id;
|
||||
if (!profileId) return;
|
||||
try {
|
||||
const chat = await chatStore.openChat(profileId, match.id);
|
||||
router.push(`/chats/${chat.id}`);
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })
|
||||
?.response?.data?.message ?? 'Не удалось открыть чат';
|
||||
uiStore.addToast(msg, 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="matches-view">
|
||||
<header class="matches-view__header">
|
||||
<h1 class="matches-view__title">Совпадения</h1>
|
||||
<span class="meta">{{ matches.length }} {{ matches.length === 1 ? 'человек' : 'людей' }}</span>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="matches-view__loading">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-else-if="matches.length === 0"
|
||||
title="Пока нет совпадений"
|
||||
description="Ставьте лайки, чтобы находить тех, кто ответит взаимностью"
|
||||
icon="heart"
|
||||
/>
|
||||
|
||||
<div v-else class="matches-list">
|
||||
<article
|
||||
v-for="match in matches"
|
||||
:key="match.id"
|
||||
class="match-card"
|
||||
>
|
||||
<RouterLink :to="`/profile/${match.partnerProfile.id}`" class="match-card__avatar-wrap">
|
||||
<img
|
||||
v-if="match.partnerProfile.avatarUrl"
|
||||
:src="match.partnerProfile.avatarUrl"
|
||||
:alt="match.partnerProfile.name"
|
||||
class="match-card__avatar"
|
||||
/>
|
||||
<div v-else class="match-card__avatar match-card__avatar--placeholder">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" width="28" height="28" opacity="0.3">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
<div class="match-card__info">
|
||||
<RouterLink :to="`/profile/${match.partnerProfile.id}`" class="match-card__name">
|
||||
{{ match.partnerProfile.name }}
|
||||
<span v-if="match.partnerProfile.age" class="match-card__age">, {{ match.partnerProfile.age }}</span>
|
||||
</RouterLink>
|
||||
<span v-if="match.partnerProfile.cityName" class="meta match-card__city">
|
||||
{{ match.partnerProfile.cityName }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<AppButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@click="openChat(match)"
|
||||
>
|
||||
Написать
|
||||
</AppButton>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.matches-view {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-cream);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.matches-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.match-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 24px;
|
||||
transition: background var(--transition-fast);
|
||||
|
||||
&:hover { background: rgba(240, 235, 224, 0.03); }
|
||||
|
||||
&__avatar-wrap {
|
||||
flex-shrink: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
|
||||
&--placeholder {
|
||||
background: var(--color-surface-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-cream);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:hover { color: var(--color-signal); }
|
||||
}
|
||||
|
||||
&__age { font-weight: 400; opacity: 0.6; }
|
||||
|
||||
&__city { color: var(--color-muted); }
|
||||
}
|
||||
</style>
|
||||
458
src/views/onboarding/ProfileSetupView.vue
Normal file
458
src/views/onboarding/ProfileSetupView.vue
Normal file
@@ -0,0 +1,458 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, helpers } from '@vuelidate/validators';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { useUiStore } from '@/stores/ui.store';
|
||||
import { apiClient } from '@/api/client';
|
||||
import type { CreateProfileDto } from '@/api/api';
|
||||
import type { UserProfile } from '@/stores/auth.store';
|
||||
import type { District } from '@/stores/ui.store';
|
||||
import AppInput from '@/components/common/AppInput.vue';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const uiStore = useUiStore();
|
||||
|
||||
const step = ref(1);
|
||||
const totalSteps = 4;
|
||||
const loading = ref(false);
|
||||
|
||||
const form = reactive<CreateProfileDto & { confirmStep?: number }>({
|
||||
name: '',
|
||||
birthDate: '',
|
||||
gender: 'female',
|
||||
cityId: '',
|
||||
districtId: '',
|
||||
description: '',
|
||||
nation: '',
|
||||
height: undefined,
|
||||
weight: undefined,
|
||||
tagIds: [],
|
||||
});
|
||||
|
||||
const selectedTags = ref<string[]>([]);
|
||||
const districts = ref<District[]>([]);
|
||||
const loadingDistricts = ref(false);
|
||||
|
||||
// Load districts when city changes
|
||||
watch(() => form.cityId, async (cityId) => {
|
||||
if (!cityId) { districts.value = []; return; }
|
||||
if (uiStore.districts[cityId]) { districts.value = uiStore.districts[cityId]; return; }
|
||||
loadingDistricts.value = true;
|
||||
try {
|
||||
const res = await apiClient.api.citiesControllerFindDistricts(cityId) as unknown as District[];
|
||||
uiStore.setDistricts(cityId, res);
|
||||
districts.value = res;
|
||||
} finally {
|
||||
loadingDistricts.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const step1Rules = {
|
||||
name: { required: helpers.withMessage('Введите имя', required) },
|
||||
birthDate: { required: helpers.withMessage('Введите дату рождения', required) },
|
||||
gender: { required: helpers.withMessage('Выберите пол', required) },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(step1Rules, form);
|
||||
|
||||
const progress = computed(() => ((step.value - 1) / totalSteps) * 100);
|
||||
|
||||
async function nextStep() {
|
||||
if (step.value === 1) {
|
||||
const valid = await v$.value.$validate();
|
||||
if (!valid) return;
|
||||
}
|
||||
if (step.value < totalSteps) step.value++;
|
||||
else await finish();
|
||||
}
|
||||
|
||||
function prevStep() {
|
||||
if (step.value > 1) step.value--;
|
||||
}
|
||||
|
||||
function toggleTag(tagId: string) {
|
||||
const idx = selectedTags.value.indexOf(tagId);
|
||||
if (idx === -1) selectedTags.value.push(tagId);
|
||||
else selectedTags.value.splice(idx, 1);
|
||||
}
|
||||
|
||||
async function finish() {
|
||||
loading.value = true;
|
||||
try {
|
||||
form.tagIds = selectedTags.value;
|
||||
const profile = await apiClient.api.profilesControllerCreate(form) as unknown as UserProfile;
|
||||
authStore.addProfile(profile);
|
||||
uiStore.addToast('Профиль создан', 'success');
|
||||
router.replace('/feed');
|
||||
} catch (err: unknown) {
|
||||
const message = (err as { response?: { data?: { message?: string } } })
|
||||
?.response?.data?.message ?? 'Не удалось создать профиль';
|
||||
uiStore.addToast(message, 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function skip() {
|
||||
router.replace('/feed');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="setup">
|
||||
<div class="setup__grain" aria-hidden="true" />
|
||||
|
||||
<div class="setup__card">
|
||||
<!-- Progress -->
|
||||
<div class="setup__progress">
|
||||
<div class="setup__progress-bar" :style="{ width: `${progress}%` }" />
|
||||
</div>
|
||||
|
||||
<div class="setup__header">
|
||||
<span class="meta">Шаг {{ step }} из {{ totalSteps }}</span>
|
||||
<h1 class="setup__title">
|
||||
<template v-if="step === 1">Расскажите о себе</template>
|
||||
<template v-if="step === 2">Где вы находитесь?</template>
|
||||
<template v-if="step === 3">Ваши интересы</template>
|
||||
<template v-if="step === 4">Добавьте фото</template>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Basic info -->
|
||||
<div v-if="step === 1" class="setup__step">
|
||||
<AppInput
|
||||
v-model="form.name"
|
||||
label="Имя"
|
||||
placeholder="Как вас зовут?"
|
||||
name="name"
|
||||
required
|
||||
:error="v$.name.$errors.map(e => e.$message as string)"
|
||||
@blur="v$.name.$touch()"
|
||||
/>
|
||||
<AppInput
|
||||
v-model="form.birthDate"
|
||||
label="Дата рождения"
|
||||
placeholder="1995-06-15"
|
||||
type="date"
|
||||
name="birthDate"
|
||||
required
|
||||
:error="v$.birthDate.$errors.map(e => e.$message as string)"
|
||||
@blur="v$.birthDate.$touch()"
|
||||
/>
|
||||
<div class="setup__gender">
|
||||
<span class="field__label label">Пол</span>
|
||||
<div class="setup__gender-options">
|
||||
<button
|
||||
type="button"
|
||||
class="setup__gender-btn"
|
||||
:class="{ 'setup__gender-btn--active': form.gender === 'female' }"
|
||||
@click="form.gender = 'female'"
|
||||
>Женщина</button>
|
||||
<button
|
||||
type="button"
|
||||
class="setup__gender-btn"
|
||||
:class="{ 'setup__gender-btn--active': form.gender === 'male' }"
|
||||
@click="form.gender = 'male'"
|
||||
>Мужчина</button>
|
||||
</div>
|
||||
</div>
|
||||
<AppInput
|
||||
v-model="form.description"
|
||||
label="О себе"
|
||||
placeholder="Расскажите что-нибудь..."
|
||||
name="description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Location -->
|
||||
<div v-if="step === 2" class="setup__step">
|
||||
<div class="field">
|
||||
<label class="field__label label" for="city-select">Город</label>
|
||||
<select id="city-select" v-model="form.cityId" class="field__select">
|
||||
<option value="">Выберите город</option>
|
||||
<option v-for="city in uiStore.cities" :key="city.id" :value="city.id">{{ city.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="form.cityId" class="field">
|
||||
<label class="field__label label" for="district-select">Район</label>
|
||||
<div v-if="loadingDistricts" class="setup__loading">
|
||||
<LoadingSpinner size="sm" />
|
||||
</div>
|
||||
<select v-else id="district-select" v-model="form.districtId" class="field__select">
|
||||
<option value="">Выберите район</option>
|
||||
<option v-for="d in districts" :key="d.id" :value="d.id">{{ d.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<AppInput v-model="form.nation" label="Национальность" placeholder="Необязательно" name="nation" />
|
||||
<div class="setup__row">
|
||||
<AppInput v-model.number="(form.height as unknown as string)" label="Рост (см)" placeholder="170" type="number" name="height" />
|
||||
<AppInput v-model.number="(form.weight as unknown as string)" label="Вес (кг)" placeholder="60" type="number" name="weight" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Tags/interests -->
|
||||
<div v-if="step === 3" class="setup__step">
|
||||
<p class="setup__hint">Выберите теги, которые вас описывают</p>
|
||||
<div class="setup__tags">
|
||||
<button
|
||||
v-for="tag in uiStore.tags"
|
||||
:key="tag.id"
|
||||
type="button"
|
||||
class="setup__tag"
|
||||
:class="{ 'setup__tag--active': selectedTags.includes(tag.id) }"
|
||||
@click="toggleTag(tag.id)"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="uiStore.tags.length === 0" class="setup__hint setup__hint--muted">Теги загружаются...</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Photo upload reminder -->
|
||||
<div v-if="step === 4" class="setup__step">
|
||||
<div class="setup__photo-hint">
|
||||
<svg viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="1.5" width="64" height="64">
|
||||
<rect x="4" y="12" width="56" height="40" rx="4"/>
|
||||
<circle cx="32" cy="32" r="10"/>
|
||||
<circle cx="50" cy="20" r="3"/>
|
||||
</svg>
|
||||
<p>После создания профиля вы сможете добавить фото в разделе <strong>Мой профиль</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="setup__nav">
|
||||
<AppButton
|
||||
v-if="step > 1"
|
||||
variant="ghost"
|
||||
@click="prevStep"
|
||||
>Назад</AppButton>
|
||||
<span v-else />
|
||||
<div class="setup__nav-right">
|
||||
<AppButton v-if="step < totalSteps" variant="ghost" @click="skip">Пропустить</AppButton>
|
||||
<AppButton
|
||||
variant="primary"
|
||||
size="md"
|
||||
:loading="loading"
|
||||
@click="nextStep"
|
||||
>
|
||||
{{ step === totalSteps ? 'Готово' : 'Далее' }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.setup {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-base);
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
|
||||
&__grain {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url('@/assets/grain.svg');
|
||||
background-repeat: repeat;
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
animation: fade-up var(--transition-base) both;
|
||||
}
|
||||
|
||||
&__progress {
|
||||
height: 2px;
|
||||
background: var(--color-dim);
|
||||
margin-bottom: 40px;
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
|
||||
&-bar {
|
||||
height: 100%;
|
||||
background: var(--color-signal);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width var(--transition-base);
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 2rem;
|
||||
color: var(--color-cream);
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
&__step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
min-height: 260px;
|
||||
animation: fade-up var(--transition-base) both;
|
||||
}
|
||||
|
||||
&__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 36px;
|
||||
|
||||
&-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&__gender {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
&-options {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&-btn {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
|
||||
&--active {
|
||||
background: var(--color-signal-bg);
|
||||
border-color: var(--color-signal);
|
||||
color: var(--color-cream);
|
||||
}
|
||||
|
||||
&:hover:not(.setup__gender-btn--active) {
|
||||
border-color: var(--color-border-strong);
|
||||
color: var(--color-cream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__tag {
|
||||
padding: 6px 14px;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--color-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
|
||||
&--active {
|
||||
background: var(--color-signal-bg);
|
||||
border-color: var(--color-signal);
|
||||
color: var(--color-cream);
|
||||
}
|
||||
|
||||
&:hover:not(.setup__tag--active) {
|
||||
border-color: var(--color-border-strong);
|
||||
color: var(--color-cream);
|
||||
}
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-muted);
|
||||
margin: 0;
|
||||
|
||||
&--muted { color: var(--color-dim); }
|
||||
}
|
||||
|
||||
&__loading {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__photo-hint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 20px;
|
||||
padding: 32px 0;
|
||||
color: var(--color-muted);
|
||||
|
||||
svg { opacity: 0.4; }
|
||||
|
||||
p {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-muted);
|
||||
max-width: 36ch;
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
|
||||
strong { color: var(--color-cream); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
&__label { color: var(--color-muted); }
|
||||
|
||||
&__select {
|
||||
height: 44px;
|
||||
padding: 0 14px;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-cream);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast);
|
||||
outline: none;
|
||||
appearance: none;
|
||||
|
||||
&:focus { border-color: var(--color-signal); }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
315
src/views/profile/MyProfileView.vue
Normal file
315
src/views/profile/MyProfileView.vue
Normal file
@@ -0,0 +1,315 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { useUiStore } from '@/stores/ui.store';
|
||||
import { apiClient } from '@/api/client';
|
||||
import type { UserProfile } from '@/stores/auth.store';
|
||||
import ProfileEditor from '@/components/profile/ProfileEditor.vue';
|
||||
import MediaGallery from '@/components/profile/MediaGallery.vue';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
import AppModal from '@/components/common/AppModal.vue';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const uiStore = useUiStore();
|
||||
|
||||
const editing = ref(false);
|
||||
const confirmDelete = ref(false);
|
||||
const deleting = ref(false);
|
||||
|
||||
const profile = computed(() => authStore.activeProfile);
|
||||
|
||||
function onSaved(updated: UserProfile) {
|
||||
editing.value = false;
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!profile.value) return;
|
||||
deleting.value = true;
|
||||
try {
|
||||
await apiClient.api.profilesControllerDelete(profile.value.id);
|
||||
authStore.removeProfile(profile.value.id);
|
||||
confirmDelete.value = false;
|
||||
uiStore.addToast('Профиль удалён', 'success');
|
||||
} catch {
|
||||
uiStore.addToast('Не удалось удалить профиль', 'error');
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
authStore.logout();
|
||||
}
|
||||
|
||||
function cityName(cityId?: string) {
|
||||
return uiStore.cities.find((c) => c.id === cityId)?.name ?? '';
|
||||
}
|
||||
|
||||
function tagNames(tagIds?: string[]) {
|
||||
return (tagIds ?? []).map((id) => uiStore.tags.find((t) => t.id === id)?.name ?? id);
|
||||
}
|
||||
|
||||
function calcAge(birthDate: string) {
|
||||
const b = new Date(birthDate);
|
||||
const now = new Date();
|
||||
let age = now.getFullYear() - b.getFullYear();
|
||||
if (now.getMonth() < b.getMonth() || (now.getMonth() === b.getMonth() && now.getDate() < b.getDate())) age--;
|
||||
return age;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="my-profile">
|
||||
<!-- Profile view -->
|
||||
<div v-if="!editing && profile" class="my-profile__view">
|
||||
<!-- Hero -->
|
||||
<div class="my-profile__hero">
|
||||
<div class="my-profile__avatar-wrap">
|
||||
<img
|
||||
v-if="profile.avatarUrl"
|
||||
:src="profile.avatarUrl"
|
||||
:alt="profile.name"
|
||||
class="my-profile__avatar"
|
||||
/>
|
||||
<div v-else class="my-profile__avatar my-profile__avatar--placeholder">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" width="48" height="48" opacity="0.2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-profile__hero-info">
|
||||
<h1 class="my-profile__name">{{ profile.name }}<span class="my-profile__age">, {{ calcAge(profile.birthDate) }}</span></h1>
|
||||
<span v-if="profile.cityId" class="meta my-profile__location">{{ cityName(profile.cityId) }}</span>
|
||||
</div>
|
||||
<div class="my-profile__hero-actions">
|
||||
<AppButton variant="secondary" size="sm" @click="editing = true">Редактировать</AppButton>
|
||||
<AppButton variant="ghost" size="sm" @click="logout">Выйти</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bio -->
|
||||
<div v-if="profile.description" class="my-profile__section">
|
||||
<h3 class="my-profile__section-title">О себе</h3>
|
||||
<p class="my-profile__bio">{{ profile.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="my-profile__section">
|
||||
<h3 class="my-profile__section-title">Данные</h3>
|
||||
<div class="my-profile__stats">
|
||||
<div v-if="profile.nation" class="my-profile__stat">
|
||||
<span class="meta">Национальность</span>
|
||||
<span>{{ profile.nation }}</span>
|
||||
</div>
|
||||
<div v-if="profile.height" class="my-profile__stat">
|
||||
<span class="meta">Рост</span>
|
||||
<span>{{ profile.height }} см</span>
|
||||
</div>
|
||||
<div v-if="profile.weight" class="my-profile__stat">
|
||||
<span class="meta">Вес</span>
|
||||
<span>{{ profile.weight }} кг</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div v-if="profile.tagIds?.length" class="my-profile__section">
|
||||
<h3 class="my-profile__section-title">Интересы</h3>
|
||||
<div class="my-profile__tags">
|
||||
<span v-for="name in tagNames(profile.tagIds)" :key="name" class="my-profile__tag">{{ name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media gallery -->
|
||||
<div class="my-profile__section">
|
||||
<h3 class="my-profile__section-title">Фото</h3>
|
||||
<MediaGallery :profile-id="profile.id" :editable="true" />
|
||||
</div>
|
||||
|
||||
<!-- Danger zone -->
|
||||
<div class="my-profile__section my-profile__danger">
|
||||
<AppButton variant="danger" size="sm" @click="confirmDelete = true">Удалить профиль</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No profile state -->
|
||||
<div v-else-if="!editing && !profile" class="my-profile__empty">
|
||||
<p class="meta">У вас нет профилей</p>
|
||||
<RouterLink to="/setup">
|
||||
<AppButton>Создать профиль</AppButton>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Editor -->
|
||||
<div v-else-if="editing && profile" class="my-profile__editor">
|
||||
<header class="my-profile__editor-header">
|
||||
<h2>Редактирование профиля</h2>
|
||||
</header>
|
||||
<ProfileEditor
|
||||
:profile="profile"
|
||||
@saved="onSaved"
|
||||
@cancel="editing = false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Delete confirm -->
|
||||
<AppModal
|
||||
:open="confirmDelete"
|
||||
title="Удалить профиль"
|
||||
size="sm"
|
||||
@close="confirmDelete = false"
|
||||
>
|
||||
<p style="font-family: var(--font-mono); font-size: 0.875rem; color: var(--color-muted); margin: 0">
|
||||
Профиль будет удалён навсегда. Это действие нельзя отменить.
|
||||
</p>
|
||||
<template #footer>
|
||||
<AppButton variant="ghost" @click="confirmDelete = false">Отмена</AppButton>
|
||||
<AppButton variant="danger" :loading="deleting" @click="doDelete">Удалить</AppButton>
|
||||
</template>
|
||||
</AppModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.my-profile {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
&__view,
|
||||
&__editor {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&__hero {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__avatar-wrap { flex-shrink: 0; }
|
||||
|
||||
&__avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
|
||||
&--placeholder {
|
||||
background: var(--color-surface-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__hero-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.75rem;
|
||||
color: var(--color-cream);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
&__age {
|
||||
font-size: 1.25rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&__location { color: var(--color-muted); }
|
||||
|
||||
&__hero-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__section {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-muted);
|
||||
margin: 0 0 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
&__bio {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-cream);
|
||||
line-height: 1.65;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__stats {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
|
||||
span:last-child {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-cream);
|
||||
}
|
||||
}
|
||||
|
||||
&__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__tag {
|
||||
padding: 4px 12px;
|
||||
background: rgba(240, 235, 224, 0.06);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-muted);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
&__danger { padding-top: 16px; border-top: 1px solid rgba(196, 92, 58, 0.2); }
|
||||
|
||||
&__editor-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-cream);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
311
src/views/profile/ProfileDetailView.vue
Normal file
311
src/views/profile/ProfileDetailView.vue
Normal file
@@ -0,0 +1,311 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { useUiStore } from '@/stores/ui.store';
|
||||
import { useChatStore } from '@/stores/chat.store';
|
||||
import { apiClient } from '@/api/client';
|
||||
import type { UserProfile } from '@/stores/auth.store';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
import AppModal from '@/components/common/AppModal.vue';
|
||||
import ReportModal from '@/components/reports/ReportModal.vue';
|
||||
import DateProposalForm from '@/components/dates/DateProposalForm.vue';
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
const uiStore = useUiStore();
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const profileId = route.params.profileId as string;
|
||||
const profile = ref<UserProfile | null>(null);
|
||||
const loading = ref(false);
|
||||
const reportOpen = ref(false);
|
||||
const dateOpen = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.api.profilesControllerFindOne(profileId) as unknown as UserProfile;
|
||||
profile.value = res;
|
||||
} catch {
|
||||
uiStore.addToast('Не удалось загрузить профиль', 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const age = computed(() => {
|
||||
if (!profile.value?.birthDate) return null;
|
||||
const b = new Date(profile.value.birthDate);
|
||||
const now = new Date();
|
||||
let a = now.getFullYear() - b.getFullYear();
|
||||
if (now.getMonth() < b.getMonth() || (now.getMonth() === b.getMonth() && now.getDate() < b.getDate())) a--;
|
||||
return a;
|
||||
});
|
||||
|
||||
const cityName = computed(() => uiStore.cities.find((c) => c.id === profile.value?.cityId)?.name ?? '');
|
||||
const tagNames = computed(() => (profile.value?.tagIds ?? []).map((id) => uiStore.tags.find((t) => t.id === id)?.name ?? id));
|
||||
const currentImageIndex = ref(0);
|
||||
|
||||
const isOwnProfile = computed(() =>
|
||||
authStore.profiles.some((p) => p.id === profileId),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="profile-detail">
|
||||
<div v-if="loading" class="profile-detail__loading">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
|
||||
<template v-else-if="profile">
|
||||
<!-- Image strip -->
|
||||
<div class="profile-detail__cover">
|
||||
<img
|
||||
v-if="profile.mediaUrls?.[currentImageIndex]"
|
||||
:src="profile.mediaUrls[currentImageIndex]"
|
||||
:alt="profile.name"
|
||||
class="profile-detail__cover-img"
|
||||
/>
|
||||
<div v-else class="profile-detail__cover-placeholder" />
|
||||
|
||||
<!-- Navigation -->
|
||||
<button
|
||||
v-if="currentImageIndex > 0"
|
||||
class="profile-detail__img-nav profile-detail__img-nav--prev"
|
||||
@click="currentImageIndex--"
|
||||
aria-label="Предыдущее фото"
|
||||
>‹</button>
|
||||
<button
|
||||
v-if="currentImageIndex < (profile.mediaUrls?.length ?? 1) - 1"
|
||||
class="profile-detail__img-nav profile-detail__img-nav--next"
|
||||
@click="currentImageIndex++"
|
||||
aria-label="Следующее фото"
|
||||
>›</button>
|
||||
|
||||
<!-- Back button -->
|
||||
<button class="profile-detail__back" @click="history.back()" aria-label="Назад">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
|
||||
<path d="M19 12H5M12 5l-7 7 7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="profile-detail__content">
|
||||
<div class="profile-detail__header">
|
||||
<div>
|
||||
<h1 class="profile-detail__name">
|
||||
{{ profile.name }}
|
||||
<span v-if="age" class="profile-detail__age">, {{ age }}</span>
|
||||
</h1>
|
||||
<span v-if="cityName" class="meta profile-detail__location">{{ cityName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions (non-own) -->
|
||||
<div v-if="!isOwnProfile" class="profile-detail__actions">
|
||||
<AppButton size="sm" variant="primary" @click="dateOpen = true">Встреча</AppButton>
|
||||
<AppButton size="sm" variant="ghost" @click="reportOpen = true">Пожаловаться</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p v-if="profile.description" class="profile-detail__bio">{{ profile.description }}</p>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="profile-detail__stats">
|
||||
<div v-if="profile.nation" class="profile-detail__stat">
|
||||
<span class="meta">Национальность</span>
|
||||
<span>{{ profile.nation }}</span>
|
||||
</div>
|
||||
<div v-if="profile.height" class="profile-detail__stat">
|
||||
<span class="meta">Рост</span>
|
||||
<span>{{ profile.height }} см</span>
|
||||
</div>
|
||||
<div v-if="profile.weight" class="profile-detail__stat">
|
||||
<span class="meta">Вес</span>
|
||||
<span>{{ profile.weight }} кг</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div v-if="tagNames.length" class="profile-detail__tags">
|
||||
<span v-for="name in tagNames" :key="name" class="profile-detail__tag">{{ name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Report modal -->
|
||||
<ReportModal
|
||||
v-if="profile"
|
||||
:open="reportOpen"
|
||||
entity-type="profile"
|
||||
:entity-id="profileId"
|
||||
@close="reportOpen = false"
|
||||
/>
|
||||
|
||||
<!-- Date proposal -->
|
||||
<AppModal :open="dateOpen" title="Предложить встречу" size="md" @close="dateOpen = false">
|
||||
<DateProposalForm
|
||||
v-if="profile"
|
||||
:partner-profile-id="profile.id"
|
||||
@close="dateOpen = false"
|
||||
@created="dateOpen = false"
|
||||
/>
|
||||
</AppModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.profile-detail {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
&__loading {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__cover {
|
||||
position: relative;
|
||||
height: 60dvh;
|
||||
background: var(--color-surface-2);
|
||||
|
||||
@include mobile { height: 50dvh; }
|
||||
}
|
||||
|
||||
&__cover-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&__cover-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--color-surface-3);
|
||||
}
|
||||
|
||||
&__img-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: rgba(13, 13, 13, 0.6);
|
||||
border: none;
|
||||
color: var(--color-cream);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background var(--transition-fast);
|
||||
backdrop-filter: blur(8px);
|
||||
|
||||
&:hover { background: rgba(13, 13, 13, 0.85); }
|
||||
|
||||
&--prev { left: 12px; }
|
||||
&--next { right: 12px; }
|
||||
}
|
||||
|
||||
&__back {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(13, 13, 13, 0.6);
|
||||
border: none;
|
||||
color: var(--color-cream);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(8px);
|
||||
transition: background var(--transition-fast);
|
||||
|
||||
&:hover { background: rgba(13, 13, 13, 0.85); }
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding: 24px;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-family: var(--font-display);
|
||||
font-size: 2rem;
|
||||
color: var(--color-cream);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
&__age { opacity: 0.6; font-size: 1.5rem; }
|
||||
|
||||
&__location { color: var(--color-muted); }
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__bio {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875rem;
|
||||
color: rgba(240, 235, 224, 0.8);
|
||||
line-height: 1.65;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
&__stats {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
|
||||
span:last-child {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-cream);
|
||||
}
|
||||
}
|
||||
|
||||
&__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__tag {
|
||||
padding: 4px 12px;
|
||||
background: rgba(240, 235, 224, 0.06);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user