feat(panel): pagination for server lists
This commit is contained in:
6 files changed
+85
-18
No files matched your search
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}}"
|
||||||
}
|
}
|
||||||
@@ -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}} 页"
|
||||||
}
|
}
|
||||||
@@ -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); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user