first commit
This commit is contained in:
11
lastochka-ui/.env.example
Normal file
11
lastochka-ui/.env.example
Normal file
@@ -0,0 +1,11 @@
|
||||
# Tinode backend WebSocket host (without protocol)
|
||||
VITE_TINODE_HOST=localhost:6060
|
||||
|
||||
# API key (from tinode server config, default for dev)
|
||||
VITE_TINODE_API_KEY=AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K
|
||||
|
||||
# Use WSS (secure WebSocket) - set to "true" in production
|
||||
VITE_TINODE_SECURE=false
|
||||
|
||||
# App name sent to Tinode server
|
||||
VITE_APP_NAME=Ласточка
|
||||
2
lastochka-ui/.env.production
Normal file
2
lastochka-ui/.env.production
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_TINODE_HOST=app.lastochka-m.ru
|
||||
VITE_TINODE_SECURE=true
|
||||
332
lastochka-ui/AUTH.md
Normal file
332
lastochka-ui/AUTH.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# Аутентификация в Ласточке
|
||||
|
||||
## Обзор
|
||||
|
||||
Ласточка поддерживает два способа аутентификации:
|
||||
|
||||
1. **Классическая** — логин и пароль
|
||||
2. **По номеру телефона** — SMS-код подтверждения
|
||||
|
||||
## Регистрация по номеру телефона
|
||||
|
||||
### Процесс регистрации
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
| Ввод номера | -> | SMS с кодом | -> | Верификация |
|
||||
| телефона | | отправлено | | завершена |
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
#### 1. Отправка SMS для регистрации
|
||||
|
||||
```typescript
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
|
||||
const { sendRegistrationSms } = useAuthStore()
|
||||
await sendRegistrationSms('+7 (999) 999-99-99')
|
||||
```
|
||||
|
||||
**Что происходит:**
|
||||
- Валидация номера телефона
|
||||
- Подключение к Tinode
|
||||
- Создание учётной записи с credential (номер телефона)
|
||||
- Отправка SMS кода подтверждения
|
||||
|
||||
#### 2. Проверка SMS кода
|
||||
|
||||
```typescript
|
||||
const { verifyRegistrationSms } = useAuthStore()
|
||||
await verifyRegistrationSms('123456', 'Имя пользователя')
|
||||
```
|
||||
|
||||
**Что происходит:**
|
||||
- Проверка кода подтверждения
|
||||
- Создание полноценного аккаунта
|
||||
- Автоматический вход в систему
|
||||
|
||||
### Утилиты для работы с телефоном
|
||||
|
||||
```typescript
|
||||
import {
|
||||
formatPhoneNumber, // Форматирование для отображения
|
||||
cleanPhoneNumber, // Очистка для отправки на сервер
|
||||
isValidPhoneNumber, // Валидация номера
|
||||
} from '@/lib/phone-utils'
|
||||
|
||||
// Примеры
|
||||
formatPhoneNumber('79999999999') // "+7 (999) 999-99-99"
|
||||
cleanPhoneNumber('+7 (999)...') // "79999999999"
|
||||
isValidPhoneNumber('+7 (999)...') // true
|
||||
```
|
||||
|
||||
## Вход по номеру телефона
|
||||
|
||||
### Процесс входа
|
||||
|
||||
```typescript
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
|
||||
const { loginByPhone, loginWithSmsCode } = useAuthStore()
|
||||
|
||||
// 1. Запрос SMS кода
|
||||
await loginByPhone('+7 (999) 999-99-99')
|
||||
|
||||
// 2. Ввод кода из SMS
|
||||
await loginWithSmsCode('123456')
|
||||
```
|
||||
|
||||
## Классическая аутентификация
|
||||
|
||||
### Вход по логину/паролю
|
||||
|
||||
```typescript
|
||||
const { login } = useAuthStore()
|
||||
await login('username', 'password')
|
||||
```
|
||||
|
||||
### Регистрация по логину/паролю
|
||||
|
||||
```typescript
|
||||
// Пока не реализовано через отдельный метод
|
||||
// Используйте login с новым логином
|
||||
```
|
||||
|
||||
## Компоненты
|
||||
|
||||
### LoginScreen.tsx
|
||||
|
||||
Основной компонент экрана входа/регистрации.
|
||||
|
||||
**Режимы работы:**
|
||||
- `phone-login` — вход по номеру телефона
|
||||
- `phone-register` — регистрация по номеру телефона
|
||||
- `login` — вход по логину/паролю
|
||||
- `register` — регистрация по логину/паролю
|
||||
|
||||
**Состояния:**
|
||||
- `verificationStep: 'none'` — ввод номера/логина
|
||||
- `verificationStep: 'sms-sent'` — ожидание SMS кода
|
||||
- `verificationStep: 'verified'` — успешная верификация
|
||||
|
||||
### SmsVerification.tsx
|
||||
|
||||
Компонент ввода SMS кода.
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
interface SmsVerificationProps {
|
||||
phone: string // Номер телефона для отображения
|
||||
onComplete: (code) => void // Callback при вводе кода
|
||||
onBack: () => void // Callback при возврате назад
|
||||
isLoading: boolean // Индикатор загрузки
|
||||
error: string | null // Сообщение об ошибке
|
||||
title?: string // Заголовок
|
||||
description?: string // Описание
|
||||
}
|
||||
```
|
||||
|
||||
**Функции:**
|
||||
- Автофокус на первом поле
|
||||
- Автопереход между полями
|
||||
- Поддержка вставки кода из буфера обмена
|
||||
- Обработка Backspace для возврата назад
|
||||
|
||||
## Store (auth.ts)
|
||||
|
||||
### Состояние
|
||||
|
||||
```typescript
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean
|
||||
userId: string | null
|
||||
displayName: string | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
|
||||
// Для SMS-верификации
|
||||
phoneForVerification: string | null
|
||||
verificationStep: 'none' | 'sms-sent' | 'verified'
|
||||
|
||||
// Методы
|
||||
login: (login, password) => Promise<void>
|
||||
registerByPhone: (phone, displayName) => Promise<void>
|
||||
sendRegistrationSms: (phone) => Promise<void>
|
||||
verifyRegistrationSms: (code, displayName) => Promise<void>
|
||||
loginByPhone: (phone) => Promise<void>
|
||||
loginWithSmsCode: (code) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
tryAutoLogin: () => Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
## SMS-провайдеры
|
||||
|
||||
### Настройка
|
||||
|
||||
Для отправки SMS необходимо настроить SMS-провайдера на стороне сервера Tinode.
|
||||
|
||||
**Популярные провайдеры для РФ:**
|
||||
- SMS.ru
|
||||
- SMS Pilot
|
||||
- Prostor SMS
|
||||
- Telesign
|
||||
|
||||
### Конфигурация на сервере
|
||||
|
||||
В конфигурации сервера Tinode укажите:
|
||||
|
||||
```yaml
|
||||
sms:
|
||||
provider: "sms.ru"
|
||||
api_key: "your-api-key"
|
||||
sender: "Lastochka"
|
||||
```
|
||||
|
||||
## Безопасность
|
||||
|
||||
### Валидация номеров
|
||||
|
||||
- Проверка формата российского номера (+7 XXX XXX-XX-XX)
|
||||
- Очистка от спецсимволов перед отправкой
|
||||
- Проверка длины номера (11 цифр)
|
||||
|
||||
### Защита от злоупотреблений
|
||||
|
||||
- Rate limiting на отправку SMS (не чаще 1 раза в минуту)
|
||||
- Максимум 3 попытки ввода кода
|
||||
- Блокировка при множественных неудачных попытках
|
||||
|
||||
### Хранение токенов
|
||||
|
||||
- Токен сохраняется в localStorage
|
||||
- Токен автоматически обновляется при продлении сессии
|
||||
- При logout токен удаляется
|
||||
|
||||
## Пример использования
|
||||
|
||||
### Полная регистрация по номеру телефона
|
||||
|
||||
```typescript
|
||||
import { useState } from 'react'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { formatPhoneNumber, isValidPhoneNumber } from '@/lib/phone-utils'
|
||||
|
||||
function RegistrationForm() {
|
||||
const [phone, setPhone] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
|
||||
const { sendRegistrationSms, verifyRegistrationSms, isLoading, error } = useAuthStore()
|
||||
|
||||
const handleSendSms = async () => {
|
||||
if (!isValidPhoneNumber(phone)) return
|
||||
await sendRegistrationSms(phone)
|
||||
}
|
||||
|
||||
const handleVerify = async () => {
|
||||
await verifyRegistrationSms(code, displayName)
|
||||
}
|
||||
|
||||
return (
|
||||
<form>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(formatPhoneNumber(e.target.value))}
|
||||
placeholder="+7 (999) 999-99-99"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Ваше имя"
|
||||
/>
|
||||
<button onClick={handleSendSms} disabled={isLoading}>
|
||||
Получить код
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="Код из SMS"
|
||||
maxLength={6}
|
||||
/>
|
||||
<button onClick={handleVerify} disabled={isLoading}>
|
||||
Подтвердить
|
||||
</button>
|
||||
|
||||
{error && <p className="error">{error}</p>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Интеграция с бэкендом
|
||||
|
||||
### Эндпоинты
|
||||
|
||||
Сервер должен поддерживать следующие эндпоинты:
|
||||
|
||||
1. **POST /v1/creds/tel** — запрос кода подтверждения
|
||||
2. **PUT /v1/creds/tel** — проверка кода подтверждения
|
||||
|
||||
### Формат запроса
|
||||
|
||||
```json
|
||||
// Запрос SMS кода
|
||||
{
|
||||
"tel": "79999999999",
|
||||
"op": "add"
|
||||
}
|
||||
|
||||
// Проверка кода
|
||||
{
|
||||
"tel": "79999999999",
|
||||
"val": "123456",
|
||||
"op": "add"
|
||||
}
|
||||
```
|
||||
|
||||
### Формат ответа
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"text": "OK",
|
||||
"params": {
|
||||
"cred": "tel:79999999999"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Тестирование
|
||||
|
||||
### Тестовые номера
|
||||
|
||||
Для тестирования без реальной отправки SMS:
|
||||
|
||||
```
|
||||
+7 (999) 000-00-01 → код: 123456
|
||||
+7 (999) 000-00-02 → код: 654321
|
||||
```
|
||||
|
||||
### Mock SMS-провайдера
|
||||
|
||||
```typescript
|
||||
// В разработке можно использовать mock
|
||||
export async function sendSmsCode(phone: string) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`SMS код для ${phone}: 123456`)
|
||||
return { success: true }
|
||||
}
|
||||
// Реальная отправка SMS
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Ласточка** — народный мессенджер с открытым кодом 🕊️
|
||||
110
lastochka-ui/CHANGELOG.md
Normal file
110
lastochka-ui/CHANGELOG.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Веб-клиент «Ласточка» — Журнал изменений
|
||||
|
||||
## Версия 0.2.0 — Полная функциональность
|
||||
|
||||
### ✅ Реализованные функции
|
||||
|
||||
#### 1. Пагинация сообщений
|
||||
- **Файлы:** `src/store/chat.ts`, `src/components/chat/MessagesList.tsx`
|
||||
- **Что сделано:**
|
||||
- Подгрузка сообщений при скролле вверх
|
||||
- Индикатор загрузки «Загрузка...»
|
||||
- Сохранение позиции скролла после подгрузки
|
||||
- Метод `loadMoreMessages()` в store
|
||||
- Флаг `hasMoreMessages` для отслеживания наличия истории
|
||||
|
||||
#### 2. Статусы прочтения
|
||||
- **Файлы:** `src/store/chat.ts`
|
||||
- **Что сделано:**
|
||||
- Автоматическая отметка прочтения при открытии чата (`topic.noteRead()`)
|
||||
- Метод `markMessagesAsRead()` для ручного вызова
|
||||
- Интеграция с Tinode SDK
|
||||
|
||||
#### 3. Typing Indicator (индикатор набора текста)
|
||||
- **Файлы:** `src/store/chat.ts`, `src/components/chat/MessageInput.tsx`
|
||||
- **Что сделано:**
|
||||
- Отправка уведомления при вводе текста (`topic.noteKeyPress()`)
|
||||
- Debounce 1 секунда для избежания спама
|
||||
- Метод `sendTypingNotification()` в store
|
||||
|
||||
#### 4. Отображение аватаров из Tinode
|
||||
- **Файлы:**
|
||||
- `src/lib/tinode-client.ts` — функция `getAvatarUrl()`
|
||||
- `src/store/chat.ts` — извлечение аватара из контакта
|
||||
- `src/tinode.d.ts` — типы для photo.large/ref
|
||||
- **Что сделано:**
|
||||
- Поддержка base64 (`data:image/...`)
|
||||
- Поддержка ссылок (`ref`)
|
||||
- Поддержка large версии аватара
|
||||
- Отображение в ChatItem и Avatar компонентах
|
||||
|
||||
#### 5. Звук при новом сообщении
|
||||
- **Файлы:** `src/store/chat.ts`, `src/components/layout/Sidebar.tsx`
|
||||
- **Что сделано:**
|
||||
- Web Audio API для генерации звука (без внешних файлов)
|
||||
- Кнопка включения/выключения звука в Sidebar
|
||||
- Звук воспроизводится только для неактивного чата
|
||||
- Флаг `playSound` в store
|
||||
- Метод `toggleSound()` для переключения
|
||||
|
||||
### 🐛 Исправления
|
||||
|
||||
#### TypeScript типы
|
||||
- **Файл:** `src/tinode.d.ts`
|
||||
- **Что исправлено:**
|
||||
- Добавлены методы `getDesc()`, `noteRead()`, `noteKeyPress()` в Topic
|
||||
- Добавлен `ImportMetaEnv` для Vite environment variables
|
||||
- Обновлён тип TinodeContact с поддержкой `photo.large`
|
||||
|
||||
#### Email/SMS аутентификация
|
||||
- **Файлы:** `src/lib/email-auth.ts`, `src/lib/sms-auth.ts`
|
||||
- **Что исправлено:**
|
||||
- Замена `reqCred()` на `acc()` (API Tinode)
|
||||
- Корректная передача credential через `scheme/secret`
|
||||
|
||||
#### UI компоненты
|
||||
- **Файлы:**
|
||||
- `src/components/layout/Sidebar.tsx`
|
||||
- `src/components/ui/Icon.tsx`
|
||||
- **Что исправлено:**
|
||||
- Добавлены иконки: `settings`, `group_add`, `campaign`, `admin_panel_settings`, `logout`
|
||||
- Исправлена передача props в ChatItem (active, onClick)
|
||||
|
||||
### 📦 Сборка
|
||||
|
||||
```bash
|
||||
cd dev/lastochka-ui
|
||||
npm run build
|
||||
# ✓ built in ~12s
|
||||
```
|
||||
|
||||
### 🚀 Запуск dev-сервера
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 📋 Известные проблемы
|
||||
|
||||
1. **Админ-панель** — ошибки TypeScript в `Logs.tsx` и `Settings.tsx` (не критично для основного функционала)
|
||||
2. **E2E-шифрование** — не реализовано (в планах)
|
||||
3. **Отправка файлов** — требует доработки UI
|
||||
|
||||
### 🎯 Готовность к запуску
|
||||
|
||||
| Компонент | Статус |
|
||||
|-----------|--------|
|
||||
| Личные чаты | ✅ 100% |
|
||||
| Группы | ✅ 100% |
|
||||
| Каналы | ✅ 100% |
|
||||
| Пагинация | ✅ 100% |
|
||||
| Статусы прочтения | ✅ 100% |
|
||||
| Typing indicator | ✅ 100% |
|
||||
| Аватары | ✅ 100% |
|
||||
| Звук | ✅ 100% |
|
||||
| Тёмная тема | ✅ 100% |
|
||||
| Адаптивность | ✅ 100% |
|
||||
|
||||
---
|
||||
|
||||
**Следующий этап:** Мобильные приложения (iOS/Android) — ребрендинг форков Tinode.
|
||||
346
lastochka-ui/LOGS.md
Normal file
346
lastochka-ui/LOGS.md
Normal file
@@ -0,0 +1,346 @@
|
||||
# Логирование в Ласточке
|
||||
|
||||
## Обзор
|
||||
|
||||
Система логирования записывает все важные действия пользователей и администраторов для:
|
||||
|
||||
- 🔍 Аудита безопасности
|
||||
- 📊 Анализа использования
|
||||
- 🐛 Отладки проблем
|
||||
- 📋 Соответствия требованиям
|
||||
|
||||
## Типы действий
|
||||
|
||||
### Пользовательские
|
||||
|
||||
| Действие | Код | Описание |
|
||||
|----------|-----|----------|
|
||||
| Вход | `login` | Пользователь вошёл в систему |
|
||||
| Выход | `logout` | Пользователь вышел из системы |
|
||||
| Регистрация | `register` | Новый пользователь зарегистрировался |
|
||||
| Обновление профиля | `update_profile` | Изменены данные профиля |
|
||||
| Смена пароля | `change_password` | Пользователь сменил пароль |
|
||||
|
||||
### Группы и каналы
|
||||
|
||||
| Действие | Код | Описание |
|
||||
|----------|-----|----------|
|
||||
| Создание группы | `create_group` | Создана новая группа/канал |
|
||||
| Удаление группы | `delete_group` | Группа удалена |
|
||||
|
||||
### Административные
|
||||
|
||||
| Действие | Код | Описание |
|
||||
|----------|-----|----------|
|
||||
| Блокировка | `ban_user` | Пользователь заблокирован |
|
||||
| Разблокировка | `unban_user` | Пользователь разблокирован |
|
||||
| Удаление пользователя | `delete_user` | Аккаунт удалён |
|
||||
| Изменение настроек | `update_settings` | Изменены настройки системы |
|
||||
| Отправка уведомления | `send_notification` | Массовая рассылка |
|
||||
| Экспорт данных | `export_data` | Выгрузка данных |
|
||||
|
||||
## Структура лога
|
||||
|
||||
```typescript
|
||||
interface ActivityLog {
|
||||
id: string // Уникальный ID записи
|
||||
userId: string // ID пользователя
|
||||
userName: string // Отображаемое имя
|
||||
action: ActionType // Тип действия
|
||||
target: string // Цель (user/group/system)
|
||||
targetId?: string // ID цели (если есть)
|
||||
details?: string // Дополнительные детали
|
||||
ip: string // IP адрес
|
||||
timestamp: Date // Время действия
|
||||
}
|
||||
```
|
||||
|
||||
## Примеры записей
|
||||
|
||||
### Вход пользователя
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "log_1234567890",
|
||||
"userId": "user_abc123",
|
||||
"userName": "Иван Петров",
|
||||
"action": "login",
|
||||
"target": "system",
|
||||
"ip": "192.168.1.100",
|
||||
"timestamp": "2026-03-17T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Создание группы
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "log_1234567891",
|
||||
"userId": "user_abc123",
|
||||
"userName": "Иван Петров",
|
||||
"action": "create_group",
|
||||
"target": "group",
|
||||
"targetId": "grp_xyz789",
|
||||
"details": "Создана группа \"Рабочая\"",
|
||||
"ip": "192.168.1.100",
|
||||
"timestamp": "2026-03-17T11:45:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Блокировка пользователя (админ)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "log_1234567892",
|
||||
"userId": "admin_001",
|
||||
"userName": "Администратор",
|
||||
"action": "ban_user",
|
||||
"target": "user",
|
||||
"targetId": "user_def456",
|
||||
"details": "Нарушение правил сообщества",
|
||||
"ip": "10.0.0.1",
|
||||
"timestamp": "2026-03-17T14:20:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Страница логов
|
||||
|
||||
### Фильтры
|
||||
|
||||
**Поиск:**
|
||||
- По имени пользователя
|
||||
- По типу действия
|
||||
- По цели действия
|
||||
|
||||
**Фильтры:**
|
||||
- Тип действия (все/конкретный)
|
||||
- Пользователь (все/конкретный)
|
||||
- Период дат (с/по)
|
||||
|
||||
**Сортировка:**
|
||||
- По времени (возрастанию/убыванию)
|
||||
- По типу действия
|
||||
|
||||
**Пагинация:**
|
||||
- 25 / 50 / 100 / 200 записей на странице
|
||||
|
||||
### Экспорт
|
||||
|
||||
```typescript
|
||||
// Экспорт в JSON
|
||||
const handleExport = () => {
|
||||
const data = JSON.stringify(filteredLogs, null, 2)
|
||||
const blob = new Blob([data], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `logs-${new Date().toISOString().split('T')[0]}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### Эндпоинты
|
||||
|
||||
```
|
||||
GET /api/admin/logs
|
||||
?action=login # Фильтр по действию
|
||||
&userId=user123 # Фильтр по пользователю
|
||||
&from=2026-03-01 # С даты
|
||||
&to=2026-03-17 # По дату
|
||||
&sort=timestamp # Сортировка
|
||||
&order=desc # Порядок
|
||||
&page=1 # Страница
|
||||
&limit=50 # Лимит
|
||||
|
||||
DELETE /api/admin/logs # Очистка логов
|
||||
```
|
||||
|
||||
### Серверная логика
|
||||
|
||||
```typescript
|
||||
// Middleware для логирования действий
|
||||
async function logAction(
|
||||
userId: string,
|
||||
action: ActionType,
|
||||
target: string,
|
||||
details?: string
|
||||
) {
|
||||
const log: ActivityLog = {
|
||||
id: generateId(),
|
||||
userId,
|
||||
userName: await getUserName(userId),
|
||||
action,
|
||||
target,
|
||||
details,
|
||||
ip: getRequestIP(),
|
||||
timestamp: new Date(),
|
||||
}
|
||||
|
||||
await db.logs.insert(log)
|
||||
|
||||
// Асинхронная отправка в аналитику
|
||||
sendToAnalytics(log)
|
||||
}
|
||||
```
|
||||
|
||||
## Хранение
|
||||
|
||||
### Стратегия
|
||||
|
||||
```typescript
|
||||
// Автоматическая очистка старых логов
|
||||
const RETENTION_DAYS = 90
|
||||
|
||||
async function cleanupOldLogs() {
|
||||
const cutoffDate = new Date()
|
||||
cutoffDate.setDate(cutoffDate.getDate() - RETENTION_DAYS)
|
||||
|
||||
await db.logs.delete({
|
||||
timestamp: { lt: cutoffDate }
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Индексы
|
||||
|
||||
```sql
|
||||
-- Для ускорения поиска
|
||||
CREATE INDEX idx_logs_timestamp ON logs(timestamp DESC);
|
||||
CREATE INDEX idx_logs_user ON logs(userId);
|
||||
CREATE INDEX idx_logs_action ON logs(action);
|
||||
CREATE INDEX idx_logs_target ON logs(target);
|
||||
```
|
||||
|
||||
## Использование
|
||||
|
||||
### Логирование действия
|
||||
|
||||
```typescript
|
||||
import { logAction } from '@/lib/logging'
|
||||
|
||||
// При входе
|
||||
await logAction(userId, 'login', 'system')
|
||||
|
||||
// При создании группы
|
||||
await logAction(userId, 'create_group', 'group', {
|
||||
groupId: newGroup.id,
|
||||
groupName: newGroup.name,
|
||||
})
|
||||
|
||||
// При блокировке (админ)
|
||||
await logAction(adminId, 'ban_user', 'user', {
|
||||
bannedUserId: userId,
|
||||
reason: 'Нарушение правил',
|
||||
duration: 7, // дней
|
||||
})
|
||||
```
|
||||
|
||||
### Поиск логов
|
||||
|
||||
```typescript
|
||||
import { useAdminStore } from '@/store/admin'
|
||||
|
||||
const { logs, loadLogs } = useAdminStore()
|
||||
|
||||
// Загрузка с фильтрами
|
||||
await loadLogs({
|
||||
action: 'login',
|
||||
userId: 'user123',
|
||||
from: '2026-03-01',
|
||||
to: '2026-03-17',
|
||||
limit: 100,
|
||||
})
|
||||
```
|
||||
|
||||
## Безопасность
|
||||
|
||||
### Защита логов
|
||||
|
||||
- Доступ только для администраторов
|
||||
- Логи действий администраторов тоже логируются
|
||||
- Запрет на удаление отдельных записей
|
||||
- Только массовая очистка по истечении срока
|
||||
|
||||
### Аудит
|
||||
|
||||
```typescript
|
||||
// Проверка прав доступа
|
||||
async function canAccessLogs(userId: string): Promise<boolean> {
|
||||
const user = await getUser(userId)
|
||||
return user?.role === 'admin' || user?.role === 'superadmin'
|
||||
}
|
||||
|
||||
// Логирование доступа к логам
|
||||
await logAction(adminId, 'view_logs', 'system')
|
||||
```
|
||||
|
||||
## Мониторинг
|
||||
|
||||
### Оповещения
|
||||
|
||||
```typescript
|
||||
// Подозрительная активность
|
||||
if (failedLoginAttempts > 10) {
|
||||
await sendAlert('Много неудачных попыток входа', {
|
||||
userId,
|
||||
ip,
|
||||
count: failedLoginAttempts,
|
||||
})
|
||||
}
|
||||
|
||||
// Массовые действия
|
||||
if (actionsPerMinute > 100) {
|
||||
await sendAlert('Подозрительная активность', {
|
||||
userId,
|
||||
actions: actionsPerMinute,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Метрики
|
||||
|
||||
```typescript
|
||||
// Статистика за период
|
||||
const stats = {
|
||||
totalLogs: await logs.count(),
|
||||
byAction: await logs.groupBy('action'),
|
||||
byUser: await logs.groupBy('userId'),
|
||||
peakHour: await logs.peakHour(),
|
||||
avgPerDay: await logs.averagePerDay(),
|
||||
}
|
||||
```
|
||||
|
||||
## Примеры использования
|
||||
|
||||
### Поиск всех входов пользователя
|
||||
|
||||
```typescript
|
||||
const logins = logs.filter(log =>
|
||||
log.userId === 'user123' && log.action === 'login'
|
||||
)
|
||||
```
|
||||
|
||||
### Поиск действий за сегодня
|
||||
|
||||
```typescript
|
||||
const today = new Date().toDateString()
|
||||
const todayLogs = logs.filter(log =>
|
||||
new Date(log.timestamp).toDateString() === today
|
||||
)
|
||||
```
|
||||
|
||||
### Подсчёт действий по типу
|
||||
|
||||
```typescript
|
||||
const actionCounts = logs.reduce((acc, log) => {
|
||||
acc[log.action] = (acc[log.action] || 0) + 1
|
||||
return acc
|
||||
}, {} as Record<string, number>)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Ласточка** — народный мессенджер с открытым кодом 🕊️
|
||||
340
lastochka-ui/PROFILE_SETTINGS.md
Normal file
340
lastochka-ui/PROFILE_SETTINGS.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# Настройки профиля в Ласточке
|
||||
|
||||
## Обзор
|
||||
|
||||
Настройки профиля позволяют пользователю управлять своей учётной записью:
|
||||
|
||||
- 👤 **Профиль** — имя, аватар, био
|
||||
- 🔒 **Безопасность** — смена пароля
|
||||
- 📱 **Контакты** — email и телефон
|
||||
- ⚙️ **Приватность** — настройки видимости
|
||||
|
||||
## Компонент ProfileSettings
|
||||
|
||||
### Вкладки
|
||||
|
||||
#### 1. Профиль
|
||||
|
||||
**Поля:**
|
||||
- **Аватар** — загрузка изображения (макс 5MB)
|
||||
- **Отображаемое имя** — как вас видят другие
|
||||
- **О себе** — краткая информация
|
||||
|
||||
**Функции:**
|
||||
- Предпросмотр аватара
|
||||
- Редактирование полей
|
||||
- Сохранение изменений
|
||||
- Отмена редактирования
|
||||
|
||||
#### 2. Безопасность
|
||||
|
||||
**Поля:**
|
||||
- **Текущий пароль**
|
||||
- **Новый пароль** (минимум 6 символов)
|
||||
- **Подтверждение пароля**
|
||||
|
||||
**Функции:**
|
||||
- Показать/скрыть пароль
|
||||
- Валидация сложности пароля
|
||||
- Проверка совпадения паролей
|
||||
|
||||
### Props
|
||||
|
||||
```typescript
|
||||
interface ProfileSettingsProps {
|
||||
onClose?: () => void
|
||||
}
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### updateProfile
|
||||
|
||||
Обновление информации о профиле.
|
||||
|
||||
```typescript
|
||||
import { updateProfile } from '@/lib/email-auth'
|
||||
|
||||
const result = await updateProfile({
|
||||
displayName: 'Новое имя',
|
||||
avatar: 'data:image/png;base64,...',
|
||||
bio: 'О себе',
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
console.log('Профиль обновлён')
|
||||
} else {
|
||||
console.error(result.error)
|
||||
}
|
||||
```
|
||||
|
||||
### changePassword
|
||||
|
||||
Смена пароля.
|
||||
|
||||
```typescript
|
||||
import { changePassword } from '@/lib/email-auth'
|
||||
|
||||
const result = await changePassword('old-password', 'new-password')
|
||||
|
||||
if (result.success) {
|
||||
console.log('Пароль изменён')
|
||||
} else {
|
||||
console.error(result.error)
|
||||
}
|
||||
```
|
||||
|
||||
## Загрузка аватара
|
||||
|
||||
### Обработка файла
|
||||
|
||||
```typescript
|
||||
const handleAvatarUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// Проверка размера (макс 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setError('Размер файла не должен превышать 5MB')
|
||||
return
|
||||
}
|
||||
|
||||
// Проверка типа
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setError('Загрузите изображение')
|
||||
return
|
||||
}
|
||||
|
||||
// Конвертация в base64
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
setAvatar(event.target?.result as string)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
```
|
||||
|
||||
### Требования к изображению
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| **Формат** | PNG, JPEG, GIF, WebP |
|
||||
| **Размер** | до 5 MB |
|
||||
| **Разрешение** | от 100x100 px |
|
||||
| **Соотношение** | 1:1 (квадрат) |
|
||||
|
||||
## Валидация
|
||||
|
||||
### Имя
|
||||
|
||||
```typescript
|
||||
if (name.trim().length < 2) {
|
||||
error: 'Имя должно быть не менее 2 символов'
|
||||
}
|
||||
```
|
||||
|
||||
### Пароль
|
||||
|
||||
```typescript
|
||||
// Минимальная длина
|
||||
if (newPassword.length < 6) {
|
||||
error: 'Пароль должен быть не менее 6 символов'
|
||||
}
|
||||
|
||||
// Совпадение
|
||||
if (newPassword !== confirmPassword) {
|
||||
error: 'Пароли не совпадают'
|
||||
}
|
||||
|
||||
// Текущий пароль
|
||||
if (!currentPassword) {
|
||||
error: 'Введите текущий пароль'
|
||||
}
|
||||
```
|
||||
|
||||
## Интеграция с Tinode
|
||||
|
||||
### Обновление профиля
|
||||
|
||||
```typescript
|
||||
const me = tn.getMeTopic()
|
||||
|
||||
await me.setMeta({
|
||||
desc: {
|
||||
public: {
|
||||
fn: displayName, // Отображаемое имя
|
||||
photo: { // Аватар
|
||||
type: 'image',
|
||||
data: avatarData,
|
||||
},
|
||||
note: bio, // Био
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Смена пароля
|
||||
|
||||
```typescript
|
||||
await tn.setMeta({
|
||||
private: {
|
||||
password: {
|
||||
old: oldPassword,
|
||||
new: newPassword,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Примеры использования
|
||||
|
||||
### Открытие настроек
|
||||
|
||||
```typescript
|
||||
import { useState } from 'react'
|
||||
import ProfileSettings from '@/components/ui/ProfileSettings'
|
||||
|
||||
function App() {
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setShowSettings(true)}>
|
||||
Настройки
|
||||
</button>
|
||||
|
||||
{showSettings && (
|
||||
<ProfileSettings onClose={() => setShowSettings(false)} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Интеграция в Sidebar
|
||||
|
||||
```typescript
|
||||
import ProfileSettings from '@/components/ui/ProfileSettings'
|
||||
|
||||
function Sidebar() {
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Кнопка настроек */}
|
||||
<button onClick={() => setShowSettings(true)}>
|
||||
<Settings size={20} />
|
||||
</button>
|
||||
|
||||
{/* Модальное окно */}
|
||||
{showSettings && (
|
||||
<div className="modal">
|
||||
<ProfileSettings onClose={() => setShowSettings(false)} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Состояния
|
||||
|
||||
### Успешное обновление
|
||||
|
||||
```typescript
|
||||
const [success, setSuccess] = useState('')
|
||||
|
||||
if (result.success) {
|
||||
setSuccess('Профиль успешно обновлён')
|
||||
setTimeout(() => setSuccess(''), 3000)
|
||||
}
|
||||
```
|
||||
|
||||
### Ошибка
|
||||
|
||||
```typescript
|
||||
const [error, setError] = useState('')
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || 'Ошибка обновления')
|
||||
}
|
||||
```
|
||||
|
||||
### Загрузка
|
||||
|
||||
```typescript
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await updateProfile({...})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
```
|
||||
|
||||
## Советы по UX
|
||||
|
||||
### 1. Debounced сохранение
|
||||
|
||||
Автоматическое сохранение через 1 секунду после последнего изменения:
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(async () => {
|
||||
if (isDirty) {
|
||||
await saveProfile()
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [name, bio, isDirty])
|
||||
```
|
||||
|
||||
### 2. Предпросмотр изменений
|
||||
|
||||
Показ изменений до сохранения:
|
||||
|
||||
```typescript
|
||||
const [preview, setPreview] = useState({
|
||||
name: displayName,
|
||||
bio: bio,
|
||||
avatar: avatar,
|
||||
})
|
||||
```
|
||||
|
||||
### 3. Подтверждение важных действий
|
||||
|
||||
Запрос подтверждения перед сменой пароля:
|
||||
|
||||
```typescript
|
||||
const handleChangePassword = () => {
|
||||
if (!window.confirm('Вы уверены, что хотите изменить пароль?')) {
|
||||
return
|
||||
}
|
||||
// Смена пароля
|
||||
}
|
||||
```
|
||||
|
||||
## Безопасность
|
||||
|
||||
### Требования к паролю
|
||||
|
||||
- Минимум 6 символов
|
||||
- Рекомендуется: буквы + цифры
|
||||
- Не рекомендуется: простые комбинации (123456, password)
|
||||
|
||||
### Защита от CSRF
|
||||
|
||||
Все запросы на изменение данных должны включать CSRF-токен.
|
||||
|
||||
### Сессии
|
||||
|
||||
При смене пароля:
|
||||
- Завершить все другие сессии
|
||||
- Отправить уведомление на email
|
||||
- Запросить повторный вход на других устройствах
|
||||
|
||||
---
|
||||
|
||||
**Ласточка** — народный мессенджер с открытым кодом 🕊️
|
||||
505
lastochka-ui/README.md
Normal file
505
lastochka-ui/README.md
Normal file
@@ -0,0 +1,505 @@
|
||||
# 🕊️ Ласточка — Веб-кабинет
|
||||
|
||||
**Веб-кабинет** — браузерная версия мессенджера Ласточка.
|
||||
|
||||
## 📋 Содержание
|
||||
|
||||
- [Быстрый старт](#быстрый-старт)
|
||||
- [Структура](#структура)
|
||||
- [Аутентификация](#аутентификация)
|
||||
- [Чаты](#чаты)
|
||||
- [Группы и каналы](#группы-и-каналы)
|
||||
- [Админ-панель](#админ-панель)
|
||||
- [Настройки](#настройки)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
```bash
|
||||
cd D:\Projects\Messenger\dev\lastochka-ui
|
||||
|
||||
# Установка
|
||||
npm install
|
||||
|
||||
# Запуск
|
||||
npm run dev
|
||||
|
||||
# Сборка
|
||||
npm run build
|
||||
```
|
||||
|
||||
**Адрес:** http://localhost:5173
|
||||
|
||||
---
|
||||
|
||||
## 📁 Структура
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/
|
||||
│ ├── admin/ # Админ-панель
|
||||
│ │ ├── AdminPanel.tsx
|
||||
│ │ ├── Dashboard.tsx
|
||||
│ │ ├── Users.tsx
|
||||
│ │ ├── Settings.tsx
|
||||
│ │ └── Logs.tsx
|
||||
│ ├── auth/ # Аутентификация
|
||||
│ │ ├── LoginScreen.tsx
|
||||
│ │ ├── RegisterForm.tsx
|
||||
│ │ └── EmailVerification.tsx
|
||||
│ ├── chat/ # Чат
|
||||
│ │ ├── ChatWindow.tsx
|
||||
│ │ ├── ChatHeader.tsx
|
||||
│ │ ├── MessagesList.tsx
|
||||
│ │ └── MessageInput.tsx
|
||||
│ ├── layout/ # Layout
|
||||
│ │ └── Sidebar.tsx
|
||||
│ ├── sidebar/ # Боковая панель
|
||||
│ │ ├── ChatList.tsx
|
||||
│ │ └── ChatItem.tsx
|
||||
│ └── ui/ # UI компоненты
|
||||
│ ├── Avatar.tsx
|
||||
│ ├── UserSearch.tsx
|
||||
│ ├── CreateGroupModal.tsx
|
||||
│ ├── MembersPanel.tsx
|
||||
│ └── ProfileSettings.tsx
|
||||
├── lib/ # Утилиты
|
||||
│ ├── email-auth.ts
|
||||
│ ├── sms-auth.ts
|
||||
│ ├── phone-utils.ts
|
||||
│ └── tinode-client.ts
|
||||
├── store/ # Zustand store
|
||||
│ ├── auth.ts
|
||||
│ ├── chat.ts
|
||||
│ ├── groups.ts
|
||||
│ └── admin.ts
|
||||
├── types/ # TypeScript типы
|
||||
│ ├── index.ts
|
||||
│ └── admin.ts
|
||||
├── App.tsx
|
||||
├── main.tsx
|
||||
└── tinode.d.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Аутентификация
|
||||
|
||||
### Регистрация
|
||||
|
||||
**Поля:**
|
||||
- Логин (3-32 символа, буквы/цифры/подчёркивание)
|
||||
- Email (валидный формат)
|
||||
- Телефон (+7 XXX XXX-XX-XX)
|
||||
- Пароль (минимум 6 символов)
|
||||
- Подтверждение пароля
|
||||
- Отображаемое имя (необязательно)
|
||||
|
||||
**Проверки:**
|
||||
```typescript
|
||||
// Проверка логина на дубликат
|
||||
const result = await checkLoginAvailability(login)
|
||||
if (!result.available) {
|
||||
setError('Этот логин уже занят')
|
||||
}
|
||||
|
||||
// Проверка телефона на дубликат
|
||||
const result = await checkPhoneAvailability(phone)
|
||||
if (!result.available) {
|
||||
setError('Этот номер уже зарегистрирован')
|
||||
}
|
||||
```
|
||||
|
||||
### Вход
|
||||
|
||||
```typescript
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
|
||||
const { login } = useAuthStore()
|
||||
await login('username', 'password')
|
||||
```
|
||||
|
||||
### Настройки профиля
|
||||
|
||||
**Вкладка Профиль:**
|
||||
- Аватар (загрузка файла, макс 5MB)
|
||||
- Отображаемое имя
|
||||
- Био/о себе
|
||||
|
||||
**Вкладка Безопасность:**
|
||||
- Текущий пароль
|
||||
- Новый пароль
|
||||
- Подтверждение пароля
|
||||
|
||||
---
|
||||
|
||||
## 💬 Чаты
|
||||
|
||||
### Типы чатов
|
||||
|
||||
| Тип | Описание | Макс. участников |
|
||||
|-----|----------|------------------|
|
||||
| **P2P** | Личная переписка | 2 |
|
||||
| **Группа** | Групповой чат | 200 000 |
|
||||
| **Канал** | Публикация контента | Неограниченно |
|
||||
|
||||
### Отправка сообщений
|
||||
|
||||
```typescript
|
||||
import { useChatStore } from '@/store/chat'
|
||||
|
||||
const { sendMessage } = useChatStore()
|
||||
await sendMessage('Привет, мир!')
|
||||
```
|
||||
|
||||
### Поиск пользователей
|
||||
|
||||
```typescript
|
||||
import UserSearch from '@/components/ui/UserSearch'
|
||||
|
||||
<UserSearch
|
||||
showStartChat
|
||||
onSelect={(user) => {
|
||||
console.log('Выбран пользователь:', user)
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 👥 Группы и каналы
|
||||
|
||||
### Создание группы
|
||||
|
||||
```typescript
|
||||
import { useGroupsStore } from '@/store/groups'
|
||||
|
||||
const { createGroup } = useGroupsStore()
|
||||
|
||||
const group = await createGroup({
|
||||
name: 'Моя группа',
|
||||
description: 'Описание',
|
||||
isChannel: false,
|
||||
isPublic: false,
|
||||
members: ['user1', 'user2'],
|
||||
})
|
||||
```
|
||||
|
||||
### Создание канала
|
||||
|
||||
```typescript
|
||||
const { createChannel } = useGroupsStore()
|
||||
|
||||
const channel = await createChannel({
|
||||
name: 'Мой канал',
|
||||
description: 'Новости проекта',
|
||||
isPublic: true,
|
||||
members: [],
|
||||
})
|
||||
```
|
||||
|
||||
### Управление участниками
|
||||
|
||||
```typescript
|
||||
const { addMember, removeMember, leaveGroup } = useGroupsStore()
|
||||
|
||||
// Добавить участника
|
||||
await addMember('group-id', 'user-id')
|
||||
|
||||
// Удалить участника
|
||||
await removeMember('group-id', 'user-id')
|
||||
|
||||
// Покинуть группу
|
||||
await leaveGroup('group-id')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Админ-панель
|
||||
|
||||
### Доступ
|
||||
|
||||
Только для пользователей с ролью `admin` или `superadmin`.
|
||||
|
||||
**Кнопка в Sidebar:**
|
||||
```tsx
|
||||
<button onClick={() => onOpenAdmin?.()}>
|
||||
<Shield size={20} />
|
||||
</button>
|
||||
```
|
||||
|
||||
### Дашборд
|
||||
|
||||
**Метрики:**
|
||||
- Пользователи (всего, активные, новые)
|
||||
- Сообщения (всего, сегодня)
|
||||
- Группы/каналы
|
||||
- Хранилище
|
||||
- API запросы
|
||||
- Ошибки
|
||||
|
||||
**Графики:**
|
||||
- Активность (день/неделя/месяц)
|
||||
- Прогресс-бары ресурсов (CPU, RAM, Disk)
|
||||
|
||||
### Пользователи
|
||||
|
||||
**Функции:**
|
||||
- Поиск по имени/email/телефону
|
||||
- Фильтры (роль, статус, сортировка)
|
||||
- Изменение роли
|
||||
- Блокировка/разблокировка
|
||||
- Удаление
|
||||
- Экспорт (CSV/JSON)
|
||||
|
||||
**Роли:**
|
||||
- `user` — обычный пользователь
|
||||
- `moderator` — модератор
|
||||
- `admin` — администратор
|
||||
- `superadmin` — супер-админ
|
||||
|
||||
**Статусы:**
|
||||
- `active` — активен
|
||||
- `banned` — заблокирован
|
||||
- `pending` — ожидание
|
||||
- `deleted` — удалён
|
||||
|
||||
### Логи
|
||||
|
||||
**Фильтры:**
|
||||
- Поиск по пользователю/действию/цели
|
||||
- Тип действия (14 типов)
|
||||
- Период дат
|
||||
- Сортировка
|
||||
|
||||
**Экспорт:**
|
||||
```typescript
|
||||
const handleExport = () => {
|
||||
const data = JSON.stringify(filteredLogs, null, 2)
|
||||
const blob = new Blob([data], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `logs-${new Date().toISOString().split('T')[0]}.json`
|
||||
a.click()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Настройки
|
||||
|
||||
### Общие
|
||||
|
||||
- Режим обслуживания
|
||||
- Регистрация включена/выключена
|
||||
- Сообщение о техобслуживании
|
||||
|
||||
### Пользователи
|
||||
|
||||
- Требовать подтверждение email
|
||||
- Требовать подтверждение телефона
|
||||
- Разрешить несколько сессий
|
||||
- Минимальная длина пароля
|
||||
- Таймаут сессии
|
||||
|
||||
### Контент
|
||||
|
||||
- Макс. размер файла (50MB)
|
||||
- Макс. длина сообщения (4096 символов)
|
||||
- Макс. групп на пользователя (50)
|
||||
- Макс. каналов на пользователя (100)
|
||||
- Разрешённые типы файлов
|
||||
|
||||
### Безопасность
|
||||
|
||||
- Rate limiting (60 запросов/мин)
|
||||
- Защита от перебора (5 попыток)
|
||||
- Длительность блокировки (15 мин)
|
||||
|
||||
### Уведомления
|
||||
|
||||
- Email уведомления
|
||||
- Push уведомления
|
||||
- SMTP настройки
|
||||
|
||||
### Модерация
|
||||
|
||||
- Автомодерация
|
||||
- Запрещённые слова
|
||||
- Порог жалоб (10)
|
||||
|
||||
---
|
||||
|
||||
## 🎨 UI компоненты
|
||||
|
||||
### Avatar
|
||||
|
||||
```tsx
|
||||
import Avatar from '@/components/ui/Avatar'
|
||||
|
||||
<Avatar name="Иван Петров" size="md" online={true} />
|
||||
```
|
||||
|
||||
### UserSearch
|
||||
|
||||
```tsx
|
||||
import UserSearch from '@/components/ui/UserSearch'
|
||||
|
||||
<UserSearch
|
||||
showStartChat
|
||||
showAddToGroup
|
||||
groupId="grp123"
|
||||
onSelect={(user) => console.log(user)}
|
||||
/>
|
||||
```
|
||||
|
||||
### CreateGroupModal
|
||||
|
||||
```tsx
|
||||
import CreateGroupModal from '@/components/ui/CreateGroupModal'
|
||||
|
||||
<CreateGroupModal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
mode="group"
|
||||
/>
|
||||
```
|
||||
|
||||
### MembersPanel
|
||||
|
||||
```tsx
|
||||
import MembersPanel from '@/components/ui/MembersPanel'
|
||||
|
||||
<MembersPanel
|
||||
isOpen={showPanel}
|
||||
onClose={() => setShowPanel(false)}
|
||||
groupId="grp123"
|
||||
canAddMembers
|
||||
canRemoveMembers
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📡 API клиент
|
||||
|
||||
### Tinode Client
|
||||
|
||||
```typescript
|
||||
import { getTinode } from '@/lib/tinode-client'
|
||||
|
||||
const tn = getTinode()
|
||||
|
||||
// Подключение
|
||||
await tn.connect()
|
||||
|
||||
// Вход
|
||||
await tn.loginBasic('username', 'password')
|
||||
|
||||
// Получение темы
|
||||
const topic = tn.getTopic('grp123')
|
||||
|
||||
// Подписка
|
||||
await topic.subscribe(query)
|
||||
|
||||
// Отправка сообщения
|
||||
const draft = topic.createMessage('Привет!', false)
|
||||
await topic.publishMessage(draft)
|
||||
```
|
||||
|
||||
### Store
|
||||
|
||||
```typescript
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useChatStore } from '@/store/chat'
|
||||
import { useGroupsStore } from '@/store/groups'
|
||||
import { useAdminStore } from '@/store/admin'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Конфигурация
|
||||
|
||||
### Переменные окружения
|
||||
|
||||
Создайте файл `.env` в корне проекта:
|
||||
|
||||
```env
|
||||
VITE_TINODE_HOST=localhost:6060
|
||||
VITE_TINODE_API_KEY=AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K
|
||||
VITE_TINODE_SECURE=false
|
||||
VITE_APP_NAME=Ласточка
|
||||
```
|
||||
|
||||
### TypeScript
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"jsx": "react-jsx",
|
||||
"strict": false,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Сборка
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```dockerfile
|
||||
FROM node:18-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
EXPOSE 4173
|
||||
CMD ["npm", "run", "preview"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Вклад
|
||||
|
||||
### Как помочь
|
||||
|
||||
1. Форкните репозиторий
|
||||
2. Создайте ветку (`git checkout -b feature/amazing-feature`)
|
||||
3. Закоммитьте изменения (`git commit -m 'Add amazing feature'`)
|
||||
4. Запушьте (`git push origin feature/amazing-feature`)
|
||||
5. Создайте Pull Request
|
||||
|
||||
### Стандарты кода
|
||||
|
||||
- **TypeScript:** strict mode выключен для совместимости
|
||||
- **Именование:** camelCase для переменных, PascalCase для компонентов
|
||||
- **Импорты:** сначала npm пакеты, потом локальные файлы
|
||||
|
||||
---
|
||||
|
||||
**Ласточка** — народный мессенджер с открытым кодом 🕊️
|
||||
|
||||
*Сделано с ❤️ для свободного общения*
|
||||
532
lastochka-ui/REGISTRATION.md
Normal file
532
lastochka-ui/REGISTRATION.md
Normal file
@@ -0,0 +1,532 @@
|
||||
# Регистрация в Ласточке
|
||||
|
||||
## Обзор
|
||||
|
||||
Ласточка поддерживает регистрацию с полной верификацией пользователя через email.
|
||||
|
||||
**Требуемые данные при регистрации:**
|
||||
1. ✅ Логин (уникальный, проверяется на дубликат)
|
||||
2. ✅ Email (с верификацией кодом)
|
||||
3. ✅ Телефон (обязательно, маска +7 XXX XXX-XX-XX)
|
||||
4. ✅ Пароль (минимум 6 символов)
|
||||
5. ✅ Подтверждение пароля
|
||||
6. ⭕ Отображаемое имя (необязательно)
|
||||
|
||||
## Процесс регистрации
|
||||
|
||||
```
|
||||
┌─────────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
| Ввод данных | -> | Email с кодом | -> | Верификация |
|
||||
| (логин, email, | | отправлено | | завершена |
|
||||
| телефон, пароль) | | | | Вход в систему |
|
||||
└─────────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
## Форма регистрации
|
||||
|
||||
### Поля формы
|
||||
|
||||
#### 1. Логин
|
||||
- **Обязательно:** да
|
||||
- **Минимальная длина:** 3 символа
|
||||
- **Допустимые символы:** буквы (a-z), цифры (0-9), подчёркивание (_)
|
||||
- **Проверка:** автоматическая проверка на уникальность (debounce 500ms)
|
||||
- **Пример:** `ivan_petrov`, `user123`
|
||||
|
||||
#### 2. Email
|
||||
- **Обязательно:** да
|
||||
- **Формат:** стандартный email (example@domain.com)
|
||||
- **Проверка:** валидация формата + верификация кодом
|
||||
- **Пример:** `ivan@mail.ru`
|
||||
|
||||
#### 3. Телефон
|
||||
- **Обязательно:** да
|
||||
- **Формат:** российский номер +7 (XXX) XXX-XX-XX
|
||||
- **Маска ввода:** автоматическое форматирование
|
||||
- **Пример:** `+7 (999) 123-45-67`
|
||||
|
||||
#### 4. Пароль
|
||||
- **Обязательно:** да
|
||||
- **Минимальная длина:** 6 символов
|
||||
- **Отображение:** кнопка показать/скрыть
|
||||
|
||||
#### 5. Подтверждение пароля
|
||||
- **Обязательно:** да
|
||||
- **Проверка:** совпадение с паролем
|
||||
|
||||
#### 6. Отображаемое имя
|
||||
- **Обязательно:** нет
|
||||
- **По умолчанию:** используется логин
|
||||
- **Назначение:** отображается в списке контактов
|
||||
|
||||
## API
|
||||
|
||||
### 1. Проверка логина на доступность
|
||||
|
||||
```typescript
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
|
||||
const { checkLogin } = useAuthStore()
|
||||
const isAvailable = await checkLogin('username')
|
||||
```
|
||||
|
||||
**Что происходит:**
|
||||
- Проверка логина в базе данных
|
||||
- Возвращает `true` если логин свободен
|
||||
|
||||
### 2. Отправка email для регистрации
|
||||
|
||||
```typescript
|
||||
const { sendRegistrationEmail } = useAuthStore()
|
||||
|
||||
await sendRegistrationEmail(
|
||||
'username', // логин
|
||||
'password123', // пароль
|
||||
'email@test.com',// email
|
||||
'79991234567', // телефон
|
||||
'Имя' // отображаемое имя
|
||||
)
|
||||
```
|
||||
|
||||
**Что происходит:**
|
||||
- Валидация всех данных
|
||||
- Проверка логина на уникальность
|
||||
- Создание учётной записи в Tinode
|
||||
- Отправка email с кодом подтверждения
|
||||
- Переход в режим ожидания кода
|
||||
|
||||
### 3. Проверка email кода
|
||||
|
||||
```typescript
|
||||
const { verifyRegistrationEmail } = useAuthStore()
|
||||
await verifyRegistrationEmail('123456')
|
||||
```
|
||||
|
||||
**Что происходит:**
|
||||
- Проверка кода подтверждения
|
||||
- Активация учётной записи
|
||||
- Автоматический вход в систему
|
||||
|
||||
## Компоненты
|
||||
|
||||
### RegisterForm.tsx
|
||||
|
||||
Основной компонент формы регистрации.
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
interface RegisterFormProps {
|
||||
onSuccess?: () => void // Callback после успешной регистрации
|
||||
}
|
||||
```
|
||||
|
||||
**Функции:**
|
||||
- Валидация всех полей в реальном времени
|
||||
- Проверка логина на уникальность (debounced)
|
||||
- Проверка совпадения паролей
|
||||
- Валидация email и телефона
|
||||
- Отображение ошибок для каждого поля
|
||||
- Переключение видимости пароля
|
||||
|
||||
**Состояния:**
|
||||
- `verificationStep: 'none'` — ввод данных
|
||||
- `verificationStep: 'email-sent'` — ожидание кода
|
||||
- `verificationStep: 'verified'` — успешно
|
||||
|
||||
### EmailVerification.tsx
|
||||
|
||||
Компонент ввода кода из email.
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
interface EmailVerificationProps {
|
||||
email: string // Email для отображения (маскированный)
|
||||
onComplete: (code) => void // Callback при вводе кода
|
||||
onBack: () => void // Callback при возврате назад
|
||||
isLoading: boolean // Индикатор загрузки
|
||||
error: string | null // Сообщение об ошибке
|
||||
title?: string // Заголовок
|
||||
description?: string // Описание
|
||||
}
|
||||
```
|
||||
|
||||
**Функции:**
|
||||
- 6 полей для ввода кода
|
||||
- Автопереход между полями
|
||||
- Поддержка вставки из буфера обмена
|
||||
- Обработка Backspace
|
||||
- Маскирование email для отображения
|
||||
|
||||
### LoginScreen.tsx
|
||||
|
||||
Главный экран с переключением между входом и регистрацией.
|
||||
|
||||
**Режимы:**
|
||||
- `login` — форма входа
|
||||
- `register` — форма регистрации
|
||||
|
||||
## Валидация
|
||||
|
||||
### Логин
|
||||
|
||||
```typescript
|
||||
// Проверка длины
|
||||
if (login.length < 3) {
|
||||
error: 'Логин должен быть не менее 3 символов'
|
||||
}
|
||||
|
||||
// Проверка символов
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(login)) {
|
||||
error: 'Логин может содержать только буквы, цифры и подчёркивание'
|
||||
}
|
||||
|
||||
// Проверка на дубликат (debounced 500ms)
|
||||
const available = await checkLogin(login)
|
||||
if (!available) {
|
||||
error: 'Этот логин уже занят'
|
||||
}
|
||||
```
|
||||
|
||||
### Email
|
||||
|
||||
```typescript
|
||||
import { isValidEmail } from '@/lib/phone-utils'
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
error: 'Введите корректный email'
|
||||
}
|
||||
```
|
||||
|
||||
### Телефон
|
||||
|
||||
```typescript
|
||||
import { isValidPhoneNumber, checkPhoneAvailability } from '@/lib/phone-utils'
|
||||
|
||||
// Валидация формата
|
||||
if (!isValidPhoneNumber(phone)) {
|
||||
error: 'Введите корректный номер телефона'
|
||||
}
|
||||
|
||||
// Проверка на дубликат (debounced 500ms)
|
||||
const result = await checkPhoneAvailability(phone)
|
||||
if (!result.available) {
|
||||
error: 'Этот номер уже зарегистрирован'
|
||||
}
|
||||
```
|
||||
|
||||
### Пароль
|
||||
|
||||
```typescript
|
||||
if (password.length < 6) {
|
||||
error: 'Пароль должен быть не менее 6 символов'
|
||||
}
|
||||
|
||||
if (password !== passwordConfirm) {
|
||||
error: 'Пароли не совпадают'
|
||||
}
|
||||
```
|
||||
|
||||
## Утилиты
|
||||
|
||||
### phone-utils.ts
|
||||
|
||||
```typescript
|
||||
import {
|
||||
formatPhoneNumber, // Форматирование: "+7 (999) 999-99-99"
|
||||
cleanPhoneNumber, // Очистка: "79999999999"
|
||||
isValidPhoneNumber, // Валидация: true/false
|
||||
isValidEmail, // Валидация email: true/false
|
||||
normalizeEmail, // Нормализация: lowercase + trim
|
||||
} from '@/lib/phone-utils'
|
||||
```
|
||||
|
||||
## Store (auth.ts)
|
||||
|
||||
### Состояние
|
||||
|
||||
```typescript
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean
|
||||
userId: string | null
|
||||
displayName: string | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
|
||||
// Для email-верификации
|
||||
emailForVerification: string | null
|
||||
loginForVerification: string | null
|
||||
passwordForVerification: string | null
|
||||
verificationStep: 'none' | 'email-sent' | 'verified'
|
||||
|
||||
// Методы
|
||||
login: (login, password) => Promise<void>
|
||||
checkLogin: (login) => Promise<boolean>
|
||||
registerWithProfile: (login, password, email, phone, displayName) => Promise<void>
|
||||
sendRegistrationEmail: (login, password, email, phone, displayName) => Promise<void>
|
||||
verifyRegistrationEmail: (code) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
tryAutoLogin: () => Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
## Email-провайдеры
|
||||
|
||||
### Настройка на сервере
|
||||
|
||||
Для отправки email необходимо настроить SMTP на сервере Tinode.
|
||||
|
||||
**Конфигурация SMTP:**
|
||||
|
||||
```yaml
|
||||
# Конфигурация сервера
|
||||
smtp:
|
||||
host: "smtp.mail.ru"
|
||||
port: 587
|
||||
username: "noreply@lastochka.ru"
|
||||
password: "your-password"
|
||||
from: "Ласточка <noreply@lastochka.ru>"
|
||||
tls: true
|
||||
```
|
||||
|
||||
**Популярные провайдеры для РФ:**
|
||||
- Mail.ru (smtp.mail.ru)
|
||||
- Yandex (smtp.yandex.ru)
|
||||
- SendPulse (smtp.sendpulse.com)
|
||||
- Unisender (smtp.unisender.com)
|
||||
|
||||
### Шаблон письма
|
||||
|
||||
**Тема:** Код подтверждения для Ласточки
|
||||
|
||||
**Текст:**
|
||||
```
|
||||
Здравствуйте!
|
||||
|
||||
Ваш код подтверждения для регистрации в мессенджере Ласточка:
|
||||
|
||||
123456
|
||||
|
||||
Код действителен в течение 10 минут.
|
||||
|
||||
Если вы не регистрировались в Ласточке, просто проигнорируйте это письмо.
|
||||
|
||||
---
|
||||
Ласточка — Твой дом в интернете
|
||||
```
|
||||
|
||||
## Безопасность
|
||||
|
||||
### Валидация данных
|
||||
|
||||
- Проверка уникальности логина (real-time)
|
||||
- Валидация формата email
|
||||
- Валидация российского номера телефона
|
||||
- Проверка сложности пароля (минимум 6 символов)
|
||||
- Проверка совпадения паролей
|
||||
|
||||
### Защита от злоупотреблений
|
||||
|
||||
- Rate limiting на отправку email (не чаще 1 раза в минуту)
|
||||
- Максимум 3 попытки ввода кода
|
||||
- Блокировка при множественных неудачных попытках
|
||||
- CSRF-токены для форм
|
||||
|
||||
### Хранение данных
|
||||
|
||||
- Пароль хешируется на сервере
|
||||
- Email и телефон шифруются в базе данных
|
||||
- Токен сессии сохраняется в localStorage
|
||||
|
||||
## Пример использования
|
||||
|
||||
### Полная регистрация
|
||||
|
||||
```typescript
|
||||
import { useState } from 'react'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { isValidEmail, isValidPhoneNumber } from '@/lib/phone-utils'
|
||||
|
||||
function RegistrationPage() {
|
||||
const [step, setStep] = useState<'form' | 'verification'>('form')
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
login: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
displayName: '',
|
||||
password: '',
|
||||
passwordConfirm: '',
|
||||
})
|
||||
|
||||
const { sendRegistrationEmail, verifyRegistrationEmail, isLoading, error } = useAuthStore()
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Валидация
|
||||
if (!isValidEmail(formData.email)) return
|
||||
if (!isValidPhoneNumber(formData.phone)) return
|
||||
if (formData.password !== formData.passwordConfirm) return
|
||||
|
||||
// Отправка
|
||||
await sendRegistrationEmail(
|
||||
formData.login,
|
||||
formData.password,
|
||||
formData.email,
|
||||
formData.phone,
|
||||
formData.displayName
|
||||
)
|
||||
|
||||
setStep('verification')
|
||||
}
|
||||
|
||||
const handleVerify = async (code: string) => {
|
||||
await verifyRegistrationEmail(code)
|
||||
// Регистрация завершена
|
||||
}
|
||||
|
||||
if (step === 'verification') {
|
||||
return (
|
||||
<EmailVerification
|
||||
email={formData.email}
|
||||
onComplete={handleVerify}
|
||||
onBack={() => setStep('form')}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.login}
|
||||
onChange={(e) => setFormData({...formData, login: e.target.value})}
|
||||
placeholder="Логин"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({...formData, email: e.target.value})}
|
||||
placeholder="Email"
|
||||
/>
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({...formData, phone: e.target.value})}
|
||||
placeholder="+7 (999) 999-99-99"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({...formData, password: e.target.value})}
|
||||
placeholder="Пароль"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.passwordConfirm}
|
||||
onChange={(e) => setFormData({...formData, passwordConfirm: e.target.value})}
|
||||
placeholder="Подтверждение пароля"
|
||||
/>
|
||||
<button type="submit" disabled={isLoading}>
|
||||
Зарегистрироваться
|
||||
</button>
|
||||
{error && <p className="error">{error}</p>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Интеграция с бэкендом
|
||||
|
||||
### Эндпоинты
|
||||
|
||||
Сервер должен поддерживать следующие эндпоинты:
|
||||
|
||||
1. **POST /v1/users** — создание учётной записи
|
||||
2. **POST /v1/creds/email** — запрос кода подтверждения
|
||||
3. **PUT /v1/creds/email** — проверка кода подтверждения
|
||||
|
||||
### Формат запроса
|
||||
|
||||
```json
|
||||
// Создание учётной записи
|
||||
{
|
||||
"login": "username",
|
||||
"password": "password123",
|
||||
"public": {
|
||||
"fn": "Display Name",
|
||||
"tel": "79991234567"
|
||||
},
|
||||
"private": {
|
||||
"email": "user@example.com"
|
||||
},
|
||||
"cred": {
|
||||
"email": "user@example.com",
|
||||
"tel": "79991234567"
|
||||
},
|
||||
"login": false
|
||||
}
|
||||
|
||||
// Запрос кода
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"op": "add"
|
||||
}
|
||||
|
||||
// Проверка кода
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"val": "123456",
|
||||
"op": "add"
|
||||
}
|
||||
```
|
||||
|
||||
### Формат ответа
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"text": "OK",
|
||||
"params": {
|
||||
"user": "user123",
|
||||
"cred": "email:user@example.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Тестирование
|
||||
|
||||
### Тестовые email
|
||||
|
||||
Для тестирования без реальной отправки email:
|
||||
|
||||
```
|
||||
test+lastochka@example.com → код: 123456
|
||||
```
|
||||
|
||||
### Mock email-провайдера
|
||||
|
||||
```typescript
|
||||
// В разработке можно использовать mock
|
||||
export async function sendEmailCode(email: string) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`Email код для ${email}: 123456`)
|
||||
return { success: true }
|
||||
}
|
||||
// Реальная отправка email
|
||||
}
|
||||
```
|
||||
|
||||
## Отличия от SMS-верификации
|
||||
|
||||
| Параметр | Email | SMS |
|
||||
|----------|-------|-----|
|
||||
| Стоимость | ~0 ₽ | ~2-5 ₽ за SMS |
|
||||
| Скорость доставки | 5-30 сек | 1-10 сек |
|
||||
| Надёжность | Высокая | Средняя |
|
||||
| Требует телефона | Нет | Да |
|
||||
| Международная поддержка | Да | Зависит от провайдера |
|
||||
|
||||
---
|
||||
|
||||
**Ласточка** — народный мессенджер с открытым кодом 🕊️
|
||||
398
lastochka-ui/WEB_APP.md
Normal file
398
lastochka-ui/WEB_APP.md
Normal file
@@ -0,0 +1,398 @@
|
||||
# Веб-кабинет Ласточки
|
||||
|
||||
## Обзор
|
||||
|
||||
Веб-кабинет мессенджера Ласточка предоставляет полный функционал для общения:
|
||||
|
||||
- 💬 **Личные чаты** — общение один на один
|
||||
- 👥 **Группы** — групповые чаты с участниками
|
||||
- 📢 **Каналы** — публикация контента подписчикам
|
||||
- 🔍 **Поиск** — поиск пользователей и сообщений
|
||||
- ⚙️ **Настройки** — управление профилем и настройками
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/
|
||||
│ ├── layout/
|
||||
│ │ └── Sidebar.tsx # Боковая панель с навигацией
|
||||
│ ├── chat/
|
||||
│ │ ├── ChatHeader.tsx # Шапка чата
|
||||
│ │ ├── ChatWindow.tsx # Окно чата
|
||||
│ │ ├── MessagesList.tsx # Список сообщений
|
||||
│ │ └── MessageInput.tsx # Ввод сообщений
|
||||
│ ├── sidebar/
|
||||
│ │ ├── ChatList.tsx # Список чатов
|
||||
│ │ └── ChatItem.tsx # Элемент чата
|
||||
│ └── ui/
|
||||
│ ├── UserSearch.tsx # Поиск пользователей
|
||||
│ ├── CreateGroupModal.tsx # Создание группы/канала
|
||||
│ ├── MembersPanel.tsx # Панель участников
|
||||
│ └── Avatar.tsx # Аватар
|
||||
├── store/
|
||||
│ ├── auth.ts # Аутентификация
|
||||
│ ├── chat.ts # Чаты и сообщения
|
||||
│ └── groups.ts # Группы и каналы
|
||||
└── types/
|
||||
└── index.ts # Типы данных
|
||||
```
|
||||
|
||||
## Компоненты
|
||||
|
||||
### Sidebar.tsx
|
||||
|
||||
Боковая панель с переключением между чатами, группами и каналами.
|
||||
|
||||
**Вкладки:**
|
||||
- **Чаты** — личные и групповые чаты
|
||||
- **Группы** — управление группами
|
||||
- **Каналы** — управление каналами
|
||||
|
||||
**Функции:**
|
||||
- Поиск по чатам
|
||||
- Счётчики непрочитанных
|
||||
- Быстрое создание групп/каналов
|
||||
|
||||
### ChatHeader.tsx
|
||||
|
||||
Шапка чата с информацией о собеседнике/группе.
|
||||
|
||||
**Отображение:**
|
||||
- Аватар и имя
|
||||
- Статус (онлайн/был недавно)
|
||||
- Для групп: количество участников
|
||||
- Для каналов: количество подписчиков
|
||||
|
||||
**Действия:**
|
||||
- Поиск по сообщениям
|
||||
- Показать/скрыть участников
|
||||
- Меню действий
|
||||
|
||||
### ChatWindow.tsx
|
||||
|
||||
Основное окно чата.
|
||||
|
||||
**Режимы:**
|
||||
- Пустое состояние (чат не выбран)
|
||||
- Активный чат с сообщениями
|
||||
|
||||
### UserSearch.tsx
|
||||
|
||||
Поиск пользователей для добавления в чат или группу.
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
interface UserSearchProps {
|
||||
onSelect?: (user: User) => void
|
||||
onClose?: () => void
|
||||
showStartChat?: boolean // Показать кнопку "Начать чат"
|
||||
showAddToGroup?: boolean // Показать кнопку "Добавить в группу"
|
||||
groupId?: string // ID группы для добавления
|
||||
}
|
||||
```
|
||||
|
||||
**Функции:**
|
||||
- Debounced поиск (300ms)
|
||||
- Отображение статуса онлайн
|
||||
- Быстрое добавление в группу
|
||||
|
||||
### CreateGroupModal.tsx
|
||||
|
||||
Модальное окно создания группы или канала.
|
||||
|
||||
**Этапы:**
|
||||
1. **Информация** — название, описание, тип доступа
|
||||
2. **Участники** — выбор пользователей
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
interface CreateGroupModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
mode: 'group' | 'channel'
|
||||
}
|
||||
```
|
||||
|
||||
**Параметры создания:**
|
||||
- Название (обязательно)
|
||||
- Описание (необязательно)
|
||||
- Тип доступа (приватный/публичный)
|
||||
- Участники (необязательно)
|
||||
|
||||
### MembersPanel.tsx
|
||||
|
||||
Выезжающая панель участников группы/канала.
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
interface MembersPanelProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
groupId: string
|
||||
canAddMembers?: boolean
|
||||
canRemoveMembers?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
**Функции:**
|
||||
- Просмотр списка участников
|
||||
- Добавление участников (если разрешено)
|
||||
- Удаление участников (для владельца)
|
||||
- Выход из группы/канала
|
||||
|
||||
## Store
|
||||
|
||||
### groups.ts
|
||||
|
||||
Управление группами и каналами.
|
||||
|
||||
**Состояние:**
|
||||
```typescript
|
||||
interface GroupsStore {
|
||||
groups: Group[] // Список групп
|
||||
channels: Group[] // Список каналов
|
||||
selectedGroup: Group | null // Выбранная группа
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
}
|
||||
```
|
||||
|
||||
**Методы:**
|
||||
- `loadGroups()` — загрузка списка групп
|
||||
- `loadChannels()` — загрузка списка каналов
|
||||
- `createGroup(params)` — создание группы
|
||||
- `createChannel(params)` — создание канала
|
||||
- `selectGroup(groupId)` — выбор группы
|
||||
- `addMember(groupId, userId)` — добавление участника
|
||||
- `removeMember(groupId, userId)` — удаление участника
|
||||
- `leaveGroup(groupId)` — выход из группы
|
||||
- `deleteGroup(groupId)` — удаление группы
|
||||
- `updateGroupInfo(groupId, name, description)` — обновление информации
|
||||
- `searchUsersForInvite(query)` — поиск пользователей
|
||||
|
||||
## Типы данных
|
||||
|
||||
### Group
|
||||
|
||||
```typescript
|
||||
interface Group {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
avatar?: string
|
||||
owner: string
|
||||
members: GroupMember[]
|
||||
created: Date
|
||||
isChannel: boolean
|
||||
isPublic: boolean
|
||||
membersCount: number
|
||||
}
|
||||
```
|
||||
|
||||
### GroupMember
|
||||
|
||||
```typescript
|
||||
interface GroupMember {
|
||||
userId: string
|
||||
name: string
|
||||
avatar?: string
|
||||
role: 'owner' | 'admin' | 'member'
|
||||
joined: Date
|
||||
online?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
### CreateGroupParams
|
||||
|
||||
```typescript
|
||||
interface CreateGroupParams {
|
||||
name: string
|
||||
description?: string
|
||||
isChannel: boolean
|
||||
isPublic: boolean
|
||||
members: string[] // user IDs
|
||||
avatar?: string
|
||||
}
|
||||
```
|
||||
|
||||
## Создание группы
|
||||
|
||||
```typescript
|
||||
import { useGroupsStore } from '@/store/groups'
|
||||
|
||||
const { createGroup } = useGroupsStore()
|
||||
|
||||
const group = await createGroup({
|
||||
name: 'Моя группа',
|
||||
description: 'Описание группы',
|
||||
isChannel: false,
|
||||
isPublic: false,
|
||||
members: ['user1', 'user2'],
|
||||
})
|
||||
```
|
||||
|
||||
## Создание канала
|
||||
|
||||
```typescript
|
||||
import { useGroupsStore } from '@/store/groups'
|
||||
|
||||
const { createChannel } = useGroupsStore()
|
||||
|
||||
const channel = await createChannel({
|
||||
name: 'Мой канал',
|
||||
description: 'Описание канала',
|
||||
isPublic: true,
|
||||
members: [], // Можно без участников
|
||||
})
|
||||
```
|
||||
|
||||
## Поиск пользователей
|
||||
|
||||
```typescript
|
||||
import { useGroupsStore } from '@/store/groups'
|
||||
|
||||
const { searchUsersForInvite } = useGroupsStore()
|
||||
|
||||
const users = await searchUsersForInvite('Иван')
|
||||
// [{ id: 'user1', name: 'Иван', online: true }, ...]
|
||||
```
|
||||
|
||||
## Добавление участника
|
||||
|
||||
```typescript
|
||||
import { useGroupsStore } from '@/store/groups'
|
||||
|
||||
const { addMember } = useGroupsStore()
|
||||
|
||||
await addMember('group-id', 'user-id')
|
||||
```
|
||||
|
||||
## Отличия групп и каналов
|
||||
|
||||
| Параметр | Группа | Канал |
|
||||
|----------|--------|-------|
|
||||
| **Цель** | Общение | Публикация контента |
|
||||
| **Участники** | Могут писать | Только читают (обычно) |
|
||||
| **Создатель** | Владелец | Администратор |
|
||||
| **Доступ** | Приватный/Публичный | Приватный/Публичный |
|
||||
| **Уведомления** | Все сообщения | Только новые посты |
|
||||
|
||||
## Типы доступа
|
||||
|
||||
### Приватный
|
||||
- Вход только по приглашению
|
||||
- Не отображается в поиске
|
||||
- Участники видны друг другу
|
||||
|
||||
### Публичный
|
||||
- Любой может вступить
|
||||
- Отображается в поиске
|
||||
- Можно пригласить ссылку
|
||||
|
||||
## Интеграция с Tinode
|
||||
|
||||
### Группы
|
||||
|
||||
Tinode использует topics с префиксом `grp`:
|
||||
|
||||
```typescript
|
||||
// Создание группы
|
||||
const groupTopic = tn.getTopic('new')
|
||||
await groupTopic.save({
|
||||
desc: {
|
||||
public: {
|
||||
fn: 'Название',
|
||||
note: 'Описание',
|
||||
type: 'group',
|
||||
},
|
||||
},
|
||||
subs: members.map(userId => ({
|
||||
user: userId,
|
||||
mode: 'RW', // Read-Write
|
||||
})),
|
||||
})
|
||||
```
|
||||
|
||||
### Каналы
|
||||
|
||||
```typescript
|
||||
// Создание канала
|
||||
const channelTopic = tn.getTopic('new')
|
||||
await channelTopic.save({
|
||||
desc: {
|
||||
public: {
|
||||
fn: 'Название',
|
||||
note: 'Описание',
|
||||
type: 'channel',
|
||||
},
|
||||
},
|
||||
subs: members.map(userId => ({
|
||||
user: userId,
|
||||
mode: 'R', // Read-only
|
||||
})),
|
||||
})
|
||||
```
|
||||
|
||||
## Роли участников
|
||||
|
||||
### Owner (Владелец)
|
||||
- Полный доступ
|
||||
- Может удалять группу
|
||||
- Назначать администраторов
|
||||
- Добавлять/удалять участников
|
||||
|
||||
### Admin (Администратор)
|
||||
- Может добавлять участников
|
||||
- Может удалять участников (не админов)
|
||||
- Может редактировать информацию
|
||||
|
||||
### Member (Участник)
|
||||
- Может писать сообщения
|
||||
- Может приглашать других (если разрешено)
|
||||
- Может покинуть группу
|
||||
|
||||
## Примеры использования
|
||||
|
||||
### Создание публичного канала
|
||||
|
||||
```typescript
|
||||
const { createChannel } = useGroupsStore()
|
||||
|
||||
const channel = await createChannel({
|
||||
name: 'Новости Ласточки',
|
||||
description: 'Официальные новости мессенджера',
|
||||
isPublic: true,
|
||||
members: [],
|
||||
})
|
||||
|
||||
// Переход в канал
|
||||
const { setActiveChat } = useChatStore.getState()
|
||||
await setActiveChat(channel.id)
|
||||
```
|
||||
|
||||
### Добавление участника из поиска
|
||||
|
||||
```typescript
|
||||
<UserSearch
|
||||
showAddToGroup
|
||||
groupId="group-id"
|
||||
onSelect={(user) => {
|
||||
console.log('Добавлен:', user)
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Просмотр участников
|
||||
|
||||
```typescript
|
||||
const { selectGroup, selectedGroup } = useGroupsStore()
|
||||
|
||||
await selectGroup('group-id')
|
||||
console.log(selectedGroup?.members)
|
||||
// [{ userId: 'user1', name: 'Иван', role: 'owner', ... }]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Ласточка** — народный мессенджер с открытым кодом 🕊️
|
||||
28
lastochka-ui/index.html
Normal file
28
lastochka-ui/index.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#2AABEE" />
|
||||
<meta name="description" content="Российский мессенджер с открытым кодом" />
|
||||
|
||||
<!-- Security: Content Security Policy -->
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' wss://app.lastochka-m.ru ws://app.lastochka-m.ru https://app.lastochka-m.ru; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" />
|
||||
|
||||
<!-- Security: Prevent clickjacking -->
|
||||
<meta http-equiv="X-Frame-Options" content="DENY" />
|
||||
|
||||
<!-- Security: MIME type sniffing prevention -->
|
||||
<meta http-equiv="X-Content-Type-Options" content="nosniff" />
|
||||
|
||||
<!-- Security: Referrer Policy -->
|
||||
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
||||
|
||||
<title>Ласточка</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2742
lastochka-ui/package-lock.json
generated
Normal file
2742
lastochka-ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
lastochka-ui/package.json
Normal file
30
lastochka-ui/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "lastochka-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tinode-sdk": "^0.25.1",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.3.1"
|
||||
}
|
||||
}
|
||||
6
lastochka-ui/postcss.config.js
Normal file
6
lastochka-ui/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
lastochka-ui/public/chat-bg.png
Normal file
BIN
lastochka-ui/public/chat-bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
BIN
lastochka-ui/public/logo.png
Normal file
BIN
lastochka-ui/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
BIN
lastochka-ui/public/slide.jpg
Normal file
BIN
lastochka-ui/public/slide.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 404 KiB |
131
lastochka-ui/src/App.tsx
Normal file
131
lastochka-ui/src/App.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useChatStore } from '@/store/chat'
|
||||
import { useGroupsStore } from '@/store/groups'
|
||||
import { useNetworkMonitor } from '@/hooks/useNetworkMonitor'
|
||||
import Sidebar from '@/components/layout/Sidebar'
|
||||
import ChatWindow from '@/components/chat/ChatWindow'
|
||||
import LoginScreen from '@/components/auth/LoginScreen'
|
||||
import MembersPanel from '@/components/ui/MembersPanel'
|
||||
import { Wifi, WifiOff } from 'lucide-react'
|
||||
|
||||
export default function App() {
|
||||
const { isAuthenticated, isLoading, tryAutoLogin } = useAuthStore()
|
||||
const { darkMode } = useChatStore()
|
||||
const network = useNetworkMonitor()
|
||||
|
||||
useEffect(() => {
|
||||
tryAutoLogin()
|
||||
}, [tryAutoLogin])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', darkMode)
|
||||
}, [darkMode])
|
||||
|
||||
if (isLoading && !isAuthenticated) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-chat dark:bg-chat-dark">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-12 h-12 border-4 border-brand border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">Подключение...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <LoginScreen />
|
||||
}
|
||||
|
||||
return <ChatLayout />
|
||||
}
|
||||
|
||||
function ChatLayout() {
|
||||
const { activeChatId } = useChatStore()
|
||||
const { selectedGroup, selectGroup } = useGroupsStore()
|
||||
const network = useNetworkMonitor()
|
||||
const [mobileView, setMobileView] = useState<'sidebar' | 'chat'>('sidebar')
|
||||
const [showMembersPanel, setShowMembersPanel] = useState(false)
|
||||
|
||||
// When a chat is opened, switch to chat view on mobile
|
||||
useEffect(() => {
|
||||
if (activeChatId) {
|
||||
setMobileView('chat')
|
||||
}
|
||||
}, [activeChatId])
|
||||
|
||||
// Загружаем данные группы при открытии панели участников
|
||||
const handleToggleMembers = async () => {
|
||||
if (!showMembersPanel && activeChatId && activeChatId.startsWith('grp')) {
|
||||
await selectGroup(activeChatId)
|
||||
}
|
||||
setShowMembersPanel((v) => !v)
|
||||
}
|
||||
|
||||
// Закрываем панель участников при смене чата
|
||||
useEffect(() => {
|
||||
setShowMembersPanel(false)
|
||||
}, [activeChatId])
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full overflow-hidden relative">
|
||||
{/* Offline Banner */}
|
||||
{!network.isOnline && (
|
||||
<div className="fixed top-3 left-1/2 -translate-x-1/2 z-50 bg-red-500/90 backdrop-blur-xl text-white px-5 py-2.5 flex items-center gap-2.5 text-sm font-medium shadow-glass-lg rounded-xl animate-slide-down">
|
||||
<WifiOff size={16} />
|
||||
<span>Нет подключения</span>
|
||||
{network.reconnectCount > 0 && (
|
||||
<span className="text-xs opacity-80">
|
||||
(#{network.reconnectCount})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reconnecting banner */}
|
||||
{network.isOnline && network.lastReconnectAttempt && network.reconnectCount > 0 && (
|
||||
<div className="fixed top-3 left-1/2 -translate-x-1/2 z-50 bg-brand/90 backdrop-blur-xl text-white px-5 py-2.5 flex items-center gap-2.5 text-sm font-medium shadow-glass-lg rounded-xl animate-slide-down">
|
||||
<Wifi size={16} className="animate-pulse" />
|
||||
<span>Переподключение...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sidebar - full width on mobile, fixed width on desktop */}
|
||||
<div
|
||||
className={[
|
||||
'flex-col h-full w-full flex-shrink-0',
|
||||
mobileView === 'sidebar' ? 'flex' : 'hidden',
|
||||
'md:flex md:w-80 lg:w-96',
|
||||
].join(' ')}
|
||||
>
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
{/* Chat area - full screen on mobile, flex-1 on desktop */}
|
||||
<div
|
||||
className={[
|
||||
'flex flex-col h-full w-full min-w-0',
|
||||
mobileView === 'chat' ? 'flex' : 'hidden',
|
||||
'md:flex',
|
||||
].join(' ')}
|
||||
>
|
||||
<ChatWindow
|
||||
onBack={() => setMobileView('sidebar')}
|
||||
showMembers={showMembersPanel}
|
||||
onToggleMembers={handleToggleMembers}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Панель участников (для групп) */}
|
||||
{selectedGroup && showMembersPanel && (
|
||||
<MembersPanel
|
||||
isOpen={showMembersPanel}
|
||||
onClose={() => setShowMembersPanel(false)}
|
||||
groupId={selectedGroup.id}
|
||||
canAddMembers
|
||||
canRemoveMembers
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
168
lastochka-ui/src/components/auth/EmailVerification.tsx
Normal file
168
lastochka-ui/src/components/auth/EmailVerification.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useState, type FormEvent, useRef, useEffect } from 'react'
|
||||
import { ArrowLeft, Mail } from 'lucide-react'
|
||||
|
||||
interface EmailVerificationProps {
|
||||
email: string
|
||||
onComplete: (code: string) => void
|
||||
onBack: () => void
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export default function EmailVerification({
|
||||
email,
|
||||
onComplete,
|
||||
onBack,
|
||||
isLoading,
|
||||
error,
|
||||
title = 'Введите код из письма',
|
||||
description = 'Мы отправили код подтверждения на ваш email',
|
||||
}: EmailVerificationProps) {
|
||||
const [code, setCode] = useState(['', '', '', '', '', ''])
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([])
|
||||
|
||||
// Форматирование email для отображения (скрываем часть)
|
||||
const formatEmailForDisplay = (emailAddr: string) => {
|
||||
const [name, domain] = emailAddr.split('@')
|
||||
if (!domain) return emailAddr
|
||||
|
||||
// Показываем первые 2 символа и ***@domain
|
||||
const maskedName = name.length > 2
|
||||
? name.slice(0, 2) + '***'
|
||||
: name + '**'
|
||||
|
||||
return `${maskedName}@${domain}`
|
||||
}
|
||||
|
||||
// Автофокус на первом поле
|
||||
useEffect(() => {
|
||||
if (inputRefs.current[0]) {
|
||||
inputRefs.current[0]?.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleChange = (index: number, value: string) => {
|
||||
// Разрешаем только цифры и буквы
|
||||
if (value && !/^[a-zA-Z0-9]$/.test(value)) return
|
||||
|
||||
const newCode = [...code]
|
||||
newCode[index] = value.toUpperCase()
|
||||
setCode(newCode)
|
||||
|
||||
// Автопереход к следующему полю
|
||||
if (value && index < 5) {
|
||||
inputRefs.current[index + 1]?.focus()
|
||||
}
|
||||
|
||||
// Проверка на заполненность всех полей
|
||||
const fullCode = newCode.join('')
|
||||
if (fullCode.length === 6) {
|
||||
onComplete(fullCode)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// Переход назад при Backspace
|
||||
if (e.key === 'Backspace' && !code[index] && index > 0) {
|
||||
inputRefs.current[index - 1]?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
const pastedData = e.clipboardData.getData('text').replace(/[^a-zA-Z0-9]/g, '').slice(0, 6).toUpperCase()
|
||||
|
||||
if (pastedData.length === 6) {
|
||||
const newCode = pastedData.split('')
|
||||
setCode(newCode)
|
||||
onComplete(pastedData)
|
||||
}
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'w-12 h-14 text-center text-xl font-semibold rounded-xl bg-white dark:bg-[#242f3d] border border-black/10 dark:border-white/10 text-gray-900 dark:text-gray-100 outline-none focus:border-brand dark:focus:border-brand transition-colors'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full">
|
||||
{/* Кнопка назад */}
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="self-start flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mb-6"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Назад
|
||||
</button>
|
||||
|
||||
{/* Заголовок */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 mx-auto rounded-full bg-brand/10 dark:bg-brand/20 flex items-center justify-center mb-4">
|
||||
<Mail className="w-8 h-8 text-brand" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-brand mt-2">
|
||||
{formatEmailForDisplay(email)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Поля ввода кода */}
|
||||
<form onSubmit={(e) => e.preventDefault()} className="mb-6">
|
||||
<div className="flex gap-2" onPaste={handlePaste}>
|
||||
{code.map((digit, index) => (
|
||||
<input
|
||||
key={index}
|
||||
ref={(el) => (inputRefs.current[index] = el)}
|
||||
type="text"
|
||||
inputMode="text"
|
||||
autoCapitalize="characters"
|
||||
maxLength={1}
|
||||
value={digit}
|
||||
onChange={(e) => handleChange(index, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(index, e)}
|
||||
disabled={isLoading}
|
||||
className={inputClass}
|
||||
aria-label={`Символ ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Ошибка */}
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 dark:text-red-400 text-center mb-4">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Индикатор загрузки */}
|
||||
{isLoading && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-center">
|
||||
Проверяем код...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Повторная отправка */}
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Не получили письмо?{' '}
|
||||
<button
|
||||
className="text-brand dark:text-brand hover:underline font-medium"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Отправить повторно
|
||||
</button>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2">
|
||||
Проверьте также папку «Спам»
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
199
lastochka-ui/src/components/auth/LoginScreen.tsx
Normal file
199
lastochka-ui/src/components/auth/LoginScreen.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useState } from 'react'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import RegisterForm from './RegisterForm'
|
||||
import { Eye, EyeOff, Shield, Server, Code } from 'lucide-react'
|
||||
|
||||
type AuthMode = 'login' | 'register'
|
||||
|
||||
const features = [
|
||||
{ icon: <Code size={15} />, text: 'Открытый исходный код — GPL v3' },
|
||||
{ icon: <Server size={15} />, text: 'Серверы расположены в России' },
|
||||
{ icon: <Shield size={15} />, text: 'Без рекламы и скрытого трекинга' },
|
||||
]
|
||||
|
||||
const inputClass =
|
||||
'w-full h-12 px-4 rounded-xl bg-gray-50/80 dark:bg-[#242f3d]/60 border border-gray-200/50 dark:border-white/5 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 outline-none focus:border-brand/60 focus:ring-2 focus:ring-brand/10 focus:bg-white dark:focus:bg-[#2a3547] transition-all duration-200 text-[15px]'
|
||||
|
||||
export default function LoginScreen() {
|
||||
const { login, isLoading, error } = useAuthStore()
|
||||
const [mode, setMode] = useState<AuthMode>('login')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
await login(username.trim(), password)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen overflow-hidden">
|
||||
|
||||
{/* ── Левая панель: изображение + фраза ─────────────────── */}
|
||||
<div className="hidden lg:flex lg:w-[52%] relative flex-col overflow-hidden flex-shrink-0">
|
||||
{/* Фоновое изображение */}
|
||||
<img
|
||||
src="/slide.jpg"
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full object-cover object-center"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Затемнение сверху вниз */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-[#0B0D1F]/90 via-[#0B0D1F]/65 to-[#0B0D1F]/40" />
|
||||
|
||||
{/* Контент поверх */}
|
||||
<div className="relative z-10 flex flex-col h-full p-10 xl:p-14">
|
||||
{/* Логотип */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-9 h-9 rounded-xl bg-brand flex items-center justify-center shadow-lg flex-shrink-0">
|
||||
<img src="/logo.png" alt="Ласточка" className="w-6 h-6 object-contain" />
|
||||
</div>
|
||||
<span className="text-white text-xl font-bold tracking-tight">Ласточка</span>
|
||||
</div>
|
||||
|
||||
{/* Основной текст — по центру */}
|
||||
<div className="flex-1 flex flex-col justify-center max-w-md">
|
||||
<h2 className="text-4xl xl:text-5xl font-bold text-white leading-tight mb-5">
|
||||
Мессенджер,<br />
|
||||
которому можно<br />
|
||||
<span className="text-brand">доверять.</span>
|
||||
</h2>
|
||||
<p className="text-white/60 text-lg leading-relaxed mb-10">
|
||||
Общайтесь свободно — ваши данные остаются вашими.
|
||||
</p>
|
||||
|
||||
{/* Три пункта */}
|
||||
<div className="space-y-3">
|
||||
{features.map((f) => (
|
||||
<div key={f.text} className="flex items-center gap-3">
|
||||
<div className="w-7 h-7 rounded-lg bg-white/10 flex items-center justify-center text-brand flex-shrink-0">
|
||||
{f.icon}
|
||||
</div>
|
||||
<span className="text-white/75 text-sm font-medium">{f.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Низ */}
|
||||
<p className="text-white/25 text-xs">
|
||||
© {new Date().getFullYear()} Ласточка · Лицензия GPL v3
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Правая панель: форма ───────────────────────────────── */}
|
||||
<div className="flex-1 flex items-center justify-center overflow-y-auto bg-white dark:bg-[#0e1621] px-6 py-10">
|
||||
<div className="w-full max-w-sm">
|
||||
|
||||
{/* Логотип для мобильных */}
|
||||
<div className="flex lg:hidden items-center justify-center gap-2.5 mb-8">
|
||||
<div className="w-9 h-9 rounded-xl bg-brand flex items-center justify-center">
|
||||
<svg viewBox="0 0 24 24" fill="white" width="20" height="20">
|
||||
<path d="M12 3C7 3 3 7 3 12l3-1-1 3c1-1 2.5-1.5 4-1l-1 4 4-2 4 2-1-4c1.5-.5 3 0 4 1l-1-3 3 1c0-5-4-9-9-9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-gray-900 dark:text-white text-xl font-bold">Ласточка</span>
|
||||
</div>
|
||||
|
||||
{mode === 'login' ? (
|
||||
<>
|
||||
{/* Заголовок */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-1">
|
||||
Вход в аккаунт
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Нет аккаунта?{' '}
|
||||
<button
|
||||
onClick={() => setMode('register')}
|
||||
className="text-brand font-semibold hover:underline"
|
||||
>
|
||||
Зарегистрироваться
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Форма */}
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Логин"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
className={inputClass}
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Пароль"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className={inputClass}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3.5 rounded-xl bg-red-50/80 dark:bg-red-900/10 border border-red-100/50 dark:border-red-800/30 animate-slide-down">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full h-12 rounded-xl bg-gradient-to-r from-brand to-brand-dark hover:shadow-lg hover:shadow-brand/25 disabled:opacity-60 disabled:cursor-not-allowed text-white font-semibold text-[15px] transition-all duration-200 mt-2 active:scale-[0.98]"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Вход...
|
||||
</span>
|
||||
) : 'Войти'}
|
||||
</button>
|
||||
|
||||
<div className="text-center pt-1">
|
||||
<a href="#forgot-password" className="text-sm text-gray-400 hover:text-brand transition-colors">
|
||||
Забыли пароль?
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Заголовок регистрации */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-1">
|
||||
Создать аккаунт
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Уже есть аккаунт?{' '}
|
||||
<button
|
||||
onClick={() => setMode('login')}
|
||||
className="text-brand font-semibold hover:underline"
|
||||
>
|
||||
Войти
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RegisterForm onSuccess={() => {}} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
402
lastochka-ui/src/components/auth/RegisterForm.tsx
Normal file
402
lastochka-ui/src/components/auth/RegisterForm.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
import { useState, type FormEvent, useEffect } from 'react'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { formatPhoneNumber, isValidPhoneNumber, isValidEmail } from '@/lib/phone-utils'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { checkPhoneAvailability, checkLoginAvailability, checkEmailAvailability } from '@/lib/email-auth'
|
||||
|
||||
interface RegisterFormProps {
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export default function RegisterForm({ onSuccess }: RegisterFormProps) {
|
||||
const {
|
||||
sendRegistrationEmail,
|
||||
isLoading,
|
||||
error
|
||||
} = useAuthStore()
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showPasswordConfirm, setShowPasswordConfirm] = useState(false)
|
||||
|
||||
// Данные регистрации
|
||||
const [login, setLogin] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [phone, setPhone] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('')
|
||||
|
||||
// Ошибки валидации
|
||||
const [loginError, setLoginError] = useState('')
|
||||
const [emailError, setEmailError] = useState('')
|
||||
const [phoneError, setPhoneError] = useState('')
|
||||
const [passwordError, setPasswordError] = useState('')
|
||||
|
||||
// Проверка логина на доступность
|
||||
const [isCheckingLogin, setIsCheckingLogin] = useState(false)
|
||||
const [loginAvailable, setLoginAvailable] = useState<boolean | null>(null)
|
||||
|
||||
// Проверка email на доступность
|
||||
const [isCheckingEmail, setIsCheckingEmail] = useState(false)
|
||||
const [emailAvailable, setEmailAvailable] = useState<boolean | null>(null)
|
||||
|
||||
// Проверка телефона на доступность
|
||||
const [isCheckingPhone, setIsCheckingPhone] = useState(false)
|
||||
const [phoneAvailable, setPhoneAvailable] = useState<boolean | null>(null)
|
||||
|
||||
// Debounced проверка логина
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(async () => {
|
||||
if (login.length >= 3) {
|
||||
setIsCheckingLogin(true)
|
||||
const result = await checkLoginAvailability(login)
|
||||
setLoginAvailable(result.available)
|
||||
setLoginError(result.available ? '' : result.error || 'Этот логин уже занят')
|
||||
setIsCheckingLogin(false)
|
||||
} else {
|
||||
setLoginAvailable(null)
|
||||
}
|
||||
}, 500)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [login])
|
||||
|
||||
// Debounced проверка email
|
||||
useEffect(() => {
|
||||
setEmailAvailable(null)
|
||||
const trimmed = email.trim()
|
||||
if (!isValidEmail(trimmed)) return
|
||||
const timer = setTimeout(async () => {
|
||||
setIsCheckingEmail(true)
|
||||
const result = await checkEmailAvailability(trimmed)
|
||||
setEmailAvailable(result.available)
|
||||
setEmailError(result.available ? '' : result.error || 'Этот email уже зарегистрирован')
|
||||
setIsCheckingEmail(false)
|
||||
}, 500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [email])
|
||||
|
||||
// Debounced проверка телефона
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(async () => {
|
||||
const cleanedPhone = phone.replace(/\D/g, '')
|
||||
if (cleanedPhone.length === 11) {
|
||||
setIsCheckingPhone(true)
|
||||
const result = await checkPhoneAvailability(cleanedPhone)
|
||||
setPhoneAvailable(result.available)
|
||||
setPhoneError(result.available ? '' : result.error || 'Этот номер уже зарегистрирован')
|
||||
setIsCheckingPhone(false)
|
||||
} else {
|
||||
setPhoneAvailable(null)
|
||||
}
|
||||
}, 500)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [phone])
|
||||
|
||||
const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const formatted = formatPhoneNumber(e.target.value)
|
||||
setPhone(formatted)
|
||||
setPhoneError('')
|
||||
}
|
||||
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(e.target.value)
|
||||
setEmailError('')
|
||||
setEmailAvailable(null)
|
||||
}
|
||||
|
||||
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPassword(e.target.value)
|
||||
setPasswordError('')
|
||||
}
|
||||
|
||||
const handlePasswordConfirmChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPasswordConfirm(e.target.value)
|
||||
}
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
let isValid = true
|
||||
|
||||
// Проверка логина
|
||||
if (login.length < 3) {
|
||||
setLoginError('Логин должен быть не менее 3 символов')
|
||||
isValid = false
|
||||
} else if (!/^[a-zA-Z0-9_]+$/.test(login)) {
|
||||
setLoginError('Логин может содержать только буквы, цифры и подчёркивание')
|
||||
isValid = false
|
||||
} else if (loginAvailable === false) {
|
||||
setLoginError('Этот логин уже занят')
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Проверка email
|
||||
if (!isValidEmail(email)) {
|
||||
setEmailError('Введите корректный email')
|
||||
isValid = false
|
||||
} else if (emailAvailable === false) {
|
||||
setEmailError('Этот email уже зарегистрирован')
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Проверка телефона
|
||||
if (!isValidPhoneNumber(phone)) {
|
||||
setPhoneError('Введите корректный номер телефона')
|
||||
isValid = false
|
||||
} else if (phoneAvailable === false) {
|
||||
setPhoneError('Этот номер уже зарегистрирован')
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Проверка пароля
|
||||
if (password.length < 6) {
|
||||
setPasswordError('Пароль должен быть не менее 6 символов')
|
||||
isValid = false
|
||||
} else if (password !== passwordConfirm) {
|
||||
setPasswordError('Пароли не совпадают')
|
||||
isValid = false
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Регистрация без верификации email
|
||||
await sendRegistrationEmail(
|
||||
login,
|
||||
password,
|
||||
email,
|
||||
phone,
|
||||
displayName.trim() || login
|
||||
)
|
||||
|
||||
// После успешной регистрации переходим на главную
|
||||
onSuccess?.()
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'w-full h-12 px-4 rounded-xl bg-white dark:bg-[#242f3d] border border-black/10 dark:border-white/10 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 outline-none focus:border-brand dark:focus:border-brand transition-colors text-[15px]'
|
||||
|
||||
const labelClass = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'
|
||||
const errorClass = 'text-sm text-red-500 dark:text-red-400 mt-1'
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Логин */}
|
||||
<div>
|
||||
<label className={labelClass}>
|
||||
Логин <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="username"
|
||||
value={login}
|
||||
onChange={(e) => setLogin(e.target.value.toLowerCase().replace(/[^a-zA-Z0-9_]/g, ''))}
|
||||
autoComplete="username"
|
||||
required
|
||||
disabled={isLoading || isCheckingLogin}
|
||||
className={`${inputClass} ${loginError ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
{isCheckingLogin && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<div className="w-4 h-4 border-2 border-brand border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{loginAvailable === true && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-green-500">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{loginError && <p className={errorClass}>{loginError}</p>}
|
||||
{!loginError && login.length >= 3 && loginAvailable === true && (
|
||||
<p className="text-sm text-green-500 mt-1">Логин доступен</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className={labelClass}>
|
||||
Email <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="example@mail.ru"
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
autoComplete="email"
|
||||
required
|
||||
disabled={isLoading || isCheckingEmail}
|
||||
className={`${inputClass} ${emailError ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
{isCheckingEmail && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<div className="w-4 h-4 border-2 border-brand border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{emailAvailable === true && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-green-500">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{emailError && <p className={errorClass}>{emailError}</p>}
|
||||
{!emailError && emailAvailable === true && (
|
||||
<p className="text-sm text-green-500 mt-1">Email доступен</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Телефон */}
|
||||
<div>
|
||||
<label className={labelClass}>
|
||||
Телефон <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="tel"
|
||||
placeholder="+7 (999) 999-99-99"
|
||||
value={phone}
|
||||
onChange={handlePhoneChange}
|
||||
autoComplete="tel"
|
||||
required
|
||||
disabled={isLoading || isCheckingPhone}
|
||||
className={`${inputClass} ${phoneError ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
{isCheckingPhone && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<div className="w-4 h-4 border-2 border-brand border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{phoneAvailable === true && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-green-500">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{phoneError && <p className={errorClass}>{phoneError}</p>}
|
||||
{!phoneError && phone.length === 18 && phoneAvailable === true && (
|
||||
<p className="text-sm text-green-500 mt-1">Номер доступен</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Отображаемое имя */}
|
||||
<div>
|
||||
<label className={labelClass}>
|
||||
Ваше имя <span className="text-gray-400">(необязательно)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Как к вам обращаться"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
autoComplete="name"
|
||||
disabled={isLoading}
|
||||
className={inputClass}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Будет отображаться в списке контактов. Если не указать, будет использоваться логин.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Пароль */}
|
||||
<div>
|
||||
<label className={labelClass}>
|
||||
Пароль <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Минимум 6 символов"
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
disabled={isLoading}
|
||||
className={`${inputClass} ${passwordError ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
{passwordError && <p className={errorClass}>{passwordError}</p>}
|
||||
</div>
|
||||
|
||||
{/* Подтверждение пароля */}
|
||||
<div>
|
||||
<label className={labelClass}>
|
||||
Подтверждение пароля <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPasswordConfirm ? 'text' : 'password'}
|
||||
placeholder="Повторите пароль"
|
||||
value={passwordConfirm}
|
||||
onChange={handlePasswordConfirmChange}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
disabled={isLoading}
|
||||
className={`${inputClass} ${passwordConfirm && password !== passwordConfirm ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPasswordConfirm(!showPasswordConfirm)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{showPasswordConfirm ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
{passwordConfirm && password === passwordConfirm && (
|
||||
<p className="text-sm text-green-500 mt-1">Пароли совпадают</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Общая ошибка */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопка отправки */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || isCheckingLogin || isCheckingEmail || isCheckingPhone}
|
||||
className="w-full h-12 rounded-xl bg-brand hover:bg-brand-dark disabled:opacity-60 disabled:cursor-not-allowed text-white font-semibold text-[15px] transition-colors"
|
||||
>
|
||||
{isLoading ? 'Регистрация...' : 'Зарегистрироваться'}
|
||||
</button>
|
||||
|
||||
{/* Информация */}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-center">
|
||||
Регистрируясь, вы принимаете{' '}
|
||||
<a href="#terms" className="text-brand hover:underline">
|
||||
условия использования
|
||||
</a>{' '}
|
||||
и{' '}
|
||||
<a href="#privacy" className="text-brand hover:underline">
|
||||
политику конфиденциальности
|
||||
</a>
|
||||
</p>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
160
lastochka-ui/src/components/auth/SmsVerification.tsx
Normal file
160
lastochka-ui/src/components/auth/SmsVerification.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useState, type FormEvent, useRef, useEffect } from 'react'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
|
||||
interface SmsVerificationProps {
|
||||
phone: string
|
||||
onComplete: (code: string) => void
|
||||
onBack: () => void
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export default function SmsVerification({
|
||||
phone,
|
||||
onComplete,
|
||||
onBack,
|
||||
isLoading,
|
||||
error,
|
||||
title = 'Введите код из SMS',
|
||||
description = 'Мы отправили код подтверждения на ваш номер',
|
||||
}: SmsVerificationProps) {
|
||||
const [code, setCode] = useState(['', '', '', '', '', ''])
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([])
|
||||
|
||||
// Форматирование номера для отображения
|
||||
const formatPhoneForDisplay = (phoneNum: string) => {
|
||||
const digits = phoneNum.replace(/\D/g, '')
|
||||
if (digits.length === 11 && digits.startsWith('7')) {
|
||||
return `+7 (${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7, 9)}-${digits.slice(9, 11)}`
|
||||
}
|
||||
return phoneNum
|
||||
}
|
||||
|
||||
// Автофокус на первом поле
|
||||
useEffect(() => {
|
||||
if (inputRefs.current[0]) {
|
||||
inputRefs.current[0]?.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleChange = (index: number, value: string) => {
|
||||
// Разрешаем только цифры
|
||||
if (value && !/^\d$/.test(value)) return
|
||||
|
||||
const newCode = [...code]
|
||||
newCode[index] = value
|
||||
setCode(newCode)
|
||||
|
||||
// Автопереход к следующему полю
|
||||
if (value && index < 5) {
|
||||
inputRefs.current[index + 1]?.focus()
|
||||
}
|
||||
|
||||
// Проверка на заполненность всех полей
|
||||
const fullCode = newCode.join('')
|
||||
if (fullCode.length === 6) {
|
||||
onComplete(fullCode)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// Переход назад при Backspace
|
||||
if (e.key === 'Backspace' && !code[index] && index > 0) {
|
||||
inputRefs.current[index - 1]?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
const pastedData = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6)
|
||||
|
||||
if (pastedData.length === 6) {
|
||||
const newCode = pastedData.split('')
|
||||
setCode(newCode)
|
||||
onComplete(pastedData)
|
||||
}
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'w-12 h-14 text-center text-xl font-semibold rounded-xl bg-white dark:bg-[#242f3d] border border-black/10 dark:border-white/10 text-gray-900 dark:text-gray-100 outline-none focus:border-brand dark:focus:border-brand transition-colors'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full">
|
||||
{/* Кнопка назад */}
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="self-start flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mb-6"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Назад
|
||||
</button>
|
||||
|
||||
{/* Заголовок */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 mx-auto rounded-full bg-brand/10 dark:bg-brand/20 flex items-center justify-center mb-4">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="w-8 h-8 text-brand">
|
||||
<path d="M12 18v-6M12 6v.01" strokeLinecap="round" />
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-brand mt-2">
|
||||
{formatPhoneForDisplay(phone)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Поля ввода кода */}
|
||||
<form onSubmit={(e) => e.preventDefault()} className="mb-6">
|
||||
<div className="flex gap-2" onPaste={handlePaste}>
|
||||
{code.map((digit, index) => (
|
||||
<input
|
||||
key={index}
|
||||
ref={(el) => (inputRefs.current[index] = el)}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="\d*"
|
||||
maxLength={1}
|
||||
value={digit}
|
||||
onChange={(e) => handleChange(index, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(index, e)}
|
||||
disabled={isLoading}
|
||||
className={inputClass}
|
||||
aria-label={`Цифра ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Ошибка */}
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 dark:text-red-400 text-center mb-4">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Индикатор загрузки */}
|
||||
{isLoading && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-center">
|
||||
Проверяем код...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Повторная отправка */}
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Не получили код?{' '}
|
||||
<button className="text-brand dark:text-brand hover:underline font-medium">
|
||||
Отправить повторно
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
97
lastochka-ui/src/components/chat/ChatHeader.tsx
Normal file
97
lastochka-ui/src/components/chat/ChatHeader.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useChatStore } from '@/store/chat'
|
||||
import { useGroupsStore } from '@/store/groups'
|
||||
import Avatar from '@/components/ui/Avatar'
|
||||
import Icon from '@/components/ui/Icon'
|
||||
import { Users, ArrowLeft } from 'lucide-react'
|
||||
|
||||
interface ChatHeaderProps {
|
||||
onBack?: () => void
|
||||
showMembers?: boolean
|
||||
onToggleMembers?: () => void
|
||||
onOpenContactInfo?: () => void
|
||||
}
|
||||
|
||||
export default function ChatHeader({ onBack, showMembers, onToggleMembers, onOpenContactInfo }: ChatHeaderProps) {
|
||||
const { chats, activeChatId, typingUsers, typingNames } = useChatStore()
|
||||
const { selectedGroup } = useGroupsStore()
|
||||
const chat = chats.find((c) => c.id === activeChatId)
|
||||
|
||||
if (!chat) return null
|
||||
|
||||
const isGroup = chat.isGroup || (selectedGroup && !selectedGroup.isChannel)
|
||||
const isP2P = !isGroup
|
||||
|
||||
const typingUserIds = activeChatId ? Array.from(typingUsers[activeChatId] || new Set()) : []
|
||||
const typingText = typingUserIds.length > 0
|
||||
? typingUserIds.map(id => typingNames[id as string] || 'Пользователь').join(', ') + (typingUserIds.length === 1 ? ' печатает' : ' печатают')
|
||||
: ''
|
||||
|
||||
let subtitle = ''
|
||||
if (typingText) {
|
||||
subtitle = typingText
|
||||
} else if (isGroup) {
|
||||
subtitle = `${chat.membersCount || selectedGroup?.membersCount || 0} участников`
|
||||
} else {
|
||||
subtitle = chat.online ? 'в сети' : 'был(а) недавно'
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="flex items-center gap-3 px-3 h-16 bg-white/90 dark:bg-[#17212b] backdrop-blur-xl border-b border-gray-200/50 dark:border-gray-800/50 flex-shrink-0">
|
||||
{onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-xl hover:bg-gray-100/80 dark:hover:bg-gray-800/60 text-gray-500 dark:text-gray-400 transition-all duration-200 active:scale-90"
|
||||
>
|
||||
<ArrowLeft size={21} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="cursor-pointer transition-transform duration-200 active:scale-95"
|
||||
onClick={() => onOpenContactInfo?.()}
|
||||
>
|
||||
<Avatar name={chat.name} src={chat.avatar} size="md" online={isP2P ? chat.online : undefined} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold text-[15px] leading-tight text-gray-900 dark:text-white truncate">
|
||||
{chat.name}
|
||||
</p>
|
||||
{isGroup && <Users size={15} className="text-gray-400 dark:text-gray-500 flex-shrink-0" />}
|
||||
</div>
|
||||
<p className={`text-[12px] truncate transition-all duration-200 ${
|
||||
typingText
|
||||
? 'text-brand dark:text-brand-dark font-medium animate-pulse'
|
||||
: 'text-gray-400 dark:text-gray-500'
|
||||
}`}>
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button className="w-10 h-10 flex items-center justify-center rounded-xl hover:bg-gray-100/80 dark:hover:bg-gray-800/60 text-gray-400 dark:text-gray-500 transition-all duration-200 active:scale-90">
|
||||
<Icon name="search" size={19} />
|
||||
</button>
|
||||
|
||||
{isGroup && onToggleMembers && (
|
||||
<button
|
||||
onClick={onToggleMembers}
|
||||
className={`w-10 h-10 flex items-center justify-center rounded-xl transition-all duration-200 active:scale-90 ${
|
||||
showMembers
|
||||
? 'bg-brand/10 text-brand'
|
||||
: 'hover:bg-gray-100/80 dark:hover:bg-gray-800/60 text-gray-400 dark:text-gray-500'
|
||||
}`}
|
||||
title={showMembers ? 'Скрыть участников' : 'Показать участников'}
|
||||
>
|
||||
<Users size={19} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button className="w-10 h-10 flex items-center justify-center rounded-xl hover:bg-gray-100/80 dark:hover:bg-gray-800/60 text-gray-400 dark:text-gray-500 transition-all duration-200 active:scale-90">
|
||||
<Icon name="more_vert" size={19} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
81
lastochka-ui/src/components/chat/ChatWindow.tsx
Normal file
81
lastochka-ui/src/components/chat/ChatWindow.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useChatStore } from '@/store/chat'
|
||||
import ChatHeader from './ChatHeader'
|
||||
import MessagesList from './MessagesList'
|
||||
import MessageInput from './MessageInput'
|
||||
import MessageContextMenu from './MessageContextMenu'
|
||||
import InlineEditBar from './InlineEditBar'
|
||||
import ForwardModal from './ForwardModal'
|
||||
import ContactInfo from '@/components/ui/ContactInfo'
|
||||
|
||||
interface ChatWindowProps {
|
||||
onBack?: () => void
|
||||
showMembers?: boolean
|
||||
onToggleMembers?: () => void
|
||||
}
|
||||
|
||||
export default function ChatWindow({ onBack, showMembers, onToggleMembers }: ChatWindowProps) {
|
||||
const { activeChatId, contextMenuMessage, contextMenuPosition, setContextMenuMessage } = useChatStore()
|
||||
const [showContactInfo, setShowContactInfo] = useState(false)
|
||||
const { editingMessage, cancelEditing } = useChatStore()
|
||||
|
||||
// Cancel editing when switching chats
|
||||
useEffect(() => {
|
||||
cancelEditing()
|
||||
}, [activeChatId])
|
||||
|
||||
if (!activeChatId) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center bg-chat dark:bg-chat-dark chat-bg opacity-60">
|
||||
<div className="text-center space-y-3 opacity-60">
|
||||
<div className="w-24 h-24 mx-auto rounded-full bg-brand/20 flex items-center justify-center">
|
||||
<svg viewBox="0 0 24 24" fill="none" className="w-12 h-12 text-brand" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3C7 3 3 7 3 12l3-1-1 3c1-1 2.5-1.5 4-1l-1 4 4-2 4 2-1-4c1.5-.5 3 0 4 1l-1-3 3 1c0-5-4-9-9-9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-2xl font-semibold text-gray-600 dark:text-gray-300">Ласточка</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500 max-w-xs">
|
||||
Выберите чат, чтобы начать общение
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// If showing contact info
|
||||
if (showContactInfo) {
|
||||
return (
|
||||
<ContactInfo
|
||||
contactId={activeChatId}
|
||||
onClose={() => setShowContactInfo(false)}
|
||||
onOpenChat={() => setShowContactInfo(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full min-w-0 bg-chat dark:bg-chat-dark chat-bg">
|
||||
<ChatHeader
|
||||
onBack={onBack}
|
||||
showMembers={showMembers}
|
||||
onToggleMembers={onToggleMembers}
|
||||
onOpenContactInfo={() => setShowContactInfo(true)}
|
||||
/>
|
||||
<MessagesList />
|
||||
<InlineEditBar />
|
||||
<MessageInput />
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenuMessage && contextMenuPosition && (
|
||||
<MessageContextMenu
|
||||
message={contextMenuMessage}
|
||||
position={contextMenuPosition}
|
||||
onClose={() => setContextMenuMessage(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Forward Modal */}
|
||||
<ForwardModal />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
101
lastochka-ui/src/components/chat/ForwardModal.tsx
Normal file
101
lastochka-ui/src/components/chat/ForwardModal.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useChatStore } from '@/store/chat'
|
||||
import Avatar from '@/components/ui/Avatar'
|
||||
import { X, Search, Send } from 'lucide-react'
|
||||
|
||||
export default function ForwardModal() {
|
||||
const { forwardingMessage, forwardTo, cancelForward, chats } = useChatStore()
|
||||
const [search, setSearch] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Focus search on open
|
||||
useEffect(() => {
|
||||
if (forwardingMessage) {
|
||||
requestAnimationFrame(() => inputRef.current?.focus())
|
||||
}
|
||||
return () => setSearch('')
|
||||
}, [forwardingMessage?.id])
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') cancelForward()
|
||||
}
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
return () => document.removeEventListener('keydown', handleEsc)
|
||||
}, [cancelForward])
|
||||
|
||||
if (!forwardingMessage) return null
|
||||
|
||||
const filteredChats = search.trim().length > 0
|
||||
? chats.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()))
|
||||
: chats
|
||||
|
||||
const handleSelect = (chatId: string) => {
|
||||
forwardTo(chatId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden max-h-[80vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white">Переслать сообщение</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate max-w-[260px]">
|
||||
{forwardingMessage.text}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={cancelForward}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} className="text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-800">
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Поиск чата..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full h-10 pl-9 pr-4 rounded-xl bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 outline-none focus:ring-2 focus:ring-brand transition-all text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filteredChats.length > 0 ? (
|
||||
filteredChats.map((chat) => (
|
||||
<button
|
||||
key={chat.id}
|
||||
onClick={() => handleSelect(chat.id)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors text-left"
|
||||
>
|
||||
<Avatar name={chat.name} src={chat.avatar} size="md" online={chat.online} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 dark:text-white truncate">{chat.name}</p>
|
||||
{chat.lastMessage && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{chat.lastMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
<Send size={16} className="text-gray-400 flex-shrink-0" />
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<Search size={32} className="mb-2 opacity-40" />
|
||||
<p className="text-sm">Чаты не найдены</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
97
lastochka-ui/src/components/chat/FullscreenImageViewer.tsx
Normal file
97
lastochka-ui/src/components/chat/FullscreenImageViewer.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { X, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
|
||||
interface FullscreenImageViewerProps {
|
||||
url: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function FullscreenImageViewer({ url, onClose }: FullscreenImageViewerProps) {
|
||||
const [scale, setScale] = useState(1)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
|
||||
const imgRef = useRef<HTMLImageElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
return () => document.removeEventListener('keydown', handleEsc)
|
||||
}, [onClose])
|
||||
|
||||
const lastTouchDistance = useRef<number>(0)
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
if (e.touches.length === 2) {
|
||||
const dx = e.touches[0].clientX - e.touches[1].clientX
|
||||
const dy = e.touches[0].clientY - e.touches[1].clientY
|
||||
lastTouchDistance.current = Math.sqrt(dx * dx + dy * dy)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
if (e.touches.length === 2) {
|
||||
e.preventDefault()
|
||||
const dx = e.touches[0].clientX - e.touches[1].clientX
|
||||
const dy = e.touches[0].clientY - e.touches[1].clientY
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
if (lastTouchDistance.current > 0) {
|
||||
const ratio = distance / lastTouchDistance.current
|
||||
setScale((prev) => Math.min(Math.max(prev * ratio, 1), 5))
|
||||
}
|
||||
lastTouchDistance.current = distance
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleTouchEnd = useCallback(() => { lastTouchDistance.current = 0 }, [])
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (scale > 1) { setIsDragging(true); setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y }) }
|
||||
}, [scale, position])
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (isDragging) setPosition({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y })
|
||||
}, [isDragging, dragStart])
|
||||
|
||||
const handleMouseUp = useCallback(() => { setIsDragging(false) }, [])
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
setScale((prev) => (prev === 1 ? 2 : prev === 2 ? 3 : prev === 3 ? 1 : 1))
|
||||
setPosition({ x: 0, y: 0 })
|
||||
}, [])
|
||||
|
||||
const zoomIn = () => setScale((prev) => Math.min(prev + 0.5, 5))
|
||||
const zoomOut = () => {
|
||||
setScale((prev) => { const next = Math.max(prev - 0.5, 1); if (next === 1) setPosition({ x: 0, y: 0 }); return next })
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="fixed inset-0 z-50 flex items-center justify-center bg-black/95 backdrop-blur-sm animate-fade-in cursor-zoom-out" onClick={onClose}>
|
||||
<button onClick={onClose} className="absolute top-5 right-5 z-10 p-2.5 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-full text-white transition-all duration-200 active:scale-90">
|
||||
<X size={22} />
|
||||
</button>
|
||||
|
||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2 bg-white/10 backdrop-blur-xl rounded-2xl px-4 py-2.5 border border-white/10">
|
||||
<button onClick={(e) => { e.stopPropagation(); zoomOut() }} className="p-2 text-white/70 hover:text-white hover:bg-white/10 rounded-xl transition-all active:scale-90"><ZoomOut size={18} /></button>
|
||||
<span className="text-white/90 text-sm font-medium min-w-[50px] text-center tabular-nums">{Math.round(scale * 100)}%</span>
|
||||
<button onClick={(e) => { e.stopPropagation(); zoomIn() }} className="p-2 text-white/70 hover:text-white hover:bg-white/10 rounded-xl transition-all active:scale-90"><ZoomIn size={18} /></button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative flex items-center justify-center overflow-hidden w-full h-full"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd}
|
||||
onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={url}
|
||||
alt="Полноэкранный просмотр"
|
||||
className="max-w-[90vw] max-h-[85vh] object-contain rounded-lg shadow-2xl transition-transform duration-200"
|
||||
style={{ transform: `scale(${scale}) translate(${position.x / scale}px, ${position.y / scale}px)`, cursor: scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'zoom-out' }}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
82
lastochka-ui/src/components/chat/InlineEditBar.tsx
Normal file
82
lastochka-ui/src/components/chat/InlineEditBar.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useState, useRef, useEffect, type KeyboardEvent } from 'react'
|
||||
import { useChatStore } from '@/store/chat'
|
||||
import { X, Check } from 'lucide-react'
|
||||
|
||||
export default function InlineEditBar() {
|
||||
const { editingMessage, submitEdit, cancelEditing } = useChatStore()
|
||||
const [text, setText] = useState('')
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Sync text when editing message changes
|
||||
useEffect(() => {
|
||||
if (editingMessage) {
|
||||
setText(editingMessage.text)
|
||||
// Focus input after mount
|
||||
requestAnimationFrame(() => inputRef.current?.focus())
|
||||
}
|
||||
}, [editingMessage?.id])
|
||||
|
||||
if (!editingMessage) return null
|
||||
|
||||
const handleSubmit = () => {
|
||||
submitEdit(text)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
cancelEditing()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-brand/30 dark:border-brand-dark/30 bg-brand/5 dark:bg-brand-dark/5">
|
||||
{/* Edit header */}
|
||||
<div className="flex items-center gap-3 px-4 py-2 border-b border-brand/10 dark:border-brand-dark/10">
|
||||
<div className="w-1 h-8 rounded-full bg-brand flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-brand font-medium">Редактирование сообщения</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate opacity-70">
|
||||
{editingMessage.text}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={cancelEditing}
|
||||
className="p-1 hover:bg-brand/10 dark:hover:bg-brand-dark/10 rounded-full transition-colors"
|
||||
>
|
||||
<X size={16} className="text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Edit input */}
|
||||
<div className="flex items-end gap-2 px-4 py-3">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
rows={1}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={() => {
|
||||
const ta = inputRef.current
|
||||
if (ta) {
|
||||
ta.style.height = 'auto'
|
||||
ta.style.height = Math.min(ta.scrollHeight, 150) + 'px'
|
||||
}
|
||||
}}
|
||||
placeholder="Отредактируйте сообщение"
|
||||
className="flex-1 bg-input-field dark:bg-input-field-dark rounded-2xl px-3 py-2 resize-none outline-none text-[14.5px] text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 leading-relaxed max-h-[150px] overflow-y-auto scrollbar-none"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!text.trim()}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-brand hover:bg-brand-dark disabled:opacity-50 disabled:cursor-not-allowed transition-all text-white flex-shrink-0 shadow-bubble"
|
||||
>
|
||||
<Check size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
148
lastochka-ui/src/components/chat/MessageBubble.tsx
Normal file
148
lastochka-ui/src/components/chat/MessageBubble.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useState } from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import clsx from 'clsx'
|
||||
import Icon from '@/components/ui/Icon'
|
||||
import type { Message } from '@/types'
|
||||
import FullscreenImageViewer from './FullscreenImageViewer'
|
||||
import VoiceMessage from './VoiceMessage'
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message
|
||||
isOwn: boolean
|
||||
showTail?: boolean
|
||||
onRightClick?: (e: React.MouseEvent, msg: Message) => void
|
||||
}
|
||||
|
||||
export default function MessageBubble({ message, isOwn, showTail, onRightClick }: MessageBubbleProps) {
|
||||
const [showFullscreen, setShowFullscreen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={clsx('flex', isOwn ? 'justify-end' : 'justify-start')}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
onRightClick?.(e, message)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'relative max-w-[65%] min-w-[80px] px-3.5 pt-2.5 pb-2 animate-slide-up',
|
||||
isOwn
|
||||
? 'bg-gradient-to-br from-bubble-own to-[#e8f7d8] dark:from-[#2b5278] dark:to-[#254a6e] rounded-bubble rounded-br-bubble-sm shadow-sm'
|
||||
: 'bg-white dark:bg-[#182533] rounded-bubble rounded-bl-bubble-sm shadow-sm border border-gray-100/50 dark:border-gray-700/30',
|
||||
showTail && isOwn && 'rounded-br-none',
|
||||
showTail && !isOwn && 'rounded-bl-none',
|
||||
)}
|
||||
>
|
||||
{/* Bubble tail */}
|
||||
{showTail && (
|
||||
<span
|
||||
className={clsx(
|
||||
'absolute bottom-0 w-3 h-3',
|
||||
isOwn
|
||||
? 'right-[-6px] text-bubble-own dark:text-[#2b5278]'
|
||||
: 'left-[-6px] text-white dark:text-[#182533]',
|
||||
)}
|
||||
>
|
||||
<svg viewBox="0 0 11 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="11" height="20" className={clsx(isOwn ? '' : 'scale-x-[-1]')}>
|
||||
<path d="M10 0 Q10 20 0 20 Q5 20 10 10 Z" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Reply Quote */}
|
||||
{message.replyTo && (
|
||||
<div className="mb-2 pl-2.5 py-1.5 pr-1.5 rounded-lg border-l-[3px] border-brand/50 bg-brand/5 dark:bg-brand/10">
|
||||
<p className="text-[11px] text-brand font-semibold truncate">{message.replyTo.senderName}</p>
|
||||
<p className="text-[11px] text-gray-500 dark:text-gray-400 truncate">{message.replyTo.text}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Forwarded indicator */}
|
||||
{message.text.startsWith('⟮ переслано от') && (
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[11px] text-blue-500 dark:text-blue-400 font-medium">
|
||||
<svg viewBox="0 0 24 24" fill="none" className="w-3.5 h-3.5" stroke="currentColor" strokeWidth="2.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5-5 5M6 12h12" />
|
||||
</svg>
|
||||
<span>Переслано</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Voice message */}
|
||||
{message.attachments?.[0]?.type === 'audio' && message.attachments[0].url && (
|
||||
<VoiceMessage
|
||||
audioUrl={message.attachments[0].url}
|
||||
duration={message.duration ?? 0}
|
||||
isOwn={isOwn}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Image */}
|
||||
{message.imageUrl && (
|
||||
<div
|
||||
className="mb-2 rounded-xl overflow-hidden cursor-pointer group relative"
|
||||
onClick={() => setShowFullscreen(true)}
|
||||
>
|
||||
<img
|
||||
src={message.imageUrl}
|
||||
alt="Изображение"
|
||||
className="max-w-[280px] max-h-[280px] object-contain rounded-xl transition-transform duration-200 group-hover:scale-[1.02]"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors rounded-xl flex items-center justify-center opacity-0 group-hover:opacity-100">
|
||||
<div className="w-10 h-10 rounded-full bg-white/80 backdrop-blur-sm flex items-center justify-center shadow-lg">
|
||||
<svg viewBox="0 0 24 24" fill="none" className="w-5 h-5 text-gray-700" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload progress */}
|
||||
{message.uploadProgress !== undefined && message.uploadProgress < 1 && (
|
||||
<div className="mb-2 w-full bg-gray-200/50 dark:bg-gray-700/50 rounded-full h-1 overflow-hidden">
|
||||
<div className="h-full bg-brand rounded-full transition-all duration-500 ease-out" style={{ width: `${message.uploadProgress * 100}%` }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload failed */}
|
||||
{message.uploadFailed && (
|
||||
<p className="text-[11px] text-red-500 mb-1 font-medium">Ошибка отправки</p>
|
||||
)}
|
||||
|
||||
{/* Text content */}
|
||||
{message.text && !message.text.startsWith('⟮ переслано от') && (
|
||||
<p className="text-[14px] leading-relaxed text-gray-900 dark:text-gray-100 break-words whitespace-pre-wrap">
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Time + read status */}
|
||||
<div className={clsx('flex items-center gap-1 float-right ml-2.5 -mb-0.5 relative', 'top-0.5')}>
|
||||
{message.edited && (
|
||||
<span className="text-[10px] text-gray-400/80 dark:text-gray-500/80 mr-0.5 font-medium">ред.</span>
|
||||
)}
|
||||
<span className="text-[11px] text-gray-400/80 dark:text-gray-500/80 whitespace-nowrap tabular-nums">
|
||||
{format(message.ts, 'HH:mm')}
|
||||
</span>
|
||||
{isOwn && (
|
||||
<span className={clsx('transition-colors', message.read ? 'text-brand' : 'text-gray-300 dark:text-gray-600')}>
|
||||
<Icon name={message.read ? 'check_all' : 'check'} size={15} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen image viewer */}
|
||||
{showFullscreen && message.imageUrl && (
|
||||
<FullscreenImageViewer
|
||||
url={message.imageUrl}
|
||||
onClose={() => setShowFullscreen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
77
lastochka-ui/src/components/chat/MessageContextMenu.tsx
Normal file
77
lastochka-ui/src/components/chat/MessageContextMenu.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useChatStore } from '@/store/chat'
|
||||
import { Copy, Reply, Edit3, Trash2, Share2 } from 'lucide-react'
|
||||
import type { Message } from '@/types'
|
||||
|
||||
interface MessageContextMenuProps {
|
||||
message: Message
|
||||
position: { x: number; y: number }
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function MessageContextMenu({ message, position, onClose }: MessageContextMenuProps) {
|
||||
const { setReplyTo, setContextMenuMessage, deleteMessage, startEditing, startForward } = useChatStore()
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const isOwn = message.senderId === 'me'
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
return () => document.removeEventListener('keydown', handleEsc)
|
||||
}, [onClose])
|
||||
|
||||
const handleReply = () => { setReplyTo(message); setContextMenuMessage(null); onClose() }
|
||||
const handleCopy = () => { navigator.clipboard.writeText(message.text); setContextMenuMessage(null); onClose() }
|
||||
const handleEdit = () => { startEditing(message); onClose() }
|
||||
const handleForward = () => { startForward(message); onClose() }
|
||||
const handleDelete = () => { if (confirm('Удалить сообщение?')) deleteMessage(message); onClose() }
|
||||
|
||||
const menuWidth = 210
|
||||
const menuHeight = isOwn ? 210 : 170
|
||||
const x = Math.min(position.x, window.innerWidth - menuWidth - 12)
|
||||
const y = Math.min(position.y, window.innerHeight - menuHeight - 12)
|
||||
|
||||
const menuItemClass = "w-full flex items-center gap-3 px-4 py-2.5 text-[13px] text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors rounded-lg"
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-50 bg-white/95 dark:bg-[#1c2733]/95 backdrop-blur-xl rounded-xl shadow-glass-lg border border-gray-100/50 dark:border-gray-700/50 py-1.5 px-1 animate-scale-in"
|
||||
style={{ left: x, top: y }}
|
||||
>
|
||||
<button onClick={handleReply} className={menuItemClass}>
|
||||
<Reply size={16} className="text-brand" />
|
||||
<span className="font-medium">Ответить</span>
|
||||
</button>
|
||||
<button onClick={handleCopy} className={menuItemClass}>
|
||||
<Copy size={16} className="text-gray-400" />
|
||||
<span className="font-medium">Копировать</span>
|
||||
</button>
|
||||
<button onClick={handleForward} className={menuItemClass}>
|
||||
<Share2 size={16} className="text-blue-500" />
|
||||
<span className="font-medium">Переслать</span>
|
||||
</button>
|
||||
<div className="h-px bg-gray-100 dark:bg-gray-700/50 mx-3 my-1" />
|
||||
{isOwn && (
|
||||
<>
|
||||
<button onClick={handleEdit} className={menuItemClass}>
|
||||
<Edit3 size={16} className="text-amber-500" />
|
||||
<span className="font-medium">Редактировать</span>
|
||||
</button>
|
||||
<button onClick={handleDelete} className="w-full flex items-center gap-3 px-4 py-2.5 text-[13px] text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors rounded-lg font-medium">
|
||||
<Trash2 size={16} />
|
||||
<span>Удалить</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
243
lastochka-ui/src/components/chat/MessageInput.tsx
Normal file
243
lastochka-ui/src/components/chat/MessageInput.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useState, useRef, type KeyboardEvent, useEffect, useCallback } from 'react'
|
||||
import { useChatStore } from '@/store/chat'
|
||||
import Icon from '@/components/ui/Icon'
|
||||
import { X, Image as ImageIcon, Mic, Send, Square } from 'lucide-react'
|
||||
import { useVoiceRecorder, formatDuration } from '@/hooks/useVoiceRecorder'
|
||||
|
||||
export default function MessageInput() {
|
||||
const [text, setText] = useState('')
|
||||
const {
|
||||
activeChatId,
|
||||
sendMessage,
|
||||
sendTypingNotification,
|
||||
replyToMessage,
|
||||
setReplyTo,
|
||||
sendImageMessage,
|
||||
setImagePreview,
|
||||
imagePreview,
|
||||
isSendingImage,
|
||||
imageUploadProgress,
|
||||
sendVoiceMessage,
|
||||
cancelVoiceRecording,
|
||||
} = useChatStore()
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const selectedFileRef = useRef<File | null>(null)
|
||||
|
||||
const {
|
||||
isRecording,
|
||||
duration,
|
||||
blob: voiceBlob,
|
||||
audioUrl: voiceAudioUrl,
|
||||
error: voiceError,
|
||||
startRecording,
|
||||
stopRecording: stopRecorder,
|
||||
cancelRecording,
|
||||
} = useVoiceRecorder()
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (voiceAudioUrl && voiceBlob) {
|
||||
// Recording finished
|
||||
}
|
||||
}, [voiceAudioUrl, voiceBlob])
|
||||
|
||||
const handleSend = () => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed || !activeChatId) return
|
||||
sendMessage(trimmed)
|
||||
setText('')
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
const handleInput = () => {
|
||||
const ta = textareaRef.current
|
||||
if (!ta) return
|
||||
ta.style.height = 'auto'
|
||||
ta.style.height = Math.min(ta.scrollHeight, 150) + 'px'
|
||||
if (activeChatId) {
|
||||
sendTypingNotification(activeChatId)
|
||||
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current)
|
||||
typingTimeoutRef.current = setTimeout(() => { typingTimeoutRef.current = null }, 500)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file || !activeChatId) return
|
||||
if (!file.type.startsWith('image/')) return
|
||||
selectedFileRef.current = file
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => setImagePreview(event.target?.result as string)
|
||||
reader.readAsDataURL(file)
|
||||
}, [activeChatId, setImagePreview])
|
||||
|
||||
const handleSendImage = useCallback(() => {
|
||||
const file = selectedFileRef.current
|
||||
if (!file) return
|
||||
sendImageMessage(file)
|
||||
selectedFileRef.current = null
|
||||
setImagePreview(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}, [sendImageMessage, setImagePreview])
|
||||
|
||||
const handleCancelImage = useCallback(() => {
|
||||
setImagePreview(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}, [setImagePreview])
|
||||
|
||||
const handleSendVoice = useCallback(() => {
|
||||
sendVoiceMessage()
|
||||
}, [sendVoiceMessage])
|
||||
|
||||
const handleCancelVoice = useCallback(() => {
|
||||
cancelRecording()
|
||||
cancelVoiceRecording()
|
||||
}, [cancelRecording, cancelVoiceRecording])
|
||||
|
||||
const handleMicClick = useCallback(() => {
|
||||
if (isRecording) stopRecorder()
|
||||
else startRecording()
|
||||
}, [isRecording, startRecording, stopRecorder])
|
||||
|
||||
const hasContent = text.trim() || imagePreview || voiceAudioUrl || isRecording
|
||||
|
||||
return (
|
||||
<div className="flex-shrink-0 bg-white/90 dark:bg-[#17212b] backdrop-blur-xl border-t border-gray-200/50 dark:border-gray-800/50">
|
||||
{/* Voice Recording / Preview */}
|
||||
{(isRecording || voiceAudioUrl) && (
|
||||
<div className="px-4 py-3 border-b border-gray-100/50 dark:border-gray-800/50">
|
||||
{isRecording && (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<div className="w-11 h-11 rounded-full bg-red-500 flex items-center justify-center shadow-lg shadow-red-500/30">
|
||||
<Mic size={20} className="text-white" />
|
||||
</div>
|
||||
<div className="absolute inset-0 rounded-full bg-red-500/30 animate-ping" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">Запись голосового...</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 font-mono">{formatDuration(duration)}</p>
|
||||
</div>
|
||||
<button onClick={cancelRecording} className="p-2 text-gray-400 hover:text-red-500 transition-colors">
|
||||
<X size={18} />
|
||||
</button>
|
||||
<button onClick={stopRecorder} className="w-11 h-11 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center text-white transition-all shadow-lg shadow-red-500/30 active:scale-90">
|
||||
<Square size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isRecording && voiceAudioUrl && (
|
||||
<div className="flex items-center gap-3">
|
||||
<audio src={voiceAudioUrl} controls className="flex-1 h-10 rounded-lg" />
|
||||
<button onClick={handleSendVoice} className="w-11 h-11 rounded-full bg-brand hover:bg-brand-dark flex items-center justify-center text-white transition-all shadow-lg shadow-brand/25 active:scale-90">
|
||||
<Send size={18} />
|
||||
</button>
|
||||
<button onClick={handleCancelVoice} className="p-2 text-gray-400 hover:text-red-500 transition-colors">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{voiceError && <p className="text-sm text-red-500">{voiceError}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image Preview */}
|
||||
{imagePreview && !voiceAudioUrl && !isRecording && (
|
||||
<div className="relative px-4 py-3 border-b border-gray-100/50 dark:border-gray-800/50">
|
||||
<div className="relative inline-block group">
|
||||
<img src={imagePreview} alt="Превью" className="max-h-[200px] max-w-[300px] rounded-xl object-contain border border-gray-200/50 dark:border-gray-700/50 shadow-sm" />
|
||||
<button onClick={handleCancelImage} className="absolute -top-2 -right-2 w-7 h-7 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white shadow-lg transition-all active:scale-90">
|
||||
<X size={14} />
|
||||
</button>
|
||||
{isSendingImage && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-xl backdrop-blur-sm">
|
||||
<div className="text-white text-center">
|
||||
<div className="w-10 h-10 border-3 border-white/30 border-t-white rounded-full animate-spin mx-auto mb-2" />
|
||||
<p className="text-xs font-medium">{Math.round(imageUploadProgress * 100)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isSendingImage && (
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button onClick={handleSendImage} className="px-5 py-2 bg-brand hover:bg-brand-dark text-white text-sm font-medium rounded-xl transition-all shadow-lg shadow-brand/25 active:scale-95">
|
||||
Отправить
|
||||
</button>
|
||||
<button onClick={handleCancelImage} className="px-5 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 text-sm font-medium rounded-xl transition-all active:scale-95">
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reply Preview */}
|
||||
{replyToMessage && !imagePreview && !voiceAudioUrl && !isRecording && (
|
||||
<div className="flex items-center gap-3 px-4 py-2 border-b border-gray-100/50 dark:border-gray-800/50">
|
||||
<div className="w-1 h-9 rounded-full bg-brand flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[11px] text-brand font-semibold truncate">{replyToMessage.senderName || 'Пользователь'}</p>
|
||||
<p className="text-[12px] text-gray-500 dark:text-gray-400 truncate">{replyToMessage.text}</p>
|
||||
</div>
|
||||
<button onClick={() => setReplyTo(null)} className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors">
|
||||
<X size={16} className="text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
<div className="flex items-end gap-2 px-3 py-3">
|
||||
<button className="w-10 h-10 flex items-center justify-center rounded-xl text-gray-400 dark:text-gray-500 hover:text-brand hover:bg-brand/5 dark:hover:bg-brand/10 transition-all duration-200 flex-shrink-0 active:scale-90">
|
||||
<Icon name="emoji" size={21} />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 bg-gray-50/80 dark:bg-gray-800/50 border border-gray-200/50 dark:border-gray-700/50 rounded-2xl px-4 py-2.5 flex items-end gap-2 focus-within:border-brand/50 focus-within:bg-white dark:focus-within:bg-gray-800 focus-within:shadow-sm transition-all duration-200">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
placeholder="Сообщение"
|
||||
className="flex-1 bg-transparent resize-none outline-none text-[14px] text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 leading-relaxed max-h-[150px] overflow-y-auto scrollbar-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-8 h-8 flex items-center justify-center text-gray-400 dark:text-gray-500 hover:text-brand transition-colors flex-shrink-0 rounded-lg hover:bg-brand/5 transition-all active:scale-90"
|
||||
>
|
||||
<ImageIcon size={19} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleFileSelect} className="hidden" />
|
||||
|
||||
<button
|
||||
onClick={hasContent ? (text.trim() ? handleSend : handleMicClick) : undefined}
|
||||
className={`w-11 h-11 flex items-center justify-center rounded-xl transition-all duration-200 flex-shrink-0 active:scale-90 ${
|
||||
hasContent
|
||||
? 'bg-brand hover:bg-brand-dark text-white shadow-lg shadow-brand/30'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500'
|
||||
}`}
|
||||
title={text.trim() ? 'Отправить' : isRecording ? 'Остановить запись' : 'Голосовое сообщение'}
|
||||
>
|
||||
{isRecording ? <Square size={18} /> : text.trim() ? <Icon name="send" size={19} /> : <Mic size={19} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
123
lastochka-ui/src/components/chat/MessagesList.tsx
Normal file
123
lastochka-ui/src/components/chat/MessagesList.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { isToday, isYesterday, format } from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
import { ArrowDown } from 'lucide-react'
|
||||
import { useChatStore } from '@/store/chat'
|
||||
import MessageBubble from './MessageBubble'
|
||||
import type { Message } from '@/types'
|
||||
|
||||
function dateDivider(date: Date) {
|
||||
if (isToday(date)) return 'Сегодня'
|
||||
if (isYesterday(date)) return 'Вчера'
|
||||
return format(date, 'd MMMM yyyy', { locale: ru })
|
||||
}
|
||||
|
||||
function isSameDay(a: Date, b: Date) {
|
||||
return a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
}
|
||||
|
||||
export default function MessagesList() {
|
||||
const { messages, activeChatId, hasMoreMessages, isLoadingMoreMessages, loadMoreMessages } = useChatStore()
|
||||
|
||||
const rawMsgs: Message[] = activeChatId ? (messages[activeChatId] ?? []) : []
|
||||
const msgs = [...rawMsgs].sort((a, b) => {
|
||||
const aSeq = a.seq ?? 0
|
||||
const bSeq = b.seq ?? 0
|
||||
return aSeq - bSeq
|
||||
})
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const isAtBottomRef = useRef(true)
|
||||
const prevCountRef = useRef(msgs.length)
|
||||
|
||||
useEffect(() => {
|
||||
if (msgs.length === 0 || !containerRef.current) return
|
||||
const newMessages = msgs.length > prevCountRef.current
|
||||
prevCountRef.current = msgs.length
|
||||
if (newMessages && isAtBottomRef.current) {
|
||||
const el = containerRef.current
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
}, [msgs.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
requestAnimationFrame(() => {
|
||||
if (containerRef.current) containerRef.current.scrollTop = containerRef.current.scrollHeight
|
||||
})
|
||||
isAtBottomRef.current = true
|
||||
prevCountRef.current = msgs.length
|
||||
}, [activeChatId])
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!containerRef.current) return
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
isAtBottomRef.current = distanceFromBottom < 100
|
||||
const threshold = 100
|
||||
if (scrollTop < threshold && !isLoadingMoreMessages) {
|
||||
if (activeChatId && hasMoreMessages[activeChatId]) loadMoreMessages()
|
||||
}
|
||||
}, [activeChatId, hasMoreMessages, isLoadingMoreMessages, loadMoreMessages])
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (containerRef.current) containerRef.current.scrollTop = containerRef.current.scrollHeight
|
||||
isAtBottomRef.current = true
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full overflow-y-auto px-3 py-4 space-y-1 chat-bg"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{isLoadingMoreMessages && (
|
||||
<div className="flex justify-center my-4">
|
||||
<div className="flex items-center gap-2 px-4 py-2 rounded-full bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm shadow-sm">
|
||||
<div className="w-4 h-4 border-2 border-brand/30 border-t-brand rounded-full animate-spin" />
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">Загрузка...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msgs.map((msg, i) => {
|
||||
const isOwn = msg.senderId === 'me'
|
||||
const prev = msgs[i - 1]
|
||||
const showDate = !prev || !isSameDay(prev.ts, msg.ts)
|
||||
const next = msgs[i + 1]
|
||||
const showTail = !next || next.senderId !== msg.senderId
|
||||
|
||||
return (
|
||||
<div key={msg.id}>
|
||||
{showDate && (
|
||||
<div className="flex justify-center my-4">
|
||||
<span className="text-[11px] font-semibold text-gray-500/80 dark:text-gray-400/80 bg-white/80 dark:bg-gray-800/60 backdrop-blur-sm px-3.5 py-1.5 rounded-full shadow-sm">
|
||||
{dateDivider(msg.ts)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<MessageBubble
|
||||
message={msg}
|
||||
isOwn={isOwn}
|
||||
showTail={showTail}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Scroll-to-bottom FAB with glass effect */}
|
||||
{!isAtBottomRef.current && (
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
className="absolute bottom-5 right-5 w-10 h-10 bg-white/90 dark:bg-gray-800/90 backdrop-blur-xl rounded-full shadow-fab border border-gray-200/50 dark:border-gray-700/50 flex items-center justify-center hover:bg-white dark:hover:bg-gray-700 transition-all duration-200 z-10 active:scale-90 animate-bounce-in"
|
||||
>
|
||||
<ArrowDown size={18} className="text-gray-600 dark:text-gray-300" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
103
lastochka-ui/src/components/chat/VoiceMessage.tsx
Normal file
103
lastochka-ui/src/components/chat/VoiceMessage.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Play, Pause, Square } from 'lucide-react'
|
||||
import { formatDuration } from '@/hooks/useVoiceRecorder'
|
||||
|
||||
interface VoiceMessageProps {
|
||||
audioUrl: string
|
||||
duration: number // seconds
|
||||
isOwn: boolean
|
||||
}
|
||||
|
||||
export default function VoiceMessage({ audioUrl, duration, isOwn }: VoiceMessageProps) {
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
|
||||
const onTimeUpdate = () => setCurrentTime(audio.currentTime)
|
||||
const onEnded = () => {
|
||||
setIsPlaying(false)
|
||||
setCurrentTime(0)
|
||||
}
|
||||
|
||||
audio.addEventListener('timeupdate', onTimeUpdate)
|
||||
audio.addEventListener('ended', onEnded)
|
||||
return () => {
|
||||
audio.removeEventListener('timeupdate', onTimeUpdate)
|
||||
audio.removeEventListener('ended', onEnded)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const togglePlay = () => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
|
||||
if (isPlaying) {
|
||||
audio.pause()
|
||||
setIsPlaying(false)
|
||||
} else {
|
||||
audio.play()
|
||||
setIsPlaying(true)
|
||||
}
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
audio.pause()
|
||||
audio.currentTime = 0
|
||||
setIsPlaying(false)
|
||||
setCurrentTime(0)
|
||||
}
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 min-w-[200px] max-w-[280px]">
|
||||
<audio ref={audioRef} src={audioUrl} preload="metadata" />
|
||||
|
||||
{/* Play/Pause button */}
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className={`w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 transition-colors ${
|
||||
isOwn
|
||||
? 'bg-brand/20 hover:bg-brand/30 text-brand'
|
||||
: 'bg-black/10 dark:bg-white/10 hover:bg-black/15 dark:hover:bg-white/15 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{isPlaying ? <Pause size={16} /> : <Play size={16} className="ml-0.5" />}
|
||||
</button>
|
||||
|
||||
{/* Progress bar + time */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`h-1.5 rounded-full overflow-hidden ${isOwn ? 'bg-black/10' : 'bg-black/10 dark:bg-white/10'}`}>
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-200 ${isOwn ? 'bg-brand' : 'bg-brand dark:bg-brand-dark'}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="text-[10px] text-gray-500 dark:text-gray-400">
|
||||
{formatDuration(Math.floor(currentTime))}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500">
|
||||
{formatDuration(duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stop button */}
|
||||
{isPlaying && (
|
||||
<button
|
||||
onClick={stop}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<Square size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
258
lastochka-ui/src/components/layout/Sidebar.tsx
Normal file
258
lastochka-ui/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
MessageCircle,
|
||||
Users,
|
||||
Search,
|
||||
Plus,
|
||||
Settings,
|
||||
Volume2,
|
||||
VolumeX
|
||||
} from 'lucide-react'
|
||||
import { useChatStore } from '@/store/chat'
|
||||
import { useGroupsStore } from '@/store/groups'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import ChatItem from '../sidebar/ChatItem'
|
||||
import CreateGroupModal from '../ui/CreateGroupModal'
|
||||
import SettingsScreen from '../ui/SettingsScreen'
|
||||
import type { User } from '@/types'
|
||||
|
||||
type TabType = 'chats' | 'groups'
|
||||
|
||||
export default function Sidebar() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('chats')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [searchResults, setSearchResults] = useState<User[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
|
||||
const { activeChatId, setActiveChat, chats, toggleSound, playSound } = useChatStore()
|
||||
const { groups, searchUsersForInvite } = useGroupsStore()
|
||||
const { displayName, avatar } = useAuthStore()
|
||||
|
||||
// FND поиск пользователей с debounce
|
||||
useEffect(() => {
|
||||
if (searchQuery.length < 2) {
|
||||
setSearchResults([])
|
||||
return
|
||||
}
|
||||
setIsSearching(true)
|
||||
const timer = setTimeout(async () => {
|
||||
const results = await searchUsersForInvite(searchQuery)
|
||||
setSearchResults(results)
|
||||
setIsSearching(false)
|
||||
}, 400)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchQuery, searchUsersForInvite])
|
||||
|
||||
const filteredChats = chats.filter(chat =>
|
||||
chat.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
const filteredGroups = groups.filter(group =>
|
||||
group.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
const tabs = [
|
||||
{ id: 'chats' as TabType, label: 'Чаты', icon: MessageCircle, count: chats.reduce((acc, c) => acc + (c.unread || 0), 0) },
|
||||
{ id: 'groups' as TabType, label: 'Группы', icon: Users, count: groups.length },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sidebar with glass effect */}
|
||||
<aside className="flex flex-col w-full md:w-80 lg:w-96 h-full bg-white/95 dark:bg-[#17212b] backdrop-blur-xl border-r border-gray-200/50 dark:border-gray-800/50">
|
||||
{/* Header with gradient border */}
|
||||
<div className="p-4 border-b border-gray-100/80 dark:border-gray-800/80">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand to-brand-dark flex items-center justify-center text-white font-bold overflow-hidden shadow-lg shadow-brand/20">
|
||||
{avatar
|
||||
? <img src={avatar} alt={displayName || ''} className="w-full h-full object-cover" />
|
||||
: displayName?.charAt(0).toUpperCase() || 'U'}
|
||||
</div>
|
||||
<div className="absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 bg-green-500 border-2 border-white dark:border-[#17212b] rounded-full online-pulse" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-[15px] text-gray-900 dark:text-gray-100">{displayName || 'Пользователь'}</p>
|
||||
<p className="text-[11px] text-green-500 font-medium tracking-wide">Онлайн</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{[
|
||||
{ icon: playSound ? Volume2 : VolumeX, title: playSound ? 'Отключить звук' : 'Включить звук', action: toggleSound },
|
||||
{ icon: Settings, title: 'Настройки', action: () => setShowSettings(true) },
|
||||
].map(({ icon: Icon, title, action }) => (
|
||||
<button
|
||||
key={title}
|
||||
onClick={action}
|
||||
className="p-2 rounded-lg text-gray-400 dark:text-gray-500 hover:text-brand dark:hover:text-brand hover:bg-brand/5 dark:hover:bg-brand/10 transition-all duration-200"
|
||||
title={title}
|
||||
>
|
||||
<Icon size={19} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search with modern styling */}
|
||||
<div className="relative group">
|
||||
<Search size={17} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500 group-focus-within:text-brand transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full h-11 pl-11 pr-4 rounded-xl bg-gray-50/80 dark:bg-[#242f3d]/60 border border-gray-200/50 dark:border-gray-700/50 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 outline-none focus:ring-2 focus:ring-brand/30 focus:border-brand/50 focus:bg-white dark:focus:bg-[#2a3547] transition-all duration-200 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs with modern pill style */}
|
||||
<div className="flex gap-1.5 p-3 pb-2 border-b border-gray-100/80 dark:border-gray-800/80 flex-shrink-0">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${
|
||||
activeTab === tab.id
|
||||
? 'bg-brand text-white shadow-lg shadow-brand/25 scale-[1.02]'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100/60 dark:hover:bg-[#242f3d]/60'
|
||||
}`}
|
||||
>
|
||||
<tab.icon size={17} />
|
||||
{tab.label}
|
||||
{tab.count > 0 && (
|
||||
<span className={`text-[11px] px-1.5 py-0.5 rounded-full leading-none font-semibold ${
|
||||
activeTab === tab.id
|
||||
? 'bg-white/20 text-white'
|
||||
: 'bg-gray-200/80 dark:bg-gray-700/80 text-gray-600 dark:text-gray-300'
|
||||
}`}>
|
||||
{tab.count > 99 ? '99+' : tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||
{/* Чаты */}
|
||||
{activeTab === 'chats' && (
|
||||
<div className="space-y-0.5">
|
||||
{searchQuery.length >= 2 && (
|
||||
<>
|
||||
{isSearching ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-brand/30 border-t-brand rounded-full animate-spin" />
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-3">Поиск...</p>
|
||||
</div>
|
||||
) : searchResults.length > 0 ? (
|
||||
<>
|
||||
<p className="text-[11px] font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider px-3 py-2">Найденные</p>
|
||||
{searchResults.map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
onClick={() => { setActiveChat(user.id); setSearchQuery('') }}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-gray-100/80 dark:hover:bg-[#242f3d]/60 active:scale-[0.98] transition-all duration-150 text-left"
|
||||
>
|
||||
<div className="w-11 h-11 rounded-full bg-gradient-to-br from-brand to-brand-dark flex items-center justify-center text-white font-semibold flex-shrink-0 shadow-md shadow-brand/20">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100 truncate text-[14px]">{user.name}</p>
|
||||
<p className="text-[11px] text-gray-400 dark:text-gray-500 truncate">{user.id}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400 dark:text-gray-500">
|
||||
<div className="w-14 h-14 rounded-full bg-gray-100 dark:bg-[#242f3d] flex items-center justify-center mb-3">
|
||||
<Search size={24} className="opacity-40" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">Никого не найдено</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{searchQuery.length < 2 && (
|
||||
filteredChats.length > 0 ? (
|
||||
filteredChats.map((chat) => (
|
||||
<ChatItem
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
active={chat.id === activeChatId}
|
||||
onClick={() => setActiveChat(chat.id)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-gray-400 dark:text-gray-500">
|
||||
<div className="w-16 h-16 rounded-full bg-gray-100 dark:bg-[#242f3d] flex items-center justify-center mb-4">
|
||||
<MessageCircle size={28} className="opacity-40" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">Нет чатов</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">Начните общение</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Группы */}
|
||||
{activeTab === 'groups' && (
|
||||
<div className="space-y-0.5">
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl border-2 border-dashed border-gray-200/60 dark:border-gray-700/60 text-gray-400 dark:text-gray-500 hover:border-brand/50 hover:text-brand dark:hover:text-brand hover:bg-brand/5 dark:hover:bg-brand/10 transition-all duration-200 mb-2"
|
||||
>
|
||||
<Plus size={18} />
|
||||
<span className="text-sm font-medium">Создать группу</span>
|
||||
</button>
|
||||
{filteredGroups.length > 0 ? (
|
||||
filteredGroups.map((group) => (
|
||||
<ChatItem
|
||||
key={group.id}
|
||||
chat={{
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
avatar: group.avatar,
|
||||
isGroup: true,
|
||||
membersCount: group.membersCount,
|
||||
}}
|
||||
active={group.id === activeChatId}
|
||||
onClick={() => setActiveChat(group.id)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400 dark:text-gray-500">
|
||||
<div className="w-16 h-16 rounded-full bg-gray-100 dark:bg-[#242f3d] flex items-center justify-center mb-4">
|
||||
<Users size={28} className="opacity-40" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">Нет групп</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Modal для создания группы */}
|
||||
<CreateGroupModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
/>
|
||||
|
||||
{/* Modal настроек with glass effect */}
|
||||
{showSettings && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm" onClick={() => setShowSettings(false)}>
|
||||
<div className="w-full max-w-2xl h-[80vh] md:max-h-[90vh] bg-white dark:bg-[#17212b] rounded-2xl shadow-glass-lg overflow-hidden animate-scale-in" onClick={(e) => e.stopPropagation()}>
|
||||
<SettingsScreen onClose={() => setShowSettings(false)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
131
lastochka-ui/src/components/sidebar/ChatItem.tsx
Normal file
131
lastochka-ui/src/components/sidebar/ChatItem.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { format, isToday, isYesterday } from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
import clsx from 'clsx'
|
||||
import Avatar from '@/components/ui/Avatar'
|
||||
import Icon from '@/components/ui/Icon'
|
||||
import { useChatStore } from '@/store/chat'
|
||||
import { Pin, PinOff, Volume2, VolumeX } from 'lucide-react'
|
||||
import type { Chat } from '@/types'
|
||||
|
||||
interface ChatItemProps {
|
||||
chat: Chat
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function formatTime(date: Date) {
|
||||
if (isToday(date)) return format(date, 'HH:mm')
|
||||
if (isYesterday(date)) return 'вчера'
|
||||
return format(date, 'd MMM', { locale: ru })
|
||||
}
|
||||
|
||||
export default function ChatItem({ chat, active, onClick }: ChatItemProps) {
|
||||
const { toggleMute, togglePin } = useChatStore()
|
||||
const [showContext, setShowContext] = useState(false)
|
||||
const [contextPos, setContextPos] = useState({ x: 0, y: 0 })
|
||||
const ctxRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!showContext) return
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (ctxRef.current && !ctxRef.current.contains(e.target as Node)) {
|
||||
setShowContext(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [showContext])
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setContextPos({ x: e.clientX, y: e.clientY })
|
||||
setShowContext(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={onClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-200 active:scale-[0.98] text-left group',
|
||||
active
|
||||
? 'bg-brand/10 dark:bg-brand/15 text-brand shadow-sm'
|
||||
: 'hover:bg-gray-50/80 dark:hover:bg-gray-800/40 text-gray-900 dark:text-gray-100'
|
||||
)}
|
||||
>
|
||||
<div className="relative flex-shrink-0">
|
||||
<Avatar name={chat.name} src={chat.avatar} size="md" online={chat.online} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{chat.pinned && (
|
||||
<Pin size={13} className={clsx('flex-shrink-0', active ? 'text-brand/60' : 'text-gray-300 dark:text-gray-600')} />
|
||||
)}
|
||||
<span className={clsx('font-semibold truncate text-[14px] leading-tight', active ? 'text-brand' : '')}>
|
||||
{chat.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{chat.muted && (
|
||||
<VolumeX size={13} className={clsx('flex-shrink-0', active ? 'text-brand/60' : 'text-gray-300 dark:text-gray-600')} />
|
||||
)}
|
||||
{chat.lastMessageTs && (
|
||||
<span className={clsx('text-[11px] tabular-nums', active ? 'text-brand/70' : 'text-gray-400 dark:text-gray-500')}>
|
||||
{formatTime(chat.lastMessageTs)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 mt-0.5">
|
||||
<p className={clsx('text-[13px] truncate leading-tight', active ? 'text-brand/70' : 'text-gray-500 dark:text-gray-400')}>
|
||||
{chat.lastMessage ?? ''}
|
||||
</p>
|
||||
{!!chat.unread && chat.unread > 0 && (
|
||||
<span className={clsx(
|
||||
'flex-shrink-0 min-w-[20px] h-5 px-1.5 rounded-full text-[11px] font-bold flex items-center justify-center transition-all',
|
||||
active
|
||||
? 'bg-brand text-white shadow-md shadow-brand/30'
|
||||
: chat.muted
|
||||
? 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
|
||||
: 'bg-brand text-white shadow-md shadow-brand/25'
|
||||
)}>
|
||||
{chat.unread > 99 ? '99+' : chat.unread}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Context menu */}
|
||||
{showContext && (
|
||||
<div
|
||||
ref={ctxRef}
|
||||
className="fixed z-50 bg-white dark:bg-[#1c2733] rounded-xl shadow-glass border border-gray-100 dark:border-gray-700/50 py-1.5 min-w-[180px] animate-scale-in"
|
||||
style={{ left: Math.min(contextPos.x, window.innerWidth - 200), top: Math.min(contextPos.y, window.innerHeight - 130) }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); togglePin(chat.id); setShowContext(false) }}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
{chat.pinned ? <PinOff size={16} className="text-amber-500" /> : <Pin size={16} className="text-brand" />}
|
||||
<span>{chat.pinned ? 'Открепить' : 'Закрепить'}</span>
|
||||
</button>
|
||||
<div className="h-px bg-gray-100 dark:bg-gray-700/50 mx-3 my-1" />
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleMute(chat.id); setShowContext(false) }}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
{chat.muted ? <Volume2 size={16} className="text-brand" /> : <VolumeX size={16} className="text-gray-400" />}
|
||||
<span>{chat.muted ? 'Включить уведомления' : 'Отключить уведомления'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
70
lastochka-ui/src/components/sidebar/ChatList.tsx
Normal file
70
lastochka-ui/src/components/sidebar/ChatList.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useMemo, useEffect, useRef } from 'react'
|
||||
import { useChatStore } from '@/store/chat'
|
||||
import ChatItem from './ChatItem'
|
||||
|
||||
export default function ChatList() {
|
||||
const { chats, activeChatId, setActiveChat, searchQuery, searchUsers, searchResults, isSearching } = useChatStore()
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Debounced fnd search when query >= 3 chars
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
searchUsers(searchQuery)
|
||||
}, 400)
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
|
||||
}, [searchQuery, searchUsers])
|
||||
|
||||
// Local filter — always shown (filtered by name)
|
||||
const filtered = useMemo(() => {
|
||||
const q = searchQuery.toLowerCase()
|
||||
const list = q ? chats.filter((c) => c.name.toLowerCase().includes(q)) : chats
|
||||
return [...list].sort((a, b) => {
|
||||
if (a.pinned && !b.pinned) return -1
|
||||
if (!a.pinned && b.pinned) return 1
|
||||
const ta = a.lastMessageTs?.getTime() ?? 0
|
||||
const tb = b.lastMessageTs?.getTime() ?? 0
|
||||
return tb - ta
|
||||
})
|
||||
}, [chats, searchQuery])
|
||||
|
||||
// fnd results excluding already existing chats
|
||||
const existingIds = useMemo(() => new Set(chats.map((c) => c.id)), [chats])
|
||||
const newResults = useMemo(
|
||||
() => searchResults.filter((r) => !existingIds.has(r.id)),
|
||||
[searchResults, existingIds],
|
||||
)
|
||||
|
||||
const showSearch = searchQuery.length >= 3
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto px-1 py-1 space-y-0.5">
|
||||
{/* Existing chats */}
|
||||
{filtered.map((chat) => (
|
||||
<ChatItem key={chat.id} chat={chat} active={chat.id === activeChatId} onClick={() => setActiveChat(chat.id)} />
|
||||
))}
|
||||
|
||||
{/* Global search results */}
|
||||
{showSearch && (
|
||||
<>
|
||||
{isSearching && (
|
||||
<p className="text-center text-xs text-gray-400 dark:text-gray-500 py-3">Поиск...</p>
|
||||
)}
|
||||
{!isSearching && newResults.length > 0 && (
|
||||
<>
|
||||
<p className="text-xs font-medium text-gray-400 dark:text-gray-500 px-3 pt-3 pb-1 uppercase tracking-wide">
|
||||
Найдено
|
||||
</p>
|
||||
{newResults.map((chat) => (
|
||||
<ChatItem key={chat.id} chat={chat} active={chat.id === activeChatId} onClick={() => setActiveChat(chat.id)} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{!isSearching && filtered.length === 0 && newResults.length === 0 && (
|
||||
<p className="text-center text-sm text-gray-400 dark:text-gray-500 mt-8">Ничего не найдено</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
46
lastochka-ui/src/components/ui/Avatar.tsx
Normal file
46
lastochka-ui/src/components/ui/Avatar.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface AvatarProps {
|
||||
name: string
|
||||
src?: string
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
online?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'bg-[#e17076]', 'bg-[#7bc862]', 'bg-[#65aadd]',
|
||||
'bg-[#a695e7]', 'bg-[#ee7aae]', 'bg-[#faa774]',
|
||||
'bg-[#6ec9cb]', 'bg-[#2AABEE]',
|
||||
]
|
||||
|
||||
function getColor(name: string) {
|
||||
let hash = 0
|
||||
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash)
|
||||
return COLORS[Math.abs(hash) % COLORS.length]
|
||||
}
|
||||
|
||||
function initials(name: string) {
|
||||
const parts = name.trim().split(/\s+/)
|
||||
if (parts.length === 1) return parts[0][0].toUpperCase()
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
||||
}
|
||||
|
||||
export default function Avatar({ name, src, size = 'md', online, className }: AvatarProps) {
|
||||
const sizeClass = { sm: 'w-8 h-8 text-xs', md: 'w-10 h-10 text-sm', lg: 'w-14 h-14 text-base', xl: 'w-32 h-32 text-4xl' }[size]
|
||||
|
||||
return (
|
||||
<div className={clsx('relative flex-shrink-0', className)}>
|
||||
<div className={clsx('rounded-full flex items-center justify-center font-medium text-white select-none', sizeClass, !src && getColor(name))}>
|
||||
{src ? (
|
||||
<img src={src} alt={name} className="w-full h-full rounded-full object-cover" />
|
||||
) : (
|
||||
initials(name)
|
||||
)}
|
||||
</div>
|
||||
{online && (
|
||||
<span className="absolute bottom-0 right-0 w-3 h-3 bg-[#4dcd5e] border-2 border-sidebar dark:border-sidebar-dark rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
189
lastochka-ui/src/components/ui/ContactInfo.tsx
Normal file
189
lastochka-ui/src/components/ui/ContactInfo.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useChatStore } from '@/store/chat'
|
||||
import { useGroupsStore } from '@/store/groups'
|
||||
import Avatar from '@/components/ui/Avatar'
|
||||
import { X, MessageCircle, Phone, Video, User, Trash2, Copy } from 'lucide-react'
|
||||
import { getTinode, contactDisplayName } from '@/lib/tinode-client'
|
||||
|
||||
interface ContactInfoProps {
|
||||
contactId: string // topic name (usrXXX, grpXXX, chnXXX)
|
||||
onClose: () => void
|
||||
onOpenChat?: () => void
|
||||
}
|
||||
|
||||
export default function ContactInfo({ contactId, onClose, onOpenChat }: ContactInfoProps) {
|
||||
const { chats, setActiveChat } = useChatStore()
|
||||
const { selectedGroup, selectGroup } = useGroupsStore()
|
||||
|
||||
const chat = chats.find((c) => c.id === contactId)
|
||||
const isGroup = contactId.startsWith('grp')
|
||||
const isChannel = contactId.startsWith('chn')
|
||||
const isP2P = !isGroup && !isChannel
|
||||
|
||||
// Загружаем данные группы если нужно
|
||||
useEffect(() => {
|
||||
if ((isGroup || isChannel) && !selectedGroup) {
|
||||
selectGroup(contactId)
|
||||
}
|
||||
}, [contactId, isGroup, isChannel])
|
||||
|
||||
const displayName = chat?.name || contactId
|
||||
const avatarUrl = chat?.avatar
|
||||
const online = isP2P ? (chat?.online ?? false) : undefined
|
||||
const membersCount = chat?.membersCount || selectedGroup?.membersCount || 0
|
||||
const description = chat?.description || selectedGroup?.description
|
||||
|
||||
const handleOpenChat = () => {
|
||||
setActiveChat(contactId)
|
||||
onOpenChat?.()
|
||||
}
|
||||
|
||||
const handleCopyId = () => {
|
||||
navigator.clipboard.writeText(contactId)
|
||||
}
|
||||
|
||||
const handleDeleteChat = () => {
|
||||
if (confirm('Удалить чат? Это действие нельзя отменить.')) {
|
||||
const tn = getTinode()
|
||||
const topic = tn.getTopic(contactId)
|
||||
topic.delTopic(true).then(() => {
|
||||
setActiveChat(null)
|
||||
onClose()
|
||||
}).catch((err: unknown) => {
|
||||
console.error('Failed to delete chat', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-white dark:bg-gray-900 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800 sticky top-0 bg-white dark:bg-gray-900 z-10">
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-white">Информация</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} className="text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Profile section */}
|
||||
<div className="flex flex-col items-center py-8 border-b border-gray-200 dark:border-gray-800">
|
||||
<Avatar
|
||||
name={displayName}
|
||||
src={avatarUrl}
|
||||
size="xl"
|
||||
online={online}
|
||||
/>
|
||||
<h3 className="mt-4 text-xl font-semibold text-gray-900 dark:text-white">{displayName}</h3>
|
||||
{online && (
|
||||
<p className="text-sm text-green-500 mt-1">в сети</p>
|
||||
)}
|
||||
{isGroup && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{membersCount} участников
|
||||
</p>
|
||||
)}
|
||||
{isChannel && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{membersCount} подписчиков
|
||||
</p>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2 text-center px-8 max-w-md">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="py-2">
|
||||
{/* Написать */}
|
||||
<button
|
||||
onClick={handleOpenChat}
|
||||
className="w-full flex items-center gap-4 px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<MessageCircle size={22} className="text-brand" />
|
||||
<span className="text-gray-900 dark:text-white">Написать</span>
|
||||
</button>
|
||||
|
||||
{/* Позвонить (stub) */}
|
||||
<button
|
||||
disabled
|
||||
className="w-full flex items-center gap-4 px-6 py-4 opacity-50 cursor-not-allowed"
|
||||
>
|
||||
<Phone size={22} className="text-gray-400" />
|
||||
<span className="text-gray-500 dark:text-gray-400">Позвонить</span>
|
||||
<span className="ml-auto text-xs text-gray-400">скоро</span>
|
||||
</button>
|
||||
|
||||
{/* Видеозвонок (stub) */}
|
||||
<button
|
||||
disabled
|
||||
className="w-full flex items-center gap-4 px-6 py-4 opacity-50 cursor-not-allowed"
|
||||
>
|
||||
<Video size={22} className="text-gray-400" />
|
||||
<span className="text-gray-500 dark:text-gray-400">Видеозвонок</span>
|
||||
<span className="ml-auto text-xs text-gray-400">скоро</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Media, links, files (stub) */}
|
||||
<div className="py-2 border-t border-gray-200 dark:border-gray-800">
|
||||
<div className="px-6 py-4">
|
||||
<h4 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-3">
|
||||
Медиа, ссылки и файлы
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="aspect-square bg-gray-100 dark:bg-gray-800 rounded-lg flex items-center justify-center"
|
||||
>
|
||||
<span className="text-xs text-gray-400">—</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="py-2 border-t border-gray-200 dark:border-gray-800">
|
||||
<div className="px-6 py-4">
|
||||
<h4 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-3">
|
||||
Подробная информация
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<User size={18} className="text-gray-400" />
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">ID</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 font-mono">{contactId}</span>
|
||||
<button
|
||||
onClick={handleCopyId}
|
||||
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors"
|
||||
>
|
||||
<Copy size={14} className="text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Danger zone */}
|
||||
<div className="py-4 border-t border-gray-200 dark:border-gray-800 mt-auto">
|
||||
<button
|
||||
onClick={handleDeleteChat}
|
||||
className="w-full flex items-center justify-center gap-3 px-6 py-3 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
<span>Удалить чат</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
211
lastochka-ui/src/components/ui/CreateGroupModal.tsx
Normal file
211
lastochka-ui/src/components/ui/CreateGroupModal.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useState } from 'react'
|
||||
import { X, Users, Lock, UserPlus } from 'lucide-react'
|
||||
import { useGroupsStore } from '@/store/groups'
|
||||
import UserSearch from './UserSearch'
|
||||
import type { User } from '@/types'
|
||||
|
||||
interface CreateGroupModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function CreateGroupModal({ isOpen, onClose }: CreateGroupModalProps) {
|
||||
const { createGroup } = useGroupsStore()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [selectedMembers, setSelectedMembers] = useState<User[]>([])
|
||||
const [showUserSearch, setShowUserSearch] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) {
|
||||
setError('Введите название')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await createGroup({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
isChannel: false,
|
||||
isPublic: false,
|
||||
members: selectedMembers.map(u => u.id),
|
||||
})
|
||||
|
||||
if (result) {
|
||||
onClose()
|
||||
// Сброс формы
|
||||
setName('')
|
||||
setDescription('')
|
||||
setSelectedMembers([])
|
||||
} else {
|
||||
setError('Ошибка создания. Попробуйте снова.')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Ошибка создания. Попробуйте снова.')
|
||||
console.error('Failed to create:', err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectUser = (user: User) => {
|
||||
if (!selectedMembers.find(m => m.id === user.id)) {
|
||||
setSelectedMembers([...selectedMembers, user])
|
||||
}
|
||||
setShowUserSearch(false)
|
||||
}
|
||||
|
||||
const handleRemoveMember = (userId: string) => {
|
||||
setSelectedMembers(selectedMembers.filter(m => m.id !== userId))
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="w-full max-w-lg bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden">
|
||||
{/* Заголовок */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-brand to-brand-dark flex items-center justify-center text-white">
|
||||
<Users size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-white">Создать группу</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Для общения с друзьями и коллегами</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} className="text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Форма */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Название */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Название <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); setError('') }}
|
||||
placeholder="Название группы"
|
||||
className="w-full h-12 px-4 rounded-xl bg-gray-100 dark:bg-gray-800 border-0 text-gray-900 dark:text-white placeholder-gray-400 outline-none focus:ring-2 focus:ring-brand transition-all"
|
||||
autoFocus
|
||||
maxLength={60}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Описание */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Описание
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Краткое описание"
|
||||
rows={2}
|
||||
maxLength={200}
|
||||
className="w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 border-0 text-gray-900 dark:text-white placeholder-gray-400 outline-none focus:ring-2 focus:ring-brand transition-all resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Участники */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Участники {selectedMembers.length > 0 && `(${selectedMembers.length})`}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUserSearch(true)}
|
||||
className="text-sm text-brand hover:underline flex items-center gap-1"
|
||||
>
|
||||
<UserPlus size={14} />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedMembers.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2 p-3 rounded-xl bg-gray-100 dark:bg-gray-800">
|
||||
{selectedMembers.map((user) => (
|
||||
<span
|
||||
key={user.id}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-white dark:bg-gray-700 rounded-lg text-sm"
|
||||
>
|
||||
<span className="w-5 h-5 rounded-full bg-brand/20 text-brand text-xs flex items-center justify-center font-medium">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="text-gray-900 dark:text-white">{user.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveMember(user.id)}
|
||||
className="hover:text-red-500 transition-colors ml-0.5"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 rounded-xl bg-gray-100 dark:bg-gray-800 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
Участники не выбраны
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ошибка */}
|
||||
{error && (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Кнопки */}
|
||||
<div className="flex items-center justify-end gap-3 px-6 pb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-6 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-colors"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading || !name.trim()}
|
||||
className="px-6 py-2.5 bg-brand hover:bg-brand-dark disabled:opacity-60 disabled:cursor-not-allowed text-white text-sm font-medium rounded-xl transition-colors"
|
||||
>
|
||||
{isLoading ? 'Создание...' : 'Создать группу'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Поиск пользователей */}
|
||||
{showUserSearch && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md bg-white dark:bg-gray-900 rounded-2xl shadow-2xl p-6">
|
||||
<UserSearch
|
||||
onSelect={handleSelectUser}
|
||||
onClose={() => setShowUserSearch(false)}
|
||||
showStartChat={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
48
lastochka-ui/src/components/ui/Icon.tsx
Normal file
48
lastochka-ui/src/components/ui/Icon.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
// Simple SVG icon wrapper — uses Material-style path data inline
|
||||
// Add icons as needed
|
||||
|
||||
interface IconProps {
|
||||
name: keyof typeof ICONS
|
||||
className?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
const ICONS = {
|
||||
search: 'M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z',
|
||||
send: 'M2.01 21L23 12 2.01 3 2 10l15 2-15 2z',
|
||||
more_vert: 'M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z',
|
||||
attach_file: 'M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5S15 16.88 15 15.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z',
|
||||
emoji: 'M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z',
|
||||
check: 'M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z',
|
||||
check_all: 'M18 7l-1.41-1.41-6.34 6.34 1.41 1.41L18 7zm4.24-1.41L11.66 16.17 7.48 12l-1.41 1.41L11.66 19l12-12-1.42-1.41zM.41 13.41L6 19l1.41-1.41L1.83 12 .41 13.41z',
|
||||
pin: 'M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z',
|
||||
volume_off: 'M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z',
|
||||
dark_mode: 'M12 3a9 9 0 1 0 9 9c0-.46-.04-.92-.1-1.36a5.389 5.389 0 0 1-4.4 2.26 5.403 5.403 0 0 1-3.14-9.8c-.44-.06-.9-.1-1.36-.1z',
|
||||
light_mode: 'M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58a.996.996 0 0 0-1.41 0 .996.996 0 0 0 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37a.996.996 0 0 0-1.41 0 .996.996 0 0 0 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0a.996.996 0 0 0 0-1.41l-1.06-1.06zm1.06-10.96a.996.996 0 0 0 0-1.41.996.996 0 0 0-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36a.996.996 0 0 0 0-1.41.996.996 0 0 0-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z',
|
||||
pencil: 'M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z',
|
||||
close: 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z',
|
||||
arrow_back: 'M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z',
|
||||
mic: 'M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.91-3c-.49 0-.9.36-.98.85C16.52 14.2 14.47 16 12 16s-4.52-1.8-4.93-4.15c-.08-.49-.49-.85-.98-.85-.61 0-1.09.54-1 1.14.49 3 2.89 5.35 5.91 5.78V20c0 .55.45 1 1 1s1-.45 1-1v-2.08c3.02-.43 5.42-2.78 5.91-5.78.1-.6-.39-1.14-1-1.14z',
|
||||
settings: 'M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z',
|
||||
group_add: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22zM.05 19H8v-1c0-1.1-.9-2-2-2H4.05c-.55 0-1 .45-1 1v2zm0-6H8v-1c0-1.1-.9-2-2-2H4.05c-.55 0-1 .45-1 1v2zM16 11v2h6v-2h-6zm0-5v2h6V6h-6z',
|
||||
campaign: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z',
|
||||
admin_panel_settings: 'M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 2.18l7 3.12v5.7c0 4.83-3.4 9.36-7 10.53-3.6-1.17-7-5.7-7-10.53V6.3l7-3.12zm2 14.82h-1v-2h-1v-1h1V9h1v5h1v1h-1v2zm-1-9c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1z',
|
||||
logout: 'M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z',
|
||||
}
|
||||
|
||||
export default function Icon({ name, className, size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path d={ICONS[name]} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export type IconName = keyof typeof ICONS
|
||||
269
lastochka-ui/src/components/ui/MembersPanel.tsx
Normal file
269
lastochka-ui/src/components/ui/MembersPanel.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import { useState } from 'react'
|
||||
import { X, UserPlus, Crown, Shield, Pencil, Check } from 'lucide-react'
|
||||
import { useGroupsStore } from '@/store/groups'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import UserSearch from './UserSearch'
|
||||
import type { GroupMember, User } from '@/types'
|
||||
|
||||
interface MembersPanelProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
groupId: string
|
||||
canAddMembers?: boolean
|
||||
canRemoveMembers?: boolean
|
||||
}
|
||||
|
||||
export default function MembersPanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
groupId,
|
||||
canAddMembers = false,
|
||||
canRemoveMembers = false,
|
||||
}: MembersPanelProps) {
|
||||
const { selectedGroup, removeMember, leaveGroup, deleteGroup, addMember, updateGroupInfo } = useGroupsStore()
|
||||
const { userId } = useAuthStore()
|
||||
const [showAddUser, setShowAddUser] = useState(false)
|
||||
const [isEditingName, setIsEditingName] = useState(false)
|
||||
const [editName, setEditName] = useState('')
|
||||
const [editDesc, setEditDesc] = useState('')
|
||||
|
||||
if (!isOpen || !selectedGroup) return null
|
||||
|
||||
const isOwner = selectedGroup.members.some(m => m.userId === userId && m.role === 'owner')
|
||||
|
||||
const handleStartEdit = () => {
|
||||
setEditName(selectedGroup.name)
|
||||
setEditDesc(selectedGroup.description || '')
|
||||
setIsEditingName(true)
|
||||
}
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (editName.trim()) {
|
||||
await updateGroupInfo(groupId, editName.trim(), editDesc.trim())
|
||||
}
|
||||
setIsEditingName(false)
|
||||
}
|
||||
|
||||
const handleAddUser = async (user: User) => {
|
||||
await addMember(groupId, user.id)
|
||||
setShowAddUser(false)
|
||||
}
|
||||
|
||||
const handleLeaveOrDelete = async () => {
|
||||
if (isOwner) {
|
||||
if (confirm(`Удалить ${selectedGroup.isChannel ? 'канал' : 'группу'} "${selectedGroup.name}"?`)) {
|
||||
await deleteGroup(groupId)
|
||||
onClose()
|
||||
}
|
||||
} else {
|
||||
if (confirm(`Покинуть ${selectedGroup.isChannel ? 'канал' : 'группу'} "${selectedGroup.name}"?`)) {
|
||||
await leaveGroup(groupId)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div className="fixed top-0 right-0 h-full w-80 bg-white dark:bg-gray-900 z-50 flex flex-col transform transition-transform duration-300 translate-x-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-white">
|
||||
{selectedGroup.isChannel ? 'Подписчики' : 'Участники'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} className="text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Информация о группе */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
|
||||
{isEditingName ? (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
placeholder="Название группы"
|
||||
className="w-full px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white text-sm outline-none focus:ring-2 focus:ring-brand"
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
value={editDesc}
|
||||
onChange={(e) => setEditDesc(e.target.value)}
|
||||
placeholder="Описание (необязательно)"
|
||||
className="w-full px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white text-sm outline-none focus:ring-2 focus:ring-brand"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSaveEdit}
|
||||
className="flex-1 flex items-center justify-center gap-1 py-2 rounded-lg bg-brand text-white text-sm hover:bg-brand-dark transition-colors"
|
||||
>
|
||||
<Check size={16} /> Сохранить
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsEditingName(false)}
|
||||
className="flex-1 py-2 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-sm hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">{selectedGroup.name}</p>
|
||||
{selectedGroup.description && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">{selectedGroup.description}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{selectedGroup.membersCount} {selectedGroup.isChannel ? 'подписчиков' : 'участников'}
|
||||
</p>
|
||||
</div>
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={handleStartEdit}
|
||||
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors flex-shrink-0"
|
||||
title="Редактировать"
|
||||
>
|
||||
<Pencil size={16} className="text-gray-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Кнопка добавления */}
|
||||
{canAddMembers && (
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<button
|
||||
onClick={() => setShowAddUser(true)}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl bg-brand/10 text-brand hover:bg-brand/20 transition-colors font-medium"
|
||||
>
|
||||
<UserPlus size={18} />
|
||||
Добавить участника
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Список участников */}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{selectedGroup.members.length === 0 && (
|
||||
<p className="text-center text-sm text-gray-400 py-8">Загрузка участников...</p>
|
||||
)}
|
||||
|
||||
{/* Владелец */}
|
||||
{selectedGroup.members.filter(m => m.role === 'owner').map((member) => (
|
||||
<MemberItem key={member.userId} member={member} showRole canRemove={false} />
|
||||
))}
|
||||
|
||||
{/* Админы */}
|
||||
{selectedGroup.members.filter(m => m.role === 'admin').map((member) => (
|
||||
<MemberItem
|
||||
key={member.userId}
|
||||
member={member}
|
||||
showRole
|
||||
canRemove={canRemoveMembers && isOwner}
|
||||
onRemove={() => removeMember(groupId, member.userId)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Обычные участники */}
|
||||
{selectedGroup.members.filter(m => m.role === 'member').map((member) => (
|
||||
<MemberItem
|
||||
key={member.userId}
|
||||
member={member}
|
||||
showRole={false}
|
||||
canRemove={canRemoveMembers && isOwner}
|
||||
onRemove={() => removeMember(groupId, member.userId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Кнопка выхода / удаления */}
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-800">
|
||||
<button
|
||||
onClick={handleLeaveOrDelete}
|
||||
className="w-full py-2.5 text-sm text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-xl transition-colors"
|
||||
>
|
||||
{isOwner
|
||||
? `Удалить ${selectedGroup.isChannel ? 'канал' : 'группу'}`
|
||||
: `Покинуть ${selectedGroup.isChannel ? 'канал' : 'группу'}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модальное окно поиска пользователей */}
|
||||
{showAddUser && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md bg-white dark:bg-gray-900 rounded-2xl shadow-2xl p-6">
|
||||
<UserSearch
|
||||
onSelect={handleAddUser}
|
||||
onClose={() => setShowAddUser(false)}
|
||||
showStartChat={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface MemberItemProps {
|
||||
member: GroupMember
|
||||
showRole?: boolean
|
||||
canRemove?: boolean
|
||||
onRemove?: () => void
|
||||
}
|
||||
|
||||
function MemberItem({ member, showRole, canRemove, onRemove }: MemberItemProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl transition-colors group">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-brand to-brand-dark flex items-center justify-center text-white font-semibold flex-shrink-0 relative">
|
||||
{member.avatar ? (
|
||||
<img src={member.avatar} alt={member.name} className="w-full h-full object-cover rounded-full" />
|
||||
) : (
|
||||
(member.name || '?').charAt(0).toUpperCase()
|
||||
)}
|
||||
{member.online && (
|
||||
<span className="absolute bottom-0 right-0 w-3 h-3 bg-green-500 border-2 border-white dark:border-gray-900 rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-gray-900 dark:text-white truncate">{member.name}</p>
|
||||
{showRole && (
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{member.role === 'owner' && (
|
||||
<><Crown size={12} className="text-yellow-500" /><span>Владелец</span></>
|
||||
)}
|
||||
{member.role === 'admin' && (
|
||||
<><Shield size={12} className="text-brand" /><span>Администратор</span></>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canRemove && (
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
|
||||
title="Удалить участника"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
469
lastochka-ui/src/components/ui/SettingsScreen.tsx
Normal file
469
lastochka-ui/src/components/ui/SettingsScreen.tsx
Normal file
@@ -0,0 +1,469 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useChatStore } from '@/store/chat'
|
||||
import {
|
||||
User, Camera, Save, X, Eye, EyeOff, Lock,
|
||||
Moon, Sun, Volume2, VolumeX, Bell, BellOff,
|
||||
LogOut, Shield, Info, Smartphone
|
||||
} from 'lucide-react'
|
||||
import { updateProfile, changePassword } from '@/lib/email-auth'
|
||||
import { getTinode, getAvatarUrl } from '@/lib/tinode-client'
|
||||
|
||||
interface SettingsProps {
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export default function SettingsScreen({ onClose }: SettingsProps) {
|
||||
const { userId, displayName: authDisplayName, avatar: storedAvatar, logout } = useAuthStore()
|
||||
const { darkMode, toggleDarkMode, playSound, toggleSound } = useChatStore()
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'profile' | 'security' | 'settings'>('settings')
|
||||
|
||||
// Profile state
|
||||
const [avatar, setAvatar] = useState(storedAvatar || '')
|
||||
const [name, setName] = useState('')
|
||||
const [bio, setBio] = useState('')
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
||||
// Security state
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [showCurrentPassword, setShowCurrentPassword] = useState(false)
|
||||
const [showNewPassword, setShowNewPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
|
||||
// Common state
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
|
||||
// Загрузка текущих данных из me-топика
|
||||
useEffect(() => {
|
||||
if (authDisplayName) setName(authDisplayName)
|
||||
const me = getTinode().getMeTopic()
|
||||
const pub = (me as any).public
|
||||
if (pub) {
|
||||
if (pub.fn) setName(pub.fn)
|
||||
if (pub.note) setBio(pub.note)
|
||||
const url = getAvatarUrl(pub.photo)
|
||||
if (url) setAvatar(url)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ─── Profile actions ──────────────────────────────────────────
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
try {
|
||||
const result = await updateProfile({
|
||||
displayName: name.trim() || undefined,
|
||||
avatar: avatar || undefined,
|
||||
bio: bio.trim() || undefined,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
setSuccess('Профиль обновлён')
|
||||
setIsEditing(false)
|
||||
useAuthStore.setState({ displayName: name.trim() || authDisplayName, avatar: avatar || null })
|
||||
} else {
|
||||
setError(result.error || 'Ошибка обновления профиля')
|
||||
}
|
||||
} catch {
|
||||
setError('Ошибка обновления профиля')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Security actions ─────────────────────────────────────────
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (newPassword.length < 6) {
|
||||
setError('Пароль должен быть не менее 6 символов')
|
||||
return
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Пароли не совпадают')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
try {
|
||||
const result = await changePassword(currentPassword, newPassword)
|
||||
if (result.success) {
|
||||
setSuccess('Пароль изменён')
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
} else {
|
||||
setError(result.error || 'Ошибка смены пароля')
|
||||
}
|
||||
} catch {
|
||||
setError('Ошибка смены пароля')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Avatar upload ────────────────────────────────────────────
|
||||
|
||||
const handleAvatarUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setError('Размер файла не более 5MB')
|
||||
return
|
||||
}
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setError('Загрузите изображение')
|
||||
return
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => setAvatar(event.target?.result as string)
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
// ─── Logout ───────────────────────────────────────────────────
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (confirm('Выйти из аккаунта?')) {
|
||||
await logout()
|
||||
}
|
||||
}
|
||||
|
||||
const displayName = name || authDisplayName || 'Пользователь'
|
||||
const inputClass =
|
||||
'w-full h-12 px-4 rounded-xl bg-gray-100 dark:bg-gray-800 border-0 text-gray-900 dark:text-white placeholder-gray-400 outline-none focus:ring-2 focus:ring-brand transition-all'
|
||||
const labelClass = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-white dark:bg-gray-900">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Настройки</h2>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} className="text-gray-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-800">
|
||||
{[
|
||||
{ id: 'settings' as const, label: 'Общие', icon: Smartphone },
|
||||
{ id: 'profile' as const, label: 'Профиль', icon: User },
|
||||
{ id: 'security' as const, label: 'Безопасность', icon: Lock },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex-1 py-3 text-sm font-medium transition-colors relative ${
|
||||
activeTab === tab.id
|
||||
? 'text-brand'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<tab.icon size={16} />
|
||||
{tab.label}
|
||||
</div>
|
||||
{activeTab === tab.id && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-brand" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{/* ═══════════ SETTINGS TAB ═══════════ */}
|
||||
{activeTab === 'settings' && (
|
||||
<>
|
||||
{/* Appearance */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
|
||||
Внешний вид
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-gray-50 dark:bg-gray-800/50">
|
||||
<div className="flex items-center gap-3">
|
||||
{darkMode ? <Moon size={20} className="text-brand" /> : <Sun size={20} className="text-gray-400" />}
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">Тёмная тема</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Переключить оформление</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleDarkMode}
|
||||
className={`w-12 h-7 rounded-full transition-colors relative ${
|
||||
darkMode ? 'bg-brand' : 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-0.5 w-6 h-6 bg-white rounded-full shadow transition-transform ${
|
||||
darkMode ? 'translate-x-5' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
|
||||
Уведомления
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-gray-50 dark:bg-gray-800/50">
|
||||
<div className="flex items-center gap-3">
|
||||
{playSound ? <Volume2 size={20} className="text-brand" /> : <VolumeX size={20} className="text-gray-400" />}
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">Звук уведомлений</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Звуковой сигнал при новых сообщениях</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleSound}
|
||||
className={`w-12 h-7 rounded-full transition-colors relative ${
|
||||
playSound ? 'bg-brand' : 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-0.5 w-6 h-6 bg-white rounded-full shadow transition-transform ${
|
||||
playSound ? 'translate-x-5' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* About */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
|
||||
О приложении
|
||||
</h3>
|
||||
<div className="p-4 rounded-xl bg-gray-50 dark:bg-gray-800/50 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-brand to-brand-dark flex items-center justify-center text-white font-bold">
|
||||
Л
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">Ласточка</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Версия 1.0.0 (Alpha)</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||
Российский суверенный мессенджер с открытым кодом.
|
||||
Аналог Telegram: личные сообщения, группы, каналы, звонки.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<Shield size={14} />
|
||||
<span>GPL v3 · Данные хранятся в РФ</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center justify-center gap-3 p-4 rounded-xl bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors font-medium"
|
||||
>
|
||||
<LogOut size={20} />
|
||||
Выйти из аккаунта
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ═══════════ PROFILE TAB ═══════════ */}
|
||||
{activeTab === 'profile' && (
|
||||
<>
|
||||
{/* Avatar */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative w-32 h-32 mb-4">
|
||||
<div className="w-full h-full rounded-full bg-gradient-to-br from-brand to-brand-dark flex items-center justify-center text-white text-4xl font-bold overflow-hidden">
|
||||
{avatar ? (
|
||||
<img src={avatar} alt="Avatar" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
displayName.charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<label className="absolute bottom-0 right-0 w-10 h-10 bg-brand hover:bg-brand-dark rounded-full flex items-center justify-center cursor-pointer transition-colors shadow-lg">
|
||||
<Camera size={20} className="text-white" />
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Нажмите, чтобы загрузить фото</p>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className={labelClass}>Отображаемое имя</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Ваше имя"
|
||||
disabled={!isEditing || isLoading}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bio */}
|
||||
<div>
|
||||
<label className={labelClass}>О себе</label>
|
||||
<textarea
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
placeholder="Расскажите о себе"
|
||||
rows={4}
|
||||
disabled={!isEditing || isLoading}
|
||||
className={`${inputClass} resize-none`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
{isEditing ? (
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSaveProfile}
|
||||
disabled={isLoading}
|
||||
className="flex-1 flex items-center justify-center gap-2 h-12 bg-brand hover:bg-brand-dark disabled:opacity-60 text-white font-medium rounded-xl transition-colors"
|
||||
>
|
||||
<Save size={20} />
|
||||
{isLoading ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setIsEditing(false); setName(authDisplayName || '') }}
|
||||
className="flex-1 flex items-center justify-center gap-2 h-12 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 font-medium rounded-xl transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="w-full h-12 bg-brand hover:bg-brand-dark text-white font-medium rounded-xl transition-colors"
|
||||
>
|
||||
Редактировать профиль
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ═══════════ SECURITY TAB ═══════════ */}
|
||||
{activeTab === 'security' && (
|
||||
<>
|
||||
{/* Current password */}
|
||||
<div>
|
||||
<label className={labelClass}>Текущий пароль</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showCurrentPassword ? 'text' : 'password'}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
placeholder="Введите текущий пароль"
|
||||
disabled={isLoading}
|
||||
className={inputClass}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showCurrentPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New password */}
|
||||
<div>
|
||||
<label className={labelClass}>Новый пароль</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Минимум 6 символов"
|
||||
disabled={isLoading}
|
||||
className={inputClass}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showNewPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm password */}
|
||||
<div>
|
||||
<label className={labelClass}>Подтверждение пароля</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Повторите новый пароль"
|
||||
disabled={isLoading}
|
||||
className={inputClass}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="p-3 rounded-xl bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||
<p className="text-sm text-green-600 dark:text-green-400">{success}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
onClick={handleChangePassword}
|
||||
disabled={isLoading || !currentPassword || !newPassword || !confirmPassword}
|
||||
className="w-full h-12 bg-brand hover:bg-brand-dark disabled:opacity-60 disabled:cursor-not-allowed text-white font-medium rounded-xl transition-colors"
|
||||
>
|
||||
{isLoading ? 'Изменение...' : 'Изменить пароль'}
|
||||
</button>
|
||||
|
||||
<div className="p-4 rounded-xl bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
<strong>Совет:</strong> Используйте надёжный пароль из букв, цифр и специальных символов.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
190
lastochka-ui/src/components/ui/UserSearch.tsx
Normal file
190
lastochka-ui/src/components/ui/UserSearch.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Search, User, UserPlus, MessageCircle, X } from 'lucide-react'
|
||||
import { useChatStore } from '@/store/chat'
|
||||
import { useGroupsStore } from '@/store/groups'
|
||||
import type { User as UserType } from '@/types'
|
||||
|
||||
interface UserSearchProps {
|
||||
onSelect?: (user: UserType) => void
|
||||
onClose?: () => void
|
||||
showStartChat?: boolean
|
||||
showAddToGroup?: boolean
|
||||
groupId?: string
|
||||
}
|
||||
|
||||
export default function UserSearch({
|
||||
onSelect,
|
||||
onClose,
|
||||
showStartChat = true,
|
||||
showAddToGroup = false,
|
||||
groupId
|
||||
}: UserSearchProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<UserType[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
|
||||
const { searchUsers } = useChatStore()
|
||||
const { searchUsersForInvite, addMember } = useGroupsStore()
|
||||
|
||||
// Debounced поиск
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(async () => {
|
||||
if (query.length < 2) {
|
||||
setResults([])
|
||||
return
|
||||
}
|
||||
|
||||
setIsSearching(true)
|
||||
|
||||
try {
|
||||
// Поиск через FND topic
|
||||
const users = await searchUsersForInvite(query)
|
||||
setResults(users)
|
||||
} catch (err) {
|
||||
console.error('Search failed:', err)
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}, 300)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [query, searchUsersForInvite])
|
||||
|
||||
const handleStartChat = async (user: UserType) => {
|
||||
// Создаём P2P чат с пользователем
|
||||
const { setActiveChat } = useChatStore.getState()
|
||||
await setActiveChat(user.id)
|
||||
onSelect?.(user)
|
||||
}
|
||||
|
||||
const handleAddToGroup = async (user: UserType) => {
|
||||
if (!groupId) return
|
||||
|
||||
try {
|
||||
await addMember(groupId, user.id)
|
||||
onSelect?.(user)
|
||||
} catch (err) {
|
||||
console.error('Failed to add member:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md">
|
||||
{/* Заголовок */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-white">
|
||||
Поиск пользователей
|
||||
</h2>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} className="text-gray-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Поле поиска */}
|
||||
<div className="relative mb-4">
|
||||
<Search
|
||||
size={20}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по имени или логину..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full h-12 pl-10 pr-4 rounded-xl bg-gray-100 dark:bg-gray-800 border-0 text-gray-900 dark:text-white placeholder-gray-400 outline-none focus:ring-2 focus:ring-brand transition-all"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Результаты */}
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{isSearching && (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-6 h-6 border-2 border-brand border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Поиск...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSearching && query.length >= 2 && results.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<User size={40} className="mx-auto mb-2 text-gray-300 dark:text-gray-600" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Пользователи не найдены
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center justify-between p-3 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Аватар */}
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-brand to-brand-dark flex items-center justify-center text-white font-semibold flex-shrink-0">
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} alt={user.name} className="w-full h-full object-cover rounded-full" />
|
||||
) : (
|
||||
user.name.charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Информация */}
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{user.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className={`w-2 h-2 rounded-full ${user.online ? 'bg-green-500' : 'bg-gray-400'}`} />
|
||||
{user.online ? 'Онлайн' : 'Не в сети'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Действия */}
|
||||
<div className="flex items-center gap-2">
|
||||
{showStartChat && (
|
||||
<button
|
||||
onClick={() => handleStartChat(user)}
|
||||
className="p-2 text-brand hover:bg-brand/10 rounded-lg transition-colors"
|
||||
title="Начать чат"
|
||||
>
|
||||
<MessageCircle size={18} />
|
||||
</button>
|
||||
)}
|
||||
{showAddToGroup && (
|
||||
<button
|
||||
onClick={() => handleAddToGroup(user)}
|
||||
className="p-2 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-lg transition-colors"
|
||||
title="Добавить в группу"
|
||||
>
|
||||
<UserPlus size={18} />
|
||||
</button>
|
||||
)}
|
||||
{onSelect && !showStartChat && !showAddToGroup && (
|
||||
<button
|
||||
onClick={() => onSelect(user)}
|
||||
className="px-3 py-1.5 text-sm bg-brand text-white rounded-lg hover:bg-brand-dark transition-colors"
|
||||
>
|
||||
Выбрать
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Подсказка */}
|
||||
{query.length < 2 && (
|
||||
<p className="text-center text-sm text-gray-400 dark:text-gray-500 mt-4">
|
||||
Введите минимум 2 символа для поиска
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
122
lastochka-ui/src/hooks/useNetworkMonitor.ts
Normal file
122
lastochka-ui/src/hooks/useNetworkMonitor.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Network monitor — обнаружение потери соединения и автореконнект.
|
||||
* Аналог NetworkMonitor из Android + SessionRepository.autoLogin.
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { getTinode, loadAuthToken } from '@/lib/tinode-client'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useChatStore } from '@/store/chat'
|
||||
|
||||
interface NetworkState {
|
||||
isOnline: boolean
|
||||
lastReconnectAttempt: Date | null
|
||||
reconnectCount: number
|
||||
}
|
||||
|
||||
const MAX_RECONNECT_ATTEMPTS = 5
|
||||
const RECONNECT_BASE_DELAY = 2000 // 2 sec
|
||||
const RECONNECT_MAX_DELAY = 30000 // 30 sec
|
||||
|
||||
export function useNetworkMonitor() {
|
||||
const [network, setNetwork] = useState<NetworkState>({
|
||||
isOnline: navigator.onLine,
|
||||
lastReconnectAttempt: null,
|
||||
reconnectCount: 0,
|
||||
})
|
||||
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const attemptRef = useRef(0)
|
||||
|
||||
const tryReconnect = useCallback(() => {
|
||||
const tn = getTinode()
|
||||
const authToken = loadAuthToken()
|
||||
|
||||
if (!authToken) {
|
||||
console.warn('No auth token — cannot reconnect')
|
||||
return
|
||||
}
|
||||
|
||||
if (attemptRef.current >= MAX_RECONNECT_ATTEMPTS) {
|
||||
console.warn('Max reconnect attempts reached')
|
||||
return
|
||||
}
|
||||
|
||||
attemptRef.current += 1
|
||||
const delay = Math.min(RECONNECT_BASE_DELAY * Math.pow(2, attemptRef.current - 1), RECONNECT_MAX_DELAY)
|
||||
|
||||
console.log(`Reconnect attempt ${attemptRef.current}/${MAX_RECONNECT_ATTEMPTS} in ${delay}ms`)
|
||||
setNetwork((s) => ({ ...s, lastReconnectAttempt: new Date(), reconnectCount: attemptRef.current }))
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(async () => {
|
||||
try {
|
||||
if (!tn.isConnected()) {
|
||||
await tn.connect()
|
||||
// onConnect callback in auth store handles re-auth
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Reconnect failed', err)
|
||||
tryReconnect() // retry with backoff
|
||||
}
|
||||
}, delay)
|
||||
}, [])
|
||||
|
||||
// Browser online/offline events
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
console.log('Network: online')
|
||||
setNetwork((s) => ({ ...s, isOnline: true }))
|
||||
attemptRef.current = 0 // reset counter
|
||||
tryReconnect()
|
||||
}
|
||||
|
||||
const handleOffline = () => {
|
||||
console.log('Network: offline')
|
||||
setNetwork((s) => ({ ...s, isOnline: false }))
|
||||
}
|
||||
|
||||
window.addEventListener('online', handleOnline)
|
||||
window.addEventListener('offline', handleOffline)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
}
|
||||
}, [tryReconnect])
|
||||
|
||||
// Tinode disconnect event — trigger reconnect
|
||||
useEffect(() => {
|
||||
const tn = getTinode()
|
||||
const originalOnDisconnect = tn.onDisconnect
|
||||
|
||||
tn.onDisconnect = (err?: Error) => {
|
||||
console.log('Tinode disconnected', err?.message)
|
||||
setNetwork((s) => ({ ...s, isOnline: false }))
|
||||
|
||||
// Call original handler if exists
|
||||
originalOnDisconnect?.(err)
|
||||
|
||||
// Try to reconnect after a short delay
|
||||
setTimeout(() => {
|
||||
if (navigator.onLine) {
|
||||
tryReconnect()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
return () => {
|
||||
tn.onDisconnect = originalOnDisconnect
|
||||
}
|
||||
}, [tryReconnect])
|
||||
|
||||
// Cleanup
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return network
|
||||
}
|
||||
150
lastochka-ui/src/hooks/useVoiceRecorder.ts
Normal file
150
lastochka-ui/src/hooks/useVoiceRecorder.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Voice recording hook using MediaRecorder API.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
|
||||
export interface VoiceRecording {
|
||||
isRecording: boolean
|
||||
duration: number
|
||||
blob: Blob | null
|
||||
audioUrl: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export function useVoiceRecorder() {
|
||||
const [state, setState] = useState<VoiceRecording>({
|
||||
isRecording: false,
|
||||
duration: 0,
|
||||
blob: null,
|
||||
audioUrl: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||
const chunksRef = useRef<Blob[]>([])
|
||||
const streamRef = useRef<MediaStream | null>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const durationRef = useRef(0)
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopRecording()
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
streamRef.current = stream
|
||||
|
||||
// Prefer Opus/OGG, fallback to webm
|
||||
const mimeType = MediaRecorder.isTypeSupported('audio/webm')
|
||||
? 'audio/webm'
|
||||
: MediaRecorder.isTypeSupported('audio/ogg')
|
||||
? 'audio/ogg'
|
||||
: ''
|
||||
|
||||
const recorder = mimeType
|
||||
? new MediaRecorder(stream, { mimeType })
|
||||
: new MediaRecorder(stream)
|
||||
|
||||
chunksRef.current = []
|
||||
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunksRef.current.push(e.data)
|
||||
}
|
||||
|
||||
recorder.onstop = () => {
|
||||
const blob = new Blob(chunksRef.current, { type: mimeType || 'audio/webm' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
setState((s) => ({
|
||||
...s,
|
||||
isRecording: false,
|
||||
blob,
|
||||
audioUrl: url,
|
||||
duration: durationRef.current,
|
||||
}))
|
||||
// Stop all tracks
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop())
|
||||
streamRef.current = null
|
||||
}
|
||||
|
||||
recorder.start(100) // Collect data every 100ms
|
||||
mediaRecorderRef.current = recorder
|
||||
|
||||
// Start duration timer
|
||||
durationRef.current = 0
|
||||
timerRef.current = setInterval(() => {
|
||||
durationRef.current += 1
|
||||
setState((s) => ({ ...s, duration: durationRef.current }))
|
||||
}, 1000)
|
||||
|
||||
setState((s) => ({
|
||||
...s,
|
||||
isRecording: true,
|
||||
duration: 0,
|
||||
blob: null,
|
||||
audioUrl: null,
|
||||
error: null,
|
||||
}))
|
||||
} catch (err) {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
error: 'Нет доступа к микрофону',
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const cancelRecording = useCallback(() => {
|
||||
stopRecording()
|
||||
if (state.audioUrl) URL.revokeObjectURL(state.audioUrl)
|
||||
setState({
|
||||
isRecording: false,
|
||||
duration: 0,
|
||||
blob: null,
|
||||
audioUrl: null,
|
||||
error: null,
|
||||
})
|
||||
}, [stopRecording, state.audioUrl])
|
||||
|
||||
const clearRecording = useCallback(() => {
|
||||
if (state.audioUrl) URL.revokeObjectURL(state.audioUrl)
|
||||
setState({
|
||||
isRecording: false,
|
||||
duration: 0,
|
||||
blob: null,
|
||||
audioUrl: null,
|
||||
error: null,
|
||||
})
|
||||
}, [state.audioUrl])
|
||||
|
||||
return {
|
||||
...state,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
cancelRecording,
|
||||
clearRecording,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in seconds to MM:SS
|
||||
*/
|
||||
export function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
120
lastochka-ui/src/index.css
Normal file
120
lastochka-ui/src/index.css
Normal file
@@ -0,0 +1,120 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply font-sans text-[15px] antialiased;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Modern thin scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-black/15 rounded-full;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-black/25;
|
||||
}
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
@apply bg-white/15;
|
||||
}
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-white/25;
|
||||
}
|
||||
|
||||
/* Smooth focus states */
|
||||
:focus-visible {
|
||||
@apply outline-none ring-2 ring-brand ring-offset-2 ring-offset-white dark:ring-offset-gray-900;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.scrollbar-none {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Glass morphism utilities */
|
||||
.glass {
|
||||
@apply bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl;
|
||||
}
|
||||
.glass-strong {
|
||||
@apply bg-white/90 dark:bg-gray-900/90 backdrop-blur-2xl;
|
||||
}
|
||||
|
||||
/* Subtle gradient overlay */
|
||||
.gradient-border {
|
||||
position: relative;
|
||||
}
|
||||
.gradient-border::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 1px;
|
||||
background: linear-gradient(135deg, rgba(91,94,244,0.2), rgba(91,94,244,0.05));
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Chat background tile pattern */
|
||||
.chat-bg {
|
||||
background-image: url('/chat-bg.png');
|
||||
background-repeat: repeat;
|
||||
background-position: center;
|
||||
background-color: #EFEFF3;
|
||||
}
|
||||
|
||||
.dark .chat-bg {
|
||||
background-color: #0e1621;
|
||||
filter: brightness(0.5) saturate(0.8);
|
||||
}
|
||||
|
||||
/* Message animation classes */
|
||||
.message-enter {
|
||||
animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* Smooth transitions for all interactive elements */
|
||||
button, a {
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
|
||||
/* Improved selection color */
|
||||
::selection {
|
||||
@apply bg-brand/20 text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
/* Pulse animation for online status */
|
||||
@keyframes onlinePulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); }
|
||||
50% { box-shadow: 0 0 0 4px rgba(34, 197, 94, 0); }
|
||||
}
|
||||
.online-pulse {
|
||||
animation: onlinePulse 2s infinite;
|
||||
}
|
||||
319
lastochka-ui/src/lib/email-auth.ts
Normal file
319
lastochka-ui/src/lib/email-auth.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import { getTinode } from './tinode-client'
|
||||
|
||||
/**
|
||||
* Отправка email кода подтверждения
|
||||
*/
|
||||
export async function sendEmailCode(email: string): Promise<{ success: boolean; error?: string }> {
|
||||
const tn = getTinode()
|
||||
|
||||
try {
|
||||
if (!tn.isConnected()) {
|
||||
await tn.connect()
|
||||
}
|
||||
|
||||
// Tinode не имеет прямого API для reqCred в SDK
|
||||
// Используем acc() для запроса кода подтверждения
|
||||
const ctrl = await tn.acc({
|
||||
scheme: 'email',
|
||||
secret: email,
|
||||
login: false
|
||||
})
|
||||
|
||||
if (ctrl.code >= 300) {
|
||||
return {
|
||||
success: false,
|
||||
error: ctrl.text || 'Ошибка отправки email'
|
||||
}
|
||||
}
|
||||
|
||||
// Сервер должен отправить email с кодом
|
||||
return { success: true }
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Ошибка отправки email'
|
||||
return { success: false, error: msg }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка email кода и завершение верификации
|
||||
*/
|
||||
export async function verifyEmailCode(
|
||||
email: string,
|
||||
code: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const tn = getTinode()
|
||||
|
||||
try {
|
||||
if (!tn.isConnected()) {
|
||||
await tn.connect()
|
||||
}
|
||||
|
||||
// Подтверждаем credential (email) через acc с кодом
|
||||
const ctrl = await tn.acc({
|
||||
scheme: 'email',
|
||||
secret: `${email}:${code}`,
|
||||
login: false
|
||||
})
|
||||
|
||||
if (ctrl.code >= 300) {
|
||||
return {
|
||||
success: false,
|
||||
error: ctrl.text || 'Неверный код подтверждения'
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Ошибка проверки кода'
|
||||
return { success: false, error: msg }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка логина, email и телефона на доступность
|
||||
* Использует серверный API эндпоинт /v1/check-availability
|
||||
*/
|
||||
export async function checkAvailability(params: {
|
||||
login?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
}): Promise<{
|
||||
loginAvailable: boolean
|
||||
emailAvailable: boolean
|
||||
phoneAvailable: boolean
|
||||
error?: string
|
||||
}> {
|
||||
const url = '/v1/check-availability'
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка проверки доступности')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return {
|
||||
loginAvailable: data.login_available ?? true,
|
||||
emailAvailable: data.email_available ?? true,
|
||||
phoneAvailable: data.phone_available ?? true,
|
||||
error: data.error,
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Ошибка проверки доступности'
|
||||
return {
|
||||
loginAvailable: true,
|
||||
emailAvailable: true,
|
||||
phoneAvailable: true,
|
||||
error: msg,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка логина на доступность
|
||||
*/
|
||||
export async function checkLoginAvailability(login: string): Promise<{ available: boolean; error?: string }> {
|
||||
const result = await checkAvailability({ login })
|
||||
return {
|
||||
available: result.loginAvailable,
|
||||
error: result.loginAvailable ? undefined : (result.error || 'Этот логин уже занят'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка email на доступность
|
||||
*/
|
||||
export async function checkEmailAvailability(email: string): Promise<{ available: boolean; error?: string }> {
|
||||
const result = await checkAvailability({ email })
|
||||
return {
|
||||
available: result.emailAvailable,
|
||||
error: result.emailAvailable ? undefined : (result.error || 'Этот email уже зарегистрирован'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка телефона на доступность
|
||||
*/
|
||||
export async function checkPhoneAvailability(phone: string): Promise<{ available: boolean; error?: string }> {
|
||||
const result = await checkAvailability({ phone })
|
||||
return {
|
||||
available: result.phoneAvailable,
|
||||
error: result.phoneAvailable ? undefined : (result.error || 'Этот номер уже зарегистрирован'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление профиля пользователя
|
||||
*/
|
||||
export async function updateProfile(params: {
|
||||
displayName?: string
|
||||
avatar?: string
|
||||
bio?: string
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const tn = getTinode()
|
||||
|
||||
try {
|
||||
if (!tn.isConnected()) {
|
||||
await tn.connect()
|
||||
}
|
||||
|
||||
const me = tn.getMeTopic()
|
||||
|
||||
await me.setMeta({
|
||||
desc: {
|
||||
public: {
|
||||
fn: params.displayName,
|
||||
photo: params.avatar ? (() => {
|
||||
// params.avatar — Data URL: "data:image/jpeg;base64,/9j/..."
|
||||
// Tinode хранит {type: "image/jpeg", data: "<raw base64>"}
|
||||
const match = params.avatar!.match(/^data:([^;]+);base64,(.+)$/)
|
||||
if (match) return { type: match[1], data: match[2] }
|
||||
return { type: 'image/jpeg', data: params.avatar! }
|
||||
})() : undefined,
|
||||
note: params.bio,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Ошибка обновления профиля'
|
||||
return { success: false, error: msg }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Смена пароля
|
||||
*/
|
||||
export async function changePassword(
|
||||
oldPassword: string,
|
||||
newPassword: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const tn = getTinode()
|
||||
|
||||
try {
|
||||
if (!tn.isConnected()) {
|
||||
await tn.connect()
|
||||
}
|
||||
|
||||
// Tinode использует схему обновления пароля через setMeta на me topic
|
||||
const me = tn.getMeTopic()
|
||||
|
||||
const ctrl = await me.setMeta({
|
||||
private: {
|
||||
password: {
|
||||
old: oldPassword,
|
||||
new: newPassword,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (ctrl && (ctrl as { code?: number }).code >= 300) {
|
||||
return {
|
||||
success: false,
|
||||
error: (ctrl as { text?: string }).text || 'Ошибка смены пароля'
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Ошибка смены пароля'
|
||||
return { success: false, error: msg }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Полная регистрация с email, телефоном и паролем
|
||||
*/
|
||||
export async function registerWithFullProfile(
|
||||
login: string,
|
||||
password: string,
|
||||
email: string,
|
||||
phone: string,
|
||||
displayName: string
|
||||
): Promise<{ success: boolean; error?: string; requiresVerification?: boolean }> {
|
||||
const tn = getTinode()
|
||||
|
||||
try {
|
||||
if (!tn.isConnected()) {
|
||||
await tn.connect()
|
||||
}
|
||||
|
||||
// Создаём учётную запись через createAccountBasic
|
||||
// email передаётся как credential для верификации
|
||||
// телефон хранится как тег tel:79991234567
|
||||
const ctrl = await tn.createAccountBasic(login, password, {
|
||||
public: { fn: displayName },
|
||||
cred: [{ meth: 'email', val: email }],
|
||||
login: true,
|
||||
})
|
||||
|
||||
if (ctrl.code >= 300) {
|
||||
if (ctrl.code === 409) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Этот логин уже зарегистрирован. Пожалуйста, выберите другой.'
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: (ctrl as { code: number; text: string }).text || 'Ошибка регистрации'
|
||||
}
|
||||
}
|
||||
|
||||
// Успешная регистрация и вход
|
||||
return {
|
||||
success: true,
|
||||
requiresVerification: false,
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// Tinode SDK бросает объекты с полем code при серверных ошибках
|
||||
const errObj = err as { code?: number; message?: string }
|
||||
if (errObj.code === 409) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Этот логин уже зарегистрирован. Пожалуйста, выберите другой.'
|
||||
}
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : 'Ошибка регистрации'
|
||||
return { success: false, error: msg }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Завершение регистрации после верификации email
|
||||
*/
|
||||
export async function completeRegistration(
|
||||
login: string,
|
||||
password: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const tn = getTinode()
|
||||
|
||||
try {
|
||||
if (!tn.isConnected()) {
|
||||
await tn.connect()
|
||||
}
|
||||
|
||||
// Логинимся после успешной верификации
|
||||
const ctrl = await tn.loginBasic(login, password)
|
||||
|
||||
if (ctrl.code >= 300) {
|
||||
return {
|
||||
success: false,
|
||||
error: ctrl.text || 'Ошибка входа'
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Ошибка завершения регистрации'
|
||||
return { success: false, error: msg }
|
||||
}
|
||||
}
|
||||
203
lastochka-ui/src/lib/image-upload.ts
Normal file
203
lastochka-ui/src/lib/image-upload.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Загрузка изображений на Tinode сервер через HTTP multipart POST.
|
||||
* Аналог LargeFileHelper из Android SDK.
|
||||
*/
|
||||
|
||||
import { getTinode } from './tinode-client'
|
||||
|
||||
const HOST = (import.meta.env.VITE_TINODE_HOST as string) || 'localhost:6060'
|
||||
const API_KEY = (import.meta.env.VITE_TINODE_API_KEY as string) || 'AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K'
|
||||
const SECURE = import.meta.env.VITE_TINODE_SECURE === 'true'
|
||||
|
||||
export interface ImageUploadResult {
|
||||
/** Relative URL: /v0/file/s/... */
|
||||
url: string
|
||||
/** MIME type */
|
||||
mimeType: string
|
||||
/** File name */
|
||||
fileName: string
|
||||
/** File size in bytes */
|
||||
size: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить базовый URL для файловых операций.
|
||||
*/
|
||||
function getBaseUrl(): string {
|
||||
const scheme = SECURE ? 'https' : 'http'
|
||||
// VITE_TINODE_HOST может быть "localhost:6060" или "api.lastochka-m.ru"
|
||||
return `${scheme}://${HOST}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузить файл на Tinode сервер.
|
||||
* Поддерживает редирект 307.
|
||||
*/
|
||||
export async function uploadFile(
|
||||
file: File,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<ImageUploadResult> {
|
||||
const tn = getTinode()
|
||||
const authToken = tn.getAuthToken()
|
||||
if (!authToken) {
|
||||
throw new Error('Нет токена авторизации')
|
||||
}
|
||||
|
||||
const uploadUrl = `${getBaseUrl()}/v0/file/u/?apikey=${API_KEY}&auth=token&secret=${authToken.token}`
|
||||
|
||||
return uploadToUrl(file, uploadUrl, onProgress)
|
||||
}
|
||||
|
||||
async function uploadToUrl(
|
||||
file: File,
|
||||
url: string,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<ImageUploadResult> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
|
||||
// Прогресс загрузки
|
||||
if (onProgress) {
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
onProgress(e.loaded / e.total)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText)
|
||||
// Tinode server returns file ref in ctrl.params.ref or ctrl.params.url
|
||||
const params = response?.ctrl?.params || response?.params || {}
|
||||
const fileUrl = params.ref || params.url
|
||||
if (!fileUrl) {
|
||||
console.error('[uploadFile] No file URL in response:', JSON.stringify(response))
|
||||
reject(new Error('Нет URL файла в ответе сервера'))
|
||||
return
|
||||
}
|
||||
resolve({
|
||||
url: normalizeFileUrl(fileUrl),
|
||||
mimeType: file.type,
|
||||
fileName: file.name,
|
||||
size: file.size,
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[uploadFile] Parse error:', e, 'Raw response:', xhr.responseText)
|
||||
reject(new Error('Ошибка парсинга ответа сервера'))
|
||||
}
|
||||
} else if (xhr.status === 307) {
|
||||
// Redirect — повторяем загрузку на новый URL
|
||||
const redirectUrl = xhr.getResponseHeader('Location')
|
||||
if (!redirectUrl) {
|
||||
reject(new Error('Нет URL редиректа в ответе 307'))
|
||||
return
|
||||
}
|
||||
// Рекурсивно загружаем на новый URL
|
||||
uploadToUrl(file, redirectUrl, onProgress).then(resolve).catch(reject)
|
||||
} else {
|
||||
reject(new Error(`Загрузка не удалась: ${xhr.status} ${xhr.statusText}`))
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
reject(new Error('Ошибка сети при загрузке файла'))
|
||||
})
|
||||
|
||||
xhr.open('POST', url)
|
||||
xhr.send(formData)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализовать URL файла — вернуть чистый URL без query-параметров.
|
||||
* Auth params добавляются отдельно при загрузке через getFileUrl().
|
||||
*/
|
||||
function normalizeFileUrl(fileUrl: string): string {
|
||||
// Если уже полный URL — вернуть без query params
|
||||
if (fileUrl.startsWith('http')) {
|
||||
const urlObj = new URL(fileUrl)
|
||||
return `${urlObj.origin}${urlObj.pathname}`
|
||||
}
|
||||
// Относительный URL — вернуть как есть (без query params)
|
||||
const cleanPath = fileUrl.split('?')[0]
|
||||
return cleanPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Сжать изображение для отправки.
|
||||
* Возвращает Blob (JPEG) и метаданные.
|
||||
*/
|
||||
export async function compressImage(
|
||||
file: File,
|
||||
options: {
|
||||
maxWidth?: number
|
||||
maxHeight?: number
|
||||
quality?: number
|
||||
skipIfBelow?: number // bytes — пропускать если файл уже маленький
|
||||
} = {}
|
||||
): Promise<{ blob: Blob; width: number; height: number; mimeType: string }> {
|
||||
const {
|
||||
maxWidth = 1920,
|
||||
maxHeight = 1920,
|
||||
quality = 0.85,
|
||||
skipIfBelow = 1_048_576, // 1MB
|
||||
} = options
|
||||
|
||||
// Если файл уже маленький и это JPEG/PNG — пропускаем сжатие
|
||||
if (file.size < skipIfBelow && (file.type === 'image/jpeg' || file.type === 'image/png')) {
|
||||
return { blob: file, width: 0, height: 0, mimeType: file.type }
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
const url = URL.createObjectURL(file)
|
||||
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
let { width, height } = img
|
||||
// Масштабируем если нужно
|
||||
if (width > maxWidth || height > maxHeight) {
|
||||
const ratio = Math.min(maxWidth / width, maxHeight / height)
|
||||
width = Math.round(width * ratio)
|
||||
height = Math.round(height * ratio)
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
reject(new Error('Не удалось получить контекст canvas'))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('Не удалось сжать изображение'))
|
||||
return
|
||||
}
|
||||
resolve({ blob, width, height, mimeType: 'image/jpeg' })
|
||||
},
|
||||
'image/jpeg',
|
||||
quality
|
||||
)
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
reject(new Error('Не удалось загрузить изображение'))
|
||||
}
|
||||
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
96
lastochka-ui/src/lib/phone-utils.ts
Normal file
96
lastochka-ui/src/lib/phone-utils.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Утилиты для работы с номерами телефонов
|
||||
*/
|
||||
|
||||
// Форматирование номера телефона для отображения
|
||||
export function formatPhoneNumber(value: string): string {
|
||||
// Удаляем всё кроме цифр
|
||||
const digits = value.replace(/\D/g, '')
|
||||
|
||||
// Если начинается с 8, заменяем на +7
|
||||
let normalized = digits
|
||||
if (normalized.startsWith('8')) {
|
||||
normalized = '7' + normalized.slice(1)
|
||||
}
|
||||
|
||||
// Если не начинается с 7, добавляем 7
|
||||
if (!normalized.startsWith('7') && normalized.length > 0) {
|
||||
normalized = '7' + normalized
|
||||
}
|
||||
|
||||
// Форматируем: +7 (XXX) XXX-XX-XX
|
||||
if (normalized.length <= 1) {
|
||||
return normalized ? `+${normalized}` : ''
|
||||
}
|
||||
if (normalized.length <= 4) {
|
||||
return `+${normalized.slice(0, 1)} (${normalized.slice(1)})`
|
||||
}
|
||||
if (normalized.length <= 7) {
|
||||
return `+${normalized.slice(0, 1)} (${normalized.slice(1, 4)}) ${normalized.slice(4)}`
|
||||
}
|
||||
if (normalized.length <= 9) {
|
||||
return `+${normalized.slice(0, 1)} (${normalized.slice(1, 4)}) ${normalized.slice(4, 7)}-${normalized.slice(7)}`
|
||||
}
|
||||
return `+${normalized.slice(0, 1)} (${normalized.slice(1, 4)}) ${normalized.slice(4, 7)}-${normalized.slice(7, 9)}-${normalized.slice(9, 11)}`
|
||||
}
|
||||
|
||||
// Очистка номера для отправки на сервер
|
||||
export function cleanPhoneNumber(value: string): string {
|
||||
const digits = value.replace(/\D/g, '')
|
||||
|
||||
// Если начинается с 8, заменяем на 7
|
||||
let cleaned = digits
|
||||
if (cleaned.startsWith('8')) {
|
||||
cleaned = '7' + cleaned.slice(1)
|
||||
}
|
||||
|
||||
// Если не начинается с 7, добавляем 7 (для России)
|
||||
if (!cleaned.startsWith('7') && cleaned.length > 0) {
|
||||
cleaned = '7' + cleaned
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// Валидация номера телефона (российские номера)
|
||||
export function isValidPhoneNumber(value: string): boolean {
|
||||
const cleaned = cleanPhoneNumber(value)
|
||||
// Российские номера: 7 + 10 цифр = 11 цифр
|
||||
return cleaned.length === 11 && /^7\d{10}$/.test(cleaned)
|
||||
}
|
||||
|
||||
// Проверка, является ли номер российским
|
||||
export function isRussianPhone(value: string): boolean {
|
||||
const cleaned = cleanPhoneNumber(value)
|
||||
return cleaned.startsWith('7') && cleaned.length === 11
|
||||
}
|
||||
|
||||
// Получение маски для ввода
|
||||
export function getPhoneMask(): string {
|
||||
return '+7 (999) 999-99-99'
|
||||
}
|
||||
|
||||
/**
|
||||
* Утилиты для работы с email
|
||||
*/
|
||||
|
||||
// Валидация email
|
||||
export function isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(email.trim())
|
||||
}
|
||||
|
||||
// Нормализация email (приведение к нижнему регистру, trim)
|
||||
export function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase()
|
||||
}
|
||||
|
||||
// Проверка домена email
|
||||
export function isValidEmailDomain(email: string): boolean {
|
||||
const domain = email.split('@')[1]
|
||||
if (!domain) return false
|
||||
|
||||
// Минимум 2 части (domain.tld)
|
||||
const parts = domain.split('.')
|
||||
return parts.length >= 2 && parts.every(p => p.length > 0)
|
||||
}
|
||||
170
lastochka-ui/src/lib/sms-auth.ts
Normal file
170
lastochka-ui/src/lib/sms-auth.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { getTinode } from './tinode-client'
|
||||
|
||||
/**
|
||||
* Отправка SMS кода подтверждения
|
||||
*/
|
||||
export async function sendSmsCode(phone: string): Promise<{ success: boolean; error?: string }> {
|
||||
const tn = getTinode()
|
||||
|
||||
try {
|
||||
if (!tn.isConnected()) {
|
||||
await tn.connect()
|
||||
}
|
||||
|
||||
// Создаём учётную запись с credential (номер телефона)
|
||||
// Tinode использует систему credentials для верификакации
|
||||
const ctrl = await tn.createAccountBasic('', '', {
|
||||
public: {},
|
||||
cred: [{
|
||||
meth: 'tel',
|
||||
val: phone,
|
||||
}],
|
||||
login: false, // Не логинимся сразу, сначала верификация
|
||||
})
|
||||
|
||||
if (ctrl.code >= 300) {
|
||||
return {
|
||||
success: false,
|
||||
error: ctrl.text || 'Ошибка отправки SMS'
|
||||
}
|
||||
}
|
||||
|
||||
// Сервер должен отправить SMS с кодом
|
||||
return { success: true }
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Ошибка отправки SMS'
|
||||
return { success: false, error: msg }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка SMS кода и завершение регистрации
|
||||
*/
|
||||
export async function verifySmsCode(
|
||||
phone: string,
|
||||
code: string,
|
||||
displayName: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const tn = getTinode()
|
||||
|
||||
try {
|
||||
if (!tn.isConnected()) {
|
||||
await tn.connect()
|
||||
}
|
||||
|
||||
// Подтверждаем credential (номер телефона) через acc
|
||||
const ctrl = await tn.acc({
|
||||
scheme: 'tel',
|
||||
secret: `${phone}:${code}`,
|
||||
login: false
|
||||
})
|
||||
|
||||
if (ctrl.code >= 300) {
|
||||
return {
|
||||
success: false,
|
||||
error: ctrl.text || 'Неверный код подтверждения'
|
||||
}
|
||||
}
|
||||
|
||||
// После верификации создаём полноценный аккаунт
|
||||
// Генерируем случайный пароль или используем код как временный пароль
|
||||
const tempPassword = `sms_${Date.now()}_${Math.random().toString(36).slice(-6)}`
|
||||
|
||||
const registerCtrl = await tn.createAccountBasic(phone, tempPassword, {
|
||||
public: { fn: displayName },
|
||||
login: true,
|
||||
cred: [{
|
||||
meth: 'tel',
|
||||
val: phone,
|
||||
}],
|
||||
})
|
||||
|
||||
if (registerCtrl.code >= 300) {
|
||||
return {
|
||||
success: false,
|
||||
error: registerCtrl.text || 'Ошибка создания аккаунта'
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Ошибка проверки кода'
|
||||
return { success: false, error: msg }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Вход по номеру телефона с SMS кодом
|
||||
*/
|
||||
export async function loginWithSms(
|
||||
phone: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const tn = getTinode()
|
||||
|
||||
try {
|
||||
if (!tn.isConnected()) {
|
||||
await tn.connect()
|
||||
}
|
||||
|
||||
// Запрашиваем отправку SMS кода для входа через acc
|
||||
const ctrl = await tn.acc({
|
||||
scheme: 'tel',
|
||||
secret: phone,
|
||||
login: false
|
||||
})
|
||||
|
||||
if (ctrl.code >= 300) {
|
||||
return {
|
||||
success: false,
|
||||
error: ctrl.text || 'Ошибка отправки SMS'
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Ошибка отправки SMS'
|
||||
return { success: false, error: msg }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Вход по номеру телефона и коду подтверждения
|
||||
*/
|
||||
export async function loginWithSmsCode(
|
||||
phone: string,
|
||||
code: string
|
||||
): Promise<{ success: boolean; error?: string; userId?: string }> {
|
||||
const tn = getTinode()
|
||||
|
||||
try {
|
||||
if (!tn.isConnected()) {
|
||||
await tn.connect()
|
||||
}
|
||||
|
||||
// Верифицируем код и получаем доступ через acc
|
||||
const ctrl = await tn.acc({
|
||||
scheme: 'tel',
|
||||
secret: `${phone}:${code}`,
|
||||
login: true
|
||||
})
|
||||
|
||||
if (ctrl.code >= 300) {
|
||||
return {
|
||||
success: false,
|
||||
error: ctrl.text || 'Неверный код подтверждения'
|
||||
}
|
||||
}
|
||||
|
||||
// После успешной верификации логинимся
|
||||
// Tinode должен автоматически авторизовать после верификации credential
|
||||
const userId = tn.getCurrentUserID()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId: userId || undefined,
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Ошибка входа'
|
||||
return { success: false, error: msg }
|
||||
}
|
||||
}
|
||||
242
lastochka-ui/src/lib/tinode-client.ts
Normal file
242
lastochka-ui/src/lib/tinode-client.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { Tinode, Drafty } from 'tinode-sdk'
|
||||
import type { MeTopic, Topic, TinodeContact, TinodeMessage } from 'tinode-sdk'
|
||||
|
||||
const HOST = (import.meta.env.VITE_TINODE_HOST as string) || 'localhost:6060'
|
||||
const API_KEY = (import.meta.env.VITE_TINODE_API_KEY as string) || 'AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K'
|
||||
const SECURE = import.meta.env.VITE_TINODE_SECURE === 'true'
|
||||
const APP_NAME = (import.meta.env.VITE_APP_NAME as string) || 'Ласточка'
|
||||
|
||||
// Number of messages to load per page
|
||||
export const MESSAGES_PAGE = 24
|
||||
|
||||
let _tinode: Tinode | null = null
|
||||
|
||||
export function getTinode(): Tinode {
|
||||
if (!_tinode) {
|
||||
_tinode = new Tinode(
|
||||
{ appName: APP_NAME, host: HOST, apiKey: API_KEY, transport: 'ws', secure: SECURE },
|
||||
)
|
||||
_tinode.enableLogging(true, false)
|
||||
}
|
||||
return _tinode
|
||||
}
|
||||
|
||||
// Build avatar URL from Tinode photo object
|
||||
export function getAvatarUrl(photo?: { type?: string; data?: string; ref?: string; large?: { type?: string; data?: string; ref?: string } }): string | undefined {
|
||||
if (!photo) return undefined
|
||||
|
||||
// Try large version first
|
||||
if (photo.large) {
|
||||
if (photo.large.ref) return photo.large.ref
|
||||
if (photo.large.data && photo.large.type) return `data:${photo.large.type};base64,${photo.large.data}`
|
||||
}
|
||||
|
||||
// Fallback to regular photo
|
||||
if (photo.ref) return photo.ref
|
||||
if (photo.data && photo.type) return `data:${photo.type};base64,${photo.data}`
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Build file URL from Tinode file reference.
|
||||
* Handles both relative (/v0/file/s/...) and absolute URLs.
|
||||
* Adds auth query parameters so browser <img> tags can download files.
|
||||
*/
|
||||
export function getFileUrl(ref?: string): string | undefined {
|
||||
if (!ref) return undefined
|
||||
if (ref.startsWith('http')) return ref
|
||||
// Relative URL — prepend base + auth params
|
||||
const scheme = SECURE ? 'https' : 'http'
|
||||
const baseUrl = `${scheme}://${HOST}${ref.startsWith('/') ? '' : '/'}${ref}`
|
||||
|
||||
// Add auth query params for browser <img> tags (Tinode requires auth for file serving)
|
||||
const tn = getTinode()
|
||||
const authToken = tn.getAuthToken()
|
||||
if (authToken) {
|
||||
// encodeURIComponent to preserve + and / in the token
|
||||
return `${baseUrl}?apikey=${API_KEY}&auth=token&secret=${encodeURIComponent(authToken.token)}`
|
||||
}
|
||||
|
||||
return baseUrl
|
||||
}
|
||||
|
||||
// Extract display name from a Tinode contact object
|
||||
export function contactDisplayName(cont: TinodeContact): string {
|
||||
return cont.public?.fn || cont.topic || cont.name || 'Без имени'
|
||||
}
|
||||
|
||||
// Convert Drafty/plain message content to preview string
|
||||
export function draftyToText(content: unknown): string {
|
||||
if (!content) return ''
|
||||
if (typeof content === 'string') return content
|
||||
// Try Drafty toPlainText
|
||||
try {
|
||||
if (typeof Drafty !== 'undefined' && Drafty && typeof Drafty.toPlainText === 'function') {
|
||||
return Drafty.toPlainText(content) as string
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// Fallback: read txt field if present (Drafty object structure)
|
||||
try {
|
||||
const d = content as Record<string, unknown>
|
||||
if (typeof d.txt === 'string') return d.txt
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return '[вложение]'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Drafty content contains an image (IM/EX entity with mime type image/*).
|
||||
* Handles both flat format { mime, ref, val } and nested { data: { mime, ref, val } }.
|
||||
*/
|
||||
export function hasImageAttachment(content: unknown): boolean {
|
||||
if (!content || typeof content !== 'object') return false
|
||||
try {
|
||||
const d = content as Record<string, unknown>
|
||||
const ent = d.ent as Array<Record<string, unknown>> | undefined
|
||||
if (!ent) return false
|
||||
return ent.some((e) => {
|
||||
const tp = (e.tp as string) || ''
|
||||
if (tp && tp !== 'IM' && tp !== 'EX') return false
|
||||
|
||||
// Try nested data object
|
||||
const dataObj = (e.data as Record<string, unknown>) || e
|
||||
const mime = (dataObj.mime as string) || ''
|
||||
if (!mime.startsWith('image/')) return false
|
||||
return !!(dataObj.ref || dataObj.val)
|
||||
})
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract image info from Drafty content.
|
||||
* Handles both formats:
|
||||
* - Standard Tinode: { tp: "IM", data: { mime, ref, width, height } }
|
||||
* - Flat: { mime, ref, width, height } (top-level entity fields)
|
||||
*/
|
||||
export function extractImageInfo(content: unknown): { url: string; width?: number; height?: number } | null {
|
||||
if (!content || typeof content !== 'object') return null
|
||||
try {
|
||||
const d = content as Record<string, unknown>
|
||||
const ent = d.ent as Array<Record<string, unknown>> | undefined
|
||||
if (!ent) return null
|
||||
|
||||
for (const e of ent) {
|
||||
const tp = (e.tp as string) || ''
|
||||
if (tp && tp !== 'IM' && tp !== 'EX') continue
|
||||
|
||||
// Try nested data object, fallback to entity itself
|
||||
const dataObj = (e.data as Record<string, unknown>) || e
|
||||
const mime = (dataObj.mime as string) || ''
|
||||
if (!mime.startsWith('image/')) continue
|
||||
|
||||
const ref = dataObj.ref as string | undefined
|
||||
const val = dataObj.val as string | undefined
|
||||
const width = dataObj.width as number | undefined
|
||||
const height = dataObj.height as number | undefined
|
||||
|
||||
// Case 1: ref (URL from server upload)
|
||||
if (ref) {
|
||||
return {
|
||||
url: getFileUrl(ref) || ref,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: val (base64 inline data)
|
||||
if (val && typeof val === 'string') {
|
||||
if (val.startsWith('data:')) {
|
||||
return { url: val, width, height }
|
||||
}
|
||||
return {
|
||||
url: `data:${mime};base64,${val}`,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Drafty content for an image message.
|
||||
* Uses standard Tinode format: { tp: "IM" | "EX", data: { mime, ref, width, height } }
|
||||
*/
|
||||
export function createImageDrafty(
|
||||
caption: string,
|
||||
imageRef: string,
|
||||
mimeType: string,
|
||||
width?: number,
|
||||
height?: number
|
||||
): unknown {
|
||||
// Ensure ref is a relative path (strip base URL and query params if present)
|
||||
let cleanRef = imageRef
|
||||
if (cleanRef.startsWith('http')) {
|
||||
try {
|
||||
const u = new URL(cleanRef)
|
||||
cleanRef = u.pathname
|
||||
} catch { /* keep as is */ }
|
||||
}
|
||||
cleanRef = cleanRef.split('?')[0] // strip query params
|
||||
|
||||
const ent: Array<Record<string, unknown>> = [{
|
||||
tp: 'IM', // Use IM for images (not EX)
|
||||
data: {
|
||||
mime: mimeType,
|
||||
ref: cleanRef,
|
||||
...(width && { width }),
|
||||
...(height && { height }),
|
||||
},
|
||||
}]
|
||||
|
||||
if (caption.trim()) {
|
||||
return {
|
||||
txt: caption,
|
||||
fmt: [{ at: 0, len: caption.length, key: 0 }],
|
||||
ent,
|
||||
}
|
||||
}
|
||||
|
||||
// Image without caption
|
||||
return {
|
||||
txt: ' ',
|
||||
fmt: [{ at: -1, len: 0, key: 0 }],
|
||||
ent,
|
||||
}
|
||||
}
|
||||
|
||||
// Save auth token to localStorage
|
||||
export function saveAuthToken(token: { token: string; expires: Date }) {
|
||||
localStorage.setItem('lastochka-auth-token', JSON.stringify({
|
||||
token: token.token,
|
||||
expires: token.expires.toISOString(),
|
||||
}))
|
||||
}
|
||||
|
||||
// Load auth token from localStorage
|
||||
export function loadAuthToken(): { token: string; expires: Date } | null {
|
||||
try {
|
||||
const raw = localStorage.getItem('lastochka-auth-token')
|
||||
if (!raw) return null
|
||||
const obj = JSON.parse(raw) as { token: string; expires: string }
|
||||
return { token: obj.token, expires: new Date(obj.expires) }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Clear stored auth token
|
||||
export function removeAuthToken() {
|
||||
localStorage.removeItem('lastochka-auth-token')
|
||||
}
|
||||
|
||||
export type { MeTopic, Topic, TinodeContact, TinodeMessage }
|
||||
10
lastochka-ui/src/main.tsx
Normal file
10
lastochka-ui/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
262
lastochka-ui/src/store/auth.ts
Normal file
262
lastochka-ui/src/store/auth.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { create } from 'zustand'
|
||||
import {
|
||||
getTinode,
|
||||
saveAuthToken,
|
||||
loadAuthToken,
|
||||
removeAuthToken,
|
||||
getAvatarUrl,
|
||||
} from '@/lib/tinode-client'
|
||||
import { useChatStore } from './chat'
|
||||
import { cleanPhoneNumber, normalizeEmail } from '@/lib/phone-utils'
|
||||
import {
|
||||
sendEmailCode,
|
||||
verifyEmailCode,
|
||||
registerWithFullProfile,
|
||||
completeRegistration,
|
||||
checkLoginAvailability,
|
||||
} from '@/lib/email-auth'
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean
|
||||
userId: string | null
|
||||
displayName: string | null
|
||||
avatar: string | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
|
||||
// Для email-верификации
|
||||
emailForVerification: string | null
|
||||
loginForVerification: string | null
|
||||
passwordForVerification: string | null
|
||||
verificationStep: 'none' | 'email-sent' | 'verified'
|
||||
|
||||
// Вход по логину/паролю
|
||||
login: (login: string, password: string) => Promise<void>
|
||||
|
||||
// Проверка логина на доступность
|
||||
checkLogin: (login: string) => Promise<boolean>
|
||||
|
||||
// Регистрация с полным профилем (email, телефон, пароль)
|
||||
registerWithProfile: (
|
||||
login: string,
|
||||
password: string,
|
||||
email: string,
|
||||
phone: string,
|
||||
displayName: string
|
||||
) => Promise<void>
|
||||
|
||||
// Отправка email кода для регистрации
|
||||
sendRegistrationEmail: (
|
||||
login: string,
|
||||
password: string,
|
||||
email: string,
|
||||
phone: string,
|
||||
displayName: string
|
||||
) => Promise<void>
|
||||
|
||||
// Проверка email кода при регистрации
|
||||
verifyRegistrationEmail: (code: string) => Promise<void>
|
||||
|
||||
logout: () => Promise<void>
|
||||
tryAutoLogin: () => Promise<void>
|
||||
}
|
||||
|
||||
// Called after successful authentication to subscribe to the 'me' topic
|
||||
// and start receiving contact list updates.
|
||||
function onLoginSuccess() {
|
||||
const tn = getTinode()
|
||||
const userId = tn.getCurrentUserID()
|
||||
|
||||
// Get display name via me topic meta callback
|
||||
const me = tn.getMeTopic()
|
||||
me.onMetaDesc = (desc: { public?: { fn?: string; photo?: { type?: string; data?: string; ref?: string } } }) => {
|
||||
const pub = desc?.public
|
||||
if (pub?.fn) {
|
||||
useAuthStore.setState({ displayName: pub.fn })
|
||||
}
|
||||
useAuthStore.setState({ avatar: getAvatarUrl(pub?.photo) ?? null })
|
||||
}
|
||||
|
||||
useAuthStore.setState({
|
||||
isAuthenticated: true,
|
||||
userId,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
emailForVerification: null,
|
||||
loginForVerification: null,
|
||||
passwordForVerification: null,
|
||||
verificationStep: 'verified',
|
||||
})
|
||||
|
||||
// Persist auth token
|
||||
const token = tn.getAuthToken()
|
||||
if (token) saveAuthToken(token)
|
||||
|
||||
// Initialize chat store with Tinode connection
|
||||
useChatStore.getState().initFromTinode()
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
isAuthenticated: false,
|
||||
userId: null,
|
||||
displayName: null,
|
||||
avatar: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
emailForVerification: null,
|
||||
loginForVerification: null,
|
||||
passwordForVerification: null,
|
||||
verificationStep: 'none',
|
||||
|
||||
// Вход по логину/паролю (классический)
|
||||
login: async (login, password) => {
|
||||
set({ isLoading: true, error: null })
|
||||
const tn = getTinode()
|
||||
try {
|
||||
// Connect if not already connected
|
||||
if (!tn.isConnected()) {
|
||||
await tn.connect()
|
||||
}
|
||||
// Login with basic credentials
|
||||
const ctrl = await tn.loginBasic(login, password)
|
||||
if (ctrl.code >= 300) {
|
||||
set({ isLoading: false, error: 'Неверный логин или пароль' })
|
||||
return
|
||||
}
|
||||
onLoginSuccess()
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Ошибка входа'
|
||||
set({ isLoading: false, error: msg })
|
||||
}
|
||||
},
|
||||
|
||||
// Проверка логина на доступность
|
||||
checkLogin: async (login) => {
|
||||
const result = await checkLoginAvailability(login)
|
||||
return result.available
|
||||
},
|
||||
|
||||
// Отправка email для регистрации
|
||||
sendRegistrationEmail: async (login, password, email, phone, displayName) => {
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
const normalizedEmail = normalizeEmail(email)
|
||||
const cleanedPhone = cleanPhoneNumber(phone)
|
||||
|
||||
// Создаём учётную запись
|
||||
const result = await registerWithFullProfile(
|
||||
login,
|
||||
password,
|
||||
normalizedEmail,
|
||||
cleanedPhone,
|
||||
displayName
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
// Успешная регистрация - сразу логинимся
|
||||
onLoginSuccess()
|
||||
} else if (result.error) {
|
||||
set({ isLoading: false, error: result.error })
|
||||
}
|
||||
},
|
||||
|
||||
// Регистрация с полным профилем
|
||||
registerWithProfile: async (login, password, email, phone, displayName) => {
|
||||
await get().sendRegistrationEmail(login, password, email, phone, displayName)
|
||||
},
|
||||
|
||||
// Проверка email кода при регистрации
|
||||
verifyRegistrationEmail: async (code) => {
|
||||
const { emailForVerification, loginForVerification, passwordForVerification } = get()
|
||||
|
||||
if (!emailForVerification || !loginForVerification || !passwordForVerification) {
|
||||
set({ error: 'Данные регистрации не найдены' })
|
||||
return
|
||||
}
|
||||
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
// Проверяем код
|
||||
const verifyResult = await verifyEmailCode(emailForVerification, code)
|
||||
|
||||
if (!verifyResult.success) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: verifyResult.error || 'Неверный код подтверждения'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// После успешной верификации логинимся
|
||||
const loginResult = await completeRegistration(loginForVerification, passwordForVerification)
|
||||
|
||||
if (loginResult.success) {
|
||||
onLoginSuccess()
|
||||
} else {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: loginResult.error || 'Ошибка завершения регистрации'
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
const tn = getTinode()
|
||||
try {
|
||||
// Unsubscribe from active topics
|
||||
useChatStore.getState().cleanup()
|
||||
await tn.logout()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
removeAuthToken()
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
userId: null,
|
||||
displayName: null,
|
||||
error: null,
|
||||
emailForVerification: null,
|
||||
loginForVerification: null,
|
||||
passwordForVerification: null,
|
||||
verificationStep: 'none',
|
||||
})
|
||||
},
|
||||
|
||||
tryAutoLogin: async () => {
|
||||
const token = loadAuthToken()
|
||||
if (!token) return
|
||||
|
||||
set({ isLoading: true })
|
||||
const tn = getTinode()
|
||||
// Attach saved token
|
||||
tn.setAuthToken(token)
|
||||
|
||||
// Setup reconnect callback — on any connection event try to re-auth with token
|
||||
tn.onConnect = async () => {
|
||||
try {
|
||||
const ctrl = await tn.loginToken(token.token)
|
||||
if (ctrl.code < 300) {
|
||||
onLoginSuccess()
|
||||
} else {
|
||||
removeAuthToken()
|
||||
set({ isLoading: false })
|
||||
}
|
||||
} catch {
|
||||
removeAuthToken()
|
||||
set({ isLoading: false })
|
||||
}
|
||||
}
|
||||
|
||||
tn.onDisconnect = () => {
|
||||
useAuthStore.setState((s) => ({
|
||||
isAuthenticated: s.isAuthenticated, // keep auth state, we'll reconnect
|
||||
}))
|
||||
}
|
||||
|
||||
try {
|
||||
await tn.connect()
|
||||
} catch {
|
||||
set({ isLoading: false })
|
||||
}
|
||||
},
|
||||
}))
|
||||
1164
lastochka-ui/src/store/chat.ts
Normal file
1164
lastochka-ui/src/store/chat.ts
Normal file
File diff suppressed because it is too large
Load Diff
387
lastochka-ui/src/store/groups.ts
Normal file
387
lastochka-ui/src/store/groups.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import { create } from 'zustand'
|
||||
import { getTinode } from '@/lib/tinode-client'
|
||||
import type { Group, GroupMember, CreateGroupParams, User } from '@/types'
|
||||
|
||||
interface GroupsStore {
|
||||
groups: Group[]
|
||||
channels: Group[]
|
||||
selectedGroup: Group | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
|
||||
loadGroups: () => Promise<void>
|
||||
loadChannels: () => Promise<void>
|
||||
createGroup: (params: CreateGroupParams) => Promise<Group | null>
|
||||
createChannel: (params: Omit<CreateGroupParams, 'isChannel'>) => Promise<Group | null>
|
||||
selectGroup: (groupId: string) => Promise<void>
|
||||
addMember: (groupId: string, userId: string) => Promise<void>
|
||||
removeMember: (groupId: string, userId: string) => Promise<void>
|
||||
leaveGroup: (groupId: string) => Promise<void>
|
||||
deleteGroup: (groupId: string) => Promise<void>
|
||||
updateGroupInfo: (groupId: string, name: string, description?: string) => Promise<void>
|
||||
searchUsersForInvite: (query: string) => Promise<User[]>
|
||||
}
|
||||
|
||||
// Строим URL аватара из Tinode photo объекта
|
||||
function makeAvatarUrl(photo?: { type?: string; data?: string; ref?: string }): string | undefined {
|
||||
if (!photo) return undefined
|
||||
if (photo.ref) return photo.ref
|
||||
if (photo.data && photo.type) return `data:${photo.type};base64,${photo.data}`
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Преобразование Tinode topic в Group
|
||||
function topicToGroup(topic: any): Group {
|
||||
const desc = topic?.desc || {}
|
||||
const pub = desc.public || topic.public || {}
|
||||
|
||||
return {
|
||||
id: topic.name || topic.topic || '',
|
||||
name: pub.fn || topic.name || '',
|
||||
description: pub.note || '',
|
||||
avatar: makeAvatarUrl(pub.photo),
|
||||
owner: desc.owner || '',
|
||||
members: [],
|
||||
created: desc.created ? new Date(desc.created) : new Date(),
|
||||
isChannel: pub.type === 'channel',
|
||||
isPublic: pub.isPublic || false,
|
||||
membersCount: desc.acs?.count || 0,
|
||||
}
|
||||
}
|
||||
|
||||
export const useGroupsStore = create<GroupsStore>((set, get) => ({
|
||||
groups: [],
|
||||
channels: [],
|
||||
selectedGroup: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
loadGroups: async () => {
|
||||
set({ isLoading: true, error: null })
|
||||
const tn = getTinode()
|
||||
const me = tn.getMeTopic()
|
||||
|
||||
try {
|
||||
const groupsData: Group[] = []
|
||||
|
||||
me.contacts((cont: any) => {
|
||||
// Tinode группы начинаются с 'grp', каналы с 'chn'
|
||||
const topicId = cont.name || cont.topic || ''
|
||||
if (topicId.startsWith('grp')) {
|
||||
const group = topicToGroup({ name: topicId, public: cont.public })
|
||||
groupsData.push(group)
|
||||
}
|
||||
})
|
||||
|
||||
set({ groups: groupsData, isLoading: false })
|
||||
} catch (err) {
|
||||
console.error('Failed to load groups:', err)
|
||||
set({ isLoading: false, error: 'Ошибка загрузки групп' })
|
||||
}
|
||||
},
|
||||
|
||||
loadChannels: async () => {
|
||||
set({ isLoading: true, error: null })
|
||||
const tn = getTinode()
|
||||
const me = tn.getMeTopic()
|
||||
|
||||
try {
|
||||
const channelsData: Group[] = []
|
||||
|
||||
me.contacts((cont: any) => {
|
||||
// Каналы в Tinode начинаются с 'chn'
|
||||
const topicId = cont.name || cont.topic || ''
|
||||
if (topicId.startsWith('chn')) {
|
||||
const channel = topicToGroup({ name: topicId, public: cont.public })
|
||||
channelsData.push(channel)
|
||||
}
|
||||
})
|
||||
|
||||
set({ channels: channelsData, isLoading: false })
|
||||
} catch (err) {
|
||||
console.error('Failed to load channels:', err)
|
||||
set({ isLoading: false, error: 'Ошибка загрузки каналов' })
|
||||
}
|
||||
},
|
||||
|
||||
createGroup: async (params: CreateGroupParams) => {
|
||||
const tn = getTinode()
|
||||
|
||||
try {
|
||||
// Создаём новую группу: subscribe на топик с уникальным именем 'new...'
|
||||
const groupTopic = tn.getTopic((tn as any).newGroupTopicName(false))
|
||||
|
||||
await groupTopic.subscribe(null, {
|
||||
desc: {
|
||||
public: {
|
||||
fn: params.name,
|
||||
note: params.description || '',
|
||||
},
|
||||
defacs: {
|
||||
auth: 'JRWPAS',
|
||||
anon: params.isPublic ? 'JR' : 'N',
|
||||
},
|
||||
},
|
||||
sub: { mode: 'JRWPASDO' },
|
||||
})
|
||||
|
||||
// После subscribe topic.name содержит реальный ID группы
|
||||
const groupId = groupTopic.name
|
||||
|
||||
// Добавляем участников
|
||||
for (const userId of (params.members || [])) {
|
||||
await groupTopic.setMeta({ sub: { user: userId, mode: 'JRWPA' } })
|
||||
}
|
||||
|
||||
const group: Group = {
|
||||
id: groupId,
|
||||
name: params.name,
|
||||
description: params.description || '',
|
||||
owner: tn.getCurrentUserID(),
|
||||
members: [],
|
||||
created: new Date(),
|
||||
isChannel: false,
|
||||
isPublic: params.isPublic || false,
|
||||
membersCount: (params.members?.length || 0) + 1,
|
||||
}
|
||||
|
||||
set((s) => ({ groups: [...s.groups, group] }))
|
||||
// Обновляем список из Tinode (на случай задержки синхронизации)
|
||||
setTimeout(() => get().loadGroups(), 1000)
|
||||
return group
|
||||
} catch (err) {
|
||||
console.error('Failed to create group:', err)
|
||||
set({ error: 'Ошибка создания группы' })
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
createChannel: async (params: Omit<CreateGroupParams, 'isChannel'>) => {
|
||||
const tn = getTinode()
|
||||
|
||||
try {
|
||||
// Каналы в Tinode — это 'chn' топики, создаются через 'nch...'
|
||||
const channelTopic = tn.getTopic((tn as any).newGroupTopicName(true))
|
||||
|
||||
await channelTopic.subscribe(null, {
|
||||
desc: {
|
||||
public: {
|
||||
fn: params.name,
|
||||
note: params.description || '',
|
||||
},
|
||||
defacs: {
|
||||
auth: params.isPublic ? 'JRWPA' : 'N',
|
||||
anon: params.isPublic ? 'JR' : 'N',
|
||||
},
|
||||
},
|
||||
sub: { mode: 'JRWPASDO' },
|
||||
})
|
||||
|
||||
const channelId = channelTopic.name
|
||||
|
||||
// Добавляем участников как подписчиков (read-only)
|
||||
for (const userId of (params.members || [])) {
|
||||
await channelTopic.setMeta({ sub: { user: userId, mode: params.isPublic ? 'JR' : 'N' } })
|
||||
}
|
||||
|
||||
const channel: Group = {
|
||||
id: channelId,
|
||||
name: params.name,
|
||||
description: params.description || '',
|
||||
owner: tn.getCurrentUserID(),
|
||||
members: [],
|
||||
created: new Date(),
|
||||
isChannel: true,
|
||||
isPublic: params.isPublic || false,
|
||||
membersCount: (params.members?.length || 0) + 1,
|
||||
}
|
||||
|
||||
set((s) => ({ channels: [...s.channels, channel] }))
|
||||
setTimeout(() => get().loadChannels(), 1000)
|
||||
return channel
|
||||
} catch (err) {
|
||||
console.error('Failed to create channel:', err)
|
||||
set({ error: 'Ошибка создания канала' })
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
selectGroup: async (groupId: string) => {
|
||||
const tn = getTinode()
|
||||
const topic = tn.getTopic(groupId)
|
||||
|
||||
try {
|
||||
if (!topic.isSubscribed()) {
|
||||
await topic.subscribe(
|
||||
topic.startMetaQuery().withDesc().withSub().build()
|
||||
)
|
||||
} else {
|
||||
// Refresh subscribers metadata if already subscribed
|
||||
await (topic as any).getMeta(topic.startMetaQuery().withSub().build())
|
||||
}
|
||||
|
||||
const members: GroupMember[] = []
|
||||
// Group topics use subscribers(), not contacts()
|
||||
;(topic as any).subscribers((sub: any) => {
|
||||
const userId = sub.user || ''
|
||||
if (!userId) return
|
||||
members.push({
|
||||
userId,
|
||||
name: (sub.public as any)?.fn || userId,
|
||||
avatar: makeAvatarUrl((sub.public as any)?.photo),
|
||||
role: sub.acs?.isOwner?.() ? 'owner' : sub.acs?.isAdmin?.() ? 'admin' : 'member',
|
||||
joined: sub.updated ? new Date(sub.updated) : new Date(),
|
||||
online: sub.online || false,
|
||||
})
|
||||
})
|
||||
|
||||
// topic.public is the direct property in Tinode SDK
|
||||
const pub = (topic as any).public || {}
|
||||
|
||||
const group: Group = {
|
||||
id: groupId,
|
||||
name: pub.fn || groupId,
|
||||
description: pub.note || '',
|
||||
avatar: makeAvatarUrl(pub.photo),
|
||||
owner: '',
|
||||
members,
|
||||
created: new Date(),
|
||||
isChannel: groupId.startsWith('chn'),
|
||||
isPublic: pub.isPublic || false,
|
||||
membersCount: members.length,
|
||||
}
|
||||
|
||||
set({ selectedGroup: group })
|
||||
} catch (err) {
|
||||
console.error('Failed to select group:', err)
|
||||
set({ error: 'Ошибка загрузки информации о группе' })
|
||||
}
|
||||
},
|
||||
|
||||
addMember: async (groupId: string, userId: string) => {
|
||||
const tn = getTinode()
|
||||
const topic = tn.getTopic(groupId)
|
||||
|
||||
try {
|
||||
// Приглашаем пользователя через setMeta sub
|
||||
await topic.setMeta({ sub: { user: userId, mode: 'JRWPA' } })
|
||||
// Обновляем список участников
|
||||
await get().selectGroup(groupId)
|
||||
} catch (err) {
|
||||
console.error('Failed to add member:', err)
|
||||
set({ error: 'Ошибка добавления участника' })
|
||||
}
|
||||
},
|
||||
|
||||
removeMember: async (groupId: string, userId: string) => {
|
||||
const tn = getTinode()
|
||||
const topic = tn.getTopic(groupId)
|
||||
|
||||
try {
|
||||
await (topic as any).delSubscription(userId)
|
||||
await get().selectGroup(groupId)
|
||||
} catch (err) {
|
||||
console.error('Failed to remove member:', err)
|
||||
set({ error: 'Ошибка удаления участника' })
|
||||
}
|
||||
},
|
||||
|
||||
leaveGroup: async (groupId: string) => {
|
||||
const tn = getTinode()
|
||||
const topic = tn.getTopic(groupId)
|
||||
|
||||
try {
|
||||
await topic.leave(true) // true = unsubscribe
|
||||
set((s) => ({
|
||||
groups: s.groups.filter(g => g.id !== groupId),
|
||||
channels: s.channels.filter(c => c.id !== groupId),
|
||||
selectedGroup: s.selectedGroup?.id === groupId ? null : s.selectedGroup,
|
||||
}))
|
||||
} catch (err) {
|
||||
console.error('Failed to leave group:', err)
|
||||
set({ error: 'Ошибка выхода из группы' })
|
||||
}
|
||||
},
|
||||
|
||||
deleteGroup: async (groupId: string) => {
|
||||
const tn = getTinode()
|
||||
const topic = tn.getTopic(groupId)
|
||||
|
||||
try {
|
||||
await topic.delTopic(true) // hard delete
|
||||
set((s) => ({
|
||||
groups: s.groups.filter(g => g.id !== groupId),
|
||||
channels: s.channels.filter(c => c.id !== groupId),
|
||||
selectedGroup: s.selectedGroup?.id === groupId ? null : s.selectedGroup,
|
||||
}))
|
||||
} catch (err) {
|
||||
console.error('Failed to delete group:', err)
|
||||
set({ error: 'Ошибка удаления группы' })
|
||||
}
|
||||
},
|
||||
|
||||
updateGroupInfo: async (groupId: string, name: string, description?: string) => {
|
||||
const tn = getTinode()
|
||||
const topic = tn.getTopic(groupId)
|
||||
|
||||
try {
|
||||
await topic.setMeta({
|
||||
desc: {
|
||||
public: {
|
||||
fn: name,
|
||||
note: description,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
set((s) => ({
|
||||
groups: s.groups.map(g =>
|
||||
g.id === groupId ? { ...g, name, description } : g
|
||||
),
|
||||
channels: s.channels.map(c =>
|
||||
c.id === groupId ? { ...c, name, description } : c
|
||||
),
|
||||
selectedGroup: s.selectedGroup?.id === groupId
|
||||
? { ...s.selectedGroup, name, description }
|
||||
: s.selectedGroup,
|
||||
}))
|
||||
} catch (err) {
|
||||
console.error('Failed to update group info:', err)
|
||||
set({ error: 'Ошибка обновления информации' })
|
||||
}
|
||||
},
|
||||
|
||||
searchUsersForInvite: async (query: string) => {
|
||||
const tn = getTinode()
|
||||
const fnd = tn.getFndTopic()
|
||||
|
||||
try {
|
||||
if (!fnd.isSubscribed()) {
|
||||
await fnd.subscribe(fnd.startMetaQuery().withSub().build())
|
||||
}
|
||||
|
||||
// Tinode ищет по тегам. Логин хранится как тег basic:<login>.
|
||||
// Если запрос без namespace-префикса — добавляем basic:
|
||||
const tagQuery = query.includes(':') ? query : `basic:${query}`
|
||||
await fnd.setMeta({ desc: { public: tagQuery } })
|
||||
await (fnd as any).getMeta(fnd.startMetaQuery().withSub().build())
|
||||
|
||||
const users: User[] = []
|
||||
fnd.contacts((sub: any) => {
|
||||
const id = sub.user || sub.topic || ''
|
||||
if (!id) return
|
||||
users.push({
|
||||
id,
|
||||
name: (sub.public as any)?.fn || id,
|
||||
avatar: makeAvatarUrl((sub.public as any)?.photo),
|
||||
online: sub.online || false,
|
||||
})
|
||||
})
|
||||
|
||||
return users
|
||||
} catch (err) {
|
||||
console.error('Failed to search users:', err)
|
||||
return []
|
||||
}
|
||||
},
|
||||
}))
|
||||
150
lastochka-ui/src/tinode.d.ts
vendored
Normal file
150
lastochka-ui/src/tinode.d.ts
vendored
Normal file
@@ -0,0 +1,150 @@
|
||||
// Vite environment variables
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_TINODE_HOST: string
|
||||
readonly VITE_TINODE_API_KEY: string
|
||||
readonly VITE_TINODE_SECURE: string
|
||||
readonly VITE_APP_NAME: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
declare module 'tinode-sdk' {
|
||||
interface TinodeConfig {
|
||||
appName: string
|
||||
host: string
|
||||
apiKey: string
|
||||
transport?: string
|
||||
secure?: boolean
|
||||
persist?: boolean
|
||||
}
|
||||
|
||||
interface MetaQueryBuilder {
|
||||
withLaterSub(): this
|
||||
withLaterDesc(): this
|
||||
withDesc(): this
|
||||
withTags(): this
|
||||
withCred(): this
|
||||
withSub(): this
|
||||
withLaterData(count?: number): this
|
||||
withLaterDel(): this
|
||||
withAux(): this
|
||||
build(): unknown
|
||||
}
|
||||
|
||||
export interface TinodeContact {
|
||||
topic: string
|
||||
name?: string
|
||||
public?: { fn?: string; photo?: { type?: string; data?: string; ref?: string; large?: { type?: string; data?: string; ref?: string } } }
|
||||
private?: { comment?: string }
|
||||
online?: boolean
|
||||
unread?: number
|
||||
touched?: Date
|
||||
acs?: unknown
|
||||
}
|
||||
|
||||
export interface TinodeMessage {
|
||||
seq: number
|
||||
from: string
|
||||
ts: Date
|
||||
content: string | unknown
|
||||
head?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface MeTopic {
|
||||
onMetaDesc: ((desc: unknown) => void) | undefined
|
||||
onContactUpdate: ((what: string, cont: TinodeContact) => void) | undefined
|
||||
onSubsUpdated: (() => void) | undefined
|
||||
subscribe(query: unknown): Promise<unknown>
|
||||
contacts(callback: (cont: TinodeContact) => void): void
|
||||
startMetaQuery(): MetaQueryBuilder
|
||||
isSubscribed(): boolean
|
||||
pinnedTopicRank(topic: string): number
|
||||
setMeta(params: unknown): Promise<unknown>
|
||||
}
|
||||
|
||||
export interface Topic {
|
||||
name: string // Topic ID, assigned after subscribe('new')
|
||||
onData: ((msg: TinodeMessage) => void) | undefined
|
||||
onAllMessagesReceived: ((count: number) => void) | undefined
|
||||
onInfo: ((msg: unknown) => void) | undefined
|
||||
onMetaDesc: ((desc: unknown) => void) | undefined
|
||||
subscribe(getQuery: unknown, setQuery?: unknown): Promise<unknown>
|
||||
publishMessage(msg: unknown): Promise<unknown>
|
||||
createMessage(content: string | unknown, noEcho?: boolean): unknown
|
||||
setMeta(params: unknown): Promise<unknown>
|
||||
startMetaQuery(): MetaQueryBuilder
|
||||
isSubscribed(): boolean
|
||||
getMessagesPage(count: number, gaps?: unknown, min?: number, max?: number): Promise<void>
|
||||
msgHasMoreMessages(min: number, max: number, desc: boolean): unknown[]
|
||||
messages(callback: (msg: TinodeMessage) => void, start?: number, end?: number): void
|
||||
contacts(callback: (cont: TinodeContact) => void): void
|
||||
leave(unsub?: boolean): Promise<unknown>
|
||||
delTopic(hard?: boolean): Promise<unknown>
|
||||
isArchived(): boolean
|
||||
getDesc(): { unread?: number; [key: string]: unknown } | null
|
||||
noteRead(): void
|
||||
noteKeyPress(): void
|
||||
}
|
||||
|
||||
export class Tinode {
|
||||
constructor(config: TinodeConfig, onSetup?: (err?: Error) => void)
|
||||
|
||||
onConnect: (() => void) | undefined
|
||||
onDisconnect: ((err?: Error) => void) | undefined
|
||||
onAutoreconnectIteration: ((sec: number, prom?: Promise<unknown>) => void) | undefined
|
||||
|
||||
connect(host?: string): Promise<unknown>
|
||||
disconnect(): void
|
||||
reconnect(): void
|
||||
logout(): Promise<unknown>
|
||||
|
||||
// Account creation
|
||||
acc(params: {
|
||||
user?: string
|
||||
scheme?: string
|
||||
secret?: string
|
||||
login?: boolean
|
||||
desc?: {
|
||||
public?: { fn?: string }
|
||||
private?: { email?: string }
|
||||
}
|
||||
}): Promise<{ code: number; text: string; params?: unknown }>
|
||||
|
||||
createAccountBasic(login: string, password: string, params?: { public?: unknown; tags?: string[]; login?: boolean; cred?: unknown; private?: unknown }): Promise<{ code: number; text: string; params?: unknown }>
|
||||
loginBasic(login: string, password: string, cred?: unknown): Promise<{ code: number; text: string; params?: unknown }>
|
||||
loginToken(token: string, cred?: unknown): Promise<{ code: number; text: string; params?: unknown }>
|
||||
|
||||
isConnected(): boolean
|
||||
isAuthenticated(): boolean
|
||||
getCurrentUserID(): string
|
||||
getAuthToken(): { token: string; expires: Date } | null
|
||||
setAuthToken(token: { token: string; expires: Date }): void
|
||||
getServerInfo(): { ver: string; build?: string; reqCred?: unknown }
|
||||
|
||||
getMeTopic(): MeTopic
|
||||
getTopic(name: string): Topic
|
||||
getFndTopic(): Topic
|
||||
|
||||
enableLogging(enable: boolean, verbose?: boolean): void
|
||||
setHumanLanguage(lang: string): void
|
||||
setDeviceToken(token: string): void
|
||||
getTopicAccessMode(topicName: string): unknown
|
||||
|
||||
initStorage(): Promise<void>
|
||||
clearStorage(): Promise<void>
|
||||
|
||||
static topicType(name: string): string | undefined
|
||||
static isP2PTopicName(name: string): boolean
|
||||
static isSelfTopicName(name: string): boolean
|
||||
static credential(cred?: unknown): unknown
|
||||
}
|
||||
|
||||
export class Drafty {
|
||||
static isPlainText(content: unknown): boolean
|
||||
static toPlainText(content: unknown): string
|
||||
static getContentType(content: unknown): string | null
|
||||
}
|
||||
|
||||
}
|
||||
104
lastochka-ui/src/types/index.ts
Normal file
104
lastochka-ui/src/types/index.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
export interface User {
|
||||
id: string
|
||||
name: string
|
||||
avatar?: string
|
||||
online?: boolean
|
||||
lastSeen?: Date
|
||||
bio?: string
|
||||
phone?: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
chatId: string
|
||||
senderId: string
|
||||
senderName?: string
|
||||
text: string
|
||||
ts: Date
|
||||
read?: boolean
|
||||
edited?: boolean
|
||||
deleted?: boolean
|
||||
// Reply
|
||||
replyTo?: {
|
||||
seq: number
|
||||
senderName: string
|
||||
text: string
|
||||
}
|
||||
// Tinode seq for API operations
|
||||
seq?: number
|
||||
// Image / media
|
||||
imageUrl?: string // URL изображения (полный или относительный)
|
||||
imageWidth?: number // Ширина изображения
|
||||
imageHeight?: number // Высота изображения
|
||||
imageThumbnail?: string // Base64 превью или data URL
|
||||
duration?: number // Duration in seconds (for audio/video)
|
||||
attachments?: Attachment[]
|
||||
hasMedia?: boolean
|
||||
// Upload progress (для optimistically добавленных сообщений)
|
||||
uploadProgress?: number // 0..1, undefined если не загружается
|
||||
uploadFailed?: boolean // true если загрузка не удалась
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
type: 'image' | 'video' | 'audio' | 'file'
|
||||
name: string
|
||||
size?: number
|
||||
url?: string
|
||||
previewUrl?: string
|
||||
}
|
||||
|
||||
export interface Chat {
|
||||
id: string
|
||||
name: string
|
||||
avatar?: string
|
||||
lastMessage?: string
|
||||
lastMessageTs?: Date
|
||||
unread?: number
|
||||
online?: boolean
|
||||
isGroup?: boolean
|
||||
isChannel?: boolean
|
||||
pinned?: boolean
|
||||
muted?: boolean
|
||||
membersCount?: number
|
||||
description?: string
|
||||
isOnline?: boolean
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
avatar?: string
|
||||
owner: string
|
||||
members: GroupMember[]
|
||||
created: Date
|
||||
isChannel: boolean
|
||||
isPublic: boolean
|
||||
membersCount: number
|
||||
}
|
||||
|
||||
export interface GroupMember {
|
||||
userId: string
|
||||
name: string
|
||||
avatar?: string
|
||||
role: 'owner' | 'admin' | 'member'
|
||||
joined: Date
|
||||
online?: boolean
|
||||
}
|
||||
|
||||
export interface CreateGroupParams {
|
||||
name: string
|
||||
description?: string
|
||||
isChannel: boolean
|
||||
isPublic: boolean
|
||||
members: string[] // user IDs
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
users: User[]
|
||||
groups: Group[]
|
||||
channels: Group[]
|
||||
messages: Message[]
|
||||
}
|
||||
96
lastochka-ui/tailwind.config.ts
Normal file
96
lastochka-ui/tailwind.config.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// ─── Бренд-цвета Ласточки ───────────────────────────
|
||||
brand: {
|
||||
DEFAULT: '#2AABEE', // Telegram-like blue
|
||||
dark: '#1e96d4',
|
||||
light: '#d6eef8',
|
||||
},
|
||||
// ─── Пузыри сообщений ────────────────────────────────
|
||||
bubble: {
|
||||
own: '#EFFDDE', // своё сообщение (светло-зелёный)
|
||||
peer: '#FFFFFF', // чужое сообщение
|
||||
'own-dark': '#2b5278',
|
||||
'peer-dark': '#182533',
|
||||
},
|
||||
// ─── Фоны ────────────────────────────────────────────
|
||||
sidebar: { DEFAULT: '#FFFFFF', dark: '#17212b' },
|
||||
chat: { DEFAULT: '#EFEFF3', dark: '#0e1621' },
|
||||
header: { DEFAULT: '#FFFFFF', dark: '#17212b' },
|
||||
input: { DEFAULT: '#FFFFFF', dark: '#17212b' },
|
||||
'input-field': { DEFAULT: '#f0f0f0', dark: '#242f3d' },
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto',
|
||||
'"Helvetica Neue"', 'Arial', 'sans-serif'],
|
||||
},
|
||||
borderRadius: {
|
||||
bubble: '18px',
|
||||
'bubble-sm': '4px',
|
||||
},
|
||||
boxShadow: {
|
||||
bubble: '0 1px 2px rgba(0,0,0,0.06), 0 2px 6px rgba(0,0,0,0.04)',
|
||||
panel: '0 1px 3px rgba(0,0,0,0.04), 0 2px 12px rgba(0,0,0,0.04)',
|
||||
glass: '0 8px 32px rgba(0,0,0,0.08), 0 2px 8px rgba(0,0,0,0.04)',
|
||||
'glass-lg':'0 16px 48px rgba(0,0,0,0.12), 0 4px 16px rgba(0,0,0,0.06)',
|
||||
fab: '0 4px 16px rgba(0,0,0,0.12), 0 2px 6px rgba(0,0,0,0.08)',
|
||||
},
|
||||
animation: {
|
||||
'slide-up': 'slideUp 0.35s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
'slide-down': 'slideDown 0.25s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
'fade-in': 'fadeIn 0.2s ease-out',
|
||||
'scale-in': 'scaleIn 0.2s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
'bounce-in': 'bounceIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'float': 'float 6s ease-in-out infinite',
|
||||
'shimmer': 'shimmer 2s linear infinite',
|
||||
},
|
||||
keyframes: {
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(16px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
slideDown: {
|
||||
'0%': { transform: 'translateY(-8px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
scaleIn: {
|
||||
'0%': { transform: 'scale(0.92)', opacity: '0' },
|
||||
'100%': { transform: 'scale(1)', opacity: '1' },
|
||||
},
|
||||
bounceIn: {
|
||||
'0%': { transform: 'scale(0.3)', opacity: '0' },
|
||||
'50%': { transform: 'scale(1.06)' },
|
||||
'70%': { transform: 'scale(0.96)' },
|
||||
'100%': { transform: 'scale(1)', opacity: '1' },
|
||||
},
|
||||
float: {
|
||||
'0%, 100%': { transform: 'translateY(0px)' },
|
||||
'50%': { transform: 'translateY(-8px)' },
|
||||
},
|
||||
shimmer: {
|
||||
'0%': { backgroundPosition: '-200% 0' },
|
||||
'100%': { backgroundPosition: '200% 0' },
|
||||
},
|
||||
},
|
||||
backdropBlur: {
|
||||
xs: '2px',
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
'spring': 'cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
'smooth': 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config
|
||||
25
lastochka-ui/tsconfig.json
Normal file
25
lastochka-ui/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
lastochka-ui/tsconfig.node.json
Normal file
10
lastochka-ui/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts", "tailwind.config.ts", "postcss.config.js"]
|
||||
}
|
||||
17
lastochka-ui/vite.config.ts
Normal file
17
lastochka-ui/vite.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: { '@': '/src' },
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/v1': {
|
||||
target: 'http://localhost:6060',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user