mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
1581 lines
55 KiB
TypeScript
1581 lines
55 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import styles from '@app/features/app/components/setup/SelfHostedSetupWizardGate.module.css';
|
|
import type {SetupBrandingAssetKind} from '@app/features/app/components/setup/SetupWizardClient';
|
|
import {
|
|
createRandomWelcomeRotationState,
|
|
createWelcomeRotationState,
|
|
WELCOME_ROTATION,
|
|
} from '@app/features/app/components/setup/SetupWizardWelcomeRotation';
|
|
import {AuthRegisterFormCore} from '@app/features/auth/flow/AuthRegisterFormCore';
|
|
import {Button} from '@app/features/ui/button/Button';
|
|
import {ColorPickerField} from '@app/features/ui/components/form/ColorPickerField';
|
|
import {Input} from '@app/features/ui/components/form/FormInput';
|
|
import {Switch} from '@app/features/ui/components/form/FormSwitch';
|
|
import {Spinner} from '@app/features/ui/components/Spinner';
|
|
import {RadioGroup, type RadioOption} from '@app/features/ui/radio_group/RadioGroup';
|
|
import {ThemeSelector} from '@app/features/user/components/modals/tabs/appearance_tab/theme/ThemeTabContent';
|
|
import {LanguageSelector} from '@app/features/user/components/modals/tabs/LanguageTab';
|
|
import * as LocaleUtils from '@app/features/user/utils/LocaleUtils';
|
|
import type {ThemeType} from '@fluxer/constants/src/UserConstants';
|
|
import type {MessageDescriptor} from '@lingui/core';
|
|
import {msg} from '@lingui/core/macro';
|
|
import {useLingui} from '@lingui/react/macro';
|
|
import {ImageIcon, TrashIcon, UploadSimpleIcon} from '@phosphor-icons/react';
|
|
import {AnimatePresence, motion, type Transition, useReducedMotion} from 'framer-motion';
|
|
import {observer} from 'mobx-react-lite';
|
|
import type React from 'react';
|
|
import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react';
|
|
|
|
export type RegistrationMode = 'open' | 'approval' | 'closed';
|
|
export type PremiumMode = 'mirror' | 'everyone';
|
|
|
|
export interface BrandingAssetState {
|
|
kind: SetupBrandingAssetKind;
|
|
url: string | null;
|
|
preview: string | null;
|
|
}
|
|
|
|
const WELCOME_AUTHED_TITLE_DESCRIPTOR = msg({
|
|
message: 'Welcome to {productName}',
|
|
comment: 'Setup wizard title shown once the operator is signed in as the instance admin.',
|
|
});
|
|
const WELCOME_AUTHED_BODY_DESCRIPTOR = msg({
|
|
message:
|
|
'You are signed in as the instance administrator. The next steps configure branding, registration, and core policy for everyone on this instance.',
|
|
comment: 'Setup wizard body shown to the signed-in instance administrator.',
|
|
});
|
|
const WELCOME_UNAUTHED_TITLE_DESCRIPTOR = msg({
|
|
message: 'Create the administrator account',
|
|
comment: 'Setup wizard title prompting the operator to create the first account.',
|
|
});
|
|
const WELCOME_UNAUTHED_BODY_DESCRIPTOR = msg({
|
|
message: 'This instance has no accounts yet. The first account you create automatically becomes the administrator.',
|
|
comment: 'Setup wizard body prompting the operator to register the first account.',
|
|
});
|
|
const WELCOME_LANGUAGE_LABEL_DESCRIPTOR = msg({
|
|
message: 'Language',
|
|
comment: 'Label for the setup wizard language selector.',
|
|
});
|
|
const THEME_TITLE_DESCRIPTOR = msg({
|
|
message: 'Choose a theme',
|
|
comment: 'Setup wizard title for choosing the initial account theme.',
|
|
});
|
|
const THEME_BODY_DESCRIPTOR = msg({
|
|
message: 'Pick the appearance this administrator account should start with.',
|
|
comment: 'Setup wizard body for choosing the initial account theme.',
|
|
});
|
|
export const CREATE_ADMIN_ACCOUNT_DESCRIPTOR = msg({
|
|
message: 'Create administrator account',
|
|
comment: 'Button that opens the registration flow to create the first administrator account.',
|
|
});
|
|
const LOADING_TITLE_DESCRIPTOR = msg({
|
|
message: 'Preparing setup',
|
|
comment: 'Setup wizard title while the instance configuration loads.',
|
|
});
|
|
const LOADING_BODY_DESCRIPTOR = msg({
|
|
message: 'Loading the current instance configuration.',
|
|
comment: 'Setup wizard body while the instance configuration loads.',
|
|
});
|
|
|
|
const BRANDING_TITLE_DESCRIPTOR = msg({
|
|
message: 'Make the instance yours',
|
|
comment: 'Setup wizard branding step title.',
|
|
});
|
|
const BRANDING_BODY_DESCRIPTOR = msg({
|
|
message: 'Set the public product name, theme color, and image assets that clients display.',
|
|
comment: 'Setup wizard branding step body.',
|
|
});
|
|
const PRODUCT_NAME_LABEL_DESCRIPTOR = msg({
|
|
message: 'Product name',
|
|
comment: 'Label for the product name input in the setup wizard.',
|
|
});
|
|
const PRODUCT_NAME_PLACEHOLDER_DESCRIPTOR = msg({
|
|
message: 'Fluxer',
|
|
comment: 'Placeholder for the product name input in the setup wizard.',
|
|
});
|
|
const PRODUCT_NAME_ERROR_DESCRIPTOR = msg({
|
|
message: 'Enter a product name between 1 and 80 characters.',
|
|
comment: 'Validation error for the product name input in the setup wizard.',
|
|
});
|
|
const THEME_COLOR_LABEL_DESCRIPTOR = msg({
|
|
message: 'Theme color',
|
|
comment: 'Label for the theme color picker in the setup wizard.',
|
|
});
|
|
const BRANDING_ASSETS_LABEL_DESCRIPTOR = msg({
|
|
message: 'Image assets',
|
|
comment: 'Label for the branding image asset uploaders in the setup wizard.',
|
|
});
|
|
const UPLOAD_ASSET_DESCRIPTOR = msg({
|
|
message: 'Upload',
|
|
comment: 'Button that opens a file picker to upload a branding image.',
|
|
});
|
|
const REPLACE_ASSET_DESCRIPTOR = msg({
|
|
message: 'Replace',
|
|
comment: 'Button that replaces an already uploaded branding image.',
|
|
});
|
|
const CLEAR_ASSET_DESCRIPTOR = msg({
|
|
message: 'Clear',
|
|
comment: 'Button that removes an uploaded branding image.',
|
|
});
|
|
const ASSET_ICON_DESCRIPTOR = msg({
|
|
message: 'Icon',
|
|
comment: 'Label for the icon branding asset.',
|
|
});
|
|
const ASSET_SYMBOL_DESCRIPTOR = msg({
|
|
message: 'Symbol',
|
|
comment: 'Label for the symbol branding asset.',
|
|
});
|
|
const ASSET_LOGO_DESCRIPTOR = msg({
|
|
message: 'Logo',
|
|
comment: 'Label for the logo branding asset.',
|
|
});
|
|
const ASSET_WORDMARK_DESCRIPTOR = msg({
|
|
message: 'Wordmark',
|
|
comment: 'Label for the wordmark branding asset.',
|
|
});
|
|
const ASSET_FAVICON_DESCRIPTOR = msg({
|
|
message: 'Favicon',
|
|
comment: 'Label for the favicon branding asset.',
|
|
});
|
|
const ASSET_PREVIEW_ALT_DESCRIPTOR = msg({
|
|
message: 'Branding asset preview',
|
|
comment: 'Accessible label for a branding image preview in the setup wizard.',
|
|
});
|
|
|
|
const REGISTRATION_TITLE_DESCRIPTOR = msg({
|
|
message: 'Who can join',
|
|
comment: 'Setup wizard registration step title.',
|
|
});
|
|
const REGISTRATION_BODY_DESCRIPTOR = msg({
|
|
message: 'Choose how new accounts are created on this instance.',
|
|
comment: 'Setup wizard registration step body.',
|
|
});
|
|
const REGISTRATION_OPEN_NAME_DESCRIPTOR = msg({
|
|
message: 'Open',
|
|
comment: 'Registration mode option allowing anyone to register.',
|
|
});
|
|
const REGISTRATION_OPEN_DESC_DESCRIPTOR = msg({
|
|
message: 'Anyone can create an account.',
|
|
comment: 'Description for the open registration mode.',
|
|
});
|
|
const REGISTRATION_APPROVAL_NAME_DESCRIPTOR = msg({
|
|
message: 'Approval required',
|
|
comment: 'Registration mode option requiring admin approval.',
|
|
});
|
|
const REGISTRATION_APPROVAL_DESC_DESCRIPTOR = msg({
|
|
message: 'Anyone can request an account, but an administrator must approve it.',
|
|
comment: 'Description for the approval registration mode.',
|
|
});
|
|
const REGISTRATION_CLOSED_NAME_DESCRIPTOR = msg({
|
|
message: 'Closed',
|
|
comment: 'Registration mode option that disables public registration.',
|
|
});
|
|
const REGISTRATION_CLOSED_DESC_DESCRIPTOR = msg({
|
|
message: 'Public registration is closed.',
|
|
comment: 'Description for the closed registration mode.',
|
|
});
|
|
|
|
const COMMUNITY_TITLE_DESCRIPTOR = msg({
|
|
message: 'Choose how your instance works',
|
|
comment: 'Setup wizard community model step title.',
|
|
});
|
|
const COMMUNITY_BODY_DESCRIPTOR = msg({
|
|
message: 'These options change how people use the instance. Some can only be chosen during setup.',
|
|
comment: 'Setup wizard community model step body.',
|
|
});
|
|
const SINGLE_COMMUNITY_LABEL_DESCRIPTOR = msg({
|
|
message: 'Single community mode',
|
|
comment: 'Label for the single community toggle in the setup wizard.',
|
|
});
|
|
const SINGLE_COMMUNITY_DESC_DESCRIPTOR = msg({
|
|
message: 'Run the instance as one community instead of many. This can only be enabled now, during setup.',
|
|
comment: 'Description for the single community toggle in the setup wizard.',
|
|
});
|
|
const SINGLE_COMMUNITY_NAME_LABEL_DESCRIPTOR = msg({
|
|
message: 'Community name',
|
|
comment: 'Label for the single community name input in the setup wizard.',
|
|
});
|
|
const SINGLE_COMMUNITY_NAME_PLACEHOLDER_DESCRIPTOR = msg({
|
|
message: 'My community',
|
|
comment: 'Placeholder for the single community name input in the setup wizard.',
|
|
});
|
|
const SINGLE_COMMUNITY_NAME_ERROR_DESCRIPTOR = msg({
|
|
message: 'Enter a community name between 1 and 100 characters.',
|
|
comment: 'Validation error for the single community name input in the setup wizard.',
|
|
});
|
|
const DISABLE_DM_LABEL_DESCRIPTOR = msg({
|
|
message: 'Disable direct messages and friend requests',
|
|
comment: 'Label for the toggle that disables direct messages and friend requests.',
|
|
});
|
|
const DISABLE_DM_DESC_DESCRIPTOR = msg({
|
|
message: 'Turn off private messaging and friends across the instance. This can be reversed only once later.',
|
|
comment: 'Description for the toggle that disables direct messages and friend requests.',
|
|
});
|
|
|
|
const MEDIA_EXPIRY_TITLE_DESCRIPTOR = msg({
|
|
message: 'Attachment expiry',
|
|
comment: 'Setup wizard media expiry step title.',
|
|
});
|
|
const MEDIA_EXPIRY_BODY_DESCRIPTOR = msg({
|
|
message: 'Choose whether uploaded attachments should expire automatically based on size.',
|
|
comment: 'Setup wizard media expiry step body.',
|
|
});
|
|
const MEDIA_EXPIRY_ENABLE_LABEL_DESCRIPTOR = msg({
|
|
message: 'Enable attachment expiry',
|
|
comment: 'Label for enabling attachment expiry during setup.',
|
|
});
|
|
const MEDIA_EXPIRY_ENABLE_DESC_DESCRIPTOR = msg({
|
|
message: 'Disabled attachments remain available until manually removed.',
|
|
comment: 'Description for the attachment expiry setup switch.',
|
|
});
|
|
const MEDIA_MIN_SIZE_LABEL_DESCRIPTOR = msg({
|
|
message: 'Small file threshold (MB)',
|
|
comment: 'Label for attachment decay minimum size threshold.',
|
|
});
|
|
const MEDIA_MAX_SIZE_LABEL_DESCRIPTOR = msg({
|
|
message: 'Large file threshold (MB)',
|
|
comment: 'Label for attachment decay maximum size threshold.',
|
|
});
|
|
const MEDIA_MAX_ELIGIBLE_SIZE_LABEL_DESCRIPTOR = msg({
|
|
message: 'Maximum eligible size (MB)',
|
|
comment: 'Label for attachment decay maximum eligible size.',
|
|
});
|
|
const MEDIA_MIN_LIFETIME_LABEL_DESCRIPTOR = msg({
|
|
message: 'Minimum lifetime (days)',
|
|
comment: 'Label for attachment decay minimum lifetime.',
|
|
});
|
|
const MEDIA_MAX_LIFETIME_LABEL_DESCRIPTOR = msg({
|
|
message: 'Maximum lifetime (days)',
|
|
comment: 'Label for attachment decay maximum lifetime.',
|
|
});
|
|
const MEDIA_CURVE_LABEL_DESCRIPTOR = msg({
|
|
message: 'Expiry curve',
|
|
comment: 'Label for attachment decay curve.',
|
|
});
|
|
const MEDIA_RENEW_THRESHOLD_LABEL_DESCRIPTOR = msg({
|
|
message: 'Renew threshold (days)',
|
|
comment: 'Label for attachment decay renewal threshold.',
|
|
});
|
|
const MEDIA_RENEW_WINDOW_LABEL_DESCRIPTOR = msg({
|
|
message: 'Renew window (days)',
|
|
comment: 'Label for attachment decay renewal window.',
|
|
});
|
|
|
|
const SERVICES_TITLE_DESCRIPTOR = msg({
|
|
message: 'Optional services',
|
|
comment: 'Setup wizard optional services step title.',
|
|
});
|
|
const SERVICES_BODY_DESCRIPTOR = msg({
|
|
message: 'Enable the integrations whose credentials you have configured. Only available services are shown.',
|
|
comment: 'Setup wizard optional services step body.',
|
|
});
|
|
const SERVICES_NONE_DESCRIPTOR = msg({
|
|
message: 'No optional services are available on this instance. You can skip this step.',
|
|
comment: 'Message shown when no optional services are available in the setup wizard.',
|
|
});
|
|
const SERVICE_GIF_LABEL_DESCRIPTOR = msg({
|
|
message: 'Klipy GIFs',
|
|
comment: 'Label for the Klipy GIF service toggle in the setup wizard.',
|
|
});
|
|
const SERVICE_GIF_DESC_DESCRIPTOR = msg({
|
|
message: 'Let people search and send GIFs powered by Klipy.',
|
|
comment: 'Description for the Klipy GIF service toggle in the setup wizard.',
|
|
});
|
|
const SERVICE_YOUTUBE_LABEL_DESCRIPTOR = msg({
|
|
message: 'YouTube enrichment',
|
|
comment: 'Label for the YouTube enrichment service toggle in the setup wizard.',
|
|
});
|
|
const SERVICE_YOUTUBE_DESC_DESCRIPTOR = msg({
|
|
message: 'Show rich previews for YouTube links.',
|
|
comment: 'Description for the YouTube enrichment service toggle in the setup wizard.',
|
|
});
|
|
const SERVICE_BLUESKY_LABEL_DESCRIPTOR = msg({
|
|
message: 'Bluesky connections',
|
|
comment: 'Label for the Bluesky connections service toggle in the setup wizard.',
|
|
});
|
|
const SERVICE_BLUESKY_DESC_DESCRIPTOR = msg({
|
|
message: 'Let people link their Bluesky account to their profile.',
|
|
comment: 'Description for the Bluesky connections service toggle in the setup wizard.',
|
|
});
|
|
|
|
const PREMIUM_TITLE_DESCRIPTOR = msg({
|
|
message: 'Choose the premium model',
|
|
comment: 'Setup wizard premium model step title.',
|
|
});
|
|
const PREMIUM_BODY_DESCRIPTOR = msg({
|
|
message: 'Decide how premium limits apply to people on this instance.',
|
|
comment: 'Setup wizard premium model step body.',
|
|
});
|
|
const PREMIUM_MIRROR_NAME_DESCRIPTOR = msg({
|
|
message: 'Mirror tiers',
|
|
comment: 'Premium model option that mirrors free and premium tiers.',
|
|
});
|
|
const PREMIUM_MIRROR_DESC_DESCRIPTOR = msg({
|
|
message: 'Keep Free and Premium tiers. You can customize the tiers later.',
|
|
comment: 'Description for the mirror premium model.',
|
|
});
|
|
const PREMIUM_EVERYONE_NAME_DESCRIPTOR = msg({
|
|
message: 'Everyone is premium',
|
|
comment: 'Premium model option that gives everyone the highest limits.',
|
|
});
|
|
const PREMIUM_EVERYONE_DESC_DESCRIPTOR = msg({
|
|
message: 'Give everyone on this instance the highest premium limits.',
|
|
comment: 'Description for the everyone-is-premium model.',
|
|
});
|
|
|
|
const FINISH_TITLE_DESCRIPTOR = msg({
|
|
message: 'Finish setup',
|
|
comment: 'Setup wizard finish step title.',
|
|
});
|
|
const FINISH_BODY_DESCRIPTOR = msg({
|
|
message: 'Review your choices below, then finish. This saves the configuration and opens the instance.',
|
|
comment: 'Setup wizard finish step body.',
|
|
});
|
|
const SUMMARY_PRODUCT_NAME_DESCRIPTOR = msg({
|
|
message: 'Product name',
|
|
comment: 'Summary row label for the product name in the setup wizard.',
|
|
});
|
|
const SUMMARY_REGISTRATION_DESCRIPTOR = msg({
|
|
message: 'Registration',
|
|
comment: 'Summary row label for the registration mode in the setup wizard.',
|
|
});
|
|
const SUMMARY_SINGLE_COMMUNITY_DESCRIPTOR = msg({
|
|
message: 'Single community',
|
|
comment: 'Summary row label for the single community choice in the setup wizard.',
|
|
});
|
|
const SUMMARY_DIRECT_MESSAGES_DESCRIPTOR = msg({
|
|
message: 'Direct messages',
|
|
comment: 'Summary row label for the direct messages choice in the setup wizard.',
|
|
});
|
|
const SUMMARY_ATTACHMENT_EXPIRY_DESCRIPTOR = msg({
|
|
message: 'Attachment expiry',
|
|
comment: 'Summary row label for the attachment expiry choice in the setup wizard.',
|
|
});
|
|
const SUMMARY_PREMIUM_DESCRIPTOR = msg({
|
|
message: 'Premium model',
|
|
comment: 'Summary row label for the premium model in the setup wizard.',
|
|
});
|
|
const SUMMARY_ON_DESCRIPTOR = msg({
|
|
message: 'Enabled',
|
|
comment: 'Summary value when a setup option is enabled.',
|
|
});
|
|
const SUMMARY_OFF_DESCRIPTOR = msg({
|
|
message: 'Disabled',
|
|
comment: 'Summary value when a setup option is disabled.',
|
|
});
|
|
|
|
const ASSET_DESCRIPTORS: Record<SetupBrandingAssetKind, MessageDescriptor> = {
|
|
icon: ASSET_ICON_DESCRIPTOR,
|
|
symbol: ASSET_SYMBOL_DESCRIPTOR,
|
|
logo: ASSET_LOGO_DESCRIPTOR,
|
|
wordmark: ASSET_WORDMARK_DESCRIPTOR,
|
|
favicon: ASSET_FAVICON_DESCRIPTOR,
|
|
};
|
|
|
|
const WELCOME_WORD_INLINE_PADDING_PX = 8;
|
|
const SETUP_LANGUAGE_MENU_MAX_HEIGHT = 220;
|
|
|
|
interface StepHeaderProps {
|
|
title: string;
|
|
body: string;
|
|
}
|
|
|
|
const StepHeader: React.FC<StepHeaderProps> = ({title, body}) => (
|
|
<div className={styles.stepHeader} data-flx="app.self-hosted-setup-wizard-gate.step-header">
|
|
<h2 className={styles.title} data-flx="app.self-hosted-setup-wizard-gate.title">
|
|
{title}
|
|
</h2>
|
|
<p className={styles.body} data-flx="app.self-hosted-setup-wizard-gate.body">
|
|
{body}
|
|
</p>
|
|
</div>
|
|
);
|
|
|
|
export const WelcomeStep = observer(
|
|
({
|
|
productName,
|
|
isAuthenticated,
|
|
initialFocusRef,
|
|
}: {
|
|
productName: string;
|
|
isAuthenticated: boolean;
|
|
initialFocusRef?: React.Ref<HTMLElement>;
|
|
}) => {
|
|
const {i18n} = useLingui();
|
|
const prefersReducedMotion = useReducedMotion();
|
|
const currentLocale = LocaleUtils.getCurrentOrDetectedLocale();
|
|
const welcomeFrameRef = useRef<HTMLDivElement | null>(null);
|
|
const welcomeMeasureRef = useRef<HTMLSpanElement | null>(null);
|
|
const [welcomeScale, setWelcomeScale] = useState(1);
|
|
const [welcomeRotation, setWelcomeRotation] = useState(() => createWelcomeRotationState(currentLocale));
|
|
const measureWelcomeScale = useCallback(() => {
|
|
const frame = welcomeFrameRef.current;
|
|
const measure = welcomeMeasureRef.current;
|
|
if (!frame || !measure) return;
|
|
const availableWidth = Math.max(0, frame.clientWidth - WELCOME_WORD_INLINE_PADDING_PX);
|
|
const measuredWidth = Math.max(measure.scrollWidth, measure.getBoundingClientRect().width);
|
|
const nextScale = measuredWidth > 0 && availableWidth > 0 ? Math.min(1, availableWidth / measuredWidth) : 1;
|
|
setWelcomeScale((currentScale) => (Math.abs(currentScale - nextScale) > 0.005 ? nextScale : currentScale));
|
|
}, []);
|
|
useLayoutEffect(() => {
|
|
measureWelcomeScale();
|
|
const frame = welcomeFrameRef.current;
|
|
if (!frame) return undefined;
|
|
const ownerWindow = frame.ownerDocument.defaultView;
|
|
const resizeObserver =
|
|
typeof ownerWindow?.ResizeObserver === 'function' ? new ownerWindow.ResizeObserver(measureWelcomeScale) : null;
|
|
resizeObserver?.observe(frame);
|
|
ownerWindow?.addEventListener('resize', measureWelcomeScale);
|
|
void frame.ownerDocument.fonts?.ready.then(measureWelcomeScale);
|
|
return () => {
|
|
resizeObserver?.disconnect();
|
|
ownerWindow?.removeEventListener('resize', measureWelcomeScale);
|
|
};
|
|
}, [measureWelcomeScale, welcomeRotation]);
|
|
useEffect(() => {
|
|
setWelcomeRotation(createWelcomeRotationState(currentLocale));
|
|
}, [currentLocale]);
|
|
useEffect(() => {
|
|
const interval = window.setInterval(() => {
|
|
setWelcomeRotation((rotation) => {
|
|
if (rotation.position + 1 < rotation.order.length) {
|
|
return {
|
|
...rotation,
|
|
position: rotation.position + 1,
|
|
};
|
|
}
|
|
return createRandomWelcomeRotationState(rotation.order[rotation.position]);
|
|
});
|
|
}, 1800);
|
|
return () => window.clearInterval(interval);
|
|
}, []);
|
|
const welcome = WELCOME_ROTATION[welcomeRotation.order[welcomeRotation.position] ?? 0];
|
|
const motionState = prefersReducedMotion
|
|
? {
|
|
initial: {opacity: 0, scale: welcomeScale},
|
|
animate: {opacity: 1, scale: welcomeScale},
|
|
exit: {opacity: 0, scale: welcomeScale},
|
|
}
|
|
: {
|
|
initial: {opacity: 0, y: 14, scale: welcomeScale * 0.98},
|
|
animate: {opacity: 1, y: 0, scale: welcomeScale},
|
|
exit: {opacity: 0, y: -14, scale: welcomeScale * 0.98},
|
|
};
|
|
return (
|
|
<section
|
|
ref={initialFocusRef}
|
|
className={styles.step}
|
|
tabIndex={-1}
|
|
data-flx="app.self-hosted-setup-wizard-gate.welcome-step"
|
|
>
|
|
<div className={styles.welcomeHero} data-flx="app.self-hosted-setup-wizard-gate.welcome-hero">
|
|
<div
|
|
ref={welcomeFrameRef}
|
|
className={styles.welcomeWordFrame}
|
|
data-flx="app.self-hosted-setup-wizard-gate.welcome-word-frame"
|
|
>
|
|
<span
|
|
ref={welcomeMeasureRef}
|
|
className={styles.welcomeWordMeasure}
|
|
aria-hidden="true"
|
|
data-flx="app.self-hosted-setup-wizard-gate.welcome-word-measure"
|
|
>
|
|
{welcome.text}
|
|
</span>
|
|
<AnimatePresence mode="wait" initial={false}>
|
|
<motion.h2
|
|
key={welcome.code}
|
|
className={styles.welcomeWord}
|
|
initial={motionState.initial}
|
|
animate={motionState.animate}
|
|
exit={motionState.exit}
|
|
transition={{duration: prefersReducedMotion ? 0.22 : 0.42, ease: [0.22, 1, 0.36, 1]}}
|
|
data-flx="app.self-hosted-setup-wizard-gate.welcome-word"
|
|
>
|
|
{welcome.text}
|
|
</motion.h2>
|
|
</AnimatePresence>
|
|
</div>
|
|
{isAuthenticated ? (
|
|
<>
|
|
<h3 className={styles.welcomeTitle} data-flx="app.self-hosted-setup-wizard-gate.welcome-title">
|
|
{i18n._(WELCOME_AUTHED_TITLE_DESCRIPTOR, {productName})}
|
|
</h3>
|
|
<p className={styles.body} data-flx="app.self-hosted-setup-wizard-gate.welcome-body">
|
|
{i18n._(WELCOME_AUTHED_BODY_DESCRIPTOR)}
|
|
</p>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
<div className={styles.localeBlock} data-flx="app.self-hosted-setup-wizard-gate.locale-block">
|
|
<div className={styles.fieldLabel} data-flx="app.self-hosted-setup-wizard-gate.locale-label">
|
|
{i18n._(WELCOME_LANGUAGE_LABEL_DESCRIPTOR)}
|
|
</div>
|
|
<LanguageSelector
|
|
value={currentLocale}
|
|
onChange={LocaleUtils.setLocalLocale}
|
|
openMenuOnFocus={false}
|
|
maxMenuHeight={SETUP_LANGUAGE_MENU_MAX_HEIGHT}
|
|
menuPlacement="bottom"
|
|
className={styles.localeSelector}
|
|
/>
|
|
</div>
|
|
</section>
|
|
);
|
|
},
|
|
);
|
|
|
|
export const ThemeStep = observer(
|
|
({theme, onThemeChange}: {theme: ThemeType; onThemeChange: (theme: ThemeType) => void}) => {
|
|
const {i18n} = useLingui();
|
|
const themeLabel = i18n._(THEME_TITLE_DESCRIPTOR);
|
|
return (
|
|
<section className={styles.centeredStep} data-flx="app.self-hosted-setup-wizard-gate.theme-step">
|
|
<StepHeader title={themeLabel} body={i18n._(THEME_BODY_DESCRIPTOR)} />
|
|
<div className={styles.themeBlock} data-flx="app.self-hosted-setup-wizard-gate.theme-block">
|
|
<ThemeSelector
|
|
value={theme}
|
|
onChange={onThemeChange}
|
|
ariaLabel={themeLabel}
|
|
className={styles.themeButtonGroup}
|
|
/>
|
|
</div>
|
|
</section>
|
|
);
|
|
},
|
|
);
|
|
|
|
export const AdminIntroStep = observer(() => {
|
|
const {i18n} = useLingui();
|
|
return (
|
|
<section className={styles.centeredStep} data-flx="app.self-hosted-setup-wizard-gate.admin-intro-step">
|
|
<StepHeader title={i18n._(WELCOME_UNAUTHED_TITLE_DESCRIPTOR)} body={i18n._(WELCOME_UNAUTHED_BODY_DESCRIPTOR)} />
|
|
</section>
|
|
);
|
|
});
|
|
|
|
export const AdminAccountStep = observer(({theme}: {theme: ThemeType}) => {
|
|
const {i18n} = useLingui();
|
|
return (
|
|
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.admin-account-step">
|
|
<div className={styles.accountSetupForm} data-flx="app.self-hosted-setup-wizard-gate.admin-form">
|
|
<AuthRegisterFormCore
|
|
fields={{
|
|
showEmail: true,
|
|
showPassword: true,
|
|
showPasswordConfirmation: true,
|
|
showUsernameValidation: true,
|
|
}}
|
|
submitLabel={i18n._(CREATE_ADMIN_ACCOUNT_DESCRIPTOR)}
|
|
redirectPath=""
|
|
theme={theme}
|
|
showLegalConsent={false}
|
|
data-flx="app.self-hosted-setup-wizard-gate.admin-register-form"
|
|
/>
|
|
</div>
|
|
</section>
|
|
);
|
|
});
|
|
|
|
export const LoadingStep = observer(() => {
|
|
const {i18n} = useLingui();
|
|
return (
|
|
<section className={styles.centeredStep} data-flx="app.self-hosted-setup-wizard-gate.loading-step">
|
|
<Spinner size="large" data-flx="app.self-hosted-setup-wizard-gate.loading-spinner" />
|
|
<StepHeader title={i18n._(LOADING_TITLE_DESCRIPTOR)} body={i18n._(LOADING_BODY_DESCRIPTOR)} />
|
|
</section>
|
|
);
|
|
});
|
|
|
|
interface BrandingAssetRowProps {
|
|
asset: BrandingAssetState;
|
|
disabled: boolean;
|
|
onUpload: (kind: SetupBrandingAssetKind) => void;
|
|
onClear: (kind: SetupBrandingAssetKind) => void;
|
|
}
|
|
|
|
const BrandingAssetRow = observer(({asset, disabled, onUpload, onClear}: BrandingAssetRowProps) => {
|
|
const {i18n} = useLingui();
|
|
const previewSrc = asset.preview ?? asset.url;
|
|
const hasImage = Boolean(previewSrc);
|
|
return (
|
|
<div className={styles.assetRow} data-flx="app.self-hosted-setup-wizard-gate.asset-row">
|
|
<div className={styles.assetPreview} data-flx="app.self-hosted-setup-wizard-gate.asset-preview">
|
|
{previewSrc ? (
|
|
<img
|
|
src={previewSrc}
|
|
alt={i18n._(ASSET_PREVIEW_ALT_DESCRIPTOR)}
|
|
className={styles.assetImage}
|
|
data-flx="app.self-hosted-setup-wizard-gate.asset-image"
|
|
/>
|
|
) : (
|
|
<ImageIcon size={22} weight="bold" data-flx="app.self-hosted-setup-wizard-gate.asset-placeholder" />
|
|
)}
|
|
</div>
|
|
<div className={styles.assetLabel} data-flx="app.self-hosted-setup-wizard-gate.asset-label">
|
|
{i18n._(ASSET_DESCRIPTORS[asset.kind])}
|
|
</div>
|
|
<div className={styles.assetActions} data-flx="app.self-hosted-setup-wizard-gate.asset-actions">
|
|
<Button
|
|
variant="secondary"
|
|
small
|
|
disabled={disabled}
|
|
leftIcon={<UploadSimpleIcon size={16} weight="bold" />}
|
|
onClick={() => onUpload(asset.kind)}
|
|
data-flx="app.self-hosted-setup-wizard-gate.asset-upload-button"
|
|
>
|
|
{hasImage ? i18n._(REPLACE_ASSET_DESCRIPTOR) : i18n._(UPLOAD_ASSET_DESCRIPTOR)}
|
|
</Button>
|
|
{hasImage && (
|
|
<Button
|
|
variant="secondary"
|
|
small
|
|
square
|
|
icon={<TrashIcon size={16} weight="bold" />}
|
|
aria-label={i18n._(CLEAR_ASSET_DESCRIPTOR)}
|
|
disabled={disabled}
|
|
onClick={() => onClear(asset.kind)}
|
|
data-flx="app.self-hosted-setup-wizard-gate.asset-clear-button"
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export const BrandingStep = observer(
|
|
({
|
|
productName,
|
|
productNameError,
|
|
themeColor,
|
|
assets,
|
|
disabled,
|
|
onProductNameChange,
|
|
onThemeColorChange,
|
|
onUploadAsset,
|
|
onClearAsset,
|
|
}: {
|
|
productName: string;
|
|
productNameError: boolean;
|
|
themeColor: number;
|
|
assets: ReadonlyArray<BrandingAssetState>;
|
|
disabled: boolean;
|
|
onProductNameChange: (value: string) => void;
|
|
onThemeColorChange: (value: number) => void;
|
|
onUploadAsset: (kind: SetupBrandingAssetKind) => void;
|
|
onClearAsset: (kind: SetupBrandingAssetKind) => void;
|
|
}) => {
|
|
const {i18n} = useLingui();
|
|
return (
|
|
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.branding-step">
|
|
<StepHeader title={i18n._(BRANDING_TITLE_DESCRIPTOR)} body={i18n._(BRANDING_BODY_DESCRIPTOR)} />
|
|
<Input
|
|
label={i18n._(PRODUCT_NAME_LABEL_DESCRIPTOR)}
|
|
value={productName}
|
|
onChange={(event) => onProductNameChange(event.target.value)}
|
|
placeholder={i18n._(PRODUCT_NAME_PLACEHOLDER_DESCRIPTOR)}
|
|
maxLength={80}
|
|
disabled={disabled}
|
|
error={productNameError ? i18n._(PRODUCT_NAME_ERROR_DESCRIPTOR) : undefined}
|
|
data-step-focus="true"
|
|
data-flx="app.self-hosted-setup-wizard-gate.product-name-input"
|
|
/>
|
|
<ColorPickerField
|
|
label={i18n._(THEME_COLOR_LABEL_DESCRIPTOR)}
|
|
value={themeColor}
|
|
onChange={onThemeColorChange}
|
|
disabled={disabled}
|
|
data-flx="app.self-hosted-setup-wizard-gate.theme-color-field"
|
|
/>
|
|
<div className={styles.assetSection} data-flx="app.self-hosted-setup-wizard-gate.asset-section">
|
|
<div className={styles.fieldLabel} data-flx="app.self-hosted-setup-wizard-gate.asset-section-label">
|
|
{i18n._(BRANDING_ASSETS_LABEL_DESCRIPTOR)}
|
|
</div>
|
|
{assets.map((asset) => (
|
|
<BrandingAssetRow
|
|
key={asset.kind}
|
|
asset={asset}
|
|
disabled={disabled}
|
|
onUpload={onUploadAsset}
|
|
onClear={onClearAsset}
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
},
|
|
);
|
|
|
|
export const RegistrationStep = observer(
|
|
({
|
|
mode,
|
|
disabled,
|
|
onChange,
|
|
}: {
|
|
mode: RegistrationMode;
|
|
disabled: boolean;
|
|
onChange: (mode: RegistrationMode) => void;
|
|
}) => {
|
|
const {i18n} = useLingui();
|
|
const options: ReadonlyArray<RadioOption<RegistrationMode>> = [
|
|
{
|
|
value: 'open',
|
|
name: i18n._(REGISTRATION_OPEN_NAME_DESCRIPTOR),
|
|
desc: i18n._(REGISTRATION_OPEN_DESC_DESCRIPTOR),
|
|
},
|
|
{
|
|
value: 'approval',
|
|
name: i18n._(REGISTRATION_APPROVAL_NAME_DESCRIPTOR),
|
|
desc: i18n._(REGISTRATION_APPROVAL_DESC_DESCRIPTOR),
|
|
},
|
|
{
|
|
value: 'closed',
|
|
name: i18n._(REGISTRATION_CLOSED_NAME_DESCRIPTOR),
|
|
desc: i18n._(REGISTRATION_CLOSED_DESC_DESCRIPTOR),
|
|
},
|
|
];
|
|
return (
|
|
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.registration-step">
|
|
<StepHeader title={i18n._(REGISTRATION_TITLE_DESCRIPTOR)} body={i18n._(REGISTRATION_BODY_DESCRIPTOR)} />
|
|
<RadioGroup
|
|
options={options}
|
|
value={mode}
|
|
onChange={onChange}
|
|
disabled={disabled}
|
|
aria-label={i18n._(REGISTRATION_TITLE_DESCRIPTOR)}
|
|
data-flx="app.self-hosted-setup-wizard-gate.registration-radio-group"
|
|
/>
|
|
</section>
|
|
);
|
|
},
|
|
);
|
|
|
|
export const CommunityStep = observer(
|
|
({
|
|
singleCommunityEnabled,
|
|
singleCommunityName,
|
|
singleCommunityNameError,
|
|
directMessagesDisabled,
|
|
disabled,
|
|
onToggleSingleCommunity,
|
|
onSingleCommunityNameChange,
|
|
onToggleDirectMessages,
|
|
}: {
|
|
singleCommunityEnabled: boolean;
|
|
singleCommunityName: string;
|
|
singleCommunityNameError: boolean;
|
|
directMessagesDisabled: boolean;
|
|
disabled: boolean;
|
|
onToggleSingleCommunity: (value: boolean) => void;
|
|
onSingleCommunityNameChange: (value: string) => void;
|
|
onToggleDirectMessages: (value: boolean) => void;
|
|
}) => {
|
|
const {i18n} = useLingui();
|
|
return (
|
|
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.community-step">
|
|
<StepHeader title={i18n._(COMMUNITY_TITLE_DESCRIPTOR)} body={i18n._(COMMUNITY_BODY_DESCRIPTOR)} />
|
|
<Switch
|
|
label={i18n._(SINGLE_COMMUNITY_LABEL_DESCRIPTOR)}
|
|
description={i18n._(SINGLE_COMMUNITY_DESC_DESCRIPTOR)}
|
|
value={singleCommunityEnabled}
|
|
onChange={onToggleSingleCommunity}
|
|
disabled={disabled}
|
|
data-flx="app.self-hosted-setup-wizard-gate.single-community-switch"
|
|
/>
|
|
{singleCommunityEnabled && (
|
|
<Input
|
|
label={i18n._(SINGLE_COMMUNITY_NAME_LABEL_DESCRIPTOR)}
|
|
value={singleCommunityName}
|
|
onChange={(event) => onSingleCommunityNameChange(event.target.value)}
|
|
placeholder={i18n._(SINGLE_COMMUNITY_NAME_PLACEHOLDER_DESCRIPTOR)}
|
|
maxLength={100}
|
|
disabled={disabled}
|
|
error={singleCommunityNameError ? i18n._(SINGLE_COMMUNITY_NAME_ERROR_DESCRIPTOR) : undefined}
|
|
data-flx="app.self-hosted-setup-wizard-gate.single-community-name-input"
|
|
/>
|
|
)}
|
|
<Switch
|
|
label={i18n._(DISABLE_DM_LABEL_DESCRIPTOR)}
|
|
description={i18n._(DISABLE_DM_DESC_DESCRIPTOR)}
|
|
value={directMessagesDisabled}
|
|
onChange={onToggleDirectMessages}
|
|
disabled={disabled}
|
|
data-flx="app.self-hosted-setup-wizard-gate.disable-dm-switch"
|
|
/>
|
|
</section>
|
|
);
|
|
},
|
|
);
|
|
|
|
export interface ServiceAvailability {
|
|
gif: boolean;
|
|
youtube: boolean;
|
|
bluesky: boolean;
|
|
}
|
|
|
|
export interface ServiceSelection {
|
|
gif: boolean;
|
|
youtube: boolean;
|
|
bluesky: boolean;
|
|
}
|
|
|
|
export interface MediaExpiryDraft {
|
|
enabled: boolean;
|
|
minSizeMb: string;
|
|
maxSizeMb: string;
|
|
maxEligibleSizeMb: string;
|
|
minLifetimeDays: string;
|
|
maxLifetimeDays: string;
|
|
curve: string;
|
|
renewThresholdDays: string;
|
|
renewWindowDays: string;
|
|
}
|
|
|
|
export type IntegrationStepKind = 'gif' | 'youtube' | 'captcha' | 'email' | 'bluesky';
|
|
export type IntegrationSetupMode = 'later' | 'configure';
|
|
export type CaptchaProvider = 'hcaptcha' | 'turnstile';
|
|
|
|
export interface ServiceIntegrationDraft {
|
|
gifMode: IntegrationSetupMode;
|
|
klipyApiKey: string;
|
|
youtubeMode: IntegrationSetupMode;
|
|
youtubeApiKey: string;
|
|
captchaMode: IntegrationSetupMode;
|
|
captchaProvider: CaptchaProvider;
|
|
hcaptchaSiteKey: string;
|
|
hcaptchaSecretKey: string;
|
|
turnstileSiteKey: string;
|
|
turnstileSecretKey: string;
|
|
emailMode: IntegrationSetupMode;
|
|
emailEnabled: boolean;
|
|
emailFromEmail: string;
|
|
emailFromName: string;
|
|
smtpHost: string;
|
|
smtpPort: string;
|
|
smtpUsername: string;
|
|
smtpPassword: string;
|
|
smtpSecure: boolean;
|
|
blueskyMode: IntegrationSetupMode;
|
|
blueskyEnabled: boolean;
|
|
blueskyClientName: string;
|
|
blueskyClientUri: string;
|
|
blueskyLogoUri: string;
|
|
blueskyTosUri: string;
|
|
blueskyPolicyUri: string;
|
|
blueskyKeyId: string;
|
|
blueskyPrivateKey: string;
|
|
}
|
|
|
|
const INTEGRATION_LATER_NAME_DESCRIPTOR = msg({
|
|
message: 'Set up later',
|
|
comment: 'Option for deferring an optional setup wizard integration.',
|
|
});
|
|
const INTEGRATION_LATER_DESC_DESCRIPTOR = msg({
|
|
message: 'Skip this now and configure it from the admin panel.',
|
|
comment: 'Description for deferring an optional setup wizard integration.',
|
|
});
|
|
const INTEGRATION_CONFIGURE_NAME_DESCRIPTOR = msg({
|
|
message: 'Configure now',
|
|
comment: 'Option for configuring an optional setup wizard integration.',
|
|
});
|
|
const INTEGRATION_CONFIGURE_DESC_DESCRIPTOR = msg({
|
|
message: 'Add the credentials during setup.',
|
|
comment: 'Description for configuring an optional setup wizard integration now.',
|
|
});
|
|
const GIF_SETUP_TITLE_DESCRIPTOR = msg({
|
|
message: 'GIF search',
|
|
comment: 'Setup wizard title for GIF integration credentials.',
|
|
});
|
|
const GIF_SETUP_BODY_DESCRIPTOR = msg({
|
|
message: 'Add a Klipy API key to enable GIF search at runtime.',
|
|
comment: 'Setup wizard body for GIF integration credentials.',
|
|
});
|
|
const YOUTUBE_SETUP_TITLE_DESCRIPTOR = msg({
|
|
message: 'YouTube previews',
|
|
comment: 'Setup wizard title for YouTube integration credentials.',
|
|
});
|
|
const YOUTUBE_SETUP_BODY_DESCRIPTOR = msg({
|
|
message: 'Add a YouTube Data API key to enrich YouTube links.',
|
|
comment: 'Setup wizard body for YouTube integration credentials.',
|
|
});
|
|
const CAPTCHA_SETUP_TITLE_DESCRIPTOR = msg({
|
|
message: 'Bot protection',
|
|
comment: 'Setup wizard title for CAPTCHA integration credentials.',
|
|
});
|
|
const CAPTCHA_SETUP_BODY_DESCRIPTOR = msg({
|
|
message: 'Choose hCaptcha or Cloudflare Turnstile for signup challenges.',
|
|
comment: 'Setup wizard body for CAPTCHA integration credentials.',
|
|
});
|
|
const EMAIL_SETUP_TITLE_DESCRIPTOR = msg({
|
|
message: 'Email delivery',
|
|
comment: 'Setup wizard title for SMTP integration credentials.',
|
|
});
|
|
const EMAIL_SETUP_BODY_DESCRIPTOR = msg({
|
|
message: 'Add SMTP credentials so the instance can send verification and notification email.',
|
|
comment: 'Setup wizard body for SMTP integration credentials.',
|
|
});
|
|
const BLUESKY_SETUP_TITLE_DESCRIPTOR = msg({
|
|
message: 'Bluesky OAuth',
|
|
comment: 'Setup wizard title for Bluesky integration credentials.',
|
|
});
|
|
const BLUESKY_SETUP_BODY_DESCRIPTOR = msg({
|
|
message: 'Add Bluesky OAuth client metadata and a signing key.',
|
|
comment: 'Setup wizard body for Bluesky integration credentials.',
|
|
});
|
|
const PROVIDER_LABEL_DESCRIPTOR = msg({
|
|
message: 'Provider',
|
|
comment: 'Label for an integration provider choice.',
|
|
});
|
|
const HCAPTCHA_NAME_DESCRIPTOR = msg({message: 'hCaptcha', comment: 'hCaptcha provider option.'});
|
|
const TURNSTILE_NAME_DESCRIPTOR = msg({
|
|
message: 'Cloudflare Turnstile',
|
|
comment: 'Cloudflare Turnstile provider option.',
|
|
});
|
|
const API_KEY_LABEL_DESCRIPTOR = msg({message: 'API key', comment: 'Label for an integration API key input.'});
|
|
const SITE_KEY_LABEL_DESCRIPTOR = msg({message: 'Site key', comment: 'Label for a CAPTCHA site key input.'});
|
|
const SECRET_KEY_LABEL_DESCRIPTOR = msg({message: 'Secret key', comment: 'Label for a CAPTCHA secret key input.'});
|
|
const ENABLE_EMAIL_LABEL_DESCRIPTOR = msg({
|
|
message: 'Enable email delivery',
|
|
comment: 'Label for enabling SMTP email delivery in setup.',
|
|
});
|
|
const EMAIL_FROM_EMAIL_LABEL_DESCRIPTOR = msg({
|
|
message: 'From email',
|
|
comment: 'Label for SMTP from email input.',
|
|
});
|
|
const EMAIL_FROM_NAME_LABEL_DESCRIPTOR = msg({
|
|
message: 'From name',
|
|
comment: 'Label for SMTP from name input.',
|
|
});
|
|
const SMTP_HOST_LABEL_DESCRIPTOR = msg({message: 'SMTP host', comment: 'Label for SMTP host input.'});
|
|
const SMTP_PORT_LABEL_DESCRIPTOR = msg({message: 'Port', comment: 'Label for SMTP port input.'});
|
|
const SMTP_USERNAME_LABEL_DESCRIPTOR = msg({message: 'Username', comment: 'Label for SMTP username input.'});
|
|
const SMTP_PASSWORD_LABEL_DESCRIPTOR = msg({message: 'Password', comment: 'Label for SMTP password input.'});
|
|
const SMTP_SECURE_LABEL_DESCRIPTOR = msg({
|
|
message: 'Use TLS',
|
|
comment: 'Label for SMTP TLS toggle.',
|
|
});
|
|
const SMTP_TEST_DESCRIPTOR = msg({
|
|
message: 'Test SMTP',
|
|
comment: 'Button that validates SMTP credentials.',
|
|
});
|
|
const SMTP_TEST_OK_DESCRIPTOR = msg({
|
|
message: 'SMTP connection verified.',
|
|
comment: 'Success message after validating SMTP credentials.',
|
|
});
|
|
const BLUESKY_ENABLED_LABEL_DESCRIPTOR = msg({
|
|
message: 'Enable Bluesky OAuth',
|
|
comment: 'Label for enabling Bluesky OAuth in setup.',
|
|
});
|
|
const BLUESKY_CLIENT_NAME_LABEL_DESCRIPTOR = msg({
|
|
message: 'Client name',
|
|
comment: 'Label for Bluesky OAuth client name input.',
|
|
});
|
|
const BLUESKY_CLIENT_URI_LABEL_DESCRIPTOR = msg({
|
|
message: 'Client URL',
|
|
comment: 'Label for Bluesky OAuth client URL input.',
|
|
});
|
|
const BLUESKY_LOGO_URI_LABEL_DESCRIPTOR = msg({
|
|
message: 'Logo URL',
|
|
comment: 'Label for Bluesky OAuth logo URL input.',
|
|
});
|
|
const BLUESKY_TOS_URI_LABEL_DESCRIPTOR = msg({
|
|
message: 'Terms URL',
|
|
comment: 'Label for Bluesky OAuth terms URL input.',
|
|
});
|
|
const BLUESKY_POLICY_URI_LABEL_DESCRIPTOR = msg({
|
|
message: 'Privacy URL',
|
|
comment: 'Label for Bluesky OAuth privacy URL input.',
|
|
});
|
|
const BLUESKY_KEY_ID_LABEL_DESCRIPTOR = msg({
|
|
message: 'Signing key ID',
|
|
comment: 'Label for Bluesky OAuth signing key ID input.',
|
|
});
|
|
const BLUESKY_PRIVATE_KEY_LABEL_DESCRIPTOR = msg({
|
|
message: 'Private key',
|
|
comment: 'Label for Bluesky OAuth private key input.',
|
|
});
|
|
|
|
function buildIntegrationModeOptions(
|
|
i18n: ReturnType<typeof useLingui>['i18n'],
|
|
): ReadonlyArray<RadioOption<IntegrationSetupMode>> {
|
|
return [
|
|
{
|
|
value: 'later',
|
|
name: i18n._(INTEGRATION_LATER_NAME_DESCRIPTOR),
|
|
desc: i18n._(INTEGRATION_LATER_DESC_DESCRIPTOR),
|
|
},
|
|
{
|
|
value: 'configure',
|
|
name: i18n._(INTEGRATION_CONFIGURE_NAME_DESCRIPTOR),
|
|
desc: i18n._(INTEGRATION_CONFIGURE_DESC_DESCRIPTOR),
|
|
},
|
|
];
|
|
}
|
|
|
|
function getIntegrationMode(draft: ServiceIntegrationDraft, kind: IntegrationStepKind): IntegrationSetupMode {
|
|
switch (kind) {
|
|
case 'gif':
|
|
return draft.gifMode;
|
|
case 'youtube':
|
|
return draft.youtubeMode;
|
|
case 'captcha':
|
|
return draft.captchaMode;
|
|
case 'email':
|
|
return draft.emailMode;
|
|
case 'bluesky':
|
|
return draft.blueskyMode;
|
|
}
|
|
}
|
|
|
|
function getIntegrationCopy(kind: IntegrationStepKind): {title: MessageDescriptor; body: MessageDescriptor} {
|
|
switch (kind) {
|
|
case 'gif':
|
|
return {title: GIF_SETUP_TITLE_DESCRIPTOR, body: GIF_SETUP_BODY_DESCRIPTOR};
|
|
case 'youtube':
|
|
return {title: YOUTUBE_SETUP_TITLE_DESCRIPTOR, body: YOUTUBE_SETUP_BODY_DESCRIPTOR};
|
|
case 'captcha':
|
|
return {title: CAPTCHA_SETUP_TITLE_DESCRIPTOR, body: CAPTCHA_SETUP_BODY_DESCRIPTOR};
|
|
case 'email':
|
|
return {title: EMAIL_SETUP_TITLE_DESCRIPTOR, body: EMAIL_SETUP_BODY_DESCRIPTOR};
|
|
case 'bluesky':
|
|
return {title: BLUESKY_SETUP_TITLE_DESCRIPTOR, body: BLUESKY_SETUP_BODY_DESCRIPTOR};
|
|
}
|
|
}
|
|
|
|
function parseSmtpPort(value: string): number | null {
|
|
const parsed = Number.parseInt(value, 10);
|
|
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535 ? parsed : null;
|
|
}
|
|
|
|
const MEDIA_EXPIRY_REVEAL_TRANSITION: Transition = {
|
|
type: 'spring',
|
|
stiffness: 460,
|
|
damping: 40,
|
|
mass: 0.7,
|
|
};
|
|
|
|
export const MediaExpiryStep = observer(
|
|
({
|
|
draft,
|
|
disabled,
|
|
onDraftChange,
|
|
}: {
|
|
draft: MediaExpiryDraft;
|
|
disabled: boolean;
|
|
onDraftChange: (patch: Partial<MediaExpiryDraft>) => void;
|
|
}) => {
|
|
const {i18n} = useLingui();
|
|
const prefersReducedMotion = useReducedMotion();
|
|
return (
|
|
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.media-expiry-step">
|
|
<StepHeader title={i18n._(MEDIA_EXPIRY_TITLE_DESCRIPTOR)} body={i18n._(MEDIA_EXPIRY_BODY_DESCRIPTOR)} />
|
|
<Switch
|
|
label={i18n._(MEDIA_EXPIRY_ENABLE_LABEL_DESCRIPTOR)}
|
|
description={i18n._(MEDIA_EXPIRY_ENABLE_DESC_DESCRIPTOR)}
|
|
value={draft.enabled}
|
|
onChange={(value) => onDraftChange({enabled: value})}
|
|
disabled={disabled}
|
|
data-flx="app.self-hosted-setup-wizard-gate.media-expiry-switch"
|
|
/>
|
|
<AnimatePresence initial={false}>
|
|
{draft.enabled && (
|
|
<motion.div
|
|
key="media-expiry-fields"
|
|
className={styles.collapsibleReveal}
|
|
initial={{height: 0, opacity: 0}}
|
|
animate={{height: 'auto', opacity: 1}}
|
|
exit={{height: 0, opacity: 0}}
|
|
transition={prefersReducedMotion ? {duration: 0} : MEDIA_EXPIRY_REVEAL_TRANSITION}
|
|
data-flx="app.self-hosted-setup-wizard-gate.media-expiry-reveal"
|
|
>
|
|
<div
|
|
className={styles.integrationFields}
|
|
data-flx="app.self-hosted-setup-wizard-gate.media-expiry-fields"
|
|
>
|
|
<div className={styles.integrationGrid}>
|
|
<Input
|
|
label={i18n._(MEDIA_MIN_SIZE_LABEL_DESCRIPTOR)}
|
|
inputMode="decimal"
|
|
value={draft.minSizeMb}
|
|
onChange={(event) => onDraftChange({minSizeMb: event.target.value})}
|
|
disabled={disabled}
|
|
/>
|
|
<Input
|
|
label={i18n._(MEDIA_MAX_SIZE_LABEL_DESCRIPTOR)}
|
|
inputMode="decimal"
|
|
value={draft.maxSizeMb}
|
|
onChange={(event) => onDraftChange({maxSizeMb: event.target.value})}
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
<Input
|
|
label={i18n._(MEDIA_MAX_ELIGIBLE_SIZE_LABEL_DESCRIPTOR)}
|
|
inputMode="decimal"
|
|
value={draft.maxEligibleSizeMb}
|
|
onChange={(event) => onDraftChange({maxEligibleSizeMb: event.target.value})}
|
|
disabled={disabled}
|
|
/>
|
|
<div className={styles.integrationGrid}>
|
|
<Input
|
|
label={i18n._(MEDIA_MIN_LIFETIME_LABEL_DESCRIPTOR)}
|
|
inputMode="numeric"
|
|
value={draft.minLifetimeDays}
|
|
onChange={(event) => onDraftChange({minLifetimeDays: event.target.value})}
|
|
disabled={disabled}
|
|
/>
|
|
<Input
|
|
label={i18n._(MEDIA_MAX_LIFETIME_LABEL_DESCRIPTOR)}
|
|
inputMode="numeric"
|
|
value={draft.maxLifetimeDays}
|
|
onChange={(event) => onDraftChange({maxLifetimeDays: event.target.value})}
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
<div className={styles.integrationGrid}>
|
|
<Input
|
|
label={i18n._(MEDIA_CURVE_LABEL_DESCRIPTOR)}
|
|
inputMode="decimal"
|
|
value={draft.curve}
|
|
onChange={(event) => onDraftChange({curve: event.target.value})}
|
|
disabled={disabled}
|
|
/>
|
|
<Input
|
|
label={i18n._(MEDIA_RENEW_THRESHOLD_LABEL_DESCRIPTOR)}
|
|
inputMode="numeric"
|
|
value={draft.renewThresholdDays}
|
|
onChange={(event) => onDraftChange({renewThresholdDays: event.target.value})}
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
<Input
|
|
label={i18n._(MEDIA_RENEW_WINDOW_LABEL_DESCRIPTOR)}
|
|
inputMode="numeric"
|
|
value={draft.renewWindowDays}
|
|
onChange={(event) => onDraftChange({renewWindowDays: event.target.value})}
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</section>
|
|
);
|
|
},
|
|
);
|
|
|
|
export const IntegrationStep = observer(
|
|
({
|
|
kind,
|
|
draft,
|
|
disabled,
|
|
smtpTesting,
|
|
smtpTestResult,
|
|
onDraftChange,
|
|
onTestSmtp,
|
|
}: {
|
|
kind: IntegrationStepKind;
|
|
draft: ServiceIntegrationDraft;
|
|
disabled: boolean;
|
|
smtpTesting: boolean;
|
|
smtpTestResult: string | null;
|
|
onDraftChange: (patch: Partial<ServiceIntegrationDraft>) => void;
|
|
onTestSmtp: () => void;
|
|
}) => {
|
|
const {i18n} = useLingui();
|
|
const copy = getIntegrationCopy(kind);
|
|
const mode = getIntegrationMode(draft, kind);
|
|
const setMode = useCallback(
|
|
(next: IntegrationSetupMode) => {
|
|
switch (kind) {
|
|
case 'gif':
|
|
onDraftChange({gifMode: next});
|
|
break;
|
|
case 'youtube':
|
|
onDraftChange({youtubeMode: next});
|
|
break;
|
|
case 'captcha':
|
|
onDraftChange({captchaMode: next});
|
|
break;
|
|
case 'email':
|
|
onDraftChange({emailMode: next});
|
|
break;
|
|
case 'bluesky':
|
|
onDraftChange({blueskyMode: next});
|
|
break;
|
|
}
|
|
},
|
|
[kind, onDraftChange],
|
|
);
|
|
const captchaOptions: ReadonlyArray<RadioOption<CaptchaProvider>> = [
|
|
{value: 'hcaptcha', name: i18n._(HCAPTCHA_NAME_DESCRIPTOR), desc: i18n._(CAPTCHA_SETUP_BODY_DESCRIPTOR)},
|
|
{value: 'turnstile', name: i18n._(TURNSTILE_NAME_DESCRIPTOR), desc: i18n._(CAPTCHA_SETUP_BODY_DESCRIPTOR)},
|
|
];
|
|
return (
|
|
<section className={styles.step} data-flx={`app.self-hosted-setup-wizard-gate.integration-${kind}-step`}>
|
|
<StepHeader title={i18n._(copy.title)} body={i18n._(copy.body)} />
|
|
<RadioGroup
|
|
options={buildIntegrationModeOptions(i18n)}
|
|
value={mode}
|
|
onChange={setMode}
|
|
disabled={disabled}
|
|
aria-label={i18n._(copy.title)}
|
|
/>
|
|
{mode === 'configure' && (
|
|
<div className={styles.integrationFields} data-flx="app.self-hosted-setup-wizard-gate.integration-fields">
|
|
{kind === 'gif' && (
|
|
<Input
|
|
label={i18n._(API_KEY_LABEL_DESCRIPTOR)}
|
|
value={draft.klipyApiKey}
|
|
onChange={(event) => onDraftChange({klipyApiKey: event.target.value})}
|
|
disabled={disabled}
|
|
/>
|
|
)}
|
|
{kind === 'youtube' && (
|
|
<Input
|
|
label={i18n._(API_KEY_LABEL_DESCRIPTOR)}
|
|
value={draft.youtubeApiKey}
|
|
onChange={(event) => onDraftChange({youtubeApiKey: event.target.value})}
|
|
disabled={disabled}
|
|
/>
|
|
)}
|
|
{kind === 'captcha' && (
|
|
<>
|
|
<div className={styles.fieldLabel}>{i18n._(PROVIDER_LABEL_DESCRIPTOR)}</div>
|
|
<RadioGroup
|
|
options={captchaOptions}
|
|
value={draft.captchaProvider}
|
|
onChange={(value) => onDraftChange({captchaProvider: value})}
|
|
disabled={disabled}
|
|
aria-label={i18n._(PROVIDER_LABEL_DESCRIPTOR)}
|
|
/>
|
|
<Input
|
|
label={i18n._(SITE_KEY_LABEL_DESCRIPTOR)}
|
|
value={draft.captchaProvider === 'hcaptcha' ? draft.hcaptchaSiteKey : draft.turnstileSiteKey}
|
|
onChange={(event) =>
|
|
onDraftChange(
|
|
draft.captchaProvider === 'hcaptcha'
|
|
? {hcaptchaSiteKey: event.target.value}
|
|
: {turnstileSiteKey: event.target.value},
|
|
)
|
|
}
|
|
disabled={disabled}
|
|
/>
|
|
<Input
|
|
label={i18n._(SECRET_KEY_LABEL_DESCRIPTOR)}
|
|
type="password"
|
|
value={draft.captchaProvider === 'hcaptcha' ? draft.hcaptchaSecretKey : draft.turnstileSecretKey}
|
|
onChange={(event) =>
|
|
onDraftChange(
|
|
draft.captchaProvider === 'hcaptcha'
|
|
? {hcaptchaSecretKey: event.target.value}
|
|
: {turnstileSecretKey: event.target.value},
|
|
)
|
|
}
|
|
disabled={disabled}
|
|
/>
|
|
</>
|
|
)}
|
|
{kind === 'email' && (
|
|
<>
|
|
<Switch
|
|
label={i18n._(ENABLE_EMAIL_LABEL_DESCRIPTOR)}
|
|
value={draft.emailEnabled}
|
|
onChange={(value) => onDraftChange({emailEnabled: value})}
|
|
disabled={disabled}
|
|
/>
|
|
<Input
|
|
label={i18n._(EMAIL_FROM_EMAIL_LABEL_DESCRIPTOR)}
|
|
value={draft.emailFromEmail}
|
|
onChange={(event) => onDraftChange({emailFromEmail: event.target.value})}
|
|
disabled={disabled}
|
|
/>
|
|
<Input
|
|
label={i18n._(EMAIL_FROM_NAME_LABEL_DESCRIPTOR)}
|
|
value={draft.emailFromName}
|
|
onChange={(event) => onDraftChange({emailFromName: event.target.value})}
|
|
disabled={disabled}
|
|
/>
|
|
<div className={styles.integrationGrid}>
|
|
<Input
|
|
label={i18n._(SMTP_HOST_LABEL_DESCRIPTOR)}
|
|
value={draft.smtpHost}
|
|
onChange={(event) => onDraftChange({smtpHost: event.target.value})}
|
|
disabled={disabled}
|
|
/>
|
|
<Input
|
|
label={i18n._(SMTP_PORT_LABEL_DESCRIPTOR)}
|
|
inputMode="numeric"
|
|
value={draft.smtpPort}
|
|
onChange={(event) => onDraftChange({smtpPort: event.target.value})}
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
<Input
|
|
label={i18n._(SMTP_USERNAME_LABEL_DESCRIPTOR)}
|
|
value={draft.smtpUsername}
|
|
onChange={(event) => onDraftChange({smtpUsername: event.target.value})}
|
|
disabled={disabled}
|
|
/>
|
|
<Input
|
|
label={i18n._(SMTP_PASSWORD_LABEL_DESCRIPTOR)}
|
|
type="password"
|
|
value={draft.smtpPassword}
|
|
onChange={(event) => onDraftChange({smtpPassword: event.target.value})}
|
|
disabled={disabled}
|
|
/>
|
|
<Switch
|
|
label={i18n._(SMTP_SECURE_LABEL_DESCRIPTOR)}
|
|
value={draft.smtpSecure}
|
|
onChange={(value) => onDraftChange({smtpSecure: value})}
|
|
disabled={disabled}
|
|
/>
|
|
<div className={styles.integrationActionRow}>
|
|
<Button
|
|
variant="secondary"
|
|
small
|
|
submitting={smtpTesting}
|
|
disabled={
|
|
disabled ||
|
|
!draft.smtpHost.trim() ||
|
|
!parseSmtpPort(draft.smtpPort) ||
|
|
!draft.smtpUsername.trim() ||
|
|
!draft.smtpPassword.trim()
|
|
}
|
|
onClick={onTestSmtp}
|
|
>
|
|
{i18n._(SMTP_TEST_DESCRIPTOR)}
|
|
</Button>
|
|
{smtpTestResult && (
|
|
<span className={styles.integrationStatus} role="status">
|
|
{smtpTestResult === 'ok' ? i18n._(SMTP_TEST_OK_DESCRIPTOR) : smtpTestResult}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
{kind === 'bluesky' && (
|
|
<>
|
|
<Switch
|
|
label={i18n._(BLUESKY_ENABLED_LABEL_DESCRIPTOR)}
|
|
value={draft.blueskyEnabled}
|
|
onChange={(value) => onDraftChange({blueskyEnabled: value})}
|
|
disabled={disabled}
|
|
/>
|
|
<Input
|
|
label={i18n._(BLUESKY_CLIENT_NAME_LABEL_DESCRIPTOR)}
|
|
value={draft.blueskyClientName}
|
|
onChange={(event) => onDraftChange({blueskyClientName: event.target.value})}
|
|
disabled={disabled}
|
|
/>
|
|
<Input
|
|
label={i18n._(BLUESKY_CLIENT_URI_LABEL_DESCRIPTOR)}
|
|
value={draft.blueskyClientUri}
|
|
onChange={(event) => onDraftChange({blueskyClientUri: event.target.value})}
|
|
disabled={disabled}
|
|
/>
|
|
<Input
|
|
label={i18n._(BLUESKY_LOGO_URI_LABEL_DESCRIPTOR)}
|
|
value={draft.blueskyLogoUri}
|
|
onChange={(event) => onDraftChange({blueskyLogoUri: event.target.value})}
|
|
disabled={disabled}
|
|
/>
|
|
<div className={styles.integrationGrid}>
|
|
<Input
|
|
label={i18n._(BLUESKY_TOS_URI_LABEL_DESCRIPTOR)}
|
|
value={draft.blueskyTosUri}
|
|
onChange={(event) => onDraftChange({blueskyTosUri: event.target.value})}
|
|
disabled={disabled}
|
|
/>
|
|
<Input
|
|
label={i18n._(BLUESKY_POLICY_URI_LABEL_DESCRIPTOR)}
|
|
value={draft.blueskyPolicyUri}
|
|
onChange={(event) => onDraftChange({blueskyPolicyUri: event.target.value})}
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
<Input
|
|
label={i18n._(BLUESKY_KEY_ID_LABEL_DESCRIPTOR)}
|
|
value={draft.blueskyKeyId}
|
|
onChange={(event) => onDraftChange({blueskyKeyId: event.target.value})}
|
|
disabled={disabled}
|
|
/>
|
|
<Input
|
|
label={i18n._(BLUESKY_PRIVATE_KEY_LABEL_DESCRIPTOR)}
|
|
type="password"
|
|
value={draft.blueskyPrivateKey}
|
|
onChange={(event) => onDraftChange({blueskyPrivateKey: event.target.value})}
|
|
disabled={disabled}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
},
|
|
);
|
|
|
|
export const ServicesStep = observer(
|
|
({
|
|
available,
|
|
selection,
|
|
disabled,
|
|
onToggle,
|
|
}: {
|
|
available: ServiceAvailability;
|
|
selection: ServiceSelection;
|
|
disabled: boolean;
|
|
onToggle: (service: keyof ServiceSelection, value: boolean) => void;
|
|
}) => {
|
|
const {i18n} = useLingui();
|
|
const anyAvailable = available.gif || available.youtube || available.bluesky;
|
|
return (
|
|
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.services-step">
|
|
<StepHeader title={i18n._(SERVICES_TITLE_DESCRIPTOR)} body={i18n._(SERVICES_BODY_DESCRIPTOR)} />
|
|
{!anyAvailable && (
|
|
<p className={styles.emptyNote} data-flx="app.self-hosted-setup-wizard-gate.services-empty">
|
|
{i18n._(SERVICES_NONE_DESCRIPTOR)}
|
|
</p>
|
|
)}
|
|
{available.gif && (
|
|
<Switch
|
|
label={i18n._(SERVICE_GIF_LABEL_DESCRIPTOR)}
|
|
description={i18n._(SERVICE_GIF_DESC_DESCRIPTOR)}
|
|
value={selection.gif}
|
|
onChange={(value) => onToggle('gif', value)}
|
|
disabled={disabled}
|
|
data-flx="app.self-hosted-setup-wizard-gate.service-gif-switch"
|
|
/>
|
|
)}
|
|
{available.youtube && (
|
|
<Switch
|
|
label={i18n._(SERVICE_YOUTUBE_LABEL_DESCRIPTOR)}
|
|
description={i18n._(SERVICE_YOUTUBE_DESC_DESCRIPTOR)}
|
|
value={selection.youtube}
|
|
onChange={(value) => onToggle('youtube', value)}
|
|
disabled={disabled}
|
|
data-flx="app.self-hosted-setup-wizard-gate.service-youtube-switch"
|
|
/>
|
|
)}
|
|
{available.bluesky && (
|
|
<Switch
|
|
label={i18n._(SERVICE_BLUESKY_LABEL_DESCRIPTOR)}
|
|
description={i18n._(SERVICE_BLUESKY_DESC_DESCRIPTOR)}
|
|
value={selection.bluesky}
|
|
onChange={(value) => onToggle('bluesky', value)}
|
|
disabled={disabled}
|
|
data-flx="app.self-hosted-setup-wizard-gate.service-bluesky-switch"
|
|
/>
|
|
)}
|
|
</section>
|
|
);
|
|
},
|
|
);
|
|
|
|
export const PremiumStep = observer(
|
|
({mode, disabled, onChange}: {mode: PremiumMode; disabled: boolean; onChange: (mode: PremiumMode) => void}) => {
|
|
const {i18n} = useLingui();
|
|
const options: ReadonlyArray<RadioOption<PremiumMode>> = [
|
|
{
|
|
value: 'mirror',
|
|
name: i18n._(PREMIUM_MIRROR_NAME_DESCRIPTOR),
|
|
desc: i18n._(PREMIUM_MIRROR_DESC_DESCRIPTOR),
|
|
},
|
|
{
|
|
value: 'everyone',
|
|
name: i18n._(PREMIUM_EVERYONE_NAME_DESCRIPTOR),
|
|
desc: i18n._(PREMIUM_EVERYONE_DESC_DESCRIPTOR),
|
|
},
|
|
];
|
|
return (
|
|
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.premium-step">
|
|
<StepHeader title={i18n._(PREMIUM_TITLE_DESCRIPTOR)} body={i18n._(PREMIUM_BODY_DESCRIPTOR)} />
|
|
<RadioGroup
|
|
options={options}
|
|
value={mode}
|
|
onChange={onChange}
|
|
disabled={disabled}
|
|
aria-label={i18n._(PREMIUM_TITLE_DESCRIPTOR)}
|
|
data-flx="app.self-hosted-setup-wizard-gate.premium-radio-group"
|
|
/>
|
|
</section>
|
|
);
|
|
},
|
|
);
|
|
|
|
interface SummaryRowProps {
|
|
label: string;
|
|
value: string;
|
|
}
|
|
|
|
const SummaryRow: React.FC<SummaryRowProps> = ({label, value}) => (
|
|
<div className={styles.summaryRow} data-flx="app.self-hosted-setup-wizard-gate.summary-row">
|
|
<span className={styles.summaryLabel} data-flx="app.self-hosted-setup-wizard-gate.summary-label">
|
|
{label}
|
|
</span>
|
|
<span className={styles.summaryValue} data-flx="app.self-hosted-setup-wizard-gate.summary-value">
|
|
{value}
|
|
</span>
|
|
</div>
|
|
);
|
|
|
|
export const FinishStep = observer(
|
|
({
|
|
productName,
|
|
registrationMode,
|
|
singleCommunityEnabled,
|
|
directMessagesDisabled,
|
|
attachmentExpiryEnabled,
|
|
premiumMode,
|
|
submitError,
|
|
}: {
|
|
productName: string;
|
|
registrationMode: RegistrationMode;
|
|
singleCommunityEnabled: boolean;
|
|
directMessagesDisabled: boolean;
|
|
attachmentExpiryEnabled: boolean;
|
|
premiumMode: PremiumMode;
|
|
submitError: string | null;
|
|
}) => {
|
|
const {i18n} = useLingui();
|
|
const registrationLabel =
|
|
registrationMode === 'open'
|
|
? i18n._(REGISTRATION_OPEN_NAME_DESCRIPTOR)
|
|
: registrationMode === 'approval'
|
|
? i18n._(REGISTRATION_APPROVAL_NAME_DESCRIPTOR)
|
|
: i18n._(REGISTRATION_CLOSED_NAME_DESCRIPTOR);
|
|
const premiumLabel =
|
|
premiumMode === 'mirror' ? i18n._(PREMIUM_MIRROR_NAME_DESCRIPTOR) : i18n._(PREMIUM_EVERYONE_NAME_DESCRIPTOR);
|
|
const onLabel = i18n._(SUMMARY_ON_DESCRIPTOR);
|
|
const offLabel = i18n._(SUMMARY_OFF_DESCRIPTOR);
|
|
return (
|
|
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.finish-step">
|
|
<StepHeader title={i18n._(FINISH_TITLE_DESCRIPTOR)} body={i18n._(FINISH_BODY_DESCRIPTOR)} />
|
|
<div className={styles.summary} data-flx="app.self-hosted-setup-wizard-gate.summary">
|
|
<SummaryRow label={i18n._(SUMMARY_PRODUCT_NAME_DESCRIPTOR)} value={productName} />
|
|
<SummaryRow label={i18n._(SUMMARY_REGISTRATION_DESCRIPTOR)} value={registrationLabel} />
|
|
<SummaryRow
|
|
label={i18n._(SUMMARY_SINGLE_COMMUNITY_DESCRIPTOR)}
|
|
value={singleCommunityEnabled ? onLabel : offLabel}
|
|
/>
|
|
<SummaryRow
|
|
label={i18n._(SUMMARY_DIRECT_MESSAGES_DESCRIPTOR)}
|
|
value={directMessagesDisabled ? offLabel : onLabel}
|
|
/>
|
|
<SummaryRow
|
|
label={i18n._(SUMMARY_ATTACHMENT_EXPIRY_DESCRIPTOR)}
|
|
value={attachmentExpiryEnabled ? onLabel : offLabel}
|
|
/>
|
|
<SummaryRow label={i18n._(SUMMARY_PREMIUM_DESCRIPTOR)} value={premiumLabel} />
|
|
</div>
|
|
{submitError && (
|
|
<p className={styles.submitError} role="alert" data-flx="app.self-hosted-setup-wizard-gate.submit-error">
|
|
{submitError}
|
|
</p>
|
|
)}
|
|
</section>
|
|
);
|
|
},
|
|
);
|