feat: add Settings page with in-app updater support
Add a Settings page with about card (app name + version) and an update section that uses the Tauri v2 updater plugin to check GitHub Releases, download signed installers, and relaunch. Includes full state machine (idle/checking/available/downloading/readyToInstall/installing/error) with progress bar and retry. Database in %APPDATA% is never touched. - Add tauri-plugin-updater and tauri-plugin-process (Rust + npm) - Configure updater endpoint, pubkey placeholder, and passive install mode - Add signing env vars and updaterJsonPreferNsis to release workflow - Add Settings nav item, route, and fr/en translations Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
118a37e761
commit
0adfa5fe5e
14 changed files with 397 additions and 4 deletions
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
|
|
@ -45,6 +45,8 @@ jobs:
|
||||||
uses: tauri-apps/tauri-action@v0
|
uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
with:
|
with:
|
||||||
tagName: ${{ github.ref_name }}
|
tagName: ${{ github.ref_name }}
|
||||||
releaseName: "Simpl'Résultat ${{ github.ref_name }}"
|
releaseName: "Simpl'Résultat ${{ github.ref_name }}"
|
||||||
|
|
@ -57,3 +59,4 @@ jobs:
|
||||||
> Cliquez sur **« Informations complémentaires »** puis **« Exécuter quand même »**.
|
> Cliquez sur **« Informations complémentaires »** puis **« Exécuter quand même »**.
|
||||||
releaseDraft: false
|
releaseDraft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
|
updaterJsonPreferNsis: true
|
||||||
|
|
|
||||||
18
package-lock.json
generated
18
package-lock.json
generated
|
|
@ -10,7 +10,9 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
"@tauri-apps/plugin-sql": "^2.3.2",
|
"@tauri-apps/plugin-sql": "^2.3.2",
|
||||||
|
"@tauri-apps/plugin-updater": "^2.10.0",
|
||||||
"i18next": "^25.8.4",
|
"i18next": "^25.8.4",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
|
|
@ -1618,6 +1620,14 @@
|
||||||
"@tauri-apps/api": "^2.8.0"
|
"@tauri-apps/api": "^2.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-process": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tauri-apps/plugin-sql": {
|
"node_modules/@tauri-apps/plugin-sql": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-sql/-/plugin-sql-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-sql/-/plugin-sql-2.3.2.tgz",
|
||||||
|
|
@ -1626,6 +1636,14 @@
|
||||||
"@tauri-apps/api": "^2.10.1"
|
"@tauri-apps/api": "^2.10.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-updater": {
|
||||||
|
"version": "2.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.0.tgz",
|
||||||
|
"integrity": "sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.10.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,9 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
"@tauri-apps/plugin-sql": "^2.3.2",
|
"@tauri-apps/plugin-sql": "^2.3.2",
|
||||||
|
"@tauri-apps/plugin-updater": "^2.10.0",
|
||||||
"i18next": "^25.8.4",
|
"i18next": "^25.8.4",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
|
tauri-plugin-updater = "2"
|
||||||
|
tauri-plugin-process = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@
|
||||||
"sql:default",
|
"sql:default",
|
||||||
"sql:allow-execute",
|
"sql:allow-execute",
|
||||||
"sql:allow-select",
|
"sql:allow-select",
|
||||||
"dialog:default"
|
"dialog:default",
|
||||||
|
"updater:default",
|
||||||
|
"process:allow-restart"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,12 @@ pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_process::init())
|
||||||
|
.setup(|app| {
|
||||||
|
#[cfg(desktop)]
|
||||||
|
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
.plugin(
|
.plugin(
|
||||||
tauri_plugin_sql::Builder::default()
|
tauri_plugin_sql::Builder::default()
|
||||||
.add_migrations("sqlite:simpl_resultat.db", migrations)
|
.add_migrations("sqlite:simpl_resultat.db", migrations)
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,18 @@
|
||||||
"icons/128x128@2x.png",
|
"icons/128x128@2x.png",
|
||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
]
|
],
|
||||||
|
"createUpdaterArtifacts": true
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"updater": {
|
||||||
|
"pubkey": "REPLACE_WITH_PUBLIC_KEY",
|
||||||
|
"endpoints": [
|
||||||
|
"https://github.com/Le-King-Fu/simpl-resultat/releases/latest/download/latest.json"
|
||||||
|
],
|
||||||
|
"windows": {
|
||||||
|
"installMode": "passive"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import CategoriesPage from "./pages/CategoriesPage";
|
||||||
import AdjustmentsPage from "./pages/AdjustmentsPage";
|
import AdjustmentsPage from "./pages/AdjustmentsPage";
|
||||||
import BudgetPage from "./pages/BudgetPage";
|
import BudgetPage from "./pages/BudgetPage";
|
||||||
import ReportsPage from "./pages/ReportsPage";
|
import ReportsPage from "./pages/ReportsPage";
|
||||||
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -20,6 +21,7 @@ export default function App() {
|
||||||
<Route path="/adjustments" element={<AdjustmentsPage />} />
|
<Route path="/adjustments" element={<AdjustmentsPage />} />
|
||||||
<Route path="/budget" element={<BudgetPage />} />
|
<Route path="/budget" element={<BudgetPage />} />
|
||||||
<Route path="/reports" element={<ReportsPage />} />
|
<Route path="/reports" element={<ReportsPage />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
PiggyBank,
|
PiggyBank,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
Settings,
|
||||||
Languages,
|
Languages,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { NAV_ITEMS, APP_NAME } from "../../shared/constants";
|
import { NAV_ITEMS, APP_NAME } from "../../shared/constants";
|
||||||
|
|
@ -20,6 +21,7 @@ const iconMap: Record<string, React.ComponentType<{ size?: number }>> = {
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
PiggyBank,
|
PiggyBank,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
Settings,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
|
|
|
||||||
122
src/hooks/useUpdater.ts
Normal file
122
src/hooks/useUpdater.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { useReducer, useCallback, useRef } from "react";
|
||||||
|
import { check, type Update } from "@tauri-apps/plugin-updater";
|
||||||
|
import { relaunch } from "@tauri-apps/plugin-process";
|
||||||
|
|
||||||
|
type UpdateStatus =
|
||||||
|
| "idle"
|
||||||
|
| "checking"
|
||||||
|
| "upToDate"
|
||||||
|
| "available"
|
||||||
|
| "downloading"
|
||||||
|
| "readyToInstall"
|
||||||
|
| "installing"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
interface UpdaterState {
|
||||||
|
status: UpdateStatus;
|
||||||
|
version: string | null;
|
||||||
|
progress: number;
|
||||||
|
contentLength: number | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdaterAction =
|
||||||
|
| { type: "CHECK_START" }
|
||||||
|
| { type: "UP_TO_DATE" }
|
||||||
|
| { type: "AVAILABLE"; version: string }
|
||||||
|
| { type: "DOWNLOAD_START" }
|
||||||
|
| { type: "DOWNLOAD_PROGRESS"; downloaded: number; contentLength: number | null }
|
||||||
|
| { type: "READY_TO_INSTALL" }
|
||||||
|
| { type: "INSTALLING" }
|
||||||
|
| { type: "ERROR"; error: string };
|
||||||
|
|
||||||
|
const initialState: UpdaterState = {
|
||||||
|
status: "idle",
|
||||||
|
version: null,
|
||||||
|
progress: 0,
|
||||||
|
contentLength: null,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function reducer(state: UpdaterState, action: UpdaterAction): UpdaterState {
|
||||||
|
switch (action.type) {
|
||||||
|
case "CHECK_START":
|
||||||
|
return { ...initialState, status: "checking" };
|
||||||
|
case "UP_TO_DATE":
|
||||||
|
return { ...state, status: "upToDate", error: null };
|
||||||
|
case "AVAILABLE":
|
||||||
|
return { ...state, status: "available", version: action.version, error: null };
|
||||||
|
case "DOWNLOAD_START":
|
||||||
|
return { ...state, status: "downloading", progress: 0, contentLength: null, error: null };
|
||||||
|
case "DOWNLOAD_PROGRESS":
|
||||||
|
return { ...state, progress: action.downloaded, contentLength: action.contentLength ?? state.contentLength };
|
||||||
|
case "READY_TO_INSTALL":
|
||||||
|
return { ...state, status: "readyToInstall", error: null };
|
||||||
|
case "INSTALLING":
|
||||||
|
return { ...state, status: "installing", error: null };
|
||||||
|
case "ERROR":
|
||||||
|
return { ...state, status: "error", error: action.error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdater() {
|
||||||
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
|
const updateRef = useRef<Update | null>(null);
|
||||||
|
|
||||||
|
const checkForUpdate = useCallback(async () => {
|
||||||
|
dispatch({ type: "CHECK_START" });
|
||||||
|
try {
|
||||||
|
const update = await check();
|
||||||
|
if (update) {
|
||||||
|
updateRef.current = update;
|
||||||
|
dispatch({ type: "AVAILABLE", version: update.version });
|
||||||
|
} else {
|
||||||
|
dispatch({ type: "UP_TO_DATE" });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({ type: "ERROR", error: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const downloadAndInstall = useCallback(async () => {
|
||||||
|
const update = updateRef.current;
|
||||||
|
if (!update) return;
|
||||||
|
|
||||||
|
dispatch({ type: "DOWNLOAD_START" });
|
||||||
|
try {
|
||||||
|
let downloaded = 0;
|
||||||
|
await update.downloadAndInstall((event) => {
|
||||||
|
if (event.event === "Started") {
|
||||||
|
dispatch({
|
||||||
|
type: "DOWNLOAD_PROGRESS",
|
||||||
|
downloaded: 0,
|
||||||
|
contentLength: event.data.contentLength ?? null,
|
||||||
|
});
|
||||||
|
} else if (event.event === "Progress") {
|
||||||
|
downloaded += event.data.chunkLength;
|
||||||
|
dispatch({
|
||||||
|
type: "DOWNLOAD_PROGRESS",
|
||||||
|
downloaded,
|
||||||
|
contentLength: null,
|
||||||
|
});
|
||||||
|
} else if (event.event === "Finished") {
|
||||||
|
// handled below
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dispatch({ type: "READY_TO_INSTALL" });
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({ type: "ERROR", error: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const installAndRestart = useCallback(async () => {
|
||||||
|
dispatch({ type: "INSTALLING" });
|
||||||
|
try {
|
||||||
|
await relaunch();
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({ type: "ERROR", error: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { state, checkForUpdate, downloadAndInstall, installAndRestart };
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,8 @@
|
||||||
"categories": "Categories",
|
"categories": "Categories",
|
||||||
"adjustments": "Adjustments",
|
"adjustments": "Adjustments",
|
||||||
"budget": "Budget",
|
"budget": "Budget",
|
||||||
"reports": "Reports"
|
"reports": "Reports",
|
||||||
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
|
|
@ -233,6 +234,25 @@
|
||||||
"trends": "Monthly Trends",
|
"trends": "Monthly Trends",
|
||||||
"export": "Export"
|
"export": "Export"
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Settings",
|
||||||
|
"version": "Version {{version}}",
|
||||||
|
"updates": {
|
||||||
|
"title": "Updates",
|
||||||
|
"checkButton": "Check for updates",
|
||||||
|
"checking": "Checking for updates...",
|
||||||
|
"upToDate": "App is up to date",
|
||||||
|
"available": "Version {{version}} available",
|
||||||
|
"downloadButton": "Download and install",
|
||||||
|
"downloading": "Downloading...",
|
||||||
|
"readyToInstall": "Update ready to install",
|
||||||
|
"installButton": "Install and restart",
|
||||||
|
"installing": "Installing...",
|
||||||
|
"error": "Update failed",
|
||||||
|
"retryButton": "Retry"
|
||||||
|
},
|
||||||
|
"dataSafeNotice": "Your data is safe — only the app binary is replaced, your database is not modified."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@
|
||||||
"categories": "Catégories",
|
"categories": "Catégories",
|
||||||
"adjustments": "Ajustements",
|
"adjustments": "Ajustements",
|
||||||
"budget": "Budget",
|
"budget": "Budget",
|
||||||
"reports": "Rapports"
|
"reports": "Rapports",
|
||||||
|
"settings": "Paramètres"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Tableau de bord",
|
"title": "Tableau de bord",
|
||||||
|
|
@ -233,6 +234,25 @@
|
||||||
"trends": "Tendances mensuelles",
|
"trends": "Tendances mensuelles",
|
||||||
"export": "Exporter"
|
"export": "Exporter"
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Paramètres",
|
||||||
|
"version": "Version {{version}}",
|
||||||
|
"updates": {
|
||||||
|
"title": "Mises à jour",
|
||||||
|
"checkButton": "Vérifier les mises à jour",
|
||||||
|
"checking": "Vérification en cours...",
|
||||||
|
"upToDate": "L'application est à jour",
|
||||||
|
"available": "Version {{version}} disponible",
|
||||||
|
"downloadButton": "Télécharger et installer",
|
||||||
|
"downloading": "Téléchargement en cours...",
|
||||||
|
"readyToInstall": "Mise à jour prête à installer",
|
||||||
|
"installButton": "Installer et redémarrer",
|
||||||
|
"installing": "Installation en cours...",
|
||||||
|
"error": "Erreur lors de la mise à jour",
|
||||||
|
"retryButton": "Réessayer"
|
||||||
|
},
|
||||||
|
"dataSafeNotice": "Vos données sont en sécurité — seul le programme est remplacé, votre base de données n'est pas modifiée."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
|
|
|
||||||
176
src/pages/SettingsPage.tsx
Normal file
176
src/pages/SettingsPage.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Info,
|
||||||
|
RefreshCw,
|
||||||
|
Download,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
RotateCcw,
|
||||||
|
Loader2,
|
||||||
|
ShieldCheck,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
|
import { useUpdater } from "../hooks/useUpdater";
|
||||||
|
import { APP_NAME } from "../shared/constants";
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { state, checkForUpdate, downloadAndInstall, installAndRestart } =
|
||||||
|
useUpdater();
|
||||||
|
const [version, setVersion] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getVersion().then(setVersion);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const progressPercent =
|
||||||
|
state.contentLength && state.contentLength > 0
|
||||||
|
? Math.round((state.progress / state.contentLength) * 100)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-2xl mx-auto space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold">{t("settings.title")}</h1>
|
||||||
|
|
||||||
|
{/* About card */}
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-[var(--primary)] flex items-center justify-center text-white font-bold text-lg">
|
||||||
|
S
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">{APP_NAME}</h2>
|
||||||
|
<p className="text-sm text-[var(--muted)]">
|
||||||
|
{t("settings.version", { version })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Update card */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* idle */}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* checking */}
|
||||||
|
{state.status === "checking" && (
|
||||||
|
<div className="flex items-center gap-2 text-[var(--muted)]">
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
{t("settings.updates.checking")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* up to date */}
|
||||||
|
{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)] hover:text-[var(--foreground)] transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* available */}
|
||||||
|
{state.status === "available" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p>
|
||||||
|
{t("settings.updates.available", { version: state.version })}
|
||||||
|
</p>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* downloading */}
|
||||||
|
{state.status === "downloading" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-[var(--muted)]">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ready to install */}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* installing */}
|
||||||
|
{state.status === "installing" && (
|
||||||
|
<div className="flex items-center gap-2 text-[var(--muted)]">
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
{t("settings.updates.installing")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* error */}
|
||||||
|
{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)]">{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>
|
||||||
|
|
||||||
|
{/* Data safety notice */}
|
||||||
|
<div className="flex items-start gap-2 text-sm text-[var(--muted)]">
|
||||||
|
<ShieldCheck size={16} className="mt-0.5 shrink-0" />
|
||||||
|
<p>{t("settings.dataSafeNotice")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -46,4 +46,10 @@ export const NAV_ITEMS: NavItem[] = [
|
||||||
icon: "BarChart3",
|
icon: "BarChart3",
|
||||||
labelKey: "nav.reports",
|
labelKey: "nav.reports",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "settings",
|
||||||
|
path: "/settings",
|
||||||
|
icon: "Settings",
|
||||||
|
labelKey: "nav.settings",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue