diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index bbc5094..921c101 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -8,6 +8,7 @@ ### 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 `` 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). diff --git a/CHANGELOG.md b/CHANGELOG.md index 15184ad..14652bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### 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). - 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). diff --git a/docs/architecture.md b/docs/architecture.md index 6b86dc3..ea9654f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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/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) | -| `/settings` | `SettingsPage` | Paramètres | -| `/docs` | `DocsPage` | Documentation in-app | -| `/changelog` | `ChangelogPage` | Historique des versions (bilingue FR/EN) | +| `/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 | +| `/settings/users` | `UsersSettingsPage` | Comptes (Maximus), licences et guide d'utilisation (rendu inline depuis `DocsContent`) | +| `/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). diff --git a/src/App.tsx b/src/App.tsx index e987ff7..d20737b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,7 +15,11 @@ import ReportsTrendsPage from "./pages/ReportsTrendsPage"; import ReportsComparePage from "./pages/ReportsComparePage"; import ReportsCategoryPage from "./pages/ReportsCategoryPage"; 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 BalancePage from "./pages/BalancePage"; import SnapshotEditPage from "./pages/SnapshotEditPage"; @@ -116,7 +120,12 @@ export default function App() { <Route path="/reports/compare" element={<ReportsComparePage />} /> <Route path="/reports/category" element={<ReportsCategoryPage />} /> <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/accounts" element={<AccountsPage />} /> <Route path="/balance/snapshot" element={<SnapshotEditPage />} /> diff --git a/src/components/settings/ChangelogContent.tsx b/src/components/settings/ChangelogContent.tsx new file mode 100644 index 0000000..4742826 --- /dev/null +++ b/src/components/settings/ChangelogContent.tsx @@ -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> + ); +} diff --git a/src/components/settings/DocsContent.tsx b/src/components/settings/DocsContent.tsx new file mode 100644 index 0000000..9709e3b --- /dev/null +++ b/src/components/settings/DocsContent.tsx @@ -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> + ); +} diff --git a/src/components/settings/UpdateCard.tsx b/src/components/settings/UpdateCard.tsx new file mode 100644 index 0000000..464b65a --- /dev/null +++ b/src/components/settings/UpdateCard.tsx @@ -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> + ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a751f4a..48d6357 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -645,6 +645,38 @@ "revokeButton": "Revoke consent", "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": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 76f08bf..1d02599 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -645,6 +645,38 @@ "revokeButton": "Révoquer le consentement", "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": { diff --git a/src/pages/CategoriesMigrationPage.tsx b/src/pages/CategoriesMigrationPage.tsx index ea3727a..caf4f6a 100644 --- a/src/pages/CategoriesMigrationPage.tsx +++ b/src/pages/CategoriesMigrationPage.tsx @@ -207,7 +207,7 @@ export default function CategoriesMigrationPage() { return ( <div className="p-6 max-w-2xl mx-auto space-y-4"> <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" > <ArrowLeft size={16} /> @@ -229,7 +229,7 @@ export default function CategoriesMigrationPage() { <div className="p-6 max-w-5xl mx-auto space-y-6"> <div> <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" > <ArrowLeft size={16} /> @@ -527,7 +527,7 @@ function ErrorScreen({ errors, onRetry }: ErrorScreenProps) { {t("categoriesSeed.migration.error.retry")} </button> <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)]" > {t("categoriesSeed.migration.error.backToSettings")} diff --git a/src/pages/CategoriesStandardGuidePage.tsx b/src/pages/CategoriesStandardGuidePage.tsx index e68a4a1..70c8625 100644 --- a/src/pages/CategoriesStandardGuidePage.tsx +++ b/src/pages/CategoriesStandardGuidePage.tsx @@ -83,7 +83,7 @@ export default function CategoriesStandardGuidePage() { {/* Back link (hidden in print) */} <div className="print:hidden"> <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" > <ArrowLeft size={16} /> diff --git a/src/pages/ChangelogPage.tsx b/src/pages/ChangelogPage.tsx index 1e5ee25..a886a4e 100644 --- a/src/pages/ChangelogPage.tsx +++ b/src/pages/ChangelogPage.tsx @@ -1,107 +1,5 @@ -import { useEffect, useState } from "react"; -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; -} +import { Navigate } from "react-router-dom"; export default function ChangelogPage() { - 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]); - - 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> - ); + return <Navigate to="/settings/systems" replace />; } diff --git a/src/pages/DocsPage.tsx b/src/pages/DocsPage.tsx index c7a0238..89dca8d 100644 --- a/src/pages/DocsPage.tsx +++ b/src/pages/DocsPage.tsx @@ -1,232 +1,5 @@ -import { useEffect, useRef, useState } from "react"; -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; +import { Navigate } from "react-router-dom"; export default function DocsPage() { - const { t } = useTranslation(); - 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> - ); + return <Navigate to="/settings/users" replace />; } diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx deleted file mode 100644 index 2507cd7..0000000 --- a/src/pages/SettingsPage.tsx +++ /dev/null @@ -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> - ); -} diff --git a/src/pages/settings/DataSettingsPage.tsx b/src/pages/settings/DataSettingsPage.tsx new file mode 100644 index 0000000..71ff44f --- /dev/null +++ b/src/pages/settings/DataSettingsPage.tsx @@ -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> + ); +} diff --git a/src/pages/settings/SettingsHomePage.tsx b/src/pages/settings/SettingsHomePage.tsx new file mode 100644 index 0000000..622d673 --- /dev/null +++ b/src/pages/settings/SettingsHomePage.tsx @@ -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> + ); +} diff --git a/src/pages/settings/SettingsLayout.tsx b/src/pages/settings/SettingsLayout.tsx new file mode 100644 index 0000000..37c69c7 --- /dev/null +++ b/src/pages/settings/SettingsLayout.tsx @@ -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> + ); +} diff --git a/src/pages/settings/SystemsSettingsPage.tsx b/src/pages/settings/SystemsSettingsPage.tsx new file mode 100644 index 0000000..6a0ba35 --- /dev/null +++ b/src/pages/settings/SystemsSettingsPage.tsx @@ -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> + ); +} diff --git a/src/pages/settings/UsersSettingsPage.tsx b/src/pages/settings/UsersSettingsPage.tsx new file mode 100644 index 0000000..2d18213 --- /dev/null +++ b/src/pages/settings/UsersSettingsPage.tsx @@ -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> + ); +}