Импорт
This commit is contained in:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user