This commit is contained in:
Oscar
2026-06-08 13:23:20 +03:00
commit 637dddf656
160 changed files with 56097 additions and 0 deletions

63
src/App.vue Normal file
View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue';
import AppShell from '@/components/layout/AppShell.vue';
import AppToast from '@/components/common/AppToast.vue';
import { useUiStore } from '@/stores/ui.store';
import { apiClient } from '@/api/client';
import type { Tag, City, Greeting } from '@/stores/ui.store';
const uiStore = useUiStore();
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>
<RouterView v-slot="{ Component, route }">
<Transition
:name="route.meta.transition as string ?? 'fade'"
mode="out-in"
>
<component :is="Component" :key="route.fullPath" />
</Transition>
</RouterView>
</template>
</AppShell>
<AppToast />
</template>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity var(--transition-base);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-right-enter-active,
.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); }
</style>

1023
src/api/api.ts Normal file

File diff suppressed because it is too large Load Diff

119
src/api/client.ts Normal file
View File

@@ -0,0 +1,119 @@
import axios from 'axios';
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
import { Api, HttpClient } from './api';
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000';
// ─── 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();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(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 }> = [];
function _processQueue(error: unknown, token: string | null) {
_failedQueue.forEach(({ resolve, reject }) => {
if (error) reject(error);
else resolve(token);
});
_failedQueue = [];
}
axiosInstance.interceptors.response.use(
(response) => response,
async (error) => {
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 });
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return axiosInstance(originalRequest);
});
}
originalRequest._retry = true;
_isRefreshing = true;
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
_processQueue(error, null);
_isRefreshing = false;
_redirectToLogin();
return Promise.reject(error);
}
try {
const res = await axios.post<{ accessToken: string; refreshToken: string }>(
`${BASE_URL}/api/v1/auth/refresh`,
{ refreshToken },
);
const { accessToken, refreshToken: newRefresh } = res.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);
},
);
// ─── In-memory token storage ─────────────────────────────────────────────────
// Access token lives only in memory; refresh token lives in localStorage
let _accessToken: string | null = null;
export function _getAccessToken() { return _accessToken; }
export function _setAccessToken(token: string) { _accessToken = token; }
export function _clearAuth() {
_accessToken = null;
localStorage.removeItem('refreshToken');
}
function _redirectToLogin() {
// Dynamic import to avoid circular dependency with router
import('@/router').then(({ router }) => router.replace('/login'));
}
// ─── Typed API client ─────────────────────────────────────────────────────────
const httpClient = new HttpClient({
baseURL: BASE_URL,
securityWorker: () => {
const token = _getAccessToken();
return token ? { headers: { Authorization: `Bearer ${token}` } } : {};
},
});
// Plug our axios instance into the generated client
httpClient.instance = axiosInstance;
export const apiClient = new Api(httpClient);

7
src/assets/grain.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
<filter id="grain">
<feTurbulence type="fractalNoise" baseFrequency="0.65" numOctaves="3" stitchTiles="stitch"/>
<feColorMatrix type="saturate" values="0"/>
</filter>
<rect width="200" height="200" filter="url(#grain)" opacity="0.08"/>
</svg>

After

Width:  |  Height:  |  Size: 323 B

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { ChatMessage } from '@/stores/chat.store';
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 }">
<MediaMessage
v-if="message.mediaUrl"
:url="message.mediaUrl"
:type="message.mediaType ?? 'photo'"
/>
<p v-if="message.text" class="bubble__text">{{ message.text }}</p>
<span class="bubble__time">{{ time }}</span>
</div>
</div>
</template>
<style scoped lang="scss">
.bubble-wrap {
display: flex;
margin-bottom: 4px;
padding: 0 16px;
&--mine {
justify-content: flex-end;
}
}
.bubble {
max-width: 72%;
padding: 10px 14px;
border-radius: var(--radius-md);
position: relative;
&--mine {
background: var(--color-cream);
color: var(--color-base);
border-bottom-right-radius: var(--radius-xs);
}
&--partner {
background: var(--color-surface-2);
color: var(--color-cream);
border: 1px solid var(--color-border);
border-bottom-left-radius: var(--radius-xs);
}
&__text {
font-family: var(--font-mono);
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
word-break: break-word;
}
&--mine &__text { color: var(--color-base); }
&__time {
display: block;
font-family: var(--font-mono);
font-size: 0.625rem;
letter-spacing: 0.04em;
margin-top: 4px;
opacity: 0.5;
text-align: right;
}
}
</style>

View File

@@ -0,0 +1,168 @@
<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="Прикрепить файл">
<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"/>
</svg>
</button>
<textarea
ref="textareaEl"
class="chat-input__textarea"
:value="text"
@input="handleInput"
@keydown="handleKeydown"
placeholder="Написать сообщение..."
rows="1"
aria-label="Текст сообщения"
/>
<button
class="chat-input__send"
:disabled="!text.trim()"
@click="send"
aria-label="Отправить"
>
<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"/>
</svg>
</button>
<input
ref="fileInput"
type="file"
accept="image/*,video/*"
class="sr-only"
@change="onFileChange"
/>
</div>
</template>
<style scoped lang="scss">
.chat-input {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 12px 16px;
background: var(--color-surface);
border-top: 1px solid var(--color-border);
flex-shrink: 0;
&__attach,
&__send {
width: 40px;
height: 40px;
flex-shrink: 0;
border: none;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all var(--transition-fast);
}
&__attach {
background: none;
color: var(--color-muted);
&:hover { color: var(--color-cream); }
}
&__send {
background: var(--color-signal);
color: white;
&:hover:not(:disabled) { background: #a84e30; }
&:disabled {
background: var(--color-dim);
color: var(--color-muted);
cursor: not-allowed;
}
}
&__textarea {
flex: 1;
min-height: 40px;
max-height: 140px;
padding: 10px 14px;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: 20px;
color: var(--color-cream);
font-family: var(--font-mono);
font-size: 0.875rem;
line-height: 1.4;
resize: none;
outline: none;
transition: border-color var(--transition-fast);
&::placeholder { color: var(--color-muted); }
&:focus { border-color: var(--color-border-strong); }
}
}
</style>

View File

@@ -0,0 +1,152 @@
<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">
<img
:src="url"
class="media__img"
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="Фото" />
</div>
</Transition>
</Teleport>
</div>
<!-- Voice -->
<div v-else-if="type === 'voice'" class="media media--voice">
<button class="media__play-btn" @click="toggleAudio" :aria-label="playing ? 'Пауза' : 'Воспроизвести'">
<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"/>
</svg>
</button>
<div class="media__waveform" aria-hidden="true">
<span v-for="i in 16" :key="i" class="media__bar" :style="{ height: `${8 + Math.random() * 16}px` }" />
</div>
<audio ref="audioEl" :src="url" @ended="playing = false" />
</div>
<!-- Video -->
<div v-else-if="type === 'video'" class="media media--video">
<video
:src="url"
class="media__video"
controls
preload="metadata"
/>
</div>
</template>
<style scoped lang="scss">
.media {
&--photo { cursor: pointer; }
&__img {
max-width: 220px;
max-height: 280px;
border-radius: var(--radius-sm);
object-fit: cover;
display: block;
transition: opacity var(--transition-fast);
&:hover { opacity: 0.9; }
}
&--voice {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 0;
min-width: 160px;
}
&__play-btn {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--color-signal);
border: none;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: transform var(--transition-fast);
&:hover { transform: scale(1.08); }
}
&__waveform {
display: flex;
align-items: center;
gap: 2px;
height: 28px;
}
&__bar {
display: block;
width: 3px;
background: currentColor;
border-radius: 2px;
opacity: 0.5;
}
&__video {
max-width: 240px;
border-radius: var(--radius-sm);
display: block;
}
}
.lightbox {
position: fixed;
inset: 0;
z-index: var(--z-modal);
background: rgba(0, 0, 0, 0.92);
display: flex;
align-items: center;
justify-content: center;
cursor: zoom-out;
&__img {
max-width: 90vw;
max-height: 90dvh;
object-fit: contain;
border-radius: var(--radius-sm);
}
}
.fade-enter-active, .fade-leave-active { transition: opacity var(--transition-base); }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>

View File

@@ -0,0 +1,144 @@
<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="Отменить запись">
<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"/>
</svg>
</button>
<button class="voice-recorder__stop" @click="stop" aria-label="Остановить запись">
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
<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="Записать голосовое сообщение">
<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"/>
</svg>
</button>
</div>
</template>
<style scoped lang="scss">
.voice-recorder {
&__btn {
width: 40px;
height: 40px;
background: none;
border: none;
color: var(--color-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: color var(--transition-fast);
&:hover { color: var(--color-cream); }
}
&__active {
display: flex;
align-items: center;
gap: 8px;
}
&__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-signal);
animation: pulse-signal 1s ease-in-out infinite;
}
&__time {
font-variant-numeric: tabular-nums;
}
&__cancel,
&__stop {
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
&__cancel {
background: var(--color-surface-2);
color: var(--color-muted);
&:hover { color: var(--color-cream); }
}
&__stop {
background: var(--color-signal);
color: white;
&:hover { background: #a84e30; }
}
}
</style>

View File

@@ -0,0 +1,123 @@
<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'"
class="btn"
:class="[
`btn--${variant ?? 'primary'}`,
`btn--${size ?? 'md'}`,
{ 'btn--loading': loading, 'btn--full': fullWidth },
]"
:disabled="disabled || loading"
>
<span v-if="loading" class="btn__spinner" aria-hidden="true" />
<span class="btn__content" :class="{ 'btn__content--hidden': loading }">
<slot />
</span>
</button>
</template>
<style scoped lang="scss">
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
position: relative;
font-family: var(--font-mono);
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition:
background var(--transition-fast),
color var(--transition-fast),
transform var(--transition-fast),
opacity var(--transition-fast);
white-space: nowrap;
user-select: none;
&:active:not(:disabled) {
transform: scale(0.98);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&--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; }
// Variants
&--primary {
background: var(--color-cream);
color: var(--color-base);
&:hover:not(:disabled) { background: #e8e0d0; }
}
&--secondary {
background: var(--color-surface-2);
color: var(--color-cream);
border: 1px solid var(--color-border);
&:hover:not(:disabled) {
background: var(--color-surface-3);
border-color: var(--color-border-strong);
}
}
&--ghost {
background: transparent;
color: var(--color-muted);
&:hover:not(:disabled) {
color: var(--color-cream);
background: rgba(240, 235, 224, 0.06);
}
}
&--danger {
background: var(--color-signal);
color: var(--color-cream);
&:hover:not(:disabled) { background: #a84e30; }
}
// Spinner
&__spinner {
position: absolute;
width: 16px;
height: 16px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
&__content--hidden {
visibility: hidden;
}
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View File

@@ -0,0 +1,140 @@
<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 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="Закрыть">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="18" height="18">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
<div class="drawer__body">
<slot />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped lang="scss">
.drawer-backdrop {
position: fixed;
inset: 0;
z-index: var(--z-overlay);
background: var(--color-overlay);
}
.drawer {
position: fixed;
background: var(--color-surface);
display: flex;
flex-direction: column;
&--right {
top: 0;
right: 0;
bottom: 0;
width: min(400px, 100vw);
border-left: 1px solid var(--color-border);
}
&--bottom {
left: 0;
right: 0;
bottom: 0;
max-height: 85dvh;
border-top: 1px solid var(--color-border);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
&__title {
font-family: var(--font-mono);
font-size: 0.875rem;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-cream);
margin: 0;
}
&__close {
background: none;
border: none;
color: var(--color-muted);
cursor: pointer;
padding: 4px;
border-radius: var(--radius-xs);
display: flex;
transition: color var(--transition-fast);
&:hover { color: var(--color-cream); }
}
&__body {
flex: 1;
overflow-y: auto;
padding: 20px;
}
}
// Right drawer
.drawer-right-enter-active, .drawer-right-leave-active {
transition: opacity var(--transition-base);
.drawer--right { transition: transform var(--transition-base); }
}
.drawer-right-enter-from, .drawer-right-leave-to {
opacity: 0;
.drawer--right { transform: translateX(100%); }
}
// Bottom drawer
.drawer-bottom-enter-active, .drawer-bottom-leave-active {
transition: opacity var(--transition-base);
.drawer--bottom { transition: transform var(--transition-base); }
}
.drawer-bottom-enter-from, .drawer-bottom-leave-to {
opacity: 0;
.drawer--bottom { transform: translateY(100%); }
}
</style>

View File

@@ -0,0 +1,139 @@
<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">
{{ label }}
<span v-if="required" class="field__required" aria-hidden="true">*</span>
</label>
<div class="field__wrap">
<input
class="field__input"
:type="type ?? 'text'"
:placeholder="placeholder"
:value="modelValue"
:disabled="disabled"
:required="required"
:name="name"
:autocomplete="autocomplete"
:aria-describedby="errorMessage ? `${name}-error` : hint ? `${name}-hint` : undefined"
: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>
</div>
</template>
<style scoped lang="scss">
.field {
display: flex;
flex-direction: column;
gap: 6px;
&__label {
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-muted);
}
&__required {
color: var(--color-signal);
margin-left: 3px;
}
&__wrap {
position: relative;
display: flex;
align-items: center;
}
&__input {
width: 100%;
height: 44px;
padding: 0 14px;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-cream);
font-family: var(--font-mono);
font-size: 0.875rem;
transition:
border-color var(--transition-fast),
background var(--transition-fast);
outline: none;
&::placeholder {
color: var(--color-muted);
}
&:hover:not(:disabled) {
border-color: var(--color-border-strong);
}
&:focus {
border-color: var(--color-signal);
background: var(--color-surface-3);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
&--error .field__input {
border-color: var(--color-signal);
}
&__error {
font-family: var(--font-mono);
font-size: 0.6875rem;
color: var(--color-signal);
margin: 0;
}
&__hint {
font-family: var(--font-mono);
font-size: 0.6875rem;
color: var(--color-muted);
margin: 0;
}
}
</style>

View File

@@ -0,0 +1,137 @@
<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 class="modal" :class="`modal--${size ?? 'md'}`">
<div v-if="title || $slots.header" class="modal__header">
<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>
</div>
<div class="modal__body">
<slot />
</div>
<div v-if="$slots.footer" class="modal__footer">
<slot name="footer" />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped lang="scss">
.modal-backdrop {
position: fixed;
inset: 0;
z-index: var(--z-modal);
background: var(--color-overlay);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.modal {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-modal);
width: 100%;
max-height: 90dvh;
display: flex;
flex-direction: column;
overflow: hidden;
&--sm { max-width: 400px; }
&--md { max-width: 560px; }
&--lg { max-width: 800px; }
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
&__title {
font-family: var(--font-display);
font-size: 1.25rem;
color: var(--color-cream);
margin: 0;
}
&__close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--color-muted);
cursor: pointer;
border-radius: var(--radius-sm);
transition: color var(--transition-fast);
flex-shrink: 0;
&:hover { color: var(--color-cream); }
svg { width: 18px; height: 18px; }
}
&__body {
flex: 1;
overflow-y: auto;
padding: 24px;
}
&__footer {
padding: 16px 24px;
border-top: 1px solid var(--color-border);
display: flex;
justify-content: flex-end;
gap: 8px;
flex-shrink: 0;
}
}
.modal-enter-active, .modal-leave-active {
transition: opacity var(--transition-base);
.modal { transition: transform var(--transition-spring), opacity var(--transition-base); }
}
.modal-enter-from, .modal-leave-to {
opacity: 0;
.modal { transform: scale(0.95) translateY(8px); opacity: 0; }
}
</style>

View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import { useUiStore } from '@/stores/ui.store';
const uiStore = useUiStore();
</script>
<template>
<Teleport to="body">
<div class="toast-container" role="region" aria-label="Уведомления" aria-live="polite">
<TransitionGroup name="toast">
<div
v-for="toast in uiStore.toasts"
:key="toast.id"
class="toast"
:class="`toast--${toast.type}`"
role="alert"
>
<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"/>
</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>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<style scoped lang="scss">
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: var(--z-toast);
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
max-width: 360px;
width: calc(100% - 48px);
}
.toast {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
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); }
&__icon {
width: 18px;
height: 18px;
flex-shrink: 0;
margin-top: 1px;
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); }
&__message {
flex: 1;
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--color-cream);
line-height: 1.4;
}
&__close {
width: 20px;
height: 20px;
background: none;
border: none;
color: var(--color-muted);
cursor: pointer;
padding: 0;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-xs);
transition: color var(--transition-fast);
&:hover { color: var(--color-cream); }
svg { width: 14px; height: 14px; }
}
}
.toast-enter-active {
transition: all var(--transition-spring);
}
.toast-leave-active {
transition: all var(--transition-base);
}
.toast-enter-from {
opacity: 0;
transform: translateX(20px) scale(0.95);
}
.toast-leave-to {
opacity: 0;
transform: translateX(20px);
}
</style>

View File

@@ -0,0 +1,91 @@
<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"/>
</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"/>
</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"/>
</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"/>
</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"/>
</template>
</svg>
</div>
<h3 class="empty__title">{{ title }}</h3>
<p v-if="description" class="empty__desc">{{ description }}</p>
<slot />
</div>
</template>
<style scoped lang="scss">
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 48px 24px;
gap: 16px;
&__illustration {
color: var(--color-dim);
margin-bottom: 8px;
}
&__svg {
width: 120px;
height: 80px;
}
&__title {
font-family: var(--font-display);
font-size: 1.25rem;
color: var(--color-cream);
margin: 0;
}
&__desc {
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--color-muted);
margin: 0;
max-width: 36ch;
line-height: 1.6;
}
}
</style>

View File

@@ -0,0 +1,31 @@
<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"/>
</svg>
<span class="sr-only">{{ label ?? 'Загрузка...' }}</span>
</div>
</template>
<style scoped lang="scss">
.spinner {
display: inline-flex;
color: var(--color-signal);
&__ring {
animation: spin 0.9s linear infinite;
}
&--sm svg { width: 16px; height: 16px; }
&--md svg { width: 24px; height: 24px; }
&--lg svg { width: 40px; height: 40px; }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useAuthStore } from '@/stores/auth.store';
import { useUiStore } from '@/stores/ui.store';
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 = useAuthStore();
const uiStore = useUiStore();
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">
<label class="label">Дата и время</label>
<input
v-model="form.time"
type="datetime-local"
class="date-form__datetime"
required
:min="new Date().toISOString().slice(0, 16)"
/>
</div>
<div class="date-form__section">
<label class="label">Место встречи</label>
<MapPicker v-model="form.location" />
</div>
<div class="date-form__actions">
<AppButton type="button" variant="ghost" @click="emit('close')">Отмена</AppButton>
<AppButton type="submit" :loading="loading" :disabled="!form.location || !form.time">
Предложить встречу
</AppButton>
</div>
</form>
</template>
<style scoped lang="scss">
.date-form {
display: flex;
flex-direction: column;
gap: 20px;
&__section {
display: flex;
flex-direction: column;
gap: 8px;
}
&__datetime {
height: 44px;
padding: 0 14px;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-cream);
font-family: var(--font-mono);
font-size: 0.875rem;
outline: none;
transition: border-color var(--transition-fast);
color-scheme: dark;
&:focus { border-color: var(--color-signal); }
}
&__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
}
}
</style>

View File

@@ -0,0 +1,99 @@
<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 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-else class="map-picker__coords meta">
{{ modelValue.lat.toFixed(5) }}, {{ modelValue.lng.toFixed(5) }}
</p>
</div>
</template>
<style scoped lang="scss">
.map-picker {
display: flex;
flex-direction: column;
gap: 8px;
&__map {
height: 280px;
border-radius: var(--radius-md);
overflow: hidden;
border: 1px solid var(--color-border);
background: var(--color-surface-2);
:deep(.leaflet-container) {
background: var(--color-surface-3);
}
}
&__hint, &__coords {
color: var(--color-muted);
margin: 0;
font-variant-numeric: tabular-nums;
}
}
</style>

View File

@@ -0,0 +1,409 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { gsap } from 'gsap';
import type { FeedProfile } from '@/stores/feed.store';
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.mediaUrls?.[currentImageIndex.value] || props.profile.avatarUrl || '',
);
// ─── 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.mediaUrls?.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"
class="feed-card"
:class="{ 'feed-card--top': isTop }"
@pointerdown="onDragStart"
@pointermove="onDragMove"
@pointerup="onDragEnd"
@touchstart.passive="onTouchStart"
@touchend="onTouchEnd"
@click="openProfile"
>
<!-- Cover image -->
<div class="feed-card__cover">
<img
v-if="coverUrl"
:src="coverUrl"
: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"/>
</svg>
</div>
<!-- Image gallery dots -->
<div v-if="(profile.mediaUrls?.length ?? 0) > 1" class="feed-card__dots">
<button
v-for="(_, i) in profile.mediaUrls"
:key="i"
class="feed-card__dot"
:class="{ 'feed-card__dot--active': i === currentImageIndex }"
@click.stop="currentImageIndex = i"
:aria-label="`Фото ${i + 1}`"
/>
</div>
<!-- Drag tint overlays -->
<div class="feed-card__tint feed-card__tint--like" aria-hidden="true" />
<div class="feed-card__tint feed-card__tint--dislike" aria-hidden="true" />
<!-- Like/dislike indicators -->
<div class="feed-card__indicator feed-card__indicator--like" aria-hidden="true">
<span>ЛАЙК</span>
</div>
<div class="feed-card__indicator feed-card__indicator--dislike" aria-hidden="true">
<span>ПРОПУСТИТЬ</span>
</div>
</div>
<!-- Info overlay -->
<div class="feed-card__info">
<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>
<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.name" />
</div>
</div>
<!-- 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="Пропустить">
<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"/>
</svg>
</button>
<button class="feed-card__btn feed-card__btn--like" @click="handleLike" aria-label="Лайк">
<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"/>
</svg>
</button>
</div>
</article>
</template>
<style scoped lang="scss">
.feed-card {
position: absolute;
inset: 0;
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-surface);
box-shadow: var(--shadow-card);
cursor: grab;
user-select: none;
touch-action: none;
will-change: transform;
// CSS variables for GSAP tint control
--tint-right: 0;
--tint-left: 0;
&--top {
z-index: 2;
}
&:not(.feed-card--top) {
transform: scale(0.96) translateY(16px);
z-index: 1;
pointer-events: none;
}
&__cover {
position: absolute;
inset: 0;
}
&__img {
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
}
&__no-photo {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-surface-2);
}
&__dots {
position: absolute;
top: 12px;
left: 12px;
right: 12px;
display: flex;
gap: 4px;
pointer-events: auto;
}
&__dot {
flex: 1;
height: 3px;
background: rgba(255, 255, 255, 0.35);
border: none;
border-radius: var(--radius-full);
cursor: pointer;
padding: 0;
transition: background var(--transition-fast);
&--active { background: rgba(255, 255, 255, 0.9); }
}
// Drag tint overlays
&__tint {
position: absolute;
inset: 0;
pointer-events: none;
transition: opacity var(--transition-fast);
&--like {
background: linear-gradient(135deg, rgba(196, 92, 58, 0.35) 0%, transparent 60%);
opacity: var(--tint-right);
}
&--dislike {
background: linear-gradient(225deg, rgba(80, 80, 100, 0.35) 0%, transparent 60%);
opacity: var(--tint-left);
}
}
// Like/dislike indicators
&__indicator {
position: absolute;
top: 24px;
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.12em;
padding: 6px 12px;
border-radius: var(--radius-xs);
opacity: 0;
transition: opacity var(--transition-fast);
&--like {
left: 20px;
color: var(--color-signal);
border: 1.5px solid var(--color-signal);
opacity: calc(var(--tint-right) * 1);
}
&--dislike {
right: 20px;
color: #9090a0;
border: 1.5px solid #9090a0;
opacity: calc(var(--tint-left) * 1);
}
}
// Info overlay — bottom gradient
&__info {
position: absolute;
bottom: 0;
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%);
pointer-events: none;
}
&__meta {
margin-bottom: 4px;
}
&__location {
color: rgba(240, 235, 224, 0.5);
}
&__name {
font-family: var(--font-display);
font-size: 2rem;
font-weight: 400;
color: var(--color-cream);
margin: 0 0 10px;
line-height: 1.1;
}
&__age {
font-size: 1.5rem;
opacity: 0.7;
}
&__tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
// Action buttons
&__actions {
position: absolute;
bottom: 20px;
left: 0;
right: 0;
display: flex;
justify-content: center;
gap: 20px;
z-index: 3;
}
&__btn {
width: 52px;
height: 52px;
border-radius: 50%;
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform var(--transition-fast), opacity var(--transition-fast);
&:hover { transform: scale(1.1); }
&:active { transform: scale(0.95); }
&--like {
background: var(--color-signal);
color: white;
}
&--dislike {
background: rgba(240, 235, 224, 0.12);
color: var(--color-cream);
backdrop-filter: blur(8px);
}
}
}
</style>

View File

@@ -0,0 +1,130 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { gsap } from 'gsap';
import { useFeedStore } from '@/stores/feed.store';
import { useAuthStore } from '@/stores/auth.store';
import { apiClient } from '@/api/client';
import { useUiStore } from '@/stores/ui.store';
import FeedCard from './FeedCard.vue';
import EmptyState from '@/components/common/EmptyState.vue';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
const feedStore = useFeedStore();
const authStore = useAuthStore();
const uiStore = useUiStore();
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">
<LoadingSpinner size="lg" />
</div>
<EmptyState
v-else-if="!feedStore.loading && feedStore.cards.length === 0 && !feedStore.searchPaused"
title="Анкеты закончились"
description="Измените фильтры или вернитесь позже"
icon="feed"
/>
<div v-else-if="feedStore.searchPaused" class="card-stack__paused">
<p class="meta">Лимит совпадений достигнут</p>
<p>Закройте один из открытых чатов, чтобы продолжить поиск.</p>
</div>
<div v-else class="card-stack__cards">
<TransitionGroup name="card">
<FeedCard
v-for="(profile, index) in visibleCards"
:key="profile.id"
:profile="profile"
:is-top="index === 0"
@like="handleLike"
@dislike="handleDislike"
/>
</TransitionGroup>
</div>
</div>
</template>
<style scoped lang="scss">
.card-stack {
position: relative;
width: 100%;
height: 100%;
&__loading,
&__paused {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--color-muted);
p {
font-family: var(--font-mono);
font-size: 0.8125rem;
max-width: 32ch;
text-align: center;
}
}
&__cards {
position: relative;
width: 100%;
height: 100%;
}
}
// 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; }
</style>

View File

@@ -0,0 +1,213 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue';
import { useUiStore } from '@/stores/ui.store';
import { useFeedStore } from '@/stores/feed.store';
import { useAuthStore } from '@/stores/auth.store';
import { apiClient } from '@/api/client';
import type { District } from '@/stores/ui.store';
import AppButton from '@/components/common/AppButton.vue';
import AppDrawer from '@/components/common/AppDrawer.vue';
defineProps<{ open: boolean }>();
const emit = defineEmits<{ close: [] }>();
const uiStore = useUiStore();
const feedStore = useFeedStore();
const authStore = useAuthStore();
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>
</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>
</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" />
<span class="filters__dash"></span>
<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="Имя, описание..." />
</div>
<div class="filters__section">
<span class="label">Интересы</span>
<div class="filters__tags">
<button
v-for="tag in uiStore.tags"
:key="tag.id"
type="button"
class="filters__tag"
:class="{ 'filters__tag--active': filters.tagIds.includes(tag.id) }"
@click="toggleTag(tag.id)"
>{{ tag.name }}</button>
</div>
</div>
<div class="filters__actions">
<AppButton variant="ghost" @click="reset">Сбросить</AppButton>
<AppButton variant="primary" @click="apply">Применить</AppButton>
</div>
</div>
</AppDrawer>
</template>
<style scoped lang="scss">
.filters {
display: flex;
flex-direction: column;
gap: 24px;
&__section {
display: flex;
flex-direction: column;
gap: 8px;
}
&__select,
&__input {
height: 44px;
padding: 0 12px;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-cream);
font-family: var(--font-mono);
font-size: 0.875rem;
outline: none;
transition: border-color var(--transition-fast);
&:focus { border-color: var(--color-signal); }
}
&__select { appearance: none; cursor: pointer; }
&__range {
display: flex;
align-items: center;
gap: 8px;
}
&__num {
width: 80px;
height: 44px;
padding: 0 12px;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-cream);
font-family: var(--font-mono);
font-size: 0.875rem;
outline: none;
text-align: center;
transition: border-color var(--transition-fast);
&:focus { border-color: var(--color-signal); }
}
&__dash {
color: var(--color-muted);
font-family: var(--font-mono);
}
&__tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
&__tag {
padding: 5px 12px;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
color: var(--color-muted);
font-family: var(--font-mono);
font-size: 0.6875rem;
letter-spacing: 0.03em;
cursor: pointer;
transition: all var(--transition-fast);
&--active {
background: var(--color-signal-bg);
border-color: var(--color-signal);
color: var(--color-cream);
}
&:hover:not(.filters__tag--active) {
border-color: var(--color-border-strong);
color: var(--color-cream);
}
}
&__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 8px;
}
}
</style>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
defineProps<{ label: string; variant?: 'default' | 'signal' }>();
</script>
<template>
<span class="badge" :class="`badge--${variant ?? 'default'}`">{{ label }}</span>
</template>
<style scoped lang="scss">
.badge {
display: inline-flex;
align-items: center;
padding: 3px 9px;
border-radius: var(--radius-full);
font-family: var(--font-mono);
font-size: 0.625rem;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
white-space: nowrap;
&--default {
background: rgba(240, 235, 224, 0.08);
color: var(--color-muted);
border: 1px solid var(--color-border);
}
&--signal {
background: var(--color-signal-bg);
color: var(--color-signal);
border: 1px solid rgba(196, 92, 58, 0.3);
}
}
</style>

View File

@@ -0,0 +1,96 @@
<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 { useAuthStore } from '@/stores/auth.store';
const route = useRoute();
const authStore = useAuthStore();
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) -->
<div class="shell__grain" aria-hidden="true" />
<!-- Tauri custom titlebar -->
<TauriTitlebar />
<div class="shell__body">
<!-- Desktop sidebar navigation -->
<SideNav v-if="showNav" class="shell__sidenav" />
<!-- Main content area -->
<main class="shell__main" :class="{ 'shell__main--no-nav': !showNav }">
<slot />
</main>
</div>
<!-- Mobile bottom navigation -->
<BottomNav v-if="showNav" class="shell__bottom-nav" />
</div>
</template>
<style scoped lang="scss">
.shell {
height: 100dvh;
display: flex;
flex-direction: column;
background: var(--color-base);
overflow: hidden;
position: relative;
&__grain {
position: fixed;
inset: 0;
z-index: var(--z-tooltip);
pointer-events: none;
background-image: url('@/assets/grain.svg');
background-repeat: repeat;
opacity: 0.35;
mix-blend-mode: overlay;
}
&__body {
flex: 1;
display: flex;
overflow: hidden;
}
&__sidenav {
// Desktop only
@include mobile {
display: none;
}
}
&__main {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
position: relative;
scroll-behavior: smooth;
&--no-nav {
width: 100%;
}
}
&__bottom-nav {
// Mobile only
@include tablet {
display: none;
}
}
}
</style>

View File

@@ -0,0 +1,106 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth.store';
const route = useRoute();
const authStore = useAuthStore();
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
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="bottom-nav__item"
:class="{ 'bottom-nav__item--active': isActive(item.path) }"
:aria-label="item.label"
:aria-current="isActive(item.path) ? 'page' : undefined"
>
<span class="bottom-nav__icon">
<BottomNavIcon :name="item.icon" />
</span>
<span class="bottom-nav__label">{{ item.label }}</span>
</RouterLink>
</nav>
</template>
<script lang="ts">
const BottomNavIcon = {
props: { name: String },
template: `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path v-if="name==='grid'" d="M3 3h7v7H3zm11 0h7v7h-7zM3 14h7v7H3zm11 0h7v7h-7z"/>
<path v-if="name==='heart'" 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 v-if="name==='chat'" d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
<path v-if="name==='calendar'" d="M8 2v4M16 2v4M3 10h18M3 6a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<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">
.bottom-nav {
display: flex;
height: var(--nav-height);
background: var(--color-surface);
border-top: 1px solid var(--color-border);
flex-shrink: 0;
&__item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
color: var(--color-muted);
text-decoration: none;
transition: color var(--transition-fast);
padding: 8px 4px;
&:hover {
color: var(--color-cream);
}
&--active {
color: var(--color-cream);
.bottom-nav__icon {
color: var(--color-signal);
}
}
}
&__icon {
width: 22px;
height: 22px;
svg {
width: 100%;
height: 100%;
}
}
&__label {
font-family: var(--font-mono);
font-size: 0.625rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
}
</style>

View File

@@ -0,0 +1,235 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth.store';
import { useUiStore } from '@/stores/ui.store';
const route = useRoute();
const authStore = useAuthStore();
const uiStore = useUiStore();
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"
:class="{ 'sidenav--expanded': uiStore.sidebarExpanded }"
aria-label="Навигация"
>
<div class="sidenav__header">
<span class="sidenav__logo">D</span>
<Transition name="fade">
<span v-if="uiStore.sidebarExpanded" class="sidenav__brand">Daiting</span>
</Transition>
</div>
<ul class="sidenav__list" role="list">
<li v-for="item in visibleItems" :key="item.path">
<RouterLink
:to="item.path"
class="sidenav__item"
:class="{ 'sidenav__item--active': isActive(item.path) }"
:aria-label="item.label"
:aria-current="isActive(item.path) ? 'page' : undefined"
>
<span class="sidenav__icon" :aria-hidden="true">
<NavIcon :name="item.icon" />
</span>
<Transition name="fade">
<span v-if="uiStore.sidebarExpanded" class="sidenav__label">{{ item.label }}</span>
</Transition>
</RouterLink>
</li>
</ul>
<div class="sidenav__footer">
<button
class="sidenav__toggle"
@click="toggle"
:aria-label="uiStore.sidebarExpanded ? 'Свернуть меню' : 'Развернуть меню'"
>
<span class="sidenav__icon">
<NavIcon :name="uiStore.sidebarExpanded ? 'chevron-left' : 'chevron-right'" />
</span>
</button>
</div>
</nav>
</template>
<script lang="ts">
// Inline icon renderer to avoid external icon library dependency
const NavIcon = {
props: { name: String },
template: `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path v-if="name==='grid'" d="M3 3h7v7H3zm11 0h7v7h-7zM3 14h7v7H3zm11 0h7v7h-7z"/>
<path v-if="name==='heart'" 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 v-if="name==='chat'" d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
<path v-if="name==='calendar'" d="M8 2v4M16 2v4M3 10h18M3 6a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<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"/>
<path v-if="name==='flag'" d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1zM4 22v-7"/>
<path v-if="name==='chevron-right'" d="M9 18l6-6-6-6"/>
<path v-if="name==='chevron-left'" d="M15 18l-6-6 6-6"/>
</svg>
`,
};
</script>
<style scoped lang="scss">
.sidenav {
width: var(--sidebar-collapsed);
height: 100%;
background: var(--color-surface);
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
transition: width var(--transition-base);
overflow: hidden;
flex-shrink: 0;
&--expanded {
width: var(--sidebar-expanded);
}
&__header {
height: 64px;
display: flex;
align-items: center;
padding: 0 18px;
gap: 12px;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
&__logo {
font-family: var(--font-display);
font-size: 1.5rem;
color: var(--color-signal);
flex-shrink: 0;
width: 28px;
text-align: center;
}
&__brand {
font-family: var(--font-display);
font-size: 1.125rem;
color: var(--color-cream);
white-space: nowrap;
}
&__list {
flex: 1;
padding: 12px 0;
margin: 0;
list-style: none;
overflow-y: auto;
overflow-x: hidden;
}
&__item {
display: flex;
align-items: center;
gap: 12px;
height: 48px;
padding: 0 18px;
color: var(--color-muted);
text-decoration: none;
transition: color var(--transition-fast), background var(--transition-fast);
white-space: nowrap;
&:hover {
color: var(--color-cream);
background: rgba(240, 235, 224, 0.04);
}
&--active {
color: var(--color-cream);
background: rgba(240, 235, 224, 0.06);
.sidenav__icon {
color: var(--color-signal);
}
}
}
&__icon {
width: 20px;
height: 20px;
flex-shrink: 0;
svg {
width: 100%;
height: 100%;
}
}
&__label {
font-family: var(--font-mono);
font-size: 0.8125rem;
font-weight: 400;
letter-spacing: 0.02em;
}
&__footer {
padding: 12px 0;
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
&__toggle {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
height: 48px;
padding: 0 18px;
background: none;
border: none;
color: var(--color-muted);
cursor: pointer;
transition: color var(--transition-fast);
&:hover { color: var(--color-cream); }
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity var(--transition-fast);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,89 @@
<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"
class="titlebar"
data-tauri-drag-region
>
<span class="titlebar__brand">Daiting</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="Закрыть" />
</div>
</div>
</template>
<style scoped lang="scss">
.titlebar {
height: var(--titlebar-height);
background: var(--color-base);
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
user-select: none;
flex-shrink: 0;
z-index: var(--z-titlebar);
&__brand {
font-family: var(--font-display);
font-size: 0.875rem;
color: var(--color-muted);
letter-spacing: 0.04em;
}
&__controls {
display: flex;
gap: 8px;
align-items: center;
}
&__btn {
width: 12px;
height: 12px;
border-radius: 50%;
border: none;
cursor: pointer;
transition: opacity var(--transition-fast);
padding: 0;
&--minimize { background: #f5a623; }
&--maximize { background: #7ed321; }
&--close { background: #d0021b; }
&:hover { opacity: 0.8; }
&:focus-visible {
outline: 2px solid var(--color-signal);
outline-offset: 2px;
}
}
}
</style>

View File

@@ -0,0 +1,243 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useUiStore } from '@/stores/ui.store';
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 = useUiStore();
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">
<!-- Upload slot -->
<button
v-if="editable"
class="gallery__upload"
:disabled="uploading"
@click="upload"
aria-label="Добавить фото"
>
<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"/>
</svg>
<div v-else class="gallery__spinner" />
</button>
<!-- Media items -->
<div
v-for="item in items"
:key="item.id"
class="gallery__item"
>
<img
:src="item.url"
class="gallery__img"
alt="Медиа"
loading="lazy"
@click="lightboxUrl = item.url"
/>
<button
v-if="editable"
class="gallery__delete"
@click.stop="deleteMedia(item.id)"
aria-label="Удалить фото"
>
<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"/>
</svg>
</button>
</div>
</div>
<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="Фото" />
</div>
</Transition>
</Teleport>
</div>
</template>
<style scoped lang="scss">
.gallery {
&__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
@include mobile {
grid-template-columns: repeat(2, 1fr);
}
}
&__item {
aspect-ratio: 1;
position: relative;
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--color-surface-2);
&:hover .gallery__delete { opacity: 1; }
}
&__img {
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
transition: transform var(--transition-fast);
&:hover { transform: scale(1.04); }
}
&__delete {
position: absolute;
top: 6px;
right: 6px;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(13, 13, 13, 0.8);
border: none;
color: var(--color-cream);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity var(--transition-fast), background var(--transition-fast);
&:hover { background: var(--color-signal); }
}
&__upload {
aspect-ratio: 1;
border-radius: var(--radius-sm);
border: 1px dashed var(--color-border);
background: transparent;
color: var(--color-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
&:hover:not(:disabled) {
border-color: var(--color-border-strong);
color: var(--color-cream);
}
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
&__spinner {
width: 20px;
height: 20px;
border: 2px solid transparent;
border-top-color: var(--color-signal);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
}
.lightbox {
position: fixed;
inset: 0;
z-index: var(--z-modal);
background: rgba(0, 0, 0, 0.92);
display: flex;
align-items: center;
justify-content: center;
cursor: zoom-out;
&__img {
max-width: 90vw;
max-height: 90dvh;
object-fit: contain;
border-radius: var(--radius-sm);
}
}
.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); } }
</style>

View File

@@ -0,0 +1,239 @@
<script setup lang="ts">
import { reactive, watch, ref } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, helpers } from '@vuelidate/validators';
import { useUiStore } from '@/stores/ui.store';
import { useAuthStore } from '@/stores/auth.store';
import { useProfileStore } from '@/stores/profile.store';
import { apiClient } from '@/api/client';
import type { UserProfile } from '@/stores/auth.store';
import type { District } from '@/stores/ui.store';
import AppInput from '@/components/common/AppInput.vue';
import AppButton from '@/components/common/AppButton.vue';
const props = defineProps<{ profile: UserProfile }>();
const emit = defineEmits<{ saved: [UserProfile]; cancel: [] }>();
const uiStore = useUiStore();
const authStore = useAuthStore();
const profileStore = useProfileStore();
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.tagIds ?? [])],
});
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>
<AppInput
v-model="form.name"
label="Имя"
name="name"
required
:error="v$.name.$errors.map(e => e.$message as string)"
@blur="v$.name.$touch()"
/>
<AppInput
v-model="form.birthDate"
label="Дата рождения"
type="date"
name="birthDate"
required
:error="v$.birthDate.$errors.map(e => e.$message as string)"
@blur="v$.birthDate.$touch()"
/>
<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>
</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>
</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>
</select>
</div>
<AppInput v-model="form.description" label="О себе" name="description" placeholder="Расскажите о себе..." />
<AppInput v-model="form.nation" label="Национальность" name="nation" placeholder="Необязательно" />
<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" />
</div>
<div class="profile-editor__field">
<span class="label">Интересы</span>
<div class="profile-editor__tags">
<button
v-for="tag in uiStore.tags"
:key="tag.id"
type="button"
class="profile-editor__tag"
:class="{ 'profile-editor__tag--active': form.tagIds.includes(tag.id) }"
@click="toggleTag(tag.id)"
>{{ tag.name }}</button>
</div>
</div>
<div class="profile-editor__actions">
<AppButton type="button" variant="ghost" @click="emit('cancel')">Отмена</AppButton>
<AppButton type="submit" :loading="loading">Сохранить</AppButton>
</div>
</form>
</template>
<style scoped lang="scss">
.profile-editor {
display: flex;
flex-direction: column;
gap: 16px;
&__field {
display: flex;
flex-direction: column;
gap: 8px;
}
&__gender {
display: flex;
gap: 8px;
&-btn, .profile-editor__gender-btn {
flex: 1;
height: 40px;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-muted);
font-family: var(--font-mono);
font-size: 0.8125rem;
cursor: pointer;
transition: all var(--transition-fast);
&.active {
background: var(--color-signal-bg);
border-color: var(--color-signal);
color: var(--color-cream);
}
}
}
&__select {
height: 44px;
padding: 0 12px;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-cream);
font-family: var(--font-mono);
font-size: 0.875rem;
outline: none;
appearance: none;
transition: border-color var(--transition-fast);
&:focus { border-color: var(--color-signal); }
}
&__row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
&__tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
&__tag {
padding: 5px 12px;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
color: var(--color-muted);
font-family: var(--font-mono);
font-size: 0.6875rem;
cursor: pointer;
transition: all var(--transition-fast);
&--active {
background: var(--color-signal-bg);
border-color: var(--color-signal);
color: var(--color-cream);
}
}
&__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 8px;
}
}
</style>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useAuthStore } from '@/stores/auth.store';
import { useUiStore } from '@/stores/ui.store';
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 = useAuthStore();
const uiStore = useUiStore();
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>
<textarea
v-model="form.description"
class="report-form__textarea"
placeholder="Опишите нарушение..."
rows="4"
/>
</form>
<template #footer>
<AppButton variant="ghost" @click="emit('close')">Отмена</AppButton>
<AppButton variant="danger" :loading="loading" @click="submit">Отправить жалобу</AppButton>
</template>
</AppModal>
</template>
<style scoped lang="scss">
.report-form {
display: flex;
flex-direction: column;
gap: 8px;
&__label { color: var(--color-muted); }
&__textarea {
width: 100%;
padding: 10px 14px;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-cream);
font-family: var(--font-mono);
font-size: 0.875rem;
resize: vertical;
min-height: 80px;
outline: none;
transition: border-color var(--transition-fast);
&::placeholder { color: var(--color-muted); }
&:focus { border-color: var(--color-border-strong); }
}
}
</style>

13
src/main.ts Normal file
View File

@@ -0,0 +1,13 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import { router } from './router';
import App from './App.vue';
import '@/styles/tailwind.css';
import '@/styles/main.scss';
const pinia = createPinia();
const app = createApp(App);
app.use(pinia);
app.use(router);
app.mount('#app');

94
src/router/index.ts Normal file
View File

@@ -0,0 +1,94 @@
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '@/stores/auth.store';
import { _getAccessToken, _setAccessToken } from '@/api/client';
import axios from 'axios';
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000';
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', redirect: () => '/feed' },
// Auth
{ 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: '/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 } },
// 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 } },
// Admin
{ path: '/admin/reports', name: 'admin-reports', component: () => import('@/views/admin/ReportsView.vue'), meta: { auth: true, admin: true } },
// Catch-all
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
});
// ─── Navigation guard ────────────────────────────────────────────────────────
let _initDone = false;
router.beforeEach(async (to, _from, next) => {
const authStore = useAuthStore();
// First navigation: try to restore session from localStorage refresh token
if (!_initDone) {
_initDone = true;
const storedRefresh = localStorage.getItem('refreshToken');
if (storedRefresh && !_getAccessToken()) {
try {
const res = await axios.post<{ accessToken: string; refreshToken: string }>(
`${BASE_URL}/api/v1/auth/refresh`,
{ refreshToken: storedRefresh },
);
_setAccessToken(res.data.accessToken);
localStorage.setItem('refreshToken', res.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;
if (requiresAuth && !isAuthed) {
return next({ name: 'login', query: { redirect: to.fullPath } });
}
if (requiresGuest && isAuthed) {
return next({ name: 'feed' });
}
if (requiresAdmin && !authStore.isAdmin) {
return next({ name: 'feed' });
}
// Redirect to setup if authenticated but no profiles
if (requiresAuth && isAuthed && !authStore.hasProfiles && to.name !== 'setup') {
return next({ name: 'setup' });
}
next();
});
export default router;

130
src/stores/auth.store.ts Normal file
View File

@@ -0,0 +1,130 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { apiClient, _setAccessToken, _clearAuth } from '@/api/client';
import type { LoginDto, RegisterDto } from '@/api/api';
export interface UserProfile {
id: string;
name: string;
birthDate: string;
gender: 'male' | 'female';
cityId?: string;
districtId?: string;
description?: string;
nation?: string;
height?: number;
weight?: number;
tagIds?: string[];
mediaUrls?: string[];
avatarUrl?: string;
}
export interface AuthUser {
id: string;
phone: string;
role: 'user' | 'admin';
isActive: boolean;
profiles: UserProfile[];
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<AuthUser | null>(null);
const activeProfileId = ref<string | null>(null);
const isAuthenticated = computed(() => !!user.value);
const isAdmin = computed(() => user.value?.role === '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);
async function login(dto: LoginDto) {
const res = await apiClient.api.authControllerLogin(dto) as unknown as {
accessToken: string;
refreshToken: string;
user: AuthUser;
};
_setAccessToken(res.accessToken);
localStorage.setItem('refreshToken', res.refreshToken);
user.value = res.user;
if (res.user.profiles.length > 0) {
activeProfileId.value = res.user.profiles[0].id;
}
}
async function register(dto: RegisterDto) {
const res = await apiClient.api.authControllerRegister(dto) as unknown as {
accessToken: string;
refreshToken: string;
user: AuthUser;
};
_setAccessToken(res.accessToken);
localStorage.setItem('refreshToken', res.refreshToken);
user.value = res.user;
}
async function logout() {
try {
await apiClient.api.authControllerLogout();
} catch {
// ignore errors on logout
}
_clearAuth();
user.value = null;
activeProfileId.value = null;
}
async function fetchMe() {
const res = await apiClient.api.usersControllerGetMe() as unknown as AuthUser;
user.value = res;
if (res.profiles.length > 0 && !activeProfileId.value) {
activeProfileId.value = res.profiles[0].id;
}
}
function setActiveProfile(profileId: string) {
activeProfileId.value = profileId;
}
function addProfile(profile: UserProfile) {
if (user.value) {
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;
}
}
function removeProfile(profileId: string) {
if (user.value) {
user.value.profiles = user.value.profiles.filter((p) => p.id !== profileId);
if (activeProfileId.value === profileId) {
activeProfileId.value = user.value.profiles[0]?.id ?? null;
}
}
}
return {
user,
activeProfileId,
isAuthenticated,
isAdmin,
profiles,
activeProfile,
hasProfiles,
login,
register,
logout,
fetchMe,
setActiveProfile,
addProfile,
updateProfile,
removeProfile,
};
});

117
src/stores/chat.store.ts Normal file
View File

@@ -0,0 +1,117 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { apiClient } from '@/api/client';
import type { SendMessageDto } from '@/api/api';
export interface ChatProfile {
id: string;
name: string;
avatarUrl?: string;
}
export interface Chat {
id: string;
matchId: string;
isActive: boolean;
partner: ChatProfile;
lastMessage?: ChatMessage;
unreadCount: number;
createdAt: string;
}
export interface ChatMessage {
id: string;
chatId: string;
senderId: string;
text?: string;
mediaUrl?: string;
mediaType?: 'photo' | 'voice' | 'video';
createdAt: string;
}
// Polling interval — replace with WebSocket when backend supports it
const POLL_INTERVAL = 2000;
export const useChatStore = defineStore('chat', () => {
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;
}
async function fetchMessages(chatId: string, profileId: string) {
loading.value = true;
try {
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;
}
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;
}
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;
}
function startPolling(chatId: string, profileId: string) {
stopPolling();
// TODO: replace with WebSocket subscription
pollingTimer.value = setInterval(async () => {
try {
const res = await apiClient.api.chatControllerGetMessages({ chatId, profileId }) as unknown as ChatMessage[];
if (res.length > messages.value.length) {
messages.value = res;
}
} catch {
// polling errors are silent
}
}, POLL_INTERVAL);
}
function stopPolling() {
if (pollingTimer.value) {
clearInterval(pollingTimer.value);
pollingTimer.value = null;
}
}
function setActiveChat(chat: Chat | null) {
activeChat.value = chat;
}
return {
chats,
activeChat,
messages,
loading,
fetchChats,
fetchMessages,
sendMessage,
openChat,
closeChat,
startPolling,
stopPolling,
setActiveChat,
};
});

71
src/stores/feed.store.ts Normal file
View File

@@ -0,0 +1,71 @@
import { defineStore } from 'pinia';
import { ref, reactive } from 'vue';
import { apiClient } from '@/api/client';
import type { FeedControllerGetFeedParams } from '@/api/api';
export interface FeedProfile {
id: string;
name: string;
birthDate: string;
age: number;
gender: 'male' | 'female';
cityId?: string;
cityName?: string;
districtId?: string;
districtName?: string;
description?: string;
nation?: string;
height?: number;
weight?: number;
tags?: Array<{ id: string; name: string }>;
mediaUrls: string[];
avatarUrl?: string;
}
export const useFeedStore = defineStore('feed', () => {
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;
try {
const res = await apiClient.api.feedControllerGetFeed({
profileId,
page: page.value,
limit: 20,
...filters,
}) as unknown as { items: FeedProfile[]; hasMore: boolean; searchPaused?: boolean };
if (page.value === 1) cards.value = res.items;
else cards.value.push(...res.items);
hasMore.value = res.hasMore;
searchPaused.value = res.searchPaused ?? false;
page.value++;
} finally {
loading.value = false;
}
}
function applyFilters(newFilters: Partial<FeedControllerGetFeedParams>) {
Object.assign(filters, newFilters);
reset();
}
function reset() {
cards.value = [];
page.value = 1;
hasMore.value = true;
}
function removeCard(profileId: string) {
cards.value = cards.value.filter((c) => c.id !== profileId);
}
return { cards, filters, page, hasMore, searchPaused, loading, fetchNextPage, applyFilters, reset, removeCard };
});

View File

@@ -0,0 +1,40 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { apiClient } from '@/api/client';
import type { CreateProfileDto, UpdateProfileDto } from '@/api/api';
import type { UserProfile } from './auth.store';
export const useProfileStore = defineStore('profile', () => {
const currentProfile = ref<UserProfile | null>(null);
const loading = ref(false);
async function fetchProfile(profileId: string) {
loading.value = true;
try {
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;
}
async function updateProfile(profileId: string, dto: UpdateProfileDto) {
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;
}
return { currentProfile, loading, fetchProfile, createProfile, updateProfile, deleteProfile };
});

83
src/stores/ui.store.ts Normal file
View File

@@ -0,0 +1,83 @@
import { defineStore } from 'pinia';
import { ref, reactive } from 'vue';
export type ToastType = 'success' | 'error' | 'info' | 'warning';
export interface Toast {
id: string;
type: ToastType;
message: string;
duration?: number;
}
export interface Tag {
id: string;
name: string;
}
export interface City {
id: string;
name: string;
}
export interface District {
id: string;
name: string;
cityId: string;
}
export interface Greeting {
id: string;
text: string;
}
export const useUiStore = defineStore('ui', () => {
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 });
if (duration > 0) {
setTimeout(() => removeToast(id), duration);
}
return id;
}
function removeToast(id: string) {
toasts.value = toasts.value.filter((t) => t.id !== id);
}
function setSidebarExpanded(value: boolean) {
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; }
return {
toasts,
sidebarExpanded,
tags,
cities,
districts,
greetings,
referencesLoaded,
addToast,
removeToast,
setSidebarExpanded,
setTags,
setCities,
setDistricts,
setGreetings,
setReferencesLoaded,
};
});

View File

@@ -0,0 +1,71 @@
// Keyframes and animation utilities
@use 'variables' as *;
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fade-up {
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); }
}
@keyframes slide-in-right {
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); }
}
@keyframes scale-in {
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; }
}
@keyframes pulse-signal {
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; }
// Skeleton shimmer
.skeleton {
background: linear-gradient(
90deg,
var(--color-surface-2) 25%,
var(--color-surface-3) 50%,
var(--color-surface-2) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: var(--radius-sm);
}
// Reduced motion — disable all animations
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

View File

@@ -0,0 +1,95 @@
// Typography scale and global text rules
@use 'variables' as *;
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
font-family: var(--font-mono);
font-size: 0.875rem; // 14px base
line-height: 1.6;
color: var(--color-cream);
background-color: var(--color-base);
}
// Display heading — Instrument Serif
.display {
font-family: var(--font-display);
font-weight: 400;
line-height: 1.1;
letter-spacing: -0.02em;
}
h1, .h1 {
font-family: var(--font-display);
font-size: 2.5rem;
font-weight: 400;
line-height: 1.1;
letter-spacing: -0.03em;
text-wrap: balance;
color: var(--color-cream);
}
h2, .h2 {
font-family: var(--font-display);
font-size: 1.75rem;
font-weight: 400;
line-height: 1.2;
letter-spacing: -0.02em;
text-wrap: balance;
color: var(--color-cream);
}
h3, .h3 {
font-family: var(--font-mono);
font-size: 1.125rem;
font-weight: 500;
line-height: 1.3;
letter-spacing: -0.01em;
color: var(--color-cream);
}
// DM Mono UI labels
.label {
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-muted);
}
.meta {
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 400;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-muted);
}
p {
line-height: 1.65;
max-width: 65ch;
text-wrap: pretty;
}
a {
color: var(--color-cream);
text-decoration: none;
transition: color var(--transition-fast);
&:hover {
color: var(--color-signal);
}
}
// Responsive heading scale
@include mobile {
h1, .h1 { font-size: 2rem; }
h2, .h2 { font-size: 1.5rem; }
}

View File

@@ -0,0 +1,73 @@
// Design tokens — do not use directly in components; use CSS custom properties
:root {
// Surfaces
--color-base: #0d0d0d;
--color-surface: #161614;
--color-surface-2: #1e1e1b;
--color-surface-3: #242420;
// Text
--color-cream: #f0ebe0;
--color-muted: #6b6860;
--color-dim: #3a3935;
// Brand signal
--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-strong: rgba(240, 235, 224, 0.16);
--color-overlay: rgba(13, 13, 13, 0.72);
// Typography
--font-display: 'Instrument Serif', Georgia, serif;
--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-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-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-spring: 420ms cubic-bezier(0.34, 1.56, 0.64, 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;
// Layout
--sidebar-collapsed: 64px;
--sidebar-expanded: 240px;
--nav-height: 60px;
--titlebar-height: 36px;
}
// SCSS breakpoints (for use in @media)
$mobile: 375px;
$tablet: 768px;
$desktop: 1024px;
$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; } }

81
src/styles/main.scss Normal file
View File

@@ -0,0 +1,81 @@
// Import order: variables → typography → animations
@use 'variables' as *;
@use 'typography';
@use 'animations';
// ─── Global reset additions ──────────────────────────────────────────────────
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden; // Tauri + app shell handle scroll internally
}
#app {
height: 100%;
display: flex;
flex-direction: column;
}
// ─── Scrollbar styling ───────────────────────────────────────────────────────
::-webkit-scrollbar {
width: 4px;
height: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-dim);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-muted);
}
// ─── Selection ───────────────────────────────────────────────────────────────
::selection {
background: var(--color-signal);
color: var(--color-cream);
}
// ─── Focus visible ───────────────────────────────────────────────────────────
:focus-visible {
outline: 2px solid var(--color-signal);
outline-offset: 2px;
border-radius: var(--radius-xs);
}
// ─── Utility ─────────────────────────────────────────────────────────────────
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

1
src/styles/tailwind.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,206 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useUiStore } from '@/stores/ui.store';
import { apiClient } from '@/api/client';
import AppButton from '@/components/common/AppButton.vue';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
import EmptyState from '@/components/common/EmptyState.vue';
interface Report {
id: string;
sourceProfileId: string;
entityId: string;
entityType: 'profile' | 'message';
description?: string;
createdAt: string;
resolved?: boolean;
reporterName?: string;
}
const uiStore = useUiStore();
const reports = ref<Report[]>([]);
const loading = ref(false);
onMounted(async () => {
loading.value = true;
try {
const res = await apiClient.api.reportsControllerGetAll() as unknown as Report[];
reports.value = res;
} catch {
uiStore.addToast('Не удалось загрузить жалобы', 'error');
} finally {
loading.value = false;
}
});
async function banUser(userId: string) {
try {
await apiClient.api.usersControllerBan(userId);
uiStore.addToast('Пользователь заблокирован', 'success');
} catch {
uiStore.addToast('Не удалось заблокировать пользователя', 'error');
}
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('ru', { day: 'numeric', month: 'short', year: 'numeric' });
}
</script>
<template>
<div class="reports-admin">
<header class="reports-admin__header">
<h1 class="reports-admin__title">Жалобы</h1>
<span class="meta">{{ reports.length }} всего</span>
</header>
<div v-if="loading" class="reports-admin__loading">
<LoadingSpinner size="lg" />
</div>
<EmptyState
v-else-if="reports.length === 0"
title="Нет жалоб"
description="Жалобы от пользователей появятся здесь"
icon="default"
/>
<div v-else class="reports-admin__table-wrap">
<table class="reports-table">
<thead>
<tr>
<th>Дата</th>
<th>Тип</th>
<th>Объект</th>
<th>Описание</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<tr v-for="report in reports" :key="report.id" :class="{ 'reports-table__row--resolved': report.resolved }">
<td class="reports-table__date meta">{{ formatDate(report.createdAt) }}</td>
<td>
<span class="reports-table__type" :class="`reports-table__type--${report.entityType}`">
{{ report.entityType === 'profile' ? 'Профиль' : 'Сообщение' }}
</span>
</td>
<td class="reports-table__entity">
<RouterLink
v-if="report.entityType === 'profile'"
:to="`/profile/${report.entityId}`"
class="reports-table__link"
>Открыть</RouterLink>
<span v-else class="meta">{{ report.entityId.slice(0, 8) }}</span>
</td>
<td class="reports-table__desc">{{ report.description ?? '—' }}</td>
<td>
<AppButton
v-if="report.entityType === 'profile'"
variant="danger"
size="sm"
@click="banUser(report.sourceProfileId)"
>Заблокировать</AppButton>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped lang="scss">
.reports-admin {
height: 100%;
display: flex;
flex-direction: column;
&__header {
display: flex;
align-items: baseline;
gap: 12px;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
&__title {
font-family: var(--font-display);
font-size: 1.5rem;
color: var(--color-cream);
margin: 0;
}
&__loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
&__table-wrap {
flex: 1;
overflow: auto;
padding: 16px 24px;
}
}
.reports-table {
width: 100%;
border-collapse: collapse;
font-family: var(--font-mono);
font-size: 0.8125rem;
th {
text-align: left;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
font-size: 0.625rem;
color: var(--color-muted);
padding: 8px 12px;
border-bottom: 1px solid var(--color-border);
}
td {
padding: 10px 12px;
color: var(--color-cream);
border-bottom: 1px solid var(--color-border);
vertical-align: middle;
}
tr:last-child td { border-bottom: none; }
&__row--resolved td { opacity: 0.5; }
&__date { color: var(--color-muted) !important; font-variant-numeric: tabular-nums; }
&__type {
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: 0.625rem;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
&--profile { background: var(--color-signal-bg); color: var(--color-signal); }
&--message { background: rgba(240, 235, 224, 0.06); color: var(--color-muted); }
}
&__link {
color: var(--color-cream);
text-decoration: underline;
text-underline-offset: 3px;
text-decoration-color: var(--color-border);
transition: color var(--transition-fast);
&:hover { color: var(--color-signal); }
}
&__desc {
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-muted) !important;
}
}
</style>

View File

@@ -0,0 +1,174 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useVuelidate } from '@vuelidate/core';
import { required, helpers } from '@vuelidate/validators';
import { useAuthStore } from '@/stores/auth.store';
import { useUiStore } from '@/stores/ui.store';
import AppInput from '@/components/common/AppInput.vue';
import AppButton from '@/components/common/AppButton.vue';
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const uiStore = useUiStore();
const form = reactive({ phone: '', password: '' });
const loading = ref(false);
const rules = {
phone: { required: helpers.withMessage('Введите номер телефона', required) },
password: { required: helpers.withMessage('Введите пароль', required) },
};
const v$ = useVuelidate(rules, form);
async function submit() {
const valid = await v$.value.$validate();
if (!valid) return;
loading.value = true;
try {
await authStore.login({ phone: form.phone, password: form.password });
const redirect = (route.query.redirect as string) || '/feed';
router.replace(redirect);
} catch (err: unknown) {
const message = (err as { response?: { data?: { message?: string } } })
?.response?.data?.message ?? 'Неверный телефон или пароль';
uiStore.addToast(message, 'error');
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="auth-page">
<div class="auth-page__grain" aria-hidden="true" />
<div class="auth-card">
<div class="auth-card__wordmark">Daiting</div>
<h1 class="auth-card__heading">С возвращением</h1>
<p class="auth-card__sub">Войдите, чтобы продолжить</p>
<form class="auth-form" @submit.prevent="submit" novalidate>
<AppInput
v-model="form.phone"
label="Телефон"
placeholder="+7 999 000 00 00"
type="tel"
name="phone"
autocomplete="tel"
required
:error="v$.phone.$errors.map(e => e.$message as string)"
@blur="v$.phone.$touch()"
/>
<AppInput
v-model="form.password"
label="Пароль"
placeholder="••••••••"
type="password"
name="password"
autocomplete="current-password"
required
:error="v$.password.$errors.map(e => e.$message as string)"
@blur="v$.password.$touch()"
/>
<AppButton type="submit" :loading="loading" full-width size="lg" class="auth-form__submit">
Войти
</AppButton>
</form>
<p class="auth-card__footer">
Нет аккаунта?
<RouterLink to="/register" class="auth-card__link">Зарегистрироваться</RouterLink>
</p>
</div>
</div>
</template>
<style scoped lang="scss">
.auth-page {
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-base);
padding: 24px;
position: relative;
overflow: hidden;
&__grain {
position: absolute;
inset: 0;
background-image: url('@/assets/grain.svg');
background-repeat: repeat;
opacity: 0.3;
pointer-events: none;
}
// Warm radial vignette
&::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse 80% 70% at 50% 0%, rgba(196, 92, 58, 0.06) 0%, transparent 70%);
pointer-events: none;
}
}
.auth-card {
position: relative;
z-index: 1;
width: 100%;
max-width: 420px;
animation: fade-up var(--transition-base) both;
&__wordmark {
font-family: var(--font-display);
font-size: 1.5rem;
color: var(--color-signal);
margin-bottom: 32px;
letter-spacing: 0.04em;
}
&__heading {
font-family: var(--font-display);
font-size: 2.25rem;
color: var(--color-cream);
margin: 0 0 8px;
}
&__sub {
font-family: var(--font-mono);
font-size: 0.875rem;
color: var(--color-muted);
margin: 0 0 40px;
}
&__footer {
margin-top: 24px;
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--color-muted);
text-align: center;
}
&__link {
color: var(--color-cream);
transition: color var(--transition-fast);
&:hover { color: var(--color-signal); }
}
}
.auth-form {
display: flex;
flex-direction: column;
gap: 20px;
&__submit {
margin-top: 8px;
}
}
</style>

View File

@@ -0,0 +1,191 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength, helpers } from '@vuelidate/validators';
import { useAuthStore } from '@/stores/auth.store';
import { useUiStore } from '@/stores/ui.store';
import AppInput from '@/components/common/AppInput.vue';
import AppButton from '@/components/common/AppButton.vue';
const router = useRouter();
const authStore = useAuthStore();
const uiStore = useUiStore();
const form = reactive({ phone: '', password: '', confirmPassword: '' });
const loading = ref(false);
const phoneRegex = helpers.regex(/^\+?[0-9\s\-()]{7,20}$/);
const rules = {
phone: {
required: helpers.withMessage('Введите номер телефона', required),
format: helpers.withMessage('Введите корректный номер', phoneRegex),
},
password: {
required: helpers.withMessage('Введите пароль', required),
minLen: helpers.withMessage('Минимум 8 символов', minLength(8)),
},
confirmPassword: {
required: helpers.withMessage('Подтвердите пароль', required),
match: helpers.withMessage('Пароли не совпадают', () => form.password === form.confirmPassword),
},
};
const v$ = useVuelidate(rules, form);
async function submit() {
const valid = await v$.value.$validate();
if (!valid) return;
loading.value = true;
try {
await authStore.register({ phone: form.phone, password: form.password });
router.replace('/setup');
} catch (err: unknown) {
const message = (err as { response?: { data?: { message?: string } } })
?.response?.data?.message ?? 'Ошибка регистрации. Попробуйте ещё раз.';
uiStore.addToast(message, 'error');
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="auth-page">
<div class="auth-page__grain" aria-hidden="true" />
<div class="auth-card">
<div class="auth-card__wordmark">Daiting</div>
<h1 class="auth-card__heading">Создать аккаунт</h1>
<p class="auth-card__sub">Начните своё путешествие</p>
<form class="auth-form" @submit.prevent="submit" novalidate>
<AppInput
v-model="form.phone"
label="Телефон"
placeholder="+7 999 000 00 00"
type="tel"
name="phone"
autocomplete="tel"
required
:error="v$.phone.$errors.map(e => e.$message as string)"
@blur="v$.phone.$touch()"
/>
<AppInput
v-model="form.password"
label="Пароль"
placeholder="Минимум 8 символов"
type="password"
name="password"
autocomplete="new-password"
required
:error="v$.password.$errors.map(e => e.$message as string)"
@blur="v$.password.$touch()"
/>
<AppInput
v-model="form.confirmPassword"
label="Подтверждение пароля"
placeholder="Повторите пароль"
type="password"
name="confirm-password"
autocomplete="new-password"
required
:error="v$.confirmPassword.$errors.map(e => e.$message as string)"
@blur="v$.confirmPassword.$touch()"
/>
<AppButton type="submit" :loading="loading" full-width size="lg" class="auth-form__submit">
Зарегистрироваться
</AppButton>
</form>
<p class="auth-card__footer">
Уже есть аккаунт?
<RouterLink to="/login" class="auth-card__link">Войти</RouterLink>
</p>
</div>
</div>
</template>
<style scoped lang="scss">
.auth-page {
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-base);
padding: 24px;
position: relative;
overflow: hidden;
&__grain {
position: absolute;
inset: 0;
background-image: url('@/assets/grain.svg');
background-repeat: repeat;
opacity: 0.3;
pointer-events: none;
}
&::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse 80% 70% at 50% 0%, rgba(196, 92, 58, 0.06) 0%, transparent 70%);
pointer-events: none;
}
}
.auth-card {
position: relative;
z-index: 1;
width: 100%;
max-width: 420px;
animation: fade-up var(--transition-base) both;
&__wordmark {
font-family: var(--font-display);
font-size: 1.5rem;
color: var(--color-signal);
margin-bottom: 32px;
letter-spacing: 0.04em;
}
&__heading {
font-family: var(--font-display);
font-size: 2.25rem;
color: var(--color-cream);
margin: 0 0 8px;
}
&__sub {
font-family: var(--font-mono);
font-size: 0.875rem;
color: var(--color-muted);
margin: 0 0 40px;
}
&__footer {
margin-top: 24px;
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--color-muted);
text-align: center;
}
&__link {
color: var(--color-cream);
transition: color var(--transition-fast);
&:hover { color: var(--color-signal); }
}
}
.auth-form {
display: flex;
flex-direction: column;
gap: 20px;
&__submit { margin-top: 8px; }
}
</style>

View File

@@ -0,0 +1,285 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick, computed } from 'vue';
import { useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth.store';
import { useChatStore } from '@/stores/chat.store';
import { useUiStore } from '@/stores/ui.store';
import { apiClient } from '@/api/client';
import type { ChatMessage } from '@/stores/chat.store';
import ChatBubble from '@/components/chat/ChatBubble.vue';
import ChatInput from '@/components/chat/ChatInput.vue';
import AppModal from '@/components/common/AppModal.vue';
import AppButton from '@/components/common/AppButton.vue';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
const route = useRoute();
const authStore = useAuthStore();
const chatStore = useChatStore();
const uiStore = useUiStore();
const chatId = route.params.chatId as string;
const profileId = computed(() => authStore.activeProfile?.id ?? '');
const messagesEnd = ref<HTMLElement | null>(null);
const confirmClose = ref(false);
const chat = computed(() => chatStore.chats.find((c) => c.id === chatId));
const isLocked = computed(() => chat.value && !chat.value.isActive);
// Group messages by date
const groupedMessages = computed(() => {
const groups: Array<{ date: string; messages: ChatMessage[] }> = [];
let lastDate = '';
for (const msg of chatStore.messages) {
const d = new Date(msg.createdAt).toLocaleDateString('ru', { day: 'numeric', month: 'long', year: 'numeric' });
if (d !== lastDate) {
groups.push({ date: d, messages: [] });
lastDate = d;
}
groups[groups.length - 1].messages.push(msg);
}
return groups;
});
async function scrollToBottom() {
await nextTick();
messagesEnd.value?.scrollIntoView({ behavior: 'smooth' });
}
onMounted(async () => {
if (!profileId.value) return;
await chatStore.fetchMessages(chatId, profileId.value);
await scrollToBottom();
chatStore.startPolling(chatId, profileId.value);
});
onUnmounted(() => {
chatStore.stopPolling();
});
async function send(text: string, mediaUrl?: string, mediaType?: 'photo' | 'voice' | 'video') {
if (!profileId.value) return;
try {
await chatStore.sendMessage(chatId, profileId.value, { text, mediaUrl, mediaType });
await scrollToBottom();
} catch {
uiStore.addToast('Не удалось отправить сообщение', 'error');
}
}
async function doCloseChat() {
if (!profileId.value) return;
try {
await chatStore.closeChat(chatId, profileId.value);
confirmClose.value = false;
history.back();
} catch {
uiStore.addToast('Не удалось закрыть чат', 'error');
}
}
</script>
<template>
<div class="chat-room">
<!-- Header -->
<header class="chat-room__header">
<button class="chat-room__back" @click="history.back()" aria-label="Назад">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="20" height="20">
<path d="M19 12H5M12 5l-7 7 7 7"/>
</svg>
</button>
<div class="chat-room__partner">
<img
v-if="chat?.partner.avatarUrl"
:src="chat.partner.avatarUrl"
class="chat-room__avatar"
:alt="chat.partner.name"
/>
<div v-else class="chat-room__avatar chat-room__avatar--placeholder" />
<span class="chat-room__name">{{ chat?.partner.name ?? 'Чат' }}</span>
</div>
<button
class="chat-room__close-btn"
@click="confirmClose = true"
aria-label="Закрыть чат"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="18" height="18">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</header>
<!-- Locked overlay -->
<div v-if="isLocked" class="chat-room__locked" role="alert">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="24" height="24">
<rect x="3" y="11" width="18" height="11" rx="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
<span>Чат заблокирован. Закройте другой активный чат, чтобы продолжить общение.</span>
</div>
<!-- Messages -->
<div v-if="chatStore.loading" class="chat-room__loading">
<LoadingSpinner size="md" />
</div>
<div v-else class="chat-room__messages" :class="{ 'chat-room__messages--blurred': isLocked }">
<div
v-for="group in groupedMessages"
:key="group.date"
class="chat-room__group"
>
<div class="chat-room__date-sep">
<span>{{ group.date }}</span>
</div>
<ChatBubble
v-for="msg in group.messages"
:key="msg.id"
:message="msg"
:is-mine="msg.senderId === authStore.activeProfile?.id"
/>
</div>
<div ref="messagesEnd" />
</div>
<!-- Input -->
<ChatInput v-if="!isLocked" @send="send" />
<!-- Close chat confirm -->
<AppModal
:open="confirmClose"
title="Закрыть чат"
size="sm"
@close="confirmClose = false"
>
<p style="font-family: var(--font-mono); font-size: 0.875rem; color: var(--color-muted); margin: 0">
Вы уверены? Переписка будет удалена и восстановить её нельзя.
</p>
<template #footer>
<AppButton variant="ghost" @click="confirmClose = false">Отмена</AppButton>
<AppButton variant="danger" @click="doCloseChat">Закрыть чат</AppButton>
</template>
</AppModal>
</div>
</template>
<style scoped lang="scss">
.chat-room {
height: 100%;
display: flex;
flex-direction: column;
&__header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
&__back {
background: none;
border: none;
color: var(--color-muted);
cursor: pointer;
padding: 4px;
display: flex;
border-radius: var(--radius-sm);
transition: color var(--transition-fast);
flex-shrink: 0;
&:hover { color: var(--color-cream); }
}
&__partner {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
}
&__avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
&--placeholder {
background: var(--color-surface-2);
}
}
&__name {
font-family: var(--font-mono);
font-size: 0.9375rem;
font-weight: 500;
color: var(--color-cream);
}
&__close-btn {
background: none;
border: none;
color: var(--color-muted);
cursor: pointer;
padding: 6px;
border-radius: var(--radius-sm);
display: flex;
transition: color var(--transition-fast);
flex-shrink: 0;
&:hover { color: var(--color-signal); }
}
&__locked {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
background: rgba(196, 92, 58, 0.1);
border-bottom: 1px solid rgba(196, 92, 58, 0.3);
color: var(--color-signal);
font-family: var(--font-mono);
font-size: 0.75rem;
flex-shrink: 0;
}
&__loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
&__messages {
flex: 1;
overflow-y: auto;
padding: 16px 0;
&--blurred {
filter: blur(4px);
pointer-events: none;
user-select: none;
}
}
&__group { margin-bottom: 16px; }
&__date-sep {
display: flex;
align-items: center;
justify-content: center;
padding: 8px 16px;
span {
font-family: var(--font-mono);
font-size: 0.625rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-muted);
background: var(--color-base);
padding: 3px 10px;
border-radius: var(--radius-full);
border: 1px solid var(--color-border);
}
}
}
</style>

View File

@@ -0,0 +1,239 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useAuthStore } from '@/stores/auth.store';
import { useChatStore } from '@/stores/chat.store';
import { useUiStore } from '@/stores/ui.store';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
import EmptyState from '@/components/common/EmptyState.vue';
const authStore = useAuthStore();
const chatStore = useChatStore();
const uiStore = useUiStore();
const loading = ref(false);
onMounted(async () => {
const profileId = authStore.activeProfile?.id;
if (!profileId) return;
loading.value = true;
try {
await chatStore.fetchChats(profileId);
} catch {
uiStore.addToast('Не удалось загрузить чаты', 'error');
} finally {
loading.value = false;
}
});
function formatTime(dateStr: string) {
const d = new Date(dateStr);
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
if (isToday) return d.toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' });
return d.toLocaleDateString('ru', { day: 'numeric', month: 'short' });
}
</script>
<template>
<div class="chats-list">
<header class="chats-list__header">
<h1 class="chats-list__title">Чаты</h1>
</header>
<div v-if="loading" class="chats-list__loading">
<LoadingSpinner size="lg" />
</div>
<EmptyState
v-else-if="chatStore.chats.length === 0"
title="Нет активных чатов"
description="Найдите совпадения и начните общение"
icon="chat"
/>
<ul v-else class="chats-list__list" role="list">
<li v-for="chat in chatStore.chats" :key="chat.id">
<RouterLink
:to="`/chats/${chat.id}`"
class="chat-item"
:class="{ 'chat-item--inactive': !chat.isActive }"
>
<div class="chat-item__avatar-wrap">
<img
v-if="chat.partner.avatarUrl"
:src="chat.partner.avatarUrl"
:alt="chat.partner.name"
class="chat-item__avatar"
/>
<div v-else class="chat-item__avatar chat-item__avatar--placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" width="24" height="24" opacity="0.3">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"/>
</svg>
</div>
<!-- Lock icon for inactive chats -->
<div v-if="!chat.isActive" class="chat-item__lock" aria-label="Чат неактивен">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12">
<rect x="3" y="7" width="10" height="8" rx="1"/>
<path d="M5 7V5a3 3 0 0 1 6 0v2"/>
</svg>
</div>
</div>
<div class="chat-item__content">
<div class="chat-item__top">
<span class="chat-item__name">{{ chat.partner.name }}</span>
<span v-if="chat.lastMessage" class="chat-item__time meta">
{{ formatTime(chat.lastMessage.createdAt) }}
</span>
</div>
<p v-if="chat.lastMessage" class="chat-item__preview">
{{ chat.lastMessage.text || (chat.lastMessage.mediaType === 'photo' ? '📷 Фото' : chat.lastMessage.mediaType === 'voice' ? '🎤 Голосовое' : '🎬 Видео') }}
</p>
<p v-else class="chat-item__preview chat-item__preview--empty">Начните переписку</p>
</div>
<div v-if="chat.unreadCount > 0" class="chat-item__badge">{{ chat.unreadCount }}</div>
</RouterLink>
</li>
</ul>
</div>
</template>
<style scoped lang="scss">
.chats-list {
height: 100%;
display: flex;
flex-direction: column;
&__header {
padding: 20px 24px 16px;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
&__title {
font-family: var(--font-display);
font-size: 1.5rem;
color: var(--color-cream);
margin: 0;
}
&__loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
&__list {
flex: 1;
overflow-y: auto;
list-style: none;
margin: 0;
padding: 0;
}
}
.chat-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 24px;
text-decoration: none;
transition: background var(--transition-fast);
&:hover { background: rgba(240, 235, 224, 0.03); }
&--inactive {
opacity: 0.6;
}
&__avatar-wrap {
position: relative;
flex-shrink: 0;
}
&__avatar {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
&--placeholder {
background: var(--color-surface-2);
display: flex;
align-items: center;
justify-content: center;
}
}
&__lock {
position: absolute;
bottom: -2px;
right: -2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--color-surface-3);
border: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-muted);
}
&__content {
flex: 1;
min-width: 0;
}
&__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 3px;
}
&__name {
font-family: var(--font-mono);
font-size: 0.9375rem;
font-weight: 500;
color: var(--color-cream);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__time {
flex-shrink: 0;
color: var(--color-muted);
}
&__preview {
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--color-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
&--empty { font-style: italic; }
}
&__badge {
flex-shrink: 0;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--color-signal);
color: white;
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
}
}
</style>

View File

@@ -0,0 +1,257 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useAuthStore } from '@/stores/auth.store';
import { useUiStore } from '@/stores/ui.store';
import { apiClient } from '@/api/client';
import AppButton from '@/components/common/AppButton.vue';
import AppModal from '@/components/common/AppModal.vue';
import EmptyState from '@/components/common/EmptyState.vue';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
interface DateStatus { id: string; name: string; }
interface DateItem {
id: string;
profileId: string;
partnerProfileId: string;
partnerName?: string;
lat: number;
lng: number;
time: string;
statusId: string;
statusName?: string;
isIncoming: boolean;
}
const authStore = useAuthStore();
const uiStore = useUiStore();
const dates = ref<DateItem[]>([]);
const statuses = ref<DateStatus[]>([]);
const loading = ref(false);
const actionLoading = ref<string | null>(null);
onMounted(async () => {
const profileId = authStore.activeProfile?.id;
if (!profileId) return;
loading.value = true;
try {
const [datesRes, statusesRes] = await Promise.all([
apiClient.api.datesControllerGetDates({ profileId }) as unknown as DateItem[],
apiClient.api.datesControllerGetStatuses() as unknown as DateStatus[],
]);
dates.value = datesRes;
statuses.value = statusesRes;
} catch {
uiStore.addToast('Не удалось загрузить встречи', 'error');
} finally {
loading.value = false;
}
});
function statusLabel(statusId: string) {
return statuses.value.find((s) => s.id === statusId)?.name ?? statusId;
}
function statusColor(statusId: string) {
const name = statusLabel(statusId).toLowerCase();
if (name.includes('ожид') || name.includes('pending')) return 'pending';
if (name.includes('приня') || name.includes('accept')) return 'accepted';
if (name.includes('отклон') || name.includes('declin')) return 'declined';
if (name.includes('заверш') || name.includes('complet')) return 'completed';
return 'default';
}
async function updateStatus(dateId: string, statusId: string) {
const profileId = authStore.activeProfile?.id;
if (!profileId) return;
actionLoading.value = `${dateId}-${statusId}`;
try {
await apiClient.api.datesControllerUpdateStatus({ id: dateId, profileId }, { statusId });
const idx = dates.value.findIndex((d) => d.id === dateId);
if (idx !== -1) dates.value[idx].statusId = statusId;
uiStore.addToast('Статус обновлён', 'success');
} catch {
uiStore.addToast('Не удалось обновить статус', 'error');
} finally {
actionLoading.value = null;
}
}
function formatDateTime(iso: string) {
return new Date(iso).toLocaleString('ru', {
day: 'numeric', month: 'long', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
// Pending = first two status ids roughly; accept/decline/complete = 3+
const pendingStatusId = computed(() => statuses.value[0]?.id ?? '');
const acceptedStatusId = computed(() => statuses.value[1]?.id ?? '');
const declinedStatusId = computed(() => statuses.value[2]?.id ?? '');
const completedStatusId = computed(() => statuses.value[3]?.id ?? '');
</script>
<template>
<div class="dates-view">
<header class="dates-view__header">
<h1 class="dates-view__title">Встречи</h1>
</header>
<div v-if="loading" class="dates-view__loading">
<LoadingSpinner size="lg" />
</div>
<EmptyState
v-else-if="dates.length === 0"
title="Нет предстоящих встреч"
description="Предложите встречу из профиля совпадения"
icon="calendar"
/>
<div v-else class="dates-list">
<article v-for="date in dates" :key="date.id" class="date-card">
<div class="date-card__header">
<div>
<h3 class="date-card__partner">{{ date.partnerName ?? 'Партнёр' }}</h3>
<time class="date-card__time meta" :datetime="date.time">{{ formatDateTime(date.time) }}</time>
</div>
<span
class="date-card__status"
:class="`date-card__status--${statusColor(date.statusId)}`"
>{{ statusLabel(date.statusId) }}</span>
</div>
<div class="date-card__location meta">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14">
<path d="M10 11a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/>
<path d="M17 10c0 6-7 10-7 10S3 16 3 10a7 7 0 1 1 14 0z"/>
</svg>
{{ date.lat.toFixed(4) }}, {{ date.lng.toFixed(4) }}
</div>
<!-- Actions for incoming proposals -->
<div v-if="date.isIncoming && statusLabel(date.statusId).toLowerCase().includes('ожид')" class="date-card__actions">
<AppButton
variant="primary"
size="sm"
:loading="actionLoading === `${date.id}-${acceptedStatusId}`"
@click="updateStatus(date.id, acceptedStatusId)"
>Принять</AppButton>
<AppButton
variant="ghost"
size="sm"
:loading="actionLoading === `${date.id}-${declinedStatusId}`"
@click="updateStatus(date.id, declinedStatusId)"
>Отклонить</AppButton>
</div>
<!-- Mark as complete -->
<div v-else-if="statusLabel(date.statusId).toLowerCase().includes('приня')" class="date-card__actions">
<AppButton
variant="secondary"
size="sm"
:loading="actionLoading === `${date.id}-${completedStatusId}`"
@click="updateStatus(date.id, completedStatusId)"
>Встреча состоялась</AppButton>
</div>
</article>
</div>
</div>
</template>
<style scoped lang="scss">
.dates-view {
height: 100%;
display: flex;
flex-direction: column;
&__header {
padding: 20px 24px 16px;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
&__title {
font-family: var(--font-display);
font-size: 1.5rem;
color: var(--color-cream);
margin: 0;
}
&__loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
}
.dates-list {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.date-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 12px;
transition: border-color var(--transition-fast);
&:hover { border-color: var(--color-border-strong); }
&__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
&__partner {
font-family: var(--font-mono);
font-size: 1rem;
font-weight: 500;
color: var(--color-cream);
margin: 0 0 4px;
}
&__time { color: var(--color-muted); }
&__status {
padding: 3px 10px;
border-radius: var(--radius-full);
font-family: var(--font-mono);
font-size: 0.625rem;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
flex-shrink: 0;
&--pending { background: rgba(200, 160, 60, 0.15); color: #c89c3c; }
&--accepted { background: rgba(80, 180, 80, 0.15); color: #50b450; }
&--declined { background: var(--color-signal-bg); color: var(--color-signal); }
&--completed { background: rgba(240, 235, 224, 0.06); color: var(--color-muted); }
&--default { background: rgba(240, 235, 224, 0.06); color: var(--color-muted); }
}
&__location {
display: flex;
align-items: center;
gap: 6px;
color: var(--color-muted);
font-variant-numeric: tabular-nums;
}
&__actions {
display: flex;
gap: 8px;
}
}
</style>

251
src/views/feed/FeedView.vue Normal file
View File

@@ -0,0 +1,251 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useFeedStore } from '@/stores/feed.store';
import { useAuthStore } from '@/stores/auth.store';
import FeedCardStack from '@/components/feed/FeedCardStack.vue';
import FeedFilters from '@/components/feed/FeedFilters.vue';
import AppButton from '@/components/common/AppButton.vue';
const feedStore = useFeedStore();
const authStore = useAuthStore();
const filtersOpen = ref(false);
const viewMode = ref<'stack' | 'scroll'>('stack');
onMounted(() => {
const profileId = authStore.activeProfile?.id;
if (profileId && feedStore.cards.length === 0) {
feedStore.fetchNextPage(profileId);
}
});
</script>
<template>
<div class="feed-view">
<!-- Header bar -->
<header class="feed-header">
<h1 class="feed-header__title">Лента</h1>
<div class="feed-header__actions">
<!-- View mode toggle -->
<div class="feed-header__toggle" role="group" aria-label="Режим просмотра">
<button
class="feed-header__toggle-btn"
:class="{ 'feed-header__toggle-btn--active': viewMode === 'stack' }"
@click="viewMode = 'stack'"
aria-label="Карточки"
>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16">
<rect x="2" y="2" width="16" height="16" rx="2"/>
<rect x="5" y="5" width="10" height="10" rx="1" stroke-dasharray="2 1"/>
</svg>
</button>
<button
class="feed-header__toggle-btn"
:class="{ 'feed-header__toggle-btn--active': viewMode === 'scroll' }"
@click="viewMode = 'scroll'"
aria-label="Лента"
>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16">
<path d="M2 5h16M2 10h16M2 15h16"/>
</svg>
</button>
</div>
<AppButton variant="secondary" size="sm" @click="filtersOpen = true">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14">
<path d="M3 5h14M6 10h8M9 15h2"/>
</svg>
Фильтры
</AppButton>
</div>
</header>
<!-- Search paused banner -->
<div v-if="feedStore.searchPaused" class="feed-paused" role="alert">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16">
<path d="M10 9v4m0 4h.01M8.29 3.86L1.82 17a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
</svg>
Поиск приостановлен: достигнут лимит совпадений
</div>
<!-- Card stack mode -->
<div v-if="viewMode === 'stack'" class="feed-view__stack">
<FeedCardStack />
</div>
<!-- Scroll mode grid of cards -->
<div v-else class="feed-view__scroll">
<div class="feed-grid">
<article
v-for="profile in feedStore.cards"
:key="profile.id"
class="feed-grid__item"
>
<RouterLink :to="`/profile/${profile.id}`" class="feed-grid__link">
<img
v-if="profile.avatarUrl"
:src="profile.avatarUrl"
:alt="profile.name"
class="feed-grid__img"
/>
<div v-else class="feed-grid__no-img" />
<div class="feed-grid__overlay">
<span class="feed-grid__name">{{ profile.name }}</span>
</div>
</RouterLink>
</article>
</div>
<div v-if="feedStore.loading" class="feed-view__load-more">
<span class="meta">Загрузка...</span>
</div>
</div>
<FeedFilters :open="filtersOpen" @close="filtersOpen = false" />
</div>
</template>
<style scoped lang="scss">
.feed-view {
height: 100%;
display: flex;
flex-direction: column;
&__stack {
flex: 1;
padding: 16px;
position: relative;
}
&__scroll {
flex: 1;
overflow-y: auto;
padding: 16px;
}
&__load-more {
text-align: center;
padding: 24px;
color: var(--color-muted);
}
}
.feed-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
&__title {
font-family: var(--font-display);
font-size: 1.5rem;
color: var(--color-cream);
margin: 0;
}
&__actions {
display: flex;
align-items: center;
gap: 8px;
}
&__toggle {
display: flex;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
overflow: hidden;
&-btn {
width: 36px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--color-muted);
cursor: pointer;
transition: all var(--transition-fast);
&--active {
background: var(--color-surface-3);
color: var(--color-cream);
}
&:hover:not(.feed-header__toggle-btn--active) {
color: var(--color-cream);
}
}
}
}
.feed-paused {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: var(--color-signal-bg);
border-bottom: 1px solid rgba(196, 92, 58, 0.3);
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--color-signal);
flex-shrink: 0;
}
.feed-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
&__item {
aspect-ratio: 3/4;
border-radius: var(--radius-md);
overflow: hidden;
background: var(--color-surface-2);
}
&__link {
display: block;
position: relative;
width: 100%;
height: 100%;
text-decoration: none;
&:hover .feed-grid__overlay {
opacity: 1;
}
}
&__img {
width: 100%;
height: 100%;
object-fit: cover;
}
&__no-img {
width: 100%;
height: 100%;
background: var(--color-surface-3);
}
&__overlay {
position: absolute;
inset: 0;
background: linear-gradient(0deg, rgba(13,13,13,0.8) 0%, transparent 50%);
display: flex;
align-items: flex-end;
padding: 12px;
opacity: 0;
transition: opacity var(--transition-fast);
}
&__name {
font-family: var(--font-display);
font-size: 1rem;
color: var(--color-cream);
}
}
</style>

View File

@@ -0,0 +1,211 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useAuthStore } from '@/stores/auth.store';
import { useChatStore } from '@/stores/chat.store';
import { useUiStore } from '@/stores/ui.store';
import { apiClient } from '@/api/client';
import { useRouter } from 'vue-router';
import AppButton from '@/components/common/AppButton.vue';
import EmptyState from '@/components/common/EmptyState.vue';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
interface Match {
id: string;
profileId: string;
partnerProfile: {
id: string;
name: string;
avatarUrl?: string;
cityName?: string;
age?: number;
};
createdAt: string;
hasChat: boolean;
}
const authStore = useAuthStore();
const chatStore = useChatStore();
const uiStore = useUiStore();
const router = useRouter();
const matches = ref<Match[]>([]);
const loading = ref(false);
onMounted(async () => {
const profileId = authStore.activeProfile?.id;
if (!profileId) return;
loading.value = true;
try {
const res = await apiClient.api.likesControllerGetMyMatches({ profileId }) as unknown as Match[];
matches.value = res;
} catch {
uiStore.addToast('Не удалось загрузить совпадения', 'error');
} finally {
loading.value = false;
}
});
async function openChat(match: Match) {
const profileId = authStore.activeProfile?.id;
if (!profileId) return;
try {
const chat = await chatStore.openChat(profileId, match.id);
router.push(`/chats/${chat.id}`);
} catch (err: unknown) {
const msg = (err as { response?: { data?: { message?: string } } })
?.response?.data?.message ?? 'Не удалось открыть чат';
uiStore.addToast(msg, 'error');
}
}
</script>
<template>
<div class="matches-view">
<header class="matches-view__header">
<h1 class="matches-view__title">Совпадения</h1>
<span class="meta">{{ matches.length }} {{ matches.length === 1 ? 'человек' : 'людей' }}</span>
</header>
<div v-if="loading" class="matches-view__loading">
<LoadingSpinner size="lg" />
</div>
<EmptyState
v-else-if="matches.length === 0"
title="Пока нет совпадений"
description="Ставьте лайки, чтобы находить тех, кто ответит взаимностью"
icon="heart"
/>
<div v-else class="matches-list">
<article
v-for="match in matches"
:key="match.id"
class="match-card"
>
<RouterLink :to="`/profile/${match.partnerProfile.id}`" class="match-card__avatar-wrap">
<img
v-if="match.partnerProfile.avatarUrl"
:src="match.partnerProfile.avatarUrl"
:alt="match.partnerProfile.name"
class="match-card__avatar"
/>
<div v-else class="match-card__avatar match-card__avatar--placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" width="28" height="28" opacity="0.3">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"/>
</svg>
</div>
</RouterLink>
<div class="match-card__info">
<RouterLink :to="`/profile/${match.partnerProfile.id}`" class="match-card__name">
{{ match.partnerProfile.name }}
<span v-if="match.partnerProfile.age" class="match-card__age">, {{ match.partnerProfile.age }}</span>
</RouterLink>
<span v-if="match.partnerProfile.cityName" class="meta match-card__city">
{{ match.partnerProfile.cityName }}
</span>
</div>
<AppButton
variant="primary"
size="sm"
@click="openChat(match)"
>
Написать
</AppButton>
</article>
</div>
</div>
</template>
<style scoped lang="scss">
.matches-view {
height: 100%;
display: flex;
flex-direction: column;
&__header {
display: flex;
align-items: baseline;
gap: 12px;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
&__title {
font-family: var(--font-display);
font-size: 1.5rem;
color: var(--color-cream);
margin: 0;
}
&__loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
}
.matches-list {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.match-card {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 24px;
transition: background var(--transition-fast);
&:hover { background: rgba(240, 235, 224, 0.03); }
&__avatar-wrap {
flex-shrink: 0;
text-decoration: none;
}
&__avatar {
width: 52px;
height: 52px;
border-radius: 50%;
object-fit: cover;
&--placeholder {
background: var(--color-surface-2);
display: flex;
align-items: center;
justify-content: center;
}
}
&__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
&__name {
font-family: var(--font-mono);
font-size: 0.9375rem;
font-weight: 500;
color: var(--color-cream);
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover { color: var(--color-signal); }
}
&__age { font-weight: 400; opacity: 0.6; }
&__city { color: var(--color-muted); }
}
</style>

View File

@@ -0,0 +1,458 @@
<script setup lang="ts">
import { reactive, ref, computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useVuelidate } from '@vuelidate/core';
import { required, helpers } from '@vuelidate/validators';
import { useAuthStore } from '@/stores/auth.store';
import { useUiStore } from '@/stores/ui.store';
import { apiClient } from '@/api/client';
import type { CreateProfileDto } from '@/api/api';
import type { UserProfile } from '@/stores/auth.store';
import type { District } from '@/stores/ui.store';
import AppInput from '@/components/common/AppInput.vue';
import AppButton from '@/components/common/AppButton.vue';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
const router = useRouter();
const authStore = useAuthStore();
const uiStore = useUiStore();
const step = ref(1);
const totalSteps = 4;
const loading = ref(false);
const form = reactive<CreateProfileDto & { confirmStep?: number }>({
name: '',
birthDate: '',
gender: 'female',
cityId: '',
districtId: '',
description: '',
nation: '',
height: undefined,
weight: undefined,
tagIds: [],
});
const selectedTags = ref<string[]>([]);
const districts = ref<District[]>([]);
const loadingDistricts = ref(false);
// Load districts when city changes
watch(() => form.cityId, async (cityId) => {
if (!cityId) { districts.value = []; return; }
if (uiStore.districts[cityId]) { districts.value = uiStore.districts[cityId]; return; }
loadingDistricts.value = true;
try {
const res = await apiClient.api.citiesControllerFindDistricts(cityId) as unknown as District[];
uiStore.setDistricts(cityId, res);
districts.value = res;
} finally {
loadingDistricts.value = false;
}
});
const step1Rules = {
name: { required: helpers.withMessage('Введите имя', required) },
birthDate: { required: helpers.withMessage('Введите дату рождения', required) },
gender: { required: helpers.withMessage('Выберите пол', required) },
};
const v$ = useVuelidate(step1Rules, form);
const progress = computed(() => ((step.value - 1) / totalSteps) * 100);
async function nextStep() {
if (step.value === 1) {
const valid = await v$.value.$validate();
if (!valid) return;
}
if (step.value < totalSteps) step.value++;
else await finish();
}
function prevStep() {
if (step.value > 1) step.value--;
}
function toggleTag(tagId: string) {
const idx = selectedTags.value.indexOf(tagId);
if (idx === -1) selectedTags.value.push(tagId);
else selectedTags.value.splice(idx, 1);
}
async function finish() {
loading.value = true;
try {
form.tagIds = selectedTags.value;
const profile = await apiClient.api.profilesControllerCreate(form) as unknown as UserProfile;
authStore.addProfile(profile);
uiStore.addToast('Профиль создан', 'success');
router.replace('/feed');
} catch (err: unknown) {
const message = (err as { response?: { data?: { message?: string } } })
?.response?.data?.message ?? 'Не удалось создать профиль';
uiStore.addToast(message, 'error');
} finally {
loading.value = false;
}
}
function skip() {
router.replace('/feed');
}
</script>
<template>
<div class="setup">
<div class="setup__grain" aria-hidden="true" />
<div class="setup__card">
<!-- Progress -->
<div class="setup__progress">
<div class="setup__progress-bar" :style="{ width: `${progress}%` }" />
</div>
<div class="setup__header">
<span class="meta">Шаг {{ step }} из {{ totalSteps }}</span>
<h1 class="setup__title">
<template v-if="step === 1">Расскажите о себе</template>
<template v-if="step === 2">Где вы находитесь?</template>
<template v-if="step === 3">Ваши интересы</template>
<template v-if="step === 4">Добавьте фото</template>
</h1>
</div>
<!-- Step 1: Basic info -->
<div v-if="step === 1" class="setup__step">
<AppInput
v-model="form.name"
label="Имя"
placeholder="Как вас зовут?"
name="name"
required
:error="v$.name.$errors.map(e => e.$message as string)"
@blur="v$.name.$touch()"
/>
<AppInput
v-model="form.birthDate"
label="Дата рождения"
placeholder="1995-06-15"
type="date"
name="birthDate"
required
:error="v$.birthDate.$errors.map(e => e.$message as string)"
@blur="v$.birthDate.$touch()"
/>
<div class="setup__gender">
<span class="field__label label">Пол</span>
<div class="setup__gender-options">
<button
type="button"
class="setup__gender-btn"
:class="{ 'setup__gender-btn--active': form.gender === 'female' }"
@click="form.gender = 'female'"
>Женщина</button>
<button
type="button"
class="setup__gender-btn"
:class="{ 'setup__gender-btn--active': form.gender === 'male' }"
@click="form.gender = 'male'"
>Мужчина</button>
</div>
</div>
<AppInput
v-model="form.description"
label="О себе"
placeholder="Расскажите что-нибудь..."
name="description"
/>
</div>
<!-- Step 2: Location -->
<div v-if="step === 2" class="setup__step">
<div class="field">
<label class="field__label label" for="city-select">Город</label>
<select id="city-select" v-model="form.cityId" class="field__select">
<option value="">Выберите город</option>
<option v-for="city in uiStore.cities" :key="city.id" :value="city.id">{{ city.name }}</option>
</select>
</div>
<div v-if="form.cityId" class="field">
<label class="field__label label" for="district-select">Район</label>
<div v-if="loadingDistricts" class="setup__loading">
<LoadingSpinner size="sm" />
</div>
<select v-else id="district-select" v-model="form.districtId" class="field__select">
<option value="">Выберите район</option>
<option v-for="d in districts" :key="d.id" :value="d.id">{{ d.name }}</option>
</select>
</div>
<AppInput v-model="form.nation" label="Национальность" placeholder="Необязательно" name="nation" />
<div class="setup__row">
<AppInput v-model.number="(form.height as unknown as string)" label="Рост (см)" placeholder="170" type="number" name="height" />
<AppInput v-model.number="(form.weight as unknown as string)" label="Вес (кг)" placeholder="60" type="number" name="weight" />
</div>
</div>
<!-- Step 3: Tags/interests -->
<div v-if="step === 3" class="setup__step">
<p class="setup__hint">Выберите теги, которые вас описывают</p>
<div class="setup__tags">
<button
v-for="tag in uiStore.tags"
:key="tag.id"
type="button"
class="setup__tag"
:class="{ 'setup__tag--active': selectedTags.includes(tag.id) }"
@click="toggleTag(tag.id)"
>
{{ tag.name }}
</button>
</div>
<p v-if="uiStore.tags.length === 0" class="setup__hint setup__hint--muted">Теги загружаются...</p>
</div>
<!-- Step 4: Photo upload reminder -->
<div v-if="step === 4" class="setup__step">
<div class="setup__photo-hint">
<svg viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="1.5" width="64" height="64">
<rect x="4" y="12" width="56" height="40" rx="4"/>
<circle cx="32" cy="32" r="10"/>
<circle cx="50" cy="20" r="3"/>
</svg>
<p>После создания профиля вы сможете добавить фото в разделе <strong>Мой профиль</strong></p>
</div>
</div>
<!-- Navigation -->
<div class="setup__nav">
<AppButton
v-if="step > 1"
variant="ghost"
@click="prevStep"
>Назад</AppButton>
<span v-else />
<div class="setup__nav-right">
<AppButton v-if="step < totalSteps" variant="ghost" @click="skip">Пропустить</AppButton>
<AppButton
variant="primary"
size="md"
:loading="loading"
@click="nextStep"
>
{{ step === totalSteps ? 'Готово' : 'Далее' }}
</AppButton>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.setup {
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-base);
padding: 24px;
position: relative;
&__grain {
position: absolute;
inset: 0;
background-image: url('@/assets/grain.svg');
background-repeat: repeat;
opacity: 0.3;
pointer-events: none;
}
&__card {
position: relative;
z-index: 1;
width: 100%;
max-width: 520px;
animation: fade-up var(--transition-base) both;
}
&__progress {
height: 2px;
background: var(--color-dim);
margin-bottom: 40px;
border-radius: var(--radius-full);
overflow: hidden;
&-bar {
height: 100%;
background: var(--color-signal);
border-radius: var(--radius-full);
transition: width var(--transition-base);
}
}
&__header {
margin-bottom: 32px;
}
&__title {
font-family: var(--font-display);
font-size: 2rem;
color: var(--color-cream);
margin: 8px 0 0;
}
&__step {
display: flex;
flex-direction: column;
gap: 20px;
min-height: 260px;
animation: fade-up var(--transition-base) both;
}
&__nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 36px;
&-right {
display: flex;
gap: 8px;
}
}
&__gender {
display: flex;
flex-direction: column;
gap: 8px;
&-options {
display: flex;
gap: 8px;
}
&-btn {
flex: 1;
height: 44px;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-muted);
font-family: var(--font-mono);
font-size: 0.875rem;
cursor: pointer;
transition: all var(--transition-fast);
&--active {
background: var(--color-signal-bg);
border-color: var(--color-signal);
color: var(--color-cream);
}
&:hover:not(.setup__gender-btn--active) {
border-color: var(--color-border-strong);
color: var(--color-cream);
}
}
}
&__row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
&__tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
&__tag {
padding: 6px 14px;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
color: var(--color-muted);
font-family: var(--font-mono);
font-size: 0.75rem;
cursor: pointer;
transition: all var(--transition-fast);
&--active {
background: var(--color-signal-bg);
border-color: var(--color-signal);
color: var(--color-cream);
}
&:hover:not(.setup__tag--active) {
border-color: var(--color-border-strong);
color: var(--color-cream);
}
}
&__hint {
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--color-muted);
margin: 0;
&--muted { color: var(--color-dim); }
}
&__loading {
height: 44px;
display: flex;
align-items: center;
}
&__photo-hint {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 20px;
padding: 32px 0;
color: var(--color-muted);
svg { opacity: 0.4; }
p {
font-family: var(--font-mono);
font-size: 0.875rem;
color: var(--color-muted);
max-width: 36ch;
margin: 0;
line-height: 1.6;
strong { color: var(--color-cream); }
}
}
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
&__label { color: var(--color-muted); }
&__select {
height: 44px;
padding: 0 14px;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-cream);
font-family: var(--font-mono);
font-size: 0.875rem;
cursor: pointer;
transition: border-color var(--transition-fast);
outline: none;
appearance: none;
&:focus { border-color: var(--color-signal); }
}
}
</style>

View File

@@ -0,0 +1,315 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useAuthStore } from '@/stores/auth.store';
import { useUiStore } from '@/stores/ui.store';
import { apiClient } from '@/api/client';
import type { UserProfile } from '@/stores/auth.store';
import ProfileEditor from '@/components/profile/ProfileEditor.vue';
import MediaGallery from '@/components/profile/MediaGallery.vue';
import AppButton from '@/components/common/AppButton.vue';
import AppModal from '@/components/common/AppModal.vue';
const authStore = useAuthStore();
const uiStore = useUiStore();
const editing = ref(false);
const confirmDelete = ref(false);
const deleting = ref(false);
const profile = computed(() => authStore.activeProfile);
function onSaved(updated: UserProfile) {
editing.value = false;
}
async function doDelete() {
if (!profile.value) return;
deleting.value = true;
try {
await apiClient.api.profilesControllerDelete(profile.value.id);
authStore.removeProfile(profile.value.id);
confirmDelete.value = false;
uiStore.addToast('Профиль удалён', 'success');
} catch {
uiStore.addToast('Не удалось удалить профиль', 'error');
} finally {
deleting.value = false;
}
}
function logout() {
authStore.logout();
}
function cityName(cityId?: string) {
return uiStore.cities.find((c) => c.id === cityId)?.name ?? '';
}
function tagNames(tagIds?: string[]) {
return (tagIds ?? []).map((id) => uiStore.tags.find((t) => t.id === id)?.name ?? id);
}
function calcAge(birthDate: string) {
const b = new Date(birthDate);
const now = new Date();
let age = now.getFullYear() - b.getFullYear();
if (now.getMonth() < b.getMonth() || (now.getMonth() === b.getMonth() && now.getDate() < b.getDate())) age--;
return age;
}
</script>
<template>
<div class="my-profile">
<!-- Profile view -->
<div v-if="!editing && profile" class="my-profile__view">
<!-- Hero -->
<div class="my-profile__hero">
<div class="my-profile__avatar-wrap">
<img
v-if="profile.avatarUrl"
:src="profile.avatarUrl"
:alt="profile.name"
class="my-profile__avatar"
/>
<div v-else class="my-profile__avatar my-profile__avatar--placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" width="48" height="48" opacity="0.2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"/>
</svg>
</div>
</div>
<div class="my-profile__hero-info">
<h1 class="my-profile__name">{{ profile.name }}<span class="my-profile__age">, {{ calcAge(profile.birthDate) }}</span></h1>
<span v-if="profile.cityId" class="meta my-profile__location">{{ cityName(profile.cityId) }}</span>
</div>
<div class="my-profile__hero-actions">
<AppButton variant="secondary" size="sm" @click="editing = true">Редактировать</AppButton>
<AppButton variant="ghost" size="sm" @click="logout">Выйти</AppButton>
</div>
</div>
<!-- Bio -->
<div v-if="profile.description" class="my-profile__section">
<h3 class="my-profile__section-title">О себе</h3>
<p class="my-profile__bio">{{ profile.description }}</p>
</div>
<!-- Stats -->
<div class="my-profile__section">
<h3 class="my-profile__section-title">Данные</h3>
<div class="my-profile__stats">
<div v-if="profile.nation" class="my-profile__stat">
<span class="meta">Национальность</span>
<span>{{ profile.nation }}</span>
</div>
<div v-if="profile.height" class="my-profile__stat">
<span class="meta">Рост</span>
<span>{{ profile.height }} см</span>
</div>
<div v-if="profile.weight" class="my-profile__stat">
<span class="meta">Вес</span>
<span>{{ profile.weight }} кг</span>
</div>
</div>
</div>
<!-- Tags -->
<div v-if="profile.tagIds?.length" class="my-profile__section">
<h3 class="my-profile__section-title">Интересы</h3>
<div class="my-profile__tags">
<span v-for="name in tagNames(profile.tagIds)" :key="name" class="my-profile__tag">{{ name }}</span>
</div>
</div>
<!-- Media gallery -->
<div class="my-profile__section">
<h3 class="my-profile__section-title">Фото</h3>
<MediaGallery :profile-id="profile.id" :editable="true" />
</div>
<!-- Danger zone -->
<div class="my-profile__section my-profile__danger">
<AppButton variant="danger" size="sm" @click="confirmDelete = true">Удалить профиль</AppButton>
</div>
</div>
<!-- No profile state -->
<div v-else-if="!editing && !profile" class="my-profile__empty">
<p class="meta">У вас нет профилей</p>
<RouterLink to="/setup">
<AppButton>Создать профиль</AppButton>
</RouterLink>
</div>
<!-- Editor -->
<div v-else-if="editing && profile" class="my-profile__editor">
<header class="my-profile__editor-header">
<h2>Редактирование профиля</h2>
</header>
<ProfileEditor
:profile="profile"
@saved="onSaved"
@cancel="editing = false"
/>
</div>
<!-- Delete confirm -->
<AppModal
:open="confirmDelete"
title="Удалить профиль"
size="sm"
@close="confirmDelete = false"
>
<p style="font-family: var(--font-mono); font-size: 0.875rem; color: var(--color-muted); margin: 0">
Профиль будет удалён навсегда. Это действие нельзя отменить.
</p>
<template #footer>
<AppButton variant="ghost" @click="confirmDelete = false">Отмена</AppButton>
<AppButton variant="danger" :loading="deleting" @click="doDelete">Удалить</AppButton>
</template>
</AppModal>
</div>
</template>
<style scoped lang="scss">
.my-profile {
height: 100%;
overflow-y: auto;
&__view,
&__editor {
max-width: 640px;
margin: 0 auto;
padding: 24px;
}
&__empty {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
&__hero {
display: flex;
align-items: flex-start;
gap: 20px;
margin-bottom: 32px;
flex-wrap: wrap;
}
&__avatar-wrap { flex-shrink: 0; }
&__avatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
&--placeholder {
background: var(--color-surface-2);
display: flex;
align-items: center;
justify-content: center;
}
}
&__hero-info {
flex: 1;
min-width: 0;
}
&__name {
font-family: var(--font-display);
font-size: 1.75rem;
color: var(--color-cream);
margin: 0 0 4px;
}
&__age {
font-size: 1.25rem;
opacity: 0.6;
}
&__location { color: var(--color-muted); }
&__hero-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
&__section {
margin-bottom: 28px;
}
&__section-title {
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-muted);
margin: 0 0 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--color-border);
}
&__bio {
font-family: var(--font-mono);
font-size: 0.875rem;
color: var(--color-cream);
line-height: 1.65;
margin: 0;
}
&__stats {
display: flex;
gap: 24px;
flex-wrap: wrap;
}
&__stat {
display: flex;
flex-direction: column;
gap: 3px;
span:last-child {
font-family: var(--font-mono);
font-size: 0.9375rem;
color: var(--color-cream);
}
}
&__tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
&__tag {
padding: 4px 12px;
background: rgba(240, 235, 224, 0.06);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
font-family: var(--font-mono);
font-size: 0.6875rem;
color: var(--color-muted);
letter-spacing: 0.03em;
}
&__danger { padding-top: 16px; border-top: 1px solid rgba(196, 92, 58, 0.2); }
&__editor-header {
margin-bottom: 24px;
h2 {
font-family: var(--font-display);
font-size: 1.5rem;
color: var(--color-cream);
margin: 0;
}
}
}
</style>

View File

@@ -0,0 +1,311 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth.store';
import { useUiStore } from '@/stores/ui.store';
import { useChatStore } from '@/stores/chat.store';
import { apiClient } from '@/api/client';
import type { UserProfile } from '@/stores/auth.store';
import AppButton from '@/components/common/AppButton.vue';
import AppModal from '@/components/common/AppModal.vue';
import ReportModal from '@/components/reports/ReportModal.vue';
import DateProposalForm from '@/components/dates/DateProposalForm.vue';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
const route = useRoute();
const authStore = useAuthStore();
const uiStore = useUiStore();
const chatStore = useChatStore();
const profileId = route.params.profileId as string;
const profile = ref<UserProfile | null>(null);
const loading = ref(false);
const reportOpen = ref(false);
const dateOpen = ref(false);
onMounted(async () => {
loading.value = true;
try {
const res = await apiClient.api.profilesControllerFindOne(profileId) as unknown as UserProfile;
profile.value = res;
} catch {
uiStore.addToast('Не удалось загрузить профиль', 'error');
} finally {
loading.value = false;
}
});
const age = computed(() => {
if (!profile.value?.birthDate) return null;
const b = new Date(profile.value.birthDate);
const now = new Date();
let a = now.getFullYear() - b.getFullYear();
if (now.getMonth() < b.getMonth() || (now.getMonth() === b.getMonth() && now.getDate() < b.getDate())) a--;
return a;
});
const cityName = computed(() => uiStore.cities.find((c) => c.id === profile.value?.cityId)?.name ?? '');
const tagNames = computed(() => (profile.value?.tagIds ?? []).map((id) => uiStore.tags.find((t) => t.id === id)?.name ?? id));
const currentImageIndex = ref(0);
const isOwnProfile = computed(() =>
authStore.profiles.some((p) => p.id === profileId),
);
</script>
<template>
<div class="profile-detail">
<div v-if="loading" class="profile-detail__loading">
<LoadingSpinner size="lg" />
</div>
<template v-else-if="profile">
<!-- Image strip -->
<div class="profile-detail__cover">
<img
v-if="profile.mediaUrls?.[currentImageIndex]"
:src="profile.mediaUrls[currentImageIndex]"
:alt="profile.name"
class="profile-detail__cover-img"
/>
<div v-else class="profile-detail__cover-placeholder" />
<!-- Navigation -->
<button
v-if="currentImageIndex > 0"
class="profile-detail__img-nav profile-detail__img-nav--prev"
@click="currentImageIndex--"
aria-label="Предыдущее фото"
></button>
<button
v-if="currentImageIndex < (profile.mediaUrls?.length ?? 1) - 1"
class="profile-detail__img-nav profile-detail__img-nav--next"
@click="currentImageIndex++"
aria-label="Следующее фото"
></button>
<!-- Back button -->
<button class="profile-detail__back" @click="history.back()" aria-label="Назад">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
<path d="M19 12H5M12 5l-7 7 7 7"/>
</svg>
</button>
</div>
<!-- Content -->
<div class="profile-detail__content">
<div class="profile-detail__header">
<div>
<h1 class="profile-detail__name">
{{ profile.name }}
<span v-if="age" class="profile-detail__age">, {{ age }}</span>
</h1>
<span v-if="cityName" class="meta profile-detail__location">{{ cityName }}</span>
</div>
<!-- Actions (non-own) -->
<div v-if="!isOwnProfile" class="profile-detail__actions">
<AppButton size="sm" variant="primary" @click="dateOpen = true">Встреча</AppButton>
<AppButton size="sm" variant="ghost" @click="reportOpen = true">Пожаловаться</AppButton>
</div>
</div>
<!-- Description -->
<p v-if="profile.description" class="profile-detail__bio">{{ profile.description }}</p>
<!-- Stats -->
<div class="profile-detail__stats">
<div v-if="profile.nation" class="profile-detail__stat">
<span class="meta">Национальность</span>
<span>{{ profile.nation }}</span>
</div>
<div v-if="profile.height" class="profile-detail__stat">
<span class="meta">Рост</span>
<span>{{ profile.height }} см</span>
</div>
<div v-if="profile.weight" class="profile-detail__stat">
<span class="meta">Вес</span>
<span>{{ profile.weight }} кг</span>
</div>
</div>
<!-- Tags -->
<div v-if="tagNames.length" class="profile-detail__tags">
<span v-for="name in tagNames" :key="name" class="profile-detail__tag">{{ name }}</span>
</div>
</div>
</template>
<!-- Report modal -->
<ReportModal
v-if="profile"
:open="reportOpen"
entity-type="profile"
:entity-id="profileId"
@close="reportOpen = false"
/>
<!-- Date proposal -->
<AppModal :open="dateOpen" title="Предложить встречу" size="md" @close="dateOpen = false">
<DateProposalForm
v-if="profile"
:partner-profile-id="profile.id"
@close="dateOpen = false"
@created="dateOpen = false"
/>
</AppModal>
</div>
</template>
<style scoped lang="scss">
.profile-detail {
height: 100%;
overflow-y: auto;
&__loading {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
&__cover {
position: relative;
height: 60dvh;
background: var(--color-surface-2);
@include mobile { height: 50dvh; }
}
&__cover-img {
width: 100%;
height: 100%;
object-fit: cover;
}
&__cover-placeholder {
width: 100%;
height: 100%;
background: var(--color-surface-3);
}
&__img-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(13, 13, 13, 0.6);
border: none;
color: var(--color-cream);
font-size: 1.5rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background var(--transition-fast);
backdrop-filter: blur(8px);
&:hover { background: rgba(13, 13, 13, 0.85); }
&--prev { left: 12px; }
&--next { right: 12px; }
}
&__back {
position: absolute;
top: 16px;
left: 16px;
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(13, 13, 13, 0.6);
border: none;
color: var(--color-cream);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(8px);
transition: background var(--transition-fast);
&:hover { background: rgba(13, 13, 13, 0.85); }
}
&__content {
padding: 24px;
max-width: 640px;
margin: 0 auto;
}
&__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
flex-wrap: wrap;
}
&__name {
font-family: var(--font-display);
font-size: 2rem;
color: var(--color-cream);
margin: 0 0 4px;
}
&__age { opacity: 0.6; font-size: 1.5rem; }
&__location { color: var(--color-muted); }
&__actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
&__bio {
font-family: var(--font-mono);
font-size: 0.875rem;
color: rgba(240, 235, 224, 0.8);
line-height: 1.65;
margin: 0 0 20px;
}
&__stats {
display: flex;
gap: 24px;
margin-bottom: 20px;
flex-wrap: wrap;
}
&__stat {
display: flex;
flex-direction: column;
gap: 3px;
span:last-child {
font-family: var(--font-mono);
font-size: 0.9375rem;
color: var(--color-cream);
}
}
&__tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
&__tag {
padding: 4px 12px;
background: rgba(240, 235, 224, 0.06);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
font-family: var(--font-mono);
font-size: 0.6875rem;
color: var(--color-muted);
}
}
</style>

5
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="vite/client" />
interface Window {
__TAURI__?: unknown;
}