stashing till I merge anthony's admin tour

This commit is contained in:
EthanHealy01
2025-11-10 13:26:57 +00:00
parent ac3e10eb99
commit 4284434919
17 changed files with 1216 additions and 640 deletions

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 462 KiB

View File

@@ -0,0 +1,298 @@
import React from 'react';
import { Modal, Button, Group, Stack, ActionIcon } from '@mantine/core';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
import OnboardingStepper from '@app/components/onboarding/OnboardingStepper';
import { useOs } from '@app/hooks/useOs';
import { useAppConfig } from '@app/contexts/AppConfigContext';
interface InitialOnboardingModalProps {
opened: boolean;
onClose: () => void;
}
export default function InitialOnboardingModal({ opened, onClose }: InitialOnboardingModalProps) {
const { preferences, updatePreference } = usePreferences();
const { startTour, setShowWelcomeModal, setStartAfterToolModeSelection } = useOnboarding();
const [step, setStep] = React.useState(0);
const totalSteps = 3;
const { config } = useAppConfig();
// NOTE: For testing, this is forced to true. Revert to config?.isAdmin for production.
const isAdmin = true; // TODO: revert to !!config?.isAdmin
React.useEffect(() => {
if (!opened) setStep(0);
}, [opened]);
const osType = useOs();
const os = React.useMemo(() => {
switch (osType) {
case 'windows':
return { label: 'Windows', url: 'https://files.stirlingpdf.com/win-installer.exe' };
case 'mac-apple':
return { label: 'Mac', url: 'https://files.stirlingpdf.com/mac-installer.dmg' };
case 'mac-intel':
return { label: 'Mac (Intel)', url: 'https://files.stirlingpdf.com/mac-x86_64-installer.dmg' };
case 'linux-x64':
case 'linux-arm64':
return { label: 'Linux', url: 'https://docs.stirlingpdf.com/Installation/Unix%20Installation/' };
// For mobile/unknown, hide OS label and skip opening a URL
default:
return { label: '', url: '' };
}
}, [osType]);
const closeAndMarkSeen = React.useCallback(() => {
if (!preferences.hasSeenIntroOnboarding) {
updatePreference('hasSeenIntroOnboarding', true);
}
onClose();
}, [onClose, preferences.hasSeenIntroOnboarding, updatePreference]);
const goNext = () => setStep((s) => Math.min(totalSteps - 1, s + 1));
const goPrev = () => setStep((s) => Math.max(0, s - 1));
const titleByStep = [
'Welcome to Stirling',
os.label ? `Download for ${os.label}` : 'Download',
isAdmin ? 'Admin Overview' : 'Plan Overview',
];
const bodyByStep: React.ReactNode[] = [
(
<span>
Stirling helps you read and edit PDFs privately. The app includes a simple <strong>Reader</strong> with basic editing tools and an advanced <strong>Editor</strong> with professional editing tools.
</span>
),
(
<span>
Stirling works best as a desktop app. You can use it offline, access documents faster, and make edits locally on your computer.
</span>
),
isAdmin ? (
<span>
As an admin, you can manage users, configure settings, and monitor server health. The first 5 people on your server get to use Stirling free of charge.
</span>
) : (
<span>
For the next <strong>30 days</strong>, youll enjoy <strong>unlimited Pro access</strong> to the Reader and the Editor. Afterwards, you can continue with the Reader for free or upgrade to keep the Editor too.
</span>
),
];
const imageByStep = [
'/branding/onboarding1.svg',
'/branding/onboarding2.svg',
'/branding/onboarding3.svg',
];
// Buttons per step
const renderButtons = () => {
if (step === 0) {
return (
<Group justify="flex-end">
<Button
onClick={goNext}
styles={{
root: {
background: 'var(--onboarding-primary-button-bg)',
color: 'var(--onboarding-primary-button-text)',
},
}}
>
Next
</Button>
</Group>
);
}
if (step === 1) {
return (
<Group justify="space-between">
<Group gap={12}>
<ActionIcon
onClick={goPrev}
radius="md"
size={40}
styles={{
root: {
background: 'var(--onboarding-secondary-button-bg)',
border: '1px solid var(--onboarding-secondary-button-border)',
color: 'var(--onboarding-secondary-button-text)',
},
}}
>
<ChevronLeftIcon fontSize="small" />
</ActionIcon>
<Button
variant="default"
onClick={goNext}
styles={{
root: {
background: 'var(--onboarding-secondary-button-bg)',
border: '1px solid var(--onboarding-secondary-button-border)',
color: 'var(--onboarding-secondary-button-text)',
},
}}
>
Skip for now
</Button>
</Group>
<Button
onClick={() => {
if (os.url) {
window.open(os.url, '_blank', 'noopener');
}
goNext();
}}
styles={{
root: {
background: 'var(--onboarding-primary-button-bg)',
color: 'var(--onboarding-primary-button-text)',
},
}}
>
Download
</Button>
</Group>
);
}
return (
<Group justify="space-between">
<Group gap={12}>
<ActionIcon
onClick={goPrev}
radius="md"
size={40}
styles={{
root: {
background: 'var(--onboarding-secondary-button-bg)',
border: '1px solid var(--onboarding-secondary-button-border)',
color: 'var(--onboarding-secondary-button-text)',
},
}}
>
<ChevronLeftIcon fontSize="small" />
</ActionIcon>
<Button
variant="default"
onClick={() => {
updatePreference('hasCompletedOnboarding', true);
closeAndMarkSeen();
}}
styles={{
root: {
background: 'var(--onboarding-secondary-button-bg)',
border: '1px solid var(--onboarding-secondary-button-border)',
color: 'var(--onboarding-secondary-button-text)',
},
}}
>
Skip the tour
</Button>
</Group>
<Button
onClick={() => {
// Close slides first
closeAndMarkSeen();
// Ensure the legacy welcome modal state is controlled by our gating
setShowWelcomeModal(false);
// If the user still needs to choose a tool mode, mark to start the tour after they do
if (!preferences.toolPanelModePromptSeen) {
setStartAfterToolModeSelection(true);
return; // The prompt will show next; tour will be started from there
}
// Otherwise, start immediately (if not completed previously)
if (!preferences.hasCompletedOnboarding) {
if (isAdmin) {
// TODO: Change to admin tour once available
startTour();
} else {
startTour();
}
}
}}
styles={{
root: {
background: 'var(--onboarding-primary-button-bg)',
color: 'var(--onboarding-primary-button-text)',
},
}}
>
Show me around
</Button>
</Group>
);
};
return (
<Modal
opened={opened}
onClose={closeAndMarkSeen}
centered
size="lg"
radius="lg"
withCloseButton={false}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
styles={{
body: { padding: 0 },
content: { overflow: 'hidden', border: 'none', background: 'var(--bg-surface)' },
}}
>
<Stack gap={0} style={{ background: 'var(--bg-surface)' }}>
<div style={{ width: '100%', height: 220, overflow: 'hidden' }}>
<img
src={imageByStep[step]}
alt={titleByStep[step]}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
</div>
<div style={{ padding: 24 }}>
<Stack gap={16}>
<div
style={{
fontFamily: 'Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
fontWeight: 600,
fontSize: 22,
color: 'var(--onboarding-title)',
}}
>
{titleByStep[step]}
</div>
<div
style={{
fontFamily: 'Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
fontSize: 16,
color: 'var(--onboarding-body)',
lineHeight: 1.5,
}}
>
{/* strong tags should match the title color */}
<div style={{ color: 'inherit' }}>
{bodyByStep[step]}
</div>
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
</div>
<OnboardingStepper totalSteps={totalSteps} activeStep={step} />
<div style={{ marginTop: 8 }}>
{renderButtons()}
</div>
</Stack>
</div>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,52 @@
import React from 'react';
interface OnboardingStepperProps {
totalSteps: number;
activeStep: number; // 0-indexed
className?: string;
}
/**
* Renders a progress indicator where the active step is a pill and others are dots.
* Colors come from theme.css variables.
*/
export function OnboardingStepper({ totalSteps, activeStep, className }: OnboardingStepperProps) {
const items = Array.from({ length: totalSteps }, (_, index) => index);
return (
<div
className={className}
style={{
display: 'flex',
gap: 8,
alignItems: 'center',
justifyContent: 'center',
}}
>
{items.map((index) => {
const isActive = index === activeStep;
const baseStyles: React.CSSProperties = {
background: isActive
? 'var(--onboarding-step-active)'
: 'var(--onboarding-step-inactive)',
};
return (
<div
key={index}
style={{
...baseStyles,
width: isActive ? 44 : 8,
height: 8,
borderRadius: 9999,
}}
/>
);
})}
</div>
);
}
export default OnboardingStepper;

View File

@@ -8,6 +8,10 @@ import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import CheckIcon from '@mui/icons-material/Check';
import TourWelcomeModal from '@app/components/onboarding/TourWelcomeModal';
import InitialOnboardingModal from '@app/components/onboarding/InitialOnboardingModal';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useAuth } from '@app/auth/UseSession';
import '@app/components/onboarding/OnboardingTour.css';
// Enum case order defines order steps will appear
@@ -54,6 +58,17 @@ function TourContent() {
export default function OnboardingTour() {
const { t } = useTranslation();
const { preferences, updatePreference } = usePreferences();
const { config } = useAppConfig();
// useAuth is provided in proprietary/desktop builds; in environments without Auth, this will be no-op
let session: any = null;
try {
// eslint-disable-next-line react-hooks/rules-of-hooks
session = useAuth()?.session ?? null;
} catch {
// Auth provider not available in this build; treat as always authenticated
session = {} as any;
}
const { completeTour, showWelcomeModal, setShowWelcomeModal, startTour } = useOnboarding();
const { openFilesModal, closeFilesModal } = useFilesModalContext();
const {
@@ -226,8 +241,20 @@ export default function OnboardingTour() {
completeTour();
};
const loginEnabled = !!config?.enableLogin;
const isAuthenticated = !!session;
const shouldShowIntro = !preferences.hasSeenIntroOnboarding && (!loginEnabled || isAuthenticated);
return (
<>
<InitialOnboardingModal
opened={shouldShowIntro}
onClose={() => {
if (!preferences.hasSeenIntroOnboarding) {
updatePreference('hasSeenIntroOnboarding', true);
}
}}
/>
<TourWelcomeModal
opened={showWelcomeModal}
onStartTour={() => {

View File

@@ -65,7 +65,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
}), []);
// Get isAdmin and runningEE from app config
const isAdmin = config?.isAdmin ?? false;
const isAdmin = true; //config?.isAdmin ?? false;
const runningEE = config?.runningEE ?? false;
console.log('[AppConfigModal] Config:', { isAdmin, runningEE, fullConfig: config });

View File

@@ -3,16 +3,21 @@ import { Badge, Button, Card, Group, Modal, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import '@app/components/tools/ToolPanelModePrompt.css';
import type { ToolPanelMode } from '@app/constants/toolPanel';
import { useAppConfig } from '@app/contexts/AppConfigContext';
const ToolPanelModePrompt = () => {
const { t } = useTranslation();
const { toolPanelMode, setToolPanelMode } = useToolWorkflow();
const { preferences, updatePreference } = usePreferences();
const { startTour, startAfterToolModeSelection, setStartAfterToolModeSelection, setShowWelcomeModal } = useOnboarding();
const [opened, setOpened] = useState(false);
const isAdmin = true; // TODO: revert to !!config?.isAdmin
const shouldShowPrompt = !preferences.toolPanelModePromptSeen;
// Only show after the new 3-slide onboarding has been completed
const shouldShowPrompt = !preferences.toolPanelModePromptSeen && preferences.hasSeenIntroOnboarding;
useEffect(() => {
if (shouldShowPrompt) {
@@ -25,6 +30,18 @@ const ToolPanelModePrompt = () => {
updatePreference('defaultToolPanelMode', mode);
updatePreference('toolPanelModePromptSeen', true);
setOpened(false);
// If the user requested the tour after completing this prompt, start it now
if (startAfterToolModeSelection && !preferences.hasCompletedOnboarding) {
setShowWelcomeModal(false);
setStartAfterToolModeSelection(false);
if (isAdmin) {
// TODO: Change to admin tour once available
startTour();
} else {
startTour();
}
}
};
const handleDismiss = () => {

View File

@@ -12,6 +12,8 @@ interface OnboardingContextValue {
resetTour: () => void;
showWelcomeModal: boolean;
setShowWelcomeModal: (show: boolean) => void;
startAfterToolModeSelection: boolean;
setStartAfterToolModeSelection: (value: boolean) => void;
}
const OnboardingContext = createContext<OnboardingContextValue | undefined>(undefined);
@@ -21,6 +23,7 @@ export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ ch
const [isOpen, setIsOpen] = useState(false);
const [currentStep, setCurrentStep] = useState(0);
const [showWelcomeModal, setShowWelcomeModal] = useState(false);
const [startAfterToolModeSelection, setStartAfterToolModeSelection] = useState(false);
const shouldShow = useShouldShowWelcomeModal();
// Auto-show welcome modal for first-time users
@@ -62,6 +65,8 @@ export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ ch
resetTour,
showWelcomeModal,
setShowWelcomeModal,
startAfterToolModeSelection,
setStartAfterToolModeSelection,
}}
>
{children}

View File

@@ -0,0 +1,90 @@
import { useEffect, useState } from 'react';
export type OS =
| 'windows'
| 'mac-intel'
| 'mac-apple'
| 'linux-x64'
| 'linux-arm64'
| 'ios'
| 'android'
| 'unknown';
function parseUA(ua: string): OS {
const uaLower = ua.toLowerCase();
// iOS (includes iPadOS masquerading as Mac in some cases)
const isIOS = /iphone|ipad|ipod/.test(uaLower) || (ua.includes('Macintosh') && typeof window !== 'undefined' && 'ontouchstart' in window);
if (isIOS) return 'ios';
if (/android/.test(uaLower)) return 'android';
if (/windows nt/.test(uaLower)) return 'windows';
if (/mac os x/.test(uaLower)) {
// Default to Intel; refine via hints below
let detected: OS = 'mac-intel';
// Safari on Apple Silicon sometimes exposes both tokens
if (ua.includes('Apple') && ua.includes('ARM')) {
detected = 'mac-apple';
}
return detected; // will be further refined via Client Hints if available
}
if (/linux|x11/.test(uaLower)) return 'linux-x64';
return 'unknown';
}
export function useOs(): OS {
const [os, setOs] = useState<OS>('unknown');
useEffect(() => {
let cancelled = false;
async function detect() {
// Start with UA fallback
let detected: OS = parseUA(navigator.userAgent);
// Try Client Hints for better platform + architecture
// @ts-ignore
const uaData = (navigator as any).userAgentData;
if (uaData?.getHighEntropyValues) {
try {
const { platform, architecture, bitness } = await uaData.getHighEntropyValues([
'platform',
'architecture',
'bitness',
'platformVersion',
]);
const plat = (platform || '').toLowerCase();
if (plat.includes('windows')) detected = 'windows';
else if (plat.includes('ios')) detected = 'ios';
else if (plat.includes('android')) detected = 'android';
else if (plat.includes('mac')) {
// CH “architecture” is often "arm" on Apple Silicon
detected = architecture?.toLowerCase().includes('arm') ? 'mac-apple' : 'mac-intel';
} else if (plat.includes('linux') || plat.includes('chrome os')) {
const archLower = (architecture || '').toLowerCase();
const isArm = archLower.includes('arm') || (bitness === '32' && /aarch|arm/.test(architecture || ''));
detected = isArm ? 'linux-arm64' : 'linux-x64';
}
} catch {
// ignore
}
} else {
// Heuristic Apple Silicon from UA when no Client Hints (Safari): uncertain, prefer not to guess
// Keep detected as-is (often 'mac-intel').
}
if (!cancelled) setOs(detected);
}
detect();
return () => {
cancelled = true;
};
}, []);
return os;
}

View File

@@ -5,7 +5,8 @@ export function useShouldShowWelcomeModal(): boolean {
const { preferences } = usePreferences();
const isMobile = useMediaQuery("(max-width: 1024px)");
return !preferences.hasCompletedOnboarding
return preferences.hasSeenIntroOnboarding
&& !preferences.hasCompletedOnboarding
&& preferences.toolPanelModePromptSeen
&& !isMobile;
}

View File

@@ -9,6 +9,7 @@ export interface UserPreferences {
toolPanelModePromptSeen: boolean;
showLegacyToolDescriptions: boolean;
hasCompletedOnboarding: boolean;
hasSeenIntroOnboarding: boolean;
}
export const DEFAULT_PREFERENCES: UserPreferences = {
@@ -19,6 +20,7 @@ export const DEFAULT_PREFERENCES: UserPreferences = {
toolPanelModePromptSeen: false,
showLegacyToolDescriptions: false,
hasCompletedOnboarding: false,
hasSeenIntroOnboarding: false,
};
const STORAGE_KEY = 'stirlingpdf_preferences';

View File

@@ -286,6 +286,19 @@
--pdf-light-simulated-page-text: 15 23 42;
}
/* Onboarding (light mode) */
:root {
--onboarding-title: #0A0A0A;
--onboarding-body: #4A5565;
--onboarding-primary-button-bg: #101828;
--onboarding-primary-button-text: #FFFFFF;
--onboarding-secondary-button-bg: #FFFFFF;
--onboarding-secondary-button-text: #6A7282;
--onboarding-secondary-button-border: #E5E5E5;
--onboarding-step-active: #1E2939;
--onboarding-step-inactive: #D1D5DC;
}
[data-mantine-color-scheme="dark"] {
/* Dark theme gray scale (inverted) */
--gray-50: 17 24 39;
@@ -500,6 +513,17 @@
--modal-nav-item-active-bg: rgba(10, 139, 255, 0.15);
--modal-content-bg: #2A2F36;
--modal-header-border: rgba(255, 255, 255, 0.08);
/* Onboarding (dark mode) */
--onboarding-title: #F9FAFB;
--onboarding-body: #D1D5DB;
--onboarding-primary-button-bg: #FFFFFF;
--onboarding-primary-button-text: #0B1220;
--onboarding-secondary-button-bg: transparent;
--onboarding-secondary-button-text: #E5E7EB;
--onboarding-secondary-button-border: #3A4047;
--onboarding-step-active: #E5E7EB;
--onboarding-step-inactive: #4B5563;
}
/* Dropzone drop state styling */