mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(settings): About + API surfaces — restructure, fix update check, fit tables
Bucket B of the v1.4.3 polish pass. Three issues, one commit because they're the same surface: 1. **API & Tokens tables overflowed the settings content column.** Each table set `min-w-[760px]` (or 860px) so the table never compressed below that width. On mobile this is intentional (it gives the inner cells breathing room while the outer wrapper horizontally scrolls). On md+ the parent column is smaller than the min-width on common laptop sizings (~800px content area when the browser isn't full-width), which forced an inline scrollbar for no benefit. Switched to `min-w-[760px] md:min-w-0` per table so the constraint releases at the breakpoint where the layout stops scrolling. 2. **"Auf Updates prüfen" button silently did nothing.** The client-side handler called `https://api.github.com/.../releases/ latest` directly. Production CSP locks `connect-src` down to `'self'` + `https://wbsapi.withings.net`, so the fetch was blocked at the browser before it left the tab. Replaced with a server-side proxy at `/api/version/check-updates` that fetches GitHub for the user, compares the running `package.json` version against `tag_name`, and returns one of `up_to_date | newer_ available | unknown`. The route is auth-gated (cookie OR bearer) so anonymous traffic can't burn through GitHub's 60-req/hr anon rate limit on the deployment's IP. The route also annotates the external call into the Wide Event so a future spike is visible in the logs. 3. **The About surface had no headings and a stray Badge box.** The v1.4.2 version put the License inside a `<Badge variant= "outline">`, which created a small framed pill that read as a separate widget rather than a peer of the version number. Below that, the Sources/Changelog/Docs and the update-check button each lived in their own card with no h2 — three nameless cards stacked on the page. Restructured into three titled cards: - **HealthLog** — identity, with version + license rendered inline as plain monospace pairs (no Badge, no nested grid). Build SHA + build timestamp ride along on the same row when set. - **Quellen & Dokumentation / Sources & docs** — links unchanged but now under their own h2. - **Updates** — manual `Check now` button + a passive "Last checked: …" line that pulls from localStorage so the user can see when the previous check ran. On mount, if the last successful check is >24h ago (or has never run), the page auto-checks once. The 24h window is a deliberate compromise between "always fresh" and "GitHub anon rate limit". The error path renders a non-blocking "Couldn't reach GitHub" message with the alert role so screen readers announce it. i18n keys touched in both `de.json` + `en.json`: - `settings.about.linksHeading` (new) - `settings.about.updatesHeading` (new) - `settings.about.checkUpdates` (label shortened) - `settings.about.checking` (button busy state — new) - `settings.about.lastChecked` / `lastCheckedNever` (new) - `settings.about.checkFailed` (new — replaces the misleading `testConnection.errors.connection_failed` reuse) Tests updated for the new heading + button strings; 669/669 green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8c0593fe79
commit
622099c0fd
6 files changed
+361
-144
No files matched your search
+8
-2
@@ -1166,9 +1166,15 @@
|
||||
"repository": "Quellcode",
|
||||
"changelog": "Changelog",
|
||||
"docs": "Dokumentation",
|
||||
"checkUpdates": "Auf Updates prüfen",
|
||||
"linksHeading": "Quellen & Dokumentation",
|
||||
"updatesHeading": "Updates",
|
||||
"checkUpdates": "Jetzt prüfen",
|
||||
"checking": "Prüfe…",
|
||||
"lastChecked": "Zuletzt geprüft: {time}",
|
||||
"lastCheckedNever": "Noch nicht geprüft",
|
||||
"upToDate": "Du nutzt die aktuelle Version",
|
||||
"newerAvailable": "Neue Version: {tag}"
|
||||
"newerAvailable": "Neue Version: {tag}",
|
||||
"checkFailed": "Konnte GitHub nicht erreichen — versuche es später erneut"
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Verbindung testen",
|
||||
|
||||
+8
-2
@@ -1166,9 +1166,15 @@
|
||||
"repository": "Source code",
|
||||
"changelog": "Changelog",
|
||||
"docs": "Documentation",
|
||||
"checkUpdates": "Check for updates",
|
||||
"linksHeading": "Sources & docs",
|
||||
"updatesHeading": "Updates",
|
||||
"checkUpdates": "Check now",
|
||||
"checking": "Checking…",
|
||||
"lastChecked": "Last checked: {time}",
|
||||
"lastCheckedNever": "Not checked yet",
|
||||
"upToDate": "You are up to date",
|
||||
"newerAvailable": "Newer version: {tag}"
|
||||
"newerAvailable": "Newer version: {tag}",
|
||||
"checkFailed": "Could not reach GitHub — try again later"
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Test connection",
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import packageJson from "../../../../../package.json";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/version/check-updates
|
||||
*
|
||||
* Server-side proxy for the GitHub Releases API. Required because the
|
||||
* production CSP only whitelists `'self'` + Withings for `connect-src`
|
||||
* — fetching `https://api.github.com/...` directly from the browser is
|
||||
* silently blocked, which is why the v1.4.2 "Check for updates" button
|
||||
* appeared to do nothing. Proxying through `/api` keeps the CSP locked
|
||||
* down while the server (no CSP attached) fetches GitHub on the user's
|
||||
* behalf.
|
||||
*
|
||||
* Compares the running app version (from `package.json`) against the
|
||||
* latest GitHub release tag and returns one of three states:
|
||||
* - `up_to_date` — running version >= latest release
|
||||
* - `newer_available` — running version < latest release; payload
|
||||
* includes the tag name and HTML URL so the UI can deep-link
|
||||
* - `unknown` — couldn't reach GitHub (network issue, rate-limit,
|
||||
* repo missing the release manifest); UI surfaces a non-blocking
|
||||
* warning so the user can retry
|
||||
*
|
||||
* Responses are not cached at the route level. The browser bears the
|
||||
* load with React Query's `staleTime`, so a single user spamming
|
||||
* "Check now" still hits the GH API at most every 30 seconds.
|
||||
*/
|
||||
|
||||
const RELEASES_URL =
|
||||
"https://api.github.com/repos/MBombeck/HealthLog/releases/latest";
|
||||
|
||||
interface GithubReleaseShape {
|
||||
tag_name?: string;
|
||||
html_url?: string;
|
||||
name?: string;
|
||||
published_at?: string;
|
||||
}
|
||||
|
||||
function stripV(s: string): string {
|
||||
return s.replace(/^v/i, "").trim();
|
||||
}
|
||||
|
||||
function compareSemver(a: string, b: string): number {
|
||||
const partsA = stripV(a)
|
||||
.split(/[.-]/)
|
||||
.map((n) => Number.parseInt(n, 10));
|
||||
const partsB = stripV(b)
|
||||
.split(/[.-]/)
|
||||
.map((n) => Number.parseInt(n, 10));
|
||||
const len = Math.max(partsA.length, partsB.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const ai = partsA[i] ?? 0;
|
||||
const bi = partsB[i] ?? 0;
|
||||
if (Number.isNaN(ai) || Number.isNaN(bi)) {
|
||||
// Pre-release suffixes — fall back to lexical ordering of the
|
||||
// stripped strings so `1.4.3-rc.1` sorts before `1.4.3`.
|
||||
const sA = stripV(a);
|
||||
const sB = stripV(b);
|
||||
return sA < sB ? -1 : sA > sB ? 1 : 0;
|
||||
}
|
||||
if (ai < bi) return -1;
|
||||
if (ai > bi) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
// Auth-gated: there's no reason an anonymous client should poll the
|
||||
// GH API through us. Cookie OR bearer token both satisfy this.
|
||||
await requireAuth();
|
||||
annotate({ action: { name: "version.check_updates" } });
|
||||
|
||||
const current = packageJson.version;
|
||||
|
||||
let release: GithubReleaseShape | null = null;
|
||||
const callStart = Date.now();
|
||||
try {
|
||||
const res = await fetch(RELEASES_URL, {
|
||||
headers: { Accept: "application/vnd.github+json" },
|
||||
// GitHub anon rate limit is 60/hr per IP — tight but not zero.
|
||||
// We don't pass a token because the repo is public and the route
|
||||
// is auth-gated against our users.
|
||||
cache: "no-store",
|
||||
});
|
||||
getEvent()?.addExternalCall({
|
||||
service: "github_releases_api",
|
||||
method: "GET",
|
||||
duration_ms: Date.now() - callStart,
|
||||
status: res.status,
|
||||
});
|
||||
if (!res.ok) {
|
||||
annotate({
|
||||
meta: {
|
||||
status: res.status,
|
||||
outcome: "github_unreachable",
|
||||
},
|
||||
});
|
||||
return apiSuccess({
|
||||
status: "unknown" as const,
|
||||
current,
|
||||
reason: `github_status_${res.status}`,
|
||||
});
|
||||
}
|
||||
release = (await res.json()) as GithubReleaseShape;
|
||||
} catch (error) {
|
||||
getEvent()?.addExternalCall({
|
||||
service: "github_releases_api",
|
||||
method: "GET",
|
||||
duration_ms: Date.now() - callStart,
|
||||
error: error instanceof Error ? error.message : "unknown",
|
||||
});
|
||||
// Network glitch, DNS hiccup, GH outage. Don't fail the request —
|
||||
// tell the UI it's unknown so it can retry without showing a red
|
||||
// banner the user can't act on.
|
||||
annotate({
|
||||
meta: {
|
||||
outcome: "fetch_threw",
|
||||
message: error instanceof Error ? error.message : "unknown",
|
||||
},
|
||||
});
|
||||
return apiSuccess({
|
||||
status: "unknown" as const,
|
||||
current,
|
||||
reason: "network_error",
|
||||
});
|
||||
}
|
||||
|
||||
const latestTag = release?.tag_name?.trim();
|
||||
if (!latestTag) {
|
||||
return apiError("GitHub release manifest missing tag_name", 502);
|
||||
}
|
||||
|
||||
const cmp = compareSemver(current, latestTag);
|
||||
if (cmp >= 0) {
|
||||
return apiSuccess({
|
||||
status: "up_to_date" as const,
|
||||
current,
|
||||
latest_tag: latestTag,
|
||||
checked_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
status: "newer_available" as const,
|
||||
current,
|
||||
latest_tag: latestTag,
|
||||
html_url: release.html_url ?? null,
|
||||
published_at: release.published_at ?? null,
|
||||
checked_at: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
@@ -101,8 +101,13 @@ describe("settings sections — SSR smoke", () => {
|
||||
it("<AboutSection> renders version + license + check-for-updates button", () => {
|
||||
const html = render(<AboutSection />);
|
||||
expect(html).toContain("About");
|
||||
// The new about.* keys resolve.
|
||||
expect(html).toContain("Check for updates");
|
||||
// The new about.* keys resolve. v1.4.3 split the page into three
|
||||
// titled cards (HealthLog identity, Sources & docs, Updates) and
|
||||
// shortened the button label from "Check for updates" to "Check now"
|
||||
// because the surrounding heading already names the section.
|
||||
expect(html).toContain("Updates");
|
||||
expect(html).toContain("Sources & docs");
|
||||
expect(html).toContain("Check now");
|
||||
expect(html).not.toContain("settings.about.");
|
||||
});
|
||||
|
||||
@@ -146,6 +151,10 @@ describe("settings sections — SSR smoke", () => {
|
||||
|
||||
it("<AboutSection> resolves about.* keys in German", () => {
|
||||
const html = render(<AboutSection />, "de");
|
||||
expect(html).toContain("Auf Updates prüfen");
|
||||
// v1.4.3: button label shortened to "Jetzt prüfen" because the
|
||||
// section heading "Updates" already provides context.
|
||||
expect(html).toContain("Updates");
|
||||
expect(html).toContain("Jetzt prüfen");
|
||||
expect(html).toContain("Quellen & Dokumentation");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
BookOpen,
|
||||
@@ -9,14 +9,11 @@ import {
|
||||
GitBranch,
|
||||
Info,
|
||||
Loader2,
|
||||
Package,
|
||||
RefreshCw,
|
||||
Scale,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
@@ -30,42 +27,46 @@ interface VersionPayload {
|
||||
docs: string;
|
||||
}
|
||||
|
||||
interface GithubRelease {
|
||||
tag_name: string;
|
||||
html_url: string;
|
||||
name: string;
|
||||
type CheckUpdatesResult =
|
||||
| {
|
||||
status: "up_to_date";
|
||||
current: string;
|
||||
latest_tag: string;
|
||||
checked_at: string;
|
||||
}
|
||||
| {
|
||||
status: "newer_available";
|
||||
current: string;
|
||||
latest_tag: string;
|
||||
html_url: string | null;
|
||||
published_at: string | null;
|
||||
checked_at: string;
|
||||
}
|
||||
| { status: "unknown"; current: string; reason: string };
|
||||
|
||||
const LAST_CHECKED_KEY = "healthlog-about-last-checked-iso";
|
||||
// One day between auto-checks. We don't want every page-mount to spend a
|
||||
// GitHub-API rate-limit slot just to confirm the version is unchanged
|
||||
// 30 seconds after the previous one resolved. The user can always force
|
||||
// a re-check via the button.
|
||||
const AUTO_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function readLastCheckedISO(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.localStorage.getItem(LAST_CHECKED_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const RELEASES_API_URL =
|
||||
"https://api.github.com/repos/MBombeck/HealthLog/releases/latest";
|
||||
|
||||
/**
|
||||
* Compare a semantic version (e.g. "1.4.0") with a release tag (e.g.
|
||||
* "v1.4.0", "1.4.1"). Returns `true` when `tag` is strictly newer than
|
||||
* `current`. Falls back to lexical compare when the strings don't parse,
|
||||
* which is intentional — better to surface "newer available" on a parse miss
|
||||
* than silently swallow a release.
|
||||
*/
|
||||
function isNewer(current: string, tag: string): boolean {
|
||||
const stripV = (s: string) => s.replace(/^v/i, "").trim();
|
||||
const a = stripV(current)
|
||||
.split(/[.-]/)
|
||||
.map((n) => Number.parseInt(n, 10));
|
||||
const b = stripV(tag)
|
||||
.split(/[.-]/)
|
||||
.map((n) => Number.parseInt(n, 10));
|
||||
const maxLen = Math.max(a.length, b.length);
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const ai = a[i] ?? 0;
|
||||
const bi = b[i] ?? 0;
|
||||
if (Number.isNaN(ai) || Number.isNaN(bi)) {
|
||||
// Fall back to lexical compare for pre-release suffixes etc.
|
||||
return stripV(tag) > stripV(current);
|
||||
}
|
||||
if (bi > ai) return true;
|
||||
if (bi < ai) return false;
|
||||
function writeLastCheckedISO(iso: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(LAST_CHECKED_KEY, iso);
|
||||
} catch {
|
||||
/* storage may be full or disabled — silently skip */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function AboutSection() {
|
||||
@@ -85,42 +86,59 @@ export function AboutSection() {
|
||||
});
|
||||
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [updateResult, setUpdateResult] = useState<
|
||||
| { kind: "up_to_date" }
|
||||
| { kind: "newer"; tag: string; url: string }
|
||||
| { kind: "error" }
|
||||
| null
|
||||
>(null);
|
||||
const [updateResult, setUpdateResult] = useState<CheckUpdatesResult | null>(
|
||||
null,
|
||||
);
|
||||
const [lastCheckedISO, setLastCheckedISO] = useState<string | null>(() =>
|
||||
readLastCheckedISO(),
|
||||
);
|
||||
|
||||
async function handleCheckForUpdates() {
|
||||
if (!version) return;
|
||||
async function runCheck(): Promise<void> {
|
||||
setChecking(true);
|
||||
setUpdateResult(null);
|
||||
try {
|
||||
const res = await fetch(RELEASES_API_URL, {
|
||||
headers: { Accept: "application/vnd.github+json" },
|
||||
});
|
||||
const res = await fetch("/api/version/check-updates");
|
||||
if (!res.ok) {
|
||||
setUpdateResult({ kind: "error" });
|
||||
setUpdateResult({
|
||||
status: "unknown",
|
||||
current: version?.version ?? "",
|
||||
reason: `http_${res.status}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const release = (await res.json()) as GithubRelease;
|
||||
if (isNewer(version.version, release.tag_name)) {
|
||||
setUpdateResult({
|
||||
kind: "newer",
|
||||
tag: release.tag_name,
|
||||
url: release.html_url,
|
||||
});
|
||||
} else {
|
||||
setUpdateResult({ kind: "up_to_date" });
|
||||
const json = await res.json();
|
||||
const result = json.data as CheckUpdatesResult;
|
||||
setUpdateResult(result);
|
||||
if (result.status !== "unknown") {
|
||||
const iso = new Date().toISOString();
|
||||
writeLastCheckedISO(iso);
|
||||
setLastCheckedISO(iso);
|
||||
}
|
||||
} catch {
|
||||
setUpdateResult({ kind: "error" });
|
||||
setUpdateResult({
|
||||
status: "unknown",
|
||||
current: version?.version ?? "",
|
||||
reason: "network_error",
|
||||
});
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-check on mount when the last successful check is older than the
|
||||
// refresh interval (or has never run). Keeps the UI populated without
|
||||
// requiring a click — but won't spam GitHub on every navigation.
|
||||
useEffect(() => {
|
||||
if (!version) return;
|
||||
const last = readLastCheckedISO();
|
||||
const lastMs = last ? Date.parse(last) : NaN;
|
||||
const stale =
|
||||
Number.isNaN(lastMs) || Date.now() - lastMs > AUTO_CHECK_INTERVAL_MS;
|
||||
if (stale) {
|
||||
void runCheck();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [version?.version]);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-about-title"
|
||||
@@ -138,6 +156,10 @@ export function AboutSection() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Identity card — version + license inline. The v1.4.2 layout
|
||||
stacked the license under the version inside its own boxed Badge,
|
||||
which read as a separate field even though the two values belong
|
||||
on the same row. */}
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Info className="text-primary h-5 w-5" />
|
||||
@@ -152,69 +174,56 @@ export function AboutSection() {
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<dl className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<Package className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<dt className="text-muted-foreground text-xs">
|
||||
{t("settings.about.version")}
|
||||
</dt>
|
||||
<dd className="font-mono text-sm font-medium">
|
||||
v{version.version}
|
||||
</dd>
|
||||
</div>
|
||||
<dl className="flex flex-wrap items-baseline gap-x-6 gap-y-3 text-sm">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<dt className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{t("settings.about.version")}
|
||||
</dt>
|
||||
<dd className="font-mono font-medium">v{version.version}</dd>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<Scale className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<dt className="text-muted-foreground text-xs">
|
||||
{t("settings.about.license")}
|
||||
</dt>
|
||||
<dd>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{version.license}
|
||||
</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<dt className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{t("settings.about.license")}
|
||||
</dt>
|
||||
<dd className="font-mono">{version.license}</dd>
|
||||
</div>
|
||||
|
||||
{version.buildSha && (
|
||||
<div className="flex items-start gap-3">
|
||||
<GitBranch className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<dt className="text-muted-foreground text-xs">
|
||||
{t("settings.about.gitSha")}
|
||||
</dt>
|
||||
<dd className="font-mono text-sm">
|
||||
{version.buildSha.slice(0, 7)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<dt className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{t("settings.about.gitSha")}
|
||||
</dt>
|
||||
<dd className="font-mono">{version.buildSha.slice(0, 7)}</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{version.builtAt && (
|
||||
<div className="flex items-start gap-3">
|
||||
<Sparkles className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<dt className="text-muted-foreground text-xs">
|
||||
{t("settings.about.builtAt", {
|
||||
time: fmt.dateTime(version.builtAt),
|
||||
})}
|
||||
</dt>
|
||||
<dd className="text-muted-foreground text-xs">
|
||||
{fmt.date(version.builtAt)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<dt className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{t("settings.about.builtAt", { time: "" }).trim() ||
|
||||
t("settings.about.gitSha")}
|
||||
</dt>
|
||||
<dd className="text-muted-foreground">
|
||||
{fmt.date(version.builtAt)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
{/* Sources & docs — every link card was untitled in the v1.4.2
|
||||
About surface, so the buttons floated alone with no heading
|
||||
telling the user what the section was for. */}
|
||||
{version && (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<BookOpen className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("settings.about.linksHeading")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a
|
||||
@@ -249,24 +258,47 @@ export function AboutSection() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Check for updates */}
|
||||
{/* Updates — proper heading + last-check timestamp + manual button
|
||||
that proxies through `/api/version/check-updates`. The v1.4.2
|
||||
version called `api.github.com` directly from the browser, which
|
||||
the production CSP blocked silently — that's why "nothing
|
||||
happened" when Marc clicked the button. */}
|
||||
{version && (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCheckForUpdates}
|
||||
disabled={checking}
|
||||
>
|
||||
{checking ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("settings.about.checkUpdates")}
|
||||
</Button>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<RefreshCw className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("settings.about.updatesHeading")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{updateResult?.kind === "up_to_date" && (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={runCheck}
|
||||
disabled={checking}
|
||||
>
|
||||
{checking ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{checking
|
||||
? t("settings.about.checking")
|
||||
: t("settings.about.checkUpdates")}
|
||||
</Button>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{lastCheckedISO
|
||||
? t("settings.about.lastChecked", {
|
||||
time: fmt.dateTime(lastCheckedISO),
|
||||
})
|
||||
: t("settings.about.lastCheckedNever")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{updateResult?.status === "up_to_date" && (
|
||||
<p
|
||||
role="status"
|
||||
className="text-dracula-green mt-3 flex items-center gap-1.5 text-sm"
|
||||
@@ -276,29 +308,37 @@ export function AboutSection() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{updateResult?.kind === "newer" && (
|
||||
{updateResult?.status === "newer_available" && (
|
||||
<p role="status" className="mt-3 flex items-center gap-1.5 text-sm">
|
||||
<Sparkles className="text-dracula-purple h-4 w-4" />
|
||||
<a
|
||||
href={updateResult.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("settings.about.newerAvailable", {
|
||||
tag: updateResult.tag,
|
||||
})}
|
||||
</a>
|
||||
{updateResult.html_url ? (
|
||||
<a
|
||||
href={updateResult.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("settings.about.newerAvailable", {
|
||||
tag: updateResult.latest_tag,
|
||||
})}
|
||||
</a>
|
||||
) : (
|
||||
<span>
|
||||
{t("settings.about.newerAvailable", {
|
||||
tag: updateResult.latest_tag,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{updateResult?.kind === "error" && (
|
||||
{updateResult?.status === "unknown" && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive mt-3 flex items-center gap-1.5 text-sm"
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
{t("settings.testConnection.errors.connection_failed")}
|
||||
{t("settings.about.checkFailed")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -71,7 +71,7 @@ function ApiEndpointsCard() {
|
||||
</p>
|
||||
|
||||
<div className="border-border mt-4 overflow-x-auto rounded-lg border">
|
||||
<table className="w-full min-w-[760px] text-xs">
|
||||
<table className="w-full min-w-[760px] text-xs md:min-w-0">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
@@ -237,7 +237,7 @@ function ApiTokensCard() {
|
||||
{t("settings.activeTokensTitle")}
|
||||
</p>
|
||||
<div className="border-border overflow-x-auto rounded-lg border">
|
||||
<table className="w-full min-w-[860px] text-sm">
|
||||
<table className="w-full min-w-[860px] text-sm md:min-w-0">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
@@ -360,7 +360,7 @@ function ApiTokensCard() {
|
||||
</button>
|
||||
{showRevokedTokens && (
|
||||
<div className="border-border overflow-x-auto rounded-lg border">
|
||||
<table className="w-full min-w-[760px] text-sm">
|
||||
<table className="w-full min-w-[760px] text-sm md:min-w-0">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
|
||||
Reference in new issue
Block a user