diff --git a/panel/src/components/BackLink.tsx b/panel/src/components/BackLink.tsx new file mode 100644 index 0000000..eaf6205 --- /dev/null +++ b/panel/src/components/BackLink.tsx @@ -0,0 +1,18 @@ +import { ArrowLeft } from "lucide-react"; +import { Link } from "react-router-dom"; + +interface Props { + to: string; + label: string; +} + +export function BackLink({ to, label }: Props) { + return ( + + {label} + + ); +} diff --git a/panel/src/components/ConfirmFooter.tsx b/panel/src/components/ConfirmFooter.tsx new file mode 100644 index 0000000..9405a36 --- /dev/null +++ b/panel/src/components/ConfirmFooter.tsx @@ -0,0 +1,41 @@ +import { Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { DialogFooter } from "@/components/ui/dialog"; + +interface Props { + onCancel: () => void; + onConfirm: () => void; + disabled?: boolean; + loading?: boolean; + cancelLabel: string; + confirmLabel: string; + confirmVariant?: "destructive" | "default" | "outline"; + /** Additional content rendered between the message and buttons */ + children?: React.ReactNode; + className?: string; +} + +export function ConfirmFooter({ + onCancel, + onConfirm, + disabled, + loading, + cancelLabel, + confirmLabel, + confirmVariant = "destructive", + children, + className, +}: Props) { + return ( + + {children} + + + + ); +} diff --git a/panel/src/components/CreateServerDialog.tsx b/panel/src/components/CreateServerDialog.tsx index 439d0fb..300ddee 100644 --- a/panel/src/components/CreateServerDialog.tsx +++ b/panel/src/components/CreateServerDialog.tsx @@ -1,11 +1,11 @@ import { useState } from "react"; import { Plus } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { ConfirmFooter } from "@/components/ConfirmFooter"; import { Dialog, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, DialogTrigger, @@ -104,8 +104,9 @@ export function CreateServerDialog({ cfg, onCreated }: Props) { return ( - @@ -231,14 +232,15 @@ export function CreateServerDialog({ cfg, onCreated }: Props) { {error &&

{error}

} - - - - + setOpen(false)} + onConfirm={submit} + disabled={!canSubmit} + loading={submitting} + cancelLabel={t("create_server_cancel")} + confirmLabel={t("create_server_submit")} + confirmVariant="default" + />
); diff --git a/panel/src/components/CreateUserDialog.tsx b/panel/src/components/CreateUserDialog.tsx index cd99b0d..0516e4e 100644 --- a/panel/src/components/CreateUserDialog.tsx +++ b/panel/src/components/CreateUserDialog.tsx @@ -20,6 +20,7 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { MessageLine } from "@/components/MessageLine"; import { api, humanizeError } from "@/lib/api"; interface Props { @@ -217,11 +218,7 @@ export function CreateUserDialog({ onCreated }: Props) { - {err && ( -

- {err} -

- )} + {err && } - - + setOpen(false)} + onConfirm={submit} + disabled={!canSubmit} + loading={submitting} + cancelLabel={t("create_server_cancel")} + confirmLabel={t("edit_server_submit")} + confirmVariant="default" + /> ); diff --git a/panel/src/components/InlineConfirm.tsx b/panel/src/components/InlineConfirm.tsx new file mode 100644 index 0000000..a45a690 --- /dev/null +++ b/panel/src/components/InlineConfirm.tsx @@ -0,0 +1,52 @@ +import { Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface Props { + open: boolean; + confirming: boolean; + onConfirm: () => void; + onCancel: () => void; + confirmLabel: string; + cancelLabel: string; + confirmVariant?: "destructive" | "default"; + size?: "default" | "sm"; + className?: string; +} + +export function InlineConfirm({ + open, + confirming, + onConfirm, + onCancel, + confirmLabel, + cancelLabel, + confirmVariant = "destructive", + size = "sm", + className, +}: Props) { + if (!open) return null; + return ( +
+ + +
+ ); +} diff --git a/panel/src/components/MessageLine.tsx b/panel/src/components/MessageLine.tsx new file mode 100644 index 0000000..e723a91 --- /dev/null +++ b/panel/src/components/MessageLine.tsx @@ -0,0 +1,48 @@ +import { AlertCircle, CheckCircle2 } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface Props { + kind: "error" | "success"; + message: string; + /** Compact variant: smaller text, no border, no icon. Error-only. */ + compact?: boolean; + className?: string; +} + +const ICON = { error: AlertCircle, success: CheckCircle2 }; + +const STYLE = { + error: "border-destructive/20 bg-destructive/10 text-destructive", + success: "border-emerald-500/20 bg-emerald-500/10 text-emerald-500", +}; + +const COMPACT_STYLE = "bg-destructive/10 text-destructive"; + +export function MessageLine({ kind, message, compact, className }: Props) { + if (compact) { + return ( +

+ {message} +

+ ); + } + const Icon = ICON[kind]; + return ( +
+ +

{message}

+
+ ); +} diff --git a/panel/src/components/PageHeader.tsx b/panel/src/components/PageHeader.tsx new file mode 100644 index 0000000..e281a78 --- /dev/null +++ b/panel/src/components/PageHeader.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import type { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export interface PageHeaderProps { + icon?: LucideIcon | React.ReactNode; + title: React.ReactNode; + subtitle?: React.ReactNode; + actions?: React.ReactNode; + className?: string; +} + +export function PageHeader({ icon: Icon, title, subtitle, actions, className }: PageHeaderProps) { + return ( +
+
+ {Icon && ( +
+ {React.isValidElement(Icon) + ? Icon + : React.createElement(Icon as React.ComponentType<{ className?: string }>, { + className: "h-6 w-6", + })} +
+ )} +
+

{title}

+ {subtitle &&
{subtitle}
} +
+
+ {actions &&
{actions}
} +
+ ); +} diff --git a/panel/src/components/Pagination.tsx b/panel/src/components/Pagination.tsx index ba6ddb9..012b874 100644 --- a/panel/src/components/Pagination.tsx +++ b/panel/src/components/Pagination.tsx @@ -21,7 +21,7 @@ export function Pagination({ page, pageSize, total, onChange }: Props) { disabled={page <= 1} onClick={() => onChange(page - 1)} > - + {t("pagination_prev")} @@ -34,7 +34,7 @@ export function Pagination({ page, pageSize, total, onChange }: Props) { onClick={() => onChange(page + 1)} > {t("pagination_next")} - + ); diff --git a/panel/src/components/RoleBadge.tsx b/panel/src/components/RoleBadge.tsx new file mode 100644 index 0000000..11e9f33 --- /dev/null +++ b/panel/src/components/RoleBadge.tsx @@ -0,0 +1,33 @@ +import { Crown, Shield, UserRound } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/utils"; + +const ICON_MAP = { owner: Crown, admin: Shield, user: UserRound }; + +const STYLE_MAP = { + owner: "bg-yellow-500/10 text-yellow-600", + admin: "bg-primary/10 text-primary", + user: "bg-muted text-muted-foreground", +}; + +const I18N_KEY = { + owner: "users_role_owner", + admin: "users_role_admin", + user: "users_role_user", +}; + +export function RoleBadge({ role }: { role: "owner" | "admin" | "user" }) { + const { t } = useTranslation("admin"); + const Icon = ICON_MAP[role]; + return ( + + + {t(I18N_KEY[role])} + + ); +} diff --git a/panel/src/components/SearchInput.tsx b/panel/src/components/SearchInput.tsx new file mode 100644 index 0000000..0d169df --- /dev/null +++ b/panel/src/components/SearchInput.tsx @@ -0,0 +1,24 @@ +import { Search } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; + +interface Props { + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; +} + +export function SearchInput({ value, onChange, placeholder, className }: Props) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + className="pl-9" + /> +
+ ); +} diff --git a/panel/src/components/StatCard.tsx b/panel/src/components/StatCard.tsx new file mode 100644 index 0000000..62820b2 --- /dev/null +++ b/panel/src/components/StatCard.tsx @@ -0,0 +1,45 @@ +import type { LucideIcon } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; + +export interface StatCardProps { + icon: LucideIcon; + label: string; + value: React.ReactNode; + /** Tailwind classes applied to the icon container (e.g. "text-amber-500 bg-amber-500/10") */ + accentClass?: string; + /** A hex colour string; applied as `color` on the icon container. Takes precedence over accentClass for the colour. */ + accentColor?: string; + /** Tailwind classes applied to the value text */ + valueClass?: string; +} + +const DEFAULT_ICON_CONTAINER = "rounded-md p-2 bg-muted/30 text-muted-foreground"; + +export function StatCard({ icon: Icon, label, value, accentClass, accentColor, valueClass }: StatCardProps) { + const containerStyle = accentColor ? { backgroundColor: `${accentColor}26`, color: accentColor } : undefined; + return ( + + +
+ +
+
+
+ {value} +
+
{label}
+
+
+
+ ); +} diff --git a/panel/src/components/States.tsx b/panel/src/components/States.tsx index 4a20197..d3fbf1b 100644 --- a/panel/src/components/States.tsx +++ b/panel/src/components/States.tsx @@ -1,6 +1,7 @@ -import { Loader2, AlertTriangle, Inbox, ShieldX, Construction } from "lucide-react"; +import { Loader2, AlertTriangle, Inbox, ShieldX, Construction, Moon } from "lucide-react"; import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; import { humanizeError } from "@/lib/api"; export function Loading({ label }: { label?: string }) { @@ -92,3 +93,48 @@ export function PendingBackend({ endpoint, note }: { endpoint: string; note: str ); } + +export interface NotYoursProps { + title: string; + body: string; + linkTo?: string; + linkLabel?: string; +} + +export function NotYours({ title, body, linkTo = "/servers", linkLabel }: NotYoursProps) { + const { t } = useTranslation("servers"); + return ( +
+ +
+

{title}

+

{body}

+
+ + {linkLabel ?? t("my_servers_breadcrumb")} + +
+ ); +} + +export interface NotRunningProps { + title: string; + body: string; + wakeLabel: string; + onWake: () => void; +} + +export function NotRunning({ title, body, wakeLabel, onWake }: NotRunningProps) { + return ( +
+ +
+

{title}

+

{body}

+
+ +
+ ); +} diff --git a/panel/src/components/SubmissionStatusBadge.tsx b/panel/src/components/SubmissionStatusBadge.tsx new file mode 100644 index 0000000..8783692 --- /dev/null +++ b/panel/src/components/SubmissionStatusBadge.tsx @@ -0,0 +1,32 @@ +import { useTranslation } from "react-i18next"; +import { Badge } from "@/components/ui/badge"; +import type { SubmissionStatus } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +const STATUS_STYLE: Record = { + pending_review: "bg-amber-500/10 text-amber-500 border-amber-500/20", + approved: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20", + rejected: "bg-rose-500/10 text-rose-500 border-rose-500/20", +}; + +const STATUS_I18N_KEY: Record = { + pending_review: "status_pending_review", + approved: "status_approved", + rejected: "status_rejected", +}; + +interface Props { + status: SubmissionStatus; +} + +export function SubmissionStatusBadge({ status }: Props) { + const { t } = useTranslation("submissions"); + return ( + + {t(STATUS_I18N_KEY[status])} + + ); +} diff --git a/panel/src/components/UserStatusBadge.tsx b/panel/src/components/UserStatusBadge.tsx new file mode 100644 index 0000000..c53f257 --- /dev/null +++ b/panel/src/components/UserStatusBadge.tsx @@ -0,0 +1,26 @@ +import { Circle } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/utils"; + +interface Props { + disabled: boolean; + className?: string; +} + +export function UserStatusBadge({ disabled, className }: Props) { + const { t } = useTranslation("admin"); + return ( + + + {disabled ? t("users_status_disabled") : t("users_status_active")} + + ); +} diff --git a/panel/src/components/players/BansSection.tsx b/panel/src/components/players/BansSection.tsx index 83b2954..4f6dce9 100644 --- a/panel/src/components/players/BansSection.tsx +++ b/panel/src/components/players/BansSection.tsx @@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Ban, Loader2, RotateCw, Undo2 } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { InlineConfirm } from "@/components/InlineConfirm"; import { api, humanizeError } from "@/lib/api"; import { useAsync } from "@/lib/hooks"; import { cn } from "@/lib/utils"; @@ -155,28 +156,15 @@ export function BansSection({ name }: { name: string }) {

{t("access_ban_confirm", { player })}

- - + /> )} @@ -215,29 +203,16 @@ export function BansSection({ name }: { name: string }) { row (取消 / 解封), a second confirms. Lighter than the ban-by-name confirm because a mistaken pardon is re-bannable in one tap. */} {confirming === p ? ( -
- - -
+ pardon(p)} + onCancel={() => setConfirming(null)} + confirmLabel={t("access_pardon_btn")} + cancelLabel={t("access_cancel")} + size="sm" + className="flex shrink-0 items-center gap-1" + /> ) : ( - - + run(p, c.action)} + onCancel={() => setConfirming(null)} + confirmLabel={c.action === "kick" ? t("access_kick_btn") : t("access_ban_btn")} + cancelLabel={t("access_cancel")} + size="sm" + className="flex shrink-0 items-center gap-1" + /> ) : (
- -
+ remove(p)} + onCancel={() => setConfirming(null)} + confirmLabel={t("access_remove")} + cancelLabel={t("access_cancel")} + size="sm" + className="flex shrink-0 items-center gap-1" + /> ) : ( - + { + setError(null); + setDisplayName(""); + setFile(null); + setDialogOpen(true); + }} + size="sm" + className="gap-1.5" + > + + {t("submit_card_title")} + + } + className="mb-6" + /> {/* Stats Cards Row */}
@@ -276,15 +244,7 @@ export function MySubmissionsPage() { {/* Filters Bar */}
-
- - setSearch(e.target.value)} - className="pl-9" - /> -
+
{/* Created At */} @@ -494,12 +447,7 @@ export function MySubmissionsPage() {
{/* Error Banner */} - {error && ( -
- - {error} -
- )} + {error && } {/* Display Name Input */}
diff --git a/panel/src/pages/ServerBackups.tsx b/panel/src/pages/ServerBackups.tsx index 90febcb..39c828a 100644 --- a/panel/src/pages/ServerBackups.tsx +++ b/panel/src/pages/ServerBackups.tsx @@ -1,30 +1,30 @@ import { useState } from "react"; -import { Link, useParams } from "react-router-dom"; +import { useParams } from "react-router-dom"; import { - ArrowLeft, Archive, CheckCircle2, Clock, HardDrive, Loader2, RotateCcw, - ShieldX, UserMinus, } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { BackLink } from "@/components/BackLink"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { ConfirmFooter } from "@/components/ConfirmFooter"; import { Dialog, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { PhaseBadge } from "@/components/PhaseBadge"; -import { Loading, ErrorState, EmptyState } from "@/components/States"; +import { Loading, ErrorState, EmptyState, NotYours } from "@/components/States"; +import { PageHeader } from "@/components/PageHeader"; import { api, humanizeError } from "@/lib/api"; import { useAsync } from "@/lib/hooks"; import { useTier } from "@/lib/tier"; @@ -32,25 +32,6 @@ import { formatBytes, formatRelative, formatAbsolute, isExpired } from "@/lib/fo import { cn } from "@/lib/utils"; import type { BackupView } from "@/lib/types"; -/** NotYours mirrors ServerPlayers: viewing and restoring backups is owner-or-admin - * gated on the backend, but this is a real route anyone can type, so it says so - * plainly and offers a way back rather than rendering blank. */ -function NotYours() { - const { t } = useTranslation("backups"); - return ( -
- -
-

{t("not_yours_title")}

-

{t("not_yours_body")}

-
- - {t("my_servers_breadcrumb", { ns: "servers" })} - -
- ); -} - /** LatestBackupCard renders the most-recent backup as the restore card — the one a * restore actually recovers (the list is created_at-descending and the backend's * LatestBackup selects the same present row), with the restore action beneath. Older @@ -298,19 +279,13 @@ function RestoreControls({ )} {error &&

{error}

} - - - - + setOpen(false)} + onConfirm={confirmRestore} + loading={submitting} + cancelLabel={t("cancel")} + confirmLabel={t("restore_confirm_yes")} + /> ); @@ -365,12 +340,7 @@ export function ServerBackups() { const backupsQ = useAsync(() => api.listBackups(), []); const back = ( - - {t("back_to_console")} - + ); if (statusQ.loading && !statusQ.data) { @@ -407,18 +377,13 @@ export function ServerBackups() { .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)); const header = ( -
-
- -
-

- {statusQ.data.displayName || statusQ.data.name} -

-

{t("title")}

-
-
- -
+ } + className="mb-6" + /> ); return ( @@ -430,7 +395,7 @@ export function ServerBackups() { ) : mineQ.error ? ( ) : !owned ? ( - + ) : (

{t("subtitle")}

diff --git a/panel/src/pages/ServerConsole.tsx b/panel/src/pages/ServerConsole.tsx index f580723..9f7a224 100644 --- a/panel/src/pages/ServerConsole.tsx +++ b/panel/src/pages/ServerConsole.tsx @@ -1,10 +1,12 @@ import { useState, useRef, useCallback, useLayoutEffect, type KeyboardEvent } from "react"; import { Link, useParams } from "react-router-dom"; -import { ArrowLeft, Terminal, Moon, Shield, ShieldAlert, HelpCircle, Loader2, Users, Archive, ChevronRight, type LucideIcon } from "lucide-react"; +import { Terminal, Moon, Shield, ShieldAlert, HelpCircle, Loader2, Users, Archive, ChevronRight, type LucideIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { BackLink } from "@/components/BackLink"; import { PhaseBadge } from "@/components/PhaseBadge"; +import { PageHeader } from "@/components/PageHeader"; import { LogConsole } from "@/components/LogConsole"; import { Loading, ErrorState } from "@/components/States"; import { api, consoleStreamURL, humanizeError } from "@/lib/api"; @@ -191,12 +193,7 @@ export function ServerConsole() { return (
- - {t("my_servers_breadcrumb")} - +
{loading && !data ? ( @@ -205,37 +202,30 @@ export function ServerConsole() { ) : data ? ( <> -
-
- -
-

- {data.displayName || data.name} -

- {cfg && ( -

- {hostFor(data.subdomain, cfg)} -

+ + + {streamable ? ( + + ) : ( + )}
-
-
- - {streamable ? ( - - ) : ( - - )} -
-
+ } + className="mb-6" + />
{/* Left/Main column: Console */} diff --git a/panel/src/pages/ServerLuckPerms.tsx b/panel/src/pages/ServerLuckPerms.tsx index 5f863f2..b74cbff 100644 --- a/panel/src/pages/ServerLuckPerms.tsx +++ b/panel/src/pages/ServerLuckPerms.tsx @@ -1,7 +1,8 @@ import { useState } from "react"; -import { Link, useParams } from "react-router-dom"; -import { ArrowLeft, Moon, Shield, ShieldX, Users, Search, Plus, Trash2, Undo, History, Loader2 } from "lucide-react"; +import { useParams } from "react-router-dom"; +import { Shield, Users, Search, Plus, Trash2, Undo, History, Loader2 } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { BackLink } from "@/components/BackLink"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -15,7 +16,8 @@ import { } from "@/components/ui/select"; import { Badge } from "@/components/ui/badge"; import { PhaseBadge } from "@/components/PhaseBadge"; -import { Loading, ErrorState } from "@/components/States"; +import { Loading, ErrorState, NotYours, NotRunning } from "@/components/States"; +import { PageHeader } from "@/components/PageHeader"; import { api, humanizeError } from "@/lib/api"; import { useAsync } from "@/lib/hooks"; import { useTier } from "@/lib/tier"; @@ -51,40 +53,6 @@ interface ActionHistoryItem { reverted?: boolean; } -function NotYours() { - const { t } = useTranslation("servers"); - return ( -
- -
-

{t("players_not_yours_title")}

-

{t("players_not_yours_body")}

-
- - {t("my_servers_breadcrumb")} - -
- ); -} - -function NotRunning({ onWake }: { onWake: () => void }) { - const { t } = useTranslation("servers"); - return ( -
- -
-

{t("players_not_running_title")}

-

- {t("players_not_running_body")} -

-
- -
- ); -} - export function ServerLuckPerms() { const { name = "" } = useParams(); const { t } = useTranslation("servers"); @@ -298,12 +266,7 @@ export function ServerLuckPerms() { }; const back = ( - - {t("luckperms_back_to_console")} - + ); if (loading && !data) { @@ -329,18 +292,13 @@ export function ServerLuckPerms() { const phase: Phase = data.phase; const header = ( -
-
- -
-

- {data.displayName || data.name} -

-

{t("luckperms_desc")}

-
-
- -
+ } + className="mb-6" + /> ); return ( @@ -352,9 +310,9 @@ export function ServerLuckPerms() { ) : mineError ? ( ) : !owned ? ( - + ) : phase !== "Running" ? ( - api.wake(name).then(reload)} /> + api.wake(name).then(reload)} /> ) : (
{/* Left Directory Sidebar: Single card for Search + Online Players */} diff --git a/panel/src/pages/ServerPlayers.tsx b/panel/src/pages/ServerPlayers.tsx index 2bd26fd..807dbce 100644 --- a/panel/src/pages/ServerPlayers.tsx +++ b/panel/src/pages/ServerPlayers.tsx @@ -1,9 +1,11 @@ -import { Link, useParams } from "react-router-dom"; -import { ArrowLeft, Moon, ShieldX, Users } from "lucide-react"; +import { useParams } from "react-router-dom"; +import { Users } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { BackLink } from "@/components/BackLink"; import { Button } from "@/components/ui/button"; import { PhaseBadge } from "@/components/PhaseBadge"; -import { Loading, ErrorState } from "@/components/States"; +import { Loading, ErrorState, NotYours, NotRunning } from "@/components/States"; +import { PageHeader } from "@/components/PageHeader"; import { OnlineSection } from "@/components/players/OnlineSection"; import { WhitelistSection } from "@/components/players/WhitelistSection"; import { BansSection } from "@/components/players/BansSection"; @@ -12,48 +14,6 @@ import { useAsync } from "@/lib/hooks"; import { useTier } from "@/lib/tier"; import type { Phase } from "@/lib/types"; -/** NotYours is the explicit "this server isn't yours to manage" state. Player - * management is owner-or-admin gated on the backend, but this page is a real route: - * a non-owner (or anyone who types the URL) reaches it, so it must say so plainly - * and offer a way back — never render blank. */ -function NotYours() { - const { t } = useTranslation("servers"); - return ( -
- -
-

{t("players_not_yours_title")}

-

{t("players_not_yours_body")}

-
- - {t("my_servers_breadcrumb")} - -
- ); -} - -/** NotRunning is the page-level asleep state. Every section here needs a live RCON - * connection, so a stopped server has nothing to manage — the whole page collapses - * to one honest "wake it first" panel with the wake control, rather than three - * separately-empty sections. */ -function NotRunning({ onWake }: { onWake: () => void }) { - const { t } = useTranslation("servers"); - return ( -
- -
-

{t("players_not_running_title")}

-

- {t("players_not_running_body")} -

-
- -
- ); -} - /** ServerPlayers is the per-server player-management subpage (/servers/:name/players): * whitelist today, online roster and bans as they land. It owns its own gating — * ownership (from /me/servers, since GET status never carries `owned`) and server @@ -73,12 +33,7 @@ export function ServerPlayers() { ); const back = ( - - {t("players_back_to_console")} - + ); if (loading && !data) { @@ -112,18 +67,13 @@ export function ServerPlayers() { const phase: Phase = data.phase; const header = ( -
-
- -
-

- {data.displayName || data.name} -

-

{t("players_title")}

-
-
- -
+ } + className="mb-6" + /> ); return ( @@ -135,9 +85,9 @@ export function ServerPlayers() { ) : mineError ? ( ) : !owned ? ( - + ) : phase !== "Running" ? ( - api.wake(name).then(reload)} /> + api.wake(name).then(reload)} /> ) : (
{/* Ordered as a who-may-be-here gradient: who is on right now → who may diff --git a/panel/src/pages/admin/ImageAdmin.tsx b/panel/src/pages/admin/ImageAdmin.tsx index 626e75d..f903e92 100644 --- a/panel/src/pages/admin/ImageAdmin.tsx +++ b/panel/src/pages/admin/ImageAdmin.tsx @@ -1,21 +1,25 @@ import { useState, useMemo } from "react"; -import { Boxes, CheckCircle2, CircleSlash, Plus, Trash2, Loader2, Search, CheckCircle, type LucideIcon } from "lucide-react"; +import { Boxes, CheckCircle2, CircleSlash, Plus, Trash2, Loader2, Wrench } from "lucide-react"; +import { SearchInput } from "@/components/SearchInput"; +import { StatCard } from "@/components/StatCard"; +import { PageHeader } from "@/components/PageHeader"; import { useTranslation } from "react-i18next"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { ConfirmFooter } from "@/components/ConfirmFooter"; import { Dialog, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; +import { MessageLine } from "@/components/MessageLine"; import { Loading, ErrorState, EmptyState } from "@/components/States"; import { Pagination } from "@/components/Pagination"; import { api, humanizeError } from "@/lib/api"; @@ -23,32 +27,6 @@ import { useAsync } from "@/lib/hooks"; const PAGE_SIZE = 10; -function StatCard({ - icon: Icon, - label, - value, - accentClass, -}: { - icon: LucideIcon; - label: string; - value: number; - accentClass?: string; -}) { - return ( - - -
- -
-
-
{value}
-
{label}
-
-
-
- ); -} - export function ImageAdmin() { const { t } = useTranslation("admin"); const { data, error, loading, reload } = useAsync(() => api.listImages(), []); @@ -71,8 +49,10 @@ export function ImageAdmin() { const total = images.length; const enabled = images.filter((img) => img.enabled).length; const disabled = total - enabled; - return { total, enabled, disabled }; + const buildCount = total; + return { total, enabled, disabled, buildCount }; }, [images]); + const { total, enabled, disabled, buildCount } = stats; // Filtered & Paginated Images const filteredImages = useMemo(() => { @@ -111,8 +91,8 @@ export function ImageAdmin() { return filteredImages.slice(start, start + PAGE_SIZE); }, [filteredImages, page]); - async function handleAdd(e: React.FormEvent) { - e.preventDefault(); + async function handleAdd(e?: React.FormEvent) { + e?.preventDefault(); if (!newImageRef.trim()) return; setSubmitting(true); setSubmitError(null); @@ -143,19 +123,7 @@ export function ImageAdmin() { return (
- {/* Header */} -
-
- -
-

{t("images_title")}

-

- {t("images_subtitle")} -

-
-
- - {/* Dialog for Add External Image */} + { setDialogOpen(o); if (!o) { @@ -164,7 +132,7 @@ export function ImageAdmin() { } }}> - @@ -189,45 +157,26 @@ export function ImageAdmin() { required />
- {submitError && ( -

- {submitError} -

- )} - - - - + {submitError && } + setDialogOpen(false)} + onConfirm={() => handleAdd()} + disabled={submitting || !newImageRef.trim()} + loading={submitting} + cancelLabel={t("common:cancel")} + confirmLabel={t("add_image_btn")} + /> -
+ } className="mb-6" /> {/* Stats Cards Row */} -
- - - +
+ + + +
{/* Whitelist Table Card */} @@ -235,15 +184,7 @@ export function ImageAdmin() { {/* Filters Bar */}
-
- - setSearch(e.target.value)} - className="pl-9" - /> -
+
@@ -421,56 +414,27 @@ export function ImageBuildPage() { />
- {triggerError && ( -
- - {triggerError} -
- )} + {triggerError && } - - - - + setDialogOpen(false)} + onConfirm={() => handleTrigger()} + disabled={triggering || !imageRef.trim() || !contextRef.trim() || !dockerfile.trim()} + loading={triggering} + cancelLabel={t("common:cancel")} + confirmLabel={t("trigger_build_btn")} + /> -
+ } className="mb-6" /> {/* Build History Table Card */} {/* Filters Bar */}
-
- - setSearch(e.target.value)} - className="pl-9" - /> -
+
{/* List Content */} diff --git a/panel/src/pages/admin/SubmissionsPage.tsx b/panel/src/pages/admin/SubmissionsPage.tsx index ad50719..49903e5 100644 --- a/panel/src/pages/admin/SubmissionsPage.tsx +++ b/panel/src/pages/admin/SubmissionsPage.tsx @@ -1,20 +1,24 @@ import { useState, useMemo } from "react"; -import { ClipboardCheck, CheckCircle2, CircleSlash, Search, ChevronDown, ChevronUp, Check, X, Loader2 } from "lucide-react"; +import { ClipboardCheck, CheckCircle2, CircleSlash, ChevronDown, ChevronUp, Check, X, Loader2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; +import { StatCard } from "@/components/StatCard"; +import { PageHeader } from "@/components/PageHeader"; +import { SubmissionStatusBadge } from "@/components/SubmissionStatusBadge"; +import { SearchInput } from "@/components/SearchInput"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { ConfirmFooter } from "@/components/ConfirmFooter"; import { Dialog, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; +import { MessageLine } from "@/components/MessageLine"; import { Loading, ErrorState, EmptyState } from "@/components/States"; import { Pagination } from "@/components/Pagination"; import { api, humanizeError } from "@/lib/api"; @@ -24,32 +28,6 @@ import type { Submission, SubmissionStatus } from "@/lib/types"; const PAGE_SIZE = 10; -function StatCard({ - icon: Icon, - label, - value, - accentClass, -}: { - icon: any; - label: string; - value: number; - accentClass?: string; -}) { - return ( - - -
- -
-
-
{value}
-
{label}
-
-
-
- ); -} - export function SubmissionsPage() { const { t, i18n } = useTranslation("admin"); const locale = i18n.language; @@ -142,8 +120,8 @@ export function SubmissionsPage() { setRejectDialogOpen(true); } - async function handleRejectSubmit(e: React.FormEvent) { - e.preventDefault(); + async function handleRejectSubmit(e?: React.FormEvent) { + e?.preventDefault(); if (!rejectingId || !rejectReason.trim()) return; setBusyId(rejectingId); setBusyType("reject"); @@ -160,33 +138,12 @@ export function SubmissionsPage() { } } - const STATUS_BADGE_STYLE: Record = { - pending_review: "bg-amber-500/10 text-amber-500 border-amber-500/20", - approved: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20", - rejected: "bg-rose-500/10 text-rose-500 border-rose-500/20", - }; - return (
- {/* Header */} -
-
- -
-

{t("submissions_title")}

-

- {t("submissions_subtitle")} -

-
-
-
+ {/* Action Error Alert */} - {actionError && ( -

- {actionError} -

- )} + {actionError && } {/* Stats Cards Row */}
@@ -201,15 +158,7 @@ export function SubmissionsPage() { {/* Filters Bar */}
-
- - setSearch(e.target.value)} - className="pl-9" - /> -
+
{/* Created At */} @@ -489,30 +431,15 @@ export function SubmissionsPage() { className="flex min-h-[100px] w-full rounded-md border border-input bg-transparent px-3 py-2 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" />
- - - - + setRejectDialogOpen(false)} + onConfirm={() => handleRejectSubmit()} + disabled={!!busyId || !rejectReason.trim()} + loading={!!busyId && busyType === "reject"} + cancelLabel={t("common:cancel")} + confirmLabel={t("reject_dialog_submit")} + confirmVariant="destructive" + /> diff --git a/panel/src/pages/admin/UpdatesPage.tsx b/panel/src/pages/admin/UpdatesPage.tsx index f2a36b6..b2781f9 100644 --- a/panel/src/pages/admin/UpdatesPage.tsx +++ b/panel/src/pages/admin/UpdatesPage.tsx @@ -1,6 +1,10 @@ -import React, { useState, useEffect } from "react"; -import { Clock, Loader2, Calendar, AlertCircle, CheckCircle2, Globe, LucideIcon } from "lucide-react"; +import { useState, useEffect } from "react"; +import { Clock, Loader2, Calendar, Globe } from "lucide-react"; +import { MessageLine } from "@/components/MessageLine"; +import { StatCard } from "@/components/StatCard"; +import { PageHeader } from "@/components/PageHeader"; import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/utils"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -9,7 +13,6 @@ import { Loading, ErrorState } from "@/components/States"; import { api, humanizeError } from "@/lib/api"; import { useAsync } from "@/lib/hooks"; import { formatAbsolute } from "@/lib/format"; -import { cn } from "@/lib/utils"; function toLocalDatetimeString(dateOrStr: Date | string | null | undefined): string { if (!dateOrStr) return ""; @@ -24,36 +27,6 @@ function toLocalDatetimeString(dateOrStr: Date | string | null | undefined): str return `${YYYY}-${MM}-${DD}T${hh}:${mm}`; } -function StatCard({ - icon: Icon, - label, - value, - accentClass, - valueClass, -}: { - icon: LucideIcon; - label: string; - value: React.ReactNode; - accentClass?: string; - valueClass?: string; -}) { - return ( - - -
- -
-
-
- {value} -
-
{label}
-
-
-
- ); -} - export function UpdatesPage() { const { t, i18n } = useTranslation("admin"); const locale = i18n.language; @@ -191,16 +164,7 @@ export function UpdatesPage() { return (
- {/* Header */} -
- -
-

{t("updates_title")}

-

- {t("updates_subtitle")} -

-
-
+ {/* Stats Cards Row */}
@@ -209,25 +173,28 @@ export function UpdatesPage() { label={t("updates_stat_status")} value={statusText} accentClass={statusAccentClass} - valueClass={statusColorClass} + valueClass={cn("text-sm sm:text-base font-bold font-mono leading-tight truncate", statusColorClass)} />
@@ -278,17 +245,11 @@ export function UpdatesPage() {
{actionError && ( -
- -

{actionError}

-
+ )} {successMsg && ( -
- -

{successMsg}

-
+ )}
diff --git a/panel/src/pages/admin/UserDetailPage.tsx b/panel/src/pages/admin/UserDetailPage.tsx index 2ad2054..bc69b89 100644 --- a/panel/src/pages/admin/UserDetailPage.tsx +++ b/panel/src/pages/admin/UserDetailPage.tsx @@ -1,13 +1,12 @@ import { useState, useEffect } from "react"; import { useParams, useNavigate } from "react-router-dom"; +import { MessageLine } from "@/components/MessageLine"; +import { RoleBadge } from "@/components/RoleBadge"; +import { UserStatusBadge } from "@/components/UserStatusBadge"; import { - ArrowLeft, UserRound, Mail, - Shield, - Crown, Calendar, - Circle, Key, Clock, Trash2, @@ -15,7 +14,6 @@ import { PowerOff, Save, Loader2, - AlertCircle, CheckCircle2, RefreshCw, Unlink, @@ -25,6 +23,7 @@ import { } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { BackLink } from "@/components/BackLink"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -35,15 +34,16 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { ConfirmFooter } from "@/components/ConfirmFooter"; import { Dialog, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Loading, ErrorState } from "@/components/States"; +import { PageHeader } from "@/components/PageHeader"; import { api, humanizeError } from "@/lib/api"; import { useAsync } from "@/lib/hooks"; import { useTier } from "@/lib/tier"; @@ -70,71 +70,43 @@ export function UserDetailPage() { return (
{/* Back link */} - + - {/* Header */} -
-
+
-
-

{user.username}

-
- {user.email && ( - - - {user.email} - {user.email_verified && ( - - )} - - )} + )} + title={user.username} + subtitle={( +
+ {user.email && ( - - {formatAbsolute(user.created_at, locale)} + + {user.email} + {user.email_verified && ( + + )} -
-
-
-
- {user.disabled ? ( - - - {t("users_status_disabled")} - - ) : ( - - - {t("users_status_active")} - - )} - - {user.role === "owner" ? ( - - ) : ( - )} - {user.role === "owner" ? t("users_role_owner") : user.role === "admin" ? t("users_role_admin") : t("users_role_user")} - -
-
+ + + {formatAbsolute(user.created_at, locale)} + +
+ )} + actions={( +
+ + +
+ )} + className="mb-6" + />
{/* Edit profile */} @@ -227,16 +199,10 @@ function EditProfileCard({ user, onSaved, isSelf }: { user: UserDetail; onSaved: )} {err && ( -
- -

{err}

-
+ )} {ok && ( -
- -

{ok}

-
+ )} - - + setUnlinkDlg(null)} onConfirm={handleUnlinkConfirm} cancelLabel={t("common:cancel")} confirmLabel={t("users_unlink_btn")} /> @@ -512,16 +471,10 @@ function QuotasCard({ userId }: { userId: string }) {
{err && ( -
- -

{err}

-
+ )} {ok && ( -
- -

{ok}

-
+ )} - - + setRevokeOneDlg(null)} onConfirm={handleRevokeOne} cancelLabel={t("common:cancel")} confirmLabel={t("users_session_revoke_confirm")} /> @@ -683,14 +623,7 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () => {t("users_session_revoke_all_dlg_title")} {t("users_session_revoke_all_dlg_desc")} - - - - + setRevokeAllDlg(false)} onConfirm={handleRevokeAll} cancelLabel={t("common:cancel")} confirmLabel={t("users_session_revoke_confirm")} /> @@ -864,21 +797,7 @@ function DangerDialogs({ {err &&

{err}

} - - - - + @@ -903,20 +822,7 @@ function DangerDialogs({ {ok}

)} - - - - + @@ -933,22 +839,7 @@ function DangerDialogs({ {err &&

{err}

} - - - - + diff --git a/panel/src/pages/admin/UsersPage.tsx b/panel/src/pages/admin/UsersPage.tsx index c86d25f..81e8489 100644 --- a/panel/src/pages/admin/UsersPage.tsx +++ b/panel/src/pages/admin/UsersPage.tsx @@ -1,12 +1,10 @@ import { useState, useCallback } from "react"; import { Link, useNavigate } from "react-router-dom"; +import { RoleBadge } from "@/components/RoleBadge"; +import { UserStatusBadge } from "@/components/UserStatusBadge"; import { Users, Search, - Circle, - Shield, - UserRound, - Crown, Server, Mail, ChevronRight, @@ -23,6 +21,8 @@ import { SelectValue, } from "@/components/ui/select"; import { Loading, ErrorState, EmptyState } from "@/components/States"; +import { PageHeader } from "@/components/PageHeader"; +import { Pagination } from "@/components/Pagination"; import { CreateUserDialog } from "@/components/CreateUserDialog"; import { api } from "@/lib/api"; import { useAsync } from "@/lib/hooks"; @@ -65,22 +65,7 @@ export function UsersPage() { return (
- {/* Header */} -
-
- -
-

{t("users_title")}

-

{t("users_subtitle")}

-
-
- { - reload(); - navigate(`/admin/users/${id}`); - }} - /> -
+ { reload(); navigate(`/admin/users/${id}`); }} />} className="mb-6" /> {/* Filters */} @@ -166,34 +151,8 @@ export function UsersPage() { - {/* Pagination */} {totalPages > 1 && ( -
- - {t("users_total_count", { count: data.total })} - -
- - - {page + 1} / {totalPages} - - -
-
+ setPage(p - 1)} /> )} )} @@ -226,23 +185,7 @@ function UserRow({ user, locale }: { user: UserView; locale: string }) { )} - - {user.role === "owner" ? ( - - ) : user.role === "admin" ? ( - - ) : ( - - )} - {user.role === "owner" ? t("users_role_owner") : user.role === "admin" ? t("users_role_admin") : t("users_role_user")} - + @@ -251,17 +194,7 @@ function UserRow({ user, locale }: { user: UserView; locale: string }) { - {user.disabled ? ( - - - {t("users_status_disabled")} - - ) : ( - - - {t("users_status_active")} - - )} + {formatAbsolute(user.created_at, locale)} diff --git a/panel/src/pages/servers/ServersPage.tsx b/panel/src/pages/servers/ServersPage.tsx index 3bea84a..e1c3100 100644 --- a/panel/src/pages/servers/ServersPage.tsx +++ b/panel/src/pages/servers/ServersPage.tsx @@ -6,7 +6,6 @@ import { Square, Terminal, ExternalLink, - Search, RefreshCw, Server, Users, @@ -17,12 +16,12 @@ import { import { useTranslation } from "react-i18next"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { SearchInput } from "@/components/SearchInput"; +import { ConfirmFooter } from "@/components/ConfirmFooter"; import { Dialog, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; @@ -37,6 +36,8 @@ import { PhaseBadge, PHASE_KEY, PHASE_COLOR } from "@/components/PhaseBadge"; import { Loading, ErrorState, EmptyState } from "@/components/States"; import { Pagination } from "@/components/Pagination"; import { CreateServerDialog } from "@/components/CreateServerDialog"; +import { StatCard } from "@/components/StatCard"; +import { PageHeader } from "@/components/PageHeader"; import { api, humanizeError } from "@/lib/api"; import { useAsync, useConfig } from "@/lib/hooks"; import { useTier } from "@/lib/tier"; @@ -83,38 +84,6 @@ interface UnifiedServer { owned?: boolean; } -function Stat({ - icon: Icon, - label, - value, - accent, -}: { - icon: typeof Server; - label: string; - value: number | string; - accent?: string; -}) { - return ( - - -
- -
-
-
{value}
-
{label}
-
-
-
- ); -} - export function ServersPage() { const { t } = useTranslation(["ops", "servers"]); const { isAdmin, identity } = useTier(); @@ -218,35 +187,30 @@ export function ServersPage() { return (
-
-
- -
-

- {isAdmin ? t("fleet_title") : t("servers:my_servers_title")} -

-

- {isAdmin ? t("fleet_subtitle") : t("servers:my_servers_subtitle")} -

+ + + + {t("fleet_live")} + + + {isAdmin && cfg && }
-
-
- {isAdmin && cfg && } - - - {t("fleet_live")} - - -
-
+ } + className="mb-6" + /> {showInitialLoading ? ( @@ -265,23 +229,23 @@ export function ServersPage() { {/* Stats Cards in a full grid row */}
- - + - - 0 ? PHASE_COLOR.Failed : undefined} + accentColor={stats.counts.Failed > 0 ? PHASE_COLOR.Failed : undefined} />
@@ -326,18 +290,14 @@ export function ServersPage() { {/* Independent Search & Filter bar */}
-
- - { - setQuery(e.target.value); - setPage(1); - }} - placeholder={isAdmin ? t("fleet_search_placeholder") : t("servers:search_placeholder")} - className="pl-9" - /> -
+ { + setQuery(v); + setPage(1); + }} + placeholder={isAdmin ? t("fleet_search_placeholder") : t("servers:search_placeholder")} + />