Новый стиль
This commit is contained in:
@@ -15,10 +15,10 @@ export default function App() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-surface">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="h-full flex items-center justify-center bg-surface-container-lowest">
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<AppLoader />
|
||||
<p className="text-zinc-500 text-sm">Загрузка...</p>
|
||||
<p className="text-on-surface-variant/40 text-[10px] font-black uppercase tracking-[0.3em] animate-pulse">Knot Loading</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -45,14 +45,14 @@ export default function App() {
|
||||
|
||||
function AppLoader() {
|
||||
return (
|
||||
<div className="relative w-12 h-12">
|
||||
<div className="absolute inset-0 rounded-full border-2 border-transparent border-t-accent animate-spin" />
|
||||
<div className="relative w-16 h-16">
|
||||
<div className="absolute inset-0 rounded-3xl border-2 border-transparent border-t-primary animate-spin shadow-[0_0_15px_rgba(var(--primary-rgb),0.3)]" />
|
||||
<div
|
||||
className="absolute inset-1 rounded-full border-2 border-transparent border-t-accent/70 animate-spin"
|
||||
className="absolute inset-2 rounded-3xl border-2 border-transparent border-t-primary/60 animate-spin"
|
||||
style={{ animationDuration: '0.8s', animationDirection: 'reverse' }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-2 rounded-full border-2 border-transparent border-t-accent/40 animate-spin"
|
||||
className="absolute inset-4 rounded-3xl border-2 border-transparent border-t-primary/30 animate-spin"
|
||||
style={{ animationDuration: '0.6s' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,10 @@ const translations = {
|
||||
// Profile
|
||||
name: 'Имя',
|
||||
username: 'Имя пользователя',
|
||||
chats: 'Чаты',
|
||||
calls: 'Звонки',
|
||||
contacts: 'Контакты',
|
||||
archive: 'Архив',
|
||||
aboutMe: 'О себе',
|
||||
birthday: 'Дата рождения',
|
||||
enterName: 'Введите имя',
|
||||
@@ -300,6 +304,10 @@ const translations = {
|
||||
tellAboutYourself: 'Tell about yourself',
|
||||
removePhoto: 'Remove photo',
|
||||
language: 'Language',
|
||||
chats: 'Chats',
|
||||
calls: 'Calls',
|
||||
contacts: 'Contacts',
|
||||
archive: 'Archive',
|
||||
interfaceLang: 'Interface language',
|
||||
theme: 'Theme',
|
||||
russian: 'Русский',
|
||||
|
||||
@@ -31,7 +31,7 @@ function AvatarInner({ src, name, size = 'md', className = '', online }: AvatarP
|
||||
const gradientClass = generateAvatarColor(name || '');
|
||||
|
||||
return (
|
||||
<div className={`relative shrink-0 ${className}`}>
|
||||
<div className={`relative shrink-0 ${className} rounded-full border-2 border-outline-variant/10 overflow-hidden`}>
|
||||
{src ? (
|
||||
<img
|
||||
src={src}
|
||||
@@ -40,15 +40,15 @@ function AvatarInner({ src, name, size = 'md', className = '', online }: AvatarP
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`${sizeClass} rounded-full bg-gradient-to-br ${gradientClass} flex items-center justify-center text-white font-medium`}
|
||||
className={`${sizeClass} rounded-full bg-gradient-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary font-bold tracking-tighter`}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
{online !== undefined && (
|
||||
<div
|
||||
className={`absolute bottom-0 right-0 ${onlineDotSize[size]} rounded-full border-surface ${
|
||||
online ? 'bg-emerald-500' : 'bg-zinc-500'
|
||||
className={`absolute bottom-0 right-0 ${onlineDotSize[size]} rounded-full border-surface-container ${
|
||||
online ? 'bg-success shadow-[0_0_8px_rgba(76,175,80,0.5)]' : 'bg-on-surface-variant/20'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
59
client-web/src/core/presentation/layouts/GlobalNavBar.tsx
Normal file
59
client-web/src/core/presentation/layouts/GlobalNavBar.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useAuthStore } from '../../../modules/auth/application/authStore';
|
||||
import { useLang } from '../../infrastructure/i18n';
|
||||
|
||||
interface GlobalNavBarProps {
|
||||
activeTab: string;
|
||||
onTabChange: (tab: string) => void;
|
||||
}
|
||||
|
||||
export default function GlobalNavBar({ activeTab, onTabChange }: GlobalNavBarProps) {
|
||||
const { user } = useAuthStore();
|
||||
const { t } = useLang();
|
||||
|
||||
const menuItems = [
|
||||
{ id: 'chats', icon: 'chat', label: t('chats') },
|
||||
{ id: 'calls', icon: 'call', label: t('calls') },
|
||||
{ id: 'contacts', icon: 'contacts', label: t('contacts') },
|
||||
{ id: 'archive', icon: 'archive', label: t('archive') },
|
||||
{ id: 'settings', icon: 'settings', label: t('settings') },
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="fixed left-0 top-0 h-full z-40 bg-surface-container w-20 flex flex-col items-center py-6 slide-on-ice border-none shadow-xl">
|
||||
<div className="mb-10 flex flex-col items-center">
|
||||
<span className="text-2xl font-black text-primary tracking-tighter italic knot-logo-spin">Knot</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 w-full px-2">
|
||||
{menuItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onTabChange(item.id)}
|
||||
className={`flex flex-col items-center justify-center p-3 rounded-xl transition-all duration-300 scale-95 active:scale-90 ${
|
||||
activeTab === item.id
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-on-surface-variant hover:bg-surface-container-high hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined mb-1 ${activeTab === item.id ? 'fill-1' : ''}`}
|
||||
style={{ fontVariationSettings: `'FILL' ${activeTab === item.id ? 1 : 0}` }}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto group cursor-pointer relative">
|
||||
<div className="absolute -inset-1 bg-primary/20 rounded-full opacity-0 group-hover:opacity-100 blur transition-opacity" />
|
||||
<img
|
||||
alt="User Profile"
|
||||
className="relative w-10 h-10 rounded-full border-2 border-outline-variant/10 hover:border-primary/50 transition-colors object-cover"
|
||||
src={user?.avatar || `https://ui-avatars.com/api/?name=${user?.username || 'user'}&background=3096e5&color=fff`}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -98,59 +98,29 @@ export default function Sidebar() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full h-full flex flex-col bg-surface-secondary sm:rounded-[2rem] overflow-hidden border-x sm:border border-border/50 shadow-2xl relative z-10">
|
||||
{/* Шапка */}
|
||||
<div className="h-14 px-3 flex items-center justify-between border-b border-border/40 bg-surface-secondary flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowSideMenu(true)}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-hover transition-colors text-zinc-400 hover:text-white"
|
||||
title={t('menu')}
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-xl bg-accent text-white flex-shrink-0">
|
||||
<MessageSquare size={16} fill="currentColor" strokeWidth={0} />
|
||||
</div>
|
||||
<h1 className="text-[16px] font-semibold text-white truncate tracking-tight">Knot Messenger</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowNewChat(true)}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-hover transition-colors text-zinc-400 hover:text-white"
|
||||
title={t('newChat')}
|
||||
>
|
||||
<Plus size={22} />
|
||||
</button>
|
||||
<div className="w-full h-full flex flex-col bg-surface-container-low overflow-hidden border-none relative z-10 slide-on-ice">
|
||||
{/* Title Section (Minimal) */}
|
||||
<div className="px-6 py-8 pb-4">
|
||||
<h1 className="text-2xl font-bold font-headline text-white tracking-tight leading-none">{t('chats')}</h1>
|
||||
</div>
|
||||
|
||||
{/* Поиск */}
|
||||
<div className="px-3 py-2 bg-surface-secondary">
|
||||
{/* Search */}
|
||||
<div className="px-6 mb-4">
|
||||
<div className="relative group">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500 transition-colors" />
|
||||
<span className="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-on-surface-variant/40 group-focus-within:text-primary transition-colors text-[20px]">search</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('searchChats')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-8 py-1.5 rounded-[10px] bg-surface-tertiary text-[14px] text-white placeholder-zinc-500 border border-transparent focus:border-accent/50 hover:bg-surface-hover transition-all outline-none"
|
||||
className="w-full pl-12 pr-4 py-3 rounded-xl bg-surface-container-highest text-sm text-on-surface placeholder-on-surface-variant/30 border-none focus:ring-2 focus:ring-primary/20 hover:bg-surface-bright transition-all outline-none"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-full text-zinc-400 hover:text-white transition-colors"
|
||||
title={t('clear')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Story circles */}
|
||||
{(storyGroups.length > 0 || true) && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 overflow-x-auto scrollbar-hide border-b border-border/20 flex-shrink-0">
|
||||
<div className="flex items-center gap-4 px-6 py-2 pb-6 overflow-x-auto scrollbar-hide flex-shrink-0">
|
||||
{/* Add story circle */}
|
||||
<button
|
||||
onClick={() => setShowCreateStory(true)}
|
||||
@@ -197,10 +167,10 @@ export default function Sidebar() {
|
||||
)}
|
||||
|
||||
{/* Список чатов */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto px-2 custom-scrollbar">
|
||||
{filteredChats.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-zinc-500 gap-3 px-6">
|
||||
<MessageSquare size={40} className="opacity-30" />
|
||||
<div className="flex flex-col items-center justify-center h-full text-on-surface-variant/40 gap-3 px-6">
|
||||
<span className="material-symbols-outlined text-5xl opacity-20">chat_bubble</span>
|
||||
<p className="text-sm text-center">
|
||||
{searchQuery ? t('nothingFound') : t('noChats')}
|
||||
</p>
|
||||
@@ -211,6 +181,15 @@ export default function Sidebar() {
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Float Action Button equivalent for Web */}
|
||||
<button
|
||||
onClick={() => setShowNewChat(true)}
|
||||
className="absolute bottom-6 right-6 w-14 h-14 rounded-2xl bg-primary text-on-primary-container shadow-lg shadow-primary/20 flex items-center justify-center hover:scale-105 active:scale-95 transition-transform slide-on-ice"
|
||||
title={t('newChat')}
|
||||
>
|
||||
<span className="material-symbols-outlined text-2xl">add</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Модалки */}
|
||||
|
||||
@@ -1,340 +1,234 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--primary: #9acbff;
|
||||
--primary-container: #3096e5;
|
||||
--on-primary: #003355;
|
||||
--on-primary-container: #e0f2ff;
|
||||
|
||||
--secondary: #c7c6ca;
|
||||
--secondary-container: #48494c;
|
||||
--on-secondary: #2f3033;
|
||||
--on-secondary-container: #e3e2e6;
|
||||
|
||||
--tertiary: #53e16f;
|
||||
--tertiary-container: #006e25;
|
||||
--on-tertiary: #00390a;
|
||||
--on-tertiary-container: #affca0;
|
||||
|
||||
--error: #ffb4ab;
|
||||
--error-container: #93000a;
|
||||
--on-error: #690005;
|
||||
--on-error-container: #ffdad6;
|
||||
|
||||
--surface: #131313;
|
||||
--surface-container-lowest: #0b0b0b;
|
||||
--surface-container-low: #1b1b1b;
|
||||
--surface-container: #201f1f;
|
||||
--surface-container-high: #2a2a2a;
|
||||
--surface-container-highest: #353535;
|
||||
|
||||
--on-surface: #f5f5fa;
|
||||
--on-surface-variant: #b4b4c3;
|
||||
--outline: #3c3c4b;
|
||||
--outline-variant: #2d2d37;
|
||||
|
||||
--ease-ice: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--shadow-ambient: 0 40px 80px -20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--font-headline: 'Inter', sans-serif;
|
||||
--font-body: 'Inter', sans-serif;
|
||||
|
||||
--color-knot-50: #eef2ff;
|
||||
--color-knot-100: #e0e7ff;
|
||||
--color-knot-200: #c7d2fe;
|
||||
--color-knot-300: #a5b4fc;
|
||||
--color-knot-400: #818cf8;
|
||||
--color-knot-500: #6366f1;
|
||||
--color-knot-600: #4f46e5;
|
||||
--color-knot-700: #4338ca;
|
||||
--color-knot-800: #3730a3;
|
||||
--color-knot-900: #312e81;
|
||||
--color-knot-950: #1e1b4b;
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-container: var(--primary-container);
|
||||
--color-on-primary: var(--on-primary);
|
||||
--color-on-primary-container: var(--on-primary-container);
|
||||
|
||||
--color-surface: #09090b;
|
||||
--color-surface-secondary: #111113;
|
||||
--color-surface-tertiary: #1a1a1e;
|
||||
--color-surface-hover: #222226;
|
||||
--color-border: rgba(255, 255, 255, 0.08);
|
||||
--color-border-light: rgba(255, 255, 255, 0.12);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-container: var(--secondary-container);
|
||||
--color-on-secondary: var(--on-secondary);
|
||||
--color-on-secondary-container: var(--on-secondary-container);
|
||||
|
||||
--color-accent: #6366f1;
|
||||
--color-accent-hover: #818cf8;
|
||||
--color-accent-light: rgba(99, 102, 241, 0.15);
|
||||
--color-tertiary: var(--tertiary);
|
||||
--color-tertiary-container: var(--tertiary-container);
|
||||
--color-on-tertiary: var(--on-tertiary);
|
||||
--color-on-tertiary-container: var(--on-tertiary-container);
|
||||
|
||||
--color-error: var(--error);
|
||||
--color-error-container: var(--error-container);
|
||||
--color-on-error: var(--on-error);
|
||||
--color-on-error-container: var(--on-error-container);
|
||||
|
||||
--color-surface: var(--surface);
|
||||
--color-surface-container-lowest: var(--surface-container-lowest);
|
||||
--color-surface-container-low: var(--surface-container-low);
|
||||
--color-surface-container: var(--surface-container);
|
||||
--color-surface-container-high: var(--surface-container-high);
|
||||
--color-surface-container-highest: var(--surface-container-highest);
|
||||
|
||||
--color-on-surface: var(--on-surface);
|
||||
--color-on-surface-variant: var(--on-surface-variant);
|
||||
--color-outline: var(--outline);
|
||||
--color-outline-variant: var(--outline-variant);
|
||||
|
||||
--animate-kinetic-in: slideInUp 0.6s var(--ease-ice) forwards;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
/* Global resets and base styles */
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'Roboto', 'Helvetica Neue', Arial, sans-serif);
|
||||
background-color: var(--color-surface, #17212b);
|
||||
color: #fafafa;
|
||||
background-color: #131313 !important;
|
||||
/* Force to Obsidian surface */
|
||||
color: #f5f5fa !important;
|
||||
/* Force to high-contrast white */
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
/* Base style for all elements if they inherit color incorrectly */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
/* Kinetic Core Components */
|
||||
.knot-card {
|
||||
background-color: var(--surface-container-low) !important;
|
||||
border-radius: 2rem;
|
||||
/* 32px */
|
||||
transition: all 0.4s var(--ease-ice);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes pulse-soft {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
.knot-card-active {
|
||||
background-color: var(--surface-container) !important;
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
.knot-input-group {
|
||||
background-color: rgba(53, 53, 53, 0.4) !important;
|
||||
border-radius: 1.75rem;
|
||||
padding: 1.25rem 1.6rem;
|
||||
border: 1px solid rgba(154, 203, 255, 0.1);
|
||||
transition: all 0.4s var(--ease-ice);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.15s ease-out;
|
||||
.knot-input-group:focus-within {
|
||||
background-color: rgba(53, 53, 53, 0.6) !important;
|
||||
border-color: #9acbff !important;
|
||||
box-shadow: 0 0 0 1px #9acbff, 0 0 40px rgba(154, 203, 255, 0.1);
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
.knot-input-group input {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
width: 100%;
|
||||
color: #f5f5fa !important;
|
||||
}
|
||||
|
||||
.knot-button-primary {
|
||||
background: linear-gradient(135deg, #9acbff, #3096e5) !important;
|
||||
color: #e0f2ff !important;
|
||||
border-radius: 1.5rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.05em;
|
||||
transition: all 0.4s var(--ease-ice);
|
||||
}
|
||||
|
||||
.knot-button-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
filter: brightness(1.1);
|
||||
box-shadow: 0 24px 32px -12px rgba(48, 150, 229, 0.4);
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.glass-effect {
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(19, 19, 19, 0.6);
|
||||
}
|
||||
|
||||
.glass-strong {
|
||||
background: rgba(17, 17, 19, 0.85);
|
||||
backdrop-filter: blur(40px);
|
||||
-webkit-backdrop-filter: blur(40px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
.slide-on-ice {
|
||||
transition: all 0.4s var(--ease-ice);
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6, #a855f7);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
.animate-kinetic {
|
||||
animation: var(--animate-kinetic-in);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--outline-variant);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* Overrides for legacy code */
|
||||
.bg-surface {
|
||||
background-color: var(--surface) !important;
|
||||
}
|
||||
|
||||
.bg-surface-container-low {
|
||||
background-color: var(--surface-container-low) !important;
|
||||
}
|
||||
|
||||
.bg-surface-container-lowest {
|
||||
background-color: var(--surface-container-lowest) !important;
|
||||
}
|
||||
|
||||
.bg-surface-container-high {
|
||||
background-color: var(--surface-container-high) !important;
|
||||
}
|
||||
|
||||
.text-on-surface {
|
||||
color: var(--on-surface) !important;
|
||||
}
|
||||
|
||||
.text-on-surface-variant {
|
||||
color: var(--on-surface-variant) !important;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--primary) !important;
|
||||
}
|
||||
|
||||
.text-accent {
|
||||
color: #9acbff !important;
|
||||
}
|
||||
|
||||
/* Bubble overrides */
|
||||
.bubble-sent {
|
||||
background: #3b82f6;
|
||||
background: linear-gradient(135deg, #9acbff, #3096e5) !important;
|
||||
color: #e0f2ff !important;
|
||||
border-radius: 1.5rem;
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.bubble-received {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: var(--surface-container-highest) !important;
|
||||
color: var(--on-surface) !important;
|
||||
border-radius: 1.5rem;
|
||||
border-bottom-left-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.bubble-sent ::selection {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
/* ==================== */
|
||||
/* Animation Keyframes */
|
||||
/* ==================== */
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-20px); }
|
||||
}
|
||||
|
||||
@keyframes call-wave {
|
||||
0% { transform: scale(1); opacity: 0.6; }
|
||||
100% { transform: scale(2.5); opacity: 0; }
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-float-delayed {
|
||||
animation: float 6s ease-in-out 3s infinite;
|
||||
}
|
||||
|
||||
.animate-call-wave {
|
||||
animation: call-wave 2s ease-out infinite;
|
||||
}
|
||||
|
||||
.animate-call-wave-delayed {
|
||||
animation: call-wave 2s ease-out 1s infinite;
|
||||
}
|
||||
|
||||
/* ==================== */
|
||||
/* Chat Themes */
|
||||
/* ==================== */
|
||||
|
||||
.chat-theme-midnight {
|
||||
background-color: #0f0f13;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='100' height='100' viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M11 18c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm48 25c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm-43-7c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm63 31c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3z' fill='%23ffffff' fill-opacity='0.02' fill-rule='evenodd'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.chat-theme-ocean {
|
||||
background-color: #0b172a;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%233b82f6' fill-opacity='0.05' fill-rule='evenodd'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.chat-theme-forest {
|
||||
background-color: #0f1c15;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='20' height='20' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%2310b981' fill-opacity='0.04' fill-rule='evenodd'%3E%3Ccircle cx='3' cy='3' r='3'/%3E%3Ccircle cx='13' cy='13' r='3'/%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.chat-theme-sunset {
|
||||
background: linear-gradient(#1f111a, #150a0f);
|
||||
position: relative;
|
||||
}
|
||||
.chat-theme-sunset::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; right: 0; bottom: 0; left: 0;
|
||||
pointer-events: none;
|
||||
background-image: radial-gradient(circle at 50% 150%, rgba(236, 72, 153, 0.1), transparent 60%);
|
||||
}
|
||||
|
||||
.chat-theme-classic {
|
||||
background-color: #121215;
|
||||
}
|
||||
|
||||
.chat-theme-neon {
|
||||
background-color: #0b0f19;
|
||||
position: relative;
|
||||
}
|
||||
.chat-theme-neon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; right: 0; bottom: 0; left: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle 400px at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(139, 92, 246, 0.15), transparent 80%);
|
||||
transition: background 0.15s ease-out;
|
||||
}
|
||||
|
||||
.chat-theme-aurora {
|
||||
background: linear-gradient(135deg, #022c22, #064e3b);
|
||||
position: relative;
|
||||
}
|
||||
.chat-theme-aurora::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; right: 0; bottom: 0; left: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle 600px at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(16, 185, 129, 0.2), transparent 70%);
|
||||
transition: background 0.15s ease-out;
|
||||
}
|
||||
|
||||
.chat-theme-cyber {
|
||||
background-color: #000;
|
||||
background-image:
|
||||
linear-gradient(rgba(245, 158, 11, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(245, 158, 11, 0.03) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
position: relative;
|
||||
}
|
||||
.chat-theme-cyber::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; right: 0; bottom: 0; left: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle 300px at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(245, 158, 11, 0.15), transparent);
|
||||
transition: background 0.15s ease-out;
|
||||
}
|
||||
|
||||
.chat-theme-glass {
|
||||
background-color: #0d1117;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chat-theme-glass::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 50vw;
|
||||
height: 50vh;
|
||||
left: var(--mouse-x, 50%);
|
||||
top: var(--mouse-y, 50%);
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle, rgba(59, 130, 246, 0.2), transparent 60%);
|
||||
filter: blur(80px);
|
||||
transition: left 0.5s cubic-bezier(0.2, 0.8, 0.2, 1), top 0.5s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.chat-theme-void {
|
||||
background-color: #000;
|
||||
position: relative;
|
||||
}
|
||||
.chat-theme-void::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; right: 0; bottom: 0; left: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle 120px at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(255, 255, 255, 0.05), transparent);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.chat-bg {
|
||||
background-color: #09090b;
|
||||
background-image:
|
||||
radial-gradient(ellipse at 20% 50%, rgba(99, 102, 241, 0.06) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 80% 20%, rgba(139, 92, 246, 0.05) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 60% 80%, rgba(168, 85, 247, 0.04) 0%, transparent 50%),
|
||||
url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%236366f1' fill-opacity='0.03'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(99, 102, 241, 0.3);
|
||||
text-shadow: 0 0 8px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
input:focus, textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Focus-visible ring for keyboard navigation accessibility */
|
||||
:focus-visible {
|
||||
outline: 2px solid rgba(99, 102, 241, 0.6);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button:focus:not(:focus-visible),
|
||||
a:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* emoji-mart dark theme overrides */
|
||||
em-emoji-picker {
|
||||
--em-rgb-background: 23, 33, 43; /* Telegram surface color */
|
||||
--em-rgb-input: 30, 44, 58;
|
||||
--em-rgb-color: 240, 240, 240;
|
||||
--border-radius: 0 0 16px 16px;
|
||||
border: none !important;
|
||||
|
||||
/* Overrides for category navigation to remove blue line and add soft bg */
|
||||
--category-icon-active-border-color: transparent !important;
|
||||
--category-icon-active-color: #5288c1 !important;
|
||||
}
|
||||
|
||||
em-emoji-picker::part(category-icons) {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
em-emoji-picker::part(category-icon) {
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
em-emoji-picker::part(category-icon):hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
em-emoji-picker::part(category-icon-active) {
|
||||
background-color: rgba(82, 136, 193, 0.15); /* Accent light bg */
|
||||
}
|
||||
|
||||
/* Highlight for quoted messages */
|
||||
@keyframes highlight-glow {
|
||||
0% { box-shadow: 0 0 0 3px rgba(82, 136, 193, 0.8), 0 0 20px rgba(82, 136, 193, 0.6); }
|
||||
20% { box-shadow: 0 0 0 4px rgba(82, 136, 193, 1), 0 0 30px rgba(82, 136, 193, 0.8); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(82, 136, 193, 0), 0 0 0 rgba(82, 136, 193, 0); }
|
||||
}
|
||||
|
||||
.highlight-message {
|
||||
animation: highlight-glow 3s ease-out forwards;
|
||||
}
|
||||
/* Autofill fix - prevent browser blue background on dark theme */
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
input:-webkit-autofill:active {
|
||||
-webkit-box-shadow: 0 0 0 1000px #0b0b0b inset !important;
|
||||
-webkit-text-fill-color: #f5f5fa !important;
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
@@ -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