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 } from 'react';
|
||||
import { lazy, Suspense, useId, type ReactNode } from 'react';
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import Layout from '@/components/layout/Layout';
|
||||
|
|
@ -61,7 +60,7 @@ const PayoffPage = lazy(() => import('@/pages/PayoffPage'));
|
|||
const SpendingPage = lazy(() => import('@/pages/SpendingPage'));
|
||||
const BankTransactionsPage = lazy(() => import('@/pages/BankTransactionsPage'));
|
||||
|
||||
function RequireAuth({ children, role }) {
|
||||
function RequireAuth({ children, role }: { children: ReactNode; role?: string }): ReactNode {
|
||||
const { user, singleUserMode } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
|
|
@ -96,7 +95,7 @@ function RequireAuth({ children, role }) {
|
|||
return children;
|
||||
}
|
||||
|
||||
function AdminShell({ children }) {
|
||||
function AdminShell({ children }: { children: ReactNode }) {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
|
|
@ -220,7 +219,7 @@ export default function App() {
|
|||
</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="summary" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SummaryPage /></Suspense></ErrorBoundary>} />
|
||||
<Route path="bills" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><BillsPage /></Suspense></ErrorBoundary>} />
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
import React, { useMemo } from 'react';
|
||||
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', []);
|
||||
|
||||
return (
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, Outlet, useLocation } from 'react-router-dom';
|
||||
import AppNavigation from './Sidebar';
|
||||
import { api } from '@/api';
|
||||
|
|
@ -9,7 +9,7 @@ function SimplefinBadge() {
|
|||
|
||||
useEffect(() => {
|
||||
api.simplefinStatus()
|
||||
.then(d => setEnabled(!!d.enabled))
|
||||
.then(d => setEnabled(!!(d as { enabled?: boolean }).enabled))
|
||||
.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();
|
||||
|
||||
return (
|
||||
|
|
@ -1,9 +1,24 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { type LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
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 to = useMemo(() => item.to, [item.to]);
|
||||
const end = useMemo(() => item.end, [item.end]);
|
||||
|
|
@ -3,7 +3,7 @@ import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
|||
import {
|
||||
Activity, BarChart3, Calculator, CalendarDays, ChevronDown, ClipboardCheck, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt,
|
||||
Search, Settings, ShieldCheck, Tag, TrendingDown, User, X,
|
||||
Landmark, Repeat, ShoppingCart,
|
||||
Landmark, Repeat, ShoppingCart, type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
|
@ -23,19 +23,28 @@ import {
|
|||
import { NavPill } from './NavPill';
|
||||
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: '/analytics', icon: BarChart3, label: 'Analytics' },
|
||||
];
|
||||
|
||||
const adminNavItems = [
|
||||
const adminNavItems: SidebarNavItem[] = [
|
||||
{ to: '/', icon: LayoutGrid, label: 'Tracker', end: true },
|
||||
{ to: '/admin', icon: ShieldCheck, label: 'Admin Panel', end: true },
|
||||
{ to: '/admin/status', icon: Activity, label: 'System Status' },
|
||||
{ to: '/roadmap', icon: Map, label: 'Roadmap' },
|
||||
];
|
||||
|
||||
const trackerItems = [
|
||||
const trackerItems: SidebarNavItem[] = [
|
||||
{ to: '/', icon: LayoutGrid, label: 'Overview', end: true },
|
||||
{ to: '/summary', icon: ClipboardList, label: 'Summary' },
|
||||
{ to: '/bills', icon: Receipt, label: 'Bills' },
|
||||
|
|
@ -49,7 +58,14 @@ const trackerItems = [
|
|||
{ 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 navigate = useNavigate();
|
||||
const isTrackerActive = useMemo(() => items.some(item => (
|
||||
|
|
@ -110,7 +126,7 @@ function TrackerMenu({ onNavigate, badge, badgeNames = [], items = trackerItems
|
|||
);
|
||||
}
|
||||
|
||||
function UserMenu({ adminMode = false }) {
|
||||
function UserMenu({ adminMode = false }: { adminMode?: boolean }) {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const name = useMemo(() =>
|
||||
|
|
@ -121,7 +137,7 @@ function UserMenu({ adminMode = false }) {
|
|||
const userRole = useMemo(() => user?.role, [user]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try { await logout(); } catch {}
|
||||
try { await logout(); } catch { /* ignore */ }
|
||||
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 { user } = useAuth();
|
||||
const items = useMemo(() => adminMode ? adminNavItems : userNavItems, [adminMode]);
|
||||
const [simplefinReady, setSimplefinReady] = useState(false);
|
||||
const { data: overdueData } = useOverdueCount();
|
||||
const overdueCount = (!adminMode && overdueData?.count) ? overdueData.count : 0;
|
||||
const overdueNames = (!adminMode && overdueData?.names) ? overdueData.names : [];
|
||||
const od = overdueData as { count?: number; names?: string[] } | undefined;
|
||||
const overdueCount = !adminMode ? (od?.count ?? 0) : 0;
|
||||
const overdueNames = !adminMode ? (od?.names ?? []) : [];
|
||||
const accountToolsAllowed = !user?.is_default_admin;
|
||||
const trackerMenuItems = useMemo(
|
||||
() => trackerItems.filter(item => (
|
||||
|
|
@ -210,7 +227,8 @@ export default function Sidebar({ adminMode = false }) {
|
|||
let cancelled = false;
|
||||
api.simplefinStatus()
|
||||
.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(() => {
|
||||
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 './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<BrowserRouter>
|
||||
|
|
@ -14,6 +14,6 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/client/main.jsx"></script>
|
||||
<script type="module" src="/client/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Reference in New Issue