feat: opt web ux
This commit is contained in:
@@ -1,12 +1,202 @@
|
||||
import { useState } from 'react';
|
||||
import { Navigate, Outlet, NavLink, Link, useLocation } from 'react-router-dom';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Navigate, Outlet, NavLink, Link, useLocation, useParams, useOutletContext } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { apiFetch } from '../lib/api';
|
||||
import ThemeToggle from '../components/ThemeToggle';
|
||||
import SettingsDialog from '../components/SettingsDialog';
|
||||
|
||||
type LayoutContext = { onOpenSettings: () => void };
|
||||
export function useLayoutContext() { return useOutletContext<LayoutContext>(); }
|
||||
|
||||
type ProjectSummary = {
|
||||
id: string; name: string; description: string | null;
|
||||
_count: { endpoints: number; modules: number };
|
||||
};
|
||||
|
||||
function UserDropdown({ user, logout, onOpenSettings }: { user: { name: string; email: string }; logout: () => void; onOpenSettings: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const initials = user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-bg-tertiary transition-colors"
|
||||
>
|
||||
<div className="w-7 h-7 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-[10px] font-bold tracking-wide">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="hidden md:block text-left">
|
||||
<div className="text-[13px] font-medium text-text-primary leading-tight">{user.name}</div>
|
||||
</div>
|
||||
<svg className="w-3.5 h-3.5 text-text-muted hidden md:block" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="user-dropdown">
|
||||
{/* User info */}
|
||||
<div className="px-3 py-2.5 border-b border-border-muted">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-9 h-9 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-[11px] font-bold tracking-wide shrink-0">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-medium text-text-primary truncate">{user.name}</div>
|
||||
<div className="text-[11px] text-text-muted truncate">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={() => { setOpen(false); onOpenSettings(); }}
|
||||
className="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-[13px] text-text-secondary hover:text-text-primary hover:bg-bg-tertiary transition-colors mx-1"
|
||||
style={{ width: 'calc(100% - 8px)' }}
|
||||
>
|
||||
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setOpen(false); logout(); }}
|
||||
className="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-[13px] text-text-muted hover:text-danger hover:bg-danger-muted transition-colors mx-1"
|
||||
style={{ width: 'calc(100% - 8px)' }}
|
||||
>
|
||||
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectSidebar() {
|
||||
const location = useLocation();
|
||||
const params = useParams();
|
||||
const activeProjectId = params.id;
|
||||
|
||||
const { data: projects, isLoading } = useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: () => apiFetch<ProjectSummary[]>('/projects'),
|
||||
});
|
||||
|
||||
const isProjectsRoot = location.pathname === '/';
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:flex w-[240px] shrink-0 flex-col border-r border-border-default bg-bg-sidebar">
|
||||
{/* Sidebar header */}
|
||||
<div className="px-4 h-12 flex items-center justify-between border-b border-border-muted shrink-0">
|
||||
<span className="section-label">Projects</span>
|
||||
</div>
|
||||
|
||||
{/* Project list */}
|
||||
<nav className="flex-1 overflow-y-auto px-2 py-2 space-y-0.5">
|
||||
<NavLink
|
||||
to="/"
|
||||
end
|
||||
className={`flex items-center gap-2 px-2.5 py-[7px] rounded-lg text-[13px] font-medium transition-all duration-150 ${
|
||||
isProjectsRoot
|
||||
? 'bg-accent-muted text-accent'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-bg-tertiary'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-[15px] h-[15px] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
All Projects
|
||||
</NavLink>
|
||||
|
||||
{projects && projects.length > 0 && (
|
||||
<div className="border-t border-border-muted my-2!" />
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="space-y-1.5 px-1">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-8 rounded-lg skeleton" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projects?.map((p) => (
|
||||
<NavLink
|
||||
key={p.id}
|
||||
to={`/projects/${p.id}`}
|
||||
className={`flex items-center gap-2 px-2.5 py-[7px] rounded-lg text-[13px] transition-all duration-150 group ${
|
||||
activeProjectId === p.id
|
||||
? 'bg-accent-muted text-accent font-medium'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-bg-tertiary'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-[15px] h-[15px] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
<span className="truncate">{p.name}</span>
|
||||
<span className="ml-auto text-[11px] text-text-muted opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
{p._count.endpoints}
|
||||
</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function OnboardingBanner({ onOpenSettings }: { onOpenSettings: () => void }) {
|
||||
const [dismissed, setDismissed] = useState(() => localStorage.getItem('agent-fox-onboarding-dismissed') === 'true');
|
||||
const { data: keyStatus } = useQuery({
|
||||
queryKey: ['api-key-status'],
|
||||
queryFn: () => apiFetch<{ hasKey: boolean }>('/auth/api-key/status'),
|
||||
});
|
||||
|
||||
if (dismissed || keyStatus?.hasKey) return null;
|
||||
// Still loading
|
||||
if (!keyStatus) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-6 p-4 rounded-xl bg-accent-muted border border-accent/20 flex items-center gap-4 animate-fade-in">
|
||||
<svg className="w-5 h-5 text-accent shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[13px] text-text-primary font-medium">Welcome! Generate an API key to start using MCP services.</p>
|
||||
<p className="text-[12px] text-text-secondary mt-0.5">You'll need an API key to connect your LLM client to your projects.</p>
|
||||
</div>
|
||||
<button onClick={onOpenSettings} className="btn-primary shrink-0 text-[13px] py-1.5">
|
||||
Generate API Key
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setDismissed(true); localStorage.setItem('agent-fox-onboarding-dismissed', 'true'); }}
|
||||
className="p-1 rounded text-text-muted hover:text-text-primary transition-colors shrink-0"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const { user, loading, logout } = useAuth();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -17,112 +207,90 @@ export default function Layout() {
|
||||
}
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
|
||||
const initials = user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
|
||||
const isSettings = location.pathname === '/settings';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-secondary flex">
|
||||
{/* Mobile overlay */}
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 z-40 bg-overlay lg:hidden" onClick={() => setSidebarOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className={`fixed inset-y-0 left-0 z-50 w-[220px] bg-bg-sidebar border-r border-border-default flex flex-col transition-transform duration-200 lg:translate-x-0 lg:static lg:z-auto ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}>
|
||||
{/* Brand */}
|
||||
<div className="px-4 h-14 flex items-center gap-2.5 border-b border-border-default">
|
||||
<div className="w-7 h-7 rounded-lg bg-accent flex items-center justify-center shadow-sm">
|
||||
<svg className="w-[14px] h-[14px] text-white" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-semibold text-[15px] text-text-primary tracking-[-0.01em]">Agent Fox</span>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-2.5 py-3 space-y-0.5">
|
||||
<NavLink
|
||||
to="/"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2.5 px-2.5 py-[7px] rounded-lg text-[13px] font-medium transition-all duration-150 ${
|
||||
isActive && !isSettings
|
||||
? 'bg-accent-muted text-accent'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-bg-tertiary'
|
||||
}`
|
||||
}
|
||||
<div className="h-screen bg-bg-secondary flex flex-col overflow-hidden">
|
||||
{/* Top Header — fixed */}
|
||||
<header className="h-14 border-b border-border-default bg-bg-sidebar flex items-center px-4 lg:px-5 shrink-0 z-30">
|
||||
{/* Left: mobile menu + logo */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="lg:hidden p-1.5 -ml-1.5 text-text-secondary hover:text-text-primary rounded-md"
|
||||
>
|
||||
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
Projects
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2.5 px-2.5 py-[7px] rounded-lg text-[13px] font-medium transition-all duration-150 ${
|
||||
isActive
|
||||
? 'bg-accent-muted text-accent'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-bg-tertiary'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
Settings
|
||||
</NavLink>
|
||||
</nav>
|
||||
|
||||
{/* Bottom section */}
|
||||
<div className="px-2.5 pb-3 space-y-2.5">
|
||||
<div className="px-1">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div className="border-t border-border-default pt-2.5">
|
||||
<Link
|
||||
to="/settings"
|
||||
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg hover:bg-bg-tertiary transition-colors"
|
||||
>
|
||||
<div className="w-7 h-7 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-[10px] font-bold tracking-wide">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-medium text-text-primary truncate leading-tight">{user.name}</div>
|
||||
<div className="text-[11px] text-text-muted truncate leading-tight mt-0.5">{user.email}</div>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex items-center gap-2.5 w-full px-2.5 py-[7px] rounded-lg text-[13px] text-text-muted hover:text-danger hover:bg-danger-muted transition-colors mt-0.5"
|
||||
>
|
||||
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Mobile header */}
|
||||
<header className="lg:hidden h-14 border-b border-border-default bg-bg-sidebar px-4 flex items-center">
|
||||
<button onClick={() => setSidebarOpen(true)} className="p-2 -ml-2 text-text-secondary hover:text-text-primary">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="ml-3 font-semibold text-[15px] text-text-primary">Agent Fox</span>
|
||||
</header>
|
||||
<main className="flex-1 p-5 lg:p-8 overflow-auto">
|
||||
<div className="animate-fade-in">
|
||||
<Outlet />
|
||||
<Link to="/" className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-lg bg-accent flex items-center justify-center shadow-sm">
|
||||
<svg className="w-[14px] h-[14px] text-white" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-semibold text-[15px] text-text-primary tracking-[-0.01em]">Agent Fox</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Right: theme toggle + user */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<div className="w-px h-5 bg-border-default mx-1" />
|
||||
<UserDropdown user={user} logout={logout} onOpenSettings={() => setSettingsOpen(true)} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Body: sidebar + main — fills remaining height */}
|
||||
<div className="flex-1 flex min-h-0">
|
||||
{/* Mobile sidebar overlay */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="fixed inset-0 z-40 bg-overlay lg:hidden" onClick={() => setMobileMenuOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* Mobile sidebar */}
|
||||
<aside className={`fixed inset-y-0 left-0 z-50 w-[260px] bg-bg-sidebar border-r border-border-default flex flex-col transition-transform duration-200 lg:hidden ${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full'}`}>
|
||||
<div className="px-4 h-14 flex items-center gap-2.5 border-b border-border-default">
|
||||
<div className="w-7 h-7 rounded-lg bg-accent flex items-center justify-center shadow-sm">
|
||||
<svg className="w-[14px] h-[14px] text-white" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-semibold text-[15px] text-text-primary tracking-[-0.01em]">Agent Fox</span>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto px-2.5 py-3 space-y-0.5">
|
||||
<NavLink
|
||||
to="/"
|
||||
end
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2.5 px-2.5 py-[7px] rounded-lg text-[13px] font-medium transition-all duration-150 ${
|
||||
isActive
|
||||
? 'bg-accent-muted text-accent'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-bg-tertiary'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
Projects
|
||||
</NavLink>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Desktop project sidebar — stays fixed, has its own scroll */}
|
||||
<ProjectSidebar />
|
||||
|
||||
{/* Main content — only this area scrolls */}
|
||||
<main className="flex-1 overflow-y-auto min-w-0">
|
||||
<div className="p-5 lg:p-8 animate-fade-in">
|
||||
<OnboardingBanner onOpenSettings={() => setSettingsOpen(true)} />
|
||||
<Outlet context={{ onOpenSettings: () => setSettingsOpen(true) } satisfies LayoutContext} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Settings dialog */}
|
||||
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user