Шифрование, GIF, хранилище, админка
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -21,7 +21,7 @@ export function connectSocket(token: string): SocketCompat {
|
||||
accessTokenFactory: () => token
|
||||
})
|
||||
.withAutomaticReconnect()
|
||||
.configureLogging(LogLevel.Information)
|
||||
.configureLogging(LogLevel.Error)
|
||||
.build();
|
||||
|
||||
// Обертка для совместимости с Socket.io API
|
||||
|
||||
704
apps/web/src/pages/AdminPage.tsx
Normal file
704
apps/web/src/pages/AdminPage.tsx
Normal 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">
|
||||
← {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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user