Reorganize web folder structurally

This commit is contained in:
Халимов Рустам
2026-03-19 22:24:21 +03:00
parent 11ebbc853b
commit 4384233aa5
62 changed files with 0 additions and 0 deletions
@@ -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',
}
)
);
+183
View File
@@ -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();
+565
View File
@@ -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>
</>
);
}
+59
View File
@@ -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;
}
+134
View File
@@ -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();
}
});
}
+160
View File
@@ -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}`;
}