This commit is contained in:
commit
efea8fb273
19 changed files with 881 additions and 658 deletions
|
|
@ -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 `<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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
13
src/App.tsx
13
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 />} />
|
||||
|
|
|
|||
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",
|
||||
"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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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