Новый стиль
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user