mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Prettier 2: Electric Boogaloo (#6113)
# Description of Changes When I added Prettier formatting in #6052, my aim was to use just the default settings in Prettier. Turns out, Prettier looks _really hard_ for any config files if it's not explicitly given one, which means that if a developer has some sort of Prettier config file lying around on their system, Prettier might find it and use it. Also, Prettier changes its defaults based on stuff in `.editorconfig` without any good way of disabling that behaviour explicitly in its config file. To solve both of these issues, I've introduced a `.prettierrc` file which sets Prettier's defaults explicitly, and then reformatted all our code _again_ in Prettier's actual default settings. This should achieve the aim of #6052 and remove the possibility for it breaking on different dev computers.
This commit is contained in:
@@ -2,7 +2,10 @@ import React from "react";
|
||||
import { Button, Group, ActionIcon } from "@mantine/core";
|
||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ButtonDefinition, type FlowState } from "@app/components/onboarding/onboardingFlowConfig";
|
||||
import {
|
||||
ButtonDefinition,
|
||||
type FlowState,
|
||||
} from "@app/components/onboarding/onboardingFlowConfig";
|
||||
import type { LicenseNotice } from "@app/types/types";
|
||||
import type { ButtonAction } from "@app/components/onboarding/onboardingFlowConfig";
|
||||
|
||||
@@ -16,10 +19,19 @@ interface SlideButtonsProps {
|
||||
onAction: (action: ButtonAction) => void;
|
||||
}
|
||||
|
||||
export function SlideButtons({ slideDefinition, licenseNotice, flowState, onAction }: SlideButtonsProps) {
|
||||
export function SlideButtons({
|
||||
slideDefinition,
|
||||
licenseNotice,
|
||||
flowState,
|
||||
onAction,
|
||||
}: SlideButtonsProps) {
|
||||
const { t } = useTranslation();
|
||||
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === "left");
|
||||
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === "right");
|
||||
const leftButtons = slideDefinition.buttons.filter(
|
||||
(btn) => btn.group === "left",
|
||||
);
|
||||
const rightButtons = slideDefinition.buttons.filter(
|
||||
(btn) => btn.group === "right",
|
||||
);
|
||||
|
||||
const buttonStyles = (variant: ButtonDefinition["variant"]) =>
|
||||
variant === "primary"
|
||||
@@ -76,7 +88,9 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
|
||||
},
|
||||
}}
|
||||
>
|
||||
{button.icon === "chevron-left" && <ChevronLeftIcon fontSize="small" />}
|
||||
{button.icon === "chevron-left" && (
|
||||
<ChevronLeftIcon fontSize="small" />
|
||||
)}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
@@ -85,7 +99,12 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
|
||||
const label = resolveButtonLabel(button);
|
||||
|
||||
return (
|
||||
<Button key={button.key} onClick={() => onAction(button.action)} disabled={disabled} styles={buttonStyles(variant)}>
|
||||
<Button
|
||||
key={button.key}
|
||||
onClick={() => onAction(button.action)}
|
||||
disabled={disabled}
|
||||
styles={buttonStyles(variant)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -6,11 +6,21 @@ import { isAuthRoute } from "@app/constants/routes";
|
||||
import { dispatchTourState } from "@app/constants/events";
|
||||
import { useOnboardingOrchestrator } from "@app/components/onboarding/orchestrator/useOnboardingOrchestrator";
|
||||
import { useBypassOnboarding } from "@app/components/onboarding/useBypassOnboarding";
|
||||
import OnboardingTour, { type AdvanceArgs, type CloseArgs } from "@app/components/onboarding/OnboardingTour";
|
||||
import OnboardingTour, {
|
||||
type AdvanceArgs,
|
||||
type CloseArgs,
|
||||
} from "@app/components/onboarding/OnboardingTour";
|
||||
import OnboardingModalSlide from "@app/components/onboarding/OnboardingModalSlide";
|
||||
import { useServerLicenseRequest, useTourRequest } from "@app/components/onboarding/useOnboardingEffects";
|
||||
import {
|
||||
useServerLicenseRequest,
|
||||
useTourRequest,
|
||||
} from "@app/components/onboarding/useOnboardingEffects";
|
||||
import { useOnboardingDownload } from "@app/components/onboarding/useOnboardingDownload";
|
||||
import { SLIDE_DEFINITIONS, type SlideId, type ButtonAction } from "@app/components/onboarding/onboardingFlowConfig";
|
||||
import {
|
||||
SLIDE_DEFINITIONS,
|
||||
type SlideId,
|
||||
type ButtonAction,
|
||||
} from "@app/components/onboarding/onboardingFlowConfig";
|
||||
import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt";
|
||||
import { useTourOrchestration } from "@app/contexts/TourOrchestrationContext";
|
||||
import { useAdminTourOrchestration } from "@app/contexts/AdminTourOrchestrationContext";
|
||||
@@ -36,9 +46,18 @@ export default function Onboarding() {
|
||||
const onAuthRoute = isAuthRoute(location.pathname);
|
||||
const { currentStep, isActive, isLoading, runtimeState, activeFlow } = state;
|
||||
|
||||
const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } = useOnboardingDownload();
|
||||
const { showLicenseSlide, licenseNotice: externalLicenseNotice, closeLicenseSlide } = useServerLicenseRequest();
|
||||
const { tourRequested: externalTourRequested, requestedTourType, clearTourRequest } = useTourRequest();
|
||||
const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } =
|
||||
useOnboardingDownload();
|
||||
const {
|
||||
showLicenseSlide,
|
||||
licenseNotice: externalLicenseNotice,
|
||||
closeLicenseSlide,
|
||||
} = useServerLicenseRequest();
|
||||
const {
|
||||
tourRequested: externalTourRequested,
|
||||
requestedTourType,
|
||||
clearTourRequest,
|
||||
} = useTourRequest();
|
||||
const { config, refetch: refetchConfig } = useAppConfig();
|
||||
const [analyticsError, setAnalyticsError] = useState<string | null>(null);
|
||||
const [analyticsLoading, setAnalyticsLoading] = useState(false);
|
||||
@@ -75,10 +94,20 @@ export default function Onboarding() {
|
||||
|
||||
// Check if we should show analytics modal before onboarding
|
||||
useEffect(() => {
|
||||
if (!isLoading && !analyticsModalDismissed && serverExperience.effectiveIsAdmin && config?.enableAnalytics == null) {
|
||||
if (
|
||||
!isLoading &&
|
||||
!analyticsModalDismissed &&
|
||||
serverExperience.effectiveIsAdmin &&
|
||||
config?.enableAnalytics == null
|
||||
) {
|
||||
setShowAnalyticsModal(true);
|
||||
}
|
||||
}, [isLoading, analyticsModalDismissed, serverExperience.effectiveIsAdmin, config?.enableAnalytics]);
|
||||
}, [
|
||||
isLoading,
|
||||
analyticsModalDismissed,
|
||||
serverExperience.effectiveIsAdmin,
|
||||
config?.enableAnalytics,
|
||||
]);
|
||||
|
||||
const handleAnalyticsChoice = useCallback(
|
||||
async (enableAnalytics: boolean) => {
|
||||
@@ -90,12 +119,17 @@ export default function Onboarding() {
|
||||
formData.append("enabled", enableAnalytics.toString());
|
||||
|
||||
try {
|
||||
await apiClient.post("/api/v1/settings/update-enable-analytics", formData);
|
||||
await apiClient.post(
|
||||
"/api/v1/settings/update-enable-analytics",
|
||||
formData,
|
||||
);
|
||||
await refetchConfig();
|
||||
setShowAnalyticsModal(false);
|
||||
setAnalyticsModalDismissed(true);
|
||||
} catch (error) {
|
||||
setAnalyticsError(error instanceof Error ? error.message : "Unknown error");
|
||||
setAnalyticsError(
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
);
|
||||
} finally {
|
||||
setAnalyticsLoading(false);
|
||||
}
|
||||
@@ -137,7 +171,11 @@ export default function Onboarding() {
|
||||
setIsTourOpen(true);
|
||||
break;
|
||||
case "launch-auto": {
|
||||
const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === "admin" ? "admin" : "whatsnew";
|
||||
const tourType =
|
||||
serverExperience.effectiveIsAdmin ||
|
||||
runtimeState.selectedRole === "admin"
|
||||
? "admin"
|
||||
: "whatsnew";
|
||||
actions.updateRuntimeState({ tourType });
|
||||
setIsTourOpen(true);
|
||||
break;
|
||||
@@ -170,7 +208,10 @@ export default function Onboarding() {
|
||||
],
|
||||
);
|
||||
|
||||
const isRTL = typeof document !== "undefined" ? document.documentElement.dir === "rtl" : false;
|
||||
const isRTL =
|
||||
typeof document !== "undefined"
|
||||
? document.documentElement.dir === "rtl"
|
||||
: false;
|
||||
const [isTourOpen, setIsTourOpen] = useState(false);
|
||||
|
||||
useEffect(() => dispatchTourState(isTourOpen), [isTourOpen]);
|
||||
@@ -241,7 +282,12 @@ export default function Onboarding() {
|
||||
default:
|
||||
return Object.values(userStepsConfig);
|
||||
}
|
||||
}, [adminStepsConfig, runtimeState.tourType, userStepsConfig, whatsNewStepsConfig]);
|
||||
}, [
|
||||
adminStepsConfig,
|
||||
runtimeState.tourType,
|
||||
userStepsConfig,
|
||||
whatsNewStepsConfig,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (externalTourRequested) {
|
||||
@@ -290,7 +336,12 @@ export default function Onboarding() {
|
||||
|
||||
const handleAdvanceTour = useCallback(
|
||||
(args: AdvanceArgs) => {
|
||||
const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args;
|
||||
const {
|
||||
setCurrentStep,
|
||||
currentStep: tourCurrentStep,
|
||||
steps,
|
||||
setIsOpen,
|
||||
} = args;
|
||||
if (steps && tourCurrentStep === steps.length - 1) {
|
||||
setIsOpen(false);
|
||||
finishTour();
|
||||
@@ -310,7 +361,11 @@ export default function Onboarding() {
|
||||
);
|
||||
|
||||
const currentSlideDefinition = useMemo(() => {
|
||||
if (!currentStep || currentStep.type !== "modal-slide" || !currentStep.slideId) {
|
||||
if (
|
||||
!currentStep ||
|
||||
currentStep.type !== "modal-slide" ||
|
||||
!currentStep.slideId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return SLIDE_DEFINITIONS[currentStep.slideId as SlideId];
|
||||
@@ -356,7 +411,9 @@ export default function Onboarding() {
|
||||
|
||||
const currentModalSlideIndex = useMemo(() => {
|
||||
if (!currentStep || currentStep.type !== "modal-slide") return 0;
|
||||
const modalSlides = activeFlow.filter((step) => step.type === "modal-slide");
|
||||
const modalSlides = activeFlow.filter(
|
||||
(step) => step.type === "modal-slide",
|
||||
);
|
||||
return modalSlides.findIndex((step) => step.id === currentStep.id);
|
||||
}, [activeFlow, currentStep]);
|
||||
|
||||
@@ -464,9 +521,12 @@ export default function Onboarding() {
|
||||
// Remove back button for external license notice
|
||||
const slideDefinition = {
|
||||
...baseSlideDefinition,
|
||||
buttons: baseSlideDefinition.buttons.filter((btn) => btn.key !== "license-back"),
|
||||
buttons: baseSlideDefinition.buttons.filter(
|
||||
(btn) => btn.key !== "license-back",
|
||||
),
|
||||
};
|
||||
const effectiveLicenseNotice = externalLicenseNotice || runtimeState.licenseNotice;
|
||||
const effectiveLicenseNotice =
|
||||
externalLicenseNotice || runtimeState.licenseNotice;
|
||||
const slideContent = slideDefinition.createSlide({
|
||||
osLabel: "",
|
||||
osUrl: "",
|
||||
@@ -482,7 +542,10 @@ export default function Onboarding() {
|
||||
<OnboardingModalSlide
|
||||
slideDefinition={slideDefinition}
|
||||
slideContent={slideContent}
|
||||
runtimeState={{ ...runtimeState, licenseNotice: effectiveLicenseNotice }}
|
||||
runtimeState={{
|
||||
...runtimeState,
|
||||
licenseNotice: effectiveLicenseNotice,
|
||||
}}
|
||||
modalSlideCount={1}
|
||||
currentModalSlideIndex={0}
|
||||
onSkip={closeLicenseSlide}
|
||||
@@ -524,7 +587,9 @@ export default function Onboarding() {
|
||||
// Render the current onboarding step
|
||||
switch (currentStep.type) {
|
||||
case "tool-prompt":
|
||||
return <ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />;
|
||||
return (
|
||||
<ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />
|
||||
);
|
||||
|
||||
case "modal-slide":
|
||||
if (!currentSlideDefinition || !currentSlideContent) return null;
|
||||
|
||||
@@ -10,7 +10,10 @@ import { Modal, Stack, ActionIcon } from "@mantine/core";
|
||||
import DiamondOutlinedIcon from "@mui/icons-material/DiamondOutlined";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
|
||||
import type { SlideDefinition, ButtonAction } from "@app/components/onboarding/onboardingFlowConfig";
|
||||
import type {
|
||||
SlideDefinition,
|
||||
ButtonAction,
|
||||
} from "@app/components/onboarding/onboardingFlowConfig";
|
||||
import type { OnboardingRuntimeState } from "@app/components/onboarding/orchestrator/onboardingConfig";
|
||||
import type { SlideConfig } from "@app/types/types";
|
||||
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
|
||||
@@ -47,7 +50,11 @@ export default function OnboardingModalSlide({
|
||||
return (
|
||||
<div className={styles.heroIconsContainer}>
|
||||
<div className={styles.iconWrapper}>
|
||||
<img src={`${BASE_PATH}/modern-logo/logo512.png`} alt="Stirling icon" className={styles.downloadIcon} />
|
||||
<img
|
||||
src={`${BASE_PATH}/modern-logo/logo512.png`}
|
||||
alt="Stirling icon"
|
||||
className={styles.downloadIcon}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -56,20 +63,45 @@ export default function OnboardingModalSlide({
|
||||
return (
|
||||
<div className={styles.heroLogoCircle}>
|
||||
{slideDefinition.hero.type === "rocket" && (
|
||||
<LocalIcon icon="rocket-launch" width={64} height={64} className={styles.heroIcon} />
|
||||
<LocalIcon
|
||||
icon="rocket-launch"
|
||||
width={64}
|
||||
height={64}
|
||||
className={styles.heroIcon}
|
||||
/>
|
||||
)}
|
||||
{slideDefinition.hero.type === "shield" && (
|
||||
<LocalIcon icon="verified-user-outline" width={64} height={64} className={styles.heroIcon} />
|
||||
<LocalIcon
|
||||
icon="verified-user-outline"
|
||||
width={64}
|
||||
height={64}
|
||||
className={styles.heroIcon}
|
||||
/>
|
||||
)}
|
||||
{slideDefinition.hero.type === "lock" && (
|
||||
<LocalIcon icon="lock-outline" width={64} height={64} className={styles.heroIcon} />
|
||||
<LocalIcon
|
||||
icon="lock-outline"
|
||||
width={64}
|
||||
height={64}
|
||||
className={styles.heroIcon}
|
||||
/>
|
||||
)}
|
||||
{slideDefinition.hero.type === "analytics" && (
|
||||
<LocalIcon icon="analytics" width={64} height={64} className={styles.heroIcon} />
|
||||
<LocalIcon
|
||||
icon="analytics"
|
||||
width={64}
|
||||
height={64}
|
||||
className={styles.heroIcon}
|
||||
/>
|
||||
)}
|
||||
{slideDefinition.hero.type === "diamond" && (
|
||||
<DiamondOutlinedIcon sx={{ fontSize: 64, color: "#000000" }} />
|
||||
)}
|
||||
{slideDefinition.hero.type === "diamond" && <DiamondOutlinedIcon sx={{ fontSize: 64, color: "#000000" }} />}
|
||||
{slideDefinition.hero.type === "logo" && (
|
||||
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
|
||||
<img
|
||||
src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`}
|
||||
alt="Stirling logo"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -88,7 +120,12 @@ export default function OnboardingModalSlide({
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
styles={{
|
||||
body: { padding: 0, maxHeight: "90vh", overflow: "hidden" },
|
||||
content: { overflow: "hidden", border: "none", background: "var(--bg-surface)", maxHeight: "90vh" },
|
||||
content: {
|
||||
overflow: "hidden",
|
||||
border: "none",
|
||||
background: "var(--bg-surface)",
|
||||
maxHeight: "90vh",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack gap={0} className={styles.modalContent}>
|
||||
@@ -129,20 +166,34 @@ export default function OnboardingModalSlide({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.modalBody} style={{ overflowY: "auto", maxHeight: "calc(90vh - 220px)" }}>
|
||||
<div
|
||||
className={styles.modalBody}
|
||||
style={{ overflowY: "auto", maxHeight: "calc(90vh - 220px)" }}
|
||||
>
|
||||
<Stack gap={16}>
|
||||
<div key={`title-${slideContent.key}`} className={`${styles.title} ${styles.titleText}`}>
|
||||
<div
|
||||
key={`title-${slideContent.key}`}
|
||||
className={`${styles.title} ${styles.titleText}`}
|
||||
>
|
||||
{slideContent.title}
|
||||
</div>
|
||||
|
||||
<div className={styles.bodyText}>
|
||||
<div key={`body-${slideContent.key}`} className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
|
||||
<div
|
||||
key={`body-${slideContent.key}`}
|
||||
className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}
|
||||
>
|
||||
{slideContent.body}
|
||||
</div>
|
||||
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
|
||||
</div>
|
||||
|
||||
{modalSlideCount > 1 && <OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />}
|
||||
{modalSlideCount > 1 && (
|
||||
<OnboardingStepper
|
||||
totalSteps={modalSlideCount}
|
||||
activeStep={currentModalSlideIndex}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
<SlideButtons
|
||||
|
||||
@@ -10,7 +10,11 @@ interface OnboardingStepperProps {
|
||||
* 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) {
|
||||
export function OnboardingStepper({
|
||||
totalSteps,
|
||||
activeStep,
|
||||
className,
|
||||
}: OnboardingStepperProps) {
|
||||
const items = Array.from({ length: totalSteps }, (_, index) => index);
|
||||
|
||||
return (
|
||||
@@ -26,7 +30,9 @@ export function OnboardingStepper({ totalSteps, activeStep, className }: Onboard
|
||||
{items.map((index) => {
|
||||
const isActive = index === activeStep;
|
||||
const baseStyles: React.CSSProperties = {
|
||||
background: isActive ? "var(--onboarding-step-active)" : "var(--onboarding-step-inactive)",
|
||||
background: isActive
|
||||
? "var(--onboarding-step-active)"
|
||||
: "var(--onboarding-step-inactive)",
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -57,7 +57,15 @@ interface OnboardingTourProps {
|
||||
onClose: (args: CloseArgs) => void;
|
||||
}
|
||||
|
||||
export default function OnboardingTour({ tourSteps, tourType, isRTL, t, isOpen, onAdvance, onClose }: OnboardingTourProps) {
|
||||
export default function OnboardingTour({
|
||||
tourSteps,
|
||||
tourType,
|
||||
isRTL,
|
||||
t,
|
||||
isOpen,
|
||||
onAdvance,
|
||||
onClose,
|
||||
}: OnboardingTourProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
@@ -111,15 +119,31 @@ export default function OnboardingTour({ tourSteps, tourType, isRTL, t, isOpen,
|
||||
disableInteraction={true}
|
||||
disableDotsNavigation={false}
|
||||
prevButton={() => null}
|
||||
nextButton={({ currentStep: tourCurrentStep, stepsLength, setCurrentStep, setIsOpen }) => {
|
||||
nextButton={({
|
||||
currentStep: tourCurrentStep,
|
||||
stepsLength,
|
||||
setCurrentStep,
|
||||
setIsOpen,
|
||||
}) => {
|
||||
const isLast = tourCurrentStep === stepsLength - 1;
|
||||
const ArrowIcon = isRTL ? ArrowBackIcon : ArrowForwardIcon;
|
||||
return (
|
||||
<ActionIcon
|
||||
onClick={() => onAdvance({ setCurrentStep, currentStep: tourCurrentStep, steps: tourSteps, setIsOpen })}
|
||||
onClick={() =>
|
||||
onAdvance({
|
||||
setCurrentStep,
|
||||
currentStep: tourCurrentStep,
|
||||
steps: tourSteps,
|
||||
setIsOpen,
|
||||
})
|
||||
}
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={isLast ? t("onboarding.finish", "Finish") : t("onboarding.next", "Next")}
|
||||
aria-label={
|
||||
isLast
|
||||
? t("onboarding.finish", "Finish")
|
||||
: t("onboarding.next", "Next")
|
||||
}
|
||||
>
|
||||
{isLast ? <CheckIcon /> : <ArrowIcon />}
|
||||
</ActionIcon>
|
||||
@@ -127,10 +151,17 @@ export default function OnboardingTour({ tourSteps, tourType, isRTL, t, isOpen,
|
||||
}}
|
||||
components={{
|
||||
Close: ({ onClick }) => (
|
||||
<CloseButton onClick={onClick} size="md" style={{ position: "absolute", top: "8px", right: "8px" }} />
|
||||
<CloseButton
|
||||
onClick={onClick}
|
||||
size="md"
|
||||
style={{ position: "absolute", top: "8px", right: "8px" }}
|
||||
/>
|
||||
),
|
||||
Content: ({ content }: { content: string }) => (
|
||||
<div style={{ paddingRight: "16px" }} dangerouslySetInnerHTML={{ __html: content }} />
|
||||
<div
|
||||
style={{ paddingRight: "16px" }}
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { StepType } from "@reactour/tour";
|
||||
import type { TFunction } from "i18next";
|
||||
import { addGlowToElements, removeAllGlows } from "@app/components/onboarding/tourGlow";
|
||||
import {
|
||||
addGlowToElements,
|
||||
removeAllGlows,
|
||||
} from "@app/components/onboarding/tourGlow";
|
||||
|
||||
export enum AdminTourStep {
|
||||
WELCOME,
|
||||
@@ -26,8 +29,16 @@ interface CreateAdminStepsConfigArgs {
|
||||
actions: AdminStepActions;
|
||||
}
|
||||
|
||||
export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArgs): Record<AdminTourStep, StepType> {
|
||||
const { saveAdminState, openConfigModal, navigateToSection, scrollNavToSection } = actions;
|
||||
export function createAdminStepsConfig({
|
||||
t,
|
||||
actions,
|
||||
}: CreateAdminStepsConfigArgs): Record<AdminTourStep, StepType> {
|
||||
const {
|
||||
saveAdminState,
|
||||
openConfigModal,
|
||||
navigateToSection,
|
||||
scrollNavToSection,
|
||||
} = actions;
|
||||
|
||||
return {
|
||||
[AdminTourStep.WELCOME]: {
|
||||
@@ -120,7 +131,10 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
|
||||
},
|
||||
[AdminTourStep.DATABASE_SECTION]: {
|
||||
selector: '[data-tour="admin-adminDatabase-nav"]',
|
||||
highlightedSelectors: ['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]'],
|
||||
highlightedSelectors: [
|
||||
'[data-tour="admin-adminDatabase-nav"]',
|
||||
'[data-tour="settings-content-area"]',
|
||||
],
|
||||
content: t(
|
||||
"adminOnboarding.databaseSection",
|
||||
"For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure.",
|
||||
@@ -131,13 +145,19 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
|
||||
removeAllGlows();
|
||||
navigateToSection("adminDatabase");
|
||||
setTimeout(() => {
|
||||
addGlowToElements(['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]']);
|
||||
addGlowToElements([
|
||||
'[data-tour="admin-adminDatabase-nav"]',
|
||||
'[data-tour="settings-content-area"]',
|
||||
]);
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
[AdminTourStep.CONNECTIONS_SECTION]: {
|
||||
selector: '[data-tour="admin-adminConnections-nav"]',
|
||||
highlightedSelectors: ['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]'],
|
||||
highlightedSelectors: [
|
||||
'[data-tour="admin-adminConnections-nav"]',
|
||||
'[data-tour="settings-content-area"]',
|
||||
],
|
||||
content: t(
|
||||
"adminOnboarding.connectionsSection",
|
||||
"The <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications.",
|
||||
@@ -148,7 +168,10 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
|
||||
removeAllGlows();
|
||||
navigateToSection("adminConnections");
|
||||
setTimeout(() => {
|
||||
addGlowToElements(['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]']);
|
||||
addGlowToElements([
|
||||
'[data-tour="admin-adminConnections-nav"]',
|
||||
'[data-tour="settings-content-area"]',
|
||||
]);
|
||||
}, 100);
|
||||
},
|
||||
actionAfter: async () => {
|
||||
|
||||
@@ -20,7 +20,14 @@ export type SlideId =
|
||||
| "analytics-choice"
|
||||
| "mfa-setup";
|
||||
|
||||
export type HeroType = "rocket" | "dual-icon" | "shield" | "diamond" | "logo" | "lock" | "analytics";
|
||||
export type HeroType =
|
||||
| "rocket"
|
||||
| "dual-icon"
|
||||
| "shield"
|
||||
| "diamond"
|
||||
| "logo"
|
||||
| "lock"
|
||||
| "analytics";
|
||||
|
||||
export type ButtonAction =
|
||||
| "next"
|
||||
@@ -91,7 +98,11 @@ export interface SlideDefinition {
|
||||
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
"first-login": {
|
||||
id: "first-login",
|
||||
createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) =>
|
||||
createSlide: ({
|
||||
firstLoginUsername,
|
||||
onPasswordChanged,
|
||||
usingDefaultCredentials,
|
||||
}) =>
|
||||
FirstLoginSlide({
|
||||
username: firstLoginUsername || "",
|
||||
onPasswordChanged: onPasswordChanged || (() => {}),
|
||||
@@ -148,7 +159,8 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
},
|
||||
"security-check": {
|
||||
id: "security-check",
|
||||
createSlide: ({ selectedRole, onRoleSelect }) => SecurityCheckSlide({ selectedRole, onRoleSelect }),
|
||||
createSlide: ({ selectedRole, onRoleSelect }) =>
|
||||
SecurityCheckSlide({ selectedRole, onRoleSelect }),
|
||||
hero: { type: "shield" },
|
||||
buttons: [
|
||||
{
|
||||
@@ -172,7 +184,8 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
},
|
||||
"admin-overview": {
|
||||
id: "admin-overview",
|
||||
createSlide: ({ licenseNotice, loginEnabled }) => PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }),
|
||||
createSlide: ({ licenseNotice, loginEnabled }) =>
|
||||
PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }),
|
||||
hero: { type: "diamond" },
|
||||
buttons: [
|
||||
{
|
||||
@@ -262,7 +275,8 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
},
|
||||
"analytics-choice": {
|
||||
id: "analytics-choice",
|
||||
createSlide: ({ analyticsError }) => AnalyticsChoiceSlide({ analyticsError }),
|
||||
createSlide: ({ analyticsError }) =>
|
||||
AnalyticsChoiceSlide({ analyticsError }),
|
||||
hero: { type: "analytics" },
|
||||
buttons: [
|
||||
{
|
||||
@@ -285,7 +299,8 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
},
|
||||
"mfa-setup": {
|
||||
id: "mfa-setup",
|
||||
createSlide: ({ onMfaSetupComplete = () => {} }: SlideFactoryParams) => MFASetupSlide({ onMfaSetupComplete }),
|
||||
createSlide: ({ onMfaSetupComplete = () => {} }: SlideFactoryParams) =>
|
||||
MFASetupSlide({ onMfaSetupComplete }),
|
||||
hero: { type: "lock" },
|
||||
buttons: [], // Form has its own submit button
|
||||
},
|
||||
|
||||
@@ -115,13 +115,15 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||
id: "tour-overview",
|
||||
type: "modal-slide",
|
||||
slideId: "tour-overview",
|
||||
condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== "admin" && !ctx.isDesktopApp,
|
||||
condition: (ctx) =>
|
||||
!ctx.effectiveIsAdmin && ctx.tourType !== "admin" && !ctx.isDesktopApp,
|
||||
},
|
||||
{
|
||||
id: "server-license",
|
||||
type: "modal-slide",
|
||||
slideId: "server-license",
|
||||
condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense,
|
||||
condition: (ctx) =>
|
||||
ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense,
|
||||
},
|
||||
{
|
||||
id: "mfa-setup",
|
||||
|
||||
@@ -16,7 +16,10 @@ export function markOnboardingCompleted(): void {
|
||||
try {
|
||||
localStorage.setItem(ONBOARDING_COMPLETED_KEY, "true");
|
||||
} catch (error) {
|
||||
console.error("[onboardingStorage] Error marking onboarding as completed:", error);
|
||||
console.error(
|
||||
"[onboardingStorage] Error marking onboarding as completed:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +28,10 @@ export function resetOnboardingProgress(): void {
|
||||
try {
|
||||
localStorage.removeItem(ONBOARDING_COMPLETED_KEY);
|
||||
} catch (error) {
|
||||
console.error("[onboardingStorage] Error resetting onboarding progress:", error);
|
||||
console.error(
|
||||
"[onboardingStorage] Error resetting onboarding progress:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +49,10 @@ export function markToursTooltipShown(): void {
|
||||
try {
|
||||
localStorage.setItem(TOURS_TOOLTIP_KEY, "true");
|
||||
} catch (error) {
|
||||
console.error("[onboardingStorage] Error marking tours tooltip as shown:", error);
|
||||
console.error(
|
||||
"[onboardingStorage] Error marking tours tooltip as shown:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +70,10 @@ export function migrateFromLegacyPreferences(): void {
|
||||
const prefs = JSON.parse(prefsRaw) as Record<string, unknown>;
|
||||
|
||||
// If user had completed onboarding in old system, mark new system as complete
|
||||
if (prefs.hasCompletedOnboarding === true || prefs.hasSeenIntroOnboarding === true) {
|
||||
if (
|
||||
prefs.hasCompletedOnboarding === true ||
|
||||
prefs.hasSeenIntroOnboarding === true
|
||||
) {
|
||||
markOnboardingCompleted();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,19 +31,27 @@ function hasAuthToken(): boolean {
|
||||
}
|
||||
|
||||
// Get initial runtime state from session storage (survives remounts)
|
||||
function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRuntimeState {
|
||||
function getInitialRuntimeState(
|
||||
baseState: OnboardingRuntimeState,
|
||||
): OnboardingRuntimeState {
|
||||
if (typeof window === "undefined") {
|
||||
return baseState;
|
||||
}
|
||||
|
||||
try {
|
||||
const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === "true";
|
||||
const tourRequested =
|
||||
sessionStorage.getItem(SESSION_TOUR_REQUESTED) === "true";
|
||||
const sessionTourType = sessionStorage.getItem(SESSION_TOUR_TYPE);
|
||||
const tourType =
|
||||
sessionTourType === "admin" || sessionTourType === "tools" || sessionTourType === "whatsnew"
|
||||
sessionTourType === "admin" ||
|
||||
sessionTourType === "tools" ||
|
||||
sessionTourType === "whatsnew"
|
||||
? sessionTourType
|
||||
: "whatsnew";
|
||||
const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as "admin" | "user" | null;
|
||||
const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as
|
||||
| "admin"
|
||||
| "user"
|
||||
| null;
|
||||
|
||||
return {
|
||||
...baseState,
|
||||
@@ -61,7 +69,10 @@ function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
|
||||
|
||||
try {
|
||||
if (state.tourRequested !== undefined) {
|
||||
sessionStorage.setItem(SESSION_TOUR_REQUESTED, state.tourRequested ? "true" : "false");
|
||||
sessionStorage.setItem(
|
||||
SESSION_TOUR_REQUESTED,
|
||||
state.tourRequested ? "true" : "false",
|
||||
);
|
||||
}
|
||||
if (state.tourType !== undefined) {
|
||||
sessionStorage.setItem(SESSION_TOUR_TYPE, state.tourType);
|
||||
@@ -74,7 +85,10 @@ function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[useOnboardingOrchestrator] Error persisting runtime state:", error);
|
||||
console.error(
|
||||
"[useOnboardingOrchestrator] Error persisting runtime state:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +111,10 @@ function parseMfaRequired(settings: string | null | undefined): boolean {
|
||||
const parsed = JSON.parse(settings) as { mfaRequired?: string };
|
||||
return parsed.mfaRequired?.toLowerCase() === "true";
|
||||
} catch (error) {
|
||||
console.warn("[useOnboardingOrchestrator] Failed to parse account settings JSON:", error);
|
||||
console.warn(
|
||||
"[useOnboardingOrchestrator] Failed to parse account settings JSON:",
|
||||
error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -152,14 +169,18 @@ export interface UseOnboardingOrchestratorOptions {
|
||||
defaultRuntimeState?: OnboardingRuntimeState;
|
||||
}
|
||||
|
||||
export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOptions): UseOnboardingOrchestratorResult {
|
||||
export function useOnboardingOrchestrator(
|
||||
options?: UseOnboardingOrchestratorOptions,
|
||||
): UseOnboardingOrchestratorResult {
|
||||
const defaultState = options?.defaultRuntimeState ?? DEFAULT_RUNTIME_STATE;
|
||||
const serverExperience = useServerExperience();
|
||||
const { config, loading: configLoading } = useAppConfig();
|
||||
const location = useLocation();
|
||||
const bypassOnboarding = useBypassOnboarding();
|
||||
|
||||
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() => getInitialRuntimeState(defaultState));
|
||||
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
|
||||
getInitialRuntimeState(defaultState),
|
||||
);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(-1);
|
||||
@@ -186,7 +207,8 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
|
||||
requiresLicense:
|
||||
!serverExperience.hasPaidLicense &&
|
||||
(serverExperience.overFreeTierLimit === true ||
|
||||
(serverExperience.effectiveIsAdmin && serverExperience.userCountResolved)),
|
||||
(serverExperience.effectiveIsAdmin &&
|
||||
serverExperience.userCountResolved)),
|
||||
},
|
||||
}));
|
||||
}, [
|
||||
@@ -217,7 +239,10 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
|
||||
requiresMfaSetup: parseMfaRequired(accountData.settings),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.log("[OnboardingOrchestrator] Failed to fetch account data for onboarding runtime state:", error);
|
||||
console.log(
|
||||
"[OnboardingOrchestrator] Failed to fetch account data for onboarding runtime state:",
|
||||
error,
|
||||
);
|
||||
// Account endpoint failed - user not logged in or security disabled
|
||||
}
|
||||
};
|
||||
@@ -227,17 +252,25 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
|
||||
}
|
||||
}, [config?.enableLogin, configLoading]);
|
||||
|
||||
const isOnAuthRoute = AUTH_ROUTES.some((route) => location.pathname.startsWith(route));
|
||||
const isOnAuthRoute = AUTH_ROUTES.some((route) =>
|
||||
location.pathname.startsWith(route),
|
||||
);
|
||||
const loginEnabled = config?.enableLogin === true;
|
||||
const isUnauthenticatedWithLoginEnabled = loginEnabled && !hasAuthToken();
|
||||
const shouldBlockOnboarding = bypassOnboarding || isOnAuthRoute || configLoading || isUnauthenticatedWithLoginEnabled;
|
||||
const shouldBlockOnboarding =
|
||||
bypassOnboarding ||
|
||||
isOnAuthRoute ||
|
||||
configLoading ||
|
||||
isUnauthenticatedWithLoginEnabled;
|
||||
|
||||
const conditionContext = useMemo<OnboardingConditionContext>(
|
||||
() => ({
|
||||
...serverExperience,
|
||||
...runtimeState,
|
||||
effectiveIsAdmin:
|
||||
serverExperience.effectiveIsAdmin || (!serverExperience.loginEnabled && runtimeState.selectedRole === "admin"),
|
||||
serverExperience.effectiveIsAdmin ||
|
||||
(!serverExperience.loginEnabled &&
|
||||
runtimeState.selectedRole === "admin"),
|
||||
}),
|
||||
[serverExperience, runtimeState],
|
||||
);
|
||||
@@ -248,7 +281,10 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
|
||||
|
||||
// Wait for config AND admin status before calculating initial step
|
||||
const adminStatusResolved =
|
||||
!configLoading && (config?.enableLogin === false || config?.enableLogin === undefined || config?.isAdmin !== undefined);
|
||||
!configLoading &&
|
||||
(config?.enableLogin === false ||
|
||||
config?.enableLogin === undefined ||
|
||||
config?.isAdmin !== undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (configLoading || !adminStatusResolved) return;
|
||||
@@ -276,9 +312,21 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
|
||||
|
||||
const totalSteps = activeFlow.length;
|
||||
|
||||
const isComplete = isInitialized && (totalSteps === 0 || currentStepIndex >= totalSteps || isOnboardingCompleted());
|
||||
const currentStep = currentStepIndex >= 0 && currentStepIndex < totalSteps ? activeFlow[currentStepIndex] : null;
|
||||
const isActive = !shouldBlockOnboarding && !isPaused && !isComplete && isInitialized && currentStep !== null;
|
||||
const isComplete =
|
||||
isInitialized &&
|
||||
(totalSteps === 0 ||
|
||||
currentStepIndex >= totalSteps ||
|
||||
isOnboardingCompleted());
|
||||
const currentStep =
|
||||
currentStepIndex >= 0 && currentStepIndex < totalSteps
|
||||
? activeFlow[currentStepIndex]
|
||||
: null;
|
||||
const isActive =
|
||||
!shouldBlockOnboarding &&
|
||||
!isPaused &&
|
||||
!isComplete &&
|
||||
isInitialized &&
|
||||
currentStep !== null;
|
||||
const isLoading =
|
||||
configLoading ||
|
||||
!adminStatusResolved ||
|
||||
@@ -322,10 +370,13 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
|
||||
setCurrentStepIndex(nextIndex);
|
||||
}, [currentStepIndex, totalSteps]);
|
||||
|
||||
const updateRuntimeState = useCallback((updates: Partial<OnboardingRuntimeState>) => {
|
||||
persistRuntimeState(updates);
|
||||
setRuntimeState((prev) => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
const updateRuntimeState = useCallback(
|
||||
(updates: Partial<OnboardingRuntimeState>) => {
|
||||
persistRuntimeState(updates);
|
||||
setRuntimeState((prev) => ({ ...prev, ...updates }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const refreshFlow = useCallback(() => {
|
||||
initialIndexSet.current = false;
|
||||
|
||||
@@ -11,10 +11,15 @@ interface AnalyticsChoiceSlideProps {
|
||||
analyticsError?: string | null;
|
||||
}
|
||||
|
||||
export default function AnalyticsChoiceSlide({ analyticsError }: AnalyticsChoiceSlideProps): SlideConfig {
|
||||
export default function AnalyticsChoiceSlide({
|
||||
analyticsError,
|
||||
}: AnalyticsChoiceSlideProps): SlideConfig {
|
||||
return {
|
||||
key: "analytics-choice",
|
||||
title: i18n.t("analytics.title", "Do you want to help make Stirling PDF better?"),
|
||||
title: i18n.t(
|
||||
"analytics.title",
|
||||
"Do you want to help make Stirling PDF better?",
|
||||
),
|
||||
body: (
|
||||
<div className={styles.bodyCopyInner}>
|
||||
<Trans
|
||||
@@ -33,13 +38,22 @@ export default function AnalyticsChoiceSlide({ analyticsError }: AnalyticsChoice
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => window.open("https://docs.stirlingpdf.com/analytics-telemetry/", "_blank")}
|
||||
onClick={() =>
|
||||
window.open(
|
||||
"https://docs.stirlingpdf.com/analytics-telemetry/",
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
rightSection={<OpenInNewIcon style={{ fontSize: 16 }} />}
|
||||
>
|
||||
{i18n.t("analytics.learnMore", "Learn more about our analytics")}
|
||||
</Button>
|
||||
</div>
|
||||
{analyticsError && <div style={{ color: "var(--mantine-color-red-6)", marginTop: 12 }}>{analyticsError}</div>}
|
||||
{analyticsError && (
|
||||
<div style={{ color: "var(--mantine-color-red-6)", marginTop: 12 }}>
|
||||
{analyticsError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
background: {
|
||||
|
||||
@@ -65,6 +65,10 @@
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(var(--circle-move-x, 40px), var(--circle-move-y, 24px), 0);
|
||||
transform: translate3d(
|
||||
var(--circle-move-x, 40px),
|
||||
var(--circle-move-y, 24px),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,16 @@ interface AnimatedSlideBackgroundComponentProps extends AnimatedSlideBackgroundP
|
||||
slideKey: string;
|
||||
}
|
||||
|
||||
export default function AnimatedSlideBackground({ gradientStops, circles, isActive }: AnimatedSlideBackgroundComponentProps) {
|
||||
const [prevGradient, setPrevGradient] = React.useState<[string, string] | null>(null);
|
||||
const [currentGradient, setCurrentGradient] = React.useState<[string, string]>(gradientStops);
|
||||
export default function AnimatedSlideBackground({
|
||||
gradientStops,
|
||||
circles,
|
||||
isActive,
|
||||
}: AnimatedSlideBackgroundComponentProps) {
|
||||
const [prevGradient, setPrevGradient] = React.useState<
|
||||
[string, string] | null
|
||||
>(null);
|
||||
const [currentGradient, setCurrentGradient] =
|
||||
React.useState<[string, string]>(gradientStops);
|
||||
const [isTransitioning, setIsTransitioning] = React.useState(false);
|
||||
const isFirstMount = React.useRef(true);
|
||||
|
||||
@@ -29,7 +36,10 @@ export default function AnimatedSlideBackground({ gradientStops, circles, isActi
|
||||
}
|
||||
|
||||
// Only transition if gradient actually changed
|
||||
if (currentGradient[0] !== gradientStops[0] || currentGradient[1] !== gradientStops[1]) {
|
||||
if (
|
||||
currentGradient[0] !== gradientStops[0] ||
|
||||
currentGradient[1] !== gradientStops[1]
|
||||
) {
|
||||
// Store previous gradient and start transition
|
||||
setPrevGradient(currentGradient);
|
||||
setIsTransitioning(true);
|
||||
@@ -69,10 +79,20 @@ export default function AnimatedSlideBackground({ gradientStops, circles, isActi
|
||||
style={currentGradientStyle}
|
||||
/>
|
||||
{circles.map((circle, index) => {
|
||||
const { position, size, color, opacity, blur, amplitude = 48, duration = 15, delay = 0 } = circle;
|
||||
const {
|
||||
position,
|
||||
size,
|
||||
color,
|
||||
opacity,
|
||||
blur,
|
||||
amplitude = 48,
|
||||
duration = 15,
|
||||
delay = 0,
|
||||
} = circle;
|
||||
|
||||
const moveX = position === "bottom-left" ? amplitude : -amplitude;
|
||||
const moveY = position === "bottom-left" ? -amplitude * 0.6 : amplitude * 0.6;
|
||||
const moveY =
|
||||
position === "bottom-left" ? -amplitude * 0.6 : amplitude * 0.6;
|
||||
|
||||
const circleStyle: CircleStyles = {
|
||||
width: size,
|
||||
@@ -98,7 +118,13 @@ export default function AnimatedSlideBackground({ gradientStops, circles, isActi
|
||||
circleStyle.top = `${defaultOffset + offsetY}px`;
|
||||
}
|
||||
|
||||
return <div key={`circle-${index}-${position}`} className={styles.circle} style={circleStyle} />;
|
||||
return (
|
||||
<div
|
||||
key={`circle-${index}-${position}`}
|
||||
className={styles.circle}
|
||||
style={circleStyle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,10 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SlideConfig } from "@app/types/types";
|
||||
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||
import { DesktopInstallTitle, type OSOption } from "@app/components/onboarding/slides/DesktopInstallTitle";
|
||||
import {
|
||||
DesktopInstallTitle,
|
||||
type OSOption,
|
||||
} from "@app/components/onboarding/slides/DesktopInstallTitle";
|
||||
|
||||
export type { OSOption };
|
||||
|
||||
|
||||
@@ -43,7 +43,9 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
|
||||
|
||||
const displayLabel = currentOsOption.label || osLabel;
|
||||
const title = displayLabel
|
||||
? t("onboarding.desktopInstall.titleWithOs", "Download for {{osLabel}}", { osLabel: displayLabel })
|
||||
? t("onboarding.desktopInstall.titleWithOs", "Download for {{osLabel}}", {
|
||||
osLabel: displayLabel,
|
||||
})
|
||||
: t("onboarding.desktopInstall.title", "Download");
|
||||
|
||||
// If only one option or no options, don't show dropdown
|
||||
@@ -52,7 +54,15 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "0.5rem", width: "100%" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "0.5rem",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<span style={{ whiteSpace: "nowrap" }}>{title}</span>
|
||||
<Menu position="bottom" offset={5} zIndex={10000}>
|
||||
<Menu.Target>
|
||||
@@ -80,7 +90,9 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
|
||||
backgroundColor: isSelected
|
||||
? "light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))"
|
||||
: "transparent",
|
||||
color: isSelected ? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))" : "inherit",
|
||||
color: isSelected
|
||||
? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))"
|
||||
: "inherit",
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
|
||||
@@ -16,10 +16,16 @@ interface FirstLoginSlideProps {
|
||||
|
||||
const DEFAULT_PASSWORD = "stirling";
|
||||
|
||||
function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = false }: FirstLoginSlideProps) {
|
||||
function FirstLoginForm({
|
||||
username,
|
||||
onPasswordChanged,
|
||||
usingDefaultCredentials = false,
|
||||
}: FirstLoginSlideProps) {
|
||||
const { t } = useTranslation();
|
||||
// If using default credentials, pre-fill with "stirling" - user won't see this field
|
||||
const [currentPassword, setCurrentPassword] = useState(usingDefaultCredentials ? DEFAULT_PASSWORD : "");
|
||||
const [currentPassword, setCurrentPassword] = useState(
|
||||
usingDefaultCredentials ? DEFAULT_PASSWORD : "",
|
||||
);
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -27,23 +33,39 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Validation
|
||||
if ((!usingDefaultCredentials && !currentPassword) || !newPassword || !confirmPassword) {
|
||||
if (
|
||||
(!usingDefaultCredentials && !currentPassword) ||
|
||||
!newPassword ||
|
||||
!confirmPassword
|
||||
) {
|
||||
setError(t("firstLogin.allFieldsRequired", "All fields are required"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError(t("firstLogin.passwordsDoNotMatch", "New passwords do not match"));
|
||||
setError(
|
||||
t("firstLogin.passwordsDoNotMatch", "New passwords do not match"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError(t("firstLogin.passwordTooShort", "Password must be at least 8 characters"));
|
||||
setError(
|
||||
t(
|
||||
"firstLogin.passwordTooShort",
|
||||
"Password must be at least 8 characters",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword === currentPassword) {
|
||||
setError(t("firstLogin.passwordMustBeDifferent", "New password must be different from current password"));
|
||||
setError(
|
||||
t(
|
||||
"firstLogin.passwordMustBeDifferent",
|
||||
"New password must be different from current password",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -51,11 +73,18 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
await accountService.changePasswordOnLogin(currentPassword, newPassword, confirmPassword);
|
||||
await accountService.changePasswordOnLogin(
|
||||
currentPassword,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
);
|
||||
|
||||
showToast({
|
||||
alertType: "success",
|
||||
title: t("firstLogin.passwordChangedSuccess", "Password changed successfully! Please log in again."),
|
||||
title: t(
|
||||
"firstLogin.passwordChangedSuccess",
|
||||
"Password changed successfully! Please log in again.",
|
||||
),
|
||||
});
|
||||
|
||||
// Clear form
|
||||
@@ -73,7 +102,10 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
||||
const axiosError = err as { response?: { data?: { message?: string } } };
|
||||
setError(
|
||||
axiosError.response?.data?.message ||
|
||||
t("firstLogin.passwordChangeFailed", "Failed to change password. Please check your current password."),
|
||||
t(
|
||||
"firstLogin.passwordChangeFailed",
|
||||
"Failed to change password. Please check your current password.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -85,18 +117,33 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
||||
<div className={styles.securityCard}>
|
||||
<Stack gap="md">
|
||||
<div className={styles.securityAlertRow}>
|
||||
<LocalIcon icon="info-rounded" width={20} height={20} style={{ color: "#3B82F6", flexShrink: 0 }} />
|
||||
<LocalIcon
|
||||
icon="info-rounded"
|
||||
width={20}
|
||||
height={20}
|
||||
style={{ color: "#3B82F6", flexShrink: 0 }}
|
||||
/>
|
||||
<span>
|
||||
{t("firstLogin.welcomeMessage", "For security reasons, you must change your password on your first login.")}
|
||||
{t(
|
||||
"firstLogin.welcomeMessage",
|
||||
"For security reasons, you must change your password on your first login.",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Text size="sm" fw={500}>
|
||||
{t("firstLogin.loggedInAs", "Logged in as")}: <strong>{username}</strong>
|
||||
{t("firstLogin.loggedInAs", "Logged in as")}:{" "}
|
||||
<strong>{username}</strong>
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<Alert icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />} color="red" variant="light">
|
||||
<Alert
|
||||
icon={
|
||||
<LocalIcon icon="error-rounded" width="1rem" height="1rem" />
|
||||
}
|
||||
color="red"
|
||||
variant="light"
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
@@ -105,7 +152,10 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
||||
{!usingDefaultCredentials && (
|
||||
<PasswordInput
|
||||
label={t("firstLogin.currentPassword", "Current Password")}
|
||||
placeholder={t("firstLogin.enterCurrentPassword", "Enter your current password")}
|
||||
placeholder={t(
|
||||
"firstLogin.enterCurrentPassword",
|
||||
"Enter your current password",
|
||||
)}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.currentTarget.value)}
|
||||
required
|
||||
@@ -117,7 +167,10 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
||||
|
||||
<PasswordInput
|
||||
label={t("firstLogin.newPassword", "New Password")}
|
||||
placeholder={t("firstLogin.enterNewPassword", "Enter new password (min 8 characters)")}
|
||||
placeholder={t(
|
||||
"firstLogin.enterNewPassword",
|
||||
"Enter new password (min 8 characters)",
|
||||
)}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.currentTarget.value)}
|
||||
minLength={8}
|
||||
@@ -129,7 +182,10 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
||||
|
||||
<PasswordInput
|
||||
label={t("firstLogin.confirmPassword", "Confirm New Password")}
|
||||
placeholder={t("firstLogin.reEnterNewPassword", "Re-enter new password")}
|
||||
placeholder={t(
|
||||
"firstLogin.reEnterNewPassword",
|
||||
"Re-enter new password",
|
||||
)}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
|
||||
required
|
||||
@@ -143,7 +199,12 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
||||
fullWidth
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
disabled={!newPassword || !confirmPassword || newPassword.length < 8 || confirmPassword.length < 8}
|
||||
disabled={
|
||||
!newPassword ||
|
||||
!confirmPassword ||
|
||||
newPassword.length < 8 ||
|
||||
confirmPassword.length < 8
|
||||
}
|
||||
size="md"
|
||||
mt="xs"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { Alert, Box, Button, Group, Loader, Stack, Text, TextInput } from "@mantine/core";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { SlideConfig } from "@app/types/types";
|
||||
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||
@@ -16,7 +31,9 @@ interface MFASetupSlideProps {
|
||||
}
|
||||
|
||||
function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
||||
const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(null);
|
||||
const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [mfaSetupCode, setMfaSetupCode] = useState("");
|
||||
const [mfaError, setMfaError] = useState("");
|
||||
const [mfaLoading, setMfaLoading] = useState(false);
|
||||
@@ -27,7 +44,10 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
||||
const accountLogout = useAccountLogout();
|
||||
const qrLogoSrc = `${BASE_PATH}/modern-logo/StirlingPDFLogoNoTextDark.svg`;
|
||||
|
||||
const normalizeMfaCode = useCallback((value: string) => value.replace(/\D/g, "").slice(0, 6), []);
|
||||
const normalizeMfaCode = useCallback(
|
||||
(value: string) => value.replace(/\D/g, "").slice(0, 6),
|
||||
[],
|
||||
);
|
||||
|
||||
const fetchMfaSetup = useCallback(async () => {
|
||||
try {
|
||||
@@ -38,7 +58,10 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
||||
setMfaSetupData(data);
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { error?: string } } };
|
||||
setMfaError(axiosError.response?.data?.error || "Unable to start two-factor setup. Please try again.");
|
||||
setMfaError(
|
||||
axiosError.response?.data?.error ||
|
||||
"Unable to start two-factor setup. Please try again.",
|
||||
);
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
@@ -84,7 +107,8 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { error?: string } } };
|
||||
setMfaError(
|
||||
axiosError.response?.data?.error || "Unable to enable two-factor authentication. Check the code and try again.",
|
||||
axiosError.response?.data?.error ||
|
||||
"Unable to enable two-factor authentication. Check the code and try again.",
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -137,12 +161,17 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
||||
<div className={styles.mfaCard}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Secure your account by linking an authenticator app. Scan the QR code or enter the setup key, then confirm the
|
||||
6-digit code to finish.
|
||||
Secure your account by linking an authenticator app. Scan the QR
|
||||
code or enter the setup key, then confirm the 6-digit code to
|
||||
finish.
|
||||
</Text>
|
||||
|
||||
{mfaError && (
|
||||
<Alert icon={<LocalIcon icon="error" width={16} height={16} />} color="red" variant="light">
|
||||
<Alert
|
||||
icon={<LocalIcon icon="error" width={16} height={16} />}
|
||||
color="red"
|
||||
variant="light"
|
||||
>
|
||||
{mfaError}
|
||||
</Alert>
|
||||
)}
|
||||
@@ -163,7 +192,9 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
||||
label="Authentication code"
|
||||
placeholder="123456"
|
||||
value={mfaSetupCode}
|
||||
onChange={(event) => setMfaSetupCode(normalizeMfaCode(event.currentTarget.value))}
|
||||
onChange={(event) =>
|
||||
setMfaSetupCode(normalizeMfaCode(event.currentTarget.value))
|
||||
}
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
minLength={6}
|
||||
@@ -179,7 +210,13 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
||||
>
|
||||
Regenerate QR code
|
||||
</Button>
|
||||
<Button type="submit" loading={submitting} disabled={!isReady || setupComplete || mfaSetupCode.length < 6}>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={submitting}
|
||||
disabled={
|
||||
!isReady || setupComplete || mfaSetupCode.length < 6
|
||||
}
|
||||
>
|
||||
Enable MFA
|
||||
</Button>
|
||||
<Button type="button" variant="light" onClick={onLogout}>
|
||||
@@ -200,7 +237,9 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function MFASetupSlide({ onMfaSetupComplete }: MFASetupSlideProps = {}): SlideConfig {
|
||||
export default function MFASetupSlide({
|
||||
onMfaSetupComplete,
|
||||
}: MFASetupSlideProps = {}): SlideConfig {
|
||||
return {
|
||||
key: "mfa-setup-slide",
|
||||
title: "Multi-Factor Authentication Setup",
|
||||
|
||||
@@ -22,7 +22,10 @@ const PlanOverviewTitle: React.FC<{ isAdmin: boolean }> = ({ isAdmin }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const AdminOverviewBody: React.FC<{ freeTierLimit: number; loginEnabled: boolean }> = ({ freeTierLimit, loginEnabled }) => {
|
||||
const AdminOverviewBody: React.FC<{
|
||||
freeTierLimit: number;
|
||||
loginEnabled: boolean;
|
||||
}> = ({ freeTierLimit, loginEnabled }) => {
|
||||
const adminBodyKey = loginEnabled
|
||||
? "onboarding.planOverview.adminBodyLoginEnabled"
|
||||
: "onboarding.planOverview.adminBodyLoginDisabled";
|
||||
@@ -32,7 +35,12 @@ const AdminOverviewBody: React.FC<{ freeTierLimit: number; loginEnabled: boolean
|
||||
: "Once you enable login mode, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge.";
|
||||
|
||||
return (
|
||||
<Trans i18nKey={adminBodyKey} values={{ freeTierLimit }} components={{ strong: <strong /> }} defaults={defaultValue} />
|
||||
<Trans
|
||||
i18nKey={adminBodyKey}
|
||||
values={{ freeTierLimit }}
|
||||
components={{ strong: <strong /> }}
|
||||
defaults={defaultValue}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -48,11 +56,19 @@ const UserOverviewBody: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const PlanOverviewBody: React.FC<{ isAdmin: boolean; freeTierLimit: number; loginEnabled: boolean }> = ({
|
||||
isAdmin,
|
||||
freeTierLimit,
|
||||
loginEnabled,
|
||||
}) => (isAdmin ? <AdminOverviewBody freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} /> : <UserOverviewBody />);
|
||||
const PlanOverviewBody: React.FC<{
|
||||
isAdmin: boolean;
|
||||
freeTierLimit: number;
|
||||
loginEnabled: boolean;
|
||||
}> = ({ isAdmin, freeTierLimit, loginEnabled }) =>
|
||||
isAdmin ? (
|
||||
<AdminOverviewBody
|
||||
freeTierLimit={freeTierLimit}
|
||||
loginEnabled={loginEnabled}
|
||||
/>
|
||||
) : (
|
||||
<UserOverviewBody />
|
||||
);
|
||||
|
||||
export default function PlanOverviewSlide({
|
||||
isAdmin,
|
||||
@@ -64,7 +80,13 @@ export default function PlanOverviewSlide({
|
||||
return {
|
||||
key: isAdmin ? "admin-overview" : "plan-overview",
|
||||
title: <PlanOverviewTitle isAdmin={isAdmin} />,
|
||||
body: <PlanOverviewBody isAdmin={isAdmin} freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} />,
|
||||
body: (
|
||||
<PlanOverviewBody
|
||||
isAdmin={isAdmin}
|
||||
freeTierLimit={freeTierLimit}
|
||||
loginEnabled={loginEnabled}
|
||||
/>
|
||||
),
|
||||
background: {
|
||||
gradientStops: isAdmin ? ["#4F46E5", "#0EA5E9"] : ["#F97316", "#EF4444"],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
|
||||
@@ -11,7 +11,10 @@ interface SecurityCheckSlideProps {
|
||||
onRoleSelect: (role: "admin" | "user" | null) => void;
|
||||
}
|
||||
|
||||
export default function SecurityCheckSlide({ selectedRole, onRoleSelect }: SecurityCheckSlideProps): SlideConfig {
|
||||
export default function SecurityCheckSlide({
|
||||
selectedRole,
|
||||
onRoleSelect,
|
||||
}: SecurityCheckSlideProps): SlideConfig {
|
||||
return {
|
||||
key: "security-check",
|
||||
title: "Security Check",
|
||||
@@ -19,7 +22,12 @@ export default function SecurityCheckSlide({ selectedRole, onRoleSelect }: Secur
|
||||
<div className={styles.securitySlideContent}>
|
||||
<div className={styles.securityCard}>
|
||||
<div className={styles.securityAlertRow}>
|
||||
<LocalIcon icon="error" width={20} height={20} style={{ color: "#F04438", flexShrink: 0 }} />
|
||||
<LocalIcon
|
||||
icon="error"
|
||||
width={20}
|
||||
height={20}
|
||||
style={{ color: "#F04438", flexShrink: 0 }}
|
||||
/>
|
||||
<span>
|
||||
{i18n.t(
|
||||
"onboarding.securityCheck.message",
|
||||
@@ -35,7 +43,9 @@ export default function SecurityCheckSlide({ selectedRole, onRoleSelect }: Secur
|
||||
{ value: "admin", label: "Admin" },
|
||||
{ value: "user", label: "User" },
|
||||
]}
|
||||
onChange={(value) => onRoleSelect((value as "admin" | "user") ?? null)}
|
||||
onChange={(value) =>
|
||||
onRoleSelect((value as "admin" | "user") ?? null)
|
||||
}
|
||||
comboboxProps={{ withinPortal: true, zIndex: 5000 }}
|
||||
styles={{
|
||||
input: {
|
||||
|
||||
@@ -10,11 +10,14 @@ interface ServerLicenseSlideProps {
|
||||
|
||||
const DEFAULT_FREE_TIER_LIMIT = 5;
|
||||
|
||||
export default function ServerLicenseSlide({ licenseNotice }: ServerLicenseSlideProps = {}): SlideConfig {
|
||||
export default function ServerLicenseSlide({
|
||||
licenseNotice,
|
||||
}: ServerLicenseSlideProps = {}): SlideConfig {
|
||||
const freeTierLimit = licenseNotice?.freeTierLimit ?? DEFAULT_FREE_TIER_LIMIT;
|
||||
const totalUsers = licenseNotice?.totalUsers ?? null;
|
||||
const isOverLimit = licenseNotice?.isOverLimit ?? false;
|
||||
const formattedTotalUsers = totalUsers != null ? totalUsers.toLocaleString() : null;
|
||||
const formattedTotalUsers =
|
||||
totalUsers != null ? totalUsers.toLocaleString() : null;
|
||||
const overLimitUserCopy = formattedTotalUsers ?? `more than ${freeTierLimit}`;
|
||||
const title = isOverLimit
|
||||
? i18n.t("onboarding.serverLicense.overLimitTitle", "Server License Needed")
|
||||
@@ -50,7 +53,9 @@ export default function ServerLicenseSlide({ licenseNotice }: ServerLicenseSlide
|
||||
title,
|
||||
body,
|
||||
background: {
|
||||
gradientStops: isOverLimit ? ["#F472B6", "#8B5CF6"] : ["#F97316", "#F59E0B"],
|
||||
gradientStops: isOverLimit
|
||||
? ["#F472B6", "#8B5CF6"]
|
||||
: ["#F97316", "#F59E0B"],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,6 +12,10 @@ export const addGlowToElements = (selectors: string[]) => {
|
||||
};
|
||||
|
||||
export const removeAllGlows = () => {
|
||||
document.querySelectorAll(".tour-content-glow").forEach((el) => el.classList.remove("tour-content-glow"));
|
||||
document.querySelectorAll(".tour-nav-glow").forEach((el) => el.classList.remove("tour-nav-glow"));
|
||||
document
|
||||
.querySelectorAll(".tour-content-glow")
|
||||
.forEach((el) => el.classList.remove("tour-content-glow"));
|
||||
document
|
||||
.querySelectorAll(".tour-nav-glow")
|
||||
.forEach((el) => el.classList.remove("tour-nav-glow"));
|
||||
};
|
||||
|
||||
@@ -38,7 +38,9 @@ function setStoredBypass(enabled: boolean): void {
|
||||
*/
|
||||
export function useBypassOnboarding(): boolean {
|
||||
const location = useLocation();
|
||||
const [bypassOnboarding, setBypassOnboarding] = useState<boolean>(() => readStoredBypass());
|
||||
const [bypassOnboarding, setBypassOnboarding] = useState<boolean>(() =>
|
||||
readStoredBypass(),
|
||||
);
|
||||
|
||||
const shouldBypassFromSearch = useMemo(() => {
|
||||
try {
|
||||
|
||||
@@ -36,7 +36,10 @@ export function useOnboardingDownload(): UseOnboardingDownloadResult {
|
||||
case "windows":
|
||||
return { label: "Windows", url: DOWNLOAD_URLS.WINDOWS };
|
||||
case "mac-apple":
|
||||
return { label: "Mac (Apple Silicon)", url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
|
||||
return {
|
||||
label: "Mac (Apple Silicon)",
|
||||
url: DOWNLOAD_URLS.MAC_APPLE_SILICON,
|
||||
};
|
||||
case "mac-intel":
|
||||
return { label: "Mac (Intel)", url: DOWNLOAD_URLS.MAC_INTEL };
|
||||
case "linux-x64":
|
||||
@@ -51,8 +54,16 @@ export function useOnboardingDownload(): UseOnboardingDownloadResult {
|
||||
() =>
|
||||
[
|
||||
{ label: "Windows", url: DOWNLOAD_URLS.WINDOWS, value: "windows" },
|
||||
{ label: "Mac (Apple Silicon)", url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: "mac-apple" },
|
||||
{ label: "Mac (Intel)", url: DOWNLOAD_URLS.MAC_INTEL, value: "mac-intel" },
|
||||
{
|
||||
label: "Mac (Apple Silicon)",
|
||||
url: DOWNLOAD_URLS.MAC_APPLE_SILICON,
|
||||
value: "mac-apple",
|
||||
},
|
||||
{
|
||||
label: "Mac (Intel)",
|
||||
url: DOWNLOAD_URLS.MAC_INTEL,
|
||||
value: "mac-intel",
|
||||
},
|
||||
{ label: "Linux", url: DOWNLOAD_URLS.LINUX_DOCS, value: "linux" },
|
||||
].filter((opt) => opt.url),
|
||||
[],
|
||||
|
||||
@@ -14,7 +14,9 @@ export function useServerLicenseRequest(): {
|
||||
closeLicenseSlide: () => void;
|
||||
} {
|
||||
const [showLicenseSlide, setShowLicenseSlide] = useState(false);
|
||||
const [licenseNotice, setLicenseNotice] = useState<OnboardingRuntimeState["licenseNotice"] | null>(null);
|
||||
const [licenseNotice, setLicenseNotice] = useState<
|
||||
OnboardingRuntimeState["licenseNotice"] | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
@@ -35,7 +37,11 @@ export function useServerLicenseRequest(): {
|
||||
};
|
||||
|
||||
window.addEventListener(SERVER_LICENSE_REQUEST_EVENT, handleLicenseRequest);
|
||||
return () => window.removeEventListener(SERVER_LICENSE_REQUEST_EVENT, handleLicenseRequest);
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
SERVER_LICENSE_REQUEST_EVENT,
|
||||
handleLicenseRequest,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const closeLicenseSlide = useCallback(() => {
|
||||
@@ -51,7 +57,8 @@ export function useTourRequest(): {
|
||||
clearTourRequest: () => void;
|
||||
} {
|
||||
const [tourRequested, setTourRequested] = useState(false);
|
||||
const [requestedTourType, setRequestedTourType] = useState<TourType>("whatsnew");
|
||||
const [requestedTourType, setRequestedTourType] =
|
||||
useState<TourType>("whatsnew");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
@@ -63,7 +70,8 @@ export function useTourRequest(): {
|
||||
};
|
||||
|
||||
window.addEventListener(START_TOUR_EVENT, handleTourRequest);
|
||||
return () => window.removeEventListener(START_TOUR_EVENT, handleTourRequest);
|
||||
return () =>
|
||||
window.removeEventListener(START_TOUR_EVENT, handleTourRequest);
|
||||
}, []);
|
||||
|
||||
const clearTourRequest = useCallback(() => {
|
||||
|
||||
@@ -36,7 +36,10 @@ interface CreateUserStepsConfigArgs {
|
||||
actions: UserStepActions;
|
||||
}
|
||||
|
||||
export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs): Record<TourStep, StepType> {
|
||||
export function createUserStepsConfig({
|
||||
t,
|
||||
actions,
|
||||
}: CreateUserStepsConfigArgs): Record<TourStep, StepType> {
|
||||
const {
|
||||
saveWorkbenchState,
|
||||
closeFilesModal,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { StepType } from "@reactour/tour";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
async function waitForElement(selector: string, timeoutMs = 7000, intervalMs = 100): Promise<void> {
|
||||
async function waitForElement(
|
||||
selector: string,
|
||||
timeoutMs = 7000,
|
||||
intervalMs = 100,
|
||||
): Promise<void> {
|
||||
if (typeof document === "undefined") return;
|
||||
const start = Date.now();
|
||||
// Immediate hit
|
||||
@@ -19,7 +23,11 @@ async function waitForElement(selector: string, timeoutMs = 7000, intervalMs = 1
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForHighlightable(selector: string, timeoutMs = 7000, intervalMs = 500): Promise<void> {
|
||||
async function waitForHighlightable(
|
||||
selector: string,
|
||||
timeoutMs = 7000,
|
||||
intervalMs = 500,
|
||||
): Promise<void> {
|
||||
if (typeof document === "undefined") return;
|
||||
const start = Date.now();
|
||||
|
||||
@@ -68,7 +76,10 @@ interface CreateWhatsNewStepsConfigArgs {
|
||||
actions: WhatsNewStepActions;
|
||||
}
|
||||
|
||||
export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsConfigArgs): Record<WhatsNewTourStep, StepType> {
|
||||
export function createWhatsNewStepsConfig({
|
||||
t,
|
||||
actions,
|
||||
}: CreateWhatsNewStepsConfigArgs): Record<WhatsNewTourStep, StepType> {
|
||||
const {
|
||||
saveWorkbenchState,
|
||||
closeFilesModal,
|
||||
@@ -128,7 +139,10 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
|
||||
},
|
||||
[WhatsNewTourStep.RIGHT_RAIL]: {
|
||||
selector: '[data-tour="right-rail-controls"]',
|
||||
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
|
||||
highlightedSelectors: [
|
||||
'[data-tour="right-rail-controls"]',
|
||||
'[data-tour="right-rail-settings"]',
|
||||
],
|
||||
content: t(
|
||||
"onboarding.whatsNew.rightRail",
|
||||
"The <strong>Right Rail</strong> holds quick actions to select files, change theme or language, and download results.",
|
||||
@@ -157,7 +171,10 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
|
||||
},
|
||||
[WhatsNewTourStep.PAGE_EDITOR_VIEW]: {
|
||||
selector: '[data-tour="view-switcher"]',
|
||||
content: t("onboarding.whatsNew.pageEditorView", "Switch to the Page Editor to reorder, rotate, or delete pages."),
|
||||
content: t(
|
||||
"onboarding.whatsNew.pageEditorView",
|
||||
"Switch to the Page Editor to reorder, rotate, or delete pages.",
|
||||
),
|
||||
position: "bottom",
|
||||
padding: 8,
|
||||
action: async () => {
|
||||
|
||||
Reference in New Issue
Block a user