Шифрование, GIF, хранилище, админка

This commit is contained in:
Халимов Рустам
2026-03-16 14:49:31 +03:00
parent 336f9ea559
commit 6d018e41ea
44 changed files with 1876 additions and 162 deletions

View File

@@ -3,6 +3,7 @@ import { AnimatePresence } from 'framer-motion';
import { useAuthStore } from './stores/authStore';
import AuthPage from './pages/AuthPage';
import ChatPage from './pages/ChatPage';
import AdminPage from './pages/AdminPage';
import NotificationProvider from './components/NotificationProvider';
export default function App() {
@@ -23,6 +24,11 @@ export default function App() {
);
}
// Simple routing for admin
if (window.location.pathname.startsWith('/admin')) {
return <AdminPage />;
}
return (
<>
<AnimatePresence mode="wait">

View File

@@ -38,7 +38,7 @@ import Avatar from './Avatar';
import { useThemeStore } from '../stores/themeStore';
export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCall?: (targetUser: UserBasic, type: 'voice' | 'video') => void; onStartGroupCall?: (chatId: string, chatName: string, type: 'voice' | 'video') => void }) {
const { user } = useAuthStore();
const { user, config } = useAuthStore();
const { t, lang } = useLang();
const { chatTheme } = useThemeStore();
const {
@@ -586,7 +586,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
{showSearch ? <X size={18} /> : <Search size={18} />}
</button>
{!isFavorites && (
{!isFavorites && config?.enableCalls && (
<>
<button
onClick={() => {
@@ -768,7 +768,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
{/* Закреплённое сообщение */}
{/* Active group call banner */}
{chat?.type === 'group' && activeGroupCallParticipants.length > 0 && (
{chat?.type === 'group' && config?.enableCalls && activeGroupCallParticipants.length > 0 && (
<button
onClick={() => onStartGroupCall?.(chat.id, chat.name || 'Group', 'voice')}
className="flex items-center gap-3 px-4 py-2.5 border-b border-border bg-emerald-500/10 hover:bg-emerald-500/20 transition-colors text-left w-full flex-shrink-0"

View File

@@ -8,14 +8,11 @@ import { useAuthStore } from '../stores/authStore';
interface KlipyGif {
id: string;
images: {
original: { url: string };
fixed_height_small: { url: string };
};
file: {
sd?: { gif?: { url: string }; webp?: { url: string } };
hd?: { gif?: { url: string }; webp?: { url: string } };
};
files?: any;
file?: any;
media_formats?: any;
media?: any;
images?: any;
title?: string;
}
@@ -25,14 +22,18 @@ interface EmojiPickerProps {
onClose: () => void;
}
const getKlipyKey = () => import.meta.env.VITE_KLIPY_API_KEY || '';
const getCustomerId = () => {
const user = useAuthStore.getState().user;
return user?.id || 'anonymous';
const getKlipyKey = (config: any) => {
return config?.klipyApiKey || import.meta.env.VITE_KLIPY_API_KEY || '';
};
const getCustomerId = (config: any, user: any) => {
return config?.klipyCustomerId || user?.id || 'anonymous';
};
export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPickerProps) {
const { lang, t } = useLang();
const { config, user } = useAuthStore();
const klipyApiKey = getKlipyKey(config);
const klipyCustomerId = getCustomerId(config, user);
const [tab, setTab] = useState<'emoji' | 'gif'>('emoji');
const [gifQuery, setGifQuery] = useState('');
const [gifs, setGifs] = useState<KlipyGif[]>([]);
@@ -40,10 +41,12 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
const [trendingGifs, setTrendingGifs] = useState<KlipyGif[]>([]);
const gifSearchRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const initialFetchDone = useRef(false);
// Helper to safely extract GIF array from various possible Klipy API responses
const extractGifs = (d: any): KlipyGif[] => {
if (!d) return [];
if (d.data && Array.isArray(d.data.data)) return d.data.data;
if (Array.isArray(d)) return d;
if (Array.isArray(d.data)) return d.data;
if (Array.isArray(d.result)) return d.result;
@@ -54,10 +57,17 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
// Load trending GIFs (Klipy)
useEffect(() => {
if (tab === 'gif' && getKlipyKey() && trendingGifs.length === 0) {
if (tab === 'gif' && klipyApiKey && !initialFetchDone.current) {
initialFetchDone.current = true;
setGifLoading(true);
fetch(`https://api.klipy.com/api/v1/${getKlipyKey()}/gifs/trending?customer_id=${getCustomerId()}&per_page=30`)
.then(r => r.json())
fetch(`https://api.klipy.com/api/v1/${klipyApiKey}/gifs/trending?page=1&per_page=30&customer_id=${klipyCustomerId}`)
.then(async (r) => {
if (!r.ok) {
const text = await r.text();
throw new Error(`Klipy error: ${r.status} ${text.substring(0, 100)}`);
}
return r.json();
})
.then(d => {
setTrendingGifs(extractGifs(d));
setGifLoading(false);
@@ -68,13 +78,19 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
setGifLoading(false);
});
}
}, [tab, trendingGifs.length]);
}, [tab, klipyApiKey, klipyCustomerId]);
const searchGifs = useCallback((q: string) => {
if (!getKlipyKey() || !q.trim()) { setGifs([]); return; }
if (!klipyApiKey || !q.trim()) { setGifs([]); return; }
setGifLoading(true);
fetch(`https://api.klipy.com/api/v1/${getKlipyKey()}/gifs/search?customer_id=${getCustomerId()}&q=${encodeURIComponent(q)}&per_page=30`)
.then(r => r.json())
fetch(`https://api.klipy.com/api/v1/${klipyApiKey}/gifs/search?page=1&per_page=30&q=${encodeURIComponent(q)}&customer_id=${klipyCustomerId}`)
.then(async (r) => {
if (!r.ok) {
const text = await r.text();
throw new Error(`Klipy error: ${r.status} ${text.substring(0, 100)}`);
}
return r.json();
})
.then(d => {
setGifs(extractGifs(d));
setGifLoading(false);
@@ -84,7 +100,7 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
setGifs([]);
setGifLoading(false);
});
}, []);
}, [klipyApiKey, klipyCustomerId]);
const handleGifSearch = (q: string) => {
setGifQuery(q);
@@ -92,9 +108,23 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
debounceRef.current = setTimeout(() => searchGifs(q), 400);
};
const getGifUrl = (gif: any): string => {
return gif.files?.hd?.gif?.url || gif.files?.sd?.gif?.url
|| gif.file?.hd?.gif?.url || gif.file?.sd?.gif?.url
|| gif.media_formats?.gif?.url || gif.media?.[0]?.gif?.url
|| gif.images?.original?.url || '';
};
const getGifPreview = (gif: any, fullUrl: string): string => {
return gif.files?.sd?.webp?.url || gif.files?.sd?.gif?.url
|| gif.file?.sd?.webp?.url || gif.file?.sd?.gif?.url
|| gif.media_formats?.tinygif?.url || gif.media?.[0]?.tinygif?.url
|| gif.images?.fixed_height_small?.url || fullUrl;
};
const pickGif = (gif: KlipyGif) => {
const url = gif.file?.hd?.gif?.url || gif.file?.sd?.gif?.url || '';
const preview = gif.file?.sd?.webp?.url || gif.file?.sd?.gif?.url || url;
const url = getGifUrl(gif);
const preview = getGifPreview(gif, url);
if (onSelectGif && url) {
onSelectGif(url, preview);
}
@@ -132,6 +162,7 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
className="fixed z-[9991] rounded-2xl shadow-2xl border border-white/10"
style={{
width: pickerWidth,
height: tab === 'gif' ? 435 : undefined,
bottom: pos ? `${window.innerHeight - pos.top}px` : undefined,
left: pos ? pos.left : undefined,
background: 'rgb(17, 17, 19)',
@@ -146,7 +177,7 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
>
EMOJI
</button>
{(getKlipyKey() || onSelectGif) && (
{config?.enableKlipy && (klipyApiKey || onSelectGif) && (
<button
onClick={() => { setTab('gif'); setTimeout(() => gifSearchRef.current?.focus(), 100); }}
className={`flex-1 py-2.5 text-xs font-semibold tracking-wide transition-colors ${tab === 'gif' ? 'text-white border-b-2 border-accent' : 'text-zinc-500 hover:text-zinc-300'}`}
@@ -176,9 +207,9 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
)}
{/* GIF tab */}
{tab === 'gif' && (
{config?.enableKlipy && tab === 'gif' && (
<div className="flex flex-col h-[calc(100%-41px)]">
{!getKlipyKey() ? (
{!klipyApiKey ? (
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
<p className="text-sm text-zinc-400 mb-2">Klipy API Key required</p>
<p className="text-xs text-zinc-500 mb-3">{t('openConsoleRun')}</p>
@@ -214,7 +245,7 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
) : displayGifs.length === 0 ? (
<p className="text-center text-xs text-zinc-500 py-10">{gifQuery ? t('nothingFound') : ''}</p>
) : (
<div className="columns-2 gap-1.5">
<div className="columns-4 gap-1.5">
{displayGifs.map((gif) => (
<button
key={gif.id}
@@ -222,7 +253,7 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
className="w-full mb-1.5 rounded-lg overflow-hidden hover:opacity-80 transition-opacity block"
>
<img
src={gif.file?.sd?.webp?.url || gif.file?.sd?.gif?.url || gif.file?.hd?.gif?.url}
src={getGifPreview(gif, getGifUrl(gif))}
alt={gif.title || 'GIF'}
className="w-full h-auto rounded-lg"
loading="lazy"

View File

@@ -498,35 +498,44 @@ function MessageBubble({
)}
{/* Изображения и Видео (Галерея) */}
{(hasImage || hasVideo) && (
{(hasImage || hasVideo) && (() => {
const galleryMedia = media.filter(m => m.type === 'image' || m.type === 'video');
const isSingleGif = galleryMedia.length === 1 && (
galleryMedia[0].filename === 'gif' ||
galleryMedia[0].filename === 'gif.gif' ||
galleryMedia[0].url?.includes('klipy') ||
galleryMedia[0].url?.endsWith('.gif')
);
return (
<div className={`
${(message.content || message.forwardedFrom) ? '-mx-4' : ''}
${(message.content || message.forwardedFrom) ? (message.forwardedFrom ? 'mt-2' : '-mt-2.5') : ''}
${(message.content || message.forwardedFrom) ? (message.content ? 'mb-2' : '-mb-2.5') : ''}
bg-black/20 overflow-hidden
${isSingleGif && !(message.content || message.forwardedFrom) ? 'max-w-[260px] rounded-[1.25rem]' : ''}
${isSingleGif && (message.content || message.forwardedFrom) ? 'max-h-[260px] mx-auto' : ''}
bg-black/20 overflow-hidden relative
`}>
<div className={`grid gap-[2px] ${media.filter(m => m.type === 'image' || m.type === 'video').length >= 3
<div className={`grid gap-[2px] ${galleryMedia.length >= 3
? 'grid-cols-3'
: media.filter(m => m.type === 'image' || m.type === 'video').length === 2
: galleryMedia.length === 2
? 'grid-cols-2'
: 'grid-cols-1'
}`}>
{media
.filter((m) => m.type === 'image' || m.type === 'video')
.map((m, idx) => (
{galleryMedia.map((m, idx) => (
m.type === 'image' ? (
<img
key={m.id}
src={m.url}
alt=""
className={`w-full h-full object-cover cursor-pointer hover:brightness-90 transition-all ${media.filter(i => i.type === 'image' || i.type === 'video').length > 1 ? 'aspect-square' : 'max-h-[500px]'
className={`w-full h-full object-cover cursor-pointer hover:brightness-90 transition-all ${galleryMedia.length > 1 ? 'aspect-square' : isSingleGif ? 'max-h-[260px]' : 'max-h-[500px]'
}`}
onClick={() => setLightboxData({ index: idx })}
/>
) : (
<div
key={m.id}
className={`relative cursor-pointer group/video ${media.filter(i => i.type === 'image' || i.type === 'video').length > 1 ? 'aspect-square' : ''
className={`relative cursor-pointer group/video ${galleryMedia.length > 1 ? 'aspect-square' : ''
}`}
onClick={() => setLightboxData({ index: idx })}
>
@@ -535,14 +544,15 @@ function MessageBubble({
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/20 group-hover/video:bg-black/40 transition-colors">
<Play size={media.filter(i => i.type === 'image' || i.type === 'video').length > 1 ? 24 : 48} className="text-white opacity-80" />
<Play size={galleryMedia.length > 1 ? 24 : 48} className="text-white opacity-80" />
</div>
</div>
)
))}
</div>
</div>
)}
);
})()}
{/* Голосовое */}
{hasVoice && (

View File

@@ -412,11 +412,12 @@ export default function MessageInput({ chatId }: MessageInputProps) {
chatId,
content: null,
type: 'voice',
mediaUrl: result.url,
mediaType: 'voice',
fileName: result.filename,
fileSize: result.size,
duration: recordingTimeRef.current,
attachments: [{
type: 'voice',
url: result.url,
fileName: result.filename,
fileSize: result.size
}],
replyToId: replyTo?.id || null,
});
setReplyTo(null);
@@ -874,9 +875,12 @@ export default function MessageInput({ chatId }: MessageInputProps) {
chatId,
content: null,
type: 'image',
mediaUrl: gifUrl,
mediaType: 'image',
fileName: 'gif',
attachments: [{
type: 'image',
url: gifUrl,
fileName: 'gif.gif',
fileSize: 0,
}],
replyToId: replyTo?.id || null,
});
setReplyTo(null);

View File

@@ -14,7 +14,7 @@ interface NewChatModalProps {
type Mode = 'personal' | 'group-select' | 'group-name';
export default function NewChatModal({ onClose }: NewChatModalProps) {
const { user } = useAuthStore();
const { user, config } = useAuthStore();
const { t } = useLang();
const { addChat, setActiveChat, loadMessages } = useChatStore();
const [mode, setMode] = useState<Mode>('personal');
@@ -212,7 +212,9 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
</div>
<div className="text-left">
<p className="text-sm font-medium text-white">{t('createGroup')}</p>
<p className="text-xs text-zinc-500">{t('upTo200')}</p>
<p className="text-xs text-zinc-500">
{t('upTo200').replace('200', String(config?.maxGroupMembers || 500))}
</p>
</div>
</button>
)}

View File

@@ -28,7 +28,7 @@ interface UserProfileProps {
type MediaTab = 'publications' | 'media' | 'files' | 'links';
export default function UserProfile({ userId, chatId, onClose, onGoToMessage, isSelf }: UserProfileProps) {
const { user: authUser } = useAuthStore();
const { user: authUser, config } = useAuthStore();
const { t, lang } = useLang();
const [profile, setProfile] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
@@ -576,15 +576,17 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
</div>
<span className="text-[11px] font-medium text-white shadow-sm">{isMutedLocally ? t('enableSound') : t('disableSound')}</span>
</button>
<button
onClick={() => handleStartCall('voice')}
className="flex-1 flex flex-col items-center gap-1.5 py-2.5 rounded-2xl bg-white/5 hover:bg-white/10 transition-colors border border-white/5"
>
<div className="w-8 h-8 rounded-full bg-black/40 flex items-center justify-center">
<Phone size={16} className="text-white" />
</div>
<span className="text-[11px] font-medium text-white">{t('call')}</span>
</button>
{config?.enableCalls && (
<button
onClick={() => handleStartCall('voice')}
className="flex-1 flex flex-col items-center gap-1.5 py-2.5 rounded-2xl bg-white/5 hover:bg-white/10 transition-colors border border-white/5"
>
<div className="w-8 h-8 rounded-full bg-black/40 flex items-center justify-center">
<Phone size={16} className="text-white" />
</div>
<span className="text-[11px] font-medium text-white">{t('call')}</span>
</button>
)}
<button
className="flex-1 flex flex-col items-center gap-1.5 py-2.5 rounded-2xl bg-white/5 border border-white/5 opacity-50 cursor-not-allowed"
>

View File

@@ -21,7 +21,7 @@ export function connectSocket(token: string): SocketCompat {
accessTokenFactory: () => token
})
.withAutomaticReconnect()
.configureLogging(LogLevel.Information)
.configureLogging(LogLevel.Error)
.build();
// Обертка для совместимости с Socket.io API

View File

@@ -0,0 +1,704 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Settings, Shield, Activity, Users, Database, Globe, Search, Trash2, Plus, Download, Upload, Loader2, User } from 'lucide-react';
interface Stats {
storageUsedBytes: number;
storageLimitBytes: number;
onlineUsers: number;
offlineUsers: number;
totalUsers: number;
}
interface Conf {
maxStorageQuotaTb: number;
maxGroupMembers: number;
maxFileSizeMb: number;
enableCalls: boolean;
turnHost: string;
turnPort: number;
turnUser: string;
turnSecret: string;
enableKlipy: boolean;
klipyApiKey: string;
klipyCustomerId: string;
enableConfederation: boolean;
allowedDomains: string[];
}
interface AppUser {
id: string;
username: string;
displayName: string;
avatar: string | null;
bio?: string;
createdAt: string;
lastOnlineAt: string;
stats?: {
messagesCount: number;
mediaCount: number;
filesCount: number;
linksCount: number;
storageUsedBytes: number;
};
}
const translations = {
en: {
loginTitle: 'Admin Login',
username: 'Username',
password: 'Password',
loginBtn: 'Login',
dashboard: 'Dashboard',
settings: 'Settings',
federation: 'Federation',
userAnalytics: 'User Analytics',
storageUsed: 'Storage Used',
storageFree: 'Storage Free',
memoryOccupied: 'Memory occupied by user messages & files',
usersOnline: 'Users Online',
usersOffline: 'Users Offline',
totalRegistered: 'total registered',
moduleSettings: 'Module Settings',
limits: 'Limits',
maxFileSize: 'Max File Size (MB)',
maxGroupMembers: 'Max Group Members',
calls: 'WebRTC Calls',
turnHost: 'TURN Host',
turnPort: 'TURN Port',
turnUser: 'TURN User',
turnSecret: 'TURN Password/Secret',
gifs: 'Klipy GIFs',
gifsDesc: 'Allow users to search and send GIFs',
apiKey: 'API Key',
saveChanges: 'Save Changes',
confederationSettings: 'Confederation Settings',
enableConfederation: 'Enable Confederation',
enableConfederationDesc: 'Connect this node with the Knot network',
callsDesc: 'Enable peer-to-peer and group calls',
allowedNodes: 'Allowed Nodes (Whitelist)',
addNode: 'Add Node',
importJson: 'Import JSON',
exportJson: 'Export JSON',
searchUsers: 'Search Users...',
noUsersFound: 'No users found.',
viewDetails: 'View Details',
backToList: 'Back to list',
profile: 'Profile Details',
registeredOn: 'Registered:',
lastSeen: 'Last seen:',
bio: 'Bio',
errorInvalidLogin: 'Invalid credentials',
successSave: 'Settings saved successfully!',
errorSave: 'Error saving settings',
messagesSent: 'Messages Sent',
mediaSent: 'Media Sent',
filesSent: 'Files Sent',
linksSent: 'Links Sent',
userStorageOccupied: 'Storage Occupied'
},
ru: {
loginTitle: 'Вход администратора',
username: 'Имя пользователя',
password: 'Пароль',
loginBtn: 'Войти',
dashboard: 'Дашборд',
settings: 'Настройки',
federation: 'Федерация',
userAnalytics: 'Пользователи',
storageUsed: 'Места использовано',
storageFree: 'Свободно',
memoryOccupied: 'Память, занятая сообщениями и файлами пользователей',
usersOnline: 'В сети',
usersOffline: 'Не в сети',
totalRegistered: 'всего зарегистрировано',
moduleSettings: 'Настройки модулей',
limits: 'Лимиты',
maxFileSize: 'Макс. размер файла (МБ)',
maxGroupMembers: 'Макс. участников группы',
calls: 'Звонки WebRTC',
turnHost: 'TURN Хост',
turnPort: 'TURN Порт',
turnUser: 'TURN Пользователь',
turnSecret: 'TURN Пароль',
gifs: 'GIF Klipy',
gifsDesc: 'Разрешить пользователям поиск и отправку GIF',
apiKey: 'API Ключ',
saveChanges: 'Сохранить изменения',
confederationSettings: 'Настройки Конфедерации',
enableConfederation: 'Включить Конфедерацию',
enableConfederationDesc: 'Подключить этот узел к сети Knot',
callsDesc: 'Включить P2P и групповые звонки',
allowedNodes: 'Разрешенные узлы (Белый список)',
addNode: 'Добавить узел',
importJson: 'Импорт JSON',
exportJson: 'Экспорт JSON',
searchUsers: 'Поиск пользователей...',
noUsersFound: 'Пользователи не найдены.',
viewDetails: 'Подробнее',
backToList: 'Назад к списку',
profile: 'Профиль пользователя',
registeredOn: 'Зарегистрирован:',
lastSeen: 'Был в сети:',
bio: 'О себе',
errorInvalidLogin: 'Неверные учетные данные',
successSave: 'Настройки успешно сохранены!',
errorSave: 'Ошибка при сохранении',
messagesSent: 'Отправлено сообщений',
mediaSent: 'Отправлено медиа',
filesSent: 'Отправлено файлов',
linksSent: 'Отправлено ссылок',
userStorageOccupied: 'Места на диске'
}
};
export default function AdminPage() {
const [activeTab, setActiveTab] = useState('dashboard');
const [lang, setLang] = useState<'en' | 'ru'>('ru');
const t = translations[lang];
const formatDateTime = (dateStr: string) => {
const d = new Date(dateStr);
const options: Intl.DateTimeFormatOptions = {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
};
return d.toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', options).replace(',', '');
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024, sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const [stats, setStats] = useState<Stats | null>(null);
const [config, setConfig] = useState<Conf | null>(null);
const [authHeader, setAuthHeader] = useState('');
const [creds, setCreds] = useState({ user: '', pass: '' });
const [authenticated, setAuthenticated] = useState(false);
// Users Tab
const [users, setUsers] = useState<AppUser[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [isSearching, setIsSearching] = useState(false);
const [selectedUser, setSelectedUser] = useState<AppUser | null>(null);
const [domainInput, setDomainInput] = useState('');
const fetchDashboard = async (header: string) => {
try {
const res = await fetch('/api/admin/dashboard', { headers: { Authorization: header } });
if (res.ok) setStats(await res.json());
} catch {}
};
const fetchSettings = async (header: string) => {
try {
const res = await fetch('/api/admin/settings', { headers: { Authorization: header } });
if (res.ok) setConfig(await res.json());
} catch {}
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
const header = `Basic ${btoa(`${creds.user}:${creds.pass}`)}`;
const res = await fetch('/api/admin/dashboard', { headers: { Authorization: header } });
if (res.ok) {
setAuthHeader(header);
setAuthenticated(true);
fetchDashboard(header);
fetchSettings(header);
searchUsers('', header); // Fetch initial users list
} else {
alert(t.errorInvalidLogin);
}
};
useEffect(() => {
if (!authenticated || !authHeader) return;
const interval = setInterval(() => {
// Refresh dashboard if we are on dashboard tab
if (activeTab === 'dashboard') {
fetchDashboard(authHeader);
}
}, 10000);
return () => clearInterval(interval);
}, [authenticated, authHeader, activeTab]);
const saveSettings = async () => {
if (!config) return;
try {
const res = await fetch('/api/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
body: JSON.stringify(config)
});
if (res.ok) alert(t.successSave);
} catch {
alert(t.errorSave);
}
};
const searchUsers = async (q: string, header: string = authHeader) => {
setIsSearching(true);
try {
const res = await fetch(`/api/admin/users?query=${encodeURIComponent(q)}`, { headers: { Authorization: header } });
if (res.ok) {
setUsers(await res.json());
}
} catch {} finally {
setIsSearching(false);
}
};
const fetchUserDetails = async (id: string) => {
try {
const res = await fetch(`/api/admin/users/${id}`, { headers: { Authorization: authHeader } });
if (res.ok) {
setSelectedUser(await res.json());
}
} catch {}
};
// Federation Domain Handlers
const addDomain = () => {
if (domainInput.trim() && config && !config.allowedDomains.includes(domainInput.trim().toLowerCase())) {
setConfig({ ...config, allowedDomains: [...config.allowedDomains, domainInput.trim().toLowerCase()] });
setDomainInput('');
}
};
const removeDomain = (d: string) => {
if (config) {
setConfig({ ...config, allowedDomains: config.allowedDomains.filter(x => x !== d) });
}
};
const exportDomains = () => {
if (!config) return;
const blob = new Blob([JSON.stringify(config.allowedDomains, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'knot_allowed_domains.json';
a.click();
URL.revokeObjectURL(url);
};
const importDomains = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.length || !config) return;
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = (ev) => {
try {
const parsed = JSON.parse(ev.target?.result as string);
if (Array.isArray(parsed)) {
setConfig({ ...config, allowedDomains: parsed });
}
} catch {
alert('Invalid JSON file');
}
};
reader.readAsText(file);
};
if (!authenticated) {
return (
<div className="min-h-screen flex items-center justify-center bg-black/90 text-white font-sans">
<form onSubmit={handleLogin} className="bg-surface border border-accent/20 p-8 rounded-2xl w-full max-w-sm">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-accent">{t.loginTitle}</h1>
<div className="flex gap-2">
<button type="button" onClick={() => setLang('en')} className={`text-sm ${lang==='en'?'text-accent font-bold':'text-gray-500'}`}>EN</button>
<button type="button" onClick={() => setLang('ru')} className={`text-sm ${lang==='ru'?'text-accent font-bold':'text-gray-500'}`}>RU</button>
</div>
</div>
<input
className="w-full bg-black/50 border border-white/10 rounded-xl px-4 py-3 mb-4 outline-none focus:border-accent"
placeholder={t.username}
value={creds.user}
onChange={(e) => setCreds({ ...creds, user: e.target.value })}
/>
<input
className="w-full bg-black/50 border border-white/10 rounded-xl px-4 py-3 mb-6 outline-none focus:border-accent"
type="password"
placeholder={t.password}
value={creds.pass}
onChange={(e) => setCreds({ ...creds, pass: e.target.value })}
/>
<button className="w-full bg-accent hover:bg-accentLight text-black font-semibold py-3 rounded-xl transition-colors">
{t.loginBtn}
</button>
</form>
</div>
);
}
const pct = stats ? Math.min((stats.storageUsedBytes / stats.storageLimitBytes) * 100, 100) : 0;
const freeSpace = stats ? stats.storageLimitBytes - stats.storageUsedBytes : 0;
return (
<div className="min-h-screen bg-black text-white font-sans flex overflow-hidden">
<aside className="w-64 border-r border-white/10 bg-surface/50 p-6 flex flex-col gap-2 shrink-0 overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold text-accent flex items-center gap-2">
<Shield className="w-6 h-6" /> Knot Admin
</h2>
</div>
<NavButton icon={<Activity />} label={t.dashboard} active={activeTab === 'dashboard'} onClick={() => setActiveTab('dashboard')} />
<NavButton icon={<Settings />} label={t.settings} active={activeTab === 'settings'} onClick={() => setActiveTab('settings')} />
<NavButton icon={<Globe />} label={t.federation} active={activeTab === 'federation'} onClick={() => setActiveTab('federation')} />
<NavButton icon={<Users />} label={t.userAnalytics} active={activeTab === 'users'} onClick={() => { setActiveTab('users'); setSelectedUser(null); }} />
<div className="mt-auto pt-6 flex gap-4 text-gray-500 text-sm justify-center">
<button onClick={() => setLang('en')} className={lang === 'en' ? 'text-accent font-bold' : 'hover:text-white'}>EN</button>
<span>|</span>
<button onClick={() => setLang('ru')} className={lang === 'ru' ? 'text-accent font-bold' : 'hover:text-white'}>RU</button>
</div>
</aside>
<main className="flex-1 p-8 overflow-y-auto relative">
<AnimatePresence mode="wait">
{/* DASHBOARD TAB */}
{activeTab === 'dashboard' && stats && (
<motion.div key="dashboard" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="flex flex-col gap-6 w-full max-w-5xl">
<h1 className="text-3xl font-bold">{t.dashboard}</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 w-full">
<div className="bg-surface border border-white/10 rounded-2xl p-6 relative overflow-hidden">
<div className="absolute -right-10 -top-10 text-white/5 rotate-12">
<Database className="w-40 h-40" />
</div>
<h3 className="text-gray-400 font-medium mb-4 flex items-center gap-2 relative z-10"><Database className="w-5 h-5"/> {t.storageUsed}</h3>
<div className="text-4xl font-bold text-white relative z-10 mb-2">{formatBytes(stats.storageUsedBytes)}</div>
<div className="text-sm text-gray-500 mb-6 relative z-10">{t.memoryOccupied}</div>
<div className="flex justify-between text-sm text-gray-400 mb-2">
<span>{pct.toFixed(1)}% Used</span>
<span>{formatBytes(freeSpace)} {t.storageFree}</span>
</div>
<div className="w-full bg-white/10 rounded-full h-3 overflow-hidden relative z-10">
<motion.div initial={{ width: 0 }} animate={{ width: `${pct}%` }} className={`h-full ${pct > 80 ? 'bg-red-500' : 'bg-accent'}`} />
</div>
</div>
<div className="bg-surface border border-white/10 rounded-2xl p-6 relative overflow-hidden">
<div className="absolute -right-10 -top-10 text-white/5 -rotate-12">
<Users className="w-40 h-40" />
</div>
<h3 className="text-gray-400 font-medium mb-4 flex items-center gap-2 relative z-10"><Users className="w-5 h-5"/> {t.userAnalytics}</h3>
<div className="flex flex-col gap-4 relative z-10">
<div className="flex justify-between items-end border-b border-white/10 pb-4">
<div className="text-gray-400">{t.usersOnline}</div>
<div className="text-3xl font-bold text-green-400">{stats.onlineUsers}</div>
</div>
<div className="flex justify-between items-end border-b border-white/10 pb-4">
<div className="text-gray-400">{t.usersOffline}</div>
<div className="text-3xl font-bold text-gray-300">{stats.offlineUsers}</div>
</div>
<div className="flex justify-between items-end pt-2">
<div className="text-sm text-gray-500 uppercase tracking-wider">{t.totalRegistered}</div>
<div className="text-xl font-bold text-accent">{stats.totalUsers}</div>
</div>
</div>
</div>
</div>
</motion.div>
)}
{/* SETTINGS TAB */}
{activeTab === 'settings' && config && (
<motion.div key="settings" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="flex flex-col gap-6 max-w-3xl pb-20">
<h1 className="text-3xl font-bold mb-4">{t.moduleSettings}</h1>
<div className="bg-surface border border-white/10 rounded-2xl p-6 flex flex-col gap-5">
<h3 className="text-xl font-semibold mb-2">{t.limits}</h3>
<div className="grid grid-cols-2 gap-4">
<label className="flex flex-col gap-1 text-sm text-gray-400">
{t.maxFileSize}
<input type="number" className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.maxFileSizeMb} onChange={e => setConfig({...config, maxFileSizeMb: parseInt(e.target.value)||0})} />
</label>
<label className="flex flex-col gap-1 text-sm text-gray-400">
{t.maxGroupMembers}
<input type="number" className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.maxGroupMembers} onChange={e => setConfig({...config, maxGroupMembers: parseInt(e.target.value)||0})} />
</label>
</div>
</div>
<div className="bg-surface border border-white/10 rounded-2xl p-6 flex flex-col gap-5">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xl font-semibold text-white">{t.calls}</h3>
<p className="text-sm text-gray-400 mt-1">{t.callsDesc}</p>
</div>
<Toggle checked={config.enableCalls} onChange={e => setConfig({...config, enableCalls: e})} />
</div>
{config.enableCalls && (
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} className="flex flex-col gap-4 mt-4 pt-4 border-t border-white/10">
<label className="flex flex-col gap-1 text-sm text-gray-400">
{t.turnHost}
<input className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.turnHost} onChange={e => setConfig({...config, turnHost: e.target.value})} placeholder="ip or domain" />
</label>
<div className="grid grid-cols-2 gap-4">
<label className="flex flex-col gap-1 text-sm text-gray-400">
{t.turnPort}
<input type="number" className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.turnPort} onChange={e => setConfig({...config, turnPort: parseInt(e.target.value)||3478})} />
</label>
<label className="flex flex-col gap-1 text-sm text-gray-400">
{t.turnUser}
<input className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.turnUser} onChange={e => setConfig({...config, turnUser: e.target.value})} />
</label>
</div>
<label className="flex flex-col gap-1 text-sm text-gray-400">
{t.turnSecret}
<input type="password" className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.turnSecret} onChange={e => setConfig({...config, turnSecret: e.target.value})} />
</label>
</motion.div>
)}
</div>
<div className="bg-surface border border-white/10 rounded-2xl p-6 flex flex-col gap-5">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xl font-semibold text-white">{t.gifs}</h3>
<p className="text-sm text-gray-400 mt-1">{t.gifsDesc}</p>
</div>
<Toggle checked={config.enableKlipy} onChange={e => setConfig({...config, enableKlipy: e})} />
</div>
{config.enableKlipy && (
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} className="mt-4 pt-4 border-t border-white/10 flex flex-col gap-4">
<label className="flex flex-col gap-1 text-sm text-gray-400">
{t.apiKey}
<input className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.klipyApiKey} onChange={e => setConfig({...config, klipyApiKey: e.target.value})} />
</label>
<label className="flex flex-col gap-1 text-sm text-gray-400">
Customer ID (for tracking/analytics)
<input className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.klipyCustomerId || ''} onChange={e => setConfig({...config, klipyCustomerId: e.target.value})} />
</label>
</motion.div>
)}
</div>
<div className="sticky bottom-8 self-end">
<button onClick={saveSettings} className="bg-accent hover:bg-accentLight text-black font-semibold py-4 px-10 rounded-xl transition-colors shadow-xl shadow-accent/20">
{t.saveChanges}
</button>
</div>
</motion.div>
)}
{/* FEDERATION TAB */}
{activeTab === 'federation' && config && (
<motion.div key="federation" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="flex flex-col gap-6 max-w-3xl pb-20">
<h1 className="text-3xl font-bold mb-4">{t.confederationSettings}</h1>
<div className="bg-surface border border-white/10 rounded-2xl p-6 flex flex-col gap-5">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xl font-semibold text-white">{t.enableConfederation}</h3>
<p className="text-sm text-gray-400 mt-1">{t.enableConfederationDesc}</p>
</div>
<Toggle checked={config.enableConfederation} onChange={e => setConfig({...config, enableConfederation: e})} />
</div>
{config.enableConfederation && (
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} className="mt-6 pt-6 border-t border-white/10">
<div className="flex justify-between items-center mb-4">
<h4 className="text-lg font-medium text-white">{t.allowedNodes}</h4>
<div className="flex gap-2">
<input type="file" id="importJson" className="hidden" accept=".json" onChange={importDomains} />
<label htmlFor="importJson" className="flex items-center gap-2 cursor-pointer text-sm bg-white/5 hover:bg-white/10 px-3 py-2 rounded-lg transition-colors">
<Upload className="w-4 h-4" /> {t.importJson}
</label>
<button onClick={exportDomains} className="flex items-center gap-2 text-sm bg-white/5 hover:bg-white/10 px-3 py-2 rounded-lg transition-colors">
<Download className="w-4 h-4" /> {t.exportJson}
</button>
</div>
</div>
<div className="flex gap-2 mb-4">
<input
className="flex-1 bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent"
value={domainInput}
onChange={e => setDomainInput(e.target.value)}
placeholder="node.example.com"
onKeyDown={e => e.key === 'Enter' && addDomain()}
/>
<button onClick={addDomain} className="bg-white/10 hover:bg-white/20 px-6 rounded-xl transition-colors font-semibold">
{t.addNode}
</button>
</div>
<div className="bg-black/30 border border-white/5 rounded-xl block p-2 max-h-80 overflow-y-auto">
{config.allowedDomains.length === 0 && <div className="p-4 text-gray-500 text-center text-sm">No domains added yet</div>}
{config.allowedDomains.map(d => (
<div key={d} className="flex justify-between items-center bg-white/5 p-3 rounded-lg mb-2 last:mb-0 group">
<span className="font-mono text-sm">{d}</span>
<button onClick={() => removeDomain(d)} className="text-gray-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all">
<Trash2 className="w-5 h-5"/>
</button>
</div>
))}
</div>
</motion.div>
)}
</div>
<div className="sticky bottom-8 self-end">
<button onClick={saveSettings} className="bg-accent hover:bg-accentLight text-black font-semibold py-4 px-10 rounded-xl transition-colors shadow-xl shadow-accent/20">
{t.saveChanges}
</button>
</div>
</motion.div>
)}
{/* USERS TAB */}
{activeTab === 'users' && (
<motion.div key="users" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="flex flex-col gap-6 w-full max-w-5xl h-full pb-10">
{!selectedUser ? (
<>
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">{t.userAnalytics}</h1>
</div>
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
className="w-full bg-surface border border-white/10 rounded-2xl pl-12 pr-4 py-4 text-white outline-none focus:border-accent transition-colors shadow-lg"
placeholder={t.searchUsers}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
searchUsers(e.target.value);
}}
/>
{isSearching && <Loader2 className="absolute right-4 top-1/2 -translate-y-1/2 text-accent w-5 h-5 animate-spin" />}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 auto-rows-max">
{users.length === 0 && !isSearching && (
<div className="col-span-full text-center py-20 text-gray-500">
{t.noUsersFound}
</div>
)}
{users.map(u => (
<div key={u.id} className="bg-surface border border-white/10 p-5 rounded-2xl flex items-center gap-4 hover:border-accent/50 transition-colors cursor-pointer" onClick={() => fetchUserDetails(u.id)}>
{u.avatar ? (
<img src={u.avatar} alt="avatar" className="w-14 h-14 rounded-full object-cover border border-white/10" />
) : (
<div className="w-14 h-14 rounded-full bg-white/10 flex items-center justify-center border border-white/5">
<User className="text-gray-400 w-6 h-6" />
</div>
)}
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-white truncate">{u.displayName}</h4>
<div className="text-sm text-gray-400 truncate">@{u.username}</div>
</div>
</div>
))}
</div>
</>
) : (
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="flex flex-col gap-6 max-w-2xl">
<button onClick={() => setSelectedUser(null)} className="text-accent hover:underline self-start flex items-center gap-2">
&larr; {t.backToList}
</button>
<div className="bg-surface border border-white/10 rounded-2xl p-8 flex flex-col items-center relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-32 bg-gradient-to-b from-accent/20 to-transparent" />
{selectedUser.avatar ? (
<img src={selectedUser.avatar} className="w-32 h-32 rounded-full object-cover border-4 border-surface shadow-2xl relative z-10 z-10" alt="Avatar" />
) : (
<div className="w-32 h-32 rounded-full bg-black border-4 border-surface shadow-2xl relative z-10 flex items-center justify-center">
<User className="text-gray-400 w-12 h-12" />
</div>
)}
<h2 className="text-3xl font-bold text-white mt-4 relative z-10">{selectedUser.displayName}</h2>
<p className="text-gray-400 text-lg relative z-10">@{selectedUser.username}</p>
<div className="w-full mt-8 flex flex-col gap-4 relative z-10">
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col gap-1">
<span className="text-xs text-gray-500 uppercase">{t.bio}</span>
<span className="text-white">{selectedUser.bio || '—'}</span>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col gap-1">
<span className="text-xs text-gray-500 uppercase">{t.registeredOn}</span>
<span className="text-white">{formatDateTime(selectedUser.createdAt)}</span>
</div>
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col gap-1">
<span className="text-xs text-gray-500 uppercase">{t.lastSeen}</span>
<span className="text-white">{formatDateTime(selectedUser.lastOnlineAt)}</span>
</div>
</div>
{selectedUser.stats && (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mt-2">
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col gap-1">
<span className="text-xs text-gray-500 uppercase">{t.messagesSent}</span>
<span className="text-white text-xl font-bold">{selectedUser.stats.messagesCount}</span>
</div>
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col gap-1">
<span className="text-xs text-gray-500 uppercase">{t.mediaSent}</span>
<span className="text-white text-xl font-bold">{selectedUser.stats.mediaCount}</span>
</div>
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col gap-1">
<span className="text-xs text-gray-500 uppercase">{t.filesSent}</span>
<span className="text-white text-xl font-bold">{selectedUser.stats.filesCount}</span>
</div>
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col gap-1">
<span className="text-xs text-gray-500 uppercase">{t.linksSent}</span>
<span className="text-white text-xl font-bold">{selectedUser.stats.linksCount}</span>
</div>
<div className="col-span-2 md:col-span-2 bg-gradient-to-br from-accent/10 to-transparent p-4 rounded-xl border border-accent/20 flex flex-col gap-1">
<span className="text-xs text-accent uppercase tracking-widest">{t.userStorageOccupied}</span>
<span className="text-accent text-2xl font-bold">{formatBytes(selectedUser.stats.storageUsedBytes)}</span>
</div>
</div>
)}
</div>
</div>
</motion.div>
)}
</motion.div>
)}
</AnimatePresence>
</main>
</div>
);
}
function NavButton({ icon, label, active, onClick }: { icon: React.ReactNode, label: string, active: boolean, onClick: () => void }) {
return (
<button
onClick={onClick}
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-colors text-left font-medium ${active ? 'bg-accent text-black shadow-lg shadow-accent/20' : 'text-gray-400 hover:text-white hover:bg-white/5'}`}
>
{icon} {label}
</button>
);
}
function Toggle({ checked, onChange }: { checked: boolean, onChange: (v: boolean) => void }) {
return (
<div
className={`w-12 h-6 rounded-full cursor-pointer p-1 transition-colors ${checked ? 'bg-accent' : 'bg-white/10 border border-white/5'}`}
onClick={() => onChange(!checked)}
>
<motion.div
className={`w-4 h-4 rounded-full ${checked ? 'bg-black' : 'bg-gray-400'}`}
animate={{ x: checked ? 24 : 0 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
/>
</div>
);
}

View File

@@ -13,6 +13,8 @@ interface AuthState {
logout: () => void;
checkAuth: () => Promise<void>;
updateUser: (data: Partial<User>) => void;
config: any;
fetchConfig: () => Promise<void>;
}
export const useAuthStore = create<AuthState>((set, get) => ({
@@ -20,6 +22,16 @@ export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
isLoading: true,
error: null,
config: null,
fetchConfig: async () => {
try {
const res = await fetch('/api/config');
if (res.ok) {
set({ config: await res.json() });
}
} catch {}
},
login: async (username, password) => {
try {
@@ -59,6 +71,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
},
checkAuth: async () => {
get().fetchConfig();
const token = get().token;
if (!token) {
set({ isLoading: false });