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[];
|
builds: Build[];
|
||||||
passkeys: Record<AccountID, { id: string; name: string; created_at: string }[]>;
|
passkeys: Record<AccountID, { id: string; name: string; created_at: string }[]>;
|
||||||
submissions: Submission[];
|
submissions: Submission[];
|
||||||
|
updateWindow: { start: string | null; end: string | null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// PLAYER_NAME mirrors the backend's mcNameRe (handlers_access.go) so the mock
|
// 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(),
|
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> {
|
async function handleSession(ctx: SessionContext): Promise<boolean> {
|
||||||
switch (route(ctx)) {
|
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":
|
case "GET me/servers":
|
||||||
sendJSON(ctx.res, 200, { servers: visibleServers(ctx.state, ctx.account) });
|
sendJSON(ctx.res, 200, { servers: visibleServers(ctx.state, ctx.account) });
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { ImageAdmin } from "@/pages/admin/ImageAdmin";
|
|||||||
import { ImageBuildPage } from "@/pages/admin/ImageBuildPage";
|
import { ImageBuildPage } from "@/pages/admin/ImageBuildPage";
|
||||||
import { SubmissionsPage } from "@/pages/admin/SubmissionsPage";
|
import { SubmissionsPage } from "@/pages/admin/SubmissionsPage";
|
||||||
import { MySubmissionsPage } from "@/pages/MySubmissionsPage";
|
import { MySubmissionsPage } from "@/pages/MySubmissionsPage";
|
||||||
|
import { UpdatesPage } from "@/pages/admin/UpdatesPage";
|
||||||
|
|
||||||
// Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES):
|
// Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES):
|
||||||
// / User-Side — app-tier, every authenticated principal
|
// / User-Side — app-tier, every authenticated principal
|
||||||
@@ -57,6 +58,7 @@ export default function App() {
|
|||||||
<Route path="images" element={<ImageAdmin />} />
|
<Route path="images" element={<ImageAdmin />} />
|
||||||
<Route path="builds" element={<ImageBuildPage />} />
|
<Route path="builds" element={<ImageBuildPage />} />
|
||||||
<Route path="submissions" element={<SubmissionsPage />} />
|
<Route path="submissions" element={<SubmissionsPage />} />
|
||||||
|
<Route path="updates" element={<UpdatesPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -71,5 +71,28 @@
|
|||||||
"no_submissions_hint": "There are currently no pending or reviewed modpack submissions.",
|
"no_submissions_hint": "There are currently no pending or reviewed modpack submissions.",
|
||||||
"reviewer": "Reviewer",
|
"reviewer": "Reviewer",
|
||||||
"reviewed_at": "Reviewed At",
|
"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_section": "Admin",
|
||||||
"admin_images": "Images",
|
"admin_images": "Images",
|
||||||
"admin_builds": "Build Pipeline",
|
"admin_builds": "Build Pipeline",
|
||||||
"admin_submissions": "Submissions"
|
"admin_submissions": "Submissions",
|
||||||
|
"admin_updates": "Maintenance Window"
|
||||||
}
|
}
|
||||||
@@ -71,5 +71,28 @@
|
|||||||
"no_submissions_hint": "当前没有待审核或已审核的模组包提交。",
|
"no_submissions_hint": "当前没有待审核或已审核的模组包提交。",
|
||||||
"reviewer": "审核人",
|
"reviewer": "审核人",
|
||||||
"reviewed_at": "审核时间",
|
"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_section": "管理",
|
||||||
"admin_images": "镜像",
|
"admin_images": "镜像",
|
||||||
"admin_builds": "构建流水线",
|
"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" });
|
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,
|
WhitelistImage,
|
||||||
WhitelistResult,
|
WhitelistResult,
|
||||||
Submission,
|
Submission,
|
||||||
|
UpdateWindow,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { loadConfig } from "./config";
|
import { loadConfig } from "./config";
|
||||||
import i18next from "i18next";
|
import i18next from "i18next";
|
||||||
@@ -301,6 +302,10 @@ export const api = {
|
|||||||
requestRaw<Submission>("POST", `/me/submissions/${id}/context`, file, {
|
requestRaw<Submission>("POST", `/me/submissions/${id}/context`, file, {
|
||||||
"Content-Type": "application/x-gzip",
|
"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,
|
Cpu,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
Upload,
|
Upload,
|
||||||
|
Clock,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
{ to: "/admin/images", key: "admin_images", icon: Boxes },
|
{ to: "/admin/images", key: "admin_images", icon: Boxes },
|
||||||
{ to: "/admin/builds", key: "admin_builds", icon: Cpu },
|
{ to: "/admin/builds", key: "admin_builds", icon: Cpu },
|
||||||
{ to: "/admin/submissions", key: "admin_submissions", icon: ClipboardCheck },
|
{ 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;
|
created_at: string;
|
||||||
reviewed_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