move the "System Check" to a new admin area
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 3m21s

This commit is contained in:
2025-11-11 12:47:38 -08:00
parent 8232ab8726
commit 5eba160a55
16 changed files with 1483 additions and 105 deletions

16
App.tsx
View File

@@ -1,4 +1,5 @@
import React, { useState, useCallback, useEffect } from 'react';
import { Routes, Route } from 'react-router-dom';
import { FlyerDisplay } from './components/FlyerDisplay';
import { ExtractedDataTable } from './components/ExtractedDataTable';
import { AnalysisPanel } from './components/AnalysisPanel';
@@ -23,8 +24,9 @@ import { withTimeout } from './utils/timeout';
import { Session } from '@supabase/supabase-js';
import { ProfileManager } from './components/ProfileManager';
import { ShoppingListComponent } from './components/ShoppingList';
import { SystemCheck } from './components/SystemCheck';
import { VoiceAssistant } from './components/VoiceAssistant';
import { AdminPage } from './pages/AdminPage';
import { AdminRoute } from './components/AdminRoute';
// Define a more descriptive type for the authentication status.
type AuthStatus = 'SIGNED_OUT' | 'ANONYMOUS' | 'AUTHENTICATED';
@@ -748,12 +750,14 @@ function App() {
toggleDarkMode={toggleDarkMode}
unitSystem={unitSystem}
toggleUnitSystem={toggleUnitSystem}
profile={profile}
authStatus={authStatus}
session={session}
onOpenProfile={() => setIsProfileManagerOpen(true)}
onOpenVoiceAssistant={() => setIsVoiceAssistantOpen(true)}
onSignOut={handleSignOut}
/>
{/* CRITICAL FIX: Only render the ProfileManager if a session AND a profile exist. This prevents crashes on initial load when profile is null. */}
{isProfileManagerOpen && session && profile && (
<ProfileManager
@@ -771,6 +775,8 @@ function App() {
onClose={() => setIsVoiceAssistantOpen(false)}
/>
)}
<Routes>
<Route path="/" element={
<main className="max-w-screen-2xl mx-auto py-4 px-2.5 sm:py-6 lg:py-8">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8 items-start">
@@ -782,7 +788,8 @@ function App() {
isProcessing={isProcessing}
/>
)}
<SystemCheck onReady={() => setIsReady(true)} />
{/* The SystemCheck component was here. It has been moved to the Admin page. */}
{/* We now set isReady to true immediately if the DB is not connected, or after a short delay if it is, to allow other components to load. */}
</div>
<div className="lg:col-span-2 flex flex-col space-y-6">
@@ -868,6 +875,11 @@ function App() {
</div>
</div>
</main>
} />
<Route element={<AdminRoute profile={profile} />}>
<Route path="/admin" element={<AdminPage />} />
</Route>
</Routes>
</div>
);
}

View File

@@ -423,3 +423,12 @@ AI Matching Robustness: The AI's ability to match items to the master_grocery_it
Formal Testing: The project lacks a formal testing suite (e.g., Vitest, Jest, React Testing Library). While the SystemCheck is great for setup, it's not a substitute for unit and integration tests to ensure code quality and prevent regressions.
Accessibility & Advanced Responsiveness: The app is functional on mobile, but it would benefit from a dedicated pass to ensure all components are fully responsive, accessible, and navigable via keyboard, adhering to WCAG standards.
By addressing these areas, we can transition Flyer Crawler from a powerful MVP into a polished, scalable, and feature-rich production application.
# when updatig the supabase schema:
PS D:\gitea\flyer-crawler.projectium.com\flyer-crawler.projectium.com> npx supabase gen types typescript --project-id azmmnxkvjryracrnmhvj --schema public > types/supabase.ts
and then restart the TS server

21
components/AdminPage.tsx Normal file
View File

@@ -0,0 +1,21 @@
import React from 'react';
import { SystemCheck } from '../components/SystemCheck';
import { Link } from 'react-router-dom';
export const AdminPage: React.FC = () => {
// This state is just for the SystemCheck component and doesn't affect the main app's readiness.
const [isReady, setIsReady] = React.useState(false);
return (
<div className="max-w-screen-md mx-auto py-8 px-4">
<div className="mb-8">
<Link to="/" className="text-brand-primary hover:underline">&larr; Back to Main App</Link>
<h1 className="text-3xl font-bold text-gray-800 dark:text-white mt-2">Admin Dashboard</h1>
<p className="text-gray-500 dark:text-gray-400">Tools and system health checks.</p>
</div>
<div className="space-y-8">
<SystemCheck onReady={() => setIsReady(true)} />
</div>
</div>
);
};

18
components/AdminRoute.tsx Normal file
View File

@@ -0,0 +1,18 @@
import React from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import type { Profile } from '../types';
interface AdminRouteProps {
profile: Profile | null;
}
export const AdminRoute: React.FC<AdminRouteProps> = ({ profile }) => {
// An admin is identified by the 'admin' role in their profile.
const isAdmin = profile?.role === 'admin';
if (!isAdmin) {
return <Navigate to="/" replace />;
}
return <Outlet />;
};

View File

@@ -7,11 +7,13 @@ import { supabase } from '../services/supabaseClient';
import { AuthModal } from './AuthModal';
import { SignUpModal } from './SignUpModal';
import { UserIcon } from './icons/UserIcon';
import { CogIcon } from './icons/CogIcon';
import { Cog8ToothIcon } from './icons/Cog8ToothIcon';
import { MicrophoneIcon } from './icons/MicrophoneIcon';
import { Link } from 'react-router-dom';
import { ShieldCheckIcon } from './icons/ShieldCheckIcon';
import { Profile } from '../types';
type AuthStatus = 'SIGNED_OUT' | 'ANONYMOUS' | 'AUTHENTICATED';
interface HeaderProps {
isDarkMode: boolean;
toggleDarkMode: () => void;
@@ -19,12 +21,13 @@ interface HeaderProps {
toggleUnitSystem: () => void;
session: Session | null;
authStatus: AuthStatus;
profile: Profile | null;
onOpenProfile: () => void;
onOpenVoiceAssistant: () => void;
onSignOut: () => void;
}
export const Header: React.FC<HeaderProps> = ({ isDarkMode, toggleDarkMode, unitSystem, toggleUnitSystem, session, authStatus, onOpenProfile, onOpenVoiceAssistant, onSignOut }) => {
export const Header: React.FC<HeaderProps> = ({ isDarkMode, toggleDarkMode, unitSystem, toggleUnitSystem, session, authStatus, profile, onOpenProfile, onOpenVoiceAssistant, onSignOut }) => {
const [isSignInModalOpen, setIsSignInModalOpen] = useState(false);
const [isSignUpModalOpen, setIsSignUpModalOpen] = useState(false);
@@ -80,7 +83,20 @@ export const Header: React.FC<HeaderProps> = ({ isDarkMode, toggleDarkMode, unit
aria-label="Open my account settings"
title="My Account"
>
<CogIcon className="w-5 h-5" />
<Cog8ToothIcon className="w-5 h-5" />
</button>
{profile?.role === 'admin' && (
<Link to="/admin" className="p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700/50 text-gray-500 dark:text-gray-400 transition-colors" title="Admin Area">
<ShieldCheckIcon className="w-5 h-5" />
</Link>
)}
<button
onClick={onOpenProfile}
className="p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700/50 text-gray-500 dark:text-gray-400 transition-colors"
aria-label="Open my account settings"
title="My Account"
>
<Cog8ToothIcon className="w-5 h-5" />
</button>
<button
onClick={onSignOut}

View File

@@ -0,0 +1,5 @@
import React from 'react';
export const Cog8ToothIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}><path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.24-.438.613-.43.992a6.759 6.759 0 0 1 0 1.905c-.008.379.137.752.43.992l1.003.827c.424.35.534.954.26 1.431l-1.296 2.247a1.125 1.125 0 0 1-1.37.49l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.127c-.331.183-.581.495-.644.87l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.063-.374-.313-.686-.645-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.075-.124l-1.217.456a1.125 1.125 0 0 1-1.37-.49l-1.296-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.759 6.759 0 0 1 0-1.905c.008-.379-.137-.752-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.431l1.296-2.247a1.125 1.125 0 0 1 1.37-.49l1.217.456c.355.133.75.072 1.076-.124.072-.044.146-.087.22-.127.332-.183.582-.495.644-.87l.213-1.281Z" /><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>
);

View File

@@ -2,6 +2,7 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { BrowserRouter } from 'react-router-dom';
const rootElement = document.getElementById('root');
if (!rootElement) {
@@ -11,7 +12,9 @@ if (!rootElement) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

54
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"@supabase/supabase-js": "^2.78.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.5",
"recharts": "^3.3.0",
"supabase": "^2.54.11"
},
@@ -1887,6 +1888,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3247,6 +3257,44 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.9.5",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.5.tgz",
"integrity": "sha512-JmxqrnBZ6E9hWmf02jzNn9Jm3UqyeimyiwzD69NjxGySG6lIz/1LVPsoTCwN7NBX2XjCEa1LIX5EMz1j2b6u6A==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.9.5",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.5.tgz",
"integrity": "sha512-mkEmq/K8tKN63Ae2M7Xgz3c9l9YNbY+NHH6NNeUmLA3kDkhKXRsNb/ZpxaEunvGo2/3YXdk5EJU3Hxp3ocaBPw==",
"license": "MIT",
"dependencies": {
"react-router": "7.9.5"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -3492,6 +3540,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@@ -13,6 +13,7 @@
"@supabase/supabase-js": "^2.78.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.5",
"recharts": "^3.3.0",
"supabase": "^2.54.11"
},

21
pages/AdminPage.tsx Normal file
View File

@@ -0,0 +1,21 @@
import React from 'react';
import { SystemCheck } from '../components/SystemCheck';
import { Link } from 'react-router-dom';
export const AdminPage: React.FC = () => {
// This state is just for the SystemCheck component and doesn't affect the main app's readiness.
const [isReady, setIsReady] = React.useState(false);
return (
<div className="max-w-screen-md mx-auto py-8 px-4">
<div className="mb-8">
<Link to="/" className="text-brand-primary hover:underline">&larr; Back to Main App</Link>
<h1 className="text-3xl font-bold text-gray-800 dark:text-white mt-2">Admin Dashboard</h1>
<p className="text-gray-500 dark:text-gray-400">Tools and system health checks.</p>
</div>
<div className="space-y-8">
<SystemCheck onReady={() => setIsReady(true)} />
</div>
</div>
);
};

View File

@@ -517,7 +517,7 @@ export const getUserProfile = async (userId: string): Promise<Profile | null> =>
if (!supabase) throw new Error("Supabase client not initialized");
const { data, error } = await supabase
.from('profiles')
.select('*')
.select('*, role')
.eq('id', userId)
.single();

1216
sql/2025-11-05.sql Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -117,7 +117,8 @@ CREATE TABLE IF NOT EXISTS public.profiles (
updated_at TIMESTAMPTZ,
full_name TEXT,
avatar_url TEXT,
preferences JSONB
preferences JSONB,
role TEXT CHECK (role IN ('admin', 'user'))
);
COMMENT ON TABLE public.profiles IS 'Stores public-facing user data, linked to the private auth.users table.';
@@ -1010,8 +1011,8 @@ RETURNS TRIGGER AS $$
DECLARE
new_profile_id UUID;
BEGIN
INSERT INTO public.profiles (id, full_name, avatar_url)
VALUES (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url')
INSERT INTO public.profiles (id, full_name, avatar_url, role)
VALUES (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url', 'user')
RETURNING id INTO new_profile_id;
-- Also create a default shopping list for the new user.
INSERT INTO public.shopping_lists (user_id, name)

View File

@@ -1 +1 @@
v2.54.11
v2.58.5

View File

@@ -79,6 +79,7 @@ export interface Profile {
updated_at?: string;
full_name?: string | null;
avatar_url?: string | null;
role?: 'admin' | 'user' | null;
preferences?: {
darkMode?: boolean;
unitSystem?: 'metric' | 'imperial';

Binary file not shown.