first commit

This commit is contained in:
Anton Budylin
2026-04-14 10:12:51 +03:00
commit ea171ed95a
247 changed files with 42642 additions and 0 deletions

11
lastochka-ui/.env.example Normal file
View 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=Ласточка

View File

@@ -0,0 +1,2 @@
VITE_TINODE_HOST=app.lastochka-m.ru
VITE_TINODE_SECURE=true

332
lastochka-ui/AUTH.md Normal file
View 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
View 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
View 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>)
```
---
**Ласточка** — народный мессенджер с открытым кодом 🕊️

View 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
View 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 пакеты, потом локальные файлы
---
**Ласточка** — народный мессенджер с открытым кодом 🕊️
*Сделано с ❤️ для свободного общения*

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

30
lastochka-ui/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

131
lastochka-ui/src/App.tsx Normal file
View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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)}
/>
)}
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)}
</>
)
}

View 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>
)}
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)}
</>
)
}

View 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

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
}

View 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
View 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;
}

View 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 }
}
}

View 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
})
}

View 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)
}

View 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 }
}
}

View 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
View 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>,
)

View 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 })
}
},
}))

File diff suppressed because it is too large Load Diff

View 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
View 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
}
}

View 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[]
}

View 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

View 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" }]
}

View 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"]
}

View 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,
},
},
},
})