eslint --fix

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

View File

@@ -1,6 +1,6 @@
// Allow build scripts for core build tools // Allow build scripts for core build tools
function readPackage(pkg) { function readPackage(pkg) {
return pkg; return pkg
} }
module.exports = { hooks: { readPackage } }; module.exports = { hooks: { readPackage } }

View File

@@ -5,7 +5,7 @@ Vue 3 + Vite + Tauri v2. Работает как PWA в браузере и ка
## Стек ## Стек
| Слой | Технология | | Слой | Технология |
|---|-------------------------------------------------| | ---------------- | ----------------------------------------------- | --- |
| UI framework | Vue 3 (Composition API, `<script setup>`) | | UI framework | Vue 3 (Composition API, `<script setup>`) |
| Build tool | Vite 6 | | Build tool | Vite 6 |
| Desktop shell | Tauri v2 | | Desktop shell | Tauri v2 |
@@ -119,7 +119,7 @@ dating-app-frontend/
## Маршрутизация ## Маршрутизация
| Путь | Компонент | Guard | | Путь | Компонент | Guard |
|---|---|---| | --------------------- | ------------------ | ------------ |
| `/` | redirect → `/feed` | — | | `/` | redirect → `/feed` | — |
| `/login` | LoginView | guest only | | `/login` | LoginView | guest only |
| `/register` | RegisterView | guest only | | `/register` | RegisterView | guest only |
@@ -134,6 +134,7 @@ dating-app-frontend/
| `/admin/reports` | ReportsView | auth + admin | | `/admin/reports` | ReportsView | auth + admin |
Логика guard: Логика guard:
1. При первой навигации пытается восстановить сессию через `refreshToken` из localStorage. 1. При первой навигации пытается восстановить сессию через `refreshToken` из localStorage.
2. Если пользователь авторизован, но профилей нет — редирект на `/setup`. 2. Если пользователь авторизован, но профилей нет — редирект на `/setup`.
3. Если токен истёк — silent refresh через `POST /api/v1/auth/refresh`. 3. Если токен истёк — silent refresh через `POST /api/v1/auth/refresh`.
@@ -209,14 +210,15 @@ import '@/styles/main.scss' // custom styles
### Дизайн-токены (CSS custom properties) ### Дизайн-токены (CSS custom properties)
```scss ```scss
--color-base: #0d0d0d // фон приложения --color-base:
#0d0d0d // фон приложения
--color-surface: #161614 // поверхность карточек --color-surface: #161614 // поверхность карточек
--color-surface-2: #1e1e1b // инпуты, вторичные поверхности --color-surface-2: #1e1e1b // инпуты, вторичные поверхности
--color-cream: #f0ebe0 // основной текст --color-cream: #f0ebe0 // основной текст
--color-muted: #6b6860 // второстепенный текст --color-muted: #6b6860 // второстепенный текст
--color-signal: #c45c3a // акцент (CTA, лайк, активный стейт) --color-signal: #c45c3a // акцент (CTA, лайк, активный стейт)
--font-display: 'Instrument Serif', serif --font-display: 'Instrument Serif',
--font-mono: 'DM Mono', monospace serif --font-mono: 'DM Mono', monospace;
``` ```
Шрифты загружаются из Google Fonts в `index.html`. Шрифты загружаются из Google Fonts в `index.html`.
@@ -228,7 +230,7 @@ import '@/styles/main.scss' // custom styles
### Обнаружение среды ### Обнаружение среды
```ts ```ts
const isTauri = typeof window !== 'undefined' && !!window.__TAURI__; const isTauri = typeof window !== 'undefined' && !!window.__TAURI__
``` ```
### Кастомный тайтлбар ### Кастомный тайтлбар
@@ -242,6 +244,7 @@ const isTauri = typeof window !== 'undefined' && !!window.__TAURI__;
### Конфигурация окна ### Конфигурация окна
`src-tauri/tauri.conf.json`: `src-tauri/tauri.conf.json`:
- `decorations: false` — убирает системный тайтлбар - `decorations: false` — убирает системный тайтлбар
- `minWidth: 375` — соответствует мобильному брейкпойнту - `minWidth: 375` — соответствует мобильному брейкпойнту
- Permissions: `dialog:allow-open`, `dialog:allow-save`, `shell:allow-open` - Permissions: `dialog:allow-open`, `dialog:allow-save`, `shell:allow-open`

View File

@@ -8,6 +8,7 @@ Project root: `C:\MyApps\dating-app-frontend`
Package manager: **pnpm** Package manager: **pnpm**
Stack: **Vue 3 (Composition API + `<script setup>`) + Vite + Tauri v2** Stack: **Vue 3 (Composition API + `<script setup>`) + Vite + Tauri v2**
The app must work in three contexts simultaneously: The app must work in three contexts simultaneously:
1. Browser (dev/PWA) — desktop viewport 1. Browser (dev/PWA) — desktop viewport
2. Browser — mobile viewport (responsive, ≥ 375 px) 2. Browser — mobile viewport (responsive, ≥ 375 px)
3. Tauri desktop window (Windows / macOS / Linux) 3. Tauri desktop window (Windows / macOS / Linux)
@@ -32,6 +33,7 @@ Before writing any code, internalize these two skill files in full:
Apply **Impeccable** design principles throughout every component. Apply **Impeccable** design principles throughout every component.
Apply **Taste-skill** with these parameters: Apply **Taste-skill** with these parameters:
- `DESIGN_VARIANCE: 8` - `DESIGN_VARIANCE: 8`
- `MOTION_INTENSITY: 6` - `MOTION_INTENSITY: 6`
- `VISUAL_DENSITY: 4` - `VISUAL_DENSITY: 4`
@@ -39,6 +41,7 @@ Apply **Taste-skill** with these parameters:
### Aesthetic Direction ### Aesthetic Direction
The visual language is **"warm brutalism meets editorial intimacy"**: The visual language is **"warm brutalism meets editorial intimacy"**:
- Dark base (`#0d0d0d`) with warm cream (`#f0ebe0`) as the primary text/accent surface - Dark base (`#0d0d0d`) with warm cream (`#f0ebe0`) as the primary text/accent surface
- A single vivid signal color — deep terracotta `#c45c3a` — used sparingly for CTAs, likes, active states - A single vivid signal color — deep terracotta `#c45c3a` — used sparingly for CTAs, likes, active states
- Typography: pair **`Instrument Serif`** (display, headers, profile names) with **`DM Mono`** (UI labels, metadata, counts). Load both from Google Fonts. - Typography: pair **`Instrument Serif`** (display, headers, profile names) with **`DM Mono`** (UI labels, metadata, counts). Load both from Google Fonts.
@@ -164,6 +167,7 @@ src/
Implement every endpoint from `src/api/api.ts`. Below is the complete feature map: Implement every endpoint from `src/api/api.ts`. Below is the complete feature map:
### Auth (`/api/v1/auth/`) ### Auth (`/api/v1/auth/`)
- `POST /register` — registration form with phone + password, validation via Vuelidate - `POST /register` — registration form with phone + password, validation via Vuelidate
- `POST /login` — login form - `POST /login` — login form
- `POST /logout` — clear store + redirect - `POST /logout` — clear store + redirect
@@ -173,11 +177,13 @@ Implement every endpoint from `src/api/api.ts`. Below is the complete feature ma
Store access token in memory (Pinia), refresh token in `localStorage`. Never store access token in `localStorage`. Store access token in memory (Pinia), refresh token in `localStorage`. Never store access token in `localStorage`.
### Users (`/api/v1/users/`) ### Users (`/api/v1/users/`)
- `GET /me` — populate auth store on app load - `GET /me` — populate auth store on app load
- `GET /:id` — view any user (admin use) - `GET /:id` — view any user (admin use)
- `PATCH /:id/ban` and `/activate` — admin panel actions - `PATCH /:id/ban` and `/activate` — admin panel actions
### Profiles (`/api/v1/profiles/`) ### Profiles (`/api/v1/profiles/`)
- `POST /` — profile creation wizard (multi-step: basic info → location → tags → photo) - `POST /` — profile creation wizard (multi-step: basic info → location → tags → photo)
- `GET /my` — list own profiles in MyProfileView - `GET /my` — list own profiles in MyProfileView
- `GET /:profileId` — public profile detail page - `GET /:profileId` — public profile detail page
@@ -185,20 +191,24 @@ Store access token in memory (Pinia), refresh token in `localStorage`. Never sto
- `DELETE /:profileId` — with confirmation modal - `DELETE /:profileId` — with confirmation modal
### Media (`/api/v1/profiles/:profileId/media/`) ### Media (`/api/v1/profiles/:profileId/media/`)
- `POST /upload?type=` — drag-and-drop + file picker; show upload progress bar - `POST /upload?type=` — drag-and-drop + file picker; show upload progress bar
- `GET /` — grid gallery with lightbox - `GET /` — grid gallery with lightbox
- `DELETE /:mediaId` — with optimistic UI removal - `DELETE /:mediaId` — with optimistic UI removal
### Feed (`/api/v1/feed`) ### Feed (`/api/v1/feed`)
- Infinite scroll OR card-stack swipe (implement BOTH modes, togglable via UI switch) - Infinite scroll OR card-stack swipe (implement BOTH modes, togglable via UI switch)
- Filter panel: cityId, districtId, ageMin/ageMax, keyword, tagIds (multi-select chips) - Filter panel: cityId, districtId, ageMin/ageMax, keyword, tagIds (multi-select chips)
- Show "search paused" banner when match limit is exceeded (detect via API response or a dedicated flag) - Show "search paused" banner when match limit is exceeded (detect via API response or a dedicated flag)
### Likes (`/api/v1/likes`) ### Likes (`/api/v1/likes`)
- `POST /` — like/dislike with swipe gesture (GSAP drag) or button - `POST /` — like/dislike with swipe gesture (GSAP drag) or button
- `GET /matches?profileId=` — matches list with match animation (confetti-lite or editorial flash) - `GET /matches?profileId=` — matches list with match animation (confetti-lite or editorial flash)
### Chat (`/api/v1/chats`) ### Chat (`/api/v1/chats`)
- `POST /` — open chat from a match card - `POST /` — open chat from a match card
- `GET /?profileId=` — chats list; lock icon on inactive chats (only one active at a time) - `GET /?profileId=` — chats list; lock icon on inactive chats (only one active at a time)
- `GET /:chatId/messages?profileId=` — message history with virtual scroll - `GET /:chatId/messages?profileId=` — message history with virtual scroll
@@ -207,16 +217,19 @@ Store access token in memory (Pinia), refresh token in `localStorage`. Never sto
- Implement **polling** (2 s interval) for new messages; note in code where WebSocket would replace this - Implement **polling** (2 s interval) for new messages; note in code where WebSocket would replace this
### Dates (`/api/v1/dates`) ### Dates (`/api/v1/dates`)
- `POST /` — proposal form: map picker (Leaflet) for lat/lng, datetime picker, optional statusId - `POST /` — proposal form: map picker (Leaflet) for lat/lng, datetime picker, optional statusId
- `GET /?profileId=` — list with status chips - `GET /?profileId=` — list with status chips
- `PATCH /:id/status?profileId=` — accept / decline / complete actions - `PATCH /:id/status?profileId=` — accept / decline / complete actions
- `GET /statuses` — fetch on mount, cache in Pinia - `GET /statuses` — fetch on mount, cache in Pinia
### Reports (`/api/v1/reports`) ### Reports (`/api/v1/reports`)
- `POST /``ReportModal` triggered from profile or chat bubble context menu - `POST /``ReportModal` triggered from profile or chat bubble context menu
- `GET /` — admin table view with pagination - `GET /` — admin table view with pagination
### Tags, Cities, Greetings ### Tags, Cities, Greetings
- `GET /tags`, `GET /cities`, `GET /cities/:cityId/districts`, `GET /greetings` — fetch on app init, cache in Pinia - `GET /tags`, `GET /cities`, `GET /cities/:cityId/districts`, `GET /greetings` — fetch on app init, cache in Pinia
- Admin: create/delete tags, cities, districts, greetings - Admin: create/delete tags, cities, districts, greetings
@@ -225,7 +238,9 @@ Store access token in memory (Pinia), refresh token in `localStorage`. Never sto
## Component Implementation Standards ## Component Implementation Standards
### FeedCard.vue ### FeedCard.vue
The centerpiece. Execute this with exceptional care: The centerpiece. Execute this with exceptional care:
- Full-bleed image background - Full-bleed image background
- Profile name in large Instrument Serif, overlapping bottom of image - Profile name in large Instrument Serif, overlapping bottom of image
- Age, city, tags as DM Mono micro-labels - Age, city, tags as DM Mono micro-labels
@@ -235,6 +250,7 @@ The centerpiece. Execute this with exceptional care:
- Tap to expand into ProfileDetailView - Tap to expand into ProfileDetailView
### ChatRoomView.vue ### ChatRoomView.vue
- Messages grouped by date separator - Messages grouped by date separator
- Own messages: right-aligned, cream background - Own messages: right-aligned, cream background
- Partner messages: left-aligned, dark card with subtle border - Partner messages: left-aligned, dark card with subtle border
@@ -245,18 +261,21 @@ The centerpiece. Execute this with exceptional care:
- Input bar: text area auto-grows, attach button opens media type picker - Input bar: text area auto-grows, attach button opens media type picker
### AppShell.vue ### AppShell.vue
- Desktop: left sidebar (64px collapsed / 240px expanded) + main content area - Desktop: left sidebar (64px collapsed / 240px expanded) + main content area
- Mobile: top header + bottom nav (5 items) - Mobile: top header + bottom nav (5 items)
- Tauri: custom titlebar with drag region, traffic lights on macOS (use `data-tauri-drag-region`) - Tauri: custom titlebar with drag region, traffic lights on macOS (use `data-tauri-drag-region`)
- Grain SVG filter applied as a pseudo-element over the entire shell - Grain SVG filter applied as a pseudo-element over the entire shell
### Responsive breakpoints ### Responsive breakpoints
```scss ```scss
$mobile: 375px; $mobile: 375px;
$tablet: 768px; $tablet: 768px;
$desktop: 1024px; $desktop: 1024px;
$wide: 1440px; $wide: 1440px;
``` ```
Use `@container` queries inside card components where possible. Use `@container` queries inside card components where possible.
--- ---
@@ -285,18 +304,21 @@ Navigation guard: check auth store on every route change. Refresh token silently
## State Management (Pinia) ## State Management (Pinia)
### `auth.store.ts` ### `auth.store.ts`
```ts ```ts
state: { user, accessToken, profiles, activeProfileId } state: { user, accessToken, profiles, activeProfileId }
actions: login, logout, register, refreshToken, setActiveProfile actions: login, logout, register, refreshToken, setActiveProfile
``` ```
### `feed.store.ts` ### `feed.store.ts`
```ts ```ts
state: { cards[], filters, page, hasMore, searchPaused } state: { cards[], filters, page, hasMore, searchPaused }
actions: fetchNextPage, applyFilters, likeCard, dislikeCard actions: fetchNextPage, applyFilters, likeCard, dislikeCard
``` ```
### `chat.store.ts` ### `chat.store.ts`
```ts ```ts
state: { chats[], activeChat, messages[], polling: NodeJS.Timer | null } state: { chats[], activeChat, messages[], polling: NodeJS.Timer | null }
actions: openChat, closeChat, sendMessage, fetchMessages, startPolling, stopPolling actions: openChat, closeChat, sendMessage, fetchMessages, startPolling, stopPolling
@@ -316,6 +338,7 @@ actions: openChat, closeChat, sendMessage, fetchMessages, startPolling, stopPoll
## CSS Architecture ## CSS Architecture
`src/styles/main.scss` imports in order: `src/styles/main.scss` imports in order:
1. `_variables.scss` — all CSS custom properties 1. `_variables.scss` — all CSS custom properties
2. `_typography.scss` — font definitions, heading scale 2. `_typography.scss` — font definitions, heading scale
3. `_animations.scss` — keyframes, transition utilities 3. `_animations.scss` — keyframes, transition utilities

24
eslint.config.js Normal file
View File

@@ -0,0 +1,24 @@
import antfu from '@antfu/eslint-config'
export default antfu({
typescript: true,
vue: true,
formatters: true,
rules: {
'no-console': 0,
'vue/no-deprecated-slot-attribute': 0,
'vue/block-order': ['error', {
order: ['template', 'script', 'style'],
}],
'unused-imports/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
'node/prefer-global/process': 'off',
'eslint-comments/no-unlimited-disable': 'off',
'eslint-comments/no-unused-disable': 'off',
},
})

View File

@@ -1,13 +1,14 @@
{ {
"name": "dating-app-frontend", "name": "dating-app-frontend",
"private": true,
"version": "0.1.0",
"type": "module", "type": "module",
"version": "0.1.0",
"private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc && vite build", "build": "vue-tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri", "tauri": "tauri",
"lint": "eslint . --fix",
"gen:api": "npx swagger-typescript-api@12.0.4 -p http://localhost:1337/api/docs-json -o ./src/api -n api.ts --axios --unwrap-response-data --extract-request-params --extract-request-body --single-http-client" "gen:api": "npx swagger-typescript-api@12.0.4 -p http://localhost:1337/api/docs-json -o ./src/api -n api.ts --axios --unwrap-response-data --extract-request-params --extract-request-body --single-http-client"
}, },
"dependencies": { "dependencies": {
@@ -26,11 +27,14 @@
"vue-router": "^4.4.5" "vue-router": "^4.4.5"
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "^9.0.0",
"@tailwindcss/vite": "^4.1.0", "@tailwindcss/vite": "^4.1.0",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@types/leaflet": "^1.9.12", "@types/leaflet": "^1.9.12",
"@vitejs/plugin-vue": "^5.1.4", "@vitejs/plugin-vue": "^5.1.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^10.4.1",
"eslint-plugin-format": "^2.0.1",
"sass": "^1.79.3", "sass": "^1.79.3",
"tailwindcss": "^4.1.0", "tailwindcss": "^4.1.0",
"typescript": "^5.6.2", "typescript": "^5.6.2",

2795
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,7 @@
shellEmulator: true
trustPolicy: no-downgrade
allowBuilds: allowBuilds:
'@parcel/watcher': set this to true or false '@parcel/watcher': set this to true or false
esbuild: set this to true or false esbuild: set this to true or false

View File

@@ -3,6 +3,7 @@
## Предисловие ## Предисловие
Перед началом работы прочитай и усвой полностью: Перед началом работы прочитай и усвой полностью:
- `.claude/skills/impeccable/SKILL.md` и все 7 файлов из `reference/` - `.claude/skills/impeccable/SKILL.md` и все 7 файлов из `reference/`
- `.claude/skills/taste-skill/SKILL.md` - `.claude/skills/taste-skill/SKILL.md`
@@ -17,6 +18,7 @@
Весь дизайн — **на русском языке**: лейблы, плейсхолдеры, заголовки, подписи, пустые состояния, сообщения об ошибках, CTA-кнопки. Весь дизайн — **на русском языке**: лейблы, плейсхолдеры, заголовки, подписи, пустые состояния, сообщения об ошибках, CTA-кнопки.
Два брейкпоинта: Два брейкпоинта:
- **Mobile** — 390 × 844 px (iPhone 14 Pro как эталон) - **Mobile** — 390 × 844 px (iPhone 14 Pro как эталон)
- **Desktop** — 1440 × 900 px - **Desktop** — 1440 × 900 px
@@ -59,6 +61,7 @@ MeetMe/
## 1. ЦВЕТОВАЯ ПАЛИТРА ## 1. ЦВЕТОВАЯ ПАЛИТРА
### Принципы (из impeccable color-and-contrast) ### Принципы (из impeccable color-and-contrast)
- Все цвета определяй в **OKLCH** — предсказуемый контраст при изменении яркости - Все цвета определяй в **OKLCH** — предсказуемый контраст при изменении яркости
- Нейтралы должны быть **тёплыми** (чуть красный/жёлтый подтон), не холодно-серыми - Нейтралы должны быть **тёплыми** (чуть красный/жёлтый подтон), не холодно-серыми
- Избегай: синих оттенков по умолчанию, generic «brand purple», чистого чёрного/белого - Избегай: синих оттенков по умолчанию, generic «brand purple», чистого чёрного/белого
@@ -67,6 +70,7 @@ MeetMe/
**Brand (основной акцент):** **Brand (основной акцент):**
Тёплый терракотово-коралловый — не стандартный розовый Tinder, не красный. Живой, зрелый. Тёплый терракотово-коралловый — не стандартный розовый Tinder, не красный. Живой, зрелый.
``` ```
--color-brand-50: oklch(97% 0.012 22) /* почти белый с теплом */ --color-brand-50: oklch(97% 0.012 22) /* почти белый с теплом */
--color-brand-100: oklch(93% 0.030 22) --color-brand-100: oklch(93% 0.030 22)
@@ -81,6 +85,7 @@ MeetMe/
``` ```
**Neutral (тёплые серые):** **Neutral (тёплые серые):**
``` ```
--color-neutral-0: oklch(100% 0 0) --color-neutral-0: oklch(100% 0 0)
--color-neutral-50: oklch(97% 0.004 60) --color-neutral-50: oklch(97% 0.004 60)
@@ -96,6 +101,7 @@ MeetMe/
``` ```
**Semantic:** **Semantic:**
``` ```
--color-success: oklch(62% 0.155 145) /* зелёный — матч состоялся */ --color-success: oklch(62% 0.155 145) /* зелёный — матч состоялся */
--color-warning: oklch(78% 0.140 72) /* янтарный — встреча pending */ --color-warning: oklch(78% 0.140 72) /* янтарный — встреча pending */
@@ -104,6 +110,7 @@ MeetMe/
``` ```
**Поверхности (Light mode):** **Поверхности (Light mode):**
``` ```
--surface-page: --color-neutral-50 --surface-page: --color-neutral-50
--surface-card: --color-neutral-0 --surface-card: --color-neutral-0
@@ -118,6 +125,7 @@ MeetMe/
## 2. ТИПОГРАФИКА ## 2. ТИПОГРАФИКА
### Принципы (из impeccable typography) ### Принципы (из impeccable typography)
- Не Inter. Никогда Inter как единственный шрифт. - Не Inter. Никогда Inter как единственный шрифт.
- Пара: выразительный дисплейный + читаемый текстовый - Пара: выразительный дисплейный + читаемый текстовый
- Модульная шкала, не произвольные размеры - Модульная шкала, не произвольные размеры
@@ -134,7 +142,7 @@ MeetMe/
### Типографическая шкала (модульная, ratio 1.25) ### Типографическая шкала (модульная, ratio 1.25)
| Токен | Шрифт | Size | Weight | Line-height | Tracking | Применение | | Токен | Шрифт | Size | Weight | Line-height | Tracking | Применение |
|-------|-------|------|--------|-------------|----------|------------| | ------------- | ---------------- | ---- | ------ | ----------- | -------- | ---------------------- |
| `display-2xl` | Playfair Display | 48px | 700 | 1.1 | -0.02em | Экран матча, hero | | `display-2xl` | Playfair Display | 48px | 700 | 1.1 | -0.02em | Экран матча, hero |
| `display-xl` | Playfair Display | 38px | 700 | 1.15 | -0.015em | Заголовки онбординга | | `display-xl` | Playfair Display | 38px | 700 | 1.15 | -0.015em | Заголовки онбординга |
| `display-lg` | Playfair Display | 30px | 600 | 1.2 | -0.01em | Имя в профиле | | `display-lg` | Playfair Display | 30px | 600 | 1.2 | -0.01em | Имя в профиле |
@@ -155,6 +163,7 @@ MeetMe/
## 3. SPACING & GRID ## 3. SPACING & GRID
### Шкала отступов (4px база) ### Шкала отступов (4px база)
``` ```
--space-1: 4px --space-1: 4px
--space-2: 8px --space-2: 8px
@@ -170,6 +179,7 @@ MeetMe/
``` ```
### Радиусы скругления ### Радиусы скругления
``` ```
--radius-sm: 6px /* чипы, теги */ --radius-sm: 6px /* чипы, теги */
--radius-md: 12px /* карточки, поля ввода */ --radius-md: 12px /* карточки, поля ввода */
@@ -179,11 +189,13 @@ MeetMe/
``` ```
### Грид Mobile (390px) ### Грид Mobile (390px)
- Колонки: 4 - Колонки: 4
- Гаттер: 16px - Гаттер: 16px
- Отступы: 20px - Отступы: 20px
### Грид Desktop (1440px) ### Грид Desktop (1440px)
- Колонки: 12 - Колонки: 12
- Гаттер: 24px - Гаттер: 24px
- Отступы: 80px - Отступы: 80px
@@ -211,6 +223,7 @@ MeetMe/
## 5. БИБЛИОТЕКА ИКОНОК ## 5. БИБЛИОТЕКА ИКОНОК
Создай коллекцию **48 иконок** в едином стиле: Создай коллекцию **48 иконок** в едином стиле:
- Стиль: **Outlined с rounded endpoints**, stroke 1.52px, 24×24 viewport - Стиль: **Outlined с rounded endpoints**, stroke 1.52px, 24×24 viewport
- Никакого filled + outlined микса в одном экране - Никакого filled + outlined микса в одном экране
- Все иконки — компоненты Penpot с именованием `icon/[category]/[name]` - Все иконки — компоненты Penpot с именованием `icon/[category]/[name]`
@@ -218,6 +231,7 @@ MeetMe/
### Список иконок (сгруппируй в Penpot по категориям): ### Список иконок (сгруппируй в Penpot по категориям):
**Navigation (8)** **Navigation (8)**
- `nav/feed` — сетка карточек или стопка - `nav/feed` — сетка карточек или стопка
- `nav/matches` — сердце с двойным контуром или звёзды - `nav/matches` — сердце с двойным контуром или звёзды
- `nav/chat` — облако диалога - `nav/chat` — облако диалога
@@ -228,6 +242,7 @@ MeetMe/
- `nav/close` — крест - `nav/close` — крест
**Actions (12)** **Actions (12)**
- `action/like` — сердце - `action/like` — сердце
- `action/dislike` — крест в круге или большой X - `action/dislike` — крест в круге или большой X
- `action/superlike` — звезда - `action/superlike` — звезда
@@ -242,6 +257,7 @@ MeetMe/
- `action/more` — три точки (горизонтальные) - `action/more` — три точки (горизонтальные)
**Profile (8)** **Profile (8)**
- `profile/age` — торт или число - `profile/age` — торт или число
- `profile/location` — пин геолокации - `profile/location` — пин геолокации
- `profile/height` — линейка роста - `profile/height` — линейка роста
@@ -252,6 +268,7 @@ MeetMe/
- `profile/tag` — тег/лейбл - `profile/tag` — тег/лейбл
**Chat (8)** **Chat (8)**
- `chat/read` — двойная галочка - `chat/read` — двойная галочка
- `chat/delivered` — одна галочка - `chat/delivered` — одна галочка
- `chat/typing` — три точки анимированные - `chat/typing` — три точки анимированные
@@ -262,6 +279,7 @@ MeetMe/
- `chat/report` — флажок - `chat/report` — флажок
**Status (6)** **Status (6)**
- `status/online` — зелёная точка - `status/online` — зелёная точка
- `status/pending` — часы - `status/pending` — часы
- `status/confirmed` — галочка - `status/confirmed` — галочка
@@ -270,6 +288,7 @@ MeetMe/
- `status/limit` — замок или стоп-сигнал - `status/limit` — замок или стоп-сигнал
**Misc (6)** **Misc (6)**
- `misc/filter` — воронка - `misc/filter` — воронка
- `misc/search` — лупа - `misc/search` — лупа
- `misc/notification` — колокол - `misc/notification` — колокол
@@ -286,17 +305,20 @@ MeetMe/
#### Button #### Button
**Варианты (Property: variant)** **Варианты (Property: variant)**
- `primary` — brand-500 фон, белый текст - `primary` — brand-500 фон, белый текст
- `secondary` — brand-100 фон, brand-700 текст - `secondary` — brand-100 фон, brand-700 текст
- `ghost` — прозрачный фон, brand-600 текст - `ghost` — прозрачный фон, brand-600 текст
- `destructive` — error фон, белый текст - `destructive` — error фон, белый текст
**Размеры (Property: size)** **Размеры (Property: size)**
- `lg` — height 52px, padding 24px, radius-full, label-lg - `lg` — height 52px, padding 24px, radius-full, label-lg
- `md` — height 44px, padding 20px, radius-full, label-lg - `md` — height 44px, padding 20px, radius-full, label-lg
- `sm` — height 36px, padding 16px, radius-md, label-sm - `sm` — height 36px, padding 16px, radius-md, label-sm
**Состояния (Property: state)** **Состояния (Property: state)**
- `default`, `hover`, `pressed`, `disabled`, `loading` - `default`, `hover`, `pressed`, `disabled`, `loading`
**Иконка (Property: icon):** leading / trailing / icon-only **Иконка (Property: icon):** leading / trailing / icon-only
@@ -327,6 +349,7 @@ MeetMe/
- Используется в профиле для отображения тегов интересов - Используется в профиле для отображения тегов интересов
#### Divider #### Divider
- Горизонтальный, с опциональным текстом по центру («или») - Горизонтальный, с опциональным текстом по центру («или»)
--- ---
@@ -338,6 +361,7 @@ MeetMe/
Основной компонент приложения. Полноэкранная карточка на мобиле. Основной компонент приложения. Полноэкранная карточка на мобиле.
**Структура:** **Структура:**
``` ```
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ │ │ │
@@ -361,11 +385,13 @@ MeetMe/
- Тени под текстом нет — только градиент - Тени под текстом нет — только градиент
**Действия (кнопки поверх карточки):** **Действия (кнопки поверх карточки):**
- Дизлайк — круглая кнопка 64px, нейтральный фон, иконка `action/dislike` в neutral-600 - Дизлайк — круглая кнопка 64px, нейтральный фон, иконка `action/dislike` в neutral-600
- Лайк — круглая кнопка 64px, brand-500 фон, иконка `action/like` белый - Лайк — круглая кнопка 64px, brand-500 фон, иконка `action/like` белый
- Суперлайк (опционально) — 52px, янтарный, звезда - Суперлайк (опционально) — 52px, янтарный, звезда
**Свайп-оверлеи:** **Свайп-оверлеи:**
- Свайп вправо: зелёный бейдж «НРАВИТСЯ» в левом верхнем углу, наклон текста - Свайп вправо: зелёный бейдж «НРАВИТСЯ» в левом верхнем углу, наклон текста
- Свайп влево: красный бейдж «ПРОПУСТИТЬ» в правом верхнем углу - Свайп влево: красный бейдж «ПРОПУСТИТЬ» в правом верхнем углу
@@ -417,6 +443,7 @@ MeetMe/
#### FilterSheet (панель фильтров) #### FilterSheet (панель фильтров)
Bottom sheet на мобиле, sidebar на десктопе. Bottom sheet на мобиле, sidebar на десктопе.
- Возраст: range slider (ageMinageMax) - Возраст: range slider (ageMinageMax)
- Город: dropdown select - Город: dropdown select
- Район: dropdown select (зависимый) - Район: dropdown select (зависимый)
@@ -430,6 +457,7 @@ Bottom sheet на мобиле, sidebar на десктопе.
#### BottomNav (Mobile) #### BottomNav (Mobile)
Высота 83px (+ safe area). 5 вкладок: Высота 83px (+ safe area). 5 вкладок:
- Лента (`nav/feed`) - Лента (`nav/feed`)
- Матчи (`nav/matches`) — с бейджем - Матчи (`nav/matches`) — с бейджем
- Чат (`nav/chat`) — с бейджем - Чат (`nav/chat`) — с бейджем
@@ -445,6 +473,7 @@ Active state: иконка brand-500, лейбл brand-600, точка под и
#### ProfileHeader #### ProfileHeader
Используется на экране публичного профиля. Используется на экране публичного профиля.
- Фото-галерея (swiper, точки-индикаторы) - Фото-галерея (swiper, точки-индикаторы)
- Кнопка назад - Кнопка назад
- Кнопка «Пожаловаться» (три точки → меню) - Кнопка «Пожаловаться» (три точки → меню)
@@ -457,6 +486,7 @@ Active state: иконка brand-500, лейбл brand-600, точка под и
#### MatchModal (bottom sheet / центральный модал) #### MatchModal (bottom sheet / центральный модал)
Появляется при взаимном лайке. Появляется при взаимном лайке.
- Анимированный фон (конфетти? мягкие частицы) - Анимированный фон (конфетти? мягкие частицы)
- Два аватара с перекрытием - Два аватара с перекрытием
- Заголовок: `display-xl` «Это матч!» - Заголовок: `display-xl` «Это матч!»
@@ -473,6 +503,7 @@ Active state: иконка brand-500, лейбл brand-600, точка под и
### Авторизация ### Авторизация
**LoginView** **LoginView**
- Логотип MeetMe (wordmark Playfair Display + иконка) по центру верхней трети - Логотип MeetMe (wordmark Playfair Display + иконка) по центру верхней трети
- `display-xl`: «Рады видеть тебя» - `display-xl`: «Рады видеть тебя»
- Поля: телефон (+7), пароль - Поля: телефон (+7), пароль
@@ -480,6 +511,7 @@ Active state: иконка brand-500, лейбл brand-600, точка под и
- Ссылка: «Нет аккаунта? Зарегистрироваться» - Ссылка: «Нет аккаунта? Зарегистрироваться»
**RegisterView** **RegisterView**
- «Создай аккаунт» - «Создай аккаунт»
- Поля: телефон, пароль, подтверждение пароля - Поля: телефон, пароль, подтверждение пароля
- Кнопка: «Зарегистрироваться» - Кнопка: «Зарегистрироваться»
@@ -488,22 +520,26 @@ Active state: иконка brand-500, лейбл brand-600, точка под и
### Онбординг ### Онбординг
**CreateProfileView — Шаг 1 (Основное)** **CreateProfileView — Шаг 1 (Основное)**
- Прогресс-бар (3 шага) - Прогресс-бар (3 шага)
- «Расскажи о себе» - «Расскажи о себе»
- Поля: Имя, Дата рождения, Пол (radio-кнопки с иконками ♂ ♀) - Поля: Имя, Дата рождения, Пол (radio-кнопки с иконками ♂ ♀)
- CTA: «Далее» - CTA: «Далее»
**CreateProfileView — Шаг 2 (Детали)** **CreateProfileView — Шаг 2 (Детали)**
- Поля: Город (select), Район (select), Рост, Вес, Национальность - Поля: Город (select), Район (select), Рост, Вес, Национальность
- Описание (textarea, 300 символов) - Описание (textarea, 300 символов)
- CTA: «Далее» - CTA: «Далее»
**CreateProfileView — Шаг 3 (Интересы)** **CreateProfileView — Шаг 3 (Интересы)**
- «Выбери до 10 интересов» - «Выбери до 10 интересов»
- Chip grid из тегов (GET /tags) - Chip grid из тегов (GET /tags)
- CTA: «Готово» - CTA: «Готово»
**MediaUploadView** **MediaUploadView**
- «Добавь фото» - «Добавь фото»
- 6 слотов в сетке 2×3, первый — обязательный (основное фото) - 6 слотов в сетке 2×3, первый — обязательный (основное фото)
- Плейсхолдер слота: `+` иконка, пунктирная рамка - Плейсхолдер слота: `+` иконка, пунктирная рамка
@@ -513,18 +549,21 @@ Active state: иконка brand-500, лейбл brand-600, точка под и
### Лента ### Лента
**FeedView** **FeedView**
- Карточка профиля на весь экран (за вычетом статус-бара и BottomNav) - Карточка профиля на весь экран (за вычетом статус-бара и BottomNav)
- Вверху: логотип MeetMe (compact) + иконка фильтра + иконка уведомлений - Вверху: логотип MeetMe (compact) + иконка фильтра + иконка уведомлений
- Кнопки действий снизу карточки - Кнопки действий снизу карточки
- Индикатор позиции (точки или счётчик «3 из 20») - Индикатор позиции (точки или счётчик «3 из 20»)
**FeedView — Empty State** **FeedView — Empty State**
- Иллюстрация (простая, монолинейная, brand-200 цвет) - Иллюстрация (простая, монолинейная, brand-200 цвет)
- «Ты просмотрел всех» - «Ты просмотрел всех»
- «Попробуй позже или измени фильтры» - «Попробуй позже или измени фильтры»
- CTA: «Изменить фильтры» - CTA: «Изменить фильтры»
**FeedView — Limit Reached** **FeedView — Limit Reached**
- «У тебя 10 матчей» - «У тебя 10 матчей»
- «Пообщайся с ними, чтобы продолжить поиск» - «Пообщайся с ними, чтобы продолжить поиск»
- CTA: «Перейти к матчам» - CTA: «Перейти к матчам»
@@ -532,6 +571,7 @@ Active state: иконка brand-500, лейбл brand-600, точка под и
### Матчи ### Матчи
**MatchesView** **MatchesView**
- Заголовок «Матчи» - Заголовок «Матчи»
- Horizontal scroll «Новые» — аватары в кружках (как в Instagram Stories) - Horizontal scroll «Новые» — аватары в кружках (как в Instagram Stories)
- Список MatchCard ниже — все матчи в хронологии - Список MatchCard ниже — все матчи в хронологии
@@ -539,12 +579,14 @@ Active state: иконка brand-500, лейбл brand-600, точка под и
### Чат ### Чат
**ChatsListView** **ChatsListView**
- Заголовок «Сообщения» - Заголовок «Сообщения»
- Поиск по чатам - Поиск по чатам
- Список ChatListItem - Список ChatListItem
- Empty state: «Пока нет чатов. Напиши кому-нибудь из матчей!» - Empty state: «Пока нет чатов. Напиши кому-нибудь из матчей!»
**ChatView** **ChatView**
- ChatHeader - ChatHeader
- Сообщения (с группировкой по датам — «Сегодня», «Вчера», дата) - Сообщения (с группировкой по датам — «Сегодня», «Вчера», дата)
- Быстрые приветствия (горизонтальный скролл чипов, появляется при первом сообщении) - Быстрые приветствия (горизонтальный скролл чипов, появляется при первом сообщении)
@@ -554,12 +596,14 @@ Active state: иконка brand-500, лейбл brand-600, точка под и
### Встречи ### Встречи
**DatesView** **DatesView**
- Заголовок «Встречи» - Заголовок «Встречи»
- Фильтр по статусу (таббар: Все / Ожидают / Подтверждены) - Фильтр по статусу (таббар: Все / Ожидают / Подтверждены)
- Список DateCard - Список DateCard
- Empty state: «Пока нет встреч. Договорись в чате!» - Empty state: «Пока нет встреч. Договорись в чате!»
**DateProposalSheet (bottom sheet)** **DateProposalSheet (bottom sheet)**
- «Назначить встречу» - «Назначить встречу»
- Поле: дата и время (date-time picker) - Поле: дата и время (date-time picker)
- Карта или поле координат - Карта или поле координат
@@ -568,17 +612,20 @@ Active state: иконка brand-500, лейбл brand-600, точка под и
### Профиль ### Профиль
**MyProfilesView** **MyProfilesView**
- «Мои профили» - «Мои профили»
- Список профилей (аватар, имя, возраст) - Список профилей (аватар, имя, возраст)
- Кнопка «Добавить профиль» (+ иконка) - Кнопка «Добавить профиль» (+ иконка)
- Active badge на текущем профиле - Active badge на текущем профиле
**ProfileEditView** **ProfileEditView**
- Редактирование с теми же полями что в создании - Редактирование с теми же полями что в создании
- Секция «Медиа» — галерея с drag-to-reorder - Секция «Медиа» — галерея с drag-to-reorder
- Кнопка «Удалить профиль» (destructive, внизу) - Кнопка «Удалить профиль» (destructive, внизу)
**ProfilePublicView** **ProfilePublicView**
- ProfileHeader (фото-галерея) - ProfileHeader (фото-галерея)
- Имя, возраст, геолокация - Имя, возраст, геолокация
- Описание - Описание
@@ -589,6 +636,7 @@ Active state: иконка brand-500, лейбл brand-600, точка под и
### Настройки ### Настройки
**SettingsView** **SettingsView**
- Аватар + имя аккаунта - Аватар + имя аккаунта
- Секции: Аккаунт, Уведомления, Безопасность, О приложении - Секции: Аккаунт, Уведомления, Безопасность, О приложении
- Кнопка «Выйти» (destructive) - Кнопка «Выйти» (destructive)
@@ -600,24 +648,29 @@ Active state: иконка brand-500, лейбл brand-600, точка под и
Каждый фрейм 1440×900. Каждый фрейм 1440×900.
**Общий Layout:** **Общий Layout:**
- Левая колонка: SideNav (240px, фиксированная) - Левая колонка: SideNav (240px, фиксированная)
- Основная область: 1200px - Основная область: 1200px
- Для чата: split-view — список (320px) + сообщения - Для чата: split-view — список (320px) + сообщения
**FeedView Desktop** **FeedView Desktop**
- SideNav слева - SideNav слева
- Центр: карточка профиля 420×560px (не во весь экран!) - Центр: карточка профиля 420×560px (не во весь экран!)
- Справа от карточки: детали профиля (имя, теги, описание) — 320px колонка - Справа от карточки: детали профиля (имя, теги, описание) — 320px колонка
- Кнопки под карточкой или справа - Кнопки под карточкой или справа
**ChatsView Desktop** **ChatsView Desktop**
- SideNav | ChatList 320px | ChatMessages | (опционально ProfilePanel 280px) - SideNav | ChatList 320px | ChatMessages | (опционально ProfilePanel 280px)
- Нет bottom sheet — поле ввода снизу в колонке сообщений - Нет bottom sheet — поле ввода снизу в колонке сообщений
**MatchesView Desktop** **MatchesView Desktop**
- Сетка 3 колонки из MatchCard - Сетка 3 колонки из MatchCard
**ProfileEditView Desktop** **ProfileEditView Desktop**
- Двухколоночный layout: форма | превью профиля - Двухколоночный layout: форма | превью профиля
--- ---
@@ -637,16 +690,19 @@ Active state: иконка brand-500, лейбл brand-600, точка под и
Для каждого ключевого экрана создай: Для каждого ключевого экрана создай:
**Loading (Skeleton):** **Loading (Skeleton):**
- ProfileCard skeleton: прямоугольник с animated shimmer - ProfileCard skeleton: прямоугольник с animated shimmer
- ChatListItem skeleton: аватар-круг + две строки - ChatListItem skeleton: аватар-круг + две строки
**Empty States (с иллюстрацией):** **Empty States (с иллюстрацией):**
- Лента пуста - Лента пуста
- Нет матчей - Нет матчей
- Нет чатов - Нет чатов
- Нет встреч - Нет встреч
**Error State:** **Error State:**
- Что-то пошло не так + кнопка «Повторить» - Что-то пошло не так + кнопка «Повторить»
--- ---
@@ -654,6 +710,7 @@ Active state: иконка brand-500, лейбл brand-600, точка под и
## 11. ЛОГОТИП MEETME ## 11. ЛОГОТИП MEETME
Создай wordmark: Создай wordmark:
- Текст «MeetMe» шрифтом Playfair Display, 700 weight - Текст «MeetMe» шрифтом Playfair Display, 700 weight
- «Meet» — neutral-900 - «Meet» — neutral-900
- «Me» — brand-500 - «Me» — brand-500
@@ -694,6 +751,7 @@ Page 7: 🖥 Desktop Screens
``` ```
### Именование в Penpot: ### Именование в Penpot:
- Компоненты: `ComponentName/variant/size/state` - Компоненты: `ComponentName/variant/size/state`
- Цвета: `brand/500`, `neutral/200`, `semantic/success` - Цвета: `brand/500`, `neutral/200`, `semantic/success`
- Текстовые стили: `display/2xl`, `body/md`, `label/lg` - Текстовые стили: `display/2xl`, `body/md`, `label/lg`
@@ -739,6 +797,7 @@ Page 7: 🖥 Desktop Screens
## Финальная проверка ## Финальная проверка
После завершения прогони аудит по impeccable: После завершения прогони аудит по impeccable:
- Типографический контраст (все текстовые пары ≥ 4.5:1) - Типографический контраст (все текстовые пары ≥ 4.5:1)
- Touch targets на мобиле (≥ 44×44px) - Touch targets на мобиле (≥ 44×44px)
- Длина строк (4575 символов для body text) - Длина строк (4575 символов для body text)

View File

@@ -1,4 +1,5 @@
Before starting, read and internalize these two skills in full: Before starting, read and internalize these two skills in full:
- .claude/skills/impeccable/SKILL.md (and all 7 reference files in reference/) - .claude/skills/impeccable/SKILL.md (and all 7 reference files in reference/)
- .claude/skills/taste-skill/SKILL.md - .claude/skills/taste-skill/SKILL.md
@@ -8,6 +9,7 @@ DESIGN_VARIANCE: 8, MOTION_INTENSITY: 6, VISUAL_DENSITY: 4
You are a Figma design expert. Create a complete component library and UI screens for a mobile dating app called "Tandem". Use the Figma MCP tools to build everything directly in Figma. You are a Figma design expert. Create a complete component library and UI screens for a mobile dating app called "Tandem". Use the Figma MCP tools to build everything directly in Figma.
## Anti-slop directives (from skills — enforce strictly) ## Anti-slop directives (from skills — enforce strictly)
- NO Inter font anywhere. DM Sans is explicitly specified below and is the exception (brand choice). - NO Inter font anywhere. DM Sans is explicitly specified below and is the exception (brand choice).
- NO centered hero layouts — use asymmetric, left-weighted compositions where possible within mobile constraints. - NO centered hero layouts — use asymmetric, left-weighted compositions where possible within mobile constraints.
- NO generic 3-equal-card rows. Use asymmetric grids, varied sizes, hierarchy through scale. - NO generic 3-equal-card rows. Use asymmetric grids, varied sizes, hierarchy through scale.
@@ -21,11 +23,13 @@ You are a Figma design expert. Create a complete component library and UI screen
- Image placeholders: use picsum.photos/seed/{name}/800/600 format (never Unsplash links). - Image placeholders: use picsum.photos/seed/{name}/800/600 format (never Unsplash links).
## Project overview ## Project overview
Mobile-first dating app (iOS/Android via Tauri). Users register by phone, create profiles, swipe a feed, match, chat in real-time (text/photo/voice/video), schedule meetups, report users. Mobile-first dating app (iOS/Android via Tauri). Users register by phone, create profiles, swipe a feed, match, chat in real-time (text/photo/voice/video), schedule meetups, report users.
## Design system — establish FIRST before any screens ## Design system — establish FIRST before any screens
### Color tokens (create as Figma variables) ### Color tokens (create as Figma variables)
- bg-primary: #0D0D0F - bg-primary: #0D0D0F
- bg-surface: #1A1A1F - bg-surface: #1A1A1F
- bg-elevated: #242429 - bg-elevated: #242429
@@ -40,6 +44,7 @@ Mobile-first dating app (iOS/Android via Tauri). Users register by phone, create
- border: #2C2C35 - border: #2C2C35
### Typography ### Typography
- Display/Hero: Playfair Display Italic, 3248px (editorial moments only — match celebrations, onboarding headers) - Display/Hero: Playfair Display Italic, 3248px (editorial moments only — match celebrations, onboarding headers)
- Title: DM Sans SemiBold, 2024px, tracking-tight - Title: DM Sans SemiBold, 2024px, tracking-tight
- Body: DM Sans Regular, 1516px, leading-relaxed - Body: DM Sans Regular, 1516px, leading-relaxed
@@ -48,15 +53,19 @@ Mobile-first dating app (iOS/Android via Tauri). Users register by phone, create
- Mono data (stats, counts, timestamps): DM Mono or DM Sans Tabular Numbers - Mono data (stats, counts, timestamps): DM Mono or DM Sans Tabular Numbers
### Taste-skill typography enforcement: ### Taste-skill typography enforcement:
- Headlines use tracking-tighter. No oversized H1s that scream. - Headlines use tracking-tighter. No oversized H1s that scream.
- Control hierarchy through weight and color, not scale alone. - Control hierarchy through weight and color, not scale alone.
- Playfair Display is brand-intentional (editorial register) — use sparingly. - Playfair Display is brand-intentional (editorial register) — use sparingly.
### Spacing scale: 4, 8, 12, 16, 20, 24, 32, 48px ### Spacing scale: 4, 8, 12, 16, 20, 24, 32, 48px
### Border radius: sm=8, md=16, lg=24, full=999px ### Border radius: sm=8, md=16, lg=24, full=999px
### Safe areas: top=44px, bottom=34px (iPhone) ### Safe areas: top=44px, bottom=34px (iPhone)
### Shadow / elevation (taste-skill materiality rules): ### Shadow / elevation (taste-skill materiality rules):
- Shadows are ALWAYS tinted to the background hue, never pure black - Shadows are ALWAYS tinted to the background hue, never pure black
- No outer glows. Use inner border (1px border-white/10) + inner shadow for glass surfaces - No outer glows. Use inner border (1px border-white/10) + inner shadow for glass surfaces
- Cards appear only where elevation communicates hierarchy - Cards appear only where elevation communicates hierarchy
@@ -66,8 +75,10 @@ Mobile-first dating app (iOS/Android via Tauri). Users register by phone, create
## Component library (create as Figma components with variants) ## Component library (create as Figma components with variants)
### 1. Buttons ### 1. Buttons
Component: Button Component: Button
Variants — size: [Large, Medium, Small] × style: [Primary, Secondary, Ghost, Danger] Variants — size: [Large, Medium, Small] × style: [Primary, Secondary, Ghost, Danger]
- Large: full-width, height 56px, border-radius 16px - Large: full-width, height 56px, border-radius 16px
- Primary: bg=accent, text=white. Active state: scale(0.98) — physical press feel - Primary: bg=accent, text=white. Active state: scale(0.98) — physical press feel
- Secondary: bg=bg-elevated, border=1px border-color, text=text-primary - Secondary: bg=bg-elevated, border=1px border-color, text=text-primary
@@ -76,8 +87,10 @@ Variants — size: [Large, Medium, Small] × style: [Primary, Secondary, Ghost,
- ALL buttons: no outer glow. Primary shadow = tinted coral shadow beneath - ALL buttons: no outer glow. Primary shadow = tinted coral shadow beneath
### 2. Input field ### 2. Input field
Component: InputField Component: InputField
Variants — state: [Default, Focused, Filled, Error] Variants — state: [Default, Focused, Filled, Error]
- Height 56px, bg=bg-elevated, border-radius=12px, 1px border=border-color - Height 56px, bg=bg-elevated, border-radius=12px, 1px border=border-color
- Focused: 1.5px border=accent, subtle inner shadow accent-soft - Focused: 1.5px border=accent, subtle inner shadow accent-soft
- Label sits above input (never placeholder-only) - Label sits above input (never placeholder-only)
@@ -85,9 +98,11 @@ Variants — state: [Default, Focused, Filled, Error]
- Optional: left icon, right icon/clear - Optional: left icon, right icon/clear
### 3. Profile Card (hero swipe card) ### 3. Profile Card (hero swipe card)
Component: ProfileCard Component: ProfileCard
Size: 340×480px Size: 340×480px
Structure: Structure:
- Full-bleed photo background. Gradient overlay: bottom 50%, black 0%→75% - Full-bleed photo background. Gradient overlay: bottom 50%, black 0%→75%
- Photo: picsum.photos/seed/sofia/400/600 style - Photo: picsum.photos/seed/sofia/400/600 style
- Bottom section over gradient: - Bottom section over gradient:
@@ -100,13 +115,16 @@ Structure:
- Card stack illusion: 2 cards partially peeking behind, scale-down + slight translate - Card stack illusion: 2 cards partially peeking behind, scale-down + slight translate
### 4. SwipeActions ### 4. SwipeActions
Three buttons row, centered: Three buttons row, centered:
- Dislike: 64px circle, bg=bg-elevated, X icon (accent red), tinted shadow below - Dislike: 64px circle, bg=bg-elevated, X icon (accent red), tinted shadow below
- SuperLike: 52px circle, bg=gold 10% opacity, star icon (gold), smaller - SuperLike: 52px circle, bg=gold 10% opacity, star icon (gold), smaller
- Like: 64px circle, accent gradient, heart icon white, shadow tinted coral - Like: 64px circle, accent gradient, heart icon white, shadow tinted coral
- No labels — icons only, phosphor style - No labels — icons only, phosphor style
### 5. BottomNav ### 5. BottomNav
Height 83px (inc 34px safe area), bg=bg-surface, 1px top border=border-color Height 83px (inc 34px safe area), bg=bg-surface, 1px top border=border-color
5 tabs: Feed (flame), Matches (heart), Chats (message-circle), Dates (calendar), Profile (user) 5 tabs: Feed (flame), Matches (heart), Chats (message-circle), Dates (calendar), Profile (user)
Active: icon+label=accent, 2px accent indicator dot above icon Active: icon+label=accent, 2px accent indicator dot above icon
@@ -114,11 +132,13 @@ Inactive: text-muted
Labels: DM Sans Regular 11px Labels: DM Sans Regular 11px
### 6. Avatar ### 6. Avatar
Variants — size: [XL=80px, L=56px, M=40px, S=32px] × state: [Default, Online, Verified] Variants — size: [XL=80px, L=56px, M=40px, S=32px] × state: [Default, Online, Verified]
Online: 10px green dot (#30D158) bottom-right, 2px white border around dot Online: 10px green dot (#30D158) bottom-right, 2px white border around dot
Verified: small accent checkmark badge bottom-right instead Verified: small accent checkmark badge bottom-right instead
### 7. MatchChip ### 7. MatchChip
Height 72px, full-width, bg=bg-surface, subtle 1px border bottom=border-color Height 72px, full-width, bg=bg-surface, subtle 1px border bottom=border-color
Left: Avatar M + Online indicator Left: Avatar M + Online indicator
Center: Name (DM Sans SemiBold 15px) + last message preview (text-muted, 1 line, 13px) Center: Name (DM Sans SemiBold 15px) + last message preview (text-muted, 1 line, 13px)
@@ -126,7 +146,9 @@ Right: timestamp (Caption, text-muted) + unread count badge (accent circle, whit
Pressed state: bg=bg-elevated Pressed state: bg=bg-elevated
### 8. MessageBubble ### 8. MessageBubble
Variants — sender: [Me, Them] × type: [Text, Photo, Voice, Video] Variants — sender: [Me, Them] × type: [Text, Photo, Voice, Video]
- Me: right-aligned, bg=accent, text=white, radius 18 18 4 18 - Me: right-aligned, bg=accent, text=white, radius 18 18 4 18
- Them: left-aligned, bg=bg-elevated, text=text-primary, radius 18 18 18 4 - Them: left-aligned, bg=bg-elevated, text=text-primary, radius 18 18 18 4
- Voice: horizontal bar waveform (1012 bars, varying heights) + duration + play circle icon - Voice: horizontal bar waveform (1012 bars, varying heights) + duration + play circle icon
@@ -135,8 +157,10 @@ Variants — sender: [Me, Them] × type: [Text, Photo, Voice, Video]
- Max width: 72% of screen - Max width: 72% of screen
### 9. MatchModal ### 9. MatchModal
Full-screen, bg=rgba(0,0,0,0.85), backdrop blur Full-screen, bg=rgba(0,0,0,0.85), backdrop blur
Center: Center:
- "It's a Match!" Playfair Display Italic 40px, white (no emoji — use decorative SVG spark icon) - "It's a Match!" Playfair Display Italic 40px, white (no emoji — use decorative SVG spark icon)
- Two Avatar XL overlapping, gold border 2px, inner glow tinted gold - Two Avatar XL overlapping, gold border 2px, inner glow tinted gold
- Subtitle: "You and Sofia both liked each other" — text-secondary, DM Sans Regular - Subtitle: "You and Sofia both liked each other" — text-secondary, DM Sans Regular
@@ -145,27 +169,32 @@ Center:
- Static confetti: geometric shapes (circles, triangles, small rectangles) in accent/gold/white scattered around, no emoji stars - Static confetti: geometric shapes (circles, triangles, small rectangles) in accent/gold/white scattered around, no emoji stars
### 10. TagPill ### 10. TagPill
Variants — selected: [true, false] Variants — selected: [true, false]
Default: bg=bg-elevated, border=border, text=text-secondary, h=32px, px=12px, radius=full Default: bg=bg-elevated, border=border, text=text-secondary, h=32px, px=12px, radius=full
Selected: bg=accent-soft, border=accent, text=accent Selected: bg=accent-soft, border=accent, text=accent
### 11. SectionHeader ### 11. SectionHeader
Left: Title + optional Caption subtitle Left: Title + optional Caption subtitle
Right: optional "See all" in accent (DM Sans Medium 13px) Right: optional "See all" in accent (DM Sans Medium 13px)
### 12. GreetingCard ### 12. GreetingCard
bg=bg-elevated, border-radius=16px, p=16px bg=bg-elevated, border-radius=16px, p=16px
Top-left: decorative quote mark in accent (SVG, not emoji) Top-left: decorative quote mark in accent (SVG, not emoji)
Body text: italicized, text-primary Body text: italicized, text-primary
Pressed: 1px accent border appears Pressed: 1px accent border appears
### 13. DateCard ### 13. DateCard
Full-width, bg=bg-elevated, radius=16px, p=16px Full-width, bg=bg-elevated, radius=16px, p=16px
Left: 48px accent circle with calendar icon (phosphor) Left: 48px accent circle with calendar icon (phosphor)
Right: Partner name (SemiBold 15px), date+time (Caption), location (text-muted Caption) Right: Partner name (SemiBold 15px), date+time (Caption), location (text-muted Caption)
Bottom-right: status pill — Pending=gold bg+text, Confirmed=success, Cancelled=error, Rescheduled=text-secondary Bottom-right: status pill — Pending=gold bg+text, Confirmed=success, Cancelled=error, Rescheduled=text-secondary
### 14. Toast ### 14. Toast
Variants: [Success, Error, Info, Warning] Variants: [Success, Error, Info, Warning]
Bottom of screen, mx=16px, bg=bg-elevated, 3px left border in status color Bottom of screen, mx=16px, bg=bg-elevated, 3px left border in status color
Left: status icon (phosphor) in status color Left: status icon (phosphor) in status color
@@ -180,6 +209,7 @@ Apply /impeccable craft principles: shape UX first, then build.
Asymmetric compositions where mobile constraints allow. Every screen complete, production-ready. Asymmetric compositions where mobile constraints allow. Every screen complete, production-ready.
**1.1 — Splash / Welcome** **1.1 — Splash / Welcome**
- Full bg-primary background - Full bg-primary background
- Bottom-left: large abstract soft gradient blob in accent (#FF4D6D), heavily blurred (no hard edges) - Bottom-left: large abstract soft gradient blob in accent (#FF4D6D), heavily blurred (no hard edges)
- Center: geometric logo mark (abstract spark/connection shape, SVG — no emoji) + "Tandem" Playfair Display Italic 48px - Center: geometric logo mark (abstract spark/connection shape, SVG — no emoji) + "Tandem" Playfair Display Italic 48px
@@ -188,6 +218,7 @@ Asymmetric compositions where mobile constraints allow. Every screen complete, p
- Subtle grain texture on background (fixed pseudo-element concept) - Subtle grain texture on background (fixed pseudo-element concept)
**1.2 — Register** **1.2 — Register**
- Back arrow (phosphor) + "Create account" Title - Back arrow (phosphor) + "Create account" Title
- Subtitle: "Your number stays private." (concrete reassurance, not generic) - Subtitle: "Your number stays private." (concrete reassurance, not generic)
- Phone input with country code selector (+7 flag) - Phone input with country code selector (+7 flag)
@@ -197,12 +228,14 @@ Asymmetric compositions where mobile constraints allow. Every screen complete, p
- "Already registered? Sign in" text-secondary centered bottom - "Already registered? Sign in" text-secondary centered bottom
**1.3 — Login** **1.3 — Login**
- Back arrow + "Welcome back" Title - Back arrow + "Welcome back" Title
- Phone + password inputs - Phone + password inputs
- "Sign In" Primary Large - "Sign In" Primary Large
- "Forgot password?" ghost link centered, text-secondary - "Forgot password?" ghost link centered, text-secondary
**1.4 — Profile Setup Step 1/3** **1.4 — Profile Setup Step 1/3**
- 3-segment progress bar, segment 1 active (accent), others text-muted - 3-segment progress bar, segment 1 active (accent), others text-muted
- "Tell us about you" Playfair Display Italic Title (editorial register) - "Tell us about you" Playfair Display Italic Title (editorial register)
- Name input (filled: "Alina") - Name input (filled: "Alina")
@@ -212,6 +245,7 @@ Asymmetric compositions where mobile constraints allow. Every screen complete, p
- "Continue" Primary Large - "Continue" Primary Large
**1.5 — Profile Setup Step 2/3** **1.5 — Profile Setup Step 2/3**
- Segment 2 active - Segment 2 active
- "What moves you?" (concrete, not "What are you into?") - "What moves you?" (concrete, not "What are you into?")
- "Pick up to 5" Caption subtitle - "Pick up to 5" Caption subtitle
@@ -221,6 +255,7 @@ Asymmetric compositions where mobile constraints allow. Every screen complete, p
- "Continue" Primary Large - "Continue" Primary Large
**1.6 — Profile Setup Step 3/3** **1.6 — Profile Setup Step 3/3**
- Segment 3 active - Segment 3 active
- "Show yourself" Playfair Display Italic Title - "Show yourself" Playfair Display Italic Title
- Upload zone 340×260: dashed 1.5px accent border, camera phosphor icon, "Tap to add a photo" Caption - Upload zone 340×260: dashed 1.5px accent border, camera phosphor icon, "Tap to add a photo" Caption
@@ -232,6 +267,7 @@ Asymmetric compositions where mobile constraints allow. Every screen complete, p
## PAGE 2 — "02 · Main App" (390×844px frames, 40px gap) ## PAGE 2 — "02 · Main App" (390×844px frames, 40px gap)
**2.1 — Feed** **2.1 — Feed**
- Status bar 44px - Status bar 44px
- Top bar: small "Tandem" wordmark left (Playfair Display Italic 18px), sliders/filter icon right + Avatar M right - Top bar: small "Tandem" wordmark left (Playfair Display Italic 18px), sliders/filter icon right + Avatar M right
- ProfileCard centered, card stack visible (2 cards peeking: scale 0.95 and 0.90, translate-y) - ProfileCard centered, card stack visible (2 cards peeking: scale 0.95 and 0.90, translate-y)
@@ -239,11 +275,13 @@ Asymmetric compositions where mobile constraints allow. Every screen complete, p
- BottomNav: Feed active - BottomNav: Feed active
**2.2 — Feed + Match Modal** **2.2 — Feed + Match Modal**
- Same feed, blurred/dimmed beneath - Same feed, blurred/dimmed beneath
- MatchModal overlay. Avatars: Sofia + current user. Gold border glow (inner, not outer). - MatchModal overlay. Avatars: Sofia + current user. Gold border glow (inner, not outer).
- Static confetti in corners (geometric, not emoji) - Static confetti in corners (geometric, not emoji)
**2.3 — Matches List** **2.3 — Matches List**
- "Matches" Title top bar + filter icon - "Matches" Title top bar + filter icon
- Horizontal scroll: 5 Avatar L circles with name below. First: gold ring border + "New" pill (gold). Use names: Lena, Sofia, Masha, Alina, Katya - Horizontal scroll: 5 Avatar L circles with name below. First: gold ring border + "New" pill (gold). Use names: Lena, Sofia, Masha, Alina, Katya
- Divider line (1px border-color) - Divider line (1px border-color)
@@ -252,6 +290,7 @@ Asymmetric compositions where mobile constraints allow. Every screen complete, p
- BottomNav: Matches active - BottomNav: Matches active
**2.4 — Chats List** **2.4 — Chats List**
- "Chats" Title - "Chats" Title
- 4 MatchChip items: - 4 MatchChip items:
- Sofia — "That place sounds perfect" — 14:32 — online dot - Sofia — "That place sounds perfect" — 14:32 — online dot
@@ -261,6 +300,7 @@ Asymmetric compositions where mobile constraints allow. Every screen complete, p
- BottomNav: Chats active - BottomNav: Chats active
**2.5 — Chat View (with Sofia)** **2.5 — Chat View (with Sofia)**
- Top: back arrow + Avatar M (Sofia, online) + "Sofia" Title + "Online" Caption green + video-call icon + report icon - Top: back arrow + Avatar M (Sofia, online) + "Sofia" Title + "Online" Caption green + video-call icon + report icon
- Messages: - Messages:
- Date divider: "Today" centered Caption text-muted - Date divider: "Today" centered Caption text-muted
@@ -275,6 +315,7 @@ Asymmetric compositions where mobile constraints allow. Every screen complete, p
- BottomNav: Chats active - BottomNav: Chats active
**2.6 — Public Profile (Sofia)** **2.6 — Public Profile (Sofia)**
- Photo area top: full-width, 380px height, 2-photo carousel with dots + "2/4" pill top-right - Photo area top: full-width, 380px height, 2-photo carousel with dots + "2/4" pill top-right
- Back arrow top-left (on photo) + report icon top-right (both on photo, ghost style) - Back arrow top-left (on photo) + report icon top-right (both on photo, ghost style)
- Below photo: "Sofia, 24" Title + "Moscow · 2.3 km" Caption + verified badge - Below photo: "Sofia, 24" Title + "Moscow · 2.3 km" Caption + verified badge
@@ -285,6 +326,7 @@ Asymmetric compositions where mobile constraints allow. Every screen complete, p
- Sticky bottom: Dislike (Secondary) + "Like" (Primary accent) side by side - Sticky bottom: Dislike (Secondary) + "Like" (Primary accent) side by side
**2.7 — Dates/Meetups** **2.7 — Dates/Meetups**
- "Meetups" Title + "+" phosphor icon - "Meetups" Title + "+" phosphor icon
- SectionHeader "Upcoming" - SectionHeader "Upcoming"
- DateCard 1: Daniil · Sat, Jun 14 · 19:00 · Blue Goose Bar, Kamergersky · Confirmed (green) - DateCard 1: Daniil · Sat, Jun 14 · 19:00 · Blue Goose Bar, Kamergersky · Confirmed (green)
@@ -294,6 +336,7 @@ Asymmetric compositions where mobile constraints allow. Every screen complete, p
- BottomNav: Dates active - BottomNav: Dates active
**2.8 — Propose Meetup (bottom sheet)** **2.8 — Propose Meetup (bottom sheet)**
- Handle bar 4px, 36px wide, centered, text-muted - Handle bar 4px, 36px wide, centered, text-muted
- "Propose a Meetup" Title - "Propose a Meetup" Title
- Sofia Avatar M + "Sofia" centered below (Caption) - Sofia Avatar M + "Sofia" centered below (Caption)
@@ -307,6 +350,7 @@ Asymmetric compositions where mobile constraints allow. Every screen complete, p
## PAGE 3 — "03 · Profile & Settings" (390×844px frames, 40px gap) ## PAGE 3 — "03 · Profile & Settings" (390×844px frames, 40px gap)
**3.1 — My Profile** **3.1 — My Profile**
- "My Profile" Title + settings gear icon right - "My Profile" Title + settings gear icon right
- Profile selector: horizontal scroll of compact cards 80×100, first (Alina) has accent border, others dimmed. Last: dashed border + "+" add - Profile selector: horizontal scroll of compact cards 80×100, first (Alina) has accent border, others dimmed. Last: dashed border + "+" add
- Active profile: - Active profile:
@@ -321,6 +365,7 @@ Asymmetric compositions where mobile constraints allow. Every screen complete, p
- BottomNav: Profile active - BottomNav: Profile active
**3.2 — Edit Profile** **3.2 — Edit Profile**
- Back arrow + "Edit Profile" Title + "Save" accent text button right - Back arrow + "Edit Profile" Title + "Save" accent text button right
- Avatar XL with camera icon overlay circle (bg-elevated) - Avatar XL with camera icon overlay circle (bg-elevated)
- Scrollable form: - Scrollable form:
@@ -336,6 +381,7 @@ Asymmetric compositions where mobile constraints allow. Every screen complete, p
- "Delete Profile" Danger Ghost button (bottom, error color, no icon) - "Delete Profile" Danger Ghost button (bottom, error color, no icon)
**3.3 — Settings** **3.3 — Settings**
- "Settings" Title - "Settings" Title
- User row: Avatar M + "+7 (916) 847-2391" + "Edit account" accent link - User row: Avatar M + "+7 (916) 847-2391" + "Edit account" accent link
- Setting groups with 1px dividers: - Setting groups with 1px dividers:
@@ -345,6 +391,7 @@ Asymmetric compositions where mobile constraints allow. Every screen complete, p
- "Sign Out" Secondary Large button full-width bottom - "Sign Out" Secondary Large button full-width bottom
**3.4 — Feed Filters (bottom sheet)** **3.4 — Feed Filters (bottom sheet)**
- Handle bar - Handle bar
- "Search Filters" Title - "Search Filters" Title
- City dropdown (Moscow) - City dropdown (Moscow)
@@ -355,6 +402,7 @@ Asymmetric compositions where mobile constraints allow. Every screen complete, p
- "Apply Filters" Primary Large + "Reset" Ghost - "Apply Filters" Primary Large + "Reset" Ghost
**3.5 — Report User (bottom sheet)** **3.5 — Report User (bottom sheet)**
- Handle bar - Handle bar
- "Report" Title - "Report" Title
- "Reporting Sofia, 24" Caption text-muted centered - "Reporting Sofia, 24" Caption text-muted centered
@@ -373,6 +421,7 @@ Asymmetric compositions where mobile constraints allow. Every screen complete, p
## Final instructions (Impeccable + Taste-skill enforcement) ## Final instructions (Impeccable + Taste-skill enforcement)
**Sequence:** **Sequence:**
1. Create all color variables and text styles. 1. Create all color variables and text styles.
2. Run `/impeccable teach` mentally — establish DESIGN.md context (dark luxury premium dating, not hookup app). 2. Run `/impeccable teach` mentally — establish DESIGN.md context (dark luxury premium dating, not hookup app).
3. Create every component as a Figma component with all variants. 3. Create every component as a Figma component with all variants.
@@ -381,6 +430,7 @@ Asymmetric compositions where mobile constraints allow. Every screen complete, p
6. Run `/impeccable polish` — final pass: alignment, shadow consistency, border-radius uniformity. 6. Run `/impeccable polish` — final pass: alignment, shadow consistency, border-radius uniformity.
**Quality gates (Taste-skill Pre-flight):** **Quality gates (Taste-skill Pre-flight):**
- Every interactive element has Default + Pressed states at minimum - Every interactive element has Default + Pressed states at minimum
- No card where spacing would suffice - No card where spacing would suffice
- Shadows tinted to bg hue - Shadows tinted to bg hue

View File

@@ -1,4 +1,5 @@
Read and apply these skills before starting: Read and apply these skills before starting:
- .claude/skills/impeccable/SKILL.md - .claude/skills/impeccable/SKILL.md
- .claude/skills/taste-skill/SKILL.md - .claude/skills/taste-skill/SKILL.md
@@ -8,6 +9,7 @@ You are a Penpot design expert building a mobile dating app "Tandem".
Work in a single Penpot page. Layout everything horizontally in one continuous canvas. Use penpot mcp. Work in a single Penpot page. Layout everything horizontally in one continuous canvas. Use penpot mcp.
## EFFICIENCY RULES (critical — minimize MCP calls) ## EFFICIENCY RULES (critical — minimize MCP calls)
- Create ALL color variables in one batch operation - Create ALL color variables in one batch operation
- Create ALL text styles in one batch operation - Create ALL text styles in one batch operation
- Create components in logical groups, not one-by-one - Create components in logical groups, not one-by-one
@@ -18,6 +20,7 @@ Work in a single Penpot page. Layout everything horizontally in one continuous c
## PRIORITY ORDER — stop if rate-limited, complete in order: ## PRIORITY ORDER — stop if rate-limited, complete in order:
### PHASE 1 — Design tokens (do first, everything depends on this) ### PHASE 1 — Design tokens (do first, everything depends on this)
Color variables: Color variables:
bg-primary #0D0D0F, bg-surface #1A1A1F, bg-elevated #242429, bg-primary #0D0D0F, bg-surface #1A1A1F, bg-elevated #242429,
accent #FF4D6D, accent-soft #FF4D6D1A, gold #F5A623, accent #FF4D6D, accent-soft #FF4D6D1A, gold #F5A623,
@@ -25,6 +28,7 @@ text-primary #F5F5F7, text-secondary #8E8E9A, text-muted #4A4A55,
success #30D158, error #FF453A, border #2C2C35 success #30D158, error #FF453A, border #2C2C35
Text styles: Text styles:
- Display: Playfair Display Italic 40px (editorial moments only) - Display: Playfair Display Italic 40px (editorial moments only)
- Title: DM Sans SemiBold 22px tracking-tight - Title: DM Sans SemiBold 22px tracking-tight
- Body: DM Sans Regular 15px - Body: DM Sans Regular 15px
@@ -32,6 +36,7 @@ Text styles:
- Button: DM Sans Medium 15px - Button: DM Sans Medium 15px
### PHASE 2 — Core components (minimum viable set, batch-create) ### PHASE 2 — Core components (minimum viable set, batch-create)
Build these 6 first — screens depend on them: Build these 6 first — screens depend on them:
1. Button/Primary — 56px height, radius 16, bg=accent, DM Sans Medium 15px white. Variants: Large/Medium 1. Button/Primary — 56px height, radius 16, bg=accent, DM Sans Medium 15px white. Variants: Large/Medium
@@ -42,6 +47,7 @@ Build these 6 first — screens depend on them:
6. Avatar — Circle crop. Variants: XL 80px / L 56px / M 40px, with Online green dot state 6. Avatar — Circle crop. Variants: XL 80px / L 56px / M 40px, with Online green dot state
### PHASE 3 — Secondary components (if quota allows) ### PHASE 3 — Secondary components (if quota allows)
7. MatchChip — 72px height, Avatar M + name + last message + timestamp + unread badge 7. MatchChip — 72px height, Avatar M + name + last message + timestamp + unread badge
8. MessageBubble — Me: right, bg=accent. Them: left, bg=bg-elevated. Variants: Text/Voice 8. MessageBubble — Me: right, bg=accent. Them: left, bg=bg-elevated. Variants: Text/Voice
9. TagPill — h=32px, radius=full. Variants: Default (bg-elevated) / Selected (accent-soft + accent border) 9. TagPill — h=32px, radius=full. Variants: Default (bg-elevated) / Selected (accent-soft + accent border)
@@ -50,6 +56,7 @@ Build these 6 first — screens depend on them:
12. DateCard — bg-elevated, radius 16, calendar icon circle, partner name+date+location, status pill 12. DateCard — bg-elevated, radius 16, calendar icon circle, partner name+date+location, status pill
### PHASE 4 — Screens (one frame per screen, 390×844px, horizontal row, 40px gap) ### PHASE 4 — Screens (one frame per screen, 390×844px, horizontal row, 40px gap)
Label each frame. Use components from Phase 23. Label each frame. Use components from Phase 23.
**Auth group (screens A1A6):** **Auth group (screens A1A6):**
@@ -101,12 +108,14 @@ C5 · Report (bottom sheet) — Handle, "Report" Title, "Reporting Sofia, 24" ca
## Design principles (enforced throughout) ## Design principles (enforced throughout)
**Visual language — dark luxury:** **Visual language — dark luxury:**
- Shadows always tinted to bg hue, never pure black drop shadows - Shadows always tinted to bg hue, never pure black drop shadows
- No outer glows — inner borders (1px rgba(255,255,255,0.08)) on elevated surfaces - No outer glows — inner borders (1px rgba(255,255,255,0.08)) on elevated surfaces
- Cards only where elevation communicates hierarchy - Cards only where elevation communicates hierarchy
- Spacing > cards where possible - Spacing > cards where possible
**Anti-slop rules:** **Anti-slop rules:**
- No pure #000000 anywhere - No pure #000000 anywhere
- No Inter font - No Inter font
- No centered hero on non-splash screens - No centered hero on non-splash screens

View File

@@ -7,7 +7,11 @@ edition = "2021"
[lib] [lib]
name = "dating_app_lib" name = "dating_app_lib"
crate-type = ["staticlib", "cdylib", "rlib"] crate-type = [
"staticlib",
"cdylib",
"rlib"
]
[build-dependencies] [build-dependencies]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }

View File

@@ -1,33 +1,3 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue';
import AppShell from '@/components/layout/AppShell.vue';
import AppToast from '@/components/common/AppToast.vue';
import { useUi } from '@/composables/useUi';
import { apiClient } from '@/api/client';
import type { Tag, City, Greeting } from '@/composables/useUi';
const uiStore = useUi();
async function loadReferences() {
if (uiStore.referencesLoaded) return;
try {
const [tags, cities, greetings] = await Promise.all([
apiClient.api.tagsControllerFindAll() as unknown as Tag[],
apiClient.api.citiesControllerFindAll() as unknown as City[],
apiClient.api.greetingsControllerFindAll() as unknown as Greeting[],
]);
uiStore.setTags(tags);
uiStore.setCities(cities);
uiStore.setGreetings(greetings);
uiStore.setReferencesLoaded();
} catch {
// Reference data loading is best-effort
}
}
onMounted(loadReferences);
</script>
<template> <template>
<AppShell> <AppShell>
<template #default> <template #default>
@@ -44,6 +14,38 @@ onMounted(loadReferences);
<AppToast /> <AppToast />
</template> </template>
<script setup lang="ts">
import type { City, Greeting, Tag } from '@/composables/useUi'
import { onMounted } from 'vue'
import { apiClient } from '@/api/client'
import AppToast from '@/components/common/AppToast.vue'
import AppShell from '@/components/layout/AppShell.vue'
import { useUi } from '@/composables/useUi'
const uiStore = useUi()
async function loadReferences() {
if (uiStore.referencesLoaded)
return
try {
const [tags, cities, greetings] = await Promise.all([
apiClient.api.tagsControllerFindAll() as unknown as Tag[],
apiClient.api.citiesControllerFindAll() as unknown as City[],
apiClient.api.greetingsControllerFindAll() as unknown as Greeting[],
])
uiStore.setTags(tags)
uiStore.setCities(cities)
uiStore.setGreetings(greetings)
uiStore.setReferencesLoaded()
}
catch {
// Reference data loading is best-effort
}
}
onMounted(loadReferences)
</script>
<style> <style>
.fade-enter-active, .fade-enter-active,
.fade-leave-active { .fade-leave-active {
@@ -58,6 +60,12 @@ onMounted(loadReferences);
.slide-right-leave-active { .slide-right-leave-active {
transition: all var(--transition-base); transition: all var(--transition-base);
} }
.slide-right-enter-from { opacity: 0; transform: translateX(20px); } .slide-right-enter-from {
.slide-right-leave-to { opacity: 0; transform: translateX(-20px); } opacity: 0;
transform: translateX(20px);
}
.slide-right-leave-to {
opacity: 0;
transform: translateX(-20px);
}
</style> </style>

View File

@@ -1,111 +1,114 @@
import axios from 'axios'; import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios'
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios'; import axios from 'axios'
import { Api, HttpClient } from './api'; import { Api, HttpClient } from './api'
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:1337'; export const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:1337'
// ─── Raw axios instance with interceptors ──────────────────────────────────── // ─── Raw axios instance with interceptors ────────────────────────────────────
export const axiosInstance: AxiosInstance = axios.create({ export const axiosInstance: AxiosInstance = axios.create({
baseURL: BASE_URL, baseURL: BASE_URL,
timeout: 15_000, timeout: 15_000,
}); })
// Request interceptor — inject access token // Request interceptor — inject access token
axiosInstance.interceptors.request.use( axiosInstance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => { (config: InternalAxiosRequestConfig) => {
const token = _getAccessToken(); const token = _getAccessToken()
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`
} }
return config; return config
}, },
(error) => Promise.reject(error), error => Promise.reject(error),
); )
// Response interceptor — silent token refresh on 401 // Response interceptor — silent token refresh on 401
let _isRefreshing = false; let _isRefreshing = false
let _failedQueue: Array<{ resolve: (v: unknown) => void; reject: (r: unknown) => void }> = []; let _failedQueue: Array<{ resolve: (v: unknown) => void, reject: (r: unknown) => void }> = []
function _processQueue(error: unknown, token: string | null) { function _processQueue(error: unknown, token: string | null) {
_failedQueue.forEach(({ resolve, reject }) => { _failedQueue.forEach(({ resolve, reject }) => {
if (error) reject(error); if (error)
else resolve(token); reject(error)
}); else resolve(token)
_failedQueue = []; })
_failedQueue = []
} }
axiosInstance.interceptors.response.use( axiosInstance.interceptors.response.use(
(response) => { (response) => {
if (response.data !== null && typeof response.data === 'object' && 'data' in response.data) { if (response.data !== null && typeof response.data === 'object' && 'data' in response.data) {
response.data = response.data.data; response.data = response.data.data
} }
return response; return response
}, },
async (error) => { async (error) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
if (error.response?.status === 401 && !originalRequest._retry) { if (error.response?.status === 401 && !originalRequest._retry) {
if (_isRefreshing) { if (_isRefreshing) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
_failedQueue.push({ resolve, reject }); _failedQueue.push({ resolve, reject })
}).then((token) => { }).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`; originalRequest.headers.Authorization = `Bearer ${token}`
return axiosInstance(originalRequest); return axiosInstance(originalRequest)
}); })
} }
originalRequest._retry = true; originalRequest._retry = true
_isRefreshing = true; _isRefreshing = true
const refreshToken = localStorage.getItem('refreshToken'); const refreshToken = localStorage.getItem('refreshToken')
if (!refreshToken) { if (!refreshToken) {
_processQueue(error, null); _processQueue(error, null)
_isRefreshing = false; _isRefreshing = false
_redirectToLogin(); _redirectToLogin()
return Promise.reject(error); return Promise.reject(error)
} }
try { try {
const res = await axios.post<{ data: { accessToken: string; refreshToken: string } }>( const res = await axios.post<{ data: { accessToken: string, refreshToken: string } }>(
`${BASE_URL}/api/v1/auth/refresh`, `${BASE_URL}/api/v1/auth/refresh`,
{ refreshToken }, { refreshToken },
); )
const { accessToken, refreshToken: newRefresh } = res.data.data; const { accessToken, refreshToken: newRefresh } = res.data.data
_setAccessToken(accessToken); _setAccessToken(accessToken)
localStorage.setItem('refreshToken', newRefresh); localStorage.setItem('refreshToken', newRefresh)
_processQueue(null, accessToken); _processQueue(null, accessToken)
originalRequest.headers.Authorization = `Bearer ${accessToken}`; originalRequest.headers.Authorization = `Bearer ${accessToken}`
return axiosInstance(originalRequest); return axiosInstance(originalRequest)
} catch (refreshError) { }
_processQueue(refreshError, null); catch (refreshError) {
_clearAuth(); _processQueue(refreshError, null)
_redirectToLogin(); _clearAuth()
return Promise.reject(refreshError); _redirectToLogin()
} finally { return Promise.reject(refreshError)
_isRefreshing = false; }
finally {
_isRefreshing = false
} }
} }
return Promise.reject(error); return Promise.reject(error)
}, },
); )
// ─── In-memory token storage ───────────────────────────────────────────────── // ─── In-memory token storage ─────────────────────────────────────────────────
// Access token lives only in memory; refresh token lives in localStorage // Access token lives only in memory; refresh token lives in localStorage
let _accessToken: string | null = null; let _accessToken: string | null = null
export function _getAccessToken() { return _accessToken; } export function _getAccessToken() { return _accessToken }
export function _setAccessToken(token: string) { _accessToken = token; } export function _setAccessToken(token: string) { _accessToken = token }
export function _clearAuth() { export function _clearAuth() {
_accessToken = null; _accessToken = null
localStorage.removeItem('refreshToken'); localStorage.removeItem('refreshToken')
} }
function _redirectToLogin() { function _redirectToLogin() {
// Dynamic import to avoid circular dependency with router // Dynamic import to avoid circular dependency with router
import('@/router').then(({ router }) => router.replace('/login')); import('@/router').then(({ router }) => router.replace('/login'))
} }
// ─── Typed API client ───────────────────────────────────────────────────────── // ─── Typed API client ─────────────────────────────────────────────────────────
@@ -113,12 +116,12 @@ function _redirectToLogin() {
const httpClient = new HttpClient({ const httpClient = new HttpClient({
baseURL: BASE_URL, baseURL: BASE_URL,
securityWorker: () => { securityWorker: () => {
const token = _getAccessToken(); const token = _getAccessToken()
return token ? { headers: { Authorization: `Bearer ${token}` } } : {}; return token ? { headers: { Authorization: `Bearer ${token}` } } : {}
}, },
}); })
// Plug our axios instance into the generated client // Plug our axios instance into the generated client
httpClient.instance = axiosInstance; httpClient.instance = axiosInstance
export const apiClient = new Api(httpClient); export const apiClient = new Api(httpClient)

View File

@@ -1,19 +1,3 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { ChatMessage } from '@/composables/useChat';
import MediaMessage from './MediaMessage.vue';
const props = defineProps<{
message: ChatMessage;
isMine: boolean;
}>();
const time = computed(() => {
const d = new Date(props.message.createdAt);
return d.toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' });
});
</script>
<template> <template>
<div class="bubble-wrap" :class="{ 'bubble-wrap--mine': isMine }"> <div class="bubble-wrap" :class="{ 'bubble-wrap--mine': isMine }">
<div class="bubble" :class="{ 'bubble--mine': isMine, 'bubble--partner': !isMine }"> <div class="bubble" :class="{ 'bubble--mine': isMine, 'bubble--partner': !isMine }">
@@ -22,12 +6,30 @@ const time = computed(() => {
:url="message.mediaUrl" :url="message.mediaUrl"
:type="message.mediaType ?? 'photo'" :type="message.mediaType ?? 'photo'"
/> />
<p v-if="message.text" class="bubble__text">{{ message.text }}</p> <p v-if="message.text" class="bubble__text">
{{ message.text }}
</p>
<span class="bubble__time">{{ time }}</span> <span class="bubble__time">{{ time }}</span>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts">
import type { ChatMessage } from '@/composables/useChat'
import { computed } from 'vue'
import MediaMessage from './MediaMessage.vue'
const props = defineProps<{
message: ChatMessage
isMine: boolean
}>()
const time = computed(() => {
const d = new Date(props.message.createdAt)
return d.toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' })
})
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.bubble-wrap { .bubble-wrap {
display: flex; display: flex;
@@ -66,7 +68,9 @@ const time = computed(() => {
word-break: break-word; word-break: break-word;
} }
&--mine &__text { color: var(--color-base); } &--mine &__text {
color: var(--color-base);
}
&__time { &__time {
display: block; display: block;

View File

@@ -1,70 +1,6 @@
<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> <template>
<div class="chat-input"> <div class="chat-input">
<button class="chat-input__attach" @click="openFilePicker" aria-label="Прикрепить файл"> <button class="chat-input__attach" aria-label="Прикрепить файл" @click="openFilePicker">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="20" height="20"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="20" height="20">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" /> <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
</svg> </svg>
@@ -74,18 +10,18 @@ function onFileChange(e: Event) {
ref="textareaEl" ref="textareaEl"
class="chat-input__textarea" class="chat-input__textarea"
:value="text" :value="text"
@input="handleInput"
@keydown="handleKeydown"
placeholder="Написать сообщение..." placeholder="Написать сообщение..."
rows="1" rows="1"
aria-label="Текст сообщения" aria-label="Текст сообщения"
@input="handleInput"
@keydown="handleKeydown"
/> />
<button <button
class="chat-input__send" class="chat-input__send"
:disabled="!text.trim()" :disabled="!text.trim()"
@click="send"
aria-label="Отправить" aria-label="Отправить"
@click="send"
> >
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="20" height="20"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="20" height="20">
<path d="M22 2L11 13M22 2L15 22l-4-9-9-4 20-7z" /> <path d="M22 2L11 13M22 2L15 22l-4-9-9-4 20-7z" />
@@ -98,10 +34,78 @@ function onFileChange(e: Event) {
accept="image/*,video/*" accept="image/*,video/*"
class="sr-only" class="sr-only"
@change="onFileChange" @change="onFileChange"
/> >
</div> </div>
</template> </template>
<script setup lang="ts">
import { ref } from 'vue'
const emit = defineEmits<{
send: [text: string, mediaUrl?: string, mediaType?: 'photo' | 'voice' | 'video']
}>()
const text = ref('')
const fileInput = ref<HTMLInputElement | null>(null)
const textareaEl = ref<HTMLTextAreaElement | null>(null)
const isTauri = typeof window !== 'undefined' && !!window.__TAURI__
function handleInput(e: Event) {
const el = e.target as HTMLTextAreaElement
text.value = el.value
// Auto-grow
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 140)}px`
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
}
function send() {
const val = text.value.trim()
if (!val)
return
emit('send', val)
text.value = ''
if (textareaEl.value) {
textareaEl.value.style.height = 'auto'
}
}
async function openFilePicker() {
if (isTauri) {
try {
const { open } = await import('@tauri-apps/plugin-dialog')
const result = await open({ filters: [{ name: 'Медиа', extensions: ['jpg', 'jpeg', 'png', 'mp4', 'mov'] }] })
if (typeof result === 'string') {
// Tauri returns file path; treat as photo for now
emit('send', '', result, 'photo')
}
}
catch { /* user cancelled */ }
}
else {
fileInput.value?.click()
}
}
function onFileChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file)
return
const url = URL.createObjectURL(file)
const type = file.type.startsWith('video/') ? 'video' : 'photo'
emit('send', '', url, type)
if (fileInput.value)
fileInput.value.value = ''
}
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.chat-input { .chat-input {
display: flex; display: flex;
@@ -130,14 +134,18 @@ function onFileChange(e: Event) {
background: none; background: none;
color: var(--color-muted); color: var(--color-muted);
&:hover { color: var(--color-cream); } &:hover {
color: var(--color-cream);
}
} }
&__send { &__send {
background: var(--color-signal); background: var(--color-signal);
color: white; color: white;
&:hover:not(:disabled) { background: #a84e30; } &:hover:not(:disabled) {
background: #a84e30;
}
&:disabled { &:disabled {
background: var(--color-dim); background: var(--color-dim);
color: var(--color-muted); color: var(--color-muted);
@@ -161,8 +169,12 @@ function onFileChange(e: Event) {
outline: none; outline: none;
transition: border-color var(--transition-fast); transition: border-color var(--transition-fast);
&::placeholder { color: var(--color-muted); } &::placeholder {
&:focus { border-color: var(--color-border-strong); } color: var(--color-muted);
}
&:focus {
border-color: var(--color-border-strong);
}
} }
} }
</style> </style>

View File

@@ -1,27 +1,3 @@
<script setup lang="ts">
import { ref } from 'vue';
const props = defineProps<{
url: string;
type: 'photo' | 'voice' | 'video';
}>();
const lightboxOpen = ref(false);
const audioEl = ref<HTMLAudioElement | null>(null);
const playing = ref(false);
function toggleAudio() {
if (!audioEl.value) return;
if (playing.value) {
audioEl.value.pause();
playing.value = false;
} else {
audioEl.value.play();
playing.value = true;
}
}
</script>
<template> <template>
<!-- Photo --> <!-- Photo -->
<div v-if="type === 'photo'" class="media media--photo"> <div v-if="type === 'photo'" class="media media--photo">
@@ -31,12 +7,12 @@ function toggleAudio() {
alt="Фото" alt="Фото"
loading="lazy" loading="lazy"
@click="lightboxOpen = true" @click="lightboxOpen = true"
/> >
<!-- Lightbox --> <!-- Lightbox -->
<Teleport to="body"> <Teleport to="body">
<Transition name="fade"> <Transition name="fade">
<div v-if="lightboxOpen" class="lightbox" @click="lightboxOpen = false"> <div v-if="lightboxOpen" class="lightbox" @click="lightboxOpen = false">
<img :src="url" class="lightbox__img" alt="Фото" /> <img :src="url" class="lightbox__img" alt="Фото">
</div> </div>
</Transition> </Transition>
</Teleport> </Teleport>
@@ -44,7 +20,7 @@ function toggleAudio() {
<!-- Voice --> <!-- Voice -->
<div v-else-if="type === 'voice'" class="media media--voice"> <div v-else-if="type === 'voice'" class="media media--voice">
<button class="media__play-btn" @click="toggleAudio" :aria-label="playing ? 'Пауза' : 'Воспроизвести'"> <button class="media__play-btn" :aria-label="playing ? 'Пауза' : 'Воспроизвести'" @click="toggleAudio">
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"> <svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
<path v-if="!playing" d="M5 3l14 9-14 9V3z" /> <path v-if="!playing" d="M5 3l14 9-14 9V3z" />
<path v-else d="M6 4h4v16H6zm8 0h4v16h-4z" /> <path v-else d="M6 4h4v16H6zm8 0h4v16h-4z" />
@@ -67,9 +43,37 @@ function toggleAudio() {
</div> </div>
</template> </template>
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{
url: string
type: 'photo' | 'voice' | 'video'
}>()
const lightboxOpen = ref(false)
const audioEl = ref<HTMLAudioElement | null>(null)
const playing = ref(false)
function toggleAudio() {
if (!audioEl.value)
return
if (playing.value) {
audioEl.value.pause()
playing.value = false
}
else {
audioEl.value.play()
playing.value = true
}
}
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.media { .media {
&--photo { cursor: pointer; } &--photo {
cursor: pointer;
}
&__img { &__img {
max-width: 220px; max-width: 220px;
@@ -79,7 +83,9 @@ function toggleAudio() {
display: block; display: block;
transition: opacity var(--transition-fast); transition: opacity var(--transition-fast);
&:hover { opacity: 0.9; } &:hover {
opacity: 0.9;
}
} }
&--voice { &--voice {
@@ -104,7 +110,9 @@ function toggleAudio() {
flex-shrink: 0; flex-shrink: 0;
transition: transform var(--transition-fast); transition: transform var(--transition-fast);
&:hover { transform: scale(1.08); } &:hover {
transform: scale(1.08);
}
} }
&__waveform { &__waveform {
@@ -147,6 +155,12 @@ function toggleAudio() {
} }
} }
.fade-enter-active, .fade-leave-active { transition: opacity var(--transition-base); } .fade-enter-active,
.fade-enter-from, .fade-leave-to { opacity: 0; } .fade-leave-active {
transition: opacity var(--transition-base);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style> </style>

View File

@@ -1,77 +1,20 @@
<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> <template>
<div class="voice-recorder"> <div class="voice-recorder">
<div v-if="recording" class="voice-recorder__active"> <div v-if="recording" class="voice-recorder__active">
<span class="voice-recorder__dot" aria-hidden="true" /> <span class="voice-recorder__dot" aria-hidden="true" />
<span class="voice-recorder__time meta">{{ formatTime(duration) }}</span> <span class="voice-recorder__time meta">{{ formatTime(duration) }}</span>
<button class="voice-recorder__cancel" @click="cancel" aria-label="Отменить запись"> <button class="voice-recorder__cancel" aria-label="Отменить запись" @click="cancel">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16">
<path d="M18 6L6 18M6 6l12 12" /> <path d="M18 6L6 18M6 6l12 12" />
</svg> </svg>
</button> </button>
<button class="voice-recorder__stop" @click="stop" aria-label="Остановить запись"> <button class="voice-recorder__stop" aria-label="Остановить запись" @click="stop">
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"> <svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
<rect x="3" y="3" width="18" height="18" rx="2" /> <rect x="3" y="3" width="18" height="18" rx="2" />
</svg> </svg>
</button> </button>
</div> </div>
<button v-else class="voice-recorder__btn" @click="start" aria-label="Записать голосовое сообщение"> <button v-else class="voice-recorder__btn" aria-label="Записать голосовое сообщение" @click="start">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="20" height="20"> <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="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v4M8 23h8" /> <path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v4M8 23h8" />
@@ -80,6 +23,64 @@ function formatTime(s: number) {
</div> </div>
</template> </template>
<script setup lang="ts">
import { ref } from 'vue'
const emit = defineEmits<{ recorded: [blob: Blob] }>()
const recording = ref(false)
const mediaRecorder = ref<MediaRecorder | null>(null)
const chunks = ref<Blob[]>([])
const duration = ref(0)
let timer: ReturnType<typeof setInterval> | null = null
async function start() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
mediaRecorder.value = new MediaRecorder(stream)
chunks.value = []
mediaRecorder.value.ondataavailable = e => chunks.value.push(e.data)
mediaRecorder.value.onstop = () => {
const blob = new Blob(chunks.value, { type: 'audio/webm' })
emit('recorded', blob)
stream.getTracks().forEach(t => t.stop())
}
mediaRecorder.value.start()
recording.value = true
duration.value = 0
timer = setInterval(() => duration.value++, 1000)
}
catch {
// Microphone access denied
}
}
function stop() {
mediaRecorder.value?.stop()
recording.value = false
if (timer) { clearInterval(timer); timer = null }
}
function cancel() {
if (mediaRecorder.value?.state === 'recording') {
mediaRecorder.value.ondataavailable = null
mediaRecorder.value.onstop = null
mediaRecorder.value.stop()
}
recording.value = false
duration.value = 0
if (timer) { clearInterval(timer); timer = null }
}
function formatTime(s: number) {
const m = Math.floor(s / 60)
const sec = s % 60
return `${m}:${sec.toString().padStart(2, '0')}`
}
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.voice-recorder { .voice-recorder {
&__btn { &__btn {
@@ -95,7 +96,9 @@ function formatTime(s: number) {
border-radius: 50%; border-radius: 50%;
transition: color var(--transition-fast); transition: color var(--transition-fast);
&:hover { color: var(--color-cream); } &:hover {
color: var(--color-cream);
}
} }
&__active { &__active {
@@ -132,13 +135,17 @@ function formatTime(s: number) {
&__cancel { &__cancel {
background: var(--color-surface-2); background: var(--color-surface-2);
color: var(--color-muted); color: var(--color-muted);
&:hover { color: var(--color-cream); } &:hover {
color: var(--color-cream);
}
} }
&__stop { &__stop {
background: var(--color-signal); background: var(--color-signal);
color: white; color: white;
&:hover { background: #a84e30; } &:hover {
background: #a84e30;
}
} }
} }
</style> </style>

View File

@@ -1,14 +1,3 @@
<script setup lang="ts">
defineProps<{
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
disabled?: boolean;
fullWidth?: boolean;
type?: 'button' | 'submit' | 'reset';
}>();
</script>
<template> <template>
<button <button
:type="type ?? 'button'" :type="type ?? 'button'"
@@ -27,6 +16,17 @@ defineProps<{
</button> </button>
</template> </template>
<script setup lang="ts">
defineProps<{
variant?: 'primary' | 'secondary' | 'ghost' | 'danger'
size?: 'sm' | 'md' | 'lg'
loading?: boolean
disabled?: boolean
fullWidth?: boolean
type?: 'button' | 'submit' | 'reset'
}>()
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.btn { .btn {
display: inline-flex; display: inline-flex;
@@ -58,19 +58,35 @@ defineProps<{
cursor: not-allowed; cursor: not-allowed;
} }
&--full { width: 100%; } &--full {
width: 100%;
}
// Sizes // Sizes
&--sm { font-size: 0.6875rem; padding: 6px 12px; height: 32px; } &--sm {
&--md { font-size: 0.75rem; padding: 10px 20px; height: 40px; } font-size: 0.6875rem;
&--lg { font-size: 0.8125rem; padding: 14px 28px; height: 48px; } 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 // Variants
&--primary { &--primary {
background: var(--color-cream); background: var(--color-cream);
color: var(--color-base); color: var(--color-base);
&:hover:not(:disabled) { background: #e8e0d0; } &:hover:not(:disabled) {
background: #e8e0d0;
}
} }
&--secondary { &--secondary {
@@ -98,7 +114,9 @@ defineProps<{
background: var(--color-signal); background: var(--color-signal);
color: var(--color-cream); color: var(--color-cream);
&:hover:not(:disabled) { background: #a84e30; } &:hover:not(:disabled) {
background: #a84e30;
}
} }
// Spinner // Spinner
@@ -118,6 +136,8 @@ defineProps<{
} }
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to {
transform: rotate(360deg);
}
} }
</style> </style>

View File

@@ -1,40 +1,13 @@
<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> <template>
<Teleport to="body"> <Teleport to="body">
<Transition :name="`drawer-${side}`"> <Transition :name="`drawer-${side}`">
<div v-if="open" class="drawer-backdrop" @click.self="emit('close')" role="dialog" aria-modal="true"> <div v-if="open" class="drawer-backdrop" role="dialog" aria-modal="true" @click.self="emit('close')">
<div class="drawer" :class="`drawer--${side}`"> <div class="drawer" :class="`drawer--${side}`">
<div class="drawer__header"> <div class="drawer__header">
<h3 v-if="title" class="drawer__title">{{ title }}</h3> <h3 v-if="title" class="drawer__title">
<button class="drawer__close" @click="emit('close')" aria-label="Закрыть"> {{ title }}
</h3>
<button class="drawer__close" aria-label="Закрыть" @click="emit('close')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="18" height="18"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="18" height="18">
<path d="M18 6L6 18M6 6l12 12" /> <path d="M18 6L6 18M6 6l12 12" />
</svg> </svg>
@@ -49,6 +22,37 @@ const side = props.side ?? 'right';
</Teleport> </Teleport>
</template> </template>
<script setup lang="ts">
import { onUnmounted, watch } from 'vue'
const props = defineProps<{
open: boolean
title?: string
side?: 'right' | 'bottom'
}>()
const emit = defineEmits<{ close: [] }>()
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape')
emit('close')
}
watch(() => props.open, (val) => {
document.body.style.overflow = val ? 'hidden' : ''
if (val)
document.addEventListener('keydown', handleKeydown)
else document.removeEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
document.body.style.overflow = ''
})
const side = props.side ?? 'right'
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.drawer-backdrop { .drawer-backdrop {
position: fixed; position: fixed;
@@ -108,7 +112,9 @@ const side = props.side ?? 'right';
border-radius: var(--radius-xs); border-radius: var(--radius-xs);
display: flex; display: flex;
transition: color var(--transition-fast); transition: color var(--transition-fast);
&:hover { color: var(--color-cream); } &:hover {
color: var(--color-cream);
}
} }
&__body { &__body {
@@ -119,22 +125,34 @@ const side = props.side ?? 'right';
} }
// Right drawer // Right drawer
.drawer-right-enter-active, .drawer-right-leave-active { .drawer-right-enter-active,
.drawer-right-leave-active {
transition: opacity var(--transition-base); transition: opacity var(--transition-base);
.drawer--right { transition: transform var(--transition-base); } .drawer--right {
transition: transform var(--transition-base);
} }
.drawer-right-enter-from, .drawer-right-leave-to { }
.drawer-right-enter-from,
.drawer-right-leave-to {
opacity: 0; opacity: 0;
.drawer--right { transform: translateX(100%); } .drawer--right {
transform: translateX(100%);
}
} }
// Bottom drawer // Bottom drawer
.drawer-bottom-enter-active, .drawer-bottom-leave-active { .drawer-bottom-enter-active,
.drawer-bottom-leave-active {
transition: opacity var(--transition-base); transition: opacity var(--transition-base);
.drawer--bottom { transition: transform var(--transition-base); } .drawer--bottom {
transition: transform var(--transition-base);
} }
.drawer-bottom-enter-from, .drawer-bottom-leave-to { }
.drawer-bottom-enter-from,
.drawer-bottom-leave-to {
opacity: 0; opacity: 0;
.drawer--bottom { transform: translateY(100%); } .drawer--bottom {
transform: translateY(100%);
}
} }
</style> </style>

View File

@@ -1,33 +1,3 @@
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{
modelValue: string;
label?: string;
placeholder?: string;
type?: string;
error?: string | string[];
hint?: string;
disabled?: boolean;
required?: boolean;
name?: string;
autocomplete?: string;
}>();
const emit = defineEmits<{
'update:modelValue': [value: string];
blur: [event: FocusEvent];
}>();
const errorMessage = computed(() =>
Array.isArray(props.error) ? props.error[0] : props.error,
);
function onInput(e: Event) {
emit('update:modelValue', (e.target as HTMLInputElement).value);
}
</script>
<template> <template>
<div class="field" :class="{ 'field--error': !!errorMessage, 'field--disabled': disabled }"> <div class="field" :class="{ 'field--error': !!errorMessage, 'field--disabled': disabled }">
<label v-if="label" class="field__label"> <label v-if="label" class="field__label">
@@ -48,16 +18,48 @@ function onInput(e: Event) {
:aria-invalid="!!errorMessage" :aria-invalid="!!errorMessage"
@input="onInput" @input="onInput"
@blur="emit('blur', $event)" @blur="emit('blur', $event)"
/> >
<slot name="suffix" /> <slot name="suffix" />
</div> </div>
<p v-if="errorMessage" :id="`${name}-error`" class="field__error" role="alert"> <p v-if="errorMessage" :id="`${name}-error`" class="field__error" role="alert">
{{ errorMessage }} {{ errorMessage }}
</p> </p>
<p v-else-if="hint" :id="`${name}-hint`" class="field__hint">{{ hint }}</p> <p v-else-if="hint" :id="`${name}-hint`" class="field__hint">
{{ hint }}
</p>
</div> </div>
</template> </template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
modelValue: string
label?: string
placeholder?: string
type?: string
error?: string | string[]
hint?: string
disabled?: boolean
required?: boolean
name?: string
autocomplete?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
'blur': [event: FocusEvent]
}>()
const errorMessage = computed(() =>
Array.isArray(props.error) ? props.error[0] : props.error,
)
function onInput(e: Event) {
emit('update:modelValue', (e.target as HTMLInputElement).value)
}
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.field { .field {
display: flex; display: flex;

View File

@@ -1,38 +1,14 @@
<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> <template>
<Teleport to="body"> <Teleport to="body">
<Transition name="modal"> <Transition name="modal">
<div v-if="open" class="modal-backdrop" @click.self="emit('close')" role="dialog" aria-modal="true" :aria-label="title"> <div v-if="open" class="modal-backdrop" role="dialog" aria-modal="true" :aria-label="title" @click.self="emit('close')">
<div class="modal" :class="`modal--${size ?? 'md'}`"> <div class="modal" :class="`modal--${size ?? 'md'}`">
<div v-if="title || $slots.header" class="modal__header"> <div v-if="title || $slots.header" class="modal__header">
<h2 v-if="title" class="modal__title">{{ title }}</h2> <h2 v-if="title" class="modal__title">
{{ title }}
</h2>
<slot name="header" /> <slot name="header" />
<button class="modal__close" @click="emit('close')" aria-label="Закрыть"> <button class="modal__close" aria-label="Закрыть" @click="emit('close')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M18 6L6 18M6 6l12 12" /></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M18 6L6 18M6 6l12 12" /></svg>
</button> </button>
</div> </div>
@@ -48,6 +24,33 @@ onUnmounted(() => {
</Teleport> </Teleport>
</template> </template>
<script setup lang="ts">
import { onMounted, onUnmounted, watch } from 'vue'
const props = defineProps<{
open: boolean
title?: string
size?: 'sm' | 'md' | 'lg'
}>()
const emit = defineEmits<{ close: [] }>()
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape')
emit('close')
}
watch(() => props.open, (val) => {
document.body.style.overflow = val ? 'hidden' : ''
})
onMounted(() => document.addEventListener('keydown', handleKeydown))
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
document.body.style.overflow = ''
})
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.modal-backdrop { .modal-backdrop {
position: fixed; position: fixed;
@@ -71,9 +74,15 @@ onUnmounted(() => {
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
&--sm { max-width: 400px; } &--sm {
&--md { max-width: 560px; } max-width: 400px;
&--lg { max-width: 800px; } }
&--md {
max-width: 560px;
}
&--lg {
max-width: 800px;
}
&__header { &__header {
display: flex; display: flex;
@@ -105,9 +114,14 @@ onUnmounted(() => {
transition: color var(--transition-fast); transition: color var(--transition-fast);
flex-shrink: 0; flex-shrink: 0;
&:hover { color: var(--color-cream); } &:hover {
color: var(--color-cream);
}
svg { width: 18px; height: 18px; } svg {
width: 18px;
height: 18px;
}
} }
&__body { &__body {
@@ -126,12 +140,21 @@ onUnmounted(() => {
} }
} }
.modal-enter-active, .modal-leave-active { .modal-enter-active,
.modal-leave-active {
transition: opacity var(--transition-base); transition: opacity var(--transition-base);
.modal { transition: transform var(--transition-spring), opacity var(--transition-base); } .modal {
transition:
transform var(--transition-spring),
opacity var(--transition-base);
} }
.modal-enter-from, .modal-leave-to { }
.modal-enter-from,
.modal-leave-to {
opacity: 0; opacity: 0;
.modal { transform: scale(0.95) translateY(8px); opacity: 0; } .modal {
transform: scale(0.95) translateY(8px);
opacity: 0;
}
} }
</style> </style>

View File

@@ -1,8 +1,3 @@
<script setup lang="ts">
import { useUi } from '@/composables/useUi';
const uiStore = useUi();
</script>
<template> <template>
<Teleport to="body"> <Teleport to="body">
<div class="toast-container" role="region" aria-label="Уведомления" aria-live="polite"> <div class="toast-container" role="region" aria-label="Уведомления" aria-live="polite">
@@ -22,7 +17,7 @@ const uiStore = useUi();
</svg> </svg>
</span> </span>
<span class="toast__message">{{ toast.message }}</span> <span class="toast__message">{{ toast.message }}</span>
<button class="toast__close" @click="uiStore.removeToast(toast.id)" aria-label="Закрыть"> <button class="toast__close" aria-label="Закрыть" @click="uiStore.removeToast(toast.id)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" /></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" /></svg>
</button> </button>
</div> </div>
@@ -31,6 +26,12 @@ const uiStore = useUi();
</Teleport> </Teleport>
</template> </template>
<script setup lang="ts">
import { useUi } from '@/composables/useUi'
const uiStore = useUi()
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.toast-container { .toast-container {
position: fixed; position: fixed;
@@ -56,9 +57,15 @@ const uiStore = useUi();
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
pointer-events: all; pointer-events: all;
&--success { border-color: rgba(122, 184, 80, 0.3); } &--success {
&--error { border-color: rgba(196, 92, 58, 0.4); } border-color: rgba(122, 184, 80, 0.3);
&--warning { border-color: rgba(210, 151, 60, 0.3); } }
&--error {
border-color: rgba(196, 92, 58, 0.4);
}
&--warning {
border-color: rgba(210, 151, 60, 0.3);
}
&__icon { &__icon {
width: 18px; width: 18px;
@@ -66,13 +73,24 @@ const uiStore = useUi();
flex-shrink: 0; flex-shrink: 0;
margin-top: 1px; margin-top: 1px;
svg { width: 100%; height: 100%; } svg {
width: 100%;
height: 100%;
}
} }
&--success .toast__icon { color: #7ab850; } &--success .toast__icon {
&--error .toast__icon { color: var(--color-signal); } color: #7ab850;
&--warning .toast__icon { color: #d2973c; } }
&--info .toast__icon { color: var(--color-muted); } &--error .toast__icon {
color: var(--color-signal);
}
&--warning .toast__icon {
color: #d2973c;
}
&--info .toast__icon {
color: var(--color-muted);
}
&__message { &__message {
flex: 1; flex: 1;
@@ -97,9 +115,14 @@ const uiStore = useUi();
border-radius: var(--radius-xs); border-radius: var(--radius-xs);
transition: color var(--transition-fast); transition: color var(--transition-fast);
&:hover { color: var(--color-cream); } &:hover {
color: var(--color-cream);
}
svg { width: 14px; height: 14px; } svg {
width: 14px;
height: 14px;
}
} }
} }

View File

@@ -1,11 +1,3 @@
<script setup lang="ts">
defineProps<{
title: string;
description?: string;
icon?: 'feed' | 'chat' | 'heart' | 'calendar' | 'search' | 'default';
}>();
</script>
<template> <template>
<div class="empty"> <div class="empty">
<div class="empty__illustration" aria-hidden="true"> <div class="empty__illustration" aria-hidden="true">
@@ -46,12 +38,24 @@ defineProps<{
</template> </template>
</svg> </svg>
</div> </div>
<h3 class="empty__title">{{ title }}</h3> <h3 class="empty__title">
<p v-if="description" class="empty__desc">{{ description }}</p> {{ title }}
</h3>
<p v-if="description" class="empty__desc">
{{ description }}
</p>
<slot /> <slot />
</div> </div>
</template> </template>
<script setup lang="ts">
defineProps<{
title: string
description?: string
icon?: 'feed' | 'chat' | 'heart' | 'calendar' | 'search' | 'default'
}>()
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.empty { .empty {
display: flex; display: flex;

View File

@@ -1,7 +1,3 @@
<script setup lang="ts">
defineProps<{ size?: 'sm' | 'md' | 'lg'; label?: string }>();
</script>
<template> <template>
<div class="spinner" :class="`spinner--${size ?? 'md'}`" role="status"> <div class="spinner" :class="`spinner--${size ?? 'md'}`" role="status">
<svg viewBox="0 0 24 24" fill="none" class="spinner__ring"> <svg viewBox="0 0 24 24" fill="none" class="spinner__ring">
@@ -11,6 +7,10 @@ defineProps<{ size?: 'sm' | 'md' | 'lg'; label?: string }>();
</div> </div>
</template> </template>
<script setup lang="ts">
defineProps<{ size?: 'sm' | 'md' | 'lg', label?: string }>()
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.spinner { .spinner {
display: inline-flex; display: inline-flex;
@@ -20,12 +20,23 @@ defineProps<{ size?: 'sm' | 'md' | 'lg'; label?: string }>();
animation: spin 0.9s linear infinite; animation: spin 0.9s linear infinite;
} }
&--sm svg { width: 16px; height: 16px; } &--sm svg {
&--md svg { width: 24px; height: 24px; } width: 16px;
&--lg svg { width: 40px; height: 40px; } height: 16px;
}
&--md svg {
width: 24px;
height: 24px;
}
&--lg svg {
width: 40px;
height: 40px;
}
} }
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to {
transform: rotate(360deg);
}
} }
</style> </style>

View File

@@ -1,49 +1,3 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useAuth } from '@/composables/useAuth';
import { useUi } from '@/composables/useUi';
import { apiClient } from '@/api/client';
import MapPicker from './MapPicker.vue';
import AppButton from '@/components/common/AppButton.vue';
import AppInput from '@/components/common/AppInput.vue';
const props = defineProps<{ partnerProfileId: string }>();
const emit = defineEmits<{ close: []; created: [] }>();
const authStore = useAuth();
const uiStore = useUi();
const form = reactive({
time: '',
location: null as { lat: number; lng: number } | null,
});
const loading = ref(false);
async function submit() {
if (!authStore.activeProfile || !form.location || !form.time) {
uiStore.addToast('Выберите место и время встречи', 'warning');
return;
}
loading.value = true;
try {
await apiClient.api.datesControllerCreate({
profileId: authStore.activeProfile.id,
partnerProfileId: props.partnerProfileId,
lat: form.location.lat,
lng: form.location.lng,
time: form.time,
});
uiStore.addToast('Предложение встречи отправлено', 'success');
emit('created');
emit('close');
} catch {
uiStore.addToast('Не удалось отправить предложение', 'error');
} finally {
loading.value = false;
}
}
</script>
<template> <template>
<form class="date-form" @submit.prevent="submit"> <form class="date-form" @submit.prevent="submit">
<div class="date-form__section"> <div class="date-form__section">
@@ -54,7 +8,7 @@ async function submit() {
class="date-form__datetime" class="date-form__datetime"
required required
:min="new Date().toISOString().slice(0, 16)" :min="new Date().toISOString().slice(0, 16)"
/> >
</div> </div>
<div class="date-form__section"> <div class="date-form__section">
@@ -63,7 +17,9 @@ async function submit() {
</div> </div>
<div class="date-form__actions"> <div class="date-form__actions">
<AppButton type="button" variant="ghost" @click="emit('close')">Отмена</AppButton> <AppButton type="button" variant="ghost" @click="emit('close')">
Отмена
</AppButton>
<AppButton type="submit" :loading="loading" :disabled="!form.location || !form.time"> <AppButton type="submit" :loading="loading" :disabled="!form.location || !form.time">
Предложить встречу Предложить встречу
</AppButton> </AppButton>
@@ -71,6 +27,53 @@ async function submit() {
</form> </form>
</template> </template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { apiClient } from '@/api/client'
import AppButton from '@/components/common/AppButton.vue'
import { useAuth } from '@/composables/useAuth'
import { useUi } from '@/composables/useUi'
import MapPicker from './MapPicker.vue'
const props = defineProps<{ partnerProfileId: string }>()
const emit = defineEmits<{ close: [], created: [] }>()
const authStore = useAuth()
const uiStore = useUi()
const form = reactive({
time: '',
location: null as { lat: number, lng: number } | null,
})
const loading = ref(false)
async function submit() {
if (!authStore.activeProfile || !form.location || !form.time) {
uiStore.addToast('Выберите место и время встречи', 'warning')
return
}
loading.value = true
try {
await apiClient.api.datesControllerCreate({
profileId: authStore.activeProfile.id,
partnerProfileId: props.partnerProfileId,
lat: form.location.lat,
lng: form.location.lng,
time: form.time,
})
uiStore.addToast('Предложение встречи отправлено', 'success')
emit('created')
emit('close')
}
catch {
uiStore.addToast('Не удалось отправить предложение', 'error')
}
finally {
loading.value = false
}
}
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.date-form { .date-form {
display: flex; display: flex;
@@ -96,7 +99,9 @@ async function submit() {
transition: border-color var(--transition-fast); transition: border-color var(--transition-fast);
color-scheme: dark; color-scheme: dark;
&:focus { border-color: var(--color-signal); } &:focus {
border-color: var(--color-signal);
}
} }
&__actions { &__actions {

View File

@@ -1,77 +1,84 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
const props = defineProps<{
modelValue: { lat: number; lng: number } | null;
}>();
const emit = defineEmits<{ 'update:modelValue': [value: { lat: number; lng: number }] }>();
const mapEl = ref<HTMLElement | null>(null);
let map: import('leaflet').Map | null = null;
let marker: import('leaflet').Marker | null = null;
onMounted(async () => {
const L = await import('leaflet');
await import('leaflet/dist/leaflet.css');
// Fix Leaflet default icon paths
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
});
if (!mapEl.value) return;
const initialCenter: [number, number] = props.modelValue
? [props.modelValue.lat, props.modelValue.lng]
: [55.75, 37.62]; // Moscow default
map = L.map(mapEl.value).setView(initialCenter, 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap',
}).addTo(map);
if (props.modelValue) {
marker = L.marker([props.modelValue.lat, props.modelValue.lng]).addTo(map);
}
map.on('click', (e: import('leaflet').LeafletMouseEvent) => {
const { lat, lng } = e.latlng;
if (!map) return;
if (marker) marker.setLatLng([lat, lng]);
else marker = L.marker([lat, lng]).addTo(map!);
emit('update:modelValue', { lat, lng });
});
});
onUnmounted(() => {
map?.remove();
map = null;
});
watch(() => props.modelValue, async (val) => {
if (!val || !map) return;
const L = await import('leaflet');
map.setView([val.lat, val.lng], map.getZoom());
if (marker) marker.setLatLng([val.lat, val.lng]);
else marker = L.marker([val.lat, val.lng]).addTo(map);
});
</script>
<template> <template>
<div class="map-picker"> <div class="map-picker">
<div ref="mapEl" class="map-picker__map" /> <div ref="mapEl" class="map-picker__map" />
<p v-if="!modelValue" class="map-picker__hint meta">Нажмите на карту, чтобы выбрать место встречи</p> <p v-if="!modelValue" class="map-picker__hint meta">
Нажмите на карту, чтобы выбрать место встречи
</p>
<p v-else class="map-picker__coords meta"> <p v-else class="map-picker__coords meta">
{{ modelValue.lat.toFixed(5) }}, {{ modelValue.lng.toFixed(5) }} {{ modelValue.lat.toFixed(5) }}, {{ modelValue.lng.toFixed(5) }}
</p> </p>
</div> </div>
</template> </template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
const props = defineProps<{
modelValue: { lat: number, lng: number } | null
}>()
const emit = defineEmits<{ 'update:modelValue': [value: { lat: number, lng: number }] }>()
const mapEl = ref<HTMLElement | null>(null)
let map: import('leaflet').Map | null = null
let marker: import('leaflet').Marker | null = null
onMounted(async () => {
const L = await import('leaflet')
await import('leaflet/dist/leaflet.css')
// Fix Leaflet default icon paths
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
})
if (!mapEl.value)
return
const initialCenter: [number, number] = props.modelValue
? [props.modelValue.lat, props.modelValue.lng]
: [55.75, 37.62] // Moscow default
map = L.map(mapEl.value).setView(initialCenter, 13)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap',
}).addTo(map)
if (props.modelValue) {
marker = L.marker([props.modelValue.lat, props.modelValue.lng]).addTo(map)
}
map.on('click', (e: import('leaflet').LeafletMouseEvent) => {
const { lat, lng } = e.latlng
if (!map)
return
if (marker)
marker.setLatLng([lat, lng])
else marker = L.marker([lat, lng]).addTo(map!)
emit('update:modelValue', { lat, lng })
})
})
onUnmounted(() => {
map?.remove()
map = null
})
watch(() => props.modelValue, async (val) => {
if (!val || !map)
return
const L = await import('leaflet')
map.setView([val.lat, val.lng], map.getZoom())
if (marker)
marker.setLatLng([val.lat, val.lng])
else marker = L.marker([val.lat, val.lng]).addTo(map)
})
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.map-picker { .map-picker {
display: flex; display: flex;
@@ -90,7 +97,8 @@ watch(() => props.modelValue, async (val) => {
} }
} }
&__hint, &__coords { &__hint,
&__coords {
color: var(--color-muted); color: var(--color-muted);
margin: 0; margin: 0;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;

View File

@@ -1,138 +1,3 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { gsap } from 'gsap';
import type { FeedProfile } from '@/composables/useFeed';
import ProfileBadge from './ProfileBadge.vue';
const props = defineProps<{
profile: FeedProfile;
isTop: boolean;
}>();
const emit = defineEmits<{
like: [profileId: string];
dislike: [profileId: string];
}>();
const router = useRouter();
const cardEl = ref<HTMLElement | null>(null);
const currentImageIndex = ref(0);
const age = computed(() => {
const birth = new Date(props.profile.birthDate);
const today = new Date();
let a = today.getFullYear() - birth.getFullYear();
if (today.getMonth() < birth.getMonth() || (today.getMonth() === birth.getMonth() && today.getDate() < birth.getDate())) a--;
return a;
});
const coverUrl = computed(() =>
props.profile.media?.[currentImageIndex.value]?.path ?? '',
);
// ─── Drag / swipe mechanics ───────────────────────────────────────────────────
let startX = 0;
let startY = 0;
let isDragging = false;
const THROW_THRESHOLD = 80;
const prefersReducedMotion = typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function onDragStart(e: PointerEvent) {
if (!props.isTop) return;
isDragging = true;
startX = e.clientX;
startY = e.clientY;
cardEl.value?.setPointerCapture(e.pointerId);
}
function onDragMove(e: PointerEvent) {
if (!isDragging || !cardEl.value) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
const rotation = dx * 0.06;
const tintRight = Math.max(0, dx / THROW_THRESHOLD);
const tintLeft = Math.max(0, -dx / THROW_THRESHOLD);
if (!prefersReducedMotion) {
gsap.set(cardEl.value, {
x: dx,
y: dy * 0.4,
rotation,
'--tint-right': Math.min(tintRight, 1),
'--tint-left': Math.min(tintLeft, 1),
});
}
}
function onDragEnd(e: PointerEvent) {
if (!isDragging || !cardEl.value) return;
isDragging = false;
const dx = e.clientX - startX;
if (Math.abs(dx) > THROW_THRESHOLD) {
const direction = dx > 0 ? 1 : -1;
throwCard(direction);
} else {
// Snap back
if (!prefersReducedMotion) {
gsap.to(cardEl.value, { x: 0, y: 0, rotation: 0, '--tint-right': 0, '--tint-left': 0, duration: 0.3, ease: 'back.out(2)' });
}
}
}
function throwCard(direction: 1 | -1) {
if (!cardEl.value) return;
const target = direction === 1 ? props.profile.id : null;
if (!prefersReducedMotion) {
gsap.to(cardEl.value, {
x: direction * window.innerWidth * 1.5,
rotation: direction * 25,
opacity: 0,
duration: 0.4,
ease: 'power2.in',
onComplete: () => {
if (direction === 1) emit('like', props.profile.id);
else emit('dislike', props.profile.id);
},
});
} else {
if (direction === 1) emit('like', props.profile.id);
else emit('dislike', props.profile.id);
}
}
function handleLike() { throwCard(1); }
function handleDislike() { throwCard(-1); }
function openProfile() {
if (isDragging) return;
router.push(`/profile/${props.profile.id}`);
}
function nextImage() {
if (currentImageIndex.value < (props.profile.media?.length ?? 1) - 1) {
currentImageIndex.value++;
}
}
function prevImage(e: Event) {
e.stopPropagation();
if (currentImageIndex.value > 0) currentImageIndex.value--;
}
// Touch support
let touchStartX = 0;
function onTouchStart(e: TouchEvent) { touchStartX = e.touches[0].clientX; }
function onTouchEnd(e: TouchEvent) {
if (!props.isTop) return;
const dx = e.changedTouches[0].clientX - touchStartX;
if (Math.abs(dx) > THROW_THRESHOLD) throwCard(dx > 0 ? 1 : -1);
}
</script>
<template> <template>
<article <article
ref="cardEl" ref="cardEl"
@@ -153,7 +18,7 @@ function onTouchEnd(e: TouchEvent) {
:alt="`Фото ${profile.name}`" :alt="`Фото ${profile.name}`"
class="feed-card__img" class="feed-card__img"
draggable="false" draggable="false"
/> >
<div v-else class="feed-card__no-photo" aria-label="Нет фото"> <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"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" width="48" height="48" opacity="0.3">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z" /> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z" />
@@ -167,8 +32,8 @@ function onTouchEnd(e: TouchEvent) {
:key="i" :key="i"
class="feed-card__dot" class="feed-card__dot"
:class="{ 'feed-card__dot--active': i === currentImageIndex }" :class="{ 'feed-card__dot--active': i === currentImageIndex }"
@click.stop="currentImageIndex = i"
:aria-label="`Фото ${i + 1}`" :aria-label="`Фото ${i + 1}`"
@click.stop="currentImageIndex = i"
/> />
</div> </div>
@@ -190,7 +55,9 @@ function onTouchEnd(e: TouchEvent) {
<div class="feed-card__meta"> <div class="feed-card__meta">
<span v-if="profile.cityName" class="meta feed-card__location">{{ profile.cityName }}</span> <span v-if="profile.cityName" class="meta feed-card__location">{{ profile.cityName }}</span>
</div> </div>
<h2 class="feed-card__name">{{ profile.name }}<span class="feed-card__age">, {{ age }}</span></h2> <h2 class="feed-card__name">
{{ profile.name }}<span class="feed-card__age">, {{ age }}</span>
</h2>
<div v-if="profile.tags?.length" class="feed-card__tags"> <div v-if="profile.tags?.length" class="feed-card__tags">
<ProfileBadge v-for="tag in profile.tags?.slice(0, 4)" :key="tag.id" :label="tag.value" /> <ProfileBadge v-for="tag in profile.tags?.slice(0, 4)" :key="tag.id" :label="tag.value" />
</div> </div>
@@ -198,12 +65,12 @@ function onTouchEnd(e: TouchEvent) {
<!-- Action buttons (visible on non-drag mode) --> <!-- Action buttons (visible on non-drag mode) -->
<div v-if="isTop" class="feed-card__actions" @click.stop> <div v-if="isTop" class="feed-card__actions" @click.stop>
<button class="feed-card__btn feed-card__btn--dislike" @click="handleDislike" aria-label="Пропустить"> <button class="feed-card__btn feed-card__btn--dislike" aria-label="Пропустить" @click="handleDislike">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="24" height="24"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="24" height="24">
<path d="M18 6L6 18M6 6l12 12" /> <path d="M18 6L6 18M6 6l12 12" />
</svg> </svg>
</button> </button>
<button class="feed-card__btn feed-card__btn--like" @click="handleLike" aria-label="Лайк"> <button class="feed-card__btn feed-card__btn--like" aria-label="Лайк" @click="handleLike">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="24" height="24"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="24" height="24">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg> </svg>
@@ -212,6 +79,154 @@ function onTouchEnd(e: TouchEvent) {
</article> </article>
</template> </template>
<script setup lang="ts">
import type { FeedProfile } from '@/composables/useFeed'
import { gsap } from 'gsap'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import ProfileBadge from './ProfileBadge.vue'
const props = defineProps<{
profile: FeedProfile
isTop: boolean
}>()
const emit = defineEmits<{
like: [profileId: string]
dislike: [profileId: string]
}>()
const router = useRouter()
const cardEl = ref<HTMLElement | null>(null)
const currentImageIndex = ref(0)
const age = computed(() => {
const birth = new Date(props.profile.birthDate)
const today = new Date()
let a = today.getFullYear() - birth.getFullYear()
if (today.getMonth() < birth.getMonth() || (today.getMonth() === birth.getMonth() && today.getDate() < birth.getDate()))
a--
return a
})
const coverUrl = computed(() =>
props.profile.media?.[currentImageIndex.value]?.path ?? '',
)
// ─── Drag / swipe mechanics ───────────────────────────────────────────────────
let startX = 0
let startY = 0
let isDragging = false
const THROW_THRESHOLD = 80
const prefersReducedMotion = typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
function onDragStart(e: PointerEvent) {
if (!props.isTop)
return
isDragging = true
startX = e.clientX
startY = e.clientY
cardEl.value?.setPointerCapture(e.pointerId)
}
function onDragMove(e: PointerEvent) {
if (!isDragging || !cardEl.value)
return
const dx = e.clientX - startX
const dy = e.clientY - startY
const rotation = dx * 0.06
const tintRight = Math.max(0, dx / THROW_THRESHOLD)
const tintLeft = Math.max(0, -dx / THROW_THRESHOLD)
if (!prefersReducedMotion) {
gsap.set(cardEl.value, {
'x': dx,
'y': dy * 0.4,
rotation,
'--tint-right': Math.min(tintRight, 1),
'--tint-left': Math.min(tintLeft, 1),
})
}
}
function onDragEnd(e: PointerEvent) {
if (!isDragging || !cardEl.value)
return
isDragging = false
const dx = e.clientX - startX
if (Math.abs(dx) > THROW_THRESHOLD) {
const direction = dx > 0 ? 1 : -1
throwCard(direction)
}
else {
// Snap back
if (!prefersReducedMotion) {
gsap.to(cardEl.value, { 'x': 0, 'y': 0, 'rotation': 0, '--tint-right': 0, '--tint-left': 0, 'duration': 0.3, 'ease': 'back.out(2)' })
}
}
}
function throwCard(direction: 1 | -1) {
if (!cardEl.value)
return
const target = direction === 1 ? props.profile.id : null
if (!prefersReducedMotion) {
gsap.to(cardEl.value, {
x: direction * window.innerWidth * 1.5,
rotation: direction * 25,
opacity: 0,
duration: 0.4,
ease: 'power2.in',
onComplete: () => {
if (direction === 1)
emit('like', props.profile.id)
else emit('dislike', props.profile.id)
},
})
}
else {
if (direction === 1)
emit('like', props.profile.id)
else emit('dislike', props.profile.id)
}
}
function handleLike() { throwCard(1) }
function handleDislike() { throwCard(-1) }
function openProfile() {
if (isDragging)
return
router.push(`/profile/${props.profile.id}`)
}
function nextImage() {
if (currentImageIndex.value < (props.profile.media?.length ?? 1) - 1) {
currentImageIndex.value++
}
}
function prevImage(e: Event) {
e.stopPropagation()
if (currentImageIndex.value > 0)
currentImageIndex.value--
}
// Touch support
let touchStartX = 0
function onTouchStart(e: TouchEvent) { touchStartX = e.touches[0].clientX }
function onTouchEnd(e: TouchEvent) {
if (!props.isTop)
return
const dx = e.changedTouches[0].clientX - touchStartX
if (Math.abs(dx) > THROW_THRESHOLD)
throwCard(dx > 0 ? 1 : -1)
}
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.feed-card { .feed-card {
position: absolute; position: absolute;
@@ -280,7 +295,9 @@ function onTouchEnd(e: TouchEvent) {
padding: 0; padding: 0;
transition: background var(--transition-fast); transition: background var(--transition-fast);
&--active { background: rgba(255, 255, 255, 0.9); } &--active {
background: rgba(255, 255, 255, 0.9);
}
} }
// Drag tint overlays // Drag tint overlays
@@ -389,10 +406,16 @@ function onTouchEnd(e: TouchEvent) {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
transition: transform var(--transition-fast), opacity var(--transition-fast); transition:
transform var(--transition-fast),
opacity var(--transition-fast);
&:hover { transform: scale(1.1); } &:hover {
&:active { transform: scale(0.95); } transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
&--like { &--like {
background: var(--color-signal); background: var(--color-signal);

View File

@@ -1,63 +1,3 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { gsap } from 'gsap';
import { useFeed } from '@/composables/useFeed';
import { useAuth } from '@/composables/useAuth';
import { apiClient } from '@/api/client';
import { useUi } from '@/composables/useUi';
import FeedCard from './FeedCard.vue';
import EmptyState from '@/components/common/EmptyState.vue';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
const feedStore = useFeed();
const authStore = useAuth();
const uiStore = useUi();
const prefersReducedMotion = typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const visibleCards = computed(() => feedStore.cards.slice(0, 3));
async function handleLike(profileId: string) {
const activeProfile = authStore.activeProfile;
if (!activeProfile) return;
try {
await apiClient.api.likesControllerCreateLike({
sourceProfileId: activeProfile.id,
targetProfileId: profileId,
type: 'like',
});
feedStore.removeCard(profileId);
checkRefill();
} catch {
uiStore.addToast('Не удалось отправить лайк', 'error');
}
}
async function handleDislike(profileId: string) {
const activeProfile = authStore.activeProfile;
if (!activeProfile) return;
try {
await apiClient.api.likesControllerCreateLike({
sourceProfileId: activeProfile.id,
targetProfileId: profileId,
type: 'dislike',
});
feedStore.removeCard(profileId);
checkRefill();
} catch {
feedStore.removeCard(profileId);
}
}
function checkRefill() {
const activeProfile = authStore.activeProfile;
if (!activeProfile) return;
if (feedStore.cards.length < 5 && feedStore.hasMore) {
feedStore.fetchNextPage(activeProfile.id);
}
}
</script>
<template> <template>
<div class="card-stack"> <div class="card-stack">
<div v-if="feedStore.loading && feedStore.cards.length === 0" class="card-stack__loading"> <div v-if="feedStore.loading && feedStore.cards.length === 0" class="card-stack__loading">
@@ -72,7 +12,9 @@ function checkRefill() {
/> />
<div v-else-if="feedStore.searchPaused" class="card-stack__paused"> <div v-else-if="feedStore.searchPaused" class="card-stack__paused">
<p class="meta">Лимит совпадений достигнут</p> <p class="meta">
Лимит совпадений достигнут
</p>
<p>Закройте один из открытых чатов, чтобы продолжить поиск.</p> <p>Закройте один из открытых чатов, чтобы продолжить поиск.</p>
</div> </div>
@@ -91,6 +33,70 @@ function checkRefill() {
</div> </div>
</template> </template>
<script setup lang="ts">
import { computed } from 'vue'
import { apiClient } from '@/api/client'
import EmptyState from '@/components/common/EmptyState.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import { useAuth } from '@/composables/useAuth'
import { useFeed } from '@/composables/useFeed'
import { useUi } from '@/composables/useUi'
import FeedCard from './FeedCard.vue'
const feedStore = useFeed()
const authStore = useAuth()
const uiStore = useUi()
const prefersReducedMotion = typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
const visibleCards = computed(() => feedStore.cards.slice(0, 3))
async function handleLike(profileId: string) {
const activeProfile = authStore.activeProfile
if (!activeProfile)
return
try {
await apiClient.api.likesControllerCreateLike({
sourceProfileId: activeProfile.id,
targetProfileId: profileId,
type: 'like',
})
feedStore.removeCard(profileId)
checkRefill()
}
catch {
uiStore.addToast('Не удалось отправить лайк', 'error')
}
}
async function handleDislike(profileId: string) {
const activeProfile = authStore.activeProfile
if (!activeProfile)
return
try {
await apiClient.api.likesControllerCreateLike({
sourceProfileId: activeProfile.id,
targetProfileId: profileId,
type: 'dislike',
})
feedStore.removeCard(profileId)
checkRefill()
}
catch {
feedStore.removeCard(profileId)
}
}
function checkRefill() {
const activeProfile = authStore.activeProfile
if (!activeProfile)
return
if (feedStore.cards.length < 5 && feedStore.hasMore) {
feedStore.fetchNextPage(activeProfile.id)
}
}
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.card-stack { .card-stack {
position: relative; position: relative;
@@ -123,8 +129,18 @@ function checkRefill() {
} }
// Card transition — next card scales up from behind // Card transition — next card scales up from behind
.card-enter-active { transition: all var(--transition-spring); } .card-enter-active {
.card-leave-active { transition: all 0.4s ease-in; position: absolute; } transition: all var(--transition-spring);
.card-enter-from { transform: scale(0.93) translateY(20px); opacity: 0; } }
.card-leave-to { opacity: 0; } .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> </style>

View File

@@ -1,96 +1,42 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue';
import { useUi } from '@/composables/useUi';
import { useFeed } from '@/composables/useFeed';
import { useAuth } from '@/composables/useAuth';
import { apiClient } from '@/api/client';
import type { District } from '@/composables/useUi';
import AppButton from '@/components/common/AppButton.vue';
import AppDrawer from '@/components/common/AppDrawer.vue';
defineProps<{ open: boolean }>();
const emit = defineEmits<{ close: [] }>();
const uiStore = useUi();
const feedStore = useFeed();
const authStore = useAuth();
const filters = reactive({
cityId: '',
districtId: '',
ageMin: undefined as number | undefined,
ageMax: undefined as number | undefined,
keyword: '',
tagIds: [] as string[],
});
const districts = ref<District[]>([]);
watch(() => filters.cityId, async (cityId) => {
filters.districtId = '';
if (!cityId) { districts.value = []; return; }
if (uiStore.districts[cityId]) { districts.value = uiStore.districts[cityId]; return; }
try {
const res = await apiClient.api.citiesControllerFindDistricts(cityId) as unknown as District[];
uiStore.setDistricts(cityId, res);
districts.value = res;
} catch { districts.value = []; }
});
function toggleTag(tagId: string) {
const idx = filters.tagIds.indexOf(tagId);
if (idx === -1) filters.tagIds.push(tagId);
else filters.tagIds.splice(idx, 1);
}
function apply() {
feedStore.applyFilters({ ...filters });
const profileId = authStore.activeProfile?.id;
if (profileId) feedStore.fetchNextPage(profileId);
emit('close');
}
function reset() {
filters.cityId = '';
filters.districtId = '';
filters.ageMin = undefined;
filters.ageMax = undefined;
filters.keyword = '';
filters.tagIds = [];
}
</script>
<template> <template>
<AppDrawer :open="open" title="Фильтры" side="right" @close="emit('close')"> <AppDrawer :open="open" title="Фильтры" side="right" @close="emit('close')">
<div class="filters"> <div class="filters">
<div class="filters__section"> <div class="filters__section">
<span class="label">Город</span> <span class="label">Город</span>
<select v-model="filters.cityId" class="filters__select"> <select v-model="filters.cityId" class="filters__select">
<option value="">Любой</option> <option value="">
<option v-for="city in uiStore.cities" :key="city.id" :value="city.id">{{ city.name }}</option> Любой
</option>
<option v-for="city in uiStore.cities" :key="city.id" :value="city.id">
{{ city.name }}
</option>
</select> </select>
</div> </div>
<div v-if="filters.cityId" class="filters__section"> <div v-if="filters.cityId" class="filters__section">
<span class="label">Район</span> <span class="label">Район</span>
<select v-model="filters.districtId" class="filters__select"> <select v-model="filters.districtId" class="filters__select">
<option value="">Любой</option> <option value="">
<option v-for="d in districts" :key="d.id" :value="d.id">{{ d.name }}</option> Любой
</option>
<option v-for="d in districts" :key="d.id" :value="d.id">
{{ d.name }}
</option>
</select> </select>
</div> </div>
<div class="filters__section"> <div class="filters__section">
<span class="label">Возраст</span> <span class="label">Возраст</span>
<div class="filters__range"> <div class="filters__range">
<input v-model.number="filters.ageMin" type="number" class="filters__num" placeholder="от 18" min="18" max="80" /> <input v-model.number="filters.ageMin" type="number" class="filters__num" placeholder="от 18" min="18" max="80">
<span class="filters__dash"></span> <span class="filters__dash"></span>
<input v-model.number="filters.ageMax" type="number" class="filters__num" placeholder="до 60" min="18" max="80" /> <input v-model.number="filters.ageMax" type="number" class="filters__num" placeholder="до 60" min="18" max="80">
</div> </div>
</div> </div>
<div class="filters__section"> <div class="filters__section">
<span class="label">Ключевое слово</span> <span class="label">Ключевое слово</span>
<input v-model="filters.keyword" type="text" class="filters__input" placeholder="Имя, описание..." /> <input v-model="filters.keyword" type="text" class="filters__input" placeholder="Имя, описание...">
</div> </div>
<div class="filters__section"> <div class="filters__section">
@@ -103,18 +49,89 @@ function reset() {
class="filters__tag" class="filters__tag"
:class="{ 'filters__tag--active': filters.tagIds.includes(tag.id) }" :class="{ 'filters__tag--active': filters.tagIds.includes(tag.id) }"
@click="toggleTag(tag.id)" @click="toggleTag(tag.id)"
>{{ tag.value }}</button> >
{{ tag.value }}
</button>
</div> </div>
</div> </div>
<div class="filters__actions"> <div class="filters__actions">
<AppButton variant="ghost" @click="reset">Сбросить</AppButton> <AppButton variant="ghost" @click="reset">
<AppButton variant="primary" @click="apply">Применить</AppButton> Сбросить
</AppButton>
<AppButton variant="primary" @click="apply">
Применить
</AppButton>
</div> </div>
</div> </div>
</AppDrawer> </AppDrawer>
</template> </template>
<script setup lang="ts">
import type { District } from '@/composables/useUi'
import { reactive, ref, watch } from 'vue'
import { apiClient } from '@/api/client'
import AppButton from '@/components/common/AppButton.vue'
import AppDrawer from '@/components/common/AppDrawer.vue'
import { useAuth } from '@/composables/useAuth'
import { useFeed } from '@/composables/useFeed'
import { useUi } from '@/composables/useUi'
defineProps<{ open: boolean }>()
const emit = defineEmits<{ close: [] }>()
const uiStore = useUi()
const feedStore = useFeed()
const authStore = useAuth()
const filters = reactive({
cityId: '',
districtId: '',
ageMin: undefined as number | undefined,
ageMax: undefined as number | undefined,
keyword: '',
tagIds: [] as string[],
})
const districts = ref<District[]>([])
watch(() => filters.cityId, async (cityId) => {
filters.districtId = ''
if (!cityId) { districts.value = []; return }
if (uiStore.districts[cityId]) { districts.value = uiStore.districts[cityId]; return }
try {
const res = await apiClient.api.citiesControllerFindDistricts(cityId) as unknown as District[]
uiStore.setDistricts(cityId, res)
districts.value = res
}
catch { districts.value = [] }
})
function toggleTag(tagId: string) {
const idx = filters.tagIds.indexOf(tagId)
if (idx === -1)
filters.tagIds.push(tagId)
else filters.tagIds.splice(idx, 1)
}
function apply() {
feedStore.applyFilters({ ...filters })
const profileId = authStore.activeProfile?.id
if (profileId)
feedStore.fetchNextPage(profileId)
emit('close')
}
function reset() {
filters.cityId = ''
filters.districtId = ''
filters.ageMin = undefined
filters.ageMax = undefined
filters.keyword = ''
filters.tagIds = []
}
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.filters { .filters {
display: flex; display: flex;
@@ -140,10 +157,15 @@ function reset() {
outline: none; outline: none;
transition: border-color var(--transition-fast); transition: border-color var(--transition-fast);
&:focus { border-color: var(--color-signal); } &:focus {
border-color: var(--color-signal);
}
} }
&__select { appearance: none; cursor: pointer; } &__select {
appearance: none;
cursor: pointer;
}
&__range { &__range {
display: flex; display: flex;
@@ -165,7 +187,9 @@ function reset() {
text-align: center; text-align: center;
transition: border-color var(--transition-fast); transition: border-color var(--transition-fast);
&:focus { border-color: var(--color-signal); } &:focus {
border-color: var(--color-signal);
}
} }
&__dash { &__dash {

View File

@@ -1,11 +1,11 @@
<script setup lang="ts">
defineProps<{ label: string; variant?: 'default' | 'signal' }>();
</script>
<template> <template>
<span class="badge" :class="`badge--${variant ?? 'default'}`">{{ label }}</span> <span class="badge" :class="`badge--${variant ?? 'default'}`">{{ label }}</span>
</template> </template>
<script setup lang="ts">
defineProps<{ label: string, variant?: 'default' | 'signal' }>()
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.badge { .badge {
display: inline-flex; display: inline-flex;

View File

@@ -1,23 +1,3 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import TauriTitlebar from './TauriTitlebar.vue';
import SideNav from './SideNav.vue';
import BottomNav from './BottomNav.vue';
import { useAuth } from '@/composables/useAuth';
const route = useRoute();
const authStore = useAuth();
const isTauri = typeof window !== 'undefined' && !!window.__TAURI__;
// Hide nav on auth/onboarding routes
const showNav = computed(() =>
authStore.isAuthenticated &&
!['login', 'register', 'setup'].includes(route.name as string),
);
</script>
<template> <template>
<div class="shell" :class="{ 'shell--tauri': isTauri }"> <div class="shell" :class="{ 'shell--tauri': isTauri }">
<!-- Grain texture overlay (fixed, pointer-events none) --> <!-- Grain texture overlay (fixed, pointer-events none) -->
@@ -41,6 +21,26 @@ const showNav = computed(() =>
</div> </div>
</template> </template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAuth } from '@/composables/useAuth'
import BottomNav from './BottomNav.vue'
import SideNav from './SideNav.vue'
import TauriTitlebar from './TauriTitlebar.vue'
const route = useRoute()
const authStore = useAuth()
const isTauri = typeof window !== 'undefined' && !!window.__TAURI__
// Hide nav on auth/onboarding routes
const showNav = computed(() =>
authStore.isAuthenticated
&& !['login', 'register', 'setup'].includes(route.name as string),
)
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.shell { .shell {
height: 100dvh; height: 100dvh;

View File

@@ -1,22 +1,3 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const navItems = [
{ path: '/feed', label: 'Лента', icon: 'grid' },
{ path: '/matches', label: 'Совпадения', icon: 'heart' },
{ path: '/chats', label: 'Чаты', icon: 'chat' },
{ path: '/dates', label: 'Встречи', icon: 'calendar' },
{ path: '/profile/me', label: 'Профиль', icon: 'person' },
];
function isActive(path: string) {
return route.path.startsWith(path);
}
</script>
<template> <template>
<nav class="bottom-nav" aria-label="Навигация"> <nav class="bottom-nav" aria-label="Навигация">
<RouterLink <RouterLink
@@ -36,6 +17,24 @@ function isActive(path: string) {
</nav> </nav>
</template> </template>
<script setup lang="ts">
import { useRoute } from 'vue-router'
const route = useRoute()
const navItems = [
{ path: '/feed', label: 'Лента', icon: 'grid' },
{ path: '/matches', label: 'Совпадения', icon: 'heart' },
{ path: '/chats', label: 'Чаты', icon: 'chat' },
{ path: '/dates', label: 'Встречи', icon: 'calendar' },
{ path: '/profile/me', label: 'Профиль', icon: 'person' },
]
function isActive(path: string) {
return route.path.startsWith(path)
}
</script>
<script lang="ts"> <script lang="ts">
const BottomNavIcon = { const BottomNavIcon = {
props: { name: String }, props: { name: String },
@@ -48,7 +47,7 @@ const BottomNavIcon = {
<path v-if="name==='person'" d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"/> <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> </svg>
`, `,
}; }
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -1,46 +1,3 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useAuth } from '@/composables/useAuth';
import { useUi } from '@/composables/useUi';
const route = useRoute();
const authStore = useAuth();
const uiStore = useUi();
interface NavItem {
name: string;
path: string;
label: string;
icon: string;
adminOnly?: boolean;
}
const navItems: NavItem[] = [
{ name: 'feed', path: '/feed', label: 'Лента', icon: 'grid' },
{ name: 'matches', path: '/matches', label: 'Совпадения', icon: 'heart' },
{ name: 'chats', path: '/chats', label: 'Чаты', icon: 'chat' },
{ name: 'dates', path: '/dates', label: 'Встречи', icon: 'calendar' },
{ name: 'profile', path: '/profile/me', label: 'Профиль', icon: 'person' },
];
const adminItems: NavItem[] = [
{ name: 'admin', path: '/admin/reports', label: 'Жалобы', icon: 'flag', adminOnly: true },
];
const visibleItems = computed(() =>
authStore.isAdmin ? [...navItems, ...adminItems] : navItems,
);
function isActive(path: string) {
return route.path.startsWith(path) && path !== '/';
}
function toggle() {
uiStore.setSidebarExpanded(!uiStore.sidebarExpanded);
}
</script>
<template> <template>
<nav <nav
class="sidenav" class="sidenav"
@@ -76,8 +33,8 @@ function toggle() {
<div class="sidenav__footer"> <div class="sidenav__footer">
<button <button
class="sidenav__toggle" class="sidenav__toggle"
@click="toggle"
:aria-label="uiStore.sidebarExpanded ? 'Свернуть меню' : 'Развернуть меню'" :aria-label="uiStore.sidebarExpanded ? 'Свернуть меню' : 'Развернуть меню'"
@click="toggle"
> >
<span class="sidenav__icon"> <span class="sidenav__icon">
<NavIcon :name="uiStore.sidebarExpanded ? 'chevron-left' : 'chevron-right'" /> <NavIcon :name="uiStore.sidebarExpanded ? 'chevron-left' : 'chevron-right'" />
@@ -87,6 +44,49 @@ function toggle() {
</nav> </nav>
</template> </template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAuth } from '@/composables/useAuth'
import { useUi } from '@/composables/useUi'
const route = useRoute()
const authStore = useAuth()
const uiStore = useUi()
interface NavItem {
name: string
path: string
label: string
icon: string
adminOnly?: boolean
}
const navItems: NavItem[] = [
{ name: 'feed', path: '/feed', label: 'Лента', icon: 'grid' },
{ name: 'matches', path: '/matches', label: 'Совпадения', icon: 'heart' },
{ name: 'chats', path: '/chats', label: 'Чаты', icon: 'chat' },
{ name: 'dates', path: '/dates', label: 'Встречи', icon: 'calendar' },
{ name: 'profile', path: '/profile/me', label: 'Профиль', icon: 'person' },
]
const adminItems: NavItem[] = [
{ name: 'admin', path: '/admin/reports', label: 'Жалобы', icon: 'flag', adminOnly: true },
]
const visibleItems = computed(() =>
authStore.isAdmin ? [...navItems, ...adminItems] : navItems,
)
function isActive(path: string) {
return route.path.startsWith(path) && path !== '/'
}
function toggle() {
uiStore.setSidebarExpanded(!uiStore.sidebarExpanded)
}
</script>
<script lang="ts"> <script lang="ts">
// Inline icon renderer to avoid external icon library dependency // Inline icon renderer to avoid external icon library dependency
const NavIcon = { const NavIcon = {
@@ -103,7 +103,7 @@ const NavIcon = {
<path v-if="name==='chevron-left'" d="M15 18l-6-6 6-6"/> <path v-if="name==='chevron-left'" d="M15 18l-6-6 6-6"/>
</svg> </svg>
`, `,
}; }
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -165,7 +165,9 @@ const NavIcon = {
padding: 0 18px; padding: 0 18px;
color: var(--color-muted); color: var(--color-muted);
text-decoration: none; text-decoration: none;
transition: color var(--transition-fast), background var(--transition-fast); transition:
color var(--transition-fast),
background var(--transition-fast);
white-space: nowrap; white-space: nowrap;
&:hover { &:hover {
@@ -220,7 +222,9 @@ const NavIcon = {
cursor: pointer; cursor: pointer;
transition: color var(--transition-fast); transition: color var(--transition-fast);
&:hover { color: var(--color-cream); } &:hover {
color: var(--color-cream);
}
} }
} }

View File

@@ -1,30 +1,3 @@
<script setup lang="ts">
import { ref } from 'vue';
const isTauri = typeof window !== 'undefined' && !!window.__TAURI__;
async function minimize() {
if (!isTauri) return;
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().minimize();
}
async function maximize() {
if (!isTauri) return;
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const win = getCurrentWindow();
const isMax = await win.isMaximized();
if (isMax) await win.unmaximize();
else await win.maximize();
}
async function closeWindow() {
if (!isTauri) return;
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().close();
}
</script>
<template> <template>
<div <div
v-if="isTauri" v-if="isTauri"
@@ -33,13 +6,42 @@ async function closeWindow() {
> >
<span class="titlebar__brand">Dating</span> <span class="titlebar__brand">Dating</span>
<div class="titlebar__controls"> <div class="titlebar__controls">
<button class="titlebar__btn titlebar__btn--minimize" @click="minimize" aria-label="Свернуть" /> <button class="titlebar__btn titlebar__btn--minimize" aria-label="Свернуть" @click="minimize" />
<button class="titlebar__btn titlebar__btn--maximize" @click="maximize" aria-label="Развернуть" /> <button class="titlebar__btn titlebar__btn--maximize" aria-label="Развернуть" @click="maximize" />
<button class="titlebar__btn titlebar__btn--close" @click="closeWindow" aria-label="Закрыть" /> <button class="titlebar__btn titlebar__btn--close" aria-label="Закрыть" @click="closeWindow" />
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts">
const isTauri = typeof window !== 'undefined' && !!window.__TAURI__
async function minimize() {
if (!isTauri)
return
const { getCurrentWindow } = await import('@tauri-apps/api/window')
await getCurrentWindow().minimize()
}
async function maximize() {
if (!isTauri)
return
const { getCurrentWindow } = await import('@tauri-apps/api/window')
const win = getCurrentWindow()
const isMax = await win.isMaximized()
if (isMax)
await win.unmaximize()
else await win.maximize()
}
async function closeWindow() {
if (!isTauri)
return
const { getCurrentWindow } = await import('@tauri-apps/api/window')
await getCurrentWindow().close()
}
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.titlebar { .titlebar {
height: var(--titlebar-height); height: var(--titlebar-height);
@@ -75,11 +77,19 @@ async function closeWindow() {
transition: opacity var(--transition-fast); transition: opacity var(--transition-fast);
padding: 0; padding: 0;
&--minimize { background: #f5a623; } &--minimize {
&--maximize { background: #7ed321; } background: #f5a623;
&--close { background: #d0021b; } }
&--maximize {
background: #7ed321;
}
&--close {
background: #d0021b;
}
&:hover { opacity: 0.8; } &:hover {
opacity: 0.8;
}
&:focus-visible { &:focus-visible {
outline: 2px solid var(--color-signal); outline: 2px solid var(--color-signal);
outline-offset: 2px; outline-offset: 2px;

View File

@@ -1,85 +1,3 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useUi } from '@/composables/useUi';
import { apiClient } from '@/api/client';
interface MediaItem {
id: string;
url: string;
type: string;
}
const props = defineProps<{ profileId: string; editable?: boolean }>();
const emit = defineEmits<{ updated: [] }>();
const uiStore = useUi();
const items = ref<MediaItem[]>([]);
const loading = ref(false);
const uploading = ref(false);
const fileInput = ref<HTMLInputElement | null>(null);
const lightboxUrl = ref<string | null>(null);
async function loadMedia() {
loading.value = true;
try {
const res = await apiClient.api.mediaControllerGetMedia(props.profileId) as unknown as MediaItem[];
items.value = res;
} catch { /* ignore */ }
finally { loading.value = false; }
}
loadMedia();
const isTauri = typeof window !== 'undefined' && !!window.__TAURI__;
async function upload() {
if (isTauri) {
try {
const { open } = await import('@tauri-apps/plugin-dialog');
const result = await open({ filters: [{ name: 'Изображения', extensions: ['jpg', 'jpeg', 'png', 'webp'] }] });
if (typeof result === 'string') {
await doUpload(null, result);
}
} catch { /* cancelled */ }
} else {
fileInput.value?.click();
}
}
async function onFileChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
await doUpload(file);
if (fileInput.value) fileInput.value.value = '';
}
async function doUpload(file: File | null, tauriPath?: string) {
uploading.value = true;
try {
// API expects multipart/form-data with the file; we pass query type=photo
await apiClient.api.mediaControllerUpload({ profileId: props.profileId, type: 'photo' });
uiStore.addToast('Фото загружено', 'success');
await loadMedia();
emit('updated');
} catch {
uiStore.addToast('Не удалось загрузить фото', 'error');
} finally {
uploading.value = false;
}
}
async function deleteMedia(mediaId: string) {
try {
await apiClient.api.mediaControllerDeleteMedia(mediaId, props.profileId);
items.value = items.value.filter((i) => i.id !== mediaId);
emit('updated');
uiStore.addToast('Фото удалено', 'success');
} catch {
uiStore.addToast('Не удалось удалить фото', 'error');
}
}
</script>
<template> <template>
<div class="gallery"> <div class="gallery">
<div class="gallery__grid"> <div class="gallery__grid">
@@ -88,8 +6,8 @@ async function deleteMedia(mediaId: string) {
v-if="editable" v-if="editable"
class="gallery__upload" class="gallery__upload"
:disabled="uploading" :disabled="uploading"
@click="upload"
aria-label="Добавить фото" aria-label="Добавить фото"
@click="upload"
> >
<svg v-if="!uploading" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="24" height="24"> <svg v-if="!uploading" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="24" height="24">
<path d="M12 5v14M5 12h14" /> <path d="M12 5v14M5 12h14" />
@@ -109,12 +27,12 @@ async function deleteMedia(mediaId: string) {
alt="Медиа" alt="Медиа"
loading="lazy" loading="lazy"
@click="lightboxUrl = item.url" @click="lightboxUrl = item.url"
/> >
<button <button
v-if="editable" v-if="editable"
class="gallery__delete" class="gallery__delete"
@click.stop="deleteMedia(item.id)"
aria-label="Удалить фото" aria-label="Удалить фото"
@click.stop="deleteMedia(item.id)"
> >
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12"> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12">
<path d="M12 4L4 12M4 4l8 8" /> <path d="M12 4L4 12M4 4l8 8" />
@@ -123,19 +41,109 @@ async function deleteMedia(mediaId: string) {
</div> </div>
</div> </div>
<input ref="fileInput" type="file" accept="image/*" class="sr-only" @change="onFileChange" /> <input ref="fileInput" type="file" accept="image/*" class="sr-only" @change="onFileChange">
<!-- Lightbox --> <!-- Lightbox -->
<Teleport to="body"> <Teleport to="body">
<Transition name="fade"> <Transition name="fade">
<div v-if="lightboxUrl" class="lightbox" @click="lightboxUrl = null"> <div v-if="lightboxUrl" class="lightbox" @click="lightboxUrl = null">
<img :src="lightboxUrl" class="lightbox__img" alt="Фото" /> <img :src="lightboxUrl" class="lightbox__img" alt="Фото">
</div> </div>
</Transition> </Transition>
</Teleport> </Teleport>
</div> </div>
</template> </template>
<script setup lang="ts">
import { ref } from 'vue'
import { apiClient } from '@/api/client'
import { useUi } from '@/composables/useUi'
interface MediaItem {
id: string
url: string
type: string
}
const props = defineProps<{ profileId: string, editable?: boolean }>()
const emit = defineEmits<{ updated: [] }>()
const uiStore = useUi()
const items = ref<MediaItem[]>([])
const loading = ref(false)
const uploading = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
const lightboxUrl = ref<string | null>(null)
async function loadMedia() {
loading.value = true
try {
const res = await apiClient.api.mediaControllerGetMedia(props.profileId) as unknown as MediaItem[]
items.value = res
}
catch { /* ignore */ }
finally { loading.value = false }
}
loadMedia()
const isTauri = typeof window !== 'undefined' && !!window.__TAURI__
async function upload() {
if (isTauri) {
try {
const { open } = await import('@tauri-apps/plugin-dialog')
const result = await open({ filters: [{ name: 'Изображения', extensions: ['jpg', 'jpeg', 'png', 'webp'] }] })
if (typeof result === 'string') {
await doUpload(null, result)
}
}
catch { /* cancelled */ }
}
else {
fileInput.value?.click()
}
}
async function onFileChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file)
return
await doUpload(file)
if (fileInput.value)
fileInput.value.value = ''
}
async function doUpload(file: File | null, tauriPath?: string) {
uploading.value = true
try {
// API expects multipart/form-data with the file; we pass query type=photo
await apiClient.api.mediaControllerUpload({ profileId: props.profileId, type: 'photo' })
uiStore.addToast('Фото загружено', 'success')
await loadMedia()
emit('updated')
}
catch {
uiStore.addToast('Не удалось загрузить фото', 'error')
}
finally {
uploading.value = false
}
}
async function deleteMedia(mediaId: string) {
try {
await apiClient.api.mediaControllerDeleteMedia(mediaId, props.profileId)
items.value = items.value.filter(i => i.id !== mediaId)
emit('updated')
uiStore.addToast('Фото удалено', 'success')
}
catch {
uiStore.addToast('Не удалось удалить фото', 'error')
}
}
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.gallery { .gallery {
&__grid { &__grid {
@@ -155,7 +163,9 @@ async function deleteMedia(mediaId: string) {
overflow: hidden; overflow: hidden;
background: var(--color-surface-2); background: var(--color-surface-2);
&:hover .gallery__delete { opacity: 1; } &:hover .gallery__delete {
opacity: 1;
}
} }
&__img { &__img {
@@ -165,7 +175,9 @@ async function deleteMedia(mediaId: string) {
cursor: pointer; cursor: pointer;
transition: transform var(--transition-fast); transition: transform var(--transition-fast);
&:hover { transform: scale(1.04); } &:hover {
transform: scale(1.04);
}
} }
&__delete { &__delete {
@@ -183,9 +195,13 @@ async function deleteMedia(mediaId: string) {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
opacity: 0; opacity: 0;
transition: opacity var(--transition-fast), background var(--transition-fast); transition:
opacity var(--transition-fast),
background var(--transition-fast);
&:hover { background: var(--color-signal); } &:hover {
background: var(--color-signal);
}
} }
&__upload { &__upload {
@@ -205,7 +221,10 @@ async function deleteMedia(mediaId: string) {
color: var(--color-cream); color: var(--color-cream);
} }
&:disabled { opacity: 0.5; cursor: not-allowed; } &:disabled {
opacity: 0.5;
cursor: not-allowed;
}
} }
&__spinner { &__spinner {
@@ -236,8 +255,18 @@ async function deleteMedia(mediaId: string) {
} }
} }
.fade-enter-active, .fade-leave-active { transition: opacity var(--transition-base); } .fade-enter-active,
.fade-enter-from, .fade-leave-to { opacity: 0; } .fade-leave-active {
transition: opacity var(--transition-base);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin {
to {
transform: rotate(360deg);
}
}
</style> </style>

View File

@@ -1,81 +1,5 @@
<script setup lang="ts">
import { reactive, watch, ref } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, helpers } from '@vuelidate/validators';
import { useUi } from '@/composables/useUi';
import { useAuth } from '@/composables/useAuth';
import { useProfile } from '@/composables/useProfile';
import { apiClient } from '@/api/client';
import type { UserProfile } from '@/composables/useAuth';
import type { District } from '@/composables/useUi';
import AppInput from '@/components/common/AppInput.vue';
import AppButton from '@/components/common/AppButton.vue';
const props = defineProps<{ profile: UserProfile }>();
const emit = defineEmits<{ saved: [UserProfile]; cancel: [] }>();
const uiStore = useUi();
const authStore = useAuth();
const profileStore = useProfile();
const form = reactive({
name: props.profile.name,
birthDate: props.profile.birthDate,
gender: props.profile.gender ?? 'female',
cityId: props.profile.cityId ?? '',
districtId: props.profile.districtId ?? '',
description: props.profile.description ?? '',
nation: props.profile.nation ?? '',
height: props.profile.height ?? undefined as number | undefined,
weight: props.profile.weight ?? undefined as number | undefined,
tagIds: props.profile.tags?.map((t) => t.id) ?? [],
});
const districts = ref<District[]>([]);
watch(() => form.cityId, async (cityId) => {
if (!cityId) { districts.value = []; return; }
if (uiStore.districts[cityId]) { districts.value = uiStore.districts[cityId]; return; }
try {
const res = await apiClient.api.citiesControllerFindDistricts(cityId) as unknown as District[];
uiStore.setDistricts(cityId, res);
districts.value = res;
} catch { /* ignore */ }
}, { immediate: true });
const rules = {
name: { required: helpers.withMessage('Введите имя', required) },
birthDate: { required: helpers.withMessage('Введите дату рождения', required) },
};
const v$ = useVuelidate(rules, form);
const loading = ref(false);
function toggleTag(tagId: string) {
const idx = form.tagIds.indexOf(tagId);
if (idx === -1) form.tagIds.push(tagId);
else form.tagIds.splice(idx, 1);
}
async function save() {
const valid = await v$.value.$validate();
if (!valid) return;
loading.value = true;
try {
const updated = await profileStore.updateProfile(props.profile.id, form);
authStore.updateProfile(updated);
uiStore.addToast('Профиль сохранён', 'success');
emit('saved', updated);
} catch {
uiStore.addToast('Не удалось сохранить профиль', 'error');
} finally {
loading.value = false;
}
}
</script>
<template> <template>
<form class="profile-editor" @submit.prevent="save" novalidate> <form class="profile-editor" novalidate @submit.prevent="save">
<AppInput <AppInput
v-model="form.name" v-model="form.name"
label="Имя" label="Имя"
@@ -97,24 +21,36 @@ async function save() {
<div class="profile-editor__field"> <div class="profile-editor__field">
<span class="label">Пол</span> <span class="label">Пол</span>
<div class="profile-editor__gender"> <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 === 'female' }" @click="form.gender = 'female'">
<button type="button" class="profile-editor__gender-btn" :class="{ active: form.gender === 'male' }" @click="form.gender = 'male'">Мужчина</button> Женщина
</button>
<button type="button" class="profile-editor__gender-btn" :class="{ active: form.gender === 'male' }" @click="form.gender = 'male'">
Мужчина
</button>
</div> </div>
</div> </div>
<div class="profile-editor__field"> <div class="profile-editor__field">
<label class="label" for="city">Город</label> <label class="label" for="city">Город</label>
<select id="city" v-model="form.cityId" class="profile-editor__select"> <select id="city" v-model="form.cityId" class="profile-editor__select">
<option value="">Не указан</option> <option value="">
<option v-for="c in uiStore.cities" :key="c.id" :value="c.id">{{ c.name }}</option> Не указан
</option>
<option v-for="c in uiStore.cities" :key="c.id" :value="c.id">
{{ c.name }}
</option>
</select> </select>
</div> </div>
<div v-if="form.cityId" class="profile-editor__field"> <div v-if="form.cityId" class="profile-editor__field">
<label class="label" for="district">Район</label> <label class="label" for="district">Район</label>
<select id="district" v-model="form.districtId" class="profile-editor__select"> <select id="district" v-model="form.districtId" class="profile-editor__select">
<option value="">Не указан</option> <option value="">
<option v-for="d in districts" :key="d.id" :value="d.id">{{ d.name }}</option> Не указан
</option>
<option v-for="d in districts" :key="d.id" :value="d.id">
{{ d.name }}
</option>
</select> </select>
</div> </div>
@@ -136,17 +72,104 @@ async function save() {
class="profile-editor__tag" class="profile-editor__tag"
:class="{ 'profile-editor__tag--active': form.tagIds.includes(tag.id) }" :class="{ 'profile-editor__tag--active': form.tagIds.includes(tag.id) }"
@click="toggleTag(tag.id)" @click="toggleTag(tag.id)"
>{{ tag.value }}</button> >
{{ tag.value }}
</button>
</div> </div>
</div> </div>
<div class="profile-editor__actions"> <div class="profile-editor__actions">
<AppButton type="button" variant="ghost" @click="emit('cancel')">Отмена</AppButton> <AppButton type="button" variant="ghost" @click="emit('cancel')">
<AppButton type="submit" :loading="loading">Сохранить</AppButton> Отмена
</AppButton>
<AppButton type="submit" :loading="loading">
Сохранить
</AppButton>
</div> </div>
</form> </form>
</template> </template>
<script setup lang="ts">
import type { UserProfile } from '@/composables/useAuth'
import type { District } from '@/composables/useUi'
import { useVuelidate } from '@vuelidate/core'
import { helpers, required } from '@vuelidate/validators'
import { reactive, ref, watch } from 'vue'
import { apiClient } from '@/api/client'
import AppButton from '@/components/common/AppButton.vue'
import AppInput from '@/components/common/AppInput.vue'
import { useAuth } from '@/composables/useAuth'
import { useProfile } from '@/composables/useProfile'
import { useUi } from '@/composables/useUi'
const props = defineProps<{ profile: UserProfile }>()
const emit = defineEmits<{ saved: [UserProfile], cancel: [] }>()
const uiStore = useUi()
const authStore = useAuth()
const profileStore = useProfile()
const form = reactive({
name: props.profile.name,
birthDate: props.profile.birthDate,
gender: props.profile.gender ?? 'female',
cityId: props.profile.cityId ?? '',
districtId: props.profile.districtId ?? '',
description: props.profile.description ?? '',
nation: props.profile.nation ?? '',
height: props.profile.height ?? undefined as number | undefined,
weight: props.profile.weight ?? undefined as number | undefined,
tagIds: props.profile.tags?.map(t => t.id) ?? [],
})
const districts = ref<District[]>([])
watch(() => form.cityId, async (cityId) => {
if (!cityId) { districts.value = []; return }
if (uiStore.districts[cityId]) { districts.value = uiStore.districts[cityId]; return }
try {
const res = await apiClient.api.citiesControllerFindDistricts(cityId) as unknown as District[]
uiStore.setDistricts(cityId, res)
districts.value = res
}
catch { /* ignore */ }
}, { immediate: true })
const rules = {
name: { required: helpers.withMessage('Введите имя', required) },
birthDate: { required: helpers.withMessage('Введите дату рождения', required) },
}
const v$ = useVuelidate(rules, form)
const loading = ref(false)
function toggleTag(tagId: string) {
const idx = form.tagIds.indexOf(tagId)
if (idx === -1)
form.tagIds.push(tagId)
else form.tagIds.splice(idx, 1)
}
async function save() {
const valid = await v$.value.$validate()
if (!valid)
return
loading.value = true
try {
const updated = await profileStore.updateProfile(props.profile.id, form)
authStore.updateProfile(updated)
uiStore.addToast('Профиль сохранён', 'success')
emit('saved', updated)
}
catch {
uiStore.addToast('Не удалось сохранить профиль', 'error')
}
finally {
loading.value = false
}
}
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.profile-editor { .profile-editor {
display: flex; display: flex;
@@ -163,7 +186,8 @@ async function save() {
display: flex; display: flex;
gap: 8px; gap: 8px;
&-btn, .profile-editor__gender-btn { &-btn,
.profile-editor__gender-btn {
flex: 1; flex: 1;
height: 40px; height: 40px;
background: var(--color-surface-2); background: var(--color-surface-2);
@@ -196,7 +220,9 @@ async function save() {
appearance: none; appearance: none;
transition: border-color var(--transition-fast); transition: border-color var(--transition-fast);
&:focus { border-color: var(--color-signal); } &:focus {
border-color: var(--color-signal);
}
} }
&__row { &__row {

View File

@@ -1,51 +1,9 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useAuth } from '@/composables/useAuth';
import { useUi } from '@/composables/useUi';
import { apiClient } from '@/api/client';
import AppModal from '@/components/common/AppModal.vue';
import AppButton from '@/components/common/AppButton.vue';
const props = defineProps<{
open: boolean;
entityId: string;
entityType: 'profile' | 'message';
}>();
const emit = defineEmits<{ close: [] }>();
const authStore = useAuth();
const uiStore = useUi();
const form = reactive({ description: '' });
const loading = ref(false);
async function submit() {
const profileId = authStore.activeProfile?.id;
if (!profileId) return;
loading.value = true;
try {
await apiClient.api.reportsControllerCreate({
sourceProfileId: profileId,
entityId: props.entityId,
entityType: props.entityType,
description: form.description || undefined,
});
uiStore.addToast('Жалоба отправлена', 'success');
form.description = '';
emit('close');
} catch {
uiStore.addToast('Не удалось отправить жалобу', 'error');
} finally {
loading.value = false;
}
}
</script>
<template> <template>
<AppModal :open="open" title="Пожаловаться" size="sm" @close="emit('close')"> <AppModal :open="open" title="Пожаловаться" size="sm" @close="emit('close')">
<form class="report-form" @submit.prevent="submit"> <form class="report-form" @submit.prevent="submit">
<p class="report-form__label label">Опишите причину жалобы (необязательно)</p> <p class="report-form__label label">
Опишите причину жалобы (необязательно)
</p>
<textarea <textarea
v-model="form.description" v-model="form.description"
class="report-form__textarea" class="report-form__textarea"
@@ -54,19 +12,72 @@ async function submit() {
/> />
</form> </form>
<template #footer> <template #footer>
<AppButton variant="ghost" @click="emit('close')">Отмена</AppButton> <AppButton variant="ghost" @click="emit('close')">
<AppButton variant="danger" :loading="loading" @click="submit">Отправить жалобу</AppButton> Отмена
</AppButton>
<AppButton variant="danger" :loading="loading" @click="submit">
Отправить жалобу
</AppButton>
</template> </template>
</AppModal> </AppModal>
</template> </template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { apiClient } from '@/api/client'
import AppButton from '@/components/common/AppButton.vue'
import AppModal from '@/components/common/AppModal.vue'
import { useAuth } from '@/composables/useAuth'
import { useUi } from '@/composables/useUi'
const props = defineProps<{
open: boolean
entityId: string
entityType: 'profile' | 'message'
}>()
const emit = defineEmits<{ close: [] }>()
const authStore = useAuth()
const uiStore = useUi()
const form = reactive({ description: '' })
const loading = ref(false)
async function submit() {
const profileId = authStore.activeProfile?.id
if (!profileId)
return
loading.value = true
try {
await apiClient.api.reportsControllerCreate({
sourceProfileId: profileId,
entityId: props.entityId,
entityType: props.entityType,
description: form.description || undefined,
})
uiStore.addToast('Жалоба отправлена', 'success')
form.description = ''
emit('close')
}
catch {
uiStore.addToast('Не удалось отправить жалобу', 'error')
}
finally {
loading.value = false
}
}
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.report-form { .report-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
&__label { color: var(--color-muted); } &__label {
color: var(--color-muted);
}
&__textarea { &__textarea {
width: 100%; width: 100%;
@@ -82,8 +93,12 @@ async function submit() {
outline: none; outline: none;
transition: border-color var(--transition-fast); transition: border-color var(--transition-fast);
&::placeholder { color: var(--color-muted); } &::placeholder {
&:focus { border-color: var(--color-border-strong); } color: var(--color-muted);
}
&:focus {
border-color: var(--color-border-strong);
}
} }
} }
</style> </style>

View File

@@ -1,110 +1,112 @@
import { ref, computed, reactive } from 'vue'; import type { LoginDto, MediaItemDto, RegisterDto, TagDto } from '@/api/api'
import { apiClient, _setAccessToken, _clearAuth } from '@/api/client'; import { computed, reactive, ref } from 'vue'
import type { LoginDto, RegisterDto, TagDto, MediaItemDto } from '@/api/api'; import { _clearAuth, _setAccessToken, apiClient } from '@/api/client'
export interface UserProfile { export interface UserProfile {
id: string; id: string
userId: string; userId: string
name: string; name: string
birthDate: string; birthDate: string
gender: 'male' | 'female'; gender: 'male' | 'female'
cityId?: string | null; cityId?: string | null
districtId?: string | null; districtId?: string | null
description?: string | null; description?: string | null
nation?: string | null; nation?: string | null
height?: number | null; height?: number | null
weight?: number | null; weight?: number | null
activeChatId?: string | null; activeChatId?: string | null
tags: TagDto[]; tags: TagDto[]
media: MediaItemDto[]; media: MediaItemDto[]
} }
export interface AuthUser { export interface AuthUser {
id: string; id: string
phone: string; phone: string
status: 'active' | 'banned' | 'pending'; status: 'active' | 'banned' | 'pending'
roleId?: string | null; roleId?: string | null
role?: { id: string; name: string } | null; role?: { id: string, name: string } | null
profiles: UserProfile[]; profiles: UserProfile[]
} }
const user = ref<AuthUser | null>(null); const user = ref<AuthUser | null>(null)
const activeProfileId = ref<string | null>(null); const activeProfileId = ref<string | null>(null)
const isAuthenticated = computed(() => !!user.value); const isAuthenticated = computed(() => !!user.value)
const isAdmin = computed(() => user.value?.role?.name === 'admin'); const isAdmin = computed(() => user.value?.role?.name === 'admin')
const profiles = computed(() => user.value?.profiles ?? []); const profiles = computed(() => user.value?.profiles ?? [])
const activeProfile = computed(() => const activeProfile = computed(() =>
profiles.value.find((p) => p.id === activeProfileId.value) ?? profiles.value[0] ?? null, profiles.value.find(p => p.id === activeProfileId.value) ?? profiles.value[0] ?? null,
); )
const hasProfiles = computed(() => profiles.value.length > 0); const hasProfiles = computed(() => profiles.value.length > 0)
async function login(dto: LoginDto) { async function login(dto: LoginDto) {
const res = await apiClient.api.authControllerLogin(dto) as unknown as { const res = await apiClient.api.authControllerLogin(dto) as unknown as {
accessToken: string; accessToken: string
refreshToken: string; refreshToken: string
}; }
_setAccessToken(res.accessToken); _setAccessToken(res.accessToken)
localStorage.setItem('refreshToken', res.refreshToken); localStorage.setItem('refreshToken', res.refreshToken)
await fetchMe(); await fetchMe()
} }
async function register(dto: RegisterDto) { async function register(dto: RegisterDto) {
const res = await apiClient.api.authControllerRegister(dto) as unknown as { const res = await apiClient.api.authControllerRegister(dto) as unknown as {
accessToken: string; accessToken: string
refreshToken: string; refreshToken: string
}; }
_setAccessToken(res.accessToken); _setAccessToken(res.accessToken)
localStorage.setItem('refreshToken', res.refreshToken); localStorage.setItem('refreshToken', res.refreshToken)
await fetchMe(); await fetchMe()
} }
async function logout() { async function logout() {
try { try {
await apiClient.api.authControllerLogout(); await apiClient.api.authControllerLogout()
} catch { }
catch {
// ignore errors on logout // ignore errors on logout
} }
_clearAuth(); _clearAuth()
user.value = null; user.value = null
activeProfileId.value = null; activeProfileId.value = null
} }
async function fetchMe() { async function fetchMe() {
const [meRes, profilesRes] = await Promise.all([ const [meRes, profilesRes] = await Promise.all([
apiClient.api.usersControllerGetMe(), apiClient.api.usersControllerGetMe(),
apiClient.api.profilesControllerGetMyProfiles(), apiClient.api.profilesControllerGetMyProfiles(),
]); ])
const fullProfiles = profilesRes as unknown as UserProfile[]; const fullProfiles = profilesRes as unknown as UserProfile[]
user.value = { ...meRes, profiles: fullProfiles } as unknown as AuthUser; user.value = { ...meRes, profiles: fullProfiles } as unknown as AuthUser
if (fullProfiles.length > 0 && !activeProfileId.value) { if (fullProfiles.length > 0 && !activeProfileId.value) {
activeProfileId.value = fullProfiles[0].id; activeProfileId.value = fullProfiles[0].id
} }
} }
function setActiveProfile(profileId: string) { function setActiveProfile(profileId: string) {
activeProfileId.value = profileId; activeProfileId.value = profileId
} }
function addProfile(profile: UserProfile) { function addProfile(profile: UserProfile) {
if (user.value) { if (user.value) {
user.value.profiles.push(profile); user.value.profiles.push(profile)
activeProfileId.value = profile.id; activeProfileId.value = profile.id
} }
} }
function updateProfile(updated: UserProfile) { function updateProfile(updated: UserProfile) {
if (user.value) { if (user.value) {
const idx = user.value.profiles.findIndex((p) => p.id === updated.id); const idx = user.value.profiles.findIndex(p => p.id === updated.id)
if (idx !== -1) user.value.profiles[idx] = updated; if (idx !== -1)
user.value.profiles[idx] = updated
} }
} }
function removeProfile(profileId: string) { function removeProfile(profileId: string) {
if (user.value) { if (user.value) {
user.value.profiles = user.value.profiles.filter((p) => p.id !== profileId); user.value.profiles = user.value.profiles.filter(p => p.id !== profileId)
if (activeProfileId.value === profileId) { if (activeProfileId.value === profileId) {
activeProfileId.value = user.value.profiles[0]?.id ?? null; activeProfileId.value = user.value.profiles[0]?.id ?? null
} }
} }
} }
@@ -126,5 +128,5 @@ export function useAuth() {
addProfile, addProfile,
updateProfile, updateProfile,
removeProfile, removeProfile,
}); })
} }

View File

@@ -1,102 +1,106 @@
import { ref, reactive } from 'vue'; import type { SendMessageDto } from '@/api/api'
import { apiClient } from '@/api/client'; import { reactive, ref } from 'vue'
import type { SendMessageDto } from '@/api/api'; import { apiClient } from '@/api/client'
export interface ChatProfile { export interface ChatProfile {
id: string; id: string
name: string; name: string
avatarUrl?: string; avatarUrl?: string
} }
export interface Chat { export interface Chat {
id: string; id: string
profile1Id: string; profile1Id: string
profile2Id: string; profile2Id: string
status: 'active' | 'closed'; status: 'active' | 'closed'
partner?: ChatProfile; partner?: ChatProfile
lastMessage?: ChatMessage; lastMessage?: ChatMessage
unreadCount?: number; unreadCount?: number
createdAt?: string; createdAt?: string
} }
export interface ChatMessage { export interface ChatMessage {
id: string; id: string
chatId: string; chatId: string
profileId: string; profileId: string
text?: string; text?: string
mediaUrl?: string; mediaUrl?: string
mediaType?: 'photo' | 'voice' | 'video'; mediaType?: 'photo' | 'voice' | 'video'
createdAt: string; createdAt: string
} }
// Polling interval — replace with WebSocket when backend supports it // Polling interval — replace with WebSocket when backend supports it
const POLL_INTERVAL = 2000; const POLL_INTERVAL = 2000
const chats = ref<Chat[]>([]); const chats = ref<Chat[]>([])
const activeChat = ref<Chat | null>(null); const activeChat = ref<Chat | null>(null)
const messages = ref<ChatMessage[]>([]); const messages = ref<ChatMessage[]>([])
const pollingTimer = ref<ReturnType<typeof setInterval> | null>(null); const pollingTimer = ref<ReturnType<typeof setInterval> | null>(null)
const loading = ref(false); const loading = ref(false)
async function fetchChats(profileId: string) { async function fetchChats(profileId: string) {
const res = await apiClient.api.chatControllerGetChats({ profileId }) as unknown as Chat[]; const res = await apiClient.api.chatControllerGetChats({ profileId }) as unknown as Chat[]
chats.value = res; chats.value = res
} }
async function fetchMessages(chatId: string, profileId: string) { async function fetchMessages(chatId: string, profileId: string) {
loading.value = true; loading.value = true
try { try {
const res = await apiClient.api.chatControllerGetMessages({ chatId, profileId }) as unknown as ChatMessage[]; const res = await apiClient.api.chatControllerGetMessages({ chatId, profileId }) as unknown as ChatMessage[]
messages.value = res; messages.value = res
} finally { }
loading.value = false; finally {
loading.value = false
} }
} }
async function sendMessage(chatId: string, profileId: string, dto: SendMessageDto) { async function sendMessage(chatId: string, profileId: string, dto: SendMessageDto) {
const res = await apiClient.api.chatControllerSendMessage({ chatId, profileId }, dto) as unknown as ChatMessage; const res = await apiClient.api.chatControllerSendMessage({ chatId, profileId }, dto) as unknown as ChatMessage
messages.value.push(res); messages.value.push(res)
return res; return res
} }
async function openChat(profileId: string, matchId: string) { async function openChat(profileId: string, matchId: string) {
const res = await apiClient.api.chatControllerCreateChat({ profileId, matchId }) as unknown as Chat; const res = await apiClient.api.chatControllerCreateChat({ profileId, matchId }) as unknown as Chat
const existing = chats.value.findIndex((c) => c.id === res.id); const existing = chats.value.findIndex(c => c.id === res.id)
if (existing === -1) chats.value.unshift(res); if (existing === -1)
activeChat.value = res; chats.value.unshift(res)
return res; activeChat.value = res
return res
} }
async function closeChat(chatId: string, profileId: string) { async function closeChat(chatId: string, profileId: string) {
await apiClient.api.chatControllerCloseChat({ chatId, profileId }); await apiClient.api.chatControllerCloseChat({ chatId, profileId })
chats.value = chats.value.filter((c) => c.id !== chatId); chats.value = chats.value.filter(c => c.id !== chatId)
if (activeChat.value?.id === chatId) activeChat.value = null; if (activeChat.value?.id === chatId)
activeChat.value = null
} }
function startPolling(chatId: string, profileId: string) { function startPolling(chatId: string, profileId: string) {
stopPolling(); stopPolling()
// TODO: replace with WebSocket subscription // TODO: replace with WebSocket subscription
pollingTimer.value = setInterval(async () => { pollingTimer.value = setInterval(async () => {
try { try {
const res = await apiClient.api.chatControllerGetMessages({ chatId, profileId }) as unknown as ChatMessage[]; const res = await apiClient.api.chatControllerGetMessages({ chatId, profileId }) as unknown as ChatMessage[]
if (res.length > messages.value.length) { if (res.length > messages.value.length) {
messages.value = res; messages.value = res
} }
} catch { }
catch {
// polling errors are silent // polling errors are silent
} }
}, POLL_INTERVAL); }, POLL_INTERVAL)
} }
function stopPolling() { function stopPolling() {
if (pollingTimer.value) { if (pollingTimer.value) {
clearInterval(pollingTimer.value); clearInterval(pollingTimer.value)
pollingTimer.value = null; pollingTimer.value = null
} }
} }
function setActiveChat(chat: Chat | null) { function setActiveChat(chat: Chat | null) {
activeChat.value = chat; activeChat.value = chat
} }
export function useChat() { export function useChat() {
@@ -113,5 +117,5 @@ export function useChat() {
startPolling, startPolling,
stopPolling, stopPolling,
setActiveChat, setActiveChat,
}); })
} }

View File

@@ -1,66 +1,69 @@
import { ref, reactive } from 'vue'; import type { FeedControllerGetFeedParams, MediaItemDto, TagDto } from '@/api/api'
import { apiClient } from '@/api/client'; import { reactive, ref } from 'vue'
import type { FeedControllerGetFeedParams, TagDto, MediaItemDto } from '@/api/api'; import { apiClient } from '@/api/client'
export interface FeedProfile { export interface FeedProfile {
id: string; id: string
name: string; name: string
birthDate: string; birthDate: string
gender: 'male' | 'female'; gender: 'male' | 'female'
cityId?: string | null; cityId?: string | null
cityName?: string; cityName?: string
districtId?: string | null; districtId?: string | null
description?: string | null; description?: string | null
nation?: string | null; nation?: string | null
height?: number | null; height?: number | null
weight?: number | null; weight?: number | null
tags?: TagDto[]; tags?: TagDto[]
media?: MediaItemDto[]; media?: MediaItemDto[]
} }
const cards = ref<FeedProfile[]>([]); const cards = ref<FeedProfile[]>([])
const filters = reactive<Partial<FeedControllerGetFeedParams>>({}); const filters = reactive<Partial<FeedControllerGetFeedParams>>({})
const page = ref(1); const page = ref(1)
const hasMore = ref(true); const hasMore = ref(true)
const searchPaused = ref(false); const searchPaused = ref(false)
const loading = ref(false); const loading = ref(false)
async function fetchNextPage(profileId: string) { async function fetchNextPage(profileId: string) {
if (loading.value || !hasMore.value) return; if (loading.value || !hasMore.value)
loading.value = true; return
loading.value = true
try { try {
const res = await apiClient.api.feedControllerGetFeed({ const res = await apiClient.api.feedControllerGetFeed({
profileId, profileId,
page: page.value, page: page.value,
limit: 20, limit: 20,
...filters, ...filters,
}) as unknown as FeedProfile[]; }) as unknown as FeedProfile[]
if (page.value === 1) cards.value = res; if (page.value === 1)
else cards.value.push(...res); cards.value = res
else cards.value.push(...res)
hasMore.value = res.length >= 20; hasMore.value = res.length >= 20
page.value++; page.value++
} finally { }
loading.value = false; finally {
loading.value = false
} }
} }
function applyFilters(newFilters: Partial<FeedControllerGetFeedParams>) { function applyFilters(newFilters: Partial<FeedControllerGetFeedParams>) {
Object.assign(filters, newFilters); Object.assign(filters, newFilters)
reset(); reset()
} }
function reset() { function reset() {
cards.value = []; cards.value = []
page.value = 1; page.value = 1
hasMore.value = true; hasMore.value = true
} }
function removeCard(profileId: string) { function removeCard(profileId: string) {
cards.value = cards.value.filter((c) => c.id !== profileId); cards.value = cards.value.filter(c => c.id !== profileId)
} }
export function useFeed() { export function useFeed() {
return reactive({ cards, filters, page, hasMore, searchPaused, loading, fetchNextPage, applyFilters, reset, removeCard }); return reactive({ cards, filters, page, hasMore, searchPaused, loading, fetchNextPage, applyFilters, reset, removeCard })
} }

View File

@@ -1,39 +1,40 @@
import { ref } from 'vue'; import type { UserProfile } from './useAuth'
import { apiClient } from '@/api/client'; import type { CreateProfileDto, UpdateProfileDto } from '@/api/api'
import type { CreateProfileDto, UpdateProfileDto } from '@/api/api'; import { ref } from 'vue'
import type { UserProfile } from './useAuth'; import { apiClient } from '@/api/client'
const currentProfile = ref<UserProfile | null>(null); const currentProfile = ref<UserProfile | null>(null)
const loading = ref(false); const loading = ref(false)
async function fetchProfile(profileId: string) { async function fetchProfile(profileId: string) {
loading.value = true; loading.value = true
try { try {
const res = await apiClient.api.profilesControllerFindOne(profileId) as unknown as UserProfile; const res = await apiClient.api.profilesControllerFindOne(profileId) as unknown as UserProfile
currentProfile.value = res; currentProfile.value = res
return res; return res
} finally { }
loading.value = false; finally {
loading.value = false
} }
} }
async function createProfile(dto: CreateProfileDto) { async function createProfile(dto: CreateProfileDto) {
const res = await apiClient.api.profilesControllerCreate(dto) as unknown as UserProfile; const res = await apiClient.api.profilesControllerCreate(dto) as unknown as UserProfile
currentProfile.value = res; currentProfile.value = res
return res; return res
} }
async function updateProfile(profileId: string, dto: UpdateProfileDto) { async function updateProfile(profileId: string, dto: UpdateProfileDto) {
const res = await apiClient.api.profilesControllerUpdate(profileId, dto) as unknown as UserProfile; const res = await apiClient.api.profilesControllerUpdate(profileId, dto) as unknown as UserProfile
currentProfile.value = res; currentProfile.value = res
return res; return res
} }
async function deleteProfile(profileId: string) { async function deleteProfile(profileId: string) {
await apiClient.api.profilesControllerDelete(profileId); await apiClient.api.profilesControllerDelete(profileId)
currentProfile.value = null; currentProfile.value = null
} }
export function useProfile() { export function useProfile() {
return { currentProfile, loading, fetchProfile, createProfile, updateProfile, deleteProfile }; return { currentProfile, loading, fetchProfile, createProfile, updateProfile, deleteProfile }
} }

View File

@@ -1,65 +1,65 @@
import { ref, reactive } from 'vue'; import { reactive, ref } from 'vue'
export type ToastType = 'success' | 'error' | 'info' | 'warning'; export type ToastType = 'success' | 'error' | 'info' | 'warning'
export interface Toast { export interface Toast {
id: string; id: string
type: ToastType; type: ToastType
message: string; message: string
duration?: number; duration?: number
} }
export interface Tag { export interface Tag {
id: string; id: string
value: string; value: string
} }
export interface City { export interface City {
id: string; id: string
name: string; name: string
} }
export interface District { export interface District {
id: string; id: string
name: string; name: string
cityId: string; cityId: string
} }
export interface Greeting { export interface Greeting {
id: string; id: string
text: string; text: string
} }
const toasts = ref<Toast[]>([]); const toasts = ref<Toast[]>([])
const sidebarExpanded = ref(false); const sidebarExpanded = ref(false)
const tags = ref<Tag[]>([]); const tags = ref<Tag[]>([])
const cities = ref<City[]>([]); const cities = ref<City[]>([])
const districts = reactive<Record<string, District[]>>({}); const districts = reactive<Record<string, District[]>>({})
const greetings = ref<Greeting[]>([]); const greetings = ref<Greeting[]>([])
const referencesLoaded = ref(false); const referencesLoaded = ref(false)
function addToast(message: string, type: ToastType = 'info', duration = 4000) { function addToast(message: string, type: ToastType = 'info', duration = 4000) {
const id = `${Date.now()}-${Math.random()}`; const id = `${Date.now()}-${Math.random()}`
toasts.value.push({ id, type, message, duration }); toasts.value.push({ id, type, message, duration })
if (duration > 0) { if (duration > 0) {
setTimeout(() => removeToast(id), duration); setTimeout(removeToast, duration, id)
} }
return id; return id
} }
function removeToast(id: string) { function removeToast(id: string) {
toasts.value = toasts.value.filter((t) => t.id !== id); toasts.value = toasts.value.filter(t => t.id !== id)
} }
function setSidebarExpanded(value: boolean) { function setSidebarExpanded(value: boolean) {
sidebarExpanded.value = value; sidebarExpanded.value = value
} }
function setTags(data: Tag[]) { tags.value = data; } function setTags(data: Tag[]) { tags.value = data }
function setCities(data: City[]) { cities.value = data; } function setCities(data: City[]) { cities.value = data }
function setDistricts(cityId: string, data: District[]) { districts[cityId] = data; } function setDistricts(cityId: string, data: District[]) { districts[cityId] = data }
function setGreetings(data: Greeting[]) { greetings.value = data; } function setGreetings(data: Greeting[]) { greetings.value = data }
function setReferencesLoaded() { referencesLoaded.value = true; } function setReferencesLoaded() { referencesLoaded.value = true }
export function useUi() { export function useUi() {
return reactive({ return reactive({
@@ -78,5 +78,5 @@ export function useUi() {
setDistricts, setDistricts,
setGreetings, setGreetings,
setReferencesLoaded, setReferencesLoaded,
}); })
} }

View File

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

View File

@@ -1,9 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'; import axios from 'axios'
import { useAuth } from '@/composables/useAuth'; import { createRouter, createWebHistory } from 'vue-router'
import { _getAccessToken, _setAccessToken } from '@/api/client'; import { _getAccessToken, _setAccessToken, BASE_URL } from '@/api/client'
import axios from 'axios'; import { useAuth } from '@/composables/useAuth'
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000';
export const router = createRouter({ export const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
@@ -38,57 +36,58 @@ export const router = createRouter({
// Catch-all // Catch-all
{ path: '/:pathMatch(.*)*', redirect: '/' }, { path: '/:pathMatch(.*)*', redirect: '/' },
], ],
}); })
// ─── Navigation guard ──────────────────────────────────────────────────────── // ─── Navigation guard ────────────────────────────────────────────────────────
let _initDone = false; let _initDone = false
router.beforeEach(async (to, _from, next) => { router.beforeEach(async (to, _from, next) => {
const authStore = useAuth(); const authStore = useAuth()
// First navigation: try to restore session from localStorage refresh token // First navigation: try to restore session from localStorage refresh token
if (!_initDone) { if (!_initDone) {
_initDone = true; _initDone = true
const storedRefresh = localStorage.getItem('refreshToken'); const storedRefresh = localStorage.getItem('refreshToken')
if (storedRefresh && !_getAccessToken()) { if (storedRefresh && !_getAccessToken()) {
try { try {
const res = await axios.post<{ data: { accessToken: string; refreshToken: string } }>( const res = await axios.post<{ data: { accessToken: string, refreshToken: string } }>(
`${BASE_URL}/api/v1/auth/refresh`, `${BASE_URL}/api/v1/auth/refresh`,
{ refreshToken: storedRefresh }, { refreshToken: storedRefresh },
); )
_setAccessToken(res.data.data.accessToken); _setAccessToken(res.data.data.accessToken)
localStorage.setItem('refreshToken', res.data.data.refreshToken); localStorage.setItem('refreshToken', res.data.data.refreshToken)
await authStore.fetchMe(); await authStore.fetchMe()
} catch { }
localStorage.removeItem('refreshToken'); catch {
localStorage.removeItem('refreshToken')
} }
} }
} }
const requiresAuth = to.meta.auth; const requiresAuth = to.meta.auth
const requiresGuest = to.meta.guest; const requiresGuest = to.meta.guest
const requiresAdmin = to.meta.admin; const requiresAdmin = to.meta.admin
const isAuthed = authStore.isAuthenticated; const isAuthed = authStore.isAuthenticated
if (requiresAuth && !isAuthed) { if (requiresAuth && !isAuthed) {
return next({ name: 'login', query: { redirect: to.fullPath } }); return next({ name: 'login', query: { redirect: to.fullPath } })
} }
if (requiresGuest && isAuthed) { if (requiresGuest && isAuthed) {
return next({ name: 'feed' }); return next({ name: 'feed' })
} }
if (requiresAdmin && !authStore.isAdmin) { if (requiresAdmin && !authStore.isAdmin) {
return next({ name: 'feed' }); return next({ name: 'feed' })
} }
// Redirect to setup if authenticated but no profiles // Redirect to setup if authenticated but no profiles
if (requiresAuth && isAuthed && !authStore.hasProfiles && to.name !== 'setup') { if (requiresAuth && isAuthed && !authStore.hasProfiles && to.name !== 'setup') {
return next({ name: 'setup' }); return next({ name: 'setup' })
} }
next(); next()
}); })
export default router; export default router

View File

@@ -2,49 +2,98 @@
@use 'variables' as *; @use 'variables' as *;
@keyframes fade-in { @keyframes fade-in {
from { opacity: 0; } from {
to { opacity: 1; } opacity: 0;
}
to {
opacity: 1;
}
} }
@keyframes fade-up { @keyframes fade-up {
from { opacity: 0; transform: translateY(12px); } from {
to { opacity: 1; transform: translateY(0); } opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
@keyframes fade-down { @keyframes fade-down {
from { opacity: 0; transform: translateY(-12px); } from {
to { opacity: 1; transform: translateY(0); } opacity: 0;
transform: translateY(-12px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
@keyframes slide-in-right { @keyframes slide-in-right {
from { opacity: 0; transform: translateX(24px); } from {
to { opacity: 1; transform: translateX(0); } opacity: 0;
transform: translateX(24px);
}
to {
opacity: 1;
transform: translateX(0);
}
} }
@keyframes slide-in-left { @keyframes slide-in-left {
from { opacity: 0; transform: translateX(-24px); } from {
to { opacity: 1; transform: translateX(0); } opacity: 0;
transform: translateX(-24px);
}
to {
opacity: 1;
transform: translateX(0);
}
} }
@keyframes scale-in { @keyframes scale-in {
from { opacity: 0; transform: scale(0.94); } from {
to { opacity: 1; transform: scale(1); } opacity: 0;
transform: scale(0.94);
}
to {
opacity: 1;
transform: scale(1);
}
} }
@keyframes shimmer { @keyframes shimmer {
0% { background-position: -200% 0; } 0% {
100% { background-position: 200% 0; } background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
} }
@keyframes pulse-signal { @keyframes pulse-signal {
0%, 100% { opacity: 1; } 0%,
50% { opacity: 0.5; } 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
} }
// Utility classes // Utility classes
.animate-fade-in { animation: fade-in var(--transition-base) both; } .animate-fade-in {
.animate-fade-up { animation: fade-up var(--transition-base) both; } animation: fade-in var(--transition-base) both;
.animate-scale-in { animation: scale-in var(--transition-spring) both; } }
.animate-fade-up {
animation: fade-up var(--transition-base) both;
}
.animate-scale-in {
animation: scale-in var(--transition-spring) both;
}
// Skeleton shimmer // Skeleton shimmer
.skeleton { .skeleton {

View File

@@ -24,7 +24,8 @@ body {
letter-spacing: -0.02em; letter-spacing: -0.02em;
} }
h1, .h1 { h1,
.h1 {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 2.5rem; font-size: 2.5rem;
font-weight: 400; font-weight: 400;
@@ -34,7 +35,8 @@ h1, .h1 {
color: var(--color-cream); color: var(--color-cream);
} }
h2, .h2 { h2,
.h2 {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 1.75rem; font-size: 1.75rem;
font-weight: 400; font-weight: 400;
@@ -44,7 +46,8 @@ h2, .h2 {
color: var(--color-cream); color: var(--color-cream);
} }
h3, .h3 { h3,
.h3 {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 1.125rem; font-size: 1.125rem;
font-weight: 500; font-weight: 500;
@@ -90,6 +93,12 @@ a {
// Responsive heading scale // Responsive heading scale
@include mobile { @include mobile {
h1, .h1 { font-size: 2rem; } h1,
h2, .h2 { font-size: 1.5rem; } .h1 {
font-size: 2rem;
}
h2,
.h2 {
font-size: 1.5rem;
}
} }

View File

@@ -67,7 +67,23 @@ $tablet: 768px;
$desktop: 1024px; $desktop: 1024px;
$wide: 1440px; $wide: 1440px;
@mixin mobile { @media (max-width: #{$tablet - 1px}) { @content; } } @mixin mobile {
@mixin tablet { @media (min-width: $tablet) { @content; } } @media (max-width: #{$tablet - 1px}) {
@mixin desktop { @media (min-width: $desktop) { @content; } } @content;
@mixin wide { @media (min-width: $wide) { @content; } } }
}
@mixin tablet {
@media (min-width: $tablet) {
@content;
}
}
@mixin desktop {
@media (min-width: $desktop) {
@content;
}
}
@mixin wide {
@media (min-width: $wide) {
@content;
}
}

View File

@@ -1 +1 @@
@import "tailwindcss"; @import 'tailwindcss';

View File

@@ -1,56 +1,9 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useUi } from '@/composables/useUi';
import { apiClient } from '@/api/client';
import AppButton from '@/components/common/AppButton.vue';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
import EmptyState from '@/components/common/EmptyState.vue';
interface Report {
id: string;
sourceProfileId: string;
entityId: string;
entityType: 'profile' | 'message';
description?: string;
createdAt: string;
resolved?: boolean;
reporterName?: string;
}
const uiStore = useUi();
const reports = ref<Report[]>([]);
const loading = ref(false);
onMounted(async () => {
loading.value = true;
try {
const res = await apiClient.api.reportsControllerGetAll() as unknown as Report[];
reports.value = res;
} catch {
uiStore.addToast('Не удалось загрузить жалобы', 'error');
} finally {
loading.value = false;
}
});
async function banUser(userId: string) {
try {
await apiClient.api.usersControllerBan(userId);
uiStore.addToast('Пользователь заблокирован', 'success');
} catch {
uiStore.addToast('Не удалось заблокировать пользователя', 'error');
}
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('ru', { day: 'numeric', month: 'short', year: 'numeric' });
}
</script>
<template> <template>
<div class="reports-admin"> <div class="reports-admin">
<header class="reports-admin__header"> <header class="reports-admin__header">
<h1 class="reports-admin__title">Жалобы</h1> <h1 class="reports-admin__title">
Жалобы
</h1>
<span class="meta">{{ reports.length }} всего</span> <span class="meta">{{ reports.length }} всего</span>
</header> </header>
@@ -78,7 +31,9 @@ function formatDate(iso: string) {
</thead> </thead>
<tbody> <tbody>
<tr v-for="report in reports" :key="report.id" :class="{ 'reports-table__row--resolved': report.resolved }"> <tr v-for="report in reports" :key="report.id" :class="{ 'reports-table__row--resolved': report.resolved }">
<td class="reports-table__date meta">{{ formatDate(report.createdAt) }}</td> <td class="reports-table__date meta">
{{ formatDate(report.createdAt) }}
</td>
<td> <td>
<span class="reports-table__type" :class="`reports-table__type--${report.entityType}`"> <span class="reports-table__type" :class="`reports-table__type--${report.entityType}`">
{{ report.entityType === 'profile' ? 'Профиль' : 'Сообщение' }} {{ report.entityType === 'profile' ? 'Профиль' : 'Сообщение' }}
@@ -89,17 +44,23 @@ function formatDate(iso: string) {
v-if="report.entityType === 'profile'" v-if="report.entityType === 'profile'"
:to="`/profile/${report.entityId}`" :to="`/profile/${report.entityId}`"
class="reports-table__link" class="reports-table__link"
>Открыть</RouterLink> >
Открыть
</RouterLink>
<span v-else class="meta">{{ report.entityId.slice(0, 8) }}</span> <span v-else class="meta">{{ report.entityId.slice(0, 8) }}</span>
</td> </td>
<td class="reports-table__desc">{{ report.description ?? '—' }}</td> <td class="reports-table__desc">
{{ report.description ?? '—' }}
</td>
<td> <td>
<AppButton <AppButton
v-if="report.entityType === 'profile'" v-if="report.entityType === 'profile'"
variant="danger" variant="danger"
size="sm" size="sm"
@click="banUser(report.sourceProfileId)" @click="banUser(report.sourceProfileId)"
>Заблокировать</AppButton> >
Заблокировать
</AppButton>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -108,6 +69,58 @@ function formatDate(iso: string) {
</div> </div>
</template> </template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { apiClient } from '@/api/client'
import AppButton from '@/components/common/AppButton.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import { useUi } from '@/composables/useUi'
interface Report {
id: string
sourceProfileId: string
entityId: string
entityType: 'profile' | 'message'
description?: string
createdAt: string
resolved?: boolean
reporterName?: string
}
const uiStore = useUi()
const reports = ref<Report[]>([])
const loading = ref(false)
onMounted(async () => {
loading.value = true
try {
const res = await apiClient.api.reportsControllerGetAll() as unknown as Report[]
reports.value = res
}
catch {
uiStore.addToast('Не удалось загрузить жалобы', 'error')
}
finally {
loading.value = false
}
})
async function banUser(userId: string) {
try {
await apiClient.api.usersControllerBan(userId)
uiStore.addToast('Пользователь заблокирован', 'success')
}
catch {
uiStore.addToast('Не удалось заблокировать пользователя', 'error')
}
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('ru', { day: 'numeric', month: 'short', year: 'numeric' })
}
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.reports-admin { .reports-admin {
height: 100%; height: 100%;
@@ -168,11 +181,18 @@ function formatDate(iso: string) {
vertical-align: middle; vertical-align: middle;
} }
tr:last-child td { border-bottom: none; } tr:last-child td {
border-bottom: none;
}
&__row--resolved td { opacity: 0.5; } &__row--resolved td {
opacity: 0.5;
}
&__date { color: var(--color-muted) !important; font-variant-numeric: tabular-nums; } &__date {
color: var(--color-muted) !important;
font-variant-numeric: tabular-nums;
}
&__type { &__type {
padding: 2px 8px; padding: 2px 8px;
@@ -182,8 +202,14 @@ function formatDate(iso: string) {
letter-spacing: 0.06em; letter-spacing: 0.06em;
text-transform: uppercase; text-transform: uppercase;
&--profile { background: var(--color-signal-bg); color: var(--color-signal); } &--profile {
&--message { background: rgba(240, 235, 224, 0.06); color: var(--color-muted); } background: var(--color-signal-bg);
color: var(--color-signal);
}
&--message {
background: rgba(240, 235, 224, 0.06);
color: var(--color-muted);
}
} }
&__link { &__link {
@@ -192,7 +218,9 @@ function formatDate(iso: string) {
text-underline-offset: 3px; text-underline-offset: 3px;
text-decoration-color: var(--color-border); text-decoration-color: var(--color-border);
transition: color var(--transition-fast); transition: color var(--transition-fast);
&:hover { color: var(--color-signal); } &:hover {
color: var(--color-signal);
}
} }
&__desc { &__desc {

View File

@@ -1,57 +1,19 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useVuelidate } from '@vuelidate/core';
import { required, helpers } from '@vuelidate/validators';
import { useAuth } from '@/composables/useAuth';
import { useUi } from '@/composables/useUi';
import AppInput from '@/components/common/AppInput.vue';
import AppButton from '@/components/common/AppButton.vue';
const router = useRouter();
const route = useRoute();
const authStore = useAuth();
const uiStore = useUi();
const form = reactive({ phone: '', password: '' });
const loading = ref(false);
const rules = {
phone: { required: helpers.withMessage('Введите номер телефона', required) },
password: { required: helpers.withMessage('Введите пароль', required) },
};
const v$ = useVuelidate(rules, form);
async function submit() {
const valid = await v$.value.$validate();
if (!valid) return;
loading.value = true;
try {
await authStore.login({ phone: form.phone, password: form.password });
const redirect = (route.query.redirect as string) || '/feed';
router.replace(redirect);
} catch (err: unknown) {
const message = (err as { response?: { data?: { message?: string } } })
?.response?.data?.message ?? 'Неверный телефон или пароль';
uiStore.addToast(message, 'error');
} finally {
loading.value = false;
}
}
</script>
<template> <template>
<div class="auth-page"> <div class="auth-page">
<div class="auth-page__grain" aria-hidden="true" /> <div class="auth-page__grain" aria-hidden="true" />
<div class="auth-card"> <div class="auth-card">
<div class="auth-card__wordmark">Dating</div> <div class="auth-card__wordmark">
<h1 class="auth-card__heading">С возвращением</h1> Dating
<p class="auth-card__sub">Войдите, чтобы продолжить</p> </div>
<h1 class="auth-card__heading">
С возвращением
</h1>
<p class="auth-card__sub">
Войдите, чтобы продолжить
</p>
<form class="auth-form" @submit.prevent="submit" novalidate> <form class="auth-form" novalidate @submit.prevent="submit">
<AppInput <AppInput
v-model="form.phone" v-model="form.phone"
label="Телефон" label="Телефон"
@@ -81,12 +43,63 @@ async function submit() {
<p class="auth-card__footer"> <p class="auth-card__footer">
Нет аккаунта? Нет аккаунта?
<RouterLink to="/register" class="auth-card__link">Зарегистрироваться</RouterLink> <RouterLink to="/register" class="auth-card__link">
Зарегистрироваться
</RouterLink>
</p> </p>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts">
import { useVuelidate } from '@vuelidate/core'
import { helpers, required } from '@vuelidate/validators'
import { reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppButton from '@/components/common/AppButton.vue'
import AppInput from '@/components/common/AppInput.vue'
import { useAuth } from '@/composables/useAuth'
import { useUi } from '@/composables/useUi'
const router = useRouter()
const route = useRoute()
const authStore = useAuth()
const uiStore = useUi()
const form = reactive({ phone: '', password: '' })
const loading = ref(false)
const rules = {
phone: { required: helpers.withMessage('Введите номер телефона', required) },
password: { required: helpers.withMessage('Введите пароль', required) },
}
const v$ = useVuelidate(rules, form)
async function submit() {
const valid = await v$.value.$validate()
if (!valid)
return
loading.value = true
try {
await authStore.login({ phone: form.phone, password: form.password })
const redirect = (route.query.redirect as string) || '/feed'
router.replace(redirect)
}
catch (err: unknown) {
const message = (err as { response?: { data?: { message?: string } } })
?.response
?.data
?.message ?? 'Неверный телефон или пароль'
uiStore.addToast(message, 'error')
}
finally {
loading.value = false
}
}
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.auth-page { .auth-page {
min-height: 100dvh; min-height: 100dvh;
@@ -158,7 +171,9 @@ async function submit() {
color: var(--color-cream); color: var(--color-cream);
transition: color var(--transition-fast); transition: color var(--transition-fast);
&:hover { color: var(--color-signal); } &:hover {
color: var(--color-signal);
}
} }
} }

View File

@@ -1,67 +1,19 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength, helpers } from '@vuelidate/validators';
import { useAuth } from '@/composables/useAuth';
import { useUi } from '@/composables/useUi';
import AppInput from '@/components/common/AppInput.vue';
import AppButton from '@/components/common/AppButton.vue';
const router = useRouter();
const authStore = useAuth();
const uiStore = useUi();
const form = reactive({ phone: '', password: '', confirmPassword: '' });
const loading = ref(false);
const phoneRegex = helpers.regex(/^\+?[0-9\s\-()]{7,20}$/);
const rules = {
phone: {
required: helpers.withMessage('Введите номер телефона', required),
format: helpers.withMessage('Введите корректный номер', phoneRegex),
},
password: {
required: helpers.withMessage('Введите пароль', required),
minLen: helpers.withMessage('Минимум 8 символов', minLength(8)),
},
confirmPassword: {
required: helpers.withMessage('Подтвердите пароль', required),
match: helpers.withMessage('Пароли не совпадают', () => form.password === form.confirmPassword),
},
};
const v$ = useVuelidate(rules, form);
async function submit() {
const valid = await v$.value.$validate();
if (!valid) return;
loading.value = true;
try {
await authStore.register({ phone: form.phone, password: form.password });
router.replace('/setup');
} catch (err: unknown) {
const message = (err as { response?: { data?: { message?: string } } })
?.response?.data?.message ?? 'Ошибка регистрации. Попробуйте ещё раз.';
uiStore.addToast(message, 'error');
} finally {
loading.value = false;
}
}
</script>
<template> <template>
<div class="auth-page"> <div class="auth-page">
<div class="auth-page__grain" aria-hidden="true" /> <div class="auth-page__grain" aria-hidden="true" />
<div class="auth-card"> <div class="auth-card">
<div class="auth-card__wordmark">Dating</div> <div class="auth-card__wordmark">
<h1 class="auth-card__heading">Создать аккаунт</h1> Dating
<p class="auth-card__sub">Начните своё путешествие</p> </div>
<h1 class="auth-card__heading">
Создать аккаунт
</h1>
<p class="auth-card__sub">
Начните своё путешествие
</p>
<form class="auth-form" @submit.prevent="submit" novalidate> <form class="auth-form" novalidate @submit.prevent="submit">
<AppInput <AppInput
v-model="form.phone" v-model="form.phone"
label="Телефон" label="Телефон"
@@ -102,12 +54,73 @@ async function submit() {
<p class="auth-card__footer"> <p class="auth-card__footer">
Уже есть аккаунт? Уже есть аккаунт?
<RouterLink to="/login" class="auth-card__link">Войти</RouterLink> <RouterLink to="/login" class="auth-card__link">
Войти
</RouterLink>
</p> </p>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts">
import { useVuelidate } from '@vuelidate/core'
import { helpers, minLength, required } from '@vuelidate/validators'
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import AppButton from '@/components/common/AppButton.vue'
import AppInput from '@/components/common/AppInput.vue'
import { useAuth } from '@/composables/useAuth'
import { useUi } from '@/composables/useUi'
const router = useRouter()
const authStore = useAuth()
const uiStore = useUi()
const form = reactive({ phone: '', password: '', confirmPassword: '' })
const loading = ref(false)
const phoneRegex = helpers.regex(/^\+?[0-9\s\-()]{7,20}$/)
const rules = {
phone: {
required: helpers.withMessage('Введите номер телефона', required),
format: helpers.withMessage('Введите корректный номер', phoneRegex),
},
password: {
required: helpers.withMessage('Введите пароль', required),
minLen: helpers.withMessage('Минимум 8 символов', minLength(8)),
},
confirmPassword: {
required: helpers.withMessage('Подтвердите пароль', required),
match: helpers.withMessage('Пароли не совпадают', () => form.password === form.confirmPassword),
},
}
const v$ = useVuelidate(rules, form)
async function submit() {
const valid = await v$.value.$validate()
if (!valid)
return
loading.value = true
try {
await authStore.register({ phone: form.phone, password: form.password })
router.replace('/setup')
}
catch (err: unknown) {
const message = (err as { response?: { data?: { message?: string } } })
?.response
?.data
?.message ?? 'Ошибка регистрации. Попробуйте ещё раз.'
uiStore.addToast(message, 'error')
}
finally {
loading.value = false
}
}
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.auth-page { .auth-page {
min-height: 100dvh; min-height: 100dvh;
@@ -177,7 +190,9 @@ async function submit() {
&__link { &__link {
color: var(--color-cream); color: var(--color-cream);
transition: color var(--transition-fast); transition: color var(--transition-fast);
&:hover { color: var(--color-signal); } &:hover {
color: var(--color-signal);
}
} }
} }
@@ -186,6 +201,8 @@ async function submit() {
flex-direction: column; flex-direction: column;
gap: 20px; gap: 20px;
&__submit { margin-top: 8px; } &__submit {
margin-top: 8px;
}
} }
</style> </style>

View File

@@ -1,90 +1,8 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick, computed } from 'vue';
import { useRoute } from 'vue-router';
import { useAuth } from '@/composables/useAuth';
import { useChat } from '@/composables/useChat';
import { useUi } from '@/composables/useUi';
import { apiClient } from '@/api/client';
import type { ChatMessage } from '@/composables/useChat';
import ChatBubble from '@/components/chat/ChatBubble.vue';
import ChatInput from '@/components/chat/ChatInput.vue';
import AppModal from '@/components/common/AppModal.vue';
import AppButton from '@/components/common/AppButton.vue';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
const route = useRoute();
const authStore = useAuth();
const chatStore = useChat();
const uiStore = useUi();
const chatId = route.params.chatId as string;
const profileId = computed(() => authStore.activeProfile?.id ?? '');
const messagesEnd = ref<HTMLElement | null>(null);
const confirmClose = ref(false);
const chat = computed(() => chatStore.chats.find((c) => c.id === chatId));
const isLocked = computed(() => chat.value?.status === 'closed');
// Group messages by date
const groupedMessages = computed(() => {
const groups: Array<{ date: string; messages: ChatMessage[] }> = [];
let lastDate = '';
for (const msg of chatStore.messages) {
const d = new Date(msg.createdAt).toLocaleDateString('ru', { day: 'numeric', month: 'long', year: 'numeric' });
if (d !== lastDate) {
groups.push({ date: d, messages: [] });
lastDate = d;
}
groups[groups.length - 1].messages.push(msg);
}
return groups;
});
async function scrollToBottom() {
await nextTick();
messagesEnd.value?.scrollIntoView({ behavior: 'smooth' });
}
onMounted(async () => {
if (!profileId.value) return;
await chatStore.fetchMessages(chatId, profileId.value);
await scrollToBottom();
chatStore.startPolling(chatId, profileId.value);
});
onUnmounted(() => {
chatStore.stopPolling();
});
async function send(text: string, mediaUrl?: string, mediaType?: 'photo' | 'voice' | 'video') {
if (!profileId.value) return;
try {
await chatStore.sendMessage(chatId, profileId.value, { text, mediaUrl, mediaType });
await scrollToBottom();
} catch {
uiStore.addToast('Не удалось отправить сообщение', 'error');
}
}
function goBack() { window.history.back(); }
async function doCloseChat() {
if (!profileId.value) return;
try {
await chatStore.closeChat(chatId, profileId.value);
confirmClose.value = false;
goBack();
} catch {
uiStore.addToast('Не удалось закрыть чат', 'error');
}
}
</script>
<template> <template>
<div class="chat-room"> <div class="chat-room">
<!-- Header --> <!-- Header -->
<header class="chat-room__header"> <header class="chat-room__header">
<button class="chat-room__back" @click="goBack()" aria-label="Назад"> <button class="chat-room__back" aria-label="Назад" @click="goBack()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="20" height="20"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="20" height="20">
<path d="M19 12H5M12 5l-7 7 7 7" /> <path d="M19 12H5M12 5l-7 7 7 7" />
</svg> </svg>
@@ -95,14 +13,14 @@ async function doCloseChat() {
:src="chat.partner.avatarUrl" :src="chat.partner.avatarUrl"
class="chat-room__avatar" class="chat-room__avatar"
:alt="chat.partner?.name" :alt="chat.partner?.name"
/> >
<div v-else class="chat-room__avatar chat-room__avatar--placeholder" /> <div v-else class="chat-room__avatar chat-room__avatar--placeholder" />
<span class="chat-room__name">{{ chat?.partner?.name ?? 'Чат' }}</span> <span class="chat-room__name">{{ chat?.partner?.name ?? 'Чат' }}</span>
</div> </div>
<button <button
class="chat-room__close-btn" class="chat-room__close-btn"
@click="confirmClose = true"
aria-label="Закрыть чат" aria-label="Закрыть чат"
@click="confirmClose = true"
> >
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="18" height="18"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="18" height="18">
<path d="M18 6L6 18M6 6l12 12" /> <path d="M18 6L6 18M6 6l12 12" />
@@ -157,13 +75,103 @@ async function doCloseChat() {
Вы уверены? Переписка будет удалена и восстановить её нельзя. Вы уверены? Переписка будет удалена и восстановить её нельзя.
</p> </p>
<template #footer> <template #footer>
<AppButton variant="ghost" @click="confirmClose = false">Отмена</AppButton> <AppButton variant="ghost" @click="confirmClose = false">
<AppButton variant="danger" @click="doCloseChat">Закрыть чат</AppButton> Отмена
</AppButton>
<AppButton variant="danger" @click="doCloseChat">
Закрыть чат
</AppButton>
</template> </template>
</AppModal> </AppModal>
</div> </div>
</template> </template>
<script setup lang="ts">
import type { ChatMessage } from '@/composables/useChat'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import ChatBubble from '@/components/chat/ChatBubble.vue'
import ChatInput from '@/components/chat/ChatInput.vue'
import AppButton from '@/components/common/AppButton.vue'
import AppModal from '@/components/common/AppModal.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import { useAuth } from '@/composables/useAuth'
import { useChat } from '@/composables/useChat'
import { useUi } from '@/composables/useUi'
const route = useRoute()
const authStore = useAuth()
const chatStore = useChat()
const uiStore = useUi()
const chatId = route.params.chatId as string
const profileId = computed(() => authStore.activeProfile?.id ?? '')
const messagesEnd = ref<HTMLElement | null>(null)
const confirmClose = ref(false)
const chat = computed(() => chatStore.chats.find(c => c.id === chatId))
const isLocked = computed(() => chat.value?.status === 'closed')
// Group messages by date
const groupedMessages = computed(() => {
const groups: Array<{ date: string, messages: ChatMessage[] }> = []
let lastDate = ''
for (const msg of chatStore.messages) {
const d = new Date(msg.createdAt).toLocaleDateString('ru', { day: 'numeric', month: 'long', year: 'numeric' })
if (d !== lastDate) {
groups.push({ date: d, messages: [] })
lastDate = d
}
groups[groups.length - 1].messages.push(msg)
}
return groups
})
async function scrollToBottom() {
await nextTick()
messagesEnd.value?.scrollIntoView({ behavior: 'smooth' })
}
onMounted(async () => {
if (!profileId.value)
return
await chatStore.fetchMessages(chatId, profileId.value)
await scrollToBottom()
chatStore.startPolling(chatId, profileId.value)
})
onUnmounted(() => {
chatStore.stopPolling()
})
async function send(text: string, mediaUrl?: string, mediaType?: 'photo' | 'voice' | 'video') {
if (!profileId.value)
return
try {
await chatStore.sendMessage(chatId, profileId.value, { text, mediaUrl, mediaType })
await scrollToBottom()
}
catch {
uiStore.addToast('Не удалось отправить сообщение', 'error')
}
}
function goBack() { window.history.back() }
async function doCloseChat() {
if (!profileId.value)
return
try {
await chatStore.closeChat(chatId, profileId.value)
confirmClose.value = false
goBack()
}
catch {
uiStore.addToast('Не удалось закрыть чат', 'error')
}
}
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.chat-room { .chat-room {
height: 100%; height: 100%;
@@ -189,7 +197,9 @@ async function doCloseChat() {
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
transition: color var(--transition-fast); transition: color var(--transition-fast);
flex-shrink: 0; flex-shrink: 0;
&:hover { color: var(--color-cream); } &:hover {
color: var(--color-cream);
}
} }
&__partner { &__partner {
@@ -228,7 +238,9 @@ async function doCloseChat() {
display: flex; display: flex;
transition: color var(--transition-fast); transition: color var(--transition-fast);
flex-shrink: 0; flex-shrink: 0;
&:hover { color: var(--color-signal); } &:hover {
color: var(--color-signal);
}
} }
&__locked { &__locked {
@@ -263,7 +275,9 @@ async function doCloseChat() {
} }
} }
&__group { margin-bottom: 16px; } &__group {
margin-bottom: 16px;
}
&__date-sep { &__date-sep {
display: flex; display: flex;

View File

@@ -1,42 +1,9 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useAuth } from '@/composables/useAuth';
import { useChat } from '@/composables/useChat';
import { useUi } from '@/composables/useUi';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
import EmptyState from '@/components/common/EmptyState.vue';
const authStore = useAuth();
const chatStore = useChat();
const uiStore = useUi();
const loading = ref(false);
onMounted(async () => {
const profileId = authStore.activeProfile?.id;
if (!profileId) return;
loading.value = true;
try {
await chatStore.fetchChats(profileId);
} catch {
uiStore.addToast('Не удалось загрузить чаты', 'error');
} finally {
loading.value = false;
}
});
function formatTime(dateStr: string) {
const d = new Date(dateStr);
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
if (isToday) return d.toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' });
return d.toLocaleDateString('ru', { day: 'numeric', month: 'short' });
}
</script>
<template> <template>
<div class="chats-list"> <div class="chats-list">
<header class="chats-list__header"> <header class="chats-list__header">
<h1 class="chats-list__title">Чаты</h1> <h1 class="chats-list__title">
Чаты
</h1>
</header> </header>
<div v-if="loading" class="chats-list__loading"> <div v-if="loading" class="chats-list__loading">
@@ -63,7 +30,7 @@ function formatTime(dateStr: string) {
:src="chat.partner.avatarUrl" :src="chat.partner.avatarUrl"
:alt="chat.partner?.name" :alt="chat.partner?.name"
class="chat-item__avatar" class="chat-item__avatar"
/> >
<div v-else class="chat-item__avatar chat-item__avatar--placeholder"> <div v-else class="chat-item__avatar chat-item__avatar--placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" width="24" height="24" opacity="0.3"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" width="24" height="24" opacity="0.3">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z" /> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z" />
@@ -88,16 +55,59 @@ function formatTime(dateStr: string) {
<p v-if="chat.lastMessage" class="chat-item__preview"> <p v-if="chat.lastMessage" class="chat-item__preview">
{{ chat.lastMessage.text || (chat.lastMessage.mediaType === 'photo' ? '📷 Фото' : chat.lastMessage.mediaType === 'voice' ? '🎤 Голосовое' : '🎬 Видео') }} {{ chat.lastMessage.text || (chat.lastMessage.mediaType === 'photo' ? '📷 Фото' : chat.lastMessage.mediaType === 'voice' ? '🎤 Голосовое' : '🎬 Видео') }}
</p> </p>
<p v-else class="chat-item__preview chat-item__preview--empty">Начните переписку</p> <p v-else class="chat-item__preview chat-item__preview--empty">
Начните переписку
</p>
</div> </div>
<div v-if="(chat.unreadCount ?? 0) > 0" class="chat-item__badge">{{ chat.unreadCount }}</div> <div v-if="(chat.unreadCount ?? 0) > 0" class="chat-item__badge">
{{ chat.unreadCount }}
</div>
</RouterLink> </RouterLink>
</li> </li>
</ul> </ul>
</div> </div>
</template> </template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import EmptyState from '@/components/common/EmptyState.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import { useAuth } from '@/composables/useAuth'
import { useChat } from '@/composables/useChat'
import { useUi } from '@/composables/useUi'
const authStore = useAuth()
const chatStore = useChat()
const uiStore = useUi()
const loading = ref(false)
onMounted(async () => {
const profileId = authStore.activeProfile?.id
if (!profileId)
return
loading.value = true
try {
await chatStore.fetchChats(profileId)
}
catch {
uiStore.addToast('Не удалось загрузить чаты', 'error')
}
finally {
loading.value = false
}
})
function formatTime(dateStr: string) {
const d = new Date(dateStr)
const now = new Date()
const isToday = d.toDateString() === now.toDateString()
if (isToday)
return d.toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' })
return d.toLocaleDateString('ru', { day: 'numeric', month: 'short' })
}
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.chats-list { .chats-list {
height: 100%; height: 100%;
@@ -141,7 +151,9 @@ function formatTime(dateStr: string) {
text-decoration: none; text-decoration: none;
transition: background var(--transition-fast); transition: background var(--transition-fast);
&:hover { background: rgba(240, 235, 224, 0.03); } &:hover {
background: rgba(240, 235, 224, 0.03);
}
&--inactive { &--inactive {
opacity: 0.6; opacity: 0.6;
@@ -218,7 +230,9 @@ function formatTime(dateStr: string) {
text-overflow: ellipsis; text-overflow: ellipsis;
margin: 0; margin: 0;
&--empty { font-style: italic; } &--empty {
font-style: italic;
}
} }
&__badge { &__badge {

View File

@@ -1,100 +1,9 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useAuth } from '@/composables/useAuth';
import { useUi } from '@/composables/useUi';
import { apiClient } from '@/api/client';
import AppButton from '@/components/common/AppButton.vue';
import AppModal from '@/components/common/AppModal.vue';
import EmptyState from '@/components/common/EmptyState.vue';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
interface DateStatus { id: string; name: string; }
interface DateItem {
id: string;
profileId: string;
partnerProfileId: string;
partnerName?: string;
lat: number;
lng: number;
time: string;
statusId: string;
statusName?: string;
isIncoming: boolean;
}
const authStore = useAuth();
const uiStore = useUi();
const dates = ref<DateItem[]>([]);
const statuses = ref<DateStatus[]>([]);
const loading = ref(false);
const actionLoading = ref<string | null>(null);
onMounted(async () => {
const profileId = authStore.activeProfile?.id;
if (!profileId) return;
loading.value = true;
try {
const [datesRes, statusesRes] = await Promise.all([
apiClient.api.datesControllerGetDates({ profileId }) as unknown as DateItem[],
apiClient.api.datesControllerGetStatuses() as unknown as DateStatus[],
]);
dates.value = datesRes;
statuses.value = statusesRes;
} catch {
uiStore.addToast('Не удалось загрузить встречи', 'error');
} finally {
loading.value = false;
}
});
function statusLabel(statusId: string) {
return statuses.value.find((s) => s.id === statusId)?.name ?? statusId;
}
function statusColor(statusId: string) {
const name = statusLabel(statusId).toLowerCase();
if (name.includes('ожид') || name.includes('pending')) return 'pending';
if (name.includes('приня') || name.includes('accept')) return 'accepted';
if (name.includes('отклон') || name.includes('declin')) return 'declined';
if (name.includes('заверш') || name.includes('complet')) return 'completed';
return 'default';
}
async function updateStatus(dateId: string, statusId: string) {
const profileId = authStore.activeProfile?.id;
if (!profileId) return;
actionLoading.value = `${dateId}-${statusId}`;
try {
await apiClient.api.datesControllerUpdateStatus({ id: dateId, profileId }, { statusId });
const idx = dates.value.findIndex((d) => d.id === dateId);
if (idx !== -1) dates.value[idx].statusId = statusId;
uiStore.addToast('Статус обновлён', 'success');
} catch {
uiStore.addToast('Не удалось обновить статус', 'error');
} finally {
actionLoading.value = null;
}
}
function formatDateTime(iso: string) {
return new Date(iso).toLocaleString('ru', {
day: 'numeric', month: 'long', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
// Pending = first two status ids roughly; accept/decline/complete = 3+
const pendingStatusId = computed(() => statuses.value[0]?.id ?? '');
const acceptedStatusId = computed(() => statuses.value[1]?.id ?? '');
const declinedStatusId = computed(() => statuses.value[2]?.id ?? '');
const completedStatusId = computed(() => statuses.value[3]?.id ?? '');
</script>
<template> <template>
<div class="dates-view"> <div class="dates-view">
<header class="dates-view__header"> <header class="dates-view__header">
<h1 class="dates-view__title">Встречи</h1> <h1 class="dates-view__title">
Встречи
</h1>
</header> </header>
<div v-if="loading" class="dates-view__loading"> <div v-if="loading" class="dates-view__loading">
@@ -112,7 +21,9 @@ const completedStatusId = computed(() => statuses.value[3]?.id ?? '');
<article v-for="date in dates" :key="date.id" class="date-card"> <article v-for="date in dates" :key="date.id" class="date-card">
<div class="date-card__header"> <div class="date-card__header">
<div> <div>
<h3 class="date-card__partner">{{ date.partnerName ?? 'Партнёр' }}</h3> <h3 class="date-card__partner">
{{ date.partnerName ?? 'Партнёр' }}
</h3>
<time class="date-card__time meta" :datetime="date.time">{{ formatDateTime(date.time) }}</time> <time class="date-card__time meta" :datetime="date.time">{{ formatDateTime(date.time) }}</time>
</div> </div>
<span <span
@@ -136,13 +47,17 @@ const completedStatusId = computed(() => statuses.value[3]?.id ?? '');
size="sm" size="sm"
:loading="actionLoading === `${date.id}-${acceptedStatusId}`" :loading="actionLoading === `${date.id}-${acceptedStatusId}`"
@click="updateStatus(date.id, acceptedStatusId)" @click="updateStatus(date.id, acceptedStatusId)"
>Принять</AppButton> >
Принять
</AppButton>
<AppButton <AppButton
variant="ghost" variant="ghost"
size="sm" size="sm"
:loading="actionLoading === `${date.id}-${declinedStatusId}`" :loading="actionLoading === `${date.id}-${declinedStatusId}`"
@click="updateStatus(date.id, declinedStatusId)" @click="updateStatus(date.id, declinedStatusId)"
>Отклонить</AppButton> >
Отклонить
</AppButton>
</div> </div>
<!-- Mark as complete --> <!-- Mark as complete -->
@@ -152,13 +67,121 @@ const completedStatusId = computed(() => statuses.value[3]?.id ?? '');
size="sm" size="sm"
:loading="actionLoading === `${date.id}-${completedStatusId}`" :loading="actionLoading === `${date.id}-${completedStatusId}`"
@click="updateStatus(date.id, completedStatusId)" @click="updateStatus(date.id, completedStatusId)"
>Встреча состоялась</AppButton> >
Встреча состоялась
</AppButton>
</div> </div>
</article> </article>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { apiClient } from '@/api/client'
import AppButton from '@/components/common/AppButton.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import { useAuth } from '@/composables/useAuth'
import { useUi } from '@/composables/useUi'
interface DateStatus { id: string, name: string }
interface DateItem {
id: string
profileId: string
partnerProfileId: string
partnerName?: string
lat: number
lng: number
time: string
statusId: string
statusName?: string
isIncoming: boolean
}
const authStore = useAuth()
const uiStore = useUi()
const dates = ref<DateItem[]>([])
const statuses = ref<DateStatus[]>([])
const loading = ref(false)
const actionLoading = ref<string | null>(null)
onMounted(async () => {
const profileId = authStore.activeProfile?.id
if (!profileId)
return
loading.value = true
try {
const [datesRes, statusesRes] = await Promise.all([
apiClient.api.datesControllerGetDates({ profileId }) as unknown as DateItem[],
apiClient.api.datesControllerGetStatuses() as unknown as DateStatus[],
])
dates.value = datesRes
statuses.value = statusesRes
}
catch {
uiStore.addToast('Не удалось загрузить встречи', 'error')
}
finally {
loading.value = false
}
})
function statusLabel(statusId: string) {
return statuses.value.find(s => s.id === statusId)?.name ?? statusId
}
function statusColor(statusId: string) {
const name = statusLabel(statusId).toLowerCase()
if (name.includes('ожид') || name.includes('pending'))
return 'pending'
if (name.includes('приня') || name.includes('accept'))
return 'accepted'
if (name.includes('отклон') || name.includes('declin'))
return 'declined'
if (name.includes('заверш') || name.includes('complet'))
return 'completed'
return 'default'
}
async function updateStatus(dateId: string, statusId: string) {
const profileId = authStore.activeProfile?.id
if (!profileId)
return
actionLoading.value = `${dateId}-${statusId}`
try {
await apiClient.api.datesControllerUpdateStatus({ id: dateId, profileId }, { statusId })
const idx = dates.value.findIndex(d => d.id === dateId)
if (idx !== -1)
dates.value[idx].statusId = statusId
uiStore.addToast('Статус обновлён', 'success')
}
catch {
uiStore.addToast('Не удалось обновить статус', 'error')
}
finally {
actionLoading.value = null
}
}
function formatDateTime(iso: string) {
return new Date(iso).toLocaleString('ru', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
// Pending = first two status ids roughly; accept/decline/complete = 3+
const pendingStatusId = computed(() => statuses.value[0]?.id ?? '')
const acceptedStatusId = computed(() => statuses.value[1]?.id ?? '')
const declinedStatusId = computed(() => statuses.value[2]?.id ?? '')
const completedStatusId = computed(() => statuses.value[3]?.id ?? '')
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.dates-view { .dates-view {
height: 100%; height: 100%;
@@ -205,7 +228,9 @@ const completedStatusId = computed(() => statuses.value[3]?.id ?? '');
gap: 12px; gap: 12px;
transition: border-color var(--transition-fast); transition: border-color var(--transition-fast);
&:hover { border-color: var(--color-border-strong); } &:hover {
border-color: var(--color-border-strong);
}
&__header { &__header {
display: flex; display: flex;
@@ -222,7 +247,9 @@ const completedStatusId = computed(() => statuses.value[3]?.id ?? '');
margin: 0 0 4px; margin: 0 0 4px;
} }
&__time { color: var(--color-muted); } &__time {
color: var(--color-muted);
}
&__status { &__status {
padding: 3px 10px; padding: 3px 10px;
@@ -234,11 +261,26 @@ const completedStatusId = computed(() => statuses.value[3]?.id ?? '');
text-transform: uppercase; text-transform: uppercase;
flex-shrink: 0; flex-shrink: 0;
&--pending { background: rgba(200, 160, 60, 0.15); color: #c89c3c; } &--pending {
&--accepted { background: rgba(80, 180, 80, 0.15); color: #50b450; } background: rgba(200, 160, 60, 0.15);
&--declined { background: var(--color-signal-bg); color: var(--color-signal); } color: #c89c3c;
&--completed { background: rgba(240, 235, 224, 0.06); color: var(--color-muted); } }
&--default { background: rgba(240, 235, 224, 0.06); color: var(--color-muted); } &--accepted {
background: rgba(80, 180, 80, 0.15);
color: #50b450;
}
&--declined {
background: var(--color-signal-bg);
color: var(--color-signal);
}
&--completed {
background: rgba(240, 235, 224, 0.06);
color: var(--color-muted);
}
&--default {
background: rgba(240, 235, 224, 0.06);
color: var(--color-muted);
}
} }
&__location { &__location {

View File

@@ -1,38 +1,18 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useFeed } from '@/composables/useFeed';
import { useAuth } from '@/composables/useAuth';
import FeedCardStack from '@/components/feed/FeedCardStack.vue';
import FeedFilters from '@/components/feed/FeedFilters.vue';
import AppButton from '@/components/common/AppButton.vue';
const feedStore = useFeed();
const authStore = useAuth();
const filtersOpen = ref(false);
const viewMode = ref<'stack' | 'scroll'>('stack');
onMounted(() => {
const profileId = authStore.activeProfile?.id;
if (profileId && feedStore.cards.length === 0) {
feedStore.fetchNextPage(profileId);
}
});
</script>
<template> <template>
<div class="feed-view"> <div class="feed-view">
<!-- Header bar --> <!-- Header bar -->
<header class="feed-header"> <header class="feed-header">
<h1 class="feed-header__title">Лента</h1> <h1 class="feed-header__title">
Лента
</h1>
<div class="feed-header__actions"> <div class="feed-header__actions">
<!-- View mode toggle --> <!-- View mode toggle -->
<div class="feed-header__toggle" role="group" aria-label="Режим просмотра"> <div class="feed-header__toggle" role="group" aria-label="Режим просмотра">
<button <button
class="feed-header__toggle-btn" class="feed-header__toggle-btn"
:class="{ 'feed-header__toggle-btn--active': viewMode === 'stack' }" :class="{ 'feed-header__toggle-btn--active': viewMode === 'stack' }"
@click="viewMode = 'stack'"
aria-label="Карточки" aria-label="Карточки"
@click="viewMode = 'stack'"
> >
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16"> <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16">
<rect x="2" y="2" width="16" height="16" rx="2" /> <rect x="2" y="2" width="16" height="16" rx="2" />
@@ -42,8 +22,8 @@ onMounted(() => {
<button <button
class="feed-header__toggle-btn" class="feed-header__toggle-btn"
:class="{ 'feed-header__toggle-btn--active': viewMode === 'scroll' }" :class="{ 'feed-header__toggle-btn--active': viewMode === 'scroll' }"
@click="viewMode = 'scroll'"
aria-label="Лента" aria-label="Лента"
@click="viewMode = 'scroll'"
> >
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16"> <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16">
<path d="M2 5h16M2 10h16M2 15h16" /> <path d="M2 5h16M2 10h16M2 15h16" />
@@ -87,7 +67,7 @@ onMounted(() => {
:src="profile.media[0].path" :src="profile.media[0].path"
:alt="profile.name" :alt="profile.name"
class="feed-grid__img" class="feed-grid__img"
/> >
<div v-else class="feed-grid__no-img" /> <div v-else class="feed-grid__no-img" />
<div class="feed-grid__overlay"> <div class="feed-grid__overlay">
<span class="feed-grid__name">{{ profile.name }}</span> <span class="feed-grid__name">{{ profile.name }}</span>
@@ -105,6 +85,28 @@ onMounted(() => {
</div> </div>
</template> </template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import AppButton from '@/components/common/AppButton.vue'
import FeedCardStack from '@/components/feed/FeedCardStack.vue'
import FeedFilters from '@/components/feed/FeedFilters.vue'
import { useAuth } from '@/composables/useAuth'
import { useFeed } from '@/composables/useFeed'
const feedStore = useFeed()
const authStore = useAuth()
const filtersOpen = ref(false)
const viewMode = ref<'stack' | 'scroll'>('stack')
onMounted(() => {
const profileId = authStore.activeProfile?.id
if (profileId && feedStore.cards.length === 0) {
feedStore.fetchNextPage(profileId)
}
})
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.feed-view { .feed-view {
height: 100%; height: 100%;

View File

@@ -1,68 +1,9 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useAuth } from '@/composables/useAuth';
import { useChat } from '@/composables/useChat';
import { useUi } from '@/composables/useUi';
import { apiClient } from '@/api/client';
import { useRouter } from 'vue-router';
import AppButton from '@/components/common/AppButton.vue';
import EmptyState from '@/components/common/EmptyState.vue';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
interface Match {
id: string;
profileId: string;
partnerProfile: {
id: string;
name: string;
avatarUrl?: string;
cityName?: string;
age?: number;
};
createdAt: string;
hasChat: boolean;
}
const authStore = useAuth();
const chatStore = useChat();
const uiStore = useUi();
const router = useRouter();
const matches = ref<Match[]>([]);
const loading = ref(false);
onMounted(async () => {
const profileId = authStore.activeProfile?.id;
if (!profileId) return;
loading.value = true;
try {
const res = await apiClient.api.likesControllerGetMyMatches({ profileId }) as unknown as Match[];
matches.value = res;
} catch {
uiStore.addToast('Не удалось загрузить совпадения', 'error');
} finally {
loading.value = false;
}
});
async function openChat(match: Match) {
const profileId = authStore.activeProfile?.id;
if (!profileId) return;
try {
const chat = await chatStore.openChat(profileId, match.id);
router.push(`/chats/${chat.id}`);
} catch (err: unknown) {
const msg = (err as { response?: { data?: { message?: string } } })
?.response?.data?.message ?? 'Не удалось открыть чат';
uiStore.addToast(msg, 'error');
}
}
</script>
<template> <template>
<div class="matches-view"> <div class="matches-view">
<header class="matches-view__header"> <header class="matches-view__header">
<h1 class="matches-view__title">Совпадения</h1> <h1 class="matches-view__title">
Совпадения
</h1>
<span class="meta">{{ matches.length }} {{ matches.length === 1 ? 'человек' : 'людей' }}</span> <span class="meta">{{ matches.length }} {{ matches.length === 1 ? 'человек' : 'людей' }}</span>
</header> </header>
@@ -89,7 +30,7 @@ async function openChat(match: Match) {
:src="match.partnerProfile.avatarUrl" :src="match.partnerProfile.avatarUrl"
:alt="match.partnerProfile.name" :alt="match.partnerProfile.name"
class="match-card__avatar" class="match-card__avatar"
/> >
<div v-else class="match-card__avatar match-card__avatar--placeholder"> <div v-else class="match-card__avatar match-card__avatar--placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" width="28" height="28" opacity="0.3"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" width="28" height="28" opacity="0.3">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z" /> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z" />
@@ -119,6 +60,74 @@ async function openChat(match: Match) {
</div> </div>
</template> </template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { apiClient } from '@/api/client'
import AppButton from '@/components/common/AppButton.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import { useAuth } from '@/composables/useAuth'
import { useChat } from '@/composables/useChat'
import { useUi } from '@/composables/useUi'
interface Match {
id: string
profileId: string
partnerProfile: {
id: string
name: string
avatarUrl?: string
cityName?: string
age?: number
}
createdAt: string
hasChat: boolean
}
const authStore = useAuth()
const chatStore = useChat()
const uiStore = useUi()
const router = useRouter()
const matches = ref<Match[]>([])
const loading = ref(false)
onMounted(async () => {
const profileId = authStore.activeProfile?.id
if (!profileId)
return
loading.value = true
try {
const res = await apiClient.api.likesControllerGetMyMatches({ profileId }) as unknown as Match[]
matches.value = res
}
catch {
uiStore.addToast('Не удалось загрузить совпадения', 'error')
}
finally {
loading.value = false
}
})
async function openChat(match: Match) {
const profileId = authStore.activeProfile?.id
if (!profileId)
return
try {
const chat = await chatStore.openChat(profileId, match.id)
router.push(`/chats/${chat.id}`)
}
catch (err: unknown) {
const msg = (err as { response?: { data?: { message?: string } } })
?.response
?.data
?.message ?? 'Не удалось открыть чат'
uiStore.addToast(msg, 'error')
}
}
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.matches-view { .matches-view {
height: 100%; height: 100%;
@@ -162,7 +171,9 @@ async function openChat(match: Match) {
padding: 12px 24px; padding: 12px 24px;
transition: background var(--transition-fast); transition: background var(--transition-fast);
&:hover { background: rgba(240, 235, 224, 0.03); } &:hover {
background: rgba(240, 235, 224, 0.03);
}
&__avatar-wrap { &__avatar-wrap {
flex-shrink: 0; flex-shrink: 0;
@@ -201,11 +212,18 @@ async function openChat(match: Match) {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
&:hover { color: var(--color-signal); } &:hover {
color: var(--color-signal);
}
} }
&__age { font-weight: 400; opacity: 0.6; } &__age {
font-weight: 400;
opacity: 0.6;
}
&__city { color: var(--color-muted); } &__city {
color: var(--color-muted);
}
} }
</style> </style>

View File

@@ -1,108 +1,3 @@
<script setup lang="ts">
import { reactive, ref, computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useVuelidate } from '@vuelidate/core';
import { required, helpers } from '@vuelidate/validators';
import { useAuth } from '@/composables/useAuth';
import { useUi } from '@/composables/useUi';
import { apiClient } from '@/api/client';
import type { CreateProfileDto } from '@/api/api';
import type { UserProfile } from '@/composables/useAuth';
import type { District } from '@/composables/useUi';
import AppInput from '@/components/common/AppInput.vue';
import AppButton from '@/components/common/AppButton.vue';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
const router = useRouter();
const authStore = useAuth();
const uiStore = useUi();
const step = ref(1);
const totalSteps = 4;
const loading = ref(false);
const form = reactive({
name: '',
birthDate: '',
gender: 'female' as 'female' | 'male',
cityId: '',
districtId: '',
description: '',
nation: '',
height: undefined as number | undefined | null,
weight: undefined as number | undefined | null,
tagIds: [] as string[],
});
const selectedTags = ref<string[]>([]);
const districts = ref<District[]>([]);
const loadingDistricts = ref(false);
// Load districts when city changes
watch(() => form.cityId, async (cityId) => {
if (!cityId) { districts.value = []; return; }
if (uiStore.districts[cityId]) { districts.value = uiStore.districts[cityId]; return; }
loadingDistricts.value = true;
try {
const res = await apiClient.api.citiesControllerFindDistricts(cityId) as unknown as District[];
uiStore.setDistricts(cityId, res);
districts.value = res;
} finally {
loadingDistricts.value = false;
}
});
const step1Rules = {
name: { required: helpers.withMessage('Введите имя', required) },
birthDate: { required: helpers.withMessage('Введите дату рождения', required) },
gender: { required: helpers.withMessage('Выберите пол', required) },
};
const v$ = useVuelidate(step1Rules, form);
const progress = computed(() => ((step.value - 1) / totalSteps) * 100);
async function nextStep() {
if (step.value === 1) {
const valid = await v$.value.$validate();
if (!valid) return;
}
if (step.value < totalSteps) step.value++;
else await finish();
}
function prevStep() {
if (step.value > 1) step.value--;
}
function toggleTag(tagId: string) {
const idx = selectedTags.value.indexOf(tagId);
if (idx === -1) selectedTags.value.push(tagId);
else selectedTags.value.splice(idx, 1);
}
async function finish() {
loading.value = true;
try {
form.tagIds = selectedTags.value;
const profile = await apiClient.api.profilesControllerCreate(form as unknown as CreateProfileDto) as unknown as UserProfile;
authStore.addProfile(profile);
uiStore.addToast('Профиль создан', 'success');
router.replace('/feed');
} catch (err: unknown) {
const message = (err as { response?: { data?: { message?: string } } })
?.response?.data?.message ?? 'Не удалось создать профиль';
uiStore.addToast(message, 'error');
} finally {
loading.value = false;
}
}
function skip() {
router.replace('/feed');
}
</script>
<template> <template>
<div class="setup"> <div class="setup">
<div class="setup__grain" aria-hidden="true" /> <div class="setup__grain" aria-hidden="true" />
@@ -116,10 +11,18 @@ function skip() {
<div class="setup__header"> <div class="setup__header">
<span class="meta">Шаг {{ step }} из {{ totalSteps }}</span> <span class="meta">Шаг {{ step }} из {{ totalSteps }}</span>
<h1 class="setup__title"> <h1 class="setup__title">
<template v-if="step === 1">Расскажите о себе</template> <template v-if="step === 1">
<template v-if="step === 2">Где вы находитесь?</template> Расскажите о себе
<template v-if="step === 3">Ваши интересы</template> </template>
<template v-if="step === 4">Добавьте фото</template> <template v-if="step === 2">
Где вы находитесь?
</template>
<template v-if="step === 3">
Ваши интересы
</template>
<template v-if="step === 4">
Добавьте фото
</template>
</h1> </h1>
</div> </div>
@@ -152,13 +55,17 @@ function skip() {
class="setup__gender-btn" class="setup__gender-btn"
:class="{ 'setup__gender-btn--active': form.gender === 'female' }" :class="{ 'setup__gender-btn--active': form.gender === 'female' }"
@click="form.gender = 'female'" @click="form.gender = 'female'"
>Женщина</button> >
Женщина
</button>
<button <button
type="button" type="button"
class="setup__gender-btn" class="setup__gender-btn"
:class="{ 'setup__gender-btn--active': form.gender === 'male' }" :class="{ 'setup__gender-btn--active': form.gender === 'male' }"
@click="form.gender = 'male'" @click="form.gender = 'male'"
>Мужчина</button> >
Мужчина
</button>
</div> </div>
</div> </div>
<AppInput <AppInput
@@ -174,8 +81,12 @@ function skip() {
<div class="field"> <div class="field">
<label class="field__label label" for="city-select">Город</label> <label class="field__label label" for="city-select">Город</label>
<select id="city-select" v-model="form.cityId" class="field__select"> <select id="city-select" v-model="form.cityId" class="field__select">
<option value="">Выберите город</option> <option value="">
<option v-for="city in uiStore.cities" :key="city.id" :value="city.id">{{ city.name }}</option> Выберите город
</option>
<option v-for="city in uiStore.cities" :key="city.id" :value="city.id">
{{ city.name }}
</option>
</select> </select>
</div> </div>
<div v-if="form.cityId" class="field"> <div v-if="form.cityId" class="field">
@@ -184,8 +95,12 @@ function skip() {
<LoadingSpinner size="sm" /> <LoadingSpinner size="sm" />
</div> </div>
<select v-else id="district-select" v-model="form.districtId" class="field__select"> <select v-else id="district-select" v-model="form.districtId" class="field__select">
<option value="">Выберите район</option> <option value="">
<option v-for="d in districts" :key="d.id" :value="d.id">{{ d.name }}</option> Выберите район
</option>
<option v-for="d in districts" :key="d.id" :value="d.id">
{{ d.name }}
</option>
</select> </select>
</div> </div>
<AppInput v-model="form.nation" label="Национальность" placeholder="Необязательно" name="nation" /> <AppInput v-model="form.nation" label="Национальность" placeholder="Необязательно" name="nation" />
@@ -197,7 +112,9 @@ function skip() {
<!-- Step 3: Tags/interests --> <!-- Step 3: Tags/interests -->
<div v-if="step === 3" class="setup__step"> <div v-if="step === 3" class="setup__step">
<p class="setup__hint">Выберите теги, которые вас описывают</p> <p class="setup__hint">
Выберите теги, которые вас описывают
</p>
<div class="setup__tags"> <div class="setup__tags">
<button <button
v-for="tag in uiStore.tags" v-for="tag in uiStore.tags"
@@ -210,7 +127,9 @@ function skip() {
{{ tag.value }} {{ tag.value }}
</button> </button>
</div> </div>
<p v-if="uiStore.tags.length === 0" class="setup__hint setup__hint--muted">Теги загружаются...</p> <p v-if="uiStore.tags.length === 0" class="setup__hint setup__hint--muted">
Теги загружаются...
</p>
</div> </div>
<!-- Step 4: Photo upload reminder --> <!-- Step 4: Photo upload reminder -->
@@ -231,10 +150,14 @@ function skip() {
v-if="step > 1" v-if="step > 1"
variant="ghost" variant="ghost"
@click="prevStep" @click="prevStep"
>Назад</AppButton> >
Назад
</AppButton>
<span v-else /> <span v-else />
<div class="setup__nav-right"> <div class="setup__nav-right">
<AppButton v-if="step < totalSteps" variant="ghost" @click="skip">Пропустить</AppButton> <AppButton v-if="step < totalSteps" variant="ghost" @click="skip">
Пропустить
</AppButton>
<AppButton <AppButton
variant="primary" variant="primary"
size="md" size="md"
@@ -249,6 +172,120 @@ function skip() {
</div> </div>
</template> </template>
<script setup lang="ts">
import type { CreateProfileDto } from '@/api/api'
import type { UserProfile } from '@/composables/useAuth'
import type { District } from '@/composables/useUi'
import { useVuelidate } from '@vuelidate/core'
import { helpers, required } from '@vuelidate/validators'
import { computed, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { apiClient } from '@/api/client'
import AppButton from '@/components/common/AppButton.vue'
import AppInput from '@/components/common/AppInput.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import { useAuth } from '@/composables/useAuth'
import { useUi } from '@/composables/useUi'
const router = useRouter()
const authStore = useAuth()
const uiStore = useUi()
const step = ref(1)
const totalSteps = 4
const loading = ref(false)
const form = reactive({
name: '',
birthDate: '',
gender: 'female' as 'female' | 'male',
cityId: '',
districtId: '',
description: '',
nation: '',
height: undefined as number | undefined | null,
weight: undefined as number | undefined | null,
tagIds: [] as string[],
})
const selectedTags = ref<string[]>([])
const districts = ref<District[]>([])
const loadingDistricts = ref(false)
// Load districts when city changes
watch(() => form.cityId, async (cityId) => {
if (!cityId) { districts.value = []; return }
if (uiStore.districts[cityId]) { districts.value = uiStore.districts[cityId]; return }
loadingDistricts.value = true
try {
const res = await apiClient.api.citiesControllerFindDistricts(cityId) as unknown as District[]
uiStore.setDistricts(cityId, res)
districts.value = res
}
finally {
loadingDistricts.value = false
}
})
const step1Rules = {
name: { required: helpers.withMessage('Введите имя', required) },
birthDate: { required: helpers.withMessage('Введите дату рождения', required) },
gender: { required: helpers.withMessage('Выберите пол', required) },
}
const v$ = useVuelidate(step1Rules, form)
const progress = computed(() => ((step.value - 1) / totalSteps) * 100)
async function nextStep() {
if (step.value === 1) {
const valid = await v$.value.$validate()
if (!valid)
return
}
if (step.value < totalSteps)
step.value++
else await finish()
}
function prevStep() {
if (step.value > 1)
step.value--
}
function toggleTag(tagId: string) {
const idx = selectedTags.value.indexOf(tagId)
if (idx === -1)
selectedTags.value.push(tagId)
else selectedTags.value.splice(idx, 1)
}
async function finish() {
loading.value = true
try {
form.tagIds = selectedTags.value
const profile = await apiClient.api.profilesControllerCreate(form as unknown as CreateProfileDto) as unknown as UserProfile
authStore.addProfile(profile)
uiStore.addToast('Профиль создан', 'success')
router.replace('/feed')
}
catch (err: unknown) {
const message = (err as { response?: { data?: { message?: string } } })
?.response
?.data
?.message ?? 'Не удалось создать профиль'
uiStore.addToast(message, 'error')
}
finally {
loading.value = false
}
}
function skip() {
router.replace('/feed')
}
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.setup { .setup {
min-height: 100dvh; min-height: 100dvh;
@@ -398,7 +435,9 @@ function skip() {
color: var(--color-muted); color: var(--color-muted);
margin: 0; margin: 0;
&--muted { color: var(--color-dim); } &--muted {
color: var(--color-dim);
}
} }
&__loading { &__loading {
@@ -416,7 +455,9 @@ function skip() {
padding: 32px 0; padding: 32px 0;
color: var(--color-muted); color: var(--color-muted);
svg { opacity: 0.4; } svg {
opacity: 0.4;
}
p { p {
font-family: var(--font-mono); font-family: var(--font-mono);
@@ -426,7 +467,9 @@ function skip() {
margin: 0; margin: 0;
line-height: 1.6; line-height: 1.6;
strong { color: var(--color-cream); } strong {
color: var(--color-cream);
}
} }
} }
} }
@@ -436,7 +479,9 @@ function skip() {
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
&__label { color: var(--color-muted); } &__label {
color: var(--color-muted);
}
&__select { &__select {
height: 44px; height: 44px;
@@ -452,7 +497,9 @@ function skip() {
outline: none; outline: none;
appearance: none; appearance: none;
&:focus { border-color: var(--color-signal); } &:focus {
border-color: var(--color-signal);
}
} }
} }
</style> </style>

View File

@@ -1,63 +1,3 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useAuth } from '@/composables/useAuth';
import { useUi } from '@/composables/useUi';
import { apiClient } from '@/api/client';
import type { UserProfile } from '@/composables/useAuth';
import ProfileEditor from '@/components/profile/ProfileEditor.vue';
import MediaGallery from '@/components/profile/MediaGallery.vue';
import AppButton from '@/components/common/AppButton.vue';
import AppModal from '@/components/common/AppModal.vue';
const authStore = useAuth();
const uiStore = useUi();
const editing = ref(false);
const confirmDelete = ref(false);
const deleting = ref(false);
const profile = computed(() => authStore.activeProfile);
function onSaved(updated: UserProfile) {
editing.value = false;
}
async function doDelete() {
if (!profile.value) return;
deleting.value = true;
try {
await apiClient.api.profilesControllerDelete(profile.value.id);
authStore.removeProfile(profile.value.id);
confirmDelete.value = false;
uiStore.addToast('Профиль удалён', 'success');
} catch {
uiStore.addToast('Не удалось удалить профиль', 'error');
} finally {
deleting.value = false;
}
}
function logout() {
authStore.logout();
}
function cityName(cityId?: string | null) {
return uiStore.cities.find((c) => c.id === cityId)?.name ?? '';
}
function tagValues(tags?: Array<{ id: string; value: string }>) {
return (tags ?? []).map((t) => t.value);
}
function calcAge(birthDate: string) {
const b = new Date(birthDate);
const now = new Date();
let age = now.getFullYear() - b.getFullYear();
if (now.getMonth() < b.getMonth() || (now.getMonth() === b.getMonth() && now.getDate() < b.getDate())) age--;
return age;
}
</script>
<template> <template>
<div class="my-profile"> <div class="my-profile">
<!-- Profile view --> <!-- Profile view -->
@@ -70,7 +10,7 @@ function calcAge(birthDate: string) {
:src="profile.media[0].path" :src="profile.media[0].path"
:alt="profile.name" :alt="profile.name"
class="my-profile__avatar" class="my-profile__avatar"
/> >
<div v-else class="my-profile__avatar my-profile__avatar--placeholder"> <div v-else class="my-profile__avatar my-profile__avatar--placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" width="48" height="48" opacity="0.2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" width="48" height="48" opacity="0.2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z" /> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z" />
@@ -78,24 +18,36 @@ function calcAge(birthDate: string) {
</div> </div>
</div> </div>
<div class="my-profile__hero-info"> <div class="my-profile__hero-info">
<h1 class="my-profile__name">{{ profile.name }}<span class="my-profile__age">, {{ calcAge(profile.birthDate) }}</span></h1> <h1 class="my-profile__name">
{{ profile.name }}<span class="my-profile__age">, {{ calcAge(profile.birthDate) }}</span>
</h1>
<span v-if="profile.cityId" class="meta my-profile__location">{{ cityName(profile.cityId) }}</span> <span v-if="profile.cityId" class="meta my-profile__location">{{ cityName(profile.cityId) }}</span>
</div> </div>
<div class="my-profile__hero-actions"> <div class="my-profile__hero-actions">
<AppButton variant="secondary" size="sm" @click="editing = true">Редактировать</AppButton> <AppButton variant="secondary" size="sm" @click="editing = true">
<AppButton variant="ghost" size="sm" @click="logout">Выйти</AppButton> Редактировать
</AppButton>
<AppButton variant="ghost" size="sm" @click="logout">
Выйти
</AppButton>
</div> </div>
</div> </div>
<!-- Bio --> <!-- Bio -->
<div v-if="profile.description" class="my-profile__section"> <div v-if="profile.description" class="my-profile__section">
<h3 class="my-profile__section-title">О себе</h3> <h3 class="my-profile__section-title">
<p class="my-profile__bio">{{ profile.description }}</p> О себе
</h3>
<p class="my-profile__bio">
{{ profile.description }}
</p>
</div> </div>
<!-- Stats --> <!-- Stats -->
<div class="my-profile__section"> <div class="my-profile__section">
<h3 class="my-profile__section-title">Данные</h3> <h3 class="my-profile__section-title">
Данные
</h3>
<div class="my-profile__stats"> <div class="my-profile__stats">
<div v-if="profile.nation" class="my-profile__stat"> <div v-if="profile.nation" class="my-profile__stat">
<span class="meta">Национальность</span> <span class="meta">Национальность</span>
@@ -114,7 +66,9 @@ function calcAge(birthDate: string) {
<!-- Tags --> <!-- Tags -->
<div v-if="profile.tags?.length" class="my-profile__section"> <div v-if="profile.tags?.length" class="my-profile__section">
<h3 class="my-profile__section-title">Интересы</h3> <h3 class="my-profile__section-title">
Интересы
</h3>
<div class="my-profile__tags"> <div class="my-profile__tags">
<span v-for="name in tagValues(profile.tags)" :key="name" class="my-profile__tag">{{ name }}</span> <span v-for="name in tagValues(profile.tags)" :key="name" class="my-profile__tag">{{ name }}</span>
</div> </div>
@@ -122,19 +76,25 @@ function calcAge(birthDate: string) {
<!-- Media gallery --> <!-- Media gallery -->
<div class="my-profile__section"> <div class="my-profile__section">
<h3 class="my-profile__section-title">Фото</h3> <h3 class="my-profile__section-title">
Фото
</h3>
<MediaGallery :profile-id="profile.id" :editable="true" /> <MediaGallery :profile-id="profile.id" :editable="true" />
</div> </div>
<!-- Danger zone --> <!-- Danger zone -->
<div class="my-profile__section my-profile__danger"> <div class="my-profile__section my-profile__danger">
<AppButton variant="danger" size="sm" @click="confirmDelete = true">Удалить профиль</AppButton> <AppButton variant="danger" size="sm" @click="confirmDelete = true">
Удалить профиль
</AppButton>
</div> </div>
</div> </div>
<!-- No profile state --> <!-- No profile state -->
<div v-else-if="!editing && !profile" class="my-profile__empty"> <div v-else-if="!editing && !profile" class="my-profile__empty">
<p class="meta">У вас нет профилей</p> <p class="meta">
У вас нет профилей
</p>
<RouterLink to="/setup"> <RouterLink to="/setup">
<AppButton>Создать профиль</AppButton> <AppButton>Создать профиль</AppButton>
</RouterLink> </RouterLink>
@@ -163,13 +123,81 @@ function calcAge(birthDate: string) {
Профиль будет удалён навсегда. Это действие нельзя отменить. Профиль будет удалён навсегда. Это действие нельзя отменить.
</p> </p>
<template #footer> <template #footer>
<AppButton variant="ghost" @click="confirmDelete = false">Отмена</AppButton> <AppButton variant="ghost" @click="confirmDelete = false">
<AppButton variant="danger" :loading="deleting" @click="doDelete">Удалить</AppButton> Отмена
</AppButton>
<AppButton variant="danger" :loading="deleting" @click="doDelete">
Удалить
</AppButton>
</template> </template>
</AppModal> </AppModal>
</div> </div>
</template> </template>
<script setup lang="ts">
import type { UserProfile } from '@/composables/useAuth'
import { computed, ref } from 'vue'
import { apiClient } from '@/api/client'
import AppButton from '@/components/common/AppButton.vue'
import AppModal from '@/components/common/AppModal.vue'
import MediaGallery from '@/components/profile/MediaGallery.vue'
import ProfileEditor from '@/components/profile/ProfileEditor.vue'
import { useAuth } from '@/composables/useAuth'
import { useUi } from '@/composables/useUi'
const authStore = useAuth()
const uiStore = useUi()
const editing = ref(false)
const confirmDelete = ref(false)
const deleting = ref(false)
const profile = computed(() => authStore.activeProfile)
function onSaved(updated: UserProfile) {
editing.value = false
}
async function doDelete() {
if (!profile.value)
return
deleting.value = true
try {
await apiClient.api.profilesControllerDelete(profile.value.id)
authStore.removeProfile(profile.value.id)
confirmDelete.value = false
uiStore.addToast('Профиль удалён', 'success')
}
catch {
uiStore.addToast('Не удалось удалить профиль', 'error')
}
finally {
deleting.value = false
}
}
function logout() {
authStore.logout()
}
function cityName(cityId?: string | null) {
return uiStore.cities.find(c => c.id === cityId)?.name ?? ''
}
function tagValues(tags?: Array<{ id: string, value: string }>) {
return (tags ?? []).map(t => t.value)
}
function calcAge(birthDate: string) {
const b = new Date(birthDate)
const now = new Date()
let age = now.getFullYear() - b.getFullYear()
if (now.getMonth() < b.getMonth() || (now.getMonth() === b.getMonth() && now.getDate() < b.getDate()))
age--
return age
}
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.my-profile { .my-profile {
height: 100%; height: 100%;
@@ -199,7 +227,9 @@ function calcAge(birthDate: string) {
flex-wrap: wrap; flex-wrap: wrap;
} }
&__avatar-wrap { flex-shrink: 0; } &__avatar-wrap {
flex-shrink: 0;
}
&__avatar { &__avatar {
width: 80px; width: 80px;
@@ -232,7 +262,9 @@ function calcAge(birthDate: string) {
opacity: 0.6; opacity: 0.6;
} }
&__location { color: var(--color-muted); } &__location {
color: var(--color-muted);
}
&__hero-actions { &__hero-actions {
display: flex; display: flex;
@@ -299,7 +331,10 @@ function calcAge(birthDate: string) {
letter-spacing: 0.03em; letter-spacing: 0.03em;
} }
&__danger { padding-top: 16px; border-top: 1px solid rgba(196, 92, 58, 0.2); } &__danger {
padding-top: 16px;
border-top: 1px solid rgba(196, 92, 58, 0.2);
}
&__editor-header { &__editor-header {
margin-bottom: 24px; margin-bottom: 24px;

View File

@@ -1,63 +1,3 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useAuth } from '@/composables/useAuth';
import { useUi } from '@/composables/useUi';
import { useChat } from '@/composables/useChat';
import { apiClient } from '@/api/client';
import type { UserProfile } from '@/composables/useAuth';
import AppButton from '@/components/common/AppButton.vue';
import AppModal from '@/components/common/AppModal.vue';
import ReportModal from '@/components/reports/ReportModal.vue';
import DateProposalForm from '@/components/dates/DateProposalForm.vue';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
const route = useRoute();
const authStore = useAuth();
const uiStore = useUi();
const chatStore = useChat();
const profileId = route.params.profileId as string;
const profile = ref<UserProfile | null>(null);
const loading = ref(false);
const reportOpen = ref(false);
const dateOpen = ref(false);
onMounted(async () => {
loading.value = true;
try {
const res = await apiClient.api.profilesControllerFindOne(profileId) as unknown as UserProfile;
profile.value = res;
} catch {
uiStore.addToast('Не удалось загрузить профиль', 'error');
} finally {
loading.value = false;
}
});
const age = computed(() => {
if (!profile.value?.birthDate) return null;
const b = new Date(profile.value.birthDate);
const now = new Date();
let a = now.getFullYear() - b.getFullYear();
if (now.getMonth() < b.getMonth() || (now.getMonth() === b.getMonth() && now.getDate() < b.getDate())) a--;
return a;
});
const cityName = computed(() => {
const cid = profile.value?.cityId;
return cid ? uiStore.cities.find((c) => c.id === cid)?.name ?? '' : '';
});
const tagNames = computed(() => profile.value?.tags?.map((t) => t.value) ?? []);
const currentImageIndex = ref(0);
const isOwnProfile = computed(() =>
authStore.profiles.some((p) => p.id === profileId),
);
function goBack() { window.history.back(); }
</script>
<template> <template>
<div class="profile-detail"> <div class="profile-detail">
<div v-if="loading" class="profile-detail__loading"> <div v-if="loading" class="profile-detail__loading">
@@ -72,25 +12,29 @@ function goBack() { window.history.back(); }
:src="profile.media[currentImageIndex].path" :src="profile.media[currentImageIndex].path"
:alt="profile.name" :alt="profile.name"
class="profile-detail__cover-img" class="profile-detail__cover-img"
/> >
<div v-else class="profile-detail__cover-placeholder" /> <div v-else class="profile-detail__cover-placeholder" />
<!-- Navigation --> <!-- Navigation -->
<button <button
v-if="currentImageIndex > 0" v-if="currentImageIndex > 0"
class="profile-detail__img-nav profile-detail__img-nav--prev" class="profile-detail__img-nav profile-detail__img-nav--prev"
@click="currentImageIndex--"
aria-label="Предыдущее фото" aria-label="Предыдущее фото"
></button> @click="currentImageIndex--"
>
</button>
<button <button
v-if="currentImageIndex < (profile.media?.length ?? 1) - 1" v-if="currentImageIndex < (profile.media?.length ?? 1) - 1"
class="profile-detail__img-nav profile-detail__img-nav--next" class="profile-detail__img-nav profile-detail__img-nav--next"
@click="currentImageIndex++"
aria-label="Следующее фото" aria-label="Следующее фото"
></button> @click="currentImageIndex++"
>
</button>
<!-- Back button --> <!-- Back button -->
<button class="profile-detail__back" @click="goBack()" aria-label="Назад"> <button class="profile-detail__back" aria-label="Назад" @click="goBack()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
<path d="M19 12H5M12 5l-7 7 7 7" /> <path d="M19 12H5M12 5l-7 7 7 7" />
</svg> </svg>
@@ -110,13 +54,19 @@ function goBack() { window.history.back(); }
<!-- Actions (non-own) --> <!-- Actions (non-own) -->
<div v-if="!isOwnProfile" class="profile-detail__actions"> <div v-if="!isOwnProfile" class="profile-detail__actions">
<AppButton size="sm" variant="primary" @click="dateOpen = true">Встреча</AppButton> <AppButton size="sm" variant="primary" @click="dateOpen = true">
<AppButton size="sm" variant="ghost" @click="reportOpen = true">Пожаловаться</AppButton> Встреча
</AppButton>
<AppButton size="sm" variant="ghost" @click="reportOpen = true">
Пожаловаться
</AppButton>
</div> </div>
</div> </div>
<!-- Description --> <!-- Description -->
<p v-if="profile.description" class="profile-detail__bio">{{ profile.description }}</p> <p v-if="profile.description" class="profile-detail__bio">
{{ profile.description }}
</p>
<!-- Stats --> <!-- Stats -->
<div class="profile-detail__stats"> <div class="profile-detail__stats">
@@ -162,6 +112,70 @@ function goBack() { window.history.back(); }
</div> </div>
</template> </template>
<script setup lang="ts">
import type { UserProfile } from '@/composables/useAuth'
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { apiClient } from '@/api/client'
import AppButton from '@/components/common/AppButton.vue'
import AppModal from '@/components/common/AppModal.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import DateProposalForm from '@/components/dates/DateProposalForm.vue'
import ReportModal from '@/components/reports/ReportModal.vue'
import { useAuth } from '@/composables/useAuth'
import { useChat } from '@/composables/useChat'
import { useUi } from '@/composables/useUi'
const route = useRoute()
const authStore = useAuth()
const uiStore = useUi()
const chatStore = useChat()
const profileId = route.params.profileId as string
const profile = ref<UserProfile | null>(null)
const loading = ref(false)
const reportOpen = ref(false)
const dateOpen = ref(false)
onMounted(async () => {
loading.value = true
try {
const res = await apiClient.api.profilesControllerFindOne(profileId) as unknown as UserProfile
profile.value = res
}
catch {
uiStore.addToast('Не удалось загрузить профиль', 'error')
}
finally {
loading.value = false
}
})
const age = computed(() => {
if (!profile.value?.birthDate)
return null
const b = new Date(profile.value.birthDate)
const now = new Date()
let a = now.getFullYear() - b.getFullYear()
if (now.getMonth() < b.getMonth() || (now.getMonth() === b.getMonth() && now.getDate() < b.getDate()))
a--
return a
})
const cityName = computed(() => {
const cid = profile.value?.cityId
return cid ? uiStore.cities.find(c => c.id === cid)?.name ?? '' : ''
})
const tagNames = computed(() => profile.value?.tags?.map(t => t.value) ?? [])
const currentImageIndex = ref(0)
const isOwnProfile = computed(() =>
authStore.profiles.some(p => p.id === profileId),
)
function goBack() { window.history.back() }
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.profile-detail { .profile-detail {
height: 100%; height: 100%;
@@ -179,7 +193,9 @@ function goBack() { window.history.back(); }
height: 60dvh; height: 60dvh;
background: var(--color-surface-2); background: var(--color-surface-2);
@include mobile { height: 50dvh; } @include mobile {
height: 50dvh;
}
} }
&__cover-img { &__cover-img {
@@ -212,10 +228,16 @@ function goBack() { window.history.back(); }
transition: background var(--transition-fast); transition: background var(--transition-fast);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
&:hover { background: rgba(13, 13, 13, 0.85); } &:hover {
background: rgba(13, 13, 13, 0.85);
}
&--prev { left: 12px; } &--prev {
&--next { right: 12px; } left: 12px;
}
&--next {
right: 12px;
}
} }
&__back { &__back {
@@ -235,7 +257,9 @@ function goBack() { window.history.back(); }
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
transition: background var(--transition-fast); transition: background var(--transition-fast);
&:hover { background: rgba(13, 13, 13, 0.85); } &:hover {
background: rgba(13, 13, 13, 0.85);
}
} }
&__content { &__content {
@@ -260,9 +284,14 @@ function goBack() { window.history.back(); }
margin: 0 0 4px; margin: 0 0 4px;
} }
&__age { opacity: 0.6; font-size: 1.5rem; } &__age {
opacity: 0.6;
font-size: 1.5rem;
}
&__location { color: var(--color-muted); } &__location {
color: var(--color-muted);
}
&__actions { &__actions {
display: flex; display: flex;

2
src/vite-env.d.ts vendored
View File

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

View File

@@ -1,21 +1,21 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"jsx": "preserve", "jsx": "preserve",
"resolveJsonModule": true, "lib": ["ES2020", "DOM", "DOM.Iterable"],
"sourceMap": true, "useDefineForClassFields": true,
"baseUrl": ".", "baseUrl": ".",
"module": "ESNext",
"moduleResolution": "bundler",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
}
}, },
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], "resolveJsonModule": true,
"references": [{ "path": "./tsconfig.node.json" }] "strict": true,
"noEmit": true,
"sourceMap": true,
"skipLibCheck": true
},
"references": [{ "path": "./tsconfig.node.json" }],
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
} }

View File

@@ -1,11 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"skipLibCheck": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"strict": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"strict": true "skipLibCheck": true
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts"]
} }

View File

@@ -1,16 +1,16 @@
import { defineConfig } from "vite"; import { resolve } from 'node:path'
import vue from "@vitejs/plugin-vue"; import tailwindcss from '@tailwindcss/vite'
import tailwindcss from "@tailwindcss/vite"; import vue from '@vitejs/plugin-vue'
import { resolve } from "path"; import { defineConfig } from 'vite'
const host = process.env.TAURI_DEV_HOST; const host = process.env.TAURI_DEV_HOST
export default defineConfig(async () => ({ export default defineConfig(async () => ({
plugins: [vue(), tailwindcss()], plugins: [vue(), tailwindcss()],
resolve: { resolve: {
alias: { alias: {
"@": resolve(__dirname, "./src"), '@': resolve(__dirname, './src'),
}, },
}, },
@@ -21,9 +21,9 @@ export default defineConfig(async () => ({
additionalData: (source: string, filePath: string) => { additionalData: (source: string, filePath: string) => {
// Skip the variables file itself and the main style files to avoid circular deps // Skip the variables file itself and the main style files to avoid circular deps
if (filePath.includes('_variables') || filePath.includes('styles/main') || filePath.includes('styles/tailwind')) { if (filePath.includes('_variables') || filePath.includes('styles/main') || filePath.includes('styles/tailwind')) {
return source; return source
} }
return `@use "@/styles/_variables.scss" as *;\n${source}`; return `@use "@/styles/_variables.scss" as *;\n${source}`
}, },
}, },
}, },
@@ -37,13 +37,13 @@ export default defineConfig(async () => ({
host: host || false, host: host || false,
hmr: host hmr: host
? { ? {
protocol: "ws", protocol: 'ws',
host, host,
port: 1421, port: 1421,
} }
: undefined, : undefined,
watch: { watch: {
ignored: ["**/src-tauri/**"], ignored: ['**/src-tauri/**'],
}, },
}, },
})); }))