refactor(ts): convert the app shell to TSX (B7)
App.tsx (routing + QueryClient), main.tsx (entry; index.html updated to main.tsx), useAuth.tsx (typed AuthContextValue/User + MeResponse; uses the safe useContext()||defaults pattern so no `never` trap), and the layout components (Layout, Sidebar, NavPill, BrandBlock — NavItem type, LucideIcon-typed nav config, casts for the untyped overdue/simplefin API responses). TS caught a dead prop: App passed mainContentId to TrackerPage, which takes no params — removed. typecheck 0, lint 0 errors (39 warns), build green, 17/17 e2e probe. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c2097efc49
commit
609de2a946
|
|
@ -1,5 +1,4 @@
|
||||||
|
import { lazy, Suspense, useId, type ReactNode } from 'react';
|
||||||
import { lazy, Suspense, useId } from 'react';
|
|
||||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import Layout from '@/components/layout/Layout';
|
import Layout from '@/components/layout/Layout';
|
||||||
|
|
@ -61,7 +60,7 @@ const PayoffPage = lazy(() => import('@/pages/PayoffPage'));
|
||||||
const SpendingPage = lazy(() => import('@/pages/SpendingPage'));
|
const SpendingPage = lazy(() => import('@/pages/SpendingPage'));
|
||||||
const BankTransactionsPage = lazy(() => import('@/pages/BankTransactionsPage'));
|
const BankTransactionsPage = lazy(() => import('@/pages/BankTransactionsPage'));
|
||||||
|
|
||||||
function RequireAuth({ children, role }) {
|
function RequireAuth({ children, role }: { children: ReactNode; role?: string }): ReactNode {
|
||||||
const { user, singleUserMode } = useAuth();
|
const { user, singleUserMode } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
|
|
@ -96,7 +95,7 @@ function RequireAuth({ children, role }) {
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdminShell({ children }) {
|
function AdminShell({ children }: { children: ReactNode }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -220,7 +219,7 @@ export default function App() {
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<ErrorBoundary><Suspense fallback={<PageLoader />}><TrackerPage mainContentId={mainContentId} /></Suspense></ErrorBoundary>} />
|
<Route index element={<ErrorBoundary><Suspense fallback={<PageLoader />}><TrackerPage /></Suspense></ErrorBoundary>} />
|
||||||
<Route path="calendar" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><CalendarPage /></Suspense></ErrorBoundary>} />
|
<Route path="calendar" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><CalendarPage /></Suspense></ErrorBoundary>} />
|
||||||
<Route path="summary" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SummaryPage /></Suspense></ErrorBoundary>} />
|
<Route path="summary" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SummaryPage /></Suspense></ErrorBoundary>} />
|
||||||
<Route path="bills" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><BillsPage /></Suspense></ErrorBoundary>} />
|
<Route path="bills" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><BillsPage /></Suspense></ErrorBoundary>} />
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
export const BrandBlock = React.memo(function BrandBlock({ adminMode = false }) {
|
export const BrandBlock = React.memo(function BrandBlock({ adminMode = false }: { adminMode?: boolean }) {
|
||||||
const logoSrc = useMemo(() => '/img/logo.png', []);
|
const logoSrc = useMemo(() => '/img/logo.png', []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
to={adminMode ? '/admin' : '/'}
|
to={adminMode ? '/admin' : '/'}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Link, Outlet, useLocation } from 'react-router-dom';
|
import { Link, Outlet, useLocation } from 'react-router-dom';
|
||||||
import AppNavigation from './Sidebar';
|
import AppNavigation from './Sidebar';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
|
|
@ -9,7 +9,7 @@ function SimplefinBadge() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.simplefinStatus()
|
api.simplefinStatus()
|
||||||
.then(d => setEnabled(!!d.enabled))
|
.then(d => setEnabled(!!(d as { enabled?: boolean }).enabled))
|
||||||
.catch(err => console.error('[Layout] simplefinStatus check failed', err));
|
.catch(err => console.error('[Layout] simplefinStatus check failed', err));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -23,7 +23,7 @@ function SimplefinBadge() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Layout({ mainContentId }) {
|
export default function Layout({ mainContentId }: { mainContentId?: string }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1,9 +1,24 @@
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import { type LucideIcon } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
|
||||||
export const NavPill = React.memo(function NavPill({ item, onNavigate, badge, badgeNames = [] }) {
|
export interface NavItem {
|
||||||
|
icon: LucideIcon;
|
||||||
|
to: string;
|
||||||
|
end?: boolean;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavPillProps {
|
||||||
|
item: NavItem;
|
||||||
|
onNavigate?: () => void;
|
||||||
|
badge?: number;
|
||||||
|
badgeNames?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NavPill = React.memo(function NavPill({ item, onNavigate, badge = 0, badgeNames = [] }: NavPillProps) {
|
||||||
const Icon = useMemo(() => item.icon, [item.icon]);
|
const Icon = useMemo(() => item.icon, [item.icon]);
|
||||||
const to = useMemo(() => item.to, [item.to]);
|
const to = useMemo(() => item.to, [item.to]);
|
||||||
const end = useMemo(() => item.end, [item.end]);
|
const end = useMemo(() => item.end, [item.end]);
|
||||||
|
|
@ -3,7 +3,7 @@ import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Activity, BarChart3, Calculator, CalendarDays, ChevronDown, ClipboardCheck, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt,
|
Activity, BarChart3, Calculator, CalendarDays, ChevronDown, ClipboardCheck, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt,
|
||||||
Search, Settings, ShieldCheck, Tag, TrendingDown, User, X,
|
Search, Settings, ShieldCheck, Tag, TrendingDown, User, X,
|
||||||
Landmark, Repeat, ShoppingCart,
|
Landmark, Repeat, ShoppingCart, type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
@ -23,19 +23,28 @@ import {
|
||||||
import { NavPill } from './NavPill';
|
import { NavPill } from './NavPill';
|
||||||
import { BrandBlock } from './BrandBlock';
|
import { BrandBlock } from './BrandBlock';
|
||||||
|
|
||||||
const userNavItems = [
|
interface SidebarNavItem {
|
||||||
|
to: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
end?: boolean;
|
||||||
|
simplefinOnly?: boolean;
|
||||||
|
accountToolsOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userNavItems: SidebarNavItem[] = [
|
||||||
{ to: '/calendar', icon: CalendarDays, label: 'Calendar' },
|
{ to: '/calendar', icon: CalendarDays, label: 'Calendar' },
|
||||||
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminNavItems = [
|
const adminNavItems: SidebarNavItem[] = [
|
||||||
{ to: '/', icon: LayoutGrid, label: 'Tracker', end: true },
|
{ to: '/', icon: LayoutGrid, label: 'Tracker', end: true },
|
||||||
{ to: '/admin', icon: ShieldCheck, label: 'Admin Panel', end: true },
|
{ to: '/admin', icon: ShieldCheck, label: 'Admin Panel', end: true },
|
||||||
{ to: '/admin/status', icon: Activity, label: 'System Status' },
|
{ to: '/admin/status', icon: Activity, label: 'System Status' },
|
||||||
{ to: '/roadmap', icon: Map, label: 'Roadmap' },
|
{ to: '/roadmap', icon: Map, label: 'Roadmap' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const trackerItems = [
|
const trackerItems: SidebarNavItem[] = [
|
||||||
{ to: '/', icon: LayoutGrid, label: 'Overview', end: true },
|
{ to: '/', icon: LayoutGrid, label: 'Overview', end: true },
|
||||||
{ to: '/summary', icon: ClipboardList, label: 'Summary' },
|
{ to: '/summary', icon: ClipboardList, label: 'Summary' },
|
||||||
{ to: '/bills', icon: Receipt, label: 'Bills' },
|
{ to: '/bills', icon: Receipt, label: 'Bills' },
|
||||||
|
|
@ -49,7 +58,14 @@ const trackerItems = [
|
||||||
{ to: '/data', icon: Database, label: 'Data', accountToolsOnly: true },
|
{ to: '/data', icon: Database, label: 'Data', accountToolsOnly: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
function TrackerMenu({ onNavigate, badge, badgeNames = [], items = trackerItems }) {
|
interface TrackerMenuProps {
|
||||||
|
onNavigate?: () => void;
|
||||||
|
badge?: number;
|
||||||
|
badgeNames?: string[];
|
||||||
|
items?: SidebarNavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrackerMenu({ onNavigate, badge = 0, badgeNames = [], items = trackerItems }: TrackerMenuProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isTrackerActive = useMemo(() => items.some(item => (
|
const isTrackerActive = useMemo(() => items.some(item => (
|
||||||
|
|
@ -110,18 +126,18 @@ function TrackerMenu({ onNavigate, badge, badgeNames = [], items = trackerItems
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UserMenu({ adminMode = false }) {
|
function UserMenu({ adminMode = false }: { adminMode?: boolean }) {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const name = useMemo(() =>
|
const name = useMemo(() =>
|
||||||
user?.display_name || user?.displayName || user?.name || user?.username || (adminMode ? 'Admin' : 'Profile'),
|
user?.display_name || user?.displayName || user?.name || user?.username || (adminMode ? 'Admin' : 'Profile'),
|
||||||
[user, adminMode]
|
[user, adminMode]
|
||||||
);
|
);
|
||||||
const accountToolsAllowed = useMemo(() => !user?.is_default_admin, [user]);
|
const accountToolsAllowed = useMemo(() => !user?.is_default_admin, [user]);
|
||||||
const userRole = useMemo(() => user?.role, [user]);
|
const userRole = useMemo(() => user?.role, [user]);
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try { await logout(); } catch {}
|
try { await logout(); } catch { /* ignore */ }
|
||||||
navigate('/login', { replace: true });
|
navigate('/login', { replace: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -188,14 +204,15 @@ function UserMenu({ adminMode = false }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Sidebar({ adminMode = false }) {
|
export default function Sidebar({ adminMode = false }: { adminMode?: boolean; mainContentId?: string }) {
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const items = useMemo(() => adminMode ? adminNavItems : userNavItems, [adminMode]);
|
const items = useMemo(() => adminMode ? adminNavItems : userNavItems, [adminMode]);
|
||||||
const [simplefinReady, setSimplefinReady] = useState(false);
|
const [simplefinReady, setSimplefinReady] = useState(false);
|
||||||
const { data: overdueData } = useOverdueCount();
|
const { data: overdueData } = useOverdueCount();
|
||||||
const overdueCount = (!adminMode && overdueData?.count) ? overdueData.count : 0;
|
const od = overdueData as { count?: number; names?: string[] } | undefined;
|
||||||
const overdueNames = (!adminMode && overdueData?.names) ? overdueData.names : [];
|
const overdueCount = !adminMode ? (od?.count ?? 0) : 0;
|
||||||
|
const overdueNames = !adminMode ? (od?.names ?? []) : [];
|
||||||
const accountToolsAllowed = !user?.is_default_admin;
|
const accountToolsAllowed = !user?.is_default_admin;
|
||||||
const trackerMenuItems = useMemo(
|
const trackerMenuItems = useMemo(
|
||||||
() => trackerItems.filter(item => (
|
() => trackerItems.filter(item => (
|
||||||
|
|
@ -210,7 +227,8 @@ export default function Sidebar({ adminMode = false }) {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
api.simplefinStatus()
|
api.simplefinStatus()
|
||||||
.then(status => {
|
.then(status => {
|
||||||
if (!cancelled) setSimplefinReady(Boolean(status?.enabled && status?.has_connections));
|
const s = status as { enabled?: boolean; has_connections?: boolean };
|
||||||
|
if (!cancelled) setSimplefinReady(Boolean(s?.enabled && s?.has_connections));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setSimplefinReady(false);
|
if (!cancelled) setSimplefinReady(false);
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
import { createContext, useContext, useEffect, useState } from 'react';
|
|
||||||
import { api } from '@/api';
|
|
||||||
|
|
||||||
const AuthContext = createContext(null);
|
|
||||||
|
|
||||||
export function AuthProvider({ children }) {
|
|
||||||
const [user, setUser] = useState(undefined); // undefined = loading
|
|
||||||
const [singleUserMode, setSUM] = useState(false);
|
|
||||||
const [hasNewVersion, setHasNewVersion] = useState(false);
|
|
||||||
|
|
||||||
function applyMeResponse(d) {
|
|
||||||
setUser(d.user);
|
|
||||||
setSUM(d.single_user_mode || false);
|
|
||||||
setHasNewVersion(!!d.has_new_version);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.authMode().then(d => {
|
|
||||||
if (d.auth_mode === 'single') setSUM(true);
|
|
||||||
}).catch(err => console.error('[useAuth] authMode check failed', err));
|
|
||||||
|
|
||||||
api.me().then(applyMeResponse).catch(() => setUser(null));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const logout = async () => {
|
|
||||||
await api.logout();
|
|
||||||
setUser(null);
|
|
||||||
setSUM(false);
|
|
||||||
setHasNewVersion(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const refresh = () => {
|
|
||||||
api.me().then(applyMeResponse).catch(() => {
|
|
||||||
setUser(null);
|
|
||||||
setSUM(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthContext.Provider value={{
|
|
||||||
user, singleUserMode, setUser, logout, refresh,
|
|
||||||
hasNewVersion, setHasNewVersion,
|
|
||||||
}}>
|
|
||||||
{children}
|
|
||||||
</AuthContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuth() {
|
|
||||||
return useContext(AuthContext) || {
|
|
||||||
user: null, setUser: () => {}, logout: () => {}, refresh: () => {},
|
|
||||||
singleUserMode: false, hasNewVersion: false, setHasNewVersion: () => {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||||
|
import { api } from '@/api';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username?: string;
|
||||||
|
role?: string;
|
||||||
|
display_name?: string;
|
||||||
|
displayName?: string;
|
||||||
|
name?: string;
|
||||||
|
is_default_admin?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MeResponse {
|
||||||
|
user?: User | null;
|
||||||
|
single_user_mode?: boolean;
|
||||||
|
has_new_version?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
user: User | null | undefined; // undefined = loading
|
||||||
|
singleUserMode: boolean;
|
||||||
|
setUser: (user: User | null | undefined) => void;
|
||||||
|
logout: () => void | Promise<void>;
|
||||||
|
refresh: () => void;
|
||||||
|
hasNewVersion: boolean;
|
||||||
|
setHasNewVersion: (v: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null | undefined>(undefined); // undefined = loading
|
||||||
|
const [singleUserMode, setSUM] = useState(false);
|
||||||
|
const [hasNewVersion, setHasNewVersion] = useState(false);
|
||||||
|
|
||||||
|
function applyMeResponse(raw: unknown) {
|
||||||
|
const d = raw as MeResponse;
|
||||||
|
setUser(d.user);
|
||||||
|
setSUM(d.single_user_mode || false);
|
||||||
|
setHasNewVersion(!!d.has_new_version);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.authMode().then(d => {
|
||||||
|
if ((d as { auth_mode?: string }).auth_mode === 'single') setSUM(true);
|
||||||
|
}).catch(err => console.error('[useAuth] authMode check failed', err));
|
||||||
|
|
||||||
|
api.me().then(applyMeResponse).catch(() => setUser(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await api.logout();
|
||||||
|
setUser(null);
|
||||||
|
setSUM(false);
|
||||||
|
setHasNewVersion(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
api.me().then(applyMeResponse).catch(() => {
|
||||||
|
setUser(null);
|
||||||
|
setSUM(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{
|
||||||
|
user, singleUserMode, setUser, logout, refresh,
|
||||||
|
hasNewVersion, setHasNewVersion,
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
return useContext(AuthContext) || {
|
||||||
|
user: null, setUser: () => {}, logout: () => {}, refresh: () => {},
|
||||||
|
singleUserMode: false, hasNewVersion: false, setHasNewVersion: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,7 @@ import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
import 'sonner/dist/styles.css';
|
import 'sonner/dist/styles.css';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
|
@ -14,6 +14,6 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/client/main.jsx"></script>
|
<script type="module" src="/client/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue