eslint --fix

This commit is contained in:
Oscar
2026-06-08 15:09:53 +03:00
parent 10d696f4ca
commit b98387ea58
64 changed files with 6070 additions and 2467 deletions

View File

@@ -1,56 +1,9 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useUi } from '@/composables/useUi';
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 = useUi();
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>
<h1 class="reports-admin__title">
Жалобы
</h1>
<span class="meta">{{ reports.length }} всего</span>
</header>
@@ -78,7 +31,9 @@ function formatDate(iso: string) {
</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 class="reports-table__date meta">
{{ formatDate(report.createdAt) }}
</td>
<td>
<span class="reports-table__type" :class="`reports-table__type--${report.entityType}`">
{{ report.entityType === 'profile' ? 'Профиль' : 'Сообщение' }}
@@ -89,17 +44,23 @@ function formatDate(iso: string) {
v-if="report.entityType === 'profile'"
:to="`/profile/${report.entityId}`"
class="reports-table__link"
>Открыть</RouterLink>
>
Открыть
</RouterLink>
<span v-else class="meta">{{ report.entityId.slice(0, 8) }}</span>
</td>
<td class="reports-table__desc">{{ report.description ?? '—' }}</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>
>
Заблокировать
</AppButton>
</td>
</tr>
</tbody>
@@ -108,6 +69,58 @@ function formatDate(iso: string) {
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { apiClient } from '@/api/client'
import AppButton from '@/components/common/AppButton.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import { useUi } from '@/composables/useUi'
interface Report {
id: string
sourceProfileId: string
entityId: string
entityType: 'profile' | 'message'
description?: string
createdAt: string
resolved?: boolean
reporterName?: string
}
const uiStore = useUi()
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>
<style scoped lang="scss">
.reports-admin {
height: 100%;
@@ -168,11 +181,18 @@ function formatDate(iso: string) {
vertical-align: middle;
}
tr:last-child td { border-bottom: none; }
tr:last-child td {
border-bottom: none;
}
&__row--resolved td { opacity: 0.5; }
&__row--resolved td {
opacity: 0.5;
}
&__date { color: var(--color-muted) !important; font-variant-numeric: tabular-nums; }
&__date {
color: var(--color-muted) !important;
font-variant-numeric: tabular-nums;
}
&__type {
padding: 2px 8px;
@@ -182,8 +202,14 @@ function formatDate(iso: string) {
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); }
&--profile {
background: var(--color-signal-bg);
color: var(--color-signal);
}
&--message {
background: rgba(240, 235, 224, 0.06);
color: var(--color-muted);
}
}
&__link {
@@ -192,7 +218,9 @@ function formatDate(iso: string) {
text-underline-offset: 3px;
text-decoration-color: var(--color-border);
transition: color var(--transition-fast);
&:hover { color: var(--color-signal); }
&:hover {
color: var(--color-signal);
}
}
&__desc {

View File

@@ -1,57 +1,19 @@
<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 { useAuth } from '@/composables/useAuth';
import { useUi } from '@/composables/useUi';
import AppInput from '@/components/common/AppInput.vue';
import AppButton from '@/components/common/AppButton.vue';
const router = useRouter();
const route = useRoute();
const authStore = useAuth();
const uiStore = useUi();
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">Dating</div>
<h1 class="auth-card__heading">С возвращением</h1>
<p class="auth-card__sub">Войдите, чтобы продолжить</p>
<div class="auth-card__wordmark">
Dating
</div>
<h1 class="auth-card__heading">
С возвращением
</h1>
<p class="auth-card__sub">
Войдите, чтобы продолжить
</p>
<form class="auth-form" @submit.prevent="submit" novalidate>
<form class="auth-form" novalidate @submit.prevent="submit">
<AppInput
v-model="form.phone"
label="Телефон"
@@ -81,12 +43,63 @@ async function submit() {
<p class="auth-card__footer">
Нет аккаунта?
<RouterLink to="/register" class="auth-card__link">Зарегистрироваться</RouterLink>
<RouterLink to="/register" class="auth-card__link">
Зарегистрироваться
</RouterLink>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { useVuelidate } from '@vuelidate/core'
import { helpers, required } from '@vuelidate/validators'
import { reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppButton from '@/components/common/AppButton.vue'
import AppInput from '@/components/common/AppInput.vue'
import { useAuth } from '@/composables/useAuth'
import { useUi } from '@/composables/useUi'
const router = useRouter()
const route = useRoute()
const authStore = useAuth()
const uiStore = useUi()
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>
<style scoped lang="scss">
.auth-page {
min-height: 100dvh;
@@ -158,7 +171,9 @@ async function submit() {
color: var(--color-cream);
transition: color var(--transition-fast);
&:hover { color: var(--color-signal); }
&:hover {
color: var(--color-signal);
}
}
}

View File

@@ -1,67 +1,19 @@
<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 { useAuth } from '@/composables/useAuth';
import { useUi } from '@/composables/useUi';
import AppInput from '@/components/common/AppInput.vue';
import AppButton from '@/components/common/AppButton.vue';
const router = useRouter();
const authStore = useAuth();
const uiStore = useUi();
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">Dating</div>
<h1 class="auth-card__heading">Создать аккаунт</h1>
<p class="auth-card__sub">Начните своё путешествие</p>
<div class="auth-card__wordmark">
Dating
</div>
<h1 class="auth-card__heading">
Создать аккаунт
</h1>
<p class="auth-card__sub">
Начните своё путешествие
</p>
<form class="auth-form" @submit.prevent="submit" novalidate>
<form class="auth-form" novalidate @submit.prevent="submit">
<AppInput
v-model="form.phone"
label="Телефон"
@@ -102,12 +54,73 @@ async function submit() {
<p class="auth-card__footer">
Уже есть аккаунт?
<RouterLink to="/login" class="auth-card__link">Войти</RouterLink>
<RouterLink to="/login" class="auth-card__link">
Войти
</RouterLink>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { useVuelidate } from '@vuelidate/core'
import { helpers, minLength, required } from '@vuelidate/validators'
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import AppButton from '@/components/common/AppButton.vue'
import AppInput from '@/components/common/AppInput.vue'
import { useAuth } from '@/composables/useAuth'
import { useUi } from '@/composables/useUi'
const router = useRouter()
const authStore = useAuth()
const uiStore = useUi()
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>
<style scoped lang="scss">
.auth-page {
min-height: 100dvh;
@@ -177,7 +190,9 @@ async function submit() {
&__link {
color: var(--color-cream);
transition: color var(--transition-fast);
&:hover { color: var(--color-signal); }
&:hover {
color: var(--color-signal);
}
}
}
@@ -186,6 +201,8 @@ async function submit() {
flex-direction: column;
gap: 20px;
&__submit { margin-top: 8px; }
&__submit {
margin-top: 8px;
}
}
</style>

View File

@@ -1,92 +1,10 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick, computed } from 'vue';
import { useRoute } from 'vue-router';
import { useAuth } from '@/composables/useAuth';
import { useChat } from '@/composables/useChat';
import { useUi } from '@/composables/useUi';
import { apiClient } from '@/api/client';
import type { ChatMessage } from '@/composables/useChat';
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 = useAuth();
const chatStore = useChat();
const uiStore = useUi();
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?.status === 'closed');
// 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');
}
}
function goBack() { window.history.back(); }
async function doCloseChat() {
if (!profileId.value) return;
try {
await chatStore.closeChat(chatId, profileId.value);
confirmClose.value = false;
goBack();
} catch {
uiStore.addToast('Не удалось закрыть чат', 'error');
}
}
</script>
<template>
<div class="chat-room">
<!-- Header -->
<header class="chat-room__header">
<button class="chat-room__back" @click="goBack()" aria-label="Назад">
<button class="chat-room__back" aria-label="Назад" @click="goBack()">
<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"/>
<path d="M19 12H5M12 5l-7 7 7 7" />
</svg>
</button>
<div class="chat-room__partner">
@@ -95,17 +13,17 @@ async function doCloseChat() {
: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="Закрыть чат"
@click="confirmClose = true"
>
<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"/>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</header>
@@ -113,8 +31,8 @@ async function doCloseChat() {
<!-- 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"/>
<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>
@@ -157,13 +75,103 @@ async function doCloseChat() {
Вы уверены? Переписка будет удалена и восстановить её нельзя.
</p>
<template #footer>
<AppButton variant="ghost" @click="confirmClose = false">Отмена</AppButton>
<AppButton variant="danger" @click="doCloseChat">Закрыть чат</AppButton>
<AppButton variant="ghost" @click="confirmClose = false">
Отмена
</AppButton>
<AppButton variant="danger" @click="doCloseChat">
Закрыть чат
</AppButton>
</template>
</AppModal>
</div>
</template>
<script setup lang="ts">
import type { ChatMessage } from '@/composables/useChat'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import ChatBubble from '@/components/chat/ChatBubble.vue'
import ChatInput from '@/components/chat/ChatInput.vue'
import AppButton from '@/components/common/AppButton.vue'
import AppModal from '@/components/common/AppModal.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import { useAuth } from '@/composables/useAuth'
import { useChat } from '@/composables/useChat'
import { useUi } from '@/composables/useUi'
const route = useRoute()
const authStore = useAuth()
const chatStore = useChat()
const uiStore = useUi()
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?.status === 'closed')
// 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')
}
}
function goBack() { window.history.back() }
async function doCloseChat() {
if (!profileId.value)
return
try {
await chatStore.closeChat(chatId, profileId.value)
confirmClose.value = false
goBack()
}
catch {
uiStore.addToast('Не удалось закрыть чат', 'error')
}
}
</script>
<style scoped lang="scss">
.chat-room {
height: 100%;
@@ -189,7 +197,9 @@ async function doCloseChat() {
border-radius: var(--radius-sm);
transition: color var(--transition-fast);
flex-shrink: 0;
&:hover { color: var(--color-cream); }
&:hover {
color: var(--color-cream);
}
}
&__partner {
@@ -228,7 +238,9 @@ async function doCloseChat() {
display: flex;
transition: color var(--transition-fast);
flex-shrink: 0;
&:hover { color: var(--color-signal); }
&:hover {
color: var(--color-signal);
}
}
&__locked {
@@ -263,7 +275,9 @@ async function doCloseChat() {
}
}
&__group { margin-bottom: 16px; }
&__group {
margin-bottom: 16px;
}
&__date-sep {
display: flex;

View File

@@ -1,42 +1,9 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useAuth } from '@/composables/useAuth';
import { useChat } from '@/composables/useChat';
import { useUi } from '@/composables/useUi';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
import EmptyState from '@/components/common/EmptyState.vue';
const authStore = useAuth();
const chatStore = useChat();
const uiStore = useUi();
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>
<h1 class="chats-list__title">
Чаты
</h1>
</header>
<div v-if="loading" class="chats-list__loading">
@@ -63,17 +30,17 @@ function formatTime(dateStr: string) {
: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"/>
<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.status === 'closed'" 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"/>
<rect x="3" y="7" width="10" height="8" rx="1" />
<path d="M5 7V5a3 3 0 0 1 6 0v2" />
</svg>
</div>
</div>
@@ -88,16 +55,59 @@ function formatTime(dateStr: string) {
<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>
<p v-else class="chat-item__preview chat-item__preview--empty">
Начните переписку
</p>
</div>
<div v-if="(chat.unreadCount ?? 0) > 0" class="chat-item__badge">{{ chat.unreadCount }}</div>
<div v-if="(chat.unreadCount ?? 0) > 0" class="chat-item__badge">
{{ chat.unreadCount }}
</div>
</RouterLink>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import EmptyState from '@/components/common/EmptyState.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import { useAuth } from '@/composables/useAuth'
import { useChat } from '@/composables/useChat'
import { useUi } from '@/composables/useUi'
const authStore = useAuth()
const chatStore = useChat()
const uiStore = useUi()
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>
<style scoped lang="scss">
.chats-list {
height: 100%;
@@ -141,7 +151,9 @@ function formatTime(dateStr: string) {
text-decoration: none;
transition: background var(--transition-fast);
&:hover { background: rgba(240, 235, 224, 0.03); }
&:hover {
background: rgba(240, 235, 224, 0.03);
}
&--inactive {
opacity: 0.6;
@@ -218,7 +230,9 @@ function formatTime(dateStr: string) {
text-overflow: ellipsis;
margin: 0;
&--empty { font-style: italic; }
&--empty {
font-style: italic;
}
}
&__badge {

View File

@@ -1,100 +1,9 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useAuth } from '@/composables/useAuth';
import { useUi } from '@/composables/useUi';
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 = useAuth();
const uiStore = useUi();
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>
<h1 class="dates-view__title">
Встречи
</h1>
</header>
<div v-if="loading" class="dates-view__loading">
@@ -112,7 +21,9 @@ const completedStatusId = computed(() => statuses.value[3]?.id ?? '');
<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>
<h3 class="date-card__partner">
{{ date.partnerName ?? 'Партнёр' }}
</h3>
<time class="date-card__time meta" :datetime="date.time">{{ formatDateTime(date.time) }}</time>
</div>
<span
@@ -123,8 +34,8 @@ const completedStatusId = computed(() => statuses.value[3]?.id ?? '');
<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"/>
<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>
@@ -136,13 +47,17 @@ const completedStatusId = computed(() => statuses.value[3]?.id ?? '');
size="sm"
:loading="actionLoading === `${date.id}-${acceptedStatusId}`"
@click="updateStatus(date.id, acceptedStatusId)"
>Принять</AppButton>
>
Принять
</AppButton>
<AppButton
variant="ghost"
size="sm"
:loading="actionLoading === `${date.id}-${declinedStatusId}`"
@click="updateStatus(date.id, declinedStatusId)"
>Отклонить</AppButton>
>
Отклонить
</AppButton>
</div>
<!-- Mark as complete -->
@@ -152,13 +67,121 @@ const completedStatusId = computed(() => statuses.value[3]?.id ?? '');
size="sm"
:loading="actionLoading === `${date.id}-${completedStatusId}`"
@click="updateStatus(date.id, completedStatusId)"
>Встреча состоялась</AppButton>
>
Встреча состоялась
</AppButton>
</div>
</article>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { apiClient } from '@/api/client'
import AppButton from '@/components/common/AppButton.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import { useAuth } from '@/composables/useAuth'
import { useUi } from '@/composables/useUi'
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 = useAuth()
const uiStore = useUi()
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>
<style scoped lang="scss">
.dates-view {
height: 100%;
@@ -205,7 +228,9 @@ const completedStatusId = computed(() => statuses.value[3]?.id ?? '');
gap: 12px;
transition: border-color var(--transition-fast);
&:hover { border-color: var(--color-border-strong); }
&:hover {
border-color: var(--color-border-strong);
}
&__header {
display: flex;
@@ -222,7 +247,9 @@ const completedStatusId = computed(() => statuses.value[3]?.id ?? '');
margin: 0 0 4px;
}
&__time { color: var(--color-muted); }
&__time {
color: var(--color-muted);
}
&__status {
padding: 3px 10px;
@@ -234,11 +261,26 @@ const completedStatusId = computed(() => statuses.value[3]?.id ?? '');
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); }
&--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 {

View File

@@ -1,59 +1,39 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useFeed } from '@/composables/useFeed';
import { useAuth } from '@/composables/useAuth';
import FeedCardStack from '@/components/feed/FeedCardStack.vue';
import FeedFilters from '@/components/feed/FeedFilters.vue';
import AppButton from '@/components/common/AppButton.vue';
const feedStore = useFeed();
const authStore = useAuth();
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>
<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="Карточки"
@click="viewMode = 'stack'"
>
<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"/>
<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="Лента"
@click="viewMode = 'scroll'"
>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16">
<path d="M2 5h16M2 10h16M2 15h16"/>
<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"/>
<path d="M3 5h14M6 10h8M9 15h2" />
</svg>
Фильтры
</AppButton>
@@ -63,7 +43,7 @@ onMounted(() => {
<!-- 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"/>
<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>
@@ -87,7 +67,7 @@ onMounted(() => {
:src="profile.media[0].path"
: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>
@@ -105,6 +85,28 @@ onMounted(() => {
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import AppButton from '@/components/common/AppButton.vue'
import FeedCardStack from '@/components/feed/FeedCardStack.vue'
import FeedFilters from '@/components/feed/FeedFilters.vue'
import { useAuth } from '@/composables/useAuth'
import { useFeed } from '@/composables/useFeed'
const feedStore = useFeed()
const authStore = useAuth()
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>
<style scoped lang="scss">
.feed-view {
height: 100%;
@@ -234,7 +236,7 @@ onMounted(() => {
&__overlay {
position: absolute;
inset: 0;
background: linear-gradient(0deg, rgba(13,13,13,0.8) 0%, transparent 50%);
background: linear-gradient(0deg, rgba(13, 13, 13, 0.8) 0%, transparent 50%);
display: flex;
align-items: flex-end;
padding: 12px;

View File

@@ -1,68 +1,9 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useAuth } from '@/composables/useAuth';
import { useChat } from '@/composables/useChat';
import { useUi } from '@/composables/useUi';
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 = useAuth();
const chatStore = useChat();
const uiStore = useUi();
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>
<h1 class="matches-view__title">
Совпадения
</h1>
<span class="meta">{{ matches.length }} {{ matches.length === 1 ? 'человек' : 'людей' }}</span>
</header>
@@ -89,10 +30,10 @@ async function openChat(match: Match) {
: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"/>
<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>
@@ -119,6 +60,74 @@ async function openChat(match: Match) {
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { apiClient } from '@/api/client'
import AppButton from '@/components/common/AppButton.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import { useAuth } from '@/composables/useAuth'
import { useChat } from '@/composables/useChat'
import { useUi } from '@/composables/useUi'
interface Match {
id: string
profileId: string
partnerProfile: {
id: string
name: string
avatarUrl?: string
cityName?: string
age?: number
}
createdAt: string
hasChat: boolean
}
const authStore = useAuth()
const chatStore = useChat()
const uiStore = useUi()
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>
<style scoped lang="scss">
.matches-view {
height: 100%;
@@ -162,7 +171,9 @@ async function openChat(match: Match) {
padding: 12px 24px;
transition: background var(--transition-fast);
&:hover { background: rgba(240, 235, 224, 0.03); }
&:hover {
background: rgba(240, 235, 224, 0.03);
}
&__avatar-wrap {
flex-shrink: 0;
@@ -201,11 +212,18 @@ async function openChat(match: Match) {
overflow: hidden;
text-overflow: ellipsis;
&:hover { color: var(--color-signal); }
&:hover {
color: var(--color-signal);
}
}
&__age { font-weight: 400; opacity: 0.6; }
&__age {
font-weight: 400;
opacity: 0.6;
}
&__city { color: var(--color-muted); }
&__city {
color: var(--color-muted);
}
}
</style>

View File

@@ -1,108 +1,3 @@
<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 { useAuth } from '@/composables/useAuth';
import { useUi } from '@/composables/useUi';
import { apiClient } from '@/api/client';
import type { CreateProfileDto } from '@/api/api';
import type { UserProfile } from '@/composables/useAuth';
import type { District } from '@/composables/useUi';
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 = useAuth();
const uiStore = useUi();
const step = ref(1);
const totalSteps = 4;
const loading = ref(false);
const form = reactive({
name: '',
birthDate: '',
gender: 'female' as 'female' | 'male',
cityId: '',
districtId: '',
description: '',
nation: '',
height: undefined as number | undefined | null,
weight: undefined as number | undefined | null,
tagIds: [] as string[],
});
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 CreateProfileDto) 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" />
@@ -116,10 +11,18 @@ function skip() {
<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>
<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>
@@ -152,13 +55,17 @@ function skip() {
class="setup__gender-btn"
:class="{ 'setup__gender-btn--active': form.gender === 'female' }"
@click="form.gender = 'female'"
>Женщина</button>
>
Женщина
</button>
<button
type="button"
class="setup__gender-btn"
:class="{ 'setup__gender-btn--active': form.gender === 'male' }"
@click="form.gender = 'male'"
>Мужчина</button>
>
Мужчина
</button>
</div>
</div>
<AppInput
@@ -174,8 +81,12 @@ function skip() {
<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>
<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">
@@ -184,20 +95,26 @@ function skip() {
<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>
<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" />
<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>
<p class="setup__hint">
Выберите теги, которые вас описывают
</p>
<div class="setup__tags">
<button
v-for="tag in uiStore.tags"
@@ -210,16 +127,18 @@ function skip() {
{{ tag.value }}
</button>
</div>
<p v-if="uiStore.tags.length === 0" class="setup__hint setup__hint--muted">Теги загружаются...</p>
<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"/>
<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>
@@ -231,10 +150,14 @@ function skip() {
v-if="step > 1"
variant="ghost"
@click="prevStep"
>Назад</AppButton>
>
Назад
</AppButton>
<span v-else />
<div class="setup__nav-right">
<AppButton v-if="step < totalSteps" variant="ghost" @click="skip">Пропустить</AppButton>
<AppButton v-if="step < totalSteps" variant="ghost" @click="skip">
Пропустить
</AppButton>
<AppButton
variant="primary"
size="md"
@@ -249,6 +172,120 @@ function skip() {
</div>
</template>
<script setup lang="ts">
import type { CreateProfileDto } from '@/api/api'
import type { UserProfile } from '@/composables/useAuth'
import type { District } from '@/composables/useUi'
import { useVuelidate } from '@vuelidate/core'
import { helpers, required } from '@vuelidate/validators'
import { computed, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { apiClient } from '@/api/client'
import AppButton from '@/components/common/AppButton.vue'
import AppInput from '@/components/common/AppInput.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import { useAuth } from '@/composables/useAuth'
import { useUi } from '@/composables/useUi'
const router = useRouter()
const authStore = useAuth()
const uiStore = useUi()
const step = ref(1)
const totalSteps = 4
const loading = ref(false)
const form = reactive({
name: '',
birthDate: '',
gender: 'female' as 'female' | 'male',
cityId: '',
districtId: '',
description: '',
nation: '',
height: undefined as number | undefined | null,
weight: undefined as number | undefined | null,
tagIds: [] as string[],
})
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 CreateProfileDto) 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>
<style scoped lang="scss">
.setup {
min-height: 100dvh;
@@ -398,7 +435,9 @@ function skip() {
color: var(--color-muted);
margin: 0;
&--muted { color: var(--color-dim); }
&--muted {
color: var(--color-dim);
}
}
&__loading {
@@ -416,7 +455,9 @@ function skip() {
padding: 32px 0;
color: var(--color-muted);
svg { opacity: 0.4; }
svg {
opacity: 0.4;
}
p {
font-family: var(--font-mono);
@@ -426,7 +467,9 @@ function skip() {
margin: 0;
line-height: 1.6;
strong { color: var(--color-cream); }
strong {
color: var(--color-cream);
}
}
}
}
@@ -436,7 +479,9 @@ function skip() {
flex-direction: column;
gap: 6px;
&__label { color: var(--color-muted); }
&__label {
color: var(--color-muted);
}
&__select {
height: 44px;
@@ -452,7 +497,9 @@ function skip() {
outline: none;
appearance: none;
&:focus { border-color: var(--color-signal); }
&:focus {
border-color: var(--color-signal);
}
}
}
</style>

View File

@@ -1,63 +1,3 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useAuth } from '@/composables/useAuth';
import { useUi } from '@/composables/useUi';
import { apiClient } from '@/api/client';
import type { UserProfile } from '@/composables/useAuth';
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 = useAuth();
const uiStore = useUi();
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 | null) {
return uiStore.cities.find((c) => c.id === cityId)?.name ?? '';
}
function tagValues(tags?: Array<{ id: string; value: string }>) {
return (tags ?? []).map((t) => t.value);
}
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 -->
@@ -70,32 +10,44 @@ function calcAge(birthDate: string) {
:src="profile.media[0].path"
: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"/>
<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>
<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>
<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>
<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>
<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>
@@ -114,7 +66,9 @@ function calcAge(birthDate: string) {
<!-- Tags -->
<div v-if="profile.tags?.length" class="my-profile__section">
<h3 class="my-profile__section-title">Интересы</h3>
<h3 class="my-profile__section-title">
Интересы
</h3>
<div class="my-profile__tags">
<span v-for="name in tagValues(profile.tags)" :key="name" class="my-profile__tag">{{ name }}</span>
</div>
@@ -122,19 +76,25 @@ function calcAge(birthDate: string) {
<!-- Media gallery -->
<div class="my-profile__section">
<h3 class="my-profile__section-title">Фото</h3>
<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>
<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>
<p class="meta">
У вас нет профилей
</p>
<RouterLink to="/setup">
<AppButton>Создать профиль</AppButton>
</RouterLink>
@@ -163,13 +123,81 @@ function calcAge(birthDate: string) {
Профиль будет удалён навсегда. Это действие нельзя отменить.
</p>
<template #footer>
<AppButton variant="ghost" @click="confirmDelete = false">Отмена</AppButton>
<AppButton variant="danger" :loading="deleting" @click="doDelete">Удалить</AppButton>
<AppButton variant="ghost" @click="confirmDelete = false">
Отмена
</AppButton>
<AppButton variant="danger" :loading="deleting" @click="doDelete">
Удалить
</AppButton>
</template>
</AppModal>
</div>
</template>
<script setup lang="ts">
import type { UserProfile } from '@/composables/useAuth'
import { computed, ref } from 'vue'
import { apiClient } from '@/api/client'
import AppButton from '@/components/common/AppButton.vue'
import AppModal from '@/components/common/AppModal.vue'
import MediaGallery from '@/components/profile/MediaGallery.vue'
import ProfileEditor from '@/components/profile/ProfileEditor.vue'
import { useAuth } from '@/composables/useAuth'
import { useUi } from '@/composables/useUi'
const authStore = useAuth()
const uiStore = useUi()
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 | null) {
return uiStore.cities.find(c => c.id === cityId)?.name ?? ''
}
function tagValues(tags?: Array<{ id: string, value: string }>) {
return (tags ?? []).map(t => t.value)
}
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>
<style scoped lang="scss">
.my-profile {
height: 100%;
@@ -199,7 +227,9 @@ function calcAge(birthDate: string) {
flex-wrap: wrap;
}
&__avatar-wrap { flex-shrink: 0; }
&__avatar-wrap {
flex-shrink: 0;
}
&__avatar {
width: 80px;
@@ -232,7 +262,9 @@ function calcAge(birthDate: string) {
opacity: 0.6;
}
&__location { color: var(--color-muted); }
&__location {
color: var(--color-muted);
}
&__hero-actions {
display: flex;
@@ -299,7 +331,10 @@ function calcAge(birthDate: string) {
letter-spacing: 0.03em;
}
&__danger { padding-top: 16px; border-top: 1px solid rgba(196, 92, 58, 0.2); }
&__danger {
padding-top: 16px;
border-top: 1px solid rgba(196, 92, 58, 0.2);
}
&__editor-header {
margin-bottom: 24px;

View File

@@ -1,63 +1,3 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useAuth } from '@/composables/useAuth';
import { useUi } from '@/composables/useUi';
import { useChat } from '@/composables/useChat';
import { apiClient } from '@/api/client';
import type { UserProfile } from '@/composables/useAuth';
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 = useAuth();
const uiStore = useUi();
const chatStore = useChat();
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(() => {
const cid = profile.value?.cityId;
return cid ? uiStore.cities.find((c) => c.id === cid)?.name ?? '' : '';
});
const tagNames = computed(() => profile.value?.tags?.map((t) => t.value) ?? []);
const currentImageIndex = ref(0);
const isOwnProfile = computed(() =>
authStore.profiles.some((p) => p.id === profileId),
);
function goBack() { window.history.back(); }
</script>
<template>
<div class="profile-detail">
<div v-if="loading" class="profile-detail__loading">
@@ -72,27 +12,31 @@ function goBack() { window.history.back(); }
:src="profile.media[currentImageIndex].path"
: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>
@click="currentImageIndex--"
>
</button>
<button
v-if="currentImageIndex < (profile.media?.length ?? 1) - 1"
class="profile-detail__img-nav profile-detail__img-nav--next"
@click="currentImageIndex++"
aria-label="Следующее фото"
></button>
@click="currentImageIndex++"
>
</button>
<!-- Back button -->
<button class="profile-detail__back" @click="goBack()" aria-label="Назад">
<button class="profile-detail__back" aria-label="Назад" @click="goBack()">
<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"/>
<path d="M19 12H5M12 5l-7 7 7 7" />
</svg>
</button>
</div>
@@ -110,13 +54,19 @@ function goBack() { window.history.back(); }
<!-- 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>
<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>
<p v-if="profile.description" class="profile-detail__bio">
{{ profile.description }}
</p>
<!-- Stats -->
<div class="profile-detail__stats">
@@ -162,6 +112,70 @@ function goBack() { window.history.back(); }
</div>
</template>
<script setup lang="ts">
import type { UserProfile } from '@/composables/useAuth'
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { apiClient } from '@/api/client'
import AppButton from '@/components/common/AppButton.vue'
import AppModal from '@/components/common/AppModal.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import DateProposalForm from '@/components/dates/DateProposalForm.vue'
import ReportModal from '@/components/reports/ReportModal.vue'
import { useAuth } from '@/composables/useAuth'
import { useChat } from '@/composables/useChat'
import { useUi } from '@/composables/useUi'
const route = useRoute()
const authStore = useAuth()
const uiStore = useUi()
const chatStore = useChat()
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(() => {
const cid = profile.value?.cityId
return cid ? uiStore.cities.find(c => c.id === cid)?.name ?? '' : ''
})
const tagNames = computed(() => profile.value?.tags?.map(t => t.value) ?? [])
const currentImageIndex = ref(0)
const isOwnProfile = computed(() =>
authStore.profiles.some(p => p.id === profileId),
)
function goBack() { window.history.back() }
</script>
<style scoped lang="scss">
.profile-detail {
height: 100%;
@@ -179,7 +193,9 @@ function goBack() { window.history.back(); }
height: 60dvh;
background: var(--color-surface-2);
@include mobile { height: 50dvh; }
@include mobile {
height: 50dvh;
}
}
&__cover-img {
@@ -212,10 +228,16 @@ function goBack() { window.history.back(); }
transition: background var(--transition-fast);
backdrop-filter: blur(8px);
&:hover { background: rgba(13, 13, 13, 0.85); }
&:hover {
background: rgba(13, 13, 13, 0.85);
}
&--prev { left: 12px; }
&--next { right: 12px; }
&--prev {
left: 12px;
}
&--next {
right: 12px;
}
}
&__back {
@@ -235,7 +257,9 @@ function goBack() { window.history.back(); }
backdrop-filter: blur(8px);
transition: background var(--transition-fast);
&:hover { background: rgba(13, 13, 13, 0.85); }
&:hover {
background: rgba(13, 13, 13, 0.85);
}
}
&__content {
@@ -260,9 +284,14 @@ function goBack() { window.history.back(); }
margin: 0 0 4px;
}
&__age { opacity: 0.6; font-size: 1.5rem; }
&__age {
opacity: 0.6;
font-size: 1.5rem;
}
&__location { color: var(--color-muted); }
&__location {
color: var(--color-muted);
}
&__actions {
display: flex;