style: unify panel cards and form controls
This commit is contained in:
27 files changed
+194
-182
No files matched your search
@@ -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>}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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,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",
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user