Локализация
This commit is contained in:
@@ -37,6 +37,13 @@ const translations = {
|
||||
modernMessenger: 'Современный мессенджер с фокусом',
|
||||
onPrivacy: 'на приватность и удобство',
|
||||
modernMessengerShort: 'Современный мессенджер',
|
||||
settingsDesc: 'Управляйте своим профилем и настройками приложения',
|
||||
appSettings: 'Настройки приложения',
|
||||
enterDisplayName: 'Как вас называть?',
|
||||
tellAboutBio: 'Расскажите немного о себе...',
|
||||
storageAndData: 'Хранилище и данные',
|
||||
importTelegram: 'Импорт истории Telegram',
|
||||
importTelegramDesc: 'Перенести сообщения и медиа из Telegram',
|
||||
// Chat
|
||||
chat: 'Чат',
|
||||
group: 'Группа',
|
||||
@@ -54,12 +61,16 @@ const translations = {
|
||||
members: 'участников',
|
||||
files: 'файлов',
|
||||
clearAll: 'Очистить всё',
|
||||
maxFilesLimit: 'Максимум 20 файлов',
|
||||
searchingHistory: 'Поиск сообщения в истории...',
|
||||
messageNotFound: 'Сообщение не найдено',
|
||||
messagePlaceholder: 'Сообщение...',
|
||||
message: 'Сообщение...',
|
||||
addCaption: 'Добавьте подпись...',
|
||||
searchMessages: 'Поиск сообщений...',
|
||||
searchMessagesBtn: 'Поиск сообщений',
|
||||
userProfile: 'Профиль пользователя',
|
||||
userProfileUpper: 'ПРОФИЛЬ ПОЛЬЗОВАТЕЛЯ',
|
||||
enableSound: 'Включить звук',
|
||||
disableSound: 'Выключить звук',
|
||||
deleteChat: 'Удалить чат',
|
||||
@@ -90,6 +101,9 @@ const translations = {
|
||||
incomingCall: 'Входящий звонок',
|
||||
incomingVideoCall: 'Входящий видеозвонок',
|
||||
calling: 'Вызов...',
|
||||
isCalling: 'звонит...',
|
||||
groupCall: 'Групповой звонок',
|
||||
comingSoon: 'Скоро будет',
|
||||
accept: 'Принять',
|
||||
decline: 'Отклонить',
|
||||
endCall: 'Завершить',
|
||||
@@ -197,6 +211,39 @@ const translations = {
|
||||
yes: 'Да',
|
||||
no: 'Нет',
|
||||
you: 'вы',
|
||||
// Stories Editor
|
||||
cropVideo: 'Обрезка видео',
|
||||
trimVideo: 'Обрезать видео',
|
||||
trim: 'Обрезать',
|
||||
reset: 'Сбросить',
|
||||
startCreation: 'Начать создание',
|
||||
brushTool: 'Кисти',
|
||||
textTool: 'Текст',
|
||||
stickersTool: 'Стикеры',
|
||||
filtersTool: 'Коррекция',
|
||||
textContent: 'Контент',
|
||||
typography: 'Типографика',
|
||||
textSize: 'Размер',
|
||||
textColor: 'Цвет текста',
|
||||
brushThickness: 'Толщина',
|
||||
clearCanvas: 'Очистить холст',
|
||||
correctionMaster: 'Мастер коррекции',
|
||||
brightness: 'Яркость',
|
||||
contrast: 'Контраст',
|
||||
saturation: 'Насыщенность',
|
||||
sepia: 'Сепия',
|
||||
bgColor: 'Цвет фона',
|
||||
clickToChooseColor: 'Нажмите для выбора цвета',
|
||||
done: 'Готово',
|
||||
stop: 'Остановить',
|
||||
zoomHint: 'Колесико мыши для зума',
|
||||
secSelected: 'сек выбрано',
|
||||
muted: 'Без звука',
|
||||
unmuted: 'Со звуком',
|
||||
muteVideo: 'Отключить звук',
|
||||
unmuteVideo: 'Включить звук',
|
||||
interactive: 'Интерактив',
|
||||
apply: 'Применить',
|
||||
// User profile
|
||||
mediaTab: 'Медиа',
|
||||
gifs: 'GIF',
|
||||
@@ -207,6 +254,7 @@ const translations = {
|
||||
namePlaceholder: 'Имя',
|
||||
notSpecified: 'Не указано',
|
||||
onKnotSince: 'На Knot с',
|
||||
joined: 'С нами с',
|
||||
cancel: 'Отмена',
|
||||
confirm: 'Подтвердить',
|
||||
save: 'Сохранить',
|
||||
@@ -217,10 +265,13 @@ const translations = {
|
||||
storiesTab: 'Истории',
|
||||
publicationsTab: 'Публикации',
|
||||
noStories: 'Публикаций пока нет',
|
||||
noBio: 'Информация отсутствует',
|
||||
goToMessage: 'Перейти к сообщению',
|
||||
story: 'История',
|
||||
commonGroups: 'Общие группы',
|
||||
more: 'Ещё',
|
||||
empty: 'Пусто',
|
||||
acceptRequest: 'Принять запрос',
|
||||
// Typing
|
||||
typingText: 'печатает',
|
||||
// Emoji
|
||||
@@ -350,6 +401,13 @@ const translations = {
|
||||
modernMessenger: 'A modern messenger focused',
|
||||
onPrivacy: 'on privacy and convenience',
|
||||
modernMessengerShort: 'Knot Messenger',
|
||||
settingsDesc: 'Manage your profile and app settings',
|
||||
appSettings: 'App Settings',
|
||||
enterDisplayName: 'How should we call you?',
|
||||
tellAboutBio: 'Tell us a bit about yourself...',
|
||||
storageAndData: 'Storage and Data',
|
||||
importTelegram: 'Import Telegram History',
|
||||
importTelegramDesc: 'Move messages and media from Telegram',
|
||||
searchChats: 'Search chats...',
|
||||
chat: 'Chat',
|
||||
group: 'Group',
|
||||
@@ -366,12 +424,16 @@ const translations = {
|
||||
members: 'members',
|
||||
files: 'files',
|
||||
clearAll: 'Clear all',
|
||||
maxFilesLimit: 'Maximum 20 files',
|
||||
searchingHistory: 'Searching history...',
|
||||
messageNotFound: 'Message not found',
|
||||
messagePlaceholder: 'Message...',
|
||||
message: 'Message...',
|
||||
addCaption: 'Add a caption...',
|
||||
searchMessages: 'Search messages...',
|
||||
searchMessagesBtn: 'Search messages',
|
||||
userProfile: 'User profile',
|
||||
userProfileUpper: 'USER PROFILE',
|
||||
enableSound: 'Enable sound',
|
||||
disableSound: 'Mute',
|
||||
deleteChat: 'Delete chat',
|
||||
@@ -400,6 +462,9 @@ const translations = {
|
||||
incomingCall: 'Incoming call',
|
||||
incomingVideoCall: 'Incoming video call',
|
||||
calling: 'Calling...',
|
||||
isCalling: 'is calling...',
|
||||
groupCall: 'Group call',
|
||||
comingSoon: 'Coming soon',
|
||||
accept: 'Accept',
|
||||
decline: 'Decline',
|
||||
endCall: 'End call',
|
||||
@@ -485,6 +550,40 @@ const translations = {
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
you: 'you',
|
||||
// Stories Editor
|
||||
cropVideo: 'Video Crop',
|
||||
trimVideo: 'Trim Video',
|
||||
trim: 'Trim',
|
||||
reset: 'Reset',
|
||||
startCreation: 'Start Creation',
|
||||
brushTool: 'Brush',
|
||||
textTool: 'Text',
|
||||
stickersTool: 'Stickers',
|
||||
filtersTool: 'Filters',
|
||||
textContent: 'Content',
|
||||
typography: 'Typography',
|
||||
textSize: 'Size',
|
||||
textColor: 'Color',
|
||||
brushThickness: 'Thickness',
|
||||
clearCanvas: 'Clear Canvas',
|
||||
correctionMaster: 'Correction Master',
|
||||
brightness: 'Brightness',
|
||||
contrast: 'Contrast',
|
||||
saturation: 'Saturation',
|
||||
sepia: 'Sepia',
|
||||
bgColor: 'Background',
|
||||
clickToChooseColor: 'Click to choose color',
|
||||
done: 'Done',
|
||||
stop: 'Stop',
|
||||
zoomHint: 'Mouse wheel to zoom',
|
||||
secSelected: 'sec selected',
|
||||
muted: 'Muted',
|
||||
unmuted: 'Unmuted',
|
||||
muteVideo: 'Mute Video',
|
||||
unmuteVideo: 'Unmute Video',
|
||||
interactive: 'Interactive',
|
||||
apply: 'Apply',
|
||||
// User profile
|
||||
mediaTab: 'Media',
|
||||
gifs: 'GIF',
|
||||
filesTab: 'Files',
|
||||
@@ -494,6 +593,7 @@ const translations = {
|
||||
namePlaceholder: 'Name',
|
||||
notSpecified: 'Not specified',
|
||||
onKnotSince: 'On Knot since',
|
||||
joined: 'Joined',
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Confirm',
|
||||
save: 'Save',
|
||||
@@ -504,10 +604,13 @@ const translations = {
|
||||
storiesTab: 'Stories',
|
||||
publicationsTab: 'Publications',
|
||||
noStories: 'No stories yet',
|
||||
noBio: 'No information shared yet',
|
||||
goToMessage: 'Go to message',
|
||||
story: 'Story',
|
||||
commonGroups: 'Common groups',
|
||||
more: 'More',
|
||||
empty: 'Empty',
|
||||
acceptRequest: 'Accept Request',
|
||||
typingText: 'typing',
|
||||
emojiFrequent: 'Frequent',
|
||||
emojiGestures: 'Gestures',
|
||||
@@ -547,7 +650,7 @@ const translations = {
|
||||
formatMono: 'Monospace',
|
||||
formatMonoHint: '`text`',
|
||||
draft: 'Draft:',
|
||||
datePlaceholder: 'dd.mm.yyyy',
|
||||
datePlaceholder: 'mm/dd/yyyy',
|
||||
tenorKeyRequired: 'Tenor API key required for GIFs',
|
||||
openConsoleRun: 'Open console and run:',
|
||||
searchGifs: 'Search GIFs...',
|
||||
@@ -613,13 +716,10 @@ const translations = {
|
||||
},
|
||||
} 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>;
|
||||
t: (key: keyof typeof translations['ru']) => any;
|
||||
}
|
||||
|
||||
export const useLang = create<LangState>((set, get) => ({
|
||||
@@ -630,6 +730,6 @@ export const useLang = create<LangState>((set, get) => ({
|
||||
},
|
||||
t: (key) => {
|
||||
const { lang } = get();
|
||||
return (translations[lang][key] ?? translations.ru[key] ?? key) as TranslationValue<typeof key>;
|
||||
return translations[lang][key] || translations['ru'][key] || key;
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -66,9 +66,9 @@ export default function DatePicker({ value, onChange }: DatePickerProps) {
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const months = t('months');
|
||||
const weekDays = t('weekDays');
|
||||
const shortMonths = useMemo(() => months.map(m => m.slice(0, 3)), [months]);
|
||||
const months = t('months') as string[];
|
||||
const weekDays = t('weekDays') as string[];
|
||||
const shortMonths = useMemo(() => months.map((m: string) => m.slice(0, 3)), [months]);
|
||||
|
||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
const firstDayRaw = new Date(viewYear, viewMonth, 1).getDay();
|
||||
@@ -209,7 +209,7 @@ export default function DatePicker({ value, onChange }: DatePickerProps) {
|
||||
transition={{ duration: 0.12 }}
|
||||
>
|
||||
<div className="grid grid-cols-7 px-3 pt-2">
|
||||
{weekDays.map((d) => (
|
||||
{weekDays.map((d: string) => (
|
||||
<div key={d} className="text-center text-[11px] text-zinc-500 font-medium py-1">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -249,7 +249,7 @@ export default function DatePicker({ value, onChange }: DatePickerProps) {
|
||||
transition={{ duration: 0.12 }}
|
||||
className="grid grid-cols-3 gap-1 p-3"
|
||||
>
|
||||
{shortMonths.map((m, idx) => {
|
||||
{shortMonths.map((m: string, idx: number) => {
|
||||
const isCurrentMonth = viewYear === today.getFullYear() && idx === today.getMonth();
|
||||
const isSelectedMonth = parsed && viewYear === parsed.getFullYear() && idx === parsed.getMonth();
|
||||
return (
|
||||
|
||||
@@ -62,12 +62,15 @@ export default function Sidebar() {
|
||||
socket?.on('story_viewed', onStoryViewed);
|
||||
|
||||
const handleOpenNewChat = () => setShowNewChat(true);
|
||||
const handleOpenSideMenu = () => setShowSideMenu(true);
|
||||
window.addEventListener('OPEN_NEW_CHAT', handleOpenNewChat);
|
||||
window.addEventListener('OPEN_SIDE_MENU', handleOpenSideMenu);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
socket?.off('story_viewed', onStoryViewed);
|
||||
window.removeEventListener('OPEN_NEW_CHAT', handleOpenNewChat);
|
||||
window.removeEventListener('OPEN_SIDE_MENU', handleOpenSideMenu);
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
@@ -105,8 +108,14 @@ export default function Sidebar() {
|
||||
<>
|
||||
<div className="w-full h-full flex flex-col bg-surface-container-low overflow-hidden border-none relative z-10 slide-on-ice">
|
||||
{/* Title Section (Minimal) */}
|
||||
<div className="px-6 py-8 pb-4">
|
||||
<div className="px-6 py-8 pb-4 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold font-headline text-white tracking-tight leading-none">{t('chats')}</h1>
|
||||
<button
|
||||
onClick={() => setShowSideMenu(true)}
|
||||
className="p-2 rounded-xl text-zinc-400 hover:text-white hover:bg-white/10 transition-all"
|
||||
>
|
||||
<Menu size={22} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
@@ -126,7 +135,7 @@ export default function Sidebar() {
|
||||
{/* Stories Section */}
|
||||
<div className="px-6 mb-2">
|
||||
<h2 className="text-[11px] font-black text-on-surface-variant/40 uppercase tracking-[0.12em] select-none">
|
||||
{t('stories') || 'ИСТОРИИ'}
|
||||
{t('stories')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import CallModal from '../../calls/presentation/components/CallModal';
|
||||
import GroupCallModal from '../../calls/presentation/components/GroupCallModal';
|
||||
import ContactsSidebar from '../../friends/presentation/components/ContactsSidebar';
|
||||
import SettingsPage from '../../users/presentation/components/SettingsPage';
|
||||
import UserProfile from '../../users/presentation/components/UserProfile';
|
||||
|
||||
export default function ChatPage() {
|
||||
const {
|
||||
@@ -419,10 +420,12 @@ export default function ChatPage() {
|
||||
<SettingsPage />
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-zinc-500">Coming soon</p>
|
||||
<p className="text-zinc-500">{t('comingSoon') || 'Coming soon'}</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
|
||||
<CallModal
|
||||
key={callSessionId}
|
||||
isOpen={callOpen}
|
||||
@@ -486,10 +489,10 @@ export default function ChatPage() {
|
||||
{incomingGroupCall.chatName}
|
||||
</h2>
|
||||
<p className="text-emerald-400 font-medium mb-1 truncate w-full text-center">
|
||||
{incomingGroupCall.callerInfo?.displayName || incomingGroupCall.callerInfo?.username || 'User'} {t('calling' as any) || 'звонит...'}
|
||||
{incomingGroupCall.callerInfo?.displayName || incomingGroupCall.callerInfo?.username || 'User'} {t('isCalling' as any) || 'звонит...'}
|
||||
</p>
|
||||
<p className="text-zinc-400 text-sm mb-8 bg-white/5 px-3 py-1 rounded-full border border-white/5">
|
||||
{t('group' as any) || 'Групповой звонок'}
|
||||
{t('groupCall' as any) || 'Групповой звонок'}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-8 w-full justify-center">
|
||||
|
||||
@@ -186,7 +186,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
||||
</p>
|
||||
</div>
|
||||
{chat.unreadCount > 0 && !isActive && (
|
||||
<span className="ml-2 flex-shrink-0 min-w-[20px] h-5 px-1.5 rounded-full bg-primary text-black flex items-center justify-center text-[10px] font-black shadow-lg shadow-primary/20 animate-pulse">
|
||||
<span className="ml-2 flex-shrink-0 min-w-[20px] h-5 px-1.5 rounded-full bg-primary text-[#0a0a0a] flex items-center justify-center text-[10px] font-black shadow-lg shadow-primary/20 animate-pulse">
|
||||
{chat.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1171,7 +1171,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
if (!activeChat) return;
|
||||
|
||||
const NotificationStore = await import('../../../../core/application/stores/notificationStore');
|
||||
NotificationStore.useNotificationStore.getState().addNotification('info', lang === 'ru' ? 'Поиск сообщения в истории...' : 'Searching message in history...');
|
||||
NotificationStore.useNotificationStore.getState().addNotification('info', t('searchingHistory' as any) || 'Searching message in history...');
|
||||
|
||||
const chatStore = useChatStore.getState();
|
||||
let found = false;
|
||||
@@ -1209,7 +1209,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
NotificationStore.useNotificationStore.getState().addNotification('warning', lang === 'ru' ? 'Сообщение не найдено' : 'Message not found');
|
||||
NotificationStore.useNotificationStore.getState().addNotification('warning', t('messageNotFound' as any) || 'Message not found');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -236,7 +236,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
} catch (e) {
|
||||
console.error('Ошибка загрузки файла:', e);
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
addNotification('error', t('uploadError') || 'Ошибка загрузки файла');
|
||||
addNotification('error', t('uploadError') || 'Upload error');
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
@@ -324,8 +324,8 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
newAttachments.push({ file, type: isAudio ? 'audio' : 'file' });
|
||||
}
|
||||
|
||||
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Некоторые файлы слишком большие');
|
||||
if (limitExceeded) addNotification('warning', 'Максимум 20 файлов');
|
||||
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Some files are too large');
|
||||
if (limitExceeded) addNotification('warning', t('maxFilesLimit' as any) || 'Maximum 20 files');
|
||||
|
||||
setAttachments(prev => [...prev, ...newAttachments]);
|
||||
inputRef.current?.focus();
|
||||
@@ -352,7 +352,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
newAttachments.push({ file, preview, type: isVideo ? 'video' : 'image' });
|
||||
}
|
||||
|
||||
if (limitExceeded) addNotification('warning', 'Максимум 20 файлов');
|
||||
if (limitExceeded) addNotification('warning', t('maxFilesLimit' as any) || 'Maximum 20 files');
|
||||
|
||||
setAttachments(prev => [...prev, ...newAttachments]);
|
||||
inputRef.current?.focus();
|
||||
@@ -567,8 +567,8 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
newAttachments.push({ file, type, preview });
|
||||
}
|
||||
|
||||
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Некоторые файлы слишком большие');
|
||||
if (limitExceeded) addNotification('warning', 'Максимум 20 файлов');
|
||||
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Some files are too large');
|
||||
if (limitExceeded) addNotification('warning', t('maxFilesLimit' as any) || 'Maximum 20 files');
|
||||
|
||||
setAttachments(prev => [...prev, ...newAttachments]);
|
||||
inputRef.current?.focus();
|
||||
@@ -624,7 +624,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
: `${t('replyTo')} ${replyTo?.sender?.displayName || replyTo?.sender?.userName || replyTo?.sender?.username || ''}`}
|
||||
</p>
|
||||
<div className="text-[13px] text-[#efeff3] truncate opacity-90 pl-1 border-l border-white/20 ml-0.5">
|
||||
{replyTo?.quote ? `«${replyTo.quote}»` : (editingMessage || replyTo)?.content || t('media') || 'Медиа'}
|
||||
{replyTo?.quote ? `«${replyTo.quote}»` : (editingMessage || replyTo)?.content || t('media')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -36,7 +36,7 @@ interface CreateStoryModalProps {
|
||||
}
|
||||
|
||||
export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps) {
|
||||
const { t } = useLang();
|
||||
const { t, lang } = useLang();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const [mediaFile, setMediaFile] = useState<File | null>(null);
|
||||
@@ -209,7 +209,7 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
|
||||
const addText = () => {
|
||||
const id = Date.now();
|
||||
setTextObjects([...textObjects, { id, text: 'Текст', x: 20, y: 40, color: '#ffffff', font: FONTS[0].family, size: 42, scale: 1 }]);
|
||||
setTextObjects([...textObjects, { id, text: t('textStory'), x: 20, y: 40, color: '#ffffff', font: FONTS[0].family, size: 42, scale: 1 }]);
|
||||
setSelectedTextId(id);
|
||||
setTool('none');
|
||||
setTimeout(() => setTool('text'), 50);
|
||||
@@ -376,19 +376,19 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
<div className="flex items-center gap-4">
|
||||
{mediaFile?.type.startsWith('video/') && (
|
||||
<>
|
||||
<button onClick={() => setTool('trim')} className={`p-3 rounded-xl border transition-all ${tool === 'trim' ? 'bg-primary/10 border-primary/40 text-primary' : 'bg-white/5 border-white/10 text-zinc-400 hover:text-white'}`} title="Обрезать видео">
|
||||
<button onClick={() => setTool('trim')} className={`p-3 rounded-xl border transition-all ${tool === 'trim' ? 'bg-primary/10 border-primary/40 text-primary' : 'bg-white/5 border-white/10 text-zinc-400 hover:text-white'}`} title={t('trimVideo')}>
|
||||
<span className="material-symbols-outlined">content_cut</span>
|
||||
<span className="ml-2 text-[10px] font-black uppercase tracking-widest">Обрезать</span>
|
||||
<span className="ml-2 text-[10px] font-black uppercase tracking-widest">{t('trim')}</span>
|
||||
</button>
|
||||
<button onClick={() => setIsMuted(!isMuted)} className={`p-3 rounded-xl border transition-all ${isMuted ? 'bg-red-500/10 border-red-500/40 text-red-500' : 'bg-white/5 border-white/10 text-zinc-400 hover:text-white'}`} title={isMuted ? "Включить звук" : "Отключить звук"}>
|
||||
<button onClick={() => setIsMuted(!isMuted)} className={`p-3 rounded-xl border transition-all ${isMuted ? 'bg-red-500/10 border-red-500/40 text-red-500' : 'bg-white/5 border-white/10 text-zinc-400 hover:text-white'}`} title={isMuted ? t("unmuteVideo") : t("muteVideo")}>
|
||||
{isMuted ? <Wind size={18} /> : <Droplets size={18} />}
|
||||
<span className="ml-2 text-[10px] font-black uppercase tracking-widest">{isMuted ? 'Без звука' : 'Со звуком'}</span>
|
||||
<span className="ml-2 text-[10px] font-black uppercase tracking-widest">{isMuted ? t('muted') : t('unmuted')}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => { setStickers([]); setTextObjects([]); setMediaPreview(null); setMediaFile(null); setCroppedPreview(null); clearCanvas(); setBgColor('#1e1e2e'); setIsMuted(false); }} className="px-6 py-3 text-[10px] font-black uppercase tracking-widest text-zinc-500 hover:text-white border border-white/5 rounded-xl hover:bg-white/5 transition-all">Сброс</button>
|
||||
<button onClick={() => { setStickers([]); setTextObjects([]); setMediaPreview(null); setMediaFile(null); setCroppedPreview(null); clearCanvas(); setBgColor('#1e1e2e'); setIsMuted(false); }} className="px-6 py-3 text-[10px] font-black uppercase tracking-widest text-zinc-500 hover:text-white border border-white/5 rounded-xl hover:bg-white/5 transition-all">{t('reset')}</button>
|
||||
<button onClick={handlePublish} disabled={isUploading} className="px-8 py-3 bg-primary text-on-primary rounded-xl font-black text-xs uppercase tracking-widest hover:scale-105 active:scale-95 disabled:opacity-50 transition-all shadow-xl shadow-primary/20">
|
||||
{isUploading ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : 'Опубликовать'}
|
||||
{isUploading ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : t('publishStory')}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -464,7 +464,7 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
}
|
||||
}}
|
||||
className="w-10 h-10 rounded-xl bg-black/60 backdrop-blur-md flex items-center justify-center text-white border border-white/10 hover:bg-red-500/20 hover:border-red-500/40 transition-all"
|
||||
title="Остановить"
|
||||
title={t('stop')}
|
||||
>
|
||||
<span className="material-symbols-outlined">stop</span>
|
||||
</button>
|
||||
@@ -495,25 +495,23 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{textObjects.map(t => (
|
||||
<motion.div key={t.id} drag dragConstraints={stageRef} dragMomentum={false} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className={`absolute z-30 cursor-grab active:cursor-grabbing p-4 group ${selectedTextId === t.id ? 'ring-2 ring-primary bg-white/5 rounded-[2rem] backdrop-blur-md' : ''}`} style={{ left: `${t.x}%`, top: `${t.y}%`, color: t.color, fontFamily: t.font, fontSize: `${t.size}px`, transform: `scale(${t.scale})` }} onClick={() => {setSelectedTextId(t.id); setSelectedStickerId(null); setTool('text');}}>
|
||||
{t.text}
|
||||
{selectedTextId === t.id && <button onClick={(e) => { e.stopPropagation(); setTextObjects(prev => prev.filter(to => to.id !== t.id)); setSelectedTextId(null); }} className="absolute -top-3 -right-3 w-8 h-8 bg-red-500 rounded-full flex items-center justify-center text-white shadow-xl border-2 border-[#161616]"><X size={16}/></button>}
|
||||
{textObjects.map(tobj => (
|
||||
<motion.div key={tobj.id} drag dragConstraints={stageRef} dragMomentum={false} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className={`absolute z-30 cursor-grab active:cursor-grabbing p-4 group ${selectedTextId === tobj.id ? 'ring-2 ring-primary bg-white/5 rounded-[2rem] backdrop-blur-md' : ''}`} style={{ left: `${tobj.x}%`, top: `${tobj.y}%`, color: tobj.color, fontFamily: tobj.font, fontSize: `${tobj.size}px`, transform: `scale(${tobj.scale})` }} onClick={() => {setSelectedTextId(tobj.id); setSelectedStickerId(null); setTool('text');}}>
|
||||
{tobj.text}
|
||||
{selectedTextId === tobj.id && <button onClick={(e) => { e.stopPropagation(); setTextObjects(prev => prev.filter(to => to.id !== tobj.id)); setSelectedTextId(null); }} className="absolute -top-3 -right-3 w-8 h-8 bg-red-500 rounded-full flex items-center justify-center text-white shadow-xl border-2 border-[#161616]"><X size={16}/></button>}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{!mediaPreview && textObjects.length === 0 && stickers.length === 0 && (
|
||||
<button onClick={() => fileInputRef.current?.click()} className="absolute inset-0 flex flex-col items-center justify-center gap-6 text-zinc-800 hover:text-white transition-all group">
|
||||
<div className="w-20 h-20 rounded-full border-2 border-dashed border-zinc-800 flex items-center justify-center group-hover:border-primary group-hover:bg-primary/5 group-hover:scale-110 transition-all duration-500">
|
||||
<button onClick={() => fileInputRef.current?.click()} className="absolute inset-0 flex flex-col items-center justify-center gap-6 text-zinc-800 hover:text-white transition-all group">
|
||||
<div className="w-20 h-20 rounded-full border-2 border-dashed border-zinc-800 flex items-center justify-center group-hover:border-primary group-hover:bg-primary/5 group-hover:scale-110 transition-all duration-500">
|
||||
<Camera size={40} className="group-hover:rotate-12 transition-transform" />
|
||||
</div>
|
||||
<span className="text-[9px] font-black uppercase tracking-[0.4em]">Начать создание</span>
|
||||
<span className="text-[9px] font-black uppercase tracking-[0.4em]">{t('startCreation')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile interface components */}
|
||||
</div>
|
||||
<input ref={fileInputRef} type="file" accept="image/*,video/*" className="hidden" onChange={handleMediaSelect} />
|
||||
</section>
|
||||
@@ -523,13 +521,13 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[
|
||||
{ id: 'text', icon: <Type />, label: 'Текст', action: addText },
|
||||
{ id: 'stickers', icon: <Smile />, label: 'Стикеры', active: tool === 'stickers' },
|
||||
{ id: 'brush', icon: <Brush />, label: 'Кисти', active: tool === 'brush' },
|
||||
{ id: 'filters', icon: <SlidersHorizontal />, label: 'Коррекция', active: tool === 'filters' },
|
||||
{ id: 'stickers', icon: <Smile />, label: t('stickersTool'), active: tool === 'stickers' },
|
||||
{ id: 'brush', icon: <Brush />, label: t('brushTool'), active: tool === 'brush' },
|
||||
{ id: 'filters', icon: <SlidersHorizontal />, label: t('filtersTool'), active: tool === 'filters' },
|
||||
].map(item => (
|
||||
<button key={item.id} onClick={() => { if(item.id === 'text') { if(item.action) item.action(); setTool('text'); } else { setTool(item.id as any); } }} className={`flex flex-col items-start justify-between h-32 p-7 rounded-[2.5rem] border transition-all duration-500 ${tool === item.id ? 'bg-primary/10 border-primary/40 text-white shadow-[0_15px_40px_-10px_rgba(var(--primary-rgb),0.3)]' : 'bg-[#121212] border-white/5 text-zinc-500 hover:text-white hover:border-white/10'}`}>
|
||||
<div className={tool === item.id ? 'text-primary' : 'text-zinc-700'}>{item.icon}</div>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest">{item.label}</span>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest">{item.id === 'text' ? t('textTool') : item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -538,25 +536,25 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
{tool === 'text' && selectedTextId && (
|
||||
<motion.div key="text-tool" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="space-y-8 bg-[#121212] p-8 rounded-[2.5rem] border border-white/5 shadow-2xl">
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="text-[10px] font-black uppercase text-primary tracking-widest">Контент</span>
|
||||
<input value={textObjects.find(t => t.id === selectedTextId)?.text} onChange={e => setTextObjects(prev => prev.map(t => t.id === selectedTextId ? { ...t, text: e.target.value } : t))} className="bg-transparent border-b border-white/10 w-full py-2 text-2xl font-black outline-none italic" autoFocus />
|
||||
<span className="text-[10px] font-black uppercase text-primary tracking-widest">{t('textContent')}</span>
|
||||
<input value={textObjects.find(tx => tx.id === selectedTextId)?.text} onChange={e => setTextObjects(prev => prev.map(tx => tx.id === selectedTextId ? { ...tx, text: e.target.value } : tx))} className="bg-transparent border-b border-white/10 w-full py-2 text-2xl font-black outline-none italic" autoFocus />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<span className="text-[10px] font-black uppercase text-zinc-600 tracking-widest">Типографика</span>
|
||||
<span className="text-[10px] font-black uppercase text-zinc-600 tracking-widest">{t('typography')}</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FONTS.map(f => (
|
||||
<button key={f.id} onClick={() => setTextObjects(prev => prev.map(t => t.id === selectedTextId ? { ...t, font: f.family } : t))} className={`px-5 py-2 rounded-2xl text-[9px] font-black uppercase transition-all ${textObjects.find(t => t.id === selectedTextId)?.font === f.family ? 'bg-primary text-white shadow-lg' : 'bg-white/5 text-zinc-500 hover:text-white'}`} style={{ fontFamily: f.family }}>{f.name}</button>
|
||||
<button key={f.id} onClick={() => setTextObjects(prev => prev.map(tx => tx.id === selectedTextId ? { ...tx, font: f.family } : tx))} className={`px-5 py-2 rounded-2xl text-[9px] font-black uppercase transition-all ${textObjects.find(tx => tx.id === selectedTextId)?.font === f.family ? 'bg-primary text-white shadow-lg' : 'bg-white/5 text-zinc-500 hover:text-white'}`} style={{ fontFamily: f.family }}>{f.name}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between text-[10px] font-black uppercase text-zinc-600"><span>Размер</span><span className="text-white">{textObjects.find(t => t.id === selectedTextId)?.size}px</span></div>
|
||||
<input type="range" min="12" max="150" value={textObjects.find(t => t.id === selectedTextId)?.size} onChange={e => setTextObjects(prev => prev.map(t => t.id === selectedTextId ? { ...t, size: parseInt(e.target.value) } : t))} className="w-full accent-primary h-1 bg-white/5 rounded-full" />
|
||||
<div className="flex justify-between text-[10px] font-black uppercase text-zinc-600"><span>{t('textSize')}</span><span className="text-white">{textObjects.find(tx => tx.id === selectedTextId)?.size}px</span></div>
|
||||
<input type="range" min="12" max="150" value={textObjects.find(tx => tx.id === selectedTextId)?.size} onChange={e => setTextObjects(prev => prev.map(tx => tx.id === selectedTextId ? { ...tx, size: parseInt(e.target.value) } : tx))} className="w-full accent-primary h-1 bg-white/5 rounded-full" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-6 border-t border-white/5">
|
||||
<span className="text-[10px] font-black uppercase text-zinc-600">Цвет текста</span>
|
||||
<div className="w-12 h-12 rounded-full border-2 border-white/10 relative shadow-inner" style={{ backgroundColor: textObjects.find(t => t.id === selectedTextId)?.color }}>
|
||||
<input type="color" value={textObjects.find(t => t.id === selectedTextId)?.color} onChange={e => setTextObjects(prev => prev.map(t => t.id === selectedTextId ? { ...t, color: e.target.value } : t))} className="absolute inset-0 opacity-0 w-full h-full cursor-pointer" />
|
||||
<span className="text-[10px] font-black uppercase text-zinc-600">{t('textColor')}</span>
|
||||
<div className="w-12 h-12 rounded-full border-2 border-white/10 relative shadow-inner" style={{ backgroundColor: textObjects.find(tx => tx.id === selectedTextId)?.color }}>
|
||||
<input type="color" value={textObjects.find(tx => tx.id === selectedTextId)?.color} onChange={e => setTextObjects(prev => prev.map(tx => tx.id === selectedTextId ? { ...tx, color: e.target.value } : tx))} className="absolute inset-0 opacity-0 w-full h-full cursor-pointer" />
|
||||
<Pipette size={18} className="absolute inset-0 m-auto mix-blend-difference" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -570,10 +568,10 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
<Pipette size={32} className="absolute inset-0 m-auto mix-blend-difference group-hover:scale-125 transition-transform" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between text-[10px] font-bold text-zinc-600 uppercase tracking-widest"><span>Толщина</span><span className="text-white">{brushSize}px</span></div>
|
||||
<div className="flex justify-between text-[10px] font-bold text-zinc-600 uppercase tracking-widest"><span>{t('brushThickness')}</span><span className="text-white">{brushSize}px</span></div>
|
||||
<input type="range" min="1" max="100" value={brushSize} onChange={e => setBrushSize(parseInt(e.target.value))} className="w-full accent-primary h-1 bg-white/5 rounded-full" />
|
||||
</div>
|
||||
<button onClick={clearCanvas} className="w-full py-5 text-[10px] font-black uppercase tracking-[0.2em] text-red-500 bg-red-500/10 rounded-3xl hover:bg-red-500 hover:text-white transition-all border border-red-500/20">Очистить холст</button>
|
||||
<button onClick={clearCanvas} className="w-full py-5 text-[10px] font-black uppercase tracking-[0.2em] text-red-500 bg-red-500/10 rounded-3xl hover:bg-red-500 hover:text-white transition-all border border-red-500/20">{t('clearCanvas')}</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -585,13 +583,13 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
|
||||
{tool === 'filters' && (
|
||||
<motion.div key="filters-tool" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="p-8 rounded-[2.5rem] bg-[#121212] border border-white/5 space-y-8 shadow-2xl">
|
||||
<h4 className="text-[10px] font-black uppercase tracking-widest text-primary">Мастер коррекции</h4>
|
||||
<h4 className="text-[10px] font-black uppercase tracking-widest text-primary">{t('correctionMaster')}</h4>
|
||||
<div className="space-y-6">
|
||||
{[
|
||||
{ id: 'brightness', icon: <Sun size={14}/>, label: 'Яркость', min: 40, max: 160 },
|
||||
{ id: 'contrast', icon: <Contrast size={14}/>, label: 'Контраст', min: 40, max: 160 },
|
||||
{ id: 'saturate', icon: <Droplets size={14}/>, label: 'Насыщенность', min: 0, max: 250 },
|
||||
{ id: 'sepia', icon: <Palette size={14}/>, label: 'Сепия', min: 0, max: 100 },
|
||||
{ id: 'brightness', icon: <Sun size={14}/>, label: t('brightness'), min: 40, max: 160 },
|
||||
{ id: 'contrast', icon: <Contrast size={14}/>, label: t('contrast'), min: 40, max: 160 },
|
||||
{ id: 'saturate', icon: <Droplets size={14}/>, label: t('saturation'), min: 0, max: 250 },
|
||||
{ id: 'sepia', icon: <Palette size={14}/>, label: t('sepia'), min: 0, max: 100 },
|
||||
].map(adj => (
|
||||
<div key={adj.id} className="space-y-3">
|
||||
<div className="flex items-center justify-between text-[10px] font-bold text-zinc-700 uppercase">
|
||||
@@ -609,13 +607,13 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
<motion.div key="bg-tool" initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="p-10 rounded-[2.5rem] bg-[#121212] flex flex-col items-center gap-8 border border-white/5 shadow-2xl">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Palette size={24} className="text-primary" />
|
||||
<span className="text-[10px] font-black uppercase text-zinc-500 tracking-widest">Цвет фона</span>
|
||||
<span className="text-[10px] font-black uppercase text-zinc-500 tracking-widest">{t('bgColor')}</span>
|
||||
</div>
|
||||
<div className="w-28 h-28 rounded-full border-[6px] border-white/5 relative shadow-inner transition-transform hover:scale-105 active:scale-95 cursor-pointer" style={{ backgroundColor: bgColor }}>
|
||||
<input type="color" value={bgColor} onChange={e => setBgColor(e.target.value)} className="absolute inset-0 opacity-0 w-full h-full cursor-pointer" />
|
||||
<Pipette size={36} className="absolute inset-0 m-auto mix-blend-difference" />
|
||||
</div>
|
||||
<span className="text-[9px] font-bold text-zinc-700 uppercase">Нажмите для выбора цвета</span>
|
||||
<span className="text-[9px] font-bold text-zinc-700 uppercase">{t('clickToChooseColor')}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -649,7 +647,7 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
}} className="w-12 h-12 rounded-full hover:bg-white/5 flex items-center justify-center text-zinc-400 transition-colors">
|
||||
<ChevronLeft size={28} />
|
||||
</button>
|
||||
<h2 className="text-sm font-black uppercase tracking-[0.3em] text-zinc-200">Обрезка видео</h2>
|
||||
<h2 className="text-sm font-black uppercase tracking-[0.3em] text-zinc-200">{t('cropVideo')}</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTool('none');
|
||||
@@ -665,7 +663,7 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
}}
|
||||
className="px-6 py-2 rounded-xl bg-primary text-on-primary text-[10px] font-black uppercase tracking-widest hover:scale-105 active:scale-95 transition-all"
|
||||
>
|
||||
Готово
|
||||
{t('done')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -726,12 +724,12 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-primary animate-pulse" />
|
||||
<span className="text-[10px] font-black uppercase text-zinc-500 tracking-widest">Колесико мыши для зума ({zoom.toFixed(1)}x)</span>
|
||||
<span className="text-[10px] font-black uppercase text-zinc-500 tracking-widest">{t('zoomHint')} ({zoom.toFixed(1)}x)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-[9px] font-black uppercase text-zinc-600 tracking-widest">{new Date(0 * 1000).toISOString().substr(14, 5)}</span>
|
||||
<div className="px-4 py-1.5 rounded-lg bg-primary/10 border border-primary/20">
|
||||
<span className="text-[10px] font-black italic text-primary uppercase">{(endTime - startTime).toFixed(1)} сек выбрано</span>
|
||||
<span className="text-[10px] font-black italic text-primary uppercase">{(endTime - startTime).toFixed(1)} {t('secSelected')}</span>
|
||||
</div>
|
||||
<span className="text-[9px] font-black uppercase text-zinc-600 tracking-widest">{new Date(videoDuration * 1000).toISOString().substr(14, 5)}</span>
|
||||
</div>
|
||||
@@ -741,8 +739,8 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
{/* Overview Track (Mini-map) */}
|
||||
<div className="relative h-4 bg-white/5 rounded-full overflow-hidden border border-white/10 group/mini">
|
||||
<div className="absolute inset-0 flex opacity-20 pointer-events-none">
|
||||
{thumbnails.map((t, i) => (
|
||||
<img key={i} src={t} className="flex-1 h-full object-cover grayscale" alt="mini" />
|
||||
{thumbnails.map((th, i) => (
|
||||
<img key={i} src={th} className="flex-1 h-full object-cover grayscale" alt="mini" />
|
||||
))}
|
||||
</div>
|
||||
{/* Selection UI on Mini-map */}
|
||||
@@ -753,9 +751,9 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
const rect = e.currentTarget.parentElement?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const time = x * videoDuration;
|
||||
const tval = x * videoDuration;
|
||||
(timelineRef.current as any)._dragging = 'range';
|
||||
(timelineRef.current as any)._offsetTime = time - startTime;
|
||||
(timelineRef.current as any)._offsetTime = tval - startTime;
|
||||
}}
|
||||
style={{
|
||||
left: `${(startTime / videoDuration) * 100}%`,
|
||||
@@ -773,9 +771,9 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const time = x * videoDuration;
|
||||
if (trimVideoRef.current) trimVideoRef.current.currentTime = time;
|
||||
setCurrentPlayTime(time);
|
||||
const tval = x * videoDuration;
|
||||
if (trimVideoRef.current) trimVideoRef.current.currentTime = tval;
|
||||
setCurrentPlayTime(tval);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -798,7 +796,7 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
onMouseDown={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const time = x * videoDuration;
|
||||
const tval = x * videoDuration;
|
||||
|
||||
const startX = (startTime / videoDuration) * rect.width;
|
||||
const endX = (endTime / videoDuration) * rect.width;
|
||||
@@ -810,12 +808,12 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
(e.currentTarget as any)._dragging = 'end';
|
||||
} else if (mouseX > startX && mouseX < endX) {
|
||||
(e.currentTarget as any)._dragging = 'range';
|
||||
(e.currentTarget as any)._offsetTime = time - startTime;
|
||||
(e.currentTarget as any)._offsetTime = tval - startTime;
|
||||
} else {
|
||||
(e.currentTarget as any)._dragging = 'scrub';
|
||||
if (trimVideoRef.current) {
|
||||
trimVideoRef.current.currentTime = time;
|
||||
setCurrentPlayTime(time);
|
||||
trimVideoRef.current.currentTime = tval;
|
||||
setCurrentPlayTime(tval);
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -825,40 +823,40 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const time = Math.max(0, Math.min(videoDuration, x * videoDuration));
|
||||
const tval = Math.max(0, Math.min(videoDuration, x * videoDuration));
|
||||
|
||||
if (dragging === 'start') {
|
||||
if (time < endTime - 0.2) {
|
||||
setStartTime(time);
|
||||
if (tval < endTime - 0.2) {
|
||||
setStartTime(tval);
|
||||
if (trimVideoRef.current) {
|
||||
trimVideoRef.current.currentTime = time;
|
||||
setCurrentPlayTime(time);
|
||||
trimVideoRef.current.currentTime = tval;
|
||||
setCurrentPlayTime(tval);
|
||||
}
|
||||
}
|
||||
} else if (dragging === 'end') {
|
||||
if (time > startTime + 0.2) {
|
||||
setEndTime(time);
|
||||
if (tval > startTime + 0.2) {
|
||||
setEndTime(tval);
|
||||
if (trimVideoRef.current) {
|
||||
trimVideoRef.current.currentTime = time;
|
||||
setCurrentPlayTime(time);
|
||||
trimVideoRef.current.currentTime = tval;
|
||||
setCurrentPlayTime(tval);
|
||||
}
|
||||
}
|
||||
} else if (dragging === 'range') {
|
||||
const offset = (e.currentTarget as any)._offsetTime;
|
||||
const duration = endTime - startTime;
|
||||
let newStart = time - offset;
|
||||
const dr = endTime - startTime;
|
||||
let newStart = tval - offset;
|
||||
if (newStart < 0) newStart = 0;
|
||||
if (newStart + duration > videoDuration) newStart = videoDuration - duration;
|
||||
if (newStart + dr > videoDuration) newStart = videoDuration - dr;
|
||||
setStartTime(newStart);
|
||||
setEndTime(newStart + duration);
|
||||
setEndTime(newStart + dr);
|
||||
if (trimVideoRef.current) {
|
||||
trimVideoRef.current.currentTime = newStart;
|
||||
setCurrentPlayTime(newStart);
|
||||
}
|
||||
} else if (dragging === 'scrub') {
|
||||
if (trimVideoRef.current) {
|
||||
trimVideoRef.current.currentTime = time;
|
||||
setCurrentPlayTime(time);
|
||||
trimVideoRef.current.currentTime = tval;
|
||||
setCurrentPlayTime(tval);
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -867,8 +865,8 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
>
|
||||
{/* Thumbnails Track */}
|
||||
<div className="absolute inset-0 flex">
|
||||
{thumbnails.map((t, i) => (
|
||||
<img key={i} src={t} className="flex-1 h-full object-cover opacity-20 filter grayscale" alt="frame" />
|
||||
{thumbnails.map((th, i) => (
|
||||
<img key={i} src={th} className="flex-1 h-full object-cover opacity-20 filter grayscale" alt="frame" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -916,7 +914,7 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
|
||||
<div className="grid grid-cols-2 gap-8 max-w-2xl mx-auto w-full">
|
||||
<div className="bg-[#121212] p-8 rounded-[2.5rem] border border-white/5 flex flex-col gap-2 shadow-xl">
|
||||
<span className="text-[11px] font-black uppercase text-zinc-600 tracking-[0.2em]">Начало</span>
|
||||
<span className="text-[11px] font-black uppercase text-zinc-600 tracking-[0.2em]">{lang === 'ru' ? 'Начало' : 'Start'}</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-3xl font-black italic tracking-tighter text-white font-mono">
|
||||
{new Date(startTime * 1000).toISOString().substr(14, 5)}<span className="text-primary text-xl">.{(startTime % 1).toFixed(1).substr(2)}</span>
|
||||
@@ -925,7 +923,7 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#121212] p-8 rounded-[2.5rem] border border-white/5 flex flex-col gap-2 shadow-xl">
|
||||
<span className="text-[11px] font-black uppercase text-zinc-600 tracking-[0.2em]">Конец</span>
|
||||
<span className="text-[11px] font-black uppercase text-zinc-600 tracking-[0.2em]">{lang === 'ru' ? 'Конец' : 'End'}</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-3xl font-black italic tracking-tighter text-white font-mono">
|
||||
{new Date(endTime * 1000).toISOString().substr(14, 5)}<span className="text-primary text-xl">.{(endTime % 1).toFixed(1).substr(2)}</span>
|
||||
@@ -951,7 +949,7 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
disabled={videoDuration > 120 && (endTime - startTime) > 120}
|
||||
className="px-24 py-7 bg-white text-black rounded-full font-black uppercase tracking-[0.2em] text-sm hover:scale-105 active:scale-95 disabled:opacity-30 transition-all shadow-2xl"
|
||||
>
|
||||
{videoDuration > 120 && (endTime - startTime) > 120 ? 'Слишком длинное' : 'Применить'}
|
||||
{videoDuration > 120 && (endTime - startTime) > 120 ? (lang === 'ru' ? 'Слишком длинное' : 'Too Long') : t('apply')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -106,9 +106,9 @@ export default function SettingsPage() {
|
||||
<div className="flex items-end justify-between border-b border-white/[0.04] pb-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-headline font-black text-white mb-2 leading-none uppercase tracking-tighter">
|
||||
{t('settings') || 'Settings'}
|
||||
{t('myProfile') || 'My Profile'}
|
||||
</h1>
|
||||
<p className="text-zinc-500 text-sm font-medium tracking-tight">Управляйте своим профилем и настройками приложения</p>
|
||||
<p className="text-zinc-500 text-sm font-medium tracking-tight">{t('settingsDesc')}</p>
|
||||
</div>
|
||||
{!editing ? (
|
||||
<button
|
||||
@@ -116,7 +116,7 @@ export default function SettingsPage() {
|
||||
className="px-6 py-2.5 rounded-2xl bg-white/5 border border-white/10 text-white text-xs font-black uppercase tracking-widest hover:bg-primary hover:text-on-primary hover:border-primary transition-all flex items-center gap-2 group"
|
||||
>
|
||||
<Edit3 size={14} className="group-hover:scale-110 transition-transform" />
|
||||
{t('edit') || 'Изменить'}
|
||||
{t('edit')}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -124,7 +124,7 @@ export default function SettingsPage() {
|
||||
onClick={() => setEditing(false)}
|
||||
className="px-6 py-2.5 rounded-2xl text-on-surface-variant text-xs font-black uppercase tracking-widest hover:text-white transition-colors"
|
||||
>
|
||||
{t('cancel') || 'Отмена'}
|
||||
{t('cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveProfile}
|
||||
@@ -132,7 +132,7 @@ export default function SettingsPage() {
|
||||
className="px-8 py-2.5 rounded-2xl bg-primary text-on-primary text-xs font-black uppercase tracking-widest shadow-lg shadow-primary/20 hover:scale-[1.03] active:scale-95 transition-all flex items-center gap-3"
|
||||
>
|
||||
{saving && <Loader2 size={14} className="animate-spin" />}
|
||||
{t('save') || 'Сохранить'}
|
||||
{t('save')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -194,14 +194,14 @@ export default function SettingsPage() {
|
||||
|
||||
<div className="flex-1 space-y-8 w-full">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-on-surface-variant opacity-40">{t('displayName') || 'Имя профиля'}</label>
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-on-surface-variant opacity-40">{t('displayName')}</label>
|
||||
{editing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={formData.displayName}
|
||||
onChange={(e) => setFormData(p => ({ ...p, displayName: e.target.value }))}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-3 text-lg font-bold text-white focus:border-primary/50 transition-all outline-none"
|
||||
placeholder="Как вас называть?"
|
||||
placeholder={t('enterDisplayName')}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-3xl font-black text-white tracking-tighter">{user?.displayName}</p>
|
||||
@@ -211,7 +211,7 @@ export default function SettingsPage() {
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 text-on-surface-variant opacity-40">
|
||||
<AtSign size={12} />
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em]">{t('username') || 'Username'}</label>
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em]">{t('username')}</label>
|
||||
</div>
|
||||
<p className="text-base font-bold text-zinc-400">@{user?.username || user?.userName}</p>
|
||||
</div>
|
||||
@@ -223,18 +223,18 @@ export default function SettingsPage() {
|
||||
<div className="space-y-3 p-8 rounded-[2.5rem] bg-white/[0.02] border border-white/5 transition-colors hover:bg-white/[0.04]">
|
||||
<div className="flex items-center gap-3 text-primary mb-1">
|
||||
<Info size={16} />
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em]">{t('about') || 'О себе'}</span>
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em]">{t('aboutMe')}</span>
|
||||
</div>
|
||||
{editing ? (
|
||||
<textarea
|
||||
value={formData.bio}
|
||||
onChange={(e) => setFormData(p => ({ ...p, bio: e.target.value }))}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-3 text-sm font-medium text-white/80 focus:border-primary/50 transition-all outline-none resize-none h-32"
|
||||
placeholder="Расскажите немного о себе..."
|
||||
placeholder={t('tellAboutBio')}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-base text-white/70 leading-relaxed font-medium min-h-[4rem]">
|
||||
{user?.bio || 'Информация не указана'}
|
||||
{user?.bio || t('noBio')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -242,7 +242,7 @@ export default function SettingsPage() {
|
||||
<div className="space-y-3 p-8 rounded-[2.5rem] bg-white/[0.02] border border-white/5 transition-colors hover:bg-white/[0.04]">
|
||||
<div className="flex items-center gap-3 text-primary mb-1">
|
||||
<Calendar size={16} />
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em]">{t('birthday') || 'Дата рождения'}</span>
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em]">{t('birthday')}</span>
|
||||
</div>
|
||||
{editing ? (
|
||||
<DatePicker
|
||||
@@ -251,7 +251,7 @@ export default function SettingsPage() {
|
||||
/>
|
||||
) : (
|
||||
<p className="text-base text-white/70 leading-relaxed font-black uppercase tracking-widest">
|
||||
{user?.birthday ? new Date(user.birthday).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'long', year: 'numeric' }) : 'Не указана'}
|
||||
{user?.birthday ? new Date(user.birthday).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'long', year: 'numeric' }) : t('notSpecified')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -261,7 +261,7 @@ export default function SettingsPage() {
|
||||
<div className="space-y-6 pt-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-px h-6 bg-primary/40 rounded-full" />
|
||||
<h2 className="text-sm font-black uppercase tracking-[0.3em] text-white/40">Настройки приложения</h2>
|
||||
<h2 className="text-sm font-black uppercase tracking-[0.3em] text-white/40">{t('appSettings')}</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -269,7 +269,7 @@ export default function SettingsPage() {
|
||||
<div className="p-6 rounded-3xl bg-white/[0.02] border border-white/5 space-y-4">
|
||||
<div className="flex items-center gap-3 opacity-60">
|
||||
<Languages size={16} className="text-zinc-500" />
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400">{t('language') || 'Language'}</span>
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400">{t('language')}</span>
|
||||
</div>
|
||||
<div className="flex gap-2 p-1.5 rounded-2xl bg-black/20 w-fit">
|
||||
<button
|
||||
@@ -297,7 +297,7 @@ export default function SettingsPage() {
|
||||
<div className="w-9 h-9 rounded-xl bg-red-500/10 flex items-center justify-center text-red-500 group-hover:scale-105 transition-transform">
|
||||
<LogOut size={16} />
|
||||
</div>
|
||||
<span className="text-[11px] font-black uppercase tracking-widest text-red-500/70 group-hover:text-red-500 transition-colors">{t('logout') || 'Выйти'}</span>
|
||||
<span className="text-[11px] font-black uppercase tracking-widest text-red-500/70 group-hover:text-red-500 transition-colors">{t('logout')}</span>
|
||||
</div>
|
||||
<ChevronRight size={16} className="text-red-500/40" />
|
||||
</button>
|
||||
|
||||
@@ -29,7 +29,7 @@ interface UserProfileProps {
|
||||
type TabType = 'media' | 'gif' | 'files' | 'links';
|
||||
|
||||
export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelfProp, chatId, onGoToMessage }: UserProfileProps) {
|
||||
const { lang } = useLang();
|
||||
const { t, lang } = useLang();
|
||||
const { user: currentUser } = useAuthStore();
|
||||
const { friends, friendRequests, sendRequest, removeFriend, loadFriends } = useFriendStore();
|
||||
|
||||
@@ -156,13 +156,13 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
|
||||
|
||||
const availableTabs = useMemo(() => {
|
||||
const all: { id: TabType, icon: any, label: string }[] = [
|
||||
{ id: 'media', icon: ImageIcon, label: lang === 'ru' ? 'Медиа' : 'Media' },
|
||||
{ id: 'media', icon: ImageIcon, label: t('mediaTab') },
|
||||
{ id: 'gif', icon: Film, label: 'GIF' },
|
||||
{ id: 'files', icon: FileText, label: lang === 'ru' ? 'Файлы' : 'Files' },
|
||||
{ id: 'links', icon: LinkIcon, label: lang === 'ru' ? 'Ссылки' : 'Links' }
|
||||
{ id: 'files', icon: FileText, label: t('filesTab') },
|
||||
{ id: 'links', icon: LinkIcon, label: t('linksTab') }
|
||||
];
|
||||
return isMe ? all : all.filter(t => counts[t.id] > 0);
|
||||
}, [counts, lang, isMe]);
|
||||
}, [counts, lang, isMe, t]);
|
||||
|
||||
// Gallery items for Lightbox
|
||||
const galleryItems = useMemo(() => {
|
||||
@@ -214,7 +214,7 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
|
||||
className="relative w-full max-w-[560px] h-[85vh] bg-[#0a0a0a] rounded-[3rem] border border-white/10 shadow-2xl overflow-hidden flex flex-col backdrop-blur-3xl"
|
||||
>
|
||||
<div className="flex items-center justify-between px-8 py-5 border-b border-white/5 bg-white/[0.01]">
|
||||
<span className="text-sm font-black uppercase tracking-[0.3em] text-white/50">{lang === 'ru' ? 'ПРОФИЛЬ ПОЛЬЗОВАТЕЛЯ' : 'USER PROFILE'}</span>
|
||||
<span className="text-sm font-black uppercase tracking-[0.3em] text-white/50">{t('userProfileUpper')}</span>
|
||||
<button onClick={onClose} className="p-3 rounded-full hover:bg-white/10 text-white/40 hover:text-white transition-all"><X size={24} /></button>
|
||||
</div>
|
||||
|
||||
@@ -240,19 +240,19 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
|
||||
<div className="px-4 py-1.5 rounded-full bg-primary/15 border border-primary/25">
|
||||
<span className="text-xs font-black text-primary uppercase tracking-widest">@{user.username || 'user_' + userId.slice(0, 5)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-white/40 font-black uppercase tracking-widest"><Calendar size={14} className="text-primary/50" /><span>{lang === 'ru' ? 'С нами с ' : 'Joined '} {formatRegistrationDate(user.createdAt)}</span></div>
|
||||
<div className="flex items-center gap-2 text-xs text-white/40 font-black uppercase tracking-widest"><Calendar size={14} className="text-primary/50" /><span>{t('joined')} {formatRegistrationDate(user.createdAt)}</span></div>
|
||||
</div>
|
||||
|
||||
{!isMe && (
|
||||
<div className="mt-10 w-full max-w-[320px]">
|
||||
{friend ? (
|
||||
<button onClick={handleFriendAction} className="w-full py-4 rounded-2xl bg-red-500/10 border border-red-500/20 text-red-500 text-[11px] font-black uppercase tracking-widest hover:bg-red-500 hover:text-white transition-all flex items-center justify-center gap-3"><UserMinus size={18} />{lang === 'ru' ? 'Удалить из контактов' : 'Remove Contact'}</button>
|
||||
<button onClick={handleFriendAction} className="w-full py-4 rounded-2xl bg-red-500/10 border border-red-500/20 text-red-500 text-[11px] font-black uppercase tracking-widest hover:bg-red-500 hover:text-white transition-all flex items-center justify-center gap-3"><UserMinus size={18} />{t('removeFriend')}</button>
|
||||
) : outgoingReq ? (
|
||||
<button className="w-full py-4 rounded-2xl bg-white/5 border border-white/10 text-white/40 text-[11px] font-black uppercase tracking-widest cursor-default flex items-center justify-center gap-3 opacity-60"><Loader2 size={16} className="animate-spin" />{lang === 'ru' ? 'Запрос отправлен' : 'Request Pending'}</button>
|
||||
<button className="w-full py-4 rounded-2xl bg-white/5 border border-white/10 text-white/40 text-[11px] font-black uppercase tracking-widest cursor-default flex items-center justify-center gap-3 opacity-60"><Loader2 size={16} className="animate-spin" />{t('requestSent')}</button>
|
||||
) : incomingReq ? (
|
||||
<button onClick={() => useFriendStore.getState().acceptRequest(incomingReq.id)} className="w-full py-4 rounded-2xl bg-green-500/15 border border-green-500/25 text-green-500 text-[11px] font-black uppercase tracking-widest hover:bg-green-500 hover:text-white transition-all flex items-center justify-center gap-3"><UserCheck size={18} />{lang === 'ru' ? 'Принять запрос' : 'Accept Request'}</button>
|
||||
<button onClick={() => useFriendStore.getState().acceptRequest(incomingReq.id)} className="w-full py-4 rounded-2xl bg-green-500/15 border border-green-500/25 text-green-500 text-[11px] font-black uppercase tracking-widest hover:bg-green-500 hover:text-white transition-all flex items-center justify-center gap-3"><UserCheck size={18} />{t('acceptRequest')}</button>
|
||||
) : (
|
||||
<button onClick={handleFriendAction} className="w-full py-4 rounded-2xl bg-primary text-white text-[11px] font-black uppercase tracking-widest hover:bg-primary-container transition-all flex items-center justify-center gap-3"><UserPlus size={18} />{lang === 'ru' ? 'Добавить в контакты' : 'Add to Contacts'}</button>
|
||||
<button onClick={handleFriendAction} className="w-full py-4 rounded-2xl bg-primary text-white text-[11px] font-black uppercase tracking-widest hover:bg-primary-container transition-all flex items-center justify-center gap-3"><UserPlus size={18} />{t('addFriend')}</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -260,8 +260,8 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
|
||||
|
||||
{/* About Section */}
|
||||
<div className="p-8 rounded-[2.5rem] bg-white/[0.02] border border-white/5 mb-10 group relative transition-colors hover:bg-white/[0.04]">
|
||||
<div className="flex items-center gap-3 mb-4 text-primary"><Info size={16} /><span className="text-xs font-black uppercase tracking-[0.2em]">{lang === 'ru' ? 'О себе' : 'About me'}</span></div>
|
||||
<p className="text-base text-white/80 leading-relaxed font-medium">{user.about || (lang === 'ru' ? 'Информация отсутствует' : 'No information shared yet')}</p>
|
||||
<div className="flex items-center gap-3 mb-4 text-primary"><Info size={16} /><span className="text-xs font-black uppercase tracking-[0.2em]">{t('aboutMe')}</span></div>
|
||||
<p className="text-base text-white/80 leading-relaxed font-medium">{user.about || t('noBio')}</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs Nav */}
|
||||
@@ -293,7 +293,7 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
|
||||
{contentLoading ? (
|
||||
<motion.div key="loading" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="flex items-center justify-center py-20"><Loader2 className="w-10 h-10 text-primary/40 animate-spin" /></motion.div>
|
||||
) : tabData.length === 0 ? (
|
||||
<motion.div key="empty" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="flex flex-col items-center justify-center py-20 opacity-20"><Search size={48} className="mb-4" /><span className="text-sm font-black uppercase tracking-[0.3em]">{lang === 'ru' ? 'Пусто' : 'Empty'}</span></motion.div>
|
||||
<motion.div key="empty" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="flex flex-col items-center justify-center py-20 opacity-20"><Search size={48} className="mb-4" /><span className="text-sm font-black uppercase tracking-[0.3em]">{t('empty')}</span></motion.div>
|
||||
) : (
|
||||
<motion.div key="content" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 10 }} className={activeTab === 'media' || activeTab === 'gif' ? "grid grid-cols-3 gap-3" : "flex flex-col gap-3"}>
|
||||
|
||||
@@ -336,7 +336,7 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(item.id, item.createdAt); }}
|
||||
className="p-2.5 rounded-2xl bg-primary/95 text-white shadow-xl backdrop-blur-md hover:scale-110 active:scale-95 transition-all"
|
||||
title={lang === 'ru' ? 'Показать в чате' : 'Show in Chat'}
|
||||
title={t('showInChat')}
|
||||
>
|
||||
<Search size={16} strokeWidth={3} />
|
||||
</button>
|
||||
@@ -365,7 +365,7 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
|
||||
<button
|
||||
onClick={() => onGoToMessage?.(item.id, item.createdAt)}
|
||||
className="p-3 rounded-2xl bg-white/5 opacity-0 group-hover/row:opacity-100 text-white/40 hover:text-white hover:bg-primary/20 transition-all"
|
||||
title={lang === 'ru' ? 'Показать в чате' : 'Show in Chat'}
|
||||
title={t('showInChat')}
|
||||
>
|
||||
<MessageSquare size={20} />
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user