Новый стиль

This commit is contained in:
Халимов Рустам
2026-03-31 21:11:05 +03:00
parent 4faa7561a0
commit aa69c44a64
39 changed files with 3812 additions and 735 deletions

View File

@@ -800,7 +800,7 @@ export default function AdminPage() {
}
};
const fetchUserDetails = async (id: string) => {
const fetchUserStats = async (id: string) => {
try {
const res = await httpClient.request<AppUser>(`/admin/users/${id}`);
setSelectedUser(res);
@@ -838,73 +838,112 @@ export default function AdminPage() {
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 className="min-h-screen flex items-center justify-center bg-surface-container-lowest text-on-surface font-sans">
<form onSubmit={handleLogin} className="knot-card p-12 w-full max-w-sm flex flex-col items-center gap-8 border border-outline-variant/10">
<div className="w-16 h-16 rounded-3xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary">
<span className="material-symbols-outlined text-4xl">hub</span>
</div>
<div className="text-center">
<h1 className="text-3xl font-black font-headline tracking-tighter uppercase mb-2">{t.loginTitle}</h1>
<p className="text-[10px] font-black text-on-surface-variant/40 uppercase tracking-[0.3em]">Administrator</p>
</div>
<div className="w-full flex flex-col gap-5">
<div className="knot-input-group">
<label className="block text-[11px] font-black uppercase tracking-[0.2em] text-on-surface-variant/40 mb-3 ml-1">
{lang === 'ru' ? 'ИМЯ ПОЛЬЗОВАТЕЛЯ' : 'USERNAME'}
</label>
<div className="relative">
<span className="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-on-surface-variant/40 text-[20px]">person</span>
<input
type="text"
placeholder="username"
className="w-full pl-12 pr-4 py-4 rounded-2xl bg-surface-container-highest/60 text-on-surface placeholder-on-surface-variant/20 border-none focus:ring-2 focus:ring-primary/20 transition-all outline-none text-[15px]"
value={creds.user}
onChange={(e) => setCreds({ ...creds, user: e.target.value })}
autoComplete="off"
/>
</div>
</div>
<div className="knot-input-group">
<label className="block text-[11px] font-black uppercase tracking-[0.2em] text-on-surface-variant/40 mb-3 ml-1">
{lang === 'ru' ? 'ПАРОЛЬ' : 'PASSWORD'}
</label>
<div className="relative">
<span className="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-on-surface-variant/40 text-[20px]">lock</span>
<input
type="password"
placeholder="••••••••"
className="w-full pl-12 pr-4 py-4 rounded-2xl bg-surface-container-highest/60 text-on-surface placeholder-on-surface-variant/20 border-none focus:ring-2 focus:ring-primary/20 transition-all outline-none text-[15px]"
value={creds.pass}
onChange={(e) => setCreds({ ...creds, pass: e.target.value })}
autoComplete="current-password"
/>
</div>
</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 className="knot-button-primary w-full py-4.5 flex items-center justify-center gap-3 slide-on-ice mt-4">
<span className="font-black uppercase tracking-widest text-[14px]">{t.loginBtn}</span>
<span className="material-symbols-outlined text-[20px]">arrow_forward</span>
</button>
<div className="flex gap-4 p-1.5 bg-surface-container-highest/20 rounded-2xl w-full">
<button type="button" onClick={() => setLang('ru')} className={`flex-1 py-3 text-[11px] font-black rounded-xl transition-all ${lang === 'ru' ? 'bg-primary text-on-primary' : 'text-on-surface-variant/40 hover:text-on-surface'}`}>RU</button>
<button type="button" onClick={() => setLang('en')} className={`flex-1 py-3 text-[11px] font-black rounded-xl transition-all ${lang === 'en' ? 'bg-primary text-on-primary' : 'text-on-surface-variant/40 hover:text-on-surface'}`}>EN</button>
</div>
</form>
</div>
);
}
return (
<div className="h-[100dvh] w-full bg-black text-white font-sans flex overflow-hidden">
<div className="h-full flex overflow-hidden bg-surface-container-lowest text-on-surface font-sans selection:bg-primary/30">
{/* Sidebar */}
<aside className="w-72 border-r border-white/10 bg-surface/50 p-6 flex flex-col gap-2 shrink-0">
<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 Control
</h2>
<aside className="w-[280px] flex-shrink-0 bg-surface-container-low border-r border-outline-variant/10 flex flex-col slide-on-ice z-20">
<div className="p-8 pb-4 flex items-center gap-4">
<div className="w-10 h-10 rounded-2xl bg-primary/10 border border-primary/20 flex items-center justify-center">
<span className="material-symbols-outlined text-primary text-[24px]">hub</span>
</div>
<h2 className="text-xl font-black font-headline tracking-tighter">Knot</h2>
</div>
<NavButton icon={<Activity />} label={t.dashboard} active={activeTab === 'dashboard'} onClick={() => setActiveTab('dashboard')} />
<NavButton icon={<Users />} label={t.userAnalytics} active={activeTab === 'users'} onClick={() => { setActiveTab('users'); setSelectedUser(null); }} />
<nav className="flex-1 overflow-y-auto px-4 py-6 space-y-1 custom-scrollbar">
<div className="px-4 mb-4 text-[10px] font-black uppercase tracking-[0.2em] text-on-surface-variant/30">
{t.dashboard}
</div>
<NavButton icon="dashboard" label={t.dashboard} active={activeTab === 'dashboard'} onClick={() => setActiveTab('dashboard')} />
<NavButton icon="group" label={t.userAnalytics} active={activeTab === 'users'} onClick={() => { setActiveTab('users'); setSelectedUser(null); }} />
<div className="mt-6 mb-2 text-[10px] uppercase tracking-widest text-gray-500 font-bold px-4">{t.settingsGroup}</div>
<NavButton icon={<Settings />} label={t.system} active={activeTab === 'settings' && activeSettingsTab === 'system'} onClick={() => { setActiveTab('settings'); setActiveSettingsTab('system'); }} />
<NavButton icon={<Activity />} label={t.stories} active={activeTab === 'settings' && activeSettingsTab === 'stories'} onClick={() => { setActiveTab('settings'); setActiveSettingsTab('stories'); }} />
<NavButton icon={<Users />} label={t.chats} active={activeTab === 'settings' && activeSettingsTab === 'chats'} onClick={() => { setActiveTab('settings'); setActiveSettingsTab('chats'); }} />
<NavButton icon={<Activity />} label={t.messages} active={activeTab === 'settings' && activeSettingsTab === 'messages'} onClick={() => { setActiveTab('settings'); setActiveSettingsTab('messages'); }} />
<NavButton icon={<Globe />} label={t.webRtc} active={activeTab === 'settings' && activeSettingsTab === 'webRtc'} onClick={() => { setActiveTab('settings'); setActiveSettingsTab('webRtc'); }} />
<NavButton icon={<Database />} label={t.klipy} active={activeTab === 'settings' && activeSettingsTab === 'klipy'} onClick={() => { setActiveTab('settings'); setActiveSettingsTab('klipy'); }} />
<NavButton icon={<Download />} label={t.import} active={activeTab === 'settings' && activeSettingsTab === 'import'} onClick={() => { setActiveTab('settings'); setActiveSettingsTab('import'); }} />
<NavButton icon={<Globe />} label={t.federation} active={activeTab === 'settings' && activeSettingsTab === 'federation'} onClick={() => { setActiveTab('settings'); setActiveSettingsTab('federation'); }} />
<div className="px-4 mt-8 mb-4 text-[10px] font-black uppercase tracking-[0.2em] text-on-surface-variant/30">
{t.settingsGroup}
</div>
<NavButton icon="settings" label={t.system} active={activeTab === 'settings' && activeSettingsTab === 'system'} onClick={() => { setActiveTab('settings'); setActiveSettingsTab('system'); }} />
<NavButton icon="auto_stories" label={t.stories} active={activeTab === 'settings' && activeSettingsTab === 'stories'} onClick={() => { setActiveTab('settings'); setActiveSettingsTab('stories'); }} />
<NavButton icon="chat_bubble" label={t.chats} active={activeTab === 'settings' && activeSettingsTab === 'chats'} onClick={() => { setActiveTab('settings'); setActiveSettingsTab('chats'); }} />
<NavButton icon="message" label={t.messages} active={activeTab === 'settings' && activeSettingsTab === 'messages'} onClick={() => { setActiveTab('settings'); setActiveSettingsTab('messages'); }} />
<NavButton icon="call" label={t.webRtc} active={activeTab === 'settings' && activeSettingsTab === 'webRtc'} onClick={() => { setActiveTab('settings'); setActiveSettingsTab('webRtc'); }} />
<NavButton icon="gif_box" label={t.klipy} active={activeTab === 'settings' && activeSettingsTab === 'klipy'} onClick={() => { setActiveTab('settings'); setActiveSettingsTab('klipy'); }} />
<NavButton icon="download" label={t.importTitle} active={activeTab === 'settings' && activeSettingsTab === 'import'} onClick={() => { setActiveTab('settings'); setActiveSettingsTab('import'); }} />
<NavButton icon="language" label={t.federation} active={activeTab === 'settings' && activeSettingsTab === 'federation'} onClick={() => { setActiveTab('settings'); setActiveSettingsTab('federation'); }} />
</nav>
<div className="mt-auto pt-6 border-t border-white/5">
<div className="p-4 border-t border-outline-variant/10">
<button
onClick={() => setLang(lang === 'ru' ? 'en' : 'ru')}
className="w-full flex items-center justify-between p-3 rounded-xl bg-white/5 hover:bg-white/10 transition-all border border-white/5 group"
className="w-full flex items-center justify-between p-3 rounded-2xl bg-surface-container-highest/10 hover:bg-surface-container-highest/20 transition-all border border-outline-variant/5 group slide-on-ice"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center text-xs font-bold text-accent">
<div className="w-8 h-8 rounded-xl bg-primary/10 flex items-center justify-center text-[10px] font-black text-primary">
{lang.toUpperCase()}
</div>
<span className="text-sm font-medium text-gray-300 group-hover:text-white transition-colors">
<span className="text-[13px] font-bold text-on-surface-variant group-hover:text-on-surface transition-colors">
{lang === 'ru' ? 'Русский' : 'English'}
</span>
</div>
<Globe className="w-4 h-4 text-gray-600 group-hover:text-accent transition-colors" />
<span className="material-symbols-outlined text-on-surface-variant/20 group-hover:text-primary transition-colors text-[18px]">language</span>
</button>
</div>
</aside>
@@ -915,16 +954,16 @@ export default function AdminPage() {
{activeTab === 'dashboard' && stats && (
<motion.div key="dashboard" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} className="flex flex-col gap-6 max-w-5xl">
<h1 className="text-3xl font-bold">{t.dashboard}</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="bg-surface border border-white/10 p-8 rounded-3xl lg:col-span-2 relative overflow-hidden group">
<div className="flex justify-between items-end mb-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div className="knot-card p-10 lg:col-span-2 relative overflow-hidden group border border-outline-variant/10">
<div className="flex justify-between items-end mb-8">
<div>
<h3 className="text-gray-400 text-[10px] mb-1 flex items-center gap-2 uppercase tracking-[0.2em] font-black">
<Database className="w-4 h-4 text-accent" /> {t.storageUsed}
<h3 className="text-on-surface-variant/40 text-[10px] mb-2 flex items-center gap-3 uppercase tracking-[0.3em] font-black">
<span className="material-symbols-outlined text-primary text-sm">database</span> {t.storageUsed}
</h3>
<div className="text-4xl font-black flex items-baseline gap-2">
<div className="text-5xl font-black font-headline tracking-tighter flex items-baseline gap-3">
{formatBytes(stats.storageUsedBytes)}
<span className="text-sm font-medium text-gray-500">/ {formatBytes(stats.storageLimitBytes)}</span>
<span className="text-sm font-medium text-on-surface-variant/20 tracking-normal">/ {formatBytes(stats.storageLimitBytes)}</span>
</div>
</div>
<div className="text-right">
@@ -989,31 +1028,32 @@ export default function AdminPage() {
{String(t[activeSettingsTab as keyof typeof t] || activeSettingsTab)}
</h1>
<div className="ml-auto flex gap-2">
<button onClick={fetchSettings} className="p-2.5 hover:bg-white/5 rounded-xl text-gray-400 transition-all active:scale-95">
<Activity className="w-5 h-5" />
<button onClick={fetchSettings} className="p-3 hover:bg-surface-container-highest/10 rounded-2xl text-on-surface-variant/60 hover:text-on-surface transition-all active:scale-95 slide-on-ice">
<span className="material-symbols-outlined text-[20px] font-black">sync</span>
</button>
<button onClick={saveSettings} className="bg-accent hover:bg-accentLight text-black font-bold px-8 py-2.5 rounded-xl transition-all shadow-lg shadow-accent/20 active:scale-95">
{t.saveChanges}
<button onClick={saveSettings} className="knot-button-primary px-10 py-3 rounded-2xl flex items-center gap-3 slide-on-ice">
<span className="text-[11px] font-black uppercase tracking-widest">{t.saveChanges}</span>
<span className="material-symbols-outlined text-[18px]">save</span>
</button>
</div>
</div>
{/* System Settings */}
{activeSettingsTab === 'system' && (
<div className="bg-surface border border-white/10 rounded-2xl p-8 flex flex-col gap-8 shadow-xl shadow-black/40">
<div className="knot-card p-10 flex flex-col gap-8 border border-outline-variant/10">
<h3 className="text-xl font-semibold opacity-30 uppercase tracking-widest text-sm">{t.system}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-8">
<label className="flex flex-col gap-1.5 group">
<span className="text-xs font-bold text-gray-500 uppercase ml-1 group-focus-within:text-accent transition-colors">{t.domainUrl}</span>
<input className="bg-black/40 border border-white/10 rounded-xl px-4 py-3.5 outline-none text-white focus:border-accent/50 focus:bg-black/60 transition-all font-mono text-sm" value={config.system.domainUrl} onChange={e => setConfig({ ...config, system: { ...config.system, domainUrl: e.target.value } })} />
<span className="text-[11px] font-black uppercase tracking-widest text-on-surface-variant/40 ml-1 group-focus-within:text-accent transition-colors">{t.domainUrl}</span>
<input className="knot-input-group" value={config.system.domainUrl} onChange={e => setConfig({ ...config, system: { ...config.system, domainUrl: e.target.value } })} />
<Hint>{t.hints.domainUrl}</Hint>
</label>
<label className="flex flex-col gap-1.5 group">
<span className="text-xs font-bold text-gray-500 uppercase ml-1 group-focus-within:text-accent transition-colors">{t.adminPublicPath}</span>
<input className="bg-black/40 border border-white/10 rounded-xl px-4 py-3.5 outline-none text-white focus:border-accent/50 focus:bg-black/60 transition-all font-mono text-sm" value={config.system.adminRoute} onChange={e => setConfig({ ...config, system: { ...config.system, adminRoute: e.target.value } })} placeholder="/admin" />
<span className="text-[11px] font-black uppercase tracking-widest text-on-surface-variant/40 ml-1 group-focus-within:text-accent transition-colors">{t.adminPublicPath}</span>
<input className="knot-input-group" value={config.system.adminRoute} onChange={e => setConfig({ ...config, system: { ...config.system, adminRoute: e.target.value } })} placeholder="/admin" />
<Hint>{t.hints.adminRoute}</Hint>
</label>
<div className="flex flex-col gap-2 bg-white/[0.02] p-4 rounded-2xl border border-white/5">
<div className="bg-surface-container-highest/20 p-6 rounded-3xl border border-outline-variant/10 flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-200 font-semibold">{t.enableReg}</span>
<Toggle checked={config.system.enableRegistration} onChange={v => setConfig({ ...config, system: { ...config.system, enableRegistration: v } })} />
@@ -1021,10 +1061,10 @@ export default function AdminPage() {
<Hint>{t.hints.enableRegistration}</Hint>
</div>
<div className="flex flex-col gap-1.5 group relative">
<span className="text-xs font-bold text-gray-500 uppercase ml-1 group-focus-within:text-accent transition-colors">{t.timezone}</span>
<span className="text-[11px] font-black uppercase tracking-widest text-on-surface-variant/40 ml-1 group-focus-within:text-primary transition-colors">{t.timezone}</span>
<div className="relative">
<input
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3.5 outline-none text-white focus:border-accent/50 focus:bg-black/60 transition-all font-mono text-xs pr-10"
className="w-full knot-input-group transition-all font-mono text-xs pr-10"
value={config.system.serverTimezone}
onFocus={() => setShowTzDropdown(true)}
onChange={e => {
@@ -1033,7 +1073,7 @@ export default function AdminPage() {
}}
placeholder="UTC"
/>
<Globe className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<span className="material-symbols-outlined absolute right-4 top-1/2 -translate-y-1/2 text-on-surface-variant/30">public</span>
<AnimatePresence>
{showTzDropdown && (
@@ -1041,7 +1081,7 @@ export default function AdminPage() {
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute left-0 right-0 top-full mt-2 bg-surface border border-white/10 rounded-xl shadow-2xl z-[100] max-h-60 overflow-y-auto overflow-x-hidden p-1"
className="absolute left-0 right-0 top-full mt-2 bg-surface-container-high border border-outline-variant/10 rounded-3xl shadow-[0_16px_48px_rgba(0,0,0,0.6)] z-[100] max-h-64 overflow-y-auto custom-scrollbar p-2"
>
{timezones
.filter(tz => tz.displayName.toLowerCase().includes(tzSearch.toLowerCase()) || tz.id.toLowerCase().includes(tzSearch.toLowerCase()))
@@ -1053,10 +1093,10 @@ export default function AdminPage() {
setTzSearch('');
setShowTzDropdown(false);
}}
className="w-full text-left p-3 hover:bg-white/5 rounded-lg transition-colors flex flex-col group/tz"
className="w-full text-left p-4 hover:bg-primary/10 rounded-2xl transition-all flex flex-col gap-1 group"
>
<div className="flex justify-between items-center w-full">
<span className="text-xs font-bold text-white group-hover/tz:text-accent truncate">{tz.displayName}</span>
<span className="text-[13px] font-bold text-on-surface group-hover:text-primary transition-colors">{tz.displayName}</span>
<span className="text-[10px] font-mono text-gray-500 group-hover/tz:text-accent/50 ml-2 shrink-0">{tz.offsetString}</span>
</div>
<span className="text-[10px] text-gray-600 truncate">{tz.id}</span>
@@ -1076,7 +1116,7 @@ export default function AdminPage() {
{/* Stories Settings */}
{activeSettingsTab === 'stories' && (
<div className="bg-surface border border-white/10 rounded-2xl p-8 flex flex-col gap-8">
<div className="knot-card p-10 flex flex-col gap-8 border border-outline-variant/10">
<div className="flex items-center justify-between">
<h3 className="text-xl font-semibold opacity-30 uppercase tracking-widest text-sm">{t.stories}</h3>
<Toggle checked={config.stories.enabled} onChange={v => setConfig({ ...config, stories: { ...config.stories, enabled: v } })} />
@@ -1084,16 +1124,16 @@ export default function AdminPage() {
{config.stories.enabled && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-10 pt-4 border-t border-white/10">
<label className="flex flex-col gap-1.5 group">
<span className="text-xs font-bold text-gray-500 uppercase ml-1">{t.maxStories}</span>
<input type="number" className="bg-black/40 border border-white/10 rounded-xl px-4 py-3.5 outline-none text-white focus:border-accent/50 transition-all" value={config.stories.maxStoriesPerPeriod} onChange={e => setConfig({ ...config, stories: { ...config.stories, maxStoriesPerPeriod: parseInt(e.target.value) || 0 } })} />
<span className="text-[11px] font-black uppercase tracking-widest text-on-surface-variant/40 ml-1">{t.maxStories}</span>
<input type="number" className="knot-input-group" value={config.stories.maxStoriesPerPeriod} onChange={e => setConfig({ ...config, stories: { ...config.stories, maxStoriesPerPeriod: parseInt(e.target.value) || 0 } })} />
<Hint>{t.hints.maxStories}</Hint>
</label>
<label className="flex flex-col gap-1.5 group">
<span className="text-xs font-bold text-gray-500 uppercase ml-1">{t.storyLifetime}</span>
<input type="number" className="bg-black/40 border border-white/10 rounded-xl px-4 py-3.5 outline-none text-white focus:border-accent/50 transition-all" value={config.stories.storyLifetimeHours} onChange={e => setConfig({ ...config, stories: { ...config.stories, storyLifetimeHours: parseInt(e.target.value) || 0 } })} />
<span className="text-[11px] font-black uppercase tracking-widest text-on-surface-variant/40 ml-1">{t.storyLifetime}</span>
<input type="number" className="knot-input-group" value={config.stories.storyLifetimeHours} onChange={e => setConfig({ ...config, stories: { ...config.stories, storyLifetimeHours: parseInt(e.target.value) || 0 } })} />
<Hint>{t.hints.storyLifetime}</Hint>
</label>
<div className="flex flex-col gap-2 bg-white/[0.02] p-4 rounded-2xl border border-white/5">
<div className="bg-surface-container-highest/20 p-6 rounded-3xl border border-outline-variant/10 flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-200 font-semibold">{t.textStories}</span>
<Toggle checked={config.stories.textStoriesEnabled} onChange={v => setConfig({ ...config, stories: { ...config.stories, textStoriesEnabled: v } })} />
@@ -1101,13 +1141,13 @@ export default function AdminPage() {
<Hint>{t.hints.textStoryDuration}</Hint>
</div>
<label className="flex flex-col gap-1.5 group">
<span className="text-xs font-bold text-gray-500 uppercase ml-1">{t.mediaStoryDuration}</span>
<input type="number" className="bg-black/40 border border-white/10 rounded-xl px-4 py-3.5 outline-none text-white focus:border-accent/50 transition-all" value={config.stories.mediaStoryMaxDurationSeconds} onChange={e => setConfig({ ...config, stories: { ...config.stories, mediaStoryMaxDurationSeconds: parseInt(e.target.value) || 0 } })} />
<span className="text-[11px] font-black uppercase tracking-widest text-on-surface-variant/40 ml-1">{t.mediaStoryDuration}</span>
<input type="number" className="knot-input-group" value={config.stories.mediaStoryMaxDurationSeconds} onChange={e => setConfig({ ...config, stories: { ...config.stories, mediaStoryMaxDurationSeconds: parseInt(e.target.value) || 0 } })} />
<Hint>{t.hints.mediaStoryDuration}</Hint>
</label>
<label className="flex flex-col gap-1.5 group">
<span className="text-xs font-bold text-gray-500 uppercase ml-1">{t.maxMediaSize}</span>
<input type="number" className="bg-black/40 border border-white/10 rounded-xl px-4 py-3.5 outline-none text-white focus:border-accent/50 transition-all" value={bytesToMb(config.stories.maxMediaSizeBytes)} onChange={e => setConfig({ ...config, stories: { ...config.stories, maxMediaSizeBytes: mbToBytes(parseInt(e.target.value) || 0) } })} />
<span className="text-[11px] font-black uppercase tracking-widest text-on-surface-variant/40 ml-1">{t.maxMediaSize}</span>
<input type="number" className="knot-input-group" value={bytesToMb(config.stories.maxMediaSizeBytes)} onChange={e => setConfig({ ...config, stories: { ...config.stories, maxMediaSizeBytes: mbToBytes(parseInt(e.target.value) || 0) } })} />
<Hint>{t.hints.maxMediaSize}</Hint>
</label>
</div>
@@ -1117,11 +1157,11 @@ export default function AdminPage() {
{/* Chats Settings */}
{activeSettingsTab === 'chats' && (
<div className="bg-surface border border-white/10 rounded-2xl p-8 flex flex-col gap-8">
<div className="knot-card p-10 flex flex-col gap-8 border border-outline-variant/10">
<h3 className="text-xl font-semibold opacity-30 uppercase tracking-widest text-sm">{t.chats}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-2 bg-white/[0.02] p-4 rounded-2xl border border-white/5">
<div className="bg-surface-container-highest/20 p-6 rounded-3xl border border-outline-variant/10 flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-200 font-semibold">{t.supportGroups}</span>
<Toggle checked={config.chats.supportGroups} onChange={v => setConfig({ ...config, chats: { ...config.chats, supportGroups: v } })} />
@@ -1129,27 +1169,27 @@ export default function AdminPage() {
<Hint>{t.hints.supportGroups}</Hint>
</div>
<label className="flex flex-col gap-1.5 group">
<span className="text-xs font-bold text-gray-500 uppercase ml-1">{t.maxGroupMembers}</span>
<input type="number" className="bg-black/40 border border-white/10 rounded-xl px-4 py-3.5 outline-none text-white focus:border-accent/50 transition-all" value={config.chats.maxGroupParticipants} onChange={e => setConfig({ ...config, chats: { ...config.chats, maxGroupParticipants: parseInt(e.target.value) || 0 } })} />
<span className="text-[11px] font-black uppercase tracking-widest text-on-surface-variant/40 ml-1">{t.maxGroupMembers}</span>
<input type="number" className="knot-input-group" value={config.chats.maxGroupParticipants} onChange={e => setConfig({ ...config, chats: { ...config.chats, maxGroupParticipants: parseInt(e.target.value) || 0 } })} />
<Hint>{t.hints.maxGroupMembers}</Hint>
</label>
</div>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2 bg-white/[0.02] p-4 rounded-2xl border border-white/5">
<div className="bg-surface-container-highest/20 p-6 rounded-3xl border border-outline-variant/10 flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-200 font-semibold">{t.autoClean}</span>
<Toggle checked={config.chats.enableAutoClean} onChange={v => setConfig({ ...config, chats: { ...config.chats, enableAutoClean: v } })} />
</div>
<Hint>{t.hints.autoClean}</Hint>
</div>
<div className="flex flex-col gap-2 bg-white/[0.02] p-4 rounded-2xl border border-white/5">
<div className="bg-surface-container-highest/20 p-6 rounded-3xl border border-outline-variant/10 flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-200 font-semibold">{t.chatToGroup}</span>
<Toggle checked={config.chats.allowChatToGroupConversion} onChange={v => setConfig({ ...config, chats: { ...config.chats, allowChatToGroupConversion: v } })} />
</div>
<Hint>{t.hints.chatToGroup}</Hint>
</div>
<div className="flex flex-col gap-2 bg-white/[0.02] p-4 rounded-2xl border border-white/5">
<div className="bg-surface-container-highest/20 p-6 rounded-3xl border border-outline-variant/10 flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-200 font-semibold">{t.enableFolders}</span>
<Toggle checked={config.chats.enableFolders} onChange={v => setConfig({ ...config, chats: { ...config.chats, enableFolders: v } })} />
@@ -1163,23 +1203,23 @@ export default function AdminPage() {
{/* Messages Settings */}
{activeSettingsTab === 'messages' && (
<div className="bg-surface border border-white/10 rounded-2xl p-8 flex flex-col gap-8">
<div className="knot-card p-10 flex flex-col gap-8 border border-outline-variant/10">
<h3 className="text-xl font-semibold opacity-30 uppercase tracking-widest text-sm">{t.messages}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-16 gap-y-10">
<div className="flex flex-col gap-8">
<label className="flex flex-col gap-1.5 group">
<span className="text-xs font-bold text-gray-500 uppercase ml-1">{t.dailyLimit}</span>
<input type="number" className="bg-black/40 border border-white/10 rounded-xl px-4 py-3.5 outline-none text-white focus:border-accent/50 transition-all font-mono" value={config.messages.dailyMessageLimitPerUser} onChange={e => setConfig({ ...config, messages: { ...config.messages, dailyMessageLimitPerUser: parseInt(e.target.value) || 0 } })} />
<span className="text-[11px] font-black uppercase tracking-widest text-on-surface-variant/40 ml-1">{t.dailyLimit}</span>
<input type="number" className="knot-input-group font-mono" value={config.messages.dailyMessageLimitPerUser} onChange={e => setConfig({ ...config, messages: { ...config.messages, dailyMessageLimitPerUser: parseInt(e.target.value) || 0 } })} />
<Hint>{t.hints.dailyLimit}</Hint>
</label>
<label className="flex flex-col gap-1.5 group">
<span className="text-xs font-bold text-gray-500 uppercase ml-1">{t.chatLimit}</span>
<input type="number" className="bg-black/40 border border-white/10 rounded-xl px-4 py-3.5 outline-none text-white focus:border-accent/50 transition-all font-mono" value={config.messages.chatMessageLimit} onChange={e => setConfig({ ...config, messages: { ...config.messages, chatMessageLimit: parseInt(e.target.value) || 0 } })} />
<span className="text-[11px] font-black uppercase tracking-widest text-on-surface-variant/40 ml-1">{t.chatLimit}</span>
<input type="number" className="knot-input-group font-mono" value={config.messages.chatMessageLimit} onChange={e => setConfig({ ...config, messages: { ...config.messages, chatMessageLimit: parseInt(e.target.value) || 0 } })} />
<Hint>{t.hints.chatLimit}</Hint>
</label>
<label className="flex flex-col gap-1.5 group">
<span className="text-xs font-bold text-gray-500 uppercase ml-1">{t.maxFileSize}</span>
<input type="number" className="bg-black/40 border border-white/10 rounded-xl px-4 py-3.5 outline-none text-white focus:border-accent/50 transition-all font-mono" value={bytesToMb(config.messages.maxFileSize)} onChange={e => setConfig({ ...config, messages: { ...config.messages, maxFileSize: mbToBytes(parseInt(e.target.value) || 0) } })} />
<span className="text-[11px] font-black uppercase tracking-widest text-on-surface-variant/40 ml-1">{t.maxFileSize}</span>
<input type="number" className="knot-input-group font-mono" value={bytesToMb(config.messages.maxFileSize)} onChange={e => setConfig({ ...config, messages: { ...config.messages, maxFileSize: mbToBytes(parseInt(e.target.value) || 0) } })} />
<Hint>{t.hints.maxFileSize}</Hint>
</label>
</div>
@@ -1213,7 +1253,7 @@ export default function AdminPage() {
{/* WebRTC Settings */}
{activeSettingsTab === 'webRtc' && (
<div className="bg-surface border border-white/10 rounded-2xl p-8 flex flex-col gap-8 shadow-xl shadow-accent/5">
<div className="knot-card p-10 flex flex-col gap-8 border border-outline-variant/10 shadow-xl shadow-accent/5">
<div className="flex items-center justify-between">
<h3 className="text-xl font-bold text-accent uppercase tracking-tighter flex items-center gap-2">
<Globe className="w-5 h-5" /> {t.webRtc}
@@ -1250,22 +1290,22 @@ export default function AdminPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<label className="flex flex-col gap-1.5 group">
<span className="text-[10px] font-black uppercase text-gray-500 ml-1 group-focus-within:text-accent transition-colors">{t.turnHost}</span>
<input className="bg-black/40 border border-white/10 rounded-xl px-4 py-3.5 outline-none text-white focus:border-accent font-mono text-xs" value={config.webRtc.turnHost} onChange={e => setConfig({ ...config, webRtc: { ...config.webRtc, turnHost: e.target.value } })} placeholder="turn.example.com" />
<input className="knot-input-group" value={config.webRtc.turnHost} onChange={e => setConfig({ ...config, webRtc: { ...config.webRtc, turnHost: e.target.value } })} placeholder="turn.example.com" />
<Hint>{t.hints.turnHost}</Hint>
</label>
<label className="flex flex-col gap-1.5 group">
<span className="text-[10px] font-black uppercase text-gray-500 ml-1 group-focus-within:text-accent transition-colors">{t.turnPort}</span>
<input type="number" className="bg-black/40 border border-white/10 rounded-xl px-4 py-3.5 outline-none text-white focus:border-accent font-mono text-xs" value={config.webRtc.turnPort} onChange={e => setConfig({ ...config, webRtc: { ...config.webRtc, turnPort: parseInt(e.target.value) || 0 } })} />
<input type="number" className="knot-input-group" value={config.webRtc.turnPort} onChange={e => setConfig({ ...config, webRtc: { ...config.webRtc, turnPort: parseInt(e.target.value) || 0 } })} />
<Hint>{t.hints.turnPort}</Hint>
</label>
<label className="flex flex-col gap-1.5 group">
<span className="text-[10px] font-black uppercase text-gray-500 ml-1 group-focus-within:text-accent transition-colors">{t.turnUser}</span>
<input className="bg-black/40 border border-white/10 rounded-xl px-4 py-3.5 outline-none text-white focus:border-accent font-mono text-xs" value={config.webRtc.turnUser} onChange={e => setConfig({ ...config, webRtc: { ...config.webRtc, turnUser: e.target.value } })} />
<input className="knot-input-group" value={config.webRtc.turnUser} onChange={e => setConfig({ ...config, webRtc: { ...config.webRtc, turnUser: e.target.value } })} />
<Hint>{t.hints.turnUser}</Hint>
</label>
<label className="flex flex-col gap-1.5 group">
<span className="text-[10px] font-black uppercase text-gray-500 ml-1 group-focus-within:text-accent transition-colors">{t.turnSecret}</span>
<input type="password" name="turnSecret" className="bg-black/40 border border-white/10 rounded-xl px-4 py-3.5 outline-none text-white focus:border-accent font-mono text-xs" value={config.webRtc.turnSecret} onChange={e => setConfig({ ...config, webRtc: { ...config.webRtc, turnSecret: e.target.value } })} />
<input type="password" name="turnSecret" className="knot-input-group" value={config.webRtc.turnSecret} onChange={e => setConfig({ ...config, webRtc: { ...config.webRtc, turnSecret: e.target.value } })} />
<Hint>{t.hints.turnSecret}</Hint>
</label>
</div>
@@ -1280,7 +1320,7 @@ export default function AdminPage() {
{/* Klipy Settings */}
{activeSettingsTab === 'klipy' && (
<div className="bg-surface border border-white/10 rounded-2xl p-8 flex flex-col gap-8 shadow-xl shadow-purple-500/5">
<div className="knot-card p-10 flex flex-col gap-8 border border-outline-variant/10 shadow-xl shadow-purple-500/5">
<div className="flex items-center justify-between">
<h3 className="text-xl font-bold flex items-center gap-3">
<div className="w-6 h-6 bg-purple-500 rounded-md" /> {t.klipy}
@@ -1292,18 +1332,18 @@ export default function AdminPage() {
<div className="flex flex-col gap-8 pt-4 border-t border-white/10">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<label className="flex flex-col gap-1.5 group">
<span className="text-xs font-bold text-gray-500 uppercase ml-1 group-focus-within:text-purple-400 transition-colors">{t.appName}</span>
<input className="bg-black/40 border border-white/10 rounded-xl px-4 py-3.5 outline-none text-white focus:border-purple-500/50 transition-all font-mono text-sm" value={config.klipy.appName} onChange={e => setConfig({ ...config, klipy: { ...config.klipy, appName: e.target.value } })} />
<span className="text-[11px] font-black uppercase tracking-widest text-on-surface-variant/40 ml-1 group-focus-within:text-purple-400 transition-colors">{t.appName}</span>
<input className="knot-input-group" value={config.klipy.appName} onChange={e => setConfig({ ...config, klipy: { ...config.klipy, appName: e.target.value } })} />
<Hint>{t.hints.appName}</Hint>
</label>
<label className="flex flex-col gap-1.5 group">
<span className="text-xs font-bold text-gray-500 uppercase ml-1 group-focus-within:text-purple-400 transition-colors">{t.apiKey}</span>
<input className="bg-black/40 border border-white/10 rounded-xl px-4 py-3.5 outline-none text-white focus:border-purple-500/50 transition-all font-mono text-sm" value={config.klipy.apiKey} onChange={e => setConfig({ ...config, klipy: { ...config.klipy, apiKey: e.target.value } })} type="password" />
<span className="text-[11px] font-black uppercase tracking-widest text-on-surface-variant/40 ml-1 group-focus-within:text-purple-400 transition-colors">{t.apiKey}</span>
<input className="knot-input-group" value={config.klipy.apiKey} onChange={e => setConfig({ ...config, klipy: { ...config.klipy, apiKey: e.target.value } })} type="password" />
<Hint>{t.hints.apiKey}</Hint>
</label>
</div>
<button onClick={handleTestKlipy} disabled={isTestingKlipy} className="bg-purple-500/10 hover:bg-purple-500/20 text-purple-400 font-bold px-8 py-3.5 rounded-xl flex items-center justify-center gap-3 transition-all w-full md:w-max">
{isTestingKlipy ? <Loader2 className="w-5 h-5 animate-spin" /> : <Activity className="w-5 h-5" />}
<button onClick={handleTestKlipy} disabled={isTestingKlipy} className="bg-primary/10 hover:bg-primary/20 text-primary font-black px-8 py-3.5 rounded-2xl flex items-center justify-center gap-3 transition-all w-full md:w-max uppercase text-[10px] tracking-widest">
{isTestingKlipy ? <span className="material-symbols-outlined animate-spin">refresh</span> : <span className="material-symbols-outlined">network_check</span>}
{t.testConnection}
</button>
</div>
@@ -1313,21 +1353,21 @@ export default function AdminPage() {
{/* Import Settings */}
{activeSettingsTab === 'import' && (
<div className="bg-surface border border-white/10 rounded-2xl p-8 flex flex-col gap-8 shadow-xl shadow-blue-500/5">
<div className="knot-card p-10 flex flex-col gap-8 border border-outline-variant/10 shadow-xl shadow-blue-500/5">
<div className="flex items-center justify-between">
<h3 className="text-xl font-bold flex items-center gap-3">
<Download className="w-6 h-6 text-blue-400" /> {t.importTitle}
<span className="material-symbols-outlined text-primary text-2xl">download</span> {t.importTitle}
</h3>
</div>
<Hint>{t.hints.import}</Hint>
<div className="flex flex-col gap-6 pt-4 border-t border-white/10">
<div className="bg-white/[0.03] p-5 rounded-2xl border border-white/5 flex flex-col gap-4">
<div className="bg-surface-container-highest/10 p-6 rounded-3xl border border-outline-variant/10 flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-gray-200">{t.telegramImport}</span>
<span className="text-sm font-black uppercase tracking-widest text-on-surface-variant/70">{t.telegramImport}</span>
<Toggle checked={config.import.enableTelegramImport} onChange={v => setConfig({ ...config, import: { ...config.import, enableTelegramImport: v } })} />
</div>
<Hint>{t.hints.import}</Hint>
<p className="text-xs text-gray-500 italic mt-2">
<p className="text-[11px] text-on-surface-variant/30 leading-relaxed italic mt-2">
{t.importDescription}
</p>
</div>
@@ -1337,7 +1377,7 @@ export default function AdminPage() {
{/* Federation Settings */}
{activeSettingsTab === 'federation' && (
<div className="bg-surface border border-white/10 rounded-2xl p-8 flex flex-col gap-8">
<div className="knot-card p-10 flex flex-col gap-8 border border-outline-variant/10">
<div className="flex items-center justify-between">
<h3 className="text-xl font-semibold opacity-30 uppercase tracking-widest text-sm">{t.federation}</h3>
<Toggle checked={config.federation.enabled} onChange={v => setConfig({ ...config, federation: { ...config.federation, enabled: v } })} />
@@ -1346,28 +1386,30 @@ export default function AdminPage() {
{config.federation.enabled && (
<div className="flex flex-col gap-6 pt-4 border-t border-white/10">
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-400 font-medium">{t.allowedNodes}</span>
<span className="text-[10px] font-black uppercase tracking-widest text-on-surface-variant/40 ml-1">{t.allowedNodes}</span>
<div className="flex gap-2">
<input type="file" id="jsonIn" className="hidden" accept=".json" onChange={importDomains} />
<label htmlFor="jsonIn" className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white/5 hover:bg-white/10 transition-colors text-[10px] uppercase font-bold cursor-pointer border border-white/5">
<Upload className="w-3.5 h-3.5" /> {t.importJson}
<label htmlFor="jsonIn" className="flex items-center gap-2 px-4 py-2 rounded-xl bg-surface-container-highest/10 hover:bg-surface-container-highest/20 transition-all text-[10px] uppercase font-black tracking-widest cursor-pointer border border-outline-variant/10">
<span className="material-symbols-outlined text-sm">upload</span> {t.importJson}
</label>
<button onClick={exportDomains} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white/5 hover:bg-white/10 transition-colors text-[10px] uppercase font-bold border border-white/5">
<Download className="w-3.5 h-3.5" /> {t.exportJson}
<button onClick={exportDomains} className="flex items-center gap-2 px-4 py-2 rounded-xl bg-surface-container-highest/10 hover:bg-surface-container-highest/20 transition-all text-[10px] uppercase font-black tracking-widest border border-outline-variant/10">
<span className="material-symbols-outlined text-sm">download</span> {t.exportJson}
</button>
</div>
</div>
<div className="flex gap-2">
<input className="flex-1 bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent text-sm font-mono" value={domainInput} onChange={e => setDomainInput(e.target.value)} placeholder="domain.tld" />
<button onClick={addDomain} className="bg-accent/10 hover:bg-accent/20 text-accent px-6 rounded-xl transition-colors font-bold text-sm">{t.addNode}</button>
<div className="knot-input-group flex-1">
<input value={domainInput} onChange={e => setDomainInput(e.target.value)} placeholder="domain.tld" />
</div>
<button onClick={addDomain} className="bg-primary/10 hover:bg-primary/20 text-primary px-8 rounded-2xl font-black text-[11px] uppercase tracking-widest transition-all">{t.addNode}</button>
</div>
<div className="flex flex-col gap-2 max-h-60 overflow-y-auto pr-1">
{config.federation.allowedDomains.map(d => (
<div key={d.domain} className="flex items-center justify-between p-4 rounded-xl bg-black/30 border border-white/5 group hover:border-white/10 transition-all">
<span className="text-sm font-mono text-gray-300">{d.domain}</span>
<button onClick={() => removeDomain(d.domain)} className="text-gray-600 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all p-1"><Trash2 className="w-4 h-4" /></button>
<div key={d.domain} className="flex items-center justify-between p-5 rounded-2xl bg-surface-container-highest/5 border border-outline-variant/5 group hover:border-outline-variant/20 transition-all">
<span className="text-sm font-mono text-on-surface-variant/70">{d.domain}</span>
<button onClick={() => removeDomain(d.domain)} className="text-on-surface-variant/20 hover:text-error opacity-0 group-hover:opacity-100 transition-all p-2"><span className="material-symbols-outlined text-[18px]">delete</span></button>
</div>
))}
{config.federation.allowedDomains.length === 0 && (
@@ -1398,14 +1440,22 @@ export default function AdminPage() {
<div className="flex-1 overflow-y-auto p-2 flex flex-col gap-1">
{users.length === 0 && !isSearching && <div className="text-center py-10 text-gray-500">{t.noUsersFound}</div>}
{users.map(u => (
<button key={u.id} onClick={() => fetchUserDetails(u.id)} className={`flex items-center gap-3 p-3 rounded-xl transition-all text-left group ${selectedUser?.id === u.id ? 'bg-accent/10' : 'hover:bg-white/5'}`}>
<button
key={u.id}
onClick={() => fetchUserStats(u.id)}
className={`flex items-center gap-3 p-4 rounded-2xl transition-all text-left group border shadow-sm ${
selectedUser?.id === u.id
? 'bg-primary/10 border-primary/30 scale-[1.01] shadow-primary/5'
: 'hover:bg-surface-container-highest/10 border-transparent'
}`}
>
<div className="relative">
<div className="w-10 h-10 rounded-full bg-accent/20 flex items-center justify-center text-accent font-bold shrink-0">
<div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-black shrink-0 slide-on-ice border border-primary/10">
{u.avatar ? <img src={u.avatar} className="w-full h-full rounded-full" /> : u.username.charAt(0).toUpperCase()}
</div>
{u.isBanned && (
<div className="absolute -bottom-0.5 -left-0.5 w-4 h-4 bg-surface rounded-full flex items-center justify-center" title={t.blockedTooltip}>
<XCircle className="w-3.5 h-3.5 text-red-500" />
<div className="absolute -bottom-1 -right-1 w-5 h-5 bg-surface-container-lowest rounded-full flex items-center justify-center" title={t.blockedTooltip}>
<span className="material-symbols-outlined text-error text-[14px]">block</span>
</div>
)}
</div>
@@ -1440,12 +1490,12 @@ export default function AdminPage() {
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} className="w-80 bg-surface border border-white/10 rounded-2xl p-6 flex flex-col gap-6 shrink-0 sticky top-0 overflow-y-auto max-h-full">
<div className="flex flex-col items-center gap-4 text-center pb-6 border-b border-white/10">
<div className="relative">
<div className="w-20 h-20 rounded-full bg-accent/20 flex items-center justify-center text-2xl font-bold text-accent">
<div className="w-24 h-24 rounded-full bg-primary/20 flex items-center justify-center text-3xl font-black text-primary slide-on-ice border border-primary/10">
{selectedUser.avatar ? <img src={selectedUser.avatar} className="w-full h-full rounded-full" /> : selectedUser.username.charAt(0).toUpperCase()}
</div>
{selectedUser.isBanned && (
<div className="absolute -bottom-1 -left-1 w-6 h-6 bg-surface rounded-full flex items-center justify-center" title={t.blockedTooltip}>
<XCircle className="w-5 h-5 text-red-500" />
<div className="absolute -bottom-1 -right-1 w-8 h-8 bg-surface-container-lowest rounded-full flex items-center justify-center border border-outline-variant/10" title={t.blockedTooltip}>
<span className="material-symbols-outlined text-error text-[20px]">block</span>
</div>
)}
</div>
@@ -1466,26 +1516,26 @@ export default function AdminPage() {
</div>
</div>
<div className="mt-auto flex flex-col gap-2">
<button onClick={() => handleResetPassword(selectedUser.id)} className="w-full py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-white font-semibold transition-all border border-white/5 text-sm">
<div className="mt-auto flex flex-col gap-4">
<button onClick={() => handleResetPassword(selectedUser.id)} className="w-full py-4 rounded-2xl bg-surface-container-highest/10 hover:bg-surface-container-highest/20 text-on-surface-variant font-black uppercase text-[10px] tracking-widest transition-all slide-on-ice">
{t.resetPassword}
</button>
{generatedPass && (
<div className="p-3 bg-accent/10 border border-accent/20 rounded-xl flex items-center justify-between">
<span className="font-mono text-accent text-sm">{generatedPass}</span>
<button onClick={() => { navigator.clipboard.writeText(generatedPass); showToast(t.copied); }} className="text-accent underline text-[10px]">{t.copy}</button>
<div className="p-4 bg-primary/10 border border-primary/20 rounded-2xl flex items-center justify-between">
<span className="font-mono text-primary text-sm font-black">{generatedPass}</span>
<button onClick={() => { navigator.clipboard.writeText(generatedPass); showToast(t.copied); }} className="text-primary underline text-[10px] font-black uppercase tracking-widest">{t.copy}</button>
</div>
)}
{selectedUser.isBanned ? (
<button onClick={() => handleUnbanUser(selectedUser.id)} className="w-full py-2.5 rounded-xl bg-green-500/10 hover:bg-green-500/20 text-green-500 font-bold transition-all border border-green-500/20 text-sm mt-2">
<button onClick={() => handleUnbanUser(selectedUser.id)} className="w-full py-4 rounded-2xl bg-primary/10 hover:bg-primary/20 text-primary font-black uppercase text-[10px] tracking-widest transition-all slide-on-ice">
{t.unblockUser}
</button>
) : (
<button onClick={() => handleBanUser(selectedUser.id)} className="w-full py-2.5 rounded-xl bg-red-500/10 hover:bg-red-500/20 text-red-500 font-bold transition-all border border-red-500/20 text-sm mt-2">
<button onClick={() => handleBanUser(selectedUser.id)} className="w-full py-4 rounded-2xl bg-error/10 hover:bg-error/20 text-error font-black uppercase text-[10px] tracking-widest transition-all slide-on-ice">
{t.blockUser}
</button>
)}
<button onClick={() => handleDeleteUser(selectedUser.id)} className="w-full py-2.5 rounded-xl bg-red-500/10 hover:bg-red-500/20 text-red-500 font-bold transition-all border border-red-500/20 text-sm mt-4">
<button onClick={() => handleDeleteUser(selectedUser.id)} className="w-full py-4 rounded-2xl bg-error/5 hover:bg-error/10 text-error/60 hover:text-error font-black uppercase text-[10px] tracking-widest transition-all slide-on-ice">
{t.deleteUser}
</button>
</div>
@@ -1502,27 +1552,33 @@ export default function AdminPage() {
{/* MODALS */}
{showAddUserModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm" onClick={() => setShowAddUserModal(false)}>
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} onClick={e => e.stopPropagation()} className="w-full max-w-sm bg-surface border border-white/10 rounded-2xl p-6 shadow-2xl relative">
<button onClick={() => setShowAddUserModal(false)} className="absolute top-4 right-4 text-gray-500 hover:text-white"><XCircle className="w-5 h-5" /></button>
<h2 className="text-xl font-bold text-white mb-4">{t.addUser}</h2>
<div className="flex flex-col gap-4">
<label className="flex flex-col gap-1 text-xs text-gray-400">
{t.username}
<input className="bg-black/50 border border-white/10 rounded-xl px-4 py-2.5 outline-none text-white focus:border-accent" value={newUser.username} onChange={e => setNewUser({ ...newUser, username: e.target.value.toLowerCase() })} placeholder={t.usernamePlaceholder} />
</label>
<label className="flex flex-col gap-1 text-xs text-gray-400">
{t.displayName}
<input className="bg-black/50 border border-white/10 rounded-xl px-4 py-2.5 outline-none text-white focus:border-accent" value={newUser.displayName} onChange={e => setNewUser({ ...newUser, displayName: e.target.value })} placeholder={t.displayNamePlaceholder} />
</label>
<label className="flex flex-col gap-1 text-xs text-gray-400">
{t.password}
<div className="flex gap-2">
<input className="bg-black/50 border border-white/10 rounded-xl px-4 py-2.5 outline-none text-white focus:border-accent flex-1 font-mono" value={newUser.password} onChange={e => setNewUser({ ...newUser, password: e.target.value })} placeholder={t.passwordPlaceholder} />
<button onClick={() => setNewUser({ ...newUser, password: generatePassword() })} className="bg-white/5 hover:bg-white/10 px-3 rounded-xl text-[10px] transition-colors">{t.generate}</button>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-surface-container-lowest/90 backdrop-blur-xl" onClick={() => setShowAddUserModal(false)}>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} onClick={e => e.stopPropagation()} className="w-full max-w-sm knot-card p-10 border border-outline-variant/20 relative shadow-2xl">
<button onClick={() => setShowAddUserModal(false)} className="absolute top-6 right-6 text-on-surface-variant/40 hover:text-on-surface"><span className="material-symbols-outlined">close</span></button>
<h2 className="text-2xl font-black font-headline tracking-tighter mb-8">{t.addUser}</h2>
<div className="flex flex-col gap-6">
<label className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-widest text-on-surface-variant/40 ml-1">{t.username}</span>
<div className="knot-input-group">
<input value={newUser.username} onChange={e => setNewUser({ ...newUser, username: e.target.value.toLowerCase() })} placeholder={t.usernamePlaceholder} />
</div>
</label>
<button onClick={handleAddUser} disabled={!newUser.username || !newUser.password} className="bg-accent text-black font-bold py-2.5 rounded-xl hover:bg-accentLight transition-all mt-2 disabled:opacity-50">
<label className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-widest text-on-surface-variant/40 ml-1">{t.displayName}</span>
<div className="knot-input-group">
<input value={newUser.displayName} onChange={e => setNewUser({ ...newUser, displayName: e.target.value })} placeholder={t.displayNamePlaceholder} />
</div>
</label>
<label className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-widest text-on-surface-variant/40 ml-1">{t.password}</span>
<div className="flex gap-2">
<div className="knot-input-group flex-1">
<input className="font-mono text-sm" value={newUser.password} onChange={e => setNewUser({ ...newUser, password: e.target.value })} placeholder={t.passwordPlaceholder} />
</div>
<button onClick={() => setNewUser({ ...newUser, password: generatePassword() })} className="bg-primary/10 hover:bg-primary/20 text-primary px-4 rounded-2xl font-black text-[10px] uppercase tracking-widest transition-all">{t.generate}</button>
</div>
</label>
<button onClick={handleAddUser} disabled={!newUser.username || !newUser.password} className="knot-button-primary w-full py-4 mt-4 disabled:opacity-30">
{t.createUser}
</button>
</div>
@@ -1532,28 +1588,67 @@ export default function AdminPage() {
{/* Global Toast */}
{toast && (
<div className="fixed top-4 right-4 z-[9999] px-5 py-3 rounded-2xl bg-surface border border-white/10 text-white shadow-2xl flex items-center gap-2">
{toast.type === 'success' ? <CheckCircle className="text-green-400 w-5 h-5" /> : <XCircle className="text-red-400 w-5 h-5" />}
{toast.message}
</div>
<motion.div
initial={{ opacity: 0, y: 20, x: '-50%' }}
animate={{ opacity: 1, y: 0, x: '-50%' }}
exit={{ opacity: 0, y: 20, x: '-50%' }}
className="fixed bottom-10 left-1/2 z-[9999] px-6 py-4 rounded-3xl bg-surface-container-high border border-outline-variant/20 text-on-surface shadow-[0_24px_64px_rgba(0,0,0,0.8)] flex items-center gap-4 min-w-[280px] backdrop-blur-3xl"
>
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${toast.type === 'success' ? 'bg-primary/10' : 'bg-error/10'}`}>
<span className={`material-symbols-outlined text-[22px] ${toast.type === 'success' ? 'text-primary' : 'text-error'}`}>
{toast.type === 'success' ? 'check_circle' : 'error'}
</span>
</div>
<div className="flex flex-col">
<span className="text-[10px] font-black uppercase tracking-widest opacity-40">{toast.type === 'success' ? (lang === 'ru' ? 'Успешно' : 'Success') : (lang === 'ru' ? 'Ошибка' : 'Error')}</span>
<span className="text-[13px] font-bold">{toast.message}</span>
</div>
</motion.div>
)}
</div>
);
}
function NavButton({ icon, label, active, onClick }: { icon: any, label: string, active: boolean, onClick: () => void }) {
function NavButton({ icon, label, active, onClick }: { icon: string, label: string, active: boolean, onClick: () => void }) {
return (
<button onClick={onClick} className={`flex items-center gap-3 px-5 py-3.5 rounded-xl transition-all font-medium text-sm group ${active ? 'bg-accent/20 text-accent shadow-inner shadow-white/5' : 'text-gray-500 hover:text-white hover:bg-white/[0.03]'}`}>
<span className={`transition-transform duration-300 ${active ? 'scale-110' : 'group-hover:scale-105'}`}>{icon}</span>
{label}
<button
onClick={onClick}
className={`w-full flex items-center gap-4 px-4 py-3 rounded-2xl transition-all duration-400 ease-ice group relative ${
active
? 'bg-primary/10 text-primary'
: 'text-on-surface-variant/40 hover:text-on-surface hover:bg-surface-container-highest/10'
}`}
>
{active && (
<motion.div
layoutId="nav-active"
className="absolute left-0 w-1 h-6 bg-primary rounded-full"
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
/>
)}
<span className="material-symbols-outlined text-[22px] transition-transform group-hover:scale-110">
{icon}
</span>
<span className="text-[13px] font-black uppercase tracking-widest">{label}</span>
</button>
);
}
function Toggle({ checked, onChange }: { checked: boolean, onChange: (v: boolean) => void }) {
return (
<button onClick={() => onChange(!checked)} className={`w-12 h-6 rounded-full relative transition-colors border ${checked ? 'bg-accent border-accent' : 'bg-black border-white/20'}`}>
<motion.div animate={{ x: checked ? 26 : 2 }} transition={{ type: 'spring', stiffness: 500, damping: 30 }} className={`absolute top-1 w-4 h-4 rounded-full ${checked ? 'bg-black' : 'bg-white/40'}`} />
<button
onClick={() => onChange(!checked)}
className={`w-11 h-6 rounded-full relative transition-all duration-300 ${
checked ? 'bg-primary' : 'bg-surface-container-highest'
}`}
>
<motion.div
animate={{ x: checked ? 22 : 3 }}
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
className={`absolute top-1 w-4 h-4 rounded-full ${
checked ? 'bg-surface-container-lowest' : 'bg-on-surface-variant/40'
}`}
/>
</button>
);
}

View File

@@ -27,37 +27,36 @@ export default function AuthPage() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="h-full flex flex-col items-center justify-center relative overflow-hidden bg-[#0a0a0c]"
className="h-full flex flex-col items-center justify-center relative overflow-hidden bg-surface-container-lowest"
>
{/* Переключатель языка сверху по центру */}
<div className="absolute top-8 left-1/2 -translate-x-1/2 flex gap-4 text-sm font-semibold text-zinc-500 z-50">
<button onClick={() => setLang('en')} className={lang === 'en' ? 'text-[#9b66ff]' : 'hover:text-white transition-colors'}>EN</button>
<div className="w-px h-4 bg-white/10 self-center" />
<button onClick={() => setLang('ru')} className={lang === 'ru' ? 'text-[#9b66ff]' : 'hover:text-white transition-colors'}>RU</button>
{/* Language Switcher */}
<div className="absolute top-12 left-1/2 -translate-x-1/2 flex items-center gap-1 p-1 bg-surface-container-low rounded-full border border-outline-variant/10 shadow-lg z-50 slide-on-ice">
<button onClick={() => setLang('en')} className={`px-4 py-1.5 rounded-full text-xs font-black tracking-widest transition-all ${lang === 'en' ? 'bg-primary text-on-primary' : 'text-on-surface-variant/40 hover:text-on-surface'}`}>EN</button>
<button onClick={() => setLang('ru')} className={`px-4 py-1.5 rounded-full text-xs font-black tracking-widest transition-all ${lang === 'ru' ? 'bg-primary text-on-primary' : 'text-on-surface-variant/40 hover:text-on-surface'}`}>RU</button>
</div>
{/* Карточка авторизации */}
<motion.div
initial={{ scale: 0.95, y: 20 }}
animate={{ scale: 1, y: 0 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
className="relative z-10 w-full max-w-[420px] mx-4"
initial={{ scale: 0.98, opacity: 0, y: 10 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
className="relative z-10 w-full max-w-[440px] mx-4"
>
<div className="bg-[#111113] rounded-[32px] p-10 shadow-2xl border border-white/5">
<div className="knot-card p-10 shadow-none border-none">
{/* Заголовок */}
<div className="flex flex-col items-center mb-10">
{/* Header */}
<div className="flex flex-col items-center mb-12">
<motion.div
initial={{ rotate: -180, scale: 0 }}
initial={{ rotate: -10, scale: 0.9 }}
animate={{ rotate: 0, scale: 1 }}
transition={{ duration: 0.6, type: 'spring', bounce: 0.4 }}
className="w-[84px] h-[84px] rounded-[28px] bg-[#1a1625] flex items-center justify-center mb-6 shadow-inner border border-white/5"
transition={{ duration: 0.8, type: 'spring' }}
className="w-20 h-20 rounded-3xl bg-primary/10 flex items-center justify-center mb-6 shadow-none border-2 border-primary/20 slide-on-ice group"
>
<MessageSquare className="w-9 h-9 text-[#8b5cf6]" />
<span className="material-symbols-outlined text-4xl text-primary group-hover:scale-110 transition-transform">hub</span>
</motion.div>
<h1 className="text-[28px] font-bold bg-gradient-to-r from-[#9b66ff] to-[#bd99ff] text-transparent bg-clip-text tracking-tight">Knot Messenger</h1>
<p className="text-zinc-500 text-[11px] mt-2.5 tracking-widest uppercase font-semibold">
{isLogin ? (lang === 'ru' ? 'вход' : 'login') : (lang === 'ru' ? 'регистрация' : 'registration')}
<h1 className="text-3xl font-black font-headline text-on-surface tracking-tighter mb-1">Knot</h1>
<p className="text-on-surface-variant/40 text-[11px] tracking-[0.2em] uppercase font-black">
{isLogin ? (lang === 'ru' ? 'authorization' : 'authorization') : (lang === 'ru' ? 'registration' : 'registration')}
</p>
</div>

View File

@@ -37,10 +37,10 @@ export default function LoginForm({ onRegisterClick, enableRegistration }: Props
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="mb-6 p-3.5 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm font-medium text-center"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
className="mb-6 p-4 rounded-2xl bg-error/10 border-none text-error text-[13px] font-bold text-center uppercase tracking-widest"
role="alert"
>
{error}
@@ -49,76 +49,79 @@ export default function LoginForm({ onRegisterClick, enableRegistration }: Props
</AnimatePresence>
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
<div>
<label className="block text-sm font-semibold text-zinc-300 mb-2">
Username
<div className="knot-input-group">
<label className="block text-[11px] font-black uppercase tracking-[0.2em] text-on-surface-variant/40 mb-3 ml-1">
{lang === 'ru' ? 'Имя пользователя' : 'Username'}
</label>
<input
type="text"
value={credentials.username}
onChange={(e) => setCredentials({ ...credentials, username: e.target.value.replace(/[^a-zA-Z0-9_]/g, '') })}
placeholder="username"
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-none text-[15px]"
required
autoFocus
autoComplete="off"
/>
<div className="relative">
<span className="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-on-surface-variant/40 text-[20px]">person</span>
<input
type="text"
value={credentials.username}
onChange={(e) => setCredentials({ ...credentials, username: e.target.value.replace(/[^a-zA-Z0-9_]/g, '') })}
placeholder="username"
className="w-full pl-12 pr-4 py-4 rounded-2xl bg-surface-container-highest/60 text-on-surface placeholder-on-surface-variant/20 border-none focus:ring-2 focus:ring-primary/20 transition-all outline-none text-[15px]"
required
autoFocus
autoComplete="off"
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-zinc-300 mb-2">
<div className="knot-input-group">
<label className="block text-[11px] font-black uppercase tracking-[0.2em] text-on-surface-variant/40 mb-3 ml-1">
{lang === 'ru' ? 'Пароль' : 'Password'}
</label>
<div className="relative">
<span className="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-on-surface-variant/40 text-[20px]">lock</span>
<input
type={showPassword ? 'text' : 'password'}
value={credentials.password}
onChange={(e) => setCredentials({ ...credentials, password: e.target.value })}
placeholder={lang === 'ru' ? 'Введите пароль' : 'Enter password'}
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors pr-12 outline-none text-[15px]"
placeholder={lang === 'ru' ? '••••••••' : '••••••••'}
className="w-full pl-12 pr-12 py-4 rounded-2xl bg-surface-container-highest/60 text-on-surface placeholder-on-surface-variant/20 border-none focus:ring-2 focus:ring-primary/20 transition-all outline-none text-[15px]"
required
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
className="absolute right-4 top-1/2 -translate-y-1/2 text-on-surface-variant/40 hover:text-primary transition-colors slide-on-ice"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
<span className="material-symbols-outlined text-[20px]">{showPassword ? 'visibility_off' : 'visibility'}</span>
</button>
</div>
</div>
<motion.button
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
<button
disabled={isSubmitting}
type="submit"
className="w-full py-3.5 px-4 rounded-xl bg-[#8b5cf6] hover:bg-[#7c3aed] text-white font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed mt-8 transition-colors text-[16px]"
style={{ marginTop: '32px' }}
className="knot-button-primary w-full py-4 mt-8 flex items-center justify-center gap-3 disabled:opacity-50 slide-on-ice"
>
{isSubmitting ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
<div className="w-5 h-5 border-2 border-on-primary/30 border-t-on-primary rounded-full animate-spin" />
) : (
<>
{lang === 'ru' ? 'Войти' : 'Login'}
<ArrowRight size={18} />
<span className="font-black uppercase tracking-widest text-[14px]">
{lang === 'ru' ? 'Войти' : 'Continue'}
</span>
<span className="material-symbols-outlined text-[20px]">arrow_forward</span>
</>
)}
</motion.button>
</button>
</form>
{enableRegistration && onRegisterClick && (
<div className="mt-8 pt-6 border-t border-white/5 flex justify-center items-center gap-2">
<p className="text-zinc-500 text-[13px] font-medium">
{lang === 'ru' ? 'Нет аккаунта?' : "Don't have an account?"}
<div className="mt-10 pt-8 border-t border-on-surface-variant/5 flex flex-col items-center gap-4">
<p className="text-on-surface-variant/40 text-[11px] font-black uppercase tracking-widest">
{lang === 'ru' ? 'Нет аккаунта?' : "New to Knot?"}
</p>
<button
onClick={onRegisterClick}
className="text-[#7c3aed] hover:text-[#8b5cf6] text-[13px] font-semibold transition-colors"
className="text-primary hover:text-primary/80 text-[13px] font-black uppercase tracking-widest transition-all slide-on-ice"
type="button"
>
{lang === 'ru' ? 'Зарегистрироваться' : 'Register'}
{lang === 'ru' ? 'Создать аккаунт' : 'Create Account'}
</button>
</div>
)}

View File

@@ -39,10 +39,10 @@ export default function RegisterForm({ onLoginClick, enableRegistration }: Props
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="mb-6 p-3.5 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm font-medium text-center"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
className="mb-8 p-4 rounded-2xl bg-error/10 border-none text-error text-[13px] font-bold text-center uppercase tracking-widest"
role="alert"
>
{error}
@@ -51,46 +51,53 @@ export default function RegisterForm({ onLoginClick, enableRegistration }: Props
</AnimatePresence>
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
<div>
<label className="block text-sm font-semibold text-zinc-300 mb-2">
Username <span className="text-zinc-600 font-normal ml-1">({lang === 'ru' ? 'латиница, нельзя изменить' : 'latin, cannot change'})</span>
<div className="knot-input-group">
<label className="block text-[11px] font-black uppercase tracking-[0.2em] text-on-surface-variant/40 mb-3 ml-1">
{lang === 'ru' ? 'Имя пользователя (латиница)' : 'Username (latin only)'}
</label>
<input
type="text"
value={credentials.username}
onChange={(e) => setCredentials({ ...credentials, username: e.target.value.replace(/[^a-zA-Z0-9_]/g, '') })}
placeholder="username"
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-none text-[15px]"
required
autoFocus
autoComplete="off"
/>
<div className="relative">
<span className="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-on-surface-variant/40 text-[20px]">alternate_email</span>
<input
type="text"
value={credentials.username}
onChange={(e) => setCredentials({ ...credentials, username: e.target.value.replace(/[^a-zA-Z0-9_]/g, '') })}
placeholder="username"
className="w-full pl-12 pr-4 py-4 rounded-2xl bg-surface-container-highest/60 text-on-surface placeholder-on-surface-variant/20 border-none focus:ring-2 focus:ring-primary/20 transition-all outline-none text-[15px]"
required
autoFocus
autoComplete="off"
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-zinc-300 mb-2">
<div className="knot-input-group">
<label className="block text-[11px] font-black uppercase tracking-[0.2em] text-on-surface-variant/40 mb-3 ml-1">
{lang === 'ru' ? 'Отображаемое имя' : 'Display Name'}
</label>
<input
type="text"
value={credentials.displayName}
onChange={(e) => setCredentials({ ...credentials, displayName: e.target.value })}
placeholder={lang === 'ru' ? 'Ваше имя (любой язык)' : 'Your name'}
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-none text-[15px]"
/>
<div className="relative">
<span className="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-on-surface-variant/40 text-[20px]">face</span>
<input
type="text"
value={credentials.displayName}
onChange={(e) => setCredentials({ ...credentials, displayName: e.target.value })}
placeholder={lang === 'ru' ? 'Ваше имя' : 'Your name'}
className="w-full pl-12 pr-4 py-4 rounded-2xl bg-surface-container-highest/60 text-on-surface placeholder-on-surface-variant/20 border-none focus:ring-2 focus:ring-primary/20 transition-all outline-none text-[15px]"
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-zinc-300 mb-2">
<div className="knot-input-group">
<label className="block text-[11px] font-black uppercase tracking-[0.2em] text-on-surface-variant/40 mb-3 ml-1">
{lang === 'ru' ? 'Пароль' : 'Password'}
</label>
<div className="relative">
<span className="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-on-surface-variant/40 text-[20px]">lock_open</span>
<input
type={showPassword ? 'text' : 'password'}
value={credentials.password}
onChange={(e) => setCredentials({ ...credentials, password: e.target.value })}
placeholder={lang === 'ru' ? 'Введите пароль' : 'Enter password'}
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors pr-12 outline-none text-[15px]"
placeholder="••••••••"
className="w-full pl-12 pr-12 py-4 rounded-2xl bg-surface-container-highest/60 text-on-surface placeholder-on-surface-variant/20 border-none focus:ring-2 focus:ring-primary/20 transition-all outline-none text-[15px]"
required
autoComplete="new-password"
minLength={8}
@@ -98,14 +105,14 @@ export default function RegisterForm({ onLoginClick, enableRegistration }: Props
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
className="absolute right-4 top-1/2 -translate-y-1/2 text-on-surface-variant/40 hover:text-primary transition-colors slide-on-ice"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
<span className="material-symbols-outlined text-[20px]">{showPassword ? 'visibility_off' : 'visibility'}</span>
</button>
</div>
<p className="mt-2 text-[12px] text-zinc-500 flex items-center gap-1.5 font-medium">
<span className="w-1 h-1 rounded-full bg-[#9b66ff]" />
{lang === 'ru' ? 'Минимум 8 символов, буквы и цифры' : 'Minimum 8 characters, letters and numbers'}
<p className="mt-3 text-[10px] text-on-surface-variant/40 flex items-center gap-1.5 font-black uppercase tracking-widest ml-1">
<span className="w-2.5 h-2.5 rounded-full border-2 border-primary" />
{lang === 'ru' ? '8+ символов, любые знаки' : '8+ characters required'}
</p>
</div>
@@ -122,36 +129,35 @@ export default function RegisterForm({ onLoginClick, enableRegistration }: Props
/>
</div>
<motion.button
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
<button
disabled={isSubmitting}
type="submit"
className="w-full py-3.5 px-4 rounded-xl bg-[#8b5cf6] hover:bg-[#7c3aed] text-white font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed mt-8 transition-colors text-[16px]"
style={{ marginTop: '32px' }}
className="knot-button-primary w-full py-4 mt-8 flex items-center justify-center gap-3 disabled:opacity-50 slide-on-ice"
>
{isSubmitting ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
<div className="w-5 h-5 border-2 border-on-primary/30 border-t-on-primary rounded-full animate-spin" />
) : (
<>
{lang === 'ru' ? 'Создать аккаунт' : 'Create account'}
<ArrowRight size={18} />
<span className="font-black uppercase tracking-widest text-[14px]">
{lang === 'ru' ? 'Создать аккаунт' : 'Create Account'}
</span>
<span className="material-symbols-outlined text-[20px]">check_circle</span>
</>
)}
</motion.button>
</button>
</form>
{enableRegistration && (
<div className="mt-8 pt-6 border-t border-white/5 flex justify-center items-center gap-2">
<p className="text-zinc-500 text-[13px] font-medium">
<div className="mt-10 pt-8 border-t border-on-surface-variant/5 flex flex-col items-center gap-4">
<p className="text-on-surface-variant/40 text-[11px] font-black uppercase tracking-widest">
{lang === 'ru' ? 'Уже есть аккаунт?' : 'Already have an account?'}
</p>
<button
onClick={onLoginClick}
className="text-[#7c3aed] hover:text-[#8b5cf6] text-[13px] font-semibold transition-colors"
className="text-primary hover:text-primary/80 text-[13px] font-black uppercase tracking-widest transition-all slide-on-ice"
type="button"
>
{lang === 'ru' ? 'Войти' : 'Login'}
{lang === 'ru' ? 'Войти в систему' : 'Sign In instead'}
</button>
</div>
)}

View File

@@ -9,6 +9,7 @@ import { useLang } from '../../../core/infrastructure/i18n';
import type { Message, UserBasic, CallInfo, ChatMember } from '../../../core/domain/types';
import { Send, Check, Phone, PhoneOff } from 'lucide-react';
import Sidebar from '../../../core/presentation/layouts/Sidebar';
import GlobalNavBar from '../../../core/presentation/layouts/GlobalNavBar';
import ChatView from './components/ChatView';
import CallModal from '../../calls/presentation/components/CallModal';
import GroupCallModal from '../../calls/presentation/components/GroupCallModal';
@@ -362,14 +363,25 @@ export default function ChatPage() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="h-full flex bg-surface sm:p-3 sm:gap-3 overflow-hidden"
className="h-screen w-screen flex bg-surface-dim overflow-hidden antialiased font-body selection:bg-primary/30"
>
<div className={`${activeChat ? 'hidden sm:block' : 'block'} w-full sm:w-[340px] flex-shrink-0`}>
<Sidebar />
</div>
<div className={`${activeChat ? 'block' : 'hidden sm:block'} flex-1 h-full min-w-0`}>
<ChatView onStartCall={handleStartCall} onStartGroupCall={handleStartGroupCall} />
</div>
<GlobalNavBar activeTab="chats" onTabChange={() => {}} />
<main className="ml-20 flex-1 flex flex-row relative h-full">
{/* Chat List (Sidebar) */}
<div
className={`${activeChat ? 'hidden lg:block' : 'block'} w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low tonal-transition-no-border h-full`}
>
<Sidebar />
</div>
{/* Selected Chat View (Main Area) */}
<div
className={`${activeChat ? 'block' : 'hidden lg:block'} flex-1 h-full min-w-0 bg-surface-container-lowest m-4 rounded-3xl overflow-hidden relative group slide-on-ice`}
>
<ChatView onStartCall={handleStartCall} onStartGroupCall={handleStartGroupCall} />
</div>
</main>
<CallModal
key={callSessionId}
isOpen={callOpen}

View File

@@ -141,18 +141,20 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
<button
onClick={handleClick}
onContextMenu={handleContextMenu}
className={`w-full flex items-center gap-3 px-3 py-3 transition-colors text-left ${
isActive ? 'bg-accent/15 border-r-2 border-accent' : 'hover:bg-surface-hover'
className={`w-full flex items-center gap-4 px-4 py-3.5 transition-all duration-300 slide-on-ice text-left rounded-2xl mx-1 my-0.5 w-[calc(100%-8px)] ${
isActive ? 'bg-primary/10' : 'hover:bg-surface-container-highest/20'
}`}
>
{/* Аватар */}
<div className="relative flex-shrink-0">
{isFavorites ? (
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center shadow-lg">
<Bookmark size={22} className="text-white" />
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-primary to-primary-container flex items-center justify-center shadow-lg shadow-primary/10 border-2 border-outline-variant/10">
<span className="material-symbols-outlined text-on-primary-container text-[24px]">bookmark</span>
</div>
) : (
<Avatar src={chatAvatar || undefined} name={chatName || '??'} size="lg" online={isOnline ? true : false} />
<div className="avatar-knot-container group-hover:scale-105 transition-transform">
<Avatar src={chatAvatar || undefined} name={chatName || '??'} size="lg" online={isOnline ? true : false} />
</div>
)}
</div>
@@ -160,28 +162,26 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 min-w-0">
{isPinned && <Pin size={12} className="text-knot-400 flex-shrink-0 rotate-45" />}
<span className="text-sm font-medium text-white truncate">{chatName}</span>
{isPinned && <span className="material-symbols-outlined text-[14px] text-primary rotate-45">push_pin</span>}
<span className={`text-sm font-bold tracking-tight truncate ${isActive ? 'text-primary' : 'text-on-surface'}`}>{chatName}</span>
</div>
{timeStr && <span className="text-xs text-zinc-500 flex-shrink-0 ml-2">{timeStr}</span>}
{timeStr && <span className="text-[10px] uppercase font-bold tracking-wider text-on-surface-variant/50 flex-shrink-0 ml-2">{timeStr}</span>}
</div>
<div className="flex items-center justify-between mt-0.5">
<div className="flex items-center gap-1 min-w-0 flex-1">
{isMine && lastMessage && !lastMessage.isDeleted && (
<span className="flex-shrink-0">
{isRead ? (
<CheckCheck size={14} className="text-knot-400" />
) : (
<Check size={14} className="text-zinc-500" />
)}
<span className={`material-symbols-outlined text-[14px] ${isRead ? 'text-primary fill-1' : 'text-on-surface-variant/40'}`} style={{ fontVariationSettings: `'FILL' ${isRead ? 1 : 0}` }}>
{isRead ? 'done_all' : 'done'}
</span>
</span>
)}
<p className={`text-xs truncate ${isTyping ? 'text-knot-400 font-medium' : draft ? 'text-red-400' : 'text-zinc-400'}`}>
{isTyping ? t('typing') : draft ? <><span className="font-medium">{t('draft')} </span>{stripMarkdown(draft)}</> : previewText}
<p className={`text-[13px] truncate leading-tight ${isTyping ? 'text-tertiary font-bold' : draft ? 'text-error font-medium' : 'text-on-surface-variant/60'}`}>
{isTyping ? t('typing') : draft ? <><span className="font-bold">{t('draft')} </span>{stripMarkdown(draft)}</> : previewText}
</p>
</div>
{chat.unreadCount > 0 && !isActive && (
<span className="ml-2 flex-shrink-0 min-w-[20px] h-5 px-1.5 rounded-full bg-accent flex items-center justify-center text-[11px] text-white font-medium">
<span className="ml-2 flex-shrink-0 min-w-[20px] h-5 px-1.5 rounded-full bg-primary text-on-primary-container flex items-center justify-center text-[10px] font-black shadow-lg shadow-primary/20 animate-pulse">
{chat.unreadCount}
</span>
)}

View File

@@ -501,15 +501,16 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
<div
ref={chatViewRef}
onMouseMove={handleMouseMove}
className={`flex-1 flex flex-col h-full sm:rounded-[2rem] overflow-hidden shadow-[0_0_120px_-20px_rgba(0,0,0,0.5)] border-x sm:border border-border/50 relative z-0 chat-theme-${chatTheme} transition-colors duration-500`}
className={`flex-1 flex flex-col h-full overflow-hidden bg-surface-container-lowest relative z-0 chat-theme-${chatTheme} transition-colors duration-500`}
>
<div className="absolute inset-0 pointer-events-none bg-gradient-to-b from-primary/5 to-transparent h-32 opacity-30" />
{selectionMode ? (
<div className="h-[76px] flex items-center justify-between px-4 sm:px-6 border-b border-border/40 bg-surface-secondary/80 backdrop-blur-xl z-20 flex-shrink-0 animate-in slide-in-from-top-2">
<div className="flex items-center gap-2 sm:gap-4 text-white">
<button onClick={() => { setSelectionMode(false); setSelectedMessages(new Set()); }} className="p-2 -ml-2 rounded-full hover:bg-white/10 transition">
<X size={20} className="text-zinc-300" />
<div className="h-[76px] flex items-center justify-between px-6 bg-surface-container-highest/80 backdrop-blur-2xl z-20 flex-shrink-0 animate-in slide-in-from-top-2 border-none">
<div className="flex items-center gap-4 text-on-surface">
<button onClick={() => { setSelectionMode(false); setSelectedMessages(new Set()); }} className="p-2 -ml-2 rounded-xl hover:bg-on-surface/10 transition slide-on-ice">
<span className="material-symbols-outlined">close</span>
</button>
<span className="font-medium text-[15px]">{selectedMessages.size} {t('selected') || 'выбрано'}</span>
<span className="text-lg font-bold font-headline">{selectedMessages.size} {t('selected')}</span>
</div>
<div className="flex items-center gap-3">
<div className="relative" ref={deleteMenuRef}>
@@ -560,16 +561,16 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
</div>
</div>
) : (
<div className="h-[76px] flex items-center justify-between px-3 sm:px-6 border-b border-border/40 bg-surface-secondary/80 backdrop-blur-xl z-20 flex-shrink-0 gap-2">
<div className="h-[76px] flex items-center justify-between px-6 bg-surface-container-lowest/40 backdrop-blur-xl z-20 flex-shrink-0 gap-2 border-none">
<div className="flex items-center gap-2 min-w-0 flex-1">
<button
onClick={() => setActiveChat(null)}
className="sm:hidden p-2 -ml-1 rounded-xl hover:bg-surface-hover text-zinc-400 transition-colors"
className="lg:hidden p-2 -ml-1 rounded-xl hover:bg-surface-container-highest/30 text-on-surface-variant transition-colors"
>
<ArrowLeft size={22} />
<span className="material-symbols-outlined">arrow_back</span>
</button>
<button
className="flex items-center gap-3 min-w-0 flex-1 group transition-all"
<div
className="flex items-center gap-4 min-w-0 flex-1 group cursor-pointer"
onClick={() => {
if (chat.type === 'personal' && otherMember) {
setProfileUserId(otherMember.user.id);
@@ -578,10 +579,10 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
}
}}
>
<div className="relative flex-shrink-0 transform transition-transform duration-300 group-hover:scale-105">
<div className="relative flex-shrink-0 transform transition-all duration-500 group-hover:scale-105 active:scale-95">
{isFavorites ? (
<div className="w-11 h-11 rounded-full bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center shadow-lg ring-2 ring-transparent group-hover:ring-accent/30 transition-all duration-300">
<Bookmark size={20} className="text-white" />
<div className="w-11 h-11 rounded-full bg-gradient-to-br from-primary to-primary-container flex items-center justify-center shadow-lg shadow-primary/10 border-2 border-outline-variant/10">
<span className="material-symbols-outlined text-on-primary-container text-[20px]">bookmark</span>
</div>
) : (
<Avatar
@@ -589,27 +590,27 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
name={chatName}
size="md"
online={isOnline ? true : undefined}
className="ring-2 ring-transparent group-hover:ring-accent/30 transition-all duration-300 rounded-full"
className="avatar-knot-container group-hover:border-primary/30"
/>
)}
</div>
<div className="min-w-0 text-left">
<h3 className="text-base font-semibold text-white truncate drop-shadow-sm group-hover:text-accent/90 transition-colors">{chatName}</h3>
<p className="text-xs text-zinc-400 truncate">
<h3 className="text-lg font-bold font-headline text-on-surface truncate group-hover:text-primary transition-colors leading-none mb-1">{chatName}</h3>
<p className="text-[11px] font-bold uppercase tracking-widest text-on-surface-variant/50">
{isFavorites
? t('favoritesDescription')
: typingInChat.length > 0
? <span className="text-accent font-medium">{t('typing')}</span>
? <span className="text-primary font-black animate-pulse">{t('typing')}</span>
: isOnline
? <span className="text-emerald-400">{t('online')}</span>
? <span className="text-success">{t('online')}</span>
: chat.type === 'personal' && otherMember?.user.lastSeen
? `${t('lastSeenAt')} ${formatLastSeen(otherMember.user.lastSeen, lang)}`
? `${formatLastSeen(otherMember.user.lastSeen, lang)}`
: chat.type === 'group'
? `${chat.members.length} ${t('members')}`
: ''}
</p>
</div>
</button>
</div>
</div>
<div className="flex items-center gap-1.5 ml-4">
@@ -627,7 +628,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
placeholder={t('searchMessages')}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-full px-3 py-1.5 rounded-lg bg-surface-tertiary text-sm text-white placeholder-zinc-500 border border-border focus:border-accent"
className="w-full px-4 py-2 rounded-xl bg-surface-container text-sm text-on-surface placeholder-on-surface-variant/30 border-none focus:ring-2 focus:ring-primary/20"
/>
</motion.div>
)}
@@ -643,9 +644,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
openSearch();
}
}}
className="p-2 rounded-lg hover:bg-surface-hover transition-colors text-zinc-400 hover:text-white"
className="p-2 rounded-xl hover:bg-surface-container-highest/40 transition-all duration-300 text-on-surface-variant hover:text-primary slide-on-ice"
>
{showSearch ? <X size={18} /> : <Search size={18} />}
{showSearch ? <span className="material-symbols-outlined">close</span> : <span className="material-symbols-outlined">search</span>}
</button>
{!isFavorites && config?.enableCalls && (
@@ -658,8 +659,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
onStartGroupCall?.(chat.id, chat.name || 'Group', 'voice');
}
}}
className="p-2 rounded-lg hover:bg-surface-hover transition-colors text-zinc-400 hover:text-white" title={t('call')}>
<Phone size={18} />
className="p-2 rounded-xl hover:bg-surface-container-highest/40 transition-all duration-300 text-on-surface-variant hover:text-primary slide-on-ice" title={t('call')}>
<span className="material-symbols-outlined">call</span>
</button>
<button
onClick={() => {
@@ -669,8 +670,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
onStartGroupCall?.(chat.id, chat.name || 'Group', 'video');
}
}}
className="p-2 rounded-lg hover:bg-surface-hover transition-colors text-zinc-400 hover:text-white" title={t('videoCall')}>
<Video size={18} />
className="p-2 rounded-xl hover:bg-surface-container-highest/40 transition-all duration-300 text-on-surface-variant hover:text-primary slide-on-ice" title={t('videoCall')}>
<span className="material-symbols-outlined">videocam</span>
</button>
</>
)}
@@ -678,9 +679,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
<div className="relative" ref={topMenuRef}>
<button
onClick={() => setShowTopMenu(!showTopMenu)}
className="p-2 rounded-lg hover:bg-surface-hover transition-colors text-zinc-400 hover:text-white"
className="p-2 rounded-xl hover:bg-surface-container-highest/40 transition-all duration-300 text-on-surface-variant hover:text-primary slide-on-ice"
>
<MoreVertical size={18} />
<span className="material-symbols-outlined">more_vert</span>
</button>
<AnimatePresence>
{showTopMenu && (

View File

@@ -426,12 +426,12 @@ function MessageBubble({
onContextMenu={handleContextMenu}
onDoubleClick={handleReply}
title={t('reply') ? `${t('reply')} (Double Click)` : 'Double click to reply'}
className={`cursor-pointer max-w-full min-w-[40px] rounded-[1.25rem] transition-all duration-300 overflow-hidden ${
className={`cursor-pointer max-w-full min-w-[60px] transition-all duration-500 overflow-hidden ${
!needsFrame
? 'p-0 shadow-none border-none bg-transparent'
: isMine
? 'bubble-sent text-white shadow-sm px-[14px] py-[8px] hover:shadow-md rounded-br-sm'
: 'bubble-received text-zinc-100 shadow-sm px-[14px] py-[8px] hover:shadow-md rounded-bl-[4px]'
? 'bubble-sent px-4 py-2.5 hover:bg-primary/[0.12] rounded-2xl rounded-br-[4px]'
: 'bubble-received px-4 py-2.5 hover:bg-surface-container-highest/80 rounded-2xl rounded-bl-[4px]'
}`}
>
@@ -451,7 +451,7 @@ function MessageBubble({
}
}}
>
<p className={`text-[13.5px] font-semibold mb-0.5 truncate ${isMine ? 'text-white' : 'text-knot-500'}`}>
<p className={`text-[13px] font-bold mb-0.5 truncate ${isMine ? 'text-primary' : 'text-primary'}`}>
{message.replyTo.sender?.displayName || message.replyTo.sender?.userName || message.replyTo.sender?.username || ''}
</p>
<div className="flex items-center gap-1.5">
@@ -481,7 +481,7 @@ function MessageBubble({
</div>
);
})()}
<p className={`text-[13px] line-clamp-2 break-words whitespace-pre-wrap ${isMine ? 'text-white/80' : 'text-zinc-600 dark:text-zinc-300'}`}>
<p className={`text-[13px] line-clamp-2 break-words whitespace-pre-wrap ${isMine ? 'text-on-surface-variant' : 'text-on-surface-variant'}`}>
{message.quote || message.replyTo.content || (message.replyTo.media && message.replyTo.media.length > 0 ? (() => {
const m = message.replyTo.media[0];
if (m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4')) return 'GIF';
@@ -619,14 +619,12 @@ function MessageBubble({
{!message.content && (
<div className="absolute bottom-1.5 right-1.5 z-10 pointer-events-none flex justify-end">
<span className="text-[10px] text-white/80 bg-black/40 shadow-sm px-2 py-0.5 rounded-full flex items-center gap-1 backdrop-blur-md pointer-events-auto">
<span className="text-[10px] font-bold text-on-surface-variant/40 bg-surface-container-highest/40 px-2 py-0.5 rounded-full flex items-center gap-1 backdrop-blur-md pointer-events-auto">
{timeStr}
{isMine && !message.scheduledAt && (
isRead ? (
<CheckCheck size={13} className="text-sky-300" />
) : (
<Check size={13} />
)
<span className={`material-symbols-outlined text-[14px] ${isRead ? 'text-primary fill-1' : 'text-on-surface-variant/40'}`} style={{ fontVariationSettings: `'FILL' ${isRead ? 1 : 0}` }}>
{isRead ? 'done_all' : 'done'}
</span>
)}
</span>
</div>
@@ -677,8 +675,8 @@ function MessageBubble({
<div
key={i}
className={`flex-1 rounded-full transition-colors duration-150 ${isActive
? isMine ? 'bg-white/80' : 'bg-knot-400'
: isMine ? 'bg-white/20' : 'bg-white/10'
? isMine ? 'bg-primary/40' : 'bg-primary'
: isMine ? 'bg-primary/10' : 'bg-on-surface-variant/10'
}`}
style={{ height: `${barHeight}%` }}
/>
@@ -797,17 +795,14 @@ function MessageBubble({
</div>
)}
</div>
<span className={`text-[10.5px] flex-shrink-0 flex items-center gap-0.5 self-end float-right leading-none ${isOnlyEmojis ? '-mb-1' : 'mb-0.5'} ${isMine ? 'text-white/60' : 'text-zinc-500'
}`}>
<span className={`text-[10px] font-bold flex-shrink-0 flex items-center gap-0.5 self-end float-right leading-none ${isOnlyEmojis ? '-mb-1' : 'mb-0.5'} ${isMine ? 'text-primary/60' : 'text-on-surface-variant/40'}`}>
{message.isEdited && <span className="mr-0.5">{t('edited')}</span>}
{message.scheduledAt && <Clock size={11} className="text-amber-400 mr-0.5" />}
{message.scheduledAt && <span className="material-symbols-outlined text-[12px] text-amber-400 mr-0.5">schedule</span>}
{timeStr}
{isMine && !message.scheduledAt && (
isRead ? (
<CheckCheck size={14} className="text-sky-300 ml-0.5" />
) : (
<Check size={14} className="ml-0.5" />
)
<span className={`material-symbols-outlined text-[14px] ${isRead ? 'text-primary fill-1' : 'text-on-surface-variant/40'}`} style={{ fontVariationSettings: `'FILL' ${isRead ? 1 : 0}` }}>
{isRead ? 'done_all' : 'done'}
</span>
)}
</span>
</div>

View File

@@ -589,11 +589,11 @@ export default function MessageInput({ chatId }: MessageInputProps) {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-50 rounded-[2rem] mx-6 mb-6 mt-2 bg-knot-500/10 border-2 border-dashed border-knot-400 backdrop-blur-sm flex items-center justify-center pointer-events-none"
className="absolute inset-0 z-50 rounded-3xl mx-6 mb-6 mt-2 bg-primary/10 border-2 border-dashed border-primary/40 backdrop-blur-sm flex items-center justify-center pointer-events-none"
>
<div className="flex flex-col items-center gap-2 text-knot-300">
<FileText size={32} className="animate-bounce" />
<p className="font-semibold">{t('dropFileHere')}</p>
<div className="flex flex-col items-center gap-4 text-primary">
<span className="material-symbols-outlined text-5xl animate-bounce">upload_file</span>
<p className="font-bold text-lg font-headline uppercase tracking-widest">{t('dropFileHere')}</p>
</div>
</motion.div>
)}
@@ -603,27 +603,25 @@ export default function MessageInput({ chatId }: MessageInputProps) {
<AnimatePresence>
{(replyTo || editingMessage) && (
<motion.div
initial={{ height: 0, opacity: 0, y: 10, scale: 0.95 }}
animate={{ height: 'auto', opacity: 1, y: 0, scale: 1 }}
exit={{ height: 0, opacity: 0, y: 10, scale: 0.95 }}
className="mb-2 max-w-3xl mx-auto overflow-hidden px-1.5"
initial={{ height: 0, opacity: 0, scale: 0.98 }}
animate={{ height: 'auto', opacity: 1, scale: 1 }}
exit={{ height: 0, opacity: 0, scale: 0.98 }}
className="mb-2 max-w-4xl mx-auto overflow-hidden"
>
<div className="flex items-center gap-3 px-4 py-2.5 bg-white/[0.04] backdrop-blur-2xl border border-white/10 rounded-2xl relative shadow-xl">
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-3/5 bg-gradient-to-b from-knot-400 to-purple-500 rounded-r-md" />
<div className="w-6 h-6 rounded-full bg-white/5 flex items-center justify-center flex-shrink-0">
{editingMessage ? (
<Pencil size={12} className="text-knot-400" />
) : (
<Reply size={12} className="text-knot-400" />
)}
<div className="flex items-center gap-4 px-5 py-3 bg-surface-container-high/80 backdrop-blur-2xl border-none rounded-2xl relative shadow-xl slide-on-ice">
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1.5 h-3/5 bg-primary rounded-r-full" />
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-primary text-[20px]">
{editingMessage ? 'edit' : 'reply'}
</span>
</div>
<div className="flex-1 min-w-0 flex flex-col justify-center">
<p className="text-xs font-semibold text-knot-400 mb-0.5">
<p className="text-[11px] font-black tracking-widest uppercase text-primary mb-0.5">
{editingMessage
? t('editing')
: `${t('replyTo')} ${replyTo?.sender?.displayName || replyTo?.sender?.userName || replyTo?.sender?.username || ''}`}
</p>
<div className="text-xs text-zinc-300 truncate opacity-80 border-l border-white/20 pl-2 ml-1">
<div className="text-[13px] text-on-surface-variant truncate opacity-80 pl-1 border-l border-on-surface-variant/10 ml-0.5">
{replyTo?.quote ? `«${replyTo.quote}»` : (editingMessage || replyTo)?.content || t('media') || 'Медиа'}
</div>
</div>
@@ -634,9 +632,9 @@ export default function MessageInput({ chatId }: MessageInputProps) {
setAttachments([]);
setText('');
}}
className="w-7 h-7 rounded-full flex items-center justify-center text-white/40 hover:text-white hover:bg-white/10 transition-colors"
className="w-8 h-8 rounded-full flex items-center justify-center text-on-surface-variant/40 hover:text-primary hover:bg-primary/10 transition-all slide-on-ice"
>
<X size={14} />
<span className="material-symbols-outlined text-[20px]">close</span>
</button>
</div>
</motion.div>
@@ -711,21 +709,23 @@ export default function MessageInput({ chatId }: MessageInputProps) {
{/* Recording UI */}
{isRecording ? (
<div className="flex items-center gap-3">
<div className="flex items-center gap-4 bg-surface-container-high rounded-[2rem] px-6 py-4 w-full max-w-4xl mx-auto shadow-2xl animate-in slide-in-from-bottom-4 slide-on-ice">
<button
onClick={cancelRecording}
className="p-2 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors flex-shrink-0"
className="w-10 h-10 rounded-full flex items-center justify-center text-error hover:bg-error/10 transition-all slide-on-ice"
>
<X size={20} />
<span className="material-symbols-outlined">delete</span>
</button>
<div className="flex-1 flex items-center gap-3">
<span className="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse" />
<span className="text-sm text-zinc-300 font-mono">{formatTime(recordingTime)}</span>
<div className="flex-1 flex items-center gap-0.5 h-6">
<div className="flex-1 flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-error animate-pulse shadow-[0_0_8px_rgba(244,67,54,0.5)]" />
<span className="text-sm font-bold font-mono text-on-surface tracking-tighter w-12">{formatTime(recordingTime)}</span>
</div>
<div className="flex-1 flex items-center gap-[3px] h-8 items-center">
{liveBars.map((h, i) => (
<div
key={i}
className="flex-1 bg-knot-400/60 rounded-full transition-all duration-100"
className="flex-1 bg-primary/40 rounded-full transition-all duration-100"
style={{ height: `${h}%` }}
/>
))}
@@ -733,20 +733,20 @@ export default function MessageInput({ chatId }: MessageInputProps) {
</div>
<button
onClick={stopRecording}
className="p-3 rounded-full bg-accent hover:bg-accent-hover transition-colors text-white flex-shrink-0"
className="w-12 h-12 rounded-2xl bg-primary text-on-primary flex items-center justify-center hover:scale-105 active:scale-95 transition-all shadow-lg shadow-primary/20 slide-on-ice"
>
<Send size={18} />
<span className="material-symbols-outlined">send</span>
</button>
</div>
) : (
<div className="flex items-end gap-1.5 bg-surface-secondary rounded-2xl border border-border/40 px-2 py-1.5 w-full max-w-4xl mx-auto transition-all duration-200 focus-within:border-border/80">
<div className="flex items-end gap-2 bg-surface-container-high rounded-[2rem] px-4 py-2 w-full max-w-4xl mx-auto shadow-sm transition-all duration-500 focus-within:shadow-xl focus-within:bg-surface-container-highest slide-on-ice">
{/* Attach */}
<div className="relative mb-0 flex-shrink-0 self-center">
<button
onClick={() => setShowAttachMenu(!showAttachMenu)}
className="p-2 rounded-full text-zinc-400 hover:text-accent hover:bg-surface-hover transition-colors"
className="w-10 h-10 rounded-full text-on-surface-variant hover:text-primary hover:bg-primary/10 transition-all slide-on-ice flex items-center justify-center"
>
<Paperclip size={22} strokeWidth={1.5} />
<span className="material-symbols-outlined text-[24px]">add</span>
</button>
<AnimatePresence>
{showAttachMenu && (
@@ -887,14 +887,13 @@ export default function MessageInput({ chatId }: MessageInputProps) {
}}
onKeyDown={handleKeyDown}
onContextMenu={handleInputContextMenu}
className="w-full bg-transparent border-none focus:ring-0 text-on-surface placeholder-on-surface-variant/30 text-[15px] leading-relaxed resize-none max-h-[150px] custom-scrollbar scroll-smooth outline-none py-1"
placeholder={attachments.length > 0 ? t('addCaption') : t('message')}
rows={1}
className="w-full resize-none bg-transparent text-[15px] text-white placeholder-zinc-500 leading-[22px] py-[10px] px-2 border-none focus:ring-0 max-h-[150px] outline-none"
/>
</div>
{/* Emoji */}
<div className="relative mb-0 flex-shrink-0 self-center">
{/* Action Buttons */}
<div className="flex items-center gap-1 self-center pb-2 flex-shrink-0">
<button
onClick={() => setShowEmoji(!showEmoji)}
className="p-2 rounded-full text-zinc-400 hover:text-accent hover:bg-surface-hover transition-colors"