Make banner reusable

This commit is contained in:
James Brunton 2025-11-14 09:57:16 +00:00
parent ec89b77e95
commit ae66e74687
10 changed files with 202 additions and 134 deletions

View File

@ -1,5 +1,6 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { AppProviders } from "@app/components/AppProviders"; import { AppProviders } from "@app/components/AppProviders";
import { AppLayout } from "@app/components/AppLayout";
import { LoadingFallback } from "@app/components/shared/LoadingFallback"; import { LoadingFallback } from "@app/components/shared/LoadingFallback";
import HomePage from "@app/pages/HomePage"; import HomePage from "@app/pages/HomePage";
import OnboardingTour from "@app/components/onboarding/OnboardingTour"; import OnboardingTour from "@app/components/onboarding/OnboardingTour";
@ -16,8 +17,10 @@ export default function App() {
return ( return (
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<LoadingFallback />}>
<AppProviders> <AppProviders>
<HomePage /> <AppLayout>
<OnboardingTour /> <HomePage />
<OnboardingTour />
</AppLayout>
</AppProviders> </AppProviders>
</Suspense> </Suspense>
); );

View File

@ -0,0 +1,31 @@
import { ReactNode } from 'react';
import { useBanner } from '@app/contexts/BannerContext';
interface AppLayoutProps {
children: ReactNode;
}
/**
* App layout wrapper that handles banner rendering and viewport sizing
* Automatically adjusts child components to fit remaining space after banner
*/
export function AppLayout({ children }: AppLayoutProps) {
const { banner } = useBanner();
return (
<>
<style>{`
.h-screen,
.right-rail {
height: 100% !important;
}
`}</style>
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
{banner}
<div style={{ flex: 1, minHeight: 0, height: 0 }}>
{children}
</div>
</div>
</>
);
}

View File

@ -16,6 +16,7 @@ import { OnboardingProvider } from "@app/contexts/OnboardingContext";
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext"; import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext"; import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
import { PageEditorProvider } from "@app/contexts/PageEditorContext"; import { PageEditorProvider } from "@app/contexts/PageEditorContext";
import { BannerProvider } from "@app/contexts/BannerContext";
import ErrorBoundary from "@app/components/shared/ErrorBoundary"; import ErrorBoundary from "@app/components/shared/ErrorBoundary";
import { useScarfTracking } from "@app/hooks/useScarfTracking"; import { useScarfTracking } from "@app/hooks/useScarfTracking";
import { useAppInitialization } from "@app/hooks/useAppInitialization"; import { useAppInitialization } from "@app/hooks/useAppInitialization";
@ -50,22 +51,23 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
<PreferencesProvider> <PreferencesProvider>
<RainbowThemeProvider> <RainbowThemeProvider>
<ErrorBoundary> <ErrorBoundary>
<OnboardingProvider> <BannerProvider>
<AppConfigProvider <OnboardingProvider>
retryOptions={appConfigRetryOptions} <AppConfigProvider
{...appConfigProviderProps} retryOptions={appConfigRetryOptions}
> {...appConfigProviderProps}
<ScarfTrackingInitializer /> >
<FileContextProvider enableUrlSync={true} enablePersistence={true}> <ScarfTrackingInitializer />
<AppInitializer /> <FileContextProvider enableUrlSync={true} enablePersistence={true}>
<ToolRegistryProvider> <AppInitializer />
<NavigationProvider> <ToolRegistryProvider>
<FilesModalProvider> <NavigationProvider>
<ToolWorkflowProvider> <FilesModalProvider>
<HotkeyProvider> <ToolWorkflowProvider>
<SidebarProvider> <HotkeyProvider>
<ViewerProvider> <SidebarProvider>
<PageEditorProvider> <ViewerProvider>
<PageEditorProvider>
<SignatureProvider> <SignatureProvider>
<RightRailProvider> <RightRailProvider>
<TourOrchestrationProvider> <TourOrchestrationProvider>
@ -76,16 +78,17 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
</RightRailProvider> </RightRailProvider>
</SignatureProvider> </SignatureProvider>
</PageEditorProvider> </PageEditorProvider>
</ViewerProvider> </ViewerProvider>
</SidebarProvider> </SidebarProvider>
</HotkeyProvider> </HotkeyProvider>
</ToolWorkflowProvider> </ToolWorkflowProvider>
</FilesModalProvider> </FilesModalProvider>
</NavigationProvider> </NavigationProvider>
</ToolRegistryProvider> </ToolRegistryProvider>
</FileContextProvider> </FileContextProvider>
</AppConfigProvider> </AppConfigProvider>
</OnboardingProvider> </OnboardingProvider>
</BannerProvider>
</ErrorBoundary> </ErrorBoundary>
</RainbowThemeProvider> </RainbowThemeProvider>
</PreferencesProvider> </PreferencesProvider>

View File

@ -0,0 +1,72 @@
import React from 'react';
import { Paper, Group, Text, Button, ActionIcon } from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
interface InfoBannerProps {
icon: string;
message: string;
buttonText: string;
buttonIcon?: string;
onButtonClick: () => void;
onDismiss: () => void;
loading?: boolean;
show?: boolean;
}
/**
* Generic info banner component for displaying dismissible messages at the top of the app
*/
export const InfoBanner: React.FC<InfoBannerProps> = ({
icon,
message,
buttonText,
buttonIcon = 'check-circle-rounded',
onButtonClick,
onDismiss,
loading = false,
show = true,
}) => {
if (!show) {
return null;
}
return (
<Paper
p="sm"
radius={0}
style={{
background: 'var(--mantine-color-blue-0)',
borderBottom: '1px solid var(--mantine-color-blue-2)',
position: 'relative',
}}
>
<Group gap="sm" align="center" wrap="nowrap">
<LocalIcon icon={icon} width="1.2rem" height="1.2rem" style={{ color: 'var(--mantine-color-blue-6)', flexShrink: 0 }} />
<Text fw={500} size="sm" style={{ color: 'var(--mantine-color-blue-9)' }}>
{message}
</Text>
<Button
variant="light"
color="blue"
size="xs"
onClick={onButtonClick}
loading={loading}
leftSection={<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />}
style={{ flexShrink: 0 }}
>
{buttonText}
</Button>
</Group>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={onDismiss}
aria-label="Dismiss"
style={{ position: 'absolute', top: '50%', right: '0.5rem', transform: 'translateY(-50%)' }}
>
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
</ActionIcon>
</Paper>
);
};

View File

@ -0,0 +1,26 @@
import { createContext, useContext, useState, ReactNode } from 'react';
interface BannerContextType {
banner: ReactNode;
setBanner: (banner: ReactNode) => void;
}
const BannerContext = createContext<BannerContextType | undefined>(undefined);
export function BannerProvider({ children }: { children: ReactNode }) {
const [banner, setBanner] = useState<ReactNode>(null);
return (
<BannerContext.Provider value={{ banner, setBanner }}>
{children}
</BannerContext.Provider>
);
}
export function useBanner() {
const context = useContext(BannerContext);
if (!context) {
throw new Error('useBanner must be used within BannerProvider');
}
return context;
}

View File

@ -1,53 +0,0 @@
import { Suspense } from "react";
import { Routes, Route } from "react-router-dom";
import { AppProviders } from "@app/components/AppProviders";
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
import Landing from "@app/routes/Landing";
import Login from "@app/routes/Login";
import Signup from "@app/routes/Signup";
import AuthCallback from "@app/routes/AuthCallback";
import InviteAccept from "@app/routes/InviteAccept";
import OnboardingTour from "@app/components/onboarding/OnboardingTour";
import { DefaultAppBanner } from "@app/components/shared/DefaultAppBanner";
// Import global styles
import "@app/styles/tailwind.css";
import "@app/styles/cookieconsent.css";
import "@app/styles/index.css";
import "@app/styles/auth-theme.css";
// Import file ID debugging helpers (development only)
import "@app/utils/fileIdSafety";
export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
<AppProviders>
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
<DefaultAppBanner />
<div
style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
className="desktop-app-content"
>
<style>{`
.desktop-app-content .h-screen {
height: 100% !important;
}
`}</style>
<Routes>
{/* Auth routes - no nested providers needed */}
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
<OnboardingTour />
</div>
</div>
</AppProviders>
</Suspense>
);
}

View File

@ -1,6 +1,7 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders"; import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
import { DesktopConfigSync } from '@app/components/DesktopConfigSync'; import { DesktopConfigSync } from '@app/components/DesktopConfigSync';
import { DesktopBannerInitializer } from '@app/components/DesktopBannerInitializer';
import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig'; import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig';
/** /**
@ -22,6 +23,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
}} }}
> >
<DesktopConfigSync /> <DesktopConfigSync />
<DesktopBannerInitializer />
{children} {children}
</ProprietaryAppProviders> </ProprietaryAppProviders>
); );

View File

@ -0,0 +1,13 @@
import { useEffect } from 'react';
import { useBanner } from '@app/contexts/BannerContext';
import { DefaultAppBanner } from '@app/components/shared/DefaultAppBanner';
export function DesktopBannerInitializer() {
const { setBanner } = useBanner();
useEffect(() => {
setBanner(<DefaultAppBanner />);
}, [setBanner]);
return null;
}

View File

@ -1,7 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Paper, Group, Text, Button, ActionIcon } from '@mantine/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon'; import { InfoBanner } from '@app/components/shared/InfoBanner';
import { defaultAppService } from '@app/services/defaultAppService'; import { defaultAppService } from '@app/services/defaultAppService';
import { alert } from '@app/components/toast'; import { alert } from '@app/components/toast';
@ -64,47 +63,16 @@ export const DefaultAppBanner: React.FC = () => {
localStorage.setItem(PROMPT_DISMISSED_KEY, 'true'); localStorage.setItem(PROMPT_DISMISSED_KEY, 'true');
}; };
if (promptDismissed || isDefault !== false) {
return null;
}
return ( return (
<Paper <InfoBanner
p="sm" icon="picture-as-pdf-rounded"
radius={0} message={t('defaultApp.prompt.message', 'Make Stirling PDF your default application for opening PDF files.')}
style={{ buttonText={t('defaultApp.setDefault', 'Set Default')}
background: 'var(--mantine-color-blue-0)', buttonIcon="check-circle-rounded"
borderBottom: '1px solid var(--mantine-color-blue-2)', onButtonClick={handleSetDefault}
position: 'relative', onDismiss={handleDismissPrompt}
}} loading={isLoading}
> show={!promptDismissed && isDefault === false}
<Group gap="sm" align="center" wrap="nowrap"> />
<LocalIcon icon="picture-as-pdf-rounded" width="1.2rem" height="1.2rem" style={{ color: 'var(--mantine-color-blue-6)', flexShrink: 0 }} />
<Text fw={500} size="sm" style={{ color: 'var(--mantine-color-blue-9)' }}>
{t('defaultApp.prompt.message', 'Make Stirling PDF your default application for opening PDF files.')}
</Text>
<Button
variant="light"
color="blue"
size="xs"
onClick={handleSetDefault}
loading={isLoading}
leftSection={<LocalIcon icon="check-circle-rounded" width="0.9rem" height="0.9rem" />}
style={{ flexShrink: 0 }}
>
{t('defaultApp.setDefault', 'Set Default')}
</Button>
</Group>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={handleDismissPrompt}
aria-label={t('defaultApp.dismiss', 'Dismiss')}
style={{ position: 'absolute', top: '0.5rem', right: '0.5rem' }}
>
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
</ActionIcon>
</Paper>
); );
}; };

View File

@ -1,6 +1,7 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { Routes, Route } from "react-router-dom"; import { Routes, Route } from "react-router-dom";
import { AppProviders } from "@app/components/AppProviders"; import { AppProviders } from "@app/components/AppProviders";
import { AppLayout } from "@app/components/AppLayout";
import { LoadingFallback } from "@app/components/shared/LoadingFallback"; import { LoadingFallback } from "@app/components/shared/LoadingFallback";
import Landing from "@app/routes/Landing"; import Landing from "@app/routes/Landing";
import Login from "@app/routes/Login"; import Login from "@app/routes/Login";
@ -22,17 +23,19 @@ export default function App() {
return ( return (
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<LoadingFallback />}>
<AppProviders> <AppProviders>
<Routes> <AppLayout>
{/* Auth routes - no nested providers needed */} <Routes>
<Route path="/login" element={<Login />} /> {/* Auth routes - no nested providers needed */}
<Route path="/signup" element={<Signup />} /> <Route path="/login" element={<Login />} />
<Route path="/auth/callback" element={<AuthCallback />} /> <Route path="/signup" element={<Signup />} />
<Route path="/invite/:token" element={<InviteAccept />} /> <Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
{/* Main app routes - Landing handles auth logic */} {/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} /> <Route path="/*" element={<Landing />} />
</Routes> </Routes>
<OnboardingTour /> <OnboardingTour />
</AppLayout>
</AppProviders> </AppProviders>
</Suspense> </Suspense>
); );