refactor(settings): split monolithic page into /settings/{users,data,systems} (#190) #192

Merged
maximus merged 1 commit from issue-190-restructure-settings-pages into main 2026-05-03 13:57:44 +00:00
19 changed files with 881 additions and 658 deletions
Showing only changes of commit f02fd95ab1 - Show all commits

View file

@ -8,6 +8,7 @@
### Modifié ### Modifié
- **Paramètres réorganisés en 3 sous-pages** — la page unique de 12 cartes est éclatée en un hub (`/settings`) qui pointe vers trois sous-pages thématiques : `/settings/users` (comptes, licences, guide d'utilisation), `/settings/data` (catégories, sauvegarde, confidentialité de la récupération de prix) et `/settings/systems` (version, mise à jour, historique des versions, journaux + commentaires). Le guide d'utilisation et l'historique des versions, qui occupaient leurs propres pages, sont maintenant intégrés dans leur sous-page parente ; les anciennes URL `/docs` et `/changelog` redirigent automatiquement pour préserver les marque-pages externes et les liens des notes de version. Le bandeau de sécurité du fallback token-store est maintenant rendu une seule fois en haut du layout des paramètres, visible depuis chaque sous-page principale (#190).
- **Icône de l'application** — remplacement de l'icône par défaut de Tauri par un design sur mesure : une calculatrice au visage de robot souriant avec un cadenas de confidentialité sur la touche Entrée / `=`. Reflète les quatre valeurs du produit — robot (assistant), simplicité (formes géométriques), comptabilité (calculatrice), confidentialité (cadenas). Le SVG source est conservé dans `src-tauri/icons/icon.svg` pour les futures itérations ; les 16 fichiers raster spécifiques aux plateformes ont été régénérés via `tauri icon`. Le favicon web et le `<title>` de la fenêtre sont mis à jour aussi (auparavant *« Tauri + React + Typescript »* hérité du scaffolding par défaut). - **Icône de l'application** — remplacement de l'icône par défaut de Tauri par un design sur mesure : une calculatrice au visage de robot souriant avec un cadenas de confidentialité sur la touche Entrée / `=`. Reflète les quatre valeurs du produit — robot (assistant), simplicité (formes géométriques), comptabilité (calculatrice), confidentialité (cadenas). Le SVG source est conservé dans `src-tauri/icons/icon.svg` pour les futures itérations ; les 16 fichiers raster spécifiques aux plateformes ont été régénérés via `tauri icon`. Le favicon web et le `<title>` de la fenêtre sont mis à jour aussi (auparavant *« Tauri + React + Typescript »* hérité du scaffolding par défaut).
- Bilan : remplacement de l'état vide de /balance par une carte d'onboarding à 2 étapes (Créer un compte → Saisir un snapshot) pour éviter l'écran « aucun snapshot » déroutant avant qu'un compte n'existe. Le bouton « + Nouveau snapshot » est masqué tant qu'aucun compte n'existe. La copie de l'état vide de /balance/snapshot clarifie la différence entre un compte et un snapshot (#178). - Bilan : remplacement de l'état vide de /balance par une carte d'onboarding à 2 étapes (Créer un compte → Saisir un snapshot) pour éviter l'écran « aucun snapshot » déroutant avant qu'un compte n'existe. Le bouton « + Nouveau snapshot » est masqué tant qu'aucun compte n'existe. La copie de l'état vide de /balance/snapshot clarifie la différence entre un compte et un snapshot (#178).

View file

@ -8,6 +8,7 @@
### Changed ### Changed
- **Settings reorganized into 3 sub-pages** — the single 12-card `Settings` page is split into a hub (`/settings`) that links to three thematic sub-pages: `/settings/users` (accounts, licenses, user guide), `/settings/data` (categories, backup, price-fetch privacy) and `/settings/systems` (version, update, version history, logs + feedback). The user guide and changelog, previously full-page routes, are now embedded inside their parent sub-page; the legacy `/docs` and `/changelog` URLs redirect to keep external bookmarks and release-note links working. The token-store fallback security banner is now rendered once at the top of the settings layout, visible from every main settings sub-page (#190).
- **App icon** — replaced the default Tauri scaffolding icon with a custom design: a robot-faced calculator with a privacy lock on the Enter / `=` key. Conveys the four product values — robot (assistant), simplicity (geometric shapes), accounting (calculator), privacy (lock). Source SVG kept at `src-tauri/icons/icon.svg` for future iterations; all 16 platform-specific raster sizes regenerated via `tauri icon`. Web favicon and window `<title>` updated too (was *"Tauri + React + Typescript"* from the default scaffold). - **App icon** — replaced the default Tauri scaffolding icon with a custom design: a robot-faced calculator with a privacy lock on the Enter / `=` key. Conveys the four product values — robot (assistant), simplicity (geometric shapes), accounting (calculator), privacy (lock). Source SVG kept at `src-tauri/icons/icon.svg` for future iterations; all 16 platform-specific raster sizes regenerated via `tauri icon`. Web favicon and window `<title>` updated too (was *"Tauri + React + Typescript"* from the default scaffold).
- Bilan: replaced empty /balance state with a 2-step onboarding card (Create an account → Enter a snapshot) so users no longer see a confusing "no snapshot" screen before any account exists. The "+ New snapshot" button is hidden until at least one account exists. The /balance/snapshot empty-state copy now clarifies what an account is vs. what a snapshot is (#178). - Bilan: replaced empty /balance state with a 2-step onboarding card (Create an account → Enter a snapshot) so users no longer see a confusing "no snapshot" screen before any account exists. The "+ New snapshot" button is hidden until at least one account exists. The /balance/snapshot empty-state copy now clarifies what an account is vs. what a snapshot is (#178).

View file

@ -346,9 +346,14 @@ Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `App
| `/balance` | `BalancePage` | Bilan — vue d'ensemble : carte "Aujourd'hui" + Δ% + avertissement bilan pas à jour > 60j, graphique d'évolution (toggle ligne / aire empilée par catégorie), tableau des comptes avec rendements multi-horizons (3M / 1A / depuis création — Modified Dietz) côte-à-côte avec rendement non-ajusté | | `/balance` | `BalancePage` | Bilan — vue d'ensemble : carte "Aujourd'hui" + Δ% + avertissement bilan pas à jour > 60j, graphique d'évolution (toggle ligne / aire empilée par catégorie), tableau des comptes avec rendements multi-horizons (3M / 1A / depuis création — Modified Dietz) côte-à-côte avec rendement non-ajusté |
| `/balance/snapshot` | `SnapshotEditPage` | Saisie / édition d'un snapshot daté. Mode `?date=today` (création) ou `?date=YYYY-MM-DD` (édition, date immutable). Lignes groupées par catégorie : `simple` = champ valeur, `priced` = `quantity` × `unit_price` (`value` calculé read-only). Bouton "Pré-remplir depuis le snapshot précédent". Suppression à double-confirmation par re-saisie de la date | | `/balance/snapshot` | `SnapshotEditPage` | Saisie / édition d'un snapshot daté. Mode `?date=today` (création) ou `?date=YYYY-MM-DD` (édition, date immutable). Lignes groupées par catégorie : `simple` = champ valeur, `priced` = `quantity` × `unit_price` (`value` calculé read-only). Bouton "Pré-remplir depuis le snapshot précédent". Suppression à double-confirmation par re-saisie de la date |
| `/balance/accounts` | `AccountsPage` | CRUD comptes + catégories de bilan (deux onglets). Catégories seedées (`is_seed = 1`) renommables mais non-supprimables ; refus de suppression d'une catégorie avec comptes liés (FK RESTRICT) | | `/balance/accounts` | `AccountsPage` | CRUD comptes + catégories de bilan (deux onglets). Catégories seedées (`is_seed = 1`) renommables mais non-supprimables ; refus de suppression d'une catégorie avec comptes liés (FK RESTRICT) |
| `/settings` | `SettingsPage` | Paramètres | | `/settings` | `SettingsLayout` (layout) + `SettingsHomePage` (index) | Hub des paramètres : 3 cards-cluster vers les sous-pages. Le layout monte `TokenStoreFallbackBanner` une seule fois, partagé par les 4 routes principales |
| `/docs` | `DocsPage` | Documentation in-app | | `/settings/users` | `UsersSettingsPage` | Comptes (Maximus), licences et guide d'utilisation (rendu inline depuis `DocsContent`) |
| `/changelog` | `ChangelogPage` | Historique des versions (bilingue FR/EN) | | `/settings/data` | `DataSettingsPage` | Catégories (avec liens vers `/settings/categories/standard` et `/settings/categories/migrate`), backup chiffré et confidentialité de la récupération de prix |
| `/settings/systems` | `SystemsSettingsPage` | Version, mise à jour (`UpdateCard`), historique des versions (`ChangelogContent`), journaux + commentaires (`LogViewerCard`) |
| `/settings/categories/standard` | `CategoriesStandardGuidePage` | Guide imprimable de la structure de catégories standard (route flat, hors `SettingsLayout`) |
| `/settings/categories/migrate` | `CategoriesMigrationPage` | Flux de migration v1→v2 (route flat, hors `SettingsLayout`) |
| `/docs` | `DocsPage` | Redirige vers `/settings/users` (rétrocompatibilité bookmarks) |
| `/changelog` | `ChangelogPage` | Redirige vers `/settings/systems` (rétrocompatibilité release notes) |
Page spéciale : `ProfileSelectionPage` (affichée quand aucun profil n'est actif). Page spéciale : `ProfileSelectionPage` (affichée quand aucun profil n'est actif).

View file

@ -15,7 +15,11 @@ import ReportsTrendsPage from "./pages/ReportsTrendsPage";
import ReportsComparePage from "./pages/ReportsComparePage"; import ReportsComparePage from "./pages/ReportsComparePage";
import ReportsCategoryPage from "./pages/ReportsCategoryPage"; import ReportsCategoryPage from "./pages/ReportsCategoryPage";
import ReportsCartesPage from "./pages/ReportsCartesPage"; import ReportsCartesPage from "./pages/ReportsCartesPage";
import SettingsPage from "./pages/SettingsPage"; import SettingsLayout from "./pages/settings/SettingsLayout";
import SettingsHomePage from "./pages/settings/SettingsHomePage";
import UsersSettingsPage from "./pages/settings/UsersSettingsPage";
import DataSettingsPage from "./pages/settings/DataSettingsPage";
import SystemsSettingsPage from "./pages/settings/SystemsSettingsPage";
import AccountsPage from "./pages/AccountsPage"; import AccountsPage from "./pages/AccountsPage";
import BalancePage from "./pages/BalancePage"; import BalancePage from "./pages/BalancePage";
import SnapshotEditPage from "./pages/SnapshotEditPage"; import SnapshotEditPage from "./pages/SnapshotEditPage";
@ -116,7 +120,12 @@ export default function App() {
<Route path="/reports/compare" element={<ReportsComparePage />} /> <Route path="/reports/compare" element={<ReportsComparePage />} />
<Route path="/reports/category" element={<ReportsCategoryPage />} /> <Route path="/reports/category" element={<ReportsCategoryPage />} />
<Route path="/reports/cartes" element={<ReportsCartesPage />} /> <Route path="/reports/cartes" element={<ReportsCartesPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsLayout />}>
<Route index element={<SettingsHomePage />} />
<Route path="users" element={<UsersSettingsPage />} />
<Route path="data" element={<DataSettingsPage />} />
<Route path="systems" element={<SystemsSettingsPage />} />
</Route>
<Route path="/balance" element={<BalancePage />} /> <Route path="/balance" element={<BalancePage />} />
<Route path="/balance/accounts" element={<AccountsPage />} /> <Route path="/balance/accounts" element={<AccountsPage />} />
<Route path="/balance/snapshot" element={<SnapshotEditPage />} /> <Route path="/balance/snapshot" element={<SnapshotEditPage />} />

View file

@ -0,0 +1,92 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
interface ChangelogEntry {
version: string;
sections: { heading: string; items: string[] }[];
}
function parseChangelog(markdown: string): ChangelogEntry[] {
const entries: ChangelogEntry[] = [];
let current: ChangelogEntry | null = null;
let currentSection: { heading: string; items: string[] } | null = null;
for (const line of markdown.split("\n")) {
const trimmed = line.trim();
const versionMatch = trimmed.match(/^## \[?([^\]]+)\]?/);
if (versionMatch) {
if (currentSection && current) current.sections.push(currentSection);
if (current) entries.push(current);
current = { version: versionMatch[1], sections: [] };
currentSection = null;
continue;
}
const sectionMatch = trimmed.match(/^### (.+)/);
if (sectionMatch && current) {
if (currentSection) current.sections.push(currentSection);
currentSection = { heading: sectionMatch[1], items: [] };
continue;
}
if (trimmed.startsWith("- ") && currentSection) {
currentSection.items.push(trimmed.slice(2));
}
}
if (currentSection && current) current.sections.push(currentSection);
if (current) entries.push(current);
return entries;
}
export default function ChangelogContent() {
const { t, i18n } = useTranslation();
const [entries, setEntries] = useState<ChangelogEntry[]>([]);
useEffect(() => {
const file = i18n.language === "fr" ? "/CHANGELOG.fr.md" : "/CHANGELOG.md";
fetch(file)
.then((r) => r.text())
.then((text) => setEntries(parseChangelog(text)))
.catch(() => setEntries([]));
}, [i18n.language]);
if (entries.length === 0) {
return (
<p className="text-[var(--muted-foreground)]">{t("changelog.empty")}</p>
);
}
return (
<div className="space-y-6">
{entries.map((entry) => (
<div
key={entry.version}
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-3"
>
<h3 className="text-lg font-semibold">{entry.version}</h3>
{entry.sections.map((section, si) => (
<div key={si} className="space-y-1.5">
<h4 className="text-sm font-semibold text-[var(--primary)]">
{section.heading}
</h4>
<ul className="space-y-1">
{section.items.map((item, ii) => (
<li
key={ii}
className="text-sm text-[var(--muted-foreground)] pl-3"
>
{"• "}
{item.replace(/\*\*(.+?)\*\*/g, "$1")}
</li>
))}
</ul>
</div>
))}
</div>
))}
</div>
);
}

View file

@ -0,0 +1,201 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import {
Rocket,
LayoutDashboard,
Upload,
ArrowLeftRight,
Tags,
SlidersHorizontal,
PiggyBank,
BarChart3,
Wallet,
Settings,
Lightbulb,
ListChecks,
Footprints,
Printer,
Users,
} from "lucide-react";
const SECTIONS = [
{ key: "gettingStarted", icon: Rocket },
{ key: "profiles", icon: Users },
{ key: "dashboard", icon: LayoutDashboard },
{ key: "import", icon: Upload },
{ key: "transactions", icon: ArrowLeftRight },
{ key: "categories", icon: Tags },
{ key: "adjustments", icon: SlidersHorizontal },
{ key: "budget", icon: PiggyBank },
{ key: "reports", icon: BarChart3 },
{ key: "balance", icon: Wallet },
{ key: "settings", icon: Settings },
] as const;
export default function DocsContent() {
const { t } = useTranslation();
const location = useLocation();
const sectionRefs = useRef<Record<string, HTMLElement | null>>({});
const [activeSection, setActiveSection] = useState<string>(SECTIONS[0].key);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActiveSection(entry.target.id);
}
}
},
{ rootMargin: "-30% 0px -60% 0px", threshold: 0 },
);
for (const { key } of SECTIONS) {
const el = sectionRefs.current[key];
if (el) observer.observe(el);
}
return () => observer.disconnect();
}, []);
useEffect(() => {
const hash = location.hash.replace("#", "");
if (hash && sectionRefs.current[hash]) {
requestAnimationFrame(() => {
sectionRefs.current[hash]?.scrollIntoView({
behavior: "smooth",
block: "start",
});
});
}
}, [location.hash]);
const scrollToSection = (key: string) => {
sectionRefs.current[key]?.scrollIntoView({
behavior: "smooth",
block: "start",
});
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-3">
<h2 className="text-xl font-semibold">{t("docs.title")}</h2>
<button
onClick={() => window.print()}
className="print:hidden flex items-center gap-2 px-3 py-2 text-sm rounded-lg bg-[var(--card)] border border-[var(--border)] text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
title={t("docs.print")}
>
<Printer size={16} />
{t("docs.print")}
</button>
</div>
<nav
className="print:hidden bg-[var(--card)] border border-[var(--border)] rounded-xl p-3"
aria-label={t("docs.title")}
>
<ul className="flex flex-wrap gap-1">
{SECTIONS.map(({ key, icon: Icon }) => (
<li key={key}>
<button
onClick={() => scrollToSection(key)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs transition-colors ${
activeSection === key
? "bg-[var(--primary)] text-white font-medium"
: "text-[var(--muted-foreground)] hover:bg-[var(--border)] hover:text-[var(--foreground)]"
}`}
>
<Icon size={13} />
{t(`docs.${key}.title`)}
</button>
</li>
))}
</ul>
</nav>
{SECTIONS.map(({ key, icon: Icon }) => (
<section
key={key}
id={key}
ref={(el) => {
sectionRefs.current[key] = el;
}}
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4 scroll-mt-4"
>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-[var(--primary)]/10 flex items-center justify-center text-[var(--primary)]">
<Icon size={20} />
</div>
<h3 className="text-lg font-semibold">{t(`docs.${key}.title`)}</h3>
</div>
<p className="text-[var(--muted-foreground)]">
{t(`docs.${key}.overview`)}
</p>
<div>
<h4 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)] mb-2">
<ListChecks size={14} />
{t("docs.features")}
</h4>
<ul className="space-y-1">
{(
t(`docs.${key}.features`, { returnObjects: true }) as string[]
).map((item, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<span className="text-[var(--primary)] mt-0.5 shrink-0">
&bull;
</span>
{item}
</li>
))}
</ul>
</div>
<div>
<h4 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)] mb-2">
<Footprints size={14} />
{key === "gettingStarted"
? t("docs.quickStart")
: t("docs.howTo")}
</h4>
<ol className="space-y-1 list-decimal list-inside">
{(
t(`docs.${key}.steps`, { returnObjects: true }) as string[]
).map((item, i) => (
<li key={i} className="text-sm">
{item}
</li>
))}
</ol>
</div>
<div className="bg-[var(--background)] rounded-lg p-4">
<h4 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)] mb-2">
<Lightbulb size={14} />
{t("docs.tipsHeader")}
</h4>
<ul className="space-y-1">
{(
t(`docs.${key}.tips`, { returnObjects: true }) as string[]
).map((item, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]"
>
<Lightbulb
size={13}
className="text-[var(--primary)] mt-0.5 shrink-0"
/>
{item}
</li>
))}
</ul>
</div>
</section>
))}
</div>
);
}

View file

@ -0,0 +1,211 @@
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>
);
}

View file

@ -645,6 +645,38 @@
"revokeButton": "Revoke consent", "revokeButton": "Revoke consent",
"notPremium": "Premium licenses only" "notPremium": "Premium licenses only"
} }
},
"backToHome": "Back to settings",
"home": {
"intro": "Configure the app across three sections: users, data and system."
},
"users": {
"title": "Users",
"description": "Accounts, licenses and user guide.",
"sections": {
"accounts": "Accounts",
"licenses": "Licenses",
"userGuide": "User guide"
}
},
"data": {
"title": "Data",
"description": "Categories, backups and privacy.",
"sections": {
"categories": "Categories",
"backup": "Backup",
"priceFetch": "Price privacy"
}
},
"systems": {
"title": "System",
"description": "Version, updates, logs and history.",
"sections": {
"version": "Version",
"update": "Update",
"changelog": "Version history",
"logs": "Logs and feedback"
}
} }
}, },
"charts": { "charts": {

View file

@ -645,6 +645,38 @@
"revokeButton": "Révoquer le consentement", "revokeButton": "Révoquer le consentement",
"notPremium": "Réservé aux licences premium" "notPremium": "Réservé aux licences premium"
} }
},
"backToHome": "Retour aux paramètres",
"home": {
"intro": "Configurez l'application en trois sections : utilisateurs, données et système."
},
"users": {
"title": "Utilisateurs",
"description": "Comptes, licences et guide d'utilisation.",
"sections": {
"accounts": "Comptes",
"licenses": "Licences",
"userGuide": "Guide d'utilisation"
}
},
"data": {
"title": "Données",
"description": "Catégories, sauvegardes et confidentialité.",
"sections": {
"categories": "Catégories",
"backup": "Sauvegarde",
"priceFetch": "Confidentialité des prix"
}
},
"systems": {
"title": "Système",
"description": "Version, mises à jour, journaux et historique.",
"sections": {
"version": "Version",
"update": "Mise à jour",
"changelog": "Historique des versions",
"logs": "Journaux et commentaires"
}
} }
}, },
"charts": { "charts": {

View file

@ -207,7 +207,7 @@ export default function CategoriesMigrationPage() {
return ( return (
<div className="p-6 max-w-2xl mx-auto space-y-4"> <div className="p-6 max-w-2xl mx-auto space-y-4">
<Link <Link
to="/settings" to="/settings/data"
className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors" className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
> >
<ArrowLeft size={16} /> <ArrowLeft size={16} />
@ -229,7 +229,7 @@ export default function CategoriesMigrationPage() {
<div className="p-6 max-w-5xl mx-auto space-y-6"> <div className="p-6 max-w-5xl mx-auto space-y-6">
<div> <div>
<Link <Link
to="/settings" to="/settings/data"
className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors" className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
> >
<ArrowLeft size={16} /> <ArrowLeft size={16} />
@ -527,7 +527,7 @@ function ErrorScreen({ errors, onRetry }: ErrorScreenProps) {
{t("categoriesSeed.migration.error.retry")} {t("categoriesSeed.migration.error.retry")}
</button> </button>
<Link <Link
to="/settings" to="/settings/data"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--muted)]" className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--muted)]"
> >
{t("categoriesSeed.migration.error.backToSettings")} {t("categoriesSeed.migration.error.backToSettings")}

View file

@ -83,7 +83,7 @@ export default function CategoriesStandardGuidePage() {
{/* Back link (hidden in print) */} {/* Back link (hidden in print) */}
<div className="print:hidden"> <div className="print:hidden">
<Link <Link
to="/settings" to="/settings/data"
className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors" className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
> >
<ArrowLeft size={16} /> <ArrowLeft size={16} />

View file

@ -1,107 +1,5 @@
import { useEffect, useState } from "react"; import { Navigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
interface ChangelogEntry {
version: string;
sections: { heading: string; items: string[] }[];
}
function parseChangelog(markdown: string): ChangelogEntry[] {
const entries: ChangelogEntry[] = [];
let current: ChangelogEntry | null = null;
let currentSection: { heading: string; items: string[] } | null = null;
for (const line of markdown.split("\n")) {
const trimmed = line.trim();
// Version heading: ## [0.6.0] or ## 0.6.0
const versionMatch = trimmed.match(/^## \[?([^\]]+)\]?/);
if (versionMatch) {
if (currentSection && current) current.sections.push(currentSection);
if (current) entries.push(current);
current = { version: versionMatch[1], sections: [] };
currentSection = null;
continue;
}
// Section heading: ### Added, ### Corrigé, etc.
const sectionMatch = trimmed.match(/^### (.+)/);
if (sectionMatch && current) {
if (currentSection) current.sections.push(currentSection);
currentSection = { heading: sectionMatch[1], items: [] };
continue;
}
// List item
if (trimmed.startsWith("- ") && currentSection) {
currentSection.items.push(trimmed.slice(2));
}
}
if (currentSection && current) current.sections.push(currentSection);
if (current) entries.push(current);
return entries;
}
export default function ChangelogPage() { export default function ChangelogPage() {
const { t, i18n } = useTranslation(); return <Navigate to="/settings/systems" replace />;
const [entries, setEntries] = useState<ChangelogEntry[]>([]);
useEffect(() => {
const file = i18n.language === "fr" ? "/CHANGELOG.fr.md" : "/CHANGELOG.md";
fetch(file)
.then((r) => r.text())
.then((text) => setEntries(parseChangelog(text)))
.catch(() => setEntries([]));
}, [i18n.language]);
return (
<div className="p-6 max-w-2xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<Link
to="/settings"
className="p-1.5 rounded-lg hover:bg-[var(--muted)] transition-colors"
>
<ArrowLeft size={18} />
</Link>
<h1 className="text-2xl font-bold">{t("changelog.title")}</h1>
</div>
{entries.length === 0 ? (
<p className="text-[var(--muted-foreground)]">{t("changelog.empty")}</p>
) : (
<div className="space-y-6">
{entries.map((entry) => (
<div
key={entry.version}
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-3"
>
<h2 className="text-lg font-semibold">{entry.version}</h2>
{entry.sections.map((section, si) => (
<div key={si} className="space-y-1.5">
<h3 className="text-sm font-semibold text-[var(--primary)]">
{section.heading}
</h3>
<ul className="space-y-1">
{section.items.map((item, ii) => (
<li
key={ii}
className="text-sm text-[var(--muted-foreground)] pl-3"
>
{"\u2022 "}
{item.replace(/\*\*(.+?)\*\*/g, "$1")}
</li>
))}
</ul>
</div>
))}
</div>
))}
</div>
)}
</div>
);
} }

View file

@ -1,232 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { Navigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router-dom";
import {
Rocket,
LayoutDashboard,
Upload,
ArrowLeftRight,
Tags,
SlidersHorizontal,
PiggyBank,
BarChart3,
Wallet,
Settings,
ArrowLeft,
Lightbulb,
ListChecks,
Footprints,
Printer,
Users,
} from "lucide-react";
const SECTIONS = [
{ key: "gettingStarted", icon: Rocket },
{ key: "profiles", icon: Users },
{ key: "dashboard", icon: LayoutDashboard },
{ key: "import", icon: Upload },
{ key: "transactions", icon: ArrowLeftRight },
{ key: "categories", icon: Tags },
{ key: "adjustments", icon: SlidersHorizontal },
{ key: "budget", icon: PiggyBank },
{ key: "reports", icon: BarChart3 },
{ key: "balance", icon: Wallet },
{ key: "settings", icon: Settings },
] as const;
export default function DocsPage() { export default function DocsPage() {
const { t } = useTranslation(); return <Navigate to="/settings/users" replace />;
const location = useLocation();
const [activeSection, setActiveSection] = useState<string>(SECTIONS[0].key);
const sectionRefs = useRef<Record<string, HTMLElement | null>>({});
const contentRef = useRef<HTMLDivElement>(null);
// Scroll spy via IntersectionObserver
useEffect(() => {
const container = contentRef.current;
if (!container) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActiveSection(entry.target.id);
}
}
},
{
root: container,
rootMargin: "-10% 0px -80% 0px",
threshold: 0,
}
);
for (const { key } of SECTIONS) {
const el = sectionRefs.current[key];
if (el) observer.observe(el);
}
return () => observer.disconnect();
}, []);
// Handle initial anchor from URL
useEffect(() => {
const hash = location.hash.replace("#", "");
if (hash && sectionRefs.current[hash]) {
requestAnimationFrame(() => {
sectionRefs.current[hash]?.scrollIntoView({ behavior: "smooth" });
});
}
}, [location.hash]);
const scrollToSection = (key: string) => {
sectionRefs.current[key]?.scrollIntoView({ behavior: "smooth" });
};
return (
<div className="flex h-full overflow-hidden">
{/* Sidebar TOC */}
<nav className="w-56 shrink-0 border-r border-[var(--border)] p-4 overflow-y-auto">
<Link
to="/settings"
className="flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors mb-4"
>
<ArrowLeft size={14} />
{t("docs.backToSettings")}
</Link>
<h2 className="text-sm font-semibold text-[var(--muted-foreground)] uppercase tracking-wider mb-3">
{t("docs.title")}
</h2>
<ul className="space-y-1">
{SECTIONS.map(({ key, icon: Icon }) => (
<li key={key}>
<button
onClick={() => scrollToSection(key)}
className={`flex items-center gap-2 w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
activeSection === key
? "bg-[var(--primary)] text-white font-medium"
: "text-[var(--muted-foreground)] hover:bg-[var(--border)] hover:text-[var(--foreground)]"
}`}
>
<Icon size={15} />
{t(`docs.${key}.title`)}
</button>
</li>
))}
</ul>
</nav>
{/* Scrollable content */}
<div ref={contentRef} className="flex-1 overflow-y-auto p-6">
<div className="max-w-3xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t("docs.title")}</h1>
<button
onClick={() => window.print()}
className="print:hidden flex items-center gap-2 px-3 py-2 text-sm rounded-lg bg-[var(--card)] border border-[var(--border)] text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
title={t("docs.print")}
>
<Printer size={16} />
{t("docs.print")}
</button>
</div>
{SECTIONS.map(({ key, icon: Icon }) => (
<section
key={key}
id={key}
ref={(el) => {
sectionRefs.current[key] = el;
}}
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4"
>
{/* Section header */}
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-[var(--primary)]/10 flex items-center justify-center text-[var(--primary)]">
<Icon size={20} />
</div>
<h2 className="text-lg font-semibold">
{t(`docs.${key}.title`)}
</h2>
</div>
{/* Overview */}
<p className="text-[var(--muted-foreground)]">
{t(`docs.${key}.overview`)}
</p>
{/* Features */}
<div>
<h3 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)] mb-2">
<ListChecks size={14} />
{t("docs.features")}
</h3>
<ul className="space-y-1">
{(
t(`docs.${key}.features`, {
returnObjects: true,
}) as string[]
).map((item, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm"
>
<span className="text-[var(--primary)] mt-0.5 shrink-0"></span>
{item}
</li>
))}
</ul>
</div>
{/* Steps */}
<div>
<h3 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)] mb-2">
<Footprints size={14} />
{key === "gettingStarted"
? t("docs.quickStart")
: t("docs.howTo")}
</h3>
<ol className="space-y-1 list-decimal list-inside">
{(
t(`docs.${key}.steps`, {
returnObjects: true,
}) as string[]
).map((item, i) => (
<li key={i} className="text-sm">
{item}
</li>
))}
</ol>
</div>
{/* Tips */}
<div className="bg-[var(--background)] rounded-lg p-4">
<h3 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)] mb-2">
<Lightbulb size={14} />
{t("docs.tipsHeader")}
</h3>
<ul className="space-y-1">
{(
t(`docs.${key}.tips`, {
returnObjects: true,
}) as string[]
).map((item, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]"
>
<Lightbulb size={13} className="text-[var(--primary)] mt-0.5 shrink-0" />
{item}
</li>
))}
</ul>
</div>
</section>
))}
</div>
</div>
</div>
);
} }

View file

@ -1,316 +0,0 @@
import { useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
Info,
RefreshCw,
Download,
CheckCircle,
AlertCircle,
RotateCcw,
Loader2,
ShieldCheck,
BookOpen,
ChevronRight,
FileText,
} from "lucide-react";
import { getVersion } from "@tauri-apps/api/app";
import { useUpdater } from "../hooks/useUpdater";
import { Link } from "react-router-dom";
import { APP_NAME } from "../shared/constants";
import { PageHelp } from "../components/shared/PageHelp";
import DataManagementCard from "../components/settings/DataManagementCard";
import LicenseCard from "../components/settings/LicenseCard";
import AccountCard from "../components/settings/AccountCard";
import LogViewerCard from "../components/settings/LogViewerCard";
import TokenStoreFallbackBanner from "../components/settings/TokenStoreFallbackBanner";
import CategoriesCard from "../components/settings/CategoriesCard";
import { PriceFetchConsentToggle } from "../components/settings/PriceFetchConsentToggle";
export default function SettingsPage() {
const { t, i18n } = useTranslation();
const { state, checkForUpdate, downloadAndInstall, installAndRestart } =
useUpdater();
const [version, setVersion] = useState("");
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(() => {
getVersion().then(setVersion);
}, []);
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="p-6 max-w-2xl mx-auto space-y-6">
<div className="relative flex items-center gap-3">
<h1 className="text-2xl font-bold">{t("settings.title")}</h1>
<PageHelp helpKey="settings" />
</div>
{/* License card */}
<LicenseCard />
{/* Account card */}
<AccountCard />
{/* Security banner renders only when OAuth tokens are in the
file fallback instead of the OS keychain */}
<TokenStoreFallbackBanner />
{/* 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-foreground)]">
{t("settings.version", { version })}
</p>
</div>
</div>
</div>
{/* User guide card */}
<Link
to="/docs"
className="block bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 hover:border-[var(--primary)] transition-colors group"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-[var(--primary)]/10 flex items-center justify-center text-[var(--primary)]">
<BookOpen size={22} />
</div>
<div>
<h2 className="text-lg font-semibold">{t("settings.userGuide.title")}</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("settings.userGuide.description")}
</p>
</div>
</div>
<ChevronRight size={18} className="text-[var(--muted-foreground)] group-hover:text-[var(--primary)] transition-colors" />
</div>
</Link>
{/* Categories card — entry to the standard categories guide */}
<CategoriesCard />
{/* Changelog card */}
<Link
to="/changelog"
className="block bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 hover:border-[var(--primary)] transition-colors group"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-[var(--primary)]/10 flex items-center justify-center text-[var(--primary)]">
<FileText size={22} />
</div>
<div>
<h2 className="text-lg font-semibold">{t("changelog.title")}</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("changelog.description")}
</p>
</div>
</div>
<ChevronRight size={18} className="text-[var(--muted-foreground)] group-hover:text-[var(--primary)] transition-colors" />
</div>
</Link>
{/* 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-foreground)]">
<Loader2 size={16} className="animate-spin" />
{t("settings.updates.checking")}
</div>
)}
{/* not entitled (free edition) */}
{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>
)}
{/* 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-foreground)] 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>
{(() => {
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">{"\u2022 "}{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>
)}
{/* downloading */}
{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>
)}
{/* 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-foreground)]">
<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-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>
{/* Logs */}
<LogViewerCard />
{/* Data management */}
<DataManagementCard />
{/* Privacy — price fetching consent (premium only) */}
<PriceFetchConsentToggle />
{/* Data safety notice */}
<div className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]">
<ShieldCheck size={16} className="mt-0.5 shrink-0" />
<p>{t("settings.dataSafeNotice")}</p>
</div>
</div>
);
}

View file

@ -0,0 +1,45 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import CategoriesCard from "../../components/settings/CategoriesCard";
import DataManagementCard from "../../components/settings/DataManagementCard";
import { PriceFetchConsentToggle } from "../../components/settings/PriceFetchConsentToggle";
export default function DataSettingsPage() {
const { t } = useTranslation();
return (
<div className="space-y-6">
<Link
to="/settings"
className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
<ArrowLeft size={16} />
{t("settings.backToHome")}
</Link>
<h1 className="text-2xl font-bold">{t("settings.data.title")}</h1>
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.data.sections.categories")}
</h2>
<CategoriesCard />
</section>
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.data.sections.backup")}
</h2>
<DataManagementCard />
</section>
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.data.sections.priceFetch")}
</h2>
<PriceFetchConsentToggle />
</section>
</div>
);
}

View file

@ -0,0 +1,106 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { Users, Database, Cpu, ChevronRight } from "lucide-react";
import { PageHelp } from "../../components/shared/PageHelp";
interface SectionCard {
to: string;
titleKey: string;
descriptionKey: string;
Icon: React.ComponentType<{ size?: number }>;
itemKeys: string[];
}
const SECTIONS: SectionCard[] = [
{
to: "/settings/users",
titleKey: "settings.users.title",
descriptionKey: "settings.users.description",
Icon: Users,
itemKeys: [
"settings.users.sections.accounts",
"settings.users.sections.licenses",
"settings.users.sections.userGuide",
],
},
{
to: "/settings/data",
titleKey: "settings.data.title",
descriptionKey: "settings.data.description",
Icon: Database,
itemKeys: [
"settings.data.sections.categories",
"settings.data.sections.backup",
"settings.data.sections.priceFetch",
],
},
{
to: "/settings/systems",
titleKey: "settings.systems.title",
descriptionKey: "settings.systems.description",
Icon: Cpu,
itemKeys: [
"settings.systems.sections.version",
"settings.systems.sections.update",
"settings.systems.sections.changelog",
"settings.systems.sections.logs",
],
},
];
export default function SettingsHomePage() {
const { t } = useTranslation();
return (
<div className="space-y-6">
<div className="relative flex items-center gap-3">
<h1 className="text-2xl font-bold">{t("settings.title")}</h1>
<PageHelp helpKey="settings" />
</div>
<p className="text-sm text-[var(--muted-foreground)]">
{t("settings.home.intro")}
</p>
<div className="space-y-4">
{SECTIONS.map(({ to, titleKey, descriptionKey, Icon, itemKeys }) => (
<Link
key={to}
to={to}
className="block bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 hover:border-[var(--primary)] transition-colors group"
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-4 flex-1">
<div className="w-12 h-12 rounded-xl bg-[var(--primary)]/10 flex items-center justify-center text-[var(--primary)] shrink-0">
<Icon size={22} />
</div>
<div className="flex-1 space-y-2">
<div>
<h2 className="text-lg font-semibold">{t(titleKey)}</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t(descriptionKey)}
</p>
</div>
<ul className="flex flex-wrap gap-2 text-xs text-[var(--muted-foreground)]">
{itemKeys.map((key) => (
<li
key={key}
className="px-2 py-0.5 rounded-full bg-[var(--background)] border border-[var(--border)]"
>
{t(key)}
</li>
))}
</ul>
</div>
</div>
<ChevronRight
size={18}
className="text-[var(--muted-foreground)] group-hover:text-[var(--primary)] transition-colors shrink-0"
/>
</div>
</Link>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,11 @@
import { Outlet } from "react-router-dom";
import TokenStoreFallbackBanner from "../../components/settings/TokenStoreFallbackBanner";
export default function SettingsLayout() {
return (
<div className="p-6 max-w-3xl mx-auto space-y-6">
<TokenStoreFallbackBanner />
<Outlet />
</div>
);
}

View file

@ -0,0 +1,77 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft, ShieldCheck } from "lucide-react";
import { getVersion } from "@tauri-apps/api/app";
import { APP_NAME } from "../../shared/constants";
import UpdateCard from "../../components/settings/UpdateCard";
import ChangelogContent from "../../components/settings/ChangelogContent";
import LogViewerCard from "../../components/settings/LogViewerCard";
export default function SystemsSettingsPage() {
const { t } = useTranslation();
const [version, setVersion] = useState("");
useEffect(() => {
getVersion().then(setVersion);
}, []);
return (
<div className="space-y-6">
<Link
to="/settings"
className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
<ArrowLeft size={16} />
{t("settings.backToHome")}
</Link>
<h1 className="text-2xl font-bold">{t("settings.systems.title")}</h1>
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.systems.sections.version")}
</h2>
<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>
<h3 className="text-lg font-semibold">{APP_NAME}</h3>
<p className="text-sm text-[var(--muted-foreground)]">
{t("settings.version", { version })}
</p>
</div>
</div>
</div>
</section>
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.systems.sections.update")}
</h2>
<UpdateCard />
</section>
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.systems.sections.changelog")}
</h2>
<ChangelogContent />
</section>
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.systems.sections.logs")}
</h2>
<LogViewerCard />
</section>
<div className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]">
<ShieldCheck size={16} className="mt-0.5 shrink-0" />
<p>{t("settings.dataSafeNotice")}</p>
</div>
</div>
);
}

View file

@ -0,0 +1,45 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import AccountCard from "../../components/settings/AccountCard";
import LicenseCard from "../../components/settings/LicenseCard";
import DocsContent from "../../components/settings/DocsContent";
export default function UsersSettingsPage() {
const { t } = useTranslation();
return (
<div className="space-y-6">
<Link
to="/settings"
className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
<ArrowLeft size={16} />
{t("settings.backToHome")}
</Link>
<h1 className="text-2xl font-bold">{t("settings.users.title")}</h1>
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.users.sections.accounts")}
</h2>
<AccountCard />
</section>
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.users.sections.licenses")}
</h2>
<LicenseCard />
</section>
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.users.sections.userGuide")}
</h2>
<DocsContent />
</section>
</div>
);
}