This commit is contained in:
commit
efea8fb273
19 changed files with 881 additions and 658 deletions
|
|
@ -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).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
||||||
|
|
|
||||||
13
src/App.tsx
13
src/App.tsx
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
92
src/components/settings/ChangelogContent.tsx
Normal file
92
src/components/settings/ChangelogContent.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
201
src/components/settings/DocsContent.tsx
Normal file
201
src/components/settings/DocsContent.tsx
Normal 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">
|
||||||
|
•
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
211
src/components/settings/UpdateCard.tsx
Normal file
211
src/components/settings/UpdateCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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")}
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
45
src/pages/settings/DataSettingsPage.tsx
Normal file
45
src/pages/settings/DataSettingsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/pages/settings/SettingsHomePage.tsx
Normal file
106
src/pages/settings/SettingsHomePage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/pages/settings/SettingsLayout.tsx
Normal file
11
src/pages/settings/SettingsLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/pages/settings/SystemsSettingsPage.tsx
Normal file
77
src/pages/settings/SystemsSettingsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/pages/settings/UsersSettingsPage.tsx
Normal file
45
src/pages/settings/UsersSettingsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue