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 +1 @@
{"onlyBuiltDependencies":["esbuild","@parcel/watcher","vue-demi"]} { "onlyBuiltDependencies": ["esbuild", "@parcel/watcher", "vue-demi"] }

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

@@ -4,21 +4,21 @@ 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 |
| State management | Composables | | | State management | Composables | |
| Routing | Vue Router 4 | | Routing | Vue Router 4 |
| HTTP client | Axios (сгенерированный клиент `src/api/api.ts`) | | HTTP client | Axios (сгенерированный клиент `src/api/api.ts`) |
| Форм-валидация | Vuelidate | | Форм-валидация | Vuelidate |
| Анимации | GSAP 3 | | Анимации | GSAP 3 |
| Карты | Leaflet | | Карты | Leaflet |
| Утилиты | VueUse | | Утилиты | VueUse |
| CSS | SCSS + Tailwind v4 (reset-only) | | CSS | SCSS + Tailwind v4 (reset-only) |
| Package manager | pnpm | | Package manager | pnpm |
| TypeScript | строгий режим, `no any` | | TypeScript | строгий режим, `no any` |
--- ---
@@ -118,22 +118,23 @@ 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 |
| `/setup` | ProfileSetupView | auth | | `/setup` | ProfileSetupView | auth |
| `/feed` | FeedView | auth | | `/feed` | FeedView | auth |
| `/matches` | MatchesView | auth | | `/matches` | MatchesView | auth |
| `/chats` | ChatsListView | auth | | `/chats` | ChatsListView | auth |
| `/chats/:chatId` | ChatRoomView | auth | | `/chats/:chatId` | ChatRoomView | auth |
| `/dates` | DatesView | auth | | `/dates` | DatesView | auth |
| `/profile/me` | MyProfileView | auth | | `/profile/me` | MyProfileView | auth |
| `/profile/:profileId` | ProfileDetailView | auth | | `/profile/:profileId` | ProfileDetailView | auth |
| `/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`.
@@ -192,8 +193,8 @@ Tailwind v4 импортируется **отдельно** от SCSS:
```ts ```ts
// main.ts — порядок важен // main.ts — порядок важен
import '@/styles/tailwind.css' // @import "tailwindcss" import '@/styles/tailwind.css' // @import "tailwindcss"
import '@/styles/main.scss' // custom styles import '@/styles/main.scss' // custom styles
``` ```
Каждый SCSS-парциал (`_typography.scss`, `_animations.scss`) включает в начале: Каждый SCSS-парциал (`_typography.scss`, `_animations.scss`) включает в начале:
@@ -209,14 +210,15 @@ import '@/styles/main.scss' // custom styles
### Дизайн-токены (CSS custom properties) ### Дизайн-токены (CSS custom properties)
```scss ```scss
--color-base: #0d0d0d // фон приложения --color-base:
--color-surface: #161614 // поверхность карточек #0d0d0d // фон приложения
--color-surface-2: #1e1e1b // инпуты, вторичные поверхности --color-surface: #161614 // поверхность карточек
--color-cream: #f0ebe0 // основной текст --color-surface-2: #1e1e1b // инпуты, вторичные поверхности
--color-muted: #6b6860 // второстепенный текст --color-cream: #f0ebe0 // основной текст
--color-signal: #c45c3a // акцент (CTA, лайк, активный стейт) --color-muted: #6b6860 // второстепенный текст
--font-display: 'Instrument Serif', serif --color-signal: #c45c3a // акцент (CTA, лайк, активный стейт)
--font-mono: 'DM Mono', monospace --font-display: 'Instrument Serif',
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,14 +8,15 @@ 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)
--- ---
All user-facing text (button labels, placeholders, error messages, All user-facing text (button labels, placeholders, error messages,
toast notifications, empty states, navigation items, form hints, toast notifications, empty states, navigation items, form hints,
section headings) must be written in Russian. section headings) must be written in Russian.
Brand name, logo, and any decorative/editorial text remain in English. Brand name, logo, and any decorative/editorial text remain in English.
@@ -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
@@ -324,26 +347,26 @@ actions: openChat, closeChat, sendMessage, fetchMessages, startPolling, stopPoll
```scss ```scss
// _variables.scss // _variables.scss
:root { :root {
--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; --color-signal: #c45c3a;
--color-signal-dim:#7a3822; --color-signal-dim: #7a3822;
--color-border: rgba(240, 235, 224, 0.08); --color-border: rgba(240, 235, 224, 0.08);
--font-display: 'Instrument Serif', Georgia, serif; --font-display: 'Instrument Serif', Georgia, serif;
--font-mono: 'DM Mono', 'Courier New', monospace; --font-mono: 'DM Mono', 'Courier New', monospace;
--radius-sm: 4px; --radius-sm: 4px;
--radius-md: 8px; --radius-md: 8px;
--radius-lg: 16px; --radius-lg: 16px;
--shadow-card: 0 2px 24px rgba(0,0,0,0.6); --shadow-card: 0 2px 24px rgba(0, 0, 0, 0.6);
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-base: 280ms cubic-bezier(0.4, 0, 0.2, 1); --transition-base: 280ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-spring: 420ms cubic-bezier(0.34, 1.56, 0.64, 1); --transition-spring: 420ms cubic-bezier(0.34, 1.56, 0.64, 1);
} }
``` ```

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 как единственный шрифт.
- Пара: выразительный дисплейный + читаемый текстовый - Пара: выразительный дисплейный + читаемый текстовый
- Модульная шкала, не произвольные размеры - Модульная шкала, не произвольные размеры
@@ -133,20 +141,20 @@ 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 | Имя в профиле |
| `display-md` | Playfair Display | 24px | 600 | 1.25 | -0.005em | Заголовки секций | | `display-md` | Playfair Display | 24px | 600 | 1.25 | -0.005em | Заголовки секций |
| `heading-lg` | Manrope | 20px | 700 | 1.3 | -0.01em | Навбар, диалоги | | `heading-lg` | Manrope | 20px | 700 | 1.3 | -0.01em | Навбар, диалоги |
| `heading-md` | Manrope | 17px | 600 | 1.35 | -0.005em | Карточки, подзаголовки | | `heading-md` | Manrope | 17px | 600 | 1.35 | -0.005em | Карточки, подзаголовки |
| `heading-sm` | Manrope | 15px | 600 | 1.4 | 0 | Лейблы полей | | `heading-sm` | Manrope | 15px | 600 | 1.4 | 0 | Лейблы полей |
| `body-lg` | Manrope | 16px | 400 | 1.6 | 0 | Основной текст | | `body-lg` | Manrope | 16px | 400 | 1.6 | 0 | Основной текст |
| `body-md` | Manrope | 14px | 400 | 1.6 | 0 | Описание профиля | | `body-md` | Manrope | 14px | 400 | 1.6 | 0 | Описание профиля |
| `body-sm` | Manrope | 12px | 400 | 1.5 | 0.01em | Мета, временные метки | | `body-sm` | Manrope | 12px | 400 | 1.5 | 0.01em | Мета, временные метки |
| `label-lg` | Manrope | 14px | 500 | 1 | 0.04em | Кнопки, таббар | | `label-lg` | Manrope | 14px | 500 | 1 | 0.04em | Кнопки, таббар |
| `label-sm` | Manrope | 11px | 500 | 1 | 0.06em | Бейджи, чипы | | `label-sm` | Manrope | 11px | 500 | 1 | 0.06em | Бейджи, чипы |
**Все токены фиксируй в Penpot как Text Styles.** **Все токены фиксируй в Penpot как Text Styles.**
@@ -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
@@ -364,7 +412,7 @@ Asymmetric compositions where mobile constraints allow. Every screen complete, p
· Inappropriate content · Inappropriate content
· Harassment · Harassment
· Other · Other
- Description textarea "Add details (optional)..." - Description textarea "Add details (optional)..."
- "Submit Report" Danger Primary button - "Submit Report" Danger Primary button
- "Cancel" Ghost - "Cancel" Ghost
@@ -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
@@ -390,4 +440,4 @@ Asymmetric compositions where mobile constraints allow. Every screen complete, p
- Photo placeholders: picsum.photos/seed/{name}/400/600 (sofia, lena, masha, daniil, artem) - Photo placeholders: picsum.photos/seed/{name}/400/600 (sofia, lena, masha, daniil, artem)
**Aesthetic directive:** **Aesthetic directive:**
Dark luxury. Warm coral accent. The refinement of a premium product — not a hookup app, not a social network. Think: the design confidence of Locket or BeReal's intentionality, the premium feel of a high-end financial app, applied to human connection. Dark luxury. Warm coral accent. The refinement of a premium product — not a hookup app, not a social network. Think: the design confidence of Locket or BeReal's intentionality, the premium feel of a high-end financial app, applied to human connection.

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,8 +9,9 @@ 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
- Screens go into one frame row, left to right, 40px gaps - Screens go into one frame row, left to right, 40px gaps
- Do NOT switch pages — one page only - Do NOT switch pages — one page only
@@ -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
@@ -118,4 +127,4 @@ C5 · Report (bottom sheet) — Handle, "Report" Title, "Reporting Sofia, 24" ca
**Touch targets:** All interactive elements minimum 44×44px **Touch targets:** All interactive elements minimum 44×44px
**Aesthetic:** Premium dating app. The design confidence of a high-end product — refined, warm, intentional. **Aesthetic:** Premium dating app. The design confidence of a high-end product — refined, warm, intentional.

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 = [] }
@@ -16,7 +20,7 @@ tauri-build = { version = "2", features = [] }
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = [ "derive" ] }
serde_json = "1" serde_json = "1"
[profile.release] [profile.release]

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,72 +1,8 @@
<script setup lang="ts">
import { ref } from 'vue';
import AppButton from '@/components/common/AppButton.vue';
const emit = defineEmits<{
send: [text: string, mediaUrl?: string, mediaType?: 'photo' | 'voice' | 'video'];
}>();
const text = ref('');
const fileInput = ref<HTMLInputElement | null>(null);
const textareaEl = ref<HTMLTextAreaElement | null>(null);
const isTauri = typeof window !== 'undefined' && !!window.__TAURI__;
function handleInput(e: Event) {
const el = e.target as HTMLTextAreaElement;
text.value = el.value;
// Auto-grow
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, 140)}px`;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
send();
}
}
function send() {
const val = text.value.trim();
if (!val) return;
emit('send', val);
text.value = '';
if (textareaEl.value) {
textareaEl.value.style.height = 'auto';
}
}
async function openFilePicker() {
if (isTauri) {
try {
const { open } = await import('@tauri-apps/plugin-dialog');
const result = await open({ filters: [{ name: 'Медиа', extensions: ['jpg', 'jpeg', 'png', 'mp4', 'mov'] }] });
if (typeof result === 'string') {
// Tauri returns file path; treat as photo for now
emit('send', '', result, 'photo');
}
} catch { /* user cancelled */ }
} else {
fileInput.value?.click();
}
}
function onFileChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const url = URL.createObjectURL(file);
const type = file.type.startsWith('video/') ? 'video' : 'photo';
emit('send', '', url, type);
if (fileInput.value) fileInput.value.value = '';
}
</script>
<template> <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>
</button> </button>
@@ -74,21 +10,21 @@ 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" />
</svg> </svg>
</button> </button>
@@ -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,10 +20,10 @@ 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" />
</svg> </svg>
</button> </button>
<div class="media__waveform" aria-hidden="true"> <div class="media__waveform" aria-hidden="true">
@@ -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,85 +1,86 @@
<script setup lang="ts">
import { ref } from 'vue';
const emit = defineEmits<{ recorded: [blob: Blob] }>();
const recording = ref(false);
const mediaRecorder = ref<MediaRecorder | null>(null);
const chunks = ref<Blob[]>([]);
const duration = ref(0);
let timer: ReturnType<typeof setInterval> | null = null;
async function start() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder.value = new MediaRecorder(stream);
chunks.value = [];
mediaRecorder.value.ondataavailable = (e) => chunks.value.push(e.data);
mediaRecorder.value.onstop = () => {
const blob = new Blob(chunks.value, { type: 'audio/webm' });
emit('recorded', blob);
stream.getTracks().forEach((t) => t.stop());
};
mediaRecorder.value.start();
recording.value = true;
duration.value = 0;
timer = setInterval(() => duration.value++, 1000);
} catch {
// Microphone access denied
}
}
function stop() {
mediaRecorder.value?.stop();
recording.value = false;
if (timer) { clearInterval(timer); timer = null; }
}
function cancel() {
if (mediaRecorder.value?.state === 'recording') {
mediaRecorder.value.ondataavailable = null;
mediaRecorder.value.onstop = null;
mediaRecorder.value.stop();
}
recording.value = false;
duration.value = 0;
if (timer) { clearInterval(timer); timer = null; }
}
function formatTime(s: number) {
const m = Math.floor(s / 60);
const sec = s % 60;
return `${m}:${sec.toString().padStart(2, '0')}`;
}
</script>
<template> <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" />
</svg> </svg>
</button> </button>
</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,42 +1,15 @@
<script setup lang="ts">
import { watch, onUnmounted } from 'vue';
const props = defineProps<{
open: boolean;
title?: string;
side?: 'right' | 'bottom';
}>();
const emit = defineEmits<{ close: [] }>();
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close');
}
watch(() => props.open, (val) => {
document.body.style.overflow = val ? 'hidden' : '';
if (val) document.addEventListener('keydown', handleKeydown);
else document.removeEventListener('keydown', handleKeydown);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown);
document.body.style.overflow = '';
});
const side = props.side ?? 'right';
</script>
<template> <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>
</button> </button>
</div> </div>
@@ -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,39 +1,15 @@
<script setup lang="ts">
import { onMounted, onUnmounted, watch } from 'vue';
const props = defineProps<{
open: boolean;
title?: string;
size?: 'sm' | 'md' | 'lg';
}>();
const emit = defineEmits<{ close: [] }>();
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close');
}
watch(() => props.open, (val) => {
document.body.style.overflow = val ? 'hidden' : '';
});
onMounted(() => document.addEventListener('keydown', handleKeydown));
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown);
document.body.style.overflow = '';
});
</script>
<template> <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>
<div class="modal__body"> <div class="modal__body">
@@ -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">
@@ -16,14 +11,14 @@ const uiStore = useUi();
> >
<span class="toast__icon"> <span class="toast__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path v-if="toast.type === 'success'" d="M20 6L9 17l-5-5"/> <path v-if="toast.type === 'success'" d="M20 6L9 17l-5-5" />
<path v-else-if="toast.type === 'error'" d="M12 8v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/> <path v-else-if="toast.type === 'error'" d="M12 8v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<path v-else d="M12 16v-4m0-4h.01M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/> <path v-else d="M12 16v-4m0-4h.01M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z" />
</svg> </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>
</TransitionGroup> </TransitionGroup>
@@ -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,57 +1,61 @@
<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">
<svg viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg" class="empty__svg"> <svg viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg" class="empty__svg">
<!-- Feed empty state --> <!-- Feed empty state -->
<template v-if="icon === 'feed'"> <template v-if="icon === 'feed'">
<rect x="20" y="10" width="80" height="60" rx="4" stroke="currentColor" stroke-width="1.5" stroke-dasharray="4 3"/> <rect x="20" y="10" width="80" height="60" rx="4" stroke="currentColor" stroke-width="1.5" stroke-dasharray="4 3" />
<circle cx="45" cy="30" r="8" stroke="currentColor" stroke-width="1.5"/> <circle cx="45" cy="30" r="8" stroke="currentColor" stroke-width="1.5" />
<path d="M20 55 L40 40 L55 50 L75 35 L100 55" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> <path d="M20 55 L40 40 L55 50 L75 35 L100 55" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M60 20 L80 20" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.4"/> <path d="M60 20 L80 20" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.4" />
<path d="M60 27 L90 27" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.3"/> <path d="M60 27 L90 27" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.3" />
</template> </template>
<!-- Heart/matches empty state --> <!-- Heart/matches empty state -->
<template v-else-if="icon === 'heart'"> <template v-else-if="icon === 'heart'">
<path d="M60 64 L26 42 A18 18 0 0 1 26 11 A18 18 0 0 1 60 30 A18 18 0 0 1 94 11 A18 18 0 0 1 94 42 Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="5 3"/> <path d="M60 64 L26 42 A18 18 0 0 1 26 11 A18 18 0 0 1 60 30 A18 18 0 0 1 94 11 A18 18 0 0 1 94 42 Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="5 3" />
</template> </template>
<!-- Chat empty state --> <!-- Chat empty state -->
<template v-else-if="icon === 'chat'"> <template v-else-if="icon === 'chat'">
<path d="M15 15 L105 15 L105 55 L75 55 L60 70 L60 55 L15 55 Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="4 3"/> <path d="M15 15 L105 15 L105 55 L75 55 L60 70 L60 55 L15 55 Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="4 3" />
<path d="M30 30 L60 30" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.5"/> <path d="M30 30 L60 30" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.5" />
<path d="M30 40 L75 40" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.3"/> <path d="M30 40 L75 40" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.3" />
</template> </template>
<!-- Calendar/dates empty state --> <!-- Calendar/dates empty state -->
<template v-else-if="icon === 'calendar'"> <template v-else-if="icon === 'calendar'">
<rect x="15" y="15" width="90" height="65" rx="4" stroke="currentColor" stroke-width="1.5"/> <rect x="15" y="15" width="90" height="65" rx="4" stroke="currentColor" stroke-width="1.5" />
<path d="M15 30 L105 30" stroke="currentColor" stroke-width="1.5" opacity="0.4"/> <path d="M15 30 L105 30" stroke="currentColor" stroke-width="1.5" opacity="0.4" />
<path d="M40 10 L40 22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> <path d="M40 10 L40 22" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M80 10 L80 22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> <path d="M80 10 L80 22" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<circle cx="42" cy="52" r="3" fill="currentColor" opacity="0.4"/> <circle cx="42" cy="52" r="3" fill="currentColor" opacity="0.4" />
<circle cx="60" cy="52" r="3" fill="currentColor" opacity="0.6"/> <circle cx="60" cy="52" r="3" fill="currentColor" opacity="0.6" />
<circle cx="78" cy="52" r="3" fill="currentColor" opacity="0.3"/> <circle cx="78" cy="52" r="3" fill="currentColor" opacity="0.3" />
</template> </template>
<!-- Default / search --> <!-- Default / search -->
<template v-else> <template v-else>
<circle cx="50" cy="40" r="24" stroke="currentColor" stroke-width="1.5" stroke-dasharray="5 3"/> <circle cx="50" cy="40" r="24" stroke="currentColor" stroke-width="1.5" stroke-dasharray="5 3" />
<path d="M68 58 L95 70" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> <path d="M68 58 L95 70" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M40 40 L50 40 M50 32 L50 48" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.5"/> <path d="M40 40 L50 40 M50 32 L50 48" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.5" />
</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,16 +1,16 @@
<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">
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-dasharray="56.5" stroke-dashoffset="14"/> <circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-dasharray="56.5" stroke-dashoffset="14" />
</svg> </svg>
<span class="sr-only">{{ label ?? 'Загрузка...' }}</span> <span class="sr-only">{{ label ?? 'Загрузка...' }}</span>
</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,10 +18,10 @@ 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" />
</svg> </svg>
</div> </div>
@@ -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,20 +65,168 @@ 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>
</button> </button>
</div> </div>
</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
@@ -336,7 +353,7 @@ function onTouchEnd(e: TouchEvent) {
left: 0; left: 0;
right: 0; right: 0;
padding: 80px 20px 80px; padding: 80px 20px 80px;
background: linear-gradient(0deg, rgba(13,13,13,0.92) 0%, rgba(13,13,13,0.6) 50%, transparent 100%); background: linear-gradient(0deg, rgba(13, 13, 13, 0.92) 0%, rgba(13, 13, 13, 0.6) 50%, transparent 100%);
pointer-events: none; pointer-events: none;
} }
@@ -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,11 +6,11 @@ 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" />
</svg> </svg>
<div v-else class="gallery__spinner" /> <div v-else class="gallery__spinner" />
</button> </button>
@@ -109,33 +27,123 @@ 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" />
</svg> </svg>
</button> </button>
</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>
@@ -123,7 +59,7 @@ async function save() {
<div class="profile-editor__row"> <div class="profile-editor__row">
<AppInput v-model.number="(form.height as unknown as string)" label="Рост (см)" type="number" name="height" placeholder="170" /> <AppInput v-model.number="(form.height as unknown as string)" label="Рост (см)" type="number" name="height" placeholder="170" />
<AppInput v-model.number="(form.weight as unknown as string)" label="Вес (кг)" type="number" name="weight" placeholder="60" /> <AppInput v-model.number="(form.weight as unknown as string)" label="Вес (кг)" type="number" name="weight" placeholder="60" />
</div> </div>
<div class="profile-editor__field"> <div class="profile-editor__field">
@@ -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(),
@@ -11,26 +9,26 @@ export const router = createRouter({
{ path: '/', redirect: () => '/feed' }, { path: '/', redirect: () => '/feed' },
// Auth // Auth
{ path: '/login', name: 'login', component: () => import('@/views/auth/LoginView.vue'), meta: { guest: true } }, { path: '/login', name: 'login', component: () => import('@/views/auth/LoginView.vue'), meta: { guest: true } },
{ path: '/register', name: 'register', component: () => import('@/views/auth/RegisterView.vue'), meta: { guest: true } }, { path: '/register', name: 'register', component: () => import('@/views/auth/RegisterView.vue'), meta: { guest: true } },
// Onboarding // Onboarding
{ path: '/setup', name: 'setup', component: () => import('@/views/onboarding/ProfileSetupView.vue'), meta: { auth: true } }, { path: '/setup', name: 'setup', component: () => import('@/views/onboarding/ProfileSetupView.vue'), meta: { auth: true } },
// Main app // Main app
{ path: '/feed', name: 'feed', component: () => import('@/views/feed/FeedView.vue'), meta: { auth: true } }, { path: '/feed', name: 'feed', component: () => import('@/views/feed/FeedView.vue'), meta: { auth: true } },
{ path: '/matches', name: 'matches', component: () => import('@/views/matches/MatchesView.vue'), meta: { auth: true } }, { path: '/matches', name: 'matches', component: () => import('@/views/matches/MatchesView.vue'), meta: { auth: true } },
// Chat // Chat
{ path: '/chats', name: 'chats', component: () => import('@/views/chat/ChatsListView.vue'), meta: { auth: true } }, { path: '/chats', name: 'chats', component: () => import('@/views/chat/ChatsListView.vue'), meta: { auth: true } },
{ path: '/chats/:chatId', name: 'chat-room', component: () => import('@/views/chat/ChatRoomView.vue'), meta: { auth: true } }, { path: '/chats/:chatId', name: 'chat-room', component: () => import('@/views/chat/ChatRoomView.vue'), meta: { auth: true } },
// Dates // Dates
{ path: '/dates', name: 'dates', component: () => import('@/views/dates/DatesView.vue'), meta: { auth: true } }, { path: '/dates', name: 'dates', component: () => import('@/views/dates/DatesView.vue'), meta: { auth: true } },
// Profile // Profile
{ path: '/profile/me', name: 'my-profile', component: () => import('@/views/profile/MyProfileView.vue'), meta: { auth: true } }, { path: '/profile/me', name: 'my-profile', component: () => import('@/views/profile/MyProfileView.vue'), meta: { auth: true } },
{ path: '/profile/:profileId', name: 'profile-detail', component: () => import('@/views/profile/ProfileDetailView.vue'), meta: { auth: true } }, { path: '/profile/:profileId', name: 'profile-detail', component: () => import('@/views/profile/ProfileDetailView.vue'), meta: { auth: true } },
// Admin // Admin
{ path: '/admin/reports', name: 'admin-reports', component: () => import('@/views/admin/ReportsView.vue'), meta: { auth: true, admin: true } }, { path: '/admin/reports', name: 'admin-reports', component: () => import('@/views/admin/ReportsView.vue'), meta: { auth: true, admin: true } },
@@ -38,57 +36,58 @@ export const router = createRouter({
// Catch-all // 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

@@ -1,73 +1,89 @@
// Design tokens — do not use directly in components; use CSS custom properties // Design tokens — do not use directly in components; use CSS custom properties
:root { :root {
// Surfaces // Surfaces
--color-base: #0d0d0d; --color-base: #0d0d0d;
--color-surface: #161614; --color-surface: #161614;
--color-surface-2: #1e1e1b; --color-surface-2: #1e1e1b;
--color-surface-3: #242420; --color-surface-3: #242420;
// Text // Text
--color-cream: #f0ebe0; --color-cream: #f0ebe0;
--color-muted: #6b6860; --color-muted: #6b6860;
--color-dim: #3a3935; --color-dim: #3a3935;
// Brand signal // Brand signal
--color-signal: #c45c3a; --color-signal: #c45c3a;
--color-signal-dim:#7a3822; --color-signal-dim: #7a3822;
--color-signal-bg: rgba(196, 92, 58, 0.12); --color-signal-bg: rgba(196, 92, 58, 0.12);
// Utility // Utility
--color-border: rgba(240, 235, 224, 0.08); --color-border: rgba(240, 235, 224, 0.08);
--color-border-strong: rgba(240, 235, 224, 0.16); --color-border-strong: rgba(240, 235, 224, 0.16);
--color-overlay: rgba(13, 13, 13, 0.72); --color-overlay: rgba(13, 13, 13, 0.72);
// Typography // Typography
--font-display: 'Instrument Serif', Georgia, serif; --font-display: 'Instrument Serif', Georgia, serif;
--font-mono: 'DM Mono', 'Courier New', monospace; --font-mono: 'DM Mono', 'Courier New', monospace;
// Border radius // Border radius
--radius-xs: 2px; --radius-xs: 2px;
--radius-sm: 4px; --radius-sm: 4px;
--radius-md: 8px; --radius-md: 8px;
--radius-lg: 16px; --radius-lg: 16px;
--radius-xl: 24px; --radius-xl: 24px;
--radius-full: 9999px; --radius-full: 9999px;
// Shadows // Shadows
--shadow-card: 0 2px 24px rgba(0, 0, 0, 0.6); --shadow-card: 0 2px 24px rgba(0, 0, 0, 0.6);
--shadow-modal: 0 8px 64px rgba(0, 0, 0, 0.8); --shadow-modal: 0 8px 64px rgba(0, 0, 0, 0.8);
--shadow-signal: 0 0 20px rgba(196, 92, 58, 0.3); --shadow-signal: 0 0 20px rgba(196, 92, 58, 0.3);
// Transitions // Transitions
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-base: 280ms cubic-bezier(0.4, 0, 0.2, 1); --transition-base: 280ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-spring: 420ms cubic-bezier(0.34, 1.56, 0.64, 1); --transition-spring: 420ms cubic-bezier(0.34, 1.56, 0.64, 1);
--transition-slow: 600ms cubic-bezier(0.4, 0, 0.2, 1); --transition-slow: 600ms cubic-bezier(0.4, 0, 0.2, 1);
// Z-index scale // Z-index scale
--z-base: 1; --z-base: 1;
--z-dropdown: 100; --z-dropdown: 100;
--z-sticky: 200; --z-sticky: 200;
--z-overlay: 300; --z-overlay: 300;
--z-modal: 400; --z-modal: 400;
--z-toast: 500; --z-toast: 500;
--z-tooltip: 600; --z-tooltip: 600;
--z-titlebar: 700; --z-titlebar: 700;
// Layout // Layout
--sidebar-collapsed: 64px; --sidebar-collapsed: 64px;
--sidebar-expanded: 240px; --sidebar-expanded: 240px;
--nav-height: 60px; --nav-height: 60px;
--titlebar-height: 36px; --titlebar-height: 36px;
} }
// SCSS breakpoints (for use in @media) // SCSS breakpoints (for use in @media)
$mobile: 375px; $mobile: 375px;
$tablet: 768px; $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,92 +1,10 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick, computed } from 'vue';
import { useRoute } from 'vue-router';
import { useAuth } from '@/composables/useAuth';
import { useChat } from '@/composables/useChat';
import { useUi } from '@/composables/useUi';
import { apiClient } from '@/api/client';
import type { ChatMessage } from '@/composables/useChat';
import ChatBubble from '@/components/chat/ChatBubble.vue';
import ChatInput from '@/components/chat/ChatInput.vue';
import AppModal from '@/components/common/AppModal.vue';
import AppButton from '@/components/common/AppButton.vue';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
const route = useRoute();
const authStore = useAuth();
const chatStore = useChat();
const uiStore = useUi();
const chatId = route.params.chatId as string;
const profileId = computed(() => authStore.activeProfile?.id ?? '');
const messagesEnd = ref<HTMLElement | null>(null);
const confirmClose = ref(false);
const chat = computed(() => chatStore.chats.find((c) => c.id === chatId));
const isLocked = computed(() => chat.value?.status === 'closed');
// Group messages by date
const groupedMessages = computed(() => {
const groups: Array<{ date: string; messages: ChatMessage[] }> = [];
let lastDate = '';
for (const msg of chatStore.messages) {
const d = new Date(msg.createdAt).toLocaleDateString('ru', { day: 'numeric', month: 'long', year: 'numeric' });
if (d !== lastDate) {
groups.push({ date: d, messages: [] });
lastDate = d;
}
groups[groups.length - 1].messages.push(msg);
}
return groups;
});
async function scrollToBottom() {
await nextTick();
messagesEnd.value?.scrollIntoView({ behavior: 'smooth' });
}
onMounted(async () => {
if (!profileId.value) return;
await chatStore.fetchMessages(chatId, profileId.value);
await scrollToBottom();
chatStore.startPolling(chatId, profileId.value);
});
onUnmounted(() => {
chatStore.stopPolling();
});
async function send(text: string, mediaUrl?: string, mediaType?: 'photo' | 'voice' | 'video') {
if (!profileId.value) return;
try {
await chatStore.sendMessage(chatId, profileId.value, { text, mediaUrl, mediaType });
await scrollToBottom();
} catch {
uiStore.addToast('Не удалось отправить сообщение', 'error');
}
}
function goBack() { window.history.back(); }
async function doCloseChat() {
if (!profileId.value) return;
try {
await chatStore.closeChat(chatId, profileId.value);
confirmClose.value = false;
goBack();
} catch {
uiStore.addToast('Не удалось закрыть чат', 'error');
}
}
</script>
<template> <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>
</button> </button>
<div class="chat-room__partner"> <div class="chat-room__partner">
@@ -95,17 +13,17 @@ 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" />
</svg> </svg>
</button> </button>
</header> </header>
@@ -113,8 +31,8 @@ async function doCloseChat() {
<!-- Locked overlay --> <!-- Locked overlay -->
<div v-if="isLocked" class="chat-room__locked" role="alert"> <div v-if="isLocked" class="chat-room__locked" role="alert">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="24" height="24"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="24" height="24">
<rect x="3" y="11" width="18" height="11" rx="2"/> <rect x="3" y="11" width="18" height="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4"/> <path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg> </svg>
<span>Чат заблокирован. Закройте другой активный чат, чтобы продолжить общение.</span> <span>Чат заблокирован. Закройте другой активный чат, чтобы продолжить общение.</span>
</div> </div>
@@ -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,17 +30,17 @@ 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" />
</svg> </svg>
</div> </div>
<!-- Lock icon for inactive chats --> <!-- Lock icon for inactive chats -->
<div v-if="chat.status === 'closed'" class="chat-item__lock" aria-label="Чат неактивен"> <div v-if="chat.status === 'closed'" class="chat-item__lock" aria-label="Чат неактивен">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12"> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12">
<rect x="3" y="7" width="10" height="8" rx="1"/> <rect x="3" y="7" width="10" height="8" rx="1" />
<path d="M5 7V5a3 3 0 0 1 6 0v2"/> <path d="M5 7V5a3 3 0 0 1 6 0v2" />
</svg> </svg>
</div> </div>
</div> </div>
@@ -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
@@ -123,8 +34,8 @@ const completedStatusId = computed(() => statuses.value[3]?.id ?? '');
<div class="date-card__location meta"> <div class="date-card__location meta">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"> <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14">
<path d="M10 11a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/> <path d="M10 11a2 2 0 1 0 0-4 2 2 0 0 0 0 4z" />
<path d="M17 10c0 6-7 10-7 10S3 16 3 10a7 7 0 1 1 14 0z"/> <path d="M17 10c0 6-7 10-7 10S3 16 3 10a7 7 0 1 1 14 0z" />
</svg> </svg>
{{ date.lat.toFixed(4) }}, {{ date.lng.toFixed(4) }} {{ date.lat.toFixed(4) }}, {{ date.lng.toFixed(4) }}
</div> </div>
@@ -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,59 +1,39 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useFeed } from '@/composables/useFeed';
import { useAuth } from '@/composables/useAuth';
import FeedCardStack from '@/components/feed/FeedCardStack.vue';
import FeedFilters from '@/components/feed/FeedFilters.vue';
import AppButton from '@/components/common/AppButton.vue';
const feedStore = useFeed();
const authStore = useAuth();
const filtersOpen = ref(false);
const viewMode = ref<'stack' | 'scroll'>('stack');
onMounted(() => {
const profileId = authStore.activeProfile?.id;
if (profileId && feedStore.cards.length === 0) {
feedStore.fetchNextPage(profileId);
}
});
</script>
<template> <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" />
<rect x="5" y="5" width="10" height="10" rx="1" stroke-dasharray="2 1"/> <rect x="5" y="5" width="10" height="10" rx="1" stroke-dasharray="2 1" />
</svg> </svg>
</button> </button>
<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" />
</svg> </svg>
</button> </button>
</div> </div>
<AppButton variant="secondary" size="sm" @click="filtersOpen = true"> <AppButton variant="secondary" size="sm" @click="filtersOpen = true">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"> <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14">
<path d="M3 5h14M6 10h8M9 15h2"/> <path d="M3 5h14M6 10h8M9 15h2" />
</svg> </svg>
Фильтры Фильтры
</AppButton> </AppButton>
@@ -63,7 +43,7 @@ onMounted(() => {
<!-- Search paused banner --> <!-- Search paused banner -->
<div v-if="feedStore.searchPaused" class="feed-paused" role="alert"> <div v-if="feedStore.searchPaused" class="feed-paused" role="alert">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16"> <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16">
<path d="M10 9v4m0 4h.01M8.29 3.86L1.82 17a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/> <path d="M10 9v4m0 4h.01M8.29 3.86L1.82 17a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
</svg> </svg>
Поиск приостановлен: достигнут лимит совпадений Поиск приостановлен: достигнут лимит совпадений
</div> </div>
@@ -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%;
@@ -234,7 +236,7 @@ onMounted(() => {
&__overlay { &__overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: linear-gradient(0deg, rgba(13,13,13,0.8) 0%, transparent 50%); background: linear-gradient(0deg, rgba(13, 13, 13, 0.8) 0%, transparent 50%);
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
padding: 12px; padding: 12px;

View File

@@ -1,68 +1,9 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useAuth } from '@/composables/useAuth';
import { useChat } from '@/composables/useChat';
import { useUi } from '@/composables/useUi';
import { apiClient } from '@/api/client';
import { useRouter } from 'vue-router';
import AppButton from '@/components/common/AppButton.vue';
import EmptyState from '@/components/common/EmptyState.vue';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
interface Match {
id: string;
profileId: string;
partnerProfile: {
id: string;
name: string;
avatarUrl?: string;
cityName?: string;
age?: number;
};
createdAt: string;
hasChat: boolean;
}
const authStore = useAuth();
const chatStore = useChat();
const uiStore = useUi();
const router = useRouter();
const matches = ref<Match[]>([]);
const loading = ref(false);
onMounted(async () => {
const profileId = authStore.activeProfile?.id;
if (!profileId) return;
loading.value = true;
try {
const res = await apiClient.api.likesControllerGetMyMatches({ profileId }) as unknown as Match[];
matches.value = res;
} catch {
uiStore.addToast('Не удалось загрузить совпадения', 'error');
} finally {
loading.value = false;
}
});
async function openChat(match: Match) {
const profileId = authStore.activeProfile?.id;
if (!profileId) return;
try {
const chat = await chatStore.openChat(profileId, match.id);
router.push(`/chats/${chat.id}`);
} catch (err: unknown) {
const msg = (err as { response?: { data?: { message?: string } } })
?.response?.data?.message ?? 'Не удалось открыть чат';
uiStore.addToast(msg, 'error');
}
}
</script>
<template> <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,10 +30,10 @@ 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" />
</svg> </svg>
</div> </div>
</RouterLink> </RouterLink>
@@ -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,20 +95,26 @@ 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" />
<div class="setup__row"> <div class="setup__row">
<AppInput v-model.number="(form.height as unknown as string)" label="Рост (см)" placeholder="170" type="number" name="height" /> <AppInput v-model.number="(form.height as unknown as string)" label="Рост (см)" placeholder="170" type="number" name="height" />
<AppInput v-model.number="(form.weight as unknown as string)" label="Вес (кг)" placeholder="60" type="number" name="weight" /> <AppInput v-model.number="(form.weight as unknown as string)" label="Вес (кг)" placeholder="60" type="number" name="weight" />
</div> </div>
</div> </div>
<!-- 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,16 +127,18 @@ 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 -->
<div v-if="step === 4" class="setup__step"> <div v-if="step === 4" class="setup__step">
<div class="setup__photo-hint"> <div class="setup__photo-hint">
<svg viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="1.5" width="64" height="64"> <svg viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="1.5" width="64" height="64">
<rect x="4" y="12" width="56" height="40" rx="4"/> <rect x="4" y="12" width="56" height="40" rx="4" />
<circle cx="32" cy="32" r="10"/> <circle cx="32" cy="32" r="10" />
<circle cx="50" cy="20" r="3"/> <circle cx="50" cy="20" r="3" />
</svg> </svg>
<p>После создания профиля вы сможете добавить фото в разделе <strong>Мой профиль</strong></p> <p>После создания профиля вы сможете добавить фото в разделе <strong>Мой профиль</strong></p>
</div> </div>
@@ -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,32 +10,44 @@ 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" />
</svg> </svg>
</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,27 +12,31 @@ 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>
</button> </button>
</div> </div>
@@ -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/*"]
} },
"resolveJsonModule": true,
"strict": true,
"noEmit": true,
"sourceMap": true,
"skipLibCheck": true
}, },
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], "references": [{ "path": "./tsconfig.node.json" }],
"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/**'],
}, },
}, },
})); }))