The single 12-card SettingsPage is replaced by a hub at /settings linking
to three thematic sub-pages mounted via a shared SettingsLayout (Outlet):
/settings SettingsHomePage (3 cards-cluster + PageHelp)
/settings/users UsersSettingsPage (Account, License, DocsContent)
/settings/data DataSettingsPage (Categories, DataManagement,
PriceFetchConsentToggle)
/settings/systems SystemsSettingsPage (Version, UpdateCard,
ChangelogContent, LogViewer)
DocsPage and ChangelogPage are extracted into reusable DocsContent /
ChangelogContent components and the standalone /docs and /changelog
routes become Navigate redirects to preserve external bookmarks and
release-note links. UpdateCard is extracted from the inline updater
block for symmetry and testability.
TokenStoreFallbackBanner is mounted once in SettingsLayout, surfacing
the OS-keychain-fallback warning across the four main routes only.
The two existing /settings/categories/{standard,migrate} sub-routes
stay flat (siblings of SettingsLayout) to keep their focused flows
free of the banner — their internal back-links now point to
/settings/data.
i18n FR/EN gain settings.{home, users, data, systems, backToHome};
docs/architecture.md and CHANGELOG{,.fr}.md updated. Pure refactor of
presentation: no new business logic, no Tauri commands, no SQL
migrations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
211 lines
7.4 KiB
TypeScript
211 lines
7.4 KiB
TypeScript
import { useEffect, useState, useCallback } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
Info,
|
|
RefreshCw,
|
|
Download,
|
|
CheckCircle,
|
|
AlertCircle,
|
|
RotateCcw,
|
|
Loader2,
|
|
} from "lucide-react";
|
|
import { useUpdater } from "../../hooks/useUpdater";
|
|
|
|
export default function UpdateCard() {
|
|
const { t, i18n } = useTranslation();
|
|
const { state, checkForUpdate, downloadAndInstall, installAndRestart } =
|
|
useUpdater();
|
|
const [releaseNotes, setReleaseNotes] = useState<string | null>(null);
|
|
|
|
const fetchReleaseNotes = useCallback(
|
|
(targetVersion: string) => {
|
|
const file =
|
|
i18n.language === "fr" ? "/CHANGELOG.fr.md" : "/CHANGELOG.md";
|
|
fetch(file)
|
|
.then((r) => r.text())
|
|
.then((text) => {
|
|
const escaped = targetVersion.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const re = new RegExp(
|
|
`^## \\[?${escaped}\\]?.*$\\n([\\s\\S]*?)(?=^## |$(?!\\n))`,
|
|
"m",
|
|
);
|
|
const match = text.match(re);
|
|
setReleaseNotes(match ? match[1].trim() : null);
|
|
})
|
|
.catch(() => setReleaseNotes(null));
|
|
},
|
|
[i18n.language],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (state.status === "available" && state.version) {
|
|
fetchReleaseNotes(state.version);
|
|
}
|
|
}, [state.status, state.version, fetchReleaseNotes]);
|
|
|
|
const progressPercent =
|
|
state.contentLength && state.contentLength > 0
|
|
? Math.round((state.progress / state.contentLength) * 100)
|
|
: null;
|
|
|
|
return (
|
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
|
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
|
<Info size={18} />
|
|
{t("settings.updates.title")}
|
|
</h2>
|
|
|
|
{state.status === "idle" && (
|
|
<button
|
|
onClick={checkForUpdate}
|
|
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity"
|
|
>
|
|
<RefreshCw size={16} />
|
|
{t("settings.updates.checkButton")}
|
|
</button>
|
|
)}
|
|
|
|
{state.status === "checking" && (
|
|
<div className="flex items-center gap-2 text-[var(--muted-foreground)]">
|
|
<Loader2 size={16} className="animate-spin" />
|
|
{t("settings.updates.checking")}
|
|
</div>
|
|
)}
|
|
|
|
{state.status === "notEntitled" && (
|
|
<div className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]">
|
|
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
|
<p>{t("settings.updates.notEntitled")}</p>
|
|
</div>
|
|
)}
|
|
|
|
{state.status === "upToDate" && (
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2 text-[var(--positive)]">
|
|
<CheckCircle size={16} />
|
|
{t("settings.updates.upToDate")}
|
|
</div>
|
|
<button
|
|
onClick={checkForUpdate}
|
|
className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
|
>
|
|
<RefreshCw size={14} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{state.status === "available" && (
|
|
<div className="space-y-3">
|
|
<p>
|
|
{t("settings.updates.available", { version: state.version })}
|
|
</p>
|
|
{(() => {
|
|
const notes = releaseNotes || state.body;
|
|
if (!notes) return null;
|
|
return (
|
|
<div className="space-y-2">
|
|
<h3 className="text-sm font-semibold text-[var(--foreground)]">
|
|
{t("settings.updates.releaseNotes")}
|
|
</h3>
|
|
<div className="max-h-48 overflow-y-auto rounded-lg bg-[var(--background)] border border-[var(--border)] p-3 text-sm text-[var(--muted-foreground)] space-y-1">
|
|
{notes.split("\n").map((line, i) => {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) return <div key={i} className="h-2" />;
|
|
if (trimmed.startsWith("### "))
|
|
return (
|
|
<p
|
|
key={i}
|
|
className="font-semibold text-[var(--foreground)] mt-2"
|
|
>
|
|
{trimmed.slice(4)}
|
|
</p>
|
|
);
|
|
if (trimmed.startsWith("## "))
|
|
return (
|
|
<p
|
|
key={i}
|
|
className="font-bold text-[var(--foreground)] mt-2"
|
|
>
|
|
{trimmed.slice(3)}
|
|
</p>
|
|
);
|
|
if (trimmed.startsWith("- "))
|
|
return (
|
|
<p key={i} className="pl-3">
|
|
{"• "}
|
|
{trimmed.slice(2).replace(/\*\*(.+?)\*\*/g, "$1")}
|
|
</p>
|
|
);
|
|
return <p key={i}>{trimmed}</p>;
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
<button
|
|
onClick={downloadAndInstall}
|
|
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity"
|
|
>
|
|
<Download size={16} />
|
|
{t("settings.updates.downloadButton")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{state.status === "downloading" && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2 text-[var(--muted-foreground)]">
|
|
<Loader2 size={16} className="animate-spin" />
|
|
{t("settings.updates.downloading")}
|
|
{progressPercent !== null && <span>{progressPercent}%</span>}
|
|
</div>
|
|
<div className="w-full bg-[var(--border)] rounded-full h-2">
|
|
<div
|
|
className="bg-[var(--primary)] h-2 rounded-full transition-all duration-300"
|
|
style={{ width: `${progressPercent ?? 0}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{state.status === "readyToInstall" && (
|
|
<div className="space-y-3">
|
|
<p className="text-[var(--positive)]">
|
|
{t("settings.updates.readyToInstall")}
|
|
</p>
|
|
<button
|
|
onClick={installAndRestart}
|
|
className="flex items-center gap-2 px-4 py-2 bg-[var(--positive)] text-white rounded-lg hover:opacity-90 transition-opacity"
|
|
>
|
|
<RotateCcw size={16} />
|
|
{t("settings.updates.installButton")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{state.status === "installing" && (
|
|
<div className="flex items-center gap-2 text-[var(--muted-foreground)]">
|
|
<Loader2 size={16} className="animate-spin" />
|
|
{t("settings.updates.installing")}
|
|
</div>
|
|
)}
|
|
|
|
{state.status === "error" && (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2 text-[var(--negative)]">
|
|
<AlertCircle size={16} />
|
|
{t("settings.updates.error")}
|
|
</div>
|
|
<p className="text-sm text-[var(--muted-foreground)]">{state.error}</p>
|
|
<button
|
|
onClick={checkForUpdate}
|
|
className="flex items-center gap-2 px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors"
|
|
>
|
|
<RotateCcw size={16} />
|
|
{t("settings.updates.retryButton")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|