eslint --fix
This commit is contained in:
72
src/App.vue
72
src/App.vue
@@ -1,33 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
import AppShell from '@/components/layout/AppShell.vue';
|
||||
import AppToast from '@/components/common/AppToast.vue';
|
||||
import { useUi } from '@/composables/useUi';
|
||||
import { apiClient } from '@/api/client';
|
||||
import type { Tag, City, Greeting } from '@/composables/useUi';
|
||||
|
||||
const uiStore = useUi();
|
||||
|
||||
async function loadReferences() {
|
||||
if (uiStore.referencesLoaded) return;
|
||||
try {
|
||||
const [tags, cities, greetings] = await Promise.all([
|
||||
apiClient.api.tagsControllerFindAll() as unknown as Tag[],
|
||||
apiClient.api.citiesControllerFindAll() as unknown as City[],
|
||||
apiClient.api.greetingsControllerFindAll() as unknown as Greeting[],
|
||||
]);
|
||||
uiStore.setTags(tags);
|
||||
uiStore.setCities(cities);
|
||||
uiStore.setGreetings(greetings);
|
||||
uiStore.setReferencesLoaded();
|
||||
} catch {
|
||||
// Reference data loading is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadReferences);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<template #default>
|
||||
@@ -44,6 +14,38 @@ onMounted(loadReferences);
|
||||
<AppToast />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { City, Greeting, Tag } from '@/composables/useUi'
|
||||
import { onMounted } from 'vue'
|
||||
import { apiClient } from '@/api/client'
|
||||
import AppToast from '@/components/common/AppToast.vue'
|
||||
import AppShell from '@/components/layout/AppShell.vue'
|
||||
import { useUi } from '@/composables/useUi'
|
||||
|
||||
const uiStore = useUi()
|
||||
|
||||
async function loadReferences() {
|
||||
if (uiStore.referencesLoaded)
|
||||
return
|
||||
try {
|
||||
const [tags, cities, greetings] = await Promise.all([
|
||||
apiClient.api.tagsControllerFindAll() as unknown as Tag[],
|
||||
apiClient.api.citiesControllerFindAll() as unknown as City[],
|
||||
apiClient.api.greetingsControllerFindAll() as unknown as Greeting[],
|
||||
])
|
||||
uiStore.setTags(tags)
|
||||
uiStore.setCities(cities)
|
||||
uiStore.setGreetings(greetings)
|
||||
uiStore.setReferencesLoaded()
|
||||
}
|
||||
catch {
|
||||
// Reference data loading is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadReferences)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
@@ -58,6 +60,12 @@ onMounted(loadReferences);
|
||||
.slide-right-leave-active {
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
.slide-right-enter-from { opacity: 0; transform: translateX(20px); }
|
||||
.slide-right-leave-to { opacity: 0; transform: translateX(-20px); }
|
||||
.slide-right-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
.slide-right-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,111 +1,114 @@
|
||||
import axios from 'axios';
|
||||
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
|
||||
import { Api, HttpClient } from './api';
|
||||
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios'
|
||||
import axios from 'axios'
|
||||
import { Api, HttpClient } from './api'
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:1337';
|
||||
export const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:1337'
|
||||
|
||||
// ─── Raw axios instance with interceptors ────────────────────────────────────
|
||||
|
||||
export const axiosInstance: AxiosInstance = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
timeout: 15_000,
|
||||
});
|
||||
})
|
||||
|
||||
// Request interceptor — inject access token
|
||||
axiosInstance.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const token = _getAccessToken();
|
||||
const token = _getAccessToken()
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config;
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
);
|
||||
error => Promise.reject(error),
|
||||
)
|
||||
|
||||
// Response interceptor — silent token refresh on 401
|
||||
let _isRefreshing = false;
|
||||
let _failedQueue: Array<{ resolve: (v: unknown) => void; reject: (r: unknown) => void }> = [];
|
||||
let _isRefreshing = false
|
||||
let _failedQueue: Array<{ resolve: (v: unknown) => void, reject: (r: unknown) => void }> = []
|
||||
|
||||
function _processQueue(error: unknown, token: string | null) {
|
||||
_failedQueue.forEach(({ resolve, reject }) => {
|
||||
if (error) reject(error);
|
||||
else resolve(token);
|
||||
});
|
||||
_failedQueue = [];
|
||||
if (error)
|
||||
reject(error)
|
||||
else resolve(token)
|
||||
})
|
||||
_failedQueue = []
|
||||
}
|
||||
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response) => {
|
||||
if (response.data !== null && typeof response.data === 'object' && 'data' in response.data) {
|
||||
response.data = response.data.data;
|
||||
response.data = response.data.data
|
||||
}
|
||||
return response;
|
||||
return response
|
||||
},
|
||||
async (error) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
if (_isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
_failedQueue.push({ resolve, reject });
|
||||
_failedQueue.push({ resolve, reject })
|
||||
}).then((token) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
return axiosInstance(originalRequest);
|
||||
});
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`
|
||||
return axiosInstance(originalRequest)
|
||||
})
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
_isRefreshing = true;
|
||||
originalRequest._retry = true
|
||||
_isRefreshing = true
|
||||
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
const refreshToken = localStorage.getItem('refreshToken')
|
||||
if (!refreshToken) {
|
||||
_processQueue(error, null);
|
||||
_isRefreshing = false;
|
||||
_redirectToLogin();
|
||||
return Promise.reject(error);
|
||||
_processQueue(error, null)
|
||||
_isRefreshing = false
|
||||
_redirectToLogin()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.post<{ data: { accessToken: string; refreshToken: string } }>(
|
||||
const res = await axios.post<{ data: { accessToken: string, refreshToken: string } }>(
|
||||
`${BASE_URL}/api/v1/auth/refresh`,
|
||||
{ refreshToken },
|
||||
);
|
||||
const { accessToken, refreshToken: newRefresh } = res.data.data;
|
||||
_setAccessToken(accessToken);
|
||||
localStorage.setItem('refreshToken', newRefresh);
|
||||
_processQueue(null, accessToken);
|
||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
||||
return axiosInstance(originalRequest);
|
||||
} catch (refreshError) {
|
||||
_processQueue(refreshError, null);
|
||||
_clearAuth();
|
||||
_redirectToLogin();
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
_isRefreshing = false;
|
||||
)
|
||||
const { accessToken, refreshToken: newRefresh } = res.data.data
|
||||
_setAccessToken(accessToken)
|
||||
localStorage.setItem('refreshToken', newRefresh)
|
||||
_processQueue(null, accessToken)
|
||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`
|
||||
return axiosInstance(originalRequest)
|
||||
}
|
||||
catch (refreshError) {
|
||||
_processQueue(refreshError, null)
|
||||
_clearAuth()
|
||||
_redirectToLogin()
|
||||
return Promise.reject(refreshError)
|
||||
}
|
||||
finally {
|
||||
_isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
return Promise.reject(error)
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
// ─── In-memory token storage ─────────────────────────────────────────────────
|
||||
// Access token lives only in memory; refresh token lives in localStorage
|
||||
|
||||
let _accessToken: string | null = null;
|
||||
let _accessToken: string | null = null
|
||||
|
||||
export function _getAccessToken() { return _accessToken; }
|
||||
export function _setAccessToken(token: string) { _accessToken = token; }
|
||||
export function _getAccessToken() { return _accessToken }
|
||||
export function _setAccessToken(token: string) { _accessToken = token }
|
||||
export function _clearAuth() {
|
||||
_accessToken = null;
|
||||
localStorage.removeItem('refreshToken');
|
||||
_accessToken = null
|
||||
localStorage.removeItem('refreshToken')
|
||||
}
|
||||
|
||||
function _redirectToLogin() {
|
||||
// Dynamic import to avoid circular dependency with router
|
||||
import('@/router').then(({ router }) => router.replace('/login'));
|
||||
import('@/router').then(({ router }) => router.replace('/login'))
|
||||
}
|
||||
|
||||
// ─── Typed API client ─────────────────────────────────────────────────────────
|
||||
@@ -113,12 +116,12 @@ function _redirectToLogin() {
|
||||
const httpClient = new HttpClient({
|
||||
baseURL: BASE_URL,
|
||||
securityWorker: () => {
|
||||
const token = _getAccessToken();
|
||||
return token ? { headers: { Authorization: `Bearer ${token}` } } : {};
|
||||
const token = _getAccessToken()
|
||||
return token ? { headers: { Authorization: `Bearer ${token}` } } : {}
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// Plug our axios instance into the generated client
|
||||
httpClient.instance = axiosInstance;
|
||||
httpClient.instance = axiosInstance
|
||||
|
||||
export const apiClient = new Api(httpClient);
|
||||
export const apiClient = new Api(httpClient)
|
||||
|
||||
@@ -1,19 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { ChatMessage } from '@/composables/useChat';
|
||||
import MediaMessage from './MediaMessage.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
message: ChatMessage;
|
||||
isMine: boolean;
|
||||
}>();
|
||||
|
||||
const time = computed(() => {
|
||||
const d = new Date(props.message.createdAt);
|
||||
return d.toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bubble-wrap" :class="{ 'bubble-wrap--mine': isMine }">
|
||||
<div class="bubble" :class="{ 'bubble--mine': isMine, 'bubble--partner': !isMine }">
|
||||
@@ -22,12 +6,30 @@ const time = computed(() => {
|
||||
:url="message.mediaUrl"
|
||||
:type="message.mediaType ?? 'photo'"
|
||||
/>
|
||||
<p v-if="message.text" class="bubble__text">{{ message.text }}</p>
|
||||
<p v-if="message.text" class="bubble__text">
|
||||
{{ message.text }}
|
||||
</p>
|
||||
<span class="bubble__time">{{ time }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ChatMessage } from '@/composables/useChat'
|
||||
import { computed } from 'vue'
|
||||
import MediaMessage from './MediaMessage.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
message: ChatMessage
|
||||
isMine: boolean
|
||||
}>()
|
||||
|
||||
const time = computed(() => {
|
||||
const d = new Date(props.message.createdAt)
|
||||
return d.toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.bubble-wrap {
|
||||
display: flex;
|
||||
@@ -66,7 +68,9 @@ const time = computed(() => {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&--mine &__text { color: var(--color-base); }
|
||||
&--mine &__text {
|
||||
color: var(--color-base);
|
||||
}
|
||||
|
||||
&__time {
|
||||
display: block;
|
||||
|
||||
@@ -1,72 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
|
||||
const emit = defineEmits<{
|
||||
send: [text: string, mediaUrl?: string, mediaType?: 'photo' | 'voice' | 'video'];
|
||||
}>();
|
||||
|
||||
const text = ref('');
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const textareaEl = ref<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const isTauri = typeof window !== 'undefined' && !!window.__TAURI__;
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const el = e.target as HTMLTextAreaElement;
|
||||
text.value = el.value;
|
||||
// Auto-grow
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.min(el.scrollHeight, 140)}px`;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
}
|
||||
|
||||
function send() {
|
||||
const val = text.value.trim();
|
||||
if (!val) return;
|
||||
emit('send', val);
|
||||
text.value = '';
|
||||
if (textareaEl.value) {
|
||||
textareaEl.value.style.height = 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
async function openFilePicker() {
|
||||
if (isTauri) {
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||
const result = await open({ filters: [{ name: 'Медиа', extensions: ['jpg', 'jpeg', 'png', 'mp4', 'mov'] }] });
|
||||
if (typeof result === 'string') {
|
||||
// Tauri returns file path; treat as photo for now
|
||||
emit('send', '', result, 'photo');
|
||||
}
|
||||
} catch { /* user cancelled */ }
|
||||
} else {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
}
|
||||
|
||||
function onFileChange(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
const url = URL.createObjectURL(file);
|
||||
const type = file.type.startsWith('video/') ? 'video' : 'photo';
|
||||
emit('send', '', url, type);
|
||||
if (fileInput.value) fileInput.value.value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-input">
|
||||
<button class="chat-input__attach" @click="openFilePicker" aria-label="Прикрепить файл">
|
||||
<button class="chat-input__attach" aria-label="Прикрепить файл" @click="openFilePicker">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="20" height="20">
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -74,21 +10,21 @@ function onFileChange(e: Event) {
|
||||
ref="textareaEl"
|
||||
class="chat-input__textarea"
|
||||
:value="text"
|
||||
@input="handleInput"
|
||||
@keydown="handleKeydown"
|
||||
placeholder="Написать сообщение..."
|
||||
rows="1"
|
||||
aria-label="Текст сообщения"
|
||||
@input="handleInput"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="chat-input__send"
|
||||
:disabled="!text.trim()"
|
||||
@click="send"
|
||||
aria-label="Отправить"
|
||||
@click="send"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="20" height="20">
|
||||
<path d="M22 2L11 13M22 2L15 22l-4-9-9-4 20-7z"/>
|
||||
<path d="M22 2L11 13M22 2L15 22l-4-9-9-4 20-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -98,10 +34,78 @@ function onFileChange(e: Event) {
|
||||
accept="image/*,video/*"
|
||||
class="sr-only"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
send: [text: string, mediaUrl?: string, mediaType?: 'photo' | 'voice' | 'video']
|
||||
}>()
|
||||
|
||||
const text = ref('')
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const textareaEl = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
const isTauri = typeof window !== 'undefined' && !!window.__TAURI__
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const el = e.target as HTMLTextAreaElement
|
||||
text.value = el.value
|
||||
// Auto-grow
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${Math.min(el.scrollHeight, 140)}px`
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
send()
|
||||
}
|
||||
}
|
||||
|
||||
function send() {
|
||||
const val = text.value.trim()
|
||||
if (!val)
|
||||
return
|
||||
emit('send', val)
|
||||
text.value = ''
|
||||
if (textareaEl.value) {
|
||||
textareaEl.value.style.height = 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
async function openFilePicker() {
|
||||
if (isTauri) {
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog')
|
||||
const result = await open({ filters: [{ name: 'Медиа', extensions: ['jpg', 'jpeg', 'png', 'mp4', 'mov'] }] })
|
||||
if (typeof result === 'string') {
|
||||
// Tauri returns file path; treat as photo for now
|
||||
emit('send', '', result, 'photo')
|
||||
}
|
||||
}
|
||||
catch { /* user cancelled */ }
|
||||
}
|
||||
else {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
}
|
||||
|
||||
function onFileChange(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file)
|
||||
return
|
||||
const url = URL.createObjectURL(file)
|
||||
const type = file.type.startsWith('video/') ? 'video' : 'photo'
|
||||
emit('send', '', url, type)
|
||||
if (fileInput.value)
|
||||
fileInput.value.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-input {
|
||||
display: flex;
|
||||
@@ -130,14 +134,18 @@ function onFileChange(e: Event) {
|
||||
background: none;
|
||||
color: var(--color-muted);
|
||||
|
||||
&:hover { color: var(--color-cream); }
|
||||
&:hover {
|
||||
color: var(--color-cream);
|
||||
}
|
||||
}
|
||||
|
||||
&__send {
|
||||
background: var(--color-signal);
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) { background: #a84e30; }
|
||||
&:hover:not(:disabled) {
|
||||
background: #a84e30;
|
||||
}
|
||||
&:disabled {
|
||||
background: var(--color-dim);
|
||||
color: var(--color-muted);
|
||||
@@ -161,8 +169,12 @@ function onFileChange(e: Event) {
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
|
||||
&::placeholder { color: var(--color-muted); }
|
||||
&:focus { border-color: var(--color-border-strong); }
|
||||
&::placeholder {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
&:focus {
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,27 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
url: string;
|
||||
type: 'photo' | 'voice' | 'video';
|
||||
}>();
|
||||
|
||||
const lightboxOpen = ref(false);
|
||||
const audioEl = ref<HTMLAudioElement | null>(null);
|
||||
const playing = ref(false);
|
||||
|
||||
function toggleAudio() {
|
||||
if (!audioEl.value) return;
|
||||
if (playing.value) {
|
||||
audioEl.value.pause();
|
||||
playing.value = false;
|
||||
} else {
|
||||
audioEl.value.play();
|
||||
playing.value = true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Photo -->
|
||||
<div v-if="type === 'photo'" class="media media--photo">
|
||||
@@ -31,12 +7,12 @@ function toggleAudio() {
|
||||
alt="Фото"
|
||||
loading="lazy"
|
||||
@click="lightboxOpen = true"
|
||||
/>
|
||||
>
|
||||
<!-- Lightbox -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="lightboxOpen" class="lightbox" @click="lightboxOpen = false">
|
||||
<img :src="url" class="lightbox__img" alt="Фото" />
|
||||
<img :src="url" class="lightbox__img" alt="Фото">
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
@@ -44,10 +20,10 @@ function toggleAudio() {
|
||||
|
||||
<!-- Voice -->
|
||||
<div v-else-if="type === 'voice'" class="media media--voice">
|
||||
<button class="media__play-btn" @click="toggleAudio" :aria-label="playing ? 'Пауза' : 'Воспроизвести'">
|
||||
<button class="media__play-btn" :aria-label="playing ? 'Пауза' : 'Воспроизвести'" @click="toggleAudio">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
|
||||
<path v-if="!playing" d="M5 3l14 9-14 9V3z"/>
|
||||
<path v-else d="M6 4h4v16H6zm8 0h4v16h-4z"/>
|
||||
<path v-if="!playing" d="M5 3l14 9-14 9V3z" />
|
||||
<path v-else d="M6 4h4v16H6zm8 0h4v16h-4z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="media__waveform" aria-hidden="true">
|
||||
@@ -67,9 +43,37 @@ function toggleAudio() {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
url: string
|
||||
type: 'photo' | 'voice' | 'video'
|
||||
}>()
|
||||
|
||||
const lightboxOpen = ref(false)
|
||||
const audioEl = ref<HTMLAudioElement | null>(null)
|
||||
const playing = ref(false)
|
||||
|
||||
function toggleAudio() {
|
||||
if (!audioEl.value)
|
||||
return
|
||||
if (playing.value) {
|
||||
audioEl.value.pause()
|
||||
playing.value = false
|
||||
}
|
||||
else {
|
||||
audioEl.value.play()
|
||||
playing.value = true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.media {
|
||||
&--photo { cursor: pointer; }
|
||||
&--photo {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__img {
|
||||
max-width: 220px;
|
||||
@@ -79,7 +83,9 @@ function toggleAudio() {
|
||||
display: block;
|
||||
transition: opacity var(--transition-fast);
|
||||
|
||||
&:hover { opacity: 0.9; }
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
&--voice {
|
||||
@@ -104,7 +110,9 @@ function toggleAudio() {
|
||||
flex-shrink: 0;
|
||||
transition: transform var(--transition-fast);
|
||||
|
||||
&:hover { transform: scale(1.08); }
|
||||
&:hover {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
&__waveform {
|
||||
@@ -147,6 +155,12 @@ function toggleAudio() {
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity var(--transition-base); }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity var(--transition-base);
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,85 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
const emit = defineEmits<{ recorded: [blob: Blob] }>();
|
||||
|
||||
const recording = ref(false);
|
||||
const mediaRecorder = ref<MediaRecorder | null>(null);
|
||||
const chunks = ref<Blob[]>([]);
|
||||
const duration = ref(0);
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function start() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
mediaRecorder.value = new MediaRecorder(stream);
|
||||
chunks.value = [];
|
||||
|
||||
mediaRecorder.value.ondataavailable = (e) => chunks.value.push(e.data);
|
||||
mediaRecorder.value.onstop = () => {
|
||||
const blob = new Blob(chunks.value, { type: 'audio/webm' });
|
||||
emit('recorded', blob);
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
};
|
||||
|
||||
mediaRecorder.value.start();
|
||||
recording.value = true;
|
||||
duration.value = 0;
|
||||
timer = setInterval(() => duration.value++, 1000);
|
||||
} catch {
|
||||
// Microphone access denied
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
mediaRecorder.value?.stop();
|
||||
recording.value = false;
|
||||
if (timer) { clearInterval(timer); timer = null; }
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (mediaRecorder.value?.state === 'recording') {
|
||||
mediaRecorder.value.ondataavailable = null;
|
||||
mediaRecorder.value.onstop = null;
|
||||
mediaRecorder.value.stop();
|
||||
}
|
||||
recording.value = false;
|
||||
duration.value = 0;
|
||||
if (timer) { clearInterval(timer); timer = null; }
|
||||
}
|
||||
|
||||
function formatTime(s: number) {
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
return `${m}:${sec.toString().padStart(2, '0')}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="voice-recorder">
|
||||
<div v-if="recording" class="voice-recorder__active">
|
||||
<span class="voice-recorder__dot" aria-hidden="true" />
|
||||
<span class="voice-recorder__time meta">{{ formatTime(duration) }}</span>
|
||||
<button class="voice-recorder__cancel" @click="cancel" aria-label="Отменить запись">
|
||||
<button class="voice-recorder__cancel" aria-label="Отменить запись" @click="cancel">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="voice-recorder__stop" @click="stop" aria-label="Остановить запись">
|
||||
<button class="voice-recorder__stop" aria-label="Остановить запись" @click="stop">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button v-else class="voice-recorder__btn" @click="start" aria-label="Записать голосовое сообщение">
|
||||
<button v-else class="voice-recorder__btn" aria-label="Записать голосовое сообщение" @click="start">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="20" height="20">
|
||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v4M8 23h8"/>
|
||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v4M8 23h8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const emit = defineEmits<{ recorded: [blob: Blob] }>()
|
||||
|
||||
const recording = ref(false)
|
||||
const mediaRecorder = ref<MediaRecorder | null>(null)
|
||||
const chunks = ref<Blob[]>([])
|
||||
const duration = ref(0)
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function start() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
mediaRecorder.value = new MediaRecorder(stream)
|
||||
chunks.value = []
|
||||
|
||||
mediaRecorder.value.ondataavailable = e => chunks.value.push(e.data)
|
||||
mediaRecorder.value.onstop = () => {
|
||||
const blob = new Blob(chunks.value, { type: 'audio/webm' })
|
||||
emit('recorded', blob)
|
||||
stream.getTracks().forEach(t => t.stop())
|
||||
}
|
||||
|
||||
mediaRecorder.value.start()
|
||||
recording.value = true
|
||||
duration.value = 0
|
||||
timer = setInterval(() => duration.value++, 1000)
|
||||
}
|
||||
catch {
|
||||
// Microphone access denied
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
mediaRecorder.value?.stop()
|
||||
recording.value = false
|
||||
if (timer) { clearInterval(timer); timer = null }
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (mediaRecorder.value?.state === 'recording') {
|
||||
mediaRecorder.value.ondataavailable = null
|
||||
mediaRecorder.value.onstop = null
|
||||
mediaRecorder.value.stop()
|
||||
}
|
||||
recording.value = false
|
||||
duration.value = 0
|
||||
if (timer) { clearInterval(timer); timer = null }
|
||||
}
|
||||
|
||||
function formatTime(s: number) {
|
||||
const m = Math.floor(s / 60)
|
||||
const sec = s % 60
|
||||
return `${m}:${sec.toString().padStart(2, '0')}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.voice-recorder {
|
||||
&__btn {
|
||||
@@ -95,7 +96,9 @@ function formatTime(s: number) {
|
||||
border-radius: 50%;
|
||||
transition: color var(--transition-fast);
|
||||
|
||||
&:hover { color: var(--color-cream); }
|
||||
&:hover {
|
||||
color: var(--color-cream);
|
||||
}
|
||||
}
|
||||
|
||||
&__active {
|
||||
@@ -132,13 +135,17 @@ function formatTime(s: number) {
|
||||
&__cancel {
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-muted);
|
||||
&:hover { color: var(--color-cream); }
|
||||
&:hover {
|
||||
color: var(--color-cream);
|
||||
}
|
||||
}
|
||||
|
||||
&__stop {
|
||||
background: var(--color-signal);
|
||||
color: white;
|
||||
&:hover { background: #a84e30; }
|
||||
&:hover {
|
||||
background: #a84e30;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
fullWidth?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="type ?? 'button'"
|
||||
@@ -27,6 +16,17 @@ defineProps<{
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
fullWidth?: boolean
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
@@ -58,19 +58,35 @@ defineProps<{
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--full { width: 100%; }
|
||||
&--full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// Sizes
|
||||
&--sm { font-size: 0.6875rem; padding: 6px 12px; height: 32px; }
|
||||
&--md { font-size: 0.75rem; padding: 10px 20px; height: 40px; }
|
||||
&--lg { font-size: 0.8125rem; padding: 14px 28px; height: 48px; }
|
||||
&--sm {
|
||||
font-size: 0.6875rem;
|
||||
padding: 6px 12px;
|
||||
height: 32px;
|
||||
}
|
||||
&--md {
|
||||
font-size: 0.75rem;
|
||||
padding: 10px 20px;
|
||||
height: 40px;
|
||||
}
|
||||
&--lg {
|
||||
font-size: 0.8125rem;
|
||||
padding: 14px 28px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
// Variants
|
||||
&--primary {
|
||||
background: var(--color-cream);
|
||||
color: var(--color-base);
|
||||
|
||||
&:hover:not(:disabled) { background: #e8e0d0; }
|
||||
&:hover:not(:disabled) {
|
||||
background: #e8e0d0;
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
@@ -98,7 +114,9 @@ defineProps<{
|
||||
background: var(--color-signal);
|
||||
color: var(--color-cream);
|
||||
|
||||
&:hover:not(:disabled) { background: #a84e30; }
|
||||
&:hover:not(:disabled) {
|
||||
background: #a84e30;
|
||||
}
|
||||
}
|
||||
|
||||
// Spinner
|
||||
@@ -118,6 +136,8 @@ defineProps<{
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,42 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { watch, onUnmounted } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
title?: string;
|
||||
side?: 'right' | 'bottom';
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') emit('close');
|
||||
}
|
||||
|
||||
watch(() => props.open, (val) => {
|
||||
document.body.style.overflow = val ? 'hidden' : '';
|
||||
if (val) document.addEventListener('keydown', handleKeydown);
|
||||
else document.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
|
||||
const side = props.side ?? 'right';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition :name="`drawer-${side}`">
|
||||
<div v-if="open" class="drawer-backdrop" @click.self="emit('close')" role="dialog" aria-modal="true">
|
||||
<div v-if="open" class="drawer-backdrop" role="dialog" aria-modal="true" @click.self="emit('close')">
|
||||
<div class="drawer" :class="`drawer--${side}`">
|
||||
<div class="drawer__header">
|
||||
<h3 v-if="title" class="drawer__title">{{ title }}</h3>
|
||||
<button class="drawer__close" @click="emit('close')" aria-label="Закрыть">
|
||||
<h3 v-if="title" class="drawer__title">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<button class="drawer__close" aria-label="Закрыть" @click="emit('close')">
|
||||
<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>
|
||||
</div>
|
||||
@@ -49,6 +22,37 @@ const side = props.side ?? 'right';
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onUnmounted, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
title?: string
|
||||
side?: 'right' | 'bottom'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
watch(() => props.open, (val) => {
|
||||
document.body.style.overflow = val ? 'hidden' : ''
|
||||
if (val)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
else document.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
|
||||
const side = props.side ?? 'right'
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
@@ -108,7 +112,9 @@ const side = props.side ?? 'right';
|
||||
border-radius: var(--radius-xs);
|
||||
display: flex;
|
||||
transition: color var(--transition-fast);
|
||||
&:hover { color: var(--color-cream); }
|
||||
&:hover {
|
||||
color: var(--color-cream);
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
@@ -119,22 +125,34 @@ const side = props.side ?? 'right';
|
||||
}
|
||||
|
||||
// Right drawer
|
||||
.drawer-right-enter-active, .drawer-right-leave-active {
|
||||
.drawer-right-enter-active,
|
||||
.drawer-right-leave-active {
|
||||
transition: opacity var(--transition-base);
|
||||
.drawer--right { transition: transform var(--transition-base); }
|
||||
.drawer--right {
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
}
|
||||
.drawer-right-enter-from, .drawer-right-leave-to {
|
||||
.drawer-right-enter-from,
|
||||
.drawer-right-leave-to {
|
||||
opacity: 0;
|
||||
.drawer--right { transform: translateX(100%); }
|
||||
.drawer--right {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom drawer
|
||||
.drawer-bottom-enter-active, .drawer-bottom-leave-active {
|
||||
.drawer-bottom-enter-active,
|
||||
.drawer-bottom-leave-active {
|
||||
transition: opacity var(--transition-base);
|
||||
.drawer--bottom { transition: transform var(--transition-base); }
|
||||
.drawer--bottom {
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
}
|
||||
.drawer-bottom-enter-from, .drawer-bottom-leave-to {
|
||||
.drawer-bottom-enter-from,
|
||||
.drawer-bottom-leave-to {
|
||||
opacity: 0;
|
||||
.drawer--bottom { transform: translateY(100%); }
|
||||
.drawer--bottom {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,33 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
type?: string;
|
||||
error?: string | string[];
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
name?: string;
|
||||
autocomplete?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
blur: [event: FocusEvent];
|
||||
}>();
|
||||
|
||||
const errorMessage = computed(() =>
|
||||
Array.isArray(props.error) ? props.error[0] : props.error,
|
||||
);
|
||||
|
||||
function onInput(e: Event) {
|
||||
emit('update:modelValue', (e.target as HTMLInputElement).value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="field" :class="{ 'field--error': !!errorMessage, 'field--disabled': disabled }">
|
||||
<label v-if="label" class="field__label">
|
||||
@@ -48,16 +18,48 @@ function onInput(e: Event) {
|
||||
:aria-invalid="!!errorMessage"
|
||||
@input="onInput"
|
||||
@blur="emit('blur', $event)"
|
||||
/>
|
||||
>
|
||||
<slot name="suffix" />
|
||||
</div>
|
||||
<p v-if="errorMessage" :id="`${name}-error`" class="field__error" role="alert">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<p v-else-if="hint" :id="`${name}-hint`" class="field__hint">{{ hint }}</p>
|
||||
<p v-else-if="hint" :id="`${name}-hint`" class="field__hint">
|
||||
{{ hint }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
type?: string
|
||||
error?: string | string[]
|
||||
hint?: string
|
||||
disabled?: boolean
|
||||
required?: boolean
|
||||
name?: string
|
||||
autocomplete?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
'blur': [event: FocusEvent]
|
||||
}>()
|
||||
|
||||
const errorMessage = computed(() =>
|
||||
Array.isArray(props.error) ? props.error[0] : props.error,
|
||||
)
|
||||
|
||||
function onInput(e: Event) {
|
||||
emit('update:modelValue', (e.target as HTMLInputElement).value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.field {
|
||||
display: flex;
|
||||
|
||||
@@ -1,39 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, watch } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
title?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') emit('close');
|
||||
}
|
||||
|
||||
watch(() => props.open, (val) => {
|
||||
document.body.style.overflow = val ? 'hidden' : '';
|
||||
});
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', handleKeydown));
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="open" class="modal-backdrop" @click.self="emit('close')" role="dialog" aria-modal="true" :aria-label="title">
|
||||
<div v-if="open" class="modal-backdrop" role="dialog" aria-modal="true" :aria-label="title" @click.self="emit('close')">
|
||||
<div class="modal" :class="`modal--${size ?? 'md'}`">
|
||||
<div v-if="title || $slots.header" class="modal__header">
|
||||
<h2 v-if="title" class="modal__title">{{ title }}</h2>
|
||||
<h2 v-if="title" class="modal__title">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<slot name="header" />
|
||||
<button class="modal__close" @click="emit('close')" aria-label="Закрыть">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||||
<button class="modal__close" aria-label="Закрыть" @click="emit('close')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M18 6L6 18M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal__body">
|
||||
@@ -48,6 +24,33 @@ onUnmounted(() => {
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
title?: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
watch(() => props.open, (val) => {
|
||||
document.body.style.overflow = val ? 'hidden' : ''
|
||||
})
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', handleKeydown))
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
@@ -71,9 +74,15 @@ onUnmounted(() => {
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
&--sm { max-width: 400px; }
|
||||
&--md { max-width: 560px; }
|
||||
&--lg { max-width: 800px; }
|
||||
&--sm {
|
||||
max-width: 400px;
|
||||
}
|
||||
&--md {
|
||||
max-width: 560px;
|
||||
}
|
||||
&--lg {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
@@ -105,9 +114,14 @@ onUnmounted(() => {
|
||||
transition: color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover { color: var(--color-cream); }
|
||||
&:hover {
|
||||
color: var(--color-cream);
|
||||
}
|
||||
|
||||
svg { width: 18px; height: 18px; }
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
@@ -126,12 +140,21 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.modal-enter-active, .modal-leave-active {
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity var(--transition-base);
|
||||
.modal { transition: transform var(--transition-spring), opacity var(--transition-base); }
|
||||
.modal {
|
||||
transition:
|
||||
transform var(--transition-spring),
|
||||
opacity var(--transition-base);
|
||||
}
|
||||
}
|
||||
.modal-enter-from, .modal-leave-to {
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
.modal { transform: scale(0.95) translateY(8px); opacity: 0; }
|
||||
.modal {
|
||||
transform: scale(0.95) translateY(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import { useUi } from '@/composables/useUi';
|
||||
const uiStore = useUi();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="toast-container" role="region" aria-label="Уведомления" aria-live="polite">
|
||||
@@ -16,14 +11,14 @@ const uiStore = useUi();
|
||||
>
|
||||
<span class="toast__icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path v-if="toast.type === 'success'" d="M20 6L9 17l-5-5"/>
|
||||
<path v-else-if="toast.type === 'error'" d="M12 8v4m0 4h.01M10.29 3.86L1.82 18a2 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 v-else d="M12 16v-4m0-4h.01M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/>
|
||||
<path v-if="toast.type === 'success'" d="M20 6L9 17l-5-5" />
|
||||
<path v-else-if="toast.type === 'error'" d="M12 8v4m0 4h.01M10.29 3.86L1.82 18a2 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 v-else d="M12 16v-4m0-4h.01M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="toast__message">{{ toast.message }}</span>
|
||||
<button class="toast__close" @click="uiStore.removeToast(toast.id)" aria-label="Закрыть">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||||
<button class="toast__close" aria-label="Закрыть" @click="uiStore.removeToast(toast.id)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
@@ -31,6 +26,12 @@ const uiStore = useUi();
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUi } from '@/composables/useUi'
|
||||
|
||||
const uiStore = useUi()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
@@ -56,9 +57,15 @@ const uiStore = useUi();
|
||||
box-shadow: var(--shadow-card);
|
||||
pointer-events: all;
|
||||
|
||||
&--success { border-color: rgba(122, 184, 80, 0.3); }
|
||||
&--error { border-color: rgba(196, 92, 58, 0.4); }
|
||||
&--warning { border-color: rgba(210, 151, 60, 0.3); }
|
||||
&--success {
|
||||
border-color: rgba(122, 184, 80, 0.3);
|
||||
}
|
||||
&--error {
|
||||
border-color: rgba(196, 92, 58, 0.4);
|
||||
}
|
||||
&--warning {
|
||||
border-color: rgba(210, 151, 60, 0.3);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 18px;
|
||||
@@ -66,13 +73,24 @@ const uiStore = useUi();
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
|
||||
svg { width: 100%; height: 100%; }
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&--success .toast__icon { color: #7ab850; }
|
||||
&--error .toast__icon { color: var(--color-signal); }
|
||||
&--warning .toast__icon { color: #d2973c; }
|
||||
&--info .toast__icon { color: var(--color-muted); }
|
||||
&--success .toast__icon {
|
||||
color: #7ab850;
|
||||
}
|
||||
&--error .toast__icon {
|
||||
color: var(--color-signal);
|
||||
}
|
||||
&--warning .toast__icon {
|
||||
color: #d2973c;
|
||||
}
|
||||
&--info .toast__icon {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
&__message {
|
||||
flex: 1;
|
||||
@@ -97,9 +115,14 @@ const uiStore = useUi();
|
||||
border-radius: var(--radius-xs);
|
||||
transition: color var(--transition-fast);
|
||||
|
||||
&:hover { color: var(--color-cream); }
|
||||
&:hover {
|
||||
color: var(--color-cream);
|
||||
}
|
||||
|
||||
svg { width: 14px; height: 14px; }
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,57 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: 'feed' | 'chat' | 'heart' | 'calendar' | 'search' | 'default';
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="empty">
|
||||
<div class="empty__illustration" aria-hidden="true">
|
||||
<svg viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg" class="empty__svg">
|
||||
<!-- Feed empty state -->
|
||||
<template v-if="icon === 'feed'">
|
||||
<rect x="20" y="10" width="80" height="60" rx="4" stroke="currentColor" stroke-width="1.5" stroke-dasharray="4 3"/>
|
||||
<circle cx="45" cy="30" r="8" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M20 55 L40 40 L55 50 L75 35 L100 55" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M60 20 L80 20" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.4"/>
|
||||
<path d="M60 27 L90 27" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.3"/>
|
||||
<rect x="20" y="10" width="80" height="60" rx="4" stroke="currentColor" stroke-width="1.5" stroke-dasharray="4 3" />
|
||||
<circle cx="45" cy="30" r="8" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M20 55 L40 40 L55 50 L75 35 L100 55" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M60 20 L80 20" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.4" />
|
||||
<path d="M60 27 L90 27" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.3" />
|
||||
</template>
|
||||
<!-- Heart/matches empty state -->
|
||||
<template v-else-if="icon === 'heart'">
|
||||
<path d="M60 64 L26 42 A18 18 0 0 1 26 11 A18 18 0 0 1 60 30 A18 18 0 0 1 94 11 A18 18 0 0 1 94 42 Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="5 3"/>
|
||||
<path d="M60 64 L26 42 A18 18 0 0 1 26 11 A18 18 0 0 1 60 30 A18 18 0 0 1 94 11 A18 18 0 0 1 94 42 Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="5 3" />
|
||||
</template>
|
||||
<!-- Chat empty state -->
|
||||
<template v-else-if="icon === 'chat'">
|
||||
<path d="M15 15 L105 15 L105 55 L75 55 L60 70 L60 55 L15 55 Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="4 3"/>
|
||||
<path d="M30 30 L60 30" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M30 40 L75 40" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.3"/>
|
||||
<path d="M15 15 L105 15 L105 55 L75 55 L60 70 L60 55 L15 55 Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="4 3" />
|
||||
<path d="M30 30 L60 30" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.5" />
|
||||
<path d="M30 40 L75 40" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.3" />
|
||||
</template>
|
||||
<!-- Calendar/dates empty state -->
|
||||
<template v-else-if="icon === 'calendar'">
|
||||
<rect x="15" y="15" width="90" height="65" rx="4" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M15 30 L105 30" stroke="currentColor" stroke-width="1.5" opacity="0.4"/>
|
||||
<path d="M40 10 L40 22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M80 10 L80 22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="42" cy="52" r="3" fill="currentColor" opacity="0.4"/>
|
||||
<circle cx="60" cy="52" r="3" fill="currentColor" opacity="0.6"/>
|
||||
<circle cx="78" cy="52" r="3" fill="currentColor" opacity="0.3"/>
|
||||
<rect x="15" y="15" width="90" height="65" rx="4" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M15 30 L105 30" stroke="currentColor" stroke-width="1.5" opacity="0.4" />
|
||||
<path d="M40 10 L40 22" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
<path d="M80 10 L80 22" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
<circle cx="42" cy="52" r="3" fill="currentColor" opacity="0.4" />
|
||||
<circle cx="60" cy="52" r="3" fill="currentColor" opacity="0.6" />
|
||||
<circle cx="78" cy="52" r="3" fill="currentColor" opacity="0.3" />
|
||||
</template>
|
||||
<!-- Default / search -->
|
||||
<template v-else>
|
||||
<circle cx="50" cy="40" r="24" stroke="currentColor" stroke-width="1.5" stroke-dasharray="5 3"/>
|
||||
<path d="M68 58 L95 70" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M40 40 L50 40 M50 32 L50 48" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.5"/>
|
||||
<circle cx="50" cy="40" r="24" stroke="currentColor" stroke-width="1.5" stroke-dasharray="5 3" />
|
||||
<path d="M68 58 L95 70" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
<path d="M40 40 L50 40 M50 32 L50 48" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.5" />
|
||||
</template>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="empty__title">{{ title }}</h3>
|
||||
<p v-if="description" class="empty__desc">{{ description }}</p>
|
||||
<h3 class="empty__title">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p v-if="description" class="empty__desc">
|
||||
{{ description }}
|
||||
</p>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
icon?: 'feed' | 'chat' | 'heart' | 'calendar' | 'search' | 'default'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.empty {
|
||||
display: flex;
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ size?: 'sm' | 'md' | 'lg'; label?: string }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="spinner" :class="`spinner--${size ?? 'md'}`" role="status">
|
||||
<svg viewBox="0 0 24 24" fill="none" class="spinner__ring">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-dasharray="56.5" stroke-dashoffset="14"/>
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-dasharray="56.5" stroke-dashoffset="14" />
|
||||
</svg>
|
||||
<span class="sr-only">{{ label ?? 'Загрузка...' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ size?: 'sm' | 'md' | 'lg', label?: string }>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.spinner {
|
||||
display: inline-flex;
|
||||
@@ -20,12 +20,23 @@ defineProps<{ size?: 'sm' | 'md' | 'lg'; label?: string }>();
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
&--sm svg { width: 16px; height: 16px; }
|
||||
&--md svg { width: 24px; height: 24px; }
|
||||
&--lg svg { width: 40px; height: 40px; }
|
||||
&--sm svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
&--md svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
&--lg svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,49 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useAuth } from '@/composables/useAuth';
|
||||
import { useUi } from '@/composables/useUi';
|
||||
import { apiClient } from '@/api/client';
|
||||
import MapPicker from './MapPicker.vue';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
import AppInput from '@/components/common/AppInput.vue';
|
||||
|
||||
const props = defineProps<{ partnerProfileId: string }>();
|
||||
const emit = defineEmits<{ close: []; created: [] }>();
|
||||
|
||||
const authStore = useAuth();
|
||||
const uiStore = useUi();
|
||||
|
||||
const form = reactive({
|
||||
time: '',
|
||||
location: null as { lat: number; lng: number } | null,
|
||||
});
|
||||
const loading = ref(false);
|
||||
|
||||
async function submit() {
|
||||
if (!authStore.activeProfile || !form.location || !form.time) {
|
||||
uiStore.addToast('Выберите место и время встречи', 'warning');
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
await apiClient.api.datesControllerCreate({
|
||||
profileId: authStore.activeProfile.id,
|
||||
partnerProfileId: props.partnerProfileId,
|
||||
lat: form.location.lat,
|
||||
lng: form.location.lng,
|
||||
time: form.time,
|
||||
});
|
||||
uiStore.addToast('Предложение встречи отправлено', 'success');
|
||||
emit('created');
|
||||
emit('close');
|
||||
} catch {
|
||||
uiStore.addToast('Не удалось отправить предложение', 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="date-form" @submit.prevent="submit">
|
||||
<div class="date-form__section">
|
||||
@@ -54,7 +8,7 @@ async function submit() {
|
||||
class="date-form__datetime"
|
||||
required
|
||||
:min="new Date().toISOString().slice(0, 16)"
|
||||
/>
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="date-form__section">
|
||||
@@ -63,7 +17,9 @@ async function submit() {
|
||||
</div>
|
||||
|
||||
<div class="date-form__actions">
|
||||
<AppButton type="button" variant="ghost" @click="emit('close')">Отмена</AppButton>
|
||||
<AppButton type="button" variant="ghost" @click="emit('close')">
|
||||
Отмена
|
||||
</AppButton>
|
||||
<AppButton type="submit" :loading="loading" :disabled="!form.location || !form.time">
|
||||
Предложить встречу
|
||||
</AppButton>
|
||||
@@ -71,6 +27,53 @@ async function submit() {
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { apiClient } from '@/api/client'
|
||||
import AppButton from '@/components/common/AppButton.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useUi } from '@/composables/useUi'
|
||||
import MapPicker from './MapPicker.vue'
|
||||
|
||||
const props = defineProps<{ partnerProfileId: string }>()
|
||||
const emit = defineEmits<{ close: [], created: [] }>()
|
||||
|
||||
const authStore = useAuth()
|
||||
const uiStore = useUi()
|
||||
|
||||
const form = reactive({
|
||||
time: '',
|
||||
location: null as { lat: number, lng: number } | null,
|
||||
})
|
||||
const loading = ref(false)
|
||||
|
||||
async function submit() {
|
||||
if (!authStore.activeProfile || !form.location || !form.time) {
|
||||
uiStore.addToast('Выберите место и время встречи', 'warning')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await apiClient.api.datesControllerCreate({
|
||||
profileId: authStore.activeProfile.id,
|
||||
partnerProfileId: props.partnerProfileId,
|
||||
lat: form.location.lat,
|
||||
lng: form.location.lng,
|
||||
time: form.time,
|
||||
})
|
||||
uiStore.addToast('Предложение встречи отправлено', 'success')
|
||||
emit('created')
|
||||
emit('close')
|
||||
}
|
||||
catch {
|
||||
uiStore.addToast('Не удалось отправить предложение', 'error')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.date-form {
|
||||
display: flex;
|
||||
@@ -96,7 +99,9 @@ async function submit() {
|
||||
transition: border-color var(--transition-fast);
|
||||
color-scheme: dark;
|
||||
|
||||
&:focus { border-color: var(--color-signal); }
|
||||
&:focus {
|
||||
border-color: var(--color-signal);
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
|
||||
@@ -1,77 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: { lat: number; lng: number } | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: { lat: number; lng: number }] }>();
|
||||
|
||||
const mapEl = ref<HTMLElement | null>(null);
|
||||
let map: import('leaflet').Map | null = null;
|
||||
let marker: import('leaflet').Marker | null = null;
|
||||
|
||||
onMounted(async () => {
|
||||
const L = await import('leaflet');
|
||||
await import('leaflet/dist/leaflet.css');
|
||||
|
||||
// Fix Leaflet default icon paths
|
||||
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
});
|
||||
|
||||
if (!mapEl.value) return;
|
||||
|
||||
const initialCenter: [number, number] = props.modelValue
|
||||
? [props.modelValue.lat, props.modelValue.lng]
|
||||
: [55.75, 37.62]; // Moscow default
|
||||
|
||||
map = L.map(mapEl.value).setView(initialCenter, 13);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap',
|
||||
}).addTo(map);
|
||||
|
||||
if (props.modelValue) {
|
||||
marker = L.marker([props.modelValue.lat, props.modelValue.lng]).addTo(map);
|
||||
}
|
||||
|
||||
map.on('click', (e: import('leaflet').LeafletMouseEvent) => {
|
||||
const { lat, lng } = e.latlng;
|
||||
if (!map) return;
|
||||
if (marker) marker.setLatLng([lat, lng]);
|
||||
else marker = L.marker([lat, lng]).addTo(map!);
|
||||
emit('update:modelValue', { lat, lng });
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
map?.remove();
|
||||
map = null;
|
||||
});
|
||||
|
||||
watch(() => props.modelValue, async (val) => {
|
||||
if (!val || !map) return;
|
||||
const L = await import('leaflet');
|
||||
map.setView([val.lat, val.lng], map.getZoom());
|
||||
if (marker) marker.setLatLng([val.lat, val.lng]);
|
||||
else marker = L.marker([val.lat, val.lng]).addTo(map);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="map-picker">
|
||||
<div ref="mapEl" class="map-picker__map" />
|
||||
<p v-if="!modelValue" class="map-picker__hint meta">Нажмите на карту, чтобы выбрать место встречи</p>
|
||||
<p v-if="!modelValue" class="map-picker__hint meta">
|
||||
Нажмите на карту, чтобы выбрать место встречи
|
||||
</p>
|
||||
<p v-else class="map-picker__coords meta">
|
||||
{{ modelValue.lat.toFixed(5) }}, {{ modelValue.lng.toFixed(5) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: { lat: number, lng: number } | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: { lat: number, lng: number }] }>()
|
||||
|
||||
const mapEl = ref<HTMLElement | null>(null)
|
||||
let map: import('leaflet').Map | null = null
|
||||
let marker: import('leaflet').Marker | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
const L = await import('leaflet')
|
||||
await import('leaflet/dist/leaflet.css')
|
||||
|
||||
// Fix Leaflet default icon paths
|
||||
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
})
|
||||
|
||||
if (!mapEl.value)
|
||||
return
|
||||
|
||||
const initialCenter: [number, number] = props.modelValue
|
||||
? [props.modelValue.lat, props.modelValue.lng]
|
||||
: [55.75, 37.62] // Moscow default
|
||||
|
||||
map = L.map(mapEl.value).setView(initialCenter, 13)
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap',
|
||||
}).addTo(map)
|
||||
|
||||
if (props.modelValue) {
|
||||
marker = L.marker([props.modelValue.lat, props.modelValue.lng]).addTo(map)
|
||||
}
|
||||
|
||||
map.on('click', (e: import('leaflet').LeafletMouseEvent) => {
|
||||
const { lat, lng } = e.latlng
|
||||
if (!map)
|
||||
return
|
||||
if (marker)
|
||||
marker.setLatLng([lat, lng])
|
||||
else marker = L.marker([lat, lng]).addTo(map!)
|
||||
emit('update:modelValue', { lat, lng })
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
map?.remove()
|
||||
map = null
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, async (val) => {
|
||||
if (!val || !map)
|
||||
return
|
||||
const L = await import('leaflet')
|
||||
map.setView([val.lat, val.lng], map.getZoom())
|
||||
if (marker)
|
||||
marker.setLatLng([val.lat, val.lng])
|
||||
else marker = L.marker([val.lat, val.lng]).addTo(map)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.map-picker {
|
||||
display: flex;
|
||||
@@ -90,7 +97,8 @@ watch(() => props.modelValue, async (val) => {
|
||||
}
|
||||
}
|
||||
|
||||
&__hint, &__coords {
|
||||
&__hint,
|
||||
&__coords {
|
||||
color: var(--color-muted);
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
@@ -1,138 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { gsap } from 'gsap';
|
||||
import type { FeedProfile } from '@/composables/useFeed';
|
||||
import ProfileBadge from './ProfileBadge.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
profile: FeedProfile;
|
||||
isTop: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
like: [profileId: string];
|
||||
dislike: [profileId: string];
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const cardEl = ref<HTMLElement | null>(null);
|
||||
const currentImageIndex = ref(0);
|
||||
|
||||
const age = computed(() => {
|
||||
const birth = new Date(props.profile.birthDate);
|
||||
const today = new Date();
|
||||
let a = today.getFullYear() - birth.getFullYear();
|
||||
if (today.getMonth() < birth.getMonth() || (today.getMonth() === birth.getMonth() && today.getDate() < birth.getDate())) a--;
|
||||
return a;
|
||||
});
|
||||
|
||||
const coverUrl = computed(() =>
|
||||
props.profile.media?.[currentImageIndex.value]?.path ?? '',
|
||||
);
|
||||
|
||||
// ─── Drag / swipe mechanics ───────────────────────────────────────────────────
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let isDragging = false;
|
||||
const THROW_THRESHOLD = 80;
|
||||
|
||||
const prefersReducedMotion = typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
function onDragStart(e: PointerEvent) {
|
||||
if (!props.isTop) return;
|
||||
isDragging = true;
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
cardEl.value?.setPointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
function onDragMove(e: PointerEvent) {
|
||||
if (!isDragging || !cardEl.value) return;
|
||||
const dx = e.clientX - startX;
|
||||
const dy = e.clientY - startY;
|
||||
const rotation = dx * 0.06;
|
||||
const tintRight = Math.max(0, dx / THROW_THRESHOLD);
|
||||
const tintLeft = Math.max(0, -dx / THROW_THRESHOLD);
|
||||
|
||||
if (!prefersReducedMotion) {
|
||||
gsap.set(cardEl.value, {
|
||||
x: dx,
|
||||
y: dy * 0.4,
|
||||
rotation,
|
||||
'--tint-right': Math.min(tintRight, 1),
|
||||
'--tint-left': Math.min(tintLeft, 1),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onDragEnd(e: PointerEvent) {
|
||||
if (!isDragging || !cardEl.value) return;
|
||||
isDragging = false;
|
||||
const dx = e.clientX - startX;
|
||||
|
||||
if (Math.abs(dx) > THROW_THRESHOLD) {
|
||||
const direction = dx > 0 ? 1 : -1;
|
||||
throwCard(direction);
|
||||
} else {
|
||||
// Snap back
|
||||
if (!prefersReducedMotion) {
|
||||
gsap.to(cardEl.value, { x: 0, y: 0, rotation: 0, '--tint-right': 0, '--tint-left': 0, duration: 0.3, ease: 'back.out(2)' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function throwCard(direction: 1 | -1) {
|
||||
if (!cardEl.value) return;
|
||||
const target = direction === 1 ? props.profile.id : null;
|
||||
|
||||
if (!prefersReducedMotion) {
|
||||
gsap.to(cardEl.value, {
|
||||
x: direction * window.innerWidth * 1.5,
|
||||
rotation: direction * 25,
|
||||
opacity: 0,
|
||||
duration: 0.4,
|
||||
ease: 'power2.in',
|
||||
onComplete: () => {
|
||||
if (direction === 1) emit('like', props.profile.id);
|
||||
else emit('dislike', props.profile.id);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
if (direction === 1) emit('like', props.profile.id);
|
||||
else emit('dislike', props.profile.id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLike() { throwCard(1); }
|
||||
function handleDislike() { throwCard(-1); }
|
||||
|
||||
function openProfile() {
|
||||
if (isDragging) return;
|
||||
router.push(`/profile/${props.profile.id}`);
|
||||
}
|
||||
|
||||
function nextImage() {
|
||||
if (currentImageIndex.value < (props.profile.media?.length ?? 1) - 1) {
|
||||
currentImageIndex.value++;
|
||||
}
|
||||
}
|
||||
|
||||
function prevImage(e: Event) {
|
||||
e.stopPropagation();
|
||||
if (currentImageIndex.value > 0) currentImageIndex.value--;
|
||||
}
|
||||
|
||||
// Touch support
|
||||
let touchStartX = 0;
|
||||
function onTouchStart(e: TouchEvent) { touchStartX = e.touches[0].clientX; }
|
||||
function onTouchEnd(e: TouchEvent) {
|
||||
if (!props.isTop) return;
|
||||
const dx = e.changedTouches[0].clientX - touchStartX;
|
||||
if (Math.abs(dx) > THROW_THRESHOLD) throwCard(dx > 0 ? 1 : -1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
ref="cardEl"
|
||||
@@ -153,10 +18,10 @@ function onTouchEnd(e: TouchEvent) {
|
||||
:alt="`Фото ${profile.name}`"
|
||||
class="feed-card__img"
|
||||
draggable="false"
|
||||
/>
|
||||
>
|
||||
<div v-else class="feed-card__no-photo" aria-label="Нет фото">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" width="48" height="48" 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>
|
||||
|
||||
@@ -167,8 +32,8 @@ function onTouchEnd(e: TouchEvent) {
|
||||
:key="i"
|
||||
class="feed-card__dot"
|
||||
:class="{ 'feed-card__dot--active': i === currentImageIndex }"
|
||||
@click.stop="currentImageIndex = i"
|
||||
:aria-label="`Фото ${i + 1}`"
|
||||
@click.stop="currentImageIndex = i"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -190,7 +55,9 @@ function onTouchEnd(e: TouchEvent) {
|
||||
<div class="feed-card__meta">
|
||||
<span v-if="profile.cityName" class="meta feed-card__location">{{ profile.cityName }}</span>
|
||||
</div>
|
||||
<h2 class="feed-card__name">{{ profile.name }}<span class="feed-card__age">, {{ age }}</span></h2>
|
||||
<h2 class="feed-card__name">
|
||||
{{ profile.name }}<span class="feed-card__age">, {{ age }}</span>
|
||||
</h2>
|
||||
<div v-if="profile.tags?.length" class="feed-card__tags">
|
||||
<ProfileBadge v-for="tag in profile.tags?.slice(0, 4)" :key="tag.id" :label="tag.value" />
|
||||
</div>
|
||||
@@ -198,20 +65,168 @@ function onTouchEnd(e: TouchEvent) {
|
||||
|
||||
<!-- Action buttons (visible on non-drag mode) -->
|
||||
<div v-if="isTop" class="feed-card__actions" @click.stop>
|
||||
<button class="feed-card__btn feed-card__btn--dislike" @click="handleDislike" aria-label="Пропустить">
|
||||
<button class="feed-card__btn feed-card__btn--dislike" aria-label="Пропустить" @click="handleDislike">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="24" height="24">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="feed-card__btn feed-card__btn--like" @click="handleLike" aria-label="Лайк">
|
||||
<button class="feed-card__btn feed-card__btn--like" aria-label="Лайк" @click="handleLike">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="24" height="24">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FeedProfile } from '@/composables/useFeed'
|
||||
import { gsap } from 'gsap'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ProfileBadge from './ProfileBadge.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
profile: FeedProfile
|
||||
isTop: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
like: [profileId: string]
|
||||
dislike: [profileId: string]
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const cardEl = ref<HTMLElement | null>(null)
|
||||
const currentImageIndex = ref(0)
|
||||
|
||||
const age = computed(() => {
|
||||
const birth = new Date(props.profile.birthDate)
|
||||
const today = new Date()
|
||||
let a = today.getFullYear() - birth.getFullYear()
|
||||
if (today.getMonth() < birth.getMonth() || (today.getMonth() === birth.getMonth() && today.getDate() < birth.getDate()))
|
||||
a--
|
||||
return a
|
||||
})
|
||||
|
||||
const coverUrl = computed(() =>
|
||||
props.profile.media?.[currentImageIndex.value]?.path ?? '',
|
||||
)
|
||||
|
||||
// ─── Drag / swipe mechanics ───────────────────────────────────────────────────
|
||||
let startX = 0
|
||||
let startY = 0
|
||||
let isDragging = false
|
||||
const THROW_THRESHOLD = 80
|
||||
|
||||
const prefersReducedMotion = typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
function onDragStart(e: PointerEvent) {
|
||||
if (!props.isTop)
|
||||
return
|
||||
isDragging = true
|
||||
startX = e.clientX
|
||||
startY = e.clientY
|
||||
cardEl.value?.setPointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
function onDragMove(e: PointerEvent) {
|
||||
if (!isDragging || !cardEl.value)
|
||||
return
|
||||
const dx = e.clientX - startX
|
||||
const dy = e.clientY - startY
|
||||
const rotation = dx * 0.06
|
||||
const tintRight = Math.max(0, dx / THROW_THRESHOLD)
|
||||
const tintLeft = Math.max(0, -dx / THROW_THRESHOLD)
|
||||
|
||||
if (!prefersReducedMotion) {
|
||||
gsap.set(cardEl.value, {
|
||||
'x': dx,
|
||||
'y': dy * 0.4,
|
||||
rotation,
|
||||
'--tint-right': Math.min(tintRight, 1),
|
||||
'--tint-left': Math.min(tintLeft, 1),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onDragEnd(e: PointerEvent) {
|
||||
if (!isDragging || !cardEl.value)
|
||||
return
|
||||
isDragging = false
|
||||
const dx = e.clientX - startX
|
||||
|
||||
if (Math.abs(dx) > THROW_THRESHOLD) {
|
||||
const direction = dx > 0 ? 1 : -1
|
||||
throwCard(direction)
|
||||
}
|
||||
else {
|
||||
// Snap back
|
||||
if (!prefersReducedMotion) {
|
||||
gsap.to(cardEl.value, { 'x': 0, 'y': 0, 'rotation': 0, '--tint-right': 0, '--tint-left': 0, 'duration': 0.3, 'ease': 'back.out(2)' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function throwCard(direction: 1 | -1) {
|
||||
if (!cardEl.value)
|
||||
return
|
||||
const target = direction === 1 ? props.profile.id : null
|
||||
|
||||
if (!prefersReducedMotion) {
|
||||
gsap.to(cardEl.value, {
|
||||
x: direction * window.innerWidth * 1.5,
|
||||
rotation: direction * 25,
|
||||
opacity: 0,
|
||||
duration: 0.4,
|
||||
ease: 'power2.in',
|
||||
onComplete: () => {
|
||||
if (direction === 1)
|
||||
emit('like', props.profile.id)
|
||||
else emit('dislike', props.profile.id)
|
||||
},
|
||||
})
|
||||
}
|
||||
else {
|
||||
if (direction === 1)
|
||||
emit('like', props.profile.id)
|
||||
else emit('dislike', props.profile.id)
|
||||
}
|
||||
}
|
||||
|
||||
function handleLike() { throwCard(1) }
|
||||
function handleDislike() { throwCard(-1) }
|
||||
|
||||
function openProfile() {
|
||||
if (isDragging)
|
||||
return
|
||||
router.push(`/profile/${props.profile.id}`)
|
||||
}
|
||||
|
||||
function nextImage() {
|
||||
if (currentImageIndex.value < (props.profile.media?.length ?? 1) - 1) {
|
||||
currentImageIndex.value++
|
||||
}
|
||||
}
|
||||
|
||||
function prevImage(e: Event) {
|
||||
e.stopPropagation()
|
||||
if (currentImageIndex.value > 0)
|
||||
currentImageIndex.value--
|
||||
}
|
||||
|
||||
// Touch support
|
||||
let touchStartX = 0
|
||||
function onTouchStart(e: TouchEvent) { touchStartX = e.touches[0].clientX }
|
||||
function onTouchEnd(e: TouchEvent) {
|
||||
if (!props.isTop)
|
||||
return
|
||||
const dx = e.changedTouches[0].clientX - touchStartX
|
||||
if (Math.abs(dx) > THROW_THRESHOLD)
|
||||
throwCard(dx > 0 ? 1 : -1)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.feed-card {
|
||||
position: absolute;
|
||||
@@ -280,7 +295,9 @@ function onTouchEnd(e: TouchEvent) {
|
||||
padding: 0;
|
||||
transition: background var(--transition-fast);
|
||||
|
||||
&--active { background: rgba(255, 255, 255, 0.9); }
|
||||
&--active {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
// Drag tint overlays
|
||||
@@ -336,7 +353,7 @@ function onTouchEnd(e: TouchEvent) {
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 80px 20px 80px;
|
||||
background: linear-gradient(0deg, rgba(13,13,13,0.92) 0%, rgba(13,13,13,0.6) 50%, transparent 100%);
|
||||
background: linear-gradient(0deg, rgba(13, 13, 13, 0.92) 0%, rgba(13, 13, 13, 0.6) 50%, transparent 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -389,10 +406,16 @@ function onTouchEnd(e: TouchEvent) {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-fast), opacity var(--transition-fast);
|
||||
transition:
|
||||
transform var(--transition-fast),
|
||||
opacity var(--transition-fast);
|
||||
|
||||
&:hover { transform: scale(1.1); }
|
||||
&:active { transform: scale(0.95); }
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&--like {
|
||||
background: var(--color-signal);
|
||||
|
||||
@@ -1,63 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { gsap } from 'gsap';
|
||||
import { useFeed } from '@/composables/useFeed';
|
||||
import { useAuth } from '@/composables/useAuth';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { useUi } from '@/composables/useUi';
|
||||
import FeedCard from './FeedCard.vue';
|
||||
import EmptyState from '@/components/common/EmptyState.vue';
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
|
||||
|
||||
const feedStore = useFeed();
|
||||
const authStore = useAuth();
|
||||
const uiStore = useUi();
|
||||
|
||||
const prefersReducedMotion = typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
const visibleCards = computed(() => feedStore.cards.slice(0, 3));
|
||||
|
||||
async function handleLike(profileId: string) {
|
||||
const activeProfile = authStore.activeProfile;
|
||||
if (!activeProfile) return;
|
||||
try {
|
||||
await apiClient.api.likesControllerCreateLike({
|
||||
sourceProfileId: activeProfile.id,
|
||||
targetProfileId: profileId,
|
||||
type: 'like',
|
||||
});
|
||||
feedStore.removeCard(profileId);
|
||||
checkRefill();
|
||||
} catch {
|
||||
uiStore.addToast('Не удалось отправить лайк', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDislike(profileId: string) {
|
||||
const activeProfile = authStore.activeProfile;
|
||||
if (!activeProfile) return;
|
||||
try {
|
||||
await apiClient.api.likesControllerCreateLike({
|
||||
sourceProfileId: activeProfile.id,
|
||||
targetProfileId: profileId,
|
||||
type: 'dislike',
|
||||
});
|
||||
feedStore.removeCard(profileId);
|
||||
checkRefill();
|
||||
} catch {
|
||||
feedStore.removeCard(profileId);
|
||||
}
|
||||
}
|
||||
|
||||
function checkRefill() {
|
||||
const activeProfile = authStore.activeProfile;
|
||||
if (!activeProfile) return;
|
||||
if (feedStore.cards.length < 5 && feedStore.hasMore) {
|
||||
feedStore.fetchNextPage(activeProfile.id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card-stack">
|
||||
<div v-if="feedStore.loading && feedStore.cards.length === 0" class="card-stack__loading">
|
||||
@@ -72,7 +12,9 @@ function checkRefill() {
|
||||
/>
|
||||
|
||||
<div v-else-if="feedStore.searchPaused" class="card-stack__paused">
|
||||
<p class="meta">Лимит совпадений достигнут</p>
|
||||
<p class="meta">
|
||||
Лимит совпадений достигнут
|
||||
</p>
|
||||
<p>Закройте один из открытых чатов, чтобы продолжить поиск.</p>
|
||||
</div>
|
||||
|
||||
@@ -91,6 +33,70 @@ function checkRefill() {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { apiClient } from '@/api/client'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useFeed } from '@/composables/useFeed'
|
||||
import { useUi } from '@/composables/useUi'
|
||||
import FeedCard from './FeedCard.vue'
|
||||
|
||||
const feedStore = useFeed()
|
||||
const authStore = useAuth()
|
||||
const uiStore = useUi()
|
||||
|
||||
const prefersReducedMotion = typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
const visibleCards = computed(() => feedStore.cards.slice(0, 3))
|
||||
|
||||
async function handleLike(profileId: string) {
|
||||
const activeProfile = authStore.activeProfile
|
||||
if (!activeProfile)
|
||||
return
|
||||
try {
|
||||
await apiClient.api.likesControllerCreateLike({
|
||||
sourceProfileId: activeProfile.id,
|
||||
targetProfileId: profileId,
|
||||
type: 'like',
|
||||
})
|
||||
feedStore.removeCard(profileId)
|
||||
checkRefill()
|
||||
}
|
||||
catch {
|
||||
uiStore.addToast('Не удалось отправить лайк', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDislike(profileId: string) {
|
||||
const activeProfile = authStore.activeProfile
|
||||
if (!activeProfile)
|
||||
return
|
||||
try {
|
||||
await apiClient.api.likesControllerCreateLike({
|
||||
sourceProfileId: activeProfile.id,
|
||||
targetProfileId: profileId,
|
||||
type: 'dislike',
|
||||
})
|
||||
feedStore.removeCard(profileId)
|
||||
checkRefill()
|
||||
}
|
||||
catch {
|
||||
feedStore.removeCard(profileId)
|
||||
}
|
||||
}
|
||||
|
||||
function checkRefill() {
|
||||
const activeProfile = authStore.activeProfile
|
||||
if (!activeProfile)
|
||||
return
|
||||
if (feedStore.cards.length < 5 && feedStore.hasMore) {
|
||||
feedStore.fetchNextPage(activeProfile.id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.card-stack {
|
||||
position: relative;
|
||||
@@ -123,8 +129,18 @@ function checkRefill() {
|
||||
}
|
||||
|
||||
// Card transition — next card scales up from behind
|
||||
.card-enter-active { transition: all var(--transition-spring); }
|
||||
.card-leave-active { transition: all 0.4s ease-in; position: absolute; }
|
||||
.card-enter-from { transform: scale(0.93) translateY(20px); opacity: 0; }
|
||||
.card-leave-to { opacity: 0; }
|
||||
.card-enter-active {
|
||||
transition: all var(--transition-spring);
|
||||
}
|
||||
.card-leave-active {
|
||||
transition: all 0.4s ease-in;
|
||||
position: absolute;
|
||||
}
|
||||
.card-enter-from {
|
||||
transform: scale(0.93) translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
.card-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,96 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
import { useUi } from '@/composables/useUi';
|
||||
import { useFeed } from '@/composables/useFeed';
|
||||
import { useAuth } from '@/composables/useAuth';
|
||||
import { apiClient } from '@/api/client';
|
||||
import type { District } from '@/composables/useUi';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
import AppDrawer from '@/components/common/AppDrawer.vue';
|
||||
|
||||
defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
const uiStore = useUi();
|
||||
const feedStore = useFeed();
|
||||
const authStore = useAuth();
|
||||
|
||||
const filters = reactive({
|
||||
cityId: '',
|
||||
districtId: '',
|
||||
ageMin: undefined as number | undefined,
|
||||
ageMax: undefined as number | undefined,
|
||||
keyword: '',
|
||||
tagIds: [] as string[],
|
||||
});
|
||||
|
||||
const districts = ref<District[]>([]);
|
||||
|
||||
watch(() => filters.cityId, async (cityId) => {
|
||||
filters.districtId = '';
|
||||
if (!cityId) { districts.value = []; return; }
|
||||
if (uiStore.districts[cityId]) { districts.value = uiStore.districts[cityId]; return; }
|
||||
try {
|
||||
const res = await apiClient.api.citiesControllerFindDistricts(cityId) as unknown as District[];
|
||||
uiStore.setDistricts(cityId, res);
|
||||
districts.value = res;
|
||||
} catch { districts.value = []; }
|
||||
});
|
||||
|
||||
function toggleTag(tagId: string) {
|
||||
const idx = filters.tagIds.indexOf(tagId);
|
||||
if (idx === -1) filters.tagIds.push(tagId);
|
||||
else filters.tagIds.splice(idx, 1);
|
||||
}
|
||||
|
||||
function apply() {
|
||||
feedStore.applyFilters({ ...filters });
|
||||
const profileId = authStore.activeProfile?.id;
|
||||
if (profileId) feedStore.fetchNextPage(profileId);
|
||||
emit('close');
|
||||
}
|
||||
|
||||
function reset() {
|
||||
filters.cityId = '';
|
||||
filters.districtId = '';
|
||||
filters.ageMin = undefined;
|
||||
filters.ageMax = undefined;
|
||||
filters.keyword = '';
|
||||
filters.tagIds = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDrawer :open="open" title="Фильтры" side="right" @close="emit('close')">
|
||||
<div class="filters">
|
||||
<div class="filters__section">
|
||||
<span class="label">Город</span>
|
||||
<select v-model="filters.cityId" class="filters__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="filters.cityId" class="filters__section">
|
||||
<span class="label">Район</span>
|
||||
<select v-model="filters.districtId" class="filters__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>
|
||||
|
||||
<div class="filters__section">
|
||||
<span class="label">Возраст</span>
|
||||
<div class="filters__range">
|
||||
<input v-model.number="filters.ageMin" type="number" class="filters__num" placeholder="от 18" min="18" max="80" />
|
||||
<input v-model.number="filters.ageMin" type="number" class="filters__num" placeholder="от 18" min="18" max="80">
|
||||
<span class="filters__dash">—</span>
|
||||
<input v-model.number="filters.ageMax" type="number" class="filters__num" placeholder="до 60" min="18" max="80" />
|
||||
<input v-model.number="filters.ageMax" type="number" class="filters__num" placeholder="до 60" min="18" max="80">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filters__section">
|
||||
<span class="label">Ключевое слово</span>
|
||||
<input v-model="filters.keyword" type="text" class="filters__input" placeholder="Имя, описание..." />
|
||||
<input v-model="filters.keyword" type="text" class="filters__input" placeholder="Имя, описание...">
|
||||
</div>
|
||||
|
||||
<div class="filters__section">
|
||||
@@ -103,18 +49,89 @@ function reset() {
|
||||
class="filters__tag"
|
||||
:class="{ 'filters__tag--active': filters.tagIds.includes(tag.id) }"
|
||||
@click="toggleTag(tag.id)"
|
||||
>{{ tag.value }}</button>
|
||||
>
|
||||
{{ tag.value }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filters__actions">
|
||||
<AppButton variant="ghost" @click="reset">Сбросить</AppButton>
|
||||
<AppButton variant="primary" @click="apply">Применить</AppButton>
|
||||
<AppButton variant="ghost" @click="reset">
|
||||
Сбросить
|
||||
</AppButton>
|
||||
<AppButton variant="primary" @click="apply">
|
||||
Применить
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { District } from '@/composables/useUi'
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { apiClient } from '@/api/client'
|
||||
import AppButton from '@/components/common/AppButton.vue'
|
||||
import AppDrawer from '@/components/common/AppDrawer.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useFeed } from '@/composables/useFeed'
|
||||
import { useUi } from '@/composables/useUi'
|
||||
|
||||
defineProps<{ open: boolean }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const uiStore = useUi()
|
||||
const feedStore = useFeed()
|
||||
const authStore = useAuth()
|
||||
|
||||
const filters = reactive({
|
||||
cityId: '',
|
||||
districtId: '',
|
||||
ageMin: undefined as number | undefined,
|
||||
ageMax: undefined as number | undefined,
|
||||
keyword: '',
|
||||
tagIds: [] as string[],
|
||||
})
|
||||
|
||||
const districts = ref<District[]>([])
|
||||
|
||||
watch(() => filters.cityId, async (cityId) => {
|
||||
filters.districtId = ''
|
||||
if (!cityId) { districts.value = []; return }
|
||||
if (uiStore.districts[cityId]) { districts.value = uiStore.districts[cityId]; return }
|
||||
try {
|
||||
const res = await apiClient.api.citiesControllerFindDistricts(cityId) as unknown as District[]
|
||||
uiStore.setDistricts(cityId, res)
|
||||
districts.value = res
|
||||
}
|
||||
catch { districts.value = [] }
|
||||
})
|
||||
|
||||
function toggleTag(tagId: string) {
|
||||
const idx = filters.tagIds.indexOf(tagId)
|
||||
if (idx === -1)
|
||||
filters.tagIds.push(tagId)
|
||||
else filters.tagIds.splice(idx, 1)
|
||||
}
|
||||
|
||||
function apply() {
|
||||
feedStore.applyFilters({ ...filters })
|
||||
const profileId = authStore.activeProfile?.id
|
||||
if (profileId)
|
||||
feedStore.fetchNextPage(profileId)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function reset() {
|
||||
filters.cityId = ''
|
||||
filters.districtId = ''
|
||||
filters.ageMin = undefined
|
||||
filters.ageMax = undefined
|
||||
filters.keyword = ''
|
||||
filters.tagIds = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.filters {
|
||||
display: flex;
|
||||
@@ -140,10 +157,15 @@ function reset() {
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
|
||||
&:focus { border-color: var(--color-signal); }
|
||||
&:focus {
|
||||
border-color: var(--color-signal);
|
||||
}
|
||||
}
|
||||
|
||||
&__select { appearance: none; cursor: pointer; }
|
||||
&__select {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__range {
|
||||
display: flex;
|
||||
@@ -165,7 +187,9 @@ function reset() {
|
||||
text-align: center;
|
||||
transition: border-color var(--transition-fast);
|
||||
|
||||
&:focus { border-color: var(--color-signal); }
|
||||
&:focus {
|
||||
border-color: var(--color-signal);
|
||||
}
|
||||
}
|
||||
|
||||
&__dash {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ label: string; variant?: 'default' | 'signal' }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="badge" :class="`badge--${variant ?? 'default'}`">{{ label }}</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ label: string, variant?: 'default' | 'signal' }>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -1,23 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import TauriTitlebar from './TauriTitlebar.vue';
|
||||
import SideNav from './SideNav.vue';
|
||||
import BottomNav from './BottomNav.vue';
|
||||
import { useAuth } from '@/composables/useAuth';
|
||||
|
||||
const route = useRoute();
|
||||
const authStore = useAuth();
|
||||
|
||||
const isTauri = typeof window !== 'undefined' && !!window.__TAURI__;
|
||||
|
||||
// Hide nav on auth/onboarding routes
|
||||
const showNav = computed(() =>
|
||||
authStore.isAuthenticated &&
|
||||
!['login', 'register', 'setup'].includes(route.name as string),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell" :class="{ 'shell--tauri': isTauri }">
|
||||
<!-- Grain texture overlay (fixed, pointer-events none) -->
|
||||
@@ -41,6 +21,26 @@ const showNav = computed(() =>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import BottomNav from './BottomNav.vue'
|
||||
import SideNav from './SideNav.vue'
|
||||
import TauriTitlebar from './TauriTitlebar.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuth()
|
||||
|
||||
const isTauri = typeof window !== 'undefined' && !!window.__TAURI__
|
||||
|
||||
// Hide nav on auth/onboarding routes
|
||||
const showNav = computed(() =>
|
||||
authStore.isAuthenticated
|
||||
&& !['login', 'register', 'setup'].includes(route.name as string),
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.shell {
|
||||
height: 100dvh;
|
||||
|
||||
@@ -1,22 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const navItems = [
|
||||
{ path: '/feed', label: 'Лента', icon: 'grid' },
|
||||
{ path: '/matches', label: 'Совпадения', icon: 'heart' },
|
||||
{ path: '/chats', label: 'Чаты', icon: 'chat' },
|
||||
{ path: '/dates', label: 'Встречи', icon: 'calendar' },
|
||||
{ path: '/profile/me', label: 'Профиль', icon: 'person' },
|
||||
];
|
||||
|
||||
function isActive(path: string) {
|
||||
return route.path.startsWith(path);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="bottom-nav" aria-label="Навигация">
|
||||
<RouterLink
|
||||
@@ -36,6 +17,24 @@ function isActive(path: string) {
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const navItems = [
|
||||
{ path: '/feed', label: 'Лента', icon: 'grid' },
|
||||
{ path: '/matches', label: 'Совпадения', icon: 'heart' },
|
||||
{ path: '/chats', label: 'Чаты', icon: 'chat' },
|
||||
{ path: '/dates', label: 'Встречи', icon: 'calendar' },
|
||||
{ path: '/profile/me', label: 'Профиль', icon: 'person' },
|
||||
]
|
||||
|
||||
function isActive(path: string) {
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
const BottomNavIcon = {
|
||||
props: { name: String },
|
||||
@@ -48,7 +47,7 @@ const BottomNavIcon = {
|
||||
<path v-if="name==='person'" 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>
|
||||
`,
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,46 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useAuth } from '@/composables/useAuth';
|
||||
import { useUi } from '@/composables/useUi';
|
||||
|
||||
const route = useRoute();
|
||||
const authStore = useAuth();
|
||||
const uiStore = useUi();
|
||||
|
||||
interface NavItem {
|
||||
name: string;
|
||||
path: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
adminOnly?: boolean;
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ name: 'feed', path: '/feed', label: 'Лента', icon: 'grid' },
|
||||
{ name: 'matches', path: '/matches', label: 'Совпадения', icon: 'heart' },
|
||||
{ name: 'chats', path: '/chats', label: 'Чаты', icon: 'chat' },
|
||||
{ name: 'dates', path: '/dates', label: 'Встречи', icon: 'calendar' },
|
||||
{ name: 'profile', path: '/profile/me', label: 'Профиль', icon: 'person' },
|
||||
];
|
||||
|
||||
const adminItems: NavItem[] = [
|
||||
{ name: 'admin', path: '/admin/reports', label: 'Жалобы', icon: 'flag', adminOnly: true },
|
||||
];
|
||||
|
||||
const visibleItems = computed(() =>
|
||||
authStore.isAdmin ? [...navItems, ...adminItems] : navItems,
|
||||
);
|
||||
|
||||
function isActive(path: string) {
|
||||
return route.path.startsWith(path) && path !== '/';
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
uiStore.setSidebarExpanded(!uiStore.sidebarExpanded);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="sidenav"
|
||||
@@ -76,8 +33,8 @@ function toggle() {
|
||||
<div class="sidenav__footer">
|
||||
<button
|
||||
class="sidenav__toggle"
|
||||
@click="toggle"
|
||||
:aria-label="uiStore.sidebarExpanded ? 'Свернуть меню' : 'Развернуть меню'"
|
||||
@click="toggle"
|
||||
>
|
||||
<span class="sidenav__icon">
|
||||
<NavIcon :name="uiStore.sidebarExpanded ? 'chevron-left' : 'chevron-right'" />
|
||||
@@ -87,6 +44,49 @@ function toggle() {
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useUi } from '@/composables/useUi'
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuth()
|
||||
const uiStore = useUi()
|
||||
|
||||
interface NavItem {
|
||||
name: string
|
||||
path: string
|
||||
label: string
|
||||
icon: string
|
||||
adminOnly?: boolean
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ name: 'feed', path: '/feed', label: 'Лента', icon: 'grid' },
|
||||
{ name: 'matches', path: '/matches', label: 'Совпадения', icon: 'heart' },
|
||||
{ name: 'chats', path: '/chats', label: 'Чаты', icon: 'chat' },
|
||||
{ name: 'dates', path: '/dates', label: 'Встречи', icon: 'calendar' },
|
||||
{ name: 'profile', path: '/profile/me', label: 'Профиль', icon: 'person' },
|
||||
]
|
||||
|
||||
const adminItems: NavItem[] = [
|
||||
{ name: 'admin', path: '/admin/reports', label: 'Жалобы', icon: 'flag', adminOnly: true },
|
||||
]
|
||||
|
||||
const visibleItems = computed(() =>
|
||||
authStore.isAdmin ? [...navItems, ...adminItems] : navItems,
|
||||
)
|
||||
|
||||
function isActive(path: string) {
|
||||
return route.path.startsWith(path) && path !== '/'
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
uiStore.setSidebarExpanded(!uiStore.sidebarExpanded)
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
// Inline icon renderer to avoid external icon library dependency
|
||||
const NavIcon = {
|
||||
@@ -103,7 +103,7 @@ const NavIcon = {
|
||||
<path v-if="name==='chevron-left'" d="M15 18l-6-6 6-6"/>
|
||||
</svg>
|
||||
`,
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -165,7 +165,9 @@ const NavIcon = {
|
||||
padding: 0 18px;
|
||||
color: var(--color-muted);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
background var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
@@ -220,7 +222,9 @@ const NavIcon = {
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast);
|
||||
|
||||
&:hover { color: var(--color-cream); }
|
||||
&:hover {
|
||||
color: var(--color-cream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
const isTauri = typeof window !== 'undefined' && !!window.__TAURI__;
|
||||
|
||||
async function minimize() {
|
||||
if (!isTauri) return;
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
await getCurrentWindow().minimize();
|
||||
}
|
||||
|
||||
async function maximize() {
|
||||
if (!isTauri) return;
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
const win = getCurrentWindow();
|
||||
const isMax = await win.isMaximized();
|
||||
if (isMax) await win.unmaximize();
|
||||
else await win.maximize();
|
||||
}
|
||||
|
||||
async function closeWindow() {
|
||||
if (!isTauri) return;
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
await getCurrentWindow().close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isTauri"
|
||||
@@ -33,13 +6,42 @@ async function closeWindow() {
|
||||
>
|
||||
<span class="titlebar__brand">Dating</span>
|
||||
<div class="titlebar__controls">
|
||||
<button class="titlebar__btn titlebar__btn--minimize" @click="minimize" aria-label="Свернуть" />
|
||||
<button class="titlebar__btn titlebar__btn--maximize" @click="maximize" aria-label="Развернуть" />
|
||||
<button class="titlebar__btn titlebar__btn--close" @click="closeWindow" aria-label="Закрыть" />
|
||||
<button class="titlebar__btn titlebar__btn--minimize" aria-label="Свернуть" @click="minimize" />
|
||||
<button class="titlebar__btn titlebar__btn--maximize" aria-label="Развернуть" @click="maximize" />
|
||||
<button class="titlebar__btn titlebar__btn--close" aria-label="Закрыть" @click="closeWindow" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const isTauri = typeof window !== 'undefined' && !!window.__TAURI__
|
||||
|
||||
async function minimize() {
|
||||
if (!isTauri)
|
||||
return
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||
await getCurrentWindow().minimize()
|
||||
}
|
||||
|
||||
async function maximize() {
|
||||
if (!isTauri)
|
||||
return
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||
const win = getCurrentWindow()
|
||||
const isMax = await win.isMaximized()
|
||||
if (isMax)
|
||||
await win.unmaximize()
|
||||
else await win.maximize()
|
||||
}
|
||||
|
||||
async function closeWindow() {
|
||||
if (!isTauri)
|
||||
return
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||
await getCurrentWindow().close()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.titlebar {
|
||||
height: var(--titlebar-height);
|
||||
@@ -75,11 +77,19 @@ async function closeWindow() {
|
||||
transition: opacity var(--transition-fast);
|
||||
padding: 0;
|
||||
|
||||
&--minimize { background: #f5a623; }
|
||||
&--maximize { background: #7ed321; }
|
||||
&--close { background: #d0021b; }
|
||||
&--minimize {
|
||||
background: #f5a623;
|
||||
}
|
||||
&--maximize {
|
||||
background: #7ed321;
|
||||
}
|
||||
&--close {
|
||||
background: #d0021b;
|
||||
}
|
||||
|
||||
&:hover { opacity: 0.8; }
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-signal);
|
||||
outline-offset: 2px;
|
||||
|
||||
@@ -1,85 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useUi } from '@/composables/useUi';
|
||||
import { apiClient } from '@/api/client';
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
url: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{ profileId: string; editable?: boolean }>();
|
||||
const emit = defineEmits<{ updated: [] }>();
|
||||
|
||||
const uiStore = useUi();
|
||||
const items = ref<MediaItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const uploading = ref(false);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const lightboxUrl = ref<string | null>(null);
|
||||
|
||||
async function loadMedia() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.api.mediaControllerGetMedia(props.profileId) as unknown as MediaItem[];
|
||||
items.value = res;
|
||||
} catch { /* ignore */ }
|
||||
finally { loading.value = false; }
|
||||
}
|
||||
|
||||
loadMedia();
|
||||
|
||||
const isTauri = typeof window !== 'undefined' && !!window.__TAURI__;
|
||||
|
||||
async function upload() {
|
||||
if (isTauri) {
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||
const result = await open({ filters: [{ name: 'Изображения', extensions: ['jpg', 'jpeg', 'png', 'webp'] }] });
|
||||
if (typeof result === 'string') {
|
||||
await doUpload(null, result);
|
||||
}
|
||||
} catch { /* cancelled */ }
|
||||
} else {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
}
|
||||
|
||||
async function onFileChange(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
await doUpload(file);
|
||||
if (fileInput.value) fileInput.value.value = '';
|
||||
}
|
||||
|
||||
async function doUpload(file: File | null, tauriPath?: string) {
|
||||
uploading.value = true;
|
||||
try {
|
||||
// API expects multipart/form-data with the file; we pass query type=photo
|
||||
await apiClient.api.mediaControllerUpload({ profileId: props.profileId, type: 'photo' });
|
||||
uiStore.addToast('Фото загружено', 'success');
|
||||
await loadMedia();
|
||||
emit('updated');
|
||||
} catch {
|
||||
uiStore.addToast('Не удалось загрузить фото', 'error');
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMedia(mediaId: string) {
|
||||
try {
|
||||
await apiClient.api.mediaControllerDeleteMedia(mediaId, props.profileId);
|
||||
items.value = items.value.filter((i) => i.id !== mediaId);
|
||||
emit('updated');
|
||||
uiStore.addToast('Фото удалено', 'success');
|
||||
} catch {
|
||||
uiStore.addToast('Не удалось удалить фото', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gallery">
|
||||
<div class="gallery__grid">
|
||||
@@ -88,11 +6,11 @@ async function deleteMedia(mediaId: string) {
|
||||
v-if="editable"
|
||||
class="gallery__upload"
|
||||
:disabled="uploading"
|
||||
@click="upload"
|
||||
aria-label="Добавить фото"
|
||||
@click="upload"
|
||||
>
|
||||
<svg v-if="!uploading" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="24" height="24">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
<div v-else class="gallery__spinner" />
|
||||
</button>
|
||||
@@ -109,33 +27,123 @@ async function deleteMedia(mediaId: string) {
|
||||
alt="Медиа"
|
||||
loading="lazy"
|
||||
@click="lightboxUrl = item.url"
|
||||
/>
|
||||
>
|
||||
<button
|
||||
v-if="editable"
|
||||
class="gallery__delete"
|
||||
@click.stop="deleteMedia(item.id)"
|
||||
aria-label="Удалить фото"
|
||||
@click.stop="deleteMedia(item.id)"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12">
|
||||
<path d="M12 4L4 12M4 4l8 8"/>
|
||||
<path d="M12 4L4 12M4 4l8 8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input ref="fileInput" type="file" accept="image/*" class="sr-only" @change="onFileChange" />
|
||||
<input ref="fileInput" type="file" accept="image/*" class="sr-only" @change="onFileChange">
|
||||
|
||||
<!-- Lightbox -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="lightboxUrl" class="lightbox" @click="lightboxUrl = null">
|
||||
<img :src="lightboxUrl" class="lightbox__img" alt="Фото" />
|
||||
<img :src="lightboxUrl" class="lightbox__img" alt="Фото">
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { apiClient } from '@/api/client'
|
||||
import { useUi } from '@/composables/useUi'
|
||||
|
||||
interface MediaItem {
|
||||
id: string
|
||||
url: string
|
||||
type: string
|
||||
}
|
||||
|
||||
const props = defineProps<{ profileId: string, editable?: boolean }>()
|
||||
const emit = defineEmits<{ updated: [] }>()
|
||||
|
||||
const uiStore = useUi()
|
||||
const items = ref<MediaItem[]>([])
|
||||
const loading = ref(false)
|
||||
const uploading = ref(false)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const lightboxUrl = ref<string | null>(null)
|
||||
|
||||
async function loadMedia() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await apiClient.api.mediaControllerGetMedia(props.profileId) as unknown as MediaItem[]
|
||||
items.value = res
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
loadMedia()
|
||||
|
||||
const isTauri = typeof window !== 'undefined' && !!window.__TAURI__
|
||||
|
||||
async function upload() {
|
||||
if (isTauri) {
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog')
|
||||
const result = await open({ filters: [{ name: 'Изображения', extensions: ['jpg', 'jpeg', 'png', 'webp'] }] })
|
||||
if (typeof result === 'string') {
|
||||
await doUpload(null, result)
|
||||
}
|
||||
}
|
||||
catch { /* cancelled */ }
|
||||
}
|
||||
else {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
}
|
||||
|
||||
async function onFileChange(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file)
|
||||
return
|
||||
await doUpload(file)
|
||||
if (fileInput.value)
|
||||
fileInput.value.value = ''
|
||||
}
|
||||
|
||||
async function doUpload(file: File | null, tauriPath?: string) {
|
||||
uploading.value = true
|
||||
try {
|
||||
// API expects multipart/form-data with the file; we pass query type=photo
|
||||
await apiClient.api.mediaControllerUpload({ profileId: props.profileId, type: 'photo' })
|
||||
uiStore.addToast('Фото загружено', 'success')
|
||||
await loadMedia()
|
||||
emit('updated')
|
||||
}
|
||||
catch {
|
||||
uiStore.addToast('Не удалось загрузить фото', 'error')
|
||||
}
|
||||
finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMedia(mediaId: string) {
|
||||
try {
|
||||
await apiClient.api.mediaControllerDeleteMedia(mediaId, props.profileId)
|
||||
items.value = items.value.filter(i => i.id !== mediaId)
|
||||
emit('updated')
|
||||
uiStore.addToast('Фото удалено', 'success')
|
||||
}
|
||||
catch {
|
||||
uiStore.addToast('Не удалось удалить фото', 'error')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.gallery {
|
||||
&__grid {
|
||||
@@ -155,7 +163,9 @@ async function deleteMedia(mediaId: string) {
|
||||
overflow: hidden;
|
||||
background: var(--color-surface-2);
|
||||
|
||||
&:hover .gallery__delete { opacity: 1; }
|
||||
&:hover .gallery__delete {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__img {
|
||||
@@ -165,7 +175,9 @@ async function deleteMedia(mediaId: string) {
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-fast);
|
||||
|
||||
&:hover { transform: scale(1.04); }
|
||||
&:hover {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
&__delete {
|
||||
@@ -183,9 +195,13 @@ async function deleteMedia(mediaId: string) {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast), background var(--transition-fast);
|
||||
transition:
|
||||
opacity var(--transition-fast),
|
||||
background var(--transition-fast);
|
||||
|
||||
&:hover { background: var(--color-signal); }
|
||||
&:hover {
|
||||
background: var(--color-signal);
|
||||
}
|
||||
}
|
||||
|
||||
&__upload {
|
||||
@@ -205,7 +221,10 @@ async function deleteMedia(mediaId: string) {
|
||||
color: var(--color-cream);
|
||||
}
|
||||
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
@@ -236,8 +255,18 @@ async function deleteMedia(mediaId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity var(--transition-base); }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity var(--transition-base);
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,81 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch, ref } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, helpers } from '@vuelidate/validators';
|
||||
import { useUi } from '@/composables/useUi';
|
||||
import { useAuth } from '@/composables/useAuth';
|
||||
import { useProfile } from '@/composables/useProfile';
|
||||
import { apiClient } from '@/api/client';
|
||||
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';
|
||||
|
||||
const props = defineProps<{ profile: UserProfile }>();
|
||||
const emit = defineEmits<{ saved: [UserProfile]; cancel: [] }>();
|
||||
|
||||
const uiStore = useUi();
|
||||
const authStore = useAuth();
|
||||
const profileStore = useProfile();
|
||||
|
||||
const form = reactive({
|
||||
name: props.profile.name,
|
||||
birthDate: props.profile.birthDate,
|
||||
gender: props.profile.gender ?? 'female',
|
||||
cityId: props.profile.cityId ?? '',
|
||||
districtId: props.profile.districtId ?? '',
|
||||
description: props.profile.description ?? '',
|
||||
nation: props.profile.nation ?? '',
|
||||
height: props.profile.height ?? undefined as number | undefined,
|
||||
weight: props.profile.weight ?? undefined as number | undefined,
|
||||
tagIds: props.profile.tags?.map((t) => t.id) ?? [],
|
||||
});
|
||||
|
||||
const districts = ref<District[]>([]);
|
||||
|
||||
watch(() => form.cityId, async (cityId) => {
|
||||
if (!cityId) { districts.value = []; return; }
|
||||
if (uiStore.districts[cityId]) { districts.value = uiStore.districts[cityId]; return; }
|
||||
try {
|
||||
const res = await apiClient.api.citiesControllerFindDistricts(cityId) as unknown as District[];
|
||||
uiStore.setDistricts(cityId, res);
|
||||
districts.value = res;
|
||||
} catch { /* ignore */ }
|
||||
}, { immediate: true });
|
||||
|
||||
const rules = {
|
||||
name: { required: helpers.withMessage('Введите имя', required) },
|
||||
birthDate: { required: helpers.withMessage('Введите дату рождения', required) },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(rules, form);
|
||||
const loading = ref(false);
|
||||
|
||||
function toggleTag(tagId: string) {
|
||||
const idx = form.tagIds.indexOf(tagId);
|
||||
if (idx === -1) form.tagIds.push(tagId);
|
||||
else form.tagIds.splice(idx, 1);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const valid = await v$.value.$validate();
|
||||
if (!valid) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const updated = await profileStore.updateProfile(props.profile.id, form);
|
||||
authStore.updateProfile(updated);
|
||||
uiStore.addToast('Профиль сохранён', 'success');
|
||||
emit('saved', updated);
|
||||
} catch {
|
||||
uiStore.addToast('Не удалось сохранить профиль', 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="profile-editor" @submit.prevent="save" novalidate>
|
||||
<form class="profile-editor" novalidate @submit.prevent="save">
|
||||
<AppInput
|
||||
v-model="form.name"
|
||||
label="Имя"
|
||||
@@ -97,24 +21,36 @@ async function save() {
|
||||
<div class="profile-editor__field">
|
||||
<span class="label">Пол</span>
|
||||
<div class="profile-editor__gender">
|
||||
<button type="button" class="profile-editor__gender-btn" :class="{ active: form.gender === 'female' }" @click="form.gender = 'female'">Женщина</button>
|
||||
<button type="button" class="profile-editor__gender-btn" :class="{ active: form.gender === 'male' }" @click="form.gender = 'male'">Мужчина</button>
|
||||
<button type="button" class="profile-editor__gender-btn" :class="{ active: form.gender === 'female' }" @click="form.gender = 'female'">
|
||||
Женщина
|
||||
</button>
|
||||
<button type="button" class="profile-editor__gender-btn" :class="{ active: form.gender === 'male' }" @click="form.gender = 'male'">
|
||||
Мужчина
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-editor__field">
|
||||
<label class="label" for="city">Город</label>
|
||||
<select id="city" v-model="form.cityId" class="profile-editor__select">
|
||||
<option value="">Не указан</option>
|
||||
<option v-for="c in uiStore.cities" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
<option value="">
|
||||
Не указан
|
||||
</option>
|
||||
<option v-for="c in uiStore.cities" :key="c.id" :value="c.id">
|
||||
{{ c.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="form.cityId" class="profile-editor__field">
|
||||
<label class="label" for="district">Район</label>
|
||||
<select id="district" v-model="form.districtId" class="profile-editor__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>
|
||||
|
||||
@@ -123,7 +59,7 @@ async function save() {
|
||||
|
||||
<div class="profile-editor__row">
|
||||
<AppInput v-model.number="(form.height as unknown as string)" label="Рост (см)" type="number" name="height" placeholder="170" />
|
||||
<AppInput v-model.number="(form.weight as unknown as string)" label="Вес (кг)" type="number" name="weight" placeholder="60" />
|
||||
<AppInput v-model.number="(form.weight as unknown as string)" label="Вес (кг)" type="number" name="weight" placeholder="60" />
|
||||
</div>
|
||||
|
||||
<div class="profile-editor__field">
|
||||
@@ -136,17 +72,104 @@ async function save() {
|
||||
class="profile-editor__tag"
|
||||
:class="{ 'profile-editor__tag--active': form.tagIds.includes(tag.id) }"
|
||||
@click="toggleTag(tag.id)"
|
||||
>{{ tag.value }}</button>
|
||||
>
|
||||
{{ tag.value }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-editor__actions">
|
||||
<AppButton type="button" variant="ghost" @click="emit('cancel')">Отмена</AppButton>
|
||||
<AppButton type="submit" :loading="loading">Сохранить</AppButton>
|
||||
<AppButton type="button" variant="ghost" @click="emit('cancel')">
|
||||
Отмена
|
||||
</AppButton>
|
||||
<AppButton type="submit" :loading="loading">
|
||||
Сохранить
|
||||
</AppButton>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UserProfile } from '@/composables/useAuth'
|
||||
import type { District } from '@/composables/useUi'
|
||||
import { useVuelidate } from '@vuelidate/core'
|
||||
import { helpers, required } from '@vuelidate/validators'
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { apiClient } from '@/api/client'
|
||||
import AppButton from '@/components/common/AppButton.vue'
|
||||
import AppInput from '@/components/common/AppInput.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useProfile } from '@/composables/useProfile'
|
||||
import { useUi } from '@/composables/useUi'
|
||||
|
||||
const props = defineProps<{ profile: UserProfile }>()
|
||||
const emit = defineEmits<{ saved: [UserProfile], cancel: [] }>()
|
||||
|
||||
const uiStore = useUi()
|
||||
const authStore = useAuth()
|
||||
const profileStore = useProfile()
|
||||
|
||||
const form = reactive({
|
||||
name: props.profile.name,
|
||||
birthDate: props.profile.birthDate,
|
||||
gender: props.profile.gender ?? 'female',
|
||||
cityId: props.profile.cityId ?? '',
|
||||
districtId: props.profile.districtId ?? '',
|
||||
description: props.profile.description ?? '',
|
||||
nation: props.profile.nation ?? '',
|
||||
height: props.profile.height ?? undefined as number | undefined,
|
||||
weight: props.profile.weight ?? undefined as number | undefined,
|
||||
tagIds: props.profile.tags?.map(t => t.id) ?? [],
|
||||
})
|
||||
|
||||
const districts = ref<District[]>([])
|
||||
|
||||
watch(() => form.cityId, async (cityId) => {
|
||||
if (!cityId) { districts.value = []; return }
|
||||
if (uiStore.districts[cityId]) { districts.value = uiStore.districts[cityId]; return }
|
||||
try {
|
||||
const res = await apiClient.api.citiesControllerFindDistricts(cityId) as unknown as District[]
|
||||
uiStore.setDistricts(cityId, res)
|
||||
districts.value = res
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}, { immediate: true })
|
||||
|
||||
const rules = {
|
||||
name: { required: helpers.withMessage('Введите имя', required) },
|
||||
birthDate: { required: helpers.withMessage('Введите дату рождения', required) },
|
||||
}
|
||||
|
||||
const v$ = useVuelidate(rules, form)
|
||||
const loading = ref(false)
|
||||
|
||||
function toggleTag(tagId: string) {
|
||||
const idx = form.tagIds.indexOf(tagId)
|
||||
if (idx === -1)
|
||||
form.tagIds.push(tagId)
|
||||
else form.tagIds.splice(idx, 1)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const valid = await v$.value.$validate()
|
||||
if (!valid)
|
||||
return
|
||||
loading.value = true
|
||||
try {
|
||||
const updated = await profileStore.updateProfile(props.profile.id, form)
|
||||
authStore.updateProfile(updated)
|
||||
uiStore.addToast('Профиль сохранён', 'success')
|
||||
emit('saved', updated)
|
||||
}
|
||||
catch {
|
||||
uiStore.addToast('Не удалось сохранить профиль', 'error')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.profile-editor {
|
||||
display: flex;
|
||||
@@ -163,7 +186,8 @@ async function save() {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
&-btn, .profile-editor__gender-btn {
|
||||
&-btn,
|
||||
.profile-editor__gender-btn {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
background: var(--color-surface-2);
|
||||
@@ -196,7 +220,9 @@ async function save() {
|
||||
appearance: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
|
||||
&:focus { border-color: var(--color-signal); }
|
||||
&:focus {
|
||||
border-color: var(--color-signal);
|
||||
}
|
||||
}
|
||||
|
||||
&__row {
|
||||
|
||||
@@ -1,51 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useAuth } from '@/composables/useAuth';
|
||||
import { useUi } from '@/composables/useUi';
|
||||
import { apiClient } from '@/api/client';
|
||||
import AppModal from '@/components/common/AppModal.vue';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
entityId: string;
|
||||
entityType: 'profile' | 'message';
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
const authStore = useAuth();
|
||||
const uiStore = useUi();
|
||||
|
||||
const form = reactive({ description: '' });
|
||||
const loading = ref(false);
|
||||
|
||||
async function submit() {
|
||||
const profileId = authStore.activeProfile?.id;
|
||||
if (!profileId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
await apiClient.api.reportsControllerCreate({
|
||||
sourceProfileId: profileId,
|
||||
entityId: props.entityId,
|
||||
entityType: props.entityType,
|
||||
description: form.description || undefined,
|
||||
});
|
||||
uiStore.addToast('Жалоба отправлена', 'success');
|
||||
form.description = '';
|
||||
emit('close');
|
||||
} catch {
|
||||
uiStore.addToast('Не удалось отправить жалобу', 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppModal :open="open" title="Пожаловаться" size="sm" @close="emit('close')">
|
||||
<form class="report-form" @submit.prevent="submit">
|
||||
<p class="report-form__label label">Опишите причину жалобы (необязательно)</p>
|
||||
<p class="report-form__label label">
|
||||
Опишите причину жалобы (необязательно)
|
||||
</p>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
class="report-form__textarea"
|
||||
@@ -54,19 +12,72 @@ async function submit() {
|
||||
/>
|
||||
</form>
|
||||
<template #footer>
|
||||
<AppButton variant="ghost" @click="emit('close')">Отмена</AppButton>
|
||||
<AppButton variant="danger" :loading="loading" @click="submit">Отправить жалобу</AppButton>
|
||||
<AppButton variant="ghost" @click="emit('close')">
|
||||
Отмена
|
||||
</AppButton>
|
||||
<AppButton variant="danger" :loading="loading" @click="submit">
|
||||
Отправить жалобу
|
||||
</AppButton>
|
||||
</template>
|
||||
</AppModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { apiClient } from '@/api/client'
|
||||
import AppButton from '@/components/common/AppButton.vue'
|
||||
import AppModal from '@/components/common/AppModal.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useUi } from '@/composables/useUi'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
entityId: string
|
||||
entityType: 'profile' | 'message'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const authStore = useAuth()
|
||||
const uiStore = useUi()
|
||||
|
||||
const form = reactive({ description: '' })
|
||||
const loading = ref(false)
|
||||
|
||||
async function submit() {
|
||||
const profileId = authStore.activeProfile?.id
|
||||
if (!profileId)
|
||||
return
|
||||
loading.value = true
|
||||
try {
|
||||
await apiClient.api.reportsControllerCreate({
|
||||
sourceProfileId: profileId,
|
||||
entityId: props.entityId,
|
||||
entityType: props.entityType,
|
||||
description: form.description || undefined,
|
||||
})
|
||||
uiStore.addToast('Жалоба отправлена', 'success')
|
||||
form.description = ''
|
||||
emit('close')
|
||||
}
|
||||
catch {
|
||||
uiStore.addToast('Не удалось отправить жалобу', 'error')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.report-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
&__label { color: var(--color-muted); }
|
||||
&__label {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
&__textarea {
|
||||
width: 100%;
|
||||
@@ -82,8 +93,12 @@ async function submit() {
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
|
||||
&::placeholder { color: var(--color-muted); }
|
||||
&:focus { border-color: var(--color-border-strong); }
|
||||
&::placeholder {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
&:focus {
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,110 +1,112 @@
|
||||
import { ref, computed, reactive } from 'vue';
|
||||
import { apiClient, _setAccessToken, _clearAuth } from '@/api/client';
|
||||
import type { LoginDto, RegisterDto, TagDto, MediaItemDto } from '@/api/api';
|
||||
import type { LoginDto, MediaItemDto, RegisterDto, TagDto } from '@/api/api'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { _clearAuth, _setAccessToken, apiClient } from '@/api/client'
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
birthDate: string;
|
||||
gender: 'male' | 'female';
|
||||
cityId?: string | null;
|
||||
districtId?: string | null;
|
||||
description?: string | null;
|
||||
nation?: string | null;
|
||||
height?: number | null;
|
||||
weight?: number | null;
|
||||
activeChatId?: string | null;
|
||||
tags: TagDto[];
|
||||
media: MediaItemDto[];
|
||||
id: string
|
||||
userId: string
|
||||
name: string
|
||||
birthDate: string
|
||||
gender: 'male' | 'female'
|
||||
cityId?: string | null
|
||||
districtId?: string | null
|
||||
description?: string | null
|
||||
nation?: string | null
|
||||
height?: number | null
|
||||
weight?: number | null
|
||||
activeChatId?: string | null
|
||||
tags: TagDto[]
|
||||
media: MediaItemDto[]
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
phone: string;
|
||||
status: 'active' | 'banned' | 'pending';
|
||||
roleId?: string | null;
|
||||
role?: { id: string; name: string } | null;
|
||||
profiles: UserProfile[];
|
||||
id: string
|
||||
phone: string
|
||||
status: 'active' | 'banned' | 'pending'
|
||||
roleId?: string | null
|
||||
role?: { id: string, name: string } | null
|
||||
profiles: UserProfile[]
|
||||
}
|
||||
|
||||
const user = ref<AuthUser | null>(null);
|
||||
const activeProfileId = ref<string | null>(null);
|
||||
const user = ref<AuthUser | null>(null)
|
||||
const activeProfileId = ref<string | null>(null)
|
||||
|
||||
const isAuthenticated = computed(() => !!user.value);
|
||||
const isAdmin = computed(() => user.value?.role?.name === 'admin');
|
||||
const profiles = computed(() => user.value?.profiles ?? []);
|
||||
const isAuthenticated = computed(() => !!user.value)
|
||||
const isAdmin = computed(() => user.value?.role?.name === 'admin')
|
||||
const profiles = computed(() => user.value?.profiles ?? [])
|
||||
const activeProfile = computed(() =>
|
||||
profiles.value.find((p) => p.id === activeProfileId.value) ?? profiles.value[0] ?? null,
|
||||
);
|
||||
const hasProfiles = computed(() => profiles.value.length > 0);
|
||||
profiles.value.find(p => p.id === activeProfileId.value) ?? profiles.value[0] ?? null,
|
||||
)
|
||||
const hasProfiles = computed(() => profiles.value.length > 0)
|
||||
|
||||
async function login(dto: LoginDto) {
|
||||
const res = await apiClient.api.authControllerLogin(dto) as unknown as {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
};
|
||||
_setAccessToken(res.accessToken);
|
||||
localStorage.setItem('refreshToken', res.refreshToken);
|
||||
await fetchMe();
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
}
|
||||
_setAccessToken(res.accessToken)
|
||||
localStorage.setItem('refreshToken', res.refreshToken)
|
||||
await fetchMe()
|
||||
}
|
||||
|
||||
async function register(dto: RegisterDto) {
|
||||
const res = await apiClient.api.authControllerRegister(dto) as unknown as {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
};
|
||||
_setAccessToken(res.accessToken);
|
||||
localStorage.setItem('refreshToken', res.refreshToken);
|
||||
await fetchMe();
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
}
|
||||
_setAccessToken(res.accessToken)
|
||||
localStorage.setItem('refreshToken', res.refreshToken)
|
||||
await fetchMe()
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await apiClient.api.authControllerLogout();
|
||||
} catch {
|
||||
await apiClient.api.authControllerLogout()
|
||||
}
|
||||
catch {
|
||||
// ignore errors on logout
|
||||
}
|
||||
_clearAuth();
|
||||
user.value = null;
|
||||
activeProfileId.value = null;
|
||||
_clearAuth()
|
||||
user.value = null
|
||||
activeProfileId.value = null
|
||||
}
|
||||
|
||||
async function fetchMe() {
|
||||
const [meRes, profilesRes] = await Promise.all([
|
||||
apiClient.api.usersControllerGetMe(),
|
||||
apiClient.api.profilesControllerGetMyProfiles(),
|
||||
]);
|
||||
const fullProfiles = profilesRes as unknown as UserProfile[];
|
||||
user.value = { ...meRes, profiles: fullProfiles } as unknown as AuthUser;
|
||||
])
|
||||
const fullProfiles = profilesRes as unknown as UserProfile[]
|
||||
user.value = { ...meRes, profiles: fullProfiles } as unknown as AuthUser
|
||||
if (fullProfiles.length > 0 && !activeProfileId.value) {
|
||||
activeProfileId.value = fullProfiles[0].id;
|
||||
activeProfileId.value = fullProfiles[0].id
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveProfile(profileId: string) {
|
||||
activeProfileId.value = profileId;
|
||||
activeProfileId.value = profileId
|
||||
}
|
||||
|
||||
function addProfile(profile: UserProfile) {
|
||||
if (user.value) {
|
||||
user.value.profiles.push(profile);
|
||||
activeProfileId.value = profile.id;
|
||||
user.value.profiles.push(profile)
|
||||
activeProfileId.value = profile.id
|
||||
}
|
||||
}
|
||||
|
||||
function updateProfile(updated: UserProfile) {
|
||||
if (user.value) {
|
||||
const idx = user.value.profiles.findIndex((p) => p.id === updated.id);
|
||||
if (idx !== -1) user.value.profiles[idx] = updated;
|
||||
const idx = user.value.profiles.findIndex(p => p.id === updated.id)
|
||||
if (idx !== -1)
|
||||
user.value.profiles[idx] = updated
|
||||
}
|
||||
}
|
||||
|
||||
function removeProfile(profileId: string) {
|
||||
if (user.value) {
|
||||
user.value.profiles = user.value.profiles.filter((p) => p.id !== profileId);
|
||||
user.value.profiles = user.value.profiles.filter(p => p.id !== profileId)
|
||||
if (activeProfileId.value === profileId) {
|
||||
activeProfileId.value = user.value.profiles[0]?.id ?? null;
|
||||
activeProfileId.value = user.value.profiles[0]?.id ?? null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,5 +128,5 @@ export function useAuth() {
|
||||
addProfile,
|
||||
updateProfile,
|
||||
removeProfile,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,102 +1,106 @@
|
||||
import { ref, reactive } from 'vue';
|
||||
import { apiClient } from '@/api/client';
|
||||
import type { SendMessageDto } from '@/api/api';
|
||||
import type { SendMessageDto } from '@/api/api'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { apiClient } from '@/api/client'
|
||||
|
||||
export interface ChatProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
id: string
|
||||
name: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
export interface Chat {
|
||||
id: string;
|
||||
profile1Id: string;
|
||||
profile2Id: string;
|
||||
status: 'active' | 'closed';
|
||||
partner?: ChatProfile;
|
||||
lastMessage?: ChatMessage;
|
||||
unreadCount?: number;
|
||||
createdAt?: string;
|
||||
id: string
|
||||
profile1Id: string
|
||||
profile2Id: string
|
||||
status: 'active' | 'closed'
|
||||
partner?: ChatProfile
|
||||
lastMessage?: ChatMessage
|
||||
unreadCount?: number
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
chatId: string;
|
||||
profileId: string;
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
mediaType?: 'photo' | 'voice' | 'video';
|
||||
createdAt: string;
|
||||
id: string
|
||||
chatId: string
|
||||
profileId: string
|
||||
text?: string
|
||||
mediaUrl?: string
|
||||
mediaType?: 'photo' | 'voice' | 'video'
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// Polling interval — replace with WebSocket when backend supports it
|
||||
const POLL_INTERVAL = 2000;
|
||||
const POLL_INTERVAL = 2000
|
||||
|
||||
const chats = ref<Chat[]>([]);
|
||||
const activeChat = ref<Chat | null>(null);
|
||||
const messages = ref<ChatMessage[]>([]);
|
||||
const pollingTimer = ref<ReturnType<typeof setInterval> | null>(null);
|
||||
const loading = ref(false);
|
||||
const chats = ref<Chat[]>([])
|
||||
const activeChat = ref<Chat | null>(null)
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const pollingTimer = ref<ReturnType<typeof setInterval> | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchChats(profileId: string) {
|
||||
const res = await apiClient.api.chatControllerGetChats({ profileId }) as unknown as Chat[];
|
||||
chats.value = res;
|
||||
const res = await apiClient.api.chatControllerGetChats({ profileId }) as unknown as Chat[]
|
||||
chats.value = res
|
||||
}
|
||||
|
||||
async function fetchMessages(chatId: string, profileId: string) {
|
||||
loading.value = true;
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await apiClient.api.chatControllerGetMessages({ chatId, profileId }) as unknown as ChatMessage[];
|
||||
messages.value = res;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
const res = await apiClient.api.chatControllerGetMessages({ chatId, profileId }) as unknown as ChatMessage[]
|
||||
messages.value = res
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage(chatId: string, profileId: string, dto: SendMessageDto) {
|
||||
const res = await apiClient.api.chatControllerSendMessage({ chatId, profileId }, dto) as unknown as ChatMessage;
|
||||
messages.value.push(res);
|
||||
return res;
|
||||
const res = await apiClient.api.chatControllerSendMessage({ chatId, profileId }, dto) as unknown as ChatMessage
|
||||
messages.value.push(res)
|
||||
return res
|
||||
}
|
||||
|
||||
async function openChat(profileId: string, matchId: string) {
|
||||
const res = await apiClient.api.chatControllerCreateChat({ profileId, matchId }) as unknown as Chat;
|
||||
const existing = chats.value.findIndex((c) => c.id === res.id);
|
||||
if (existing === -1) chats.value.unshift(res);
|
||||
activeChat.value = res;
|
||||
return res;
|
||||
const res = await apiClient.api.chatControllerCreateChat({ profileId, matchId }) as unknown as Chat
|
||||
const existing = chats.value.findIndex(c => c.id === res.id)
|
||||
if (existing === -1)
|
||||
chats.value.unshift(res)
|
||||
activeChat.value = res
|
||||
return res
|
||||
}
|
||||
|
||||
async function closeChat(chatId: string, profileId: string) {
|
||||
await apiClient.api.chatControllerCloseChat({ chatId, profileId });
|
||||
chats.value = chats.value.filter((c) => c.id !== chatId);
|
||||
if (activeChat.value?.id === chatId) activeChat.value = null;
|
||||
await apiClient.api.chatControllerCloseChat({ chatId, profileId })
|
||||
chats.value = chats.value.filter(c => c.id !== chatId)
|
||||
if (activeChat.value?.id === chatId)
|
||||
activeChat.value = null
|
||||
}
|
||||
|
||||
function startPolling(chatId: string, profileId: string) {
|
||||
stopPolling();
|
||||
stopPolling()
|
||||
// TODO: replace with WebSocket subscription
|
||||
pollingTimer.value = setInterval(async () => {
|
||||
try {
|
||||
const res = await apiClient.api.chatControllerGetMessages({ chatId, profileId }) as unknown as ChatMessage[];
|
||||
const res = await apiClient.api.chatControllerGetMessages({ chatId, profileId }) as unknown as ChatMessage[]
|
||||
if (res.length > messages.value.length) {
|
||||
messages.value = res;
|
||||
messages.value = res
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
catch {
|
||||
// polling errors are silent
|
||||
}
|
||||
}, POLL_INTERVAL);
|
||||
}, POLL_INTERVAL)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollingTimer.value) {
|
||||
clearInterval(pollingTimer.value);
|
||||
pollingTimer.value = null;
|
||||
clearInterval(pollingTimer.value)
|
||||
pollingTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveChat(chat: Chat | null) {
|
||||
activeChat.value = chat;
|
||||
activeChat.value = chat
|
||||
}
|
||||
|
||||
export function useChat() {
|
||||
@@ -113,5 +117,5 @@ export function useChat() {
|
||||
startPolling,
|
||||
stopPolling,
|
||||
setActiveChat,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,66 +1,69 @@
|
||||
import { ref, reactive } from 'vue';
|
||||
import { apiClient } from '@/api/client';
|
||||
import type { FeedControllerGetFeedParams, TagDto, MediaItemDto } from '@/api/api';
|
||||
import type { FeedControllerGetFeedParams, MediaItemDto, TagDto } from '@/api/api'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { apiClient } from '@/api/client'
|
||||
|
||||
export interface FeedProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
birthDate: string;
|
||||
gender: 'male' | 'female';
|
||||
cityId?: string | null;
|
||||
cityName?: string;
|
||||
districtId?: string | null;
|
||||
description?: string | null;
|
||||
nation?: string | null;
|
||||
height?: number | null;
|
||||
weight?: number | null;
|
||||
tags?: TagDto[];
|
||||
media?: MediaItemDto[];
|
||||
id: string
|
||||
name: string
|
||||
birthDate: string
|
||||
gender: 'male' | 'female'
|
||||
cityId?: string | null
|
||||
cityName?: string
|
||||
districtId?: string | null
|
||||
description?: string | null
|
||||
nation?: string | null
|
||||
height?: number | null
|
||||
weight?: number | null
|
||||
tags?: TagDto[]
|
||||
media?: MediaItemDto[]
|
||||
}
|
||||
|
||||
const cards = ref<FeedProfile[]>([]);
|
||||
const filters = reactive<Partial<FeedControllerGetFeedParams>>({});
|
||||
const page = ref(1);
|
||||
const hasMore = ref(true);
|
||||
const searchPaused = ref(false);
|
||||
const loading = ref(false);
|
||||
const cards = ref<FeedProfile[]>([])
|
||||
const filters = reactive<Partial<FeedControllerGetFeedParams>>({})
|
||||
const page = ref(1)
|
||||
const hasMore = ref(true)
|
||||
const searchPaused = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchNextPage(profileId: string) {
|
||||
if (loading.value || !hasMore.value) return;
|
||||
loading.value = true;
|
||||
if (loading.value || !hasMore.value)
|
||||
return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await apiClient.api.feedControllerGetFeed({
|
||||
profileId,
|
||||
page: page.value,
|
||||
limit: 20,
|
||||
...filters,
|
||||
}) as unknown as FeedProfile[];
|
||||
}) as unknown as FeedProfile[]
|
||||
|
||||
if (page.value === 1) cards.value = res;
|
||||
else cards.value.push(...res);
|
||||
if (page.value === 1)
|
||||
cards.value = res
|
||||
else cards.value.push(...res)
|
||||
|
||||
hasMore.value = res.length >= 20;
|
||||
page.value++;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
hasMore.value = res.length >= 20
|
||||
page.value++
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters(newFilters: Partial<FeedControllerGetFeedParams>) {
|
||||
Object.assign(filters, newFilters);
|
||||
reset();
|
||||
Object.assign(filters, newFilters)
|
||||
reset()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
cards.value = [];
|
||||
page.value = 1;
|
||||
hasMore.value = true;
|
||||
cards.value = []
|
||||
page.value = 1
|
||||
hasMore.value = true
|
||||
}
|
||||
|
||||
function removeCard(profileId: string) {
|
||||
cards.value = cards.value.filter((c) => c.id !== profileId);
|
||||
cards.value = cards.value.filter(c => c.id !== profileId)
|
||||
}
|
||||
|
||||
export function useFeed() {
|
||||
return reactive({ cards, filters, page, hasMore, searchPaused, loading, fetchNextPage, applyFilters, reset, removeCard });
|
||||
return reactive({ cards, filters, page, hasMore, searchPaused, loading, fetchNextPage, applyFilters, reset, removeCard })
|
||||
}
|
||||
|
||||
@@ -1,39 +1,40 @@
|
||||
import { ref } from 'vue';
|
||||
import { apiClient } from '@/api/client';
|
||||
import type { CreateProfileDto, UpdateProfileDto } from '@/api/api';
|
||||
import type { UserProfile } from './useAuth';
|
||||
import type { UserProfile } from './useAuth'
|
||||
import type { CreateProfileDto, UpdateProfileDto } from '@/api/api'
|
||||
import { ref } from 'vue'
|
||||
import { apiClient } from '@/api/client'
|
||||
|
||||
const currentProfile = ref<UserProfile | null>(null);
|
||||
const loading = ref(false);
|
||||
const currentProfile = ref<UserProfile | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchProfile(profileId: string) {
|
||||
loading.value = true;
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await apiClient.api.profilesControllerFindOne(profileId) as unknown as UserProfile;
|
||||
currentProfile.value = res;
|
||||
return res;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
const res = await apiClient.api.profilesControllerFindOne(profileId) as unknown as UserProfile
|
||||
currentProfile.value = res
|
||||
return res
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createProfile(dto: CreateProfileDto) {
|
||||
const res = await apiClient.api.profilesControllerCreate(dto) as unknown as UserProfile;
|
||||
currentProfile.value = res;
|
||||
return res;
|
||||
const res = await apiClient.api.profilesControllerCreate(dto) as unknown as UserProfile
|
||||
currentProfile.value = res
|
||||
return res
|
||||
}
|
||||
|
||||
async function updateProfile(profileId: string, dto: UpdateProfileDto) {
|
||||
const res = await apiClient.api.profilesControllerUpdate(profileId, dto) as unknown as UserProfile;
|
||||
currentProfile.value = res;
|
||||
return res;
|
||||
const res = await apiClient.api.profilesControllerUpdate(profileId, dto) as unknown as UserProfile
|
||||
currentProfile.value = res
|
||||
return res
|
||||
}
|
||||
|
||||
async function deleteProfile(profileId: string) {
|
||||
await apiClient.api.profilesControllerDelete(profileId);
|
||||
currentProfile.value = null;
|
||||
await apiClient.api.profilesControllerDelete(profileId)
|
||||
currentProfile.value = null
|
||||
}
|
||||
|
||||
export function useProfile() {
|
||||
return { currentProfile, loading, fetchProfile, createProfile, updateProfile, deleteProfile };
|
||||
return { currentProfile, loading, fetchProfile, createProfile, updateProfile, deleteProfile }
|
||||
}
|
||||
|
||||
@@ -1,65 +1,65 @@
|
||||
import { ref, reactive } from 'vue';
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||
export type ToastType = 'success' | 'error' | 'info' | 'warning'
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
duration?: number;
|
||||
id: string
|
||||
type: ToastType
|
||||
message: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
value: string;
|
||||
id: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface City {
|
||||
id: string;
|
||||
name: string;
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface District {
|
||||
id: string;
|
||||
name: string;
|
||||
cityId: string;
|
||||
id: string
|
||||
name: string
|
||||
cityId: string
|
||||
}
|
||||
|
||||
export interface Greeting {
|
||||
id: string;
|
||||
text: string;
|
||||
id: string
|
||||
text: string
|
||||
}
|
||||
|
||||
const toasts = ref<Toast[]>([]);
|
||||
const sidebarExpanded = ref(false);
|
||||
const tags = ref<Tag[]>([]);
|
||||
const cities = ref<City[]>([]);
|
||||
const districts = reactive<Record<string, District[]>>({});
|
||||
const greetings = ref<Greeting[]>([]);
|
||||
const referencesLoaded = ref(false);
|
||||
const toasts = ref<Toast[]>([])
|
||||
const sidebarExpanded = ref(false)
|
||||
const tags = ref<Tag[]>([])
|
||||
const cities = ref<City[]>([])
|
||||
const districts = reactive<Record<string, District[]>>({})
|
||||
const greetings = ref<Greeting[]>([])
|
||||
const referencesLoaded = ref(false)
|
||||
|
||||
function addToast(message: string, type: ToastType = 'info', duration = 4000) {
|
||||
const id = `${Date.now()}-${Math.random()}`;
|
||||
toasts.value.push({ id, type, message, duration });
|
||||
const id = `${Date.now()}-${Math.random()}`
|
||||
toasts.value.push({ id, type, message, duration })
|
||||
if (duration > 0) {
|
||||
setTimeout(() => removeToast(id), duration);
|
||||
setTimeout(removeToast, duration, id)
|
||||
}
|
||||
return id;
|
||||
return id
|
||||
}
|
||||
|
||||
function removeToast(id: string) {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id);
|
||||
toasts.value = toasts.value.filter(t => t.id !== id)
|
||||
}
|
||||
|
||||
function setSidebarExpanded(value: boolean) {
|
||||
sidebarExpanded.value = value;
|
||||
sidebarExpanded.value = value
|
||||
}
|
||||
|
||||
function setTags(data: Tag[]) { tags.value = data; }
|
||||
function setCities(data: City[]) { cities.value = data; }
|
||||
function setDistricts(cityId: string, data: District[]) { districts[cityId] = data; }
|
||||
function setGreetings(data: Greeting[]) { greetings.value = data; }
|
||||
function setReferencesLoaded() { referencesLoaded.value = true; }
|
||||
function setTags(data: Tag[]) { tags.value = data }
|
||||
function setCities(data: City[]) { cities.value = data }
|
||||
function setDistricts(cityId: string, data: District[]) { districts[cityId] = data }
|
||||
function setGreetings(data: Greeting[]) { greetings.value = data }
|
||||
function setReferencesLoaded() { referencesLoaded.value = true }
|
||||
|
||||
export function useUi() {
|
||||
return reactive({
|
||||
@@ -78,5 +78,5 @@ export function useUi() {
|
||||
setDistricts,
|
||||
setGreetings,
|
||||
setReferencesLoaded,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
16
src/main.ts
16
src/main.ts
@@ -1,10 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import { router } from './router';
|
||||
import App from './App.vue';
|
||||
import '@/styles/tailwind.css';
|
||||
import '@/styles/main.scss';
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { router } from './router'
|
||||
import '@/styles/tailwind.css'
|
||||
import '@/styles/main.scss'
|
||||
|
||||
const app = createApp(App);
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(router);
|
||||
app.mount('#app');
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { useAuth } from '@/composables/useAuth';
|
||||
import { _getAccessToken, _setAccessToken } from '@/api/client';
|
||||
import axios from 'axios';
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000';
|
||||
import axios from 'axios'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { _getAccessToken, _setAccessToken, BASE_URL } from '@/api/client'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -11,26 +9,26 @@ export const router = createRouter({
|
||||
{ path: '/', redirect: () => '/feed' },
|
||||
|
||||
// Auth
|
||||
{ path: '/login', name: 'login', component: () => import('@/views/auth/LoginView.vue'), meta: { guest: true } },
|
||||
{ path: '/login', name: 'login', component: () => import('@/views/auth/LoginView.vue'), meta: { guest: true } },
|
||||
{ path: '/register', name: 'register', component: () => import('@/views/auth/RegisterView.vue'), meta: { guest: true } },
|
||||
|
||||
// Onboarding
|
||||
{ path: '/setup', name: 'setup', component: () => import('@/views/onboarding/ProfileSetupView.vue'), meta: { auth: true } },
|
||||
|
||||
// Main app
|
||||
{ path: '/feed', name: 'feed', component: () => import('@/views/feed/FeedView.vue'), meta: { auth: true } },
|
||||
{ path: '/feed', name: 'feed', component: () => import('@/views/feed/FeedView.vue'), meta: { auth: true } },
|
||||
{ path: '/matches', name: 'matches', component: () => import('@/views/matches/MatchesView.vue'), meta: { auth: true } },
|
||||
|
||||
// Chat
|
||||
{ path: '/chats', name: 'chats', component: () => import('@/views/chat/ChatsListView.vue'), meta: { auth: true } },
|
||||
{ path: '/chats/:chatId', name: 'chat-room', component: () => import('@/views/chat/ChatRoomView.vue'), meta: { auth: true } },
|
||||
{ path: '/chats', name: 'chats', component: () => import('@/views/chat/ChatsListView.vue'), meta: { auth: true } },
|
||||
{ path: '/chats/:chatId', name: 'chat-room', component: () => import('@/views/chat/ChatRoomView.vue'), meta: { auth: true } },
|
||||
|
||||
// Dates
|
||||
{ path: '/dates', name: 'dates', component: () => import('@/views/dates/DatesView.vue'), meta: { auth: true } },
|
||||
|
||||
// Profile
|
||||
{ path: '/profile/me', name: 'my-profile', component: () => import('@/views/profile/MyProfileView.vue'), meta: { auth: true } },
|
||||
{ path: '/profile/:profileId', name: 'profile-detail', component: () => import('@/views/profile/ProfileDetailView.vue'), meta: { auth: true } },
|
||||
{ path: '/profile/me', name: 'my-profile', component: () => import('@/views/profile/MyProfileView.vue'), meta: { auth: true } },
|
||||
{ path: '/profile/:profileId', name: 'profile-detail', component: () => import('@/views/profile/ProfileDetailView.vue'), meta: { auth: true } },
|
||||
|
||||
// Admin
|
||||
{ path: '/admin/reports', name: 'admin-reports', component: () => import('@/views/admin/ReportsView.vue'), meta: { auth: true, admin: true } },
|
||||
@@ -38,57 +36,58 @@ export const router = createRouter({
|
||||
// Catch-all
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||
],
|
||||
});
|
||||
})
|
||||
|
||||
// ─── Navigation guard ────────────────────────────────────────────────────────
|
||||
|
||||
let _initDone = false;
|
||||
let _initDone = false
|
||||
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
const authStore = useAuth();
|
||||
const authStore = useAuth()
|
||||
|
||||
// First navigation: try to restore session from localStorage refresh token
|
||||
if (!_initDone) {
|
||||
_initDone = true;
|
||||
const storedRefresh = localStorage.getItem('refreshToken');
|
||||
_initDone = true
|
||||
const storedRefresh = localStorage.getItem('refreshToken')
|
||||
if (storedRefresh && !_getAccessToken()) {
|
||||
try {
|
||||
const res = await axios.post<{ data: { accessToken: string; refreshToken: string } }>(
|
||||
const res = await axios.post<{ data: { accessToken: string, refreshToken: string } }>(
|
||||
`${BASE_URL}/api/v1/auth/refresh`,
|
||||
{ refreshToken: storedRefresh },
|
||||
);
|
||||
_setAccessToken(res.data.data.accessToken);
|
||||
localStorage.setItem('refreshToken', res.data.data.refreshToken);
|
||||
await authStore.fetchMe();
|
||||
} catch {
|
||||
localStorage.removeItem('refreshToken');
|
||||
)
|
||||
_setAccessToken(res.data.data.accessToken)
|
||||
localStorage.setItem('refreshToken', res.data.data.refreshToken)
|
||||
await authStore.fetchMe()
|
||||
}
|
||||
catch {
|
||||
localStorage.removeItem('refreshToken')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const requiresAuth = to.meta.auth;
|
||||
const requiresGuest = to.meta.guest;
|
||||
const requiresAdmin = to.meta.admin;
|
||||
const isAuthed = authStore.isAuthenticated;
|
||||
const requiresAuth = to.meta.auth
|
||||
const requiresGuest = to.meta.guest
|
||||
const requiresAdmin = to.meta.admin
|
||||
const isAuthed = authStore.isAuthenticated
|
||||
|
||||
if (requiresAuth && !isAuthed) {
|
||||
return next({ name: 'login', query: { redirect: to.fullPath } });
|
||||
return next({ name: 'login', query: { redirect: to.fullPath } })
|
||||
}
|
||||
|
||||
if (requiresGuest && isAuthed) {
|
||||
return next({ name: 'feed' });
|
||||
return next({ name: 'feed' })
|
||||
}
|
||||
|
||||
if (requiresAdmin && !authStore.isAdmin) {
|
||||
return next({ name: 'feed' });
|
||||
return next({ name: 'feed' })
|
||||
}
|
||||
|
||||
// Redirect to setup if authenticated but no profiles
|
||||
if (requiresAuth && isAuthed && !authStore.hasProfiles && to.name !== 'setup') {
|
||||
return next({ name: 'setup' });
|
||||
return next({ name: 'setup' })
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
next()
|
||||
})
|
||||
|
||||
export default router;
|
||||
export default router
|
||||
|
||||
@@ -2,49 +2,98 @@
|
||||
@use 'variables' as *;
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-up {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-down {
|
||||
from { opacity: 0; transform: translateY(-12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-right {
|
||||
from { opacity: 0; transform: translateX(24px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-left {
|
||||
from { opacity: 0; transform: translateX(-24px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-24px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
from { opacity: 0; transform: scale(0.94); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.94);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-signal {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// Utility classes
|
||||
.animate-fade-in { animation: fade-in var(--transition-base) both; }
|
||||
.animate-fade-up { animation: fade-up var(--transition-base) both; }
|
||||
.animate-scale-in { animation: scale-in var(--transition-spring) both; }
|
||||
.animate-fade-in {
|
||||
animation: fade-in var(--transition-base) both;
|
||||
}
|
||||
.animate-fade-up {
|
||||
animation: fade-up var(--transition-base) both;
|
||||
}
|
||||
.animate-scale-in {
|
||||
animation: scale-in var(--transition-spring) both;
|
||||
}
|
||||
|
||||
// Skeleton shimmer
|
||||
.skeleton {
|
||||
|
||||
@@ -24,7 +24,8 @@ body {
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
h1, .h1 {
|
||||
h1,
|
||||
.h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 2.5rem;
|
||||
font-weight: 400;
|
||||
@@ -34,7 +35,8 @@ h1, .h1 {
|
||||
color: var(--color-cream);
|
||||
}
|
||||
|
||||
h2, .h2 {
|
||||
h2,
|
||||
.h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.75rem;
|
||||
font-weight: 400;
|
||||
@@ -44,7 +46,8 @@ h2, .h2 {
|
||||
color: var(--color-cream);
|
||||
}
|
||||
|
||||
h3, .h3 {
|
||||
h3,
|
||||
.h3 {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
@@ -90,6 +93,12 @@ a {
|
||||
|
||||
// Responsive heading scale
|
||||
@include mobile {
|
||||
h1, .h1 { font-size: 2rem; }
|
||||
h2, .h2 { font-size: 1.5rem; }
|
||||
h1,
|
||||
.h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
h2,
|
||||
.h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,73 +1,89 @@
|
||||
// Design tokens — do not use directly in components; use CSS custom properties
|
||||
:root {
|
||||
// Surfaces
|
||||
--color-base: #0d0d0d;
|
||||
--color-surface: #161614;
|
||||
--color-base: #0d0d0d;
|
||||
--color-surface: #161614;
|
||||
--color-surface-2: #1e1e1b;
|
||||
--color-surface-3: #242420;
|
||||
|
||||
// Text
|
||||
--color-cream: #f0ebe0;
|
||||
--color-muted: #6b6860;
|
||||
--color-dim: #3a3935;
|
||||
--color-cream: #f0ebe0;
|
||||
--color-muted: #6b6860;
|
||||
--color-dim: #3a3935;
|
||||
|
||||
// Brand signal
|
||||
--color-signal: #c45c3a;
|
||||
--color-signal-dim:#7a3822;
|
||||
--color-signal: #c45c3a;
|
||||
--color-signal-dim: #7a3822;
|
||||
--color-signal-bg: rgba(196, 92, 58, 0.12);
|
||||
|
||||
// Utility
|
||||
--color-border: rgba(240, 235, 224, 0.08);
|
||||
--color-border: rgba(240, 235, 224, 0.08);
|
||||
--color-border-strong: rgba(240, 235, 224, 0.16);
|
||||
--color-overlay: rgba(13, 13, 13, 0.72);
|
||||
--color-overlay: rgba(13, 13, 13, 0.72);
|
||||
|
||||
// Typography
|
||||
--font-display: 'Instrument Serif', Georgia, serif;
|
||||
--font-mono: 'DM Mono', 'Courier New', monospace;
|
||||
--font-mono: 'DM Mono', 'Courier New', monospace;
|
||||
|
||||
// Border radius
|
||||
--radius-xs: 2px;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 24px;
|
||||
--radius-xs: 2px;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 24px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
// Shadows
|
||||
--shadow-card: 0 2px 24px rgba(0, 0, 0, 0.6);
|
||||
--shadow-modal: 0 8px 64px rgba(0, 0, 0, 0.8);
|
||||
--shadow-card: 0 2px 24px rgba(0, 0, 0, 0.6);
|
||||
--shadow-modal: 0 8px 64px rgba(0, 0, 0, 0.8);
|
||||
--shadow-signal: 0 0 20px rgba(196, 92, 58, 0.3);
|
||||
|
||||
// Transitions
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-base: 280ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-base: 280ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-spring: 420ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--transition-slow: 600ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 600ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
// Z-index scale
|
||||
--z-base: 1;
|
||||
--z-dropdown: 100;
|
||||
--z-sticky: 200;
|
||||
--z-overlay: 300;
|
||||
--z-modal: 400;
|
||||
--z-toast: 500;
|
||||
--z-tooltip: 600;
|
||||
--z-titlebar: 700;
|
||||
--z-base: 1;
|
||||
--z-dropdown: 100;
|
||||
--z-sticky: 200;
|
||||
--z-overlay: 300;
|
||||
--z-modal: 400;
|
||||
--z-toast: 500;
|
||||
--z-tooltip: 600;
|
||||
--z-titlebar: 700;
|
||||
|
||||
// Layout
|
||||
--sidebar-collapsed: 64px;
|
||||
--sidebar-expanded: 240px;
|
||||
--nav-height: 60px;
|
||||
--titlebar-height: 36px;
|
||||
--sidebar-expanded: 240px;
|
||||
--nav-height: 60px;
|
||||
--titlebar-height: 36px;
|
||||
}
|
||||
|
||||
// SCSS breakpoints (for use in @media)
|
||||
$mobile: 375px;
|
||||
$tablet: 768px;
|
||||
$mobile: 375px;
|
||||
$tablet: 768px;
|
||||
$desktop: 1024px;
|
||||
$wide: 1440px;
|
||||
$wide: 1440px;
|
||||
|
||||
@mixin mobile { @media (max-width: #{$tablet - 1px}) { @content; } }
|
||||
@mixin tablet { @media (min-width: $tablet) { @content; } }
|
||||
@mixin desktop { @media (min-width: $desktop) { @content; } }
|
||||
@mixin wide { @media (min-width: $wide) { @content; } }
|
||||
@mixin mobile {
|
||||
@media (max-width: #{$tablet - 1px}) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
@mixin tablet {
|
||||
@media (min-width: $tablet) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
@mixin desktop {
|
||||
@media (min-width: $desktop) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
@mixin wide {
|
||||
@media (min-width: $wide) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
@import "tailwindcss";
|
||||
@import 'tailwindcss';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
2
src/vite-env.d.ts
vendored
2
src/vite-env.d.ts
vendored
@@ -1,5 +1,5 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface Window {
|
||||
__TAURI__?: unknown;
|
||||
__TAURI__?: unknown
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user