feat(panel): implement admin maintenance window settings page
This commit is contained in:
11 files changed
+462
-4
No files matched your search
@@ -55,6 +55,7 @@ interface MockState {
|
||||
builds: Build[];
|
||||
passkeys: Record<AccountID, { id: string; name: string; created_at: string }[]>;
|
||||
submissions: Submission[];
|
||||
updateWindow: { start: string | null; end: string | null };
|
||||
}
|
||||
|
||||
// PLAYER_NAME mirrors the backend's mcNameRe (handlers_access.go) so the mock
|
||||
@@ -333,6 +334,7 @@ function initialState(): MockState {
|
||||
reviewed_at: new Date(Date.now() - 10700000).toISOString(),
|
||||
},
|
||||
],
|
||||
updateWindow: { start: null, end: null },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -630,6 +632,40 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
|
||||
|
||||
async function handleSession(ctx: SessionContext): Promise<boolean> {
|
||||
switch (route(ctx)) {
|
||||
case "GET updates/window":
|
||||
if (ctx.account.role !== "admin") {
|
||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||
return true;
|
||||
}
|
||||
sendJSON(ctx.res, 200, ctx.state.updateWindow);
|
||||
return true;
|
||||
case "PUT updates/window": {
|
||||
if (ctx.account.role !== "admin") {
|
||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||
return true;
|
||||
}
|
||||
const body = await readJSON<{ start: string | null; end: string | null }>(ctx.req);
|
||||
if (body.start === null && body.end === null) {
|
||||
ctx.state.updateWindow = { start: null, end: null };
|
||||
} else if (body.start === null || body.end === null) {
|
||||
sendError(ctx.res, 400, "bad_request", "start and end must both be set or both be null");
|
||||
return true;
|
||||
} else {
|
||||
const start = new Date(body.start);
|
||||
const end = new Date(body.end);
|
||||
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
||||
sendError(ctx.res, 400, "bad_request", "invalid start or end time format");
|
||||
return true;
|
||||
}
|
||||
if (end <= start) {
|
||||
sendError(ctx.res, 400, "bad_request", "end must be after start");
|
||||
return true;
|
||||
}
|
||||
ctx.state.updateWindow = { start: body.start, end: body.end };
|
||||
}
|
||||
sendJSON(ctx.res, 200, ctx.state.updateWindow);
|
||||
return true;
|
||||
}
|
||||
case "GET me/servers":
|
||||
sendJSON(ctx.res, 200, { servers: visibleServers(ctx.state, ctx.account) });
|
||||
return true;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ImageAdmin } from "@/pages/admin/ImageAdmin";
|
||||
import { ImageBuildPage } from "@/pages/admin/ImageBuildPage";
|
||||
import { SubmissionsPage } from "@/pages/admin/SubmissionsPage";
|
||||
import { MySubmissionsPage } from "@/pages/MySubmissionsPage";
|
||||
import { UpdatesPage } from "@/pages/admin/UpdatesPage";
|
||||
|
||||
// Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES):
|
||||
// / User-Side — app-tier, every authenticated principal
|
||||
@@ -57,6 +58,7 @@ export default function App() {
|
||||
<Route path="images" element={<ImageAdmin />} />
|
||||
<Route path="builds" element={<ImageBuildPage />} />
|
||||
<Route path="submissions" element={<SubmissionsPage />} />
|
||||
<Route path="updates" element={<UpdatesPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -71,5 +71,28 @@
|
||||
"no_submissions_hint": "There are currently no pending or reviewed modpack submissions.",
|
||||
"reviewer": "Reviewer",
|
||||
"reviewed_at": "Reviewed At",
|
||||
"reject_reason": "Rejection Reason"
|
||||
"reject_reason": "Rejection Reason",
|
||||
"updates_title": "Maintenance Window",
|
||||
"updates_subtitle": "Configure the platform-wide maintenance window. A Scheduled auto-update component may only be applied by Felis within this window; outside it, updates are notify-only.",
|
||||
"updates_current_title": "Current Setting",
|
||||
"updates_current_unset": "No maintenance window set. Scheduled updates will degrade to notify-only and will not be applied automatically.",
|
||||
"updates_current_set": "Felis may apply auto-updates between the following period:",
|
||||
"updates_start_label": "Start Time",
|
||||
"updates_end_label": "End Time",
|
||||
"updates_set_title": "Configure Maintenance Window",
|
||||
"updates_submit_btn": "Save Maintenance Window",
|
||||
"updates_clear_btn": "Clear Maintenance Window",
|
||||
"updates_save_success": "Maintenance window updated successfully.",
|
||||
"updates_clear_success": "Maintenance window cleared successfully.",
|
||||
"updates_validation_both": "Both start and end times must be set, or both cleared.",
|
||||
"updates_validation_order": "End time must be strictly after start time.",
|
||||
"updates_status_active": "Currently Active",
|
||||
"updates_status_pending": "Scheduled (Future)",
|
||||
"updates_status_expired": "Expired (Past)",
|
||||
"updates_status_unset": "Unset",
|
||||
"updates_timezone_hint": "Times are selected and displayed in your browser's local time zone ({{tz}}).",
|
||||
"updates_stat_status": "System Status",
|
||||
"updates_stat_start": "Start Time",
|
||||
"updates_stat_end": "End Time",
|
||||
"updates_stat_timezone": "Local Timezone"
|
||||
}
|
||||
@@ -6,5 +6,6 @@
|
||||
"admin_section": "Admin",
|
||||
"admin_images": "Images",
|
||||
"admin_builds": "Build Pipeline",
|
||||
"admin_submissions": "Submissions"
|
||||
"admin_submissions": "Submissions",
|
||||
"admin_updates": "Maintenance Window"
|
||||
}
|
||||
@@ -71,5 +71,28 @@
|
||||
"no_submissions_hint": "当前没有待审核或已审核的模组包提交。",
|
||||
"reviewer": "审核人",
|
||||
"reviewed_at": "审核时间",
|
||||
"reject_reason": "驳回理由"
|
||||
"reject_reason": "驳回理由",
|
||||
"updates_title": "维护窗口",
|
||||
"updates_subtitle": "配置全局系统维护窗口。在此窗口内,Felis 可以自动应用系统更新;在窗口外,更新将降级为仅通知,不会自动执行。",
|
||||
"updates_current_title": "当前设置",
|
||||
"updates_current_unset": "当前未设置维护窗口。自动更新将降级为仅通知,不会自动执行。",
|
||||
"updates_current_set": "Felis 可在以下时间段内自动执行更新:",
|
||||
"updates_start_label": "开始时间",
|
||||
"updates_end_label": "结束时间",
|
||||
"updates_set_title": "配置维护窗口",
|
||||
"updates_submit_btn": "保存维护窗口",
|
||||
"updates_clear_btn": "清除维护窗口",
|
||||
"updates_save_success": "维护窗口更新成功。",
|
||||
"updates_clear_success": "维护窗口已清除。",
|
||||
"updates_validation_both": "开始时间和结束时间必须同时设置,或同时清空。",
|
||||
"updates_validation_order": "结束时间必须在开始时间之后。",
|
||||
"updates_status_active": "当前生效中",
|
||||
"updates_status_pending": "已计划(未来)",
|
||||
"updates_status_expired": "已过期(过去)",
|
||||
"updates_status_unset": "未设置",
|
||||
"updates_timezone_hint": "时间以您的浏览器本地时区 ({{tz}}) 选择和显示。",
|
||||
"updates_stat_status": "系统状态",
|
||||
"updates_stat_start": "维护开始时间",
|
||||
"updates_stat_end": "维护结束时间",
|
||||
"updates_stat_timezone": "本地时区"
|
||||
}
|
||||
@@ -6,5 +6,6 @@
|
||||
"admin_section": "管理",
|
||||
"admin_images": "镜像",
|
||||
"admin_builds": "构建流水线",
|
||||
"admin_submissions": "审核提交"
|
||||
"admin_submissions": "审核提交",
|
||||
"admin_updates": "维护窗口"
|
||||
}
|
||||
@@ -482,4 +482,30 @@ describe("image whitelist and builds wire shapes", () => {
|
||||
expect((opts as RequestInit).headers).toEqual({ "Content-Type": "application/x-gzip" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("updates maintenance window", () => {
|
||||
it("getUpdateWindow GETs from /updates/window", async () => {
|
||||
const win = { start: "2026-07-03T12:00:00Z", end: "2026-07-03T14:00:00Z" };
|
||||
const fetchSpy = fakeFetch(win);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.getUpdateWindow();
|
||||
expect(res).toEqual(win);
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(String(url)).toBe("/updates/window");
|
||||
expect((opts as RequestInit).method).toBe("GET");
|
||||
});
|
||||
|
||||
it("setUpdateWindow PUTs UpdateWindow payload to /updates/window", async () => {
|
||||
const win = { start: "2026-07-03T12:00:00Z", end: "2026-07-03T14:00:00Z" };
|
||||
const fetchSpy = fakeFetch(win);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.setUpdateWindow(win);
|
||||
expect(res).toEqual(win);
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(String(url)).toBe("/updates/window");
|
||||
expect((opts as RequestInit).method).toBe("PUT");
|
||||
expect((opts as RequestInit).headers).toEqual({ "Content-Type": "application/json" });
|
||||
expect(JSON.parse((opts as RequestInit).body as string)).toEqual(win);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
WhitelistImage,
|
||||
WhitelistResult,
|
||||
Submission,
|
||||
UpdateWindow,
|
||||
} from "./types";
|
||||
import { loadConfig } from "./config";
|
||||
import i18next from "i18next";
|
||||
@@ -301,6 +302,10 @@ export const api = {
|
||||
requestRaw<Submission>("POST", `/me/submissions/${id}/context`, file, {
|
||||
"Content-Type": "application/x-gzip",
|
||||
}),
|
||||
|
||||
getUpdateWindow: () => request<UpdateWindow>("GET", "/updates/window"),
|
||||
|
||||
setUpdateWindow: (window: UpdateWindow) => request<UpdateWindow>("PUT", "/updates/window", window),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Cpu,
|
||||
ClipboardCheck,
|
||||
Upload,
|
||||
Clock,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -57,6 +58,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
{ to: "/admin/images", key: "admin_images", icon: Boxes },
|
||||
{ to: "/admin/builds", key: "admin_builds", icon: Cpu },
|
||||
{ to: "/admin/submissions", key: "admin_submissions", icon: ClipboardCheck },
|
||||
{ to: "/admin/updates", key: "admin_updates", icon: Clock },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -269,3 +269,8 @@ export interface Submission {
|
||||
created_at: string;
|
||||
reviewed_at?: string;
|
||||
}
|
||||
|
||||
export interface UpdateWindow {
|
||||
start: string | null;
|
||||
end: string | null;
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Clock, Loader2, Calendar, AlertCircle, CheckCircle2, Globe, LucideIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
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 "";
|
||||
const d = new Date(dateOrStr);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
|
||||
const YYYY = d.getFullYear();
|
||||
const MM = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const DD = String(d.getDate()).padStart(2, "0");
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
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 (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className={cn("rounded-md p-2 bg-muted/30", accentClass || "text-muted-foreground")}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={cn("text-sm sm:text-base font-bold font-mono leading-tight truncate", valueClass || "text-foreground")} title={typeof value === "string" ? value : undefined}>
|
||||
{value}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground font-medium">{label}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdatesPage() {
|
||||
const { t, i18n } = useTranslation("admin");
|
||||
const locale = i18n.language;
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
const { data, error, loading, reload } = useAsync(() => api.getUpdateWindow(), []);
|
||||
|
||||
const [startVal, setStartVal] = useState("");
|
||||
const [endVal, setEndVal] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setStartVal(toLocalDatetimeString(data.start));
|
||||
setEndVal(toLocalDatetimeString(data.end));
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const windowStatus = (() => {
|
||||
if (!data || !data.start || !data.end) return "unset";
|
||||
const now = new Date();
|
||||
const start = new Date(data.start);
|
||||
const end = new Date(data.end);
|
||||
if (now < start) return "pending";
|
||||
if (now >= start && now < end) return "active";
|
||||
return "expired";
|
||||
})();
|
||||
|
||||
async function handleClear() {
|
||||
if (clearing) return;
|
||||
setClearing(true);
|
||||
setActionError(null);
|
||||
setSuccessMsg(null);
|
||||
try {
|
||||
await api.setUpdateWindow({ start: null, end: null });
|
||||
setStartVal("");
|
||||
setEndVal("");
|
||||
setSuccessMsg(t("updates_clear_success"));
|
||||
reload();
|
||||
} catch (err) {
|
||||
setActionError(humanizeError(err));
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (submitting) return;
|
||||
setActionError(null);
|
||||
setSuccessMsg(null);
|
||||
|
||||
if (!startVal && !endVal) {
|
||||
handleClear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!startVal || !endVal) {
|
||||
setActionError(t("updates_validation_both"));
|
||||
return;
|
||||
}
|
||||
|
||||
const startD = new Date(startVal);
|
||||
const endD = new Date(endVal);
|
||||
|
||||
if (endD <= startD) {
|
||||
setActionError(t("updates_validation_order"));
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await api.setUpdateWindow({
|
||||
start: startD.toISOString(),
|
||||
end: endD.toISOString(),
|
||||
});
|
||||
setSuccessMsg(t("updates_save_success"));
|
||||
reload();
|
||||
} catch (err) {
|
||||
setActionError(humanizeError(err));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && !data) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorState error={error} onRetry={reload} />;
|
||||
}
|
||||
|
||||
const statusText = (() => {
|
||||
switch (windowStatus) {
|
||||
case "active":
|
||||
return t("updates_status_active");
|
||||
case "pending":
|
||||
return t("updates_status_pending");
|
||||
case "expired":
|
||||
return t("updates_status_expired");
|
||||
default:
|
||||
return t("updates_status_unset");
|
||||
}
|
||||
})();
|
||||
|
||||
const statusColorClass = (() => {
|
||||
switch (windowStatus) {
|
||||
case "active":
|
||||
return "text-emerald-500";
|
||||
case "pending":
|
||||
return "text-blue-500";
|
||||
case "expired":
|
||||
return "text-zinc-500";
|
||||
default:
|
||||
return "text-amber-500";
|
||||
}
|
||||
})();
|
||||
|
||||
const statusAccentClass = (() => {
|
||||
switch (windowStatus) {
|
||||
case "active":
|
||||
return "text-emerald-500 bg-emerald-500/10";
|
||||
case "pending":
|
||||
return "text-blue-500 bg-blue-500/10";
|
||||
case "expired":
|
||||
return "text-zinc-500 bg-zinc-500/10";
|
||||
default:
|
||||
return "text-amber-500 bg-amber-500/10";
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="h-6 w-6 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t("updates_title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("updates_subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards Row */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||
<StatCard
|
||||
icon={Clock}
|
||||
label={t("updates_stat_status")}
|
||||
value={statusText}
|
||||
accentClass={statusAccentClass}
|
||||
valueClass={statusColorClass}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Calendar}
|
||||
label={t("updates_stat_start")}
|
||||
value={data?.start ? formatAbsolute(data.start, locale) : "—"}
|
||||
accentClass="text-primary bg-primary/10"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Calendar}
|
||||
label={t("updates_stat_end")}
|
||||
value={data?.end ? formatAbsolute(data.end, locale) : "—"}
|
||||
accentClass="text-primary bg-primary/10"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Globe}
|
||||
label={t("updates_stat_timezone")}
|
||||
value={tz}
|
||||
accentClass="text-primary bg-primary/10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Configuration Card */}
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">{t("updates_set_title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 text-sm">
|
||||
{/* Unset hint warning */}
|
||||
{windowStatus === "unset" && (
|
||||
<p className="text-muted-foreground bg-muted/15 border border-dashed border-border rounded-lg p-4 leading-relaxed">
|
||||
{t("updates_current_unset")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="startTime" className="text-xs font-semibold text-muted-foreground">{t("updates_start_label")} *</Label>
|
||||
<Input
|
||||
id="startTime"
|
||||
type="datetime-local"
|
||||
value={startVal}
|
||||
onChange={(e) => setStartVal(e.target.value)}
|
||||
disabled={submitting || clearing}
|
||||
className="w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="endTime" className="text-xs font-semibold text-muted-foreground">{t("updates_end_label")} *</Label>
|
||||
<Input
|
||||
id="endTime"
|
||||
type="datetime-local"
|
||||
value={endVal}
|
||||
onChange={(e) => setEndVal(e.target.value)}
|
||||
disabled={submitting || clearing}
|
||||
className="w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-muted-foreground leading-normal bg-muted/15 border border-border/40 rounded p-3">
|
||||
{t("updates_timezone_hint", { tz })}
|
||||
</div>
|
||||
|
||||
{actionError && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<p>{actionError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{successMsg && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-emerald-500/20 bg-emerald-500/10 p-3 text-sm text-emerald-500">
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||
<p>{successMsg}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-3 pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting || clearing}
|
||||
className="text-xs px-4"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-1.5" />
|
||||
{t("common:creating", "Saving...")}
|
||||
</>
|
||||
) : (
|
||||
t("updates_submit_btn")
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{windowStatus !== "unset" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="text-xs px-4 text-destructive hover:text-destructive hover:bg-destructive/10 border-input hover:border-destructive/20"
|
||||
disabled={submitting || clearing}
|
||||
onClick={handleClear}
|
||||
>
|
||||
{clearing ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-1.5" />
|
||||
{t("common:creating", "Processing...")}
|
||||
</>
|
||||
) : (
|
||||
t("updates_clear_btn")
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user