Правки админки, вход
This commit is contained in:
@@ -543,6 +543,9 @@ export default function AdminPage() {
|
||||
const [newUser, setNewUser] = useState({ username: '', displayName: '', password: '' });
|
||||
const [generatedPass, setGeneratedPass] = useState('');
|
||||
|
||||
// Delete User Modal State
|
||||
const [deleteUserModal, setDeleteUserModal] = useState<{ userId: string; username: string } | null>(null);
|
||||
|
||||
const [timezones, setTimezones] = useState<TimezoneDto[]>([]);
|
||||
const [tzSearch, setTzSearch] = useState('');
|
||||
const [showTzDropdown, setShowTzDropdown] = useState(false);
|
||||
@@ -616,7 +619,7 @@ export default function AdminPage() {
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (userId: string) => {
|
||||
if (!confirm(t.confirmDelete)) return;
|
||||
setDeleteUserModal(null);
|
||||
try {
|
||||
await httpClient.request(`/admin/users/${userId}`, { method: 'DELETE' });
|
||||
showToast(t.successSave, 'success');
|
||||
@@ -843,7 +846,7 @@ export default function AdminPage() {
|
||||
<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>
|
||||
@@ -889,7 +892,7 @@ export default function AdminPage() {
|
||||
<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>
|
||||
@@ -1440,14 +1443,13 @@ 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={() => 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'
|
||||
}`}
|
||||
<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-primary/20 flex items-center justify-center text-primary font-black shrink-0 slide-on-ice border border-primary/10">
|
||||
@@ -1535,7 +1537,7 @@ export default function AdminPage() {
|
||||
{t.blockUser}
|
||||
</button>
|
||||
)}
|
||||
<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">
|
||||
<button onClick={() => setDeleteUserModal({ userId: selectedUser.id, username: selectedUser.username })} 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>
|
||||
@@ -1566,7 +1568,7 @@ export default function AdminPage() {
|
||||
<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} />
|
||||
<input value={newUser.displayName} onChange={e => setNewUser({ ...newUser, displayName: e.target.value })} placeholder={t.displayNamePlaceholder} />
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2">
|
||||
@@ -1588,9 +1590,9 @@ export default function AdminPage() {
|
||||
|
||||
{/* Global Toast */}
|
||||
{toast && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, x: '-50%' }}
|
||||
animate={{ opacity: 1, y: 0, x: '-50%' }}
|
||||
<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"
|
||||
>
|
||||
@@ -1605,24 +1607,78 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Delete User Confirmation Modal */}
|
||||
<AnimatePresence>
|
||||
{deleteUserModal && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => setDeleteUserModal(null)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
className="bg-surface-container-high border border-outline-variant/20 rounded-3xl p-8 max-w-md w-full shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="w-12 h-12 rounded-2xl bg-error/10 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-error text-2xl">warning</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-black">{lang === 'ru' ? 'Удаление пользователя' : 'Delete User'}</h3>
|
||||
<p className="text-sm text-on-surface-variant/60">{lang === 'ru' ? 'Это действие необратимо' : 'This action is irreversible'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-container-lowest/50 rounded-2xl p-4 mb-6">
|
||||
<p className="text-sm text-on-surface-variant/60 mb-2">{lang === 'ru' ? 'Вы собираетесь удалить:' : 'You are about to delete:'}</p>
|
||||
<p className="text-lg font-black text-on-surface">@{deleteUserModal.username}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-on-surface-variant/40 mb-8">
|
||||
{t.confirmDelete}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setDeleteUserModal(null)}
|
||||
className="flex-1 py-4 rounded-2xl bg-surface-container-highest hover:bg-surface-container-high transition-all font-black uppercase text-[12px] tracking-widest"
|
||||
>
|
||||
{t.cancel}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteUser(deleteUserModal.userId)}
|
||||
className="flex-1 py-4 rounded-2xl bg-error hover:bg-error/90 text-white font-black uppercase text-[12px] tracking-widest transition-all"
|
||||
>
|
||||
{lang === 'ru' ? 'Удалить' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavButton({ icon, label, active, onClick }: { icon: string, label: string, active: boolean, onClick: () => void }) {
|
||||
return (
|
||||
<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'
|
||||
}`}
|
||||
<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"
|
||||
<motion.div
|
||||
layoutId="nav-active"
|
||||
className="absolute left-0 w-1 h-6 bg-primary rounded-full"
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
@@ -1636,18 +1692,16 @@ function NavButton({ icon, label, active, onClick }: { icon: string, label: stri
|
||||
|
||||
function Toggle({ checked, onChange }: { checked: boolean, onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`w-11 h-6 rounded-full relative transition-all duration-300 ${
|
||||
checked ? 'bg-primary' : 'bg-surface-container-highest'
|
||||
}`}
|
||||
<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'
|
||||
}`}
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -3,17 +3,37 @@ import type { User } from '../../../core/domain/types';
|
||||
|
||||
export class AuthApi {
|
||||
static async login(username: string, password: string) {
|
||||
return httpClient.request<{ token: string; user: User }>('/auth/login', {
|
||||
const response = await httpClient.request<{ accessToken: string; userId: string; username: string; displayName: string }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
return {
|
||||
token: response.accessToken,
|
||||
user: {
|
||||
id: response.userId,
|
||||
username: response.username,
|
||||
displayName: response.displayName,
|
||||
avatar: null
|
||||
} as User
|
||||
};
|
||||
}
|
||||
|
||||
static async register(username: string, displayName: string, password: string, bio?: string) {
|
||||
return httpClient.request<{ token: string; user: User }>('/auth/register', {
|
||||
const response = await httpClient.request<{ accessToken: string; userId: string; username: string; displayName: string }>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, displayName, password, bio }),
|
||||
});
|
||||
|
||||
return {
|
||||
token: response.accessToken,
|
||||
user: {
|
||||
id: response.userId,
|
||||
username: response.username,
|
||||
displayName: response.displayName,
|
||||
avatar: null
|
||||
} as User
|
||||
};
|
||||
}
|
||||
|
||||
static async getMe() {
|
||||
|
||||
Reference in New Issue
Block a user