style: unify panel cards and form controls

This commit is contained in:
Lemon-miaow committed 2026-10-07 17:32:58 +08:00
1 parent 20994be996
commit 81ba242f0f
27 files changed
+194 -182

No files matched your search

+1 -1
View File
@@ -17,7 +17,7 @@ export function DistributedNodes() {
usePolling(nodes.reload, 10_000);
return (
<Card>
<CardHeader className="flex-row items-center justify-between gap-3"><CardTitle>{t("nodes")}</CardTitle><Button variant="outline" size="sm" disabled={nodes.loading} onClick={nodes.reload}><RefreshCw />{t("nodes_reload")}</Button></CardHeader>
<CardHeader className="flex-row flex-wrap items-center justify-between gap-3"><CardTitle>{t("nodes")}</CardTitle><Button variant="outline" size="sm" disabled={nodes.loading} onClick={nodes.reload}><RefreshCw />{t("nodes_reload")}</Button></CardHeader>
<CardContent className="space-y-3">
{nodes.loading && <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" />{t("nodes_loading")}</p>}
{!nodes.loading && !nodes.error && nodes.data?.length === 0 && <p className="text-sm text-muted-foreground">{t("nodes_empty")}</p>}
+1 -1
View File
@@ -214,7 +214,7 @@ export function EditServerDialog({
{/* Unified Sidebar Link/Card style trigger */}
<button
type="button"
className="group flex w-full text-left items-center gap-3 rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-accent focus:outline-none"
className="group flex w-full text-left items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-accent focus:outline-none"
>
<Settings className="h-5 w-5 shrink-0 text-primary" />
<div className="min-w-0 flex-1 text-left">
+1 -1
View File
@@ -52,7 +52,7 @@ export function NodeControlPanel({ distributed }: { distributed: boolean | null
const showForm = distributed === true || distributed === false && enabling;
const valid = confirmed && (action === "enable" || !!name.trim() && !!ssh.trim()) && (action === "approve" || !!ip.trim());
return <Card>
<CardHeader className="flex-row items-center justify-between gap-3"><CardTitle>{t("node_control_title")}</CardTitle><Button variant="outline" size="sm" disabled={tasks.loading} onClick={() => { tasks.reload(); progress.reload(); }}><RefreshCw />{t("node_control_reload")}</Button></CardHeader>
<CardHeader className="flex-row flex-wrap items-center justify-between gap-3"><CardTitle>{t("node_control_title")}</CardTitle><Button variant="outline" size="sm" disabled={tasks.loading} onClick={() => { tasks.reload(); progress.reload(); }}><RefreshCw />{t("node_control_reload")}</Button></CardHeader>
<CardContent className="space-y-5">
<p className="text-sm leading-relaxed text-muted-foreground">{t("node_control_description")}</p>
{tasks.loading && !tasks.data && <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" />{t("node_control_loading")}</p>}
+4 -4
View File
@@ -3,7 +3,7 @@ import { Link } from "react-router-dom";
import { ArrowRight, DoorOpen, Globe, Loader2, LogIn, RefreshCw, Route, Save, Server, ShieldCheck } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -60,7 +60,7 @@ export function PlayerEntrySettings() {
} finally { setSaving(false); }
}
return <Card>
<CardHeader className="flex-row items-center justify-between gap-3"><CardTitle className="flex items-center gap-2"><Route className="h-4 w-4 text-primary" aria-hidden="true" />{t("entry_title")}</CardTitle><Button variant="outline" size="sm" disabled={dirty || saving || query.loading} onClick={() => { query.reload(); fleet.reload(); }}><RefreshCw />{t("platform_reload")}</Button></CardHeader>
<CardHeader className="flex-row flex-wrap items-center justify-between gap-3"><CardTitle className="flex items-center gap-2"><Route className="h-4 w-4 text-primary" aria-hidden="true" />{t("entry_title")}</CardTitle><Button variant="outline" size="sm" disabled={dirty || saving || query.loading} onClick={() => { query.reload(); fleet.reload(); }}><RefreshCw />{t("platform_reload")}</Button></CardHeader>
<CardContent className="space-y-6" aria-busy={query.loading}>
<p className="text-sm text-muted-foreground">{t("entry_description")}</p>
{query.loading && <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" />{t("platform_loading")}</p>}
@@ -90,8 +90,8 @@ export function PlayerEntrySettings() {
{query.error != null && <MessageLine kind="error" message={humanizeError(query.error)} />}
{fleet.error != null && <MessageLine kind="error" message={humanizeError(fleet.error)} />}
{message && <MessageLine kind={message.kind} message={message.text} />}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border pt-4"><p className="text-xs text-muted-foreground">{t("entry_scope")}</p><div className="flex gap-2">{dirty && <Button variant="outline" disabled={saving} onClick={() => { if (saved) setDraft(saved); setMessage(null); }}>{t("platform_discard")}</Button>}<Button disabled={busy || !dirty || !valid} onClick={() => void save()}>{saving ? <Loader2 className="animate-spin" /> : <Save />}{t("entry_save")}</Button></div></div>
{reauth.dialog}
</CardContent>
<CardFooter><p className="text-xs text-muted-foreground">{t("entry_scope")}</p><div className="flex gap-2">{dirty && <Button variant="outline" disabled={saving} onClick={() => { if (saved) setDraft(saved); setMessage(null); }}>{t("platform_discard")}</Button>}<Button disabled={busy || !dirty || !valid} onClick={() => void save()}>{saving ? <Loader2 className="animate-spin" /> : <Save />}{t("entry_save")}</Button></div></CardFooter>
{reauth.dialog}
</Card>;
}
+4 -4
View File
@@ -4,16 +4,16 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 active:scale-[0.97]",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow",
"bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90 hover:shadow",
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline bg-transparent",
},
+5 -5
View File
@@ -8,7 +8,7 @@ export const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
"rounded-xl border border-border bg-card text-card-foreground",
className,
)}
{...props}
@@ -20,7 +20,7 @@ export const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-5", className)} {...props} />
<div ref={ref} className={cn("flex flex-col gap-1.5 border-b border-border p-5 last:border-b-0", className)} {...props} />
));
CardHeader.displayName = "CardHeader";
@@ -30,7 +30,7 @@ export const CardTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
className={cn("text-sm font-semibold leading-5 tracking-tight [&_svg]:size-4 [&_svg]:shrink-0", className)}
{...props}
/>
));
@@ -40,7 +40,7 @@ export const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-5 pt-0", className)} {...props} />
<div ref={ref} className={cn("p-5", className)} {...props} />
));
CardContent.displayName = "CardContent";
@@ -48,6 +48,6 @@ export const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-5 pt-0", className)} {...props} />
<div ref={ref} className={cn("flex flex-wrap items-center justify-between gap-3 border-t border-border p-4", className)} {...props} />
));
CardFooter.displayName = "CardFooter";
+1 -1
View File
@@ -9,7 +9,7 @@ export const Input = React.forwardRef<
type={type}
ref={ref}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
+1 -1
View File
@@ -14,7 +14,7 @@ export const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
+5 -5
View File
@@ -300,7 +300,7 @@ export function Account() {
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CardTitle className="flex items-center gap-2">
<Link2 className="h-4 w-4 text-primary" /> {t("minecraft_link")}
</CardTitle>
</CardHeader>
@@ -328,7 +328,7 @@ export function Account() {
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CardTitle className="flex items-center gap-2">
<Mail className="h-4 w-4 text-primary" /> {t("email_verification")}
</CardTitle>
</CardHeader>
@@ -423,8 +423,8 @@ export function Account() {
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="flex items-center gap-2 text-base">
<CardHeader className="flex flex-row flex-wrap items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Fingerprint className="h-4 w-4 text-primary" /> {t("passkeys")}
</CardTitle>
<Dialog open={registerDialogOpen} onOpenChange={(open) => {
@@ -817,7 +817,7 @@ function MigrationCard({ userId, hasPasskey }: { userId?: string; hasPasskey: bo
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CardTitle className="flex items-center gap-2">
<ArrowRightLeft className="h-4 w-4 text-primary" /> {t("migration")}
</CardTitle>
</CardHeader>
+1 -1
View File
@@ -91,7 +91,7 @@ export function AccountSessionsCard({ version, staff, onSignOut, signingOut }: P
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CardTitle className="flex items-center gap-2">
<MonitorSmartphone className="h-4 w-4 text-primary" /> {t("sessions_title")}
</CardTitle>
</CardHeader>
+12 -12
View File
@@ -289,7 +289,7 @@ function FleetView({
<div className="lg:col-span-8 flex flex-col gap-6 h-full">
{/* 1. 游戏角色绑定 Banner */}
<Card className="overflow-hidden">
<CardContent className="p-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<CardContent className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<LinkCard link={link} staff={isAdmin} />
</CardContent>
</Card>
@@ -299,10 +299,10 @@ function FleetView({
<>
{/* 集群负载与健康卡片 */}
<Card>
<CardHeader className="pb-3 pt-5">
<CardTitle className="text-base">{t("fleet_load")}</CardTitle>
<CardHeader>
<CardTitle>{t("fleet_load")}</CardTitle>
</CardHeader>
<CardContent className="space-y-5 pb-5">
<CardContent className="space-y-5">
{/* 玩家负载进度条 */}
<div className="space-y-2">
<div className="flex justify-between text-xs">
@@ -356,12 +356,12 @@ function FleetView({
{/* 平台环境与配置参数卡片 (flex-1 自动拉伸) */}
<Card className="flex-1 flex flex-col justify-between">
<CardHeader className="pb-3 pt-5 px-6">
<CardTitle className="text-base font-semibold text-foreground">
<CardHeader>
<CardTitle>
{t("system_specs")}
</CardTitle>
</CardHeader>
<CardContent className="px-6 pb-6 pt-1 flex-1 flex flex-col justify-between">
<CardContent className="flex-1 flex flex-col justify-between">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-x-8 gap-y-6">
{/* 镜像总数 */}
<div className="space-y-1">
@@ -449,12 +449,12 @@ function FleetView({
</>
) : (
<Card className="overflow-hidden flex flex-col justify-between flex-1">
<CardHeader className="pb-3 pt-5 px-6">
<CardTitle className="text-base font-semibold text-foreground">
<CardHeader>
<CardTitle>
{t("welcome_title")}
</CardTitle>
</CardHeader>
<CardContent className="px-6 pb-6 pt-1 flex-1 flex flex-col justify-between gap-4">
<CardContent className="flex-1 flex flex-col justify-between gap-4">
<p className="text-xs text-muted-foreground leading-relaxed">
{t("welcome_desc")}
</p>
@@ -483,8 +483,8 @@ function FleetView({
{/* 右侧:3D星图拓扑监控 (lg:col-span-4 h-full) */}
<Card className="lg:col-span-4 flex flex-col overflow-hidden h-full">
<CardHeader className="space-y-1 pb-4">
<CardTitle className="text-base">{t("fleet")}</CardTitle>
<CardHeader>
<CardTitle>{t("fleet")}</CardTitle>
</CardHeader>
<CardContent className="flex-1 flex flex-col p-0 justify-between">
<div className="flex-1 min-h-[220px] w-full bg-gradient-to-b from-transparent to-primary/5 border-b relative">
+17 -17
View File
@@ -342,67 +342,67 @@ export function MySubmissionsPage() {
{/* Filters Bar */}
<div className="flex flex-col sm:flex-row gap-3 p-4 border-b">
<SearchInput value={search} onChange={setSearch} placeholder={t("search_placeholder")} />
<div className="inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground shrink-0 select-none border border-border/40">
<button
<div className="inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground shrink-0 select-none border border-border/40">
<Button variant="ghost" size="sm"
type="button"
onClick={() => {
setStatusFilter("all");
setPage(1);
}}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-semibold transition-all focus-visible:outline-none",
"font-medium",
statusFilter === "all"
? "bg-background text-foreground shadow-sm"
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-background/30 hover:text-foreground",
)}
>
{t("filter_all")} ({stats.total})
</button>
<button
</Button>
<Button variant="ghost" size="sm"
type="button"
onClick={() => {
setStatusFilter("pending_review");
setPage(1);
}}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-semibold transition-all focus-visible:outline-none",
"font-medium",
statusFilter === "pending_review"
? "bg-background text-amber-500 shadow-sm"
? "bg-background text-amber-500"
: "text-muted-foreground hover:bg-background/30 hover:text-foreground",
)}
>
{t("status_pending_review")} ({stats.pending})
</button>
<button
</Button>
<Button variant="ghost" size="sm"
type="button"
onClick={() => {
setStatusFilter("approved");
setPage(1);
}}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-semibold transition-all focus-visible:outline-none",
"font-medium",
statusFilter === "approved"
? "bg-background text-emerald-500 shadow-sm"
? "bg-background text-emerald-500"
: "text-muted-foreground hover:bg-background/30 hover:text-foreground",
)}
>
{t("status_approved")} ({stats.approved})
</button>
<button
</Button>
<Button variant="ghost" size="sm"
type="button"
onClick={() => {
setStatusFilter("rejected");
setPage(1);
}}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-semibold transition-all focus-visible:outline-none",
"font-medium",
statusFilter === "rejected"
? "bg-background text-rose-500 shadow-sm"
? "bg-background text-rose-500"
: "text-muted-foreground hover:bg-background/30 hover:text-foreground",
)}
>
{t("status_rejected")} ({stats.rejected})
</button>
</Button>
</div>
</div>
+3 -3
View File
@@ -947,8 +947,8 @@ export function ServerBackups() {
<Pagination page={page} pageSize={BACKUP_PAGE_SIZE} total={total} onChange={setPage} />
)}
<Card className="border-dashed bg-transparent shadow-none">
<CardContent className="p-4 text-xs text-muted-foreground">
<Card className="border-dashed bg-transparent">
<CardContent className="text-xs text-muted-foreground">
{t("history_note")}
</CardContent>
</Card>
@@ -961,7 +961,7 @@ export function ServerBackups() {
<ErrorState error={jobsQ.error} onRetry={jobsQ.reload} />
) : (
<Card className="overflow-hidden">
<CardContent className="p-4">
<CardContent>
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
<Clock className="h-3.5 w-3.5" />
{t("jobs_title")}
+12 -12
View File
@@ -316,7 +316,7 @@ export function ServerConsole() {
)}
{!error && startup && (
<div role="status" className="shrink-0 space-y-2 rounded-lg border border-border bg-card p-4 text-sm">
<div role="status" className="shrink-0 space-y-2 rounded-xl border border-border bg-card p-4 text-sm">
<p className="flex items-center gap-2 font-medium">{startupBlocked ? <ShieldAlert className="h-4 w-4 text-amber-500" /> : <Loader2 className="h-4 w-4 animate-spin text-primary" />}{t(`startup_${startup.stage}`)}</p>
{startup.startedAt && <p className="text-xs text-muted-foreground">{t("startup_elapsed", { seconds: Math.max(0, Math.floor((Date.now() - new Date(startup.startedAt).getTime()) / 1000)) })}</p>}
{startup.reason && <p className="break-words text-muted-foreground">{startup.reason === "Unschedulable" && startup.message?.includes("Insufficient memory") ? t("startup_memory") : [startup.reason, startup.message].filter(Boolean).join(": ")}</p>}
@@ -378,9 +378,9 @@ export function ServerConsole() {
{owned && (
<Link
to={`/servers/${name}/luckperms`}
className="group flex items-center gap-3 rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-accent"
className="group flex items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-accent"
>
<Shield className="h-5 w-5 shrink-0 text-primary" />
<Shield className="h-4 w-4 shrink-0 text-primary" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{t("luckperms_link_title")}</p>
<p className="text-xs text-muted-foreground mt-0.5">
@@ -395,9 +395,9 @@ export function ServerConsole() {
itself owns the ownership + readiness gating. */}
<Link
to={`/servers/${name}/players`}
className="group flex items-center gap-3 rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-accent"
className="group flex items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-accent"
>
<Users className="h-5 w-5 shrink-0 text-primary" />
<Users className="h-4 w-4 shrink-0 text-primary" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{t("players_link_title")}</p>
<p className="text-sm text-muted-foreground">{t("players_link_desc")}</p>
@@ -410,9 +410,9 @@ export function ServerConsole() {
restore in fact needs it stopped, so this doorway shows at every phase). */}
<Link
to={`/servers/${name}/backups`}
className="group flex items-center gap-3 rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-accent"
className="group flex items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-accent"
>
<Archive className="h-5 w-5 shrink-0 text-primary" />
<Archive className="h-4 w-4 shrink-0 text-primary" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{t("backups_link_title")}</p>
<p className="text-sm text-muted-foreground">{t("backups_link_desc")}</p>
@@ -425,9 +425,9 @@ export function ServerConsole() {
and offers the stop action; the doorway shows at every phase. */}
<Link
to={`/servers/${name}/files`}
className="group flex items-center gap-3 rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-accent"
className="group flex items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-accent"
>
<FolderOpen className="h-5 w-5 shrink-0 text-primary" />
<FolderOpen className="h-4 w-4 shrink-0 text-primary" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{t("files_link_title")}</p>
<p className="text-sm text-muted-foreground">{t("files_link_desc")}</p>
@@ -440,9 +440,9 @@ export function ServerConsole() {
doorway shows at every phase and the page owns its gating. */}
<Link
to={`/servers/${name}/schedules`}
className="group flex items-center gap-3 rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-accent"
className="group flex items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-accent"
>
<CalendarClock className="h-5 w-5 shrink-0 text-primary" />
<CalendarClock className="h-4 w-4 shrink-0 text-primary" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{t("schedules_link_title")}</p>
<p className="text-sm text-muted-foreground">{t("schedules_link_desc")}</p>
@@ -467,7 +467,7 @@ export function ServerConsole() {
function HostRecovery({ name }: { name: string }) {
const { t } = useTranslation("servers");
if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(name)) return null;
return <details className="rounded-lg border border-border bg-card p-3 text-sm"><summary className="cursor-pointer font-medium">{t("host_recovery")}</summary><p className="my-3 text-muted-foreground">{t("host_recovery_hint")}</p><pre className="overflow-x-auto whitespace-pre-wrap rounded bg-muted p-3 text-xs">{`kubectl patch minecraftserver ${name} -n minecraft --type=merge -p '{"spec":{"desiredState":"Stopped"}}'
return <details className="rounded-xl border border-border bg-card p-3 text-sm"><summary className="cursor-pointer font-medium">{t("host_recovery")}</summary><p className="my-3 text-muted-foreground">{t("host_recovery_hint")}</p><pre className="overflow-x-auto whitespace-pre-wrap rounded bg-muted p-3 text-xs">{`kubectl patch minecraftserver ${name} -n minecraft --type=merge -p '{"spec":{"desiredState":"Stopped"}}'
kubectl scale statefulset ${name} -n minecraft --replicas=0
kubectl get pods -n minecraft -l felis.lolicon.best/server=${name},felis.lolicon.best/component=server -o wide`}</pre><p className="mt-3 text-muted-foreground">{t("host_recovery_verify")}</p></details>;
}
+17 -17
View File
@@ -400,9 +400,9 @@ export function ServerLuckPerms() {
{/* Left Directory Sidebar: Single card for Search + Online Players. On a
wide screen it fills the column; on a phone it fits its content and the
list scrolls inside it, so the player's panel below is on the first screen. */}
<Card className="lg:col-span-1 flex flex-col lg:h-[calc(100vh-14rem)] lg:min-h-[500px] lg:max-h-[700px] border-border bg-card/60 backdrop-blur-sm overflow-hidden">
<CardHeader className="p-4 pb-2 border-b border-border/50 shrink-0">
<CardTitle className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-2">
<Card className="lg:col-span-1 flex flex-col lg:h-[calc(100vh-14rem)] lg:min-h-[500px] lg:max-h-[700px] overflow-hidden">
<CardHeader className="shrink-0">
<CardTitle className="flex items-center gap-2">
<Users className="h-4 w-4 text-primary" />
{t("luckperms_batch_players")}
</CardTitle>
@@ -426,7 +426,7 @@ export function ServerLuckPerms() {
{t("access_online_title")}
</span>
</div>
<CardContent className="p-2 overflow-y-auto max-h-52 lg:max-h-none flex-1 space-y-0.5 min-h-0">
<CardContent className="p-2 overflow-y-auto max-h-48 lg:max-h-none flex-1 space-y-0.5 min-h-0">
{playersLoading && onlinePlayers.length === 0 ? (
<div className="flex justify-center py-6">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
@@ -465,7 +465,7 @@ export function ServerLuckPerms() {
{/* Right Main Panel */}
<div className="lg:col-span-3 space-y-6">
{!selectedPlayer ? (
<Card className="flex flex-col items-center justify-center py-8 lg:py-24 text-center border-dashed border-border bg-card/20 lg:min-h-[500px]">
<Card className="flex flex-col items-center justify-center py-8 lg:py-24 text-center border-dashed lg:min-h-[500px]">
<Shield className="h-14 w-14 text-muted-foreground/15 mb-4 animate-pulse" />
<p className="text-sm text-muted-foreground font-medium max-w-xs leading-normal">
{t("luckperms_select_player_prompt")}
@@ -525,13 +525,13 @@ export function ServerLuckPerms() {
)}
{/* Groups Management Card */}
<Card className="border-border bg-card/40 shadow-sm">
<CardHeader className="p-5 pb-3 border-b border-border/50 bg-muted/20 shrink-0">
<CardTitle className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
<Card>
<CardHeader className="shrink-0">
<CardTitle>
{t("luckperms_groups_title")}
</CardTitle>
</CardHeader>
<CardContent className="p-5 space-y-4">
<CardContent className="space-y-4">
{lpLoading && !lpInfo ? (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
@@ -635,13 +635,13 @@ export function ServerLuckPerms() {
</Card>
{/* Permissions Management Card */}
<Card className="border-border bg-card/40 shadow-sm">
<CardHeader className="p-5 pb-3 border-b border-border/50 bg-muted/20 shrink-0">
<CardTitle className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
<Card>
<CardHeader className="shrink-0">
<CardTitle>
{t("luckperms_permissions_title")}
</CardTitle>
</CardHeader>
<CardContent className="p-5 space-y-4">
<CardContent className="space-y-4">
{lpLoading && !lpInfo ? (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
@@ -831,10 +831,10 @@ export function ServerLuckPerms() {
)}
{/* Action History Log */}
<Card className="border-border bg-card/40 shadow-sm">
<CardHeader className="p-5 pb-3 border-b border-border/50 bg-muted/20 shrink-0">
<Card>
<CardHeader className="shrink-0">
<div className="flex items-center justify-between">
<CardTitle className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
<CardTitle className="flex items-center gap-1.5">
<History className="h-4 w-4 text-primary" />
{t("luckperms_recent_actions")}
</CardTitle>
@@ -851,7 +851,7 @@ export function ServerLuckPerms() {
)}
</div>
</CardHeader>
<CardContent className="p-4 pt-3">
<CardContent className="pt-3">
{history.length === 0 ? (
<p className="text-xs text-muted-foreground/60 py-4 italic select-none text-center">
{t("luckperms_no_recent_actions")}
+2 -2
View File
@@ -956,8 +956,8 @@ export function ServerSchedules() {
</ul>
</Card>
)}
<Card className="border-dashed bg-transparent shadow-none">
<CardContent className="p-4 text-xs text-muted-foreground">
<Card className="border-dashed bg-transparent">
<CardContent className="text-xs text-muted-foreground">
<p className="mb-2 flex items-center gap-1.5 font-medium text-foreground">
<Info className="h-3.5 w-3.5" />
{t("notes_title")}
+11 -11
View File
@@ -5,7 +5,7 @@ import { PageHeader } from "@/components/PageHeader";
import { InlineError, MessageLine } from "@/components/MessageLine";
import { isReauthCancelled, useReauth } from "@/components/ReauthDialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
@@ -90,8 +90,8 @@ export function AuthSourcesPage() {
return <div className="space-y-6">
<PageHeader icon={ShieldCheck} title={t("title")} subtitle={t("subtitle")} />
<Card className="rounded-xl shadow-none"><CardContent className="flex items-start gap-3 pt-5">
<ShieldCheck className="mt-0.5 h-5 w-5 shrink-0 text-primary" />
<Card><CardContent className="flex items-start gap-3">
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
<div className="space-y-1"><p className="text-sm font-semibold">{t("builtin_title")}</p><p className="text-xs leading-relaxed text-muted-foreground">{t("builtin_hint")}</p></div>
</CardContent></Card>
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -114,10 +114,10 @@ export function AuthSourcesPage() {
[sources[index], sources[index + offset]] = [sources[index + offset], sources[index]];
edit(sources);
};
return <Card key={index} className="overflow-hidden rounded-xl shadow-none">
<CardHeader className={cn("flex-row flex-wrap items-center justify-between gap-3 space-y-0 bg-muted/20 py-4", expanded && "border-b border-border/60")}>
<CardTitle className="min-w-0 flex-1 text-sm">
<Button variant="ghost" className="h-auto w-full justify-start whitespace-normal p-0 text-left hover:bg-transparent active:scale-100" aria-expanded={expanded} aria-controls={`source-fields-${index}`} aria-label={t(expanded ? "collapse" : "expand", { name: source.tag || t("new_source") })} disabled={!draft} onClick={() => setDraft((current) => current && { ...current, sources: current.sources.map((s, i) => i === index ? { ...s, expanded: !expanded } : s) })}>
return <Card key={index} className="overflow-hidden">
<CardHeader className="flex-row flex-wrap items-center justify-between gap-3">
<CardTitle className="min-w-0 flex-1">
<Button variant="ghost" className="h-auto w-full justify-start whitespace-normal p-0 text-left font-semibold hover:bg-transparent active:scale-100" aria-expanded={expanded} aria-controls={`source-fields-${index}`} aria-label={t(expanded ? "collapse" : "expand", { name: source.tag || t("new_source") })} disabled={!draft} onClick={() => setDraft((current) => current && { ...current, sources: current.sources.map((s, i) => i === index ? { ...s, expanded: !expanded } : s) })}>
<ChevronRight className={cn("transition-transform motion-reduce:transition-none", expanded && "rotate-90")} />
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-xs font-semibold text-primary">{index + 2}</span>
<span className="break-all">{source.tag || t(draft ? "new_source" : "loading")}</span>
@@ -130,7 +130,7 @@ export function AuthSourcesPage() {
{!locked && <Button variant="outline" size="icon" className="h-8 w-8" aria-label={t("remove")} disabled={!draft || busy} onClick={() => draft && edit(draft.sources.filter((_, i) => i !== index))}><Trash2 /></Button>}
</div>
</CardHeader>
<CardContent id={`source-fields-${index}`} hidden={!expanded} className="space-y-4 pt-5">
<CardContent id={`source-fields-${index}`} hidden={!expanded} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2"><Label htmlFor={`source-tag-${index}`}>{t("tag")}</Label><Input id={`source-tag-${index}`} value={source.tag} readOnly={locked} disabled={!draft || busy} placeholder="littleskin" maxLength={128} onChange={(e) => set({ tag: e.target.value })} /><p className="text-xs text-muted-foreground">{t(locked ? "tag_locked" : "tag_hint")}</p></div>
<div className="space-y-2"><Label htmlFor={`source-prefix-${index}`}>{t("prefix")}</Label><Input id={`source-prefix-${index}`} value={source.prefix} disabled={!draft || busy} placeholder="LS" maxLength={4} onChange={(e) => set({ prefix: e.target.value })} /><p className="text-xs text-muted-foreground">{t("prefix_hint")}</p></div>
@@ -142,18 +142,18 @@ export function AuthSourcesPage() {
</CardContent>
</Card>;
})}
{draft?.sources.length === 0 && <Card className="border-dashed shadow-none"><CardContent className="py-8 text-center text-sm text-muted-foreground">{t("empty")}</CardContent></Card>}
{draft?.sources.length === 0 && <Card className="border-dashed"><CardContent className="py-8 text-center text-sm text-muted-foreground">{t("empty")}</CardContent></Card>}
</div>
<p className="text-xs leading-relaxed text-muted-foreground">{t("disable_hint")}</p>
{message && <MessageLine kind={message.kind} message={message.text} />}
{draft && !valid && <InlineError message={t("invalid")} />}
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border bg-card p-3">
<Card><CardFooter className="border-t-0">
<p className="text-xs text-muted-foreground">{draft ? t(dirty ? "unsaved" : draft.managed ? "active" : "defaults") : query.loading ? t("loading") : ""}</p>
<div className="flex gap-2">
{dirty && <Button variant="outline" disabled={busy} onClick={discard}>{t("discard")}</Button>}
<Button disabled={!dirty || !valid || busy} onClick={() => void save()}>{saving ? <Loader2 className="animate-spin" /> : <Save />}{t(saving ? "saving" : "save")}</Button>
</div>
</div>
</CardFooter></Card>
{reauth.dialog}
</div>;
}
+7 -7
View File
@@ -142,22 +142,22 @@ export function DBBackupCard() {
return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between gap-3 space-y-0">
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<div
className={cn(
"hidden rounded-md p-2 sm:block",
"hidden sm:block",
state === "stale" || state === "never"
? "bg-destructive/10 text-destructive"
? "text-destructive"
: serversError
? "bg-amber-500/10 text-amber-600 dark:text-amber-400"
: "bg-primary/10 text-primary",
? "text-amber-600 dark:text-amber-400"
: "text-primary",
)}
>
<Database className="h-5 w-5" />
<Database className="h-4 w-4" />
</div>
<div className="min-w-0">
<CardTitle className="flex flex-wrap items-center gap-2 text-base font-semibold">
<CardTitle className="flex flex-wrap items-center gap-2">
{t("dbbackup_title")}
{badge}
</CardTitle>
+13 -13
View File
@@ -207,52 +207,52 @@ export function ImageAdmin() {
{/* Filters Bar */}
<div className="flex flex-col sm:flex-row gap-3 p-4 border-b">
<SearchInput value={search} onChange={setSearch} placeholder={t("images_search_placeholder")} />
<div className="inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground shrink-0 select-none border border-border/40">
<button
<div className="inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground shrink-0 select-none border border-border/40">
<Button variant="ghost" size="sm"
type="button"
onClick={() => {
setStatusFilter("all");
setPage(1);
}}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-semibold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"font-medium",
statusFilter === "all"
? "bg-background text-foreground shadow-sm"
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-background/30 hover:text-foreground"
)}
>
{t("filter_all")} ({stats.total})
</button>
<button
</Button>
<Button variant="ghost" size="sm"
type="button"
onClick={() => {
setStatusFilter("enabled");
setPage(1);
}}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-semibold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"font-medium",
statusFilter === "enabled"
? "bg-background text-primary shadow-sm"
? "bg-background text-primary"
: "text-muted-foreground hover:bg-background/30 hover:text-foreground"
)}
>
{t("filter_enabled")} ({stats.enabled})
</button>
<button
</Button>
<Button variant="ghost" size="sm"
type="button"
onClick={() => {
setStatusFilter("disabled");
setPage(1);
}}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-semibold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"font-medium",
statusFilter === "disabled"
? "bg-background text-foreground/80 shadow-sm"
? "bg-background text-foreground/80"
: "text-muted-foreground hover:bg-background/30 hover:text-foreground"
)}
>
{t("filter_disabled")} ({stats.disabled})
</button>
</Button>
</div>
</div>
+26 -25
View File
@@ -5,8 +5,9 @@ import { useTranslation } from "react-i18next";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { PageHeader } from "@/components/PageHeader";
import { Switch } from "@/components/ui/switch";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PhaseBadge, shownPhase, startFailure } from "@/components/PhaseBadge";
@@ -98,31 +99,31 @@ function ExperienceSettings({ name, server, onDirtyChange, onChanged }: { name:
const fields = group.fields.filter((field) => typeof field.value !== "boolean");
const switches = group.fields.filter((field) => typeof field.value === "boolean");
return (
<Card key={group.key} className="overflow-hidden rounded-xl shadow-none">
<CardHeader className="flex-row items-center gap-3 space-y-0 border-b border-border/60 bg-muted/20 py-4">
<span className="rounded-lg bg-primary/10 p-2 text-primary"><Icon className="h-4 w-4" /></span>
<Card key={group.key} className="overflow-hidden">
<CardHeader className="flex-row flex-wrap items-center gap-3">
<span className="shrink-0 text-primary"><Icon className="h-4 w-4" /></span>
<div className="space-y-1.5">
<CardTitle className="text-sm">{t(group.key)}</CardTitle>
<CardTitle>{t(group.key)}</CardTitle>
<p className="text-xs text-muted-foreground">{t(`${group.key}_description`)}</p>
</div>
</CardHeader>
<CardContent className="space-y-5 pt-5">
<CardContent className="space-y-5">
<div className="grid items-start gap-x-5 gap-y-4 sm:grid-cols-2">
{fields.map((field) => {
const value = draft ? draft.values[field.key] ?? field.value : "";
const id = `experience-${field.key}`;
return (
<div key={id} className="min-w-0 space-y-2">
<Label htmlFor={id} className="block text-xs font-medium text-muted-foreground">{t(field.key)}</Label>
<Label htmlFor={id} className="block">{t(field.key)}</Label>
{field.choices ? (
<select id={id} value={value as string} onChange={(e) => setValue(field.key, e.target.value)} disabled={!draft || saving || restarting} className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50">
{!draft && <option value="">{query.loading ? t("common:loading") : "—"}</option>}
{field.choices.map((choice) => <option key={choice} value={choice}>{t(choice)}</option>)}
</select>
<Select value={(value as string) || undefined} onValueChange={(choice) => setValue(field.key, choice)} disabled={!draft || saving || restarting}>
<SelectTrigger id={id}><SelectValue placeholder={query.loading ? t("common:loading") : "—"} /></SelectTrigger>
<SelectContent>{field.choices.map((choice) => <SelectItem key={choice} value={choice}>{t(choice)}</SelectItem>)}</SelectContent>
</Select>
) : field.multiline ? (
<textarea id={id} value={value as string} placeholder={!draft && query.loading ? t("common:loading") : undefined} maxLength={512} onChange={(e) => setValue(field.key, e.target.value)} disabled={!draft || saving || restarting} className="block h-32 w-full resize-y rounded-lg border border-input bg-background px-3 py-2.5 text-sm leading-6 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50" />
<textarea id={id} value={value as string} placeholder={!draft && query.loading ? t("common:loading") : undefined} maxLength={512} onChange={(e) => setValue(field.key, e.target.value)} disabled={!draft || saving || restarting} className="block h-32 w-full resize-y rounded-md border border-input bg-background px-3 py-2.5 text-sm leading-6 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50" />
) : (
<Input id={id} type={typeof field.value === "number" ? "number" : "text"} value={value as string | number} placeholder={!draft && query.loading ? t("common:loading") : undefined} min={field.min} max={field.max} step={1} maxLength={512} onChange={(e) => setValue(field.key, typeof field.value === "number" ? (e.target.value === "" ? "" : Number(e.target.value)) : e.target.value)} disabled={!draft || saving || restarting} className="h-10 rounded-lg bg-background shadow-none" />
<Input id={id} type={typeof field.value === "number" ? "number" : "text"} value={value as string | number} placeholder={!draft && query.loading ? t("common:loading") : undefined} min={field.min} max={field.max} step={1} maxLength={512} onChange={(e) => setValue(field.key, typeof field.value === "number" ? (e.target.value === "" ? "" : Number(e.target.value)) : e.target.value)} disabled={!draft || saving || restarting} />
)}
</div>
);
@@ -143,7 +144,7 @@ function ExperienceSettings({ name, server, onDirtyChange, onChanged }: { name:
<p className="text-sm text-muted-foreground">{t(name === "login" ? "login_settings_hint" : "lobby_settings_hint")}</p>
{message && <MessageLine kind={message.kind} message={message.text} />}
{draft && !experienceValid(draft.values, groups) && <InlineError message={t("invalid_values")} />}
<div className={cn("flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-card p-3", needsRestart ? "border-amber-500/30 bg-amber-500/5" : "border-border")}>
<Card className={cn(needsRestart && "border-amber-500/30 bg-amber-500/5")}><CardFooter className="border-t-0">
<p className={cn("text-xs", needsRestart ? "text-amber-700 dark:text-amber-400" : "text-muted-foreground")}>{t(dirty ? "unsaved" : needsRestart ? "restart_required" : "apply_on_start")}</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" disabled={!draft || !running || dirty || saving || restarting} onClick={() => {
@@ -152,7 +153,7 @@ function ExperienceSettings({ name, server, onDirtyChange, onChanged }: { name:
}}><RotateCw className={cn(restarting && "animate-spin")} />{t(restarting ? "restarting" : "restart")}</Button>
<Button onClick={() => void save()} disabled={!draft || !dirty || saving || restarting || !experienceValid(draft.values, groups)}><Save className="h-4 w-4" />{t(saving ? "saving" : "save")}</Button>
</div>
</div>
</CardFooter></Card>
<ConfirmDialog open={confirmRestart} onOpenChange={setConfirmRestart} title={t("restart_title")} description={t("restart_hint")} confirmLabel={t("restart")} onConfirm={restart} />
</div>
);
@@ -175,8 +176,8 @@ function BuilderAccess({ ready }: { ready: boolean }) {
setBusy(false);
}
}
return <Card className="overflow-hidden rounded-xl shadow-none">
<CardHeader className="border-b border-border/60 bg-muted/20 py-4"><CardTitle className="text-sm">{t("builder_title")}</CardTitle></CardHeader>
return <Card className="overflow-hidden">
<CardHeader><CardTitle>{t("builder_title")}</CardTitle></CardHeader>
<CardContent className="space-y-3 pt-4">
<p className="text-sm text-muted-foreground">{t("builder_hint")}</p>
<Label htmlFor="builder-player">{t("builder_player")}</Label>
@@ -215,9 +216,9 @@ export function LobbyPage() {
];
return <div className="space-y-6">
<PageHeader icon={DoorOpen} title={t("title")} subtitle={t("subtitle")} />
<Card className="rounded-xl bg-muted/20 shadow-none"><CardContent className="flex flex-wrap items-start justify-between gap-4 pt-5">
<Card><CardContent className="flex flex-wrap items-start justify-between gap-4">
<div className="flex min-w-0 gap-3">
<Globe className="mt-0.5 h-5 w-5 shrink-0 text-primary" />
<Globe className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
<div className="space-y-1">
<p className="text-sm font-medium">{t("connection_version", { version: cfg?.gameVersion ?? t("unknown_version") })}</p>
{cfg && <CopyAddress address={joinAddress("login", cfg)} />}
@@ -228,22 +229,22 @@ export function LobbyPage() {
</CardContent></Card>
<div className="inline-flex gap-1 rounded-lg bg-muted/60 p-1" role="group" aria-label={t("space_label")}>
{(["login", "lobby"] as const).map((space) => (
<Button key={space} variant="ghost" aria-pressed={name === space} onClick={() => selectSpace(space)} className={cn("px-4", name === space ? "bg-card text-primary shadow-sm hover:bg-card" : "text-muted-foreground")}>
<Button key={space} variant="ghost" aria-pressed={name === space} onClick={() => selectSpace(space)} className={cn("px-4", name === space ? "bg-card text-primary hover:bg-card" : "text-muted-foreground")}>
{space === "lobby" ? <DoorOpen /> : <BookOpen />}{t(space)}
</Button>
))}
</div>
<p className="text-sm text-muted-foreground">{t(`${name}_description`)}</p>
{status.error != null && <ErrorState error={humanizeError(status.error)} onRetry={status.reload} />}
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border bg-card p-4">
<div className="min-w-0 space-y-2"><div className="flex flex-wrap items-center gap-3"><h2 className="font-semibold">{data?.displayName || t(name)}</h2>{data ? <PhaseBadge phase={shownPhase(data)} /> : status.loading && <span role="status" className="flex items-center gap-2 text-xs text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin" />{t("common:loading")}</span>}</div><p className="break-all text-xs text-muted-foreground">{data?.image || "\u00a0"}</p></div>
<Card className="flex flex-wrap items-center justify-between gap-3 p-5">
<div className="min-w-0 space-y-2"><div className="flex flex-wrap items-center gap-3"><h2 className="text-sm font-semibold">{data?.displayName || t(name)}</h2>{data ? <PhaseBadge phase={shownPhase(data)} /> : status.loading && <span role="status" className="flex items-center gap-2 text-xs text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin" />{t("common:loading")}</span>}</div><p className="break-all text-xs text-muted-foreground">{data?.image || "\u00a0"}</p></div>
{data && <PowerButton name={name} phase={data.phase} desiredState={data.desiredState} failed={startFailure(data) !== null} playersOnline={data.playersOnline} playerCountUnknown={data.playerCountUnknown} onChanged={status.reload} />}
</div>
</Card>
<div className="grid items-start gap-5 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<ExperienceSettings key={name} name={name} server={data} onDirtyChange={setDirty} onChanged={status.reload} />
<div className="space-y-4">
<Card className="overflow-hidden rounded-xl shadow-none">
<CardHeader className="border-b border-border/60 bg-muted/20 py-4"><CardTitle className="text-sm">{t("content_tools")}</CardTitle></CardHeader>
<Card className="overflow-hidden">
<CardHeader><CardTitle>{t("content_tools")}</CardTitle></CardHeader>
<CardContent className="divide-y divide-border/60 p-0">
{links.map(({ icon: Icon, title, hint, to }) => (
<Link key={title} to={to} className="group flex gap-3 p-4 transition-colors hover:bg-muted/40">
@@ -10,7 +10,7 @@ import { PageHeader } from "@/components/PageHeader";
import { MessageLine } from "@/components/MessageLine";
import { isReauthCancelled, useReauth } from "@/components/ReauthDialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api, humanizeError } from "@/lib/api";
@@ -64,13 +64,13 @@ export function PlatformSettingsPage() {
</CardContent></Card>
{query.error != null && <MessageLine kind="error" message={humanizeError(query.error)} />}
{message && <MessageLine kind={message.kind} message={message.text} />}
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border bg-card p-3">
<Card><CardFooter className="border-t-0">
<p className="text-xs text-muted-foreground">{t(dirty ? "platform_unsaved" : "platform_live")}</p>
<div className="flex gap-2">
{dirty && <Button variant="outline" disabled={busy} onClick={() => { if (saved) load(saved); setMessage(null); }}>{t("platform_discard")}</Button>}
<Button disabled={!dirty || !valid || busy} onClick={() => void save()}>{saving ? <Loader2 className="animate-spin" /> : <Save />}{t("platform_save")}</Button>
</div>
</div>
</CardFooter></Card>
<Card>
<CardHeader><CardTitle>{t("platform_distribution_title")}</CardTitle></CardHeader>
<CardContent className="space-y-4">
+17 -17
View File
@@ -211,67 +211,67 @@ export function SubmissionsPage() {
{/* Filters Bar */}
<div className="flex flex-col sm:flex-row gap-3 p-4 border-b">
<SearchInput value={search} onChange={setSearch} placeholder={t("submissions_search_placeholder")} />
<div className="inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground shrink-0 select-none border border-border/40">
<button
<div className="inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground shrink-0 select-none border border-border/40">
<Button variant="ghost" size="sm"
type="button"
onClick={() => {
setStatusFilter("all");
setPage(1);
}}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-semibold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"font-medium",
statusFilter === "all"
? "bg-background text-foreground shadow-sm"
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-background/30 hover:text-foreground",
)}
>
{t("filter_all")} ({stats.total})
</button>
<button
</Button>
<Button variant="ghost" size="sm"
type="button"
onClick={() => {
setStatusFilter("pending_review");
setPage(1);
}}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-semibold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"font-medium",
statusFilter === "pending_review"
? "bg-background text-amber-500 shadow-sm"
? "bg-background text-amber-500"
: "text-muted-foreground hover:bg-background/30 hover:text-foreground",
)}
>
{t("status_pending_review")} ({stats.pending})
</button>
<button
</Button>
<Button variant="ghost" size="sm"
type="button"
onClick={() => {
setStatusFilter("approved");
setPage(1);
}}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-semibold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"font-medium",
statusFilter === "approved"
? "bg-background text-emerald-500 shadow-sm"
? "bg-background text-emerald-500"
: "text-muted-foreground hover:bg-background/30 hover:text-foreground",
)}
>
{t("status_approved")} ({stats.approved})
</button>
<button
</Button>
<Button variant="ghost" size="sm"
type="button"
onClick={() => {
setStatusFilter("rejected");
setPage(1);
}}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-semibold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"font-medium",
statusFilter === "rejected"
? "bg-background text-rose-500 shadow-sm"
? "bg-background text-rose-500"
: "text-muted-foreground hover:bg-background/30 hover:text-foreground",
)}
>
{t("status_rejected")} ({stats.rejected})
</button>
</Button>
</div>
</div>
+7 -7
View File
@@ -80,22 +80,22 @@ export function UpdateReportCard() {
return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between gap-3 space-y-0">
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<div
className={cn(
"hidden rounded-md p-2 sm:block",
"hidden sm:block",
state === "stale" || state === "never"
? "bg-destructive/10 text-destructive"
? "text-destructive"
: available.length > 0
? "bg-amber-500/10 text-amber-500"
: "bg-primary/10 text-primary",
? "text-amber-500"
: "text-primary",
)}
>
<PackageCheck className="h-5 w-5" />
<PackageCheck className="h-4 w-4" />
</div>
<div className="min-w-0">
<CardTitle className="flex flex-wrap items-center gap-2 text-base font-semibold">
<CardTitle className="flex flex-wrap items-center gap-2">
{t("versions_title")}
{badge}
</CardTitle>
+1 -1
View File
@@ -209,7 +209,7 @@ export function UpdatesPage() {
{/* Configuration Card */}
<Card className="w-full">
<CardHeader>
<CardTitle className="text-base font-semibold">{t("updates_set_title")}</CardTitle>
<CardTitle>{t("updates_set_title")}</CardTitle>
</CardHeader>
<CardContent className="space-y-6 text-sm">
<p className="text-muted-foreground leading-relaxed">{t("updates_window_advisory")}</p>
+8 -8
View File
@@ -179,7 +179,7 @@ function EditProfileCard({ user, onSaved, isSelf }: { user: UserDetail; onSaved:
return (
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold">{t("users_edit_profile")}</CardTitle>
<CardTitle>{t("users_edit_profile")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1.5">
@@ -302,8 +302,8 @@ function LinkedAccountsCard({ user, onChanged }: { user: UserDetail; onChanged:
return (
<>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base font-semibold">
<CardHeader className="flex flex-row flex-wrap items-center justify-between">
<CardTitle>
{t("users_linked_accounts")} ({accounts.length})
</CardTitle>
<Button
@@ -462,7 +462,7 @@ function QuotasCard({ userId }: { userId: string }) {
if (loading && !quotas) return <Loading label={t("common:loading")} />;
if (error) return (
<Card>
<CardHeader><CardTitle className="text-base font-semibold">{t("users_quotas")}</CardTitle></CardHeader>
<CardHeader><CardTitle>{t("users_quotas")}</CardTitle></CardHeader>
<CardContent><ErrorState error={error} onRetry={reload} /></CardContent>
</Card>
);
@@ -470,7 +470,7 @@ function QuotasCard({ userId }: { userId: string }) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold">{t("users_quotas")}</CardTitle>
<CardTitle>{t("users_quotas")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3">
@@ -600,8 +600,8 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () =>
return (
<>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base font-semibold">
<CardHeader className="flex flex-row flex-wrap items-center justify-between">
<CardTitle>
{t("users_sessions")} ({sessions?.length ?? 0})
</CardTitle>
<Button
@@ -764,7 +764,7 @@ function DangerZone({
return (
<Card className="border-destructive/30">
<CardHeader>
<CardTitle className="text-base font-semibold text-destructive">{t("users_danger_zone")}</CardTitle>
<CardTitle className="text-destructive">{t("users_danger_zone")}</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
{reason && (
+3 -3
View File
@@ -316,7 +316,7 @@ export function ServersPage() {
{/* Distribution card in a full grid row */}
{stats.total > 0 && (
<Card>
<CardContent className="space-y-3 p-4">
<CardContent className="space-y-3">
<div className="text-xs font-medium text-muted-foreground text-left">
{t("fleet_distribution")}
</div>
@@ -749,8 +749,8 @@ function ServerMobileCard({
return (
<li className="flex min-w-0">
<Card className="flex min-w-0 flex-1 flex-col border border-border/80">
<CardContent className="flex flex-1 flex-col gap-3 p-4">
<Card className="flex min-w-0 flex-1 flex-col border">
<CardContent className="flex flex-1 flex-col gap-3">
<div className="space-y-1">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-baseline gap-2">