Админка рабочая

This commit is contained in:
Халимов Рустам
2026-03-31 15:43:24 +03:00
parent 8025340e45
commit 4faa7561a0
6 changed files with 583 additions and 516 deletions
@@ -1,3 +1,4 @@
using System.Text.Json.Serialization;
using Knot.Contracts.Settings.Application.Abstractions; using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Settings.Application.DTOs; using Knot.Contracts.Settings.Application.DTOs;
using Knot.Modules.Admin.Application.Admin.Commands; using Knot.Modules.Admin.Application.Admin.Commands;
@@ -11,7 +12,9 @@ using Microsoft.AspNetCore.Routing;
namespace Knot.Host.Presentation.Endpoints; namespace Knot.Host.Presentation.Endpoints;
public record KlipyTestDto(string ApiKey, string AppName); public record KlipyTestDto(
[property: JsonPropertyName("apiKey")] string ApiKey,
[property: JsonPropertyName("appName")] string AppName);
public record ResetPasswordRequest(string NewPassword); public record ResetPasswordRequest(string NewPassword);
public static class AdminEndpoints public static class AdminEndpoints
@@ -34,7 +37,16 @@ public static class AdminEndpoints
group.MapPost("settings/test-klipy", async ([FromBody] KlipyTestDto dto, ISender sender, CancellationToken ct) => group.MapPost("settings/test-klipy", async ([FromBody] KlipyTestDto dto, ISender sender, CancellationToken ct) =>
{ {
Console.WriteLine($"[Admin] TestKlipy received: ApiKey={(string.IsNullOrEmpty(dto.ApiKey) ? "EMPTY" : "present")}, AppName={(string.IsNullOrEmpty(dto.AppName) ? "EMPTY" : dto.AppName)}");
if (string.IsNullOrEmpty(dto.ApiKey) || string.IsNullOrEmpty(dto.AppName))
{
Console.WriteLine($"[Admin] TestKlipy: Missing required fields - ApiKey={dto?.ApiKey}, AppName={dto?.AppName}");
return Results.BadRequest(new { error = "ApiKey and AppName are required" });
}
var result = await sender.Send(new TestKlipyConnectionCommand(dto.ApiKey, dto.AppName), ct); var result = await sender.Send(new TestKlipyConnectionCommand(dto.ApiKey, dto.AppName), ct);
Console.WriteLine($"[Admin] TestKlipy result: IsSuccess={result.IsSuccess}, Error={result.Error?.Description}");
return result.IsSuccess ? Results.Ok(new { success = true }) : Results.BadRequest(new { error = result.Error.Description }); return result.IsSuccess ? Results.Ok(new { success = true }) : Results.BadRequest(new { error = result.Error.Description });
}); });
@@ -2,11 +2,36 @@ using Microsoft.Extensions.DependencyInjection;
using Knot.Modules.Klipy.Application.Abstractions; using Knot.Modules.Klipy.Application.Abstractions;
using Knot.Modules.Klipy.Infrastructure.External; using Knot.Modules.Klipy.Infrastructure.External;
// Псевдонимы для устранения неоднозначности
using ContractIKlipyClient = Knot.Contracts.Klipy.Application.Abstractions.IKlipyClient;
using ModuleIKlipyClient = Knot.Modules.Klipy.Application.Abstractions.IKlipyClient;
namespace Knot.Modules.Klipy; namespace Knot.Modules.Klipy;
public static class DependencyInjection { public static class DependencyInjection
public static IServiceCollection AddKlipyModule(this IServiceCollection services) { {
services.AddHttpClient<IKlipyClient, KlipyClient>(); public static IServiceCollection AddKlipyModule(this IServiceCollection services)
{
services.AddHttpClient<ModuleIKlipyClient, KlipyClient>();
// Также регистрируем contract-интерфейс для Admin модуля
services.AddScoped<ContractIKlipyClient>(sp =>
new ContractKlipyClientAdapter(sp.GetRequiredService<ModuleIKlipyClient>()));
return services; return services;
} }
} }
// Адаптер для преобразования между интерфейсами
internal sealed class ContractKlipyClientAdapter : ContractIKlipyClient
{
private readonly ModuleIKlipyClient _inner;
public ContractKlipyClientAdapter(ModuleIKlipyClient inner)
{
_inner = inner;
}
public async Task<bool> TestConnectionAsync(string apiKey, string appName, CancellationToken cancellationToken = default)
{
return await _inner.TestConnectionAsync(apiKey, appName, cancellationToken);
}
}
@@ -20,14 +20,21 @@ public sealed class KlipyClient : Knot.Modules.Klipy.Application.Abstractions.IK
{ {
try try
{ {
var request = new HttpRequestMessage(HttpMethod.Get, $"https://api.klipy.com/api/v1/{appName}/gifs/trending?page=1&per_page=1&customer_id=knot_admin_test"); var url = $"https://api.klipy.com/api/v1/{apiKey}/gifs/trending?page=1&per_page=1&customer_id={appName}&locale=en";
request.Headers.Add("X-KLIPY-API-KEY", apiKey); Console.WriteLine($"[Klipy] TestConnection URL: {url}");
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("User-Agent", "KnotMessenger/1.0");
request.Headers.Add("Accept", "application/json");
using var response = await _httpClient.SendAsync(request, ct); using var response = await _httpClient.SendAsync(request, ct);
var responseBody = await response.Content.ReadAsStringAsync(ct);
Console.WriteLine($"[Klipy] TestConnection response: {response.StatusCode}, body: {responseBody}");
return response.IsSuccessStatusCode; return response.IsSuccessStatusCode;
} }
catch catch (Exception ex)
{ {
Console.WriteLine($"[Klipy] TestConnection exception: {ex.Message}");
return false; return false;
} }
} }
@@ -8,5 +8,6 @@
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" /> <ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\..\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" /> <ProjectReference Include="..\..\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" />
<ProjectReference Include="..\..\Contracts\Settings\Knot.Contracts.Settings.csproj" /> <ProjectReference Include="..\..\Contracts\Settings\Knot.Contracts.Settings.csproj" />
<ProjectReference Include="..\..\Contracts\Klipy\Knot.Contracts.Klipy.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -47,7 +47,13 @@ export class HttpClient {
throw new Error(errorMessage); throw new Error(errorMessage);
} }
const jsonData = await response.json(); // Handle empty responses (e.g., 200 OK with no body)
const text = await response.text();
if (!text) {
return undefined as T;
}
const jsonData = JSON.parse(text);
return deepNormalize(jsonData); return deepNormalize(jsonData);
} }
} }
@@ -181,6 +181,9 @@ const translations = {
banUser: 'Ban User', banUser: 'Ban User',
unbanUser: 'Unban User', unbanUser: 'Unban User',
isBanned: 'Banned', isBanned: 'Banned',
blockUser: 'Block User',
unblockUser: 'Unblock User',
blockedTooltip: 'Blocked',
authSecurity: 'Security', authSecurity: 'Security',
bio: 'Bio', bio: 'Bio',
messagesSent: 'Messages', messagesSent: 'Messages',
@@ -348,6 +351,9 @@ const translations = {
banUser: 'Забанить', banUser: 'Забанить',
unbanUser: 'Разбанить', unbanUser: 'Разбанить',
isBanned: 'Забанен', isBanned: 'Забанен',
blockUser: 'Заблокировать',
unblockUser: 'Разблокировать',
blockedTooltip: 'Заблокирован',
authSecurity: 'Безопасность', authSecurity: 'Безопасность',
bio: 'О себе', bio: 'О себе',
messagesSent: 'Сообщения', messagesSent: 'Сообщения',
@@ -541,7 +547,7 @@ export default function AdminPage() {
const [tzSearch, setTzSearch] = useState(''); const [tzSearch, setTzSearch] = useState('');
const [showTzDropdown, setShowTzDropdown] = useState(false); const [showTzDropdown, setShowTzDropdown] = useState(false);
const [toast, setToast] = useState<{message: string, type: 'success' | 'error'} | null>(null); const [toast, setToast] = useState<{ message: string, type: 'success' | 'error' } | null>(null);
const showToast = (message: string, type: 'success' | 'error' = 'success') => { const showToast = (message: string, type: 'success' | 'error' = 'success') => {
setToast({ message, type }); setToast({ message, type });
@@ -551,7 +557,7 @@ export default function AdminPage() {
const generatePassword = () => { const generatePassword = () => {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()'; const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()';
let pass = ''; let pass = '';
for(let i=0; i<12; i++) pass += chars[Math.floor(Math.random() * chars.length)]; for (let i = 0; i < 12; i++) pass += chars[Math.floor(Math.random() * chars.length)];
return pass; return pass;
}; };
@@ -625,21 +631,21 @@ export default function AdminPage() {
try { try {
const res = await httpClient.request<Stats>('/admin/dashboard'); const res = await httpClient.request<Stats>('/admin/dashboard');
setStats(res); setStats(res);
} catch {} } catch { }
}; };
const fetchSettings = async () => { const fetchSettings = async () => {
try { try {
const res = await httpClient.request<Conf>('/admin/settings'); const res = await httpClient.request<Conf>('/admin/settings');
setConfig(res); setConfig(res);
} catch {} } catch { }
}; };
const fetchTimezones = async () => { const fetchTimezones = async () => {
try { try {
const res = await httpClient.request<TimezoneDto[]>('/admin/timezones'); const res = await httpClient.request<TimezoneDto[]>('/admin/timezones');
setTimezones(res); setTimezones(res);
} catch {} } catch { }
}; };
const saveSettings = async () => { const saveSettings = async () => {
@@ -789,7 +795,7 @@ export default function AdminPage() {
try { try {
const res = await httpClient.request<AppUser[]>(`/admin/users?query=${encodeURIComponent(q)}`); const res = await httpClient.request<AppUser[]>(`/admin/users?query=${encodeURIComponent(q)}`);
setUsers(res); setUsers(res);
} catch {} finally { } catch { } finally {
setIsSearching(false); setIsSearching(false);
} }
}; };
@@ -798,7 +804,7 @@ export default function AdminPage() {
try { try {
const res = await httpClient.request<AppUser>(`/admin/users/${id}`); const res = await httpClient.request<AppUser>(`/admin/users/${id}`);
setSelectedUser(res); setSelectedUser(res);
} catch {} } catch { }
}; };
const handleLogin = async (e: React.FormEvent) => { const handleLogin = async (e: React.FormEvent) => {
@@ -837,8 +843,8 @@ export default function AdminPage() {
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-accent">{t.loginTitle}</h1> <h1 className="text-2xl font-bold text-accent">{t.loginTitle}</h1>
<div className="flex gap-2"> <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('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> <button type="button" onClick={() => setLang('ru')} className={`text-sm ${lang === 'ru' ? 'text-accent font-bold' : 'text-gray-500'}`}>RU</button>
</div> </div>
</div> </div>
<input <input
@@ -914,7 +920,7 @@ export default function AdminPage() {
<div className="flex justify-between items-end mb-6"> <div className="flex justify-between items-end mb-6">
<div> <div>
<h3 className="text-gray-400 text-[10px] mb-1 flex items-center gap-2 uppercase tracking-[0.2em] font-black"> <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} <Database className="w-4 h-4 text-accent" /> {t.storageUsed}
</h3> </h3>
<div className="text-4xl font-black flex items-baseline gap-2"> <div className="text-4xl font-black flex items-baseline gap-2">
{formatBytes(stats.storageUsedBytes)} {formatBytes(stats.storageUsedBytes)}
@@ -940,7 +946,7 @@ export default function AdminPage() {
<div className="bg-surface border border-white/10 p-8 rounded-3xl lg:col-span-1"> <div className="bg-surface border border-white/10 p-8 rounded-3xl lg:col-span-1">
<h3 className="text-gray-400 text-[10px] mb-4 flex items-center gap-2 uppercase tracking-[0.2em] font-black"> <h3 className="text-gray-400 text-[10px] mb-4 flex items-center gap-2 uppercase tracking-[0.2em] font-black">
<Users className="w-4 h-4 text-accent"/> {t.totalRegistered} <Users className="w-4 h-4 text-accent" /> {t.totalRegistered}
</h3> </h3>
<div className="text-5xl font-black mb-2 tracking-tighter">{stats.totalUsers}</div> <div className="text-5xl font-black mb-2 tracking-tighter">{stats.totalUsers}</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -984,7 +990,7 @@ export default function AdminPage() {
</h1> </h1>
<div className="ml-auto flex gap-2"> <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"> <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"/> <Activity className="w-5 h-5" />
</button> </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"> <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} {t.saveChanges}
@@ -999,18 +1005,18 @@ export default function AdminPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-8"> <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"> <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> <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}})} /> <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 } })} />
<Hint>{t.hints.domainUrl}</Hint> <Hint>{t.hints.domainUrl}</Hint>
</label> </label>
<label className="flex flex-col gap-1.5 group"> <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> <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" /> <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" />
<Hint>{t.hints.adminRoute}</Hint> <Hint>{t.hints.adminRoute}</Hint>
</label> </label>
<div className="flex flex-col gap-2 bg-white/[0.02] p-4 rounded-2xl border border-white/5"> <div className="flex flex-col gap-2 bg-white/[0.02] p-4 rounded-2xl border border-white/5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-gray-200 font-semibold">{t.enableReg}</span> <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}})} /> <Toggle checked={config.system.enableRegistration} onChange={v => setConfig({ ...config, system: { ...config.system, enableRegistration: v } })} />
</div> </div>
<Hint>{t.hints.enableRegistration}</Hint> <Hint>{t.hints.enableRegistration}</Hint>
</div> </div>
@@ -1022,7 +1028,7 @@ export default function AdminPage() {
value={config.system.serverTimezone} value={config.system.serverTimezone}
onFocus={() => setShowTzDropdown(true)} onFocus={() => setShowTzDropdown(true)}
onChange={e => { onChange={e => {
setConfig({...config, system: {...config.system, serverTimezone: e.target.value}}); setConfig({ ...config, system: { ...config.system, serverTimezone: e.target.value } });
setTzSearch(e.target.value); setTzSearch(e.target.value);
}} }}
placeholder="UTC" placeholder="UTC"
@@ -1043,7 +1049,7 @@ export default function AdminPage() {
<button <button
key={tz.id} key={tz.id}
onClick={() => { onClick={() => {
setConfig({...config, system: {...config.system, serverTimezone: tz.id}}); setConfig({ ...config, system: { ...config.system, serverTimezone: tz.id } });
setTzSearch(''); setTzSearch('');
setShowTzDropdown(false); setShowTzDropdown(false);
}} }}
@@ -1073,35 +1079,35 @@ export default function AdminPage() {
<div className="bg-surface border border-white/10 rounded-2xl p-8 flex flex-col gap-8"> <div className="bg-surface border border-white/10 rounded-2xl p-8 flex flex-col gap-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-xl font-semibold opacity-30 uppercase tracking-widest text-sm">{t.stories}</h3> <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}})} /> <Toggle checked={config.stories.enabled} onChange={v => setConfig({ ...config, stories: { ...config.stories, enabled: v } })} />
</div> </div>
{config.stories.enabled && ( {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"> <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"> <label className="flex flex-col gap-1.5 group">
<span className="text-xs font-bold text-gray-500 uppercase ml-1">{t.maxStories}</span> <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}})} /> <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 } })} />
<Hint>{t.hints.maxStories}</Hint> <Hint>{t.hints.maxStories}</Hint>
</label> </label>
<label className="flex flex-col gap-1.5 group"> <label className="flex flex-col gap-1.5 group">
<span className="text-xs font-bold text-gray-500 uppercase ml-1">{t.storyLifetime}</span> <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}})} /> <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 } })} />
<Hint>{t.hints.storyLifetime}</Hint> <Hint>{t.hints.storyLifetime}</Hint>
</label> </label>
<div className="flex flex-col gap-2 bg-white/[0.02] p-4 rounded-2xl border border-white/5"> <div className="flex flex-col gap-2 bg-white/[0.02] p-4 rounded-2xl border border-white/5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-gray-200 font-semibold">{t.textStories}</span> <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}})} /> <Toggle checked={config.stories.textStoriesEnabled} onChange={v => setConfig({ ...config, stories: { ...config.stories, textStoriesEnabled: v } })} />
</div> </div>
<Hint>{t.hints.textStoryDuration}</Hint> <Hint>{t.hints.textStoryDuration}</Hint>
</div> </div>
<label className="flex flex-col gap-1.5 group"> <label className="flex flex-col gap-1.5 group">
<span className="text-xs font-bold text-gray-500 uppercase ml-1">{t.mediaStoryDuration}</span> <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}})} /> <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 } })} />
<Hint>{t.hints.mediaStoryDuration}</Hint> <Hint>{t.hints.mediaStoryDuration}</Hint>
</label> </label>
<label className="flex flex-col gap-1.5 group"> <label className="flex flex-col gap-1.5 group">
<span className="text-xs font-bold text-gray-500 uppercase ml-1">{t.maxMediaSize}</span> <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)}})} /> <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) } })} />
<Hint>{t.hints.maxMediaSize}</Hint> <Hint>{t.hints.maxMediaSize}</Hint>
</label> </label>
</div> </div>
@@ -1118,13 +1124,13 @@ export default function AdminPage() {
<div className="flex flex-col gap-2 bg-white/[0.02] p-4 rounded-2xl border border-white/5"> <div className="flex flex-col gap-2 bg-white/[0.02] p-4 rounded-2xl border border-white/5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-gray-200 font-semibold">{t.supportGroups}</span> <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}})} /> <Toggle checked={config.chats.supportGroups} onChange={v => setConfig({ ...config, chats: { ...config.chats, supportGroups: v } })} />
</div> </div>
<Hint>{t.hints.supportGroups}</Hint> <Hint>{t.hints.supportGroups}</Hint>
</div> </div>
<label className="flex flex-col gap-1.5 group"> <label className="flex flex-col gap-1.5 group">
<span className="text-xs font-bold text-gray-500 uppercase ml-1">{t.maxGroupMembers}</span> <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}})} /> <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 } })} />
<Hint>{t.hints.maxGroupMembers}</Hint> <Hint>{t.hints.maxGroupMembers}</Hint>
</label> </label>
</div> </div>
@@ -1132,21 +1138,21 @@ export default function AdminPage() {
<div className="flex flex-col gap-2 bg-white/[0.02] p-4 rounded-2xl border border-white/5"> <div className="flex flex-col gap-2 bg-white/[0.02] p-4 rounded-2xl border border-white/5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-gray-200 font-semibold">{t.autoClean}</span> <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}})} /> <Toggle checked={config.chats.enableAutoClean} onChange={v => setConfig({ ...config, chats: { ...config.chats, enableAutoClean: v } })} />
</div> </div>
<Hint>{t.hints.autoClean}</Hint> <Hint>{t.hints.autoClean}</Hint>
</div> </div>
<div className="flex flex-col gap-2 bg-white/[0.02] p-4 rounded-2xl border border-white/5"> <div className="flex flex-col gap-2 bg-white/[0.02] p-4 rounded-2xl border border-white/5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-gray-200 font-semibold">{t.chatToGroup}</span> <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}})} /> <Toggle checked={config.chats.allowChatToGroupConversion} onChange={v => setConfig({ ...config, chats: { ...config.chats, allowChatToGroupConversion: v } })} />
</div> </div>
<Hint>{t.hints.chatToGroup}</Hint> <Hint>{t.hints.chatToGroup}</Hint>
</div> </div>
<div className="flex flex-col gap-2 bg-white/[0.02] p-4 rounded-2xl border border-white/5"> <div className="flex flex-col gap-2 bg-white/[0.02] p-4 rounded-2xl border border-white/5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-gray-200 font-semibold">{t.enableFolders}</span> <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}})} /> <Toggle checked={config.chats.enableFolders} onChange={v => setConfig({ ...config, chats: { ...config.chats, enableFolders: v } })} />
</div> </div>
<Hint>{t.hints.enableFolders}</Hint> <Hint>{t.hints.enableFolders}</Hint>
</div> </div>
@@ -1163,17 +1169,17 @@ export default function AdminPage() {
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<label className="flex flex-col gap-1.5 group"> <label className="flex flex-col gap-1.5 group">
<span className="text-xs font-bold text-gray-500 uppercase ml-1">{t.dailyLimit}</span> <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}})} /> <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 } })} />
<Hint>{t.hints.dailyLimit}</Hint> <Hint>{t.hints.dailyLimit}</Hint>
</label> </label>
<label className="flex flex-col gap-1.5 group"> <label className="flex flex-col gap-1.5 group">
<span className="text-xs font-bold text-gray-500 uppercase ml-1">{t.chatLimit}</span> <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}})} /> <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 } })} />
<Hint>{t.hints.chatLimit}</Hint> <Hint>{t.hints.chatLimit}</Hint>
</label> </label>
<label className="flex flex-col gap-1.5 group"> <label className="flex flex-col gap-1.5 group">
<span className="text-xs font-bold text-gray-500 uppercase ml-1">{t.maxFileSize}</span> <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)}})} /> <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) } })} />
<Hint>{t.hints.maxFileSize}</Hint> <Hint>{t.hints.maxFileSize}</Hint>
</label> </label>
</div> </div>
@@ -1195,7 +1201,7 @@ export default function AdminPage() {
<div key={item.k} className="flex flex-col gap-1 bg-white/[0.01] p-2 px-3 rounded-xl border border-white/[0.03]"> <div key={item.k} className="flex flex-col gap-1 bg-white/[0.01] p-2 px-3 rounded-xl border border-white/[0.03]">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-gray-300 font-medium">{item.l}</span> <span className="text-sm text-gray-300 font-medium">{item.l}</span>
<Toggle checked={(config.messages as any)[item.k]} onChange={v => setConfig({...config, messages: {...config.messages, [item.k]: v}})} /> <Toggle checked={(config.messages as any)[item.k]} onChange={v => setConfig({ ...config, messages: { ...config.messages, [item.k]: v } })} />
</div> </div>
{item.h && <Hint>{item.h}</Hint>} {item.h && <Hint>{item.h}</Hint>}
</div> </div>
@@ -1212,7 +1218,7 @@ export default function AdminPage() {
<h3 className="text-xl font-bold text-accent uppercase tracking-tighter flex items-center gap-2"> <h3 className="text-xl font-bold text-accent uppercase tracking-tighter flex items-center gap-2">
<Globe className="w-5 h-5" /> {t.webRtc} <Globe className="w-5 h-5" /> {t.webRtc}
</h3> </h3>
<Toggle checked={config.webRtc.enabled} onChange={v => setConfig({...config, webRtc: {...config.webRtc, enabled: v}})} /> <Toggle checked={config.webRtc.enabled} onChange={v => setConfig({ ...config, webRtc: { ...config.webRtc, enabled: v } })} />
</div> </div>
<Hint>{t.hints.webRtc}</Hint> <Hint>{t.hints.webRtc}</Hint>
@@ -1221,14 +1227,14 @@ export default function AdminPage() {
<div className="bg-white/[0.03] p-4 rounded-2xl border border-white/5 flex flex-col gap-3"> <div className="bg-white/[0.03] p-4 rounded-2xl border border-white/5 flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs font-bold text-gray-400 uppercase">{t.videoCalls}</span> <span className="text-xs font-bold text-gray-400 uppercase">{t.videoCalls}</span>
<Toggle checked={config.webRtc.enableVideoCalls} onChange={v => setConfig({...config, webRtc: {...config.webRtc, enableVideoCalls: v}})} /> <Toggle checked={config.webRtc.enableVideoCalls} onChange={v => setConfig({ ...config, webRtc: { ...config.webRtc, enableVideoCalls: v } })} />
</div> </div>
<Hint>{t.hints.videoCalls}</Hint> <Hint>{t.hints.videoCalls}</Hint>
</div> </div>
<div className="bg-white/[0.03] p-4 rounded-2xl border border-white/5 flex flex-col gap-3"> <div className="bg-white/[0.03] p-4 rounded-2xl border border-white/5 flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs font-bold text-gray-400 uppercase">{t.screenSharing}</span> <span className="text-xs font-bold text-gray-400 uppercase">{t.screenSharing}</span>
<Toggle checked={config.webRtc.enableScreenSharing} onChange={v => setConfig({...config, webRtc: {...config.webRtc, enableScreenSharing: v}})} /> <Toggle checked={config.webRtc.enableScreenSharing} onChange={v => setConfig({ ...config, webRtc: { ...config.webRtc, enableScreenSharing: v } })} />
</div> </div>
<Hint>{t.hints.screenSharing}</Hint> <Hint>{t.hints.screenSharing}</Hint>
</div> </div>
@@ -1244,22 +1250,22 @@ export default function AdminPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<label className="flex flex-col gap-1.5 group"> <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> <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="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" />
<Hint>{t.hints.turnHost}</Hint> <Hint>{t.hints.turnHost}</Hint>
</label> </label>
<label className="flex flex-col gap-1.5 group"> <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> <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="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 } })} />
<Hint>{t.hints.turnPort}</Hint> <Hint>{t.hints.turnPort}</Hint>
</label> </label>
<label className="flex flex-col gap-1.5 group"> <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> <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="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 } })} />
<Hint>{t.hints.turnUser}</Hint> <Hint>{t.hints.turnUser}</Hint>
</label> </label>
<label className="flex flex-col gap-1.5 group"> <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> <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="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 } })} />
<Hint>{t.hints.turnSecret}</Hint> <Hint>{t.hints.turnSecret}</Hint>
</label> </label>
</div> </div>
@@ -1279,7 +1285,7 @@ export default function AdminPage() {
<h3 className="text-xl font-bold flex items-center gap-3"> <h3 className="text-xl font-bold flex items-center gap-3">
<div className="w-6 h-6 bg-purple-500 rounded-md" /> {t.klipy} <div className="w-6 h-6 bg-purple-500 rounded-md" /> {t.klipy}
</h3> </h3>
<Toggle checked={config.klipy.enabled} onChange={v => setConfig({...config, klipy: {...config.klipy, enabled: v}})} /> <Toggle checked={config.klipy.enabled} onChange={v => setConfig({ ...config, klipy: { ...config.klipy, enabled: v } })} />
</div> </div>
<Hint>{t.hints.klipy}</Hint> <Hint>{t.hints.klipy}</Hint>
{config.klipy.enabled && ( {config.klipy.enabled && (
@@ -1287,12 +1293,12 @@ export default function AdminPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<label className="flex flex-col gap-1.5 group"> <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> <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}})} /> <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 } })} />
<Hint>{t.hints.appName}</Hint> <Hint>{t.hints.appName}</Hint>
</label> </label>
<label className="flex flex-col gap-1.5 group"> <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> <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" /> <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" />
<Hint>{t.hints.apiKey}</Hint> <Hint>{t.hints.apiKey}</Hint>
</label> </label>
</div> </div>
@@ -1318,7 +1324,7 @@ export default function AdminPage() {
<div className="bg-white/[0.03] p-5 rounded-2xl border border-white/5 flex flex-col gap-4"> <div className="bg-white/[0.03] p-5 rounded-2xl border border-white/5 flex flex-col gap-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-semibold text-gray-200">{t.telegramImport}</span> <span className="text-sm font-semibold text-gray-200">{t.telegramImport}</span>
<Toggle checked={config.import.enableTelegramImport} onChange={v => setConfig({...config, import: {...config.import, enableTelegramImport: v}})} /> <Toggle checked={config.import.enableTelegramImport} onChange={v => setConfig({ ...config, import: { ...config.import, enableTelegramImport: v } })} />
</div> </div>
<Hint>{t.hints.import}</Hint> <Hint>{t.hints.import}</Hint>
<p className="text-xs text-gray-500 italic mt-2"> <p className="text-xs text-gray-500 italic mt-2">
@@ -1334,7 +1340,7 @@ export default function AdminPage() {
<div className="bg-surface border border-white/10 rounded-2xl p-8 flex flex-col gap-8"> <div className="bg-surface border border-white/10 rounded-2xl p-8 flex flex-col gap-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-xl font-semibold opacity-30 uppercase tracking-widest text-sm">{t.federation}</h3> <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}})} /> <Toggle checked={config.federation.enabled} onChange={v => setConfig({ ...config, federation: { ...config.federation, enabled: v } })} />
</div> </div>
{config.federation.enabled && ( {config.federation.enabled && (
@@ -1361,7 +1367,7 @@ export default function AdminPage() {
{config.federation.allowedDomains.map(d => ( {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"> <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> <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> <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> </div>
))} ))}
{config.federation.allowedDomains.length === 0 && ( {config.federation.allowedDomains.length === 0 && (
@@ -1393,17 +1399,21 @@ export default function AdminPage() {
{users.length === 0 && !isSearching && <div className="text-center py-10 text-gray-500">{t.noUsersFound}</div>} {users.length === 0 && !isSearching && <div className="text-center py-10 text-gray-500">{t.noUsersFound}</div>}
{users.map(u => ( {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={() => 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'}`}>
<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-accent/20 flex items-center justify-center text-accent font-bold shrink-0">
{u.avatar ? <img src={u.avatar} className="w-full h-full rounded-full" /> : u.username.charAt(0).toUpperCase()} {u.avatar ? <img src={u.avatar} className="w-full h-full rounded-full" /> : u.username.charAt(0).toUpperCase()}
</div> </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>
)}
</div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-semibold truncate text-white">{u.displayName}</div> <div className="font-semibold truncate text-white">{u.displayName}</div>
<div className="text-xs text-gray-500 truncate">@{u.username}</div> <div className="text-xs text-gray-500 truncate">@{u.username}</div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{u.isBanned && (
<span className="text-[10px] bg-red-500/20 text-red-400 px-2 py-0.5 rounded-full font-bold">{t.isBanned.toUpperCase()}</span>
)}
{u.isOnline ? ( {u.isOnline ? (
<span className="text-[10px] bg-green-500/20 text-green-400 px-2 py-0.5 rounded-full font-bold">{t.online.toUpperCase()}</span> <span className="text-[10px] bg-green-500/20 text-green-400 px-2 py-0.5 rounded-full font-bold">{t.online.toUpperCase()}</span>
) : ( ) : (
@@ -1429,14 +1439,20 @@ export default function AdminPage() {
{selectedUser ? ( {selectedUser ? (
<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"> <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="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-20 h-20 rounded-full bg-accent/20 flex items-center justify-center text-2xl font-bold text-accent">
{selectedUser.avatar ? <img src={selectedUser.avatar} className="w-full h-full rounded-full" /> : selectedUser.username.charAt(0).toUpperCase()} {selectedUser.avatar ? <img src={selectedUser.avatar} className="w-full h-full rounded-full" /> : selectedUser.username.charAt(0).toUpperCase()}
</div> </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>
)}
</div>
<div> <div>
<h2 className="text-xl font-bold text-white leading-tight">{selectedUser.displayName}</h2> <h2 className="text-xl font-bold text-white leading-tight">{selectedUser.displayName}</h2>
<div className="text-gray-500 text-sm">@{selectedUser.username}</div> <div className="text-gray-500 text-sm">@{selectedUser.username}</div>
</div> </div>
{selectedUser.isBanned && <span className="text-xs bg-red-500 text-white px-2 py-1 rounded-lg font-bold uppercase tracking-wider">{t.isBanned}</span>}
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -1462,11 +1478,11 @@ export default function AdminPage() {
)} )}
{selectedUser.isBanned ? ( {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-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">
{t.unban} {t.unblockUser}
</button> </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-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">
{t.ban} {t.blockUser}
</button> </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-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">
@@ -1488,22 +1504,22 @@ export default function AdminPage() {
{showAddUserModal && ( {showAddUserModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm" onClick={() => setShowAddUserModal(false)}> <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"> <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> <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> <h2 className="text-xl font-bold text-white mb-4">{t.addUser}</h2>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<label className="flex flex-col gap-1 text-xs text-gray-400"> <label className="flex flex-col gap-1 text-xs text-gray-400">
{t.username} {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} /> <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>
<label className="flex flex-col gap-1 text-xs text-gray-400"> <label className="flex flex-col gap-1 text-xs text-gray-400">
{t.displayName} {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} /> <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>
<label className="flex flex-col gap-1 text-xs text-gray-400"> <label className="flex flex-col gap-1 text-xs text-gray-400">
{t.password} {t.password}
<div className="flex gap-2"> <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} /> <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> <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> </div>
</label> </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"> <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">
@@ -1517,7 +1533,7 @@ export default function AdminPage() {
{/* Global Toast */} {/* Global Toast */}
{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"> <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.type === 'success' ? <CheckCircle className="text-green-400 w-5 h-5" /> : <XCircle className="text-red-400 w-5 h-5" />}
{toast.message} {toast.message}
</div> </div>
)} )}