Импорт

This commit is contained in:
Халимов Рустам
2026-04-06 22:20:13 +03:00
parent fa185afc73
commit 1558b20470
317 changed files with 18311 additions and 924 deletions
+2
View File
@@ -124,6 +124,8 @@ export interface Chat {
members: ChatMember[];
messages: Message[];
unreadCount: number;
isImporting?: boolean;
importJobId?: string | null;
pinnedMessages?: Array<{
id: string;
message: Message;
@@ -44,6 +44,21 @@ const translations = {
storageAndData: 'Хранилище и данные',
importTelegram: 'Импорт истории Telegram',
importTelegramDesc: 'Перенести сообщения и медиа из Telegram',
importSuccess: 'Импорт завершен',
importStarted: 'Импорт запущен в фоновом режиме',
importSelectArchive: 'Загрузите архив',
importSelectArchiveDesc: 'Экспортируйте чат из Telegram (HTML формат, вкл. медиа) и загрузите полученный ZIP архив.',
importLimit: 'Лимит: до 1 ГБ',
importSelectFile: 'Выбрать ZIP-архив',
importParticipants: 'Соответствие участников',
importParticipantsDesc: 'Назначьте участников из архива пользователям в Knot.',
importInArchive: 'В архиве',
importSelectContact: '-- Выберите контакт --',
importGroupName: 'Название чата',
importGroupNameHint: 'Например, Моя группа',
importStart: 'Запустить импорт',
importRestart: 'Начать заново',
importCompletedDesc: 'Мы успешно создали чат и начали перенос сообщений в фоновом режиме.',
// Chat
chat: 'Чат',
group: 'Группа',
@@ -413,6 +428,21 @@ const translations = {
storageAndData: 'Storage and Data',
importTelegram: 'Import Telegram History',
importTelegramDesc: 'Move messages and media from Telegram',
importSuccess: 'Import completed',
importStarted: 'Import started in background',
importSelectArchive: 'Upload archive',
importSelectArchiveDesc: 'Export chat from Telegram (HTML format, incl. media) and upload the resulting ZIP archive.',
importLimit: 'Limit: up to 1 GB',
importSelectFile: 'Select ZIP archive',
importParticipants: 'Participant mapping',
importParticipantsDesc: 'Assign participants from the archive to Knot users.',
importInArchive: 'In archive',
importSelectContact: '-- Select contact --',
importGroupName: 'Chat name',
importGroupNameHint: 'e.g., My Group',
importStart: 'Start import',
importRestart: 'Start over',
importCompletedDesc: 'We successfully created the chat and started importing messages in the background.',
searchChats: 'Search chats...',
chat: 'Chat',
group: 'Group',
@@ -46,7 +46,7 @@ function AvatarInner({ src, name, size = 'md', className = '', online }: AvatarP
const gradientClass = generateAvatarColor(name || '');
return (
<div className={`relative shrink-0 ${sizeClass} ${rounding} ${className} border-2 border-outline-variant/10 overflow-hidden shadow-inner`}>
<div className={`relative shrink-0 ${sizeClass} ${rounding} ${className} overflow-hidden`}>
{src ? (
<img
src={src}
@@ -55,7 +55,7 @@ function AvatarInner({ src, name, size = 'md', className = '', online }: AvatarP
/>
) : (
<div
className={`w-full h-full ${gradientClass} flex items-center justify-center text-on-primary font-black tracking-tighter shadow-inner`}
className={`w-full h-full ${gradientClass} flex items-center justify-center text-on-primary font-black tracking-tighter`}
>
{initials}
</div>
@@ -1,5 +1,6 @@
import { motion, AnimatePresence } from 'framer-motion';
import { AlertTriangle } from 'lucide-react';
import { createPortal } from 'react-dom';
import { useLang } from '../../../infrastructure/i18n';
interface ConfirmModalProps {
@@ -25,49 +26,50 @@ export default function ConfirmModal({
}: ConfirmModalProps) {
const { t } = useLang();
return (
const content = (
<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"
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-md px-4"
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
>
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
initial={{ scale: 0.95, opacity: 0, y: 15 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0, y: 20 }}
transition={{ type: 'spring', duration: 0.35, bounce: 0.2 }}
exit={{ scale: 0.95, opacity: 0, y: 15 }}
transition={{ type: 'spring', damping: 25, stiffness: 450 }}
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"
className="w-full max-w-[400px] rounded-[2.5rem] bg-[#1c1c1c] border border-white/10 shadow-[0_40px_100px_-20px_rgba(0,0,0,0.8)] 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 className="p-8 pb-6 flex flex-col items-center text-center">
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center mb-6 ${danger ? 'bg-error/15' : 'bg-primary/15'}`}>
<AlertTriangle size={32} className={danger ? 'text-error' : 'text-primary'} />
</div>
{title && (
<h3 className="text-white text-base font-semibold mb-1">{title}</h3>
<h3 className="text-white text-xl font-black mb-3 tracking-tight leading-tight">{title}</h3>
)}
<p className="text-zinc-400 text-sm leading-relaxed">{message}</p>
<p className="text-zinc-400 text-[16px] leading-relaxed font-medium px-2">
{message}
</p>
</div>
<div className="flex border-t border-border/40">
<div className="grid grid-cols-2 p-6 pt-2 gap-4">
<button
onClick={onCancel}
className="flex-1 py-3 text-sm font-medium text-zinc-400 hover:bg-surface-hover hover:text-white transition-colors"
className="py-4 px-6 rounded-2xl text-[15px] font-bold text-zinc-400 bg-white/5 hover:bg-white/10 transition-all active:scale-95"
>
{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 ${
className={`py-4 px-6 rounded-2xl text-[15px] font-black transition-all active:scale-95 shadow-lg ${
danger
? 'text-red-400 hover:bg-red-500/10 hover:text-red-300'
: 'text-accent hover:bg-accent/10'
? 'bg-error text-on-error hover:brightness-110 shadow-error/20'
: 'bg-gradient-to-br from-primary to-primary-container text-on-primary hover:brightness-110 shadow-primary/20'
}`}
>
{confirmText || t('confirm')}
@@ -78,4 +80,6 @@ export default function ConfirmModal({
)}
</AnimatePresence>
);
return createPortal(content, document.body);
}
@@ -37,7 +37,6 @@ 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';
import { getInitials } from '../../utils/utils';
@@ -48,9 +47,10 @@ interface SideMenuProps {
isOpen: boolean;
onClose: () => void;
onOpenProfile: () => void;
onOpenTelegramImport: () => void;
}
export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuProps) {
export default function SideMenu({ isOpen, onClose, onOpenProfile, onOpenTelegramImport }: SideMenuProps) {
const { user, updateUser, logout } = useAuthStore();
const { clearStore } = useChatStore();
const { chatTheme, setChatTheme } = useThemeStore();
@@ -60,8 +60,6 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro
const [prevView, setPrevView] = useState<SideView>('main');
const [themeIndex, setThemeIndex] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const [showImportModal, setShowImportModal] = useState(false);
// Friends state
const {
@@ -325,7 +323,7 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro
<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); }}
onClick={() => { onOpenTelegramImport(); }}
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">
@@ -674,7 +672,6 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro
{view === 'about' && renderAbout()}
</AnimatePresence>
</motion.div>
<TelegramImportModal isOpen={showImportModal} onClose={() => setShowImportModal(false)} friends={friends} />
</>
)}
</AnimatePresence>
@@ -12,6 +12,7 @@ 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 { useFriendStore } from '../../../modules/friends/application/friendStore';
import { StoryApi } from '../../../modules/stories/infrastructure/storyApi';
import { getSocket } from '../../infrastructure/socket';
import { getInitials, generateAvatarColor } from '../../utils/utils';
@@ -24,6 +25,7 @@ import SideMenu from './SideMenu';
import StoryViewer from '../../../modules/stories/presentation/components/StoryViewer';
import { CreateStoryModal } from '../../../modules/stories/presentation/components/CreateStoryModal';
import { useStoryStore } from '../../../modules/stories/application/storyStore';
import TelegramImportModal from '../../../modules/users/presentation/components/TelegramImportModal';
const API_URL = import.meta.env.VITE_API_URL || '';
@@ -36,6 +38,8 @@ export default function Sidebar() {
const [showSideMenu, setShowSideMenu] = useState(false);
const { storyGroups, setStoryGroups, viewerIndex, viewerStoryIndex, openViewer, closeViewer } = useStoryStore();
const [showCreateStory, setShowCreateStory] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const { friends, loadFriends } = useFriendStore();
const loadStories = () => {
StoryApi.getStories()
@@ -63,14 +67,17 @@ export default function Sidebar() {
const handleOpenNewChat = () => setShowNewChat(true);
const handleOpenSideMenu = () => setShowSideMenu(true);
const handleOpenImport = () => { loadFriends(); setShowImportModal(true); };
window.addEventListener('OPEN_NEW_CHAT', handleOpenNewChat);
window.addEventListener('OPEN_SIDE_MENU', handleOpenSideMenu);
window.addEventListener('OPEN_TELEGRAM_IMPORT', handleOpenImport);
return () => {
clearInterval(interval);
socket?.off('story_viewed', onStoryViewed);
window.removeEventListener('OPEN_NEW_CHAT', handleOpenNewChat);
window.removeEventListener('OPEN_SIDE_MENU', handleOpenSideMenu);
window.removeEventListener('OPEN_TELEGRAM_IMPORT', handleOpenImport);
};
}, [user?.id]);
@@ -80,9 +87,9 @@ export default function Sidebar() {
if (chat.name?.toLowerCase().includes(q)) return true;
return chat.members.some(
(m) =>
m.user.id !== user?.id &&
((m.user.username || m.user.userName || '').toLowerCase().includes(q) ||
(m.user.displayName || '').toLowerCase().includes(q))
m.user?.id !== user?.id &&
((m.user?.username || m.user?.userName || '').toLowerCase().includes(q) ||
(m.user?.displayName || '').toLowerCase().includes(q))
);
}).sort((a, b) => {
// 1. Favorites chat always on top
@@ -90,13 +97,19 @@ export default function Sidebar() {
if (b.type === 'favorites') return 1;
// 2. Pinned chats next
const aPinned = a.members.find(m => m.user.id === user?.id)?.isPinned ?? false;
const bPinned = b.members.find(m => m.user.id === user?.id)?.isPinned ?? false;
const aPinned = a.members?.find(m => m.user?.id === user?.id)?.isPinned ?? false;
const bPinned = b.members?.find(m => m.user?.id === user?.id)?.isPinned ?? false;
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;
// 3. Importing chats next
if (a.isImporting && !b.isImporting) return -1;
if (!a.isImporting && b.isImporting) return 1;
// 4. Last message timestamp or CreatedAt
const aTime = new Date(a.messages?.[0]?.createdAt || a.createdAt).getTime();
const bTime = new Date(b.messages?.[0]?.createdAt || b.createdAt).getTime();
return bTime - aTime;
});
const handleLogout = () => {
@@ -214,7 +227,7 @@ export default function Sidebar() {
{/* Модалки */}
<AnimatePresence>
{showNewChat && <NewChatModal onClose={() => setShowNewChat(false)} />}
{showNewChat && <NewChatModal onClose={() => setShowNewChat(false)} onOpenTelegramImport={() => { loadFriends(); setShowImportModal(true); }} />}
</AnimatePresence>
<AnimatePresence>
{showProfile && user && <UserProfile userId={user.id} onClose={() => setShowProfile(false)} isSelf />}
@@ -223,6 +236,12 @@ export default function Sidebar() {
isOpen={showSideMenu}
onClose={() => setShowSideMenu(false)}
onOpenProfile={() => setShowProfile(true)}
onOpenTelegramImport={() => { loadFriends(); setShowImportModal(true); }}
/>
<TelegramImportModal
isOpen={showImportModal}
onClose={() => setShowImportModal(false)}
friends={friends}
/>
<AnimatePresence>
{viewerIndex !== null && storyGroups.length > 0 && (