feat: add dark mode with famille-website-inspired warm gray palette
Toggle via Moon/Sun button in sidebar. Persists to localStorage with prefers-color-scheme fallback. Fixes hardcoded colors (error banners, badges, chart tooltips, Settings text) to use CSS variables. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c73f466429
commit
f7fb6910b6
15 changed files with 114 additions and 29 deletions
|
|
@ -43,6 +43,14 @@ export default function CategoryPieChart({ data }: CategoryPieChartProps) {
|
|||
formatter={(value) =>
|
||||
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(Number(value))
|
||||
}
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
color: "var(--foreground)",
|
||||
}}
|
||||
labelStyle={{ color: "var(--foreground)" }}
|
||||
itemStyle={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
|
|
|
|||
|
|
@ -151,8 +151,8 @@ export default function DuplicateCheckPanel({
|
|||
<span
|
||||
className={`inline-block px-2 py-0.5 text-xs rounded-full ${
|
||||
isBatch
|
||||
? "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300"
|
||||
: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
|
||||
? "bg-[var(--accent)]/15 text-[var(--accent)]"
|
||||
: "bg-[var(--primary)]/15 text-[var(--primary)]"
|
||||
}`}
|
||||
>
|
||||
{isBatch
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ export default function ImportConfirmation({
|
|||
|
||||
{/* Rows to import */}
|
||||
<div className="p-4 flex items-center gap-3">
|
||||
<CheckCircle size={18} className="text-emerald-500" />
|
||||
<CheckCircle size={18} className="text-[var(--positive)]" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("import.confirm.rowsToImport")}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export default function SourceCard({
|
|||
{isConfigured && (
|
||||
<CheckCircle
|
||||
size={16}
|
||||
className="text-emerald-500"
|
||||
className="text-[var(--positive)]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,8 +10,11 @@ import {
|
|||
BarChart3,
|
||||
Settings,
|
||||
Languages,
|
||||
Moon,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import { NAV_ITEMS, APP_NAME } from "../../shared/constants";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
|
||||
const iconMap: Record<string, React.ComponentType<{ size?: number }>> = {
|
||||
LayoutDashboard,
|
||||
|
|
@ -26,6 +29,7 @@ const iconMap: Record<string, React.ComponentType<{ size?: number }>> = {
|
|||
|
||||
export default function Sidebar() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const next = i18n.language === "fr" ? "en" : "fr";
|
||||
|
|
@ -60,7 +64,14 @@ export default function Sidebar() {
|
|||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-3 border-t border-white/10">
|
||||
<div className="p-3 border-t border-white/10 space-y-1">
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm hover:bg-[var(--sidebar-hover)] transition-colors"
|
||||
>
|
||||
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
|
||||
<span>{theme === "dark" ? t("common.lightMode") : t("common.darkMode")}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleLanguage}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm hover:bg-[var(--sidebar-hover)] transition-colors"
|
||||
|
|
|
|||
|
|
@ -51,7 +51,10 @@ export default function CategoryBarChart({ data }: CategoryBarChartProps) {
|
|||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
color: "var(--foreground)",
|
||||
}}
|
||||
labelStyle={{ color: "var(--foreground)" }}
|
||||
itemStyle={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
<Bar dataKey="total" name={t("dashboard.expenses")} radius={[0, 4, 4, 0]}>
|
||||
{data.map((item, index) => (
|
||||
|
|
|
|||
|
|
@ -59,7 +59,10 @@ export default function CategoryOverTimeChart({ data }: CategoryOverTimeChartPro
|
|||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
color: "var(--foreground)",
|
||||
}}
|
||||
labelStyle={{ color: "var(--foreground)" }}
|
||||
itemStyle={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
<Legend />
|
||||
{data.categories.map((name) => (
|
||||
|
|
|
|||
|
|
@ -68,7 +68,10 @@ export default function MonthlyTrendsChart({ data }: MonthlyTrendsChartProps) {
|
|||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
color: "var(--foreground)",
|
||||
}}
|
||||
labelStyle={{ color: "var(--foreground)" }}
|
||||
itemStyle={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
|
|
|
|||
42
src/hooks/useTheme.ts
Normal file
42
src/hooks/useTheme.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
|
||||
const STORAGE_KEY = "theme";
|
||||
|
||||
function getInitialTheme(): Theme {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "light" || stored === "dark") return stored;
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<Theme>(getInitialTheme);
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
localStorage.setItem(STORAGE_KEY, theme);
|
||||
}, [theme]);
|
||||
|
||||
// Listen for OS preference changes (only when no explicit user choice)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
if (!localStorage.getItem(STORAGE_KEY)) {
|
||||
setTheme(e.matches ? "dark" : "light");
|
||||
}
|
||||
};
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||
}, []);
|
||||
|
||||
return { theme, toggleTheme } as const;
|
||||
}
|
||||
|
|
@ -362,6 +362,8 @@
|
|||
"noResults": "No results",
|
||||
"confirm": "Confirm",
|
||||
"language": "Language",
|
||||
"total": "Total"
|
||||
"total": "Total",
|
||||
"darkMode": "Dark mode",
|
||||
"lightMode": "Light mode"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -362,6 +362,8 @@
|
|||
"noResults": "Aucun résultat",
|
||||
"confirm": "Confirmer",
|
||||
"language": "Langue",
|
||||
"total": "Total"
|
||||
"total": "Total",
|
||||
"darkMode": "Mode sombre",
|
||||
"lightMode": "Mode clair"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export default function BudgetPage() {
|
|||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-100 text-red-800 text-sm border border-red-200">
|
||||
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export default function ReportsPage() {
|
|||
</div>
|
||||
|
||||
{state.error && (
|
||||
<div className="bg-red-100 text-red-700 rounded-xl p-4 mb-6">
|
||||
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{APP_NAME}</h2>
|
||||
<p className="text-sm text-[var(--muted)]">
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{t("settings.version", { version })}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -72,7 +72,7 @@ export default function SettingsPage() {
|
|||
|
||||
{/* checking */}
|
||||
{state.status === "checking" && (
|
||||
<div className="flex items-center gap-2 text-[var(--muted)]">
|
||||
<div className="flex items-center gap-2 text-[var(--muted-foreground)]">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
{t("settings.updates.checking")}
|
||||
</div>
|
||||
|
|
@ -87,7 +87,7 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
<button
|
||||
onClick={checkForUpdate}
|
||||
className="text-sm text-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
|
||||
className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
|
|
@ -113,7 +113,7 @@ export default function SettingsPage() {
|
|||
{/* downloading */}
|
||||
{state.status === "downloading" && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-[var(--muted)]">
|
||||
<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>}
|
||||
|
|
@ -145,7 +145,7 @@ export default function SettingsPage() {
|
|||
|
||||
{/* installing */}
|
||||
{state.status === "installing" && (
|
||||
<div className="flex items-center gap-2 text-[var(--muted)]">
|
||||
<div className="flex items-center gap-2 text-[var(--muted-foreground)]">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
{t("settings.updates.installing")}
|
||||
</div>
|
||||
|
|
@ -158,7 +158,7 @@ export default function SettingsPage() {
|
|||
<AlertCircle size={16} />
|
||||
{t("settings.updates.error")}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted)]">{state.error}</p>
|
||||
<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"
|
||||
|
|
@ -171,7 +171,7 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
|
||||
{/* Data safety notice */}
|
||||
<div className="flex items-start gap-2 text-sm text-[var(--muted)]">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -59,28 +59,35 @@
|
|||
--negative: #ef4444;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
/* Dark mode — inspired by famille-website warm gray palette */
|
||||
.dark {
|
||||
--background: #1a1a2e;
|
||||
--foreground: var(--color-creme-100);
|
||||
--card: #16213e;
|
||||
--card-foreground: var(--color-creme-100);
|
||||
--background: #111827;
|
||||
--foreground: #f3f4f6;
|
||||
--card: #1f2937;
|
||||
--card-foreground: #f3f4f6;
|
||||
--primary: var(--color-bleu-400);
|
||||
--primary-foreground: #1a1a2e;
|
||||
--primary-foreground: #111827;
|
||||
--accent: var(--color-terracotta-400);
|
||||
--accent-foreground: #1a1a2e;
|
||||
--muted: #16213e;
|
||||
--accent-foreground: #111827;
|
||||
--muted: #374151;
|
||||
--muted-foreground: #9ca3af;
|
||||
--border: #2a2a4a;
|
||||
--sidebar-bg: #0f0f23;
|
||||
--sidebar-fg: var(--color-creme-200);
|
||||
--sidebar-hover: #16213e;
|
||||
--sidebar-active: #1a2744;
|
||||
--border: #374151;
|
||||
--sidebar-bg: #0f172a;
|
||||
--sidebar-fg: #f3f4f6;
|
||||
--sidebar-hover: #1e293b;
|
||||
--sidebar-active: #334155;
|
||||
--positive: #4ade80;
|
||||
--negative: #f87171;
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
|
|
@ -89,6 +96,10 @@ body {
|
|||
min-height: 100vh;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
|
|
|
|||
Loading…
Reference in a new issue