feat(panel): pagination for server lists

This commit is contained in:
Lemon-miaow committed 2026-06-27 23:46:17 +08:00
1 parent e1892659ef
commit f4df3e20ea
6 files changed
+85 -18

No files matched your search

+2 -2
View File
@@ -121,8 +121,8 @@ export function AppShell() {
<ThemeToggle /> <ThemeToggle />
</div> </div>
</header> </header>
<main className="flex-1 p-6"> <main className="flex flex-1 flex-col p-6">
<div className="mx-auto max-w-7xl space-y-7"> <div className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-6">
<Outlet /> <Outlet />
</div> </div>
</main> </main>
+41
View File
@@ -0,0 +1,41 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
interface Props {
page: number;
pageSize: number;
total: number;
onChange: (page: number) => void;
}
export function Pagination({ page, pageSize, total, onChange }: Props) {
const { t } = useTranslation("common");
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<div className="flex items-center justify-center gap-2 mt-auto">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => onChange(page - 1)}
>
<ChevronLeft />
{t("pagination_prev")}
</Button>
<span className="text-xs text-muted-foreground min-w-[6rem] text-center">
{t("pagination_of", { current: page, total: totalPages })}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => onChange(page + 1)}
>
{t("pagination_next")}
<ChevronRight />
</Button>
</div>
);
}
+4 -1
View File
@@ -19,5 +19,8 @@
"lang_en": "EN", "lang_en": "EN",
"lang_zh": "中文", "lang_zh": "中文",
"lang_toggle_hint": "Switch to {{lang}}", "lang_toggle_hint": "Switch to {{lang}}",
"toggle_theme": "Toggle theme" "toggle_theme": "Toggle theme",
"pagination_prev": "Previous",
"pagination_next": "Next",
"pagination_of": "Page {{current}} of {{total}}"
} }
+4 -1
View File
@@ -19,5 +19,8 @@
"lang_en": "EN", "lang_en": "EN",
"lang_zh": "中文", "lang_zh": "中文",
"lang_toggle_hint": "切换至 {{lang}}", "lang_toggle_hint": "切换至 {{lang}}",
"toggle_theme": "切换主题" "toggle_theme": "切换主题",
"pagination_prev": "上一页",
"pagination_next": "下一页",
"pagination_of": "第 {{current}} / {{total}} 页"
} }
+17 -4
View File
@@ -1,19 +1,23 @@
import { useState } from "react";
import { Server } from "lucide-react"; import { Server } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { ServerCard } from "@/components/ServerCard"; import { ServerCard } from "@/components/ServerCard";
import { Pagination } from "@/components/Pagination";
import { Loading, ErrorState, EmptyState } from "@/components/States"; import { Loading, ErrorState, EmptyState } from "@/components/States";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useAsync, useConfig } from "@/lib/hooks"; import { useAsync, useConfig } from "@/lib/hooks";
// MyServers is the User-Side fleet: list / wake / stop / claim the servers the const PAGE_SIZE = 12;
// caller owns or may claim. Creation lives on Admin-Side now (POST /servers is
// admin-tier) — the platform provisions servers; users claim and operate them.
export function MyServers() { export function MyServers() {
const cfg = useConfig(); const cfg = useConfig();
const { data, error, loading, reload } = useAsync(() => api.myServers(), []); const { data, error, loading, reload } = useAsync(() => api.myServers(), []);
const { t } = useTranslation("servers"); const { t } = useTranslation("servers");
const servers = data ?? []; const servers = data ?? [];
const [page, setPage] = useState(1);
const totalPages = Math.max(1, Math.ceil(servers.length / PAGE_SIZE));
const paged = servers.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
return ( return (
<> <>
@@ -40,7 +44,7 @@ export function MyServers() {
/> />
) : ( ) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{servers.map((s) => ( {paged.map((s) => (
<ServerCard key={s.name} server={s} cfg={cfg} onChanged={reload} /> <ServerCard key={s.name} server={s} cfg={cfg} onChanged={reload} />
))} ))}
</div> </div>
@@ -51,6 +55,15 @@ export function MyServers() {
{t("my_servers_footer")} {t("my_servers_footer")}
</CardContent> </CardContent>
</Card> </Card>
{servers.length > 0 && (
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={servers.length}
onChange={(p) => { setPage(p); if (p > totalPages) setPage(totalPages); }}
/>
)}
</> </>
); );
} }
+17 -10
View File
@@ -1,27 +1,25 @@
import { useState } from "react";
import { ServerCog } from "lucide-react"; import { ServerCog } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { ServerCard } from "@/components/ServerCard"; import { ServerCard } from "@/components/ServerCard";
import { Pagination } from "@/components/Pagination";
import { CreateServerDialog } from "@/components/CreateServerDialog"; import { CreateServerDialog } from "@/components/CreateServerDialog";
import { Loading, ErrorState, EmptyState } from "@/components/States"; import { Loading, ErrorState, EmptyState } from "@/components/States";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useAsync, useConfig } from "@/lib/hooks"; import { useAsync, useConfig } from "@/lib/hooks";
// ServerAdmin is the Admin-Side create surface. CreateServerDialog lives here (it const PAGE_SIZE = 12;
// calls the admin-tier POST /servers, spec §15) rather than on User-Side, where it
// previously rendered for everyone and relied on a 403 — moving it removes that
// UX/tier mismatch.
//
// The list below currently uses the app-tier GET /me/servers, i.e. the servers
// THIS admin owns or may manage. A fleet-wide GET /admin/servers (every server,
// not just mine) is a separate Oracle-verifiable backend slice that powers the
// SysAdmin-Side FleetTable; it is intentionally not faked in here.
export function ServerAdmin() { export function ServerAdmin() {
const cfg = useConfig(); const cfg = useConfig();
const { data, error, loading, reload } = useAsync(() => api.myServers(), []); const { data, error, loading, reload } = useAsync(() => api.myServers(), []);
const { t } = useTranslation("admin"); const { t } = useTranslation("admin");
const { t: ts } = useTranslation("servers"); const { t: ts } = useTranslation("servers");
const servers = data ?? []; const servers = data ?? [];
const [page, setPage] = useState(1);
const totalPages = Math.max(1, Math.ceil(servers.length / PAGE_SIZE));
const paged = servers.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
return ( return (
<> <>
@@ -51,7 +49,7 @@ export function ServerAdmin() {
/> />
) : ( ) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{servers.map((s) => ( {paged.map((s) => (
<ServerCard key={s.name} server={s} cfg={cfg} onChanged={reload} /> <ServerCard key={s.name} server={s} cfg={cfg} onChanged={reload} />
))} ))}
</div> </div>
@@ -62,6 +60,15 @@ export function ServerAdmin() {
{ts("server_admin_footer")} {ts("server_admin_footer")}
</CardContent> </CardContent>
</Card> </Card>
{servers.length > 0 && (
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={servers.length}
onChange={(p) => { setPage(p); if (p > totalPages) setPage(totalPages); }}
/>
)}
</> </>
); );
} }