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

+11
View File
@@ -34,6 +34,17 @@ matching the actual UI labels. English and Chinese copy must retain the same
meaning and degree of formality. Update existing translation entries rather than meaning and degree of formality. Update existing translation entries rather than
adding duplicate messages or components. adding duplicate messages or components.
## Panel Visual Style
Reuse `PageHeader`, the `Card` family, and the shared form and button components.
Card titles, borders, corner radii, spacing, and save footers follow their shared
definitions; avoid redefining these styles in individual pages. Use white card
backgrounds in the light theme, restrained semantic colors, and 16px functional
icons. Use `CardFooter` for save actions and `Button` for interactive controls.
Tables, consoles, compact statistics, and status messages may retain spacing and
colors suited to their content. Preserve the shared page margins and bottom
spacing.
## Local Development ## Local Development
You can do most day-to-day development on macOS or Linux without a full cluster. You can do most day-to-day development on macOS or Linux without a full cluster.
+1 -1
View File
@@ -17,7 +17,7 @@ export function DistributedNodes() {
usePolling(nodes.reload, 10_000); usePolling(nodes.reload, 10_000);
return ( return (
<Card> <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"> <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 && <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>} {!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 */} {/* Unified Sidebar Link/Card style trigger */}
<button <button
type="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" /> <Settings className="h-5 w-5 shrink-0 text-primary" />
<div className="min-w-0 flex-1 text-left"> <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 showForm = distributed === true || distributed === false && enabling;
const valid = confirmed && (action === "enable" || !!name.trim() && !!ssh.trim()) && (action === "approve" || !!ip.trim()); const valid = confirmed && (action === "enable" || !!name.trim() && !!ssh.trim()) && (action === "approve" || !!ip.trim());
return <Card> 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"> <CardContent className="space-y-5">
<p className="text-sm leading-relaxed text-muted-foreground">{t("node_control_description")}</p> <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>} {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 { ArrowRight, DoorOpen, Globe, Loader2, LogIn, RefreshCw, Route, Save, Server, ShieldCheck } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; 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 { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -60,7 +60,7 @@ export function PlayerEntrySettings() {
} finally { setSaving(false); } } finally { setSaving(false); }
} }
return <Card> 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}> <CardContent className="space-y-6" aria-busy={query.loading}>
<p className="text-sm text-muted-foreground">{t("entry_description")}</p> <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>} {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)} />} {query.error != null && <MessageLine kind="error" message={humanizeError(query.error)} />}
{fleet.error != null && <MessageLine kind="error" message={humanizeError(fleet.error)} />} {fleet.error != null && <MessageLine kind="error" message={humanizeError(fleet.error)} />}
{message && <MessageLine kind={message.kind} message={message.text} />} {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> </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>; </Card>;
} }
+4 -4
View File
@@ -4,16 +4,16 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const buttonVariants = cva( 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: { variants: {
variant: { variant: {
default: default:
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow", "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90 hover:shadow", "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: 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", ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline bg-transparent", link: "text-primary underline-offset-4 hover:underline bg-transparent",
}, },
+5 -5
View File
@@ -8,7 +8,7 @@ export const Card = React.forwardRef<
<div <div
ref={ref} ref={ref}
className={cn( 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, className,
)} )}
{...props} {...props}
@@ -20,7 +20,7 @@ export const CardHeader = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( >(({ 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"; CardHeader.displayName = "CardHeader";
@@ -30,7 +30,7 @@ export const CardTitle = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<h3 <h3
ref={ref} 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} {...props}
/> />
)); ));
@@ -40,7 +40,7 @@ export const CardContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( >(({ 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"; CardContent.displayName = "CardContent";
@@ -48,6 +48,6 @@ export const CardFooter = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( >(({ 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"; CardFooter.displayName = "CardFooter";
+1 -1
View File
@@ -9,7 +9,7 @@ export const Input = React.forwardRef<
type={type} type={type}
ref={ref} ref={ref}
className={cn( 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, className,
)} )}
{...props} {...props}
+1 -1
View File
@@ -14,7 +14,7 @@ export const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
className={cn( 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, className,
)} )}
{...props} {...props}
+5 -5
View File
@@ -300,7 +300,7 @@ export function Account() {
<Card> <Card>
<CardHeader> <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")} <Link2 className="h-4 w-4 text-primary" /> {t("minecraft_link")}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@@ -328,7 +328,7 @@ export function Account() {
<Card> <Card>
<CardHeader> <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")} <Mail className="h-4 w-4 text-primary" /> {t("email_verification")}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@@ -423,8 +423,8 @@ export function Account() {
</Card> </Card>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0"> <CardHeader className="flex flex-row flex-wrap items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2">
<Fingerprint className="h-4 w-4 text-primary" /> {t("passkeys")} <Fingerprint className="h-4 w-4 text-primary" /> {t("passkeys")}
</CardTitle> </CardTitle>
<Dialog open={registerDialogOpen} onOpenChange={(open) => { <Dialog open={registerDialogOpen} onOpenChange={(open) => {
@@ -817,7 +817,7 @@ function MigrationCard({ userId, hasPasskey }: { userId?: string; hasPasskey: bo
return ( return (
<Card> <Card>
<CardHeader> <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")} <ArrowRightLeft className="h-4 w-4 text-primary" /> {t("migration")}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
+1 -1
View File
@@ -91,7 +91,7 @@ export function AccountSessionsCard({ version, staff, onSignOut, signingOut }: P
return ( return (
<Card> <Card>
<CardHeader> <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")} <MonitorSmartphone className="h-4 w-4 text-primary" /> {t("sessions_title")}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
+12 -12
View File
@@ -289,7 +289,7 @@ function FleetView({
<div className="lg:col-span-8 flex flex-col gap-6 h-full"> <div className="lg:col-span-8 flex flex-col gap-6 h-full">
{/* 1. 游戏角色绑定 Banner */} {/* 1. 游戏角色绑定 Banner */}
<Card className="overflow-hidden"> <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} /> <LinkCard link={link} staff={isAdmin} />
</CardContent> </CardContent>
</Card> </Card>
@@ -299,10 +299,10 @@ function FleetView({
<> <>
{/* 集群负载与健康卡片 */} {/* 集群负载与健康卡片 */}
<Card> <Card>
<CardHeader className="pb-3 pt-5"> <CardHeader>
<CardTitle className="text-base">{t("fleet_load")}</CardTitle> <CardTitle>{t("fleet_load")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-5 pb-5"> <CardContent className="space-y-5">
{/* 玩家负载进度条 */} {/* 玩家负载进度条 */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between text-xs"> <div className="flex justify-between text-xs">
@@ -356,12 +356,12 @@ function FleetView({
{/* 平台环境与配置参数卡片 (flex-1 自动拉伸) */} {/* 平台环境与配置参数卡片 (flex-1 自动拉伸) */}
<Card className="flex-1 flex flex-col justify-between"> <Card className="flex-1 flex flex-col justify-between">
<CardHeader className="pb-3 pt-5 px-6"> <CardHeader>
<CardTitle className="text-base font-semibold text-foreground"> <CardTitle>
{t("system_specs")} {t("system_specs")}
</CardTitle> </CardTitle>
</CardHeader> </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="grid grid-cols-2 sm:grid-cols-3 gap-x-8 gap-y-6">
{/* 镜像总数 */} {/* 镜像总数 */}
<div className="space-y-1"> <div className="space-y-1">
@@ -449,12 +449,12 @@ function FleetView({
</> </>
) : ( ) : (
<Card className="overflow-hidden flex flex-col justify-between flex-1"> <Card className="overflow-hidden flex flex-col justify-between flex-1">
<CardHeader className="pb-3 pt-5 px-6"> <CardHeader>
<CardTitle className="text-base font-semibold text-foreground"> <CardTitle>
{t("welcome_title")} {t("welcome_title")}
</CardTitle> </CardTitle>
</CardHeader> </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"> <p className="text-xs text-muted-foreground leading-relaxed">
{t("welcome_desc")} {t("welcome_desc")}
</p> </p>
@@ -483,8 +483,8 @@ function FleetView({
{/* 右侧:3D星图拓扑监控 (lg:col-span-4 h-full) */} {/* 右侧:3D星图拓扑监控 (lg:col-span-4 h-full) */}
<Card className="lg:col-span-4 flex flex-col overflow-hidden h-full"> <Card className="lg:col-span-4 flex flex-col overflow-hidden h-full">
<CardHeader className="space-y-1 pb-4"> <CardHeader>
<CardTitle className="text-base">{t("fleet")}</CardTitle> <CardTitle>{t("fleet")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex-1 flex flex-col p-0 justify-between"> <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"> <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 */} {/* Filters Bar */}
<div className="flex flex-col sm:flex-row gap-3 p-4 border-b"> <div className="flex flex-col sm:flex-row gap-3 p-4 border-b">
<SearchInput value={search} onChange={setSearch} placeholder={t("search_placeholder")} /> <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"> <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 <Button variant="ghost" size="sm"
type="button" type="button"
onClick={() => { onClick={() => {
setStatusFilter("all"); setStatusFilter("all");
setPage(1); setPage(1);
}} }}
className={cn( 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" statusFilter === "all"
? "bg-background text-foreground shadow-sm" ? "bg-background text-foreground"
: "text-muted-foreground hover:bg-background/30 hover:text-foreground", : "text-muted-foreground hover:bg-background/30 hover:text-foreground",
)} )}
> >
{t("filter_all")} ({stats.total}) {t("filter_all")} ({stats.total})
</button> </Button>
<button <Button variant="ghost" size="sm"
type="button" type="button"
onClick={() => { onClick={() => {
setStatusFilter("pending_review"); setStatusFilter("pending_review");
setPage(1); setPage(1);
}} }}
className={cn( 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" 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", : "text-muted-foreground hover:bg-background/30 hover:text-foreground",
)} )}
> >
{t("status_pending_review")} ({stats.pending}) {t("status_pending_review")} ({stats.pending})
</button> </Button>
<button <Button variant="ghost" size="sm"
type="button" type="button"
onClick={() => { onClick={() => {
setStatusFilter("approved"); setStatusFilter("approved");
setPage(1); setPage(1);
}} }}
className={cn( 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" statusFilter === "approved"
? "bg-background text-emerald-500 shadow-sm" ? "bg-background text-emerald-500"
: "text-muted-foreground hover:bg-background/30 hover:text-foreground", : "text-muted-foreground hover:bg-background/30 hover:text-foreground",
)} )}
> >
{t("status_approved")} ({stats.approved}) {t("status_approved")} ({stats.approved})
</button> </Button>
<button <Button variant="ghost" size="sm"
type="button" type="button"
onClick={() => { onClick={() => {
setStatusFilter("rejected"); setStatusFilter("rejected");
setPage(1); setPage(1);
}} }}
className={cn( 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" statusFilter === "rejected"
? "bg-background text-rose-500 shadow-sm" ? "bg-background text-rose-500"
: "text-muted-foreground hover:bg-background/30 hover:text-foreground", : "text-muted-foreground hover:bg-background/30 hover:text-foreground",
)} )}
> >
{t("status_rejected")} ({stats.rejected}) {t("status_rejected")} ({stats.rejected})
</button> </Button>
</div> </div>
</div> </div>
+3 -3
View File
@@ -947,8 +947,8 @@ export function ServerBackups() {
<Pagination page={page} pageSize={BACKUP_PAGE_SIZE} total={total} onChange={setPage} /> <Pagination page={page} pageSize={BACKUP_PAGE_SIZE} total={total} onChange={setPage} />
)} )}
<Card className="border-dashed bg-transparent shadow-none"> <Card className="border-dashed bg-transparent">
<CardContent className="p-4 text-xs text-muted-foreground"> <CardContent className="text-xs text-muted-foreground">
{t("history_note")} {t("history_note")}
</CardContent> </CardContent>
</Card> </Card>
@@ -961,7 +961,7 @@ export function ServerBackups() {
<ErrorState error={jobsQ.error} onRetry={jobsQ.reload} /> <ErrorState error={jobsQ.error} onRetry={jobsQ.reload} />
) : ( ) : (
<Card className="overflow-hidden"> <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"> <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" /> <Clock className="h-3.5 w-3.5" />
{t("jobs_title")} {t("jobs_title")}
+12 -12
View File
@@ -316,7 +316,7 @@ export function ServerConsole() {
)} )}
{!error && startup && ( {!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> <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.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>} {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 && ( {owned && (
<Link <Link
to={`/servers/${name}/luckperms`} 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"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium">{t("luckperms_link_title")}</p> <p className="text-sm font-medium">{t("luckperms_link_title")}</p>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
@@ -395,9 +395,9 @@ export function ServerConsole() {
itself owns the ownership + readiness gating. */} itself owns the ownership + readiness gating. */}
<Link <Link
to={`/servers/${name}/players`} 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"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium">{t("players_link_title")}</p> <p className="text-sm font-medium">{t("players_link_title")}</p>
<p className="text-sm text-muted-foreground">{t("players_link_desc")}</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). */} restore in fact needs it stopped, so this doorway shows at every phase). */}
<Link <Link
to={`/servers/${name}/backups`} 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"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium">{t("backups_link_title")}</p> <p className="text-sm font-medium">{t("backups_link_title")}</p>
<p className="text-sm text-muted-foreground">{t("backups_link_desc")}</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. */} and offers the stop action; the doorway shows at every phase. */}
<Link <Link
to={`/servers/${name}/files`} 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"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium">{t("files_link_title")}</p> <p className="text-sm font-medium">{t("files_link_title")}</p>
<p className="text-sm text-muted-foreground">{t("files_link_desc")}</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. */} doorway shows at every phase and the page owns its gating. */}
<Link <Link
to={`/servers/${name}/schedules`} 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"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium">{t("schedules_link_title")}</p> <p className="text-sm font-medium">{t("schedules_link_title")}</p>
<p className="text-sm text-muted-foreground">{t("schedules_link_desc")}</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 }) { function HostRecovery({ name }: { name: string }) {
const { t } = useTranslation("servers"); const { t } = useTranslation("servers");
if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(name)) return null; 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 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>; 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 {/* 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 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. */} 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"> <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="p-4 pb-2 border-b border-border/50 shrink-0"> <CardHeader className="shrink-0">
<CardTitle className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Users className="h-4 w-4 text-primary" /> <Users className="h-4 w-4 text-primary" />
{t("luckperms_batch_players")} {t("luckperms_batch_players")}
</CardTitle> </CardTitle>
@@ -426,7 +426,7 @@ export function ServerLuckPerms() {
{t("access_online_title")} {t("access_online_title")}
</span> </span>
</div> </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 ? ( {playersLoading && onlinePlayers.length === 0 ? (
<div className="flex justify-center py-6"> <div className="flex justify-center py-6">
<Loader2 className="h-5 w-5 animate-spin text-primary" /> <Loader2 className="h-5 w-5 animate-spin text-primary" />
@@ -465,7 +465,7 @@ export function ServerLuckPerms() {
{/* Right Main Panel */} {/* Right Main Panel */}
<div className="lg:col-span-3 space-y-6"> <div className="lg:col-span-3 space-y-6">
{!selectedPlayer ? ( {!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" /> <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"> <p className="text-sm text-muted-foreground font-medium max-w-xs leading-normal">
{t("luckperms_select_player_prompt")} {t("luckperms_select_player_prompt")}
@@ -525,13 +525,13 @@ export function ServerLuckPerms() {
)} )}
{/* Groups Management Card */} {/* Groups Management Card */}
<Card className="border-border bg-card/40 shadow-sm"> <Card>
<CardHeader className="p-5 pb-3 border-b border-border/50 bg-muted/20 shrink-0"> <CardHeader className="shrink-0">
<CardTitle className="text-xs font-bold uppercase tracking-wider text-muted-foreground"> <CardTitle>
{t("luckperms_groups_title")} {t("luckperms_groups_title")}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-5 space-y-4"> <CardContent className="space-y-4">
{lpLoading && !lpInfo ? ( {lpLoading && !lpInfo ? (
<div className="flex justify-center py-4"> <div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-primary" /> <Loader2 className="h-6 w-6 animate-spin text-primary" />
@@ -635,13 +635,13 @@ export function ServerLuckPerms() {
</Card> </Card>
{/* Permissions Management Card */} {/* Permissions Management Card */}
<Card className="border-border bg-card/40 shadow-sm"> <Card>
<CardHeader className="p-5 pb-3 border-b border-border/50 bg-muted/20 shrink-0"> <CardHeader className="shrink-0">
<CardTitle className="text-xs font-bold uppercase tracking-wider text-muted-foreground"> <CardTitle>
{t("luckperms_permissions_title")} {t("luckperms_permissions_title")}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-5 space-y-4"> <CardContent className="space-y-4">
{lpLoading && !lpInfo ? ( {lpLoading && !lpInfo ? (
<div className="flex justify-center py-4"> <div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-primary" /> <Loader2 className="h-6 w-6 animate-spin text-primary" />
@@ -831,10 +831,10 @@ export function ServerLuckPerms() {
)} )}
{/* Action History Log */} {/* Action History Log */}
<Card className="border-border bg-card/40 shadow-sm"> <Card>
<CardHeader className="p-5 pb-3 border-b border-border/50 bg-muted/20 shrink-0"> <CardHeader className="shrink-0">
<div className="flex items-center justify-between"> <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" /> <History className="h-4 w-4 text-primary" />
{t("luckperms_recent_actions")} {t("luckperms_recent_actions")}
</CardTitle> </CardTitle>
@@ -851,7 +851,7 @@ export function ServerLuckPerms() {
)} )}
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="p-4 pt-3"> <CardContent className="pt-3">
{history.length === 0 ? ( {history.length === 0 ? (
<p className="text-xs text-muted-foreground/60 py-4 italic select-none text-center"> <p className="text-xs text-muted-foreground/60 py-4 italic select-none text-center">
{t("luckperms_no_recent_actions")} {t("luckperms_no_recent_actions")}
+2 -2
View File
@@ -956,8 +956,8 @@ export function ServerSchedules() {
</ul> </ul>
</Card> </Card>
)} )}
<Card className="border-dashed bg-transparent shadow-none"> <Card className="border-dashed bg-transparent">
<CardContent className="p-4 text-xs text-muted-foreground"> <CardContent className="text-xs text-muted-foreground">
<p className="mb-2 flex items-center gap-1.5 font-medium text-foreground"> <p className="mb-2 flex items-center gap-1.5 font-medium text-foreground">
<Info className="h-3.5 w-3.5" /> <Info className="h-3.5 w-3.5" />
{t("notes_title")} {t("notes_title")}
+11 -11
View File
@@ -5,7 +5,7 @@ import { PageHeader } from "@/components/PageHeader";
import { InlineError, MessageLine } from "@/components/MessageLine"; import { InlineError, MessageLine } from "@/components/MessageLine";
import { isReauthCancelled, useReauth } from "@/components/ReauthDialog"; import { isReauthCancelled, useReauth } from "@/components/ReauthDialog";
import { Button } from "@/components/ui/button"; 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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
@@ -90,8 +90,8 @@ export function AuthSourcesPage() {
return <div className="space-y-6"> return <div className="space-y-6">
<PageHeader icon={ShieldCheck} title={t("title")} subtitle={t("subtitle")} /> <PageHeader icon={ShieldCheck} title={t("title")} subtitle={t("subtitle")} />
<Card className="rounded-xl shadow-none"><CardContent className="flex items-start gap-3 pt-5"> <Card><CardContent className="flex items-start gap-3">
<ShieldCheck className="mt-0.5 h-5 w-5 shrink-0 text-primary" /> <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> <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> </CardContent></Card>
<div className="flex flex-wrap items-center justify-between gap-3"> <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]]; [sources[index], sources[index + offset]] = [sources[index + offset], sources[index]];
edit(sources); edit(sources);
}; };
return <Card key={index} className="overflow-hidden rounded-xl shadow-none"> return <Card key={index} className="overflow-hidden">
<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")}> <CardHeader className="flex-row flex-wrap items-center justify-between gap-3">
<CardTitle className="min-w-0 flex-1 text-sm"> <CardTitle className="min-w-0 flex-1">
<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) })}> <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")} /> <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="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> <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>} {!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> </div>
</CardHeader> </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="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-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> <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> </CardContent>
</Card>; </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> </div>
<p className="text-xs leading-relaxed text-muted-foreground">{t("disable_hint")}</p> <p className="text-xs leading-relaxed text-muted-foreground">{t("disable_hint")}</p>
{message && <MessageLine kind={message.kind} message={message.text} />} {message && <MessageLine kind={message.kind} message={message.text} />}
{draft && !valid && <InlineError message={t("invalid")} />} {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> <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"> <div className="flex gap-2">
{dirty && <Button variant="outline" disabled={busy} onClick={discard}>{t("discard")}</Button>} {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> <Button disabled={!dirty || !valid || busy} onClick={() => void save()}>{saving ? <Loader2 className="animate-spin" /> : <Save />}{t(saving ? "saving" : "save")}</Button>
</div> </div>
</div> </CardFooter></Card>
{reauth.dialog} {reauth.dialog}
</div>; </div>;
} }
+7 -7
View File
@@ -142,22 +142,22 @@ export function DBBackupCard() {
return ( return (
<Card className="w-full"> <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="flex min-w-0 items-center gap-3">
<div <div
className={cn( className={cn(
"hidden rounded-md p-2 sm:block", "hidden sm:block",
state === "stale" || state === "never" state === "stale" || state === "never"
? "bg-destructive/10 text-destructive" ? "text-destructive"
: serversError : serversError
? "bg-amber-500/10 text-amber-600 dark:text-amber-400" ? "text-amber-600 dark:text-amber-400"
: "bg-primary/10 text-primary", : "text-primary",
)} )}
> >
<Database className="h-5 w-5" /> <Database className="h-4 w-4" />
</div> </div>
<div className="min-w-0"> <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")} {t("dbbackup_title")}
{badge} {badge}
</CardTitle> </CardTitle>
+13 -13
View File
@@ -207,52 +207,52 @@ export function ImageAdmin() {
{/* Filters Bar */} {/* Filters Bar */}
<div className="flex flex-col sm:flex-row gap-3 p-4 border-b"> <div className="flex flex-col sm:flex-row gap-3 p-4 border-b">
<SearchInput value={search} onChange={setSearch} placeholder={t("images_search_placeholder")} /> <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"> <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 <Button variant="ghost" size="sm"
type="button" type="button"
onClick={() => { onClick={() => {
setStatusFilter("all"); setStatusFilter("all");
setPage(1); setPage(1);
}} }}
className={cn( 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" statusFilter === "all"
? "bg-background text-foreground shadow-sm" ? "bg-background text-foreground"
: "text-muted-foreground hover:bg-background/30 hover:text-foreground" : "text-muted-foreground hover:bg-background/30 hover:text-foreground"
)} )}
> >
{t("filter_all")} ({stats.total}) {t("filter_all")} ({stats.total})
</button> </Button>
<button <Button variant="ghost" size="sm"
type="button" type="button"
onClick={() => { onClick={() => {
setStatusFilter("enabled"); setStatusFilter("enabled");
setPage(1); setPage(1);
}} }}
className={cn( 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" statusFilter === "enabled"
? "bg-background text-primary shadow-sm" ? "bg-background text-primary"
: "text-muted-foreground hover:bg-background/30 hover:text-foreground" : "text-muted-foreground hover:bg-background/30 hover:text-foreground"
)} )}
> >
{t("filter_enabled")} ({stats.enabled}) {t("filter_enabled")} ({stats.enabled})
</button> </Button>
<button <Button variant="ghost" size="sm"
type="button" type="button"
onClick={() => { onClick={() => {
setStatusFilter("disabled"); setStatusFilter("disabled");
setPage(1); setPage(1);
}} }}
className={cn( 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" statusFilter === "disabled"
? "bg-background text-foreground/80 shadow-sm" ? "bg-background text-foreground/80"
: "text-muted-foreground hover:bg-background/30 hover:text-foreground" : "text-muted-foreground hover:bg-background/30 hover:text-foreground"
)} )}
> >
{t("filter_disabled")} ({stats.disabled}) {t("filter_disabled")} ({stats.disabled})
</button> </Button>
</div> </div>
</div> </div>
+26 -25
View File
@@ -5,8 +5,9 @@ import { useTranslation } from "react-i18next";
import { ConfirmDialog } from "@/components/ConfirmDialog"; import { ConfirmDialog } from "@/components/ConfirmDialog";
import { PageHeader } from "@/components/PageHeader"; import { PageHeader } from "@/components/PageHeader";
import { Switch } from "@/components/ui/switch"; 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 { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { PhaseBadge, shownPhase, startFailure } from "@/components/PhaseBadge"; 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 fields = group.fields.filter((field) => typeof field.value !== "boolean");
const switches = group.fields.filter((field) => typeof field.value === "boolean"); const switches = group.fields.filter((field) => typeof field.value === "boolean");
return ( return (
<Card key={group.key} className="overflow-hidden rounded-xl shadow-none"> <Card key={group.key} className="overflow-hidden">
<CardHeader className="flex-row items-center gap-3 space-y-0 border-b border-border/60 bg-muted/20 py-4"> <CardHeader className="flex-row flex-wrap items-center gap-3">
<span className="rounded-lg bg-primary/10 p-2 text-primary"><Icon className="h-4 w-4" /></span> <span className="shrink-0 text-primary"><Icon className="h-4 w-4" /></span>
<div className="space-y-1.5"> <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> <p className="text-xs text-muted-foreground">{t(`${group.key}_description`)}</p>
</div> </div>
</CardHeader> </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"> <div className="grid items-start gap-x-5 gap-y-4 sm:grid-cols-2">
{fields.map((field) => { {fields.map((field) => {
const value = draft ? draft.values[field.key] ?? field.value : ""; const value = draft ? draft.values[field.key] ?? field.value : "";
const id = `experience-${field.key}`; const id = `experience-${field.key}`;
return ( return (
<div key={id} className="min-w-0 space-y-2"> <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 ? ( {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"> <Select value={(value as string) || undefined} onValueChange={(choice) => setValue(field.key, choice)} disabled={!draft || saving || restarting}>
{!draft && <option value="">{query.loading ? t("common:loading") : "—"}</option>} <SelectTrigger id={id}><SelectValue placeholder={query.loading ? t("common:loading") : "—"} /></SelectTrigger>
{field.choices.map((choice) => <option key={choice} value={choice}>{t(choice)}</option>)} <SelectContent>{field.choices.map((choice) => <SelectItem key={choice} value={choice}>{t(choice)}</SelectItem>)}</SelectContent>
</select> </Select>
) : field.multiline ? ( ) : 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> </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> <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} />} {message && <MessageLine kind={message.kind} message={message.text} />}
{draft && !experienceValid(draft.values, groups) && <InlineError message={t("invalid_values")} />} {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> <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"> <div className="flex flex-wrap gap-2">
<Button variant="outline" disabled={!draft || !running || dirty || saving || restarting} onClick={() => { <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> }}><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> <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>
</div> </CardFooter></Card>
<ConfirmDialog open={confirmRestart} onOpenChange={setConfirmRestart} title={t("restart_title")} description={t("restart_hint")} confirmLabel={t("restart")} onConfirm={restart} /> <ConfirmDialog open={confirmRestart} onOpenChange={setConfirmRestart} title={t("restart_title")} description={t("restart_hint")} confirmLabel={t("restart")} onConfirm={restart} />
</div> </div>
); );
@@ -175,8 +176,8 @@ function BuilderAccess({ ready }: { ready: boolean }) {
setBusy(false); setBusy(false);
} }
} }
return <Card className="overflow-hidden rounded-xl shadow-none"> return <Card className="overflow-hidden">
<CardHeader className="border-b border-border/60 bg-muted/20 py-4"><CardTitle className="text-sm">{t("builder_title")}</CardTitle></CardHeader> <CardHeader><CardTitle>{t("builder_title")}</CardTitle></CardHeader>
<CardContent className="space-y-3 pt-4"> <CardContent className="space-y-3 pt-4">
<p className="text-sm text-muted-foreground">{t("builder_hint")}</p> <p className="text-sm text-muted-foreground">{t("builder_hint")}</p>
<Label htmlFor="builder-player">{t("builder_player")}</Label> <Label htmlFor="builder-player">{t("builder_player")}</Label>
@@ -215,9 +216,9 @@ export function LobbyPage() {
]; ];
return <div className="space-y-6"> return <div className="space-y-6">
<PageHeader icon={DoorOpen} title={t("title")} subtitle={t("subtitle")} /> <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"> <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"> <div className="space-y-1">
<p className="text-sm font-medium">{t("connection_version", { version: cfg?.gameVersion ?? t("unknown_version") })}</p> <p className="text-sm font-medium">{t("connection_version", { version: cfg?.gameVersion ?? t("unknown_version") })}</p>
{cfg && <CopyAddress address={joinAddress("login", cfg)} />} {cfg && <CopyAddress address={joinAddress("login", cfg)} />}
@@ -228,22 +229,22 @@ export function LobbyPage() {
</CardContent></Card> </CardContent></Card>
<div className="inline-flex gap-1 rounded-lg bg-muted/60 p-1" role="group" aria-label={t("space_label")}> <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) => ( {(["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)} {space === "lobby" ? <DoorOpen /> : <BookOpen />}{t(space)}
</Button> </Button>
))} ))}
</div> </div>
<p className="text-sm text-muted-foreground">{t(`${name}_description`)}</p> <p className="text-sm text-muted-foreground">{t(`${name}_description`)}</p>
{status.error != null && <ErrorState error={humanizeError(status.error)} onRetry={status.reload} />} {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"> <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="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> <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} />} {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)]"> <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} /> <ExperienceSettings key={name} name={name} server={data} onDirtyChange={setDirty} onChanged={status.reload} />
<div className="space-y-4"> <div className="space-y-4">
<Card className="overflow-hidden rounded-xl shadow-none"> <Card className="overflow-hidden">
<CardHeader className="border-b border-border/60 bg-muted/20 py-4"><CardTitle className="text-sm">{t("content_tools")}</CardTitle></CardHeader> <CardHeader><CardTitle>{t("content_tools")}</CardTitle></CardHeader>
<CardContent className="divide-y divide-border/60 p-0"> <CardContent className="divide-y divide-border/60 p-0">
{links.map(({ icon: Icon, title, hint, to }) => ( {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"> <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 { MessageLine } from "@/components/MessageLine";
import { isReauthCancelled, useReauth } from "@/components/ReauthDialog"; import { isReauthCancelled, useReauth } from "@/components/ReauthDialog";
import { Button } from "@/components/ui/button"; 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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
@@ -64,13 +64,13 @@ export function PlatformSettingsPage() {
</CardContent></Card> </CardContent></Card>
{query.error != null && <MessageLine kind="error" message={humanizeError(query.error)} />} {query.error != null && <MessageLine kind="error" message={humanizeError(query.error)} />}
{message && <MessageLine kind={message.kind} message={message.text} />} {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> <p className="text-xs text-muted-foreground">{t(dirty ? "platform_unsaved" : "platform_live")}</p>
<div className="flex gap-2"> <div className="flex gap-2">
{dirty && <Button variant="outline" disabled={busy} onClick={() => { if (saved) load(saved); setMessage(null); }}>{t("platform_discard")}</Button>} {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> <Button disabled={!dirty || !valid || busy} onClick={() => void save()}>{saving ? <Loader2 className="animate-spin" /> : <Save />}{t("platform_save")}</Button>
</div> </div>
</div> </CardFooter></Card>
<Card> <Card>
<CardHeader><CardTitle>{t("platform_distribution_title")}</CardTitle></CardHeader> <CardHeader><CardTitle>{t("platform_distribution_title")}</CardTitle></CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
+17 -17
View File
@@ -211,67 +211,67 @@ export function SubmissionsPage() {
{/* Filters Bar */} {/* Filters Bar */}
<div className="flex flex-col sm:flex-row gap-3 p-4 border-b"> <div className="flex flex-col sm:flex-row gap-3 p-4 border-b">
<SearchInput value={search} onChange={setSearch} placeholder={t("submissions_search_placeholder")} /> <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"> <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 <Button variant="ghost" size="sm"
type="button" type="button"
onClick={() => { onClick={() => {
setStatusFilter("all"); setStatusFilter("all");
setPage(1); setPage(1);
}} }}
className={cn( 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" statusFilter === "all"
? "bg-background text-foreground shadow-sm" ? "bg-background text-foreground"
: "text-muted-foreground hover:bg-background/30 hover:text-foreground", : "text-muted-foreground hover:bg-background/30 hover:text-foreground",
)} )}
> >
{t("filter_all")} ({stats.total}) {t("filter_all")} ({stats.total})
</button> </Button>
<button <Button variant="ghost" size="sm"
type="button" type="button"
onClick={() => { onClick={() => {
setStatusFilter("pending_review"); setStatusFilter("pending_review");
setPage(1); setPage(1);
}} }}
className={cn( 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" 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", : "text-muted-foreground hover:bg-background/30 hover:text-foreground",
)} )}
> >
{t("status_pending_review")} ({stats.pending}) {t("status_pending_review")} ({stats.pending})
</button> </Button>
<button <Button variant="ghost" size="sm"
type="button" type="button"
onClick={() => { onClick={() => {
setStatusFilter("approved"); setStatusFilter("approved");
setPage(1); setPage(1);
}} }}
className={cn( 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" statusFilter === "approved"
? "bg-background text-emerald-500 shadow-sm" ? "bg-background text-emerald-500"
: "text-muted-foreground hover:bg-background/30 hover:text-foreground", : "text-muted-foreground hover:bg-background/30 hover:text-foreground",
)} )}
> >
{t("status_approved")} ({stats.approved}) {t("status_approved")} ({stats.approved})
</button> </Button>
<button <Button variant="ghost" size="sm"
type="button" type="button"
onClick={() => { onClick={() => {
setStatusFilter("rejected"); setStatusFilter("rejected");
setPage(1); setPage(1);
}} }}
className={cn( 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" statusFilter === "rejected"
? "bg-background text-rose-500 shadow-sm" ? "bg-background text-rose-500"
: "text-muted-foreground hover:bg-background/30 hover:text-foreground", : "text-muted-foreground hover:bg-background/30 hover:text-foreground",
)} )}
> >
{t("status_rejected")} ({stats.rejected}) {t("status_rejected")} ({stats.rejected})
</button> </Button>
</div> </div>
</div> </div>
+7 -7
View File
@@ -80,22 +80,22 @@ export function UpdateReportCard() {
return ( return (
<Card className="w-full"> <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="flex min-w-0 items-center gap-3">
<div <div
className={cn( className={cn(
"hidden rounded-md p-2 sm:block", "hidden sm:block",
state === "stale" || state === "never" state === "stale" || state === "never"
? "bg-destructive/10 text-destructive" ? "text-destructive"
: available.length > 0 : available.length > 0
? "bg-amber-500/10 text-amber-500" ? "text-amber-500"
: "bg-primary/10 text-primary", : "text-primary",
)} )}
> >
<PackageCheck className="h-5 w-5" /> <PackageCheck className="h-4 w-4" />
</div> </div>
<div className="min-w-0"> <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")} {t("versions_title")}
{badge} {badge}
</CardTitle> </CardTitle>
+1 -1
View File
@@ -209,7 +209,7 @@ export function UpdatesPage() {
{/* Configuration Card */} {/* Configuration Card */}
<Card className="w-full"> <Card className="w-full">
<CardHeader> <CardHeader>
<CardTitle className="text-base font-semibold">{t("updates_set_title")}</CardTitle> <CardTitle>{t("updates_set_title")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-6 text-sm"> <CardContent className="space-y-6 text-sm">
<p className="text-muted-foreground leading-relaxed">{t("updates_window_advisory")}</p> <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 ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base font-semibold">{t("users_edit_profile")}</CardTitle> <CardTitle>{t("users_edit_profile")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -302,8 +302,8 @@ function LinkedAccountsCard({ user, onChanged }: { user: UserDetail; onChanged:
return ( return (
<> <>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row flex-wrap items-center justify-between">
<CardTitle className="text-base font-semibold"> <CardTitle>
{t("users_linked_accounts")} ({accounts.length}) {t("users_linked_accounts")} ({accounts.length})
</CardTitle> </CardTitle>
<Button <Button
@@ -462,7 +462,7 @@ function QuotasCard({ userId }: { userId: string }) {
if (loading && !quotas) return <Loading label={t("common:loading")} />; if (loading && !quotas) return <Loading label={t("common:loading")} />;
if (error) return ( if (error) return (
<Card> <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> <CardContent><ErrorState error={error} onRetry={reload} /></CardContent>
</Card> </Card>
); );
@@ -470,7 +470,7 @@ function QuotasCard({ userId }: { userId: string }) {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base font-semibold">{t("users_quotas")}</CardTitle> <CardTitle>{t("users_quotas")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -600,8 +600,8 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () =>
return ( return (
<> <>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row flex-wrap items-center justify-between">
<CardTitle className="text-base font-semibold"> <CardTitle>
{t("users_sessions")} ({sessions?.length ?? 0}) {t("users_sessions")} ({sessions?.length ?? 0})
</CardTitle> </CardTitle>
<Button <Button
@@ -764,7 +764,7 @@ function DangerZone({
return ( return (
<Card className="border-destructive/30"> <Card className="border-destructive/30">
<CardHeader> <CardHeader>
<CardTitle className="text-base font-semibold text-destructive">{t("users_danger_zone")}</CardTitle> <CardTitle className="text-destructive">{t("users_danger_zone")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-5"> <CardContent className="space-y-5">
{reason && ( {reason && (
+3 -3
View File
@@ -316,7 +316,7 @@ export function ServersPage() {
{/* Distribution card in a full grid row */} {/* Distribution card in a full grid row */}
{stats.total > 0 && ( {stats.total > 0 && (
<Card> <Card>
<CardContent className="space-y-3 p-4"> <CardContent className="space-y-3">
<div className="text-xs font-medium text-muted-foreground text-left"> <div className="text-xs font-medium text-muted-foreground text-left">
{t("fleet_distribution")} {t("fleet_distribution")}
</div> </div>
@@ -749,8 +749,8 @@ function ServerMobileCard({
return ( return (
<li className="flex min-w-0"> <li className="flex min-w-0">
<Card className="flex min-w-0 flex-1 flex-col border border-border/80"> <Card className="flex min-w-0 flex-1 flex-col border">
<CardContent className="flex flex-1 flex-col gap-3 p-4"> <CardContent className="flex flex-1 flex-col gap-3">
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-baseline gap-2"> <div className="flex min-w-0 items-baseline gap-2">