Initial commit: MyMidas personal finance tracker

Full-stack self-hosted finance app with FastAPI backend and React frontend.

Features:
- Accounts, transactions, budgets, investments with GBP base currency
- CSV import with auto-detection for 10 UK bank formats
- ML predictions: spending forecast, net worth projection, Monte Carlo
- 7 selectable themes (Obsidian, Arctic, Midnight, Vault, Terminal, Synthwave, Ledger)
- Receipt/document attachments on transactions (JPEG, PNG, WebP, PDF)
- AES-256-GCM field encryption, RS256 JWT, TOTP 2FA, RLS, audit log
- Encrypted nightly backups + key rotation script
- Mobile-responsive layout with bottom nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-04-21 11:56:10 +00:00
commit 61a7884ee5
127 changed files with 13323 additions and 0 deletions

View file

@ -0,0 +1,36 @@
import { useUiStore } from "@/store/uiStore";
import Sidebar from "./Sidebar";
import TopBar from "./TopBar";
import MobileNav from "./MobileNav";
interface AppShellProps {
children: React.ReactNode;
}
export default function AppShell({ children }: AppShellProps) {
const sidebarOpen = useUiStore((s) => s.sidebarOpen);
return (
<div className="flex h-screen overflow-hidden bg-background">
{/* Sidebar — desktop only */}
<div className="hidden lg:block">
<Sidebar />
</div>
<div
className={`flex flex-col flex-1 min-w-0 transition-all duration-200 ${
sidebarOpen ? "lg:ml-64" : "lg:ml-16"
}`}
>
<TopBar />
{/* Extra bottom padding on mobile so content clears the nav bar */}
<main className="flex-1 overflow-y-auto p-4 md:p-6 lg:p-8 pb-24 lg:pb-8">
{children}
</main>
</div>
{/* Bottom nav — mobile only */}
<MobileNav />
</div>
);
}

View file

@ -0,0 +1,44 @@
import { Link, useLocation } from "react-router-dom";
import { cn } from "@/utils/cn";
import {
LayoutDashboard, CreditCard, ArrowLeftRight,
PiggyBank, TrendingUp, BarChart3, Sparkles, Settings,
} from "lucide-react";
const NAV = [
{ href: "/", icon: LayoutDashboard, label: "Home" },
{ href: "/accounts", icon: CreditCard, label: "Accounts" },
{ href: "/transactions",icon: ArrowLeftRight, label: "Txns" },
{ href: "/budgets", icon: PiggyBank, label: "Budgets" },
{ href: "/investments", icon: TrendingUp, label: "Invest" },
{ href: "/reports", icon: BarChart3, label: "Reports" },
{ href: "/predictions", icon: Sparkles, label: "Predict" },
{ href: "/settings", icon: Settings, label: "Settings" },
];
export default function MobileNav() {
const location = useLocation();
return (
<nav className="fixed bottom-0 left-0 right-0 z-40 lg:hidden bg-card border-t border-border">
<div className="flex overflow-x-auto scrollbar-none">
{NAV.map(({ href, icon: Icon, label }) => {
const active = location.pathname === href || (href !== "/" && location.pathname.startsWith(href));
return (
<Link
key={href}
to={href}
className={cn(
"flex flex-col items-center justify-center gap-0.5 px-3 py-2.5 min-w-[4rem] flex-1 transition-colors",
active ? "text-primary" : "text-muted-foreground hover:text-foreground"
)}
>
<Icon className="w-5 h-5 shrink-0" />
<span className="text-[10px] font-medium leading-none">{label}</span>
</Link>
);
})}
</div>
</nav>
);
}

View file

@ -0,0 +1,84 @@
import { Link, useLocation } from "react-router-dom";
import { cn } from "@/utils/cn";
import { useUiStore } from "@/store/uiStore";
import {
LayoutDashboard,
CreditCard,
ArrowLeftRight,
PiggyBank,
TrendingUp,
BarChart3,
Sparkles,
Settings,
ChevronLeft,
ChevronRight,
DollarSign,
} from "lucide-react";
const navItems = [
{ href: "/", icon: LayoutDashboard, label: "Dashboard" },
{ href: "/accounts", icon: CreditCard, label: "Accounts" },
{ href: "/transactions", icon: ArrowLeftRight, label: "Transactions" },
{ href: "/budgets", icon: PiggyBank, label: "Budgets" },
{ href: "/investments", icon: TrendingUp, label: "Investments" },
{ href: "/reports", icon: BarChart3, label: "Reports" },
{ href: "/predictions", icon: Sparkles, label: "Predictions" },
{ href: "/settings", icon: Settings, label: "Settings" },
];
export default function Sidebar() {
const location = useLocation();
const { sidebarOpen, setSidebarOpen } = useUiStore();
return (
<aside
className={cn(
"fixed left-0 top-0 h-full z-30 bg-card border-r border-border transition-all duration-200 flex flex-col",
sidebarOpen ? "w-64" : "w-16"
)}
>
{/* Logo */}
<div className="flex items-center h-16 px-4 border-b border-border shrink-0">
<DollarSign className="w-7 h-7 text-primary shrink-0" />
{sidebarOpen && (
<span className="ml-2 font-semibold text-lg truncate">Finance</span>
)}
</div>
{/* Nav */}
<nav className="flex-1 overflow-y-auto py-4 space-y-1 px-2">
{navItems.map(({ href, icon: Icon, label }) => {
const active = location.pathname === href || (href !== "/" && location.pathname.startsWith(href));
return (
<Link
key={href}
to={href}
className={cn(
"flex items-center gap-3 px-2 py-2 rounded-md text-sm font-medium transition-colors",
active
? "bg-primary/15 text-primary"
: "text-muted-foreground hover:bg-secondary hover:text-foreground"
)}
title={!sidebarOpen ? label : undefined}
>
<Icon className="w-5 h-5 shrink-0" />
{sidebarOpen && <span className="truncate">{label}</span>}
</Link>
);
})}
</nav>
{/* Toggle */}
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="flex items-center justify-center h-10 w-full border-t border-border text-muted-foreground hover:text-foreground transition-colors"
>
{sidebarOpen ? (
<ChevronLeft className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
</aside>
);
}

View file

@ -0,0 +1,157 @@
import { useState, useRef, useEffect } from "react";
import { useUiStore, type Theme } from "@/store/uiStore";
import { Palette } from "lucide-react";
import { cn } from "@/utils/cn";
const THEMES: {
id: Theme;
name: string;
description: string;
swatches: string[];
dark: boolean;
}[] = [
{
id: "obsidian",
name: "Obsidian",
description: "Deep navy · Indigo",
swatches: ["#111827", "#1e2a3b", "#6366f1"],
dark: true,
},
{
id: "arctic",
name: "Arctic",
description: "Clean white · Crisp",
swatches: ["#f8fafc", "#ffffff", "#6d28d9"],
dark: false,
},
{
id: "midnight",
name: "Midnight",
description: "True black · OLED",
swatches: ["#0a0a0a", "#111111", "#7c3aed"],
dark: true,
},
{
id: "vault",
name: "Vault",
description: "Warm dark · Gold",
swatches: ["#100c08", "#16120e", "#d97706"],
dark: true,
},
{
id: "terminal",
name: "Terminal",
description: "CRT green · Phosphor",
swatches: ["#040c04", "#071007", "#00ff41"],
dark: true,
},
{
id: "synthwave",
name: "Synthwave",
description: "80s neon · Purple",
swatches: ["#0d0221", "#130330", "#ff2d78"],
dark: true,
},
{
id: "ledger",
name: "Ledger",
description: "Aged paper · Serif",
swatches: ["#f0ebe0", "#f7f4ee", "#8b1a1a"],
dark: false,
},
];
export default function ThemePicker() {
const { theme, setTheme } = useUiStore();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const current = THEMES.find(t => t.id === theme) ?? THEMES[0];
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(o => !o)}
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors border border-border",
open ? "bg-secondary text-foreground" : "text-muted-foreground hover:text-foreground hover:bg-secondary"
)}
title="Change theme"
>
{/* Mini swatch preview */}
<span className="flex gap-0.5 items-center">
{current.swatches.map((c, i) => (
<span
key={i}
className="inline-block rounded-full"
style={{
width: i === 2 ? 8 : 6,
height: i === 2 ? 8 : 6,
backgroundColor: c,
outline: "1px solid rgba(255,255,255,0.15)",
}}
/>
))}
</span>
<Palette className="w-4 h-4" />
<span className="hidden sm:inline">{current.name}</span>
</button>
{open && (
<div className="absolute right-0 top-full mt-2 z-50 bg-card border border-border rounded-xl shadow-2xl p-3 w-72">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1 mb-2">
Choose Theme
</p>
<div className="space-y-1">
{THEMES.map(t => (
<button
key={t.id}
onClick={() => { setTheme(t.id); setOpen(false); }}
className={cn(
"w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors text-left",
theme === t.id
? "bg-primary/15 text-foreground"
: "hover:bg-secondary text-muted-foreground hover:text-foreground"
)}
>
{/* Colour preview card */}
<div
className="w-10 h-7 rounded-md flex-shrink-0 flex items-end overflow-hidden"
style={{ backgroundColor: t.swatches[0], border: "1px solid rgba(255,255,255,0.1)" }}
>
<div
className="w-full h-3"
style={{ backgroundColor: t.swatches[1] }}
/>
<div
className="absolute w-2.5 h-2.5 rounded-full m-0.5"
style={{ backgroundColor: t.swatches[2], boxShadow: `0 0 4px ${t.swatches[2]}` }}
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold leading-none">{t.name}</p>
<p className="text-xs text-muted-foreground mt-0.5">{t.description}</p>
</div>
{theme === t.id && (
<span className="w-2 h-2 rounded-full bg-primary shrink-0" />
)}
</button>
))}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,38 @@
import { useNavigate } from "react-router-dom";
import { useAuthStore } from "@/store/authStore";
import { logout } from "@/api/auth";
import { LogOut, User } from "lucide-react";
import ThemePicker from "./ThemePicker";
export default function TopBar() {
const { displayName, clearAuth } = useAuthStore();
const navigate = useNavigate();
async function handleLogout() {
try {
await logout();
} finally {
clearAuth();
navigate("/login");
}
}
return (
<header className="h-16 border-b border-border bg-card flex items-center justify-end px-4 md:px-6 gap-3 shrink-0">
<ThemePicker />
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary text-sm border border-border">
<User className="w-4 h-4 text-muted-foreground" />
<span className="text-foreground font-medium">{displayName ?? "User"}</span>
</div>
<button
onClick={handleLogout}
className="p-2 rounded-lg text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors border border-transparent hover:border-destructive/20"
title="Logout"
>
<LogOut className="w-4 h-4" />
</button>
</header>
);
}