Reorganize web folder structurally
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type NotificationType = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface NotificationState {
|
||||
notifications: Notification[];
|
||||
addNotification: (type: NotificationType, message: string, duration?: number) => void;
|
||||
removeNotification: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useNotificationStore = create<NotificationState>((set) => ({
|
||||
notifications: [],
|
||||
addNotification: (type, message, duration = 5000) => {
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
set((state) => ({
|
||||
notifications: [...state.notifications, { id, type, message, duration }],
|
||||
}));
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
set((state) => ({
|
||||
notifications: state.notifications.filter((n) => n.id !== id),
|
||||
}));
|
||||
}, duration);
|
||||
}
|
||||
},
|
||||
removeNotification: (id) =>
|
||||
set((state) => ({
|
||||
notifications: state.notifications.filter((n) => n.id !== id),
|
||||
})),
|
||||
}));
|
||||
@@ -0,0 +1,21 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export type ChatTheme = 'midnight' | 'ocean' | 'forest' | 'sunset' | 'classic' | 'neon' | 'aurora' | 'cyber' | 'glass' | 'void';
|
||||
|
||||
interface ThemeState {
|
||||
chatTheme: ChatTheme;
|
||||
setChatTheme: (theme: ChatTheme) => void;
|
||||
}
|
||||
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
chatTheme: 'midnight',
|
||||
setChatTheme: (theme) => set({ chatTheme: theme }),
|
||||
}),
|
||||
{
|
||||
name: 'knot-theme-storage',
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,183 @@
|
||||
// ─── User types ────────────────────────────────────────────────────────
|
||||
|
||||
export interface UserBasic {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
export interface UserPresence extends UserBasic {
|
||||
isOnline: boolean;
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
export interface User extends UserPresence {
|
||||
bio: string | null;
|
||||
birthday: string | null;
|
||||
createdAt: string;
|
||||
hideStoryViews?: boolean;
|
||||
}
|
||||
|
||||
// ─── Chat types ────────────────────────────────────────────────────────
|
||||
|
||||
export interface ChatMember {
|
||||
id: string;
|
||||
userId: string;
|
||||
role: string;
|
||||
isPinned?: boolean;
|
||||
isMuted?: boolean;
|
||||
isArchived?: boolean;
|
||||
clearedAt?: string | null;
|
||||
user: UserPresence;
|
||||
}
|
||||
|
||||
export interface MediaItem {
|
||||
id: string;
|
||||
type: string;
|
||||
url: string;
|
||||
filename: string | null;
|
||||
thumbnail: string | null;
|
||||
size: number | null;
|
||||
duration: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
}
|
||||
|
||||
export interface Reaction {
|
||||
id: string;
|
||||
emoji: string;
|
||||
userId: string;
|
||||
user: { id: string; username: string; displayName: string; avatar?: string | null };
|
||||
}
|
||||
|
||||
export interface MessageSender {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatar?: string | null;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
chatId: string;
|
||||
senderId: string;
|
||||
content: string | null;
|
||||
type: string;
|
||||
replyToId: string | null;
|
||||
quote?: string | null;
|
||||
forwardedFromId?: string | null;
|
||||
storyId?: string | null;
|
||||
storyMediaUrl?: string | null;
|
||||
storyMediaType?: string | null;
|
||||
isEdited: boolean;
|
||||
isDeleted: boolean;
|
||||
scheduledAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
sender: MessageSender;
|
||||
replyTo?: {
|
||||
id: string;
|
||||
content: string | null;
|
||||
isDeleted?: boolean;
|
||||
quote?: string | null;
|
||||
media?: MediaItem[];
|
||||
sender: { id: string; username: string; displayName: string };
|
||||
} | null;
|
||||
forwardedFrom?: UserBasic | null;
|
||||
media: MediaItem[];
|
||||
reactions: Reaction[];
|
||||
readBy: Array<{ userId: string }>;
|
||||
}
|
||||
|
||||
export interface Chat {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string | null;
|
||||
description?: string | null;
|
||||
avatar: string | null;
|
||||
createdAt: string;
|
||||
members: ChatMember[];
|
||||
messages: Message[];
|
||||
unreadCount: number;
|
||||
pinnedMessages?: Array<{
|
||||
id: string;
|
||||
message: Message;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ─── Socket event types ────────────────────────────────────────────────
|
||||
|
||||
export interface TypingUser {
|
||||
chatId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface CallInfo {
|
||||
from: string;
|
||||
offer: RTCSessionDescriptionInit;
|
||||
callType: 'voice' | 'video';
|
||||
chatId: string;
|
||||
callerInfo?: UserBasic | null;
|
||||
}
|
||||
|
||||
// ─── Story types ───────────────────────────────────────────────────────
|
||||
|
||||
export interface Story {
|
||||
id: string;
|
||||
type: string;
|
||||
mediaUrl: string | null;
|
||||
content: string | null;
|
||||
bgColor: string | null;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
viewCount: number;
|
||||
viewed: boolean;
|
||||
reactions?: Array<{ id: string; userId: string; emoji: string; createdAt: string }>;
|
||||
replyCount?: number;
|
||||
}
|
||||
|
||||
export interface StoryViewer {
|
||||
userId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatar: string | null;
|
||||
viewedAt: string;
|
||||
}
|
||||
|
||||
export interface StoryGroup {
|
||||
user: UserBasic;
|
||||
stories: Story[];
|
||||
hasUnviewed: boolean;
|
||||
}
|
||||
|
||||
// ─── Utility types ─────────────────────────────────────────────────
|
||||
|
||||
// ─── Friend types ──────────────────────────────────────────────────
|
||||
|
||||
export interface FriendRequest {
|
||||
id: string;
|
||||
user: User;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface FriendWithId extends UserPresence {
|
||||
friendshipId: string;
|
||||
}
|
||||
|
||||
export interface FriendshipStatus {
|
||||
status: 'none' | 'pending' | 'accepted' | 'declined' | 'self';
|
||||
friendshipId?: string | null;
|
||||
direction?: 'incoming' | 'outgoing';
|
||||
}
|
||||
|
||||
// ─── Utility types ─────────────────────────────────────────────────────
|
||||
|
||||
/** Audio file extensions recognized by the app. */
|
||||
export const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac', '.wma'] as const;
|
||||
|
||||
/** Max file size for uploads (200MB). */
|
||||
export const MAX_FILE_SIZE = 200 * 1024 * 1024;
|
||||
|
||||
/** Max avatar size (5MB). */
|
||||
export const MAX_AVATAR_SIZE = 5 * 1024 * 1024;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { httpClient } from './httpClient';
|
||||
|
||||
export class AppApi {
|
||||
static async analyzeTelegramImport(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return httpClient.request('/import/telegram/analyze', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
static async executeTelegramImport(req: { token: string; mapping: Record<string, string>; groupName?: string }) {
|
||||
return httpClient.request('/import/telegram/execute', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
}
|
||||
|
||||
static async getTrendingGifs() {
|
||||
return httpClient.request<any>('/klipy/trending');
|
||||
}
|
||||
|
||||
static async searchKlipyGifs(query: string) {
|
||||
return httpClient.request<any>(`/klipy/search?q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
const API_BASE = '/api';
|
||||
|
||||
export class HttpClient {
|
||||
private token: string | null = null;
|
||||
|
||||
setToken(token: string | null) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
async request<T>(endpoint: string, options: RequestInit & { timeout?: number } = {}): Promise<T> {
|
||||
const { timeout = 30_000, ...fetchOptions } = options;
|
||||
const controller = new AbortController();
|
||||
const timer = timeout > 0 ? setTimeout(() => controller.abort(), timeout) : undefined;
|
||||
|
||||
const isFormData = fetchOptions.body instanceof FormData;
|
||||
const computedHeaders: Record<string, string> = {
|
||||
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
||||
...(fetchOptions.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
if (!isFormData && !computedHeaders['Content-Type']) {
|
||||
computedHeaders['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...fetchOptions,
|
||||
headers: computedHeaders,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
throw new Error('Время ожидания запроса истекло');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const errorMessage = errorData.error || errorData.message || 'Ошибка запроса';
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
export const httpClient = new HttpClient();
|
||||
@@ -0,0 +1,565 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type Lang = 'ru' | 'en';
|
||||
|
||||
const LANG_KEY = 'knot_language';
|
||||
|
||||
const translations = {
|
||||
ru: {
|
||||
// Side menu
|
||||
myProfile: 'Мой профиль',
|
||||
settings: 'Настройки',
|
||||
aboutApp: 'О приложении',
|
||||
logout: 'Выйти из аккаунта',
|
||||
// Profile
|
||||
name: 'Имя',
|
||||
username: 'Имя пользователя',
|
||||
aboutMe: 'О себе',
|
||||
birthday: 'Дата рождения',
|
||||
enterName: 'Введите имя',
|
||||
tellAboutYourself: 'Расскажите о себе',
|
||||
removePhoto: 'Удалить фото',
|
||||
// Settings
|
||||
language: 'Язык / Language',
|
||||
interfaceLang: 'Язык интерфейса',
|
||||
theme: 'Оформление',
|
||||
russian: 'Русский',
|
||||
english: 'English',
|
||||
about: 'О приложении',
|
||||
version: 'Версия',
|
||||
modernMessenger: 'Современный мессенджер с фокусом',
|
||||
onPrivacy: 'на приватность и удобство',
|
||||
modernMessengerShort: 'Современный мессенджер',
|
||||
// Chat
|
||||
chat: 'Чат',
|
||||
group: 'Группа',
|
||||
searchChats: 'Поиск чатов...',
|
||||
noChats: 'Нет чатов — создайте первый!',
|
||||
nothingFound: 'Ничего не найдено',
|
||||
selectChat: 'Выберите чат для начала общения',
|
||||
noMessages: 'Нет сообщений — напишите первое!',
|
||||
typing: 'печатает...',
|
||||
online: 'в сети',
|
||||
wasRecently: 'был(а) недавно',
|
||||
members: 'участников',
|
||||
files: 'файлов',
|
||||
clearAll: 'Очистить всё',
|
||||
messagePlaceholder: 'Сообщение...',
|
||||
message: 'Сообщение...',
|
||||
addCaption: 'Добавьте подпись...',
|
||||
searchMessages: 'Поиск сообщений...',
|
||||
searchMessagesBtn: 'Поиск сообщений',
|
||||
userProfile: 'Профиль пользователя',
|
||||
enableSound: 'Включить звук',
|
||||
disableSound: 'Выключить звук',
|
||||
deleteChat: 'Удалить чат',
|
||||
// Messages
|
||||
messageDeleted: 'Сообщение удалено',
|
||||
reply: 'Ответить',
|
||||
copy: 'Копировать',
|
||||
edit: 'Редактировать',
|
||||
delete: 'Удалить',
|
||||
deleteForMe: 'Удалить у меня',
|
||||
deleteForAll: 'Удалить у всех',
|
||||
deleteAlsoFor: 'Удалить также для',
|
||||
editing: 'Редактирование',
|
||||
replyTo: 'Ответ',
|
||||
media: 'Медиа',
|
||||
download: 'Скачать',
|
||||
edited: 'ред.',
|
||||
selected: 'выбрано',
|
||||
fileLabel: 'Файл',
|
||||
kb: 'КБ',
|
||||
voice: '🎤 Голосовое',
|
||||
photo: '🖼 Фото',
|
||||
video: '🎬 Видео',
|
||||
file: 'Файл',
|
||||
// Call
|
||||
call: 'Звонок',
|
||||
videoCall: 'Видеозвонок',
|
||||
incomingCall: 'Входящий звонок',
|
||||
incomingVideoCall: 'Входящий видеозвонок',
|
||||
calling: 'Вызов...',
|
||||
accept: 'Принять',
|
||||
decline: 'Отклонить',
|
||||
endCall: 'Завершить',
|
||||
callEnded: 'Звонок завершён',
|
||||
callDeclined: 'Звонок отклонён',
|
||||
// Photo/video
|
||||
photoVideo: 'Фото / видео',
|
||||
fileBtn: 'Файл',
|
||||
sendError: 'Ошибка отправки',
|
||||
// New chat
|
||||
newChat: 'Новый чат',
|
||||
menu: 'Меню',
|
||||
// Date picker
|
||||
months: ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'],
|
||||
weekDays: ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'],
|
||||
clear: 'Очистить',
|
||||
today: 'Сегодня',
|
||||
// New chat
|
||||
newChatTitle: 'Новый чат',
|
||||
selectMembers: 'Выберите участников',
|
||||
newGroup: 'Новая группа',
|
||||
groupNamePlaceholder: 'Название группы...',
|
||||
membersCount: 'Участники',
|
||||
createGroup: 'Создать группу',
|
||||
upTo200: 'До 200 участников',
|
||||
findUser: 'Найти пользователя по имени или @username...',
|
||||
addMembers: 'Добавить участников...',
|
||||
usersNotFound: 'Пользователи не найдены',
|
||||
enterNameOrUsername: 'Введите имя или @username',
|
||||
next: 'Далее',
|
||||
pinMessage: 'Закрепить',
|
||||
unpinMessage: 'Открепить',
|
||||
pinnedMessage: 'Закреплённое сообщение',
|
||||
forwardMessage: 'Переслать сообщение',
|
||||
forward: 'Переслать',
|
||||
forwardedFrom: 'Переслано от',
|
||||
replyWithQuote: 'Ответить с цитатой',
|
||||
select: 'Выбрать',
|
||||
sending: 'Отправка...',
|
||||
scheduleMessage: 'Запланировать',
|
||||
scheduleSend: 'Отправить по расписанию',
|
||||
scheduled: 'Запланировано',
|
||||
myStory: 'Моя история',
|
||||
newStory: 'Новая история',
|
||||
textStory: 'Текст',
|
||||
imageStory: 'Фото',
|
||||
mediaStory: 'Медиа',
|
||||
typeYourStory: 'Напишите историю...',
|
||||
publishStory: 'Опубликовать',
|
||||
stories: 'Истории',
|
||||
clearChat: 'Очистить чат',
|
||||
clearChatConfirm: 'Очистить историю чата для себя? Собеседник сохранит свою историю.',
|
||||
deleteChatConfirm: 'Удалить чат? Это действие нельзя отменить.',
|
||||
pinChat: 'Закрепить чат',
|
||||
unpinChat: 'Открепить чат',
|
||||
chatCleared: 'Чат очищен',
|
||||
groupSettings: 'Настройки группы',
|
||||
editGroupName: 'Изменить название',
|
||||
addMember: 'Добавить участника',
|
||||
removeMember: 'Удалить из группы',
|
||||
leaveGroup: 'Покинуть группу',
|
||||
adminBadge: 'Админ',
|
||||
groupDescription: 'Описание',
|
||||
noDescription: 'Нет описания',
|
||||
memberBadge: 'Участник',
|
||||
screenShare: 'Демонстрация экрана',
|
||||
stopScreenShare: 'Остановить демонстрацию',
|
||||
switchCamera: 'Выбрать камеру',
|
||||
showInChat: 'Показать в чате',
|
||||
minimize: 'Свернуть',
|
||||
volume: 'Громкость',
|
||||
mute: 'Выключить микрофон',
|
||||
unmute: 'Включить микрофон',
|
||||
noiseSuppressionOn: 'Шумоподавление включено',
|
||||
noiseSuppressionOff: 'Шумоподавление выключено',
|
||||
selectMicrophone: 'Выбрать микрофон',
|
||||
microphone: 'Микрофон',
|
||||
rightClickVolume: 'ПКМ для регулировки громкости',
|
||||
participants: 'участников',
|
||||
joinCall: 'Присоединиться к звонку',
|
||||
activeCall: 'Активный звонок',
|
||||
groupNameRequired: 'Введите название группы',
|
||||
confirmRemoveMember: 'Удалить этого участника из группы?',
|
||||
yes: 'Да',
|
||||
no: 'Нет',
|
||||
you: 'вы',
|
||||
// User profile
|
||||
mediaTab: 'Медиа',
|
||||
gifs: 'GIF',
|
||||
filesTab: 'Файлы',
|
||||
linksTab: 'Ссылки',
|
||||
profileTitle: 'Профиль',
|
||||
removeAvatar: 'Удалить аватар',
|
||||
namePlaceholder: 'Имя',
|
||||
notSpecified: 'Не указано',
|
||||
onKnotSince: 'На Knot с',
|
||||
cancel: 'Отмена',
|
||||
confirm: 'Подтвердить',
|
||||
save: 'Сохранить',
|
||||
sharedPhotos: 'Общие фото и видео будут здесь',
|
||||
sharedFiles: 'Общие файлы будут здесь',
|
||||
sharedLinks: 'Общие ссылки будут здесь',
|
||||
profileNotFound: 'Профиль не найден',
|
||||
storiesTab: 'Истории',
|
||||
publicationsTab: 'Публикации',
|
||||
noStories: 'Публикаций пока нет',
|
||||
goToMessage: 'Перейти к сообщению',
|
||||
story: 'История',
|
||||
commonGroups: 'Общие группы',
|
||||
more: 'Ещё',
|
||||
// Typing
|
||||
typingText: 'печатает',
|
||||
// Emoji
|
||||
emojiFrequent: 'Часто',
|
||||
emojiGestures: 'Жесты',
|
||||
emojiEmotions: 'Эмоции',
|
||||
emojiObjects: 'Предметы',
|
||||
emojiFood: 'Еда',
|
||||
emojiNature: 'Природа',
|
||||
// Auth
|
||||
login: 'Вход',
|
||||
register: 'Регистрация',
|
||||
latinOnly: '(латиница, нельзя изменить)',
|
||||
displayNameLabel: 'Отображаемое имя',
|
||||
displayNamePlaceholder: 'Ваше имя (любой язык)',
|
||||
password: 'Пароль',
|
||||
passwordPlaceholder: 'Введите пароль',
|
||||
bioPlaceholder: 'Расскажите о себе (необязательно)',
|
||||
loginBtn: 'Войти',
|
||||
createAccount: 'Создать аккаунт',
|
||||
testAccounts: 'Тестовые аккаунты: evgeniy, anastasia, artem, polina',
|
||||
passwordForAll: 'Пароль для всех:',
|
||||
dontHaveAccount: 'Нет аккаунта?',
|
||||
alreadyHaveAccount: 'Уже есть аккаунт?',
|
||||
passwordRequirements: 'Минимум 8 символов, буквы и цифры',
|
||||
registerNow: 'Зарегистрироваться',
|
||||
loginNow: 'Войти',
|
||||
// File/upload
|
||||
fileTooLarge: 'Файл слишком большой (макс. 200МБ)',
|
||||
dropFileHere: 'Отпустите файл здесь',
|
||||
uploading: 'Загрузка...',
|
||||
uploadError: 'Ошибка загрузки файла',
|
||||
changePhoto: 'Сменить фото',
|
||||
remove: 'Удалить',
|
||||
// Formatting
|
||||
formatBold: 'Жирный',
|
||||
formatBoldHint: '**текст**',
|
||||
formatItalic: 'Курсив',
|
||||
formatItalicHint: '_текст_',
|
||||
formatStrike: 'Зачёркнут',
|
||||
formatStrikeHint: '~текст~',
|
||||
formatMono: 'Моноширинный',
|
||||
formatMonoHint: '`текст`',
|
||||
// Draft
|
||||
draft: 'Черновик:',
|
||||
// Date
|
||||
datePlaceholder: 'дд.мм.гггг',
|
||||
// GIF / Emoji
|
||||
tenorKeyRequired: 'Для GIF нужен Tenor API ключ',
|
||||
openConsoleRun: 'Откройте консоль и выполните:',
|
||||
searchGifs: 'Поиск GIF...',
|
||||
trending: 'Популярные',
|
||||
// Misc
|
||||
error: 'Ошибка',
|
||||
// Friends
|
||||
friends: 'Контакты',
|
||||
friendRequests: 'Заявки в контакты',
|
||||
friendsList: 'Список контактов',
|
||||
noFriends: 'Пока нет контактов',
|
||||
addFriend: 'Добавить в контакты',
|
||||
removeFriend: 'Удалить из контактов',
|
||||
requestSent: 'Заявка отправлена',
|
||||
searchFriends: 'Поиск по @username (мин. 3 символа)',
|
||||
noSearchResults: 'Пользователи не найдены',
|
||||
minCharsHint: 'Введите минимум 3 символа после @',
|
||||
// Story viewers
|
||||
storyViewers: 'Кто просмотрел',
|
||||
noViewers: 'Пока никто не посмотрел',
|
||||
replyToStory: 'Ответить на историю...',
|
||||
send: 'Отправить',
|
||||
// Favorites
|
||||
favorites: 'Избранное',
|
||||
favoritesDescription: 'Сохраняйте важные сообщения здесь',
|
||||
// Privacy
|
||||
privacy: 'Приватность',
|
||||
hideStoryViews: 'Скрывать просмотры историй',
|
||||
hideStoryViewsDesc: 'Другие не увидят, что вы смотрели их историю',
|
||||
// Scheduled
|
||||
scheduledFor: 'Запланировано на',
|
||||
messageScheduled: 'Сообщение запланировано',
|
||||
scheduledDelivered: 'Сообщение отправлено для',
|
||||
scheduledDeliveredAt: 'в',
|
||||
scheduleIn1h: 'Через 1 час',
|
||||
scheduleIn3h: 'Через 3 часа',
|
||||
scheduleTomorrow: 'Завтра в 9:00',
|
||||
scheduleCustom: 'Выбрать дату и время',
|
||||
scheduleTime: 'Время',
|
||||
scheduleDate: 'Дата',
|
||||
// Last seen
|
||||
lastSeenAt: 'был(а)',
|
||||
justNow: 'только что',
|
||||
loadStoriesError: 'Ошибка загрузки историй',
|
||||
loadChatsError: 'Ошибка загрузки чатов',
|
||||
loadMessagesError: 'Ошибка загрузки сообщений',
|
||||
unreadMessages: 'Непрочитанные сообщения',
|
||||
},
|
||||
en: {
|
||||
myProfile: 'My Profile',
|
||||
settings: 'Settings',
|
||||
aboutApp: 'About',
|
||||
logout: 'Log Out',
|
||||
name: 'Name',
|
||||
username: 'Username',
|
||||
aboutMe: 'About me',
|
||||
birthday: 'Birthday',
|
||||
enterName: 'Enter name',
|
||||
tellAboutYourself: 'Tell about yourself',
|
||||
removePhoto: 'Remove photo',
|
||||
language: 'Language',
|
||||
interfaceLang: 'Interface language',
|
||||
theme: 'Theme',
|
||||
russian: 'Русский',
|
||||
english: 'English',
|
||||
about: 'About',
|
||||
version: 'Version',
|
||||
modernMessenger: 'A modern messenger focused',
|
||||
onPrivacy: 'on privacy and convenience',
|
||||
modernMessengerShort: 'Knot Messenger',
|
||||
searchChats: 'Search chats...',
|
||||
chat: 'Chat',
|
||||
group: 'Group',
|
||||
noChats: 'No chats — create one!',
|
||||
nothingFound: 'Nothing found',
|
||||
selectChat: 'Select a chat to start messaging',
|
||||
noMessages: 'No messages — write the first one!',
|
||||
typing: 'typing...',
|
||||
online: 'online',
|
||||
wasRecently: 'was recently',
|
||||
members: 'members',
|
||||
files: 'files',
|
||||
clearAll: 'Clear all',
|
||||
messagePlaceholder: 'Message...',
|
||||
message: 'Message...',
|
||||
addCaption: 'Add a caption...',
|
||||
searchMessages: 'Search messages...',
|
||||
searchMessagesBtn: 'Search messages',
|
||||
userProfile: 'User profile',
|
||||
enableSound: 'Enable sound',
|
||||
disableSound: 'Mute',
|
||||
deleteChat: 'Delete chat',
|
||||
messageDeleted: 'Message deleted',
|
||||
reply: 'Reply',
|
||||
copy: 'Copy',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
deleteForMe: 'Delete for me',
|
||||
deleteForAll: 'Delete for everyone',
|
||||
deleteAlsoFor: 'Delete also for',
|
||||
editing: 'Editing',
|
||||
replyTo: 'Reply',
|
||||
media: 'Media',
|
||||
download: 'Download',
|
||||
edited: 'edit.',
|
||||
selected: 'selected',
|
||||
fileLabel: 'File',
|
||||
kb: 'KB',
|
||||
voice: '🎤 Voice',
|
||||
photo: '🖼 Photo',
|
||||
video: '🎬 Video',
|
||||
file: 'File',
|
||||
call: 'Call',
|
||||
videoCall: 'Video call',
|
||||
incomingCall: 'Incoming call',
|
||||
incomingVideoCall: 'Incoming video call',
|
||||
calling: 'Calling...',
|
||||
accept: 'Accept',
|
||||
decline: 'Decline',
|
||||
endCall: 'End call',
|
||||
callEnded: 'Call ended',
|
||||
callDeclined: 'Call declined',
|
||||
photoVideo: 'Photo / video',
|
||||
fileBtn: 'File',
|
||||
sendError: 'Send error',
|
||||
newChat: 'New chat',
|
||||
menu: 'Menu',
|
||||
months: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
|
||||
weekDays: ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'],
|
||||
clear: 'Clear',
|
||||
today: 'Today',
|
||||
newChatTitle: 'New Chat',
|
||||
selectMembers: 'Select members',
|
||||
newGroup: 'New Group',
|
||||
groupNamePlaceholder: 'Group name...',
|
||||
membersCount: 'Members',
|
||||
createGroup: 'Create Group',
|
||||
upTo200: 'Up to 200 members',
|
||||
findUser: 'Find user by name or @username...',
|
||||
addMembers: 'Add members...',
|
||||
usersNotFound: 'Users not found',
|
||||
enterNameOrUsername: 'Enter name or @username',
|
||||
next: 'Next',
|
||||
pinMessage: 'Pin',
|
||||
unpinMessage: 'Unpin',
|
||||
pinnedMessage: 'Pinned message',
|
||||
forwardMessage: 'Forward message',
|
||||
forward: 'Forward',
|
||||
forwardedFrom: 'Forwarded from',
|
||||
replyWithQuote: 'Reply with quote',
|
||||
select: 'Select',
|
||||
sending: 'Sending...',
|
||||
scheduleMessage: 'Schedule',
|
||||
scheduleSend: 'Send scheduled',
|
||||
scheduled: 'Scheduled',
|
||||
myStory: 'My story',
|
||||
newStory: 'New story',
|
||||
textStory: 'Text',
|
||||
imageStory: 'Photo',
|
||||
mediaStory: 'Media',
|
||||
typeYourStory: 'Write your story...',
|
||||
publishStory: 'Publish',
|
||||
stories: 'Stories',
|
||||
clearChat: 'Clear chat',
|
||||
clearChatConfirm: 'Clear chat history for yourself? The other person will keep their history.',
|
||||
deleteChatConfirm: 'Delete this chat? This action cannot be undone.',
|
||||
pinChat: 'Pin chat',
|
||||
unpinChat: 'Unpin chat',
|
||||
chatCleared: 'Chat cleared',
|
||||
groupSettings: 'Group settings',
|
||||
editGroupName: 'Edit name',
|
||||
addMember: 'Add member',
|
||||
removeMember: 'Remove from group',
|
||||
leaveGroup: 'Leave group',
|
||||
adminBadge: 'Admin',
|
||||
groupDescription: 'Description',
|
||||
noDescription: 'No description',
|
||||
memberBadge: 'Member',
|
||||
screenShare: 'Screen share',
|
||||
stopScreenShare: 'Stop sharing',
|
||||
switchCamera: 'Switch camera',
|
||||
showInChat: 'Show in chat',
|
||||
minimize: 'Minimize',
|
||||
volume: 'Volume',
|
||||
mute: 'Mute',
|
||||
unmute: 'Unmute',
|
||||
noiseSuppressionOn: 'Noise suppression on',
|
||||
noiseSuppressionOff: 'Noise suppression off',
|
||||
selectMicrophone: 'Select microphone',
|
||||
microphone: 'Microphone',
|
||||
rightClickVolume: 'Right-click to adjust volume',
|
||||
participants: 'participants',
|
||||
joinCall: 'Join call',
|
||||
activeCall: 'Active call',
|
||||
groupNameRequired: 'Enter a group name',
|
||||
confirmRemoveMember: 'Remove this member from the group?',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
you: 'you',
|
||||
mediaTab: 'Media',
|
||||
gifs: 'GIF',
|
||||
filesTab: 'Files',
|
||||
linksTab: 'Links',
|
||||
profileTitle: 'Profile',
|
||||
removeAvatar: 'Remove avatar',
|
||||
namePlaceholder: 'Name',
|
||||
notSpecified: 'Not specified',
|
||||
onKnotSince: 'On Knot since',
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Confirm',
|
||||
save: 'Save',
|
||||
sharedPhotos: 'Shared photos and videos will appear here',
|
||||
sharedFiles: 'Shared files will appear here',
|
||||
sharedLinks: 'Shared links will appear here',
|
||||
profileNotFound: 'Profile not found',
|
||||
storiesTab: 'Stories',
|
||||
publicationsTab: 'Publications',
|
||||
noStories: 'No stories yet',
|
||||
goToMessage: 'Go to message',
|
||||
story: 'Story',
|
||||
commonGroups: 'Common groups',
|
||||
more: 'More',
|
||||
typingText: 'typing',
|
||||
emojiFrequent: 'Frequent',
|
||||
emojiGestures: 'Gestures',
|
||||
emojiEmotions: 'Emotions',
|
||||
emojiObjects: 'Objects',
|
||||
emojiFood: 'Food',
|
||||
emojiNature: 'Nature',
|
||||
login: 'Login',
|
||||
register: 'Register',
|
||||
latinOnly: '(latin only, cannot be changed)',
|
||||
displayNameLabel: 'Display name',
|
||||
displayNamePlaceholder: 'Your name (any language)',
|
||||
password: 'Password',
|
||||
passwordPlaceholder: 'Enter password',
|
||||
bioPlaceholder: 'Tell about yourself (optional)',
|
||||
loginBtn: 'Login',
|
||||
createAccount: 'Create account',
|
||||
testAccounts: 'Test accounts: evgeniy, anastasia, artem, polina',
|
||||
passwordForAll: 'Password for all:',
|
||||
dontHaveAccount: "Don't have an account?",
|
||||
alreadyHaveAccount: 'Already have an account?',
|
||||
passwordRequirements: 'At least 8 characters, letters and numbers',
|
||||
registerNow: 'Register',
|
||||
loginNow: 'Login',
|
||||
fileTooLarge: 'File too large (max 200MB)',
|
||||
dropFileHere: 'Drop file here',
|
||||
uploading: 'Uploading...',
|
||||
uploadError: 'File upload error',
|
||||
changePhoto: 'Change photo',
|
||||
remove: 'Remove',
|
||||
formatBold: 'Bold',
|
||||
formatBoldHint: '**text**',
|
||||
formatItalic: 'Italic',
|
||||
formatItalicHint: '_text_',
|
||||
formatStrike: 'Strikethrough',
|
||||
formatStrikeHint: '~text~',
|
||||
formatMono: 'Monospace',
|
||||
formatMonoHint: '`text`',
|
||||
draft: 'Draft:',
|
||||
datePlaceholder: 'dd.mm.yyyy',
|
||||
tenorKeyRequired: 'Tenor API key required for GIFs',
|
||||
openConsoleRun: 'Open console and run:',
|
||||
searchGifs: 'Search GIFs...',
|
||||
trending: 'Trending',
|
||||
error: 'Error',
|
||||
friends: 'Contacts',
|
||||
friendRequests: 'Contact requests',
|
||||
friendsList: 'Contacts list',
|
||||
noFriends: 'No contacts yet',
|
||||
addFriend: 'Add to contacts',
|
||||
removeFriend: 'Remove from contacts',
|
||||
requestSent: 'Request sent',
|
||||
searchFriends: 'Search by @username (min. 3 chars)',
|
||||
noSearchResults: 'No users found',
|
||||
minCharsHint: 'Enter at least 3 characters after @',
|
||||
storyViewers: 'Who viewed',
|
||||
noViewers: 'No one viewed yet',
|
||||
replyToStory: 'Reply to story...',
|
||||
send: 'Send',
|
||||
favorites: 'Favorites',
|
||||
favoritesDescription: 'Save important messages here',
|
||||
privacy: 'Privacy',
|
||||
hideStoryViews: 'Hide story views',
|
||||
hideStoryViewsDesc: 'Others won\'t see that you viewed their story',
|
||||
scheduledFor: 'Scheduled for',
|
||||
messageScheduled: 'Message scheduled',
|
||||
scheduledDelivered: 'Message sent to',
|
||||
scheduledDeliveredAt: 'at',
|
||||
scheduleIn1h: 'In 1 hour',
|
||||
scheduleIn3h: 'In 3 hours',
|
||||
scheduleTomorrow: 'Tomorrow at 9:00',
|
||||
scheduleCustom: 'Choose date & time',
|
||||
scheduleTime: 'Time',
|
||||
scheduleDate: 'Date',
|
||||
lastSeenAt: 'was',
|
||||
justNow: 'just now',
|
||||
loadStoriesError: 'Error loading stories',
|
||||
loadChatsError: 'Error loading chats',
|
||||
loadMessagesError: 'Error loading messages',
|
||||
unreadMessages: 'Unread messages',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type TranslationKey = keyof typeof translations.ru;
|
||||
type TranslationValue<K extends TranslationKey> = (typeof translations.ru)[K];
|
||||
|
||||
interface LangState {
|
||||
lang: Lang;
|
||||
setLang: (lang: Lang) => void;
|
||||
t: <K extends TranslationKey>(key: K) => TranslationValue<K>;
|
||||
}
|
||||
|
||||
export const useLang = create<LangState>((set, get) => ({
|
||||
lang: (localStorage.getItem(LANG_KEY) as Lang) || 'ru',
|
||||
setLang: (lang) => {
|
||||
localStorage.setItem(LANG_KEY, lang);
|
||||
set({ lang });
|
||||
},
|
||||
t: (key) => {
|
||||
const { lang } = get();
|
||||
return (translations[lang][key] ?? translations.ru[key] ?? key) as TranslationValue<typeof key>;
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,49 @@
|
||||
export const getCroppedImg = async (
|
||||
imageSrc: string,
|
||||
pixelCrop: { x: number; y: number; width: number; height: number }
|
||||
): Promise<File | null> => {
|
||||
const image = await createImage(imageSrc);
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Set sizing
|
||||
canvas.width = pixelCrop.width;
|
||||
canvas.height = pixelCrop.height;
|
||||
|
||||
// Draw cropped image
|
||||
ctx.drawImage(
|
||||
image,
|
||||
pixelCrop.x,
|
||||
pixelCrop.y,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
0,
|
||||
0,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height
|
||||
);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const file = new File([blob], 'avatar.jpg', { type: 'image/jpeg' });
|
||||
resolve(file);
|
||||
}, 'image/jpeg', 0.95);
|
||||
});
|
||||
};
|
||||
|
||||
const createImage = (url: string): Promise<HTMLImageElement> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.addEventListener('load', () => resolve(image));
|
||||
image.addEventListener('error', (error) => reject(error));
|
||||
image.setAttribute('crossOrigin', 'anonymous');
|
||||
image.src = url;
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { HubConnection, HubConnectionBuilder, LogLevel } from '@microsoft/signalr';
|
||||
|
||||
export interface SocketCompat {
|
||||
on(event: string, callback: (...args: any[]) => void): void;
|
||||
off(event: string, callback?: (...args: any[]) => void): void;
|
||||
emit(event: string, ...args: any[]): void;
|
||||
disconnect(): void;
|
||||
status: string;
|
||||
}
|
||||
|
||||
let connection: HubConnection | null = null;
|
||||
let socketWrapper: SocketCompat | null = null;
|
||||
|
||||
export function connectSocket(token: string): SocketCompat {
|
||||
if (connection && (connection.state === 'Connected' || connection.state === 'Connecting')) {
|
||||
return socketWrapper!;
|
||||
}
|
||||
|
||||
connection = new HubConnectionBuilder()
|
||||
.withUrl('/hubs/chat', {
|
||||
accessTokenFactory: () => token
|
||||
})
|
||||
.withAutomaticReconnect()
|
||||
.configureLogging(LogLevel.Error)
|
||||
.build();
|
||||
|
||||
// Обертка для совместимости с Socket.io API
|
||||
socketWrapper = {
|
||||
on: (event: string, callback: (...args: any[]) => void) => {
|
||||
// console.log(`[SignalR] Registering handler for: ${event}`);
|
||||
connection?.on(event, callback);
|
||||
},
|
||||
off: (event: string, callback?: (...args: any[]) => void) => {
|
||||
if (callback) {
|
||||
connection?.off(event, callback);
|
||||
} else {
|
||||
connection?.off(event);
|
||||
}
|
||||
},
|
||||
emit: (event: string, ...args: any[]) => {
|
||||
const state = connection?.state;
|
||||
// console.log(`[SignalR] emit('${event}') called, connection state: ${state}`);
|
||||
if (connection?.state === 'Connected') {
|
||||
// В SignalR invoke возвращает Promise, но Socket.io emit - нет.
|
||||
// Мы просто запускаем и логируем ошибки.
|
||||
connection.invoke(event, ...args)
|
||||
// .then(() => console.log(`[SignalR] invoke('${event}') completed successfully`))
|
||||
.catch(err => console.error(`SignalR emit error (${event}):`, err));
|
||||
} else {
|
||||
// console.warn(`SignalR emit skipped (${event}): connection state is ${state}`);
|
||||
}
|
||||
},
|
||||
disconnect: () => {
|
||||
connection?.stop();
|
||||
},
|
||||
get status() {
|
||||
return connection?.state || 'Disconnected';
|
||||
}
|
||||
};
|
||||
|
||||
connection.start()
|
||||
.then(() => {
|
||||
})
|
||||
.catch(err => console.error('[SignalR] Connection error:', err.toString()));
|
||||
|
||||
connection.onclose((error) => {
|
||||
});
|
||||
|
||||
connection.onreconnecting((error) => {
|
||||
});
|
||||
|
||||
connection.onreconnected((connectionId) => {
|
||||
});
|
||||
|
||||
return socketWrapper;
|
||||
}
|
||||
|
||||
export function getSocket(): SocketCompat | null {
|
||||
return socketWrapper;
|
||||
}
|
||||
|
||||
export function disconnectSocket() {
|
||||
if (connection) {
|
||||
connection.stop();
|
||||
connection = null;
|
||||
socketWrapper = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { memo } from 'react';
|
||||
import { getInitials, generateAvatarColor } from '../../../utils/utils';
|
||||
|
||||
interface AvatarProps {
|
||||
src?: string | null;
|
||||
name: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
className?: string;
|
||||
online?: boolean;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'w-6 h-6 text-[10px]',
|
||||
sm: 'w-8 h-8 text-xs',
|
||||
md: 'w-10 h-10 text-sm',
|
||||
lg: 'w-12 h-12 text-base',
|
||||
xl: 'w-20 h-20 text-xl',
|
||||
} as const;
|
||||
|
||||
const onlineDotSize = {
|
||||
xs: 'w-1.5 h-1.5 border',
|
||||
sm: 'w-2 h-2 border',
|
||||
md: 'w-2.5 h-2.5 border-2',
|
||||
lg: 'w-3 h-3 border-2',
|
||||
xl: 'w-4 h-4 border-2',
|
||||
} as const;
|
||||
|
||||
function AvatarInner({ src, name, size = 'md', className = '', online }: AvatarProps) {
|
||||
const sizeClass = sizeClasses[size];
|
||||
const initials = getInitials(name || '?');
|
||||
const gradientClass = generateAvatarColor(name || '');
|
||||
|
||||
return (
|
||||
<div className={`relative shrink-0 ${className}`}>
|
||||
{src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={name}
|
||||
className={`${sizeClass} rounded-full object-cover`}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`${sizeClass} rounded-full bg-gradient-to-br ${gradientClass} flex items-center justify-center text-white font-medium`}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
{online !== undefined && (
|
||||
<div
|
||||
className={`absolute bottom-0 right-0 ${onlineDotSize[size]} rounded-full border-surface ${
|
||||
online ? 'bg-emerald-500' : 'bg-zinc-500'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Avatar = memo(AvatarInner);
|
||||
export default Avatar;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { useLang } from '../../../infrastructure/i18n';
|
||||
|
||||
interface ConfirmModalProps {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
danger?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ConfirmModal({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmText,
|
||||
cancelText,
|
||||
danger = true,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmModalProps) {
|
||||
const { t } = useLang();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
transition={{ type: 'spring', duration: 0.35, bounce: 0.2 }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className="w-full max-w-[360px] mx-4 rounded-2xl bg-surface-secondary border border-border/50 shadow-2xl overflow-hidden"
|
||||
>
|
||||
<div className="p-5 flex flex-col items-center text-center">
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center mb-3 ${danger ? 'bg-red-500/15' : 'bg-accent/15'}`}>
|
||||
<AlertTriangle size={24} className={danger ? 'text-red-400' : 'text-accent'} />
|
||||
</div>
|
||||
{title && (
|
||||
<h3 className="text-white text-base font-semibold mb-1">{title}</h3>
|
||||
)}
|
||||
<p className="text-zinc-400 text-sm leading-relaxed">{message}</p>
|
||||
</div>
|
||||
<div className="flex border-t border-border/40">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-1 py-3 text-sm font-medium text-zinc-400 hover:bg-surface-hover hover:text-white transition-colors"
|
||||
>
|
||||
{cancelText || t('cancel')}
|
||||
</button>
|
||||
<div className="w-px bg-border/40" />
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`flex-1 py-3 text-sm font-medium transition-colors ${
|
||||
danger
|
||||
? 'text-red-400 hover:bg-red-500/10 hover:text-red-300'
|
||||
: 'text-accent hover:bg-accent/10'
|
||||
}`}
|
||||
>
|
||||
{confirmText || t('confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react';
|
||||
import { useLang } from '../../../infrastructure/i18n';
|
||||
|
||||
interface DatePickerProps {
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
}
|
||||
|
||||
type View = 'days' | 'months' | 'years';
|
||||
|
||||
export default function DatePicker({ value, onChange }: DatePickerProps) {
|
||||
const { t, lang } = useLang();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number; openUp: boolean } | null>(null);
|
||||
|
||||
const today = new Date();
|
||||
const parsed = value ? new Date(value) : null;
|
||||
const [viewYear, setViewYear] = useState(parsed?.getFullYear() || today.getFullYear());
|
||||
const [viewMonth, setViewMonth] = useState(parsed?.getMonth() || today.getMonth());
|
||||
const [view, setView] = useState<View>('days');
|
||||
const [yearRangeStart, setYearRangeStart] = useState(() => {
|
||||
const y = parsed?.getFullYear() || today.getFullYear();
|
||||
return y - (y % 24);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handle = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (ref.current && !ref.current.contains(target) && dropdownRef.current && !dropdownRef.current.contains(target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', handle), 0);
|
||||
return () => document.removeEventListener('click', handle);
|
||||
}, [open]);
|
||||
|
||||
// Reset view to days when reopened & compute position for portal
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setView('days');
|
||||
if (parsed) {
|
||||
setViewYear(parsed.getFullYear());
|
||||
setViewMonth(parsed.getMonth());
|
||||
setYearRangeStart(parsed.getFullYear() - (parsed.getFullYear() % 24));
|
||||
}
|
||||
// Compute dropdown position
|
||||
if (ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
const dropdownHeight = 370;
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const openUp = spaceBelow < dropdownHeight;
|
||||
setPos({
|
||||
top: openUp ? rect.top : rect.bottom + 8,
|
||||
left: rect.left,
|
||||
openUp,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setPos(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const months = t('months');
|
||||
const weekDays = t('weekDays');
|
||||
const shortMonths = useMemo(() => months.map(m => m.slice(0, 3)), [months]);
|
||||
|
||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
const firstDayRaw = new Date(viewYear, viewMonth, 1).getDay();
|
||||
const firstDay = firstDayRaw === 0 ? 6 : firstDayRaw - 1;
|
||||
|
||||
const prevMonth = () => {
|
||||
if (viewMonth === 0) { setViewMonth(11); setViewYear(viewYear - 1); }
|
||||
else setViewMonth(viewMonth - 1);
|
||||
};
|
||||
const nextMonth = () => {
|
||||
if (viewMonth === 11) { setViewMonth(0); setViewYear(viewYear + 1); }
|
||||
else setViewMonth(viewMonth + 1);
|
||||
};
|
||||
|
||||
const selectDay = (day: number) => {
|
||||
const m = String(viewMonth + 1).padStart(2, '0');
|
||||
const d = String(day).padStart(2, '0');
|
||||
onChange(`${viewYear}-${m}-${d}`);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const isSelected = (day: number) => {
|
||||
if (!parsed) return false;
|
||||
return parsed.getFullYear() === viewYear && parsed.getMonth() === viewMonth && parsed.getDate() === day;
|
||||
};
|
||||
|
||||
const isToday = (day: number) => {
|
||||
return today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === day;
|
||||
};
|
||||
|
||||
const displayValue = parsed
|
||||
? parsed.toLocaleDateString(useLang.getState().lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
: '';
|
||||
|
||||
const cells: (number | null)[] = [];
|
||||
for (let i = 0; i < firstDay; i++) cells.push(null);
|
||||
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
|
||||
|
||||
// 24 years per page
|
||||
const yearCells = useMemo(() => {
|
||||
const arr: number[] = [];
|
||||
for (let i = 0; i < 24; i++) arr.push(yearRangeStart + i);
|
||||
return arr;
|
||||
}, [yearRangeStart]);
|
||||
|
||||
const handleHeaderClick = () => {
|
||||
if (view === 'days') {
|
||||
setView('months');
|
||||
} else if (view === 'months') {
|
||||
setYearRangeStart(viewYear - (viewYear % 24));
|
||||
setView('years');
|
||||
}
|
||||
};
|
||||
|
||||
const selectMonth = (monthIdx: number) => {
|
||||
setViewMonth(monthIdx);
|
||||
setView('days');
|
||||
};
|
||||
|
||||
const selectYear = (year: number) => {
|
||||
setViewYear(year);
|
||||
setView('months');
|
||||
};
|
||||
|
||||
const headerLabel = view === 'days'
|
||||
? `${months[viewMonth]} ${viewYear}`
|
||||
: view === 'months'
|
||||
? `${viewYear}`
|
||||
: `${yearRangeStart} — ${yearRangeStart + 23}`;
|
||||
|
||||
const handlePrev = () => {
|
||||
if (view === 'days') prevMonth();
|
||||
else if (view === 'months') setViewYear(y => y - 1);
|
||||
else setYearRangeStart(s => s - 24);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (view === 'days') nextMonth();
|
||||
else if (view === 'months') setViewYear(y => y + 1);
|
||||
else setYearRangeStart(s => s + 24);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg bg-surface-tertiary text-sm text-white border border-border hover:border-accent transition-colors text-left"
|
||||
>
|
||||
<Calendar size={14} className="text-zinc-500 flex-shrink-0" />
|
||||
<span className={displayValue ? 'text-white' : 'text-zinc-500'}>
|
||||
{displayValue || (lang === 'ru' ? 'дд.мм.гггг' : 'mm/dd/yyyy')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && createPortal(
|
||||
<AnimatePresence>
|
||||
{pos && (
|
||||
<motion.div
|
||||
ref={dropdownRef}
|
||||
initial={{ opacity: 0, y: pos.openUp ? 8 : -8, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: pos.openUp ? 8 : -8, scale: 0.95 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="fixed w-72 glass-strong rounded-xl shadow-2xl z-[9999] overflow-hidden border border-border"
|
||||
style={{
|
||||
left: pos.left,
|
||||
...(pos.openUp
|
||||
? { bottom: window.innerHeight - pos.top + 8 }
|
||||
: { top: pos.top }),
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<button type="button" onClick={handlePrev} className="p-1 rounded-lg hover:bg-surface-hover text-zinc-400 hover:text-white transition-colors">
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleHeaderClick}
|
||||
className={`text-sm font-medium text-white transition-colors ${view !== 'years' ? 'hover:text-accent cursor-pointer' : 'cursor-default'}`}
|
||||
>
|
||||
{headerLabel}
|
||||
</button>
|
||||
<button type="button" onClick={handleNext} className="p-1 rounded-lg hover:bg-surface-hover text-zinc-400 hover:text-white transition-colors">
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{/* ===== DAYS VIEW ===== */}
|
||||
{view === 'days' && (
|
||||
<motion.div
|
||||
key="days"
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
>
|
||||
<div className="grid grid-cols-7 px-3 pt-2">
|
||||
{weekDays.map((d) => (
|
||||
<div key={d} className="text-center text-[11px] text-zinc-500 font-medium py-1">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 px-3 pb-2">
|
||||
{cells.map((day, i) => (
|
||||
<div key={i} className="flex items-center justify-center">
|
||||
{day ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectDay(day)}
|
||||
className={`w-8 h-8 rounded-full text-sm flex items-center justify-center transition-all ${
|
||||
isSelected(day)
|
||||
? 'bg-accent text-white font-semibold shadow-lg shadow-accent/30'
|
||||
: isToday(day)
|
||||
? 'text-knot-400 font-semibold ring-1 ring-knot-500/50'
|
||||
: 'text-zinc-300 hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-8 h-8" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* ===== MONTHS VIEW ===== */}
|
||||
{view === 'months' && (
|
||||
<motion.div
|
||||
key="months"
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="grid grid-cols-3 gap-1 p-3"
|
||||
>
|
||||
{shortMonths.map((m, idx) => {
|
||||
const isCurrentMonth = viewYear === today.getFullYear() && idx === today.getMonth();
|
||||
const isSelectedMonth = parsed && viewYear === parsed.getFullYear() && idx === parsed.getMonth();
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => selectMonth(idx)}
|
||||
className={`py-2.5 rounded-lg text-sm font-medium transition-all ${
|
||||
isSelectedMonth
|
||||
? 'bg-accent text-white shadow-lg shadow-accent/30'
|
||||
: isCurrentMonth
|
||||
? 'text-knot-400 ring-1 ring-knot-500/50'
|
||||
: 'text-zinc-300 hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* ===== YEARS VIEW ===== */}
|
||||
{view === 'years' && (
|
||||
<motion.div
|
||||
key="years"
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="grid grid-cols-4 gap-1 p-3"
|
||||
>
|
||||
{yearCells.map((yr) => {
|
||||
const isCurrentYear = yr === today.getFullYear();
|
||||
const isSelectedYear = parsed && yr === parsed.getFullYear();
|
||||
return (
|
||||
<button
|
||||
key={yr}
|
||||
type="button"
|
||||
onClick={() => selectYear(yr)}
|
||||
className={`py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
isSelectedYear
|
||||
? 'bg-accent text-white shadow-lg shadow-accent/30'
|
||||
: isCurrentYear
|
||||
? 'text-knot-400 ring-1 ring-knot-500/50'
|
||||
: 'text-zinc-300 hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{yr}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onChange(''); setOpen(false); }}
|
||||
className="text-xs text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
{t('clear')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const m = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(today.getDate()).padStart(2, '0');
|
||||
onChange(`${today.getFullYear()}-${m}-${d}`);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-xs text-knot-400 hover:text-knot-300 transition-colors"
|
||||
>
|
||||
{t('today')}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Download, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface ImageLightboxProps {
|
||||
url?: string;
|
||||
images?: { url: string; type?: string }[];
|
||||
initialIndex?: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ImageLightbox({ url, images, initialIndex = 0, onClose }: ImageLightboxProps) {
|
||||
const gallery = images && images.length > 0;
|
||||
const [index, setIndex] = useState(initialIndex);
|
||||
const currentUrl = gallery ? images![index].url : url!;
|
||||
const currentType = gallery ? images![index].type : undefined;
|
||||
const total = gallery ? images!.length : 1;
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
if (gallery) setIndex((i) => (i > 0 ? i - 1 : total - 1));
|
||||
}, [gallery, total]);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
if (gallery) setIndex((i) => (i < total - 1 ? i + 1 : 0));
|
||||
}, [gallery, total]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'ArrowLeft') goPrev();
|
||||
if (e.key === 'ArrowRight') goNext();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, goPrev, goNext]);
|
||||
|
||||
return createPortal(
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[9999] bg-black/90 flex items-center justify-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Top bar */}
|
||||
<div className="absolute top-4 right-4 flex items-center gap-2 z-10">
|
||||
{gallery && total > 1 && (
|
||||
<span className="text-sm text-white/70 mr-2">{index + 1} / {total}</span>
|
||||
)}
|
||||
<a
|
||||
href={currentUrl}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||
>
|
||||
<Download size={20} />
|
||||
</a>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Left arrow */}
|
||||
{gallery && total > 1 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); goPrev(); }}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||
>
|
||||
<ChevronLeft size={28} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Right arrow */}
|
||||
{gallery && total > 1 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); goNext(); }}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||
>
|
||||
<ChevronRight size={28} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentUrl}
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.8, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="max-w-[90vw] max-h-[90vh] flex items-center justify-center"
|
||||
>
|
||||
{currentType === 'video' ? (
|
||||
<video
|
||||
src={currentUrl}
|
||||
controls
|
||||
autoPlay
|
||||
className="max-w-[90vw] max-h-[90vh] rounded-lg shadow-2xl"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={currentUrl}
|
||||
alt=""
|
||||
className="max-w-[90vw] max-h-[90vh] object-contain rounded-lg shadow-2xl"
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, AlertCircle, CheckCircle, AlertTriangle, Info } from 'lucide-react';
|
||||
import { useNotificationStore, NotificationType } from '../../../application/stores/notificationStore';
|
||||
|
||||
const icons: Record<NotificationType, React.ReactNode> = {
|
||||
info: <Info className="text-blue-400" size={20} />,
|
||||
success: <CheckCircle className="text-emerald-400" size={20} />,
|
||||
warning: <AlertTriangle className="text-amber-400" size={20} />,
|
||||
error: <AlertCircle className="text-rose-400" size={20} />,
|
||||
};
|
||||
|
||||
const colors: Record<NotificationType, string> = {
|
||||
info: 'border-blue-500/30 bg-blue-500/10',
|
||||
success: 'border-emerald-500/30 bg-emerald-500/10',
|
||||
warning: 'border-amber-500/30 bg-amber-500/10',
|
||||
error: 'border-rose-500/30 bg-rose-500/10',
|
||||
};
|
||||
|
||||
export default function NotificationProvider() {
|
||||
const { notifications, removeNotification } = useNotificationStore();
|
||||
|
||||
return (
|
||||
<div className="fixed top-6 right-6 z-[9999] flex flex-col gap-3 pointer-events-none w-full max-w-sm">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{notifications.map((n) => (
|
||||
<motion.div
|
||||
key={n.id}
|
||||
layout
|
||||
initial={{ opacity: 0, x: 50, scale: 0.9 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.2 } }}
|
||||
className={`pointer-events-auto relative group overflow-hidden rounded-2xl border backdrop-blur-xl p-4 shadow-2xl ${colors[n.type]}`}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0 mt-0.5">{icons[n.type]}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white/90 leading-relaxed">
|
||||
{n.message}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeNotification(n.id)}
|
||||
className="flex-shrink-0 -mr-1 -mt-1 p-1 rounded-full text-white/30 hover:text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress bar for auto-hide */}
|
||||
{n.duration && n.duration > 0 && (
|
||||
<motion.div
|
||||
initial={{ width: '100%' }}
|
||||
animate={{ width: 0 }}
|
||||
transition={{ duration: n.duration / 1000, ease: 'linear' }}
|
||||
className="absolute bottom-0 left-0 h-0.5 bg-white/20"
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
X,
|
||||
User,
|
||||
Users,
|
||||
Settings,
|
||||
Languages,
|
||||
Info,
|
||||
LogOut,
|
||||
ArrowLeft,
|
||||
Camera,
|
||||
Edit3,
|
||||
Check,
|
||||
Loader2,
|
||||
Trash2,
|
||||
Calendar,
|
||||
AtSign,
|
||||
MessageSquare,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Palette,
|
||||
Sparkles,
|
||||
UserPlus,
|
||||
UserMinus,
|
||||
UserCheck,
|
||||
Clock,
|
||||
Search,
|
||||
Shield,
|
||||
Eye,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '../../../modules/auth/application/authStore';
|
||||
import { useFriendStore } from '../../../modules/friends/application/friendStore';
|
||||
import { useChatStore } from '../../../modules/chats/application/chatStore';
|
||||
import { UserApi } from '../../../modules/users/infrastructure/userApi';
|
||||
import { getSocket } from '../../infrastructure/socket';
|
||||
import { useLang } from '../../infrastructure/i18n';
|
||||
import { useThemeStore, ChatTheme } from '../../application/stores/themeStore';
|
||||
import DatePicker from '../components/ui/DatePicker';
|
||||
import TelegramImportModal from '../../../modules/users/presentation/components/TelegramImportModal';
|
||||
import type { User as UserType, UserPresence, FriendRequest, FriendWithId } from '../../domain/types';
|
||||
|
||||
type SideView = 'main' | 'profile' | 'settings' | 'about' | 'themes' | 'friends';
|
||||
|
||||
interface SideMenuProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onOpenProfile: () => void;
|
||||
}
|
||||
|
||||
export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuProps) {
|
||||
const { user, updateUser, logout } = useAuthStore();
|
||||
const { clearStore } = useChatStore();
|
||||
const { chatTheme, setChatTheme } = useThemeStore();
|
||||
const { t, lang, setLang } = useLang();
|
||||
|
||||
const [view, setView] = useState<SideView>('main');
|
||||
const [prevView, setPrevView] = useState<SideView>('main');
|
||||
const [themeIndex, setThemeIndex] = useState(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
|
||||
// Friends state
|
||||
const {
|
||||
friends,
|
||||
friendRequests,
|
||||
isLoading: friendsLoading,
|
||||
searchQuery: friendSearch,
|
||||
searchResults: friendSearchResults,
|
||||
isSearching: friendSearchLoading,
|
||||
setSearchQuery: setFriendSearch,
|
||||
loadFriends,
|
||||
acceptRequest: handleAcceptRequest,
|
||||
declineRequest: handleDeclineRequest,
|
||||
removeFriend: handleRemoveFriend,
|
||||
sendRequest: handleSendFriendRequest,
|
||||
searchFriends,
|
||||
clearSearch,
|
||||
initializeSocketEvents
|
||||
} = useFriendStore();
|
||||
|
||||
const themeCards: { id: ChatTheme; color: string; accent: string; name: string; nameEn: string; desc: string; descEn: string; animated?: boolean; gradient?: string }[] = [
|
||||
{ id: 'midnight', color: '#0f0f13', accent: '#6366f1', name: 'Полночь', nameEn: 'Midnight', desc: 'Тёмная тема с мягкими акцентами', descEn: 'Dark theme with soft accents' },
|
||||
{ id: 'ocean', color: '#0b172a', accent: '#3b82f6', name: 'Океан', nameEn: 'Ocean', desc: 'Глубокий синий с прохладными тонами', descEn: 'Deep blue with cool tones' },
|
||||
{ id: 'forest', color: '#0f1c15', accent: '#10b981', name: 'Лес', nameEn: 'Forest', desc: 'Природный зелёный и спокойствие', descEn: 'Natural green and serenity' },
|
||||
{ id: 'sunset', color: '#1f111a', accent: '#ec4899', gradient: 'linear-gradient(135deg, #1f111a, #150a0f)', name: 'Закат', nameEn: 'Sunset', desc: 'Тёплый розовый градиент заката', descEn: 'Warm pink sunset gradient' },
|
||||
{ id: 'classic', color: '#121215', accent: '#a1a1aa', name: 'Классика', nameEn: 'Classic', desc: 'Минималистичная монохромная тема', descEn: 'Minimalist monochrome theme' },
|
||||
{ id: 'neon', color: '#0b0f19', accent: '#8b5cf6', name: 'Неон', nameEn: 'Neon', desc: 'Фиолетовое свечение за курсором', descEn: 'Purple glow follows your cursor', animated: true },
|
||||
{ id: 'aurora', color: '#022c22', accent: '#10b981', gradient: 'linear-gradient(135deg, #022c22, #064e3b)', name: 'Аврора', nameEn: 'Aurora', desc: 'Северное сияние реагирует на мышь', descEn: 'Northern lights react to mouse', animated: true },
|
||||
{ id: 'cyber', color: '#000000', accent: '#f59e0b', name: 'Кибер', nameEn: 'Cyber', desc: 'Сетка и янтарное свечение мыши', descEn: 'Grid pattern with amber glow', animated: true },
|
||||
{ id: 'glass', color: '#0d1117', accent: '#3b82f6', name: 'Стекло', nameEn: 'Glass', desc: 'Плавное свечение следует за мышью', descEn: 'Smooth glow follows the cursor', animated: true },
|
||||
{ id: 'void', color: '#000000', accent: '#ffffff', name: 'Бездна', nameEn: 'Void', desc: 'Абсолютный мрак с точечным светом', descEn: 'Absolute darkness with spot light', animated: true },
|
||||
];
|
||||
|
||||
const changeView = (next: SideView) => {
|
||||
setPrevView(view);
|
||||
setView(next);
|
||||
if (next === 'themes') {
|
||||
const idx = themeCards.findIndex(tc => tc.id === chatTheme);
|
||||
if (idx >= 0) setThemeIndex(idx);
|
||||
}
|
||||
if (next === 'friends') {
|
||||
loadFriends();
|
||||
}
|
||||
};
|
||||
|
||||
// Friend search effect
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
searchFriends(friendSearch, user?.id);
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [friendSearch, user?.id, searchFriends]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
const timer = setTimeout(() => { setView('main'); setPrevView('main'); }, 300);
|
||||
clearSearch();
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
// Load friend request count when menu opens
|
||||
useFriendStore.getState().loadFriends();
|
||||
}, [isOpen, clearSearch]);
|
||||
|
||||
// Real-time friend updates via socket
|
||||
useEffect(() => {
|
||||
const cleanup = initializeSocketEvents();
|
||||
return cleanup;
|
||||
}, [initializeSocketEvents]);
|
||||
|
||||
const handleLogout = () => {
|
||||
clearStore();
|
||||
logout();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const initials = (user?.displayName || user?.username || '??')
|
||||
.split(' ')
|
||||
.map((w: string) => w[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
const menuItems = [
|
||||
{ icon: User, label: t('myProfile'), onClick: () => { onClose(); onOpenProfile(); } },
|
||||
{ icon: Users, label: t('friends'), onClick: () => changeView('friends'), badge: friendRequests.length > 0 ? friendRequests.length : undefined },
|
||||
|
||||
{ icon: Settings, label: t('settings'), onClick: () => changeView('settings') },
|
||||
{ divider: true },
|
||||
{ icon: Info, label: t('aboutApp'), subtitle: 'Knot Messenger v1.0', onClick: () => changeView('about') },
|
||||
];
|
||||
|
||||
// Slide direction for animations
|
||||
const slideDir = prevView === 'main' ? 1 : -1;
|
||||
const viewVariants = {
|
||||
enter: (dir: number) => ({ x: dir * 100, opacity: 0 }),
|
||||
center: { x: 0, opacity: 1 },
|
||||
exit: (dir: number) => ({ x: -dir * 100, opacity: 0 }),
|
||||
};
|
||||
|
||||
// ======= MAIN VIEW =======
|
||||
const renderMain = () => (
|
||||
<motion.div key="main" className="flex flex-col h-full" initial={false} animate="center" exit="exit" variants={viewVariants} custom={-1} transition={{ duration: 0.2 }}>
|
||||
{/* ── Premium header with avatar ── */}
|
||||
<div className="relative overflow-hidden flex-shrink-0">
|
||||
{/* Animated gradient backdrop */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-knot-500/40 via-purple-600/25 to-transparent pointer-events-none" />
|
||||
<div className="absolute -top-20 -right-20 w-56 h-56 bg-knot-500/15 rounded-full blur-[80px] pointer-events-none" />
|
||||
<div className="absolute -bottom-10 -left-10 w-40 h-40 bg-purple-600/10 rounded-full blur-[60px] pointer-events-none" />
|
||||
|
||||
<div className="relative p-6 pb-5">
|
||||
<div className="flex items-start justify-between mb-5">
|
||||
{/* Avatar with glow ring */}
|
||||
<div className="relative group cursor-pointer" onClick={() => { onClose(); onOpenProfile(); }}>
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-accent via-purple-500 to-accent rounded-full opacity-60 blur group-hover:opacity-90 transition duration-500 animate-[spin_4s_linear_infinite]" />
|
||||
<div className="relative">
|
||||
{user?.avatar ? (
|
||||
<img src={user.avatar} alt="" className="w-[72px] h-[72px] rounded-full object-cover ring-[3px] ring-surface" />
|
||||
) : (
|
||||
<div className="w-[72px] h-[72px] rounded-full bg-gradient-to-br from-surface to-surface-secondary flex items-center justify-center ring-[3px] ring-surface relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-accent/20 to-purple-500/20" />
|
||||
<span className="relative z-10 text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-br from-white to-zinc-400 drop-shadow-md">{initials}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Online indicator */}
|
||||
<div className="absolute bottom-0 right-0 w-4 h-4 bg-emerald-500 rounded-full ring-[3px] ring-surface shadow-[0_0_8px_rgba(16,185,129,0.6)]" />
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 rounded-xl text-zinc-400 hover:text-white hover:bg-white/10 transition-all backdrop-blur-sm">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Name & username */}
|
||||
<h3 className="text-xl font-bold text-transparent bg-clip-text bg-gradient-to-b from-white to-white/70 tracking-tight leading-tight">
|
||||
{user?.displayName || user?.username}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1.5 mt-1.5">
|
||||
<AtSign size={12} className="text-knot-400" />
|
||||
<span className="text-sm font-medium text-knot-100/70">{user?.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Bottom fade line */}
|
||||
<div className="h-px bg-gradient-to-r from-transparent via-white/10 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* ── Menu items ── */}
|
||||
<div className="flex-1 overflow-y-auto px-3 py-4 space-y-1">
|
||||
{menuItems.map((item, i) => {
|
||||
if ('divider' in item) return <div key={i} className="my-2 mx-2 h-px bg-gradient-to-r from-transparent via-white/8 to-transparent" />;
|
||||
const Icon = item.icon!;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={item.onClick}
|
||||
className="group w-full flex items-center gap-3.5 px-4 py-3 rounded-2xl text-left transition-all duration-200 hover:bg-white/[0.06] active:scale-[0.98]"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-xl bg-white/[0.06] group-hover:bg-knot-500/15 flex items-center justify-center transition-all duration-200 border border-white/[0.04] group-hover:border-knot-500/20">
|
||||
<Icon size={17} className="text-zinc-400 group-hover:text-knot-400 transition-colors duration-200" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13.5px] font-medium text-zinc-200 group-hover:text-white transition-colors">{item.label}</p>
|
||||
{item.subtitle && <p className="text-[11px] text-zinc-500 mt-0.5">{item.subtitle}</p>}
|
||||
</div>
|
||||
{'badge' in item && item.badge ? (
|
||||
<span className="bg-gradient-to-r from-knot-500 to-purple-600 text-white text-[11px] font-bold min-w-[22px] h-[22px] px-1.5 rounded-full flex items-center justify-center flex-shrink-0 shadow-[0_0_12px_rgba(168,85,247,0.4)]">
|
||||
{item.badge}
|
||||
</span>
|
||||
) : (
|
||||
<ChevronRight size={15} className="text-zinc-600 group-hover:text-zinc-400 transition-colors flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Logout button ── */}
|
||||
<div className="px-3 pb-4 pt-1">
|
||||
<div className="h-px bg-gradient-to-r from-transparent via-white/8 to-transparent mb-3" />
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="group w-full flex items-center gap-3.5 px-4 py-3 rounded-2xl transition-all duration-200 hover:bg-red-500/[0.08] active:scale-[0.98]"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-xl bg-red-500/[0.08] group-hover:bg-red-500/15 flex items-center justify-center transition-all duration-200 border border-red-500/[0.06] group-hover:border-red-500/20">
|
||||
<LogOut size={17} className="text-red-400/70 group-hover:text-red-400 transition-colors duration-200" />
|
||||
</div>
|
||||
<span className="text-[13.5px] font-medium text-red-400/70 group-hover:text-red-400 transition-colors">{t('logout')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
// ======= SETTINGS VIEW =======
|
||||
const renderSettings = () => (
|
||||
<motion.div key="settings" className="flex flex-col h-full" initial={{ x: 100, opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: 100, opacity: 0 }} transition={{ duration: 0.2 }}>
|
||||
<div className="h-14 flex items-center gap-3 px-4 border-b border-border flex-shrink-0">
|
||||
<button onClick={() => changeView('main')} className="p-1.5 rounded-lg text-zinc-400 hover:text-white hover:bg-white/10 transition-colors">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h3 className="text-sm font-semibold text-white flex-1">{t('settings')}</h3>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
{/* Theme picker row */}
|
||||
<div className="px-4 py-1">
|
||||
<button
|
||||
onClick={() => changeView('themes')}
|
||||
className="w-full flex items-center gap-4 px-4 py-3.5 rounded-xl bg-surface-tertiary/50 hover:bg-surface-hover transition-colors group"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-full flex items-center justify-center" style={{ backgroundColor: themeCards.find(t => t.id === chatTheme)?.accent || '#6366f1' }}>
|
||||
<Palette size={18} className="text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<p className="text-sm font-medium text-zinc-200">{t('theme')}</p>
|
||||
<p className="text-xs text-zinc-500">{lang === 'ru' ? themeCards.find(tc => tc.id === chatTheme)?.name : themeCards.find(tc => tc.id === chatTheme)?.nameEn}</p>
|
||||
</div>
|
||||
<ChevronRight size={18} className="text-zinc-500 group-hover:text-zinc-300 transition-colors" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-5 py-3">
|
||||
<h4 className="text-xs text-zinc-500 uppercase tracking-wide mb-3">{t('language')}</h4>
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => setLang('ru')}
|
||||
className={`w-full flex items-center gap-4 px-3 py-3 rounded-xl transition-colors ${lang === 'ru' ? 'bg-knot-500/15 ring-1 ring-knot-500/30' : 'bg-surface-tertiary/50 hover:bg-surface-hover'}`}
|
||||
>
|
||||
<span className="text-lg">🇷🇺</span>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<p className="text-sm text-zinc-200">Русский</p>
|
||||
</div>
|
||||
{lang === 'ru' && <Check size={16} className="text-knot-400" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLang('en')}
|
||||
className={`w-full flex items-center gap-4 px-3 py-3 rounded-xl transition-colors ${lang === 'en' ? 'bg-knot-500/15 ring-1 ring-knot-500/30' : 'bg-surface-tertiary/50 hover:bg-surface-hover'}`}
|
||||
>
|
||||
<span className="text-lg">🇬🇧</span>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<p className="text-sm text-zinc-200">English</p>
|
||||
</div>
|
||||
{lang === 'en' && <Check size={16} className="text-knot-400" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Privacy */}
|
||||
<div className="px-5 py-3">
|
||||
<h4 className="text-xs text-zinc-500 uppercase tracking-wide mb-3">{t('privacy')}</h4>
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const newVal = !user?.hideStoryViews;
|
||||
try {
|
||||
await UserApi.updateSettings({ hideStoryViews: newVal });
|
||||
useAuthStore.getState().updateUser({ hideStoryViews: newVal });
|
||||
} catch {}
|
||||
}}
|
||||
className="w-full flex items-center gap-4 px-3 py-3 rounded-xl bg-surface-tertiary/50 hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<Eye size={18} className="text-zinc-400 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<p className="text-sm text-zinc-200">{t('hideStoryViews')}</p>
|
||||
<p className="text-[11px] text-zinc-500 mt-0.5">{t('hideStoryViewsDesc')}</p>
|
||||
</div>
|
||||
<div className={`w-10 h-6 rounded-full transition-colors flex items-center px-0.5 ${user?.hideStoryViews ? 'bg-knot-500' : 'bg-zinc-600'}`}>
|
||||
<div className={`w-5 h-5 rounded-full bg-white shadow transition-transform ${user?.hideStoryViews ? 'translate-x-4' : 'translate-x-0'}`} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-3 border-t border-border mt-2">
|
||||
<h4 className="text-xs text-zinc-500 uppercase tracking-wide mb-3">Хранилище и данные</h4>
|
||||
<button
|
||||
onClick={() => { loadFriends(); setShowImportModal(true); }}
|
||||
className="w-full flex items-center gap-4 px-3 py-3 rounded-xl bg-surface-tertiary/50 hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-500/20 flex items-center justify-center">
|
||||
<MessageSquare size={16} className="text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<p className="text-sm text-zinc-200">Импорт истории Telegram</p>
|
||||
<p className="text-[11px] text-zinc-500 mt-0.5">Перенести сообщения и медиа</p>
|
||||
</div>
|
||||
<ChevronRight size={18} className="text-zinc-500 transition-colors" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-3">
|
||||
<h4 className="text-xs text-zinc-500 uppercase tracking-wide mb-3">{t('about')}</h4>
|
||||
<div className="flex items-center gap-4 px-3 py-3 rounded-xl bg-surface-tertiary/50">
|
||||
<Info size={18} className="text-zinc-400" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-zinc-200">Knot Messenger</p>
|
||||
<p className="text-xs text-zinc-500">{t('version')} 1.0.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
// ======= THEMES VIEW =======
|
||||
const renderThemes = () => {
|
||||
const currentCard = themeCards[themeIndex];
|
||||
const isActive = chatTheme === currentCard.id;
|
||||
return (
|
||||
<motion.div key="themes" className="flex flex-col h-full" initial={{ x: 100, opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: 100, opacity: 0 }} transition={{ duration: 0.2 }}>
|
||||
<div className="h-14 flex items-center gap-3 px-4 border-b border-border flex-shrink-0">
|
||||
<button onClick={() => changeView('settings')} className="p-1.5 rounded-lg text-zinc-400 hover:text-white hover:bg-white/10 transition-colors">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h3 className="text-sm font-semibold text-white flex-1">{t('theme')}</h3>
|
||||
<span className="text-xs text-zinc-500 tabular-nums">{themeIndex + 1} / {themeCards.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-5 py-4 gap-4 overflow-hidden">
|
||||
{/* Preview card */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentCard.id}
|
||||
initial={{ opacity: 0, scale: 0.92, x: 40 }}
|
||||
animate={{ opacity: 1, scale: 1, x: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.92, x: -40 }}
|
||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||
className="w-full rounded-2xl overflow-hidden border border-border/40 shadow-xl flex flex-col"
|
||||
style={{ minHeight: 200 }}
|
||||
>
|
||||
{/* Theme background preview */}
|
||||
<div
|
||||
className={`relative w-full h-32 chat-theme-${currentCard.id}`}
|
||||
style={currentCard.gradient ? { background: currentCard.gradient } : { backgroundColor: currentCard.color }}
|
||||
>
|
||||
{/* Fake chat bubbles */}
|
||||
<div className="absolute inset-0 p-4 flex flex-col justify-end gap-2">
|
||||
<div className="self-start max-w-[65%] px-3 py-2 rounded-2xl rounded-bl-md bg-white/10 backdrop-blur-sm">
|
||||
<p className="text-[11px] text-white/70">Hey! How's it going? 👋</p>
|
||||
</div>
|
||||
<div className="self-end max-w-[65%] px-3 py-2 rounded-2xl rounded-br-md" style={{ backgroundColor: currentCard.accent + '40' }}>
|
||||
<p className="text-[11px] text-white/80">Pretty great, thanks! ✨</p>
|
||||
</div>
|
||||
</div>
|
||||
{currentCard.animated && (
|
||||
<div className="absolute top-3 right-3 flex items-center gap-1 px-2 py-0.5 rounded-full bg-white/10 backdrop-blur-sm">
|
||||
<Sparkles size={10} className="text-yellow-400" />
|
||||
<span className="text-[9px] text-white/60 font-medium">{lang === 'ru' ? 'Интерактив' : 'Interactive'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Info */}
|
||||
<div className="p-4 bg-surface-secondary">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: currentCard.accent }} />
|
||||
<h3 className="text-base font-bold text-white">{lang === 'ru' ? currentCard.name : currentCard.nameEn}</h3>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400 ml-6">{lang === 'ru' ? currentCard.desc : currentCard.descEn}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Navigation arrows + select */}
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<button
|
||||
onClick={() => setThemeIndex(i => (i - 1 + themeCards.length) % themeCards.length)}
|
||||
className="p-2.5 rounded-xl bg-surface-tertiary/60 text-zinc-400 hover:text-white hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setChatTheme(currentCard.id); }}
|
||||
className={`flex-1 py-3 rounded-xl text-sm font-semibold transition-all duration-200 ${isActive
|
||||
? 'bg-accent/20 text-accent ring-1 ring-accent/40 cursor-default'
|
||||
: 'bg-accent text-white hover:bg-accent/90 shadow-lg shadow-accent/20'
|
||||
}`}
|
||||
disabled={isActive}
|
||||
>
|
||||
{isActive ? (lang === 'ru' ? '✓ Выбрано' : '✓ Selected') : (lang === 'ru' ? 'Применить' : 'Apply')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setThemeIndex(i => (i + 1) % themeCards.length)}
|
||||
className="p-2.5 rounded-xl bg-surface-tertiary/60 text-zinc-400 hover:text-white hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dot indicators */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{themeCards.map((tc, i) => (
|
||||
<button
|
||||
key={tc.id}
|
||||
onClick={() => setThemeIndex(i)}
|
||||
className={`rounded-full transition-all duration-200 ${i === themeIndex
|
||||
? 'w-6 h-2 bg-accent'
|
||||
: chatTheme === tc.id
|
||||
? 'w-2 h-2 bg-accent/50'
|
||||
: 'w-2 h-2 bg-zinc-600 hover:bg-zinc-500'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
// ======= FRIENDS VIEW =======
|
||||
const renderFriends = () => (
|
||||
<motion.div key="friends" className="flex flex-col h-full" initial={{ x: 100, opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: 100, opacity: 0 }} transition={{ duration: 0.2 }}>
|
||||
<div className="h-14 flex items-center gap-3 px-4 border-b border-border flex-shrink-0">
|
||||
<button onClick={() => { changeView('main'); clearSearch(); }} className="p-1.5 rounded-lg text-zinc-400 hover:text-white hover:bg-white/10 transition-colors">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h3 className="text-sm font-semibold text-white flex-1">{t('friends')}</h3>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="px-4 pt-3 pb-2">
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('searchFriends')}
|
||||
value={friendSearch}
|
||||
onChange={(e) => setFriendSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2.5 rounded-xl bg-surface-tertiary text-sm text-white placeholder-zinc-500 border border-border focus:border-accent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{friendsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 size={24} className="animate-spin text-zinc-400" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Search results */}
|
||||
{friendSearch.trim().length > 0 && (
|
||||
<div className="px-4 pt-2 pb-2">
|
||||
<h4 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider mb-3">
|
||||
<Search size={12} className="inline mr-1" />{t('searchFriends').split('(')[0].trim()}
|
||||
</h4>
|
||||
{(() => {
|
||||
const raw = friendSearch.trim();
|
||||
const q = raw.startsWith('@') ? raw.slice(1) : raw;
|
||||
if (q.length < 3) {
|
||||
return <p className="text-xs text-zinc-500 text-center py-3">{t('minCharsHint')}</p>;
|
||||
}
|
||||
if (friendSearchLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 size={18} className="animate-spin text-zinc-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (friendSearchResults.length === 0) {
|
||||
return <p className="text-xs text-zinc-500 text-center py-3">{t('noSearchResults')}</p>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{friendSearchResults.map((u) => (
|
||||
<div key={u.id} className="flex items-center gap-3 p-3 rounded-xl bg-white/5 border border-border/50">
|
||||
{u.avatar ? (
|
||||
<img src={u.avatar} alt="" className="w-10 h-10 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm">
|
||||
{(u.displayName || u.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">{u.displayName || u.username}</p>
|
||||
<p className="text-xs text-zinc-500">@{u.username}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleSendFriendRequest(u.id)}
|
||||
className="p-2 rounded-lg bg-knot-500/20 text-knot-400 hover:bg-knot-500/30 transition-colors"
|
||||
title={t('addFriend')}
|
||||
>
|
||||
<UserPlus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Incoming requests */}
|
||||
{friendRequests.length > 0 && (
|
||||
<div className="px-4 pt-4 pb-2">
|
||||
<h4 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider mb-3">
|
||||
{t('friendRequests')} ({friendRequests.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{friendRequests.map((req) => (
|
||||
<div key={req.id} className="flex items-center gap-3 p-3 rounded-xl bg-white/5 border border-border/50">
|
||||
{req.user.avatar ? (
|
||||
<img src={req.user.avatar} alt="" className="w-10 h-10 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm">
|
||||
{(req.user.displayName || req.user.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">{req.user.displayName || req.user.username}</p>
|
||||
<p className="text-xs text-zinc-500">@{req.user.username}</p>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={() => handleAcceptRequest(req.id)}
|
||||
className="p-2 rounded-lg bg-green-500/20 text-green-400 hover:bg-green-500/30 transition-colors"
|
||||
title={t('accept')}
|
||||
>
|
||||
<UserCheck size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeclineRequest(req.id)}
|
||||
className="p-2 rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
|
||||
title={t('decline')}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Friends list */}
|
||||
<div className="px-4 pt-4 pb-2">
|
||||
<h4 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider mb-3">
|
||||
{t('friendsList')} ({friends.length})
|
||||
</h4>
|
||||
{friends.length === 0 ? (
|
||||
<p className="text-sm text-zinc-500 text-center py-8">{t('noFriends')}</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{friends.map((friend) => (
|
||||
<div key={friend.id} className="flex items-center gap-3 p-3 rounded-xl hover:bg-white/5 transition-colors group/friend">
|
||||
<div className="relative">
|
||||
{friend.avatar ? (
|
||||
<img src={friend.avatar} alt="" className="w-10 h-10 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm">
|
||||
{(friend.displayName || friend.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
{friend.isOnline && (
|
||||
<div className="absolute bottom-0 right-0 w-3 h-3 rounded-full bg-green-500 border-2 border-surface-secondary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">{friend.displayName || friend.username}</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{friend.isOnline ? t('online') : `@${friend.username}`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveFriend(friend.friendshipId)}
|
||||
className="p-2 rounded-lg text-zinc-600 opacity-0 group-hover/friend:opacity-100 hover:bg-red-500/20 hover:text-red-400 transition-all"
|
||||
title={t('removeFriend')}
|
||||
>
|
||||
<UserMinus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
// ======= ABOUT VIEW =======
|
||||
const renderAbout = () => (
|
||||
<motion.div key="about" className="flex flex-col h-full" initial={{ x: 100, opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: 100, opacity: 0 }} transition={{ duration: 0.2 }}>
|
||||
<div className="h-14 flex items-center gap-3 px-4 border-b border-border flex-shrink-0">
|
||||
<button onClick={() => changeView('main')} className="p-1.5 rounded-lg text-zinc-400 hover:text-white hover:bg-white/10 transition-colors">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h3 className="text-sm font-semibold text-white flex-1">{t('aboutApp')}</h3>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center">
|
||||
<div className="w-20 h-20 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-accent/20 to-purple-600/20 flex items-center justify-center shadow-[0_0_30px_-5px_var(--color-accent)] ring-1 ring-white/10 relative overflow-hidden">
|
||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-white/[0.05] to-transparent pointer-events-none" />
|
||||
<MessageSquare size={36} className="text-accent drop-shadow-md relative z-10" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold gradient-text mb-1">Knot Messenger</h2>
|
||||
<p className="text-sm text-zinc-400 mb-6">{t('version')} 1.0.0</p>
|
||||
<div className="text-xs text-zinc-500 space-y-1">
|
||||
<p>{t('modernMessenger')}</p>
|
||||
<p>{t('onPrivacy')}</p>
|
||||
<p className="mt-4 text-zinc-600">© 2026 Knot Team</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 bg-black/60 z-50" onClick={onClose} />
|
||||
<motion.div
|
||||
initial={{ x: -320, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: -320, opacity: 0 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
className="fixed left-3 top-3 bottom-3 w-[340px] max-w-[calc(100vw-24px)] bg-surface-secondary/95 backdrop-blur-3xl shadow-[0_0_100px_rgba(0,0,0,0.5)] border border-border/50 rounded-3xl z-50 flex flex-col overflow-hidden"
|
||||
>
|
||||
<AnimatePresence mode="wait" custom={slideDir}>
|
||||
{view === 'main' && renderMain()}
|
||||
{view === 'settings' && renderSettings()}
|
||||
{view === 'themes' && renderThemes()}
|
||||
{view === 'friends' && renderFriends()}
|
||||
{view === 'about' && renderAbout()}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
<TelegramImportModal isOpen={showImportModal} onClose={() => setShowImportModal(false)} friends={friends} />
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
X,
|
||||
User as UserIcon,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '../../../modules/auth/application/authStore';
|
||||
import { useChatStore } from '../../../modules/chats/application/chatStore';
|
||||
import { useNotificationStore } from '../../application/stores/notificationStore';
|
||||
import { useLang } from '../../infrastructure/i18n';
|
||||
import { StoryApi } from '../../../modules/stories/infrastructure/storyApi';
|
||||
import { getSocket } from '../../infrastructure/socket';
|
||||
import { getInitials, generateAvatarColor } from '../../utils/utils';
|
||||
import Avatar from '../components/ui/Avatar';
|
||||
import { StoryGroup } from '../../domain/types';
|
||||
import ChatListItem from '../../../modules/chats/presentation/components/ChatListItem';
|
||||
import NewChatModal from '../../../modules/chats/presentation/components/NewChatModal';
|
||||
import UserProfile from '../../../modules/users/presentation/components/UserProfile';
|
||||
import SideMenu from './SideMenu';
|
||||
import StoryViewer, { CreateStoryModal } from '../../../modules/stories/presentation/components/StoryViewer';
|
||||
import { useStoryStore } from '../../../modules/stories/application/storyStore';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
export default function Sidebar() {
|
||||
const { user, logout } = useAuthStore();
|
||||
const { chats, activeChat, searchQuery, setSearchQuery, clearStore } = useChatStore();
|
||||
const { t } = useLang();
|
||||
const [showNewChat, setShowNewChat] = useState(false);
|
||||
const [showProfile, setShowProfile] = useState(false);
|
||||
const [showSideMenu, setShowSideMenu] = useState(false);
|
||||
const { storyGroups, setStoryGroups, viewerIndex, viewerStoryIndex, openViewer, closeViewer } = useStoryStore();
|
||||
const [showCreateStory, setShowCreateStory] = useState(false);
|
||||
|
||||
const loadStories = () => {
|
||||
StoryApi.getStories()
|
||||
.then(setStoryGroups)
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
addNotification('error', (t('loadStoriesError') || 'Failed to load stories') as string);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadStories();
|
||||
const interval = setInterval(loadStories, 30000); // refresh every 30s
|
||||
|
||||
const socket = getSocket();
|
||||
const onStoryViewed = (data: any) => {
|
||||
// Refresh stories if I'm the owner or the viewer
|
||||
if (data.ownerId === user?.id || data.userId === user?.id) {
|
||||
loadStories();
|
||||
}
|
||||
};
|
||||
|
||||
socket?.on('story_viewed', onStoryViewed);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
socket?.off('story_viewed', onStoryViewed);
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
const filteredChats = chats.filter((chat) => {
|
||||
if (!searchQuery) return true;
|
||||
const q = searchQuery.toLowerCase();
|
||||
if (chat.name?.toLowerCase().includes(q)) return true;
|
||||
return chat.members.some(
|
||||
(m) =>
|
||||
m.user.id !== user?.id &&
|
||||
(m.user.username.toLowerCase().includes(q) ||
|
||||
m.user.displayName.toLowerCase().includes(q))
|
||||
);
|
||||
}).sort((a, b) => {
|
||||
// 1. Favorites chat always on top
|
||||
if (a.type === 'favorites') return -1;
|
||||
if (b.type === 'favorites') return 1;
|
||||
|
||||
// 2. Pinned chats next
|
||||
const aPinned = a.members.find(m => m.user.id === user?.id)?.isPinned;
|
||||
const bPinned = b.members.find(m => m.user.id === user?.id)?.isPinned;
|
||||
if (aPinned && !bPinned) return -1;
|
||||
if (!aPinned && bPinned) return 1;
|
||||
|
||||
// 3. Last message timestamp (if available) - though currently we don't have it on top level
|
||||
return 0;
|
||||
});
|
||||
|
||||
const handleLogout = () => {
|
||||
clearStore();
|
||||
logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full h-full flex flex-col bg-surface-secondary sm:rounded-[2rem] overflow-hidden border-x sm:border border-border/50 shadow-2xl relative z-10">
|
||||
{/* Шапка */}
|
||||
<div className="h-14 px-3 flex items-center justify-between border-b border-border/40 bg-surface-secondary flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowSideMenu(true)}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-hover transition-colors text-zinc-400 hover:text-white"
|
||||
title={t('menu')}
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-xl bg-accent text-white flex-shrink-0">
|
||||
<MessageSquare size={16} fill="currentColor" strokeWidth={0} />
|
||||
</div>
|
||||
<h1 className="text-[16px] font-semibold text-white truncate tracking-tight">Knot Messenger</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowNewChat(true)}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-hover transition-colors text-zinc-400 hover:text-white"
|
||||
title={t('newChat')}
|
||||
>
|
||||
<Plus size={22} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Поиск */}
|
||||
<div className="px-3 py-2 bg-surface-secondary">
|
||||
<div className="relative group">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500 transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('searchChats')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-8 py-1.5 rounded-[10px] bg-surface-tertiary text-[14px] text-white placeholder-zinc-500 border border-transparent focus:border-accent/50 hover:bg-surface-hover transition-all outline-none"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-full text-zinc-400 hover:text-white transition-colors"
|
||||
title={t('clear')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Story circles */}
|
||||
{(storyGroups.length > 0 || true) && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 overflow-x-auto scrollbar-hide border-b border-border/20 flex-shrink-0">
|
||||
{/* Add story circle */}
|
||||
<button
|
||||
onClick={() => setShowCreateStory(true)}
|
||||
className="flex flex-col items-center gap-1.5 flex-shrink-0 group w-[60px]"
|
||||
>
|
||||
<div className="w-[50px] h-[50px] rounded-full border border-dashed border-zinc-600 flex items-center justify-center group-hover:border-accent group-hover:bg-accent/10 transition-colors">
|
||||
<Plus size={20} className="text-accent transition-colors" />
|
||||
</div>
|
||||
<span className="text-[11px] text-zinc-400 truncate w-full text-center">{t('newStory')}</span>
|
||||
</button>
|
||||
|
||||
{storyGroups.map((group, idx) => {
|
||||
const avatarUrl = group.user.avatar ? `${API_URL}${group.user.avatar}` : null;
|
||||
const isMine = group.user.id === user?.id;
|
||||
return (
|
||||
<button
|
||||
key={group.user.id}
|
||||
onClick={() => openViewer(idx)}
|
||||
className="flex flex-col items-center gap-1.5 flex-shrink-0 group w-[60px]"
|
||||
>
|
||||
<div className={`w-[50px] h-[50px] rounded-full flex items-center justify-center transition-transform group-hover:scale-[1.03] ${
|
||||
group.hasUnviewed
|
||||
? 'border-[2px] border-accent'
|
||||
: isMine
|
||||
? 'border border-zinc-600'
|
||||
: 'border border-zinc-700'
|
||||
}`}>
|
||||
<div className="w-[44px] h-[44px] rounded-full overflow-hidden">
|
||||
<Avatar
|
||||
src={avatarUrl}
|
||||
name={group.user.displayName || group.user.username}
|
||||
size="md"
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] text-zinc-400 truncate w-full text-center">
|
||||
{isMine ? t('myStory') : (group.user.displayName || group.user.username).split(' ')[0]}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Список чатов */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filteredChats.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-zinc-500 gap-3 px-6">
|
||||
<MessageSquare size={40} className="opacity-30" />
|
||||
<p className="text-sm text-center">
|
||||
{searchQuery ? t('nothingFound') : t('noChats')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredChats.map((chat) => (
|
||||
<ChatListItem key={chat.id} chat={chat} isActive={chat.id === activeChat} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модалки */}
|
||||
<AnimatePresence>
|
||||
{showNewChat && <NewChatModal onClose={() => setShowNewChat(false)} />}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{showProfile && <UserProfile userId={user!.id} onClose={() => setShowProfile(false)} isSelf />}
|
||||
</AnimatePresence>
|
||||
<SideMenu
|
||||
isOpen={showSideMenu}
|
||||
onClose={() => setShowSideMenu(false)}
|
||||
onOpenProfile={() => setShowProfile(true)}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{viewerIndex !== null && storyGroups.length > 0 && (
|
||||
<StoryViewer
|
||||
stories={storyGroups}
|
||||
initialUserIndex={viewerIndex}
|
||||
initialStoryIndex={viewerStoryIndex}
|
||||
onClose={() => { closeViewer(); loadStories(); }}
|
||||
onRefresh={loadStories}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{showCreateStory && (
|
||||
<CreateStoryModal
|
||||
onClose={() => setShowCreateStory(false)}
|
||||
onCreated={loadStories}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Debounced value — updates value after the specified delay.
|
||||
*/
|
||||
export function useDebouncedValue<T>(value: T, delay: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced callback — returns a function that delays execution.
|
||||
*/
|
||||
export function useDebouncedCallback<T extends (...args: unknown[]) => unknown>(
|
||||
callback: T,
|
||||
delay: number,
|
||||
): (...args: Parameters<T>) => void {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const callbackRef = useRef(callback);
|
||||
callbackRef.current = callback;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return useCallback((...args: Parameters<T>) => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => callbackRef.current(...args), delay);
|
||||
}, [delay]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an AbortController that auto-aborts on unmount or when reset is called.
|
||||
*/
|
||||
export function useAbortController() {
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const getSignal = useCallback(() => {
|
||||
if (controllerRef.current) controllerRef.current.abort();
|
||||
controllerRef.current = new AbortController();
|
||||
return controllerRef.current.signal;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
controllerRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return getSignal;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Notification sound using Web Audio API — generates a pleasant chime
|
||||
let audioContext: AudioContext | null = null;
|
||||
|
||||
function getAudioContext(): AudioContext | null {
|
||||
if (typeof window !== 'undefined' && navigator && 'userActivation' in navigator) {
|
||||
if (!(navigator as any).userActivation.hasBeenActive) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!audioContext) {
|
||||
try {
|
||||
const AudioCtx = window.AudioContext || (window as any).webkitAudioContext;
|
||||
if (AudioCtx) {
|
||||
audioContext = new AudioCtx();
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return audioContext;
|
||||
}
|
||||
|
||||
export function playNotificationSound() {
|
||||
try {
|
||||
const ctx = getAudioContext();
|
||||
if (!ctx) return;
|
||||
if (ctx.state === 'suspended') {
|
||||
ctx.resume();
|
||||
}
|
||||
|
||||
const now = ctx.currentTime;
|
||||
|
||||
// Soft, warm notification — lower frequencies, triangle waves, gentle volume
|
||||
// First note — warm mellow tone
|
||||
const osc1 = ctx.createOscillator();
|
||||
const gain1 = ctx.createGain();
|
||||
osc1.type = 'triangle';
|
||||
osc1.frequency.setValueAtTime(523.25, now); // C5
|
||||
gain1.gain.setValueAtTime(0.08, now);
|
||||
gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.3);
|
||||
osc1.connect(gain1);
|
||||
gain1.connect(ctx.destination);
|
||||
osc1.start(now);
|
||||
osc1.stop(now + 0.3);
|
||||
|
||||
// Second note — gentle higher tone
|
||||
const osc2 = ctx.createOscillator();
|
||||
const gain2 = ctx.createGain();
|
||||
osc2.type = 'triangle';
|
||||
osc2.frequency.setValueAtTime(659.25, now + 0.08); // E5
|
||||
gain2.gain.setValueAtTime(0, now);
|
||||
gain2.gain.setValueAtTime(0.06, now + 0.08);
|
||||
gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.35);
|
||||
osc2.connect(gain2);
|
||||
gain2.connect(ctx.destination);
|
||||
osc2.start(now + 0.08);
|
||||
osc2.stop(now + 0.35);
|
||||
} catch (e) {
|
||||
// Audio context not supported — silent fail
|
||||
}
|
||||
}
|
||||
|
||||
// Muted chats stored in localStorage
|
||||
const MUTED_KEY = 'knot_muted_chats';
|
||||
|
||||
export function getMutedChats(): Set<string> {
|
||||
try {
|
||||
const stored = localStorage.getItem(MUTED_KEY);
|
||||
return stored ? new Set(JSON.parse(stored)) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleMuteChat(chatId: string): boolean {
|
||||
const muted = getMutedChats();
|
||||
if (muted.has(chatId)) {
|
||||
muted.delete(chatId);
|
||||
} else {
|
||||
muted.add(chatId);
|
||||
}
|
||||
localStorage.setItem(MUTED_KEY, JSON.stringify([...muted]));
|
||||
return muted.has(chatId);
|
||||
}
|
||||
|
||||
export function isChatMuted(chatId: string): boolean {
|
||||
return getMutedChats().has(chatId);
|
||||
}
|
||||
|
||||
// Call ringtone
|
||||
let callAudio: HTMLAudioElement | null = null;
|
||||
|
||||
export function playCallRingtone() {
|
||||
try {
|
||||
if (callAudio) {
|
||||
callAudio.pause();
|
||||
callAudio.currentTime = 0;
|
||||
}
|
||||
callAudio = new Audio('/sounds/call_sound.mp3');
|
||||
callAudio.loop = true;
|
||||
callAudio.volume = 0.5;
|
||||
callAudio.play().catch(() => {});
|
||||
} catch (e) {
|
||||
// silent fail
|
||||
}
|
||||
}
|
||||
|
||||
export function stopCallRingtone() {
|
||||
try {
|
||||
if (callAudio) {
|
||||
callAudio.pause();
|
||||
callAudio.currentTime = 0;
|
||||
callAudio = null;
|
||||
}
|
||||
} catch (e) {
|
||||
// silent fail
|
||||
}
|
||||
}
|
||||
|
||||
// "Абонент недоступен" sound
|
||||
export function playUnavailableSound(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const audio = new Audio('/sounds/abonent_nedostupen.mp3');
|
||||
audio.volume = 0.7;
|
||||
audio.onended = () => resolve();
|
||||
audio.onerror = () => resolve();
|
||||
audio.play().catch(() => resolve());
|
||||
} catch (e) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return clsx(inputs);
|
||||
}
|
||||
|
||||
export function formatTime(date: string | Date, lang: string = 'ru'): string {
|
||||
const d = new Date(date);
|
||||
return d.toLocaleTimeString(lang === 'ru' ? 'ru-RU' : 'en-US', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
export function formatDate(date: string | Date, lang: string = 'ru'): string {
|
||||
const d = new Date(date);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) return lang === 'ru' ? 'Сегодня' : 'Today';
|
||||
if (days === 1) return lang === 'ru' ? 'Вчера' : 'Yesterday';
|
||||
if (days < 7) {
|
||||
const weekDaysRu = ['Воскресенье', 'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота'];
|
||||
const weekDaysEn = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
return (lang === 'ru' ? weekDaysRu : weekDaysEn)[d.getDay()];
|
||||
}
|
||||
|
||||
return d.toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: days > 365 ? 'numeric' : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatLastSeen(date: string | Date, lang: string = 'ru'): string {
|
||||
const d = new Date(date);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
|
||||
if (minutes < 1) return lang === 'ru' ? 'только что' : 'just now';
|
||||
if (minutes < 60) return lang === 'ru' ? `${minutes} мин. назад` : `${minutes}m ago`;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return lang === 'ru' ? `${hours} ч. назад` : `${hours}h ago`;
|
||||
|
||||
const at = lang === 'ru' ? ' в ' : ' at ';
|
||||
return formatDate(date, lang) + at + formatTime(date, lang);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips markdown syntax (**bold**, *italic*, _italic_, ~strikethrough~, `code`)
|
||||
* and returns plain text for use in previews.
|
||||
*/
|
||||
export function stripMarkdown(text: string): string {
|
||||
if (!text) return text;
|
||||
return text
|
||||
.replace(/\*\*([\s\S]*?)\*\*/g, '$1')
|
||||
.replace(/\*([\s\S]*?)\*/g, '$1')
|
||||
.replace(/_([\s\S]*?)_/g, '$1')
|
||||
.replace(/~([\s\S]*?)~/g, '$1')
|
||||
.replace(/`([\s\S]*?)`/g, '$1');
|
||||
}
|
||||
|
||||
export function getInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((part) => part[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
export function generateAvatarColor(name: string): string {
|
||||
const colors = [
|
||||
'from-violet-500 to-purple-600',
|
||||
'from-blue-500 to-indigo-600',
|
||||
'from-emerald-500 to-teal-600',
|
||||
'from-rose-500 to-pink-600',
|
||||
'from-amber-500 to-orange-600',
|
||||
'from-cyan-500 to-blue-600',
|
||||
'from-fuchsia-500 to-purple-600',
|
||||
'from-lime-500 to-green-600',
|
||||
];
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
// Waveform cache so we don't decode the same audio twice
|
||||
const waveformCache = new Map<string, number[]>();
|
||||
|
||||
/**
|
||||
* Decodes an audio file from a URL and extracts normalized waveform peak values.
|
||||
* Returns an array of `bars` values in [0, 1].
|
||||
*/
|
||||
export async function extractWaveform(url: string, bars: number = 28): Promise<number[]> {
|
||||
const cached = waveformCache.get(url);
|
||||
if (cached) return cached;
|
||||
|
||||
let audioCtx: any;
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const OfflineCtx = window.OfflineAudioContext || (window as any).webkitOfflineAudioContext;
|
||||
if (OfflineCtx) {
|
||||
audioCtx = new OfflineCtx(1, 1, 44100);
|
||||
} else {
|
||||
audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
}
|
||||
|
||||
// We only need the buffer, so decode it
|
||||
const audioBuffer = await audioCtx!.decodeAudioData(arrayBuffer);
|
||||
|
||||
// If it was a regular AudioContext, close it.
|
||||
if (audioCtx.close) {
|
||||
await audioCtx.close();
|
||||
}
|
||||
audioCtx = undefined;
|
||||
|
||||
const channelData = audioBuffer.getChannelData(0);
|
||||
const samplesPerBar = Math.floor(channelData.length / bars);
|
||||
const peaks: number[] = [];
|
||||
|
||||
for (let i = 0; i < bars; i++) {
|
||||
let peak = 0;
|
||||
const start = i * samplesPerBar;
|
||||
// Sample a subset for performance
|
||||
const step = Math.max(1, Math.floor(samplesPerBar / 200));
|
||||
for (let j = 0; j < samplesPerBar; j += step) {
|
||||
const abs = Math.abs(channelData[start + j] || 0);
|
||||
if (abs > peak) peak = abs;
|
||||
}
|
||||
peaks.push(peak);
|
||||
}
|
||||
|
||||
// Normalize to [0, 1]
|
||||
const max = Math.max(...peaks, 0.01);
|
||||
const normalized = peaks.map(p => p / max);
|
||||
waveformCache.set(url, normalized);
|
||||
return normalized;
|
||||
} catch {
|
||||
// Close leaked AudioContext if any
|
||||
if (audioCtx && audioCtx.close) audioCtx.close().catch(() => {});
|
||||
// On error, return uniform bars
|
||||
return Array(bars).fill(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
export function getMediaUrl(url: string | null | undefined): string {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('http') || url.startsWith('blob:') || url.startsWith('data:')) return url;
|
||||
|
||||
// Use VITE_API_URL if defined, otherwise let it be a relative path which the browser
|
||||
// will resolve against the current origin (port).
|
||||
const baseUrl = import.meta.env.VITE_API_URL || '';
|
||||
return `${baseUrl}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
}
|
||||
Reference in New Issue
Block a user