init
This commit is contained in:
81
src/components/chat/ChatBubble.vue
Normal file
81
src/components/chat/ChatBubble.vue
Normal 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>
|
||||
168
src/components/chat/ChatInput.vue
Normal file
168
src/components/chat/ChatInput.vue
Normal 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>
|
||||
152
src/components/chat/MediaMessage.vue
Normal file
152
src/components/chat/MediaMessage.vue
Normal 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>
|
||||
144
src/components/chat/VoiceRecorder.vue
Normal file
144
src/components/chat/VoiceRecorder.vue
Normal 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>
|
||||
123
src/components/common/AppButton.vue
Normal file
123
src/components/common/AppButton.vue
Normal 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>
|
||||
140
src/components/common/AppDrawer.vue
Normal file
140
src/components/common/AppDrawer.vue
Normal 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>
|
||||
139
src/components/common/AppInput.vue
Normal file
139
src/components/common/AppInput.vue
Normal 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>
|
||||
137
src/components/common/AppModal.vue
Normal file
137
src/components/common/AppModal.vue
Normal 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>
|
||||
120
src/components/common/AppToast.vue
Normal file
120
src/components/common/AppToast.vue
Normal 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>
|
||||
91
src/components/common/EmptyState.vue
Normal file
91
src/components/common/EmptyState.vue
Normal 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>
|
||||
31
src/components/common/LoadingSpinner.vue
Normal file
31
src/components/common/LoadingSpinner.vue
Normal 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>
|
||||
109
src/components/dates/DateProposalForm.vue
Normal file
109
src/components/dates/DateProposalForm.vue
Normal 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>
|
||||
99
src/components/dates/MapPicker.vue
Normal file
99
src/components/dates/MapPicker.vue
Normal 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>
|
||||
409
src/components/feed/FeedCard.vue
Normal file
409
src/components/feed/FeedCard.vue
Normal 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>
|
||||
130
src/components/feed/FeedCardStack.vue
Normal file
130
src/components/feed/FeedCardStack.vue
Normal 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>
|
||||
213
src/components/feed/FeedFilters.vue
Normal file
213
src/components/feed/FeedFilters.vue
Normal 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>
|
||||
34
src/components/feed/ProfileBadge.vue
Normal file
34
src/components/feed/ProfileBadge.vue
Normal 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>
|
||||
96
src/components/layout/AppShell.vue
Normal file
96
src/components/layout/AppShell.vue
Normal 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>
|
||||
106
src/components/layout/BottomNav.vue
Normal file
106
src/components/layout/BottomNav.vue
Normal 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>
|
||||
235
src/components/layout/SideNav.vue
Normal file
235
src/components/layout/SideNav.vue
Normal 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>
|
||||
89
src/components/layout/TauriTitlebar.vue
Normal file
89
src/components/layout/TauriTitlebar.vue
Normal 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>
|
||||
243
src/components/profile/MediaGallery.vue
Normal file
243
src/components/profile/MediaGallery.vue
Normal 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>
|
||||
239
src/components/profile/ProfileEditor.vue
Normal file
239
src/components/profile/ProfileEditor.vue
Normal 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>
|
||||
89
src/components/reports/ReportModal.vue
Normal file
89
src/components/reports/ReportModal.vue
Normal 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>
|
||||
Reference in New Issue
Block a user