mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-19 02:22:11 +01:00
stashing till I merge anthony's admin tour
This commit is contained in:
1252
frontend/package-lock.json
generated
1252
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
BIN
frontend/public/branding/onboarding1.png
Normal file
BIN
frontend/public/branding/onboarding1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
22
frontend/public/branding/onboarding1.svg
Normal file
22
frontend/public/branding/onboarding1.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 462 KiB |
BIN
frontend/public/branding/onboarding2.png
Normal file
BIN
frontend/public/branding/onboarding2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
36
frontend/public/branding/onboarding2.svg
Normal file
36
frontend/public/branding/onboarding2.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 462 KiB |
BIN
frontend/public/branding/onboarding3.png
Normal file
BIN
frontend/public/branding/onboarding3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
24
frontend/public/branding/onboarding3.svg
Normal file
24
frontend/public/branding/onboarding3.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 462 KiB |
@@ -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>, you’ll 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
90
frontend/src/core/hooks/useOs.ts
Normal file
90
frontend/src/core/hooks/useOs.ts
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user