285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
import { useState, useCallback } from "react";
|
|
import { Link, useNavigate } from "react-router-dom";
|
|
import {
|
|
Users,
|
|
Search,
|
|
Circle,
|
|
Shield,
|
|
UserRound,
|
|
Crown,
|
|
Server,
|
|
Mail,
|
|
ChevronRight,
|
|
} from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Loading, ErrorState, EmptyState } from "@/components/States";
|
|
import { CreateUserDialog } from "@/components/CreateUserDialog";
|
|
import { api } from "@/lib/api";
|
|
import { useAsync } from "@/lib/hooks";
|
|
import { formatAbsolute } from "@/lib/format";
|
|
import { cn } from "@/lib/utils";
|
|
import type { UserView } from "@/lib/types";
|
|
|
|
export function UsersPage() {
|
|
const { t, i18n } = useTranslation("admin");
|
|
const locale = i18n.language;
|
|
const navigate = useNavigate();
|
|
|
|
const [query, setQuery] = useState("");
|
|
const [roleFilter, setRoleFilter] = useState("");
|
|
const [disabledFilter, setDisabledFilter] = useState("");
|
|
const [page, setPage] = useState(0);
|
|
const pageSize = 20;
|
|
|
|
const fetchUsers = useCallback(
|
|
() =>
|
|
api.listUsers({
|
|
query: query || undefined,
|
|
role: (roleFilter || undefined) as "admin" | "user" | undefined,
|
|
disabled: (disabledFilter || undefined) as "true" | "false" | undefined,
|
|
limit: pageSize,
|
|
offset: page * pageSize,
|
|
}),
|
|
[query, roleFilter, disabledFilter, page],
|
|
);
|
|
|
|
const { data, error, loading, reload } = useAsync(fetchUsers, [fetchUsers]);
|
|
|
|
const handleSearch = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setPage(0);
|
|
reload();
|
|
};
|
|
|
|
const totalPages = data ? Math.max(1, Math.ceil(data.total / pageSize)) : 1;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<Users className="h-6 w-6 text-primary" />
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">{t("users_title")}</h1>
|
|
<p className="text-sm text-muted-foreground">{t("users_subtitle")}</p>
|
|
</div>
|
|
</div>
|
|
<CreateUserDialog
|
|
onCreated={(id) => {
|
|
reload();
|
|
navigate(`/admin/users/${id}`);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<Card>
|
|
<CardContent className="p-3">
|
|
<form onSubmit={handleSearch} className="flex flex-wrap items-center gap-2">
|
|
<div className="relative min-w-[200px] flex-1">
|
|
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
placeholder={t("users_search_placeholder")}
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
className="pl-8 h-9 text-sm"
|
|
/>
|
|
</div>
|
|
<Select value={roleFilter} onValueChange={(v) => { setRoleFilter(v); setPage(0); }}>
|
|
<SelectTrigger className="h-9 w-[120px] text-sm">
|
|
<SelectValue placeholder={t("users_filter_role_all")} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="">{t("users_filter_role_all")}</SelectItem>
|
|
<SelectItem value="owner">{t("users_role_owner")}</SelectItem>
|
|
<SelectItem value="admin">{t("users_role_admin")}</SelectItem>
|
|
<SelectItem value="user">{t("users_role_user")}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Select value={disabledFilter} onValueChange={(v) => { setDisabledFilter(v); setPage(0); }}>
|
|
<SelectTrigger className="h-9 w-[130px] text-sm">
|
|
<SelectValue placeholder={t("users_filter_status_all")} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="">{t("users_filter_status_all")}</SelectItem>
|
|
<SelectItem value="false">{t("users_status_active")}</SelectItem>
|
|
<SelectItem value="true">{t("users_status_disabled")}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Button type="submit" variant="outline" size="sm" className="h-9 text-sm">
|
|
{t("users_search_btn")}
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Table */}
|
|
{loading && !data ? (
|
|
<Loading />
|
|
) : error ? (
|
|
<ErrorState error={error} onRetry={reload} />
|
|
) : !data || data.users.length === 0 ? (
|
|
<EmptyState
|
|
title={t("users_empty_title")}
|
|
hint={t("users_empty_hint")}
|
|
>
|
|
<CreateUserDialog
|
|
onCreated={(id) => {
|
|
reload();
|
|
navigate(`/admin/users/${id}`);
|
|
}}
|
|
/>
|
|
</EmptyState>
|
|
) : (
|
|
<>
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border bg-muted/30 text-left">
|
|
<th className="px-4 py-3 font-medium text-muted-foreground">{t("users_col_user")}</th>
|
|
<th className="px-4 py-3 font-medium text-muted-foreground hidden sm:table-cell">{t("users_col_role")}</th>
|
|
<th className="px-4 py-3 font-medium text-muted-foreground hidden md:table-cell">{t("users_col_servers")}</th>
|
|
<th className="px-4 py-3 font-medium text-muted-foreground hidden lg:table-cell">{t("users_col_status")}</th>
|
|
<th className="px-4 py-3 font-medium text-muted-foreground hidden lg:table-cell">{t("users_col_created")}</th>
|
|
<th className="px-4 py-3 font-medium text-muted-foreground w-0" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{data.users.map((u: UserView) => (
|
|
<UserRow key={u.id} user={u} locale={locale} />
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-muted-foreground">
|
|
{t("users_total_count", { count: data.total })}
|
|
</span>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={page === 0}
|
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
>
|
|
{t("pagination_prev")}
|
|
</Button>
|
|
<span className="px-2 text-muted-foreground">
|
|
{page + 1} / {totalPages}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={page >= totalPages - 1}
|
|
onClick={() => setPage((p) => p + 1)}
|
|
>
|
|
{t("pagination_next")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function UserRow({ user, locale }: { user: UserView; locale: string }) {
|
|
const { t } = useTranslation("admin");
|
|
|
|
return (
|
|
<tr
|
|
className={cn(
|
|
"border-b border-border/50 hover:bg-muted/20 transition-colors",
|
|
user.disabled && "opacity-60",
|
|
)}
|
|
>
|
|
<td className="px-4 py-3">
|
|
<Link
|
|
to={`/admin/users/${user.id}`}
|
|
className="font-medium text-foreground hover:text-primary hover:underline"
|
|
>
|
|
{user.username}
|
|
</Link>
|
|
{user.email && (
|
|
<div className="flex items-center gap-1 mt-0.5 text-xs text-muted-foreground">
|
|
<Mail className="h-3 w-3" />
|
|
{user.email}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 hidden sm:table-cell">
|
|
<span className={cn(
|
|
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium",
|
|
user.role === "owner"
|
|
? "bg-yellow-500/10 text-yellow-600"
|
|
: user.role === "admin"
|
|
? "bg-primary/10 text-primary"
|
|
: "bg-muted text-muted-foreground",
|
|
)}>
|
|
{user.role === "owner" ? (
|
|
<Crown className="h-3 w-3" />
|
|
) : user.role === "admin" ? (
|
|
<Shield className="h-3 w-3" />
|
|
) : (
|
|
<UserRound className="h-3 w-3" />
|
|
)}
|
|
{user.role === "owner" ? t("users_role_owner") : user.role === "admin" ? t("users_role_admin") : t("users_role_user")}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 hidden md:table-cell">
|
|
<span className="inline-flex items-center gap-1 text-muted-foreground">
|
|
<Server className="h-3.5 w-3.5" />
|
|
{user.server_count}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 hidden lg:table-cell">
|
|
{user.disabled ? (
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive">
|
|
<Circle className="h-2 w-2 fill-destructive" />
|
|
{t("users_status_disabled")}
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-500">
|
|
<Circle className="h-2 w-2 fill-emerald-500" />
|
|
{t("users_status_active")}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-muted-foreground hidden lg:table-cell text-xs">
|
|
{formatAbsolute(user.created_at, locale)}
|
|
</td>
|
|
<td className="px-4 py-3 text-right">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 gap-1 text-xs"
|
|
asChild
|
|
>
|
|
<Link to={`/admin/users/${user.id}`}>
|
|
{t("users_view_detail")}
|
|
<ChevronRight className="h-3.5 w-3.5 opacity-60" />
|
|
</Link>
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|