mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-02-17 13:52:14 +01:00
onboarding slides
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
.heroWrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.heroLogo {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
animation: heroLogoEnter 0.25s ease forwards;
|
||||
}
|
||||
|
||||
.heroLogoCircle {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.18);
|
||||
animation: heroLogoScale 0.25s ease forwards;
|
||||
}
|
||||
|
||||
.heroLogoCircle img {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
animation: heroLogoRotate 0.25s ease forwards;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
animation: bodySlideIn 0.25s ease forwards;
|
||||
}
|
||||
|
||||
.bodyCopy {
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
animation: bodySlideIn 0.25s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes heroLogoEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes heroLogoScale {
|
||||
from {
|
||||
transform: scale(0.6);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes heroLogoRotate {
|
||||
from {
|
||||
transform: rotate(-90deg) scale(0.9);
|
||||
}
|
||||
to {
|
||||
transform: rotate(0deg) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bodySlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,12 @@ 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';
|
||||
import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide';
|
||||
import DesktopInstallSlide from '@app/components/onboarding/slides/DesktopInstallSlide';
|
||||
import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide';
|
||||
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
|
||||
import { SlideConfig } from '@app/components/onboarding/slides/types';
|
||||
import styles from './InitialOnboardingModal.module.css';
|
||||
|
||||
interface InitialOnboardingModalProps {
|
||||
opened: boolean;
|
||||
@@ -54,39 +60,17 @@ export default function InitialOnboardingModal({ opened, onClose }: InitialOnboa
|
||||
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',
|
||||
];
|
||||
// Get slide content from the slide components
|
||||
const slides = React.useMemo<SlideConfig[]>(
|
||||
() => [
|
||||
WelcomeSlide(),
|
||||
DesktopInstallSlide({ osLabel: os.label, osUrl: os.url }),
|
||||
PlanOverviewSlide({ isAdmin }),
|
||||
],
|
||||
[isAdmin, os.label, os.url],
|
||||
);
|
||||
|
||||
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',
|
||||
];
|
||||
const currentSlide = slides[step];
|
||||
|
||||
// Buttons per step
|
||||
const renderButtons = () => {
|
||||
@@ -143,8 +127,9 @@ export default function InitialOnboardingModal({ opened, onClose }: InitialOnboa
|
||||
</Group>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (os.url) {
|
||||
window.open(os.url, '_blank', 'noopener');
|
||||
const downloadUrl = currentSlide.downloadUrl;
|
||||
if (downloadUrl) {
|
||||
window.open(downloadUrl, '_blank', 'noopener');
|
||||
}
|
||||
goNext();
|
||||
}}
|
||||
@@ -245,17 +230,25 @@ export default function InitialOnboardingModal({ opened, onClose }: InitialOnboa
|
||||
}}
|
||||
>
|
||||
<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 className={styles.heroWrapper}>
|
||||
<AnimatedSlideBackground
|
||||
gradientStops={currentSlide.background.gradientStops}
|
||||
circles={currentSlide.background.circles}
|
||||
isActive
|
||||
slideKey={currentSlide.key}
|
||||
/>
|
||||
<div className={styles.heroLogo} key={`logo-${currentSlide.key}`}>
|
||||
<div className={styles.heroLogoCircle}>
|
||||
<img src="/branding/StirlingPDFLogoNoTextLight.svg" alt="Stirling logo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 24 }}>
|
||||
<Stack gap={16}>
|
||||
<div
|
||||
key={`title-${currentSlide.key}`}
|
||||
className={styles.title}
|
||||
style={{
|
||||
fontFamily: 'Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
|
||||
fontWeight: 600,
|
||||
@@ -263,7 +256,7 @@ export default function InitialOnboardingModal({ opened, onClose }: InitialOnboa
|
||||
color: 'var(--onboarding-title)',
|
||||
}}
|
||||
>
|
||||
{titleByStep[step]}
|
||||
{currentSlide.title}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -275,8 +268,12 @@ export default function InitialOnboardingModal({ opened, onClose }: InitialOnboa
|
||||
}}
|
||||
>
|
||||
{/* strong tags should match the title color */}
|
||||
<div style={{ color: 'inherit' }}>
|
||||
{bodyByStep[step]}
|
||||
<div
|
||||
key={`body-${currentSlide.key}`}
|
||||
className={styles.bodyCopy}
|
||||
style={{ color: 'inherit' }}
|
||||
>
|
||||
{currentSlide.body}
|
||||
</div>
|
||||
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
.hero {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
background-size: 180% 180%;
|
||||
animation: gradientShift 18s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.heroActive {}
|
||||
|
||||
.circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.12);
|
||||
animation-name: circleSway;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-iteration-count: infinite;
|
||||
animation-direction: alternate;
|
||||
animation-duration: var(--circle-duration, 15s);
|
||||
animation-delay: var(--circle-delay, 0s);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes circleSway {
|
||||
0% {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(var(--circle-move-x, 40px), var(--circle-move-y, 24px), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import styles from './AnimatedSlideBackground.module.css';
|
||||
import { AnimatedSlideBackgroundProps } from './types';
|
||||
|
||||
type CircleStyles = React.CSSProperties & {
|
||||
'--circle-move-x'?: string;
|
||||
'--circle-move-y'?: string;
|
||||
'--circle-duration'?: string;
|
||||
'--circle-delay'?: string;
|
||||
};
|
||||
|
||||
interface AnimatedSlideBackgroundComponentProps extends AnimatedSlideBackgroundProps {
|
||||
isActive: boolean;
|
||||
slideKey: string;
|
||||
}
|
||||
|
||||
export default function AnimatedSlideBackground({
|
||||
gradientStops,
|
||||
circles,
|
||||
isActive,
|
||||
slideKey,
|
||||
}: AnimatedSlideBackgroundComponentProps) {
|
||||
const gradientStyle = React.useMemo(
|
||||
() => ({
|
||||
backgroundImage: `linear-gradient(135deg, ${gradientStops[0]}, ${gradientStops[1]})`,
|
||||
}),
|
||||
[gradientStops],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.hero} ${isActive ? styles.heroActive : ''}`.trim()}
|
||||
style={gradientStyle}
|
||||
key={slideKey}
|
||||
>
|
||||
{circles.map((circle, index) => {
|
||||
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 circleStyle: CircleStyles = {
|
||||
width: size,
|
||||
height: size,
|
||||
background: color,
|
||||
opacity: opacity ?? 0.9,
|
||||
filter: blur ? `blur(${blur}px)` : undefined,
|
||||
'--circle-move-x': `${moveX}px`,
|
||||
'--circle-move-y': `${moveY}px`,
|
||||
'--circle-duration': `${duration}s`,
|
||||
'--circle-delay': `${delay}s`,
|
||||
};
|
||||
|
||||
const defaultOffset = -size / 2;
|
||||
const offsetX = circle.offsetX ?? 0;
|
||||
const offsetY = circle.offsetY ?? 0;
|
||||
|
||||
if (position === 'bottom-left') {
|
||||
circleStyle.left = `${defaultOffset + offsetX}px`;
|
||||
circleStyle.bottom = `${defaultOffset + offsetY}px`;
|
||||
} else {
|
||||
circleStyle.right = `${defaultOffset + offsetX}px`;
|
||||
circleStyle.top = `${defaultOffset + offsetY}px`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${slideKey}-circle-${index}`}
|
||||
className={styles.circle}
|
||||
style={circleStyle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { SlideConfig } from './types';
|
||||
|
||||
interface DesktopInstallSlideProps {
|
||||
osLabel: string;
|
||||
osUrl: string;
|
||||
}
|
||||
|
||||
export default function DesktopInstallSlide({ osLabel, osUrl }: DesktopInstallSlideProps): SlideConfig {
|
||||
const title = osLabel ? `Download for ${osLabel}` : 'Download';
|
||||
|
||||
return {
|
||||
key: 'desktop-install',
|
||||
title,
|
||||
body: (
|
||||
<span>
|
||||
Stirling works best as a desktop app. You can use it offline, access documents faster, and make edits locally on your computer.
|
||||
</span>
|
||||
),
|
||||
downloadUrl: osUrl,
|
||||
background: {
|
||||
gradientStops: ['#2563EB', '#0EA5E9'],
|
||||
circles: [
|
||||
{
|
||||
position: 'bottom-left',
|
||||
size: 260,
|
||||
color: 'rgba(255, 255, 255, 0.2)',
|
||||
opacity: 0.88,
|
||||
amplitude: 24,
|
||||
duration: 11,
|
||||
offsetX: 16,
|
||||
offsetY: 12,
|
||||
},
|
||||
{
|
||||
position: 'top-right',
|
||||
size: 300,
|
||||
color: 'rgba(28, 155, 235, 0.34)',
|
||||
opacity: 0.86,
|
||||
amplitude: 28,
|
||||
duration: 12,
|
||||
delay: 1,
|
||||
offsetX: 20,
|
||||
offsetY: 16,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { SlideConfig } from './types';
|
||||
|
||||
interface PlanOverviewSlideProps {
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export default function PlanOverviewSlide({ isAdmin }: PlanOverviewSlideProps): SlideConfig {
|
||||
return {
|
||||
key: isAdmin ? 'admin-overview' : 'plan-overview',
|
||||
title: isAdmin ? 'Admin Overview' : 'Plan Overview',
|
||||
body: 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>
|
||||
),
|
||||
background: {
|
||||
gradientStops: ['#F97316', '#EF4444'],
|
||||
circles: [
|
||||
{
|
||||
position: 'bottom-left',
|
||||
size: 260,
|
||||
color: 'rgba(255, 255, 255, 0.25)',
|
||||
opacity: 0.9,
|
||||
amplitude: 26,
|
||||
duration: 11,
|
||||
offsetX: 18,
|
||||
offsetY: 12,
|
||||
},
|
||||
{
|
||||
position: 'top-right',
|
||||
size: 300,
|
||||
color: 'rgba(251, 191, 36, 0.4)',
|
||||
opacity: 0.9,
|
||||
amplitude: 30,
|
||||
duration: 12,
|
||||
delay: 1.4,
|
||||
offsetX: 24,
|
||||
offsetY: 18,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import { SlideConfig } from './types';
|
||||
|
||||
export default function WelcomeSlide(): SlideConfig {
|
||||
return {
|
||||
key: 'welcome',
|
||||
title: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
|
||||
Welcome to Stirling
|
||||
<span
|
||||
style={{
|
||||
background: '#DBEFFF',
|
||||
color: '#2A4BFF',
|
||||
padding: '4px 12px',
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
V2
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
body: (
|
||||
<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>
|
||||
),
|
||||
background: {
|
||||
gradientStops: ['#7C3AED', '#EC4899'],
|
||||
circles: [
|
||||
{
|
||||
position: 'bottom-left',
|
||||
size: 260,
|
||||
color: 'rgba(255, 255, 255, 0.25)',
|
||||
opacity: 0.9,
|
||||
amplitude: 24,
|
||||
duration: 11,
|
||||
offsetX: 18,
|
||||
offsetY: 14,
|
||||
},
|
||||
{
|
||||
position: 'top-right',
|
||||
size: 300,
|
||||
color: 'rgba(196, 181, 253, 0.4)',
|
||||
opacity: 0.9,
|
||||
amplitude: 28,
|
||||
duration: 12,
|
||||
delay: 1.2,
|
||||
offsetX: 24,
|
||||
offsetY: 18,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
27
frontend/src/core/components/onboarding/slides/types.ts
Normal file
27
frontend/src/core/components/onboarding/slides/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export interface AnimatedCircleConfig {
|
||||
size: number;
|
||||
color: string;
|
||||
opacity?: number;
|
||||
blur?: number;
|
||||
position: 'bottom-left' | 'top-right';
|
||||
amplitude?: number;
|
||||
duration?: number;
|
||||
delay?: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
}
|
||||
|
||||
export interface AnimatedSlideBackgroundProps {
|
||||
gradientStops: [string, string];
|
||||
circles: AnimatedCircleConfig[];
|
||||
}
|
||||
|
||||
export interface SlideConfig {
|
||||
key: string;
|
||||
title: ReactNode;
|
||||
body: ReactNode;
|
||||
background: AnimatedSlideBackgroundProps;
|
||||
downloadUrl?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user