Skip to content

Commit 6129f10

Browse files
committed
feat: implement update check feature
1 parent 9cdfe93 commit 6129f10

7 files changed

Lines changed: 433 additions & 4 deletions

File tree

frontend/features/admin/components/admin-sidebar.tsx

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,18 @@
33
import * as React from "react";
44
import Link from "next/link";
55
import { useTranslations } from "next-intl";
6+
import { CircleArrowUp } from "lucide-react";
67

8+
import packageMeta from "@/package.json";
9+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
710
import { ADMIN_SECTIONS, type AdminSection } from "@/features/admin/model/admin-sections";
11+
import { AdminUpdateTooltipContent } from "@/features/admin/components/admin-update-tooltip-content";
12+
import {
13+
getCachedLatestReleaseSnapshot,
14+
getServerLatestReleaseSnapshot,
15+
resolveAvailableRelease,
16+
subscribeLatestReleaseChange,
17+
} from "@/features/admin/model/update-check";
818
import { cn } from "@/lib/utils";
919

1020
export function AdminSidebar({
@@ -15,6 +25,13 @@ export function AdminSidebar({
1525
basePath: string;
1626
}) {
1727
const t = useTranslations("adminUsers");
28+
const tAbout = useTranslations("adminUsers.aboutPage");
29+
const cachedLatestRelease = React.useSyncExternalStore(
30+
subscribeLatestReleaseChange,
31+
getCachedLatestReleaseSnapshot,
32+
getServerLatestReleaseSnapshot,
33+
);
34+
const updateRelease = resolveAvailableRelease(packageMeta.version, cachedLatestRelease);
1835
const sectionLabel = React.useCallback(
1936
(id: AdminSection, fallback: string) => {
2037
const keyByID: Record<AdminSection, string> = {
@@ -54,13 +71,25 @@ export function AdminSidebar({
5471
href={`${basePath}${item.href}`}
5572
aria-current={active ? "page" : undefined}
5673
className={cn(
57-
"relative flex h-8 shrink-0 items-center whitespace-nowrap rounded-md px-3 text-sm font-medium transition-colors xl:h-9 xl:w-full xl:px-3.5",
74+
"relative flex h-8 shrink-0 items-center justify-between gap-2 whitespace-nowrap rounded-md px-3 text-sm font-medium transition-colors xl:h-9 xl:w-full xl:px-3.5",
5875
active
5976
? "bg-sidebar-accent text-sidebar-accent-foreground"
6077
: "text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
6178
)}
6279
>
63-
{sectionLabel(item.id, item.label)}
80+
<span className="truncate">{sectionLabel(item.id, item.label)}</span>
81+
{item.id === "about" && updateRelease ? (
82+
<Tooltip>
83+
<TooltipTrigger asChild>
84+
<span className="ml-auto inline-flex size-4 shrink-0 items-center justify-center text-rose-500">
85+
<CircleArrowUp className="size-3.5" aria-label={tAbout("updateAvailableIndicator")} />
86+
</span>
87+
</TooltipTrigger>
88+
<TooltipContent>
89+
<AdminUpdateTooltipContent updateRelease={updateRelease} />
90+
</TooltipContent>
91+
</Tooltip>
92+
) : null}
6493
</Link>
6594
);
6695
})}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"use client";
2+
3+
import { useTranslations } from "next-intl";
4+
5+
import packageMeta from "@/package.json";
6+
import type { ReleaseInfo } from "@/features/admin/model/update-check";
7+
import { formatReleaseVersion } from "@/features/admin/model/update-check";
8+
9+
export function AdminUpdateTooltipContent({ updateRelease }: { updateRelease: ReleaseInfo | null }) {
10+
const t = useTranslations("adminUsers.aboutPage");
11+
const currentVersion = formatReleaseVersion(packageMeta.version);
12+
13+
if (!updateRelease) {
14+
return (
15+
<div className="space-y-1">
16+
<p>{t("updateTooltip.currentTitle")}</p>
17+
<p>{t("updateTooltip.currentVersion", { current: currentVersion })}</p>
18+
</div>
19+
);
20+
}
21+
22+
return (
23+
<div className="space-y-1.5">
24+
<p>{t("updateTooltip.title")}</p>
25+
<div className="grid gap-1">
26+
<p>{t("updateTooltip.currentVersion", { current: currentVersion })}</p>
27+
<p>{t("updateTooltip.latestVersion", { latest: formatReleaseVersion(updateRelease.version) })}</p>
28+
</div>
29+
<p>{t("updateTooltip.description")}</p>
30+
</div>
31+
);
32+
}

frontend/features/admin/components/sections/admin-about.tsx

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,208 @@
11
"use client";
22

3+
import { useSyncExternalStore, useState } from "react";
34
import { useTranslations } from "next-intl";
5+
import { CircleArrowUp, RefreshCw } from "lucide-react";
46

7+
import packageMeta from "@/package.json";
8+
import { Button } from "@/components/ui/button";
9+
import {
10+
Dialog,
11+
DialogClose,
12+
DialogContent,
13+
DialogDescription,
14+
DialogFooter,
15+
DialogHeader,
16+
DialogTitle,
17+
} from "@/components/ui/dialog";
18+
import { AdminUpdateTooltipContent } from "@/features/admin/components/admin-update-tooltip-content";
19+
import {
20+
compareReleaseVersions,
21+
formatReleaseVersion,
22+
getCachedLatestReleaseSnapshot,
23+
getServerLatestReleaseSnapshot,
24+
LATEST_RELEASE_ENDPOINT,
25+
resolveAvailableRelease,
26+
subscribeLatestReleaseChange,
27+
type ReleaseInfo,
28+
writeCachedLatestRelease,
29+
} from "@/features/admin/model/update-check";
530
import { AboutSettingsContent } from "@/shared/components/about-settings-content";
31+
import { cn } from "@/lib/utils";
32+
33+
type GitHubRelease = {
34+
tag_name?: string;
35+
html_url?: string;
36+
};
37+
38+
type UpdateDialogState =
39+
| { type: "current" }
40+
| { type: "available"; release: ReleaseInfo }
41+
| { type: "failed" };
42+
43+
function AdminUpdateCheck() {
44+
const t = useTranslations("adminUsers.aboutPage");
45+
const [checking, setChecking] = useState(false);
46+
const [dialogState, setDialogState] = useState<UpdateDialogState | null>(null);
47+
48+
async function handleCheckUpdate() {
49+
if (checking) return;
50+
51+
setChecking(true);
52+
try {
53+
const response = await fetch(LATEST_RELEASE_ENDPOINT, {
54+
cache: "no-store",
55+
headers: { Accept: "application/vnd.github+json" },
56+
});
57+
58+
if (!response.ok) {
59+
throw new Error(`Release check failed with HTTP ${response.status}`);
60+
}
61+
62+
const release = (await response.json()) as GitHubRelease;
63+
const latestVersion = release.tag_name?.trim();
64+
const releaseURL = release.html_url?.trim();
65+
66+
if (!latestVersion || !releaseURL) {
67+
throw new Error("Latest release payload is incomplete");
68+
}
69+
70+
const currentVersion = packageMeta.version;
71+
const compareResult = compareReleaseVersions(currentVersion, latestVersion);
72+
73+
if (compareResult === "available" || compareResult === "unknown") {
74+
const release = { version: latestVersion, url: releaseURL };
75+
writeCachedLatestRelease(release);
76+
setDialogState({ type: "available", release });
77+
return;
78+
}
79+
80+
writeCachedLatestRelease({ version: latestVersion, url: releaseURL });
81+
setDialogState({ type: "current" });
82+
} catch {
83+
setDialogState({ type: "failed" });
84+
} finally {
85+
setChecking(false);
86+
}
87+
}
88+
89+
return (
90+
<>
91+
<button
92+
type="button"
93+
className="inline-flex items-center gap-1 text-xs text-muted-foreground/80 transition-colors hover:text-foreground disabled:cursor-wait disabled:opacity-70"
94+
onClick={() => void handleCheckUpdate()}
95+
disabled={checking}
96+
>
97+
<RefreshCw className={cn("size-3", checking && "animate-spin")} />
98+
<span>{checking ? t("checkingUpdate") : t("checkUpdate")}</span>
99+
</button>
100+
<UpdateResultDialog
101+
state={dialogState}
102+
onOpenChange={(open) => {
103+
if (!open) setDialogState(null);
104+
}}
105+
onRetry={() => void handleCheckUpdate()}
106+
/>
107+
</>
108+
);
109+
}
110+
111+
function AdminAboutVersionBadge({ updateRelease }: { updateRelease: ReleaseInfo | null }) {
112+
const t = useTranslations("adminUsers.aboutPage");
113+
const currentVersion = formatReleaseVersion(packageMeta.version);
114+
115+
return (
116+
<span className="inline-flex items-center gap-1.5">
117+
<span>{currentVersion}</span>
118+
{updateRelease ? (
119+
<CircleArrowUp className="size-3.5 text-rose-500" aria-label={t("updateAvailableIndicator")} />
120+
) : null}
121+
</span>
122+
);
123+
}
124+
125+
function UpdateResultDialog({
126+
state,
127+
onOpenChange,
128+
onRetry,
129+
}: {
130+
state: UpdateDialogState | null;
131+
onOpenChange: (open: boolean) => void;
132+
onRetry: () => void;
133+
}) {
134+
const t = useTranslations("adminUsers.aboutPage");
135+
const currentVersion = formatReleaseVersion(packageMeta.version);
136+
const latestVersion = state?.type === "available" ? formatReleaseVersion(state.release.version) : "";
137+
138+
return (
139+
<Dialog open={state !== null} onOpenChange={onOpenChange}>
140+
<DialogContent className="sm:max-w-[420px]">
141+
<DialogHeader>
142+
<DialogTitle>
143+
{state?.type === "available"
144+
? t("updateDialog.availableTitle")
145+
: state?.type === "failed"
146+
? t("updateDialog.failedTitle")
147+
: t("updateDialog.currentTitle")}
148+
</DialogTitle>
149+
<DialogDescription>
150+
{state?.type === "available"
151+
? t("updateDialog.availableDescription", { current: currentVersion, latest: latestVersion })
152+
: state?.type === "failed"
153+
? t("updateDialog.failedDescription")
154+
: t("updateDialog.currentDescription", { current: currentVersion })}
155+
</DialogDescription>
156+
</DialogHeader>
157+
{state?.type === "available" ? (
158+
<div className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 rounded-md bg-muted/50 px-3 py-2 text-xs">
159+
<span className="text-muted-foreground">{t("updateDialog.currentVersion")}</span>
160+
<span className="font-medium">{currentVersion}</span>
161+
<span className="text-muted-foreground">{t("updateDialog.latestVersion")}</span>
162+
<span className="font-medium">{latestVersion}</span>
163+
</div>
164+
) : null}
165+
<DialogFooter>
166+
{state?.type === "failed" ? (
167+
<Button type="button" variant="ghost" size="sm" onClick={onRetry}>
168+
{t("updateDialog.retry")}
169+
</Button>
170+
) : null}
171+
<DialogClose asChild>
172+
<Button type="button" variant="ghost" size="sm">
173+
{t("updateDialog.close")}
174+
</Button>
175+
</DialogClose>
176+
{state?.type === "available" ? (
177+
<Button asChild type="button" size="sm">
178+
<a href={state.release.url} target="_blank" rel="noopener noreferrer">
179+
{t("updateDialog.openRelease")}
180+
</a>
181+
</Button>
182+
) : null}
183+
</DialogFooter>
184+
</DialogContent>
185+
</Dialog>
186+
);
187+
}
6188

7189
export function AdminAboutPage() {
8190
const t = useTranslations("adminUsers.aboutPage");
191+
const cachedLatestRelease = useSyncExternalStore(
192+
subscribeLatestReleaseChange,
193+
getCachedLatestReleaseSnapshot,
194+
getServerLatestReleaseSnapshot,
195+
);
196+
const updateRelease = resolveAvailableRelease(packageMeta.version, cachedLatestRelease);
9197

10198
return (
11199
<AboutSettingsContent
12200
title={t("title")}
13201
description={t("description")}
14202
consoleLabel={t("adminConsole")}
203+
versionBadgeContent={<AdminAboutVersionBadge updateRelease={updateRelease} />}
204+
versionBadgeTooltip={<AdminUpdateTooltipContent updateRelease={updateRelease} />}
205+
versionActions={<AdminUpdateCheck />}
15206
labels={{
16207
details: t("details"),
17208
official: t("official"),

0 commit comments

Comments
 (0)