feat: initial JaniChat messenger — PWA, WebSocket, admin panel

This commit is contained in:
Ai
2026-05-22 11:06:43 +03:00
commit 1cabe9d04f
46 changed files with 3570 additions and 0 deletions

View File

@@ -0,0 +1,227 @@
import { useState, useEffect } from 'react';
import { X, Search, MessageCircle, Users, Radio } from 'lucide-react';
import api from '../api/client';
import { useStore } from '../store';
import Avatar from './Avatar';
interface Props {
onClose: () => void;
onOpen: (chatId: string) => void;
}
type Step = 'type' | 'user' | 'group';
export default function NewChatModal({ onClose, onOpen }: Props) {
const [step, setStep] = useState<Step>('type');
const [chatType, setChatType] = useState<'private' | 'group' | 'channel'>('private');
const [users, setUsers] = useState<any[]>([]);
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<string[]>([]);
const [groupTitle, setGroupTitle] = useState('');
const [loading, setLoading] = useState(false);
const { setChats, chats } = useStore();
useEffect(() => {
api.get('/api/users').then(r => setUsers(r.data));
}, []);
const filtered = users.filter(u =>
u.displayName.toLowerCase().includes(search.toLowerCase()) ||
u.username.toLowerCase().includes(search.toLowerCase())
);
function toggleSelect(id: string) {
setSelected(s => s.includes(id) ? s.filter(x => x !== id) : [...s, id]);
}
async function createPrivate(userId: string) {
setLoading(true);
try {
const { data } = await api.post(`/api/chats/private/${userId}`);
const { data: chat } = await api.get(`/api/chats/${data.id}`);
// Refresh chats
const { data: allChats } = await api.get('/api/chats');
setChats(allChats);
onOpen(data.id);
onClose();
} finally {
setLoading(false);
}
}
async function createGroup() {
if (!groupTitle.trim()) return;
setLoading(true);
try {
const { data } = await api.post('/api/chats', {
type: chatType,
title: groupTitle,
memberIds: selected,
});
const { data: allChats } = await api.get('/api/chats');
setChats(allChats);
onOpen(data.id);
onClose();
} finally {
setLoading(false);
}
}
return (
<div className="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4">
<div className="bg-white rounded-t-2xl sm:rounded-2xl w-full sm:max-w-md max-h-[85vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<h2 className="font-semibold text-gray-900">
{step === 'type' ? 'Новый чат' : step === 'user' ? 'Выбор пользователя' : 'Создание ' + (chatType === 'channel' ? 'канала' : 'группы')}
</h2>
<button onClick={onClose} className="p-1 rounded-lg hover:bg-gray-100 text-gray-400">
<X className="w-5 h-5" />
</button>
</div>
{/* Step 1: Type selection */}
{step === 'type' && (
<div className="p-5 space-y-3">
<button
onClick={() => { setChatType('private'); setStep('user'); }}
className="w-full flex items-center gap-4 p-4 rounded-xl hover:bg-blue-50 border border-gray-100 transition-colors"
>
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<MessageCircle className="w-5 h-5 text-blue-600" />
</div>
<div className="text-left">
<div className="font-medium text-gray-900">Личный чат</div>
<div className="text-sm text-gray-500">Переписка с одним пользователем</div>
</div>
</button>
<button
onClick={() => { setChatType('group'); setStep('group'); }}
className="w-full flex items-center gap-4 p-4 rounded-xl hover:bg-purple-50 border border-gray-100 transition-colors"
>
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center">
<Users className="w-5 h-5 text-purple-600" />
</div>
<div className="text-left">
<div className="font-medium text-gray-900">Группа</div>
<div className="text-sm text-gray-500">Общение нескольких участников</div>
</div>
</button>
<button
onClick={() => { setChatType('channel'); setStep('group'); }}
className="w-full flex items-center gap-4 p-4 rounded-xl hover:bg-orange-50 border border-gray-100 transition-colors"
>
<div className="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center">
<Radio className="w-5 h-5 text-orange-600" />
</div>
<div className="text-left">
<div className="font-medium text-gray-900">Канал</div>
<div className="text-sm text-gray-500">Публикации от администраторов</div>
</div>
</button>
</div>
)}
{/* Step 2: Select user (private) */}
{step === 'user' && (
<>
<div className="px-4 py-3 border-b border-gray-100">
<div className="flex items-center gap-2 bg-gray-100 rounded-xl px-3 py-2">
<Search className="w-4 h-4 text-gray-400" />
<input
value={search} onChange={e => setSearch(e.target.value)}
placeholder="Поиск..." className="flex-1 bg-transparent text-sm outline-none"
autoFocus
/>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-1">
{filtered.map(u => (
<button
key={u.id}
onClick={() => createPrivate(u.id)}
disabled={loading}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-gray-50 text-left"
>
<Avatar name={u.displayName} color={u.avatarColor} online={u.online} />
<div>
<div className="font-medium text-sm text-gray-900">{u.displayName}</div>
<div className="text-xs text-gray-400">@{u.username}</div>
</div>
</button>
))}
</div>
</>
)}
{/* Step 3: Create group/channel */}
{step === 'group' && (
<>
<div className="px-4 py-3 border-b border-gray-100 space-y-3">
<input
value={groupTitle}
onChange={e => setGroupTitle(e.target.value)}
placeholder={chatType === 'channel' ? 'Название канала' : 'Название группы'}
className="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
autoFocus
/>
<div className="flex items-center gap-2 bg-gray-100 rounded-xl px-3 py-2">
<Search className="w-4 h-4 text-gray-400" />
<input
value={search} onChange={e => setSearch(e.target.value)}
placeholder="Добавить участников..." className="flex-1 bg-transparent text-sm outline-none"
/>
</div>
{selected.length > 0 && (
<div className="flex flex-wrap gap-1">
{selected.map(id => {
const u = users.find(x => x.id === id);
return u ? (
<span key={id} onClick={() => toggleSelect(id)}
className="flex items-center gap-1 bg-blue-100 text-blue-700 text-xs px-2 py-1 rounded-full cursor-pointer">
{u.displayName} ×
</span>
) : null;
})}
</div>
)}
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-1">
{filtered.map(u => (
<button
key={u.id}
onClick={() => toggleSelect(u.id)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left transition-colors ${
selected.includes(u.id) ? 'bg-blue-50' : 'hover:bg-gray-50'
}`}
>
<div className="relative">
<Avatar name={u.displayName} color={u.avatarColor} />
{selected.includes(u.id) && (
<div className="absolute -bottom-0.5 -right-0.5 w-4 h-4 bg-blue-500 rounded-full flex items-center justify-center">
<span className="text-white text-[10px]"></span>
</div>
)}
</div>
<div>
<div className="font-medium text-sm text-gray-900">{u.displayName}</div>
<div className="text-xs text-gray-400">@{u.username}</div>
</div>
</button>
))}
</div>
<div className="p-4 border-t border-gray-100">
<button
onClick={createGroup}
disabled={!groupTitle.trim() || loading}
className="w-full bg-blue-500 hover:bg-blue-600 disabled:opacity-40 text-white py-3 rounded-xl font-medium text-sm transition-colors"
>
{loading ? 'Создание...' : `Создать ${chatType === 'channel' ? 'канал' : 'группу'}`}
</button>
</div>
</>
)}
</div>
</div>
);
}