From 0719815574bcde962c15d5a6810ae808e4e04537 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Wed, 19 Aug 2026 06:11:01 -0500 Subject: [PATCH 01/19] Moved email settings out of the settings quarantine (#30090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref Fourth chunk out of `settings/app/`: `email/*` and the shared `email-design/*` now live at `settings/email` and `settings/email-design`, beside the email acceptance tests. Moving them enables the type-aware lint rules the rest of admin uses, so that debt is cleared for these files: - async handlers passed to void callbacks are wrapped; fire-and-forget promises get `void` - `searchKeywords` → `email/search-keywords.ts` (email-settings) and `email/emails-search-keywords.ts` (emails) - `EmailDesignContext` split the way admin's other contexts are: context + `useEmailDesign` in `email-design-context.ts`, `EmailDesignProvider` in `email-design-provider.tsx` - two `act(async …)` callbacks with no awaits made sync in the customize-modal unit test --- .../src/settings/app/components/settings.tsx | 4 +-- .../email-design/email-design-context.tsx | 28 ------------------- .../settings/search-keywords.test.ts | 4 +-- .../src/settings/app/components/sidebar.tsx | 4 +-- .../design-fields/background-color-field.tsx | 2 +- .../design-fields/body-font-field.tsx | 2 +- .../design-fields/button-color-field.tsx | 2 +- .../design-fields/button-corners-field.tsx | 2 +- .../design-fields/button-style-field.tsx | 2 +- .../design-fields/color-fields.test.tsx | 16 +++++------ .../design-fields/color-swatch-helpers.ts | 0 .../design-fields/divider-color-field.tsx | 2 +- .../design-fields/font-constants.ts | 0 .../design-fields/header-background-field.tsx | 2 +- .../design-fields/heading-font-field.tsx | 2 +- .../design-fields/heading-weight-field.tsx | 2 +- .../design-fields/image-corners-field.tsx | 2 +- .../email-design/design-fields/index.ts | 0 .../design-fields/link-color-field.tsx | 2 +- .../design-fields/link-style-field.tsx | 2 +- .../section-title-color-field.tsx | 2 +- .../email-design/design-utils.ts | 0 .../email-design/email-design-context.ts | 18 ++++++++++++ .../email-design/email-design-modal.tsx | 0 .../email-design/email-design-provider.tsx | 12 ++++++++ .../email-design/email-preview.tsx | 0 .../email-design/header-image-field.tsx | 2 +- .../email-design/link-style.test.tsx | 8 +++--- .../section-title-color.test.tsx | 6 ++-- .../email-design/show-badge-field.tsx | 0 .../settings => }/email-design/types.ts | 0 .../welcome-email-customize-modal.test.tsx | 14 +++++----- .../welcome-email-preview-content.tsx | 0 .../email/default-recipients.tsx | 0 .../settings => }/email/email-settings.tsx | 8 +----- .../settings/email/emails-search-keywords.ts | 6 ++++ .../components/settings => }/email/emails.tsx | 10 ++----- .../email/enable-newsletters.tsx | 2 +- .../settings => }/email/mailgun.tsx | 2 +- .../settings => }/email/newsletters.tsx | 6 ++-- .../newsletters/add-newsletter-modal.tsx | 0 .../newsletters/newsletter-detail-modal.tsx | 6 ++-- .../newsletter-preview-content.tsx | 0 .../email/newsletters/newsletter-preview.tsx | 0 .../email/newsletters/newsletters-list.tsx | 0 .../newsletters/newsletters-tab-content.tsx | 6 ++-- .../src/settings/email/search-keywords.ts | 6 ++++ .../email/use-default-recipients-options.tsx | 0 .../member-emails/design-payload.test.ts | 2 +- .../member-emails/design-payload.ts | 2 +- .../welcome-email-customize-modal.tsx | 16 +++++------ apps/admin/src/settings/routes.tsx | 4 +-- 52 files changed, 110 insertions(+), 108 deletions(-) delete mode 100644 apps/admin/src/settings/app/components/settings/email-design/email-design-context.tsx rename apps/admin/src/settings/{app/components/settings => }/email-design/design-fields/background-color-field.tsx (85%) rename apps/admin/src/settings/{app/components/settings => }/email-design/design-fields/body-font-field.tsx (91%) rename apps/admin/src/settings/{app/components/settings => }/email-design/design-fields/button-color-field.tsx (89%) rename apps/admin/src/settings/{app/components/settings => }/email-design/design-fields/button-corners-field.tsx (90%) rename apps/admin/src/settings/{app/components/settings => }/email-design/design-fields/button-style-field.tsx (88%) rename apps/admin/src/settings/{app/components/settings => }/email-design/design-fields/color-fields.test.tsx (89%) rename apps/admin/src/settings/{app/components/settings => }/email-design/design-fields/color-swatch-helpers.ts (100%) rename apps/admin/src/settings/{app/components/settings => }/email-design/design-fields/divider-color-field.tsx (88%) rename apps/admin/src/settings/{app/components/settings => }/email-design/design-fields/font-constants.ts (100%) rename apps/admin/src/settings/{app/components/settings => }/email-design/design-fields/header-background-field.tsx (86%) rename apps/admin/src/settings/{app/components/settings => }/email-design/design-fields/heading-font-field.tsx (92%) rename apps/admin/src/settings/{app/components/settings => }/email-design/design-fields/heading-weight-field.tsx (92%) rename apps/admin/src/settings/{app/components/settings => }/email-design/design-fields/image-corners-field.tsx (89%) rename apps/admin/src/settings/{app/components/settings => }/email-design/design-fields/index.ts (100%) rename apps/admin/src/settings/{app/components/settings => }/email-design/design-fields/link-color-field.tsx (89%) rename apps/admin/src/settings/{app/components/settings => }/email-design/design-fields/link-style-field.tsx (89%) rename apps/admin/src/settings/{app/components/settings => }/email-design/design-fields/section-title-color-field.tsx (90%) rename apps/admin/src/settings/{app/components/settings => }/email-design/design-utils.ts (100%) create mode 100644 apps/admin/src/settings/email-design/email-design-context.ts rename apps/admin/src/settings/{app/components/settings => }/email-design/email-design-modal.tsx (100%) create mode 100644 apps/admin/src/settings/email-design/email-design-provider.tsx rename apps/admin/src/settings/{app/components/settings => }/email-design/email-preview.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/email-design/header-image-field.tsx (96%) rename apps/admin/src/settings/{app/components/settings => }/email-design/link-style.test.tsx (72%) rename apps/admin/src/settings/{app/components/settings => email-design}/section-title-color.test.tsx (79%) rename apps/admin/src/settings/{app/components/settings => }/email-design/show-badge-field.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/email-design/types.ts (100%) rename apps/admin/src/settings/{app/components/settings => }/email-design/welcome-email-customize-modal.test.tsx (84%) rename apps/admin/src/settings/{app/components/settings => }/email-design/welcome-email-preview-content.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/email/default-recipients.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/email/email-settings.tsx (82%) create mode 100644 apps/admin/src/settings/email/emails-search-keywords.ts rename apps/admin/src/settings/{app/components/settings => }/email/emails.tsx (95%) rename apps/admin/src/settings/{app/components/settings => }/email/enable-newsletters.tsx (97%) rename apps/admin/src/settings/{app/components/settings => }/email/mailgun.tsx (99%) rename apps/admin/src/settings/{app/components/settings => }/email/newsletters.tsx (98%) rename apps/admin/src/settings/{app/components/settings => }/email/newsletters/add-newsletter-modal.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/email/newsletters/newsletter-detail-modal.tsx (99%) rename apps/admin/src/settings/{app/components/settings => }/email/newsletters/newsletter-preview-content.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/email/newsletters/newsletter-preview.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/email/newsletters/newsletters-list.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/email/newsletters/newsletters-tab-content.tsx (97%) create mode 100644 apps/admin/src/settings/email/search-keywords.ts rename apps/admin/src/settings/{app/components/settings => }/email/use-default-recipients-options.tsx (100%) diff --git a/apps/admin/src/settings/app/components/settings.tsx b/apps/admin/src/settings/app/components/settings.tsx index a6adbde658a..f5b419a15aa 100644 --- a/apps/admin/src/settings/app/components/settings.tsx +++ b/apps/admin/src/settings/app/components/settings.tsx @@ -1,8 +1,8 @@ import React from 'react'; import AdvancedSettings from './settings/advanced/advanced-settings'; -import EmailSettings from './settings/email/email-settings'; -import Emails from './settings/email/emails'; +import EmailSettings from '@/settings/email/email-settings'; +import Emails from '@/settings/email/emails'; import GeneralSettings from '@/settings/general/general-settings'; import GrowthSettings from '@/settings/growth/growth-settings'; import MembershipSettings from '@/settings/membership/membership-settings'; diff --git a/apps/admin/src/settings/app/components/settings/email-design/email-design-context.tsx b/apps/admin/src/settings/app/components/settings/email-design/email-design-context.tsx deleted file mode 100644 index 416565488a0..00000000000 --- a/apps/admin/src/settings/app/components/settings/email-design/email-design-context.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import {type ReactNode, createContext, useContext} from 'react'; -import type {EmailDesignSettings} from './types'; - -interface EmailDesignContextValue { - settings: EmailDesignSettings; - onSettingsChange: (updates: Partial) => void; - accentColor: string; -} - -const EmailDesignContext = createContext(undefined); - -interface EmailDesignProviderProps extends EmailDesignContextValue { - children: ReactNode; -} - -export const EmailDesignProvider = ({settings, onSettingsChange, accentColor, children}: EmailDesignProviderProps) => ( - - {children} - -); - -export const useEmailDesign = (): EmailDesignContextValue => { - const ctx = useContext(EmailDesignContext); - if (!ctx) { - throw new Error('useEmailDesign must be used within an EmailDesignProvider'); - } - return ctx; -}; diff --git a/apps/admin/src/settings/app/components/settings/search-keywords.test.ts b/apps/admin/src/settings/app/components/settings/search-keywords.test.ts index bd972b1b5b1..db15176818b 100644 --- a/apps/admin/src/settings/app/components/settings/search-keywords.test.ts +++ b/apps/admin/src/settings/app/components/settings/search-keywords.test.ts @@ -1,6 +1,6 @@ import * as assert from 'assert/strict'; -import {searchKeywords as emailSearchKeywords} from '@/settings/app/components/settings/email/email-settings'; -import {searchKeywords as emailsSearchKeywords} from '@/settings/app/components/settings/email/emails'; +import {searchKeywords as emailSearchKeywords} from '@/settings/email/search-keywords'; +import {searchKeywords as emailsSearchKeywords} from '@/settings/email/emails-search-keywords'; import {searchKeywords as generalSearchKeywords} from '@/settings/general/search-keywords'; import {searchKeywords as membershipSearchKeywords} from '@/settings/membership/search-keywords'; diff --git a/apps/admin/src/settings/app/components/sidebar.tsx b/apps/admin/src/settings/app/components/sidebar.tsx index 587823002e3..b13bcc77a58 100644 --- a/apps/admin/src/settings/app/components/sidebar.tsx +++ b/apps/admin/src/settings/app/components/sidebar.tsx @@ -10,8 +10,8 @@ import {useFocusContext} from '@tryghost/shade/app'; import {checkStripeEnabled, getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {searchKeywords as advancedSearchKeywords} from './settings/advanced/advanced-settings'; -import {searchKeywords as emailSearchKeywords} from './settings/email/email-settings'; -import {searchKeywords as emailsSearchKeywords} from './settings/email/emails'; +import {searchKeywords as emailSearchKeywords} from '@/settings/email/search-keywords'; +import {searchKeywords as emailsSearchKeywords} from '@/settings/email/emails-search-keywords'; import {searchKeywords as generalSearchKeywords} from '@/settings/general/search-keywords'; import {searchKeywords as growthSearchKeywords} from '@/settings/growth/search-keywords'; import {searchKeywords as membershipSearchKeywords} from '@/settings/membership/search-keywords'; diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/background-color-field.tsx b/apps/admin/src/settings/email-design/design-fields/background-color-field.tsx similarity index 85% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/background-color-field.tsx rename to apps/admin/src/settings/email-design/design-fields/background-color-field.tsx index 56eab541b02..2887cfad3d7 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/background-color-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/background-color-field.tsx @@ -1,5 +1,5 @@ import ColorPickerField from '@/settings/app/components/color-picker-field'; -import {useEmailDesign} from '@/settings/app/components/settings/email-design/email-design-context'; +import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const BackgroundColorField = () => { const {settings, onSettingsChange} = useEmailDesign(); diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/body-font-field.tsx b/apps/admin/src/settings/email-design/design-fields/body-font-field.tsx similarity index 91% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/body-font-field.tsx rename to apps/admin/src/settings/email-design/design-fields/body-font-field.tsx index f48b0102231..624910c53fe 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/body-font-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/body-font-field.tsx @@ -1,6 +1,6 @@ import {FONT_OPTIONS} from './font-constants'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@tryghost/shade/components'; -import {useEmailDesign} from '@/settings/app/components/settings/email-design/email-design-context'; +import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const BodyFontField = () => { const {settings, onSettingsChange} = useEmailDesign(); diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/button-color-field.tsx b/apps/admin/src/settings/email-design/design-fields/button-color-field.tsx similarity index 89% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/button-color-field.tsx rename to apps/admin/src/settings/email-design/design-fields/button-color-field.tsx index 8811ed0c286..2365a78e0b1 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/button-color-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/button-color-field.tsx @@ -1,6 +1,6 @@ import ColorPickerField from '@/settings/app/components/color-picker-field'; import {getAutoSwatchHex} from './color-swatch-helpers'; -import {useEmailDesign} from '@/settings/app/components/settings/email-design/email-design-context'; +import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const ButtonColorField = () => { const {settings, onSettingsChange, accentColor} = useEmailDesign(); diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/button-corners-field.tsx b/apps/admin/src/settings/email-design/design-fields/button-corners-field.tsx similarity index 90% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/button-corners-field.tsx rename to apps/admin/src/settings/email-design/design-fields/button-corners-field.tsx index ff6a0dbcfd1..1398b946c55 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/button-corners-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/button-corners-field.tsx @@ -1,5 +1,5 @@ import {ToggleGroup, ToggleGroupItem} from '@tryghost/shade/components'; -import {useEmailDesign} from '@/settings/app/components/settings/email-design/email-design-context'; +import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const ButtonCornersField = () => { const {settings, onSettingsChange} = useEmailDesign(); diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/button-style-field.tsx b/apps/admin/src/settings/email-design/design-fields/button-style-field.tsx similarity index 88% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/button-style-field.tsx rename to apps/admin/src/settings/email-design/design-fields/button-style-field.tsx index 0eb524afef0..93fc99392df 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/button-style-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/button-style-field.tsx @@ -1,5 +1,5 @@ import {ToggleGroup, ToggleGroupItem} from '@tryghost/shade/components'; -import {useEmailDesign} from '@/settings/app/components/settings/email-design/email-design-context'; +import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const ButtonStyleField = () => { const {settings, onSettingsChange} = useEmailDesign(); diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/color-fields.test.tsx b/apps/admin/src/settings/email-design/design-fields/color-fields.test.tsx similarity index 89% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/color-fields.test.tsx rename to apps/admin/src/settings/email-design/design-fields/color-fields.test.tsx index f5248ca61ef..771e760de6f 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/color-fields.test.tsx +++ b/apps/admin/src/settings/email-design/design-fields/color-fields.test.tsx @@ -1,13 +1,13 @@ import assert from 'node:assert/strict'; -import {BackgroundColorField} from '@/settings/app/components/settings/email-design/design-fields/background-color-field'; -import {ButtonColorField} from '@/settings/app/components/settings/email-design/design-fields/button-color-field'; -import {DEFAULT_EMAIL_DESIGN} from '@/settings/app/components/settings/email-design/types'; -import {DividerColorField} from '@/settings/app/components/settings/email-design/design-fields/divider-color-field'; -import {EmailDesignProvider} from '@/settings/app/components/settings/email-design/email-design-context'; -import {HeaderBackgroundField} from '@/settings/app/components/settings/email-design/design-fields/header-background-field'; -import {LinkColorField} from '@/settings/app/components/settings/email-design/design-fields/link-color-field'; +import {BackgroundColorField} from '@/settings/email-design/design-fields/background-color-field'; +import {ButtonColorField} from '@/settings/email-design/design-fields/button-color-field'; +import {DEFAULT_EMAIL_DESIGN} from '@/settings/email-design/types'; +import {DividerColorField} from '@/settings/email-design/design-fields/divider-color-field'; +import {EmailDesignProvider} from '@/settings/email-design/email-design-provider'; +import {HeaderBackgroundField} from '@/settings/email-design/design-fields/header-background-field'; +import {LinkColorField} from '@/settings/email-design/design-fields/link-color-field'; import {type ReactNode} from 'react'; -import {SectionTitleColorField} from '@/settings/app/components/settings/email-design/design-fields/section-title-color-field'; +import {SectionTitleColorField} from '@/settings/email-design/design-fields/section-title-color-field'; import {act, fireEvent, render, screen} from '@testing-library/react'; type EmailDesignUpdate = Partial; diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/color-swatch-helpers.ts b/apps/admin/src/settings/email-design/design-fields/color-swatch-helpers.ts similarity index 100% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/color-swatch-helpers.ts rename to apps/admin/src/settings/email-design/design-fields/color-swatch-helpers.ts diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/divider-color-field.tsx b/apps/admin/src/settings/email-design/design-fields/divider-color-field.tsx similarity index 88% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/divider-color-field.tsx rename to apps/admin/src/settings/email-design/design-fields/divider-color-field.tsx index d63cbc84aa4..db1fa246a29 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/divider-color-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/divider-color-field.tsx @@ -1,5 +1,5 @@ import ColorPickerField from '@/settings/app/components/color-picker-field'; -import {useEmailDesign} from '@/settings/app/components/settings/email-design/email-design-context'; +import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const DividerColorField = () => { const {settings, onSettingsChange, accentColor} = useEmailDesign(); diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/font-constants.ts b/apps/admin/src/settings/email-design/design-fields/font-constants.ts similarity index 100% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/font-constants.ts rename to apps/admin/src/settings/email-design/design-fields/font-constants.ts diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/header-background-field.tsx b/apps/admin/src/settings/email-design/design-fields/header-background-field.tsx similarity index 86% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/header-background-field.tsx rename to apps/admin/src/settings/email-design/design-fields/header-background-field.tsx index f6ea3c767a1..e4432882326 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/header-background-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/header-background-field.tsx @@ -1,5 +1,5 @@ import ColorPickerField from '@/settings/app/components/color-picker-field'; -import {useEmailDesign} from '@/settings/app/components/settings/email-design/email-design-context'; +import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const HeaderBackgroundField = () => { const {settings, onSettingsChange} = useEmailDesign(); diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/heading-font-field.tsx b/apps/admin/src/settings/email-design/design-fields/heading-font-field.tsx similarity index 92% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/heading-font-field.tsx rename to apps/admin/src/settings/email-design/design-fields/heading-font-field.tsx index 2fd6abdb609..18834f787e3 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/heading-font-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/heading-font-field.tsx @@ -1,6 +1,6 @@ import {FONT_OPTIONS, getValidWeight} from './font-constants'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@tryghost/shade/components'; -import {useEmailDesign} from '@/settings/app/components/settings/email-design/email-design-context'; +import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const HeadingFontField = () => { const {settings, onSettingsChange} = useEmailDesign(); diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/heading-weight-field.tsx b/apps/admin/src/settings/email-design/design-fields/heading-weight-field.tsx similarity index 92% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/heading-weight-field.tsx rename to apps/admin/src/settings/email-design/design-fields/heading-weight-field.tsx index 72e8b340b43..1d19b293ac8 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/heading-weight-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/heading-weight-field.tsx @@ -1,6 +1,6 @@ import {FONT_WEIGHT_OPTIONS, getValidWeight} from './font-constants'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@tryghost/shade/components'; -import {useEmailDesign} from '@/settings/app/components/settings/email-design/email-design-context'; +import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const HeadingWeightField = () => { const {settings, onSettingsChange} = useEmailDesign(); diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/image-corners-field.tsx b/apps/admin/src/settings/email-design/design-fields/image-corners-field.tsx similarity index 89% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/image-corners-field.tsx rename to apps/admin/src/settings/email-design/design-fields/image-corners-field.tsx index 6c0be91c2a9..a16de048961 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/image-corners-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/image-corners-field.tsx @@ -1,5 +1,5 @@ import {ToggleGroup, ToggleGroupItem} from '@tryghost/shade/components'; -import {useEmailDesign} from '@/settings/app/components/settings/email-design/email-design-context'; +import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const ImageCornersField = () => { const {settings, onSettingsChange} = useEmailDesign(); diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/index.ts b/apps/admin/src/settings/email-design/design-fields/index.ts similarity index 100% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/index.ts rename to apps/admin/src/settings/email-design/design-fields/index.ts diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/link-color-field.tsx b/apps/admin/src/settings/email-design/design-fields/link-color-field.tsx similarity index 89% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/link-color-field.tsx rename to apps/admin/src/settings/email-design/design-fields/link-color-field.tsx index 16b29ad75d6..34639d46b37 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/link-color-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/link-color-field.tsx @@ -1,6 +1,6 @@ import ColorPickerField from '@/settings/app/components/color-picker-field'; import {getAutoSwatchHex} from './color-swatch-helpers'; -import {useEmailDesign} from '@/settings/app/components/settings/email-design/email-design-context'; +import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const LinkColorField = () => { const {settings, onSettingsChange, accentColor} = useEmailDesign(); diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/link-style-field.tsx b/apps/admin/src/settings/email-design/design-fields/link-style-field.tsx similarity index 89% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/link-style-field.tsx rename to apps/admin/src/settings/email-design/design-fields/link-style-field.tsx index d03afc17f53..d1be2554678 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/link-style-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/link-style-field.tsx @@ -1,6 +1,6 @@ import {Bold, Type, Underline} from 'lucide-react'; import {ToggleGroup, ToggleGroupItem} from '@tryghost/shade/components'; -import {useEmailDesign} from '@/settings/app/components/settings/email-design/email-design-context'; +import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const LinkStyleField = () => { const {settings, onSettingsChange} = useEmailDesign(); diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/section-title-color-field.tsx b/apps/admin/src/settings/email-design/design-fields/section-title-color-field.tsx similarity index 90% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/section-title-color-field.tsx rename to apps/admin/src/settings/email-design/design-fields/section-title-color-field.tsx index 5d873932909..ac8999b1b98 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/section-title-color-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/section-title-color-field.tsx @@ -1,6 +1,6 @@ import ColorPickerField from '@/settings/app/components/color-picker-field'; import {getAutoSwatchHex} from './color-swatch-helpers'; -import {useEmailDesign} from '@/settings/app/components/settings/email-design/email-design-context'; +import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const SectionTitleColorField = () => { const {settings, onSettingsChange, accentColor} = useEmailDesign(); diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-utils.ts b/apps/admin/src/settings/email-design/design-utils.ts similarity index 100% rename from apps/admin/src/settings/app/components/settings/email-design/design-utils.ts rename to apps/admin/src/settings/email-design/design-utils.ts diff --git a/apps/admin/src/settings/email-design/email-design-context.ts b/apps/admin/src/settings/email-design/email-design-context.ts new file mode 100644 index 00000000000..3b1f3bb806e --- /dev/null +++ b/apps/admin/src/settings/email-design/email-design-context.ts @@ -0,0 +1,18 @@ +import {createContext, useContext} from 'react'; +import type {EmailDesignSettings} from './types'; + +export interface EmailDesignContextValue { + settings: EmailDesignSettings; + onSettingsChange: (updates: Partial) => void; + accentColor: string; +} + +export const EmailDesignContext = createContext(undefined); + +export const useEmailDesign = (): EmailDesignContextValue => { + const ctx = useContext(EmailDesignContext); + if (!ctx) { + throw new Error('useEmailDesign must be used within an EmailDesignProvider'); + } + return ctx; +}; diff --git a/apps/admin/src/settings/app/components/settings/email-design/email-design-modal.tsx b/apps/admin/src/settings/email-design/email-design-modal.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/email-design/email-design-modal.tsx rename to apps/admin/src/settings/email-design/email-design-modal.tsx diff --git a/apps/admin/src/settings/email-design/email-design-provider.tsx b/apps/admin/src/settings/email-design/email-design-provider.tsx new file mode 100644 index 00000000000..271152bb7eb --- /dev/null +++ b/apps/admin/src/settings/email-design/email-design-provider.tsx @@ -0,0 +1,12 @@ +import {type ReactNode} from 'react'; +import {EmailDesignContext, type EmailDesignContextValue} from './email-design-context'; + +interface EmailDesignProviderProps extends EmailDesignContextValue { + children: ReactNode; +} + +export const EmailDesignProvider = ({settings, onSettingsChange, accentColor, children}: EmailDesignProviderProps) => ( + + {children} + +); diff --git a/apps/admin/src/settings/app/components/settings/email-design/email-preview.tsx b/apps/admin/src/settings/email-design/email-preview.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/email-design/email-preview.tsx rename to apps/admin/src/settings/email-design/email-preview.tsx diff --git a/apps/admin/src/settings/app/components/settings/email-design/header-image-field.tsx b/apps/admin/src/settings/email-design/header-image-field.tsx similarity index 96% rename from apps/admin/src/settings/app/components/settings/email-design/header-image-field.tsx rename to apps/admin/src/settings/email-design/header-image-field.tsx index bb9ba72508a..d4ca9f6f3f6 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/header-image-field.tsx +++ b/apps/admin/src/settings/email-design/header-image-field.tsx @@ -46,7 +46,7 @@ const HeaderImageField: React.FC = ({inputId = 'welcome-e files[0] && handleUpload(files[0])} + onDropAccepted={files => files[0] && void handleUpload(files[0])} > Upload header image diff --git a/apps/admin/src/settings/app/components/settings/email-design/link-style.test.tsx b/apps/admin/src/settings/email-design/link-style.test.tsx similarity index 72% rename from apps/admin/src/settings/app/components/settings/email-design/link-style.test.tsx rename to apps/admin/src/settings/email-design/link-style.test.tsx index 6205eb996fc..56e8b15463e 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/link-style.test.tsx +++ b/apps/admin/src/settings/email-design/link-style.test.tsx @@ -1,8 +1,8 @@ -import WelcomeEmailPreviewContent from '@/settings/app/components/settings/email-design/welcome-email-preview-content'; +import WelcomeEmailPreviewContent from '@/settings/email-design/welcome-email-preview-content'; import assert from 'node:assert/strict'; -import {DEFAULT_EMAIL_DESIGN} from '@/settings/app/components/settings/email-design/types'; -import {EmailDesignProvider} from '@/settings/app/components/settings/email-design/email-design-context'; -import {LinkStyleField} from '@/settings/app/components/settings/email-design/design-fields/link-style-field'; +import {DEFAULT_EMAIL_DESIGN} from '@/settings/email-design/types'; +import {EmailDesignProvider} from '@/settings/email-design/email-design-provider'; +import {LinkStyleField} from '@/settings/email-design/design-fields/link-style-field'; import {render, screen} from '@testing-library/react'; describe('Welcome email link styles', function () { diff --git a/apps/admin/src/settings/app/components/settings/section-title-color.test.tsx b/apps/admin/src/settings/email-design/section-title-color.test.tsx similarity index 79% rename from apps/admin/src/settings/app/components/settings/section-title-color.test.tsx rename to apps/admin/src/settings/email-design/section-title-color.test.tsx index 62d74c6b58b..a3708adc208 100644 --- a/apps/admin/src/settings/app/components/settings/section-title-color.test.tsx +++ b/apps/admin/src/settings/email-design/section-title-color.test.tsx @@ -1,8 +1,8 @@ -import WelcomeEmailPreviewContent from '@/settings/app/components/settings/email-design/welcome-email-preview-content'; +import WelcomeEmailPreviewContent from '@/settings/email-design/welcome-email-preview-content'; import assert from 'node:assert/strict'; -import {DEFAULT_EMAIL_DESIGN} from '@/settings/app/components/settings/email-design/types'; +import {DEFAULT_EMAIL_DESIGN} from '@/settings/email-design/types'; import {DesignTab} from '@/settings/membership/member-emails/welcome-email-customize-modal'; -import {EmailDesignProvider} from '@/settings/app/components/settings/email-design/email-design-context'; +import {EmailDesignProvider} from '@/settings/email-design/email-design-provider'; import {render, screen} from '@testing-library/react'; describe('Section title color', function () { diff --git a/apps/admin/src/settings/app/components/settings/email-design/show-badge-field.tsx b/apps/admin/src/settings/email-design/show-badge-field.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/email-design/show-badge-field.tsx rename to apps/admin/src/settings/email-design/show-badge-field.tsx diff --git a/apps/admin/src/settings/app/components/settings/email-design/types.ts b/apps/admin/src/settings/email-design/types.ts similarity index 100% rename from apps/admin/src/settings/app/components/settings/email-design/types.ts rename to apps/admin/src/settings/email-design/types.ts diff --git a/apps/admin/src/settings/app/components/settings/email-design/welcome-email-customize-modal.test.tsx b/apps/admin/src/settings/email-design/welcome-email-customize-modal.test.tsx similarity index 84% rename from apps/admin/src/settings/app/components/settings/email-design/welcome-email-customize-modal.test.tsx rename to apps/admin/src/settings/email-design/welcome-email-customize-modal.test.tsx index ac651e61d05..c0ebb348d1f 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/welcome-email-customize-modal.test.tsx +++ b/apps/admin/src/settings/email-design/welcome-email-customize-modal.test.tsx @@ -1,9 +1,9 @@ -import EmailDesignModal from '@/settings/app/components/settings/email-design/email-design-modal'; +import EmailDesignModal from '@/settings/email-design/email-design-modal'; import React, {useState} from 'react'; import assert from 'node:assert/strict'; -import {ButtonColorField} from '@/settings/app/components/settings/email-design/design-fields/button-color-field'; -import {DEFAULT_EMAIL_DESIGN} from '@/settings/app/components/settings/email-design/types'; -import {EmailDesignProvider} from '@/settings/app/components/settings/email-design/email-design-context'; +import {ButtonColorField} from '@/settings/email-design/design-fields/button-color-field'; +import {DEFAULT_EMAIL_DESIGN} from '@/settings/email-design/types'; +import {EmailDesignProvider} from '@/settings/email-design/email-design-provider'; import {act, fireEvent, render, screen} from '@testing-library/react'; vi.mock('@tryghost/shade/components', async () => { @@ -74,7 +74,7 @@ describe('Welcome email customize modal', function () { assert.ok(screen.getByRole('textbox')); - await act(async () => { + act(() => { fireEvent.keyDown(document, {key: 'Escape'}); }); @@ -82,13 +82,13 @@ describe('Welcome email customize modal', function () { assert.ok(screen.getByTestId('welcome-email-customize-modal')); }); - it('keeps the customize modal open when Escape is pressed immediately after opening the color picker', async function () { + it('keeps the customize modal open when Escape is pressed immediately after opening the color picker', function () { vi.useFakeTimers(); const onClose = vi.fn(); render(); - await act(async () => { + act(() => { fireEvent.click(screen.getByText('Button color')); assert.equal(screen.queryByRole('textbox'), null); fireEvent.keyDown(document, {key: 'Escape'}); diff --git a/apps/admin/src/settings/app/components/settings/email-design/welcome-email-preview-content.tsx b/apps/admin/src/settings/email-design/welcome-email-preview-content.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/email-design/welcome-email-preview-content.tsx rename to apps/admin/src/settings/email-design/welcome-email-preview-content.tsx diff --git a/apps/admin/src/settings/app/components/settings/email/default-recipients.tsx b/apps/admin/src/settings/email/default-recipients.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/email/default-recipients.tsx rename to apps/admin/src/settings/email/default-recipients.tsx diff --git a/apps/admin/src/settings/app/components/settings/email/email-settings.tsx b/apps/admin/src/settings/email/email-settings.tsx similarity index 82% rename from apps/admin/src/settings/app/components/settings/email/email-settings.tsx rename to apps/admin/src/settings/email/email-settings.tsx index d947b37b390..f8e70fc3ffb 100644 --- a/apps/admin/src/settings/app/components/settings/email/email-settings.tsx +++ b/apps/admin/src/settings/email/email-settings.tsx @@ -6,13 +6,7 @@ import React from 'react'; import SearchableSection from '@/settings/app/components/searchable-section'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; - -export const searchKeywords = { - enableNewsletters: ['emails', 'newsletters', 'newsletter sending', 'enable', 'disable', 'turn on', 'turn off'], - newsletters: ['newsletters', 'emails', 'design', 'customization'], - defaultRecipients: ['newsletters', 'default recipients', 'emails'], - mailgun: ['mailgun', 'emails', 'newsletters'] -}; +import {searchKeywords} from './search-keywords'; const EmailSettings: React.FC = () => { const {settings, config} = useGlobalData(); diff --git a/apps/admin/src/settings/email/emails-search-keywords.ts b/apps/admin/src/settings/email/emails-search-keywords.ts new file mode 100644 index 00000000000..a30d4f1229f --- /dev/null +++ b/apps/admin/src/settings/email/emails-search-keywords.ts @@ -0,0 +1,6 @@ +export const searchKeywords = { + enableNewsletters: ['emails', 'newsletters', 'newsletter sending', 'enable', 'disable', 'turn on', 'turn off'], + emails: ['emails', 'newsletters', 'automation emails', 'transactional', 'design', 'customization', 'automations', 'welcome'], + defaultRecipients: ['newsletters', 'default recipients', 'emails'], + mailgun: ['mailgun', 'emails', 'newsletters'] +}; diff --git a/apps/admin/src/settings/app/components/settings/email/emails.tsx b/apps/admin/src/settings/email/emails.tsx similarity index 95% rename from apps/admin/src/settings/app/components/settings/email/emails.tsx rename to apps/admin/src/settings/email/emails.tsx index 6f16a945594..297ee8777b5 100644 --- a/apps/admin/src/settings/app/components/settings/email/emails.tsx +++ b/apps/admin/src/settings/email/emails.tsx @@ -16,13 +16,7 @@ import {useGlobalData} from '@/settings/app/components/providers/global-data-pro import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; import {useVerifyAutomatedEmailSender} from '@tryghost/admin-x-framework/api/automated-emails'; - -export const searchKeywords = { - enableNewsletters: ['emails', 'newsletters', 'newsletter sending', 'enable', 'disable', 'turn on', 'turn off'], - emails: ['emails', 'newsletters', 'automation emails', 'transactional', 'design', 'customization', 'automations', 'welcome'], - defaultRecipients: ['newsletters', 'default recipients', 'emails'], - mailgun: ['mailgun', 'emails', 'newsletters'] -}; +import {searchKeywords} from './emails-search-keywords'; const TransactionalTabContent: React.FC = () => { const [isCustomizeOpen, setIsCustomizeOpen] = useState(false); @@ -130,7 +124,7 @@ const EmailsGroup: React.FC<{ keywords: string[]; newslettersEnabled: boolean }> } }; - verify(); + void verify(); }, [confirm, handleError, updateRoute, verifyEmailToken, verifySenderUpdate]); const openNewNewsletter = () => { diff --git a/apps/admin/src/settings/app/components/settings/email/enable-newsletters.tsx b/apps/admin/src/settings/email/enable-newsletters.tsx similarity index 97% rename from apps/admin/src/settings/app/components/settings/email/enable-newsletters.tsx rename to apps/admin/src/settings/email/enable-newsletters.tsx index 95860458acd..e0521dd39fd 100644 --- a/apps/admin/src/settings/app/components/settings/email/enable-newsletters.tsx +++ b/apps/admin/src/settings/email/enable-newsletters.tsx @@ -42,7 +42,7 @@ const EnableNewsletters: React.FC<{ keywords: string[] }> = ({keywords}) => { aria-label='Newsletters' checked={newslettersEnabled !== 'disabled' && !isDisabled} disabled={isDisabled} - onCheckedChange={handleToggleChange} + onCheckedChange={checked => void handleToggleChange(checked)} /> ); diff --git a/apps/admin/src/settings/app/components/settings/email/mailgun.tsx b/apps/admin/src/settings/email/mailgun.tsx similarity index 99% rename from apps/admin/src/settings/app/components/settings/email/mailgun.tsx rename to apps/admin/src/settings/email/mailgun.tsx index 70badb69e5c..f525dc10cb2 100644 --- a/apps/admin/src/settings/app/components/settings/email/mailgun.tsx +++ b/apps/admin/src/settings/email/mailgun.tsx @@ -120,7 +120,7 @@ const MailGun: React.FC<{ keywords: string[] }> = ({keywords}) => { return; } } - handleSave(); + void handleSave(); }} > {isEditing ? inputs : values} diff --git a/apps/admin/src/settings/app/components/settings/email/newsletters.tsx b/apps/admin/src/settings/email/newsletters.tsx similarity index 98% rename from apps/admin/src/settings/app/components/settings/email/newsletters.tsx rename to apps/admin/src/settings/email/newsletters.tsx index 400e157bf9f..c365e52b811 100644 --- a/apps/admin/src/settings/app/components/settings/email/newsletters.tsx +++ b/apps/admin/src/settings/email/newsletters.tsx @@ -90,7 +90,7 @@ const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => { handleError(e, {withToast: false}); } }; - verify(); + void verify(); }, [verifyEmailToken, handleError, verifyEmail, confirm]); const buttons = ( @@ -152,10 +152,10 @@ const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => { Active Archived - + void onSort(id, overId)} /> - {isEnd === false && } diff --git a/apps/admin/src/settings/app/components/settings/email/newsletters/add-newsletter-modal.tsx b/apps/admin/src/settings/email/newsletters/add-newsletter-modal.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/email/newsletters/add-newsletter-modal.tsx rename to apps/admin/src/settings/email/newsletters/add-newsletter-modal.tsx diff --git a/apps/admin/src/settings/app/components/settings/email/newsletters/newsletter-detail-modal.tsx b/apps/admin/src/settings/email/newsletters/newsletter-detail-modal.tsx similarity index 99% rename from apps/admin/src/settings/app/components/settings/email/newsletters/newsletter-detail-modal.tsx rename to apps/admin/src/settings/email/newsletters/newsletter-detail-modal.tsx index a7e3bdc9d36..0c49b37685a 100644 --- a/apps/admin/src/settings/app/components/settings/email/newsletters/newsletter-detail-modal.tsx +++ b/apps/admin/src/settings/email/newsletters/newsletter-detail-modal.tsx @@ -1,5 +1,5 @@ import ColorPickerField from '@/settings/app/components/color-picker-field'; -import HeaderImageField from '@/settings/app/components/settings/email-design/header-image-field'; +import HeaderImageField from '@/settings/email-design/header-image-field'; import HtmlField from '@/settings/app/components/html-field'; import NewsletterPreview from './newsletter-preview'; import React, {useCallback, useEffect, useState} from 'react'; @@ -299,7 +299,7 @@ const Sidebar: React.FC<{
- {newsletter.status === 'active' ? (!onlyOne && ) : } + {newsletter.status === 'active' ? (!onlyOne && ) : }
}, @@ -768,7 +768,7 @@ const NewsletterDetailModal: React.FC = () => { useEffect(() => { if (!newsletter && !isEnd) { - fetchNextPage(); + void fetchNextPage(); } }, [fetchNextPage, isEnd, newsletter]); diff --git a/apps/admin/src/settings/app/components/settings/email/newsletters/newsletter-preview-content.tsx b/apps/admin/src/settings/email/newsletters/newsletter-preview-content.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/email/newsletters/newsletter-preview-content.tsx rename to apps/admin/src/settings/email/newsletters/newsletter-preview-content.tsx diff --git a/apps/admin/src/settings/app/components/settings/email/newsletters/newsletter-preview.tsx b/apps/admin/src/settings/email/newsletters/newsletter-preview.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/email/newsletters/newsletter-preview.tsx rename to apps/admin/src/settings/email/newsletters/newsletter-preview.tsx diff --git a/apps/admin/src/settings/app/components/settings/email/newsletters/newsletters-list.tsx b/apps/admin/src/settings/email/newsletters/newsletters-list.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/email/newsletters/newsletters-list.tsx rename to apps/admin/src/settings/email/newsletters/newsletters-list.tsx diff --git a/apps/admin/src/settings/app/components/settings/email/newsletters/newsletters-tab-content.tsx b/apps/admin/src/settings/email/newsletters/newsletters-tab-content.tsx similarity index 97% rename from apps/admin/src/settings/app/components/settings/email/newsletters/newsletters-tab-content.tsx rename to apps/admin/src/settings/email/newsletters/newsletters-tab-content.tsx index 27434c66435..f40298be456 100644 --- a/apps/admin/src/settings/app/components/settings/email/newsletters/newsletters-tab-content.tsx +++ b/apps/admin/src/settings/email/newsletters/newsletters-tab-content.tsx @@ -97,7 +97,7 @@ const NewslettersTabContent: React.FC = ({filter}) = handleError(e, {withToast: false}); } }; - verify(); + void verify(); }, [verifyEmailToken, handleError, verifyEmail, confirm]); const sortedActiveNewsletters = newsletters.filter(n => n.status === 'active').sort((a, b) => a.sort_order - b.sort_order) || []; @@ -143,11 +143,11 @@ const NewslettersTabContent: React.FC = ({filter}) = return ( <> {showingActive ? ( - + void onSort(id, overId)} /> ) : ( )} - {isEnd === false && } diff --git a/apps/admin/src/settings/email/search-keywords.ts b/apps/admin/src/settings/email/search-keywords.ts new file mode 100644 index 00000000000..0896d5ab039 --- /dev/null +++ b/apps/admin/src/settings/email/search-keywords.ts @@ -0,0 +1,6 @@ +export const searchKeywords = { + enableNewsletters: ['emails', 'newsletters', 'newsletter sending', 'enable', 'disable', 'turn on', 'turn off'], + newsletters: ['newsletters', 'emails', 'design', 'customization'], + defaultRecipients: ['newsletters', 'default recipients', 'emails'], + mailgun: ['mailgun', 'emails', 'newsletters'] +}; diff --git a/apps/admin/src/settings/app/components/settings/email/use-default-recipients-options.tsx b/apps/admin/src/settings/email/use-default-recipients-options.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/email/use-default-recipients-options.tsx rename to apps/admin/src/settings/email/use-default-recipients-options.tsx diff --git a/apps/admin/src/settings/membership/member-emails/design-payload.test.ts b/apps/admin/src/settings/membership/member-emails/design-payload.test.ts index bf548ee5908..45f891df062 100644 --- a/apps/admin/src/settings/membership/member-emails/design-payload.test.ts +++ b/apps/admin/src/settings/membership/member-emails/design-payload.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import {DEFAULT_EMAIL_DESIGN} from '@/settings/app/components/settings/email-design/types'; +import {DEFAULT_EMAIL_DESIGN} from '@/settings/email-design/types'; import {buildAutomatedEmailDesignPayload, mapApiToDesignSettings} from '@/settings/membership/member-emails/design-payload'; describe('Welcome email design payload helpers', function () { diff --git a/apps/admin/src/settings/membership/member-emails/design-payload.ts b/apps/admin/src/settings/membership/member-emails/design-payload.ts index d3b57fca7c3..d7cf5796d83 100644 --- a/apps/admin/src/settings/membership/member-emails/design-payload.ts +++ b/apps/admin/src/settings/membership/member-emails/design-payload.ts @@ -1,5 +1,5 @@ import {type AutomatedEmailDesign, type EditAutomatedEmailDesign} from '@tryghost/admin-x-framework/api/automated-email-design'; -import {DEFAULT_EMAIL_DESIGN, type EmailDesignSettings} from '@/settings/app/components/settings/email-design/types'; +import {DEFAULT_EMAIL_DESIGN, type EmailDesignSettings} from '@/settings/email-design/types'; export interface GeneralSettings { senderName: string; diff --git a/apps/admin/src/settings/membership/member-emails/welcome-email-customize-modal.tsx b/apps/admin/src/settings/membership/member-emails/welcome-email-customize-modal.tsx index 8a8b9dbfe3b..7b33a33c113 100644 --- a/apps/admin/src/settings/membership/member-emails/welcome-email-customize-modal.tsx +++ b/apps/admin/src/settings/membership/member-emails/welcome-email-customize-modal.tsx @@ -1,8 +1,8 @@ -import EmailDesignModal from '@/settings/app/components/settings/email-design/email-design-modal'; -import EmailPreview from '@/settings/app/components/settings/email-design/email-preview'; -import HeaderImageField from '@/settings/app/components/settings/email-design/header-image-field'; -import ShowBadgeField from '@/settings/app/components/settings/email-design/show-badge-field'; -import WelcomeEmailPreviewContent from '@/settings/app/components/settings/email-design/welcome-email-preview-content'; +import EmailDesignModal from '@/settings/email-design/email-design-modal'; +import EmailPreview from '@/settings/email-design/email-preview'; +import HeaderImageField from '@/settings/email-design/header-image-field'; +import ShowBadgeField from '@/settings/email-design/show-badge-field'; +import WelcomeEmailPreviewContent from '@/settings/email-design/welcome-email-preview-content'; import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; import validator from 'validator'; import {useEditAutomatedEmailDesign, useReadAutomatedEmailDesign} from '@tryghost/admin-x-framework/api/automated-email-design'; @@ -20,9 +20,9 @@ import { LinkColorField, LinkStyleField, SectionTitleColorField -} from '@/settings/app/components/settings/email-design/design-fields'; -import {DEFAULT_EMAIL_DESIGN, type EmailDesignSettings} from '@/settings/app/components/settings/email-design/types'; -import {EmailDesignProvider} from '@/settings/app/components/settings/email-design/email-design-context'; +} from '@/settings/email-design/design-fields'; +import {DEFAULT_EMAIL_DESIGN, type EmailDesignSettings} from '@/settings/email-design/types'; +import {EmailDesignProvider} from '@/settings/email-design/email-design-provider'; import {Input, LoadingIndicator, Separator, Switch, Tabs, TabsContent, TabsList, TabsTrigger, Textarea} from '@tryghost/shade/components'; import {WELCOME_EMAIL_SLUGS, type WelcomeEmailType, getDefaultWelcomeEmailValues} from './default-welcome-email-values'; import {type GeneralSettings, type WelcomeEmailCustomizeFormState, buildAutomatedEmailDesignPayload, mapApiToDesignSettings, mapApiToGeneralSettings} from './design-payload'; diff --git a/apps/admin/src/settings/routes.tsx b/apps/admin/src/settings/routes.tsx index b3b601cda26..59c14722a41 100644 --- a/apps/admin/src/settings/routes.tsx +++ b/apps/admin/src/settings/routes.tsx @@ -27,8 +27,8 @@ export const settingsRouteChildren: RouteObject[] = [ {path: 'tiers/add', lazy: lazyComponent(() => import('./membership/tiers/tier-detail-modal'))}, {path: 'tiers/:tierId', lazy: lazyComponent(() => import('./membership/tiers/tier-detail-modal'))}, {path: 'stripe-connect', lazy: lazyComponent(() => import('./membership/stripe/stripe-connect-modal'))}, - {path: 'newsletters/new', lazy: lazyComponent(() => import('./app/components/settings/email/newsletters/add-newsletter-modal'))}, - {path: 'newsletters/:newsletterId', lazy: lazyComponent(() => import('./app/components/settings/email/newsletters/newsletter-detail-modal'))}, + {path: 'newsletters/new', lazy: lazyComponent(() => import('./email/newsletters/add-newsletter-modal'))}, + {path: 'newsletters/:newsletterId', lazy: lazyComponent(() => import('./email/newsletters/newsletter-detail-modal'))}, {path: 'history/view/:userId?', lazy: lazyComponent(() => import('./app/components/settings/advanced/history-modal'))}, {path: 'integrations/new', lazy: lazyComponent(() => import('./app/components/settings/advanced/integrations/add-integration-modal'))}, {path: 'integrations/contentapi', lazy: lazyComponent(() => import('./app/components/settings/advanced/integrations/content-api-modal'))}, From 7dfe2109338fd0b38f7083a404b0828317f1db74 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Wed, 19 Aug 2026 06:33:47 -0500 Subject: [PATCH 02/19] Removed CSS preprocessor automerge exclusion from Renovate config (#30112) ref https://github.com/TryGhost/renovate-config/pull/31 Removes the local `Require human review for CSS preprocessor updates` rule from `.github/renovate.json5`. It existed only to mirror the shared preset's CSS/style toolchain exclusion so the pnpm-catalog automerge rule wouldn't override it; the shared exclusion is being removed. ## Why Minor/patch updates to PostCSS, Sass, Stylelint, Tailwind, etc. don't cause visual regressions in practice, and the exclusion was backing up PRs. Majors for these packages still require dashboard approval via the existing `Require dashboard approval for major updates` rule. --- .github/renovate.json5 | 42 ++---------------------------------------- 1 file changed, 2 insertions(+), 40 deletions(-) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 2e1792a83e8..108fac8ec9d 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -514,44 +514,6 @@ "automergeType": "pr" }, - // The catalog rule above is local, so it is evaluated after package - // rules inherited from the shared preset. Repeat the shared CSS/style - // exclusion locally so adding catalog support does not turn intentionally - // human-reviewed build-pipeline updates into automerges, and label every - // such PR so it is easy to find. - { - "description": "Require human review for CSS preprocessor updates", - "matchPackageNames": [ - "/^postcss/", - "/^css/", - "/^sass/", - "/^less/", - "/^styl/", - "autoprefixer", - "cssnano", - "postcss", - "postcss-cli", - "postcss-import", - "postcss-custom-media", - "postcss-custom-properties", - "postcss-color-mod-function", - "postcss-easy-import", - "ember-cli-postcss", - "gulp-postcss", - "sass", - "node-sass", - "sass-embedded", - "less", - "stylus", - "stylelint", - "tailwindcss" - ], - "automerge": false, - "addLabels": [ - "needs:review" - ] - }, - // Overrides change transitive resolution across the entire workspace, // so keep them manual and make that policy explicit on their PRs. { @@ -708,8 +670,8 @@ }, // Security patches and minors automerge after CI passes regardless - // of any other automerge exclusion (e.g. the CSS preprocessor rule - // in the shared preset). Security majors are intentionally left + // of any other automerge exclusion (e.g. the third-party action rule + // above). Security majors are intentionally left // for human merge — but the `vulnerabilityAlerts` block above // still opens them without a dashboard-approval tick. { From 5a4de984ce6ae246023136205ce35fe5c8532646 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Wed, 19 Aug 2026 06:40:09 -0500 Subject: [PATCH 03/19] Moved site settings out of the settings quarantine (#30111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref Fifth area out of `settings/app/`: site components now live beside their acceptance tests in `settings/site`. Moving them enables the type-aware lint rules the rest of admin uses, so that debt is cleared for these files: - async handlers passed to void callbacks are wrapped; fire-and-forget promises get `void`; async functions that never await become sync - `searchKeywords` → `site/search-keywords.ts`; `FatalErrors` + `getIssuesFromFatalErrors`/`getIssuesFromInstalledTheme` → `theme/theme-validation-issues.ts`, so component files only export components - the three identical icon/logo/cover upload handlers in `global-settings.tsx` collapse into one `uploadSettingImage` - `getThemeLabel` builds the default/legacy label as a string instead of `+=` onto a `ReactNode` - `design-modal.tsx`'s `useForm` gets an explicit type parameter instead of the `as Dirtyable<…>` cast (the lint autofix strips that cast and breaks `dirty` typing — same trap as #30082's portal modal) --- .../src/settings/app/components/settings.tsx | 2 +- .../src/settings/app/components/sidebar.tsx | 2 +- apps/admin/src/settings/routes.tsx | 12 ++--- .../site/announcement-bar-modal.tsx | 0 .../settings => }/site/announcement-bar.tsx | 0 .../announcement-bar-preview.tsx | 0 .../settings => }/site/change-theme.tsx | 6 +-- .../design-and-branding/global-settings.tsx | 48 ++++++----------- .../design-and-branding/theme-preview.tsx | 2 +- .../design-and-branding/theme-setting.tsx | 2 +- .../design-and-branding/theme-settings.tsx | 0 .../site/design-and-theme-modal.tsx | 4 +- .../settings => }/site/design-modal.tsx | 8 +-- .../settings => }/site/design-setting.tsx | 0 .../settings => }/site/navigation-modal.tsx | 0 .../settings => }/site/navigation.tsx | 0 .../site/navigation/navigation-edit-form.tsx | 0 .../navigation/navigation-item-editor.tsx | 0 .../src/settings/site/search-keywords.ts | 6 +++ .../settings => }/site/site-settings.tsx | 8 +-- .../settings => }/site/theme-modal.tsx | 12 ++--- .../site/theme/advanced-theme-settings.tsx | 23 ++++---- .../site/theme/invalid-theme-modal.tsx | 5 +- .../site/theme/official-themes.tsx | 0 .../site/theme/theme-code-editor-modal.tsx | 18 +++---- .../site/theme/theme-editor-confirm-modal.tsx | 0 .../site/theme/theme-editor-input-modal.tsx | 0 .../site/theme/theme-editor-styles.ts | 0 .../site/theme/theme-editor-toolbar.tsx | 0 .../site/theme/theme-editor-utils.test.ts | 2 +- .../site/theme/theme-editor-utils.ts | 0 .../site/theme/theme-file-tree.tsx | 0 .../site/theme/theme-installed-modal.tsx | 3 +- .../site/theme/theme-preview.tsx | 2 +- .../site/theme/theme-validation-details.tsx | 53 +------------------ .../site/theme/theme-validation-issues.ts | 52 ++++++++++++++++++ 36 files changed, 127 insertions(+), 143 deletions(-) rename apps/admin/src/settings/{app/components/settings => }/site/announcement-bar-modal.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/site/announcement-bar.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/site/announcement-bar/announcement-bar-preview.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/site/change-theme.tsx (95%) rename apps/admin/src/settings/{app/components/settings => }/site/design-and-branding/global-settings.tsx (89%) rename apps/admin/src/settings/{app/components/settings => }/site/design-and-branding/theme-preview.tsx (99%) rename apps/admin/src/settings/{app/components/settings => }/site/design-and-branding/theme-setting.tsx (98%) rename apps/admin/src/settings/{app/components/settings => }/site/design-and-branding/theme-settings.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/site/design-and-theme-modal.tsx (99%) rename apps/admin/src/settings/{app/components/settings => }/site/design-modal.tsx (96%) rename apps/admin/src/settings/{app/components/settings => }/site/design-setting.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/site/navigation-modal.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/site/navigation.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/site/navigation/navigation-edit-form.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/site/navigation/navigation-item-editor.tsx (100%) create mode 100644 apps/admin/src/settings/site/search-keywords.ts rename apps/admin/src/settings/{app/components/settings => }/site/site-settings.tsx (67%) rename apps/admin/src/settings/{app/components/settings => }/site/theme-modal.tsx (98%) rename apps/admin/src/settings/{app/components/settings => }/site/theme/advanced-theme-settings.tsx (93%) rename apps/admin/src/settings/{app/components/settings => }/site/theme/invalid-theme-modal.tsx (90%) rename apps/admin/src/settings/{app/components/settings => }/site/theme/official-themes.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/site/theme/theme-code-editor-modal.tsx (98%) rename apps/admin/src/settings/{app/components/settings => }/site/theme/theme-editor-confirm-modal.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/site/theme/theme-editor-input-modal.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/site/theme/theme-editor-styles.ts (100%) rename apps/admin/src/settings/{app/components/settings => }/site/theme/theme-editor-toolbar.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/site/theme/theme-editor-utils.test.ts (99%) rename apps/admin/src/settings/{app/components/settings => }/site/theme/theme-editor-utils.ts (100%) rename apps/admin/src/settings/{app/components/settings => }/site/theme/theme-file-tree.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/site/theme/theme-installed-modal.tsx (96%) rename apps/admin/src/settings/{app/components/settings => }/site/theme/theme-preview.tsx (99%) rename apps/admin/src/settings/{app/components/settings => }/site/theme/theme-validation-details.tsx (80%) create mode 100644 apps/admin/src/settings/site/theme/theme-validation-issues.ts diff --git a/apps/admin/src/settings/app/components/settings.tsx b/apps/admin/src/settings/app/components/settings.tsx index f5b419a15aa..3e76ca04b24 100644 --- a/apps/admin/src/settings/app/components/settings.tsx +++ b/apps/admin/src/settings/app/components/settings.tsx @@ -6,7 +6,7 @@ import Emails from '@/settings/email/emails'; import GeneralSettings from '@/settings/general/general-settings'; import GrowthSettings from '@/settings/growth/growth-settings'; import MembershipSettings from '@/settings/membership/membership-settings'; -import SiteSettings from './settings/site/site-settings'; +import SiteSettings from '@/settings/site/site-settings'; import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; const Settings: React.FC = () => { diff --git a/apps/admin/src/settings/app/components/sidebar.tsx b/apps/admin/src/settings/app/components/sidebar.tsx index b13bcc77a58..59eb96e7849 100644 --- a/apps/admin/src/settings/app/components/sidebar.tsx +++ b/apps/admin/src/settings/app/components/sidebar.tsx @@ -15,7 +15,7 @@ import {searchKeywords as emailsSearchKeywords} from '@/settings/email/emails-se import {searchKeywords as generalSearchKeywords} from '@/settings/general/search-keywords'; import {searchKeywords as growthSearchKeywords} from '@/settings/growth/search-keywords'; import {searchKeywords as membershipSearchKeywords} from '@/settings/membership/search-keywords'; -import {searchKeywords as siteSearchKeywords} from './settings/site/site-settings'; +import {searchKeywords as siteSearchKeywords} from '@/settings/site/search-keywords'; import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; import {useGlobalData} from './providers/global-data-provider'; diff --git a/apps/admin/src/settings/routes.tsx b/apps/admin/src/settings/routes.tsx index 59c14722a41..1564ebe6a56 100644 --- a/apps/admin/src/settings/routes.tsx +++ b/apps/admin/src/settings/routes.tsx @@ -9,15 +9,15 @@ import {type RouteObject, Navigate, lazyComponent} from '@tryghost/admin-x-frame export const settingsRouteChildren: RouteObject[] = [ // Design and theme share one container across four entry paths; it reads // the path to pick its internal view. - {path: 'design/change-theme', lazy: lazyComponent(() => import('./app/components/settings/site/design-and-theme-modal'))}, - {path: 'design/edit', lazy: lazyComponent(() => import('./app/components/settings/site/design-and-theme-modal'))}, - {path: 'theme/install', lazy: lazyComponent(() => import('./app/components/settings/site/design-and-theme-modal'))}, - {path: 'theme/edit/:themeName', lazy: lazyComponent(() => import('./app/components/settings/site/design-and-theme-modal'))}, + {path: 'design/change-theme', lazy: lazyComponent(() => import('./site/design-and-theme-modal'))}, + {path: 'design/edit', lazy: lazyComponent(() => import('./site/design-and-theme-modal'))}, + {path: 'theme/install', lazy: lazyComponent(() => import('./site/design-and-theme-modal'))}, + {path: 'theme/edit/:themeName', lazy: lazyComponent(() => import('./site/design-and-theme-modal'))}, // Theme names may contain %2F; anything deeper than one segment is not a // valid editor URL. {path: 'theme/edit/*', element: }, - {path: 'navigation/edit', lazy: lazyComponent(() => import('./app/components/settings/site/navigation-modal'))}, - {path: 'announcement-bar/edit', lazy: lazyComponent(() => import('./app/components/settings/site/announcement-bar-modal'))}, + {path: 'navigation/edit', lazy: lazyComponent(() => import('./site/navigation-modal'))}, + {path: 'announcement-bar/edit', lazy: lazyComponent(() => import('./site/announcement-bar-modal'))}, {path: 'staff/invite', lazy: lazyComponent(() => import('./general/invite-user-modal'))}, {path: 'staff/:slug', lazy: lazyComponent(() => import('./general/user-detail-modal'))}, {path: 'staff/:slug/edit', lazy: lazyComponent(() => import('./general/user-detail-modal'))}, diff --git a/apps/admin/src/settings/app/components/settings/site/announcement-bar-modal.tsx b/apps/admin/src/settings/site/announcement-bar-modal.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/site/announcement-bar-modal.tsx rename to apps/admin/src/settings/site/announcement-bar-modal.tsx diff --git a/apps/admin/src/settings/app/components/settings/site/announcement-bar.tsx b/apps/admin/src/settings/site/announcement-bar.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/site/announcement-bar.tsx rename to apps/admin/src/settings/site/announcement-bar.tsx diff --git a/apps/admin/src/settings/app/components/settings/site/announcement-bar/announcement-bar-preview.tsx b/apps/admin/src/settings/site/announcement-bar/announcement-bar-preview.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/site/announcement-bar/announcement-bar-preview.tsx rename to apps/admin/src/settings/site/announcement-bar/announcement-bar-preview.tsx diff --git a/apps/admin/src/settings/app/components/settings/site/change-theme.tsx b/apps/admin/src/settings/site/change-theme.tsx similarity index 95% rename from apps/admin/src/settings/app/components/settings/site/change-theme.tsx rename to apps/admin/src/settings/site/change-theme.tsx index eae76f4eabd..21af8725911 100644 --- a/apps/admin/src/settings/app/components/settings/site/change-theme.tsx +++ b/apps/admin/src/settings/site/change-theme.tsx @@ -30,10 +30,10 @@ const ChangeTheme: React.FC<{ keywords: string[] }> = ({keywords}) => { setIsCheckingLimit(false); }; - checkIfThemeChangeAllowed(); + void checkIfThemeChangeAllowed(); }, [checkThemeLimitError]); - const openPreviewModal = async () => { + const openPreviewModal = () => { // Wait for limit check if still in progress if (isCheckingLimit) { return; @@ -88,7 +88,7 @@ const ChangeTheme: React.FC<{ keywords: string[] }> = ({keywords}) => { - Edit code + void openThemeEditor()}>Edit code Download diff --git a/apps/admin/src/settings/app/components/settings/site/design-and-branding/global-settings.tsx b/apps/admin/src/settings/site/design-and-branding/global-settings.tsx similarity index 89% rename from apps/admin/src/settings/app/components/settings/site/design-and-branding/global-settings.tsx rename to apps/admin/src/settings/site/design-and-branding/global-settings.tsx index ddd22c0561c..3f632b06d3e 100644 --- a/apps/admin/src/settings/app/components/settings/site/design-and-branding/global-settings.tsx +++ b/apps/admin/src/settings/site/design-and-branding/global-settings.tsx @@ -71,6 +71,18 @@ const GlobalSettings: React.FC<{ values: GlobalSettingValues, updateSetting: (ke const {unsplashConfig} = useFramework(); const handleError = useHandleError(); + const uploadSettingImage = async (key: 'icon' | 'logo' | 'cover_image', file: File) => { + try { + updateSetting(key, getImageUrl(await uploadImage({file}))); + } catch (e) { + const error = e as APIError; + if (error.response!.status === 415) { + error.message = 'Unsupported file type'; + } + handleError(error); + } + }; + const editor = usePinturaEditor(); const {data: themesData} = useBrowseThemes(); @@ -185,17 +197,7 @@ const GlobalSettings: React.FC<{ values: GlobalSettingValues, updateSetting: (ke ) : ( - { - try { - updateSetting('icon', getImageUrl(await uploadImage({file}))); - } catch (e) { - const error = e as APIError; - if (error.response!.status === 415) { - error.message = 'Unsupported file type'; - } - handleError(error); - } - }}>Upload icon + void uploadSettingImage('icon', file)}>Upload icon )} @@ -215,17 +217,7 @@ const GlobalSettings: React.FC<{ values: GlobalSettingValues, updateSetting: (ke ) : ( - { - try { - updateSetting('logo', getImageUrl(await uploadImage({file}))); - } catch (e) { - const error = e as APIError; - if (error.response!.status === 415) { - error.message = 'Unsupported file type'; - } - handleError(error); - } - }}>Upload logo + void uploadSettingImage('logo', file)}>Upload logo )} @@ -255,17 +247,7 @@ const GlobalSettings: React.FC<{ values: GlobalSettingValues, updateSetting: (ke ) : ( <> - { - try { - updateSetting('cover_image', getImageUrl(await uploadImage({file}))); - } catch (e) { - const error = e as APIError; - if (error.response!.status === 415) { - error.message = 'Unsupported file type'; - } - handleError(error); - } - }}>Upload cover + void uploadSettingImage('cover_image', file)}>Upload cover {unsplashEnabled && setShowUnsplash(true)}> } diff --git a/apps/admin/src/settings/app/components/settings/site/design-and-branding/theme-preview.tsx b/apps/admin/src/settings/site/design-and-branding/theme-preview.tsx similarity index 99% rename from apps/admin/src/settings/app/components/settings/site/design-and-branding/theme-preview.tsx rename to apps/admin/src/settings/site/design-and-branding/theme-preview.tsx index cdf5ff4ec34..6ca7b2489b8 100644 --- a/apps/admin/src/settings/app/components/settings/site/design-and-branding/theme-preview.tsx +++ b/apps/admin/src/settings/site/design-and-branding/theme-preview.tsx @@ -75,7 +75,7 @@ const ThemePreview: React.FC = ({settings,url}) => { const previewUrl = new URL(url); previewUrl.searchParams.set('admin_toolbar', '0'); - fetch(previewUrl.toString(), { + void fetch(previewUrl.toString(), { method: 'POST', headers: { 'Content-Type': 'text/html;charset=utf-8', diff --git a/apps/admin/src/settings/app/components/settings/site/design-and-branding/theme-setting.tsx b/apps/admin/src/settings/site/design-and-branding/theme-setting.tsx similarity index 98% rename from apps/admin/src/settings/app/components/settings/site/design-and-branding/theme-setting.tsx rename to apps/admin/src/settings/site/design-and-branding/theme-setting.tsx index 98614beea02..01018762d4a 100644 --- a/apps/admin/src/settings/app/components/settings/site/design-and-branding/theme-setting.tsx +++ b/apps/admin/src/settings/site/design-and-branding/theme-setting.tsx @@ -98,7 +98,7 @@ const ThemeSetting: React.FC = ({setting, setSetting}) => { ) : ( - handleImageUpload(files[0])}> + void handleImageUpload(files[0])}> Upload image )} diff --git a/apps/admin/src/settings/app/components/settings/site/design-and-branding/theme-settings.tsx b/apps/admin/src/settings/site/design-and-branding/theme-settings.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/site/design-and-branding/theme-settings.tsx rename to apps/admin/src/settings/site/design-and-branding/theme-settings.tsx diff --git a/apps/admin/src/settings/app/components/settings/site/design-and-theme-modal.tsx b/apps/admin/src/settings/site/design-and-theme-modal.tsx similarity index 99% rename from apps/admin/src/settings/app/components/settings/site/design-and-theme-modal.tsx rename to apps/admin/src/settings/site/design-and-theme-modal.tsx index 25a08a14ce9..4b07fe98426 100644 --- a/apps/admin/src/settings/app/components/settings/site/design-and-theme-modal.tsx +++ b/apps/admin/src/settings/site/design-and-theme-modal.tsx @@ -53,7 +53,7 @@ const DesignAndThemeModal: React.FC = () => { }; if (currentPath === 'design/change-theme' && isThemeLimitCheckReady) { - checkIfThemeChangeAllowed(); + void checkIfThemeChangeAllowed(); } else { setThemeChangeError(null); setIsCheckingLimit(false); @@ -216,7 +216,7 @@ const DesignAndThemeModal: React.FC = () => { setIsCheckingInstallation(false); }; - checkThemeInstallation(); + void checkThemeInstallation(); }, [checkThemeLimitError, currentPath, isThemeLimitCheckReady, noThemeChangesAllowed, isThemeLimited, showThemeLimitModal, updateRoute]); if (currentPath === 'design/edit') { diff --git a/apps/admin/src/settings/app/components/settings/site/design-modal.tsx b/apps/admin/src/settings/site/design-modal.tsx similarity index 96% rename from apps/admin/src/settings/app/components/settings/site/design-modal.tsx rename to apps/admin/src/settings/site/design-modal.tsx index 1dfa8695fd5..e5b0093ad73 100644 --- a/apps/admin/src/settings/app/components/settings/site/design-modal.tsx +++ b/apps/admin/src/settings/site/design-modal.tsx @@ -10,7 +10,7 @@ import {PreviewModalContent} from '@/settings/app/components/settings/preview-mo import {type Setting, type SettingValue, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {getHomepageUrl} from '@tryghost/admin-x-framework/api/site'; import {useBrowsePosts} from '@tryghost/admin-x-framework/api/posts'; -import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; +import {type Dirtyable, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; @@ -82,10 +82,10 @@ const DesignModal: React.FC = () => { updateForm, setFormState, okProps - } = useForm({ + } = useForm<{settings: Dirtyable[]; themeSettings: Dirtyable[] | undefined}>({ initialState: { - settings: settings as Array, - themeSettings: themeSettings ? (themeSettings.custom_theme_settings as Array) : undefined + settings, + themeSettings: themeSettings?.custom_theme_settings }, savingDelay: 500, onSave: async () => { diff --git a/apps/admin/src/settings/app/components/settings/site/design-setting.tsx b/apps/admin/src/settings/site/design-setting.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/site/design-setting.tsx rename to apps/admin/src/settings/site/design-setting.tsx diff --git a/apps/admin/src/settings/app/components/settings/site/navigation-modal.tsx b/apps/admin/src/settings/site/navigation-modal.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/site/navigation-modal.tsx rename to apps/admin/src/settings/site/navigation-modal.tsx diff --git a/apps/admin/src/settings/app/components/settings/site/navigation.tsx b/apps/admin/src/settings/site/navigation.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/site/navigation.tsx rename to apps/admin/src/settings/site/navigation.tsx diff --git a/apps/admin/src/settings/app/components/settings/site/navigation/navigation-edit-form.tsx b/apps/admin/src/settings/site/navigation/navigation-edit-form.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/site/navigation/navigation-edit-form.tsx rename to apps/admin/src/settings/site/navigation/navigation-edit-form.tsx diff --git a/apps/admin/src/settings/app/components/settings/site/navigation/navigation-item-editor.tsx b/apps/admin/src/settings/site/navigation/navigation-item-editor.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/site/navigation/navigation-item-editor.tsx rename to apps/admin/src/settings/site/navigation/navigation-item-editor.tsx diff --git a/apps/admin/src/settings/site/search-keywords.ts b/apps/admin/src/settings/site/search-keywords.ts new file mode 100644 index 00000000000..afccc5826b5 --- /dev/null +++ b/apps/admin/src/settings/site/search-keywords.ts @@ -0,0 +1,6 @@ +export const searchKeywords = { + design: ['site', 'logo', 'cover', 'colors', 'fonts', 'background', 'themes', 'appearance', 'style', 'design & branding', 'design and branding'], + theme: ['theme', 'template', 'upload'], + navigation: ['site', 'navigation', 'menus', 'primary', 'secondary', 'links'], + announcementBar: ['site', 'announcement bar', 'important', 'banner'] +}; diff --git a/apps/admin/src/settings/app/components/settings/site/site-settings.tsx b/apps/admin/src/settings/site/site-settings.tsx similarity index 67% rename from apps/admin/src/settings/app/components/settings/site/site-settings.tsx rename to apps/admin/src/settings/site/site-settings.tsx index d9e9e2dc782..c328a953cbd 100644 --- a/apps/admin/src/settings/app/components/settings/site/site-settings.tsx +++ b/apps/admin/src/settings/site/site-settings.tsx @@ -4,13 +4,7 @@ import DesignSetting from './design-setting'; import Navigation from './navigation'; import React from 'react'; import SearchableSection from '@/settings/app/components/searchable-section'; - -export const searchKeywords = { - design: ['site', 'logo', 'cover', 'colors', 'fonts', 'background', 'themes', 'appearance', 'style', 'design & branding', 'design and branding'], - theme: ['theme', 'template', 'upload'], - navigation: ['site', 'navigation', 'menus', 'primary', 'secondary', 'links'], - announcementBar: ['site', 'announcement bar', 'important', 'banner'] -}; +import {searchKeywords} from './search-keywords'; const SiteSettings: React.FC = () => { return ( diff --git a/apps/admin/src/settings/app/components/settings/site/theme-modal.tsx b/apps/admin/src/settings/site/theme-modal.tsx similarity index 98% rename from apps/admin/src/settings/app/components/settings/site/theme-modal.tsx rename to apps/admin/src/settings/site/theme-modal.tsx index b4692bbd3e6..446784a87ab 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme-modal.tsx +++ b/apps/admin/src/settings/site/theme-modal.tsx @@ -75,14 +75,14 @@ const ThemeToolbar: React.FC = ({ } }; - checkUploadLimit(); + void checkUploadLimit(); }, [checkThemeLimitError, isThemeLimited]); const onClose = () => { updateRoute('/'); }; - const onThemeUpload = async (file: File) => { + const onThemeUpload = (file: File) => { const themeFileName = file?.name.replace(/\.zip$/, ''); const existingThemeNames = themes.map(t => t.name); if (isDefaultOrLegacyTheme({name: themeFileName})) { @@ -96,7 +96,7 @@ const ThemeToolbar: React.FC = ({

Rename your zip file and try again.

), - onOk: async (confirmModal) => { + onOk: (confirmModal) => { confirmModal?.remove(); } }); @@ -129,7 +129,7 @@ const ThemeToolbar: React.FC = ({ }); } else { setCurrentTab('installed'); - handleThemeUpload({file, onActivate: onClose}); + void handleThemeUpload({file, onActivate: onClose}); } }; @@ -268,7 +268,7 @@ const ThemeToolbar: React.FC = ({ prompt={<>This theme couldn't be uploaded because Ghost found a blocking validation error. Fix the issue below and upload the theme again.} title='Theme not uploaded' onClose={() => setUploadErrors(null)} - onRetry={async () => { + onRetry={() => { setUploadErrors(null); handleUpload(); }} @@ -397,7 +397,7 @@ const ChangeThemeModal: React.FC = ({source, themeRef}) = } }; - handleUrlInstallation(); + void handleUrlInstallation(); }, [themeRef, source, installTheme, handleError, activateTheme, updateRoute, themes, installedFromMarketplace, checkThemeLimitError, confirm, isMounted]); if (!themes) { diff --git a/apps/admin/src/settings/app/components/settings/site/theme/advanced-theme-settings.tsx b/apps/admin/src/settings/site/theme/advanced-theme-settings.tsx similarity index 93% rename from apps/admin/src/settings/app/components/settings/site/theme/advanced-theme-settings.tsx rename to apps/admin/src/settings/site/theme/advanced-theme-settings.tsx index 3f7de204e3e..92ff686f731 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/advanced-theme-settings.tsx +++ b/apps/admin/src/settings/site/theme/advanced-theme-settings.tsx @@ -23,12 +23,13 @@ interface ThemeSettingProps { } function getThemeLabel(theme: Theme): React.ReactNode { - let label: React.ReactNode = theme.package?.name || theme.name; + const name = theme.package?.name || theme.name; + let label: React.ReactNode = name; if (isDefaultTheme(theme)) { - label += ' (default)'; + label = `${name} (default)`; } else if (isLegacyTheme(theme)) { - label += ' (legacy)'; + label = `${name} (legacy)`; } else if (theme.package?.name !== theme.name) { label = @@ -81,12 +82,12 @@ const ThemeActions: React.FC = ({ } }; - const handleDownload = async () => { + const handleDownload = () => { const {apiRoot} = getGhostPaths(); downloadFile(`${apiRoot}/themes/${theme.name}/download`); }; - const handleDelete = async () => { + const handleDelete = () => { confirm({ title: 'Are you sure you want to delete this?', prompt: ( @@ -96,9 +97,7 @@ const ThemeActions: React.FC = ({ {' '} { - handleDownload(); - }} + onClick={handleDownload} > your theme before continuing @@ -142,7 +141,7 @@ const ThemeActions: React.FC = ({ size='sm' type='button' variant='ghost' - onClick={handleActivate} + onClick={() => void handleActivate()} >Activate ); } @@ -156,7 +155,7 @@ const ThemeActions: React.FC = ({ {/* legacy Modal overlay is z-[1000]; keep the portalled menu above it */} - Edit code + void handleEditCode()}>Edit code Download {isDeletableTheme(theme) && ( Delete @@ -169,9 +168,9 @@ const ThemeActions: React.FC = ({ prompt={<>This theme couldn't be activated because Ghost found a blocking validation error. Fix the issue below and try again.} title='Theme not activated' onClose={() => setActivationErrors(null)} - onRetry={async () => { + onRetry={() => { setActivationErrors(null); - handleActivate(); + void handleActivate(); }} /> )} diff --git a/apps/admin/src/settings/app/components/settings/site/theme/invalid-theme-modal.tsx b/apps/admin/src/settings/site/theme/invalid-theme-modal.tsx similarity index 90% rename from apps/admin/src/settings/app/components/settings/site/theme/invalid-theme-modal.tsx rename to apps/admin/src/settings/site/theme/invalid-theme-modal.tsx index 1cd54100588..0ab01ee916d 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/invalid-theme-modal.tsx +++ b/apps/admin/src/settings/site/theme/invalid-theme-modal.tsx @@ -1,10 +1,11 @@ import React, {type ReactNode} from 'react'; import {ConfirmationModalContent} from '@/settings/app/components/confirmation-modal'; -import {ErrorTextCard, type FatalErrors, ThemeValidationDetailsDisclosure, ValidationProblemCard, getIssuesFromFatalErrors} from './theme-validation-details'; +import {ErrorTextCard, ThemeValidationDetailsDisclosure, ValidationProblemCard} from './theme-validation-details'; +import {type FatalErrors, getIssuesFromFatalErrors} from './theme-validation-issues'; import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config'; import {formatNumber} from '@tryghost/shade/utils'; -export type {FatalErrors} from './theme-validation-details'; +export type {FatalErrors} from './theme-validation-issues'; export type InvalidThemeModalProps = { title: string diff --git a/apps/admin/src/settings/app/components/settings/site/theme/official-themes.tsx b/apps/admin/src/settings/site/theme/official-themes.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/site/theme/official-themes.tsx rename to apps/admin/src/settings/site/theme/official-themes.tsx diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-code-editor-modal.tsx b/apps/admin/src/settings/site/theme/theme-code-editor-modal.tsx similarity index 98% rename from apps/admin/src/settings/app/components/settings/site/theme/theme-code-editor-modal.tsx rename to apps/admin/src/settings/site/theme/theme-code-editor-modal.tsx index 50ba523125e..8357601e72d 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/theme-code-editor-modal.tsx +++ b/apps/admin/src/settings/site/theme/theme-code-editor-modal.tsx @@ -263,7 +263,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { const dialogRequestRef = useRef(null); const [saveErrors, setSaveErrors] = useState(null); const [installedModal, setInstalledModal] = useState(null); - const [editorExtensions, setEditorExtensions] = useState | typeof oneDark | typeof editorSelectionTheme | typeof EditorView.lineWrapping | Awaited>>>([]); + const [editorExtensions, setEditorExtensions] = useState | Awaited>>>([]); useEffect(() => { let isMounted = true; @@ -323,7 +323,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { } }; - loadTheme(); + void loadTheme(); return () => { isMounted = false; @@ -444,7 +444,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { // the freshest version without re-registering on every render. handleSave // captures state (currentFiles, changes, isSaving, ...) and is recreated // each render — using a ref decouples that churn from the global listener. - const handleSaveRef = useRef<() => void>(() => {}); + const handleSaveRef = useRef<() => Promise>(async () => {}); const hasOpenDialog = Boolean(dialogRequest || saveErrors || installedModal); @@ -810,7 +810,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { if (!response.ok) { if (response.status === 422 && data?.errors) { - setSaveErrors(data.errors as FatalErrors); + setSaveErrors(data.errors); return; } @@ -873,7 +873,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { role='dialog' onClick={(event) => { if (event.target === event.currentTarget) { - closeEditor(); + void closeEditor(); } }} > @@ -882,7 +882,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { changes={changes} currentThemeName={currentThemeName} isSaving={isSaving} - onClose={closeEditor} + onClose={() => void closeEditor()} onSave={() => void handleSave()} /> @@ -901,10 +901,10 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { selectedNode={selectedNode} setExpandedDirectories={setExpandedDirectories} setSelectedNode={setSelectedNode} - onCreateFile={handleCreateFile} - onDeleteSelected={handleDeleteSelected} + onCreateFile={() => void handleCreateFile()} + onDeleteSelected={() => void handleDeleteSelected()} onOpenFile={openFile} - onRenameSelected={handleRenameSelected} + onRenameSelected={() => void handleRenameSelected()} />
diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-confirm-modal.tsx b/apps/admin/src/settings/site/theme/theme-editor-confirm-modal.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/site/theme/theme-editor-confirm-modal.tsx rename to apps/admin/src/settings/site/theme/theme-editor-confirm-modal.tsx diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-input-modal.tsx b/apps/admin/src/settings/site/theme/theme-editor-input-modal.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/site/theme/theme-editor-input-modal.tsx rename to apps/admin/src/settings/site/theme/theme-editor-input-modal.tsx diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-styles.ts b/apps/admin/src/settings/site/theme/theme-editor-styles.ts similarity index 100% rename from apps/admin/src/settings/app/components/settings/site/theme/theme-editor-styles.ts rename to apps/admin/src/settings/site/theme/theme-editor-styles.ts diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-toolbar.tsx b/apps/admin/src/settings/site/theme/theme-editor-toolbar.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/site/theme/theme-editor-toolbar.tsx rename to apps/admin/src/settings/site/theme/theme-editor-toolbar.tsx diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-utils.test.ts b/apps/admin/src/settings/site/theme/theme-editor-utils.test.ts similarity index 99% rename from apps/admin/src/settings/app/components/settings/site/theme/theme-editor-utils.test.ts rename to apps/admin/src/settings/site/theme/theme-editor-utils.test.ts index 60aec0c0a8c..2a2c9fb4ece 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-utils.test.ts +++ b/apps/admin/src/settings/site/theme/theme-editor-utils.test.ts @@ -11,7 +11,7 @@ import { isEditablePath, parseEditingThemeRoute, packThemeArchive -} from '@/settings/app/components/settings/site/theme/theme-editor-utils'; +} from '@/settings/site/theme/theme-editor-utils'; const createArchiveBuffer = async (build: (zip: JSZip) => void) => { const zip = new JSZip(); diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-utils.ts b/apps/admin/src/settings/site/theme/theme-editor-utils.ts similarity index 100% rename from apps/admin/src/settings/app/components/settings/site/theme/theme-editor-utils.ts rename to apps/admin/src/settings/site/theme/theme-editor-utils.ts diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-file-tree.tsx b/apps/admin/src/settings/site/theme/theme-file-tree.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/site/theme/theme-file-tree.tsx rename to apps/admin/src/settings/site/theme/theme-file-tree.tsx diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-installed-modal.tsx b/apps/admin/src/settings/site/theme/theme-installed-modal.tsx similarity index 96% rename from apps/admin/src/settings/app/components/settings/site/theme/theme-installed-modal.tsx rename to apps/admin/src/settings/site/theme/theme-installed-modal.tsx index 715fe53a898..dcd3d5c6d53 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/theme-installed-modal.tsx +++ b/apps/admin/src/settings/site/theme/theme-installed-modal.tsx @@ -2,7 +2,8 @@ import React, {type ReactNode} from 'react'; import useCustomFonts from '@/settings/app/hooks/use-custom-fonts'; import {ConfirmationModalContent} from '@/settings/app/components/confirmation-modal'; import {type InstalledTheme, useActivateTheme} from '@tryghost/admin-x-framework/api/themes'; -import {OutcomeBanner, ThemeValidationDetailsDisclosure, getIssuesFromInstalledTheme} from './theme-validation-details'; +import {OutcomeBanner, ThemeValidationDetailsDisclosure} from './theme-validation-details'; +import {getIssuesFromInstalledTheme} from './theme-validation-issues'; import {getHomepageUrl, useBrowseSite} from '@tryghost/admin-x-framework/api/site'; import {toast} from 'sonner'; import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config'; diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-preview.tsx b/apps/admin/src/settings/site/theme/theme-preview.tsx similarity index 99% rename from apps/admin/src/settings/app/components/settings/site/theme/theme-preview.tsx rename to apps/admin/src/settings/site/theme/theme-preview.tsx index 95266a6ed1f..15294862559 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/theme-preview.tsx +++ b/apps/admin/src/settings/site/theme/theme-preview.tsx @@ -75,7 +75,7 @@ const ThemePreview: React.FC<{ const handleInstall = () => { // The parent component handles all limit checks and confirmation modals - onInstall?.(); + void onInstall?.(); }; const left = diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-validation-details.tsx b/apps/admin/src/settings/site/theme/theme-validation-details.tsx similarity index 80% rename from apps/admin/src/settings/app/components/settings/site/theme/theme-validation-details.tsx rename to apps/admin/src/settings/site/theme/theme-validation-details.tsx index 309798fc888..8a25e226bf8 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/theme-validation-details.tsx +++ b/apps/admin/src/settings/site/theme/theme-validation-details.tsx @@ -1,61 +1,10 @@ import React, {useEffect, useMemo, useState} from 'react'; import {Badge, Banner} from '@tryghost/shade/components'; -import {type InstalledTheme, type ThemeProblem} from '@tryghost/admin-x-framework/api/themes'; +import {type ThemeProblem} from '@tryghost/admin-x-framework/api/themes'; import {LucideIcon} from '@tryghost/shade/utils'; -type ThemeValidationErrorDetails = { - errors?: ThemeProblem[]; - warnings?: ThemeProblem[]; -}; - -type ThemeValidationError = { - details: ThemeValidationErrorDetails | string; -}; - -export type FatalErrors = ThemeValidationError[]; - -type IssueSummary = { - blockingProblems: ThemeProblem[]; - secondaryProblems: ThemeProblem[]; - stringErrors: string[]; -}; - type DisplaySeverity = 'Error' | 'Warning' | 'Recommendation'; -function isDetailsObject(details: ThemeValidationError['details']): details is ThemeValidationErrorDetails { - return typeof details === 'object' && details !== null; -} - -function allProblemsFromDetails(details: ThemeValidationErrorDetails) { - return [...(details.errors || []), ...(details.warnings || [])]; -} - -export function getIssuesFromFatalErrors(fatalErrors: FatalErrors = []): IssueSummary { - const blockingProblems: ThemeProblem[] = []; - const secondaryProblems: ThemeProblem[] = []; - const stringErrors: string[] = []; - - fatalErrors.forEach((error) => { - if (isDetailsObject(error.details)) { - allProblemsFromDetails(error.details).forEach((problem) => { - if (problem.fatal) { - blockingProblems.push(problem); - } else { - secondaryProblems.push(problem); - } - }); - } else { - stringErrors.push(error.details); - } - }); - - return {blockingProblems, secondaryProblems, stringErrors}; -} - -export function getIssuesFromInstalledTheme(installedTheme: InstalledTheme): ThemeProblem[] { - return [...(installedTheme.errors || []), ...(installedTheme.warnings || [])]; -} - function getDisplaySeverity(problem: ThemeProblem): DisplaySeverity { if (problem.level === 'warning') { return 'Warning'; diff --git a/apps/admin/src/settings/site/theme/theme-validation-issues.ts b/apps/admin/src/settings/site/theme/theme-validation-issues.ts new file mode 100644 index 00000000000..61eb3c9b54c --- /dev/null +++ b/apps/admin/src/settings/site/theme/theme-validation-issues.ts @@ -0,0 +1,52 @@ +import {type InstalledTheme, type ThemeProblem} from '@tryghost/admin-x-framework/api/themes'; + +type ThemeValidationErrorDetails = { + errors?: ThemeProblem[]; + warnings?: ThemeProblem[]; +}; + +type ThemeValidationError = { + details: ThemeValidationErrorDetails | string; +}; + +export type FatalErrors = ThemeValidationError[]; + +type IssueSummary = { + blockingProblems: ThemeProblem[]; + secondaryProblems: ThemeProblem[]; + stringErrors: string[]; +}; + +function isDetailsObject(details: ThemeValidationError['details']): details is ThemeValidationErrorDetails { + return typeof details === 'object' && details !== null; +} + +function allProblemsFromDetails(details: ThemeValidationErrorDetails) { + return [...(details.errors || []), ...(details.warnings || [])]; +} + +export function getIssuesFromFatalErrors(fatalErrors: FatalErrors = []): IssueSummary { + const blockingProblems: ThemeProblem[] = []; + const secondaryProblems: ThemeProblem[] = []; + const stringErrors: string[] = []; + + fatalErrors.forEach((error) => { + if (isDetailsObject(error.details)) { + allProblemsFromDetails(error.details).forEach((problem) => { + if (problem.fatal) { + blockingProblems.push(problem); + } else { + secondaryProblems.push(problem); + } + }); + } else { + stringErrors.push(error.details); + } + }); + + return {blockingProblems, secondaryProblems, stringErrors}; +} + +export function getIssuesFromInstalledTheme(installedTheme: InstalledTheme): ThemeProblem[] { + return [...(installedTheme.errors || []), ...(installedTheme.warnings || [])]; +} From 3dc5bc86250e81c172c9a1d14c3cc8a3f702ea1b Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Wed, 19 Aug 2026 12:45:48 +0100 Subject: [PATCH 04/19] Moved database date handling across Core into shared utilities (#30107) ref https://linear.app/ghost/issue/BER-3851 Gift delivery needs the same UTC timestamp conversion as automations and seeders. Keeping separate implementations risks differences in timezone, precision and validation behaviour when handling raw Knex values. A single Luxon-backed conversion path beside the DbDate codec keeps MySQL and SQLite behaviour consistent and rejects invalid inputs explicitly. --- .../automation-action-revisions-importer.ts | 6 +- .../importers/automation-actions-importer.ts | 13 ++--- .../automation-run-steps-importer.ts | 6 +- .../importers/automation-runs-importer.ts | 16 +++--- .../seeders/importers/automations-importer.ts | 6 +- .../importers/comment-reports-importer.js | 10 ++-- .../seeders/importers/comments-importer.js | 10 ++-- .../importers/email-batches-importer.js | 9 +-- .../importers/email-recipients-importer.js | 32 ++++------- .../data/seeders/importers/emails-importer.js | 12 ++-- .../data/seeders/importers/labels-importer.js | 6 +- .../members-click-events-importer.js | 10 ++-- .../members-created-events-importer.js | 10 ++-- .../importers/members-feedback-importer.js | 14 ++--- .../seeders/importers/members-importer.js | 8 +-- .../members-login-events-importer.js | 6 +- .../importers/members-products-importer.js | 4 +- .../members-status-events-importer.js | 7 ++- .../members-stripe-customers-importer.js | 4 +- ...stripe-customers-subscriptions-importer.js | 14 ++--- .../members-subscribe-events-importer.js | 6 +- ...rs-subscription-created-events-importer.js | 6 +- .../importers/offer-redemptions-importer.js | 14 ++--- .../data/seeders/importers/offers-importer.js | 6 +- .../data/seeders/importers/posts-importer.js | 8 +-- .../data/seeders/importers/table-importer.ts | 4 +- .../data/seeders/importers/tags-importer.js | 4 +- .../data/seeders/importers/users-importer.js | 4 +- .../importers/web-mentions-importer.js | 4 +- .../data/seeders/utils/database-date.ts | 30 ---------- .../core/server/data/seeders/utils/random.ts | 8 +++ ghost/core/core/server/lib/db-date.ts | 50 ++++++++++++++++- .../database-automations-repository.ts | 2 +- .../services/automations/database-date.ts | 26 --------- ghost/core/package.json | 1 + .../data/seeders/data-generator.test.js | 19 +------ .../db-date.test.ts} | 55 ++++++++++++++++++- .../automations-repository.test.ts | 4 +- pnpm-lock.yaml | 3 + 39 files changed, 241 insertions(+), 216 deletions(-) delete mode 100644 ghost/core/core/server/data/seeders/utils/database-date.ts delete mode 100644 ghost/core/core/server/services/automations/database-date.ts rename ghost/core/test/unit/server/{services/automations/database-date.test.ts => lib/db-date.test.ts} (60%) diff --git a/ghost/core/core/server/data/seeders/importers/automation-action-revisions-importer.ts b/ghost/core/core/server/data/seeders/importers/automation-action-revisions-importer.ts index 5025ed7f463..b9349714799 100644 --- a/ghost/core/core/server/data/seeders/importers/automation-action-revisions-importer.ts +++ b/ghost/core/core/server/data/seeders/importers/automation-action-revisions-importer.ts @@ -2,7 +2,7 @@ import {faker} from '@faker-js/faker'; import errors from '@tryghost/errors'; import type {Knex} from 'knex'; import {TableImporter} from './table-importer'; -import * as databaseDate from '../utils/database-date'; +import {fromDatabaseDate, toDatabaseDate} from '../../../lib/db-date'; import {DEFAULT_EMAIL_DESIGN_SETTING_SLUG} from '../../../services/member-welcome-emails/constants'; type AutomationAction = { @@ -66,13 +66,13 @@ export class AutomationActionRevisionsImporter extends TableImporter const requestedStepCount = clamp(Math.floor(stepsPerRun) + extraStep, 1, actionCount); const runStepCount = fullPathGeneratedForAutomation.has(run.automation_id) ? requestedStepCount : actionCount; fullPathGeneratedForAutomation.add(run.automation_id); - const runCreatedAt = databaseDate.parse(run.created_at); + const runCreatedAt = fromDatabaseDate(run.created_at); const lastStepStatus = faker.helpers.arrayElement(['pending', 'finished', 'failed'] as const); for (let index = 0; index < runStepCount; index += 1) { const createdAt = new Date(runCreatedAt); createdAt.setHours(createdAt.getHours() + index); - const createdAtString = databaseDate.dateToDatabaseString(createdAt); + const createdAtString = toDatabaseDate(createdAt); const status = index === runStepCount - 1 ? lastStepStatus : 'finished'; const isTerminal = status !== 'pending'; diff --git a/ghost/core/core/server/data/seeders/importers/automation-runs-importer.ts b/ghost/core/core/server/data/seeders/importers/automation-runs-importer.ts index ea267402a4f..4abf63a51fd 100644 --- a/ghost/core/core/server/data/seeders/importers/automation-runs-importer.ts +++ b/ghost/core/core/server/data/seeders/importers/automation-runs-importer.ts @@ -4,7 +4,8 @@ import assert from 'node:assert/strict'; import type {Knex} from 'knex'; import {TableImporter} from './table-importer'; import {parseEmailAddress} from '@tryghost/parse-email-address'; -import * as databaseDate from '../utils/database-date'; +import {fromDatabaseDate, toDatabaseDate} from '../../../lib/db-date'; +import {randomDateBetween} from '../utils/random'; type Automation = { id: string; @@ -73,19 +74,16 @@ export class AutomationRunsImporter extends TableImporter { status: faker.helpers.arrayElement(['active', 'inactive']), name, slug, - created_at: databaseDate.dateToDatabaseString(createdAt), - updated_at: databaseDate.dateToDatabaseString(createdAt) + created_at: toDatabaseDate(createdAt), + updated_at: toDatabaseDate(createdAt) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/comment-reports-importer.js b/ghost/core/core/server/data/seeders/importers/comment-reports-importer.js index d18a086d38a..3b724d5d9ea 100644 --- a/ghost/core/core/server/data/seeders/importers/comment-reports-importer.js +++ b/ghost/core/core/server/data/seeders/importers/comment-reports-importer.js @@ -1,7 +1,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); -const {luck} = require('../utils/random'); -const databaseDate = require('../utils/database-date'); +const {luck, randomDateBetween} = require('../utils/random'); +const {toDatabaseDate} = require('../../../lib/db-date'); class CommentReportsImporter extends TableImporter { static table = 'comment_reports'; @@ -49,7 +49,7 @@ class CommentReportsImporter extends TableImporter { return null; } - const reportTime = databaseDate.randomBetween(this.model.created_at, new Date()); + const reportTime = randomDateBetween(this.model.created_at, new Date()); const reporter = this.possibleReporters[faker.number.int(this.possibleReporters.length - 1)]; @@ -57,8 +57,8 @@ class CommentReportsImporter extends TableImporter { id: this.fastFakeObjectId(), comment_id: this.model.id, member_id: reporter.id, - created_at: databaseDate.dateToDatabaseString(reportTime), - updated_at: databaseDate.dateToDatabaseString(reportTime) + created_at: toDatabaseDate(reportTime), + updated_at: toDatabaseDate(reportTime) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/comments-importer.js b/ghost/core/core/server/data/seeders/importers/comments-importer.js index 7fdaacceef8..a8aebcdeef2 100644 --- a/ghost/core/core/server/data/seeders/importers/comments-importer.js +++ b/ghost/core/core/server/data/seeders/importers/comments-importer.js @@ -2,7 +2,7 @@ const {faker} = require('@faker-js/faker'); const {TableImporter} = require('./table-importer'); const {luck} = require('../utils/random'); const generateEvents = require('../utils/event-generator'); -const databaseDate = require('../utils/database-date'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class CommentsImporter extends TableImporter { static table = 'comments'; @@ -24,7 +24,7 @@ class CommentsImporter extends TableImporter { setReferencedModel(model) { this.model = model; - const publishedAt = databaseDate.parse(model.published_at); + const publishedAt = fromDatabaseDate(model.published_at); this.commentIds = []; // Store [id, parent_id, timestamp] tuples for reply-to-reply @@ -37,7 +37,7 @@ class CommentsImporter extends TableImporter { endTime: new Date() }).sort((a, b) => a.getTime() - b.getTime()); // Sort chronologically so replies always come after their targets - this.possibleMembers = this.members.filter(member => databaseDate.parse(member.created_at) < publishedAt); + this.possibleMembers = this.members.filter(member => fromDatabaseDate(member.created_at) < publishedAt); } generate() { @@ -97,8 +97,8 @@ class CommentsImporter extends TableImporter { parent_id: parentId, in_reply_to_id: inReplyToId, status: 'published', - created_at: databaseDate.dateToDatabaseString(timestamp), - updated_at: databaseDate.dateToDatabaseString(timestamp), + created_at: toDatabaseDate(timestamp), + updated_at: toDatabaseDate(timestamp), html: `

${faker.lorem.sentence().replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`)}

` }; diff --git a/ghost/core/core/server/data/seeders/importers/email-batches-importer.js b/ghost/core/core/server/data/seeders/importers/email-batches-importer.js index a5028710528..d18e62ac84b 100644 --- a/ghost/core/core/server/data/seeders/importers/email-batches-importer.js +++ b/ghost/core/core/server/data/seeders/importers/email-batches-importer.js @@ -1,6 +1,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); -const databaseDate = require('../utils/database-date'); +const {randomDateBetween} = require('../utils/random'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class EmailBatchesImporter extends TableImporter { static table = 'email_batches'; @@ -21,8 +22,8 @@ class EmailBatchesImporter extends TableImporter { } generate() { - const emailSentDate = databaseDate.parse(this.model.created_at); - const latestUpdatedDate = databaseDate.parse(this.model.created_at); + const emailSentDate = fromDatabaseDate(this.model.created_at); + const latestUpdatedDate = new Date(emailSentDate); latestUpdatedDate.setHours(latestUpdatedDate.getHours() + 1); return { @@ -31,7 +32,7 @@ class EmailBatchesImporter extends TableImporter { mailgun_message_id: `${new Date().toISOString().split('.')[0].replace(/[^0-9]/g, '')}.${faker.string.hexadecimal({length: 16, prefix: '', casing: 'lower'})}@m.example.com`, status: 'submitted', // TODO: introduce failures created_at: this.model.created_at, - updated_at: databaseDate.dateToDatabaseString(databaseDate.randomBetween(emailSentDate, latestUpdatedDate)) + updated_at: toDatabaseDate(randomDateBetween(emailSentDate, latestUpdatedDate)) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/email-recipients-importer.js b/ghost/core/core/server/data/seeders/importers/email-recipients-importer.js index 6bbcd62febb..6bf628d9616 100644 --- a/ghost/core/core/server/data/seeders/importers/email-recipients-importer.js +++ b/ghost/core/core/server/data/seeders/importers/email-recipients-importer.js @@ -1,7 +1,8 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const generateEvents = require('../utils/event-generator'); -const databaseDate = require('../utils/database-date'); +const {randomDateBetween} = require('../utils/random'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); const debug = require('@tryghost/debug')('EmailRecipientsImporter'); const emailStatus = { @@ -109,11 +110,7 @@ class EmailRecipientsImporter extends TableImporter { this.membersSubscribeEventsCreatedAtsByNewsletterId.set(memberSubscribeEvent.newsletter_id, []); } - if (!(memberSubscribeEvent.created_at instanceof Date)) { - // SQLite fix - memberSubscribeEvent.created_at = databaseDate.parse(memberSubscribeEvent.created_at); - } - this.membersSubscribeEventsCreatedAtsByNewsletterId.get(memberSubscribeEvent.newsletter_id).push(memberSubscribeEvent.created_at.getTime()); + this.membersSubscribeEventsCreatedAtsByNewsletterId.get(memberSubscribeEvent.newsletter_id).push(fromDatabaseDate(memberSubscribeEvent.created_at).getTime()); } await this.importForEach(this.emailBatches, quantity ? quantity / emails.length : 1000); @@ -123,15 +120,12 @@ class EmailRecipientsImporter extends TableImporter { this.batch = model; this.model = this.emails.get(this.batch.email_id); this.batchIndex = this.batch.index; - - // Shallow clone members list so we can shuffle and modify it - const earliestOpenTime = databaseDate.parse(this.batch.updated_at); - const latestOpenTime = databaseDate.parse(this.batch.updated_at); - latestOpenTime.setDate(latestOpenTime.getDate() + 14); + this.batchUpdatedAt = fromDatabaseDate(this.batch.updated_at); + this.batchProcessedAt = toDatabaseDate(this.batchUpdatedAt); // Get all members that were subscribed to this newsletter BEFORE the batch was sent // We use binary search to speed up it up - const lastIndex = findFirstHigherIndex(this.membersSubscribeEventsCreatedAtsByNewsletterId.get(this.model.newsletter_id), earliestOpenTime); + const lastIndex = findFirstHigherIndex(this.membersSubscribeEventsCreatedAtsByNewsletterId.get(this.model.newsletter_id), this.batchUpdatedAt); this.membersList = this.membersSubscribeEventsByNewsletterId.get(this.model.newsletter_id).slice(0, Math.max(0, lastIndex - 1)) .slice(this.batchIndex * 1000, (this.batchIndex + 1) * 1000) @@ -164,7 +158,7 @@ class EmailRecipientsImporter extends TableImporter { } // The events are generated for a different time, so we need to move them to the batch time - timestamp = new Date(timestamp.getTime() - this.eventStartTimeUsed.getTime() + databaseDate.parse(this.batch.updated_at).getTime()); + timestamp = new Date(timestamp.getTime() - this.eventStartTimeUsed.getTime() + this.batchUpdatedAt.getTime()); if (timestamp > new Date()) { timestamp = new Date(); @@ -187,9 +181,7 @@ class EmailRecipientsImporter extends TableImporter { let deliveredTime; if (status === emailStatus.opened) { - const startDate = databaseDate.parse(this.batch.updated_at); - const endDate = timestamp; - deliveredTime = databaseDate.randomBetween(startDate, endDate); + deliveredTime = randomDateBetween(this.batchUpdatedAt, timestamp); } return { @@ -198,10 +190,10 @@ class EmailRecipientsImporter extends TableImporter { email_id: this.model.id, batch_id: this.batch.id, member_id: member.id, - processed_at: databaseDate.dateToDatabaseString(this.batch.updated_at), - delivered_at: status === emailStatus.opened ? databaseDate.dateToDatabaseString(deliveredTime) : status === emailStatus.delivered ? databaseDate.dateToDatabaseString(timestamp) : null, - opened_at: status === emailStatus.opened ? databaseDate.dateToDatabaseString(timestamp) : null, - failed_at: status === emailStatus.failed ? databaseDate.dateToDatabaseString(timestamp) : null, + processed_at: this.batchProcessedAt, + delivered_at: status === emailStatus.opened ? toDatabaseDate(deliveredTime) : status === emailStatus.delivered ? toDatabaseDate(timestamp) : null, + opened_at: status === emailStatus.opened ? toDatabaseDate(timestamp) : null, + failed_at: status === emailStatus.failed ? toDatabaseDate(timestamp) : null, member_uuid: member.uuid, member_email: member.email, member_name: member.name diff --git a/ghost/core/core/server/data/seeders/importers/emails-importer.js b/ghost/core/core/server/data/seeders/importers/emails-importer.js index 47923c99085..d63af03c798 100644 --- a/ghost/core/core/server/data/seeders/importers/emails-importer.js +++ b/ghost/core/core/server/data/seeders/importers/emails-importer.js @@ -2,7 +2,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const generateEvents = require('../utils/event-generator'); const {luck} = require('../utils/random'); -const databaseDate = require('../utils/database-date'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class EmailsImporter extends TableImporter { static table = 'emails'; @@ -42,7 +42,7 @@ class EmailsImporter extends TableImporter { : this.newsletters[1]; } - const publishedAt = databaseDate.parse(this.model.published_at); + const publishedAt = fromDatabaseDate(this.model.published_at); const timestamp = luck(60) ? publishedAt : generateEvents({ @@ -55,7 +55,7 @@ class EmailsImporter extends TableImporter { const recipientCount = this.membersSubscribeEvents .filter(entry => entry.newsletter_id === newsletter.id) - .filter(entry => databaseDate.parse(entry.created_at) < timestamp).length; + .filter(entry => fromDatabaseDate(entry.created_at) < timestamp).length; const deliveredCount = Math.ceil(recipientCount * faker.number.float({ max: 1, min: 0.9, @@ -87,10 +87,10 @@ class EmailsImporter extends TableImporter { track_opens: true, track_clicks: true, feedback_enabled: true, - submitted_at: databaseDate.dateToDatabaseString(timestamp), + submitted_at: toDatabaseDate(timestamp), newsletter_id: newsletter.id, - created_at: databaseDate.dateToDatabaseString(timestamp), - updated_at: databaseDate.dateToDatabaseString(timestamp) + created_at: toDatabaseDate(timestamp), + updated_at: toDatabaseDate(timestamp) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/labels-importer.js b/ghost/core/core/server/data/seeders/importers/labels-importer.js index e8fef6bfdcb..1832df8f674 100644 --- a/ghost/core/core/server/data/seeders/importers/labels-importer.js +++ b/ghost/core/core/server/data/seeders/importers/labels-importer.js @@ -2,7 +2,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const {slugify} = require('@tryghost/string'); const {blogStartDate} = require('../utils/blog-info'); -const databaseDate = require('../utils/database-date'); +const {toDatabaseDate} = require('../../../lib/db-date'); class LabelsImporter extends TableImporter { static table = 'labels'; @@ -30,8 +30,8 @@ class LabelsImporter extends TableImporter { id: this.fastFakeObjectId(), name: name, slug: `${slugify(name)}`, - created_at: databaseDate.dateToDatabaseString(blogStartDate), - updated_at: databaseDate.dateToDatabaseString(blogStartDate) + created_at: toDatabaseDate(blogStartDate), + updated_at: toDatabaseDate(blogStartDate) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js index f9c80d9f0a7..c52905cbf43 100644 --- a/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js @@ -1,7 +1,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); -const {luck} = require('../utils/random'); -const databaseDate = require('../utils/database-date'); +const {luck, randomDateBetween} = require('../utils/random'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class MembersClickEventsImporter extends TableImporter { static table = 'members_click_events'; @@ -50,9 +50,9 @@ class MembersClickEventsImporter extends TableImporter { } this.amount -= 1; - const openedAt = databaseDate.parse(this.model.opened_at); + const openedAt = fromDatabaseDate(this.model.opened_at); const laterOn = new Date(openedAt.getTime() + 1000 * 60 * 15); - const clickTime = faker.date.between({from: openedAt.getTime(), to: laterOn.getTime()}); //added getTime here because it threw random errors + const clickTime = randomDateBetween(openedAt, laterOn); return { id: this.fastFakeObjectId(), @@ -61,7 +61,7 @@ class MembersClickEventsImporter extends TableImporter { min: 0, max: this.redirectList.length - 1 }))].id, - created_at: databaseDate.dateToDatabaseString(clickTime) + created_at: toDatabaseDate(clickTime) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js index 650a5b4f022..641a2ee3fee 100644 --- a/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js @@ -1,7 +1,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const {luck} = require('../utils/random'); -const databaseDate = require('../utils/database-date'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class MembersCreatedEventsImporter extends TableImporter { static table = 'members_created_events'; @@ -13,7 +13,7 @@ class MembersCreatedEventsImporter extends TableImporter { async import(quantity) { const members = await this.transaction.select('id', 'created_at').from('members'); - this.posts = await this.transaction.select('id', 'published_at', 'visibility', 'type', 'slug').from('posts').orderBy('published_at', 'desc'); + this.posts = await this.transaction.select('id', 'published_at', 'visibility', 'type', 'slug').from('posts').whereNotNull('published_at').orderBy('published_at', 'desc'); this.incomingRecommendations = await this.transaction.select('id', 'source', 'created_at').from('mentions'); await this.importForEach(members, quantity ? quantity / members.length : 1); @@ -48,8 +48,8 @@ class MembersCreatedEventsImporter extends TableImporter { }; if (source === 'member' && luck(30)) { - const memberCreatedAt = databaseDate.parse(this.model.created_at); - const post = this.posts.find(p => p.visibility === 'public' && databaseDate.parse(p.published_at) < memberCreatedAt); + const memberCreatedAt = fromDatabaseDate(this.model.created_at); + const post = this.posts.find(p => p.visibility === 'public' && fromDatabaseDate(p.published_at) < memberCreatedAt); if (post) { attribution = { attribution_id: post.id, @@ -93,7 +93,7 @@ class MembersCreatedEventsImporter extends TableImporter { return { id: this.fastFakeObjectId(), - created_at: databaseDate.dateToDatabaseString(this.model.created_at), + created_at: toDatabaseDate(this.model.created_at), member_id: this.model.id, source, ...attribution, diff --git a/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js b/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js index 3b43be3cc4d..9563b87812d 100644 --- a/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js @@ -1,6 +1,6 @@ const {TableImporter} = require('./table-importer'); -const {luck} = require('../utils/random'); -const databaseDate = require('../utils/database-date'); +const {luck, randomDateBetween} = require('../utils/random'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class MembersFeedbackImporter extends TableImporter { static table = 'members_feedback'; @@ -24,10 +24,10 @@ class MembersFeedbackImporter extends TableImporter { return null; } - const openedAt = databaseDate.parse(this.model.opened_at); - const laterOn = databaseDate.parse(this.model.opened_at); + const openedAt = fromDatabaseDate(this.model.opened_at); + const laterOn = new Date(openedAt); laterOn.setMinutes(laterOn.getMinutes() + 60); - const feedbackTime = databaseDate.randomBetween(openedAt, laterOn); + const feedbackTime = randomDateBetween(openedAt, laterOn); const postId = this.emails.find(email => email.id === this.model.email_id).post_id; return { @@ -35,8 +35,8 @@ class MembersFeedbackImporter extends TableImporter { score: luck(70) ? 1 : 0, member_id: this.model.member_id, post_id: postId, - created_at: databaseDate.dateToDatabaseString(feedbackTime), - updated_at: databaseDate.dateToDatabaseString(feedbackTime) + created_at: toDatabaseDate(feedbackTime), + updated_at: toDatabaseDate(feedbackTime) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/members-importer.js b/ghost/core/core/server/data/seeders/importers/members-importer.js index 11bfcf3dbe8..a4f775fee32 100644 --- a/ghost/core/core/server/data/seeders/importers/members-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-importer.js @@ -4,7 +4,7 @@ const {faker: americanFaker} = require('@faker-js/faker/locale/en_US'); const {blogStartDate: startTime} = require('../utils/blog-info'); const generateEvents = require('../utils/event-generator'); const {luck} = require('../utils/random'); -const databaseDate = require('../utils/database-date'); +const {toDatabaseDate} = require('../../../lib/db-date'); const debug = require('@tryghost/debug')('MembersImporter'); class MembersImporter extends TableImporter { @@ -88,9 +88,9 @@ class MembersImporter extends TableImporter { email_opened_count: 0, email_open_rate: null, // 40% of users logged in within a week, 60% sometime since registering - last_seen_at: luck(40) ? databaseDate.dateToDatabaseString(faker.date.recent({days: 7})) : databaseDate.dateToDatabaseString(faker.date.between({from: timestamp, to: new Date()})), - created_at: databaseDate.dateToDatabaseString(timestamp), - updated_at: databaseDate.dateToDatabaseString(timestamp) + last_seen_at: luck(40) ? toDatabaseDate(faker.date.recent({days: 7})) : toDatabaseDate(faker.date.between({from: timestamp, to: new Date()})), + created_at: toDatabaseDate(timestamp), + updated_at: toDatabaseDate(timestamp) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/members-login-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-login-events-importer.js index 3cc32b220b9..9f020569aec 100644 --- a/ghost/core/core/server/data/seeders/importers/members-login-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-login-events-importer.js @@ -1,7 +1,7 @@ const {TableImporter} = require('./table-importer'); const {luck} = require('../utils/random'); const generateEvents = require('../utils/event-generator'); -const databaseDate = require('../utils/database-date'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class MembersLoginEventsImporter extends TableImporter { static table = 'members_login_events'; @@ -34,7 +34,7 @@ class MembersLoginEventsImporter extends TableImporter { setReferencedModel(model) { this.model = model; - const memberCreatedAt = databaseDate.parse(model.created_at); + const memberCreatedAt = fromDatabaseDate(model.created_at); const endDate = new Date(); const daysBetween = Math.ceil((endDate.valueOf() - memberCreatedAt.valueOf()) / (1000 * 60 * 60 * 24)); @@ -60,7 +60,7 @@ class MembersLoginEventsImporter extends TableImporter { } return { id: this.fastFakeObjectId(), - created_at: databaseDate.dateToDatabaseString(timestamp), + created_at: toDatabaseDate(timestamp), member_id: this.model.id }; } diff --git a/ghost/core/core/server/data/seeders/importers/members-products-importer.js b/ghost/core/core/server/data/seeders/importers/members-products-importer.js index 893e2098be2..7fed09eff64 100644 --- a/ghost/core/core/server/data/seeders/importers/members-products-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-products-importer.js @@ -1,7 +1,7 @@ const {faker} = require('@faker-js/faker'); const {TableImporter} = require('./table-importer'); const {luck} = require('../utils/random'); -const databaseDate = require('../utils/database-date'); +const {toDatabaseDate} = require('../../../lib/db-date'); class MembersProductsImporter extends TableImporter { static table = 'members_products'; @@ -34,7 +34,7 @@ class MembersProductsImporter extends TableImporter { member_id: this.model.id, product_id: this.getProduct().id, sort_order: 0, - expiry_at: this.model.status === 'paid' ? null : (luck(50) ? null : databaseDate.dateToDatabaseString(faker.date.future())) + expiry_at: this.model.status === 'paid' ? null : (luck(50) ? null : toDatabaseDate(faker.date.future())) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js index d18a602cb5b..be1752ef98d 100644 --- a/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js @@ -1,5 +1,6 @@ const {TableImporter} = require('./table-importer'); -const databaseDate = require('../utils/database-date'); +const {randomDateBetween} = require('../utils/random'); +const {toDatabaseDate} = require('../../../lib/db-date'); class MembersStatusEventsImporter extends TableImporter { static table = 'members_status_events'; @@ -31,7 +32,7 @@ class MembersStatusEventsImporter extends TableImporter { member_id: model.id, from_status: null, to_status: 'free', - created_at: databaseDate.dateToDatabaseString(model.created_at) + created_at: toDatabaseDate(model.created_at) }]; if (model.status !== 'free') { this.events.push({ @@ -39,7 +40,7 @@ class MembersStatusEventsImporter extends TableImporter { member_id: model.id, from_status: 'free', to_status: model.status, - created_at: databaseDate.dateToDatabaseString(databaseDate.randomBetween(model.created_at, new Date())) + created_at: toDatabaseDate(randomDateBetween(model.created_at, new Date())) }); } } diff --git a/ghost/core/core/server/data/seeders/importers/members-stripe-customers-importer.js b/ghost/core/core/server/data/seeders/importers/members-stripe-customers-importer.js index 34203ad2d2a..1970c99f158 100644 --- a/ghost/core/core/server/data/seeders/importers/members-stripe-customers-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-stripe-customers-importer.js @@ -1,6 +1,6 @@ const {faker} = require('@faker-js/faker'); const {TableImporter} = require('./table-importer'); -const databaseDate = require('../utils/database-date'); +const {fromDatabaseDate} = require('../../../lib/db-date'); class MembersStripeCustomersImporter extends TableImporter { static table = 'members_stripe_customers'; @@ -35,7 +35,7 @@ class MembersStripeCustomersImporter extends TableImporter { // Only 30% of free members should have a stripe customer = have had a subscription in the past or tried to subscribe // The number should increase the older the member is - const daysSinceMemberCreated = Math.floor((new Date() - databaseDate.parse(this.model.created_at)) / (1000 * 60 * 60 * 24)); + const daysSinceMemberCreated = Math.floor((new Date() - fromDatabaseDate(this.model.created_at)) / (1000 * 60 * 60 * 24)); const shouldHaveStripeCustomer = faker.number.int({min: 0, max: 100}) < Math.max(Math.min(daysSinceMemberCreated / 60, 15), 2); if (!shouldHaveStripeCustomer) { diff --git a/ghost/core/core/server/data/seeders/importers/members-stripe-customers-subscriptions-importer.js b/ghost/core/core/server/data/seeders/importers/members-stripe-customers-subscriptions-importer.js index 766eb2f6993..1e3be210c5e 100644 --- a/ghost/core/core/server/data/seeders/importers/members-stripe-customers-subscriptions-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-stripe-customers-subscriptions-importer.js @@ -1,8 +1,8 @@ const {faker} = require('@faker-js/faker'); const {TableImporter} = require('./table-importer'); -const databaseDate = require('../utils/database-date'); const generateEvents = require('../utils/event-generator'); const {luck} = require('../utils/random'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class MembersStripeCustomersSubscriptionsImporter extends TableImporter { static table = 'members_stripe_customers_subscriptions'; @@ -95,7 +95,7 @@ class MembersStripeCustomersSubscriptionsImporter extends TableImporter { (isMonthly ? price.interval === 'month' : price.interval === 'year'); }); const mrr = createValid ? (isMonthly ? stripePrice.amount : Math.floor(stripePrice.amount / 12)) : 0; - const memberCreatedAt = databaseDate.parse(member.created_at); + const memberCreatedAt = fromDatabaseDate(member.created_at); const referenceEndDate = this.lastSubscriptionStart ?? new Date(); @@ -193,8 +193,8 @@ class MembersStripeCustomersSubscriptionsImporter extends TableImporter { validStatusses.push({ status: 'trialing', cancel_at_period_end: false, - trial_end_at: databaseDate.dateToDatabaseString(endDate), - trial_start_at: databaseDate.dateToDatabaseString(startDate) + trial_end_at: toDatabaseDate(endDate), + trial_start_at: toDatabaseDate(startDate) }); } @@ -236,8 +236,8 @@ class MembersStripeCustomersSubscriptionsImporter extends TableImporter { customer_id: customer.customer_id, subscription_id: `sub_${faker.string.alphanumeric(14)}`, stripe_price_id: stripePrice.stripe_price_id, - start_date: databaseDate.dateToDatabaseString(startDate), - created_at: databaseDate.dateToDatabaseString(startDate), + start_date: toDatabaseDate(startDate), + created_at: toDatabaseDate(startDate), mrr, plan_id: stripeProduct.stripe_product_id, plan_nickname: `${ghostProduct.name} - ${stripePrice.nickname}`, @@ -248,7 +248,7 @@ class MembersStripeCustomersSubscriptionsImporter extends TableImporter { // Defaults status: 'active', cancel_at_period_end: false, - current_period_end: databaseDate.dateToDatabaseString(endDate), + current_period_end: toDatabaseDate(endDate), // Override ...status diff --git a/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js index e05856e8d37..57e3daf3185 100644 --- a/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js @@ -1,6 +1,6 @@ const {TableImporter} = require('./table-importer'); -const {luck} = require('../utils/random'); -const databaseDate = require('../utils/database-date'); +const {luck, randomDateBetween} = require('../utils/random'); +const {toDatabaseDate} = require('../../../lib/db-date'); class MembersSubscribeEventsImporter extends TableImporter { static table = 'members_subscribe_events'; @@ -50,7 +50,7 @@ class MembersSubscribeEventsImporter extends TableImporter { return null; } - const createdAt = databaseDate.dateToDatabaseString(databaseDate.randomBetween(this.model.created_at, new Date())); + const createdAt = toDatabaseDate(randomDateBetween(this.model.created_at, new Date())); const newsletterId = this.newsletters[count % this.newsletters.length].id; return { diff --git a/ghost/core/core/server/data/seeders/importers/members-subscription-created-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-subscription-created-events-importer.js index 0704cebb035..0611c14e3a8 100644 --- a/ghost/core/core/server/data/seeders/importers/members-subscription-created-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-subscription-created-events-importer.js @@ -1,7 +1,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const {luck} = require('../utils/random'); -const databaseDate = require('../utils/database-date'); +const {fromDatabaseDate} = require('../../../lib/db-date'); class MembersSubscriptionCreatedEventsImporter extends TableImporter { static table = 'members_subscription_created_events'; @@ -48,8 +48,8 @@ class MembersSubscriptionCreatedEventsImporter extends TableImporter { }; if (luck(30)) { - const createdAt = databaseDate.parse(this.model.created_at); - const post = this.posts.find(p => p.visibility === 'public' && databaseDate.parse(p.published_at) < createdAt); + const createdAt = fromDatabaseDate(this.model.created_at); + const post = this.posts.find(p => p.visibility === 'public' && fromDatabaseDate(p.published_at) < createdAt); if (post) { attribution = { attribution_id: post.id, diff --git a/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js b/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js index 016a44bd882..ba16aee37a9 100644 --- a/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js +++ b/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js @@ -1,7 +1,8 @@ const {faker} = require('@faker-js/faker'); const errors = require('@tryghost/errors'); const {TableImporter} = require('./table-importer'); -const databaseDate = require('../utils/database-date'); +const {randomDateBetween} = require('../utils/random'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class OfferRedemptionsImporter extends TableImporter { static table = 'offer_redemptions'; @@ -53,7 +54,7 @@ class OfferRedemptionsImporter extends TableImporter { this.subscriptionPool.push({ memberId, subscriptionId: subscription.id, - subscriptionCreatedAt: databaseDate.parse(subscription.created_at), + subscriptionCreatedAt: fromDatabaseDate(subscription.created_at), redemptionEndAt: this.getRedemptionEndDate(subscription.current_period_end), availableOffers: [...matchingOffers], lastRedeemedAt: null @@ -78,7 +79,7 @@ class OfferRedemptionsImporter extends TableImporter { getRedemptionEndDate(currentPeriodEnd) { const now = new Date(); - const endDate = currentPeriodEnd ? databaseDate.parse(currentPeriodEnd) : now; + const endDate = currentPeriodEnd ? fromDatabaseDate(currentPeriodEnd) : now; return endDate > now ? now : endDate; } @@ -86,19 +87,18 @@ class OfferRedemptionsImporter extends TableImporter { getCreatedAt(subscriptionState, offer) { const candidateEarliest = new Date(Math.max( subscriptionState.subscriptionCreatedAt.valueOf(), - databaseDate.parse(offer.created_at).valueOf(), + fromDatabaseDate(offer.created_at).valueOf(), subscriptionState.lastRedeemedAt ? subscriptionState.lastRedeemedAt.valueOf() + 1000 : 0 )); const earliest = new Date(Math.min( candidateEarliest.valueOf(), subscriptionState.redemptionEndAt.valueOf() )); - const latest = subscriptionState.redemptionEndAt > earliest ? subscriptionState.redemptionEndAt : earliest; - const createdAt = latest.valueOf() === earliest.valueOf() ? earliest : faker.date.between({from: earliest, to: latest}); + const createdAt = randomDateBetween(earliest, subscriptionState.redemptionEndAt); subscriptionState.lastRedeemedAt = createdAt; - return databaseDate.dateToDatabaseString(createdAt); + return toDatabaseDate(createdAt); } generate() { diff --git a/ghost/core/core/server/data/seeders/importers/offers-importer.js b/ghost/core/core/server/data/seeders/importers/offers-importer.js index c547eeaf795..ce3e84b6534 100644 --- a/ghost/core/core/server/data/seeders/importers/offers-importer.js +++ b/ghost/core/core/server/data/seeders/importers/offers-importer.js @@ -1,7 +1,7 @@ const {TableImporter} = require('./table-importer'); const {slugify} = require('@tryghost/string'); const {blogStartDate} = require('../utils/blog-info'); -const databaseDate = require('../utils/database-date'); +const {toDatabaseDate} = require('../../../lib/db-date'); const offerTemplates = [{ name: 'Black Friday', @@ -107,8 +107,8 @@ class OffersImporter extends TableImporter { portal_title: name, portal_description: template.description, redemption_type: 'signup', - created_at: databaseDate.dateToDatabaseString(blogStartDate), - updated_at: databaseDate.dateToDatabaseString(blogStartDate) + created_at: toDatabaseDate(blogStartDate), + updated_at: toDatabaseDate(blogStartDate) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/posts-importer.js b/ghost/core/core/server/data/seeders/importers/posts-importer.js index 7757c3d2bba..82746c3d0de 100644 --- a/ghost/core/core/server/data/seeders/importers/posts-importer.js +++ b/ghost/core/core/server/data/seeders/importers/posts-importer.js @@ -2,7 +2,7 @@ const {faker} = require('@faker-js/faker'); const {slugify} = require('@tryghost/string'); const {luck} = require('../utils/random'); const {TableImporter} = require('./table-importer'); -const databaseDate = require('../utils/database-date'); +const {toDatabaseDate} = require('../../../lib/db-date'); class PostsImporter extends TableImporter { static table = 'posts'; @@ -53,9 +53,9 @@ class PostsImporter extends TableImporter { return { id, - created_at: databaseDate.dateToDatabaseString(timestamp), - updated_at: databaseDate.dateToDatabaseString(timestamp), - published_at: status === 'published' ? databaseDate.dateToDatabaseString(timestamp) : status === 'scheduled' ? databaseDate.dateToDatabaseString(faker.date.soon({days: 5, refDate: timestamp})) : null, + created_at: toDatabaseDate(timestamp), + updated_at: toDatabaseDate(timestamp), + published_at: status === 'published' ? toDatabaseDate(timestamp) : status === 'scheduled' ? toDatabaseDate(faker.date.soon({days: 5, refDate: timestamp})) : null, uuid: faker.string.uuid(), comment_id: this.type === 'post' ? id : null, title: title, diff --git a/ghost/core/core/server/data/seeders/importers/table-importer.ts b/ghost/core/core/server/data/seeders/importers/table-importer.ts index 3389bf21bf2..48d8b6287bc 100644 --- a/ghost/core/core/server/data/seeders/importers/table-importer.ts +++ b/ghost/core/core/server/data/seeders/importers/table-importer.ts @@ -1,5 +1,5 @@ import debugFactory from '@tryghost/debug'; -import * as databaseDate from '../utils/database-date'; +import {toDatabaseDate} from '../../../lib/db-date'; import path from 'node:path'; import fs from 'node:fs'; import papaparse from 'papaparse'; @@ -123,7 +123,7 @@ export abstract class TableImporter< if (typeof value === 'boolean') { mutableObj[key] = value ? 1 : 0; } else if (value instanceof Date) { - mutableObj[key] = databaseDate.dateToDatabaseString(value); + mutableObj[key] = toDatabaseDate(value); } else if (value === null) { mutableObj[key] = '\\N'; } diff --git a/ghost/core/core/server/data/seeders/importers/tags-importer.js b/ghost/core/core/server/data/seeders/importers/tags-importer.js index 6f1a2b62751..36c0fbe8c0c 100644 --- a/ghost/core/core/server/data/seeders/importers/tags-importer.js +++ b/ghost/core/core/server/data/seeders/importers/tags-importer.js @@ -1,7 +1,7 @@ const {faker} = require('@faker-js/faker'); const {slugify} = require('@tryghost/string'); const {TableImporter} = require('./table-importer'); -const databaseDate = require('../utils/database-date'); +const {toDatabaseDate} = require('../../../lib/db-date'); class TagsImporter extends TableImporter { static table = 'tags'; @@ -32,7 +32,7 @@ class TagsImporter extends TableImporter { name: name, slug: slugify(name), description: faker.lorem.sentence(), - created_at: databaseDate.dateToDatabaseString(faker.date.between({from: threeYearsAgo, to: twoYearsAgo})) + created_at: toDatabaseDate(faker.date.between({from: threeYearsAgo, to: twoYearsAgo})) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/users-importer.js b/ghost/core/core/server/data/seeders/importers/users-importer.js index 1ae7d07e961..4b904522bf9 100644 --- a/ghost/core/core/server/data/seeders/importers/users-importer.js +++ b/ghost/core/core/server/data/seeders/importers/users-importer.js @@ -2,7 +2,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const {slugify} = require('@tryghost/string'); const security = require('@tryghost/security'); -const databaseDate = require('../utils/database-date'); +const {toDatabaseDate} = require('../../../lib/db-date'); class UsersImporter extends TableImporter { static table = 'users'; @@ -24,7 +24,7 @@ class UsersImporter extends TableImporter { password: await security.password.hash(faker.color.human()), email: faker.internet.email({firstName, lastName}), profile_image: faker.image.avatar(), - created_at: databaseDate.dateToDatabaseString(faker.date.between({from: new Date(2016, 0), to: new Date()})) + created_at: toDatabaseDate(faker.date.between({from: new Date(2016, 0), to: new Date()})) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/web-mentions-importer.js b/ghost/core/core/server/data/seeders/importers/web-mentions-importer.js index 2e1793a9ed7..803a912c5d9 100644 --- a/ghost/core/core/server/data/seeders/importers/web-mentions-importer.js +++ b/ghost/core/core/server/data/seeders/importers/web-mentions-importer.js @@ -1,6 +1,6 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); -const databaseDate = require('../utils/database-date'); +const {toDatabaseDate} = require('../../../lib/db-date'); class WebMentionsImporter extends TableImporter { static table = 'mentions'; @@ -31,7 +31,7 @@ class WebMentionsImporter extends TableImporter { target: `${this.baseUrl}`, resource_id: null, resource_type: null, - created_at: databaseDate.dateToDatabaseString(faker.date.past()), + created_at: toDatabaseDate(faker.date.past()), payload: JSON.stringify({}), deleted: 0, verified: 1 diff --git a/ghost/core/core/server/data/seeders/utils/database-date.ts b/ghost/core/core/server/data/seeders/utils/database-date.ts deleted file mode 100644 index 39a2512e3c7..00000000000 --- a/ghost/core/core/server/data/seeders/utils/database-date.ts +++ /dev/null @@ -1,30 +0,0 @@ -import {faker} from '@faker-js/faker'; - -const databaseDatePattern = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/; - -export function dateToDatabaseString(date: Date | string): string { - if (typeof date === 'string') { - // SQLite fix when reusing other dates from the db - return date; - } - return date.toISOString().replace('Z', '').replace('T', ' '); -} - -export function parse(date: Date | string | number): Date { - if (date instanceof Date) { - return new Date(date); - } - - if (typeof date === 'string' && databaseDatePattern.test(date)) { - return new Date(date.replace(' ', 'T') + 'Z'); - } - - return new Date(date); -} - -export function randomBetween(start: Date | string | number, end: Date | string | number): Date { - const earliest = parse(start); - const latest = parse(end); - - return latest > earliest ? faker.date.between({from: earliest, to: latest}) : earliest; -} diff --git a/ghost/core/core/server/data/seeders/utils/random.ts b/ghost/core/core/server/data/seeders/utils/random.ts index 357209a3524..0ba0a36de45 100644 --- a/ghost/core/core/server/data/seeders/utils/random.ts +++ b/ghost/core/core/server/data/seeders/utils/random.ts @@ -1,4 +1,5 @@ import {faker} from '@faker-js/faker'; +import {fromDatabaseDate, type DatabaseDate} from '../../../lib/db-date'; /** * Adds another degree of randomness into some decisions @@ -9,3 +10,10 @@ export const luck = (lowerThan: number): boolean => faker.number.int({ min: 1, max: 100 }) <= lowerThan; + +export function randomDateBetween(start: DatabaseDate, end: DatabaseDate): Date { + const earliest = fromDatabaseDate(start); + const latest = fromDatabaseDate(end); + + return latest > earliest ? faker.date.between({from: earliest, to: latest}) : earliest; +} diff --git a/ghost/core/core/server/lib/db-date.ts b/ghost/core/core/server/lib/db-date.ts index 558352e5635..be86206be43 100644 --- a/ghost/core/core/server/lib/db-date.ts +++ b/ghost/core/core/server/lib/db-date.ts @@ -1,8 +1,54 @@ +import {DateTime} from 'luxon'; +import * as errors from '@tryghost/errors'; import {z} from 'zod'; +const DATABASE_DATE_FORMAT = 'yyyy-MM-dd HH:mm:ss'; + +const databaseDateInput = z.union([z.date(), z.string(), z.number()]); + +export type DatabaseDate = z.input; + +const parseDatabaseDateString = (value: string): DateTime => { + const sql = DateTime.fromSQL(value, {zone: 'utc'}); + + return sql.isValid ? sql : DateTime.fromISO(value, {zone: 'utc'}); +}; + +const parseDatabaseDate = (date: DatabaseDate): DateTime => { + const input = databaseDateInput.safeParse(date); + + if (!input.success) { + throw new errors.InternalServerError({message: 'Invalid database date'}); + } + + let parsed: DateTime; + + if (typeof input.data === 'string') { + parsed = parseDatabaseDateString(input.data); + } else if (typeof input.data === 'number') { + parsed = DateTime.fromMillis(input.data, {zone: 'utc'}); + } else { + parsed = DateTime.fromJSDate(input.data, {zone: 'utc'}); + } + + if (!parsed.isValid) { + throw new errors.InternalServerError({message: 'Invalid database date'}); + } + + return parsed; +}; + +// Raw Knex queries need this UTC datetime format for consistent MySQL and SQLite behavior. +export const toDatabaseDate = (date: DatabaseDate): string => parseDatabaseDate(date).toFormat(DATABASE_DATE_FORMAT); + +export const fromDatabaseDate = (date: DatabaseDate): Date => parseDatabaseDate(date).toJSDate(); + // A zod codec for datetime columns: MySQL returns a Date, SQLite a string/number; // normalise to a Date on read and pass a Date through on write. -export const DbDate = z.codec(z.union([z.date(), z.string(), z.number()]), z.date(), { - decode: value => new Date(value), +// On SQLite, Knex binds the Date that DbDate passes through as epoch milliseconds (stored as +// INTEGER) while toDatabaseDate writes TEXT; SQLite orders INTEGER before TEXT, so never compare +// or order those two representations. +export const DbDate = z.codec(databaseDateInput, z.date(), { + decode: fromDatabaseDate, encode: date => date }); diff --git a/ghost/core/core/server/services/automations/database-automations-repository.ts b/ghost/core/core/server/services/automations/database-automations-repository.ts index 67b517bf32d..7c80ad94edc 100644 --- a/ghost/core/core/server/services/automations/database-automations-repository.ts +++ b/ghost/core/core/server/services/automations/database-automations-repository.ts @@ -22,7 +22,7 @@ import type { EditAutomationData, Page } from './automations-repository'; -import {fromDatabaseDate, toDatabaseDate, type DatabaseDate} from './database-date'; +import {fromDatabaseDate, toDatabaseDate, type DatabaseDate} from '../../lib/db-date'; import {getStaleLockCutoff} from './stale-lock-cutoff'; import type {ExclusifyUnion, ReadonlyDeep} from 'type-fest'; diff --git a/ghost/core/core/server/services/automations/database-date.ts b/ghost/core/core/server/services/automations/database-date.ts deleted file mode 100644 index 545f7b725cd..00000000000 --- a/ghost/core/core/server/services/automations/database-date.ts +++ /dev/null @@ -1,26 +0,0 @@ -import moment from 'moment'; -import * as errors from '@tryghost/errors'; - -export const DATABASE_DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss'; - -export type DatabaseDate = Date | string | number; - -export const toDatabaseDate = (date: Date | string): string => moment.utc(date).format(DATABASE_DATE_FORMAT); - -export const fromDatabaseDate = (date: DatabaseDate): Date => { - if (date instanceof Date) { - return new Date(date); - } - - if (typeof date === 'string') { - return moment.utc(date, DATABASE_DATE_FORMAT).toDate(); - } - - // Defense-in-depth for legacy SQLite rows stored as epoch milliseconds. - if (typeof date === 'number') { - return moment.utc(date).toDate(); - } - - const exhaustive: never = date; - throw new errors.InternalServerError({message: `Unexpected type for database date: ${exhaustive}`}); -}; diff --git a/ghost/core/package.json b/ghost/core/package.json index e1c26132830..f1fddf13d47 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -264,6 +264,7 @@ "@types/jsonwebtoken": "9.0.10", "@types/lodash": "4.17.25", "@types/lodash-es": "4.17.12", + "@types/luxon": "catalog:", "@types/mime-types": "3.0.1", "@types/nconf": "catalog:", "@types/node": "catalog:", diff --git a/ghost/core/test/unit/server/data/seeders/data-generator.test.js b/ghost/core/test/unit/server/data/seeders/data-generator.test.js index 4fbb051020a..af6b9839a19 100644 --- a/ghost/core/test/unit/server/data/seeders/data-generator.test.js +++ b/ghost/core/test/unit/server/data/seeders/data-generator.test.js @@ -11,7 +11,7 @@ const AutomationsImporter = importers.find(i => i.table === 'automations'); const AutomationRunStepsImporter = importers.find(i => i.table === 'automation_run_steps'); const generateEvents = require('../../../../../core/server/data/seeders/utils/event-generator'); -const databaseDate = require('../../../../../core/server/data/seeders/utils/database-date'); +const {randomDateBetween} = require('../../../../../core/server/data/seeders/utils/random'); const DataGenerator = require('../../../../../core/server/data/seeders/data-generator'); @@ -479,26 +479,11 @@ describe('Importer', function () { }); describe('Events Generator', function () { - it('Parses database timestamps as UTC in non-UTC timezones', function () { - const originalTZ = process.env.TZ; - try { - process.env.TZ = 'America/New_York'; - const result = databaseDate.parse('2026-03-26 11:50:00.000'); - assert.equal(result.toISOString(), '2026-03-26T11:50:00.000Z'); - } finally { - if (originalTZ === undefined) { - delete process.env.TZ; - } else { - process.env.TZ = originalTZ; - } - } - }); - it('Returns the start date when a range is inverted', function () { const startDate = new Date('2026-03-26T11:50:00.000Z'); const endDate = new Date('2026-03-26T10:00:00.000Z'); - assert.equal(databaseDate.randomBetween(startDate, endDate).toISOString(), startDate.toISOString()); + assert.equal(randomDateBetween(startDate, endDate).toISOString(), startDate.toISOString()); }); it('Generates a set of timestamps which meet the criteria', function () { diff --git a/ghost/core/test/unit/server/services/automations/database-date.test.ts b/ghost/core/test/unit/server/lib/db-date.test.ts similarity index 60% rename from ghost/core/test/unit/server/services/automations/database-date.test.ts rename to ghost/core/test/unit/server/lib/db-date.test.ts index 8ba86a91db7..6d1e6ab8257 100644 --- a/ghost/core/test/unit/server/services/automations/database-date.test.ts +++ b/ghost/core/test/unit/server/lib/db-date.test.ts @@ -1,7 +1,8 @@ import assert from 'node:assert/strict'; import {spawn} from 'node:child_process'; import {once} from 'node:events'; -import {fromDatabaseDate, toDatabaseDate} from '../../../../../core/server/services/automations/database-date'; +import * as errors from '@tryghost/errors'; +import {DbDate, fromDatabaseDate, toDatabaseDate} from '../../../../core/server/lib/db-date'; describe('database date utilities', function () { const timezones = [ @@ -19,12 +20,12 @@ describe('database date utilities', function () { const runInOtherTimezones = async (toRun: string) => { // JSON.stringify does a good job wrapping strings in quotes and escaping. const s = JSON.stringify; - const modulePath = require.resolve('../../../../../core/server/services/automations/database-date'); + const modulePath = require.resolve('../../../../core/server/lib/db-date'); await Promise.all(timezones.map(async ({tz, expectedNaive}) => { const source = ` const assert = require('node:assert/strict'); - const {fromDatabaseDate, toDatabaseDate} = require(${s(modulePath)}); + const {DbDate, fromDatabaseDate, toDatabaseDate} = require(${s(modulePath)}); assert.deepEqual(new Date('2020-01-01 12:34:56'), new Date(${s(expectedNaive)}), 'test setup'); @@ -41,6 +42,18 @@ describe('database date utilities', function () { })); }; + describe('DbDate', function () { + it('decodes database date strings as UTC', function () { + assert.deepEqual(DbDate.decode('2020-01-01 12:34:56'), new Date('2020-01-01T12:34:56.000Z')); + }); + + it('decodes database date strings as UTC in other system timezones', async function () { + await runInOtherTimezones(` + assert.deepEqual(DbDate.decode('2020-01-01 12:34:56'), new Date('2020-01-01T12:34:56.000Z')); + `); + }); + }); + describe('toDatabaseDate', function () { it('converts Dates to database date strings', function () { const input = new Date('2024-06-01T12:34:56Z'); @@ -48,6 +61,10 @@ describe('database date utilities', function () { assert.strictEqual(result, '2024-06-01 12:34:56'); }); + it('converts epoch milliseconds to database date strings', function () { + assert.strictEqual(toDatabaseDate(1577882096000), '2020-01-01 12:34:56'); + }); + it('converts Zulu date strings to database date strings', function () { const input = '2024-06-01T12:34:56Z'; const result = toDatabaseDate(input); @@ -71,6 +88,11 @@ describe('database date utilities', function () { assert.deepEqual(toDatabaseDate('2020-01-01 12:34:56'), '2020-01-01 12:34:56'); `); }); + + it('rejects invalid dates', function () { + assert.throws(() => toDatabaseDate('not-a-date'), errors.InternalServerError); + assert.throws(() => toDatabaseDate(new Date('not-a-date')), errors.InternalServerError); + }); }); describe('fromDatabaseDate', function () { @@ -85,14 +107,41 @@ describe('database date utilities', function () { assert.deepEqual(fromDatabaseDate('2020-01-01T12:34:56.000Z'), new Date('2020-01-01T12:34:56.000Z')); }); + it('preserves fractional seconds in database date strings', function () { + assert.deepEqual(fromDatabaseDate('2020-01-01 12:34:56.123'), new Date('2020-01-01T12:34:56.123Z')); + }); + + it('respects timezone offsets in ISO date strings', function () { + assert.deepEqual(fromDatabaseDate('2020-01-01T12:34:56.123-04:00'), new Date('2020-01-01T16:34:56.123Z')); + }); + + it('respects timezone offsets in SQL date strings', function () { + assert.deepEqual(fromDatabaseDate('2020-01-01 12:34:56+00:00'), new Date('2020-01-01T12:34:56.000Z')); + }); + it('converts strings to Date objects, parsing as UTC, in other system timezones', async function () { await runInOtherTimezones(` assert.deepEqual(fromDatabaseDate('2020-01-01 12:34:56'), new Date('2020-01-01T12:34:56.000Z')); + assert.deepEqual(fromDatabaseDate('2020-01-01 12:34:56.123'), new Date('2020-01-01T12:34:56.123Z')); `); }); it('converts numbers to Date objects', function () { assert.deepEqual(fromDatabaseDate(1577882096000), new Date('2020-01-01T12:34:56.000Z')); }); + + it('rejects malformed strings', function () { + assert.throws(() => fromDatabaseDate('not-a-date'), errors.InternalServerError); + }); + + it('rejects invalid Date objects', function () { + assert.throws(() => fromDatabaseDate(new Date('not-a-date')), errors.InternalServerError); + }); + + it('rejects non-finite numbers', function () { + for (const input of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) { + assert.throws(() => fromDatabaseDate(input), errors.InternalServerError); + } + }); }); }); diff --git a/ghost/core/test/unit/server/services/automations/automations-repository.test.ts b/ghost/core/test/unit/server/services/automations/automations-repository.test.ts index e390b2f5bd8..612912293be 100644 --- a/ghost/core/test/unit/server/services/automations/automations-repository.test.ts +++ b/ghost/core/test/unit/server/services/automations/automations-repository.test.ts @@ -8,7 +8,7 @@ import {NON_EMPTY_EMAIL_LEXICAL} from '../../../../utils/automations-fixtures'; import ghostConfig from '../../../../../core/shared/config'; import {createDatabaseAutomationsRepository} from '../../../../../core/server/services/automations/database-automations-repository'; import type {AutomatedEmailEvents, AutomationAction, AutomationsRepository, AutomationStepToRun} from '../../../../../core/server/services/automations/automations-repository'; -import {DATABASE_DATE_FORMAT, fromDatabaseDate, toDatabaseDate} from '../../../../../core/server/services/automations/database-date'; +import {fromDatabaseDate, toDatabaseDate} from '../../../../../core/server/lib/db-date'; const HOUR_MS = 60 * 60 * 1000; const FAKE_WAIT_HOURS_MULTIPLIER = 2500; @@ -1115,7 +1115,7 @@ describe('automations repository', function () { const step = await getStepByRunId(run.id); assert(step); - const readyAtMs = moment(step.ready_at, DATABASE_DATE_FORMAT).valueOf(); + const readyAtMs = fromDatabaseDate(step.ready_at).valueOf(); assert(readyAtMs >= beforeTrigger + (48 * FAKE_WAIT_HOURS_MULTIPLIER) - 999); assert(readyAtMs <= afterTrigger + (48 * FAKE_WAIT_HOURS_MULTIPLIER)); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27751ee72b9..a1dcf735235 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2717,6 +2717,9 @@ importers: '@types/lodash-es': specifier: 4.17.12 version: 4.17.12 + '@types/luxon': + specifier: 'catalog:' + version: 3.7.4 '@types/mime-types': specifier: 3.0.1 version: 3.0.1 From a7289099314b6f4592fa02fe343f051c5faf623e Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Wed, 19 Aug 2026 13:02:15 +0100 Subject: [PATCH 05/19] Added explicit Mailgun tracking opt-out (#30105) ref https://linear.app/ghost/issue/BER-3851 Transactional messages carrying private redemption links must be able to override publication tracking settings. Mailgun transports drop boolean false values, so the adapters use Mailgun's explicit string form and keep the transport-specific detail behind their existing interfaces. --- .../services/gifts/gift-email-service.ts | 4 ++- .../server/services/lib/mailgun-client.js | 7 ++-- .../core/server/services/mail/ghost-mailer.js | 10 +++++- .../services/gifts/gift-email-service.test.js | 3 +- .../services/lib/mailgun-client.test.js | 10 ++++-- .../server/services/mail/ghost-mailer.test.js | 36 +++++++++++++++++++ 6 files changed, 62 insertions(+), 8 deletions(-) diff --git a/ghost/core/core/server/services/gifts/gift-email-service.ts b/ghost/core/core/server/services/gifts/gift-email-service.ts index 813f5f524a6..5b67dd451e9 100644 --- a/ghost/core/core/server/services/gifts/gift-email-service.ts +++ b/ghost/core/core/server/services/gifts/gift-email-service.ts @@ -11,6 +11,7 @@ interface Mailer { text: string; from: string; forceTextContent: boolean; + disableTracking?: boolean; }): Promise; } @@ -119,7 +120,8 @@ export class GiftEmailService { html, text, from: this.getFromAddress(), - forceTextContent: true + forceTextContent: true, + disableTracking: true }); } diff --git a/ghost/core/core/server/services/lib/mailgun-client.js b/ghost/core/core/server/services/lib/mailgun-client.js index caed01255a7..7a9667a9957 100644 --- a/ghost/core/core/server/services/lib/mailgun-client.js +++ b/ghost/core/core/server/services/lib/mailgun-client.js @@ -95,8 +95,11 @@ module.exports = class MailgunClient { messageData['o:testmode'] = true; } - // enable tracking if turned on for this email - if (message.track_opens) { + if (message.disable_tracking) { + messageData['o:tracking'] = 'no'; + messageData['o:tracking-clicks'] = 'no'; + messageData['o:tracking-opens'] = 'no'; + } else if (message.track_opens) { messageData['o:tracking-opens'] = true; } diff --git a/ghost/core/core/server/services/mail/ghost-mailer.js b/ghost/core/core/server/services/mail/ghost-mailer.js index 483d62e06df..c4c6bffc50d 100644 --- a/ghost/core/core/server/services/mail/ghost-mailer.js +++ b/ghost/core/core/server/services/mail/ghost-mailer.js @@ -68,6 +68,7 @@ function createMessage(message) { delete cleanMessage.tags; delete cleanMessage.forceTextContent; delete cleanMessage.trackOpens; + delete cleanMessage.disableTracking; const addresses = getFromAddress(message.from, message.replyTo); @@ -132,6 +133,7 @@ module.exports = class GhostMailer { * @param {string} [message.text] - text version of this message * @param {string[]} [message.tags] - optional additional Mailgun tags * @param {boolean} [message.trackOpens] - per-message override for Mailgun open tracking + * @param {boolean} [message.disableTracking] - explicitly disable Mailgun open and click tracking * @param {Record} [message.headers] - optional additional email headers (merged with defaults) * @param {boolean} [message.forceTextContent] - maps to generateTextFromHTML nodemailer option * which is: "if set to true uses HTML to generate plain text body part from the HTML if the text is not defined" @@ -155,7 +157,13 @@ module.exports = class GhostMailer { const trackOpens = typeof message.trackOpens === 'boolean' ? message.trackOpens : settingsCache.get('email_track_opens'); - if (trackOpens) { + // nodemailer-mailgun-transport drops falsy option values, so an explicit + // opt-out must be Mailgun's string form rather than boolean false + if (message.disableTracking === true) { + messageToSend['o:tracking'] = 'no'; + messageToSend['o:tracking-opens'] = 'no'; + messageToSend['o:tracking-clicks'] = 'no'; + } else if (trackOpens) { messageToSend['o:tracking-opens'] = true; } if (messageToSend.headers) { diff --git a/ghost/core/test/unit/server/services/gifts/gift-email-service.test.js b/ghost/core/test/unit/server/services/gifts/gift-email-service.test.js index 9396050b4a6..1e8f0cdd1fc 100644 --- a/ghost/core/test/unit/server/services/gifts/gift-email-service.test.js +++ b/ghost/core/test/unit/server/services/gifts/gift-email-service.test.js @@ -66,7 +66,8 @@ describe('GiftEmailService', function () { sinon.assert.calledWith(mailer.send, sinon.match({ to: 'buyer@example.com', subject: 'Your gift is ready', - from: 'Test Site ' + from: 'Test Site ', + disableTracking: true })); }); diff --git a/ghost/core/test/unit/server/services/lib/mailgun-client.test.js b/ghost/core/test/unit/server/services/lib/mailgun-client.test.js index 990043c3abb..10fc25ba225 100644 --- a/ghost/core/test/unit/server/services/lib/mailgun-client.test.js +++ b/ghost/core/test/unit/server/services/lib/mailgun-client.test.js @@ -259,7 +259,8 @@ describe('MailgunClient', function () { replyTo: 'replyTo@example.com', html: '

Test Content

', plaintext: 'Test Content', - tags: ['another-tag'] + tags: ['another-tag'], + disable_tracking: true }; const recipientData = { 'test@example.com': { @@ -279,8 +280,11 @@ describe('MailgunClient', function () { /form-data; name="text"[^]*Test Content/m, /form-data; name="to"[^]*test@example.com/m, /form-data; name="recipient-variables"[^]*\{"test@example.com":\{"name":"Test User"\}\}/m, - /form-data; name="o:tag"[^]*ghost-email/m, - /form-data; name="o:tag"[^]*another-tag/m + /form-data; name="o:tag"\r?\n\r?\nghost-email\r?\n--/m, + /form-data; name="o:tag"\r?\n\r?\nanother-tag\r?\n--/m, + /form-data; name="o:tracking"\r?\n\r?\nno\r?\n--/m, + /form-data; name="o:tracking-clicks"\r?\n\r?\nno\r?\n--/m, + /form-data; name="o:tracking-opens"\r?\n\r?\nno\r?\n--/m ]; return regexList.every(regex => regex.test(body)); }) diff --git a/ghost/core/test/unit/server/services/mail/ghost-mailer.test.js b/ghost/core/test/unit/server/services/mail/ghost-mailer.test.js index 50f1da343fa..9543197fe70 100644 --- a/ghost/core/test/unit/server/services/mail/ghost-mailer.test.js +++ b/ghost/core/test/unit/server/services/mail/ghost-mailer.test.js @@ -1,4 +1,5 @@ const sinon = require('sinon'); +const nock = require('nock'); const {deferred} = require('../../../../utils/deferred') const mail = require('../../../../../core/server/services/mail'); const settingsCache = require('../../../../../core/shared/settings-cache'); @@ -424,6 +425,41 @@ describe('Mail: Ghostmailer', function () { assert.equal(sentMessage.trackOpens, undefined); }); + it('should explicitly disable Mailgun open and click tracking for transactional messages that require it', async function () { + configUtils.set({ + mail: { + transport: 'mailgun', + from: 'from@default.com', + options: {auth: {api_key: 'key', domain: 'domain.com'}} + } + }); + sandbox.stub(settingsCache, 'get').withArgs('email_track_opens').returns(true); + + // The transport whitelists and serialises the message before it reaches Mailgun, + // so assert on the request body rather than the object handed to sendMail + const sendMock = nock('https://api.mailgun.net') + .post('/v3/domain.com/messages', function (body) { + const regexList = [ + /form-data; name="o:tracking"\r?\n\r?\nno\r?\n--/m, + /form-data; name="o:tracking-opens"\r?\n\r?\nno\r?\n--/m, + /form-data; name="o:tracking-clicks"\r?\n\r?\nno\r?\n--/m + ]; + return regexList.every(regex => regex.test(body)) && !/name="disableTracking"/.test(body); + }) + .reply(200, {id: '', message: 'Queued. Thank you.'}); + + mailer = new mail.GhostMailer(); + + await mailer.send({ + to: 'recipient@example.com', + subject: 'Gift delivery', + html: 'content', + disableTracking: true + }); + + assert(sendMock.isDone()); + }); + it('should not add site ID tag when site ID is missing', async function () { configUtils.set({ hostSettings: {} // No siteId From a8504bbc77696b4b3b50dbea3e3a1df0cf05c215 Mon Sep 17 00:00:00 2001 From: John O'Nolan Date: Wed, 19 Aug 2026 05:02:59 -0700 Subject: [PATCH 06/19] Added icons and visibility controls to navigation (#28368) --- .../advanced/labs/private-features.tsx | 4 + .../hooks/site/use-navigation-editor.test.tsx | 192 ++++++++++++++++++ .../app/hooks/site/use-navigation-editor.tsx | 54 ++++- .../src/settings/site/navigation-modal.tsx | 54 ++++- .../site/navigation/navigation-edit-form.tsx | 34 +++- .../navigation/navigation-icon-upload.tsx | 51 +++++ .../navigation/navigation-item-editor.tsx | 113 +++++++---- .../site/navigation/navigation-layout.ts | 12 ++ .../navigation-visibility-dropdown.tsx | 108 ++++++++++ .../site/navigation/navigation-visibility.ts | 72 +++++++ .../ember-admin/app/models/navigation-item.js | 14 +- .../app/transforms/navigation-settings.js | 11 +- apps/ember-admin/app/validators/nav-item.js | 5 +- .../tests/unit/models/navigation-item-test.js | 26 ++- .../transforms/navigation-settings-test.js | 23 ++- .../tests/unit/validators/nav-item-test.js | 26 ++- .../core/core/frontend/helpers/navigation.js | 131 ++++++++++-- .../core/frontend/helpers/tpl/navigation.hbs | 2 +- .../utils/validators/input/settings.js | 84 +++++++- ghost/core/core/server/models/settings.js | 44 +++- ghost/core/core/shared/labs.js | 1 + .../admin/__snapshots__/settings.test.js.snap | 38 ++-- .../core/test/e2e-api/admin/settings.test.js | 3 +- .../test/legacy/api/admin/settings.test.js | 133 ++++++++++++ .../unit/frontend/helpers/navigation.test.js | 157 ++++++++++++++ 25 files changed, 1270 insertions(+), 122 deletions(-) create mode 100644 apps/admin/src/settings/app/hooks/site/use-navigation-editor.test.tsx create mode 100644 apps/admin/src/settings/site/navigation/navigation-icon-upload.tsx create mode 100644 apps/admin/src/settings/site/navigation/navigation-layout.ts create mode 100644 apps/admin/src/settings/site/navigation/navigation-visibility-dropdown.tsx create mode 100644 apps/admin/src/settings/site/navigation/navigation-visibility.ts diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx b/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx index d1fc22aa5b1..c9cd5f4a5d5 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx @@ -55,6 +55,10 @@ const features: Feature[] = [{ title: 'Get helper deduplication', description: 'Deduplicate identical {{#get}} helper queries within a single request to avoid redundant database calls', flag: 'getHelperDeduplication' +}, { + title: 'Navigation icons & visibility', + description: 'Add icons and member-visibility controls to navigation menu items. Requires theme support to render icons.', + flag: 'navigationIcons' }, { title: 'React tag details', description: 'Renders the tag detail screen (/tags/:slug) from the React app instead of the Ember screen. Gates the migration behind a runtime toggle so we can compare both implementations.', diff --git a/apps/admin/src/settings/app/hooks/site/use-navigation-editor.test.tsx b/apps/admin/src/settings/app/hooks/site/use-navigation-editor.test.tsx new file mode 100644 index 00000000000..09c10d30122 --- /dev/null +++ b/apps/admin/src/settings/app/hooks/site/use-navigation-editor.test.tsx @@ -0,0 +1,192 @@ +import * as assert from 'node:assert/strict'; +import useNavigationEditor, {type NavigationItem} from '@/settings/app/hooks/site/use-navigation-editor'; +import {act, renderHook, waitFor} from '@testing-library/react'; + +describe('useNavigationEditor', function () { + it('defaults optional icon and visibility fields for editing', function () { + const {result} = renderHook(() => useNavigationEditor({ + items: [{label: 'Home', url: '/'}], + setItems: vi.fn() + })); + + assert.equal(result.current.items[0].icon, ''); + assert.equal(result.current.items[0].visibility, 'public'); + }); + + it('serializes icon and non-public visibility when an item changes', async function () { + const setItems = vi.fn(); + const {result} = renderHook(() => useNavigationEditor({ + items: [{label: 'Home', url: '/'}], + setItems + })); + + act(() => { + result.current.updateItem('0', { + icon: ' https://example.com/nav.svg ', + visibility: 'members' + }); + }); + + await waitFor(() => { + assert.deepEqual(setItems.mock.calls.at(-1)?.[0], [{ + url: '/', + label: 'Home', + icon: 'https://example.com/nav.svg', + visibility: 'members' + }]); + }); + }); + + it('serializes granular visibility combinations', async function () { + const setItems = vi.fn(); + const {result} = renderHook(() => useNavigationEditor({ + items: [{label: 'Home', url: '/'}], + setItems + })); + + act(() => { + result.current.updateItem('0', { + visibility: 'public_free' + }); + }); + + await waitFor(() => { + assert.deepEqual(setItems.mock.calls.at(-1)?.[0], [{ + url: '/', + label: 'Home', + visibility: 'public_free' + }]); + }); + }); + + it('serializes hidden visibility', async function () { + const setItems = vi.fn(); + const {result} = renderHook(() => useNavigationEditor({ + items: [{label: 'Home', url: '/'}], + setItems + })); + + act(() => { + result.current.updateItem('0', { + visibility: 'none' + }); + }); + + await waitFor(() => { + assert.deepEqual(setItems.mock.calls.at(-1)?.[0], [{ + url: '/', + label: 'Home', + visibility: 'none' + }]); + }); + }); + + it('omits empty icon and public visibility when serializing new items', async function () { + const setItems = vi.fn(); + const {result} = renderHook(() => useNavigationEditor({ + items: [], + setItems + })); + + act(() => { + result.current.setNewItem({ + label: ' Blog ', + url: '/blog/', + icon: '', + visibility: 'public' + }); + }); + + act(() => { + result.current.addItem(); + }); + + await waitFor(() => { + assert.deepEqual(setItems.mock.calls.at(-1)?.[0], [{ + url: '/blog/', + label: 'Blog' + }]); + }); + }); + + it('allows adding an icon-only item', async function () { + const setItems = vi.fn(); + const {result} = renderHook(() => useNavigationEditor({ + items: [], + setItems + })); + + act(() => { + result.current.setNewItem({ + label: '', + url: '/icon-only/', + icon: 'https://example.com/nav.svg' + }); + }); + + act(() => { + result.current.addItem(); + }); + + await waitFor(() => { + assert.deepEqual(setItems.mock.calls.at(-1)?.[0], [{ + url: '/icon-only/', + label: '', + icon: 'https://example.com/nav.svg' + }]); + }); + }); + + it('requires either a label or an icon', async function () { + const {result} = renderHook(() => useNavigationEditor({ + items: [{ + label: '', + url: '/missing-label-and-icon/' + }], + setItems: vi.fn() + })); + + act(() => { + assert.equal(result.current.validate(), false); + }); + + await waitFor(() => { + assert.equal(result.current.items[0].errors.label, 'You must specify a label or icon'); + }); + }); + + it('validates visibility values', async function () { + const {result} = renderHook(() => useNavigationEditor({ + items: [{ + label: 'Members', + url: '/members/', + icon: 'mailto:test@example.com', + visibility: 'invalid' + } as unknown as NavigationItem], + setItems: vi.fn() + })); + + act(() => { + assert.equal(result.current.validate(), false); + }); + + await waitFor(() => { + assert.equal(result.current.items[0].errors.visibility, 'You must specify a valid visibility'); + }); + }); + + it('does not show user-facing validation errors for stored icon URLs', async function () { + const {result} = renderHook(() => useNavigationEditor({ + items: [{ + label: 'FTP icon', + url: '/ftp-icon/', + icon: 'ftp://example.com/icon.svg' + }], + setItems: vi.fn() + })); + + act(() => { + assert.equal(result.current.validate(), true); + }); + }); +}); diff --git a/apps/admin/src/settings/app/hooks/site/use-navigation-editor.tsx b/apps/admin/src/settings/app/hooks/site/use-navigation-editor.tsx index 8a30535f478..715f2682cf8 100644 --- a/apps/admin/src/settings/app/hooks/site/use-navigation-editor.tsx +++ b/apps/admin/src/settings/app/hooks/site/use-navigation-editor.tsx @@ -2,15 +2,21 @@ import useSortableIndexedList from '@/settings/app/hooks/use-sortable-indexed-li import validator from 'validator'; import {useCallback, useMemo} from 'react'; +export const NAVIGATION_ITEM_VISIBILITY = ['public', 'members', 'paid', 'public_free', 'public_paid', 'public_only', 'free_members', 'none'] as const; +export type NavigationItemVisibility = typeof NAVIGATION_ITEM_VISIBILITY[number]; + export type NavigationItem = { label: string; url: string; + icon?: string; + visibility?: NavigationItemVisibility; } export type NavigationItemErrors = { [key in keyof NavigationItem]?: string } export type EditableItem = NavigationItem & { id: string; errors: NavigationItemErrors } -const hasNewItem = (newItem: NavigationItem) => Boolean((newItem.label && !newItem.label.match(/^\s*$/)) || newItem.url !== '/'); +const hasTextValue = (value?: string) => Boolean(value && !value.match(/^\s*$/)); +const hasNewItem = (newItem: NavigationItem) => Boolean(hasTextValue(newItem.label) || newItem.url !== '/' || hasTextValue(newItem.icon)); export type NavigationEditor = { items: EditableItem[] @@ -24,41 +30,71 @@ export type NavigationEditor = { validate: () => boolean } +const normalizeItem = (item: NavigationItem) => ({ + ...item, + icon: item.icon || '', + visibility: item.visibility || 'public', + errors: {} +}); + +const serializeItem = ({url, label, icon, visibility}: NavigationItem): NavigationItem => ({ + url: url.trim(), + label: label.trim(), + ...(icon?.trim() ? {icon: icon.trim()} : {}), + ...(visibility && visibility !== 'public' ? {visibility} : {}) +}); + const useNavigationEditor = ({items, setItems}: { items: NavigationItem[]; setItems: (newItems: NavigationItem[]) => void; }): NavigationEditor => { - const editableItems = useMemo(() => items.map(item => ({...item, errors: {}})), [items]); + const editableItems = useMemo(() => items.map(normalizeItem), [items]); const setNavigationItems = useCallback((newItems: Omit[]) => { - setItems(newItems.map(({url, label}) => ({url, label}))); + setItems(newItems.map(serializeItem)); }, [setItems]); const list = useSortableIndexedList>({ items: editableItems, setItems: setNavigationItems, - blank: {url: '/',label: '', errors: {}}, + blank: {url: '/', label: '', icon: '', visibility: 'public', errors: {}}, canAddNewItem: hasNewItem }); const urlRegex = new RegExp(/^(\/|#|[a-zA-Z0-9-]+:)/); - const validateItem = (item: NavigationItem) => { const errors: NavigationItemErrors = {}; + const hasLabel = hasTextValue(item.label); + const hasIcon = hasTextValue(item.icon); - if (!item.label || item.label.match(/^\s*$/)) { - errors.label = 'You must specify a label'; + if (!hasLabel && !hasIcon) { + errors.label = 'You must specify a label or icon'; } if (!item.url || item.url.match(/\s/) || (!validator.isURL(item.url, {require_protocol: true}) && !item.url.match(urlRegex))) { errors.url = 'You must specify a valid URL or relative path'; } + if (item.visibility && !NAVIGATION_ITEM_VISIBILITY.includes(item.visibility)) { + errors.visibility = 'You must specify a valid visibility'; + } + return errors; }; + const mergeItemUpdates = (currentItem: Omit, item: Partial): Omit => { + const errors = {...currentItem.errors}; + + if (hasTextValue(item.icon)) { + errors.icon = undefined; + errors.label = undefined; + } + + return {...currentItem, ...item, errors}; + }; + const updateItem = (id: string, item: Partial) => { const currentItem = list.items.find(current => current.id === id)!; - list.updateItem(id, {...currentItem.item, ...item}); + list.updateItem(id, mergeItemUpdates(currentItem.item, item)); }; const addItem = () => { @@ -99,7 +135,7 @@ const useNavigationEditor = ({items, setItems}: { moveItem, newItem: {...list.newItem, id: newItemId}, - setNewItem: item => list.setNewItem({...list.newItem, ...item}), + setNewItem: item => list.setNewItem(mergeItemUpdates(list.newItem, item)), clearError, validate: () => { diff --git a/apps/admin/src/settings/site/navigation-modal.tsx b/apps/admin/src/settings/site/navigation-modal.tsx index 08f2b102184..1f32b936d42 100644 --- a/apps/admin/src/settings/site/navigation-modal.tsx +++ b/apps/admin/src/settings/site/navigation-modal.tsx @@ -3,12 +3,19 @@ import useNavigationEditor, {type NavigationItem} from '@/settings/app/hooks/sit import useSettingGroup from '@/settings/app/hooks/use-setting-group'; import {SettingsModal} from '@tryghost/shade/patterns'; import {Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade/components'; -import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; +import {APIError} from '@tryghost/admin-x-framework/errors'; +import {checkStripeEnabled, getSettingValues} from '@tryghost/admin-x-framework/api/settings'; +import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images'; +import {useHandleError} from '@tryghost/admin-x-framework/hooks'; +import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; import {useCallback, useMemo, useState} from 'react'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; function NavigationModal() { const {updateRoute} = useSettingsNavigation(); + const handleError = useHandleError(); + const {mutateAsync: uploadImage} = useUploadImage(); + const {config} = useGlobalData(); const { localSettings, updateSetting, @@ -17,10 +24,15 @@ function NavigationModal() { siteData } = useSettingGroup(); - const [navigationValue, secondaryNavigationValue] = getSettingValues( + const [navigationValue, secondaryNavigationValue, membersSignupAccess] = getSettingValues( localSettings, - ['navigation', 'secondary_navigation'] + ['navigation', 'secondary_navigation', 'members_signup_access'] ); + const navigationIconsEnabled = !!config.labs.navigationIcons; + const showIcon = navigationIconsEnabled; + const showVisibility = navigationIconsEnabled && membersSignupAccess !== 'none'; + const showPaidVisibility = checkStripeEnabled(localSettings, config); + const navigationItems = useMemo(() => JSON.parse(navigationValue || '[]') as NavigationItem[], [navigationValue]); const secondaryNavigationItems = useMemo(() => JSON.parse(secondaryNavigationValue || '[]') as NavigationItem[], [secondaryNavigationValue]); const setNavigationItems = useCallback((items: NavigationItem[]) => { @@ -42,6 +54,18 @@ function NavigationModal() { const [selectedTab, setSelectedTab] = useState('primary-nav'); + const uploadIcon = async (file: File) => { + try { + return getImageUrl(await uploadImage({file})); + } catch (e) { + const error = e as APIError; + if (error.response?.status === 415) { + error.message = 'Unsupported file type'; + } + handleError(error); + } + }; + return ( Primary Secondary - - + + + + + + diff --git a/apps/admin/src/settings/site/navigation/navigation-edit-form.tsx b/apps/admin/src/settings/site/navigation/navigation-edit-form.tsx index 926ba7b61b4..403c39df913 100644 --- a/apps/admin/src/settings/site/navigation/navigation-edit-form.tsx +++ b/apps/admin/src/settings/site/navigation/navigation-edit-form.tsx @@ -4,12 +4,32 @@ import {Button, SortableList} from '@tryghost/shade/components'; import {Inline} from '@tryghost/shade/primitives'; import {LucideIcon} from '@tryghost/shade/utils'; import {type NavigationEditor} from '@/settings/app/hooks/site/use-navigation-editor'; +import {navigationColumnClasses, navigationDragHandleSpacerClasses, navigationRowClasses} from './navigation-layout'; + +const columnLabelClasses = 'text-xs font-semibold text-muted-foreground'; const NavigationEditForm: React.FC<{ baseUrl: string; + idPrefix: string; navigation: NavigationEditor; -}> = ({baseUrl, navigation}) => { + showIcon: boolean; + showPaidVisibility: boolean; + showVisibility: boolean; + uploadIcon?: (file: File) => Promise; +}> = ({baseUrl, idPrefix, navigation, showIcon, showPaidVisibility, showVisibility, uploadIcon}) => { return
+ {(showIcon || showVisibility) && ( +
+
+
+ {showIcon &&
Icon
} +
Label
+
URL
+ {showVisibility &&
Visibility
} +
+
+
+ )} `Reorder ${item.label || 'navigation item'}`} @@ -20,13 +40,18 @@ const NavigationEditForm: React.FC<{ action={} baseUrl={baseUrl} clearError={key => navigation.clearError(item.id, key)} + idPrefix={idPrefix} item={item} + showIcon={showIcon} + showPaidVisibility={showPaidVisibility} + showVisibility={showVisibility} updateItem={updates => navigation.updateItem(item.id, updates)} + uploadIcon={uploadIcon} /> )} onMove={navigation.moveItem} /> - + @@ -37,9 +62,14 @@ const NavigationEditForm: React.FC<{ className="mt-1" clearError={key => navigation.clearError(navigation.newItem.id, key)} data-testid="new-navigation-item" + idPrefix={idPrefix} item={navigation.newItem} labelPlaceholder="New item label" + showIcon={showIcon} + showPaidVisibility={showPaidVisibility} + showVisibility={showVisibility} updateItem={navigation.setNewItem} + uploadIcon={uploadIcon} />
; diff --git a/apps/admin/src/settings/site/navigation/navigation-icon-upload.tsx b/apps/admin/src/settings/site/navigation/navigation-icon-upload.tsx new file mode 100644 index 00000000000..873bf118193 --- /dev/null +++ b/apps/admin/src/settings/site/navigation/navigation-icon-upload.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import {Trash2, Upload} from 'lucide-react'; +import {ImageUpload, ImageUploadAction, ImageUploadActions, ImageUploadDropzone, ImageUploadImage, ImageUploadPreview} from '@tryghost/shade/patterns'; +import {type EditableItem, type NavigationItem, type NavigationItemErrors} from '@/settings/app/hooks/site/use-navigation-editor'; + +type NavigationIconUploadProps = { + idPrefix: string; + item: EditableItem; + clearError?: (key: keyof NavigationItemErrors) => void; + updateItem?: (item: Partial) => void; + uploadIcon?: (file: File) => Promise; +} + +const NavigationIconUpload: React.FC = ({idPrefix, item, clearError, updateItem, uploadIcon}) => ( + + {item.icon ? ( + + + + { + clearError?.('icon'); + updateItem?.({icon: ''}); + }} + > + + + + + ) : ( + void uploadIcon?.(file).then((icon) => { + if (icon) { + updateItem?.({icon}); + } + })} + > + + )} + +); + +export default NavigationIconUpload; diff --git a/apps/admin/src/settings/site/navigation/navigation-item-editor.tsx b/apps/admin/src/settings/site/navigation/navigation-item-editor.tsx index 0a9b223f7e2..7fac5be198b 100644 --- a/apps/admin/src/settings/site/navigation/navigation-item-editor.tsx +++ b/apps/admin/src/settings/site/navigation/navigation-item-editor.tsx @@ -1,3 +1,5 @@ +import NavigationIconUpload from './navigation-icon-upload'; +import NavigationVisibilityDropdown from './navigation-visibility-dropdown'; import React, {type ReactNode} from 'react'; import clsx from 'clsx'; import useUrlInput from '@/settings/app/hooks/use-url-input'; @@ -5,20 +7,26 @@ import {type EditableItem, type NavigationItem, type NavigationItemErrors} from import {Field, FieldError, FieldLabel, Input} from '@tryghost/shade/components'; import {Inline} from '@tryghost/shade/primitives'; import {formatUrl} from '@/settings/app/utils/format-url'; +import {navigationColumnClasses, navigationFieldOffsetClass, navigationRowClasses} from './navigation-layout'; export type NavigationItemEditorProps = React.HTMLAttributes & { baseUrl: string; + idPrefix: string; item: EditableItem; clearError?: (key: keyof NavigationItemErrors) => void; updateItem?: (item: Partial) => void; + uploadIcon?: (file: File) => Promise; labelPlaceholder?: string unstyled?: boolean textFieldClasses?: string action?: ReactNode addItem?: () => void + showIcon: boolean + showPaidVisibility: boolean + showVisibility: boolean } -const NavigationItemEditor: React.FC = ({baseUrl, item, updateItem, addItem, clearError, labelPlaceholder, unstyled, textFieldClasses, action, className, ...props}) => { +const NavigationItemEditor: React.FC = ({baseUrl, idPrefix, item, updateItem, uploadIcon, addItem, clearError, labelPlaceholder, unstyled, textFieldClasses, action, showIcon, showPaidVisibility, showVisibility, className, ...props}) => { const urlInput = useUrlInput({ baseUrl, nullable: true, @@ -27,8 +35,19 @@ const NavigationItemEditor: React.FC = ({baseUrl, ite }); return ( -
-
+
+ {showIcon && ( +
+ +
+ )} +
Label = ({baseUrl, ite id={`navigation-label-${item.id}`} placeholder={labelPlaceholder} value={item.label} - onChange={e => updateItem?.({label: e.target.value})} - onKeyDown={(e) => { - updateItem?.({label: (e.target as HTMLInputElement).value}); - if (e.key === 'Enter') { - e.preventDefault(); - addItem?.(); - } - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - !!item.errors.label && clearError?.('label'); - }} /> + onChange={e => updateItem?.({label: e.target.value})} + onKeyDown={(e) => { + updateItem?.({label: (e.target as HTMLInputElement).value}); + if (e.key === 'Enter') { + e.preventDefault(); + addItem?.(); + } + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + !!item.errors.label && clearError?.('label'); + }} + /> {item.errors.label && {item.errors.label}}
- - URL - urlInput.setDisplayValue(event.target.value)} - onFocus={urlInput.handleFocus} - onKeyDown={(e) => { - urlInput.handleKeyDown(e); - const urls = formatUrl((e.target as HTMLInputElement).value, baseUrl, true); - updateItem?.({url: urls.save || ''}); - }} - onKeyUp={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); +
+ + URL + urlInput.setDisplayValue(event.target.value)} + onFocus={urlInput.handleFocus} + onKeyDown={(e) => { + urlInput.handleKeyDown(e); const urls = formatUrl((e.target as HTMLInputElement).value, baseUrl, true); updateItem?.({url: urls.save || ''}); - addItem?.(); - } - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - !!item.errors.url && clearError?.('url'); - }} - /> - {item.errors.url && {item.errors.url}} - + }} + onKeyUp={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + const urls = formatUrl((e.target as HTMLInputElement).value, baseUrl, true); + updateItem?.({url: urls.save || ''}); + addItem?.(); + } + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + !!item.errors.url && clearError?.('url'); + }} + /> + {item.errors.url && {item.errors.url}} + +
+ {showVisibility && ( +
+ +
+ )} {action && ( - + {action} )} diff --git a/apps/admin/src/settings/site/navigation/navigation-layout.ts b/apps/admin/src/settings/site/navigation/navigation-layout.ts new file mode 100644 index 00000000000..1e7f8d320a3 --- /dev/null +++ b/apps/admin/src/settings/site/navigation/navigation-layout.ts @@ -0,0 +1,12 @@ +export const navigationRowClasses = 'flex w-full items-start gap-3 [--control-height:38px]'; + +export const navigationColumnClasses = { + icon: 'w-[38px] shrink-0', + label: 'min-w-0 flex-[0.75]', + url: 'min-w-0 flex-[1.25]', + visibility: 'w-[190px] shrink-0', + action: 'w-10 shrink-0' +} as const; + +export const navigationDragHandleSpacerClasses = 'h-7 w-4 shrink-0'; +export const navigationFieldOffsetClass = 'pt-1'; diff --git a/apps/admin/src/settings/site/navigation/navigation-visibility-dropdown.tsx b/apps/admin/src/settings/site/navigation/navigation-visibility-dropdown.tsx new file mode 100644 index 00000000000..951cecce4d7 --- /dev/null +++ b/apps/admin/src/settings/site/navigation/navigation-visibility-dropdown.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import {ChevronDown} from 'lucide-react'; +import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Switch} from '@tryghost/shade/components'; +import {type EditableItem, type NavigationItem, type NavigationItemErrors, type NavigationItemVisibility} from '@/settings/app/hooks/site/use-navigation-editor'; +import { + type VisibilityAudience, + getAudienceVisibility, + getNavigationVisibility, + getVisibilityFromAudiences, + getVisibilityLabel, + paidVisibilityValues, + visibilityAudienceOptions +} from './navigation-visibility'; + +type NavigationVisibilityDropdownProps = { + idPrefix: string; + item: EditableItem; + clearError?: (key: keyof NavigationItemErrors) => void; + updateItem?: (item: Partial) => void; + showPaidVisibility: boolean; +} + +const NavigationVisibilityDropdown: React.FC = ({idPrefix, item, clearError, updateItem, showPaidVisibility}) => { + const visibility = getNavigationVisibility(item.visibility); + const visibilityLabel = getVisibilityLabel(visibility); + const visibilityLabelId = `${idPrefix}-visibility-${item.id}`; + const visibilityErrorId = `${visibilityLabelId}-error`; + const audienceVisibility = getAudienceVisibility(visibility, showPaidVisibility); + + const updateVisibility = (newVisibility: NavigationItemVisibility) => { + clearError?.('visibility'); + updateItem?.({visibility: newVisibility}); + }; + + const updateAudienceVisibility = (audience: VisibilityAudience, checked: boolean) => { + const nextAudiences = { + ...audienceVisibility, + [audience]: checked + }; + + // When the paid-members control is hidden (Stripe disconnected) there's no + // way to target paid separately, so paid follows free. This keeps results on + // the general values ("Members only"/"Public") rather than collapsing to the + // free-specific ones ("Free only"/"Public + free"). + if (!showPaidVisibility) { + nextAudiences['paid-members'] = nextAudiences['free-members']; + } + + const nextVisibility = getVisibilityFromAudiences(nextAudiences); + + if (!showPaidVisibility && paidVisibilityValues.includes(nextVisibility)) { + return; + } + + updateVisibility(nextVisibility); + }; + + return ( + <> + + + + + + +
+ {visibilityAudienceOptions.filter(option => option.key !== 'paid-members' || showPaidVisibility).map((option) => { + const checked = audienceVisibility[option.key]; + + return ( + { + event.preventDefault(); + updateAudienceVisibility(option.key, !checked); + }} + > + {option.label} + updateAudienceVisibility(option.key, isChecked)} + onClick={event => event.stopPropagation()} + /> + + ); + })} +
+
+
+ {item.errors.visibility && } + + ); +}; + +export default NavigationVisibilityDropdown; diff --git a/apps/admin/src/settings/site/navigation/navigation-visibility.ts b/apps/admin/src/settings/site/navigation/navigation-visibility.ts new file mode 100644 index 00000000000..24aad23149c --- /dev/null +++ b/apps/admin/src/settings/site/navigation/navigation-visibility.ts @@ -0,0 +1,72 @@ +import {NAVIGATION_ITEM_VISIBILITY, type NavigationItemVisibility} from '@/settings/app/hooks/site/use-navigation-editor'; + +export const navigationVisibilityLabels = { + public: 'Everyone', + members: 'Members only', + paid: 'Paid-members only', + public_free: 'Public + free', + public_paid: 'Public + paid', + public_only: 'Public only', + free_members: 'Free only', + none: 'Hidden' +} satisfies Record; + +export const navigationVisibilityOptions = NAVIGATION_ITEM_VISIBILITY.map(value => ({ + value, + label: navigationVisibilityLabels[value] +})); + +export const visibilityAudienceOptions = [ + {key: 'public-visitors', label: 'Public visitors'}, + {key: 'free-members', label: 'Free members'}, + {key: 'paid-members', label: 'Paid members'} +] as const; + +export type VisibilityAudience = typeof visibilityAudienceOptions[number]['key']; + +export const visibilityAudienceMap = { + public: {'public-visitors': true, 'free-members': true, 'paid-members': true}, + members: {'public-visitors': false, 'free-members': true, 'paid-members': true}, + paid: {'public-visitors': false, 'free-members': false, 'paid-members': true}, + public_free: {'public-visitors': true, 'free-members': true, 'paid-members': false}, + public_paid: {'public-visitors': true, 'free-members': false, 'paid-members': true}, + public_only: {'public-visitors': true, 'free-members': false, 'paid-members': false}, + free_members: {'public-visitors': false, 'free-members': true, 'paid-members': false}, + none: {'public-visitors': false, 'free-members': false, 'paid-members': false} +} satisfies Record>; + +export const paidVisibilityValues: NavigationItemVisibility[] = ['paid', 'public_paid']; + +type AudienceKey = '000' | '001' | '010' | '011' | '100' | '101' | '110' | '111'; + +const visibilityByAudienceKey = { + '000': 'none', + '001': 'paid', + '010': 'free_members', + '011': 'members', + 100: 'public_only', + 101: 'public_paid', + 110: 'public_free', + 111: 'public' +} satisfies Record; + +export const getNavigationVisibility = (visibility?: NavigationItemVisibility): NavigationItemVisibility => ( + navigationVisibilityOptions.some(option => option.value === visibility) ? visibility as NavigationItemVisibility : 'public' +); + +export const getVisibilityLabel = (visibility: NavigationItemVisibility) => navigationVisibilityLabels[visibility]; + +export const getAudienceVisibility = (visibility: NavigationItemVisibility, showPaidVisibility: boolean) => ({ + ...visibilityAudienceMap[visibility], + ...(!showPaidVisibility ? {'paid-members': false} : {}) +}); + +export const getVisibilityFromAudiences = (audiences: Record): NavigationItemVisibility => { + const key = [ + audiences['public-visitors'], + audiences['free-members'], + audiences['paid-members'] + ].map(value => (value ? '1' : '0')).join('') as AudienceKey; + + return visibilityByAudienceKey[key]; +}; diff --git a/apps/ember-admin/app/models/navigation-item.js b/apps/ember-admin/app/models/navigation-item.js index 3fa1183671a..3c14db87c8c 100644 --- a/apps/ember-admin/app/models/navigation-item.js +++ b/apps/ember-admin/app/models/navigation-item.js @@ -5,20 +5,22 @@ import {isBlank} from '@ember/utils'; export default EmberObject.extend(ValidationEngine, { label: '', url: '', + icon: '', + visibility: 'public', isNew: false, isSecondary: false, validationType: 'navItem', - isComplete: computed('label', 'url', function () { - let {label, url} = this; + isComplete: computed('label', 'url', 'icon', function () { + let {label, url, icon} = this; - return !isBlank(label) && !isBlank(url); + return (!isBlank(label) || !isBlank(icon)) && !isBlank(url); }), - isBlank: computed('label', 'url', function () { - let {label, url} = this; + isBlank: computed('label', 'url', 'icon', function () { + let {label, url, icon} = this; - return isBlank(label) && isBlank(url); + return isBlank(label) && isBlank(url) && isBlank(icon); }) }); diff --git a/apps/ember-admin/app/transforms/navigation-settings.js b/apps/ember-admin/app/transforms/navigation-settings.js index 9c1bd214702..0c84465fb63 100644 --- a/apps/ember-admin/app/transforms/navigation-settings.js +++ b/apps/ember-admin/app/transforms/navigation-settings.js @@ -27,8 +27,15 @@ export default class NavigationSettings extends Transform { settingsArray = deserialized.map((item) => { let label = item.label.trim(); let url = item.url.trim(); - - return {label, url}; + let icon = item.icon && item.icon.trim(); + let visibility = item.visibility || 'public'; + + return { + label, + url, + ...(icon ? {icon} : {}), + ...(visibility !== 'public' ? {visibility} : {}) + }; }).compact(); } else { settingsArray = []; diff --git a/apps/ember-admin/app/validators/nav-item.js b/apps/ember-admin/app/validators/nav-item.js index 0c41ce71855..5d9ea5a78e3 100644 --- a/apps/ember-admin/app/validators/nav-item.js +++ b/apps/ember-admin/app/validators/nav-item.js @@ -7,10 +7,11 @@ export default BaseValidator.create({ label(model) { let label = model.label; + let icon = model.icon; let hasValidated = model.hasValidated; - if (isBlank(label)) { - model.errors.add('label', 'You must specify a label'); + if (isBlank(label) && isBlank(icon)) { + model.errors.add('label', 'You must specify a label or icon'); this.invalidate(); } diff --git a/apps/ember-admin/tests/unit/models/navigation-item-test.js b/apps/ember-admin/tests/unit/models/navigation-item-test.js index 40167bf441a..e68c6799100 100644 --- a/apps/ember-admin/tests/unit/models/navigation-item-test.js +++ b/apps/ember-admin/tests/unit/models/navigation-item-test.js @@ -14,10 +14,21 @@ describe('Unit: Model: navigation-item', function () { expect(model.get('isComplete')).to.be.true; }); - it('isComplete is false when label is blank', function () { + it('isComplete is true when icon and url are filled', function () { let model = this.owner.lookup('model:navigation-item'); model.set('label', ''); + model.set('icon', 'https://example.com/icon.svg'); + model.set('url', 'test'); + + expect(model.get('isComplete')).to.be.true; + }); + + it('isComplete is false when label and icon are blank', function () { + let model = this.owner.lookup('model:navigation-item'); + + model.set('label', ''); + model.set('icon', ''); model.set('url', 'test'); expect(model.get('isComplete')).to.be.false; @@ -32,11 +43,12 @@ describe('Unit: Model: navigation-item', function () { expect(model.get('isComplete')).to.be.false; }); - it('isBlank is true when label and url are blank', function () { + it('isBlank is true when label, url, and icon are blank', function () { let model = this.owner.lookup('model:navigation-item'); model.set('label', ''); model.set('url', ''); + model.set('icon', ''); expect(model.get('isBlank')).to.be.true; }); @@ -58,4 +70,14 @@ describe('Unit: Model: navigation-item', function () { expect(model.get('isBlank')).to.be.false; }); + + it('isBlank is false when icon is present', function () { + let model = this.owner.lookup('model:navigation-item'); + + model.set('label', ''); + model.set('url', ''); + model.set('icon', 'https://example.com/icon.svg'); + + expect(model.get('isBlank')).to.be.false; + }); }); diff --git a/apps/ember-admin/tests/unit/transforms/navigation-settings-test.js b/apps/ember-admin/tests/unit/transforms/navigation-settings-test.js index 5e7adac3262..2df57e4363d 100644 --- a/apps/ember-admin/tests/unit/transforms/navigation-settings-test.js +++ b/apps/ember-admin/tests/unit/transforms/navigation-settings-test.js @@ -9,26 +9,39 @@ describe('Unit: Transform: navigation-settings', function () { it('deserializes navigation json', function () { let transform = this.owner.lookup('transform:navigation-settings'); - let serialized = '[{"label":"One","url":"/one"},{"label":"Two","url":"/two"}]'; + let serialized = '[{"label":"One","url":"/one","icon":"https://example.com/one.svg","visibility":"members"},{"label":"Two","url":"/two"},{"label":"Three","url":"/three","visibility":"public_free"},{"label":"Four","url":"/four","visibility":"none"}]'; let result = transform.deserialize(serialized); - expect(result.length).to.equal(2); + expect(result.length).to.equal(4); expect(result[0]).to.be.instanceof(NavigationItem); expect(result[0].get('label')).to.equal('One'); expect(result[0].get('url')).to.equal('/one'); + expect(result[0].get('icon')).to.equal('https://example.com/one.svg'); + expect(result[0].get('visibility')).to.equal('members'); expect(result[1]).to.be.instanceof(NavigationItem); expect(result[1].get('label')).to.equal('Two'); expect(result[1].get('url')).to.equal('/two'); + expect(result[1].get('visibility')).to.equal('public'); + expect(result[2]).to.be.instanceof(NavigationItem); + expect(result[2].get('label')).to.equal('Three'); + expect(result[2].get('url')).to.equal('/three'); + expect(result[2].get('visibility')).to.equal('public_free'); + expect(result[3]).to.be.instanceof(NavigationItem); + expect(result[3].get('label')).to.equal('Four'); + expect(result[3].get('url')).to.equal('/four'); + expect(result[3].get('visibility')).to.equal('none'); }); it('serializes array of NavigationItems', function () { let transform = this.owner.lookup('transform:navigation-settings'); let deserialized = emberA([ - NavigationItem.create({label: 'One', url: '/one'}), - NavigationItem.create({label: 'Two', url: '/two'}) + NavigationItem.create({label: 'One', url: '/one', icon: 'https://example.com/one.svg', visibility: 'members'}), + NavigationItem.create({label: 'Two', url: '/two', visibility: 'public'}), + NavigationItem.create({label: 'Three', url: '/three', visibility: 'public_free'}), + NavigationItem.create({label: 'Four', url: '/four', visibility: 'none'}) ]); let result = transform.serialize(deserialized); - expect(result).to.equal('[{"label":"One","url":"/one"},{"label":"Two","url":"/two"}]'); + expect(result).to.equal('[{"label":"One","url":"/one","icon":"https://example.com/one.svg","visibility":"members"},{"label":"Two","url":"/two"},{"label":"Three","url":"/three","visibility":"public_free"},{"label":"Four","url":"/four","visibility":"none"}]'); }); }); diff --git a/apps/ember-admin/tests/unit/validators/nav-item-test.js b/apps/ember-admin/tests/unit/validators/nav-item-test.js index a743743f215..e3022357bf2 100644 --- a/apps/ember-admin/tests/unit/validators/nav-item-test.js +++ b/apps/ember-admin/tests/unit/validators/nav-item-test.js @@ -29,7 +29,7 @@ const testValidUrl = function (url) { }; describe('Unit: Validator: nav-item', function () { - it('requires label presence', function () { + it('requires label or icon presence', function () { let navItem = NavItem.create(); validator.check(navItem, 'label'); @@ -37,11 +37,21 @@ describe('Unit: Validator: nav-item', function () { expect(validator.get('passed')).to.be.false; expect(navItem.get('errors').errorsFor('label').toArray()).to.deep.equal([{ attribute: 'label', - message: 'You must specify a label' + message: 'You must specify a label or icon' }]); expect(navItem.get('hasValidated')).to.include('label'); }); + it('allows blank label when icon is present', function () { + let navItem = NavItem.create({icon: 'https://example.com/icon.svg'}); + + validator.check(navItem, 'label'); + + expect(validator.get('passed')).to.be.true; + expect(navItem.get('errors').errorsFor('label')).to.be.empty; + expect(navItem.get('hasValidated')).to.include('label'); + }); + it('requires url presence', function () { let navItem = NavItem.create(); @@ -79,7 +89,7 @@ describe('Unit: Validator: nav-item', function () { 'http://localhost:2368/?query=test&another=example#test', 'tel:01234-567890', 'mailto:test@example.com', - 'http://some:user@example.com:1234', + 'http://example.com:1234', '/relative/path' ]; @@ -97,4 +107,14 @@ describe('Unit: Validator: nav-item', function () { expect(navItem.get('errors').errorsFor('url')).to.not.be.empty; expect(validator.get('passed')).to.be.false; }); + + it('validates url and icon-only item by default', function () { + let navItem = NavItem.create({icon: 'https://example.com/icon.svg', url: '/icon-only/'}); + + validator.check(navItem); + + expect(navItem.get('errors').errorsFor('label')).to.be.empty; + expect(navItem.get('errors').errorsFor('url')).to.be.empty; + expect(validator.get('passed')).to.be.true; + }); }); diff --git a/ghost/core/core/frontend/helpers/navigation.js b/ghost/core/core/frontend/helpers/navigation.js index ccfb8db6c18..c949c43660c 100644 --- a/ghost/core/core/frontend/helpers/navigation.js +++ b/ghost/core/core/frontend/helpers/navigation.js @@ -7,6 +7,7 @@ const errors = require('@tryghost/errors'); const tpl = require('@tryghost/tpl'); const {slugify} = require('@tryghost/string'); const _ = require('lodash'); +const labs = require('../../shared/labs'); const messages = { invalidData: 'navigation data is not an object or is a function', @@ -16,6 +17,84 @@ const messages = { const createFrame = hbs.handlebars.createFrame; +/** + * @typedef {Object} NavigationItem + * @property {string} url + * @property {string} [label] + * @property {string} [icon] + * @property {string} [visibility] + */ + +/** + * @typedef {Object} NavigationMember + * @property {'free' | 'paid' | string} status + */ + +/** + * @param {string | null | undefined} icon + * @returns {string} + */ +function getNavigationIconName(icon) { + if (!icon) { + return ''; + } + + const filename = icon.split(/[?#]/)[0].split('/').filter(Boolean).pop() || ''; + const name = filename.replace(/\.[^.]+$/, ''); + + try { + return decodeURIComponent(name); + } catch (e) { + return name; + } +} + +/** + * @param {string} href + * @param {string | undefined} url + * @returns {boolean} + */ +function isCurrentNavigationUrl(href, url) { + if (!url) { + return false; + } + + const strippedHref = href.replace(/\/+$/, ''); + const strippedCurrentUrl = url.replace(/\/+$/, ''); + return strippedHref === strippedCurrentUrl; +} + +/** + * @param {NavigationItem} item + * @param {NavigationMember | null | undefined} member + * @returns {boolean} + */ +function isNavigationItemVisible(item, member) { + const visibility = item.visibility || 'public'; + const isMember = !!member; + const isFreeMember = isMember && member.status === 'free'; + const isPaidMember = isMember && member.status !== 'free'; + + switch (visibility) { + case 'members': + return isMember; + case 'paid': + return isPaidMember; + case 'public_free': + return !isMember || isFreeMember; + case 'public_paid': + return !isMember || isPaidMember; + case 'public_only': + return !isMember; + case 'free_members': + return isFreeMember; + case 'none': + return false; + default: + return true; + } +} + module.exports = function navigation(options) { options = options || {}; options.hash = options.hash || {}; @@ -29,16 +108,17 @@ module.exports = function navigation(options) { const navigationData = options.data.site[key]; const currentUrl = options.data.root.relativeUrl; + const member = options.data.member || options.data.root.member; let output; - if (!_.isObject(navigationData) || _.isFunction(navigationData)) { + if (!Array.isArray(navigationData)) { throw new errors.IncorrectUsageError({ message: tpl(messages.invalidData) }); } if (navigationData.filter(function (e) { - return (_.isUndefined(e.label) || _.isUndefined(e.url)); + return (_.isUndefined(e.url) || (_.isUndefined(e.label) && _.isUndefined(e.icon))); }).length > 0) { throw new errors.IncorrectUsageError({ message: tpl(messages.valuesMustBeDefined) @@ -47,38 +127,45 @@ module.exports = function navigation(options) { // check for non-null string values if (navigationData.filter(function (e) { - return ((!_.isNull(e.label) && !_.isString(e.label)) || - (!_.isNull(e.url) && !_.isString(e.url))); + return ((!_.isUndefined(e.label) && !_.isNull(e.label) && !_.isString(e.label)) || + (_.isNull(e.url) || !_.isString(e.url))); }).length > 0) { throw new errors.IncorrectUsageError({ message: tpl(messages.valuesMustBeString) }); } - // strips trailing slashes and compares urls - function _isCurrentUrl(href, url) { - if (!url) { - return false; - } - - const strippedHref = href.replace(/\/+$/, ''); - const strippedCurrentUrl = url.replace(/\/+$/, ''); - return strippedHref === strippedCurrentUrl; - } + // Icon and visibility are coerced rather than thrown on (see map below): + // a bad icon is dropped and an unrecognised visibility falls back to public, + // so malformed data can never 500 a whole site's front end. // {{navigation}} should no-op if no data passed in if (navigationData.length === 0) { return new SafeString(''); } - output = navigationData.map(function (e) { - const out = {}; - out.current = _isCurrentUrl(e.url, currentUrl); - out.label = e.label; - out.slug = slugify(e.label); - out.url = e.url; - return out; - }); + const navigationIconsEnabled = labs.isSet('navigationIcons'); + + output = navigationData + .filter(item => navigationIconsEnabled ? isNavigationItemVisible(item, member) : true) + .map(function (e) { + const out = {}; + const icon = navigationIconsEnabled && _.isString(e.icon) ? e.icon : null; + const iconName = getNavigationIconName(icon); + const hasLabel = _.isString(e.label) && e.label.trim().length > 0; + + out.current = isCurrentNavigationUrl(e.url, currentUrl); + out.icon = icon || null; + out.iconAlt = hasLabel ? '' : iconName; + out.label = e.label; + out.slug = slugify(hasLabel ? e.label : iconName); + out.url = e.url; + return out; + }); + + if (output.length === 0) { + return new SafeString(''); + } // CASE: The navigation helper should have access to the navigation items at the top level. this.navigation = output; diff --git a/ghost/core/core/frontend/helpers/tpl/navigation.hbs b/ghost/core/core/frontend/helpers/tpl/navigation.hbs index 93c4bf603fc..08f3344f0bd 100644 --- a/ghost/core/core/frontend/helpers/tpl/navigation.hbs +++ b/ghost/core/core/frontend/helpers/tpl/navigation.hbs @@ -1,5 +1,5 @@ diff --git a/ghost/core/core/server/api/endpoints/utils/validators/input/settings.js b/ghost/core/core/server/api/endpoints/utils/validators/input/settings.js index 0f2cbd32275..af0477758c6 100644 --- a/ghost/core/core/server/api/endpoints/utils/validators/input/settings.js +++ b/ghost/core/core/server/api/endpoints/utils/validators/input/settings.js @@ -9,9 +9,83 @@ const messages = { invalidEmailValueReceived: 'Please enter a valid email address.', invalidEmailTypeReceived: 'Invalid email type received', invalidAnnouncementVisibilityValueReceived: 'Please enter a valid announcement visibility value', - invalidAnnouncementBackgroundValueReceived: 'Please enter a valid announcement background value' + invalidAnnouncementBackgroundValueReceived: 'Please enter a valid announcement background value', + invalidNavigationItemValueReceived: 'Please enter a valid navigation item' }; +const navigationItemVisibilityValues = ['public', 'members', 'paid', 'public_free', 'public_paid', 'public_only', 'free_members', 'none']; +const navUrlRegex = new RegExp(/^(\/|#|[a-zA-Z0-9-]+:)/); +const iconUrlRegex = new RegExp(/^(\/|__GHOST_URL__\/)/); +const iconUrlOptions = {require_protocol: true, protocols: ['http', 'https']}; + +function parseArraySettingValue(value) { + if (_.isArray(value)) { + return value; + } + + return JSON.parse(value); +} + +function isValidNavigationUrl(value) { + return _.isString(value) && !value.match(/\s/) && (validator.isURL(value, {require_protocol: true}) || value.match(navUrlRegex)); +} + +function isValidNavigationIcon(value) { + return _.isString(value) && !value.match(/\s/) && (validator.isURL(value, iconUrlOptions) || value.match(iconUrlRegex)); +} + +function validateNavigationItems(setting, errors) { + const navigationItems = parseArraySettingValue(setting.value); + + navigationItems.forEach((item) => { + if (!_.isObject(item) || _.isFunction(item)) { + errors.push(new ValidationError({ + message: tpl(messages.invalidNavigationItemValueReceived), + property: setting.key + })); + return; + } + + if (_.isUndefined(item.url) || !isValidNavigationUrl(item.url)) { + errors.push(new ValidationError({ + message: tpl(messages.invalidNavigationItemValueReceived), + property: setting.key + })); + } + + const hasLabel = _.isString(item.label) && !item.label.match(/^\s*$/); + const hasIcon = _.isString(item.icon) && !item.icon.match(/^\s*$/); + + if (!_.isUndefined(item.label) && !_.isString(item.label)) { + errors.push(new ValidationError({ + message: tpl(messages.invalidNavigationItemValueReceived), + property: setting.key + })); + } + + if (!_.isUndefined(item.icon) && item.icon !== null && item.icon !== '' && !isValidNavigationIcon(item.icon)) { + errors.push(new ValidationError({ + message: tpl(messages.invalidNavigationItemValueReceived), + property: setting.key + })); + } + + if (!hasLabel && !hasIcon) { + errors.push(new ValidationError({ + message: tpl(messages.invalidNavigationItemValueReceived), + property: setting.key + })); + } + + if (!_.isUndefined(item.visibility) && !navigationItemVisibilityValues.includes(item.visibility)) { + errors.push(new ValidationError({ + message: tpl(messages.invalidNavigationItemValueReceived), + property: setting.key + })); + } + }); +} + module.exports = { edit(apiConfig, frame) { const errors = []; @@ -52,6 +126,14 @@ module.exports = { } } + if (['navigation', 'secondary_navigation'].includes(setting.key)) { + try { + validateNavigationItems(setting, errors); + } catch (err) { + // Array type validation above will return the specific array error. + } + } + if (emailTypeSettings.includes(setting.key)) { const email = setting.value; diff --git a/ghost/core/core/server/models/settings.js b/ghost/core/core/server/models/settings.js index b8d60be3914..5589b466ce1 100644 --- a/ghost/core/core/server/models/settings.js +++ b/ghost/core/core/server/models/settings.js @@ -18,6 +18,8 @@ const messages = { const internalContext = {context: {internal: true}}; let Settings; let defaultSettings; +const imageSettingKeys = ['cover_image', 'logo', 'icon', 'portal_button_icon', 'og_image', 'twitter_image', 'pintura_js_url', 'pintura_css_url']; +const navigationSettingKeys = ['navigation', 'secondary_navigation']; const doBlock = fn => fn(); @@ -103,6 +105,36 @@ function getDefaultSettings() { return defaultSettings; } +function transformNavigationIconUrls(value, transformUrl) { + if (!value) { + return value; + } + + let navigationItems; + try { + navigationItems = _.isString(value) ? JSON.parse(value) : value; + } catch (e) { + return value; + } + + if (!_.isArray(navigationItems)) { + return value; + } + + const transformed = navigationItems.map((item) => { + if (!_.isObject(item) || _.isFunction(item) || !item.icon) { + return item; + } + + return { + ...item, + icon: transformUrl(item.icon) + }; + }); + + return _.isString(value) ? JSON.stringify(transformed) : transformed; +} + // Each setting is saved as a separate row in the database, // but the overlying API treats them as a single key:value mapping Settings = ghostBookshelf.Model.extend({ @@ -179,10 +211,14 @@ Settings = ghostBookshelf.Model.extend({ }, formatOnWrite(attrs) { - if (attrs.value && ['cover_image', 'logo', 'icon', 'portal_button_icon', 'og_image', 'twitter_image', 'pintura_js_url', 'pintura_css_url'].includes(attrs.key)) { + if (attrs.value && imageSettingKeys.includes(attrs.key)) { attrs.value = urlUtils.toTransformReady(attrs.value); } + if (attrs.value && navigationSettingKeys.includes(attrs.key)) { + attrs.value = transformNavigationIconUrls(attrs.value, urlUtils.toTransformReady.bind(urlUtils)); + } + return attrs; }, @@ -201,10 +237,14 @@ Settings = ghostBookshelf.Model.extend({ } // transform URLs from __GHOST_URL__ to absolute - if (['cover_image', 'logo', 'icon', 'portal_button_icon', 'og_image', 'twitter_image', 'pintura_js_url', 'pintura_css_url'].includes(attrs.key)) { + if (imageSettingKeys.includes(attrs.key)) { attrs.value = urlUtils.transformReadyToAbsolute(attrs.value); } + if (navigationSettingKeys.includes(attrs.key)) { + attrs.value = transformNavigationIconUrls(attrs.value, urlUtils.transformReadyToAbsolute.bind(urlUtils)); + } + return attrs; } }, { diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index 68114719172..55509c201bb 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -51,6 +51,7 @@ const PRIVATE_FEATURES = [ 'themeTranslation', 'pictureImageFormats', 'getHelperDeduplication', + 'navigationIcons', 'membersCustomFields', 'paywallImprovements', 'giftSubCustomization', diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap index 97026de646e..5bb7080cd97 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap @@ -608,7 +608,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -1001,7 +1001,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -1467,7 +1467,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -1933,7 +1933,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -2444,7 +2444,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -2909,7 +2909,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -3379,7 +3379,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -3923,7 +3923,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -4389,7 +4389,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -4855,7 +4855,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -5325,7 +5325,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -5790,7 +5790,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -6260,7 +6260,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -6729,7 +6729,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -7118,7 +7118,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -7584,7 +7584,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -8050,7 +8050,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -8517,7 +8517,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -9083,7 +9083,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", diff --git a/ghost/core/test/e2e-api/admin/settings.test.js b/ghost/core/test/e2e-api/admin/settings.test.js index 14fd59fd8d3..c8982f9ff1d 100644 --- a/ghost/core/test/e2e-api/admin/settings.test.js +++ b/ghost/core/test/e2e-api/admin/settings.test.js @@ -125,7 +125,8 @@ describe('Settings API', function () { { key: 'navigation', value: JSON.stringify([{ - label: 'label1' + label: 'label1', + url: '/' }]) }, { diff --git a/ghost/core/test/legacy/api/admin/settings.test.js b/ghost/core/test/legacy/api/admin/settings.test.js index afb811be90b..bf87543f849 100644 --- a/ghost/core/test/legacy/api/admin/settings.test.js +++ b/ghost/core/test/legacy/api/admin/settings.test.js @@ -225,6 +225,139 @@ describe('Settings API', function () { .expect(422); }); + it('Can edit navigation items with icon and visibility', async function () { + await checkCanEdit('navigation', JSON.stringify([{ + label: 'Members', + url: '/members/', + icon: `${config.get('url')}/content/images/nav-members.svg`, + visibility: 'members' + }, { + label: 'Free', + url: '/free/', + visibility: 'public_free' + }, { + label: 'Hidden', + url: '/hidden/', + visibility: 'none' + }]), [{ + label: 'Members', + url: '/members/', + icon: `${config.get('url')}/content/images/nav-members.svg`, + visibility: 'members' + }, { + label: 'Free', + url: '/free/', + visibility: 'public_free' + }, { + label: 'Hidden', + url: '/hidden/', + visibility: 'none' + }]); + + const navigationSetting = await db.knex('settings') + .select('value') + .where('key', 'navigation') + .first(); + + assert.equal(navigationSetting.value, '[{"label":"Members","url":"/members/","icon":"__GHOST_URL__/content/images/nav-members.svg","visibility":"members"},{"label":"Free","url":"/free/","visibility":"public_free"},{"label":"Hidden","url":"/hidden/","visibility":"none"}]'); + }); + + it('Can edit icon-only navigation items', async function () { + await checkCanEdit('navigation', JSON.stringify([{ + label: '', + url: '/icon-only/', + icon: `${config.get('url')}/content/images/nav-icon-only.svg` + }]), [{ + label: '', + url: '/icon-only/', + icon: `${config.get('url')}/content/images/nav-icon-only.svg` + }]); + + const navigationSetting = await db.knex('settings') + .select('value') + .where('key', 'navigation') + .first(); + + assert.equal(navigationSetting.value, '[{"label":"","url":"/icon-only/","icon":"__GHOST_URL__/content/images/nav-icon-only.svg"}]'); + }); + + it('Can edit icon-only navigation items without a label property', async function () { + await checkCanEdit('navigation', JSON.stringify([{ + url: '/icon-only-without-label/', + icon: `${config.get('url')}/content/images/nav-icon-only.svg` + }]), [{ + url: '/icon-only-without-label/', + icon: `${config.get('url')}/content/images/nav-icon-only.svg` + }]); + + const navigationSetting = await db.knex('settings') + .select('value') + .where('key', 'navigation') + .first(); + + assert.equal(navigationSetting.value, '[{"url":"/icon-only-without-label/","icon":"__GHOST_URL__/content/images/nav-icon-only.svg"}]'); + }); + + it('Cannot edit navigation items without a label or icon', async function () { + const settingsToChange = { + settings: [ + {key: 'navigation', value: JSON.stringify([{label: '', url: '/invalid/'}])} + ] + }; + + await request.put(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .send(settingsToChange) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(422); + }); + + it('Cannot edit navigation items with invalid visibility', async function () { + const settingsToChange = { + settings: [ + {key: 'navigation', value: JSON.stringify([{label: 'Invalid', url: '/invalid/', visibility: 'invalid'}])} + ] + }; + + await request.put(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .send(settingsToChange) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(422); + }); + + it('Cannot edit navigation items with invalid icon', async function () { + const settingsToChange = { + settings: [ + {key: 'navigation', value: JSON.stringify([{label: 'Invalid', url: '/invalid/', icon: 'mailto:test@example.com'}])} + ] + }; + + await request.put(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .send(settingsToChange) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(422); + }); + + it('Cannot edit navigation items with non-http icon URL protocols', async function () { + const settingsToChange = { + settings: [ + {key: 'navigation', value: JSON.stringify([{label: 'Invalid', url: '/invalid/', icon: 'ftp://example.com/icon.svg'}])} + ] + }; + + await request.put(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .send(settingsToChange) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(422); + }); + // If this test fails, it can be safely removed // but the front-end should be updated accordingly, // removing the workaround in place for this specific usecase diff --git a/ghost/core/test/unit/frontend/helpers/navigation.test.js b/ghost/core/test/unit/frontend/helpers/navigation.test.js index d973ec448a8..47a8388ad56 100644 --- a/ghost/core/test/unit/frontend/helpers/navigation.test.js +++ b/ghost/core/test/unit/frontend/helpers/navigation.test.js @@ -1,5 +1,6 @@ const assert = require('node:assert/strict'); const {assertExists} = require('../../../utils/assertions'); +const sinon = require('sinon'); const hbs = require('../../../../core/frontend/services/theme-engine/engine'); const configUtils = require('../../../utils/config-utils'); const path = require('path'); @@ -10,6 +11,7 @@ const foreach = require('../../../../core/frontend/helpers/foreach'); const link_class = require('../../../../core/frontend/helpers/link_class'); const url = require('../../../../core/frontend/helpers/url'); const navigation = require('../../../../core/frontend/helpers/navigation'); +const labs = require('../../../../core/shared/labs'); const handlebars = require('../../../../core/frontend/services/theme-engine/engine').handlebars; @@ -18,6 +20,7 @@ const runHelperThunk = data => () => runHelper(data); describe('{{navigation}} helper', function () { let optionsData; + let labsStub; beforeAll(async function () { hbs.express4({ @@ -36,6 +39,8 @@ describe('{{navigation}} helper', function () { }); beforeEach(function () { + labsStub = sinon.stub(labs, 'isSet').returns(true); + optionsData = { data: { site: { @@ -49,6 +54,10 @@ describe('{{navigation}} helper', function () { }; }); + afterEach(function () { + sinon.restore(); + }); + it('should throw errors on invalid data', function () { // Test 1: navigation = string optionsData.data.site.navigation = 'not an object'; @@ -66,6 +75,33 @@ describe('{{navigation}} helper', function () { // Test 4: invalid url optionsData.data.site.navigation = [{label: 'foo', url: 1}]; assert.throws(runHelperThunk(optionsData), {message: 'Invalid value, Url and Label must be strings'}); + + // Test 5: null url + optionsData.data.site.navigation = [{label: 'foo', url: null}]; + assert.throws(runHelperThunk(optionsData), {message: 'Invalid value, Url and Label must be strings'}); + }); + + it('coerces a non-string icon to no icon instead of throwing', function () { + // Bad data (e.g. from an older Ghost or a direct DB write) must not 500 the + // whole front end — the item renders without an icon. + optionsData.data.site.navigation = [{label: 'foo', url: '/foo', icon: 1}]; + + const rendered = runHelper(optionsData); + + assertExists(rendered); + assert(!rendered.string.includes('class="nav-icon"')); + assert(rendered.string.includes('foo')); + }); + + it('coerces an unrecognised visibility to public instead of throwing', function () { + // An unknown visibility value falls back to public (visible) rather than + // throwing — the link target enforces its own access control regardless. + optionsData.data.site.navigation = [{label: 'foo', url: '/foo', visibility: 'invalid'}]; + + const rendered = runHelper(optionsData); + + assertExists(rendered); + assert(rendered.string.includes('foo')); }); it('can render empty nav', function () { @@ -101,6 +137,41 @@ describe('{{navigation}} helper', function () { assert(rendered.string.includes(testUrl)); }); + it('can render one item with an icon', function () { + const singleItem = {label: 'Foo', url: '/foo', icon: 'https://example.com/icon.svg'}; + let rendered; + + optionsData.data.site.navigation = [singleItem]; + rendered = runHelper(optionsData); + + assertExists(rendered); + assert(rendered.string.includes('class="nav-icon"')); + assert(rendered.string.includes('src="https://example.com/icon.svg"')); + assert(rendered.string.includes('alt=""')); + assert(!rendered.string.includes('alt="icon"')); + assert(!rendered.string.includes('aria-hidden')); + assert(!rendered.string.includes('loading=')); + assert(rendered.string.includes('Foo')); + }); + + it('can render an icon-only item', function () { + const singleItem = {url: '/icon-only', icon: 'https://example.com/icons/Ambulance%20Truck.svg'}; + let rendered; + + optionsData.data.site.navigation = [singleItem]; + rendered = runHelper(optionsData); + + assertExists(rendered); + assert(rendered.string.includes('nav-ambulance-truck')); + assert(!rendered.string.includes('nav- ')); + assert(rendered.string.includes('class="nav-icon"')); + assert(rendered.string.includes('src="https://example.com/icons/Ambulance%20Truck.svg"')); + assert(rendered.string.includes('alt="Ambulance Truck"')); + assert(!rendered.string.includes('aria-hidden')); + assert(!rendered.string.includes('loading=')); + assert(!rendered.string.includes('class="nav-label"')); + }); + it('can render multiple items', function () { const firstItem = {label: 'Foo', url: '/foo'}; const secondItem = {label: 'Bar Baz Qux', url: '/qux'}; @@ -187,6 +258,92 @@ describe('{{navigation}} helper', function () { assert(!rendered.string.includes('foo=space%2520bar')); }); + it('filters navigation items by member visibility', function () { + const hasLabel = (rendered, label) => rendered.string.includes(`${label}`); + optionsData.data.site.navigation = [ + {label: 'Public', url: '/public'}, + {label: 'Members', url: '/members', visibility: 'members'}, + {label: 'Paid', url: '/paid', visibility: 'paid'}, + {label: 'Public Free', url: '/public-free', visibility: 'public_free'}, + {label: 'Public Paid', url: '/public-paid', visibility: 'public_paid'}, + {label: 'Public Only', url: '/public-only', visibility: 'public_only'}, + {label: 'Free Members', url: '/free-members', visibility: 'free_members'}, + {label: 'Hidden', url: '/hidden', visibility: 'none'} + ]; + + let rendered = runHelper(optionsData); + assert(hasLabel(rendered, 'Public')); + assert(!hasLabel(rendered, 'Members')); + assert(!hasLabel(rendered, 'Paid')); + assert(hasLabel(rendered, 'Public Free')); + assert(hasLabel(rendered, 'Public Paid')); + assert(hasLabel(rendered, 'Public Only')); + assert(!hasLabel(rendered, 'Free Members')); + assert(!hasLabel(rendered, 'Hidden')); + + optionsData.data.root.member = {status: 'free'}; + rendered = runHelper(optionsData); + assert(hasLabel(rendered, 'Public')); + assert(hasLabel(rendered, 'Members')); + assert(!hasLabel(rendered, 'Paid')); + assert(hasLabel(rendered, 'Public Free')); + assert(!hasLabel(rendered, 'Public Paid')); + assert(!hasLabel(rendered, 'Public Only')); + assert(hasLabel(rendered, 'Free Members')); + assert(!hasLabel(rendered, 'Hidden')); + + optionsData.data.root.member = {status: 'paid'}; + rendered = runHelper(optionsData); + assert(hasLabel(rendered, 'Public')); + assert(hasLabel(rendered, 'Members')); + assert(hasLabel(rendered, 'Paid')); + assert(!hasLabel(rendered, 'Public Free')); + assert(hasLabel(rendered, 'Public Paid')); + assert(!hasLabel(rendered, 'Public Only')); + assert(!hasLabel(rendered, 'Free Members')); + assert(!hasLabel(rendered, 'Hidden')); + }); + + it('renders empty nav when all items are filtered out', function () { + optionsData.data.site.navigation = [{label: 'Paid', url: '/paid', visibility: 'paid'}]; + + const rendered = runHelper(optionsData); + + assertExists(rendered); + assert.equal(rendered.string, ''); + }); + + describe('when navigationIcons lab is disabled', function () { + beforeEach(function () { + labsStub.withArgs('navigationIcons').returns(false); + }); + + it('does not render icons', function () { + optionsData.data.site.navigation = [ + {label: 'Foo', url: '/foo', icon: 'https://example.com/icon.svg'} + ]; + + const rendered = runHelper(optionsData); + + assertExists(rendered); + assert(!rendered.string.includes('class="nav-icon"')); + assert(rendered.string.includes('Foo')); + }); + + it('does not filter items by visibility', function () { + optionsData.data.site.navigation = [ + {label: 'Hidden', url: '/hidden', visibility: 'none'}, + {label: 'Paid', url: '/paid', visibility: 'paid'} + ]; + + const rendered = runHelper(optionsData); + + assertExists(rendered); + assert(rendered.string.includes('Hidden')); + assert(rendered.string.includes('Paid')); + }); + }); + describe('type="secondary"', function () { it('can render one item', function () { const singleItem = {label: 'Foo', url: '/foo'}; From 9866fcedc71f34cc4d346af27cc86340193da482 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Wed, 19 Aug 2026 07:20:11 -0500 Subject: [PATCH 07/19] Moved shared settings code out of the settings quarantine (#30113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref The settings shell and the shared `components/`, `hooks/`, `utils/`, `data/`, `assets/` and `providers/` now live directly under `settings/`, leaving only `advanced/` in `settings/app/` until the PRs touching it land (#30018, #30019, #29916, #30054, #30099, #30110, #28368). Big diff, but ~300 of the 325 files are import-line rewrites; the substantive changes are the lint-debt fixes the move surfaces: - shell files grouped in `settings/layout/`: `app.tsx` (provider tree), `main-content.tsx`, `sidebar.tsx`, `settings-sections.tsx` (was `components/settings.tsx`); root `settings.tsx` route entry unchanged - contexts split the way admin's other contexts are — hooks + context in `*-context.ts`, provider component in its own file — for global data, settings app, confirmations, scroll sections; `withErrorBoundary` → `with-error-boundary.tsx` - `loadKoenig` → `components/koenig-loader.ts`; the untyped `@tryghost/koenig-lexical` bundle gets a minimal `KoenigLexicalModule` type instead of `any` - `@tryghost/limit-service` and `@tryghost/nql` get typed ambient declarations in `vite-env.d.ts` (clears ~25 `no-unsafe-*` in `use-limiter`) - `iframe-buffering` reuses `utils/debounce`; `TopLevelGroup.saveState` uses the framework's `SaveState`; the usual `void`-wrapping of async handlers --- .../utils/default-welcome-email-values.ts | 2 +- .../settings/advanced/advanced-settings.tsx | 2 +- .../settings/advanced/code-injection.tsx | 6 +- .../settings/advanced/code/code-modal.tsx | 6 +- .../settings/advanced/danger-zone.tsx | 10 +-- .../settings/advanced/history-modal.tsx | 4 +- .../components/settings/advanced/history.tsx | 6 +- .../settings/advanced/integrations.tsx | 18 ++--- .../integrations/add-integration-modal.tsx | 8 +- .../integrations/content-api-modal.tsx | 2 +- .../integrations/custom-integration-modal.tsx | 4 +- .../integrations/first-promoter-modal.tsx | 6 +- .../advanced/integrations/pintura-modal.tsx | 8 +- .../advanced/integrations/slack-modal.tsx | 6 +- .../integrations/transistor-modal.tsx | 10 +-- .../advanced/integrations/unsplash-modal.tsx | 6 +- .../integrations/webhooks-table.test.tsx | 2 +- .../advanced/integrations/webhooks-table.tsx | 2 +- .../advanced/integrations/zapier-modal.tsx | 12 +-- .../app/components/settings/advanced/labs.tsx | 10 +-- .../settings/advanced/labs/beta-features.tsx | 4 +- .../settings/advanced/labs/feature-toggle.tsx | 6 +- .../advanced/labs/private-features.tsx | 2 +- .../advanced/labs/yaml-file-editor-modal.tsx | 2 +- .../settings/advanced/migration-tools.tsx | 4 +- .../migration-tools-export.tsx | 2 +- .../migration-tools-import.tsx | 6 +- .../universal-import-modal.test.tsx | 4 +- .../universal-import-modal.tsx | 4 +- .../settings/advanced/spam-filters.tsx | 6 +- .../{app => }/assets/images/ali-abdaal.png | Bin .../assets/images/brand-icons/beehiiv.svg | 0 .../assets/images/brand-icons/facebook.svg | 0 .../images/brand-icons/firstpromoter.svg | 0 .../assets/images/brand-icons/linkedin.svg | 0 .../assets/images/brand-icons/mailchimp.svg | 0 .../assets/images/brand-icons/medium.svg | 0 .../assets/images/brand-icons/pintura.svg | 0 .../images/brand-icons/portal-icon-1.svg | 0 .../images/brand-icons/portal-icon-2.svg | 0 .../images/brand-icons/portal-icon-3.svg | 0 .../images/brand-icons/portal-icon-4.svg | 0 .../images/brand-icons/portal-icon-5.svg | 0 .../assets/images/brand-icons/slack.svg | 0 .../assets/images/brand-icons/squarespace.svg | 0 .../assets/images/brand-icons/substack.svg | 0 .../assets/images/brand-icons/transistor.svg | 0 .../assets/images/brand-icons/twitter-x.svg | 0 .../assets/images/brand-icons/unsplash.svg | 0 .../assets/images/brand-icons/wordpress.svg | 0 .../assets/images/brand-icons/zapier.svg | 0 .../{app => }/assets/images/cover-test.jpg | Bin .../assets/images/design-settings.png | Bin .../assets/images/email-design-user-image.jpg | Bin .../assets/images/explore-default-logo.png | Bin .../assets/images/footer-marketplace-bg.png | Bin .../{app => }/assets/images/ghost-explore.png | Bin .../{app => }/assets/images/ghost-favicon.png | Bin .../assets/images/integrations-settings.png | Bin .../images/integrations/ghost-transistor.png | Bin .../images/integrations/google-docs.svg | 0 .../assets/images/integrations/mailchimp.svg | 0 .../assets/images/integrations/patreon.svg | 0 .../assets/images/integrations/paypal.svg | 0 .../assets/images/integrations/slackicon.png | Bin .../assets/images/integrations/typeform.svg | 0 .../images/integrations/zero-bounce.png | Bin .../{app => }/assets/images/isaac-saul.png | Bin .../{app => }/assets/images/joel-warner.png | Bin .../{app => }/assets/images/labs-bg.svg | 0 .../assets/images/latest-posts-1.jpg | Bin .../assets/images/latest-posts-2.jpg | Bin .../assets/images/latest-posts-3.jpg | Bin .../assets/images/logos/orb-black-1.png | Bin .../assets/images/logos/orb-black-2.png | Bin .../assets/images/logos/orb-black-3.png | Bin .../assets/images/logos/orb-black-4.png | Bin .../assets/images/logos/orb-black-5.png | Bin .../{app => }/assets/images/network.png | Bin .../{app => }/assets/images/orb-pink.png | Bin .../{app => }/assets/images/orb-squircle.png | Bin .../assets/images/pintura-screenshot.png | Bin .../images/portal-splash-default-logo.png | Bin .../assets/images/portal-splash-user-add.png | Bin .../{app => }/assets/images/stripe-emblem.svg | 0 .../{app => }/assets/images/stripe-thumb.jpg | Bin .../assets/images/stripe-verified.svg | 0 .../{app => }/assets/images/themes/Alto.png | Bin .../assets/images/themes/Bulletin.png | Bin .../{app => }/assets/images/themes/Casper.png | Bin .../{app => }/assets/images/themes/Dawn.png | Bin .../{app => }/assets/images/themes/Digest.png | Bin .../{app => }/assets/images/themes/Dope.png | Bin .../{app => }/assets/images/themes/Ease.png | Bin .../{app => }/assets/images/themes/Edge.png | Bin .../assets/images/themes/Edition.png | Bin .../assets/images/themes/Episode.png | Bin .../assets/images/themes/Headline.png | Bin .../assets/images/themes/Journal.png | Bin .../{app => }/assets/images/themes/London.png | Bin .../{app => }/assets/images/themes/Ruby.png | Bin .../{app => }/assets/images/themes/Solo.png | Bin .../assets/images/themes/Source-Magazine.png | Bin .../images/themes/Source-Newsletter.png | Bin .../{app => }/assets/images/themes/Source.png | Bin .../{app => }/assets/images/themes/Taste.png | Bin .../{app => }/assets/images/themes/Wave.png | Bin .../{app => }/assets/images/user-cover.jpg | Bin .../{app => }/assets/images/zapier-logo.svg | 0 .../assets/videos/logo-loader-dark.mp4 | Bin .../{app => }/assets/videos/logo-loader.mp4 | Bin .../{app => }/components/code-editor-view.tsx | 2 +- .../{app => }/components/code-editor.tsx | 0 .../components/color-picker-field.tsx | 2 +- .../components/color-swatch-field.tsx | 0 .../components/confirmation-modal.test.tsx | 5 +- .../components/confirmation-modal.tsx | 4 +- .../{app => }/components/error-boundary.tsx | 12 +-- .../components/exit-settings-button.tsx | 0 .../{app => }/components/html-editor.tsx | 6 +- .../{app => }/components/html-field.tsx | 0 .../{app => }/components/icons/brand-icon.tsx | 40 +++++----- .../components/infinite-scroll-listener.tsx | 0 .../components/koenig-editor-base.tsx | 63 ++------------- .../src/settings/components/koenig-loader.ts | 75 ++++++++++++++++++ .../{app => }/components/limit-modal.test.tsx | 5 +- .../{app => }/components/limit-modal.tsx | 0 .../settings => components}/preview-modal.tsx | 2 +- .../components/searchable-section.tsx | 2 +- .../selectors/unsplash-selector.tsx | 2 +- .../settings-breadcrumbs.test.tsx | 2 +- .../settings-breadcrumbs.tsx | 0 .../{app => }/components/stripe-button.tsx | 0 .../{app => }/components/top-level-group.tsx | 11 +-- .../components/with-error-boundary.tsx | 12 +++ .../{app => }/data/official-themes.ts | 42 +++++----- .../{app => }/data/zapier-templates.ts | 24 +++--- .../design-fields/background-color-field.tsx | 2 +- .../design-fields/button-color-field.tsx | 2 +- .../design-fields/divider-color-field.tsx | 2 +- .../design-fields/header-background-field.tsx | 2 +- .../design-fields/link-color-field.tsx | 2 +- .../section-title-color-field.tsx | 2 +- .../welcome-email-preview-content.tsx | 2 +- .../src/settings/email/default-recipients.tsx | 6 +- .../src/settings/email/email-settings.tsx | 4 +- apps/admin/src/settings/email/emails.tsx | 12 +-- .../src/settings/email/enable-newsletters.tsx | 8 +- apps/admin/src/settings/email/mailgun.tsx | 6 +- apps/admin/src/settings/email/newsletters.tsx | 10 +-- .../newsletters/add-newsletter-modal.tsx | 10 +-- .../newsletters/newsletter-detail-modal.tsx | 22 ++--- .../newsletter-preview-content.tsx | 10 +-- .../email/newsletters/newsletter-preview.tsx | 4 +- .../email/newsletters/newsletters-list.tsx | 2 +- .../newsletters/newsletters-tab-content.tsx | 8 +- .../email/use-default-recipients-options.tsx | 2 +- apps/admin/src/settings/general/about.tsx | 10 +-- .../src/settings/general/general-settings.tsx | 2 +- .../settings/general/invite-user-modal.tsx | 6 +- .../settings/general/publication-language.tsx | 8 +- apps/admin/src/settings/general/seo-meta.tsx | 8 +- .../src/settings/general/social-accounts.tsx | 10 +-- apps/admin/src/settings/general/time-zone.tsx | 8 +- .../general/title-and-description.tsx | 6 +- .../settings/general/user-detail-modal.tsx | 16 ++-- apps/admin/src/settings/general/users.tsx | 16 ++-- .../general/users/change-password-form.tsx | 2 +- .../general/users/email-notifications-tab.tsx | 2 +- .../settings/general/users/profile-tab.tsx | 2 +- .../settings/general/users/role-selector.tsx | 2 +- .../general/users/social-links-tab.tsx | 4 +- .../settings/general/users/staff-token.tsx | 2 +- .../embed-signup/embed-signup-form-modal.tsx | 8 +- .../growth/embed-signup/embed-signup-form.tsx | 4 +- .../embed-signup/embed-signup-preview.tsx | 4 +- .../embed-signup/embed-signup-sidebar.tsx | 4 +- apps/admin/src/settings/growth/explore.tsx | 14 ++-- .../growth/explore/testimonials-modal.tsx | 10 +-- .../src/settings/growth/growth-settings.tsx | 4 +- apps/admin/src/settings/growth/network.tsx | 12 +-- apps/admin/src/settings/growth/offers.tsx | 8 +- .../growth/offers/add-offer-modal.tsx | 10 +-- .../growth/offers/edit-offer-modal.tsx | 12 +-- .../offers/edit-retention-offer-modal.tsx | 10 +-- .../settings/growth/offers/offer-helpers.ts | 2 +- .../settings/growth/offers/offer-success.tsx | 10 +-- .../growth/offers/offers-container-modal.tsx | 2 +- .../settings/growth/offers/offers-index.tsx | 4 +- .../src/settings/growth/recommendations.tsx | 8 +- .../add-recommendation-modal-confirm.tsx | 2 +- .../add-recommendation-modal.tsx | 4 +- .../edit-recommendation-modal.tsx | 2 +- .../incoming-recommendation-list.tsx | 2 +- .../recommendations/recommendation-icon.tsx | 2 +- .../recommendations/recommendation-list.tsx | 4 +- .../growth/tips-and-donations-settings.ts | 2 +- .../settings/growth/tips-and-donations.tsx | 10 +-- .../scroll-section-provider.tsx} | 58 +------------- .../hooks/site/use-navigation-editor.test.tsx | 4 +- .../hooks/site/use-navigation-editor.tsx | 2 +- .../{app => }/hooks/use-auto-expandable.tsx | 2 +- .../hooks/use-check-theme-limit-error.tsx | 2 +- .../{app => }/hooks/use-currency-input.ts | 0 .../{app => }/hooks/use-custom-fonts.tsx | 2 +- .../{app => }/hooks/use-feature-flag.tsx | 2 +- .../settings/{app => }/hooks/use-limiter.tsx | 10 +-- .../{app => }/hooks/use-pintura-editor.ts | 2 +- .../{app => }/hooks/use-query-params.ts | 0 .../{app => }/hooks/use-save-button.ts | 0 .../src/settings/hooks/use-scroll-section.ts | 56 +++++++++++++ .../{app => }/hooks/use-setting-group.tsx | 2 +- .../hooks/use-settings-navigation.ts | 0 .../hooks/use-sortable-indexed-list.test.ts | 2 +- .../hooks/use-sortable-indexed-list.ts | 2 +- .../{app => }/hooks/use-staff-users.tsx | 6 +- .../hooks/use-upgrade-route.test.tsx | 6 +- .../{app => }/hooks/use-upgrade-route.ts | 2 +- .../settings/{app => }/hooks/use-url-input.ts | 2 +- .../use-welcome-email-link-suggestions.ts | 2 +- .../hooks/use-welcome-email-sender-details.ts | 2 +- .../src/settings/{app => layout}/app.tsx | 9 ++- .../settings/{app => layout}/main-content.tsx | 8 +- .../settings-sections.tsx} | 4 +- .../{app/components => layout}/sidebar.tsx | 14 ++-- apps/admin/src/settings/membership/access.tsx | 10 +-- .../src/settings/membership/analytics.tsx | 12 +-- .../src/settings/membership/custom-fields.tsx | 8 +- .../custom-fields/custom-field-modal.tsx | 2 +- .../membership/gift-subscriptions.tsx | 6 +- .../src/settings/membership/member-emails.tsx | 12 +-- .../member-emails/member-email-editor.tsx | 11 ++- .../welcome-email-customize-modal.tsx | 6 +- .../member-emails/welcome-email-modal.tsx | 6 +- .../membership/membership-settings.tsx | 6 +- apps/admin/src/settings/membership/portal.tsx | 12 +-- .../membership/portal/account-page.tsx | 2 +- .../membership/portal/look-and-feel.tsx | 2 +- .../membership/portal/portal-links.tsx | 2 +- .../membership/portal/portal-modal.tsx | 10 +-- .../membership/portal/portal-preview.tsx | 4 +- .../membership/portal/signup-options.tsx | 4 +- .../stripe/stripe-connect-modal.tsx | 24 +++--- apps/admin/src/settings/membership/tiers.tsx | 20 ++--- .../membership/tiers/tier-detail-modal.tsx | 14 ++-- .../membership/tiers/tier-detail-preview.tsx | 2 +- .../settings/membership/tiers/tiers-list.tsx | 4 +- .../providers/confirmation-context.ts | 20 +++++ .../providers/confirmation-provider.tsx | 24 +----- .../providers/dialog-portal.tsx | 0 .../settings/providers/global-data-context.ts | 16 ++++ .../providers/global-data-provider.tsx | 25 ++---- .../providers/routing/staff-profile-paths.tsx | 0 .../settings-app-context.ts} | 51 ++---------- .../providers/settings-app-provider.tsx | 44 ++++++++++ .../settings => }/search-keywords.test.ts | 0 apps/admin/src/settings/settings-access.ts | 2 +- apps/admin/src/settings/settings.tsx | 2 +- .../settings/site/announcement-bar-modal.tsx | 14 ++-- .../src/settings/site/announcement-bar.tsx | 6 +- .../announcement-bar-preview.tsx | 2 +- apps/admin/src/settings/site/change-theme.tsx | 12 +-- .../design-and-branding/global-settings.tsx | 8 +- .../design-and-branding/theme-preview.tsx | 4 +- .../design-and-branding/theme-setting.tsx | 2 +- .../design-and-branding/theme-settings.tsx | 4 +- .../settings/site/design-and-theme-modal.tsx | 8 +- apps/admin/src/settings/site/design-modal.tsx | 8 +- .../src/settings/site/design-setting.tsx | 8 +- .../src/settings/site/navigation-modal.tsx | 8 +- apps/admin/src/settings/site/navigation.tsx | 6 +- .../site/navigation/navigation-edit-form.tsx | 2 +- .../navigation/navigation-icon-upload.tsx | 2 +- .../navigation/navigation-item-editor.tsx | 6 +- .../navigation-visibility-dropdown.tsx | 2 +- .../site/navigation/navigation-visibility.ts | 2 +- .../admin/src/settings/site/site-settings.tsx | 2 +- apps/admin/src/settings/site/theme-modal.tsx | 10 +-- .../site/theme/advanced-theme-settings.tsx | 10 +-- .../site/theme/invalid-theme-modal.tsx | 2 +- .../settings/site/theme/official-themes.tsx | 4 +- .../site/theme/theme-code-editor-modal.tsx | 2 +- .../site/theme/theme-installed-modal.tsx | 4 +- .../src/settings/site/theme/theme-preview.tsx | 4 +- .../{app => }/utils/analytics.test.ts | 2 +- .../src/settings/{app => }/utils/analytics.ts | 0 .../src/settings/{app => }/utils/currency.ts | 0 .../utils/custom-theme-settings.test.ts | 2 +- .../src/settings/{app => }/utils/debounce.ts | 0 .../settings/{app => }/utils/escape-html.ts | 0 .../settings/{app => }/utils/format-url.ts | 0 .../utils/generate-embed-code.test.ts | 2 +- .../{app => }/utils/generate-embed-code.ts | 7 +- .../utils/get-offers-portal-preview-url.ts | 0 .../{app => }/utils/get-portal-preview-url.ts | 2 +- .../{app => }/utils/get-tiers-cadences.ts | 0 .../src/settings/{app => }/utils/helpers.ts | 0 .../{app => }/utils/iframe-buffering.test.tsx | 2 +- .../{app => }/utils/iframe-buffering.tsx | 16 +--- .../utils/is-custom-theme-settings-visible.ts | 0 .../utils/link-to-github-releases.test.ts | 2 +- .../utils/link-to-github-releases.ts | 0 .../{app => }/utils/locale-validation.test.ts | 2 +- .../{app => }/utils/locale-validation.ts | 0 .../{app => }/utils/newsletter-emails.ts | 0 .../src/settings/{app => }/utils/portal.tsx | 0 .../src/settings/{app => }/utils/search.tsx | 4 +- .../utils/show-database-warning.test.ts | 2 +- .../{app => }/utils/show-database-warning.ts | 0 .../{app => }/utils/social-urls/bluesky.ts | 0 .../{app => }/utils/social-urls/facebook.ts | 0 .../{app => }/utils/social-urls/index.ts | 0 .../{app => }/utils/social-urls/instagram.ts | 0 .../{app => }/utils/social-urls/linkedin.ts | 0 .../{app => }/utils/social-urls/mastodon.ts | 0 .../utils/social-urls/platform-config.test.ts | 2 +- .../utils/social-urls/platform-config.ts | 0 .../utils/social-urls/platform-validator.ts | 0 .../utils/social-urls/social-urls.test.ts | 2 +- .../{app => }/utils/social-urls/threads.ts | 0 .../{app => }/utils/social-urls/tiktok.ts | 0 .../{app => }/utils/social-urls/twitter.ts | 0 .../{app => }/utils/social-urls/youtube.ts | 0 .../src/settings/{app => }/utils/url.test.ts | 2 +- .../admin/src/settings/{app => }/utils/url.ts | 0 .../welcome-email-sender-details.test.ts | 2 +- .../utils/welcome-email-sender-details.ts | 0 .../admin/src/tags/detail/tag-image-field.tsx | 2 +- apps/admin/src/vite-env.d.ts | 18 ++++- 329 files changed, 840 insertions(+), 794 deletions(-) rename apps/admin/src/settings/{app => }/assets/images/ali-abdaal.png (100%) rename apps/admin/src/settings/{app => }/assets/images/brand-icons/beehiiv.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/brand-icons/facebook.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/brand-icons/firstpromoter.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/brand-icons/linkedin.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/brand-icons/mailchimp.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/brand-icons/medium.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/brand-icons/pintura.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/brand-icons/portal-icon-1.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/brand-icons/portal-icon-2.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/brand-icons/portal-icon-3.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/brand-icons/portal-icon-4.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/brand-icons/portal-icon-5.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/brand-icons/slack.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/brand-icons/squarespace.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/brand-icons/substack.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/brand-icons/transistor.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/brand-icons/twitter-x.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/brand-icons/unsplash.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/brand-icons/wordpress.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/brand-icons/zapier.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/cover-test.jpg (100%) rename apps/admin/src/settings/{app => }/assets/images/design-settings.png (100%) rename apps/admin/src/settings/{app => }/assets/images/email-design-user-image.jpg (100%) rename apps/admin/src/settings/{app => }/assets/images/explore-default-logo.png (100%) rename apps/admin/src/settings/{app => }/assets/images/footer-marketplace-bg.png (100%) rename apps/admin/src/settings/{app => }/assets/images/ghost-explore.png (100%) rename apps/admin/src/settings/{app => }/assets/images/ghost-favicon.png (100%) rename apps/admin/src/settings/{app => }/assets/images/integrations-settings.png (100%) rename apps/admin/src/settings/{app => }/assets/images/integrations/ghost-transistor.png (100%) rename apps/admin/src/settings/{app => }/assets/images/integrations/google-docs.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/integrations/mailchimp.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/integrations/patreon.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/integrations/paypal.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/integrations/slackicon.png (100%) rename apps/admin/src/settings/{app => }/assets/images/integrations/typeform.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/integrations/zero-bounce.png (100%) rename apps/admin/src/settings/{app => }/assets/images/isaac-saul.png (100%) rename apps/admin/src/settings/{app => }/assets/images/joel-warner.png (100%) rename apps/admin/src/settings/{app => }/assets/images/labs-bg.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/latest-posts-1.jpg (100%) rename apps/admin/src/settings/{app => }/assets/images/latest-posts-2.jpg (100%) rename apps/admin/src/settings/{app => }/assets/images/latest-posts-3.jpg (100%) rename apps/admin/src/settings/{app => }/assets/images/logos/orb-black-1.png (100%) rename apps/admin/src/settings/{app => }/assets/images/logos/orb-black-2.png (100%) rename apps/admin/src/settings/{app => }/assets/images/logos/orb-black-3.png (100%) rename apps/admin/src/settings/{app => }/assets/images/logos/orb-black-4.png (100%) rename apps/admin/src/settings/{app => }/assets/images/logos/orb-black-5.png (100%) rename apps/admin/src/settings/{app => }/assets/images/network.png (100%) rename apps/admin/src/settings/{app => }/assets/images/orb-pink.png (100%) rename apps/admin/src/settings/{app => }/assets/images/orb-squircle.png (100%) rename apps/admin/src/settings/{app => }/assets/images/pintura-screenshot.png (100%) rename apps/admin/src/settings/{app => }/assets/images/portal-splash-default-logo.png (100%) rename apps/admin/src/settings/{app => }/assets/images/portal-splash-user-add.png (100%) rename apps/admin/src/settings/{app => }/assets/images/stripe-emblem.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/stripe-thumb.jpg (100%) rename apps/admin/src/settings/{app => }/assets/images/stripe-verified.svg (100%) rename apps/admin/src/settings/{app => }/assets/images/themes/Alto.png (100%) rename apps/admin/src/settings/{app => }/assets/images/themes/Bulletin.png (100%) rename apps/admin/src/settings/{app => }/assets/images/themes/Casper.png (100%) rename apps/admin/src/settings/{app => }/assets/images/themes/Dawn.png (100%) rename apps/admin/src/settings/{app => }/assets/images/themes/Digest.png (100%) rename apps/admin/src/settings/{app => }/assets/images/themes/Dope.png (100%) rename apps/admin/src/settings/{app => }/assets/images/themes/Ease.png (100%) rename apps/admin/src/settings/{app => }/assets/images/themes/Edge.png (100%) rename apps/admin/src/settings/{app => }/assets/images/themes/Edition.png (100%) rename apps/admin/src/settings/{app => }/assets/images/themes/Episode.png (100%) rename apps/admin/src/settings/{app => }/assets/images/themes/Headline.png (100%) rename apps/admin/src/settings/{app => }/assets/images/themes/Journal.png (100%) rename apps/admin/src/settings/{app => }/assets/images/themes/London.png (100%) rename apps/admin/src/settings/{app => }/assets/images/themes/Ruby.png (100%) rename apps/admin/src/settings/{app => }/assets/images/themes/Solo.png (100%) rename apps/admin/src/settings/{app => }/assets/images/themes/Source-Magazine.png (100%) rename apps/admin/src/settings/{app => }/assets/images/themes/Source-Newsletter.png (100%) rename apps/admin/src/settings/{app => }/assets/images/themes/Source.png (100%) rename apps/admin/src/settings/{app => }/assets/images/themes/Taste.png (100%) rename apps/admin/src/settings/{app => }/assets/images/themes/Wave.png (100%) rename apps/admin/src/settings/{app => }/assets/images/user-cover.jpg (100%) rename apps/admin/src/settings/{app => }/assets/images/zapier-logo.svg (100%) rename apps/admin/src/settings/{app => }/assets/videos/logo-loader-dark.mp4 (100%) rename apps/admin/src/settings/{app => }/assets/videos/logo-loader.mp4 (100%) rename apps/admin/src/settings/{app => }/components/code-editor-view.tsx (97%) rename apps/admin/src/settings/{app => }/components/code-editor.tsx (100%) rename apps/admin/src/settings/{app => }/components/color-picker-field.tsx (99%) rename apps/admin/src/settings/{app => }/components/color-swatch-field.tsx (100%) rename apps/admin/src/settings/{app => }/components/confirmation-modal.test.tsx (92%) rename apps/admin/src/settings/{app => }/components/confirmation-modal.tsx (98%) rename apps/admin/src/settings/{app => }/components/error-boundary.tsx (78%) rename apps/admin/src/settings/{app => }/components/exit-settings-button.tsx (100%) rename apps/admin/src/settings/{app => }/components/html-editor.tsx (88%) rename apps/admin/src/settings/{app => }/components/html-field.tsx (100%) rename apps/admin/src/settings/{app => }/components/icons/brand-icon.tsx (54%) rename apps/admin/src/settings/{app => }/components/infinite-scroll-listener.tsx (100%) rename apps/admin/src/settings/{app => }/components/koenig-editor-base.tsx (77%) create mode 100644 apps/admin/src/settings/components/koenig-loader.ts rename apps/admin/src/settings/{app => }/components/limit-modal.test.tsx (85%) rename apps/admin/src/settings/{app => }/components/limit-modal.tsx (100%) rename apps/admin/src/settings/{app/components/settings => components}/preview-modal.tsx (99%) rename apps/admin/src/settings/{app => }/components/searchable-section.tsx (92%) rename apps/admin/src/settings/{app => }/components/selectors/unsplash-selector.tsx (93%) rename apps/admin/src/settings/{app/components/settings => components}/settings-breadcrumbs.test.tsx (93%) rename apps/admin/src/settings/{app/components/settings => components}/settings-breadcrumbs.tsx (100%) rename apps/admin/src/settings/{app => }/components/stripe-button.tsx (100%) rename apps/admin/src/settings/{app => }/components/top-level-group.tsx (93%) create mode 100644 apps/admin/src/settings/components/with-error-boundary.tsx rename apps/admin/src/settings/{app => }/data/official-themes.ts (70%) rename apps/admin/src/settings/{app => }/data/zapier-templates.ts (63%) rename apps/admin/src/settings/{app/hooks/use-scroll-section.tsx => hooks/scroll-section-provider.tsx} (81%) rename apps/admin/src/settings/{app => }/hooks/site/use-navigation-editor.test.tsx (98%) rename apps/admin/src/settings/{app => }/hooks/site/use-navigation-editor.tsx (98%) rename apps/admin/src/settings/{app => }/hooks/use-auto-expandable.tsx (93%) rename apps/admin/src/settings/{app => }/hooks/use-check-theme-limit-error.tsx (95%) rename apps/admin/src/settings/{app => }/hooks/use-currency-input.ts (100%) rename apps/admin/src/settings/{app => }/hooks/use-custom-fonts.tsx (93%) rename apps/admin/src/settings/{app => }/hooks/use-feature-flag.tsx (64%) rename apps/admin/src/settings/{app => }/hooks/use-limiter.tsx (93%) rename apps/admin/src/settings/{app => }/hooks/use-pintura-editor.ts (99%) rename apps/admin/src/settings/{app => }/hooks/use-query-params.ts (100%) rename apps/admin/src/settings/{app => }/hooks/use-save-button.ts (100%) create mode 100644 apps/admin/src/settings/hooks/use-scroll-section.ts rename apps/admin/src/settings/{app => }/hooks/use-setting-group.tsx (97%) rename apps/admin/src/settings/{app => }/hooks/use-settings-navigation.ts (100%) rename apps/admin/src/settings/{app => }/hooks/use-sortable-indexed-list.test.ts (98%) rename apps/admin/src/settings/{app => }/hooks/use-sortable-indexed-list.ts (98%) rename apps/admin/src/settings/{app => }/hooks/use-staff-users.tsx (94%) rename apps/admin/src/settings/{app => }/hooks/use-upgrade-route.test.tsx (85%) rename apps/admin/src/settings/{app => }/hooks/use-upgrade-route.ts (67%) rename apps/admin/src/settings/{app => }/hooks/use-url-input.ts (96%) rename apps/admin/src/settings/{app => }/hooks/use-welcome-email-link-suggestions.ts (92%) rename apps/admin/src/settings/{app => }/hooks/use-welcome-email-sender-details.ts (93%) rename apps/admin/src/settings/{app => layout}/app.tsx (76%) rename apps/admin/src/settings/{app => layout}/main-content.tsx (94%) rename apps/admin/src/settings/{app/components/settings.tsx => layout/settings-sections.tsx} (85%) rename apps/admin/src/settings/{app/components => layout}/sidebar.tsx (97%) create mode 100644 apps/admin/src/settings/providers/confirmation-context.ts rename apps/admin/src/settings/{app/components => }/providers/confirmation-provider.tsx (72%) rename apps/admin/src/settings/{app/components => }/providers/dialog-portal.tsx (100%) create mode 100644 apps/admin/src/settings/providers/global-data-context.ts rename apps/admin/src/settings/{app/components => }/providers/global-data-provider.tsx (69%) rename apps/admin/src/settings/{app/components => }/providers/routing/staff-profile-paths.tsx (100%) rename apps/admin/src/settings/{app/components/providers/settings-app-provider.tsx => providers/settings-app-context.ts} (53%) create mode 100644 apps/admin/src/settings/providers/settings-app-provider.tsx rename apps/admin/src/settings/{app/components/settings => }/search-keywords.test.ts (100%) rename apps/admin/src/settings/{app => }/utils/analytics.test.ts (90%) rename apps/admin/src/settings/{app => }/utils/analytics.ts (100%) rename apps/admin/src/settings/{app => }/utils/currency.ts (100%) rename apps/admin/src/settings/{app => }/utils/custom-theme-settings.test.ts (91%) rename apps/admin/src/settings/{app => }/utils/debounce.ts (100%) rename apps/admin/src/settings/{app => }/utils/escape-html.ts (100%) rename apps/admin/src/settings/{app => }/utils/format-url.ts (100%) rename apps/admin/src/settings/{app => }/utils/generate-embed-code.test.ts (98%) rename apps/admin/src/settings/{app => }/utils/generate-embed-code.ts (93%) rename apps/admin/src/settings/{app => }/utils/get-offers-portal-preview-url.ts (100%) rename apps/admin/src/settings/{app => }/utils/get-portal-preview-url.ts (97%) rename apps/admin/src/settings/{app => }/utils/get-tiers-cadences.ts (100%) rename apps/admin/src/settings/{app => }/utils/helpers.ts (100%) rename apps/admin/src/settings/{app => }/utils/iframe-buffering.test.tsx (92%) rename apps/admin/src/settings/{app => }/utils/iframe-buffering.tsx (91%) rename apps/admin/src/settings/{app => }/utils/is-custom-theme-settings-visible.ts (100%) rename apps/admin/src/settings/{app => }/utils/link-to-github-releases.test.ts (95%) rename apps/admin/src/settings/{app => }/utils/link-to-github-releases.ts (100%) rename apps/admin/src/settings/{app => }/utils/locale-validation.test.ts (98%) rename apps/admin/src/settings/{app => }/utils/locale-validation.ts (100%) rename apps/admin/src/settings/{app => }/utils/newsletter-emails.ts (100%) rename apps/admin/src/settings/{app => }/utils/portal.tsx (100%) rename apps/admin/src/settings/{app => }/utils/search.tsx (95%) rename apps/admin/src/settings/{app => }/utils/show-database-warning.test.ts (90%) rename apps/admin/src/settings/{app => }/utils/show-database-warning.ts (100%) rename apps/admin/src/settings/{app => }/utils/social-urls/bluesky.ts (100%) rename apps/admin/src/settings/{app => }/utils/social-urls/facebook.ts (100%) rename apps/admin/src/settings/{app => }/utils/social-urls/index.ts (100%) rename apps/admin/src/settings/{app => }/utils/social-urls/instagram.ts (100%) rename apps/admin/src/settings/{app => }/utils/social-urls/linkedin.ts (100%) rename apps/admin/src/settings/{app => }/utils/social-urls/mastodon.ts (100%) rename apps/admin/src/settings/{app => }/utils/social-urls/platform-config.test.ts (98%) rename apps/admin/src/settings/{app => }/utils/social-urls/platform-config.ts (100%) rename apps/admin/src/settings/{app => }/utils/social-urls/platform-validator.ts (100%) rename apps/admin/src/settings/{app => }/utils/social-urls/social-urls.test.ts (99%) rename apps/admin/src/settings/{app => }/utils/social-urls/threads.ts (100%) rename apps/admin/src/settings/{app => }/utils/social-urls/tiktok.ts (100%) rename apps/admin/src/settings/{app => }/utils/social-urls/twitter.ts (100%) rename apps/admin/src/settings/{app => }/utils/social-urls/youtube.ts (100%) rename apps/admin/src/settings/{app => }/utils/url.test.ts (98%) rename apps/admin/src/settings/{app => }/utils/url.ts (100%) rename apps/admin/src/settings/{app => }/utils/welcome-email-sender-details.test.ts (97%) rename apps/admin/src/settings/{app => }/utils/welcome-email-sender-details.ts (100%) diff --git a/apps/admin/src/automations/utils/default-welcome-email-values.ts b/apps/admin/src/automations/utils/default-welcome-email-values.ts index 9b69b9cc8e5..faaced44b7c 100644 --- a/apps/admin/src/automations/utils/default-welcome-email-values.ts +++ b/apps/admin/src/automations/utils/default-welcome-email-values.ts @@ -1,4 +1,4 @@ -// NOTE: duplicated in apps/admin/src/settings/app/components/settings/membership/member-emails/default-welcome-email-values.ts — the email design modal needs it in both apps until Automations GAs; keep in sync +// NOTE: duplicated in apps/admin/src/settings/membership/member-emails/default-welcome-email-values.ts — the email design modal needs it in both apps until Automations GAs; keep in sync import type {AutomatedEmail} from '@tryghost/admin-x-framework/api/automated-emails'; export type WelcomeEmailType = 'free' | 'paid'; diff --git a/apps/admin/src/settings/app/components/settings/advanced/advanced-settings.tsx b/apps/admin/src/settings/app/components/settings/advanced/advanced-settings.tsx index 7a35994cef7..318071885ca 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/advanced-settings.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/advanced-settings.tsx @@ -5,7 +5,7 @@ import Integrations from './integrations'; import Labs from './labs'; import MigrationTools from './migration-tools'; import React from 'react'; -import SearchableSection from '@/settings/app/components/searchable-section'; +import SearchableSection from '@/settings/components/searchable-section'; export const searchKeywords = { integrations: ['advanced', 'integrations', 'zapier', 'slack', 'unsplash', 'first promoter', 'firstpromoter', 'pintura', 'disqus', 'analytics', 'ulysses', 'typeform', 'buffer', 'plausible', 'github', 'webhooks'], diff --git a/apps/admin/src/settings/app/components/settings/advanced/code-injection.tsx b/apps/admin/src/settings/app/components/settings/advanced/code-injection.tsx index 2c48683ec8f..5fb0e83e3cd 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/code-injection.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/code-injection.tsx @@ -1,9 +1,9 @@ import CodeModal from './code/code-modal'; import React, {useState} from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; import {Button} from '@tryghost/shade/components'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; -import {DialogPortal} from '@/settings/app/components/providers/dialog-portal'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; +import {DialogPortal} from '@/settings/providers/dialog-portal'; const CodeInjection: React.FC<{ keywords: string[] }> = ({keywords}) => { const [isCodeModalOpen, setIsCodeModalOpen] = useState(false); diff --git a/apps/admin/src/settings/app/components/settings/advanced/code/code-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/code/code-modal.tsx index c2e49fadc62..d605f8f9019 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/code/code-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/code/code-modal.tsx @@ -1,12 +1,12 @@ -import CodeEditor from '@/settings/app/components/code-editor'; +import CodeEditor from '@/settings/components/code-editor'; import React, {useEffect, useMemo, useRef, useState} from 'react'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {Button, Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade/components'; import {Inline, Text} from '@tryghost/shade/primitives'; import {type ReactCodeMirrorRef} from '@uiw/react-codemirror'; import {SettingsModal} from '@tryghost/shade/patterns'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {useSaveButton} from '@/settings/app/hooks/use-save-button'; +import {useSaveButton} from '@/settings/hooks/use-save-button'; interface CodeModalProps { onClose: () => void; diff --git a/apps/admin/src/settings/app/components/settings/advanced/danger-zone.tsx b/apps/admin/src/settings/app/components/settings/advanced/danger-zone.tsx index 28ab18ff0c0..65d6648d819 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/danger-zone.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/danger-zone.tsx @@ -1,18 +1,18 @@ import React from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import trackEvent from '@/settings/app/utils/analytics'; -import useStaffUsers from '@/settings/app/hooks/use-staff-users'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import trackEvent from '@/settings/utils/analytics'; +import useStaffUsers from '@/settings/hooks/use-staff-users'; import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent, Button} from '@tryghost/shade/components'; import {formatNumber} from '@tryghost/shade/utils'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {toast} from 'sonner'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {useDeleteAllContent} from '@tryghost/admin-x-framework/api/db'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useQueryClient} from '@tryghost/admin-x-framework'; import {useRemoveAllGiftLinks} from '@tryghost/admin-x-framework/api/gift-links'; import {useResetAuth} from '@tryghost/admin-x-framework/api/security'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const DangerZone: React.FC<{ keywords: string[] }> = ({keywords}) => { const {mutateAsync: deleteAllContent} = useDeleteAllContent(); diff --git a/apps/admin/src/settings/app/components/settings/advanced/history-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/history-modal.tsx index 5f2239cfde9..cb8b5ba47e3 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/history-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/history-modal.tsx @@ -1,10 +1,10 @@ -import InfiniteScrollListener from '@/settings/app/components/infinite-scroll-listener'; +import InfiniteScrollListener from '@/settings/components/infinite-scroll-listener'; import {type Action, getActionTitle, getContextResource, getLinkTarget, isBulkAction, useBrowseActions} from '@tryghost/admin-x-framework/api/actions'; import {ActionList, ActionListItem, ActionListItemContent, Avatar, Button, Field, FieldLabel, LoadingIndicator, MultiSelectCombobox, NoValueLabel, NoValueLabelIcon, Popover, PopoverContent, PopoverTrigger, Switch, inputSurface} from '@tryghost/shade/components'; import {ChevronDown, History, Pen, Plus, Trash2, X} from 'lucide-react'; import {Inline, Stack} from '@tryghost/shade/primitives'; import {useParams} from '@tryghost/admin-x-framework'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; import {SettingsModal} from '@tryghost/shade/patterns'; import {type User} from '@tryghost/admin-x-framework/api/users'; import {formatNumber} from '@tryghost/shade/utils'; diff --git a/apps/admin/src/settings/app/components/settings/advanced/history.tsx b/apps/admin/src/settings/app/components/settings/advanced/history.tsx index 9d1d1873e87..e7885a2e91c 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/history.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/history.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; import {Button} from '@tryghost/shade/components'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const History: React.FC<{ keywords: string[] }> = ({keywords}) => { const {updateRoute} = useSettingsNavigation(); diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations.tsx b/apps/admin/src/settings/app/components/settings/advanced/integrations.tsx index ebc50fb01ff..17d254d2792 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/integrations.tsx @@ -1,21 +1,21 @@ -import BrandIcon from '@/settings/app/components/icons/brand-icon'; -import IntegrationsSettingsImg from '@/settings/app/assets/images/integrations-settings.png'; +import BrandIcon from '@/settings/components/icons/brand-icon'; +import IntegrationsSettingsImg from '@/settings/assets/images/integrations-settings.png'; import React, {useState} from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import usePinturaEditor from '@/settings/app/hooks/use-pintura-editor'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import usePinturaEditor from '@/settings/hooks/use-pintura-editor'; import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent, Button, NoValueLabel, NoValueLabelIcon, Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade/components'; import {type Integration, useBrowseIntegrations, useDeleteIntegration} from '@tryghost/admin-x-framework/api/integrations'; import {LucideIcon} from '@tryghost/shade/utils'; import {Plug} from 'lucide-react'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {toast} from 'sonner'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; import {DEFAULT_UPGRADE_ROUTE} from '@tryghost/admin-x-framework/api/config'; -import {useUpgradeRoute} from '@/settings/app/hooks/use-upgrade-route'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useUpgradeRoute} from '@/settings/hooks/use-upgrade-route'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; interface IntegrationItemProps { icon?: React.ReactNode, diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/add-integration-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/integrations/add-integration-modal.tsx index d5245bfda35..a6fb10872cf 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/add-integration-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/integrations/add-integration-modal.tsx @@ -1,9 +1,9 @@ import {useEffect, useState} from 'react'; import {Field, FieldError, FieldGroup, FieldLabel, Input} from '@tryghost/shade/components'; -import {HostLimitError, useLimiter} from '@/settings/app/hooks/use-limiter'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {useUpgradeRoute} from '@/settings/app/hooks/use-upgrade-route'; +import {HostLimitError, useLimiter} from '@/settings/hooks/use-limiter'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {useUpgradeRoute} from '@/settings/hooks/use-upgrade-route'; import {SettingsModal} from '@tryghost/shade/patterns'; import {useCreateIntegration} from '@tryghost/admin-x-framework/api/integrations'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/content-api-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/integrations/content-api-modal.tsx index 67900de4173..7183bb3571b 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/content-api-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/integrations/content-api-modal.tsx @@ -5,7 +5,7 @@ import {LucideIcon} from '@tryghost/shade/utils'; import {SettingsModal} from '@tryghost/shade/patterns'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {useBrowseIntegrations} from '@tryghost/admin-x-framework/api/integrations'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; function ContentApiModal() { const {updateRoute} = useSettingsNavigation(); diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/custom-integration-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/integrations/custom-integration-modal.tsx index 932f48e4ecd..37760af7b36 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/custom-integration-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/integrations/custom-integration-modal.tsx @@ -7,9 +7,9 @@ import {Box, Stack} from '@tryghost/shade/primitives'; import {Field, FieldError, FieldLabel, Input} from '@tryghost/shade/components'; import {ImageUpload, ImageUploadAction, ImageUploadActions, ImageUploadDropzone, ImageUploadImage, ImageUploadPreview} from '@tryghost/shade/patterns'; import {type Integration, useBrowseIntegrations, useEditIntegration} from '@tryghost/admin-x-framework/api/integrations'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {useParams} from '@tryghost/admin-x-framework'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; import {SettingsModal} from '@tryghost/shade/patterns'; import {Trash2} from 'lucide-react'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/first-promoter-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/integrations/first-promoter-modal.tsx index f59a29c9f35..f70da7b5763 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/first-promoter-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/integrations/first-promoter-modal.tsx @@ -1,12 +1,12 @@ -import BrandIcon from '@/settings/app/components/icons/brand-icon'; +import BrandIcon from '@/settings/components/icons/brand-icon'; import IntegrationHeader from './integration-header'; import {Field, FieldContent, FieldDescription, FieldGroup, FieldLabel, FieldSet, Input, Switch} from '@tryghost/shade/components'; import {type Setting, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {SettingsModal} from '@tryghost/shade/patterns'; import {useEffect, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; function FirstPromoterModal() { const {updateRoute} = useSettingsNavigation(); diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/pintura-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/integrations/pintura-modal.tsx index 70b3051048f..b2cedeaa7fe 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/pintura-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/integrations/pintura-modal.tsx @@ -1,14 +1,14 @@ -import BrandIcon from '@/settings/app/components/icons/brand-icon'; +import BrandIcon from '@/settings/components/icons/brand-icon'; import IntegrationHeader from './integration-header'; -import pinturaScreenshot from '@/settings/app/assets/images/pintura-screenshot.png'; +import pinturaScreenshot from '@/settings/assets/images/pintura-screenshot.png'; import {Dropzone, Field, FieldContent, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSet, Switch} from '@tryghost/shade/components'; import {type Setting, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {SettingsModal} from '@tryghost/shade/patterns'; import {toast} from 'sonner'; import {useEffect, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; import {useUploadFile} from '@tryghost/admin-x-framework/api/files'; function PinturaModal() { diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/slack-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/integrations/slack-modal.tsx index 28f6a1c5a4f..3875e331db2 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/slack-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/integrations/slack-modal.tsx @@ -1,12 +1,12 @@ -import BrandIcon from '@/settings/app/components/icons/brand-icon'; +import BrandIcon from '@/settings/components/icons/brand-icon'; import IntegrationHeader from './integration-header'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import validator from 'validator'; import {Button, Field, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSet, Input} from '@tryghost/shade/components'; import {SettingsModal} from '@tryghost/shade/patterns'; import {getSettingValues, useTestSlack} from '@tryghost/admin-x-framework/api/settings'; import {toast} from 'sonner'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; function SlackModal() { const {updateRoute} = useSettingsNavigation(); diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/transistor-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/integrations/transistor-modal.tsx index aa77cacf6bd..10d994d36d9 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/transistor-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/integrations/transistor-modal.tsx @@ -1,18 +1,18 @@ import APIKeys from './api-keys'; -import BookmarkThumb from '@/settings/app/assets/images/integrations/ghost-transistor.png'; -import BrandIcon from '@/settings/app/components/icons/brand-icon'; +import BookmarkThumb from '@/settings/assets/images/integrations/ghost-transistor.png'; +import BrandIcon from '@/settings/components/icons/brand-icon'; import IntegrationHeader from './integration-header'; import {Field, FieldContent, FieldDescription, FieldGroup, FieldLabel, FieldSet, Switch} from '@tryghost/shade/components'; import {type Setting, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {SettingsModal} from '@tryghost/shade/patterns'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {useBrowseIntegrations} from '@tryghost/admin-x-framework/api/integrations'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {useEffect, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useRefreshAPIKey} from '@tryghost/admin-x-framework/api/api-keys'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; function TransistorModal() { const {updateRoute} = useSettingsNavigation(); diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/unsplash-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/integrations/unsplash-modal.tsx index b027b4b3577..b275839b1fc 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/unsplash-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/integrations/unsplash-modal.tsx @@ -1,12 +1,12 @@ -import BrandIcon from '@/settings/app/components/icons/brand-icon'; +import BrandIcon from '@/settings/components/icons/brand-icon'; import IntegrationHeader from './integration-header'; import {Field, FieldContent, FieldDescription, FieldGroup, FieldLabel, Switch} from '@tryghost/shade/components'; import {type Setting, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {SettingsModal} from '@tryghost/shade/patterns'; import {useEffect, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; function UnsplashModal() { const {updateRoute} = useSettingsNavigation(); diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.test.tsx b/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.test.tsx index 977bb1a35e8..021904d92e5 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.test.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.test.tsx @@ -1,5 +1,5 @@ import WebhooksTable from '@/settings/app/components/settings/advanced/integrations/webhooks-table'; -import {ConfirmationProvider} from '@/settings/app/components/providers/confirmation-provider'; +import {ConfirmationProvider} from '@/settings/providers/confirmation-provider'; import {type Integration} from '@tryghost/admin-x-framework/api/integrations'; import {render, screen} from '@testing-library/react'; diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.tsx b/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.tsx index e1554823ec4..d9a84a3e21c 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.tsx @@ -6,7 +6,7 @@ import {type Integration} from '@tryghost/admin-x-framework/api/integrations'; import {LucideIcon, formatNumber} from '@tryghost/shade/utils'; import {getWebhookEventLabel} from './webhook-event-options'; import {toast} from 'sonner'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {type Webhook, useDeleteWebhook} from '@tryghost/admin-x-framework/api/webhooks'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/zapier-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/integrations/zapier-modal.tsx index 6e32adfaf4a..8513e5bbd7f 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/zapier-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/integrations/zapier-modal.tsx @@ -1,19 +1,19 @@ import APIKeys from './api-keys'; -import BrandIcon from '@/settings/app/components/icons/brand-icon'; +import BrandIcon from '@/settings/components/icons/brand-icon'; import IntegrationHeader from './integration-header'; -import ZapierLogo from '@/settings/app/assets/images/zapier-logo.svg'; +import ZapierLogo from '@/settings/assets/images/zapier-logo.svg'; import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent, Button} from '@tryghost/shade/components'; import {LucideIcon} from '@tryghost/shade/utils'; import {SettingsModal} from '@tryghost/shade/patterns'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {useBrowseIntegrations} from '@tryghost/admin-x-framework/api/integrations'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {useEffect, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useRefreshAPIKey} from '@tryghost/admin-x-framework/api/api-keys'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {useSettingsApp} from '@/settings/app/components/providers/settings-app-provider'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {useSettingsApp} from '@/settings/providers/settings-app-context'; export interface ZapierTemplate { ghostImage: string; diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs.tsx b/apps/admin/src/settings/app/components/settings/advanced/labs.tsx index f146307ba51..0bf93f12697 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/labs.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/labs.tsx @@ -1,12 +1,12 @@ import BetaFeatures from './labs/beta-features'; -import LabsBubbles from '@/settings/app/assets/images/labs-bg.svg'; +import LabsBubbles from '@/settings/assets/images/labs-bg.svg'; import PrivateFeatures from './labs/private-features'; import React, {useState} from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; import {Button, Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade/components'; -import {useAutoExpandable} from '@/settings/app/hooks/use-auto-expandable'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useAutoExpandable} from '@/settings/hooks/use-auto-expandable'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; type LabsTab = 'labs-private-features' | 'labs-beta-features'; diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs/beta-features.tsx b/apps/admin/src/settings/app/components/settings/advanced/labs/beta-features.tsx index 30f79243fe9..7d92e1b3fee 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/labs/beta-features.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/labs/beta-features.tsx @@ -8,9 +8,9 @@ import {downloadRedirects, useUploadRedirects} from '@tryghost/admin-x-framework import {downloadRoutes, useUploadRoutes} from '@tryghost/admin-x-framework/api/routes'; import {getSettingValue} from '@tryghost/admin-x-framework/api/settings'; import {toast} from 'sonner'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {DialogPortal} from '@/settings/app/components/providers/dialog-portal'; +import {DialogPortal} from '@/settings/providers/dialog-portal'; const IS_AUTOMATIONS_BETA_ACTIVE = true; diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs/feature-toggle.tsx b/apps/admin/src/settings/app/components/settings/advanced/labs/feature-toggle.tsx index 9586f79c080..7cb7ec64bce 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/labs/feature-toggle.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/labs/feature-toggle.tsx @@ -1,13 +1,13 @@ import React, {useState} from 'react'; -import trackEvent from '@/settings/app/utils/analytics'; +import trackEvent from '@/settings/utils/analytics'; import {type ConfigResponseType, configDataType} from '@tryghost/admin-x-framework/api/config'; import {SettingsModal} from '@tryghost/shade/patterns'; import {Switch} from '@tryghost/shade/components'; import {getSettingValue, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useQueryClient} from '@tanstack/react-query'; -import {DialogPortal} from '@/settings/app/components/providers/dialog-portal'; +import {DialogPortal} from '@/settings/providers/dialog-portal'; type ConfirmationProps = { title: string; diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx b/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx index c9cd5f4a5d5..b7d9e82eabd 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx @@ -2,7 +2,7 @@ import FeatureToggle from './feature-toggle'; import LabItem from './lab-item'; import React, {useEffect, useState} from 'react'; import {ActionList} from '@tryghost/shade/components'; -import {HostLimitError, useLimiter} from '@/settings/app/hooks/use-limiter'; +import {HostLimitError, useLimiter} from '@/settings/hooks/use-limiter'; type Feature = { title: string; diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs/yaml-file-editor-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/labs/yaml-file-editor-modal.tsx index 58bb3a92832..ec1be8c19b6 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/labs/yaml-file-editor-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/labs/yaml-file-editor-modal.tsx @@ -1,4 +1,4 @@ -import CodeEditor from '@/settings/app/components/code-editor'; +import CodeEditor from '@/settings/components/code-editor'; import React, {useEffect, useMemo, useState} from 'react'; import {APIError, JSONError} from '@tryghost/admin-x-framework/errors'; import {Button} from '@tryghost/shade/components'; diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools.tsx b/apps/admin/src/settings/app/components/settings/advanced/migration-tools.tsx index c9660236535..773ab5370bb 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/migration-tools.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/migration-tools.tsx @@ -1,9 +1,9 @@ import MigrationToolsExport from './migration-tools/migration-tools-export'; import MigrationToolsImport from './migration-tools/migration-tools-import'; import React, {useState} from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; import {Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade/components'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; type MigrationTab = 'import' | 'export'; diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-export.tsx b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-export.tsx index f487eb9dd42..e52ab2a4259 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-export.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-export.tsx @@ -1,6 +1,6 @@ import ExportAllModal, {type ExportMode} from './export-all-modal'; import React from 'react'; -import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; +import useFeatureFlag from '@/settings/hooks/use-feature-flag'; import {Button, LoadingIndicator} from '@tryghost/shade/components'; import {LucideIcon} from '@tryghost/shade/utils'; import {blobDownloadFromEndpoint} from '@tryghost/admin-x-framework/helpers'; diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-import.tsx b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-import.tsx index 83566954d5d..fd3dd235f87 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-import.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-import.tsx @@ -1,10 +1,10 @@ -import BrandIcon from '@/settings/app/components/icons/brand-icon'; +import BrandIcon from '@/settings/components/icons/brand-icon'; import React, {useState} from 'react'; import UniversalImportModal from './universal-import-modal'; import {Button} from '@tryghost/shade/components'; import {LucideIcon} from '@tryghost/shade/utils'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {DialogPortal} from '@/settings/app/components/providers/dialog-portal'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {DialogPortal} from '@/settings/providers/dialog-portal'; const MigrationToolsImport: React.FC = () => { const {updateRoute} = useSettingsNavigation(); diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.test.tsx b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.test.tsx index 78825bf3f8c..97b70c14cca 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.test.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.test.tsx @@ -1,6 +1,6 @@ import {useState} from 'react'; import UniversalImportModal from '@/settings/app/components/settings/advanced/migration-tools/universal-import-modal'; -import {ConfirmationProvider} from '@/settings/app/components/providers/confirmation-provider'; +import {ConfirmationProvider} from '@/settings/providers/confirmation-provider'; import {act, fireEvent, render, screen, waitFor} from '@testing-library/react'; const {mockImportContent, mockImportContentCSV, mockUseFeatureFlag, mockHandleError} = vi.hoisted(() => ({ @@ -25,7 +25,7 @@ vi.mock('@tryghost/admin-x-framework/hooks', async () => { return {...actual, useHandleError: () => mockHandleError}; }); -vi.mock('@/settings/app/hooks/use-feature-flag', () => ({ +vi.mock('@/settings/hooks/use-feature-flag', () => ({ default: (flag: string) => mockUseFeatureFlag(flag) })); diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.tsx index acb85bbbca0..2cbc0f46cdd 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.tsx @@ -1,10 +1,10 @@ import React, {useState} from 'react'; -import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; +import useFeatureFlag from '@/settings/hooks/use-feature-flag'; import {Button, Dropzone} from '@tryghost/shade/components'; import {ExternalLink} from 'lucide-react'; import {Inline} from '@tryghost/shade/primitives'; import {SettingsModal} from '@tryghost/shade/patterns'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useImportContent} from '@tryghost/admin-x-framework/api/db'; import {useImportContentCSV} from '@tryghost/admin-x-framework/api/posts'; diff --git a/apps/admin/src/settings/app/components/settings/advanced/spam-filters.tsx b/apps/admin/src/settings/app/components/settings/advanced/spam-filters.tsx index 0764c44cc59..b54364ff85f 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/spam-filters.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/spam-filters.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {Field, FieldDescription, FieldError, FieldLabel, Textarea} from '@tryghost/shade/components'; import {SettingGroupContent} from '@tryghost/shade/patterns'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const SpamFilters: React.FC<{ keywords: string[] }> = ({keywords}) => { const { diff --git a/apps/admin/src/settings/app/assets/images/ali-abdaal.png b/apps/admin/src/settings/assets/images/ali-abdaal.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/ali-abdaal.png rename to apps/admin/src/settings/assets/images/ali-abdaal.png diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/beehiiv.svg b/apps/admin/src/settings/assets/images/brand-icons/beehiiv.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/beehiiv.svg rename to apps/admin/src/settings/assets/images/brand-icons/beehiiv.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/facebook.svg b/apps/admin/src/settings/assets/images/brand-icons/facebook.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/facebook.svg rename to apps/admin/src/settings/assets/images/brand-icons/facebook.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/firstpromoter.svg b/apps/admin/src/settings/assets/images/brand-icons/firstpromoter.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/firstpromoter.svg rename to apps/admin/src/settings/assets/images/brand-icons/firstpromoter.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/linkedin.svg b/apps/admin/src/settings/assets/images/brand-icons/linkedin.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/linkedin.svg rename to apps/admin/src/settings/assets/images/brand-icons/linkedin.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/mailchimp.svg b/apps/admin/src/settings/assets/images/brand-icons/mailchimp.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/mailchimp.svg rename to apps/admin/src/settings/assets/images/brand-icons/mailchimp.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/medium.svg b/apps/admin/src/settings/assets/images/brand-icons/medium.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/medium.svg rename to apps/admin/src/settings/assets/images/brand-icons/medium.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/pintura.svg b/apps/admin/src/settings/assets/images/brand-icons/pintura.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/pintura.svg rename to apps/admin/src/settings/assets/images/brand-icons/pintura.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/portal-icon-1.svg b/apps/admin/src/settings/assets/images/brand-icons/portal-icon-1.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/portal-icon-1.svg rename to apps/admin/src/settings/assets/images/brand-icons/portal-icon-1.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/portal-icon-2.svg b/apps/admin/src/settings/assets/images/brand-icons/portal-icon-2.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/portal-icon-2.svg rename to apps/admin/src/settings/assets/images/brand-icons/portal-icon-2.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/portal-icon-3.svg b/apps/admin/src/settings/assets/images/brand-icons/portal-icon-3.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/portal-icon-3.svg rename to apps/admin/src/settings/assets/images/brand-icons/portal-icon-3.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/portal-icon-4.svg b/apps/admin/src/settings/assets/images/brand-icons/portal-icon-4.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/portal-icon-4.svg rename to apps/admin/src/settings/assets/images/brand-icons/portal-icon-4.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/portal-icon-5.svg b/apps/admin/src/settings/assets/images/brand-icons/portal-icon-5.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/portal-icon-5.svg rename to apps/admin/src/settings/assets/images/brand-icons/portal-icon-5.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/slack.svg b/apps/admin/src/settings/assets/images/brand-icons/slack.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/slack.svg rename to apps/admin/src/settings/assets/images/brand-icons/slack.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/squarespace.svg b/apps/admin/src/settings/assets/images/brand-icons/squarespace.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/squarespace.svg rename to apps/admin/src/settings/assets/images/brand-icons/squarespace.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/substack.svg b/apps/admin/src/settings/assets/images/brand-icons/substack.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/substack.svg rename to apps/admin/src/settings/assets/images/brand-icons/substack.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/transistor.svg b/apps/admin/src/settings/assets/images/brand-icons/transistor.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/transistor.svg rename to apps/admin/src/settings/assets/images/brand-icons/transistor.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/twitter-x.svg b/apps/admin/src/settings/assets/images/brand-icons/twitter-x.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/twitter-x.svg rename to apps/admin/src/settings/assets/images/brand-icons/twitter-x.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/unsplash.svg b/apps/admin/src/settings/assets/images/brand-icons/unsplash.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/unsplash.svg rename to apps/admin/src/settings/assets/images/brand-icons/unsplash.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/wordpress.svg b/apps/admin/src/settings/assets/images/brand-icons/wordpress.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/wordpress.svg rename to apps/admin/src/settings/assets/images/brand-icons/wordpress.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/zapier.svg b/apps/admin/src/settings/assets/images/brand-icons/zapier.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/zapier.svg rename to apps/admin/src/settings/assets/images/brand-icons/zapier.svg diff --git a/apps/admin/src/settings/app/assets/images/cover-test.jpg b/apps/admin/src/settings/assets/images/cover-test.jpg similarity index 100% rename from apps/admin/src/settings/app/assets/images/cover-test.jpg rename to apps/admin/src/settings/assets/images/cover-test.jpg diff --git a/apps/admin/src/settings/app/assets/images/design-settings.png b/apps/admin/src/settings/assets/images/design-settings.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/design-settings.png rename to apps/admin/src/settings/assets/images/design-settings.png diff --git a/apps/admin/src/settings/app/assets/images/email-design-user-image.jpg b/apps/admin/src/settings/assets/images/email-design-user-image.jpg similarity index 100% rename from apps/admin/src/settings/app/assets/images/email-design-user-image.jpg rename to apps/admin/src/settings/assets/images/email-design-user-image.jpg diff --git a/apps/admin/src/settings/app/assets/images/explore-default-logo.png b/apps/admin/src/settings/assets/images/explore-default-logo.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/explore-default-logo.png rename to apps/admin/src/settings/assets/images/explore-default-logo.png diff --git a/apps/admin/src/settings/app/assets/images/footer-marketplace-bg.png b/apps/admin/src/settings/assets/images/footer-marketplace-bg.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/footer-marketplace-bg.png rename to apps/admin/src/settings/assets/images/footer-marketplace-bg.png diff --git a/apps/admin/src/settings/app/assets/images/ghost-explore.png b/apps/admin/src/settings/assets/images/ghost-explore.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/ghost-explore.png rename to apps/admin/src/settings/assets/images/ghost-explore.png diff --git a/apps/admin/src/settings/app/assets/images/ghost-favicon.png b/apps/admin/src/settings/assets/images/ghost-favicon.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/ghost-favicon.png rename to apps/admin/src/settings/assets/images/ghost-favicon.png diff --git a/apps/admin/src/settings/app/assets/images/integrations-settings.png b/apps/admin/src/settings/assets/images/integrations-settings.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/integrations-settings.png rename to apps/admin/src/settings/assets/images/integrations-settings.png diff --git a/apps/admin/src/settings/app/assets/images/integrations/ghost-transistor.png b/apps/admin/src/settings/assets/images/integrations/ghost-transistor.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/integrations/ghost-transistor.png rename to apps/admin/src/settings/assets/images/integrations/ghost-transistor.png diff --git a/apps/admin/src/settings/app/assets/images/integrations/google-docs.svg b/apps/admin/src/settings/assets/images/integrations/google-docs.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/integrations/google-docs.svg rename to apps/admin/src/settings/assets/images/integrations/google-docs.svg diff --git a/apps/admin/src/settings/app/assets/images/integrations/mailchimp.svg b/apps/admin/src/settings/assets/images/integrations/mailchimp.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/integrations/mailchimp.svg rename to apps/admin/src/settings/assets/images/integrations/mailchimp.svg diff --git a/apps/admin/src/settings/app/assets/images/integrations/patreon.svg b/apps/admin/src/settings/assets/images/integrations/patreon.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/integrations/patreon.svg rename to apps/admin/src/settings/assets/images/integrations/patreon.svg diff --git a/apps/admin/src/settings/app/assets/images/integrations/paypal.svg b/apps/admin/src/settings/assets/images/integrations/paypal.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/integrations/paypal.svg rename to apps/admin/src/settings/assets/images/integrations/paypal.svg diff --git a/apps/admin/src/settings/app/assets/images/integrations/slackicon.png b/apps/admin/src/settings/assets/images/integrations/slackicon.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/integrations/slackicon.png rename to apps/admin/src/settings/assets/images/integrations/slackicon.png diff --git a/apps/admin/src/settings/app/assets/images/integrations/typeform.svg b/apps/admin/src/settings/assets/images/integrations/typeform.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/integrations/typeform.svg rename to apps/admin/src/settings/assets/images/integrations/typeform.svg diff --git a/apps/admin/src/settings/app/assets/images/integrations/zero-bounce.png b/apps/admin/src/settings/assets/images/integrations/zero-bounce.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/integrations/zero-bounce.png rename to apps/admin/src/settings/assets/images/integrations/zero-bounce.png diff --git a/apps/admin/src/settings/app/assets/images/isaac-saul.png b/apps/admin/src/settings/assets/images/isaac-saul.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/isaac-saul.png rename to apps/admin/src/settings/assets/images/isaac-saul.png diff --git a/apps/admin/src/settings/app/assets/images/joel-warner.png b/apps/admin/src/settings/assets/images/joel-warner.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/joel-warner.png rename to apps/admin/src/settings/assets/images/joel-warner.png diff --git a/apps/admin/src/settings/app/assets/images/labs-bg.svg b/apps/admin/src/settings/assets/images/labs-bg.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/labs-bg.svg rename to apps/admin/src/settings/assets/images/labs-bg.svg diff --git a/apps/admin/src/settings/app/assets/images/latest-posts-1.jpg b/apps/admin/src/settings/assets/images/latest-posts-1.jpg similarity index 100% rename from apps/admin/src/settings/app/assets/images/latest-posts-1.jpg rename to apps/admin/src/settings/assets/images/latest-posts-1.jpg diff --git a/apps/admin/src/settings/app/assets/images/latest-posts-2.jpg b/apps/admin/src/settings/assets/images/latest-posts-2.jpg similarity index 100% rename from apps/admin/src/settings/app/assets/images/latest-posts-2.jpg rename to apps/admin/src/settings/assets/images/latest-posts-2.jpg diff --git a/apps/admin/src/settings/app/assets/images/latest-posts-3.jpg b/apps/admin/src/settings/assets/images/latest-posts-3.jpg similarity index 100% rename from apps/admin/src/settings/app/assets/images/latest-posts-3.jpg rename to apps/admin/src/settings/assets/images/latest-posts-3.jpg diff --git a/apps/admin/src/settings/app/assets/images/logos/orb-black-1.png b/apps/admin/src/settings/assets/images/logos/orb-black-1.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/logos/orb-black-1.png rename to apps/admin/src/settings/assets/images/logos/orb-black-1.png diff --git a/apps/admin/src/settings/app/assets/images/logos/orb-black-2.png b/apps/admin/src/settings/assets/images/logos/orb-black-2.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/logos/orb-black-2.png rename to apps/admin/src/settings/assets/images/logos/orb-black-2.png diff --git a/apps/admin/src/settings/app/assets/images/logos/orb-black-3.png b/apps/admin/src/settings/assets/images/logos/orb-black-3.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/logos/orb-black-3.png rename to apps/admin/src/settings/assets/images/logos/orb-black-3.png diff --git a/apps/admin/src/settings/app/assets/images/logos/orb-black-4.png b/apps/admin/src/settings/assets/images/logos/orb-black-4.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/logos/orb-black-4.png rename to apps/admin/src/settings/assets/images/logos/orb-black-4.png diff --git a/apps/admin/src/settings/app/assets/images/logos/orb-black-5.png b/apps/admin/src/settings/assets/images/logos/orb-black-5.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/logos/orb-black-5.png rename to apps/admin/src/settings/assets/images/logos/orb-black-5.png diff --git a/apps/admin/src/settings/app/assets/images/network.png b/apps/admin/src/settings/assets/images/network.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/network.png rename to apps/admin/src/settings/assets/images/network.png diff --git a/apps/admin/src/settings/app/assets/images/orb-pink.png b/apps/admin/src/settings/assets/images/orb-pink.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/orb-pink.png rename to apps/admin/src/settings/assets/images/orb-pink.png diff --git a/apps/admin/src/settings/app/assets/images/orb-squircle.png b/apps/admin/src/settings/assets/images/orb-squircle.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/orb-squircle.png rename to apps/admin/src/settings/assets/images/orb-squircle.png diff --git a/apps/admin/src/settings/app/assets/images/pintura-screenshot.png b/apps/admin/src/settings/assets/images/pintura-screenshot.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/pintura-screenshot.png rename to apps/admin/src/settings/assets/images/pintura-screenshot.png diff --git a/apps/admin/src/settings/app/assets/images/portal-splash-default-logo.png b/apps/admin/src/settings/assets/images/portal-splash-default-logo.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/portal-splash-default-logo.png rename to apps/admin/src/settings/assets/images/portal-splash-default-logo.png diff --git a/apps/admin/src/settings/app/assets/images/portal-splash-user-add.png b/apps/admin/src/settings/assets/images/portal-splash-user-add.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/portal-splash-user-add.png rename to apps/admin/src/settings/assets/images/portal-splash-user-add.png diff --git a/apps/admin/src/settings/app/assets/images/stripe-emblem.svg b/apps/admin/src/settings/assets/images/stripe-emblem.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/stripe-emblem.svg rename to apps/admin/src/settings/assets/images/stripe-emblem.svg diff --git a/apps/admin/src/settings/app/assets/images/stripe-thumb.jpg b/apps/admin/src/settings/assets/images/stripe-thumb.jpg similarity index 100% rename from apps/admin/src/settings/app/assets/images/stripe-thumb.jpg rename to apps/admin/src/settings/assets/images/stripe-thumb.jpg diff --git a/apps/admin/src/settings/app/assets/images/stripe-verified.svg b/apps/admin/src/settings/assets/images/stripe-verified.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/stripe-verified.svg rename to apps/admin/src/settings/assets/images/stripe-verified.svg diff --git a/apps/admin/src/settings/app/assets/images/themes/Alto.png b/apps/admin/src/settings/assets/images/themes/Alto.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Alto.png rename to apps/admin/src/settings/assets/images/themes/Alto.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Bulletin.png b/apps/admin/src/settings/assets/images/themes/Bulletin.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Bulletin.png rename to apps/admin/src/settings/assets/images/themes/Bulletin.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Casper.png b/apps/admin/src/settings/assets/images/themes/Casper.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Casper.png rename to apps/admin/src/settings/assets/images/themes/Casper.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Dawn.png b/apps/admin/src/settings/assets/images/themes/Dawn.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Dawn.png rename to apps/admin/src/settings/assets/images/themes/Dawn.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Digest.png b/apps/admin/src/settings/assets/images/themes/Digest.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Digest.png rename to apps/admin/src/settings/assets/images/themes/Digest.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Dope.png b/apps/admin/src/settings/assets/images/themes/Dope.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Dope.png rename to apps/admin/src/settings/assets/images/themes/Dope.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Ease.png b/apps/admin/src/settings/assets/images/themes/Ease.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Ease.png rename to apps/admin/src/settings/assets/images/themes/Ease.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Edge.png b/apps/admin/src/settings/assets/images/themes/Edge.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Edge.png rename to apps/admin/src/settings/assets/images/themes/Edge.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Edition.png b/apps/admin/src/settings/assets/images/themes/Edition.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Edition.png rename to apps/admin/src/settings/assets/images/themes/Edition.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Episode.png b/apps/admin/src/settings/assets/images/themes/Episode.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Episode.png rename to apps/admin/src/settings/assets/images/themes/Episode.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Headline.png b/apps/admin/src/settings/assets/images/themes/Headline.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Headline.png rename to apps/admin/src/settings/assets/images/themes/Headline.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Journal.png b/apps/admin/src/settings/assets/images/themes/Journal.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Journal.png rename to apps/admin/src/settings/assets/images/themes/Journal.png diff --git a/apps/admin/src/settings/app/assets/images/themes/London.png b/apps/admin/src/settings/assets/images/themes/London.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/London.png rename to apps/admin/src/settings/assets/images/themes/London.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Ruby.png b/apps/admin/src/settings/assets/images/themes/Ruby.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Ruby.png rename to apps/admin/src/settings/assets/images/themes/Ruby.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Solo.png b/apps/admin/src/settings/assets/images/themes/Solo.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Solo.png rename to apps/admin/src/settings/assets/images/themes/Solo.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Source-Magazine.png b/apps/admin/src/settings/assets/images/themes/Source-Magazine.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Source-Magazine.png rename to apps/admin/src/settings/assets/images/themes/Source-Magazine.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Source-Newsletter.png b/apps/admin/src/settings/assets/images/themes/Source-Newsletter.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Source-Newsletter.png rename to apps/admin/src/settings/assets/images/themes/Source-Newsletter.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Source.png b/apps/admin/src/settings/assets/images/themes/Source.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Source.png rename to apps/admin/src/settings/assets/images/themes/Source.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Taste.png b/apps/admin/src/settings/assets/images/themes/Taste.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Taste.png rename to apps/admin/src/settings/assets/images/themes/Taste.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Wave.png b/apps/admin/src/settings/assets/images/themes/Wave.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Wave.png rename to apps/admin/src/settings/assets/images/themes/Wave.png diff --git a/apps/admin/src/settings/app/assets/images/user-cover.jpg b/apps/admin/src/settings/assets/images/user-cover.jpg similarity index 100% rename from apps/admin/src/settings/app/assets/images/user-cover.jpg rename to apps/admin/src/settings/assets/images/user-cover.jpg diff --git a/apps/admin/src/settings/app/assets/images/zapier-logo.svg b/apps/admin/src/settings/assets/images/zapier-logo.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/zapier-logo.svg rename to apps/admin/src/settings/assets/images/zapier-logo.svg diff --git a/apps/admin/src/settings/app/assets/videos/logo-loader-dark.mp4 b/apps/admin/src/settings/assets/videos/logo-loader-dark.mp4 similarity index 100% rename from apps/admin/src/settings/app/assets/videos/logo-loader-dark.mp4 rename to apps/admin/src/settings/assets/videos/logo-loader-dark.mp4 diff --git a/apps/admin/src/settings/app/assets/videos/logo-loader.mp4 b/apps/admin/src/settings/assets/videos/logo-loader.mp4 similarity index 100% rename from apps/admin/src/settings/app/assets/videos/logo-loader.mp4 rename to apps/admin/src/settings/assets/videos/logo-loader.mp4 diff --git a/apps/admin/src/settings/app/components/code-editor-view.tsx b/apps/admin/src/settings/components/code-editor-view.tsx similarity index 97% rename from apps/admin/src/settings/app/components/code-editor-view.tsx rename to apps/admin/src/settings/components/code-editor-view.tsx index cf4e810a11b..5406912a36b 100644 --- a/apps/admin/src/settings/app/components/code-editor-view.tsx +++ b/apps/admin/src/settings/components/code-editor-view.tsx @@ -67,7 +67,7 @@ const CodeEditorView = forwardRef(function useEffect(() => { let cancelled = false; - Promise.all(extensions).then((nextExtensions) => { + Promise.all(extensions.map(extension => Promise.resolve(extension))).then((nextExtensions) => { if (!cancelled) { setResolvedExtensions(nextExtensions); } diff --git a/apps/admin/src/settings/app/components/code-editor.tsx b/apps/admin/src/settings/components/code-editor.tsx similarity index 100% rename from apps/admin/src/settings/app/components/code-editor.tsx rename to apps/admin/src/settings/components/code-editor.tsx diff --git a/apps/admin/src/settings/app/components/color-picker-field.tsx b/apps/admin/src/settings/components/color-picker-field.tsx similarity index 99% rename from apps/admin/src/settings/app/components/color-picker-field.tsx rename to apps/admin/src/settings/components/color-picker-field.tsx index 2e47bda854f..80d767936ae 100644 --- a/apps/admin/src/settings/app/components/color-picker-field.tsx +++ b/apps/admin/src/settings/components/color-picker-field.tsx @@ -2,7 +2,7 @@ import {ColorPicker, ColorPickerTrigger, type ColorSwatchOption, ColorSwatchRow} import {FieldDescription, Popover, PopoverContent, PopoverTrigger} from '@tryghost/shade/components'; import {Inline} from '@tryghost/shade/primitives'; import {type ReactNode, useCallback, useEffect, useId, useMemo, useRef, useState} from 'react'; -import {debounce} from '@/settings/app/utils/debounce'; +import {debounce} from '@/settings/utils/debounce'; export type ColorPickerFieldSwatch = ColorSwatchOption; diff --git a/apps/admin/src/settings/app/components/color-swatch-field.tsx b/apps/admin/src/settings/components/color-swatch-field.tsx similarity index 100% rename from apps/admin/src/settings/app/components/color-swatch-field.tsx rename to apps/admin/src/settings/components/color-swatch-field.tsx diff --git a/apps/admin/src/settings/app/components/confirmation-modal.test.tsx b/apps/admin/src/settings/components/confirmation-modal.test.tsx similarity index 92% rename from apps/admin/src/settings/app/components/confirmation-modal.test.tsx rename to apps/admin/src/settings/components/confirmation-modal.test.tsx index 39472b55f48..4286a38afdb 100644 --- a/apps/admin/src/settings/app/components/confirmation-modal.test.tsx +++ b/apps/admin/src/settings/components/confirmation-modal.test.tsx @@ -1,6 +1,7 @@ -import {type ConfirmationModalProps} from '@/settings/app/components/confirmation-modal'; +import {type ConfirmationModalProps} from '@/settings/components/confirmation-modal'; import {useEffect} from 'react'; -import {ConfirmationProvider, useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {ConfirmationProvider} from '@/settings/providers/confirmation-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {fireEvent, render, screen, waitFor} from '@testing-library/react'; describe('ConfirmationModal', () => { diff --git a/apps/admin/src/settings/app/components/confirmation-modal.tsx b/apps/admin/src/settings/components/confirmation-modal.tsx similarity index 98% rename from apps/admin/src/settings/app/components/confirmation-modal.tsx rename to apps/admin/src/settings/components/confirmation-modal.tsx index 892ea1b62dc..2fe4046e9c8 100644 --- a/apps/admin/src/settings/app/components/confirmation-modal.tsx +++ b/apps/admin/src/settings/components/confirmation-modal.tsx @@ -24,7 +24,7 @@ export interface ConfirmationModalProps { onOk?: (modal?: { remove: () => void; }) => void | Promise; - customFooter?: boolean | React.ReactNode; + customFooter?: React.ReactNode; formSheet?: boolean; stickyFooter?: boolean; testId?: string; @@ -88,7 +88,7 @@ export const ConfirmationModalContent: React.FC )} {okLabel && ( - diff --git a/apps/admin/src/settings/app/components/error-boundary.tsx b/apps/admin/src/settings/components/error-boundary.tsx similarity index 78% rename from apps/admin/src/settings/app/components/error-boundary.tsx rename to apps/admin/src/settings/components/error-boundary.tsx index 82b4a2211aa..10fa7b45265 100644 --- a/apps/admin/src/settings/app/components/error-boundary.tsx +++ b/apps/admin/src/settings/components/error-boundary.tsx @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/react'; -import React, {type ComponentType, type ErrorInfo, type ReactNode} from 'react'; +import React, {type ErrorInfo, type ReactNode} from 'react'; import {Banner} from '@tryghost/shade/components'; export interface ErrorBoundaryProps { @@ -47,13 +47,3 @@ class ErrorBoundary extends React.Component { } export default ErrorBoundary; - -export const withErrorBoundary = >(Component: ComponentType, name: string) => { - return function WithErrorBoundary(props: Props) { - return ( - - - - ); - }; -}; diff --git a/apps/admin/src/settings/app/components/exit-settings-button.tsx b/apps/admin/src/settings/components/exit-settings-button.tsx similarity index 100% rename from apps/admin/src/settings/app/components/exit-settings-button.tsx rename to apps/admin/src/settings/components/exit-settings-button.tsx diff --git a/apps/admin/src/settings/app/components/html-editor.tsx b/apps/admin/src/settings/components/html-editor.tsx similarity index 88% rename from apps/admin/src/settings/app/components/html-editor.tsx rename to apps/admin/src/settings/components/html-editor.tsx index c2b389779ab..a4475a892d7 100644 --- a/apps/admin/src/settings/app/components/html-editor.tsx +++ b/apps/admin/src/settings/components/html-editor.tsx @@ -1,4 +1,4 @@ -import KoenigEditorBase, {type KoenigInstance, type NodeType} from './koenig-editor-base'; +import KoenigEditorBase, {type KoenigLexicalModule, type NodeType} from './koenig-editor-base'; import React, {useCallback} from 'react'; // Re-export for backwards compatibility @@ -27,7 +27,7 @@ const HtmlEditor: React.FC = ({ // Likely related: https://github.com/facebook/lexical/issues/4255 const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); - const elements = doc.querySelectorAll('*') as NodeListOf; + const elements = doc.querySelectorAll('*'); elements.forEach((element) => { element.style.removeProperty('white-space'); @@ -44,7 +44,7 @@ const HtmlEditor: React.FC = ({ return ( - {(koenig: KoenigInstance) => ( + {(koenig: KoenigLexicalModule) => ( )} diff --git a/apps/admin/src/settings/app/components/html-field.tsx b/apps/admin/src/settings/components/html-field.tsx similarity index 100% rename from apps/admin/src/settings/app/components/html-field.tsx rename to apps/admin/src/settings/components/html-field.tsx diff --git a/apps/admin/src/settings/app/components/icons/brand-icon.tsx b/apps/admin/src/settings/components/icons/brand-icon.tsx similarity index 54% rename from apps/admin/src/settings/app/components/icons/brand-icon.tsx rename to apps/admin/src/settings/components/icons/brand-icon.tsx index 33589e65ddd..9920cf8b74c 100644 --- a/apps/admin/src/settings/app/components/icons/brand-icon.tsx +++ b/apps/admin/src/settings/components/icons/brand-icon.tsx @@ -1,24 +1,24 @@ -import Beehiiv from '@/settings/app/assets/images/brand-icons/beehiiv.svg'; -import Facebook from '@/settings/app/assets/images/brand-icons/facebook.svg'; -import FirstPromoter from '@/settings/app/assets/images/brand-icons/firstpromoter.svg'; -import Linkedin from '@/settings/app/assets/images/brand-icons/linkedin.svg'; -import Mailchimp from '@/settings/app/assets/images/brand-icons/mailchimp.svg'; -import Medium from '@/settings/app/assets/images/brand-icons/medium.svg'; -import Pintura from '@/settings/app/assets/images/brand-icons/pintura.svg'; -import PortalIcon1 from '@/settings/app/assets/images/brand-icons/portal-icon-1.svg'; -import PortalIcon2 from '@/settings/app/assets/images/brand-icons/portal-icon-2.svg'; -import PortalIcon3 from '@/settings/app/assets/images/brand-icons/portal-icon-3.svg'; -import PortalIcon4 from '@/settings/app/assets/images/brand-icons/portal-icon-4.svg'; -import PortalIcon5 from '@/settings/app/assets/images/brand-icons/portal-icon-5.svg'; +import Beehiiv from '@/settings/assets/images/brand-icons/beehiiv.svg'; +import Facebook from '@/settings/assets/images/brand-icons/facebook.svg'; +import FirstPromoter from '@/settings/assets/images/brand-icons/firstpromoter.svg'; +import Linkedin from '@/settings/assets/images/brand-icons/linkedin.svg'; +import Mailchimp from '@/settings/assets/images/brand-icons/mailchimp.svg'; +import Medium from '@/settings/assets/images/brand-icons/medium.svg'; +import Pintura from '@/settings/assets/images/brand-icons/pintura.svg'; +import PortalIcon1 from '@/settings/assets/images/brand-icons/portal-icon-1.svg'; +import PortalIcon2 from '@/settings/assets/images/brand-icons/portal-icon-2.svg'; +import PortalIcon3 from '@/settings/assets/images/brand-icons/portal-icon-3.svg'; +import PortalIcon4 from '@/settings/assets/images/brand-icons/portal-icon-4.svg'; +import PortalIcon5 from '@/settings/assets/images/brand-icons/portal-icon-5.svg'; import React from 'react'; -import Slack from '@/settings/app/assets/images/brand-icons/slack.svg'; -import Squarespace from '@/settings/app/assets/images/brand-icons/squarespace.svg'; -import Substack from '@/settings/app/assets/images/brand-icons/substack.svg'; -import Transistor from '@/settings/app/assets/images/brand-icons/transistor.svg'; -import TwitterX from '@/settings/app/assets/images/brand-icons/twitter-x.svg'; -import Unsplash from '@/settings/app/assets/images/brand-icons/unsplash.svg'; -import Wordpress from '@/settings/app/assets/images/brand-icons/wordpress.svg'; -import Zapier from '@/settings/app/assets/images/brand-icons/zapier.svg'; +import Slack from '@/settings/assets/images/brand-icons/slack.svg'; +import Squarespace from '@/settings/assets/images/brand-icons/squarespace.svg'; +import Substack from '@/settings/assets/images/brand-icons/substack.svg'; +import Transistor from '@/settings/assets/images/brand-icons/transistor.svg'; +import TwitterX from '@/settings/assets/images/brand-icons/twitter-x.svg'; +import Unsplash from '@/settings/assets/images/brand-icons/unsplash.svg'; +import Wordpress from '@/settings/assets/images/brand-icons/wordpress.svg'; +import Zapier from '@/settings/assets/images/brand-icons/zapier.svg'; import clsx from 'clsx'; const icons = { diff --git a/apps/admin/src/settings/app/components/infinite-scroll-listener.tsx b/apps/admin/src/settings/components/infinite-scroll-listener.tsx similarity index 100% rename from apps/admin/src/settings/app/components/infinite-scroll-listener.tsx rename to apps/admin/src/settings/components/infinite-scroll-listener.tsx diff --git a/apps/admin/src/settings/app/components/koenig-editor-base.tsx b/apps/admin/src/settings/components/koenig-editor-base.tsx similarity index 77% rename from apps/admin/src/settings/app/components/koenig-editor-base.tsx rename to apps/admin/src/settings/components/koenig-editor-base.tsx index 3c70c76db95..f6ef6731c7c 100644 --- a/apps/admin/src/settings/app/components/koenig-editor-base.tsx +++ b/apps/admin/src/settings/components/koenig-editor-base.tsx @@ -1,9 +1,10 @@ import * as Sentry from '@sentry/react'; import ErrorBoundary from './error-boundary'; import React, {Suspense, useCallback, useMemo} from 'react'; -import {type FetchKoenigLexical, useFocusContext} from '@tryghost/shade/app'; +import {type EditorResource, type KoenigInstance, type KoenigLexicalModule, loadKoenig} from './koenig-loader'; +import {useFocusContext} from '@tryghost/shade/app'; -export type {FetchKoenigLexical}; +export type {FetchKoenigLexical, KoenigInstance, KoenigLexicalModule} from './koenig-loader'; export type NodeType = 'DEFAULT_NODES' | 'BASIC_NODES' | 'MINIMAL_NODES' | 'EMAIL_NODES' | 'EMAIL_EDITOR_NODES'; @@ -25,61 +26,11 @@ export interface KoenigEditorBaseProps { registerAPI?: (API: KoenigInstance | null) => void } -declare global { - interface Window { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - '@tryghost/koenig-lexical': any; - } -} - -export type KoenigInstance = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [key: string]: any - editorInstance: { - getRootElement: () => HTMLElement | null - } - focusEditor: (options?: {position?: 'top' | 'bottom'}) => void - insertParagraphAtBottom: () => void - lastNodeIsDecorator: () => boolean -}; - -export const loadKoenig = function (fetchKoenigLexical: FetchKoenigLexical) { - let status = 'pending'; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let response: any; - - const suspender = fetchKoenigLexical().then( - (res) => { - status = 'success'; - response = res; - }, - (err) => { - status = 'error'; - response = err; - } - ); - - const read = () => { - switch (status) { - case 'pending': - throw suspender; - case 'error': - throw response; - default: - return response; - } - }; - - return {read}; -}; - -type EditorResource = ReturnType; - interface KoenigWrapperProps extends KoenigEditorBaseProps { editor: EditorResource // For HtmlEditor: render prop that returns HtmlOutputPlugin // For MinimalEditor: render prop that returns null (uses native onChange) - children: (koenig: KoenigInstance) => React.ReactNode + children: (koenig: KoenigLexicalModule) => React.ReactNode // For MinimalEditor: initial lexical state initialEditorState?: string // For MinimalEditor: native onChange handler @@ -131,9 +82,9 @@ export const KoenigWrapper: React.FC = ({ setFocusState(true); }; - const koenig = useMemo(() => new Proxy({} as KoenigInstance, { + const koenig = useMemo(() => new Proxy({} as KoenigLexicalModule, { get: (_target, prop) => { - return editor.read()[prop]; + return editor.read()[prop as keyof KoenigLexicalModule]; } }), [editor]); @@ -176,7 +127,7 @@ export const KoenigWrapper: React.FC = ({ }; interface KoenigEditorBaseInternalProps extends KoenigEditorBaseProps { - children: (koenig: KoenigInstance) => React.ReactNode + children: (koenig: KoenigLexicalModule) => React.ReactNode initialEditorState?: string onChange?: (editorState: unknown) => void } diff --git a/apps/admin/src/settings/components/koenig-loader.ts b/apps/admin/src/settings/components/koenig-loader.ts new file mode 100644 index 00000000000..39d0626e7f2 --- /dev/null +++ b/apps/admin/src/settings/components/koenig-loader.ts @@ -0,0 +1,75 @@ +import type React from 'react'; +import {type FetchKoenigLexical} from '@tryghost/shade/app'; + +export type {FetchKoenigLexical}; + +declare global { + interface Window { + '@tryghost/koenig-lexical'?: {version?: string}; + } +} + +type KoenigComponent = React.ComponentType>; + +// Minimal surface of the untyped @tryghost/koenig-lexical bundle used by the settings editors +export type KoenigLexicalModule = { + KoenigComposer: KoenigComponent; + KoenigComposableEditor: KoenigComponent; + EmojiPickerPlugin: KoenigComponent; + HtmlOutputPlugin: KoenigComponent; + EmailEditor: KoenigComponent; + DEFAULT_NODES: unknown; + BASIC_NODES: unknown; + MINIMAL_NODES: unknown; + EMAIL_NODES: unknown; + EMAIL_EDITOR_NODES: unknown; + DEFAULT_TRANSFORMERS: unknown; + BASIC_TRANSFORMERS: unknown; + MINIMAL_TRANSFORMERS: unknown; + EMAIL_TRANSFORMERS: unknown; +}; + +export type KoenigInstance = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any + editorInstance: { + getRootElement: () => HTMLElement | null + } + focusEditor: (options?: {position?: 'top' | 'bottom'}) => void + insertParagraphAtBottom: () => void + lastNodeIsDecorator: () => boolean +}; + +export const loadKoenig = function (fetchKoenigLexical: FetchKoenigLexical) { + let status: 'pending' | 'success' | 'error' = 'pending'; + let response: KoenigLexicalModule | undefined; + let error: unknown; + + const suspender = fetchKoenigLexical().then( + (res) => { + status = 'success'; + response = res as KoenigLexicalModule; + }, + (err: unknown) => { + status = 'error'; + error = err; + } + ); + + const read = (): KoenigLexicalModule => { + switch (status) { + case 'pending': + // React Suspense protocol: throwing the pending promise suspends the tree + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw suspender; + case 'error': + throw error instanceof Error ? error : new Error(String(error)); + default: + return response!; + } + }; + + return {read}; +}; + +export type EditorResource = ReturnType; diff --git a/apps/admin/src/settings/app/components/limit-modal.test.tsx b/apps/admin/src/settings/components/limit-modal.test.tsx similarity index 85% rename from apps/admin/src/settings/app/components/limit-modal.test.tsx rename to apps/admin/src/settings/components/limit-modal.test.tsx index fc3e6058be1..cd904785a80 100644 --- a/apps/admin/src/settings/app/components/limit-modal.test.tsx +++ b/apps/admin/src/settings/components/limit-modal.test.tsx @@ -1,6 +1,7 @@ -import {type LimitModalProps} from '@/settings/app/components/limit-modal'; +import {type LimitModalProps} from '@/settings/components/limit-modal'; import {useEffect} from 'react'; -import {ConfirmationProvider, useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {ConfirmationProvider} from '@/settings/providers/confirmation-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {fireEvent, render, screen, waitFor} from '@testing-library/react'; describe('LimitModal', () => { diff --git a/apps/admin/src/settings/app/components/limit-modal.tsx b/apps/admin/src/settings/components/limit-modal.tsx similarity index 100% rename from apps/admin/src/settings/app/components/limit-modal.tsx rename to apps/admin/src/settings/components/limit-modal.tsx diff --git a/apps/admin/src/settings/app/components/settings/preview-modal.tsx b/apps/admin/src/settings/components/preview-modal.tsx similarity index 99% rename from apps/admin/src/settings/app/components/settings/preview-modal.tsx rename to apps/admin/src/settings/components/preview-modal.tsx index 894853bb720..453f5f06622 100644 --- a/apps/admin/src/settings/app/components/settings/preview-modal.tsx +++ b/apps/admin/src/settings/components/preview-modal.tsx @@ -46,7 +46,7 @@ export interface PreviewModalProps { size?: SettingsModalSize; width?: 'full' | number; height?: 'full' | number; - sidebar?: boolean | React.ReactNode; + sidebar?: React.ReactNode; preview?: React.ReactNode; dirty?: boolean; cancelLabel?: string; diff --git a/apps/admin/src/settings/app/components/searchable-section.tsx b/apps/admin/src/settings/components/searchable-section.tsx similarity index 92% rename from apps/admin/src/settings/app/components/searchable-section.tsx rename to apps/admin/src/settings/components/searchable-section.tsx index 64fc756402a..3a48eb4ef43 100644 --- a/apps/admin/src/settings/app/components/searchable-section.tsx +++ b/apps/admin/src/settings/components/searchable-section.tsx @@ -1,5 +1,5 @@ import {Box, Stack, Text} from '@tryghost/shade/primitives'; -import {useSearch} from './providers/settings-app-provider'; +import {useSearch} from '@/settings/providers/settings-app-context'; interface SearchableSectionProps { children?: React.ReactNode; diff --git a/apps/admin/src/settings/app/components/selectors/unsplash-selector.tsx b/apps/admin/src/settings/components/selectors/unsplash-selector.tsx similarity index 93% rename from apps/admin/src/settings/app/components/selectors/unsplash-selector.tsx rename to apps/admin/src/settings/components/selectors/unsplash-selector.tsx index ccd22103189..b5859212485 100644 --- a/apps/admin/src/settings/app/components/selectors/unsplash-selector.tsx +++ b/apps/admin/src/settings/components/selectors/unsplash-selector.tsx @@ -1,4 +1,4 @@ -import Portal from '@/settings/app/utils/portal'; +import Portal from '@/settings/utils/portal'; import React from 'react'; import {type DefaultHeaderTypes, type InsertImagePayload, UnsplashSearchModal} from '@tryghost/kg-unsplash-selector'; diff --git a/apps/admin/src/settings/app/components/settings/settings-breadcrumbs.test.tsx b/apps/admin/src/settings/components/settings-breadcrumbs.test.tsx similarity index 93% rename from apps/admin/src/settings/app/components/settings/settings-breadcrumbs.test.tsx rename to apps/admin/src/settings/components/settings-breadcrumbs.test.tsx index b11fda52533..e4fad44e530 100644 --- a/apps/admin/src/settings/app/components/settings/settings-breadcrumbs.test.tsx +++ b/apps/admin/src/settings/components/settings-breadcrumbs.test.tsx @@ -1,4 +1,4 @@ -import SettingsBreadcrumbs from '@/settings/app/components/settings/settings-breadcrumbs'; +import SettingsBreadcrumbs from '@/settings/components/settings-breadcrumbs'; import assert from 'node:assert/strict'; import React from 'react'; import {fireEvent, render, screen} from '@testing-library/react'; diff --git a/apps/admin/src/settings/app/components/settings/settings-breadcrumbs.tsx b/apps/admin/src/settings/components/settings-breadcrumbs.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/settings-breadcrumbs.tsx rename to apps/admin/src/settings/components/settings-breadcrumbs.tsx diff --git a/apps/admin/src/settings/app/components/stripe-button.tsx b/apps/admin/src/settings/components/stripe-button.tsx similarity index 100% rename from apps/admin/src/settings/app/components/stripe-button.tsx rename to apps/admin/src/settings/components/stripe-button.tsx diff --git a/apps/admin/src/settings/app/components/top-level-group.tsx b/apps/admin/src/settings/components/top-level-group.tsx similarity index 93% rename from apps/admin/src/settings/app/components/top-level-group.tsx rename to apps/admin/src/settings/components/top-level-group.tsx index ecd05f8b1b4..91fb1b2af9d 100644 --- a/apps/admin/src/settings/app/components/top-level-group.tsx +++ b/apps/admin/src/settings/components/top-level-group.tsx @@ -9,10 +9,11 @@ import { SettingGroupHeader, SettingGroupTitle } from '@tryghost/shade/patterns'; -import {createComponentId} from '@/settings/app/utils/search'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {useScrollSection} from '@/settings/app/hooks/use-scroll-section'; -import {useSearch} from './providers/settings-app-provider'; +import {createComponentId} from '@/settings/utils/search'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {useScrollSection} from '@/settings/hooks/use-scroll-section'; +import {useSearch} from '@/settings/providers/settings-app-context'; +import {type SaveState} from '@tryghost/admin-x-framework/hooks'; interface TopLevelGroupProps { keywords: string[]; @@ -21,7 +22,7 @@ interface TopLevelGroupProps { title?: React.ReactNode; description?: React.ReactNode; isEditing?: boolean; - saveState?: 'unsaved' | 'saved' | 'saving' | string; + saveState?: SaveState; headerMedia?: React.ReactNode; customButtons?: React.ReactNode; beta?: boolean; diff --git a/apps/admin/src/settings/components/with-error-boundary.tsx b/apps/admin/src/settings/components/with-error-boundary.tsx new file mode 100644 index 00000000000..3ca199a1f3b --- /dev/null +++ b/apps/admin/src/settings/components/with-error-boundary.tsx @@ -0,0 +1,12 @@ +import ErrorBoundary from './error-boundary'; +import {type ComponentType} from 'react'; + +export const withErrorBoundary = >(Component: ComponentType, name: string) => { + return function WithErrorBoundary(props: Props) { + return ( + + + + ); + }; +}; diff --git a/apps/admin/src/settings/app/data/official-themes.ts b/apps/admin/src/settings/data/official-themes.ts similarity index 70% rename from apps/admin/src/settings/app/data/official-themes.ts rename to apps/admin/src/settings/data/official-themes.ts index 65d4e3caa20..ec3b89e7a68 100644 --- a/apps/admin/src/settings/app/data/official-themes.ts +++ b/apps/admin/src/settings/data/official-themes.ts @@ -1,26 +1,26 @@ -import {type OfficialTheme} from '@/settings/app/components/providers/settings-app-provider'; +import {type OfficialTheme} from '@/settings/providers/settings-app-context'; // Theme images -import AltoImage from '@/settings/app/assets/images/themes/Alto.png'; -import BulletinImage from '@/settings/app/assets/images/themes/Bulletin.png'; -import CasperImage from '@/settings/app/assets/images/themes/Casper.png'; -import DawnImage from '@/settings/app/assets/images/themes/Dawn.png'; -import DigestImage from '@/settings/app/assets/images/themes/Digest.png'; -import DopeImage from '@/settings/app/assets/images/themes/Dope.png'; -import EaseImage from '@/settings/app/assets/images/themes/Ease.png'; -import EdgeImage from '@/settings/app/assets/images/themes/Edge.png'; -import EditionImage from '@/settings/app/assets/images/themes/Edition.png'; -import EpisodeImage from '@/settings/app/assets/images/themes/Episode.png'; -import HeadlineImage from '@/settings/app/assets/images/themes/Headline.png'; -import JournalImage from '@/settings/app/assets/images/themes/Journal.png'; -import LondonImage from '@/settings/app/assets/images/themes/London.png'; -import RubyImage from '@/settings/app/assets/images/themes/Ruby.png'; -import SoloImage from '@/settings/app/assets/images/themes/Solo.png'; -import SourceImage from '@/settings/app/assets/images/themes/Source.png'; -import SourceMagazineImage from '@/settings/app/assets/images/themes/Source-Magazine.png'; -import SourceNewsletterImage from '@/settings/app/assets/images/themes/Source-Newsletter.png'; -import TasteImage from '@/settings/app/assets/images/themes/Taste.png'; -import WaveImage from '@/settings/app/assets/images/themes/Wave.png'; +import AltoImage from '@/settings/assets/images/themes/Alto.png'; +import BulletinImage from '@/settings/assets/images/themes/Bulletin.png'; +import CasperImage from '@/settings/assets/images/themes/Casper.png'; +import DawnImage from '@/settings/assets/images/themes/Dawn.png'; +import DigestImage from '@/settings/assets/images/themes/Digest.png'; +import DopeImage from '@/settings/assets/images/themes/Dope.png'; +import EaseImage from '@/settings/assets/images/themes/Ease.png'; +import EdgeImage from '@/settings/assets/images/themes/Edge.png'; +import EditionImage from '@/settings/assets/images/themes/Edition.png'; +import EpisodeImage from '@/settings/assets/images/themes/Episode.png'; +import HeadlineImage from '@/settings/assets/images/themes/Headline.png'; +import JournalImage from '@/settings/assets/images/themes/Journal.png'; +import LondonImage from '@/settings/assets/images/themes/London.png'; +import RubyImage from '@/settings/assets/images/themes/Ruby.png'; +import SoloImage from '@/settings/assets/images/themes/Solo.png'; +import SourceImage from '@/settings/assets/images/themes/Source.png'; +import SourceMagazineImage from '@/settings/assets/images/themes/Source-Magazine.png'; +import SourceNewsletterImage from '@/settings/assets/images/themes/Source-Newsletter.png'; +import TasteImage from '@/settings/assets/images/themes/Taste.png'; +import WaveImage from '@/settings/assets/images/themes/Wave.png'; export const officialThemes: OfficialTheme[] = [{ name: 'Source', diff --git a/apps/admin/src/settings/app/data/zapier-templates.ts b/apps/admin/src/settings/data/zapier-templates.ts similarity index 63% rename from apps/admin/src/settings/app/data/zapier-templates.ts rename to apps/admin/src/settings/data/zapier-templates.ts index d028eca40ed..fa5a1f90dcb 100644 --- a/apps/admin/src/settings/app/data/zapier-templates.ts +++ b/apps/admin/src/settings/data/zapier-templates.ts @@ -1,20 +1,20 @@ import {type ZapierTemplate} from '@/settings/app/components/settings/advanced/integrations/zapier-modal'; // Ghost logo images -import OrbBlack1 from '@/settings/app/assets/images/logos/orb-black-1.png'; -import OrbBlack2 from '@/settings/app/assets/images/logos/orb-black-2.png'; -import OrbBlack3 from '@/settings/app/assets/images/logos/orb-black-3.png'; -import OrbBlack4 from '@/settings/app/assets/images/logos/orb-black-4.png'; -import OrbBlack5 from '@/settings/app/assets/images/logos/orb-black-5.png'; +import OrbBlack1 from '@/settings/assets/images/logos/orb-black-1.png'; +import OrbBlack2 from '@/settings/assets/images/logos/orb-black-2.png'; +import OrbBlack3 from '@/settings/assets/images/logos/orb-black-3.png'; +import OrbBlack4 from '@/settings/assets/images/logos/orb-black-4.png'; +import OrbBlack5 from '@/settings/assets/images/logos/orb-black-5.png'; // Integration app images -import GoogleDocsIcon from '@/settings/app/assets/images/integrations/google-docs.svg'; -import MailchimpIcon from '@/settings/app/assets/images/integrations/mailchimp.svg'; -import PatreonIcon from '@/settings/app/assets/images/integrations/patreon.svg'; -import PaypalIcon from '@/settings/app/assets/images/integrations/paypal.svg'; -import SlackIcon from '@/settings/app/assets/images/integrations/slackicon.png'; -import TypeformIcon from '@/settings/app/assets/images/integrations/typeform.svg'; -import ZeroBounceIcon from '@/settings/app/assets/images/integrations/zero-bounce.png'; +import GoogleDocsIcon from '@/settings/assets/images/integrations/google-docs.svg'; +import MailchimpIcon from '@/settings/assets/images/integrations/mailchimp.svg'; +import PatreonIcon from '@/settings/assets/images/integrations/patreon.svg'; +import PaypalIcon from '@/settings/assets/images/integrations/paypal.svg'; +import SlackIcon from '@/settings/assets/images/integrations/slackicon.png'; +import TypeformIcon from '@/settings/assets/images/integrations/typeform.svg'; +import ZeroBounceIcon from '@/settings/assets/images/integrations/zero-bounce.png'; export const zapierTemplates: ZapierTemplate[] = [{ ghostImage: OrbBlack2, diff --git a/apps/admin/src/settings/email-design/design-fields/background-color-field.tsx b/apps/admin/src/settings/email-design/design-fields/background-color-field.tsx index 2887cfad3d7..7b6d08649b1 100644 --- a/apps/admin/src/settings/email-design/design-fields/background-color-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/background-color-field.tsx @@ -1,4 +1,4 @@ -import ColorPickerField from '@/settings/app/components/color-picker-field'; +import ColorPickerField from '@/settings/components/color-picker-field'; import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const BackgroundColorField = () => { diff --git a/apps/admin/src/settings/email-design/design-fields/button-color-field.tsx b/apps/admin/src/settings/email-design/design-fields/button-color-field.tsx index 2365a78e0b1..2999399b5d3 100644 --- a/apps/admin/src/settings/email-design/design-fields/button-color-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/button-color-field.tsx @@ -1,4 +1,4 @@ -import ColorPickerField from '@/settings/app/components/color-picker-field'; +import ColorPickerField from '@/settings/components/color-picker-field'; import {getAutoSwatchHex} from './color-swatch-helpers'; import {useEmailDesign} from '@/settings/email-design/email-design-context'; diff --git a/apps/admin/src/settings/email-design/design-fields/divider-color-field.tsx b/apps/admin/src/settings/email-design/design-fields/divider-color-field.tsx index db1fa246a29..78895f0b148 100644 --- a/apps/admin/src/settings/email-design/design-fields/divider-color-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/divider-color-field.tsx @@ -1,4 +1,4 @@ -import ColorPickerField from '@/settings/app/components/color-picker-field'; +import ColorPickerField from '@/settings/components/color-picker-field'; import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const DividerColorField = () => { diff --git a/apps/admin/src/settings/email-design/design-fields/header-background-field.tsx b/apps/admin/src/settings/email-design/design-fields/header-background-field.tsx index e4432882326..88702112329 100644 --- a/apps/admin/src/settings/email-design/design-fields/header-background-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/header-background-field.tsx @@ -1,4 +1,4 @@ -import ColorPickerField from '@/settings/app/components/color-picker-field'; +import ColorPickerField from '@/settings/components/color-picker-field'; import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const HeaderBackgroundField = () => { diff --git a/apps/admin/src/settings/email-design/design-fields/link-color-field.tsx b/apps/admin/src/settings/email-design/design-fields/link-color-field.tsx index 34639d46b37..8e6962dd116 100644 --- a/apps/admin/src/settings/email-design/design-fields/link-color-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/link-color-field.tsx @@ -1,4 +1,4 @@ -import ColorPickerField from '@/settings/app/components/color-picker-field'; +import ColorPickerField from '@/settings/components/color-picker-field'; import {getAutoSwatchHex} from './color-swatch-helpers'; import {useEmailDesign} from '@/settings/email-design/email-design-context'; diff --git a/apps/admin/src/settings/email-design/design-fields/section-title-color-field.tsx b/apps/admin/src/settings/email-design/design-fields/section-title-color-field.tsx index ac8999b1b98..410b61d53ef 100644 --- a/apps/admin/src/settings/email-design/design-fields/section-title-color-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/section-title-color-field.tsx @@ -1,4 +1,4 @@ -import ColorPickerField from '@/settings/app/components/color-picker-field'; +import ColorPickerField from '@/settings/components/color-picker-field'; import {getAutoSwatchHex} from './color-swatch-helpers'; import {useEmailDesign} from '@/settings/email-design/email-design-context'; diff --git a/apps/admin/src/settings/email-design/welcome-email-preview-content.tsx b/apps/admin/src/settings/email-design/welcome-email-preview-content.tsx index 4ad80c6f9d9..bd4f056eddd 100644 --- a/apps/admin/src/settings/email-design/welcome-email-preview-content.tsx +++ b/apps/admin/src/settings/email-design/welcome-email-preview-content.tsx @@ -1,4 +1,4 @@ -import CoverImage from '@/settings/app/assets/images/email-design-user-image.jpg'; +import CoverImage from '@/settings/assets/images/email-design-user-image.jpg'; import React from 'react'; import {cn} from '@tryghost/shade/utils'; import {resolveAllColors, resolveButtonCorners, resolveFontFamily, resolveImageCorners} from './design-utils'; diff --git a/apps/admin/src/settings/email/default-recipients.tsx b/apps/admin/src/settings/email/default-recipients.tsx index 7c516da91be..9f7764fa357 100644 --- a/apps/admin/src/settings/email/default-recipients.tsx +++ b/apps/admin/src/settings/email/default-recipients.tsx @@ -1,11 +1,11 @@ import React, {useEffect, useRef, useState} from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; import useDefaultRecipientsOptions, {type SegmentOption, type SegmentOptions} from './use-default-recipients-options'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {Combobox, ComboboxContent, ComboboxTrigger, ComboboxValue, Field, FieldDescription, FieldError, FieldLabel, MultiSelectCombobox, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@tryghost/shade/components'; import {SettingGroupContent} from '@tryghost/shade/patterns'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; type RefipientValueArgs = { defaultEmailRecipients: string; diff --git a/apps/admin/src/settings/email/email-settings.tsx b/apps/admin/src/settings/email/email-settings.tsx index f8e70fc3ffb..b8eb98ebb56 100644 --- a/apps/admin/src/settings/email/email-settings.tsx +++ b/apps/admin/src/settings/email/email-settings.tsx @@ -3,9 +3,9 @@ import EnableNewsletters from './enable-newsletters'; import MailGun from './mailgun'; import Newsletters from './newsletters'; import React from 'react'; -import SearchableSection from '@/settings/app/components/searchable-section'; +import SearchableSection from '@/settings/components/searchable-section'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {searchKeywords} from './search-keywords'; const EmailSettings: React.FC = () => { diff --git a/apps/admin/src/settings/email/emails.tsx b/apps/admin/src/settings/email/emails.tsx index 297ee8777b5..80445ce916c 100644 --- a/apps/admin/src/settings/email/emails.tsx +++ b/apps/admin/src/settings/email/emails.tsx @@ -3,18 +3,18 @@ import EnableNewsletters from './enable-newsletters'; import MailGun from './mailgun'; import NewslettersTabContent, {type NewslettersFilter} from './newsletters/newsletters-tab-content'; import React, {useEffect, useRef, useState} from 'react'; -import SearchableSection from '@/settings/app/components/searchable-section'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import SearchableSection from '@/settings/components/searchable-section'; +import TopLevelGroup from '@/settings/components/top-level-group'; import WelcomeEmailCustomizeModal from '@/settings/membership/member-emails/welcome-email-customize-modal'; -import useQueryParams from '@/settings/app/hooks/use-query-params'; +import useQueryParams from '@/settings/hooks/use-query-params'; import {APIError} from '@tryghost/admin-x-framework/errors'; import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade/components'; import {LucideIcon} from '@tryghost/shade/utils'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; import {useVerifyAutomatedEmailSender} from '@tryghost/admin-x-framework/api/automated-emails'; import {searchKeywords} from './emails-search-keywords'; diff --git a/apps/admin/src/settings/email/enable-newsletters.tsx b/apps/admin/src/settings/email/enable-newsletters.tsx index e0521dd39fd..78528e566e1 100644 --- a/apps/admin/src/settings/email/enable-newsletters.tsx +++ b/apps/admin/src/settings/email/enable-newsletters.tsx @@ -1,14 +1,14 @@ import React from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; import {Banner, Switch} from '@tryghost/shade/components'; import {Inline} from '@tryghost/shade/primitives'; import {LucideIcon} from '@tryghost/shade/utils'; import {type Setting, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {SettingGroupContent} from '@tryghost/shade/patterns'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const EnableNewsletters: React.FC<{ keywords: string[] }> = ({keywords}) => { const {settings} = useGlobalData(); diff --git a/apps/admin/src/settings/email/mailgun.tsx b/apps/admin/src/settings/email/mailgun.tsx index f525dc10cb2..436c02f5ef5 100644 --- a/apps/admin/src/settings/email/mailgun.tsx +++ b/apps/admin/src/settings/email/mailgun.tsx @@ -1,13 +1,13 @@ import React from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {Field, FieldDescription, FieldLabel, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@tryghost/shade/components'; import {Inline} from '@tryghost/shade/primitives'; import {LucideIcon} from '@tryghost/shade/utils'; import {SettingGroupContent, SettingGroupValue, SettingGroupValueContent, SettingGroupValueTitle} from '@tryghost/shade/patterns'; import {getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const MAILGUN_REGIONS = [ {label: '🇺🇸 US', value: 'https://api.mailgun.net/v3'}, diff --git a/apps/admin/src/settings/email/newsletters.tsx b/apps/admin/src/settings/email/newsletters.tsx index c365e52b811..9797816109d 100644 --- a/apps/admin/src/settings/email/newsletters.tsx +++ b/apps/admin/src/settings/email/newsletters.tsx @@ -1,17 +1,17 @@ import NewslettersList from './newsletters/newsletters-list'; import React, {type ReactNode, useEffect, useState} from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import useQueryParams from '@/settings/app/hooks/use-query-params'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import useQueryParams from '@/settings/hooks/use-query-params'; import {APIError} from '@tryghost/admin-x-framework/errors'; import {Button, Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade/components'; import {type InfiniteData, useQueryClient} from '@tryghost/admin-x-framework'; import {type Newsletter, type NewslettersResponseType, newslettersDataType, useBrowseNewsletters, useEditNewsletter, useVerifyNewsletterEmail} from '@tryghost/admin-x-framework/api/newsletters'; import {arrayMove} from '@dnd-kit/sortable'; import {formatNumber} from '@tryghost/shade/utils'; -import {type ConfirmationHandle, useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {type ConfirmationHandle, useConfirmation} from '@/settings/providers/confirmation-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const NavigateToNewsletter = ({id, onNavigate, children}: {id: string; onNavigate: () => void; children: ReactNode}) => { const {updateRoute} = useSettingsNavigation(); diff --git a/apps/admin/src/settings/email/newsletters/add-newsletter-modal.tsx b/apps/admin/src/settings/email/newsletters/add-newsletter-modal.tsx index 9c80fde17d6..2d4cd86f936 100644 --- a/apps/admin/src/settings/email/newsletters/add-newsletter-modal.tsx +++ b/apps/admin/src/settings/email/newsletters/add-newsletter-modal.tsx @@ -1,10 +1,10 @@ import React, {useEffect, useState} from 'react'; -import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; +import useFeatureFlag from '@/settings/hooks/use-feature-flag'; import {Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel, Input, Switch, Textarea} from '@tryghost/shade/components'; -import {HostLimitError, useLimiter} from '@/settings/app/hooks/use-limiter'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {useUpgradeRoute} from '@/settings/app/hooks/use-upgrade-route'; +import {HostLimitError, useLimiter} from '@/settings/hooks/use-limiter'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {useUpgradeRoute} from '@/settings/hooks/use-upgrade-route'; import {SettingsModal} from '@tryghost/shade/patterns'; import {formatNumber} from '@tryghost/shade/utils'; import {useAddNewsletter} from '@tryghost/admin-x-framework/api/newsletters'; diff --git a/apps/admin/src/settings/email/newsletters/newsletter-detail-modal.tsx b/apps/admin/src/settings/email/newsletters/newsletter-detail-modal.tsx index 0c49b37685a..940fe658607 100644 --- a/apps/admin/src/settings/email/newsletters/newsletter-detail-modal.tsx +++ b/apps/admin/src/settings/email/newsletters/newsletter-detail-modal.tsx @@ -1,28 +1,28 @@ -import ColorPickerField from '@/settings/app/components/color-picker-field'; +import ColorPickerField from '@/settings/components/color-picker-field'; import HeaderImageField from '@/settings/email-design/header-image-field'; -import HtmlField from '@/settings/app/components/html-field'; +import HtmlField from '@/settings/components/html-field'; import NewsletterPreview from './newsletter-preview'; import React, {useCallback, useEffect, useState} from 'react'; -import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import useFeatureFlag from '@/settings/hooks/use-feature-flag'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import validator from 'validator'; import {Button, Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSet, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Separator, Switch, Tabs, TabsContent, TabsList, TabsTrigger, Textarea, ToggleGroup, ToggleGroupItem, Tooltip, TooltipContent, TooltipTrigger} from '@tryghost/shade/components'; import {type ErrorMessages, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {HostLimitError, useLimiter} from '@/settings/app/hooks/use-limiter'; +import {HostLimitError, useLimiter} from '@/settings/hooks/use-limiter'; import {Inline, Stack} from '@tryghost/shade/primitives'; import {LucideIcon} from '@tryghost/shade/utils'; import {type Newsletter, useBrowseNewsletters, useEditNewsletter} from '@tryghost/admin-x-framework/api/newsletters'; -import {PreviewModalContent} from '@/settings/app/components/settings/preview-modal'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {PreviewModalContent} from '@/settings/components/preview-modal'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {useParams} from '@tryghost/admin-x-framework'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {useUpgradeRoute} from '@/settings/app/hooks/use-upgrade-route'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {useUpgradeRoute} from '@/settings/hooks/use-upgrade-route'; import {getSettingValue, getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {hasSendingDomain, isManagedEmail, sendingDomain} from '@tryghost/admin-x-framework/api/config'; -import {renderReplyToEmail, renderSenderEmail} from '@/settings/app/utils/newsletter-emails'; +import {renderReplyToEmail, renderSenderEmail} from '@/settings/utils/newsletter-emails'; import {textColorForBackgroundColor} from '@tryghost/color-utils'; import {toast} from 'sonner'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; interface IconToggleOption { value: string; diff --git a/apps/admin/src/settings/email/newsletters/newsletter-preview-content.tsx b/apps/admin/src/settings/email/newsletters/newsletter-preview-content.tsx index bc0d7fabe16..127653f4d05 100644 --- a/apps/admin/src/settings/email/newsletters/newsletter-preview-content.tsx +++ b/apps/admin/src/settings/email/newsletters/newsletter-preview-content.tsx @@ -1,12 +1,12 @@ -import CoverImage from '@/settings/app/assets/images/user-cover.jpg'; -import LatestPosts1 from '@/settings/app/assets/images/latest-posts-1.jpg'; -import LatestPosts2 from '@/settings/app/assets/images/latest-posts-2.jpg'; -import LatestPosts3 from '@/settings/app/assets/images/latest-posts-3.jpg'; +import CoverImage from '@/settings/assets/images/user-cover.jpg'; +import LatestPosts1 from '@/settings/assets/images/latest-posts-1.jpg'; +import LatestPosts2 from '@/settings/assets/images/latest-posts-2.jpg'; +import LatestPosts3 from '@/settings/assets/images/latest-posts-3.jpg'; import clsx from 'clsx'; import {GhostOrb} from '@tryghost/shade/components'; import {LucideIcon} from '@tryghost/shade/utils'; import {isManagedEmail} from '@tryghost/admin-x-framework/api/config'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; const NewsletterPreviewContent: React.FC<{ senderName?: string; diff --git a/apps/admin/src/settings/email/newsletters/newsletter-preview.tsx b/apps/admin/src/settings/email/newsletters/newsletter-preview.tsx index 4eeaf6c292f..c22e4448c6b 100644 --- a/apps/admin/src/settings/email/newsletters/newsletter-preview.tsx +++ b/apps/admin/src/settings/email/newsletters/newsletter-preview.tsx @@ -2,9 +2,9 @@ import NewsletterPreviewContent from './newsletter-preview-content'; import React from 'react'; import {type Newsletter} from '@tryghost/admin-x-framework/api/newsletters'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {renderReplyToEmail, renderSenderEmail} from '@/settings/app/utils/newsletter-emails'; +import {renderReplyToEmail, renderSenderEmail} from '@/settings/utils/newsletter-emails'; import {textColorForBackgroundColor} from '@tryghost/color-utils'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; const NewsletterPreview: React.FC<{newsletter: Newsletter}> = ({newsletter}) => { const {currentUser, settings, siteData, config} = useGlobalData(); diff --git a/apps/admin/src/settings/email/newsletters/newsletters-list.tsx b/apps/admin/src/settings/email/newsletters/newsletters-list.tsx index f56972e6cf6..0d742fb2404 100644 --- a/apps/admin/src/settings/email/newsletters/newsletters-list.tsx +++ b/apps/admin/src/settings/email/newsletters/newsletters-list.tsx @@ -3,7 +3,7 @@ import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent import {Inline} from '@tryghost/shade/primitives'; import {LucideIcon, formatNumber} from '@tryghost/shade/utils'; import {type Newsletter} from '@tryghost/admin-x-framework/api/newsletters'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; interface NewslettersListProps { newsletters: Newsletter[]; diff --git a/apps/admin/src/settings/email/newsletters/newsletters-tab-content.tsx b/apps/admin/src/settings/email/newsletters/newsletters-tab-content.tsx index f40298be456..7a6124c17f8 100644 --- a/apps/admin/src/settings/email/newsletters/newsletters-tab-content.tsx +++ b/apps/admin/src/settings/email/newsletters/newsletters-tab-content.tsx @@ -1,16 +1,16 @@ import NewslettersList from './newsletters-list'; import React, {type ReactNode, useEffect, useState} from 'react'; -import useQueryParams from '@/settings/app/hooks/use-query-params'; +import useQueryParams from '@/settings/hooks/use-query-params'; import {APIError} from '@tryghost/admin-x-framework/errors'; import {Button} from '@tryghost/shade/components'; import {type InfiniteData, useQueryClient} from '@tryghost/admin-x-framework'; import {type Newsletter, type NewslettersResponseType, newslettersDataType, useBrowseNewsletters, useEditNewsletter, useVerifyNewsletterEmail} from '@tryghost/admin-x-framework/api/newsletters'; import {arrayMove} from '@dnd-kit/sortable'; import {formatNumber} from '@tryghost/shade/utils'; -import {type ConfirmationHandle, useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {type ConfirmationHandle, useConfirmation} from '@/settings/providers/confirmation-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const NavigateToNewsletter = ({id, onNavigate, children}: {id: string; onNavigate: () => void; children: ReactNode}) => { const {updateRoute} = useSettingsNavigation(); diff --git a/apps/admin/src/settings/email/use-default-recipients-options.tsx b/apps/admin/src/settings/email/use-default-recipients-options.tsx index 4f7a8c69b10..ee278f434f2 100644 --- a/apps/admin/src/settings/email/use-default-recipients-options.tsx +++ b/apps/admin/src/settings/email/use-default-recipients-options.tsx @@ -1,7 +1,7 @@ import {type Label} from '@tryghost/admin-x-framework/api/labels'; import {type Offer} from '@tryghost/admin-x-framework/api/offers'; import {type Tier} from '@tryghost/admin-x-framework/api/tiers'; -import {isObjectId} from '@/settings/app/utils/helpers'; +import {isObjectId} from '@/settings/utils/helpers'; import {useEffect, useRef, useState} from 'react'; import {useFilterableApi} from '@tryghost/admin-x-framework/hooks'; diff --git a/apps/admin/src/settings/general/about.tsx b/apps/admin/src/settings/general/about.tsx index 7c008baf0df..02efb333940 100644 --- a/apps/admin/src/settings/general/about.tsx +++ b/apps/admin/src/settings/general/about.tsx @@ -1,11 +1,11 @@ import {GhostLogo, Separator} from '@tryghost/shade/components'; import {LucideIcon} from '@tryghost/shade/utils'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; import {SettingsModal} from '@tryghost/shade/patterns'; -import {linkToGitHubReleases} from '@/settings/app/utils/link-to-github-releases'; -import {showDatabaseWarning} from '@/settings/app/utils/show-database-warning'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -import {useUpgradeStatus} from '@/settings/app/components/providers/settings-app-provider'; +import {linkToGitHubReleases} from '@/settings/utils/link-to-github-releases'; +import {showDatabaseWarning} from '@/settings/utils/show-database-warning'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {useUpgradeStatus} from '@/settings/providers/settings-app-context'; const adminBuildVersion = import.meta.env.GHOST_BUILD_VERSION; diff --git a/apps/admin/src/settings/general/general-settings.tsx b/apps/admin/src/settings/general/general-settings.tsx index 99ee0eb765b..a6fe7a7a1b9 100644 --- a/apps/admin/src/settings/general/general-settings.tsx +++ b/apps/admin/src/settings/general/general-settings.tsx @@ -3,7 +3,7 @@ import React from 'react'; import Analytics from '@/settings/membership/analytics'; import PublicationLanguage from './publication-language'; import SEOMeta from './seo-meta'; -import SearchableSection from '@/settings/app/components/searchable-section'; +import SearchableSection from '@/settings/components/searchable-section'; import SocialAccounts from './social-accounts'; import TimeZone from './time-zone'; import TitleAndDescription from './title-and-description'; diff --git a/apps/admin/src/settings/general/invite-user-modal.tsx b/apps/admin/src/settings/general/invite-user-modal.tsx index f8959d7e0cf..d6d88eea967 100644 --- a/apps/admin/src/settings/general/invite-user-modal.tsx +++ b/apps/admin/src/settings/general/invite-user-modal.tsx @@ -1,7 +1,7 @@ import validator from 'validator'; import {APIError, ValidationError} from '@tryghost/admin-x-framework/errors'; import {Field, FieldContent, FieldDescription, FieldError, FieldLabel, FieldLegend, FieldSet, Input, RadioGroup, RadioGroupItem} from '@tryghost/shade/components'; -import {HostLimitError, useLimiter} from '@/settings/app/hooks/use-limiter'; +import {HostLimitError, useLimiter} from '@/settings/hooks/use-limiter'; import {SettingsModal} from '@tryghost/shade/patterns'; import {Stack} from '@tryghost/shade/primitives'; import {toast} from 'sonner'; @@ -9,9 +9,9 @@ import {useAddInvite, useBrowseInvites} from '@tryghost/admin-x-framework/api/in import {useBrowseRoles} from '@tryghost/admin-x-framework/api/roles'; import {useBrowseUsers} from '@tryghost/admin-x-framework/api/users'; import {useEffect, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; type RoleType = 'administrator' | 'editor' | 'author' | 'contributor' | 'super editor'; diff --git a/apps/admin/src/settings/general/publication-language.tsx b/apps/admin/src/settings/general/publication-language.tsx index 3b2a090b12a..fd1a21471e1 100644 --- a/apps/admin/src/settings/general/publication-language.tsx +++ b/apps/admin/src/settings/general/publication-language.tsx @@ -1,12 +1,12 @@ import LOCALE_DATA from '@tryghost/i18n/lib/locale-data.json'; import React from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {Button, Combobox, ComboboxContent, ComboboxTrigger, ComboboxValue, Field, FieldDescription, FieldError, FieldLabel, Input, MultiSelectCombobox} from '@tryghost/shade/components'; import {SettingGroupContent} from '@tryghost/shade/patterns'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {validateLocale} from '@/settings/app/utils/locale-validation'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {validateLocale} from '@/settings/utils/locale-validation'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const PublicationLanguage: React.FC<{ keywords: string[] }> = ({keywords}) => { const languageErrorId = React.useId(); diff --git a/apps/admin/src/settings/general/seo-meta.tsx b/apps/admin/src/settings/general/seo-meta.tsx index 874b092c456..93e803fb712 100644 --- a/apps/admin/src/settings/general/seo-meta.tsx +++ b/apps/admin/src/settings/general/seo-meta.tsx @@ -1,7 +1,7 @@ import React, {useState} from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import usePinturaEditor from '@/settings/app/hooks/use-pintura-editor'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import usePinturaEditor from '@/settings/hooks/use-pintura-editor'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {APIError} from '@tryghost/admin-x-framework/errors'; import {FacebookLogo, Field, FieldDescription, FieldLabel, GoogleLogo, Input, Switch, Tabs, TabsContent, TabsList, TabsTrigger, XLogo} from '@tryghost/shade/components'; import {ImageUpload, ImageUploadAction, ImageUploadActions, ImageUploadDropzone, ImageUploadImage, ImageUploadPreview} from '@tryghost/shade/patterns'; @@ -11,7 +11,7 @@ import {SettingGroupContent} from '@tryghost/shade/patterns'; import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; interface SearchEnginePreviewProps { title: string; diff --git a/apps/admin/src/settings/general/social-accounts.tsx b/apps/admin/src/settings/general/social-accounts.tsx index 81f08830912..f68cc7fc5d2 100644 --- a/apps/admin/src/settings/general/social-accounts.tsx +++ b/apps/admin/src/settings/general/social-accounts.tsx @@ -1,13 +1,13 @@ import React, {useEffect, useMemo, useState} from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {Field, FieldError, FieldLabel, Input} from '@tryghost/shade/components'; -import {SOCIAL_PLATFORM_CONFIGS, SOCIAL_PLATFORM_KEYS, getSocialValidationError, normalizeSocialInput} from '@/settings/app/utils/social-urls'; +import {SOCIAL_PLATFORM_CONFIGS, SOCIAL_PLATFORM_KEYS, getSocialValidationError, normalizeSocialInput} from '@/settings/utils/social-urls'; import {SettingGroupContent} from '@tryghost/shade/patterns'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; import type {Setting} from '@tryghost/admin-x-framework/api/settings'; -import type {SocialPlatformKey} from '@/settings/app/utils/social-urls'; +import type {SocialPlatformKey} from '@/settings/utils/social-urls'; const LEGACY_PLATFORM_KEYS: SocialPlatformKey[] = ['facebook', 'twitter']; const NEW_PLATFORM_KEYS: SocialPlatformKey[] = SOCIAL_PLATFORM_KEYS.filter( diff --git a/apps/admin/src/settings/general/time-zone.tsx b/apps/admin/src/settings/general/time-zone.tsx index 5f0b87c49a2..7128a174966 100644 --- a/apps/admin/src/settings/general/time-zone.tsx +++ b/apps/admin/src/settings/general/time-zone.tsx @@ -1,12 +1,12 @@ import React, {useEffect, useState} from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {Combobox, ComboboxContent, ComboboxTrigger, ComboboxValue, Field, FieldDescription, FieldLabel, MultiSelectCombobox} from '@tryghost/shade/components'; import {SettingGroupContent} from '@tryghost/shade/patterns'; -import {getLocalTime} from '@/settings/app/utils/helpers'; +import {getLocalTime} from '@/settings/utils/helpers'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {timezoneDataWithGMTOffset} from '@tryghost/timezone-data'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; interface TimezoneDataDropdownOption { name: string; diff --git a/apps/admin/src/settings/general/title-and-description.tsx b/apps/admin/src/settings/general/title-and-description.tsx index 67d924f4bd5..c92da9543df 100644 --- a/apps/admin/src/settings/general/title-and-description.tsx +++ b/apps/admin/src/settings/general/title-and-description.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {Field, FieldDescription, FieldError, FieldLabel, Input} from '@tryghost/shade/components'; import {SettingGroupContent, SettingGroupValue, SettingGroupValueContent, SettingGroupValueTitle} from '@tryghost/shade/patterns'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const TitleAndDescription: React.FC<{ keywords: string[] }> = ({keywords}) => { const { diff --git a/apps/admin/src/settings/general/user-detail-modal.tsx b/apps/admin/src/settings/general/user-detail-modal.tsx index ae099d66a7b..29099bca424 100644 --- a/apps/admin/src/settings/general/user-detail-modal.tsx +++ b/apps/admin/src/settings/general/user-detail-modal.tsx @@ -3,27 +3,27 @@ import ProfileTab from './users/profile-tab'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import SocialLinksTab from './users/social-links-tab'; import clsx from 'clsx'; -import usePinturaEditor from '@/settings/app/hooks/use-pintura-editor'; -import useStaffUsers from '@/settings/app/hooks/use-staff-users'; +import usePinturaEditor from '@/settings/hooks/use-pintura-editor'; +import useStaffUsers from '@/settings/hooks/use-staff-users'; import validator from 'validator'; import {APIError} from '@tryghost/admin-x-framework/errors'; import {Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Dropzone, Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade/components'; import {type ErrorMessages, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {HostLimitError, useLimiter} from '@/settings/app/hooks/use-limiter'; +import {HostLimitError, useLimiter} from '@/settings/hooks/use-limiter'; import {ImageUpload, ImageUploadAction, ImageUploadActions, ImageUploadDropzone, ImageUploadImage, ImageUploadPreview} from '@tryghost/shade/patterns'; import {LucideIcon} from '@tryghost/shade/utils'; import {Pencil, Trash2} from 'lucide-react'; import {useLocation, useParams} from '@tryghost/admin-x-framework'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {useUpgradeRoute} from '@/settings/app/hooks/use-upgrade-route'; -import {SOCIAL_PLATFORM_CONFIGS, SOCIAL_PLATFORM_KEYS, getSocialValidationError} from '@/settings/app/utils/social-urls/index'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {useUpgradeRoute} from '@/settings/hooks/use-upgrade-route'; +import {SOCIAL_PLATFORM_CONFIGS, SOCIAL_PLATFORM_KEYS, getSocialValidationError} from '@/settings/utils/social-urls/index'; import {SettingsModal} from '@tryghost/shade/patterns'; import {Text} from '@tryghost/shade/primitives'; import {type User, canAccessSettings, hasAdminAccess, isAdminUser, isAuthorOrContributor, isEditorUser, isOwnerUser, useDeleteUser, useEditUser, useGetUserBySlug, useMakeOwner} from '@tryghost/admin-x-framework/api/users'; import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images'; import {toast} from 'sonner'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; +import {useGlobalData} from '@/settings/providers/global-data-context'; const validators: Record) => string> = { name: ({name}) => { diff --git a/apps/admin/src/settings/general/users.tsx b/apps/admin/src/settings/general/users.tsx index 3e367dae8f3..dc4b3731a53 100644 --- a/apps/admin/src/settings/general/users.tsx +++ b/apps/admin/src/settings/general/users.tsx @@ -1,8 +1,8 @@ import React, {useEffect, useState} from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; import clsx from 'clsx'; -import useQueryParams from '@/settings/app/hooks/use-query-params'; -import useStaffUsers from '@/settings/app/hooks/use-staff-users'; +import useQueryParams from '@/settings/hooks/use-query-params'; +import useStaffUsers from '@/settings/hooks/use-staff-users'; import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent, Avatar, NoValueLabel, NoValueLabelIcon, Separator, Switch, Tabs, TabsContent, TabsList, TabsTrigger, TabsTriggerCount} from '@tryghost/shade/components'; import {Button} from '@tryghost/shade/components'; import {type User, hasAdminAccess, isContributorUser, isEditorUser} from '@tryghost/admin-x-framework/api/users'; @@ -11,10 +11,10 @@ import {UserRoundX} from 'lucide-react'; import {formatNumber} from '@tryghost/shade/utils'; import {getSettingValue, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {toast} from 'sonner'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; interface OwnerProps { user: User; @@ -304,12 +304,12 @@ const Users: React.FC<{ keywords: string[], highlight?: boolean }> = ({keywords, {hasNextPage && selectedTab !== 'invited' && } {invitesHasNextPage && selectedTab === 'invited' && } {config?.security?.staffDeviceVerification && hasAdminAccess(currentUser) && ( diff --git a/apps/admin/src/settings/general/users/change-password-form.tsx b/apps/admin/src/settings/general/users/change-password-form.tsx index ee3d6441485..02921b7c927 100644 --- a/apps/admin/src/settings/general/users/change-password-form.tsx +++ b/apps/admin/src/settings/general/users/change-password-form.tsx @@ -3,7 +3,7 @@ import {type User, useUpdatePassword} from '@tryghost/admin-x-framework/api/user import {ValidationError} from '@tryghost/admin-x-framework/errors'; import {toast} from 'sonner'; import {useEffect, useRef, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; const BAD_PASSWORDS = [ diff --git a/apps/admin/src/settings/general/users/email-notifications-tab.tsx b/apps/admin/src/settings/general/users/email-notifications-tab.tsx index d30b44585f1..ba56370a9b7 100644 --- a/apps/admin/src/settings/general/users/email-notifications-tab.tsx +++ b/apps/admin/src/settings/general/users/email-notifications-tab.tsx @@ -2,7 +2,7 @@ import {Field, FieldContent, FieldDescription, FieldLabel, Switch} from '@trygho import {SettingGroup, SettingGroupContent} from '@tryghost/shade/patterns'; import {type User, hasAdminAccess} from '@tryghost/admin-x-framework/api/users'; import {checkStripeEnabled} from '@tryghost/admin-x-framework/api/settings'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; const EmailNotificationsInputs: React.FC<{ user: User; setUserData: (user: User) => void; }> = ({user, setUserData}) => { const {config, settings} = useGlobalData(); diff --git a/apps/admin/src/settings/general/users/profile-tab.tsx b/apps/admin/src/settings/general/users/profile-tab.tsx index 2368ba9b947..ee5e458047b 100644 --- a/apps/admin/src/settings/general/users/profile-tab.tsx +++ b/apps/admin/src/settings/general/users/profile-tab.tsx @@ -7,7 +7,7 @@ import {type UserDetailProps} from '@/settings/general/user-detail-modal'; import {formatNumber} from '@tryghost/shade/utils'; import {getHomepageUrl} from '@tryghost/admin-x-framework/api/site'; import {hasAdminAccess} from '@tryghost/admin-x-framework/api/users'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; const BasicInputs: React.FC = ({errors, clearError, user, setUserData}) => { const {currentUser, siteData} = useGlobalData(); diff --git a/apps/admin/src/settings/general/users/role-selector.tsx b/apps/admin/src/settings/general/users/role-selector.tsx index 0023eebc2ca..f850100cc92 100644 --- a/apps/admin/src/settings/general/users/role-selector.tsx +++ b/apps/admin/src/settings/general/users/role-selector.tsx @@ -1,7 +1,7 @@ import {Field, FieldDescription, FieldLabel, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@tryghost/shade/components'; import {type User, isOwnerUser} from '@tryghost/admin-x-framework/api/users'; import {useBrowseRoles} from '@tryghost/admin-x-framework/api/roles'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; const RoleSelector: React.FC<{ user: User; setUserData: (user: User) => void; }> = ({user, setUserData}) => { const {data: {roles} = {}} = useBrowseRoles(); diff --git a/apps/admin/src/settings/general/users/social-links-tab.tsx b/apps/admin/src/settings/general/users/social-links-tab.tsx index 92cc66dc009..77818fae657 100644 --- a/apps/admin/src/settings/general/users/social-links-tab.tsx +++ b/apps/admin/src/settings/general/users/social-links-tab.tsx @@ -1,9 +1,9 @@ import {Field, FieldError, FieldLabel, Input} from '@tryghost/shade/components'; -import {SOCIAL_PLATFORM_CONFIGS, normalizeSocialInput} from '@/settings/app/utils/social-urls/index'; +import {SOCIAL_PLATFORM_CONFIGS, normalizeSocialInput} from '@/settings/utils/social-urls/index'; import {SettingGroup, SettingGroupContent} from '@tryghost/shade/patterns'; import {type UserDetailProps} from '@/settings/general/user-detail-modal'; import {useState} from 'react'; -import type {SocialPlatformKey} from '@/settings/app/utils/social-urls/index'; +import type {SocialPlatformKey} from '@/settings/utils/social-urls/index'; export const DetailsInputs: React.FC = ({errors, clearError, validateField, user, setUserData}) => { const [urls, setUrls] = useState>(() => { diff --git a/apps/admin/src/settings/general/users/staff-token.tsx b/apps/admin/src/settings/general/users/staff-token.tsx index 05d53d52eb9..2976b31fc01 100644 --- a/apps/admin/src/settings/general/users/staff-token.tsx +++ b/apps/admin/src/settings/general/users/staff-token.tsx @@ -1,7 +1,7 @@ import APIKeys from '@/settings/app/components/settings/advanced/integrations/api-keys'; import {Text} from '@tryghost/shade/primitives'; import {genStaffToken, getStaffToken} from '@tryghost/admin-x-framework/api/staff-token'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {useEffect, useState} from 'react'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; diff --git a/apps/admin/src/settings/growth/embed-signup/embed-signup-form-modal.tsx b/apps/admin/src/settings/growth/embed-signup/embed-signup-form-modal.tsx index 8d7ae6805bc..58f180c10ce 100644 --- a/apps/admin/src/settings/growth/embed-signup/embed-signup-form-modal.tsx +++ b/apps/admin/src/settings/growth/embed-signup/embed-signup-form-modal.tsx @@ -1,12 +1,12 @@ import EmbedSignupPreview from './embed-signup-preview'; import EmbedSignupSidebar, {type SelectedLabelTypes} from './embed-signup-sidebar'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; -import {type EmbedSignupLayout, generateCode} from '@/settings/app/utils/generate-embed-code'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; +import {type EmbedSignupLayout, generateCode} from '@/settings/utils/generate-embed-code'; import {SettingsModal} from '@tryghost/shade/patterns'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {useEffect, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; const EmbedSignupFormModal = () => { const [selectedColor, setSelectedColor] = useState('#08090c'); diff --git a/apps/admin/src/settings/growth/embed-signup/embed-signup-form.tsx b/apps/admin/src/settings/growth/embed-signup/embed-signup-form.tsx index 17881387ac7..0a06b92d53e 100644 --- a/apps/admin/src/settings/growth/embed-signup/embed-signup-form.tsx +++ b/apps/admin/src/settings/growth/embed-signup/embed-signup-form.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; import {Button} from '@tryghost/shade/components'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; const EmbedSignupForm: React.FC<{ keywords: string[] }> = ({keywords}) => { const {updateRoute} = useSettingsNavigation(); diff --git a/apps/admin/src/settings/growth/embed-signup/embed-signup-preview.tsx b/apps/admin/src/settings/growth/embed-signup/embed-signup-preview.tsx index b006c0a89a8..86dfe2e37a1 100644 --- a/apps/admin/src/settings/growth/embed-signup/embed-signup-preview.tsx +++ b/apps/admin/src/settings/growth/embed-signup/embed-signup-preview.tsx @@ -1,6 +1,6 @@ -import IframeBuffering from '@/settings/app/utils/iframe-buffering'; +import IframeBuffering from '@/settings/utils/iframe-buffering'; import React, {useCallback, useRef} from 'react'; -import {type EmbedSignupLayout, getEmbedPreviewLayoutMarker} from '@/settings/app/utils/generate-embed-code'; +import {type EmbedSignupLayout, getEmbedPreviewLayoutMarker} from '@/settings/utils/generate-embed-code'; type EmbedSignupPreviewProps = { backgroundColor: string; diff --git a/apps/admin/src/settings/growth/embed-signup/embed-signup-sidebar.tsx b/apps/admin/src/settings/growth/embed-signup/embed-signup-sidebar.tsx index 2e90a96f754..89b6ccf3bfa 100644 --- a/apps/admin/src/settings/growth/embed-signup/embed-signup-sidebar.tsx +++ b/apps/admin/src/settings/growth/embed-signup/embed-signup-sidebar.tsx @@ -1,7 +1,7 @@ -import ColorPickerField from '@/settings/app/components/color-picker-field'; +import ColorPickerField from '@/settings/components/color-picker-field'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {Button, Combobox, ComboboxContent, ComboboxTrigger, ComboboxValue, Field, FieldDescription, FieldGroup, FieldLabel, MultiSelectCombobox, Textarea, ToggleGroup, ToggleGroupItem} from '@tryghost/shade/components'; -import {type EmbedSignupLayout} from '@/settings/app/utils/generate-embed-code'; +import {type EmbedSignupLayout} from '@/settings/utils/generate-embed-code'; import {Inline, Stack, Text} from '@tryghost/shade/primitives'; import {type Label} from '@tryghost/admin-x-framework/api/labels'; import {Plus} from 'lucide-react'; diff --git a/apps/admin/src/settings/growth/explore.tsx b/apps/admin/src/settings/growth/explore.tsx index 687448e8028..6c237d384c2 100644 --- a/apps/admin/src/settings/growth/explore.tsx +++ b/apps/admin/src/settings/growth/explore.tsx @@ -1,18 +1,18 @@ -import FakeLogo from '@/settings/app/assets/images/explore-default-logo.png'; +import FakeLogo from '@/settings/assets/images/explore-default-logo.png'; import React, {useEffect, useState} from 'react'; -import SettingImg from '@/settings/app/assets/images/ghost-explore.png'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import SettingImg from '@/settings/assets/images/ghost-explore.png'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {Button} from '@tryghost/shade/components'; import {Field, FieldContent, FieldDescription, FieldLabel, Separator, Switch} from '@tryghost/shade/components'; import {LucideIcon, abbreviateNumber} from '@tryghost/shade/utils'; import {type Setting, getSettingValue, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {SettingGroupContent} from '@tryghost/shade/patterns'; import {useBrowseMembers} from '@tryghost/admin-x-framework/api/members'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const Explore: React.FC<{ keywords: string[] }> = ({keywords}) => { const {settings} = useGlobalData(); diff --git a/apps/admin/src/settings/growth/explore/testimonials-modal.tsx b/apps/admin/src/settings/growth/explore/testimonials-modal.tsx index 59cee4d02ae..fb989786cc4 100644 --- a/apps/admin/src/settings/growth/explore/testimonials-modal.tsx +++ b/apps/admin/src/settings/growth/explore/testimonials-modal.tsx @@ -1,6 +1,6 @@ -import AliAbdaal from '@/settings/app/assets/images/ali-abdaal.png'; -import IsaacSaul from '@/settings/app/assets/images/isaac-saul.png'; -import JoelWarner from '@/settings/app/assets/images/joel-warner.png'; +import AliAbdaal from '@/settings/assets/images/ali-abdaal.png'; +import IsaacSaul from '@/settings/assets/images/isaac-saul.png'; +import JoelWarner from '@/settings/assets/images/joel-warner.png'; import React from 'react'; import {Avatar, Field, FieldError, FieldGroup, FieldLabel, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Textarea} from '@tryghost/shade/components'; import {Button, LoadingIndicator} from '@tryghost/shade/components'; @@ -8,8 +8,8 @@ import {SettingsModal} from '@tryghost/shade/patterns'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {toast} from 'sonner'; import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; interface FormState { content: string; diff --git a/apps/admin/src/settings/growth/growth-settings.tsx b/apps/admin/src/settings/growth/growth-settings.tsx index 2b68f8ecfbf..72fb523a346 100644 --- a/apps/admin/src/settings/growth/growth-settings.tsx +++ b/apps/admin/src/settings/growth/growth-settings.tsx @@ -4,10 +4,10 @@ import Network from './network'; import Offers from './offers'; import React from 'react'; import Recommendations from './recommendations'; -import SearchableSection from '@/settings/app/components/searchable-section'; +import SearchableSection from '@/settings/components/searchable-section'; import {checkStripeEnabled} from '@tryghost/admin-x-framework/api/settings'; import {searchKeywords} from './search-keywords'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; const GrowthSettings: React.FC = () => { const {config, settings} = useGlobalData(); diff --git a/apps/admin/src/settings/growth/network.tsx b/apps/admin/src/settings/growth/network.tsx index bfe77585731..86ade83c2f3 100644 --- a/apps/admin/src/settings/growth/network.tsx +++ b/apps/admin/src/settings/growth/network.tsx @@ -1,17 +1,17 @@ import React from 'react'; -import SettingImg from '@/settings/app/assets/images/network.png'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import SettingImg from '@/settings/assets/images/network.png'; +import TopLevelGroup from '@/settings/components/top-level-group'; import validator from 'validator'; import {LucideIcon} from '@tryghost/shade/utils'; import {type Setting, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {SettingGroupContent} from '@tryghost/shade/patterns'; import {Switch} from '@tryghost/shade/components'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useLimiter} from '@/settings/app/hooks/use-limiter'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useLimiter} from '@/settings/hooks/use-limiter'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const Network: React.FC<{ keywords: string[] }> = ({keywords}) => { const {settings} = useGlobalData(); diff --git a/apps/admin/src/settings/growth/offers.tsx b/apps/admin/src/settings/growth/offers.tsx index 82d3b5ed8b1..d7dd75e3081 100644 --- a/apps/admin/src/settings/growth/offers.tsx +++ b/apps/admin/src/settings/growth/offers.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; import {Button} from '@tryghost/shade/components'; import {checkStripeEnabled} from '@tryghost/admin-x-framework/api/settings'; import {getPaidActiveTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; import {useBrowseOffers} from '@tryghost/admin-x-framework/api/offers'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const Offers: React.FC<{ keywords: string[] }> = ({keywords}) => { const {updateRoute} = useSettingsNavigation(); diff --git a/apps/admin/src/settings/growth/offers/add-offer-modal.tsx b/apps/admin/src/settings/growth/offers/add-offer-modal.tsx index 73dd0f299d7..36254a5016a 100644 --- a/apps/admin/src/settings/growth/offers/add-offer-modal.tsx +++ b/apps/admin/src/settings/growth/offers/add-offer-modal.tsx @@ -2,18 +2,18 @@ import PortalFrame from '@/settings/membership/portal/portal-frame'; import {Button, Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel, Input, InputGroup, InputGroupAddon, InputGroupInput, InputGroupText, RadioGroup, RadioGroupItem, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Textarea} from '@tryghost/shade/components'; import {type ErrorMessages, useForm} from '@tryghost/admin-x-framework/hooks'; import {JSONError} from '@tryghost/admin-x-framework/errors'; -import {PreviewModalContent} from '@/settings/app/components/settings/preview-modal'; +import {PreviewModalContent} from '@/settings/components/preview-modal'; import {formatNumber} from '@tryghost/shade/utils'; import {getHomepageUrl} from '@tryghost/admin-x-framework/api/site'; -import {getOfferPortalPreviewUrl, type offerPortalPreviewUrlTypes} from '@/settings/app/utils/get-offers-portal-preview-url'; +import {getOfferPortalPreviewUrl, type offerPortalPreviewUrlTypes} from '@/settings/utils/get-offers-portal-preview-url'; import {getPaidActiveTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; -import {getTiersCadences} from '@/settings/app/utils/get-tiers-cadences'; +import {getTiersCadences} from '@/settings/utils/get-tiers-cadences'; import {toast} from 'sonner'; import {useAddOffer} from '@tryghost/admin-x-framework/api/offers'; import {useBrowseOffers} from '@tryghost/admin-x-framework/api/offers'; import {useEffect, useMemo, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; // we should replace this with a library function slugify(text: string): string { diff --git a/apps/admin/src/settings/growth/offers/edit-offer-modal.tsx b/apps/admin/src/settings/growth/offers/edit-offer-modal.tsx index 0ba5e7c992a..c29307ea3a6 100644 --- a/apps/admin/src/settings/growth/offers/edit-offer-modal.tsx +++ b/apps/admin/src/settings/growth/offers/edit-offer-modal.tsx @@ -1,19 +1,19 @@ import PortalFrame from '@/settings/membership/portal/portal-frame'; -import SettingsBreadcrumbs from '@/settings/app/components/settings/settings-breadcrumbs'; +import SettingsBreadcrumbs from '@/settings/components/settings-breadcrumbs'; import {Button, Field, FieldDescription, FieldError, FieldGroup, FieldLabel, Input, InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, Textarea} from '@tryghost/shade/components'; import {type ErrorMessages, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; import {JSONError} from '@tryghost/admin-x-framework/errors'; import {type Offer, useBrowseOffersById, useEditOffer} from '@tryghost/admin-x-framework/api/offers'; -import {PreviewModalContent} from '@/settings/app/components/settings/preview-modal'; +import {PreviewModalContent} from '@/settings/components/preview-modal'; import {createOfferRedemptionFilterUrl} from './offer-helpers'; import {formatNumber} from '@tryghost/shade/utils'; import {getHomepageUrl} from '@tryghost/admin-x-framework/api/site'; -import {getOfferPortalPreviewUrl, type offerPortalPreviewUrlTypes} from '@/settings/app/utils/get-offers-portal-preview-url'; +import {getOfferPortalPreviewUrl, type offerPortalPreviewUrlTypes} from '@/settings/utils/get-offers-portal-preview-url'; import {toast} from 'sonner'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {useEffect, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; function formatTimestamp(timestamp: string): string { const date = new Date(timestamp); diff --git a/apps/admin/src/settings/growth/offers/edit-retention-offer-modal.tsx b/apps/admin/src/settings/growth/offers/edit-retention-offer-modal.tsx index c593d544af0..570f65b384e 100644 --- a/apps/admin/src/settings/growth/offers/edit-retention-offer-modal.tsx +++ b/apps/admin/src/settings/growth/offers/edit-retention-offer-modal.tsx @@ -1,18 +1,18 @@ import PortalFrame from '@/settings/membership/portal/portal-frame'; -import SettingsBreadcrumbs from '@/settings/app/components/settings/settings-breadcrumbs'; +import SettingsBreadcrumbs from '@/settings/components/settings-breadcrumbs'; import {type ErrorMessages, useForm} from '@tryghost/admin-x-framework/hooks'; import {Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel, Input, InputGroup, InputGroupAddon, InputGroupInput, InputGroupText, RadioGroup, RadioGroupItem, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, Textarea} from '@tryghost/shade/components'; import {JSONError} from '@tryghost/admin-x-framework/errors'; import {type Offer, useAddOffer, useBrowseOffers, useEditOffer, useInvalidateOffers} from '@tryghost/admin-x-framework/api/offers'; -import {PreviewModalContent} from '@/settings/app/components/settings/preview-modal'; +import {PreviewModalContent} from '@/settings/components/preview-modal'; import {createOfferRedemptionsFilterUrl, formatOfferTimestamp, generateRetentionOfferName} from './offer-helpers'; import {formatNumber} from '@tryghost/shade/utils'; -import {getOfferPortalPreviewUrl, type offerPortalPreviewUrlTypes} from '@/settings/app/utils/get-offers-portal-preview-url'; +import {getOfferPortalPreviewUrl, type offerPortalPreviewUrlTypes} from '@/settings/utils/get-offers-portal-preview-url'; import {getPaidActiveTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; import {toast} from 'sonner'; import {useEffect, useMemo, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; type RetentionOfferFormState = { enabled: boolean; diff --git a/apps/admin/src/settings/growth/offers/offer-helpers.ts b/apps/admin/src/settings/growth/offers/offer-helpers.ts index f626814a868..ea66664c8e9 100644 --- a/apps/admin/src/settings/growth/offers/offer-helpers.ts +++ b/apps/admin/src/settings/growth/offers/offer-helpers.ts @@ -1,5 +1,5 @@ import {type Tier} from '@tryghost/admin-x-framework/api/tiers'; -import {currencyToDecimal, getSymbol} from '@/settings/app/utils/currency'; +import {currencyToDecimal, getSymbol} from '@/settings/utils/currency'; import {formatNumber} from '@tryghost/shade/utils'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; diff --git a/apps/admin/src/settings/growth/offers/offer-success.tsx b/apps/admin/src/settings/growth/offers/offer-success.tsx index 886675f9fe7..f0f016c1287 100644 --- a/apps/admin/src/settings/growth/offers/offer-success.tsx +++ b/apps/admin/src/settings/growth/offers/offer-success.tsx @@ -1,15 +1,15 @@ -import BrandIcon from '@/settings/app/components/icons/brand-icon'; -import SettingsBreadcrumbs from '@/settings/app/components/settings/settings-breadcrumbs'; +import BrandIcon from '@/settings/components/icons/brand-icon'; +import SettingsBreadcrumbs from '@/settings/components/settings-breadcrumbs'; import {Button, Input} from '@tryghost/shade/components'; import {LucideIcon} from '@tryghost/shade/utils'; import {type Offer, useBrowseOffersById} from '@tryghost/admin-x-framework/api/offers'; import {SettingsModal} from '@tryghost/shade/patterns'; -import {currencyToDecimal} from '@/settings/app/utils/currency'; +import {currencyToDecimal} from '@/settings/utils/currency'; import {formatNumber} from '@tryghost/shade/utils'; import {getHomepageUrl} from '@tryghost/admin-x-framework/api/site'; import {useEffect, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; const OfferSuccess: React.FC<{id: string}> = ({id}) => { const {updateRoute} = useSettingsNavigation(); diff --git a/apps/admin/src/settings/growth/offers/offers-container-modal.tsx b/apps/admin/src/settings/growth/offers/offers-container-modal.tsx index 32d88808901..9e6181bc108 100644 --- a/apps/admin/src/settings/growth/offers/offers-container-modal.tsx +++ b/apps/admin/src/settings/growth/offers/offers-container-modal.tsx @@ -3,7 +3,7 @@ import EditOfferModal from './edit-offer-modal'; import EditRetentionOfferModal from './edit-retention-offer-modal'; import OfferSuccess from './offer-success'; import {OffersIndexModal} from './offers-index'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; type OffersRouteHandlerProps = { route: string; diff --git a/apps/admin/src/settings/growth/offers/offers-index.tsx b/apps/admin/src/settings/growth/offers/offers-index.tsx index c05440910b2..a3a6da9dca7 100644 --- a/apps/admin/src/settings/growth/offers/offers-index.tsx +++ b/apps/admin/src/settings/growth/offers/offers-index.tsx @@ -7,8 +7,8 @@ import {SettingsModal} from '@tryghost/shade/patterns'; import {type Tier, getPaidActiveTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; import {createOfferRedemptionFilterUrl, createOfferRedemptionsFilterUrl, getOfferCadence, getOfferDiscount, getOfferDuration} from './offer-helpers'; import {toast} from 'sonner'; -import {useOffersShowArchived, useSortingState} from '@/settings/app/components/providers/settings-app-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useOffersShowArchived, useSortingState} from '@/settings/providers/settings-app-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; const OffersFilterMenu: React.FC<{ showArchived: boolean; diff --git a/apps/admin/src/settings/growth/recommendations.tsx b/apps/admin/src/settings/growth/recommendations.tsx index f426cfcf9a9..deef48b2985 100644 --- a/apps/admin/src/settings/growth/recommendations.tsx +++ b/apps/admin/src/settings/growth/recommendations.tsx @@ -1,16 +1,16 @@ import IncomingRecommendationList from './recommendations/incoming-recommendation-list'; import React, {useState} from 'react'; import RecommendationList from './recommendations/recommendation-list'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {Button} from '@tryghost/shade/components'; import {Tabs, TabsContent, TabsList, TabsTrigger, TabsTriggerCount} from '@tryghost/shade/components'; import {formatNumber} from '@tryghost/shade/utils'; import {keepPreviousData} from '@tanstack/react-query'; import {useBrowseIncomingRecommendations, useBrowseRecommendations} from '@tryghost/admin-x-framework/api/recommendations'; import {useReferrerHistory} from '@tryghost/admin-x-framework/api/referrers'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => { const { diff --git a/apps/admin/src/settings/growth/recommendations/add-recommendation-modal-confirm.tsx b/apps/admin/src/settings/growth/recommendations/add-recommendation-modal-confirm.tsx index edb92496993..43fedc8c6f4 100644 --- a/apps/admin/src/settings/growth/recommendations/add-recommendation-modal-confirm.tsx +++ b/apps/admin/src/settings/growth/recommendations/add-recommendation-modal-confirm.tsx @@ -1,7 +1,7 @@ import React from 'react'; import RecommendationDescriptionForm from './recommendation-description-form'; import {validateDescriptionForm} from './recommendation-validation'; -import trackEvent from '@/settings/app/utils/analytics'; +import trackEvent from '@/settings/utils/analytics'; import {Button} from '@tryghost/shade/components'; import {type EditOrAddRecommendation, useAddRecommendation} from '@tryghost/admin-x-framework/api/recommendations'; import {LucideIcon} from '@tryghost/shade/utils'; diff --git a/apps/admin/src/settings/growth/recommendations/add-recommendation-modal.tsx b/apps/admin/src/settings/growth/recommendations/add-recommendation-modal.tsx index b0af9b4644c..46f417b9212 100644 --- a/apps/admin/src/settings/growth/recommendations/add-recommendation-modal.tsx +++ b/apps/admin/src/settings/growth/recommendations/add-recommendation-modal.tsx @@ -5,9 +5,9 @@ import {type EditOrAddRecommendation, useCheckRecommendation} from '@tryghost/ad import {type ErrorMessages, useForm} from '@tryghost/admin-x-framework/hooks'; import {Field, FieldDescription, FieldError, FieldGroup, FieldLabel, Input, LoadingIndicator} from '@tryghost/shade/components'; import {useSearchParams} from '@tryghost/admin-x-framework'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; import {SettingsModal} from '@tryghost/shade/patterns'; -import {formatUrl} from '@/settings/app/utils/format-url'; +import {formatUrl} from '@/settings/utils/format-url'; import {toast} from 'sonner'; const doFormatUrl = (url: string) => { diff --git a/apps/admin/src/settings/growth/recommendations/edit-recommendation-modal.tsx b/apps/admin/src/settings/growth/recommendations/edit-recommendation-modal.tsx index 61c4a3d9c3e..a9d574af94b 100644 --- a/apps/admin/src/settings/growth/recommendations/edit-recommendation-modal.tsx +++ b/apps/admin/src/settings/growth/recommendations/edit-recommendation-modal.tsx @@ -5,7 +5,7 @@ import {Button} from '@tryghost/shade/components'; import {type Recommendation, useDeleteRecommendation, useEditRecommendation} from '@tryghost/admin-x-framework/api/recommendations'; import {SettingsModal} from '@tryghost/shade/patterns'; import {toast} from 'sonner'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; interface EditRecommendationModalProps { diff --git a/apps/admin/src/settings/growth/recommendations/incoming-recommendation-list.tsx b/apps/admin/src/settings/growth/recommendations/incoming-recommendation-list.tsx index 9d8cfe23df6..11556555820 100644 --- a/apps/admin/src/settings/growth/recommendations/incoming-recommendation-list.tsx +++ b/apps/admin/src/settings/growth/recommendations/incoming-recommendation-list.tsx @@ -6,7 +6,7 @@ import {type IncomingRecommendation} from '@tryghost/admin-x-framework/api/recom import {Inline} from '@tryghost/shade/primitives'; import {type ReferrerHistoryItem} from '@tryghost/admin-x-framework/api/referrers'; import {cn, formatNumber} from '@tryghost/shade/utils'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; interface IncomingRecommendationListProps { incomingRecommendations: IncomingRecommendation[], diff --git a/apps/admin/src/settings/growth/recommendations/recommendation-icon.tsx b/apps/admin/src/settings/growth/recommendations/recommendation-icon.tsx index c3ecea1cd4c..2ceccf2437b 100644 --- a/apps/admin/src/settings/growth/recommendations/recommendation-icon.tsx +++ b/apps/admin/src/settings/growth/recommendations/recommendation-icon.tsx @@ -1,5 +1,5 @@ /* eslint-disable camelcase */ -import GhostLogo from '@/settings/app/assets/images/ghost-favicon.png'; +import GhostLogo from '@/settings/assets/images/ghost-favicon.png'; import React, {useState} from 'react'; interface Props { diff --git a/apps/admin/src/settings/growth/recommendations/recommendation-list.tsx b/apps/admin/src/settings/growth/recommendations/recommendation-list.tsx index a595b982442..da24480faf8 100644 --- a/apps/admin/src/settings/growth/recommendations/recommendation-list.tsx +++ b/apps/admin/src/settings/growth/recommendations/recommendation-list.tsx @@ -1,12 +1,12 @@ import EditRecommendationModal from './edit-recommendation-modal'; import React, {useState} from 'react'; import RecommendationIcon from './recommendation-icon'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {ActionList, ActionListItem, ActionListItemContent, Button, LoadingIndicator, NoValueLabel, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@tryghost/shade/components'; import {Inline} from '@tryghost/shade/primitives'; import {LucideIcon, formatNumber} from '@tryghost/shade/utils'; import {type Recommendation} from '@tryghost/admin-x-framework/api/recommendations'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; interface RecommendationListProps { recommendations: Recommendation[], diff --git a/apps/admin/src/settings/growth/tips-and-donations-settings.ts b/apps/admin/src/settings/growth/tips-and-donations-settings.ts index bf3a98b8378..9b1b95ea2ff 100644 --- a/apps/admin/src/settings/growth/tips-and-donations-settings.ts +++ b/apps/admin/src/settings/growth/tips-and-donations-settings.ts @@ -1,4 +1,4 @@ -import {currencySelectGroups} from '@/settings/app/utils/currency'; +import {currencySelectGroups} from '@/settings/utils/currency'; import {z} from 'zod'; const DEFAULT_DONATIONS_CURRENCY = 'USD'; diff --git a/apps/admin/src/settings/growth/tips-and-donations.tsx b/apps/admin/src/settings/growth/tips-and-donations.tsx index c780ef7df6c..26d292bf8e2 100644 --- a/apps/admin/src/settings/growth/tips-and-donations.tsx +++ b/apps/admin/src/settings/growth/tips-and-donations.tsx @@ -1,15 +1,15 @@ import React, {useEffect, useState} from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import useCurrencyInput from '@/settings/app/hooks/use-currency-input'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import useCurrencyInput from '@/settings/hooks/use-currency-input'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {Button, CopyField, CopyFieldActions, CopyFieldContent, CopyFieldCopyButton, CopyFieldLabel, CopyFieldValue, Field, FieldError, FieldLabel, InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, MultiSelectCombobox, Popover, PopoverContent, PopoverTrigger} from '@tryghost/shade/components'; import {ChevronDown} from 'lucide-react'; import {DirtyConfirmDialog, useDirtyConfirmation} from '@tryghost/shade/patterns'; import {SettingGroupContent} from '@tryghost/shade/patterns'; -import {currencySelectGroups, validateCurrencyAmount} from '@/settings/app/utils/currency'; +import {currencySelectGroups, validateCurrencyAmount} from '@/settings/utils/currency'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {parseTipsAndDonationsSettings} from './tips-and-donations-settings'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; // Stripe doesn't allow amounts over 10,000 as a preset amount const MAX_AMOUNT = 10_000; diff --git a/apps/admin/src/settings/app/hooks/use-scroll-section.tsx b/apps/admin/src/settings/hooks/scroll-section-provider.tsx similarity index 81% rename from apps/admin/src/settings/app/hooks/use-scroll-section.tsx rename to apps/admin/src/settings/hooks/scroll-section-provider.tsx index c63ad962e0b..8e25cb177ce 100644 --- a/apps/admin/src/settings/app/hooks/use-scroll-section.tsx +++ b/apps/admin/src/settings/hooks/scroll-section-provider.tsx @@ -1,22 +1,5 @@ -import {type ReactNode, createContext, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; - -interface ScrollSectionContextData { - updateSection: (id: string, element: HTMLDivElement) => void; - updateNav: (id: string, element: HTMLLIElement) => void; - currentSection: string | null; - updateNavigatedSection: (id: string) => void; - scrollToSection: (id: string) => void; -} - -const ScrollSectionContext = createContext({ - updateSection: () => {}, - updateNav: () => {}, - currentSection: null, - updateNavigatedSection: () => {}, - scrollToSection: () => {} -}); - -export const useScrollSectionContext = () => useContext(ScrollSectionContext); +import {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {ScrollSectionContext} from './use-scroll-section'; const scrollMargin = 193; @@ -213,40 +196,3 @@ export const ScrollSectionProvider: React.FC<{ ); }; - -export const useScrollSection = (id?: string) => { - const {updateSection} = useScrollSectionContext(); - const ref = useRef(null); - - useEffect(() => { - if (id && ref.current) { - updateSection(id, ref.current); - } - }, [id, updateSection]); - - return { - ref - }; -}; - -export const useScrollSectionNav = (id?: string | string[]) => { - const {updateNav} = useScrollSectionContext(); - const ref = useRef(null); - - useEffect(() => { - if (id && ref.current) { - // Convert to array if it's a string - const ids = Array.isArray(id) ? id : [id]; - - // Register the nav element for all IDs - ids.forEach((navId) => { - updateNav(navId, ref.current!); - }); - } - }, [id, updateNav]); - - return { - ref, - props: {'data-setting-nav-item': true} - }; -}; diff --git a/apps/admin/src/settings/app/hooks/site/use-navigation-editor.test.tsx b/apps/admin/src/settings/hooks/site/use-navigation-editor.test.tsx similarity index 98% rename from apps/admin/src/settings/app/hooks/site/use-navigation-editor.test.tsx rename to apps/admin/src/settings/hooks/site/use-navigation-editor.test.tsx index 09c10d30122..9bed4af881e 100644 --- a/apps/admin/src/settings/app/hooks/site/use-navigation-editor.test.tsx +++ b/apps/admin/src/settings/hooks/site/use-navigation-editor.test.tsx @@ -1,5 +1,5 @@ import * as assert from 'node:assert/strict'; -import useNavigationEditor, {type NavigationItem} from '@/settings/app/hooks/site/use-navigation-editor'; +import useNavigationEditor, {type NavigationItem} from '@/settings/hooks/site/use-navigation-editor'; import {act, renderHook, waitFor} from '@testing-library/react'; describe('useNavigationEditor', function () { @@ -175,7 +175,7 @@ describe('useNavigationEditor', function () { }); }); - it('does not show user-facing validation errors for stored icon URLs', async function () { + it('does not show user-facing validation errors for stored icon URLs', function () { const {result} = renderHook(() => useNavigationEditor({ items: [{ label: 'FTP icon', diff --git a/apps/admin/src/settings/app/hooks/site/use-navigation-editor.tsx b/apps/admin/src/settings/hooks/site/use-navigation-editor.tsx similarity index 98% rename from apps/admin/src/settings/app/hooks/site/use-navigation-editor.tsx rename to apps/admin/src/settings/hooks/site/use-navigation-editor.tsx index 715f2682cf8..110ef5b8657 100644 --- a/apps/admin/src/settings/app/hooks/site/use-navigation-editor.tsx +++ b/apps/admin/src/settings/hooks/site/use-navigation-editor.tsx @@ -1,4 +1,4 @@ -import useSortableIndexedList from '@/settings/app/hooks/use-sortable-indexed-list'; +import useSortableIndexedList from '@/settings/hooks/use-sortable-indexed-list'; import validator from 'validator'; import {useCallback, useMemo} from 'react'; diff --git a/apps/admin/src/settings/app/hooks/use-auto-expandable.tsx b/apps/admin/src/settings/hooks/use-auto-expandable.tsx similarity index 93% rename from apps/admin/src/settings/app/hooks/use-auto-expandable.tsx rename to apps/admin/src/settings/hooks/use-auto-expandable.tsx index 8bb589f9681..3e205a0efe0 100644 --- a/apps/admin/src/settings/app/hooks/use-auto-expandable.tsx +++ b/apps/admin/src/settings/hooks/use-auto-expandable.tsx @@ -1,5 +1,5 @@ import {useEffect, useRef, useState} from 'react'; -import {useSearch} from '@/settings/app/components/providers/settings-app-provider'; +import {useSearch} from '@/settings/providers/settings-app-context'; /** * Simple hook to auto-expand a component when it's the only search result. diff --git a/apps/admin/src/settings/app/hooks/use-check-theme-limit-error.tsx b/apps/admin/src/settings/hooks/use-check-theme-limit-error.tsx similarity index 95% rename from apps/admin/src/settings/app/hooks/use-check-theme-limit-error.tsx rename to apps/admin/src/settings/hooks/use-check-theme-limit-error.tsx index 4ac969c183e..fd8ad193169 100644 --- a/apps/admin/src/settings/app/hooks/use-check-theme-limit-error.tsx +++ b/apps/admin/src/settings/hooks/use-check-theme-limit-error.tsx @@ -1,6 +1,6 @@ import {HostLimitError, useLimiter} from './use-limiter'; import {useCallback} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; interface UseCheckThemeLimitErrorReturn { checkThemeLimitError: (themeName?: string) => Promise; diff --git a/apps/admin/src/settings/app/hooks/use-currency-input.ts b/apps/admin/src/settings/hooks/use-currency-input.ts similarity index 100% rename from apps/admin/src/settings/app/hooks/use-currency-input.ts rename to apps/admin/src/settings/hooks/use-currency-input.ts diff --git a/apps/admin/src/settings/app/hooks/use-custom-fonts.tsx b/apps/admin/src/settings/hooks/use-custom-fonts.tsx similarity index 93% rename from apps/admin/src/settings/app/hooks/use-custom-fonts.tsx rename to apps/admin/src/settings/hooks/use-custom-fonts.tsx index 1c417c630fd..a41d458ff70 100644 --- a/apps/admin/src/settings/app/hooks/use-custom-fonts.tsx +++ b/apps/admin/src/settings/hooks/use-custom-fonts.tsx @@ -7,7 +7,7 @@ const useCustomFonts = () => { const supportsCustomFonts = !activeTheme?.warnings?.some(warning => warning.code === 'GS051-CUSTOM-FONTS'); const refreshActiveThemeData = useCallback(() => { - activeThemes.refetch(); + void activeThemes.refetch(); }, [activeThemes]); return {supportsCustomFonts, refreshActiveThemeData}; diff --git a/apps/admin/src/settings/app/hooks/use-feature-flag.tsx b/apps/admin/src/settings/hooks/use-feature-flag.tsx similarity index 64% rename from apps/admin/src/settings/app/hooks/use-feature-flag.tsx rename to apps/admin/src/settings/hooks/use-feature-flag.tsx index 362e54e0342..19437001cd7 100644 --- a/apps/admin/src/settings/app/hooks/use-feature-flag.tsx +++ b/apps/admin/src/settings/hooks/use-feature-flag.tsx @@ -1,4 +1,4 @@ -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; const useFeatureFlag = (flag: string) => { const {config} = useGlobalData(); diff --git a/apps/admin/src/settings/app/hooks/use-limiter.tsx b/apps/admin/src/settings/hooks/use-limiter.tsx similarity index 93% rename from apps/admin/src/settings/app/hooks/use-limiter.tsx rename to apps/admin/src/settings/hooks/use-limiter.tsx index 23b9371c37c..bde5fec73ce 100644 --- a/apps/admin/src/settings/app/hooks/use-limiter.tsx +++ b/apps/admin/src/settings/hooks/use-limiter.tsx @@ -2,7 +2,7 @@ import useStaffUsers from './use-staff-users'; import {useBrowseMembers} from '@tryghost/admin-x-framework/api/members'; import {useBrowseNewsletters} from '@tryghost/admin-x-framework/api/newsletters'; import {useEffect, useMemo, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; const limitServiceImport = import('@tryghost/limit-service'); @@ -49,10 +49,10 @@ interface LimiterLimits { export const useLimiter = () => { const {config} = useGlobalData(); - const [LimitService, setLimitService] = useState(null); + const [LimitService, setLimitService] = useState(null); useEffect(() => { - limitServiceImport.then(exports => setLimitService(() => exports.default)); + void limitServiceImport.then(exports => setLimitService(() => exports.default)); }, []); const {users, contributorUsers, invites, isLoading} = useStaffUsers(); @@ -92,13 +92,13 @@ export const useLimiter = () => { const limiter = new LimitService(); if (limits.staff) { - limits.staff.currentCountQuery = async () => { + limits.staff.currentCountQuery = () => { // useStaffUsers will only return the first 100 users by default, but we can assume // that either there's no limit or the limit is <100 const staffUsers = users.filter(u => u.status !== 'inactive' && !contributorUsers.includes(u)); const staffInvites = invites.filter(i => i.role !== 'Contributor'); - return staffUsers.length + staffInvites.length; + return Promise.resolve(staffUsers.length + staffInvites.length); }; } diff --git a/apps/admin/src/settings/app/hooks/use-pintura-editor.ts b/apps/admin/src/settings/hooks/use-pintura-editor.ts similarity index 99% rename from apps/admin/src/settings/app/hooks/use-pintura-editor.ts rename to apps/admin/src/settings/hooks/use-pintura-editor.ts index 81e49a9d402..c1b9485e75c 100644 --- a/apps/admin/src/settings/app/hooks/use-pintura-editor.ts +++ b/apps/admin/src/settings/hooks/use-pintura-editor.ts @@ -3,7 +3,7 @@ import {type Config} from '@tryghost/admin-x-framework/api/config'; import {type Setting, getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {useCallback, useEffect, useRef, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; interface OpenEditorParams { diff --git a/apps/admin/src/settings/app/hooks/use-query-params.ts b/apps/admin/src/settings/hooks/use-query-params.ts similarity index 100% rename from apps/admin/src/settings/app/hooks/use-query-params.ts rename to apps/admin/src/settings/hooks/use-query-params.ts diff --git a/apps/admin/src/settings/app/hooks/use-save-button.ts b/apps/admin/src/settings/hooks/use-save-button.ts similarity index 100% rename from apps/admin/src/settings/app/hooks/use-save-button.ts rename to apps/admin/src/settings/hooks/use-save-button.ts diff --git a/apps/admin/src/settings/hooks/use-scroll-section.ts b/apps/admin/src/settings/hooks/use-scroll-section.ts new file mode 100644 index 00000000000..567d749213b --- /dev/null +++ b/apps/admin/src/settings/hooks/use-scroll-section.ts @@ -0,0 +1,56 @@ +import {createContext, useContext, useEffect, useRef} from 'react'; + +export interface ScrollSectionContextData { + updateSection: (id: string, element: HTMLDivElement) => void; + updateNav: (id: string, element: HTMLLIElement) => void; + currentSection: string | null; + updateNavigatedSection: (id: string) => void; + scrollToSection: (id: string) => void; +} + +export const ScrollSectionContext = createContext({ + updateSection: () => {}, + updateNav: () => {}, + currentSection: null, + updateNavigatedSection: () => {}, + scrollToSection: () => {} +}); + +export const useScrollSectionContext = () => useContext(ScrollSectionContext); + +export const useScrollSection = (id?: string) => { + const {updateSection} = useScrollSectionContext(); + const ref = useRef(null); + + useEffect(() => { + if (id && ref.current) { + updateSection(id, ref.current); + } + }, [id, updateSection]); + + return { + ref + }; +}; + +export const useScrollSectionNav = (id?: string | string[]) => { + const {updateNav} = useScrollSectionContext(); + const ref = useRef(null); + + useEffect(() => { + if (id && ref.current) { + // Convert to array if it's a string + const ids = Array.isArray(id) ? id : [id]; + + // Register the nav element for all IDs + ids.forEach((navId) => { + updateNav(navId, ref.current!); + }); + } + }, [id, updateNav]); + + return { + ref, + props: {'data-setting-nav-item': true} + }; +}; diff --git a/apps/admin/src/settings/app/hooks/use-setting-group.tsx b/apps/admin/src/settings/hooks/use-setting-group.tsx similarity index 97% rename from apps/admin/src/settings/app/hooks/use-setting-group.tsx rename to apps/admin/src/settings/hooks/use-setting-group.tsx index 9cda77f229c..a279c4db0dd 100644 --- a/apps/admin/src/settings/app/hooks/use-setting-group.tsx +++ b/apps/admin/src/settings/hooks/use-setting-group.tsx @@ -2,7 +2,7 @@ import React, {useCallback, useEffect, useRef, useState} from 'react'; import {type ErrorMessages, type OkProps, type SaveHandler, type SaveState, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; import {type Setting, type SettingValue, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {type SiteData} from '@tryghost/admin-x-framework/api/site'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useGlobalDirtyState} from '@tryghost/shade/utils'; interface LocalSetting extends Setting { diff --git a/apps/admin/src/settings/app/hooks/use-settings-navigation.ts b/apps/admin/src/settings/hooks/use-settings-navigation.ts similarity index 100% rename from apps/admin/src/settings/app/hooks/use-settings-navigation.ts rename to apps/admin/src/settings/hooks/use-settings-navigation.ts diff --git a/apps/admin/src/settings/app/hooks/use-sortable-indexed-list.test.ts b/apps/admin/src/settings/hooks/use-sortable-indexed-list.test.ts similarity index 98% rename from apps/admin/src/settings/app/hooks/use-sortable-indexed-list.test.ts rename to apps/admin/src/settings/hooks/use-sortable-indexed-list.test.ts index 0f5879cd9b3..ad40d9de99d 100644 --- a/apps/admin/src/settings/app/hooks/use-sortable-indexed-list.test.ts +++ b/apps/admin/src/settings/hooks/use-sortable-indexed-list.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import useSortableIndexedList from '@/settings/app/hooks/use-sortable-indexed-list'; +import useSortableIndexedList from '@/settings/hooks/use-sortable-indexed-list'; import {act, renderHook} from '@testing-library/react'; import {describe, it, vi} from 'vitest'; diff --git a/apps/admin/src/settings/app/hooks/use-sortable-indexed-list.ts b/apps/admin/src/settings/hooks/use-sortable-indexed-list.ts similarity index 98% rename from apps/admin/src/settings/app/hooks/use-sortable-indexed-list.ts rename to apps/admin/src/settings/hooks/use-sortable-indexed-list.ts index e29e603ddc5..95afc78acf7 100644 --- a/apps/admin/src/settings/app/hooks/use-sortable-indexed-list.ts +++ b/apps/admin/src/settings/hooks/use-sortable-indexed-list.ts @@ -1,7 +1,7 @@ import {arrayMove} from '@dnd-kit/sortable'; import {useEffect, useState} from 'react'; -const stableStringify = (value: unknown) => JSON.stringify(value, (_key, nestedValue) => { +const stableStringify = (value: unknown) => JSON.stringify(value, (_key, nestedValue: unknown) => { if (nestedValue && typeof nestedValue === 'object' && !Array.isArray(nestedValue)) { return Object.keys(nestedValue).sort().reduce>((sorted, key) => { sorted[key] = (nestedValue as Record)[key]; diff --git a/apps/admin/src/settings/app/hooks/use-staff-users.tsx b/apps/admin/src/settings/hooks/use-staff-users.tsx similarity index 94% rename from apps/admin/src/settings/app/hooks/use-staff-users.tsx rename to apps/admin/src/settings/hooks/use-staff-users.tsx index 38af6856c6f..a5ab7168518 100644 --- a/apps/admin/src/settings/app/hooks/use-staff-users.tsx +++ b/apps/admin/src/settings/hooks/use-staff-users.tsx @@ -1,7 +1,7 @@ import {type User, useBrowseUsers} from '@tryghost/admin-x-framework/api/users'; import {type UserInvite, useBrowseInvites} from '@tryghost/admin-x-framework/api/invites'; import {useBrowseRoles} from '@tryghost/admin-x-framework/api/roles'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useMemo} from 'react'; export type UsersHook = { @@ -18,8 +18,8 @@ export type UsersHook = { isLoading: boolean; hasNextPage?: boolean; invitesHasNextPage?: boolean; - fetchNextPage: () => void; - fetchNextInvitePage: () => void; + fetchNextPage: () => Promise; + fetchNextInvitePage: () => Promise; }; function getUsersByRole(users: User[], role: string): User[] { diff --git a/apps/admin/src/settings/app/hooks/use-upgrade-route.test.tsx b/apps/admin/src/settings/hooks/use-upgrade-route.test.tsx similarity index 85% rename from apps/admin/src/settings/app/hooks/use-upgrade-route.test.tsx rename to apps/admin/src/settings/hooks/use-upgrade-route.test.tsx index 204b1af725c..d25738c4408 100644 --- a/apps/admin/src/settings/app/hooks/use-upgrade-route.test.tsx +++ b/apps/admin/src/settings/hooks/use-upgrade-route.test.tsx @@ -1,9 +1,9 @@ import {renderHook} from '@testing-library/react'; -import {useUpgradeRoute} from '@/settings/app/hooks/use-upgrade-route'; +import {useUpgradeRoute} from '@/settings/hooks/use-upgrade-route'; -const mockConfig = vi.fn(); +const mockConfig = vi.fn<() => unknown>(); -vi.mock('@/settings/app/components/providers/global-data-provider', () => ({ +vi.mock('@/settings/providers/global-data-context', () => ({ useGlobalData: () => ({config: mockConfig()}) })); diff --git a/apps/admin/src/settings/app/hooks/use-upgrade-route.ts b/apps/admin/src/settings/hooks/use-upgrade-route.ts similarity index 67% rename from apps/admin/src/settings/app/hooks/use-upgrade-route.ts rename to apps/admin/src/settings/hooks/use-upgrade-route.ts index 25e76816f2d..efd473e2c59 100644 --- a/apps/admin/src/settings/app/hooks/use-upgrade-route.ts +++ b/apps/admin/src/settings/hooks/use-upgrade-route.ts @@ -1,5 +1,5 @@ import {upgradeRoute} from '@tryghost/admin-x-framework/api/config'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; export function useUpgradeRoute() { const {config} = useGlobalData(); diff --git a/apps/admin/src/settings/app/hooks/use-url-input.ts b/apps/admin/src/settings/hooks/use-url-input.ts similarity index 96% rename from apps/admin/src/settings/app/hooks/use-url-input.ts rename to apps/admin/src/settings/hooks/use-url-input.ts index 937d36347c6..3f4be3bd95c 100644 --- a/apps/admin/src/settings/app/hooks/use-url-input.ts +++ b/apps/admin/src/settings/hooks/use-url-input.ts @@ -1,5 +1,5 @@ import {type FocusEvent, type KeyboardEvent, useCallback, useEffect, useState} from 'react'; -import {formatUrl} from '@/settings/app/utils/format-url'; +import {formatUrl} from '@/settings/utils/format-url'; type UseUrlInputOptions = { baseUrl?: string; diff --git a/apps/admin/src/settings/app/hooks/use-welcome-email-link-suggestions.ts b/apps/admin/src/settings/hooks/use-welcome-email-link-suggestions.ts similarity index 92% rename from apps/admin/src/settings/app/hooks/use-welcome-email-link-suggestions.ts rename to apps/admin/src/settings/hooks/use-welcome-email-link-suggestions.ts index 25a72b3c8f6..9e5f372ac56 100644 --- a/apps/admin/src/settings/app/hooks/use-welcome-email-link-suggestions.ts +++ b/apps/admin/src/settings/hooks/use-welcome-email-link-suggestions.ts @@ -1,5 +1,5 @@ import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useKoenigLinkSuggestions} from '@tryghost/admin-x-framework'; export const useWelcomeEmailLinkSuggestions = () => { diff --git a/apps/admin/src/settings/app/hooks/use-welcome-email-sender-details.ts b/apps/admin/src/settings/hooks/use-welcome-email-sender-details.ts similarity index 93% rename from apps/admin/src/settings/app/hooks/use-welcome-email-sender-details.ts rename to apps/admin/src/settings/hooks/use-welcome-email-sender-details.ts index f982e162498..816a99c92da 100644 --- a/apps/admin/src/settings/app/hooks/use-welcome-email-sender-details.ts +++ b/apps/admin/src/settings/hooks/use-welcome-email-sender-details.ts @@ -1,5 +1,5 @@ // NOTE: counterpart lives in apps/admin/src/automations/components/email-modal/use-sender-details.ts — the email design modal needs equivalent sender-detail behavior in both apps until Automations GAs; keep in sync -import {resolveWelcomeEmailSenderDetails} from '@/settings/app/utils/welcome-email-sender-details'; +import {resolveWelcomeEmailSenderDetails} from '@/settings/utils/welcome-email-sender-details'; import {useBrowseNewsletters} from '@tryghost/admin-x-framework/api/newsletters'; import {useMemo} from 'react'; import type {AutomatedEmail} from '@tryghost/admin-x-framework/api/automated-emails'; diff --git a/apps/admin/src/settings/app/app.tsx b/apps/admin/src/settings/layout/app.tsx similarity index 76% rename from apps/admin/src/settings/app/app.tsx rename to apps/admin/src/settings/layout/app.tsx index 3db6609d3d5..231560f5899 100644 --- a/apps/admin/src/settings/app/app.tsx +++ b/apps/admin/src/settings/layout/app.tsx @@ -1,10 +1,11 @@ import MainContent from './main-content'; -import SettingsAppProvider, {type UpgradeStatusType} from './components/providers/settings-app-provider'; -import {ConfirmationProvider} from './components/providers/confirmation-provider'; -import {DialogPortalProvider} from './components/providers/dialog-portal'; +import SettingsAppProvider from '@/settings/providers/settings-app-provider'; +import {type UpgradeStatusType} from '@/settings/providers/settings-app-context'; +import {ConfirmationProvider} from '@/settings/providers/confirmation-provider'; +import {DialogPortalProvider} from '@/settings/providers/dialog-portal'; import {Outlet, useLocation} from '@tryghost/admin-x-framework'; import {useEffect} from 'react'; -import {useScrollSectionContext} from './hooks/use-scroll-section'; +import {useScrollSectionContext} from '@/settings/hooks/use-scroll-section'; interface AppProps { upgradeStatus?: UpgradeStatusType; diff --git a/apps/admin/src/settings/app/main-content.tsx b/apps/admin/src/settings/layout/main-content.tsx similarity index 94% rename from apps/admin/src/settings/app/main-content.tsx rename to apps/admin/src/settings/layout/main-content.tsx index 1d1f482cf22..1bb803f521b 100644 --- a/apps/admin/src/settings/app/main-content.tsx +++ b/apps/admin/src/settings/layout/main-content.tsx @@ -1,13 +1,13 @@ -import ExitSettingsButton from './components/exit-settings-button'; -import Settings from './components/settings'; -import Sidebar from './components/sidebar'; +import ExitSettingsButton from '@/settings/components/exit-settings-button'; +import Settings from './settings-sections'; +import Sidebar from './sidebar'; import Users from '@/settings/general/users'; import {DirtyConfirmDialog, useDirtyConfirmation} from '@tryghost/shade/patterns'; import {type ReactNode, useEffect} from 'react'; import {Text} from '@tryghost/shade/primitives'; import {canAccessSettings, isEditorUser} from '@tryghost/admin-x-framework/api/users'; import {toast} from 'sonner'; -import {useGlobalData} from './components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useGlobalDirtyState} from '@tryghost/shade/utils'; import {useNavigate} from '@tryghost/admin-x-framework'; diff --git a/apps/admin/src/settings/app/components/settings.tsx b/apps/admin/src/settings/layout/settings-sections.tsx similarity index 85% rename from apps/admin/src/settings/app/components/settings.tsx rename to apps/admin/src/settings/layout/settings-sections.tsx index 3e76ca04b24..8f29bc47c5b 100644 --- a/apps/admin/src/settings/app/components/settings.tsx +++ b/apps/admin/src/settings/layout/settings-sections.tsx @@ -1,13 +1,13 @@ import React from 'react'; -import AdvancedSettings from './settings/advanced/advanced-settings'; +import AdvancedSettings from '@/settings/app/components/settings/advanced/advanced-settings'; import EmailSettings from '@/settings/email/email-settings'; import Emails from '@/settings/email/emails'; import GeneralSettings from '@/settings/general/general-settings'; import GrowthSettings from '@/settings/growth/growth-settings'; import MembershipSettings from '@/settings/membership/membership-settings'; import SiteSettings from '@/settings/site/site-settings'; -import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; +import useFeatureFlag from '@/settings/hooks/use-feature-flag'; const Settings: React.FC = () => { const hasAutomations = useFeatureFlag('automations'); diff --git a/apps/admin/src/settings/app/components/sidebar.tsx b/apps/admin/src/settings/layout/sidebar.tsx similarity index 97% rename from apps/admin/src/settings/app/components/sidebar.tsx rename to apps/admin/src/settings/layout/sidebar.tsx index 59eb96e7849..a65e189b33f 100644 --- a/apps/admin/src/settings/app/components/sidebar.tsx +++ b/apps/admin/src/settings/layout/sidebar.tsx @@ -1,4 +1,4 @@ -import GhostLogo from '@/settings/app/assets/images/orb-pink.png'; +import GhostLogo from '@/settings/assets/images/orb-pink.png'; import React, {useEffect, useRef} from 'react'; import clsx from 'clsx'; import {Badge, InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, Kbd, Separator, SidebarMenu} from '@tryghost/shade/components'; @@ -9,7 +9,7 @@ import {useFocusContext} from '@tryghost/shade/app'; import {checkStripeEnabled, getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {searchKeywords as advancedSearchKeywords} from './settings/advanced/advanced-settings'; +import {searchKeywords as advancedSearchKeywords} from '@/settings/app/components/settings/advanced/advanced-settings'; import {searchKeywords as emailSearchKeywords} from '@/settings/email/search-keywords'; import {searchKeywords as emailsSearchKeywords} from '@/settings/email/emails-search-keywords'; import {searchKeywords as generalSearchKeywords} from '@/settings/general/search-keywords'; @@ -17,11 +17,11 @@ import {searchKeywords as growthSearchKeywords} from '@/settings/growth/search-k import {searchKeywords as membershipSearchKeywords} from '@/settings/membership/search-keywords'; import {searchKeywords as siteSearchKeywords} from '@/settings/site/search-keywords'; -import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; -import {useGlobalData} from './providers/global-data-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {useScrollSectionContext, useScrollSectionNav} from '@/settings/app/hooks/use-scroll-section'; -import {useSearch} from './providers/settings-app-provider'; +import useFeatureFlag from '@/settings/hooks/use-feature-flag'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {useScrollSectionContext, useScrollSectionNav} from '@/settings/hooks/use-scroll-section'; +import {useSearch} from '@/settings/providers/settings-app-context'; interface NavItemProps { icon?: React.ReactNode; diff --git a/apps/admin/src/settings/membership/access.tsx b/apps/admin/src/settings/membership/access.tsx index 5d58e3c0372..f5e68444b15 100644 --- a/apps/admin/src/settings/membership/access.tsx +++ b/apps/admin/src/settings/membership/access.tsx @@ -1,15 +1,15 @@ import React from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {Banner, Combobox, ComboboxContent, ComboboxTrigger, ComboboxValue, Field, FieldDescription, FieldError, FieldLabel, InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, MultiSelectCombobox, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Separator, Button as ShadeButton} from '@tryghost/shade/components'; import {RefreshCw} from 'lucide-react'; import {SettingGroupContent} from '@tryghost/shade/patterns'; import {getSettingValues, isSettingReadOnly, useRegenerateAccessCode} from '@tryghost/admin-x-framework/api/settings'; import {toast} from 'sonner'; import {useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -import {useLimiter} from '@/settings/app/hooks/use-limiter'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {useLimiter} from '@/settings/hooks/use-limiter'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const SITE_VISIBILITY_OPTIONS = [ { diff --git a/apps/admin/src/settings/membership/analytics.tsx b/apps/admin/src/settings/membership/analytics.tsx index d09e5b03cbf..5ca1cf32d3b 100644 --- a/apps/admin/src/settings/membership/analytics.tsx +++ b/apps/admin/src/settings/membership/analytics.tsx @@ -1,13 +1,13 @@ import React, {useEffect, useState} from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {Field, FieldContent, FieldDescription, FieldLabel, Separator, Switch} from '@tryghost/shade/components'; -import {HostLimitError, useLimiter} from '@/settings/app/hooks/use-limiter'; +import {HostLimitError, useLimiter} from '@/settings/hooks/use-limiter'; import {SettingGroupContent} from '@tryghost/shade/patterns'; import {getSettingValues, isSettingReadOnly} from '@tryghost/admin-x-framework/api/settings'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {useUpgradeRoute} from '@/settings/app/hooks/use-upgrade-route'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {useUpgradeRoute} from '@/settings/hooks/use-upgrade-route'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const Analytics: React.FC<{ keywords: string[] }> = ({keywords}) => { const { diff --git a/apps/admin/src/settings/membership/custom-fields.tsx b/apps/admin/src/settings/membership/custom-fields.tsx index 336ceb3381c..eb42a7647e7 100644 --- a/apps/admin/src/settings/membership/custom-fields.tsx +++ b/apps/admin/src/settings/membership/custom-fields.tsx @@ -1,8 +1,8 @@ import CustomFieldIcon from '@/shared/member-custom-fields/custom-field-icon'; import CustomFieldModal from './custom-fields/custom-field-modal'; import React, {useEffect, useMemo, useRef, useState} from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import useFeatureFlag from '@/settings/hooks/use-feature-flag'; import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent, Button, DragIndicator, NoValueLabel, NoValueLabelIcon, type SortableItemContainerProps, SortableList, Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade/components'; import {Inline} from '@tryghost/shade/primitives'; import {TextCursorInput} from 'lucide-react'; @@ -10,9 +10,9 @@ import {arrayMove} from '@dnd-kit/sortable'; import {inOrderOf, memberCustomFieldsDataType, useBrowseMemberCustomFieldsIncludingArchived, useReorderMemberCustomFields, userTypeForField} from '@tryghost/admin-x-framework/api/member-custom-fields'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useQueryClient} from '@tryghost/admin-x-framework'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; import type {MemberCustomField} from '@tryghost/admin-x-framework/api/member-custom-fields'; -import {DialogPortal} from '@/settings/app/components/providers/dialog-portal'; +import {DialogPortal} from '@/settings/providers/dialog-portal'; // How many fields render before the list collapses behind "Show all" — the // recommendations list's preview size. diff --git a/apps/admin/src/settings/membership/custom-fields/custom-field-modal.tsx b/apps/admin/src/settings/membership/custom-fields/custom-field-modal.tsx index 969dbedcd63..1caf4ecc41c 100644 --- a/apps/admin/src/settings/membership/custom-fields/custom-field-modal.tsx +++ b/apps/admin/src/settings/membership/custom-fields/custom-field-modal.tsx @@ -6,7 +6,7 @@ import {SettingsModal} from '@tryghost/shade/patterns'; import {ValidationError, getErrorMessage} from '@tryghost/admin-x-framework/errors'; import {memberCustomFieldUserTypes, useCreateMemberCustomField, useDeleteMemberCustomField, useEditMemberCustomField, userTypeForField} from '@tryghost/admin-x-framework/api/member-custom-fields'; import {toast} from 'sonner'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; import type {MemberCustomField} from '@tryghost/admin-x-framework/api/member-custom-fields'; diff --git a/apps/admin/src/settings/membership/gift-subscriptions.tsx b/apps/admin/src/settings/membership/gift-subscriptions.tsx index 56ed828043c..ed12264073a 100644 --- a/apps/admin/src/settings/membership/gift-subscriptions.tsx +++ b/apps/admin/src/settings/membership/gift-subscriptions.tsx @@ -1,9 +1,9 @@ import React from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; import {Button, CopyField, CopyFieldActions, CopyFieldContent, CopyFieldCopyButton, CopyFieldLabel, CopyFieldValue} from '@tryghost/shade/components'; import {SettingGroupContent} from '@tryghost/shade/patterns'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const GiftSubscriptions: React.FC<{ keywords: string[] }> = ({keywords}) => { const {siteData} = useGlobalData(); diff --git a/apps/admin/src/settings/membership/member-emails.tsx b/apps/admin/src/settings/membership/member-emails.tsx index 6bd30bd2231..d3b71f98e06 100644 --- a/apps/admin/src/settings/membership/member-emails.tsx +++ b/apps/admin/src/settings/membership/member-emails.tsx @@ -1,8 +1,8 @@ import React, {useEffect, useRef, useState} from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; import WelcomeEmailCustomizeModal from './member-emails/welcome-email-customize-modal'; import WelcomeEmailModal, {type WelcomeEmailModalProps} from './member-emails/welcome-email-modal'; -import useQueryParams from '@/settings/app/hooks/use-query-params'; +import useQueryParams from '@/settings/hooks/use-query-params'; import {APIError} from '@tryghost/admin-x-framework/errors'; import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent, Switch} from '@tryghost/shade/components'; import {Button} from '@tryghost/shade/components'; @@ -11,12 +11,12 @@ import {WELCOME_EMAIL_SLUGS, type WelcomeEmailType, getDefaultWelcomeEmailRecord import {checkStripeEnabled, getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {toast} from 'sonner'; import {useAddAutomatedEmail, useBrowseAutomatedEmails, useEditAutomatedEmail, useVerifyAutomatedEmailSender} from '@tryghost/admin-x-framework/api/automated-emails'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; import type {AutomatedEmail} from '@tryghost/admin-x-framework/api/automated-emails'; -import {DialogPortal} from '@/settings/app/components/providers/dialog-portal'; +import {DialogPortal} from '@/settings/providers/dialog-portal'; const EmailPreviewRow: React.FC<{ automatedEmail: AutomatedEmail, diff --git a/apps/admin/src/settings/membership/member-emails/member-email-editor.tsx b/apps/admin/src/settings/membership/member-emails/member-email-editor.tsx index 4b45ffcc048..eb1eb5a362f 100644 --- a/apps/admin/src/settings/membership/member-emails/member-email-editor.tsx +++ b/apps/admin/src/settings/membership/member-emails/member-email-editor.tsx @@ -1,14 +1,14 @@ -import ErrorBoundary from '@/settings/app/components/error-boundary'; +import ErrorBoundary from '@/settings/components/error-boundary'; import React, {Suspense, useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {type KoenigInstance, loadKoenig} from '@/settings/app/components/koenig-editor-base'; +import {type KoenigInstance, loadKoenig} from '@/settings/components/koenig-loader'; import {LoadingIndicator} from '@tryghost/shade/components'; import {cn} from '@tryghost/shade/utils'; import {focusKoenigEditorOnBottomClick, useFramework} from '@tryghost/admin-x-framework'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {koenigFileUploadTypes, useKoenigFetchEmbed, useKoenigFileUpload, usePinturaConfig} from '@tryghost/admin-x-framework/hooks'; import {useFocusContext} from '@tryghost/shade/app'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -import {useWelcomeEmailLinkSuggestions} from '@/settings/app/hooks/use-welcome-email-link-suggestions'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {useWelcomeEmailLinkSuggestions} from '@/settings/hooks/use-welcome-email-link-suggestions'; export interface MemberEmailsEditorProps { value?: string; @@ -85,8 +85,7 @@ const EmailEditorInner: React.FC<{ registerAPI: (API: KoenigInstance | null) => void; onChange: (data: unknown) => void; }> = ({editor, darkMode, cardConfig, initialEditorState, placeholder, className, registerAPI, onChange}) => { - // loadKoenig resolves the untyped @tryghost/koenig-lexical bundle - const {EmailEditor} = editor.read() as {EmailEditor: React.ComponentType>}; + const {EmailEditor} = editor.read(); return (
diff --git a/apps/admin/src/settings/membership/member-emails/welcome-email-customize-modal.tsx b/apps/admin/src/settings/membership/member-emails/welcome-email-customize-modal.tsx index 7b33a33c113..c2ffe503d56 100644 --- a/apps/admin/src/settings/membership/member-emails/welcome-email-customize-modal.tsx +++ b/apps/admin/src/settings/membership/member-emails/welcome-email-customize-modal.tsx @@ -3,7 +3,7 @@ import EmailPreview from '@/settings/email-design/email-preview'; import HeaderImageField from '@/settings/email-design/header-image-field'; import ShowBadgeField from '@/settings/email-design/show-badge-field'; import WelcomeEmailPreviewContent from '@/settings/email-design/welcome-email-preview-content'; -import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; +import useFeatureFlag from '@/settings/hooks/use-feature-flag'; import validator from 'validator'; import {useEditAutomatedEmailDesign, useReadAutomatedEmailDesign} from '@tryghost/admin-x-framework/api/automated-email-design'; import { @@ -31,8 +31,8 @@ import {toast} from 'sonner'; import {useAddAutomatedEmail, useBrowseAutomatedEmails, useEditAutomatedEmailSenders} from '@tryghost/admin-x-framework/api/automated-emails'; import {useCallback, useEffect, useMemo, useState} from 'react'; import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -import {useWelcomeEmailSenderDetails} from '@/settings/app/hooks/use-welcome-email-sender-details'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {useWelcomeEmailSenderDetails} from '@/settings/hooks/use-welcome-email-sender-details'; const SAVE_ERROR_TOAST_ID = 'welcome-email-design-save-error'; diff --git a/apps/admin/src/settings/membership/member-emails/welcome-email-modal.tsx b/apps/admin/src/settings/membership/member-emails/welcome-email-modal.tsx index aeb42dea28e..9c6e57e3e39 100644 --- a/apps/admin/src/settings/membership/member-emails/welcome-email-modal.tsx +++ b/apps/admin/src/settings/membership/member-emails/welcome-email-modal.tsx @@ -10,10 +10,10 @@ import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {getWelcomeEmailValidationErrors} from './welcome-email-validation'; import {useBrowseAutomatedEmails, useEditAutomatedEmail, usePreviewWelcomeEmail} from '@tryghost/admin-x-framework/api/automated-emails'; import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; import {useWelcomeEmailPreview} from './use-welcome-email-preview'; -import {useWelcomeEmailSenderDetails} from '@/settings/app/hooks/use-welcome-email-sender-details'; +import {useWelcomeEmailSenderDetails} from '@/settings/hooks/use-welcome-email-sender-details'; import TestEmailDropdown from './test-email-dropdown'; import type {AutomatedEmail} from '@tryghost/admin-x-framework/api/automated-emails'; diff --git a/apps/admin/src/settings/membership/membership-settings.tsx b/apps/admin/src/settings/membership/membership-settings.tsx index 5ea66fe386a..fac2823c8f7 100644 --- a/apps/admin/src/settings/membership/membership-settings.tsx +++ b/apps/admin/src/settings/membership/membership-settings.tsx @@ -4,14 +4,14 @@ import GiftSubscriptions from './gift-subscriptions'; import MemberEmails from './member-emails'; import Portal from './portal'; import React from 'react'; -import SearchableSection from '@/settings/app/components/searchable-section'; +import SearchableSection from '@/settings/components/searchable-section'; import SpamFilters from '@/settings/app/components/settings/advanced/spam-filters'; import Tiers from './tiers'; import TipsAndDonations from '@/settings/growth/tips-and-donations'; -import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; +import useFeatureFlag from '@/settings/hooks/use-feature-flag'; import {checkStripeEnabled, getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {searchKeywords} from './search-keywords'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; const MembershipSettings: React.FC = () => { const {config, settings} = useGlobalData(); diff --git a/apps/admin/src/settings/membership/portal.tsx b/apps/admin/src/settings/membership/portal.tsx index 2f8cffa2e90..86423ee12f7 100644 --- a/apps/admin/src/settings/membership/portal.tsx +++ b/apps/admin/src/settings/membership/portal.tsx @@ -1,12 +1,12 @@ -import FakeLogo from '@/settings/app/assets/images/portal-splash-default-logo.png'; +import FakeLogo from '@/settings/assets/images/portal-splash-default-logo.png'; import React from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import UserAddIcon from '@/settings/app/assets/images/portal-splash-user-add.png'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import UserAddIcon from '@/settings/assets/images/portal-splash-user-add.png'; import {Button} from '@tryghost/shade/components'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const SignupOptionImage: React.FC<{color:string, title: string, price: string}> = ({title, color, price}) => { return ( diff --git a/apps/admin/src/settings/membership/portal/account-page.tsx b/apps/admin/src/settings/membership/portal/account-page.tsx index aa7ab916572..29a148e22a3 100644 --- a/apps/admin/src/settings/membership/portal/account-page.tsx +++ b/apps/admin/src/settings/membership/portal/account-page.tsx @@ -4,7 +4,7 @@ import validator from 'validator'; import {Field, FieldError, FieldGroup, FieldLabel, Input} from '@tryghost/shade/components'; import {type Setting, type SettingValue, getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {fullEmailAddress, getEmailDomain} from '@tryghost/admin-x-framework/api/site'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; const AccountPage: React.FC<{ localSettings: Setting[] diff --git a/apps/admin/src/settings/membership/portal/look-and-feel.tsx b/apps/admin/src/settings/membership/portal/look-and-feel.tsx index 5baed289ce8..b5e9452e6ec 100644 --- a/apps/admin/src/settings/membership/portal/look-and-feel.tsx +++ b/apps/admin/src/settings/membership/portal/look-and-feel.tsx @@ -1,4 +1,4 @@ -import BrandIcon, {type BrandIconName} from '@/settings/app/components/icons/brand-icon'; +import BrandIcon, {type BrandIconName} from '@/settings/components/icons/brand-icon'; import React, {useState} from 'react'; import {APIError} from '@tryghost/admin-x-framework/errors'; import {Button, Field, FieldGroup, FieldLabel, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, ToggleGroup, ToggleGroupItem} from '@tryghost/shade/components'; diff --git a/apps/admin/src/settings/membership/portal/portal-links.tsx b/apps/admin/src/settings/membership/portal/portal-links.tsx index dca8e450fd8..db5636c09f8 100644 --- a/apps/admin/src/settings/membership/portal/portal-links.tsx +++ b/apps/admin/src/settings/membership/portal/portal-links.tsx @@ -5,7 +5,7 @@ import {ModalPage} from '@tryghost/shade/page-templates'; import {getHomepageUrl} from '@tryghost/admin-x-framework/api/site'; import {getPaidActiveTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; interface PortalLinkPrefs { name: string; diff --git a/apps/admin/src/settings/membership/portal/portal-modal.tsx b/apps/admin/src/settings/membership/portal/portal-modal.tsx index d2e77b9f395..d48696c85d4 100644 --- a/apps/admin/src/settings/membership/portal/portal-modal.tsx +++ b/apps/admin/src/settings/membership/portal/portal-modal.tsx @@ -3,17 +3,17 @@ import LookAndFeel from './look-and-feel'; import PortalPreview from './portal-preview'; import React, {useEffect, useState} from 'react'; import SignupOptions from './signup-options'; -import useQueryParams from '@/settings/app/hooks/use-query-params'; +import useQueryParams from '@/settings/hooks/use-query-params'; import {type Dirtyable, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {PreviewModalContent} from '@/settings/app/components/settings/preview-modal'; +import {PreviewModalContent} from '@/settings/components/preview-modal'; import {type Setting, type SettingValue, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade/components'; import {type Tier, useBrowseTiers, useEditTier} from '@tryghost/admin-x-framework/api/tiers'; import {fullEmailAddress} from '@tryghost/admin-x-framework/api/site'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {useFocusContext} from '@tryghost/shade/app'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; import {verifyEmailToken} from '@tryghost/admin-x-framework/api/email-verification'; type PreviewTab = 'signup' | 'account' | 'links'; diff --git a/apps/admin/src/settings/membership/portal/portal-preview.tsx b/apps/admin/src/settings/membership/portal/portal-preview.tsx index 08d67b9f351..90bb539ece1 100644 --- a/apps/admin/src/settings/membership/portal/portal-preview.tsx +++ b/apps/admin/src/settings/membership/portal/portal-preview.tsx @@ -3,8 +3,8 @@ import PortalLinks from './portal-links'; import React from 'react'; import {type Setting} from '@tryghost/admin-x-framework/api/settings'; import {type Tier} from '@tryghost/admin-x-framework/api/tiers'; -import {getPortalPreviewUrl} from '@/settings/app/utils/get-portal-preview-url'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {getPortalPreviewUrl} from '@/settings/utils/get-portal-preview-url'; +import {useGlobalData} from '@/settings/providers/global-data-context'; interface PortalPreviewProps { darkMode: boolean; diff --git a/apps/admin/src/settings/membership/portal/signup-options.tsx b/apps/admin/src/settings/membership/portal/signup-options.tsx index bf7f1556b5f..035fe8e7067 100644 --- a/apps/admin/src/settings/membership/portal/signup-options.tsx +++ b/apps/admin/src/settings/membership/portal/signup-options.tsx @@ -1,9 +1,9 @@ -import HtmlField from '@/settings/app/components/html-field'; +import HtmlField from '@/settings/components/html-field'; import React, {useCallback, useEffect, useMemo} from 'react'; import {Checkbox, Field, FieldGroup, FieldLabel, FieldLegend, FieldSet, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch} from '@tryghost/shade/components'; import {type Setting, type SettingValue, checkStripeEnabled, getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {type Tier, getPaidActiveTiers} from '@tryghost/admin-x-framework/api/tiers'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; type SignupCheckbox = { checked: boolean; diff --git a/apps/admin/src/settings/membership/stripe/stripe-connect-modal.tsx b/apps/admin/src/settings/membership/stripe/stripe-connect-modal.tsx index 7a581400968..3f44022b2dc 100644 --- a/apps/admin/src/settings/membership/stripe/stripe-connect-modal.tsx +++ b/apps/admin/src/settings/membership/stripe/stripe-connect-modal.tsx @@ -1,13 +1,13 @@ -import BookmarkThumb from '@/settings/app/assets/images/stripe-thumb.jpg'; -import GhostLogo from '@/settings/app/assets/images/orb-squircle.png'; -import GhostLogoPink from '@/settings/app/assets/images/orb-pink.png'; +import BookmarkThumb from '@/settings/assets/images/stripe-thumb.jpg'; +import GhostLogo from '@/settings/assets/images/orb-squircle.png'; +import GhostLogoPink from '@/settings/assets/images/orb-pink.png'; import React, {useEffect, useState} from 'react'; -import StripeButton from '@/settings/app/components/stripe-button'; -import StripeLogo from '@/settings/app/assets/images/stripe-emblem.svg'; -import StripeVerifiedBadge from '@/settings/app/assets/images/stripe-verified.svg'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import StripeButton from '@/settings/components/stripe-button'; +import StripeLogo from '@/settings/assets/images/stripe-emblem.svg'; +import StripeVerifiedBadge from '@/settings/assets/images/stripe-verified.svg'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {Button, Field, FieldError, FieldGroup, FieldLabel, Input, Switch, Textarea} from '@tryghost/shade/components'; -import {HostLimitError, useLimiter} from '@/settings/app/hooks/use-limiter'; +import {HostLimitError, useLimiter} from '@/settings/hooks/use-limiter'; import {JSONError} from '@tryghost/admin-x-framework/errors'; import {LucideIcon} from '@tryghost/shade/utils'; import {SettingsModal} from '@tryghost/shade/patterns'; @@ -17,11 +17,11 @@ import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {toast} from 'sonner'; import {useBrowseMembers} from '@tryghost/admin-x-framework/api/members'; import {useBrowseTiers, useEditTier} from '@tryghost/admin-x-framework/api/tiers'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {useUpgradeRoute} from '@/settings/app/hooks/use-upgrade-route'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {useUpgradeRoute} from '@/settings/hooks/use-upgrade-route'; const RETRY_PRODUCT_SAVE_POLL_LENGTH = 1000; const RETRY_PRODUCT_SAVE_MAX_POLL = 15 * RETRY_PRODUCT_SAVE_POLL_LENGTH; diff --git a/apps/admin/src/settings/membership/tiers.tsx b/apps/admin/src/settings/membership/tiers.tsx index 17ab32277da..e9a04736b42 100644 --- a/apps/admin/src/settings/membership/tiers.tsx +++ b/apps/admin/src/settings/membership/tiers.tsx @@ -1,23 +1,23 @@ import React, {useEffect, useRef, useState} from 'react'; -import StripeButton from '@/settings/app/components/stripe-button'; +import StripeButton from '@/settings/components/stripe-button'; import TiersList from './tiers/tiers-list'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; import clsx from 'clsx'; -import useCurrencyInput from '@/settings/app/hooks/use-currency-input'; +import useCurrencyInput from '@/settings/hooks/use-currency-input'; import {Button, Field, FieldDescription, FieldError, FieldLabel, Indicator, InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, MultiSelectCombobox, Popover, PopoverContent, PopoverTrigger, Switch, Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade/components'; import {ChevronDown} from 'lucide-react'; -import {HostLimitError, useLimiter} from '@/settings/app/hooks/use-limiter'; +import {HostLimitError, useLimiter} from '@/settings/hooks/use-limiter'; import {SettingGroupContent} from '@tryghost/shade/patterns'; import {type Setting, checkStripeEnabled, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {type Tier, getActiveTiers, getArchivedTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; -import {currencySelectGroups, validateCurrencyAmount} from '@/settings/app/utils/currency'; +import {currencySelectGroups, validateCurrencyAmount} from '@/settings/utils/currency'; import {formatNumber} from '@tryghost/shade/utils'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {useUpgradeRoute} from '@/settings/app/hooks/use-upgrade-route'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {useUpgradeRoute} from '@/settings/hooks/use-upgrade-route'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const StripeConnectedButton: React.FC<{className?: string; onClick: () => void;}> = ({className, onClick}) => { className = clsx( diff --git a/apps/admin/src/settings/membership/tiers/tier-detail-modal.tsx b/apps/admin/src/settings/membership/tiers/tier-detail-modal.tsx index 8e3b8db54dc..b721f66edf5 100644 --- a/apps/admin/src/settings/membership/tiers/tier-detail-modal.tsx +++ b/apps/admin/src/settings/membership/tiers/tier-detail-modal.tsx @@ -1,21 +1,21 @@ import React, {useEffect, useRef} from 'react'; import TierDetailPreview from './tier-detail-preview'; -import useCurrencyInput from '@/settings/app/hooks/use-currency-input'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; -import useSortableIndexedList from '@/settings/app/hooks/use-sortable-indexed-list'; -import useUrlInput from '@/settings/app/hooks/use-url-input'; +import useCurrencyInput from '@/settings/hooks/use-currency-input'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; +import useSortableIndexedList from '@/settings/hooks/use-sortable-indexed-list'; +import useUrlInput from '@/settings/hooks/use-url-input'; import {Button, Combobox, ComboboxContent, ComboboxTrigger, ComboboxValue, Field, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSet, Input, InputGroup, InputGroupAddon, InputGroupInput, InputGroupText, MultiSelectCombobox, SortableList, Switch} from '@tryghost/shade/components'; import {type ErrorMessages, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; import {Inline, Text} from '@tryghost/shade/primitives'; import {LucideIcon} from '@tryghost/shade/utils'; import {useParams} from '@tryghost/admin-x-framework'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; import {SettingsModal} from '@tryghost/shade/patterns'; import {type Tier, useAddTier, useBrowseTiers, useEditTier} from '@tryghost/admin-x-framework/api/tiers'; -import {currencies, currencySelectGroups, validateCurrencyAmount} from '@/settings/app/utils/currency'; +import {currencies, currencySelectGroups, validateCurrencyAmount} from '@/settings/utils/currency'; import {getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {toast} from 'sonner'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; export type TierFormState = Partial> & { trial_days: string; diff --git a/apps/admin/src/settings/membership/tiers/tier-detail-preview.tsx b/apps/admin/src/settings/membership/tiers/tier-detail-preview.tsx index 273afb07666..cdb354248c0 100644 --- a/apps/admin/src/settings/membership/tiers/tier-detail-preview.tsx +++ b/apps/admin/src/settings/membership/tiers/tier-detail-preview.tsx @@ -4,7 +4,7 @@ import {Button} from '@tryghost/shade/components'; import {LucideIcon, formatNumber} from '@tryghost/shade/utils'; import {Text} from '@tryghost/shade/primitives'; import {type TierFormState} from './tier-detail-modal'; -import {currencyToDecimal, getSymbol} from '@/settings/app/utils/currency'; +import {currencyToDecimal, getSymbol} from '@/settings/utils/currency'; interface TierDetailPreviewProps { tier: TierFormState; diff --git a/apps/admin/src/settings/membership/tiers/tiers-list.tsx b/apps/admin/src/settings/membership/tiers/tiers-list.tsx index 989837828f4..453992f1b72 100644 --- a/apps/admin/src/settings/membership/tiers/tiers-list.tsx +++ b/apps/admin/src/settings/membership/tiers/tiers-list.tsx @@ -4,9 +4,9 @@ import {LucideIcon} from '@tryghost/shade/utils'; import {NoValueLabel, NoValueLabelIcon} from '@tryghost/shade/components'; import {type Tier} from '@tryghost/admin-x-framework/api/tiers'; import {TrialDaysLabel} from './tier-detail-preview'; -import {currencyToDecimal, getSymbol} from '@/settings/app/utils/currency'; +import {currencyToDecimal, getSymbol} from '@/settings/utils/currency'; import {formatNumber} from '@tryghost/shade/utils'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; interface TiersListProps { tab?: 'active-tiers' | 'archive-tiers' | 'free-tier'; diff --git a/apps/admin/src/settings/providers/confirmation-context.ts b/apps/admin/src/settings/providers/confirmation-context.ts new file mode 100644 index 00000000000..b31926f52ff --- /dev/null +++ b/apps/admin/src/settings/providers/confirmation-context.ts @@ -0,0 +1,20 @@ +import {type ConfirmationModalProps} from '@/settings/components/confirmation-modal'; +import {type LimitModalProps} from '@/settings/components/limit-modal'; +import {createContext, useContext} from 'react'; + +export type ConfirmationHandle = {remove: () => void}; + +export type ConfirmationContextType = { + confirm: (props: ConfirmationModalProps) => ConfirmationHandle; + showLimit: (props: LimitModalProps) => ConfirmationHandle; +}; + +export const ConfirmationContext = createContext(null); + +export function useConfirmation(): ConfirmationContextType { + const context = useContext(ConfirmationContext); + if (!context) { + throw new Error('useConfirmation must be used inside ConfirmationProvider'); + } + return context; +} diff --git a/apps/admin/src/settings/app/components/providers/confirmation-provider.tsx b/apps/admin/src/settings/providers/confirmation-provider.tsx similarity index 72% rename from apps/admin/src/settings/app/components/providers/confirmation-provider.tsx rename to apps/admin/src/settings/providers/confirmation-provider.tsx index bb19070ffe8..5502a372318 100644 --- a/apps/admin/src/settings/app/components/providers/confirmation-provider.tsx +++ b/apps/admin/src/settings/providers/confirmation-provider.tsx @@ -1,28 +1,12 @@ -import React, {createContext, useCallback, useContext, useRef, useState} from 'react'; -import {type ConfirmationModalProps, ConfirmationModalContent} from '@/settings/app/components/confirmation-modal'; -import {type LimitModalProps, LimitModalContent} from '@/settings/app/components/limit-modal'; - -export type ConfirmationHandle = {remove: () => void}; +import React, {useCallback, useRef, useState} from 'react'; +import {type ConfirmationHandle, ConfirmationContext} from './confirmation-context'; +import {type ConfirmationModalProps, ConfirmationModalContent} from '@/settings/components/confirmation-modal'; +import {type LimitModalProps, LimitModalContent} from '@/settings/components/limit-modal'; type ConfirmationRequest = | {id: number; kind: 'confirm'; props: ConfirmationModalProps} | {id: number; kind: 'limit'; props: LimitModalProps}; -type ConfirmationContextType = { - confirm: (props: ConfirmationModalProps) => ConfirmationHandle; - showLimit: (props: LimitModalProps) => ConfirmationHandle; -}; - -const ConfirmationContext = createContext(null); - -export function useConfirmation(): ConfirmationContextType { - const context = useContext(ConfirmationContext); - if (!context) { - throw new Error('useConfirmation must be used inside ConfirmationProvider'); - } - return context; -} - export const ConfirmationProvider: React.FC<{children: React.ReactNode}> = ({children}) => { const [requests, setRequests] = useState([]); const nextId = useRef(0); diff --git a/apps/admin/src/settings/app/components/providers/dialog-portal.tsx b/apps/admin/src/settings/providers/dialog-portal.tsx similarity index 100% rename from apps/admin/src/settings/app/components/providers/dialog-portal.tsx rename to apps/admin/src/settings/providers/dialog-portal.tsx diff --git a/apps/admin/src/settings/providers/global-data-context.ts b/apps/admin/src/settings/providers/global-data-context.ts new file mode 100644 index 00000000000..323d9d6f695 --- /dev/null +++ b/apps/admin/src/settings/providers/global-data-context.ts @@ -0,0 +1,16 @@ +import {type Config} from '@tryghost/admin-x-framework/api/config'; +import {type Setting} from '@tryghost/admin-x-framework/api/settings'; +import {type SiteData} from '@tryghost/admin-x-framework/api/site'; +import {type User} from '@tryghost/admin-x-framework/api/users'; +import {createContext, useContext} from 'react'; + +export interface GlobalData { + settings: Setting[] + siteData: SiteData + config: Config + currentUser: User +} + +export const GlobalDataContext = createContext(undefined); + +export const useGlobalData = () => useContext(GlobalDataContext)!; diff --git a/apps/admin/src/settings/app/components/providers/global-data-provider.tsx b/apps/admin/src/settings/providers/global-data-provider.tsx similarity index 69% rename from apps/admin/src/settings/app/components/providers/global-data-provider.tsx rename to apps/admin/src/settings/providers/global-data-provider.tsx index 26fc6c89eed..5082053cd98 100644 --- a/apps/admin/src/settings/app/components/providers/global-data-provider.tsx +++ b/apps/admin/src/settings/providers/global-data-provider.tsx @@ -1,21 +1,12 @@ -import SpinningOrb from '@/settings/app/assets/videos/logo-loader.mp4'; -import SpinningOrbDark from '@/settings/app/assets/videos/logo-loader-dark.mp4'; -import {type Config, useBrowseConfig} from '@tryghost/admin-x-framework/api/config'; -import {type ReactNode, createContext, useContext, useEffect, useState} from 'react'; -import {type Setting, useBrowseSettings} from '@tryghost/admin-x-framework/api/settings'; -import {type SiteData, useBrowseSite} from '@tryghost/admin-x-framework/api/site'; -import {type User} from '@tryghost/admin-x-framework/api/users'; +import SpinningOrb from '@/settings/assets/videos/logo-loader.mp4'; +import SpinningOrbDark from '@/settings/assets/videos/logo-loader-dark.mp4'; +import {GlobalDataContext} from './global-data-context'; +import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config'; +import {type ReactNode, useEffect, useState} from 'react'; +import {useBrowseSettings} from '@tryghost/admin-x-framework/api/settings'; +import {useBrowseSite} from '@tryghost/admin-x-framework/api/site'; import {useCurrentUser} from '@tryghost/admin-x-framework/api/current-user'; -interface GlobalData { - settings: Setting[] - siteData: SiteData - config: Config - currentUser: User -} - -const GlobalDataContext = createContext(undefined); - const GlobalDataProvider = ({children}: { children: ReactNode }) => { const settings = useBrowseSettings(); const site = useBrowseSite(); @@ -72,6 +63,4 @@ const GlobalDataProvider = ({children}: { children: ReactNode }) => { ; }; -export const useGlobalData = () => useContext(GlobalDataContext)!; - export default GlobalDataProvider; diff --git a/apps/admin/src/settings/app/components/providers/routing/staff-profile-paths.tsx b/apps/admin/src/settings/providers/routing/staff-profile-paths.tsx similarity index 100% rename from apps/admin/src/settings/app/components/providers/routing/staff-profile-paths.tsx rename to apps/admin/src/settings/providers/routing/staff-profile-paths.tsx diff --git a/apps/admin/src/settings/app/components/providers/settings-app-provider.tsx b/apps/admin/src/settings/providers/settings-app-context.ts similarity index 53% rename from apps/admin/src/settings/app/components/providers/settings-app-provider.tsx rename to apps/admin/src/settings/providers/settings-app-context.ts index 1c3697e45bc..ff10f5d1aa2 100644 --- a/apps/admin/src/settings/app/components/providers/settings-app-provider.tsx +++ b/apps/admin/src/settings/providers/settings-app-context.ts @@ -1,10 +1,8 @@ -import GlobalDataProvider from './global-data-provider'; -import useSearchService, {type ComponentId, type SearchService} from '@/settings/app/utils/search'; -import {type ReactNode, createContext, useContext, useState} from 'react'; -import {ScrollSectionProvider} from '@/settings/app/hooks/use-scroll-section'; +import {type ComponentId, type SearchService} from '@/settings/utils/search'; import {type ZapierTemplate} from '@/settings/app/components/settings/advanced/integrations/zapier-modal'; -import {officialThemes} from '@/settings/app/data/official-themes'; -import {zapierTemplates} from '@/settings/app/data/zapier-templates'; +import {createContext, useContext} from 'react'; +import {officialThemes} from '@/settings/data/official-themes'; +import {zapierTemplates} from '@/settings/data/zapier-templates'; export type ThemeVariant = { category: string; @@ -33,7 +31,7 @@ export interface UpgradeStatusType { message: string; } -interface SettingsAppContextType { +export interface SettingsAppContextType { officialThemes: OfficialTheme[]; zapierTemplates: ZapierTemplate[]; search: SearchService; @@ -44,7 +42,7 @@ interface SettingsAppContextType { setOffersShowArchived: (show: boolean) => void; } -const SettingsAppContext = createContext({ +export const SettingsAppContext = createContext({ officialThemes, zapierTemplates, search: { @@ -64,43 +62,6 @@ const SettingsAppContext = createContext({ setOffersShowArchived: () => {} }); -type SettingsAppProviderProps = Partial> & {children: ReactNode}; - -const SettingsAppProvider: React.FC = ({children, ...props}) => { - const search = useSearchService(); - - // a few sane defaults for keeping a sorting state - const [sortingState, setSortingState] = useState([{ - type: 'offers', - option: 'date-added', - direction: 'desc' - }]); - - const [offersShowArchived, setOffersShowArchived] = useState(false); - - return ( - - - - {children} - - - - ); -}; - -export default SettingsAppProvider; - export const useSettingsApp = () => useContext(SettingsAppContext); export const useOfficialThemes = () => useSettingsApp().officialThemes; diff --git a/apps/admin/src/settings/providers/settings-app-provider.tsx b/apps/admin/src/settings/providers/settings-app-provider.tsx new file mode 100644 index 00000000000..90d4491a05a --- /dev/null +++ b/apps/admin/src/settings/providers/settings-app-provider.tsx @@ -0,0 +1,44 @@ +import GlobalDataProvider from './global-data-provider'; +import useSearchService from '@/settings/utils/search'; +import {type ReactNode, useState} from 'react'; +import {ScrollSectionProvider} from '@/settings/hooks/scroll-section-provider'; +import {SettingsAppContext, type SettingsAppContextType, type Sorting} from './settings-app-context'; +import {officialThemes} from '@/settings/data/official-themes'; +import {zapierTemplates} from '@/settings/data/zapier-templates'; + +type SettingsAppProviderProps = Partial> & {children: ReactNode}; + +const SettingsAppProvider: React.FC = ({children, ...props}) => { + const search = useSearchService(); + + // a few sane defaults for keeping a sorting state + const [sortingState, setSortingState] = useState([{ + type: 'offers', + option: 'date-added', + direction: 'desc' + }]); + + const [offersShowArchived, setOffersShowArchived] = useState(false); + + return ( + + + + {children} + + + + ); +}; + +export default SettingsAppProvider; diff --git a/apps/admin/src/settings/app/components/settings/search-keywords.test.ts b/apps/admin/src/settings/search-keywords.test.ts similarity index 100% rename from apps/admin/src/settings/app/components/settings/search-keywords.test.ts rename to apps/admin/src/settings/search-keywords.test.ts diff --git a/apps/admin/src/settings/settings-access.ts b/apps/admin/src/settings/settings-access.ts index e2d45fb1381..612d64309e9 100644 --- a/apps/admin/src/settings/settings-access.ts +++ b/apps/admin/src/settings/settings-access.ts @@ -1,5 +1,5 @@ import { canAccessSettings } from "@tryghost/admin-x-framework/api/users"; -import { getStaffProfileSlug } from "@/settings/app/components/providers/routing/staff-profile-paths"; +import { getStaffProfileSlug } from "@/settings/providers/routing/staff-profile-paths"; import type { AccessRule } from "@/route-access-guard"; const SETTINGS_PREFIX = /^\/settings\/?/; diff --git a/apps/admin/src/settings/settings.tsx b/apps/admin/src/settings/settings.tsx index 55606e3af1c..d9b25bea080 100644 --- a/apps/admin/src/settings/settings.tsx +++ b/apps/admin/src/settings/settings.tsx @@ -1,4 +1,4 @@ -import { App } from "./app/app"; +import { App } from "./layout/app"; export default function Settings() { // Full-screen takeover inside the shell tree (automations-editor pattern); diff --git a/apps/admin/src/settings/site/announcement-bar-modal.tsx b/apps/admin/src/settings/site/announcement-bar-modal.tsx index cdc33cad678..1ec11a33beb 100644 --- a/apps/admin/src/settings/site/announcement-bar-modal.tsx +++ b/apps/admin/src/settings/site/announcement-bar-modal.tsx @@ -1,18 +1,18 @@ import AnnouncementBarPreview from './announcement-bar/announcement-bar-preview'; -import ColorSwatchField from '@/settings/app/components/color-swatch-field'; -import HtmlField from '@/settings/app/components/html-field'; +import ColorSwatchField from '@/settings/components/color-swatch-field'; +import HtmlField from '@/settings/components/html-field'; import React, {useRef, useState} from 'react'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {Checkbox, Field, FieldGroup, FieldLabel, FieldLegend, FieldSet, PreviewChrome, Tabs, TabsList, TabsTrigger, ToggleGroup, ToggleGroupItem} from '@tryghost/shade/components'; import {Laptop, Smartphone} from 'lucide-react'; -import {PreviewModalContent} from '@/settings/app/components/settings/preview-modal'; -import {debounce} from '@/settings/app/utils/debounce'; +import {PreviewModalContent} from '@/settings/components/preview-modal'; +import {debounce} from '@/settings/utils/debounce'; import {getHomepageUrl} from '@tryghost/admin-x-framework/api/site'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {toast} from 'sonner'; import {useBrowsePosts} from '@tryghost/admin-x-framework/api/posts'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; type SidebarProps = { announcementContent?: string; diff --git a/apps/admin/src/settings/site/announcement-bar.tsx b/apps/admin/src/settings/site/announcement-bar.tsx index f4ae89799a7..47d9cd6a08c 100644 --- a/apps/admin/src/settings/site/announcement-bar.tsx +++ b/apps/admin/src/settings/site/announcement-bar.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; import {Button} from '@tryghost/shade/components'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const AnnouncementBar: React.FC<{ keywords: string[] }> = ({keywords}) => { const {updateRoute} = useSettingsNavigation(); diff --git a/apps/admin/src/settings/site/announcement-bar/announcement-bar-preview.tsx b/apps/admin/src/settings/site/announcement-bar/announcement-bar-preview.tsx index db962da6854..2fd007f7502 100644 --- a/apps/admin/src/settings/site/announcement-bar/announcement-bar-preview.tsx +++ b/apps/admin/src/settings/site/announcement-bar/announcement-bar-preview.tsx @@ -1,4 +1,4 @@ -import IframeBuffering from '@/settings/app/utils/iframe-buffering'; +import IframeBuffering from '@/settings/utils/iframe-buffering'; import React, {useCallback, useMemo} from 'react'; const getPreviewData = (announcementBackgroundColor?: string, announcementContent?: string, visibility?: string[]) => { diff --git a/apps/admin/src/settings/site/change-theme.tsx b/apps/admin/src/settings/site/change-theme.tsx index 21af8725911..eb8e5582fb4 100644 --- a/apps/admin/src/settings/site/change-theme.tsx +++ b/apps/admin/src/settings/site/change-theme.tsx @@ -1,16 +1,16 @@ import React, {useEffect, useState} from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; import {Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from '@tryghost/shade/components'; import {LucideIcon} from '@tryghost/shade/utils'; import {SettingGroupContent} from '@tryghost/shade/patterns'; import {Text} from '@tryghost/shade/primitives'; import {type Theme, useBrowseThemes} from '@tryghost/admin-x-framework/api/themes'; import {downloadFile, getGhostPaths} from '@tryghost/admin-x-framework/helpers'; -import {useCheckThemeLimitError} from '@/settings/app/hooks/use-check-theme-limit-error'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {useUpgradeRoute} from '@/settings/app/hooks/use-upgrade-route'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useCheckThemeLimitError} from '@/settings/hooks/use-check-theme-limit-error'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {useUpgradeRoute} from '@/settings/hooks/use-upgrade-route'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const ChangeTheme: React.FC<{ keywords: string[] }> = ({keywords}) => { const [themeLimitError, setThemeLimitError] = useState(null); diff --git a/apps/admin/src/settings/site/design-and-branding/global-settings.tsx b/apps/admin/src/settings/site/design-and-branding/global-settings.tsx index 3f632b06d3e..9540a992778 100644 --- a/apps/admin/src/settings/site/design-and-branding/global-settings.tsx +++ b/apps/admin/src/settings/site/design-and-branding/global-settings.tsx @@ -1,8 +1,8 @@ -import ColorPickerField from '@/settings/app/components/color-picker-field'; +import ColorPickerField from '@/settings/components/color-picker-field'; import React, {useState} from 'react'; -import UnsplashSelector from '@/settings/app/components/selectors/unsplash-selector'; +import UnsplashSelector from '@/settings/components/selectors/unsplash-selector'; import clsx from 'clsx'; -import usePinturaEditor from '@/settings/app/hooks/use-pintura-editor'; +import usePinturaEditor from '@/settings/hooks/use-pintura-editor'; import {APIError} from '@tryghost/admin-x-framework/errors'; import {CUSTOM_FONTS} from '@tryghost/custom-fonts'; import {Field, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSet, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@tryghost/shade/components'; @@ -13,7 +13,7 @@ import {type Theme, useBrowseThemes} from '@tryghost/admin-x-framework/api/theme import {formatNumber} from '@tryghost/shade/utils'; import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images'; import {useFramework} from '@tryghost/admin-x-framework'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import type {BodyFontName, HeadingFontName} from '@tryghost/custom-fonts'; diff --git a/apps/admin/src/settings/site/design-and-branding/theme-preview.tsx b/apps/admin/src/settings/site/design-and-branding/theme-preview.tsx index 6ca7b2489b8..6a129a03618 100644 --- a/apps/admin/src/settings/site/design-and-branding/theme-preview.tsx +++ b/apps/admin/src/settings/site/design-and-branding/theme-preview.tsx @@ -1,7 +1,7 @@ -import IframeBuffering from '@/settings/app/utils/iframe-buffering'; +import IframeBuffering from '@/settings/utils/iframe-buffering'; import React, {useCallback} from 'react'; import {type CustomThemeSetting, hiddenCustomThemeSettingValue} from '@tryghost/admin-x-framework/api/custom-theme-settings'; -import {isCustomThemeSettingVisible} from '@/settings/app/utils/is-custom-theme-settings-visible'; +import {isCustomThemeSettingVisible} from '@/settings/utils/is-custom-theme-settings-visible'; type GlobalSettings = { description: string; diff --git a/apps/admin/src/settings/site/design-and-branding/theme-setting.tsx b/apps/admin/src/settings/site/design-and-branding/theme-setting.tsx index 01018762d4a..51de3540320 100644 --- a/apps/admin/src/settings/site/design-and-branding/theme-setting.tsx +++ b/apps/admin/src/settings/site/design-and-branding/theme-setting.tsx @@ -1,4 +1,4 @@ -import ColorPickerField from '@/settings/app/components/color-picker-field'; +import ColorPickerField from '@/settings/components/color-picker-field'; import React, {useEffect, useState} from 'react'; import {type CustomThemeSetting} from '@tryghost/admin-x-framework/api/custom-theme-settings'; import {Field, FieldContent, FieldDescription, FieldLabel, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch} from '@tryghost/shade/components'; diff --git a/apps/admin/src/settings/site/design-and-branding/theme-settings.tsx b/apps/admin/src/settings/site/design-and-branding/theme-settings.tsx index 4a48016b6e8..a83819335eb 100644 --- a/apps/admin/src/settings/site/design-and-branding/theme-settings.tsx +++ b/apps/admin/src/settings/site/design-and-branding/theme-settings.tsx @@ -1,10 +1,10 @@ import React from 'react'; import ThemeSetting from './theme-setting'; -import useCustomFonts from '@/settings/app/hooks/use-custom-fonts'; +import useCustomFonts from '@/settings/hooks/use-custom-fonts'; import {type CustomThemeSetting} from '@tryghost/admin-x-framework/api/custom-theme-settings'; import {FieldGroup, FieldLegend, FieldSet} from '@tryghost/shade/components'; import {type Theme, useBrowseThemes} from '@tryghost/admin-x-framework/api/themes'; -import {isCustomThemeSettingVisible} from '@/settings/app/utils/is-custom-theme-settings-visible'; +import {isCustomThemeSettingVisible} from '@/settings/utils/is-custom-theme-settings-visible'; interface ThemeSettingsProps { sections: Array<{ diff --git a/apps/admin/src/settings/site/design-and-theme-modal.tsx b/apps/admin/src/settings/site/design-and-theme-modal.tsx index 4b07fe98426..920436248eb 100644 --- a/apps/admin/src/settings/site/design-and-theme-modal.tsx +++ b/apps/admin/src/settings/site/design-and-theme-modal.tsx @@ -2,11 +2,11 @@ import ChangeThemeModal from './theme-modal'; import DesignModal from './design-modal'; import React, {useCallback, useEffect, useState} from 'react'; import ThemeCodeEditorModal from './theme/theme-code-editor-modal'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {useUpgradeRoute} from '@/settings/app/hooks/use-upgrade-route'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {useUpgradeRoute} from '@/settings/hooks/use-upgrade-route'; import {parseEditingThemeRoute} from './theme/theme-editor-utils'; -import {useCheckThemeLimitError} from '@/settings/app/hooks/use-check-theme-limit-error'; +import {useCheckThemeLimitError} from '@/settings/hooks/use-check-theme-limit-error'; const DesignAndThemeModal: React.FC = () => { const {route, updateRoute} = useSettingsNavigation(); diff --git a/apps/admin/src/settings/site/design-modal.tsx b/apps/admin/src/settings/site/design-modal.tsx index e5b0093ad73..1dabdeac4bb 100644 --- a/apps/admin/src/settings/site/design-modal.tsx +++ b/apps/admin/src/settings/site/design-modal.tsx @@ -2,17 +2,17 @@ import GlobalSettings, {type GlobalSettingValues} from './design-and-branding/gl import React, {useEffect, useState} from 'react'; import ThemePreview from './design-and-branding/theme-preview'; import ThemeSettings from './design-and-branding/theme-settings'; -import useQueryParams from '@/settings/app/hooks/use-query-params'; +import useQueryParams from '@/settings/hooks/use-query-params'; import {type CustomThemeSetting, useBrowseCustomThemeSettings, useEditCustomThemeSettings} from '@tryghost/admin-x-framework/api/custom-theme-settings'; import {Laptop, Smartphone} from 'lucide-react'; import {PreviewChrome, Tabs, TabsContent, TabsList, TabsTrigger, ToggleGroup, ToggleGroupItem} from '@tryghost/shade/components'; -import {PreviewModalContent} from '@/settings/app/components/settings/preview-modal'; +import {PreviewModalContent} from '@/settings/components/preview-modal'; import {type Setting, type SettingValue, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {getHomepageUrl} from '@tryghost/admin-x-framework/api/site'; import {useBrowsePosts} from '@tryghost/admin-x-framework/api/posts'; import {type Dirtyable, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; const Sidebar: React.FC<{ globalSettings: GlobalSettingValues diff --git a/apps/admin/src/settings/site/design-setting.tsx b/apps/admin/src/settings/site/design-setting.tsx index 08ff3bf48d2..c204070ec2f 100644 --- a/apps/admin/src/settings/site/design-setting.tsx +++ b/apps/admin/src/settings/site/design-setting.tsx @@ -1,9 +1,9 @@ -import DesignSettingsImg from '@/settings/app/assets/images/design-settings.png'; +import DesignSettingsImg from '@/settings/assets/images/design-settings.png'; import React from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; import {Button} from '@tryghost/shade/components'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const DesignSetting: React.FC<{ keywords: string[] }> = ({keywords}) => { const {updateRoute} = useSettingsNavigation(); diff --git a/apps/admin/src/settings/site/navigation-modal.tsx b/apps/admin/src/settings/site/navigation-modal.tsx index 1f32b936d42..02adf8ed97e 100644 --- a/apps/admin/src/settings/site/navigation-modal.tsx +++ b/apps/admin/src/settings/site/navigation-modal.tsx @@ -1,15 +1,15 @@ import NavigationEditForm from './navigation/navigation-edit-form'; -import useNavigationEditor, {type NavigationItem} from '@/settings/app/hooks/site/use-navigation-editor'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import useNavigationEditor, {type NavigationItem} from '@/settings/hooks/site/use-navigation-editor'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {SettingsModal} from '@tryghost/shade/patterns'; import {Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade/components'; import {APIError} from '@tryghost/admin-x-framework/errors'; import {checkStripeEnabled, getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useCallback, useMemo, useState} from 'react'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; function NavigationModal() { const {updateRoute} = useSettingsNavigation(); diff --git a/apps/admin/src/settings/site/navigation.tsx b/apps/admin/src/settings/site/navigation.tsx index c261ffb57e9..de375c02a09 100644 --- a/apps/admin/src/settings/site/navigation.tsx +++ b/apps/admin/src/settings/site/navigation.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; import {Button} from '@tryghost/shade/components'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const Navigation: React.FC<{ keywords: string[] }> = ({keywords}) => { const {updateRoute} = useSettingsNavigation(); diff --git a/apps/admin/src/settings/site/navigation/navigation-edit-form.tsx b/apps/admin/src/settings/site/navigation/navigation-edit-form.tsx index 403c39df913..97d43fa75fa 100644 --- a/apps/admin/src/settings/site/navigation/navigation-edit-form.tsx +++ b/apps/admin/src/settings/site/navigation/navigation-edit-form.tsx @@ -3,7 +3,7 @@ import React from 'react'; import {Button, SortableList} from '@tryghost/shade/components'; import {Inline} from '@tryghost/shade/primitives'; import {LucideIcon} from '@tryghost/shade/utils'; -import {type NavigationEditor} from '@/settings/app/hooks/site/use-navigation-editor'; +import {type NavigationEditor} from '@/settings/hooks/site/use-navigation-editor'; import {navigationColumnClasses, navigationDragHandleSpacerClasses, navigationRowClasses} from './navigation-layout'; const columnLabelClasses = 'text-xs font-semibold text-muted-foreground'; diff --git a/apps/admin/src/settings/site/navigation/navigation-icon-upload.tsx b/apps/admin/src/settings/site/navigation/navigation-icon-upload.tsx index 873bf118193..6d773b3b697 100644 --- a/apps/admin/src/settings/site/navigation/navigation-icon-upload.tsx +++ b/apps/admin/src/settings/site/navigation/navigation-icon-upload.tsx @@ -1,7 +1,7 @@ import React from 'react'; import {Trash2, Upload} from 'lucide-react'; import {ImageUpload, ImageUploadAction, ImageUploadActions, ImageUploadDropzone, ImageUploadImage, ImageUploadPreview} from '@tryghost/shade/patterns'; -import {type EditableItem, type NavigationItem, type NavigationItemErrors} from '@/settings/app/hooks/site/use-navigation-editor'; +import {type EditableItem, type NavigationItem, type NavigationItemErrors} from '@/settings/hooks/site/use-navigation-editor'; type NavigationIconUploadProps = { idPrefix: string; diff --git a/apps/admin/src/settings/site/navigation/navigation-item-editor.tsx b/apps/admin/src/settings/site/navigation/navigation-item-editor.tsx index 7fac5be198b..babf72afcd0 100644 --- a/apps/admin/src/settings/site/navigation/navigation-item-editor.tsx +++ b/apps/admin/src/settings/site/navigation/navigation-item-editor.tsx @@ -2,11 +2,11 @@ import NavigationIconUpload from './navigation-icon-upload'; import NavigationVisibilityDropdown from './navigation-visibility-dropdown'; import React, {type ReactNode} from 'react'; import clsx from 'clsx'; -import useUrlInput from '@/settings/app/hooks/use-url-input'; -import {type EditableItem, type NavigationItem, type NavigationItemErrors} from '@/settings/app/hooks/site/use-navigation-editor'; +import useUrlInput from '@/settings/hooks/use-url-input'; +import {type EditableItem, type NavigationItem, type NavigationItemErrors} from '@/settings/hooks/site/use-navigation-editor'; import {Field, FieldError, FieldLabel, Input} from '@tryghost/shade/components'; import {Inline} from '@tryghost/shade/primitives'; -import {formatUrl} from '@/settings/app/utils/format-url'; +import {formatUrl} from '@/settings/utils/format-url'; import {navigationColumnClasses, navigationFieldOffsetClass, navigationRowClasses} from './navigation-layout'; export type NavigationItemEditorProps = React.HTMLAttributes & { diff --git a/apps/admin/src/settings/site/navigation/navigation-visibility-dropdown.tsx b/apps/admin/src/settings/site/navigation/navigation-visibility-dropdown.tsx index 951cecce4d7..25cf727bceb 100644 --- a/apps/admin/src/settings/site/navigation/navigation-visibility-dropdown.tsx +++ b/apps/admin/src/settings/site/navigation/navigation-visibility-dropdown.tsx @@ -1,7 +1,7 @@ import React from 'react'; import {ChevronDown} from 'lucide-react'; import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Switch} from '@tryghost/shade/components'; -import {type EditableItem, type NavigationItem, type NavigationItemErrors, type NavigationItemVisibility} from '@/settings/app/hooks/site/use-navigation-editor'; +import {type EditableItem, type NavigationItem, type NavigationItemErrors, type NavigationItemVisibility} from '@/settings/hooks/site/use-navigation-editor'; import { type VisibilityAudience, getAudienceVisibility, diff --git a/apps/admin/src/settings/site/navigation/navigation-visibility.ts b/apps/admin/src/settings/site/navigation/navigation-visibility.ts index 24aad23149c..150ac802a76 100644 --- a/apps/admin/src/settings/site/navigation/navigation-visibility.ts +++ b/apps/admin/src/settings/site/navigation/navigation-visibility.ts @@ -1,4 +1,4 @@ -import {NAVIGATION_ITEM_VISIBILITY, type NavigationItemVisibility} from '@/settings/app/hooks/site/use-navigation-editor'; +import {NAVIGATION_ITEM_VISIBILITY, type NavigationItemVisibility} from '@/settings/hooks/site/use-navigation-editor'; export const navigationVisibilityLabels = { public: 'Everyone', diff --git a/apps/admin/src/settings/site/site-settings.tsx b/apps/admin/src/settings/site/site-settings.tsx index c328a953cbd..19ad247726a 100644 --- a/apps/admin/src/settings/site/site-settings.tsx +++ b/apps/admin/src/settings/site/site-settings.tsx @@ -3,7 +3,7 @@ import ChangeTheme from './change-theme'; import DesignSetting from './design-setting'; import Navigation from './navigation'; import React from 'react'; -import SearchableSection from '@/settings/app/components/searchable-section'; +import SearchableSection from '@/settings/components/searchable-section'; import {searchKeywords} from './search-keywords'; const SiteSettings: React.FC = () => { diff --git a/apps/admin/src/settings/site/theme-modal.tsx b/apps/admin/src/settings/site/theme-modal.tsx index 446784a87ab..de7e783328b 100644 --- a/apps/admin/src/settings/site/theme-modal.tsx +++ b/apps/admin/src/settings/site/theme-modal.tsx @@ -7,14 +7,14 @@ import ThemePreview from './theme/theme-preview'; import {Button, Dropzone, LoadingIndicator, Tabs, TabsList, TabsTrigger} from '@tryghost/shade/components'; import {type InstalledTheme, type Theme, type ThemesInstallResponseType, isDefaultOrLegacyTheme, useActivateTheme, useBrowseThemes, useInstallTheme, useUploadTheme} from '@tryghost/admin-x-framework/api/themes'; import {JSONError} from '@tryghost/admin-x-framework/errors'; -import {type OfficialTheme} from '@/settings/app/components/providers/settings-app-provider'; +import {type OfficialTheme} from '@/settings/providers/settings-app-context'; import {PageHeader, SettingsModal} from '@tryghost/shade/patterns'; import {toast} from 'sonner'; -import {useCheckThemeLimitError} from '@/settings/app/hooks/use-check-theme-limit-error'; -import {type ConfirmationHandle, useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {useCheckThemeLimitError} from '@/settings/hooks/use-check-theme-limit-error'; +import {type ConfirmationHandle, useConfirmation} from '@/settings/providers/confirmation-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {useUpgradeRoute} from '@/settings/app/hooks/use-upgrade-route'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {useUpgradeRoute} from '@/settings/hooks/use-upgrade-route'; interface ThemeToolbarProps { selectedTheme: OfficialTheme|null; diff --git a/apps/admin/src/settings/site/theme/advanced-theme-settings.tsx b/apps/admin/src/settings/site/theme/advanced-theme-settings.tsx index 92ff686f731..c6cfe166e8d 100644 --- a/apps/admin/src/settings/site/theme/advanced-theme-settings.tsx +++ b/apps/admin/src/settings/site/theme/advanced-theme-settings.tsx @@ -1,6 +1,6 @@ import InvalidThemeModal, {type FatalErrors} from './invalid-theme-modal'; import React, {useState} from 'react'; -import useCustomFonts from '@/settings/app/hooks/use-custom-fonts'; +import useCustomFonts from '@/settings/hooks/use-custom-fonts'; import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from '@tryghost/shade/components'; import {JSONError} from '@tryghost/admin-x-framework/errors'; import {LucideIcon} from '@tryghost/shade/utils'; @@ -8,11 +8,11 @@ import {ModalPage} from '@tryghost/shade/page-templates'; import {type Theme, isActiveTheme, isDefaultTheme, isDeletableTheme, isLegacyTheme, useActivateTheme, useDeleteTheme} from '@tryghost/admin-x-framework/api/themes'; import {downloadFile, getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {toast} from 'sonner'; -import {useCheckThemeLimitError} from '@/settings/app/hooks/use-check-theme-limit-error'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {useCheckThemeLimitError} from '@/settings/hooks/use-check-theme-limit-error'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {useUpgradeRoute} from '@/settings/app/hooks/use-upgrade-route'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {useUpgradeRoute} from '@/settings/hooks/use-upgrade-route'; interface ThemeActionProps { theme: Theme; diff --git a/apps/admin/src/settings/site/theme/invalid-theme-modal.tsx b/apps/admin/src/settings/site/theme/invalid-theme-modal.tsx index 0ab01ee916d..e238126f93d 100644 --- a/apps/admin/src/settings/site/theme/invalid-theme-modal.tsx +++ b/apps/admin/src/settings/site/theme/invalid-theme-modal.tsx @@ -1,5 +1,5 @@ import React, {type ReactNode} from 'react'; -import {ConfirmationModalContent} from '@/settings/app/components/confirmation-modal'; +import {ConfirmationModalContent} from '@/settings/components/confirmation-modal'; import {ErrorTextCard, ThemeValidationDetailsDisclosure, ValidationProblemCard} from './theme-validation-details'; import {type FatalErrors, getIssuesFromFatalErrors} from './theme-validation-issues'; import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config'; diff --git a/apps/admin/src/settings/site/theme/official-themes.tsx b/apps/admin/src/settings/site/theme/official-themes.tsx index ed012840239..ddd776f8d8a 100644 --- a/apps/admin/src/settings/site/theme/official-themes.tsx +++ b/apps/admin/src/settings/site/theme/official-themes.tsx @@ -1,8 +1,8 @@ -import MarketplaceBgImage from '@/settings/app/assets/images/footer-marketplace-bg.png'; +import MarketplaceBgImage from '@/settings/assets/images/footer-marketplace-bg.png'; import React, {useEffect, useState} from 'react'; import clsx from 'clsx'; import {ModalPage} from '@tryghost/shade/page-templates'; -import {type OfficialTheme, type ThemeVariant, useOfficialThemes} from '@/settings/app/components/providers/settings-app-provider'; +import {type OfficialTheme, type ThemeVariant, useOfficialThemes} from '@/settings/providers/settings-app-context'; import {Text} from '@tryghost/shade/primitives'; const VARIANT_LOOP_INTERVAL = 3000; diff --git a/apps/admin/src/settings/site/theme/theme-code-editor-modal.tsx b/apps/admin/src/settings/site/theme/theme-code-editor-modal.tsx index 8357601e72d..0dc89fc3c76 100644 --- a/apps/admin/src/settings/site/theme/theme-code-editor-modal.tsx +++ b/apps/admin/src/settings/site/theme/theme-code-editor-modal.tsx @@ -27,7 +27,7 @@ import {useBrowseThemes} from '@tryghost/admin-x-framework/api/themes'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useQueryClient} from '@tanstack/react-query'; import {formatNumber} from '@tryghost/shade/utils'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; import type {SelectedNode} from './theme-file-tree'; import type {ThemeEditorConfirmModalProps} from './theme-editor-confirm-modal'; import type {ThemeEditorFile} from './theme-editor-utils'; diff --git a/apps/admin/src/settings/site/theme/theme-installed-modal.tsx b/apps/admin/src/settings/site/theme/theme-installed-modal.tsx index dcd3d5c6d53..3d53dd7a1bf 100644 --- a/apps/admin/src/settings/site/theme/theme-installed-modal.tsx +++ b/apps/admin/src/settings/site/theme/theme-installed-modal.tsx @@ -1,6 +1,6 @@ import React, {type ReactNode} from 'react'; -import useCustomFonts from '@/settings/app/hooks/use-custom-fonts'; -import {ConfirmationModalContent} from '@/settings/app/components/confirmation-modal'; +import useCustomFonts from '@/settings/hooks/use-custom-fonts'; +import {ConfirmationModalContent} from '@/settings/components/confirmation-modal'; import {type InstalledTheme, useActivateTheme} from '@tryghost/admin-x-framework/api/themes'; import {OutcomeBanner, ThemeValidationDetailsDisclosure} from './theme-validation-details'; import {getIssuesFromInstalledTheme} from './theme-validation-issues'; diff --git a/apps/admin/src/settings/site/theme/theme-preview.tsx b/apps/admin/src/settings/site/theme/theme-preview.tsx index 15294862559..3fc3fa04500 100644 --- a/apps/admin/src/settings/site/theme/theme-preview.tsx +++ b/apps/admin/src/settings/site/theme/theme-preview.tsx @@ -1,9 +1,9 @@ import React, {useState} from 'react'; -import SettingsBreadcrumbs from '@/settings/app/components/settings/settings-breadcrumbs'; +import SettingsBreadcrumbs from '@/settings/components/settings-breadcrumbs'; import {Button, Field, FieldLabel, PreviewChrome, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@tryghost/shade/components'; import {Inline} from '@tryghost/shade/primitives'; import {LucideIcon} from '@tryghost/shade/utils'; -import {type OfficialTheme, type ThemeVariant} from '@/settings/app/components/providers/settings-app-provider'; +import {type OfficialTheme, type ThemeVariant} from '@/settings/providers/settings-app-context'; import {PageHeader} from '@tryghost/shade/patterns'; import {type Theme, isDefaultOrLegacyTheme} from '@tryghost/admin-x-framework/api/themes'; diff --git a/apps/admin/src/settings/app/utils/analytics.test.ts b/apps/admin/src/settings/utils/analytics.test.ts similarity index 90% rename from apps/admin/src/settings/app/utils/analytics.test.ts rename to apps/admin/src/settings/utils/analytics.test.ts index 79166970f57..1b4fb6d9c96 100644 --- a/apps/admin/src/settings/app/utils/analytics.test.ts +++ b/apps/admin/src/settings/utils/analytics.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert/strict'; -import trackEvent from '@/settings/app/utils/analytics'; +import trackEvent from '@/settings/utils/analytics'; describe('trackEvent', function () { it('calls plausible with the correct event name and properties', function () { diff --git a/apps/admin/src/settings/app/utils/analytics.ts b/apps/admin/src/settings/utils/analytics.ts similarity index 100% rename from apps/admin/src/settings/app/utils/analytics.ts rename to apps/admin/src/settings/utils/analytics.ts diff --git a/apps/admin/src/settings/app/utils/currency.ts b/apps/admin/src/settings/utils/currency.ts similarity index 100% rename from apps/admin/src/settings/app/utils/currency.ts rename to apps/admin/src/settings/utils/currency.ts diff --git a/apps/admin/src/settings/app/utils/custom-theme-settings.test.ts b/apps/admin/src/settings/utils/custom-theme-settings.test.ts similarity index 91% rename from apps/admin/src/settings/app/utils/custom-theme-settings.test.ts rename to apps/admin/src/settings/utils/custom-theme-settings.test.ts index 1d46c2c9dd1..ae51bd3a90b 100644 --- a/apps/admin/src/settings/app/utils/custom-theme-settings.test.ts +++ b/apps/admin/src/settings/utils/custom-theme-settings.test.ts @@ -1,6 +1,6 @@ import * as assert from 'assert/strict'; import {type CustomThemeSetting} from '@tryghost/admin-x-framework/api/custom-theme-settings'; -import {isCustomThemeSettingVisible} from '@/settings/app/utils/is-custom-theme-settings-visible'; +import {isCustomThemeSettingVisible} from '@/settings/utils/is-custom-theme-settings-visible'; describe('isCustomThemeSettingVisible', function () { it('returns whether or not a custom theme setting is visible', function () { diff --git a/apps/admin/src/settings/app/utils/debounce.ts b/apps/admin/src/settings/utils/debounce.ts similarity index 100% rename from apps/admin/src/settings/app/utils/debounce.ts rename to apps/admin/src/settings/utils/debounce.ts diff --git a/apps/admin/src/settings/app/utils/escape-html.ts b/apps/admin/src/settings/utils/escape-html.ts similarity index 100% rename from apps/admin/src/settings/app/utils/escape-html.ts rename to apps/admin/src/settings/utils/escape-html.ts diff --git a/apps/admin/src/settings/app/utils/format-url.ts b/apps/admin/src/settings/utils/format-url.ts similarity index 100% rename from apps/admin/src/settings/app/utils/format-url.ts rename to apps/admin/src/settings/utils/format-url.ts diff --git a/apps/admin/src/settings/app/utils/generate-embed-code.test.ts b/apps/admin/src/settings/utils/generate-embed-code.test.ts similarity index 98% rename from apps/admin/src/settings/app/utils/generate-embed-code.test.ts rename to apps/admin/src/settings/utils/generate-embed-code.test.ts index eef3492eb46..c8f571df1e7 100644 --- a/apps/admin/src/settings/app/utils/generate-embed-code.test.ts +++ b/apps/admin/src/settings/utils/generate-embed-code.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert/strict'; -import {type GenerateCodeOptions, generateCode, getEmbedPreviewLayoutMarker} from '@/settings/app/utils/generate-embed-code'; +import {type GenerateCodeOptions, generateCode, getEmbedPreviewLayoutMarker} from '@/settings/utils/generate-embed-code'; describe('generateCode', function () { let genOptions: GenerateCodeOptions; diff --git a/apps/admin/src/settings/app/utils/generate-embed-code.ts b/apps/admin/src/settings/utils/generate-embed-code.ts similarity index 93% rename from apps/admin/src/settings/app/utils/generate-embed-code.ts rename to apps/admin/src/settings/utils/generate-embed-code.ts index f1d6ebd98d1..59f27d6b28f 100644 --- a/apps/admin/src/settings/app/utils/generate-embed-code.ts +++ b/apps/admin/src/settings/utils/generate-embed-code.ts @@ -28,8 +28,7 @@ type OptionsType = { site: string; 'button-color': string; 'button-text-color': string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [key: string]: any; // This allows for computed properties like 'label-1', 'label-2', etc. + [key: string]: string; // This allows for computed properties like 'label-1', 'label-2', etc. }; export const getEmbedPreviewLayoutMarker = (layout: EmbedSignupLayout) => `data-preview-layout="${escapeHtml(layout)}"`; @@ -62,8 +61,8 @@ export const generateCode = ({ if (settings.icon && settings.icon !== '') { options.icon = settings.icon.replace(/\/content\/images\/(?:size\/[^/]+\/)*/, '/content/images/size/w192h192/'); } - options.title = settings.title; - options.description = settings.description; + options.title = settings.title ?? ''; + options.description = settings.description ?? ''; options['background-color'] = backgroundColor; options['text-color'] = textColorForBackgroundColor(backgroundColor).hex(); diff --git a/apps/admin/src/settings/app/utils/get-offers-portal-preview-url.ts b/apps/admin/src/settings/utils/get-offers-portal-preview-url.ts similarity index 100% rename from apps/admin/src/settings/app/utils/get-offers-portal-preview-url.ts rename to apps/admin/src/settings/utils/get-offers-portal-preview-url.ts diff --git a/apps/admin/src/settings/app/utils/get-portal-preview-url.ts b/apps/admin/src/settings/utils/get-portal-preview-url.ts similarity index 97% rename from apps/admin/src/settings/app/utils/get-portal-preview-url.ts rename to apps/admin/src/settings/utils/get-portal-preview-url.ts index 967a4393297..731fb646c78 100644 --- a/apps/admin/src/settings/app/utils/get-portal-preview-url.ts +++ b/apps/admin/src/settings/utils/get-portal-preview-url.ts @@ -23,7 +23,7 @@ export const getPortalPreviewUrl = ({settings, config, tiers, siteData, selected const baseUrl = siteData.url.replace(/\/$/, ''); const portalBase = '/?v=modal-portal-settings#/portal/preview'; - const portalPlans: string[] = JSON.parse(getSettingValue(settings, 'portal_plans') || ''); + const portalPlans = JSON.parse(getSettingValue(settings, 'portal_plans') || '') as string[]; const settingsParam = new URLSearchParams(); settingsParam.append('button', getSettingValue(settings, 'portal_button') ? 'true' : 'false'); diff --git a/apps/admin/src/settings/app/utils/get-tiers-cadences.ts b/apps/admin/src/settings/utils/get-tiers-cadences.ts similarity index 100% rename from apps/admin/src/settings/app/utils/get-tiers-cadences.ts rename to apps/admin/src/settings/utils/get-tiers-cadences.ts diff --git a/apps/admin/src/settings/app/utils/helpers.ts b/apps/admin/src/settings/utils/helpers.ts similarity index 100% rename from apps/admin/src/settings/app/utils/helpers.ts rename to apps/admin/src/settings/utils/helpers.ts diff --git a/apps/admin/src/settings/app/utils/iframe-buffering.test.tsx b/apps/admin/src/settings/utils/iframe-buffering.test.tsx similarity index 92% rename from apps/admin/src/settings/app/utils/iframe-buffering.test.tsx rename to apps/admin/src/settings/utils/iframe-buffering.test.tsx index 30c7672f081..8b7843ac549 100644 --- a/apps/admin/src/settings/app/utils/iframe-buffering.test.tsx +++ b/apps/admin/src/settings/utils/iframe-buffering.test.tsx @@ -1,4 +1,4 @@ -import IframeBuffering from '@/settings/app/utils/iframe-buffering'; +import IframeBuffering from '@/settings/utils/iframe-buffering'; import {render, waitFor} from '@testing-library/react'; describe('IframeBuffering', () => { diff --git a/apps/admin/src/settings/app/utils/iframe-buffering.tsx b/apps/admin/src/settings/utils/iframe-buffering.tsx similarity index 91% rename from apps/admin/src/settings/app/utils/iframe-buffering.tsx rename to apps/admin/src/settings/utils/iframe-buffering.tsx index 257b83023f4..451c5f86ae2 100644 --- a/apps/admin/src/settings/app/utils/iframe-buffering.tsx +++ b/apps/admin/src/settings/utils/iframe-buffering.tsx @@ -1,5 +1,6 @@ import * as Sentry from '@sentry/react'; import React, {useEffect, useRef, useState} from 'react'; +import {debounce} from './debounce'; type IframeBufferingProps = { generateContent: (iframe: HTMLIFrameElement) => void; @@ -13,21 +14,6 @@ type IframeBufferingProps = { addDelay?: boolean; }; -// eslint-disable-next-line -function debounce(func: any, wait: number) { - let timeout: NodeJS.Timeout; - - // eslint-disable-next-line - return function executedFunction(...args: any) { - const later = () => { - clearTimeout(timeout); - func(...args); - }; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - }; -} - const IframeBuffering: React.FC = ({generateContent, updateContent, className, height, width, parentClassName, testId, addDelay = false}) => { const [visibleIframeIndex, setVisibleIframeIndex] = useState(0); const iframes = [useRef(null), useRef(null)]; diff --git a/apps/admin/src/settings/app/utils/is-custom-theme-settings-visible.ts b/apps/admin/src/settings/utils/is-custom-theme-settings-visible.ts similarity index 100% rename from apps/admin/src/settings/app/utils/is-custom-theme-settings-visible.ts rename to apps/admin/src/settings/utils/is-custom-theme-settings-visible.ts diff --git a/apps/admin/src/settings/app/utils/link-to-github-releases.test.ts b/apps/admin/src/settings/utils/link-to-github-releases.test.ts similarity index 95% rename from apps/admin/src/settings/app/utils/link-to-github-releases.test.ts rename to apps/admin/src/settings/utils/link-to-github-releases.test.ts index 266372736a5..1b4eafbf98e 100644 --- a/apps/admin/src/settings/app/utils/link-to-github-releases.test.ts +++ b/apps/admin/src/settings/utils/link-to-github-releases.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert/strict'; -import {linkToGitHubReleases} from '@/settings/app/utils/link-to-github-releases'; +import {linkToGitHubReleases} from '@/settings/utils/link-to-github-releases'; describe('linkToGithubRelease', function () { it('handles empty version', function () { diff --git a/apps/admin/src/settings/app/utils/link-to-github-releases.ts b/apps/admin/src/settings/utils/link-to-github-releases.ts similarity index 100% rename from apps/admin/src/settings/app/utils/link-to-github-releases.ts rename to apps/admin/src/settings/utils/link-to-github-releases.ts diff --git a/apps/admin/src/settings/app/utils/locale-validation.test.ts b/apps/admin/src/settings/utils/locale-validation.test.ts similarity index 98% rename from apps/admin/src/settings/app/utils/locale-validation.test.ts rename to apps/admin/src/settings/utils/locale-validation.test.ts index b6058a084a7..5c625dfd107 100644 --- a/apps/admin/src/settings/app/utils/locale-validation.test.ts +++ b/apps/admin/src/settings/utils/locale-validation.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert/strict'; -import {validateLocale} from '@/settings/app/utils/locale-validation'; +import {validateLocale} from '@/settings/utils/locale-validation'; describe('validateLocale', function () { describe('valid locales', function () { diff --git a/apps/admin/src/settings/app/utils/locale-validation.ts b/apps/admin/src/settings/utils/locale-validation.ts similarity index 100% rename from apps/admin/src/settings/app/utils/locale-validation.ts rename to apps/admin/src/settings/utils/locale-validation.ts diff --git a/apps/admin/src/settings/app/utils/newsletter-emails.ts b/apps/admin/src/settings/utils/newsletter-emails.ts similarity index 100% rename from apps/admin/src/settings/app/utils/newsletter-emails.ts rename to apps/admin/src/settings/utils/newsletter-emails.ts diff --git a/apps/admin/src/settings/app/utils/portal.tsx b/apps/admin/src/settings/utils/portal.tsx similarity index 100% rename from apps/admin/src/settings/app/utils/portal.tsx rename to apps/admin/src/settings/utils/portal.tsx diff --git a/apps/admin/src/settings/app/utils/search.tsx b/apps/admin/src/settings/utils/search.tsx similarity index 95% rename from apps/admin/src/settings/app/utils/search.tsx rename to apps/admin/src/settings/utils/search.tsx index 538722fb2e9..78787ddc225 100644 --- a/apps/admin/src/settings/app/utils/search.tsx +++ b/apps/admin/src/settings/utils/search.tsx @@ -89,11 +89,11 @@ const useSearchService = () => { return {part}; }); } else if (Array.isArray(text)) { - return text.map(part => highlightKeywords(part)); + return (text as React.ReactNode[]).map(part => highlightKeywords(part)); } else if (text && typeof text === 'object' && text) { return React.Children.map(text, (child) => { if (child && typeof child === 'object' && 'props' in child) { - return highlightKeywords(child.props.children); + return highlightKeywords((child as React.ReactElement<{children?: React.ReactNode}>).props.children); } return child; }); diff --git a/apps/admin/src/settings/app/utils/show-database-warning.test.ts b/apps/admin/src/settings/utils/show-database-warning.test.ts similarity index 90% rename from apps/admin/src/settings/app/utils/show-database-warning.test.ts rename to apps/admin/src/settings/utils/show-database-warning.test.ts index 4e51135dc83..7d0aa4ed6ef 100644 --- a/apps/admin/src/settings/app/utils/show-database-warning.test.ts +++ b/apps/admin/src/settings/utils/show-database-warning.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert/strict'; -import {showDatabaseWarning} from '@/settings/app/utils/show-database-warning'; +import {showDatabaseWarning} from '@/settings/utils/show-database-warning'; describe('showDatabaseWarning', function () { it('shows a warning when in production and not using MySQL 8', function () { diff --git a/apps/admin/src/settings/app/utils/show-database-warning.ts b/apps/admin/src/settings/utils/show-database-warning.ts similarity index 100% rename from apps/admin/src/settings/app/utils/show-database-warning.ts rename to apps/admin/src/settings/utils/show-database-warning.ts diff --git a/apps/admin/src/settings/app/utils/social-urls/bluesky.ts b/apps/admin/src/settings/utils/social-urls/bluesky.ts similarity index 100% rename from apps/admin/src/settings/app/utils/social-urls/bluesky.ts rename to apps/admin/src/settings/utils/social-urls/bluesky.ts diff --git a/apps/admin/src/settings/app/utils/social-urls/facebook.ts b/apps/admin/src/settings/utils/social-urls/facebook.ts similarity index 100% rename from apps/admin/src/settings/app/utils/social-urls/facebook.ts rename to apps/admin/src/settings/utils/social-urls/facebook.ts diff --git a/apps/admin/src/settings/app/utils/social-urls/index.ts b/apps/admin/src/settings/utils/social-urls/index.ts similarity index 100% rename from apps/admin/src/settings/app/utils/social-urls/index.ts rename to apps/admin/src/settings/utils/social-urls/index.ts diff --git a/apps/admin/src/settings/app/utils/social-urls/instagram.ts b/apps/admin/src/settings/utils/social-urls/instagram.ts similarity index 100% rename from apps/admin/src/settings/app/utils/social-urls/instagram.ts rename to apps/admin/src/settings/utils/social-urls/instagram.ts diff --git a/apps/admin/src/settings/app/utils/social-urls/linkedin.ts b/apps/admin/src/settings/utils/social-urls/linkedin.ts similarity index 100% rename from apps/admin/src/settings/app/utils/social-urls/linkedin.ts rename to apps/admin/src/settings/utils/social-urls/linkedin.ts diff --git a/apps/admin/src/settings/app/utils/social-urls/mastodon.ts b/apps/admin/src/settings/utils/social-urls/mastodon.ts similarity index 100% rename from apps/admin/src/settings/app/utils/social-urls/mastodon.ts rename to apps/admin/src/settings/utils/social-urls/mastodon.ts diff --git a/apps/admin/src/settings/app/utils/social-urls/platform-config.test.ts b/apps/admin/src/settings/utils/social-urls/platform-config.test.ts similarity index 98% rename from apps/admin/src/settings/app/utils/social-urls/platform-config.test.ts rename to apps/admin/src/settings/utils/social-urls/platform-config.test.ts index 5eda6217a17..d1e84e72039 100644 --- a/apps/admin/src/settings/app/utils/social-urls/platform-config.test.ts +++ b/apps/admin/src/settings/utils/social-urls/platform-config.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert/strict'; -import {SOCIAL_PLATFORM_CONFIG_BY_KEY, normalizeSocialInput} from '@/settings/app/utils/social-urls/index'; +import {SOCIAL_PLATFORM_CONFIG_BY_KEY, normalizeSocialInput} from '@/settings/utils/social-urls/index'; describe('normalizeSocialInput', () => { it('returns empty display and null stored for empty input', () => { diff --git a/apps/admin/src/settings/app/utils/social-urls/platform-config.ts b/apps/admin/src/settings/utils/social-urls/platform-config.ts similarity index 100% rename from apps/admin/src/settings/app/utils/social-urls/platform-config.ts rename to apps/admin/src/settings/utils/social-urls/platform-config.ts diff --git a/apps/admin/src/settings/app/utils/social-urls/platform-validator.ts b/apps/admin/src/settings/utils/social-urls/platform-validator.ts similarity index 100% rename from apps/admin/src/settings/app/utils/social-urls/platform-validator.ts rename to apps/admin/src/settings/utils/social-urls/platform-validator.ts diff --git a/apps/admin/src/settings/app/utils/social-urls/social-urls.test.ts b/apps/admin/src/settings/utils/social-urls/social-urls.test.ts similarity index 99% rename from apps/admin/src/settings/app/utils/social-urls/social-urls.test.ts rename to apps/admin/src/settings/utils/social-urls/social-urls.test.ts index 37163a02076..1a362ade903 100644 --- a/apps/admin/src/settings/app/utils/social-urls/social-urls.test.ts +++ b/apps/admin/src/settings/utils/social-urls/social-urls.test.ts @@ -19,7 +19,7 @@ import { validateTwitterUrl, validateYouTubeUrl, youtubeHandleToUrl, youtubeUrlToHandle -} from '@/settings/app/utils/social-urls/index'; +} from '@/settings/utils/social-urls/index'; /* * Table-driven coverage for every social platform validator. diff --git a/apps/admin/src/settings/app/utils/social-urls/threads.ts b/apps/admin/src/settings/utils/social-urls/threads.ts similarity index 100% rename from apps/admin/src/settings/app/utils/social-urls/threads.ts rename to apps/admin/src/settings/utils/social-urls/threads.ts diff --git a/apps/admin/src/settings/app/utils/social-urls/tiktok.ts b/apps/admin/src/settings/utils/social-urls/tiktok.ts similarity index 100% rename from apps/admin/src/settings/app/utils/social-urls/tiktok.ts rename to apps/admin/src/settings/utils/social-urls/tiktok.ts diff --git a/apps/admin/src/settings/app/utils/social-urls/twitter.ts b/apps/admin/src/settings/utils/social-urls/twitter.ts similarity index 100% rename from apps/admin/src/settings/app/utils/social-urls/twitter.ts rename to apps/admin/src/settings/utils/social-urls/twitter.ts diff --git a/apps/admin/src/settings/app/utils/social-urls/youtube.ts b/apps/admin/src/settings/utils/social-urls/youtube.ts similarity index 100% rename from apps/admin/src/settings/app/utils/social-urls/youtube.ts rename to apps/admin/src/settings/utils/social-urls/youtube.ts diff --git a/apps/admin/src/settings/app/utils/url.test.ts b/apps/admin/src/settings/utils/url.test.ts similarity index 98% rename from apps/admin/src/settings/app/utils/url.test.ts rename to apps/admin/src/settings/utils/url.test.ts index 9b7bdf0943c..409956be786 100644 --- a/apps/admin/src/settings/app/utils/url.test.ts +++ b/apps/admin/src/settings/utils/url.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert/strict'; -import {trimHash, trimSearch, trimSearchAndHash} from '@/settings/app/utils/url'; +import {trimHash, trimSearch, trimSearchAndHash} from '@/settings/utils/url'; describe('trimSearch', function () { it('removes the query parameters from a URL', function () { diff --git a/apps/admin/src/settings/app/utils/url.ts b/apps/admin/src/settings/utils/url.ts similarity index 100% rename from apps/admin/src/settings/app/utils/url.ts rename to apps/admin/src/settings/utils/url.ts diff --git a/apps/admin/src/settings/app/utils/welcome-email-sender-details.test.ts b/apps/admin/src/settings/utils/welcome-email-sender-details.test.ts similarity index 97% rename from apps/admin/src/settings/app/utils/welcome-email-sender-details.test.ts rename to apps/admin/src/settings/utils/welcome-email-sender-details.test.ts index 6a987602590..c6e63f836af 100644 --- a/apps/admin/src/settings/app/utils/welcome-email-sender-details.test.ts +++ b/apps/admin/src/settings/utils/welcome-email-sender-details.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert/strict'; -import {resolveWelcomeEmailSenderDetails} from '@/settings/app/utils/welcome-email-sender-details'; +import {resolveWelcomeEmailSenderDetails} from '@/settings/utils/welcome-email-sender-details'; import type {Config} from '@tryghost/admin-x-framework/api/config'; const config = { diff --git a/apps/admin/src/settings/app/utils/welcome-email-sender-details.ts b/apps/admin/src/settings/utils/welcome-email-sender-details.ts similarity index 100% rename from apps/admin/src/settings/app/utils/welcome-email-sender-details.ts rename to apps/admin/src/settings/utils/welcome-email-sender-details.ts diff --git a/apps/admin/src/tags/detail/tag-image-field.tsx b/apps/admin/src/tags/detail/tag-image-field.tsx index 589a9c6c2ef..9db836e6913 100644 --- a/apps/admin/src/tags/detail/tag-image-field.tsx +++ b/apps/admin/src/tags/detail/tag-image-field.tsx @@ -8,7 +8,7 @@ import {createPortal} from 'react-dom'; import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images'; import {useFramework} from '@tryghost/admin-x-framework'; import {usePinturaEditor} from '@/hooks/use-pintura-editor'; -import BrandIcon from '@/settings/app/components/icons/brand-icon'; +import BrandIcon from '@/settings/components/icons/brand-icon'; import {JSONError, RequestEntityTooLargeError, UnsupportedMediaTypeError} from '@tryghost/admin-x-framework/errors'; import {toast} from 'sonner'; diff --git a/apps/admin/src/vite-env.d.ts b/apps/admin/src/vite-env.d.ts index 650bbe760f5..8a7466db950 100644 --- a/apps/admin/src/vite-env.d.ts +++ b/apps/admin/src/vite-env.d.ts @@ -4,8 +4,22 @@ interface ImportMetaEnv { readonly GHOST_BUILD_VERSION?: string; } -declare module '@tryghost/limit-service' -declare module '@tryghost/nql' +declare module '@tryghost/limit-service' { + type LimitOptions = Record; + export default class LimitService { + loadLimits(config: {limits: object; subscription?: unknown; helpLink?: string; db?: unknown; errors: Record}): void; + isLimited(limitName: string): boolean; + isDisabled(limitName: string): boolean; + checkIsOverLimit(limitName: string, options?: LimitOptions): Promise; + checkWouldGoOverLimit(limitName: string, options?: LimitOptions): Promise; + errorIfIsOverLimit(limitName: string, options?: LimitOptions): Promise; + errorIfWouldGoOverLimit(limitName: string, options?: LimitOptions): Promise; + checkIfAnyOverLimit(options?: LimitOptions): Promise; + } +} +declare module '@tryghost/nql' { + export default function nql(query: string): {queryJSON: (data: unknown) => boolean}; +} declare module '@tryghost/string' { export function slugify(string: string, options?: {requiredChangesOnly?: boolean}): string; } From 7d15645b07a41f46f8a34cae6d7d85a45985cc45 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Wed, 19 Aug 2026 07:38:36 -0500 Subject: [PATCH 08/19] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20header=20card=20as?= =?UTF-8?q?set=20exclusions=20(#30114)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes https://github.com/TryGhost/Ghost/issues/30102 - Maps the exact source path `css/header_v2.css` to the documented public `header` card when building the card asset manifest. - Keeps the versioned source files separate while combining both stylesheets under one logical manifest key. - Preserves the existing source order and separator so the default all-card bundle remains byte-for-byte identical. - Makes the manifest builder importable without executing it and adds regression coverage for mapping, inclusion, exclusion, and asset-type isolation. --- ghost/core/scripts/build-card-assets.mjs | 45 +++++++--- .../frontend/services/card-assets.test.js | 90 +++++++++++++++++++ 2 files changed, 124 insertions(+), 11 deletions(-) diff --git a/ghost/core/scripts/build-card-assets.mjs b/ghost/core/scripts/build-card-assets.mjs index 437775196e1..f12743ad789 100644 --- a/ghost/core/scripts/build-card-assets.mjs +++ b/ghost/core/scripts/build-card-assets.mjs @@ -3,7 +3,7 @@ /** * Builds the card asset manifest. * - * Every card ships one optional CSS file and one optional JS file in + * Every card ships optional CSS and JS source files in * core/frontend/src/cards. A theme picks a subset of them via its `card_assets` * config, and Ghost serves that subset as /public/cards.min.{css,js}. * @@ -32,8 +32,15 @@ const LOADERS = { js: {loader: 'js', target: ['es2020']} }; -async function buildType(type) { - const dir = path.join(srcDir, type); +// header_v2.css is a second stylesheet for the public `header` card, not a +// separate configurable card. This exact path override is intentional — `_vN` +// filename suffixes are not a convention for grouping card assets. +const PUBLIC_CARD_ASSET_NAMES = new Map([ + ['css/header_v2.css', 'header'] +]); + +export async function buildType(type, sourceDir = srcDir) { + const dir = path.join(sourceDir, type); const suffix = `.${type}`; const files = fs.readdirSync(dir).filter(file => file.endsWith(suffix)).sort(); @@ -41,7 +48,17 @@ async function buildType(type) { for (const file of files) { const contents = fs.readFileSync(path.join(dir, file), 'utf8'); const {code} = await esbuild.transform(contents, {minify: true, ...LOADERS[type]}); - chunks[file.slice(0, -suffix.length)] = code; + const sourceName = file.slice(0, -suffix.length); + const cardName = PUBLIC_CARD_ASSET_NAMES.get(`${type}/${file}`) || sourceName; + + if (Object.hasOwn(chunks, cardName)) { + // Match the separator used when the runtime service concatenates + // separate chunks, keeping the default bundle bytes stable. + const separator = type === 'js' ? ';\n' : '\n'; + chunks[cardName] += `${separator}${code}`; + } else { + chunks[cardName] = code; + } } logging.debug(`✓ ${files.length} card ${type} files minified`); @@ -49,12 +66,18 @@ async function buildType(type) { return chunks; } -const manifest = {}; -for (const type of Object.keys(LOADERS)) { - manifest[type] = await buildType(type); -} +export async function buildCardAssets() { + const manifest = {}; + for (const type of Object.keys(LOADERS)) { + manifest[type] = await buildType(type); + } -fs.mkdirSync(path.dirname(destFile), {recursive: true}); -fs.writeFileSync(destFile, JSON.stringify(manifest)); + fs.mkdirSync(path.dirname(destFile), {recursive: true}); + fs.writeFileSync(destFile, JSON.stringify(manifest)); -logging.debug(`Card asset manifest written to ${destFile}`); + logging.debug(`Card asset manifest written to ${destFile}`); +} + +if (import.meta.main) { + await buildCardAssets(); +} diff --git a/ghost/core/test/unit/frontend/services/card-assets.test.js b/ghost/core/test/unit/frontend/services/card-assets.test.js index 66a0e23c079..d9445a1bc57 100644 --- a/ghost/core/test/unit/frontend/services/card-assets.test.js +++ b/ghost/core/test/unit/frontend/services/card-assets.test.js @@ -23,6 +23,96 @@ const MANIFEST = { const expectedHash = content => crypto.createHash('sha256').update(content).digest('base64url').substring(0, 16); +describe('Card Asset Manifest Builder', function () { + let testDir, + manifestPath, + manifest, + ungroupedCssManifest, + shippedCssManifest; + + beforeAll(async function () { + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ghost-card-assets-builder-tests-')); + manifestPath = path.join(testDir, 'cards.manifest.json'); + + const cssDir = path.join(testDir, 'css'); + const jsDir = path.join(testDir, 'js'); + const ungroupedCssDir = path.join(testDir, 'ungrouped', 'css'); + await Promise.all([ + fs.mkdir(cssDir), + fs.mkdir(jsDir), + fs.mkdir(ungroupedCssDir, {recursive: true}) + ]); + await Promise.all([ + fs.writeFile(path.join(cssDir, 'gallery.css'), '.gallery { color: red; }'), + fs.writeFile(path.join(cssDir, 'header.css'), '.kg-header-card { color: blue; }'), + fs.writeFile(path.join(cssDir, 'header_v2.css'), '.kg-header-card.kg-v2 { color: green; }'), + fs.writeFile(path.join(jsDir, 'header.js'), 'window.header = true;'), + fs.writeFile(path.join(jsDir, 'header_v2.js'), 'window.headerV2 = true;'), + fs.writeFile(path.join(ungroupedCssDir, 'gallery.css'), '.gallery { color: red; }'), + fs.writeFile(path.join(ungroupedCssDir, 'header.css'), '.kg-header-card { color: blue; }'), + // This sorts directly after header.css without triggering the path override, + // reconstructing the previous separately-keyed bundle for comparison. + fs.writeFile(path.join(ungroupedCssDir, 'headerz.css'), '.kg-header-card.kg-v2 { color: green; }') + ]); + + const {buildType} = await import('../../../../scripts/build-card-assets.mjs'); + manifest = { + css: await buildType('css', testDir), + js: await buildType('js', testDir) + }; + ungroupedCssManifest = await buildType('css', path.join(testDir, 'ungrouped')); + shippedCssManifest = await buildType('css'); + await fs.writeFile(manifestPath, JSON.stringify(manifest)); + }); + + afterAll(async function () { + await fs.rm(testDir, {recursive: true}); + }); + + it('maps header v2 CSS to the public header card name', function () { + assert.deepEqual(Object.keys(manifest.css), ['gallery', 'header']); + assert.match(manifest.css.header, /\.kg-header-card\{/); + assert.match(manifest.css.header, /\.kg-header-card\.kg-v2\{/); + assert.ok(manifest.css.header.indexOf('.kg-header-card{') < manifest.css.header.indexOf('.kg-header-card.kg-v2{')); + }); + + it('does not infer public card names from version suffixes', function () { + assert.deepEqual(Object.keys(manifest.js), ['header', 'header_v2']); + }); + + it('does not expose a versioned header card name from the shipped CSS', function () { + assert.deepEqual(Object.keys(shippedCssManifest).filter(name => name.startsWith('header')), ['header']); + }); + + it('preserves the default bundle bytes when combining the header chunks', function () { + const cardAssets = new CardAssetService({manifest: manifestPath, config: true}); + const previousContent = Object.values(ungroupedCssManifest).join('\n'); + + assert.equal(cardAssets.getBundle('css').content, previousContent); + }); + + it('excludes both header styles by the public header card name', function () { + const cardAssets = new CardAssetService({ + manifest: manifestPath, + config: {exclude: ['header']} + }); + + assert.deepEqual(cardAssets.getCardNames('css'), ['gallery']); + assert.doesNotMatch(cardAssets.getBundle('css').content, /\.kg-header-card/); + }); + + it('includes both header styles by the public header card name', function () { + const cardAssets = new CardAssetService({ + manifest: manifestPath, + config: {include: ['header']} + }); + + assert.deepEqual(cardAssets.getCardNames('css'), ['header']); + assert.match(cardAssets.getBundle('css').content, /\.kg-header-card\{/); + assert.match(cardAssets.getBundle('css').content, /\.kg-header-card\.kg-v2\{/); + }); +}); + describe('Card Asset Service', function () { let testDir, manifestPath; From 0dfe197d5c65328b1eb41035e458a7163a210f54 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Wed, 19 Aug 2026 07:38:58 -0500 Subject: [PATCH 09/19] Moved advanced settings out of the settings quarantine (#30115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref Last area out of `settings/app/`: advanced components now live beside their acceptance tests in `settings/advanced`. With it gone `settings/app/` is empty and **the ESLint quarantine override is deleted** — every settings file now lints under the same type-aware rules as the rest of admin. --- apps/admin/eslint.config.js | 24 --------- .../advanced/advanced-settings.tsx | 10 +--- .../settings => }/advanced/code-injection.tsx | 0 .../advanced/code/code-modal.tsx | 4 +- .../settings => }/advanced/danger-zone.tsx | 0 .../settings => }/advanced/history-modal.tsx | 2 +- .../settings => }/advanced/history.tsx | 0 .../settings => }/advanced/integrations.tsx | 0 .../integrations/add-integration-modal.tsx | 0 .../advanced/integrations/api-keys.tsx | 0 .../integrations/content-api-modal.tsx | 0 .../integrations/custom-integration-modal.tsx | 27 +++++----- .../integrations/first-promoter-modal.tsx | 0 .../integrations/integration-header.tsx | 0 .../advanced/integrations/pintura-modal.tsx | 4 +- .../advanced/integrations/slack-modal.tsx | 2 +- .../integrations/transistor-modal.tsx | 0 .../advanced/integrations/unsplash-modal.tsx | 0 .../integrations/webhook-event-options.tsx | 0 .../advanced/integrations/webhook-modal.tsx | 0 .../integrations/webhooks-table.test.tsx | 2 +- .../advanced/integrations/webhooks-table.tsx | 0 .../advanced/integrations/zapier-modal.tsx | 0 .../settings => }/advanced/labs.tsx | 0 .../advanced/labs/beta-features.tsx | 50 ++++++++++--------- .../advanced/labs/feature-toggle.tsx | 6 +-- .../settings => }/advanced/labs/lab-item.tsx | 0 .../advanced/labs/private-features.tsx | 2 +- .../advanced/labs/yaml-file-editor-modal.tsx | 2 +- .../advanced/migration-tools.tsx | 0 .../migration-tools/export-all-modal.tsx | 2 +- .../migration-tools-export.tsx | 2 +- .../migration-tools-import.tsx | 0 .../universal-import-modal.test.tsx | 5 +- .../universal-import-modal.tsx | 48 +++++++++--------- .../src/settings/advanced/search-keywords.ts | 8 +++ .../settings => }/advanced/spam-filters.tsx | 0 .../src/settings/data/zapier-templates.ts | 2 +- .../settings/general/users/staff-token.tsx | 2 +- .../src/settings/layout/settings-sections.tsx | 2 +- apps/admin/src/settings/layout/sidebar.tsx | 2 +- .../membership/membership-settings.tsx | 2 +- .../providers/settings-app-context.ts | 2 +- apps/admin/src/settings/routes.tsx | 20 ++++---- 44 files changed, 109 insertions(+), 123 deletions(-) rename apps/admin/src/settings/{app/components/settings => }/advanced/advanced-settings.tsx (51%) rename apps/admin/src/settings/{app/components/settings => }/advanced/code-injection.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/advanced/code/code-modal.tsx (97%) rename apps/admin/src/settings/{app/components/settings => }/advanced/danger-zone.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/advanced/history-modal.tsx (99%) rename apps/admin/src/settings/{app/components/settings => }/advanced/history.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/advanced/integrations.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/advanced/integrations/add-integration-modal.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/advanced/integrations/api-keys.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/advanced/integrations/content-api-modal.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/advanced/integrations/custom-integration-modal.tsx (91%) rename apps/admin/src/settings/{app/components/settings => }/advanced/integrations/first-promoter-modal.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/advanced/integrations/integration-header.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/advanced/integrations/pintura-modal.tsx (97%) rename apps/admin/src/settings/{app/components/settings => }/advanced/integrations/slack-modal.tsx (98%) rename apps/admin/src/settings/{app/components/settings => }/advanced/integrations/transistor-modal.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/advanced/integrations/unsplash-modal.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/advanced/integrations/webhook-event-options.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/advanced/integrations/webhook-modal.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/advanced/integrations/webhooks-table.test.tsx (96%) rename apps/admin/src/settings/{app/components/settings => }/advanced/integrations/webhooks-table.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/advanced/integrations/zapier-modal.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/advanced/labs.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/advanced/labs/beta-features.tsx (85%) rename apps/admin/src/settings/{app/components/settings => }/advanced/labs/feature-toggle.tsx (95%) rename apps/admin/src/settings/{app/components/settings => }/advanced/labs/lab-item.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/advanced/labs/private-features.tsx (99%) rename apps/admin/src/settings/{app/components/settings => }/advanced/labs/yaml-file-editor-modal.tsx (99%) rename apps/admin/src/settings/{app/components/settings => }/advanced/migration-tools.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/advanced/migration-tools/export-all-modal.tsx (99%) rename apps/admin/src/settings/{app/components/settings => }/advanced/migration-tools/migration-tools-export.tsx (92%) rename apps/admin/src/settings/{app/components/settings => }/advanced/migration-tools/migration-tools-import.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/advanced/migration-tools/universal-import-modal.test.tsx (96%) rename apps/admin/src/settings/{app/components/settings => }/advanced/migration-tools/universal-import-modal.tsx (69%) create mode 100644 apps/admin/src/settings/advanced/search-keywords.ts rename apps/admin/src/settings/{app/components/settings => }/advanced/spam-filters.tsx (100%) diff --git a/apps/admin/eslint.config.js b/apps/admin/eslint.config.js index c9b27a46dbf..cbaa8054f1b 100644 --- a/apps/admin/eslint.config.js +++ b/apps/admin/eslint.config.js @@ -62,30 +62,6 @@ export default tseslint.config( } } }, - // Legacy quarantine: settings code moved from admin-x-settings, which linted without - // type-aware rules. Overrides die as files migrate out of src/settings/app; no new code there. - { - files: ['src/settings/app/**/*.{ts,tsx}'], - rules: { - '@typescript-eslint/await-thenable': 'off', - '@typescript-eslint/no-base-to-string': 'off', - '@typescript-eslint/no-duplicate-type-constituents': 'off', - '@typescript-eslint/no-floating-promises': 'off', - '@typescript-eslint/no-misused-promises': 'off', - '@typescript-eslint/no-redundant-type-constituents': 'off', - '@typescript-eslint/no-unnecessary-type-assertion': 'off', - '@typescript-eslint/no-unsafe-argument': 'off', - '@typescript-eslint/no-unsafe-assignment': 'off', - '@typescript-eslint/no-unsafe-call': 'off', - '@typescript-eslint/no-unsafe-member-access': 'off', - '@typescript-eslint/no-unsafe-return': 'off', - '@typescript-eslint/only-throw-error': 'off', - '@typescript-eslint/require-await': 'off', - '@typescript-eslint/restrict-plus-operands': 'off', - '@typescript-eslint/restrict-template-expressions': 'off', - 'react-refresh/only-export-components': 'off' - } - }, { files: ['src/**/*.{ts,tsx}'], plugins: {'no-relative-import-paths': noRelativeImportPaths}, diff --git a/apps/admin/src/settings/app/components/settings/advanced/advanced-settings.tsx b/apps/admin/src/settings/advanced/advanced-settings.tsx similarity index 51% rename from apps/admin/src/settings/app/components/settings/advanced/advanced-settings.tsx rename to apps/admin/src/settings/advanced/advanced-settings.tsx index 318071885ca..f4e9f5f9033 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/advanced-settings.tsx +++ b/apps/admin/src/settings/advanced/advanced-settings.tsx @@ -6,15 +6,7 @@ import Labs from './labs'; import MigrationTools from './migration-tools'; import React from 'react'; import SearchableSection from '@/settings/components/searchable-section'; - -export const searchKeywords = { - integrations: ['advanced', 'integrations', 'zapier', 'slack', 'unsplash', 'first promoter', 'firstpromoter', 'pintura', 'disqus', 'analytics', 'ulysses', 'typeform', 'buffer', 'plausible', 'github', 'webhooks'], - migrationtools: ['import', 'export', 'migrate', 'substack', 'substack', 'migration', 'medium', 'wordpress', 'wp', 'squarespace', 'beehiiv'], - codeInjection: ['advanced', 'code injection', 'head', 'footer'], - labs: ['advanced', 'labs', 'alpha', 'private', 'beta', 'flag', 'routes', 'redirect', 'translation', 'editor', 'portal'], - history: ['advanced', 'history', 'log', 'events', 'user events', 'staff', 'audit', 'action'], - dangerzone: ['danger zone', 'delete all content', 'delete site', 'reset all authentication', 'reset api keys', 'reset password', 'compromised credentials', 'lock staff users', 'sign out all staff'] -}; +import {searchKeywords} from './search-keywords'; const AdvancedSettings: React.FC = () => { return ( diff --git a/apps/admin/src/settings/app/components/settings/advanced/code-injection.tsx b/apps/admin/src/settings/advanced/code-injection.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/advanced/code-injection.tsx rename to apps/admin/src/settings/advanced/code-injection.tsx diff --git a/apps/admin/src/settings/app/components/settings/advanced/code/code-modal.tsx b/apps/admin/src/settings/advanced/code/code-modal.tsx similarity index 97% rename from apps/admin/src/settings/app/components/settings/advanced/code/code-modal.tsx rename to apps/admin/src/settings/advanced/code/code-modal.tsx index d605f8f9019..645fb0a25b6 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/code/code-modal.tsx +++ b/apps/admin/src/settings/advanced/code/code-modal.tsx @@ -48,7 +48,7 @@ const CodeModal: React.FC = ({onClose}) => { const handleCMDS = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); - onSaveClick(); + void onSaveClick(); } }; window.addEventListener('keydown', handleCMDS); @@ -71,7 +71,7 @@ const CodeModal: React.FC = ({onClose}) => { Code injection - +
setSelectedTab(value as typeof selectedTab)}> diff --git a/apps/admin/src/settings/app/components/settings/advanced/danger-zone.tsx b/apps/admin/src/settings/advanced/danger-zone.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/advanced/danger-zone.tsx rename to apps/admin/src/settings/advanced/danger-zone.tsx diff --git a/apps/admin/src/settings/app/components/settings/advanced/history-modal.tsx b/apps/admin/src/settings/advanced/history-modal.tsx similarity index 99% rename from apps/admin/src/settings/app/components/settings/advanced/history-modal.tsx rename to apps/admin/src/settings/advanced/history-modal.tsx index cb8b5ba47e3..46ba0b5084a 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/history-modal.tsx +++ b/apps/admin/src/settings/advanced/history-modal.tsx @@ -300,7 +300,7 @@ function HistoryModal() { const fetchNext = useCallback(() => { if (!data?.isEnd) { - fetchNextPage(); + void fetchNextPage(); } }, [data?.isEnd, fetchNextPage]); diff --git a/apps/admin/src/settings/app/components/settings/advanced/history.tsx b/apps/admin/src/settings/advanced/history.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/advanced/history.tsx rename to apps/admin/src/settings/advanced/history.tsx diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations.tsx b/apps/admin/src/settings/advanced/integrations.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/advanced/integrations.tsx rename to apps/admin/src/settings/advanced/integrations.tsx diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/add-integration-modal.tsx b/apps/admin/src/settings/advanced/integrations/add-integration-modal.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/add-integration-modal.tsx rename to apps/admin/src/settings/advanced/integrations/add-integration-modal.tsx diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/api-keys.tsx b/apps/admin/src/settings/advanced/integrations/api-keys.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/api-keys.tsx rename to apps/admin/src/settings/advanced/integrations/api-keys.tsx diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/content-api-modal.tsx b/apps/admin/src/settings/advanced/integrations/content-api-modal.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/content-api-modal.tsx rename to apps/admin/src/settings/advanced/integrations/content-api-modal.tsx diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/custom-integration-modal.tsx b/apps/admin/src/settings/advanced/integrations/custom-integration-modal.tsx similarity index 91% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/custom-integration-modal.tsx rename to apps/admin/src/settings/advanced/integrations/custom-integration-modal.tsx index 37760af7b36..12702840221 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/custom-integration-modal.tsx +++ b/apps/admin/src/settings/advanced/integrations/custom-integration-modal.tsx @@ -23,6 +23,7 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in const {mutateAsync: refreshAPIKey} = useRefreshAPIKey(); const {mutateAsync: uploadImage} = useUploadImage(); const handleError = useHandleError(); + const {confirm} = useConfirmation(); const {formState, updateForm, handleSave, saveState, errors, clearError, okProps} = useForm({ @@ -47,6 +48,19 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in } }); + const handleIconUpload = async (file: File) => { + try { + const imageUrl = getImageUrl(await uploadImage({file})); + updateForm(state => ({...state, icon_image: imageUrl})); + } catch (e) { + const error = e as APIError; + if (error.response!.status === 415) { + error.message = 'Unsupported file type'; + } + handleError(error); + } + }; + const adminApiKey = integration.api_keys?.find(key => key.type === 'admin'); const contentApiKey = integration.api_keys?.find(key => key.type === 'content'); @@ -110,18 +124,7 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in ) : ( - { - try { - const imageUrl = getImageUrl(await uploadImage({file})); - updateForm(state => ({...state, icon_image: imageUrl})); - } catch (e) { - const error = e as APIError; - if (error.response!.status === 415) { - error.message = 'Unsupported file type'; - } - handleError(error); - } - }}> + void handleIconUpload(file)}> Upload icon )} diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/first-promoter-modal.tsx b/apps/admin/src/settings/advanced/integrations/first-promoter-modal.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/first-promoter-modal.tsx rename to apps/admin/src/settings/advanced/integrations/first-promoter-modal.tsx diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/integration-header.tsx b/apps/admin/src/settings/advanced/integrations/integration-header.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/integration-header.tsx rename to apps/admin/src/settings/advanced/integrations/integration-header.tsx diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/pintura-modal.tsx b/apps/admin/src/settings/advanced/integrations/pintura-modal.tsx similarity index 97% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/pintura-modal.tsx rename to apps/admin/src/settings/advanced/integrations/pintura-modal.tsx index b2cedeaa7fe..b99b718da8d 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/pintura-modal.tsx +++ b/apps/admin/src/settings/advanced/integrations/pintura-modal.tsx @@ -121,7 +121,7 @@ function PinturaModal() {
Upload Pintura script
Upload the pintura-umd.js file from the Pintura package
- handleUpload(files[0], 'js')}> + void handleUpload(files[0], 'js')}> {uploadingState.js ? 'Uploading...' : 'Upload'}
@@ -130,7 +130,7 @@ function PinturaModal() {
Upload Pintura styles
Upload the pintura.css file from the Pintura package
- handleUpload(files[0], 'css')}> + void handleUpload(files[0], 'css')}> {uploadingState.css ? 'Uploading...' : 'Upload'}
diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/slack-modal.tsx b/apps/admin/src/settings/advanced/integrations/slack-modal.tsx similarity index 98% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/slack-modal.tsx rename to apps/admin/src/settings/advanced/integrations/slack-modal.tsx index 3875e331db2..aba94b7392d 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/slack-modal.tsx +++ b/apps/admin/src/settings/advanced/integrations/slack-modal.tsx @@ -75,7 +75,7 @@ function SlackModal() { updateSetting('slack_username', e.target.value)} /> The username to display messages from - +
diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/transistor-modal.tsx b/apps/admin/src/settings/advanced/integrations/transistor-modal.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/transistor-modal.tsx rename to apps/admin/src/settings/advanced/integrations/transistor-modal.tsx diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/unsplash-modal.tsx b/apps/admin/src/settings/advanced/integrations/unsplash-modal.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/unsplash-modal.tsx rename to apps/admin/src/settings/advanced/integrations/unsplash-modal.tsx diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhook-event-options.tsx b/apps/admin/src/settings/advanced/integrations/webhook-event-options.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/webhook-event-options.tsx rename to apps/admin/src/settings/advanced/integrations/webhook-event-options.tsx diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhook-modal.tsx b/apps/admin/src/settings/advanced/integrations/webhook-modal.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/webhook-modal.tsx rename to apps/admin/src/settings/advanced/integrations/webhook-modal.tsx diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.test.tsx b/apps/admin/src/settings/advanced/integrations/webhooks-table.test.tsx similarity index 96% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.test.tsx rename to apps/admin/src/settings/advanced/integrations/webhooks-table.test.tsx index 021904d92e5..0e07c74812f 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.test.tsx +++ b/apps/admin/src/settings/advanced/integrations/webhooks-table.test.tsx @@ -1,4 +1,4 @@ -import WebhooksTable from '@/settings/app/components/settings/advanced/integrations/webhooks-table'; +import WebhooksTable from '@/settings/advanced/integrations/webhooks-table'; import {ConfirmationProvider} from '@/settings/providers/confirmation-provider'; import {type Integration} from '@tryghost/admin-x-framework/api/integrations'; import {render, screen} from '@testing-library/react'; diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.tsx b/apps/admin/src/settings/advanced/integrations/webhooks-table.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.tsx rename to apps/admin/src/settings/advanced/integrations/webhooks-table.tsx diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/zapier-modal.tsx b/apps/admin/src/settings/advanced/integrations/zapier-modal.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/zapier-modal.tsx rename to apps/admin/src/settings/advanced/integrations/zapier-modal.tsx diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs.tsx b/apps/admin/src/settings/advanced/labs.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/advanced/labs.tsx rename to apps/admin/src/settings/advanced/labs.tsx diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs/beta-features.tsx b/apps/admin/src/settings/advanced/labs/beta-features.tsx similarity index 85% rename from apps/admin/src/settings/app/components/settings/advanced/labs/beta-features.tsx rename to apps/admin/src/settings/advanced/labs/beta-features.tsx index 7d92e1b3fee..f31f591a648 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/labs/beta-features.tsx +++ b/apps/admin/src/settings/advanced/labs/beta-features.tsx @@ -21,10 +21,34 @@ const BetaFeatures: React.FC = () => { const handleError = useHandleError(); const [redirectsUploading, setRedirectsUploading] = useState(false); const [routesUploading, setRoutesUploading] = useState(false); - const labs = JSON.parse(getSettingValue(settings, 'labs') || '{}'); + const labs = JSON.parse(getSettingValue(settings, 'labs') || '{}') as Record; const isAutomationsEnabled = !!labs.automations; const [openEditor, setOpenEditor] = useState<'redirects' | 'routes' | null>(null); + const uploadRedirectsFile = async (file: File) => { + try { + setRedirectsUploading(true); + await uploadRedirects(file); + toast.success('Redirects uploaded'); + } catch (e) { + handleError(e); + } finally { + setRedirectsUploading(false); + } + }; + + const uploadRoutesFile = async (file: File) => { + try { + setRoutesUploading(true); + await uploadRoutes(file); + toast.success('Routes uploaded'); + } catch (e) { + handleError(e); + } finally { + setRoutesUploading(false); + } + }; + const openRedirectsEditor = () => setOpenEditor('redirects'); const openRoutesEditor = () => setOpenEditor('routes'); const closeEditor = () => setOpenEditor(null); @@ -65,17 +89,7 @@ const BetaFeatures: React.FC = () => { { - try { - setRedirectsUploading(true); - await uploadRedirects(file); - toast.success('Redirects uploaded'); - } catch (e) { - handleError(e); - } finally { - setRedirectsUploading(false); - } - }} + onDropAccepted={([file]) => void uploadRedirectsFile(file)} > {redirectsUploading ? 'Uploading ...' : 'Upload redirects file'} @@ -92,17 +106,7 @@ const BetaFeatures: React.FC = () => { { - try { - setRoutesUploading(true); - await uploadRoutes(file); - toast.success('Routes uploaded'); - } catch (e) { - handleError(e); - } finally { - setRoutesUploading(false); - } - }} + onDropAccepted={([file]) => void uploadRoutesFile(file)} > {routesUploading ? 'Uploading ...' : 'Upload routes file'} diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs/feature-toggle.tsx b/apps/admin/src/settings/advanced/labs/feature-toggle.tsx similarity index 95% rename from apps/admin/src/settings/app/components/settings/advanced/labs/feature-toggle.tsx rename to apps/admin/src/settings/advanced/labs/feature-toggle.tsx index 7cb7ec64bce..724c0936e2d 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/labs/feature-toggle.tsx +++ b/apps/admin/src/settings/advanced/labs/feature-toggle.tsx @@ -71,7 +71,7 @@ const FeatureToggleConfirmationModal: React.FC = ({label, flag, disabled, confirmation}) => { const {settings} = useGlobalData(); - const labs = JSON.parse(getSettingValue(settings, 'labs') || '{}'); + const labs = JSON.parse(getSettingValue(settings, 'labs') || '{}') as Record; const {mutateAsync: editSettings} = useEditSettings(); const client = useQueryClient(); const handleError = useHandleError(); @@ -102,13 +102,13 @@ const FeatureToggle: React.FC = ({label, flag, disabled, con }; return <> - { + { if (confirmation && newValue) { setIsConfirming(true); return; } - await saveFeatureValue(newValue); + void saveFeatureValue(newValue); }} /> {confirmation && isConfirming && ( diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs/lab-item.tsx b/apps/admin/src/settings/advanced/labs/lab-item.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/advanced/labs/lab-item.tsx rename to apps/admin/src/settings/advanced/labs/lab-item.tsx diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx b/apps/admin/src/settings/advanced/labs/private-features.tsx similarity index 99% rename from apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx rename to apps/admin/src/settings/advanced/labs/private-features.tsx index b7d9e82eabd..95f2b308e39 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx +++ b/apps/admin/src/settings/advanced/labs/private-features.tsx @@ -109,7 +109,7 @@ const AlphaFeatures: React.FC = () => { } setAllowedFeatures(filtered); }; - filterFeatures(); + void filterFeatures(); }, [limiter]); return ( diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs/yaml-file-editor-modal.tsx b/apps/admin/src/settings/advanced/labs/yaml-file-editor-modal.tsx similarity index 99% rename from apps/admin/src/settings/app/components/settings/advanced/labs/yaml-file-editor-modal.tsx rename to apps/admin/src/settings/advanced/labs/yaml-file-editor-modal.tsx index ec1be8c19b6..10b3add21a5 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/labs/yaml-file-editor-modal.tsx +++ b/apps/admin/src/settings/advanced/labs/yaml-file-editor-modal.tsx @@ -87,7 +87,7 @@ const YamlFileEditorModal: React.FC = ({ } }; - loadContent(); + void loadContent(); return () => { isMounted = false; diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools.tsx b/apps/admin/src/settings/advanced/migration-tools.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/advanced/migration-tools.tsx rename to apps/admin/src/settings/advanced/migration-tools.tsx diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/export-all-modal.tsx b/apps/admin/src/settings/advanced/migration-tools/export-all-modal.tsx similarity index 99% rename from apps/admin/src/settings/app/components/settings/advanced/migration-tools/export-all-modal.tsx rename to apps/admin/src/settings/advanced/migration-tools/export-all-modal.tsx index 17286d65129..e211638d0df 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/export-all-modal.tsx +++ b/apps/admin/src/settings/advanced/migration-tools/export-all-modal.tsx @@ -80,7 +80,7 @@ const ExportAllModal: React.FC<{open: boolean; onOpenChange: (open: boolean) => }; const triggerMockDownload = () => { - const emptyZip = new Uint8Array([0x50, 0x4b, 0x05, 0x06, ...new Array(18).fill(0)]); + const emptyZip = new Uint8Array([0x50, 0x4b, 0x05, 0x06, ...new Array(18).fill(0)]); const url = URL.createObjectURL(new Blob([emptyZip], {type: 'application/zip'})); const anchor = document.createElement('a'); anchor.href = url; diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-export.tsx b/apps/admin/src/settings/advanced/migration-tools/migration-tools-export.tsx similarity index 92% rename from apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-export.tsx rename to apps/admin/src/settings/advanced/migration-tools/migration-tools-export.tsx index e52ab2a4259..6e796e70273 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-export.tsx +++ b/apps/admin/src/settings/advanced/migration-tools/migration-tools-export.tsx @@ -50,7 +50,7 @@ const MigrationToolsExport: React.FC = () => { return (
- +
); }; diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-import.tsx b/apps/admin/src/settings/advanced/migration-tools/migration-tools-import.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-import.tsx rename to apps/admin/src/settings/advanced/migration-tools/migration-tools-import.tsx diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.test.tsx b/apps/admin/src/settings/advanced/migration-tools/universal-import-modal.test.tsx similarity index 96% rename from apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.test.tsx rename to apps/admin/src/settings/advanced/migration-tools/universal-import-modal.test.tsx index 97b70c14cca..ae382dea9af 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.test.tsx +++ b/apps/admin/src/settings/advanced/migration-tools/universal-import-modal.test.tsx @@ -1,5 +1,5 @@ import {useState} from 'react'; -import UniversalImportModal from '@/settings/app/components/settings/advanced/migration-tools/universal-import-modal'; +import UniversalImportModal from '@/settings/advanced/migration-tools/universal-import-modal'; import {ConfirmationProvider} from '@/settings/providers/confirmation-provider'; import {act, fireEvent, render, screen, waitFor} from '@testing-library/react'; @@ -26,7 +26,7 @@ vi.mock('@tryghost/admin-x-framework/hooks', async () => { }); vi.mock('@/settings/hooks/use-feature-flag', () => ({ - default: (flag: string) => mockUseFeatureFlag(flag) + default: (flag: string) => mockUseFeatureFlag(flag) as boolean })); describe('UniversalImportModal', () => { @@ -57,6 +57,7 @@ describe('UniversalImportModal', () => { // "not called" assertion afterwards can't pass vacuously await act(async () => { fireEvent.change(input, {target: {files: [file]}}); + await Promise.resolve(); }); }; diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.tsx b/apps/admin/src/settings/advanced/migration-tools/universal-import-modal.tsx similarity index 69% rename from apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.tsx rename to apps/admin/src/settings/advanced/migration-tools/universal-import-modal.tsx index 2cbc0f46cdd..d7f19fead1e 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.tsx +++ b/apps/admin/src/settings/advanced/migration-tools/universal-import-modal.tsx @@ -21,6 +21,30 @@ const UniversalImportModal: React.FC<{onClose: () => void}> = ({onClose}) => { ? {'application/json': ['.json'], 'application/zip': ['.zip'], 'text/csv': ['.csv']} : {'application/json': ['.json'], 'application/zip': ['.zip']}; + const importFile = async (file: File) => { + setUploading(true); + try { + if (csvContentImporter && file.name.toLowerCase().endsWith('.csv')) { + await importContentCSV(file); + } else { + await importContent(file); + } + onClose(); + confirm({ + title: 'Import in progress', + prompt: `Your import is being processed, and you'll receive a confirmation email as soon as it’s complete. Usually this only takes a few minutes, but larger imports may take longer.`, + cancelLabel: '', + okLabel: 'Got it', + onOk: confirmModal => confirmModal?.remove(), + formSheet: false + }); + } catch (e) { + handleError(e); + } finally { + setUploading(false); + } + }; + return ( void}> = ({onClose}) => { accept={acceptedTypes} inputId="import-file" inputTestId="import-file" - onDropAccepted={async ([file]) => { - setUploading(true); - try { - if (csvContentImporter && file.name.toLowerCase().endsWith('.csv')) { - await importContentCSV(file); - } else { - await importContent(file); - } - onClose(); - confirm({ - title: 'Import in progress', - prompt: `Your import is being processed, and you'll receive a confirmation email as soon as it’s complete. Usually this only takes a few minutes, but larger imports may take longer.`, - cancelLabel: '', - okLabel: 'Got it', - onOk: confirmModal => confirmModal?.remove(), - formSheet: false - }); - } catch (e) { - handleError(e); - } finally { - setUploading(false); - } - }} + onDropAccepted={([file]) => void importFile(file)} >
{uploading ? 'Uploading...' : <> diff --git a/apps/admin/src/settings/advanced/search-keywords.ts b/apps/admin/src/settings/advanced/search-keywords.ts new file mode 100644 index 00000000000..22b132143fd --- /dev/null +++ b/apps/admin/src/settings/advanced/search-keywords.ts @@ -0,0 +1,8 @@ +export const searchKeywords = { + integrations: ['advanced', 'integrations', 'zapier', 'slack', 'unsplash', 'first promoter', 'firstpromoter', 'pintura', 'disqus', 'analytics', 'ulysses', 'typeform', 'buffer', 'plausible', 'github', 'webhooks'], + migrationtools: ['import', 'export', 'migrate', 'substack', 'substack', 'migration', 'medium', 'wordpress', 'wp', 'squarespace', 'beehiiv'], + codeInjection: ['advanced', 'code injection', 'head', 'footer'], + labs: ['advanced', 'labs', 'alpha', 'private', 'beta', 'flag', 'routes', 'redirect', 'translation', 'editor', 'portal'], + history: ['advanced', 'history', 'log', 'events', 'user events', 'staff', 'audit', 'action'], + dangerzone: ['danger zone', 'delete all content', 'delete site', 'reset all authentication', 'reset api keys', 'reset password', 'compromised credentials', 'lock staff users', 'sign out all staff'] +}; diff --git a/apps/admin/src/settings/app/components/settings/advanced/spam-filters.tsx b/apps/admin/src/settings/advanced/spam-filters.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/advanced/spam-filters.tsx rename to apps/admin/src/settings/advanced/spam-filters.tsx diff --git a/apps/admin/src/settings/data/zapier-templates.ts b/apps/admin/src/settings/data/zapier-templates.ts index fa5a1f90dcb..69d01b90e99 100644 --- a/apps/admin/src/settings/data/zapier-templates.ts +++ b/apps/admin/src/settings/data/zapier-templates.ts @@ -1,4 +1,4 @@ -import {type ZapierTemplate} from '@/settings/app/components/settings/advanced/integrations/zapier-modal'; +import {type ZapierTemplate} from '@/settings/advanced/integrations/zapier-modal'; // Ghost logo images import OrbBlack1 from '@/settings/assets/images/logos/orb-black-1.png'; diff --git a/apps/admin/src/settings/general/users/staff-token.tsx b/apps/admin/src/settings/general/users/staff-token.tsx index 2976b31fc01..a777ac24685 100644 --- a/apps/admin/src/settings/general/users/staff-token.tsx +++ b/apps/admin/src/settings/general/users/staff-token.tsx @@ -1,4 +1,4 @@ -import APIKeys from '@/settings/app/components/settings/advanced/integrations/api-keys'; +import APIKeys from '@/settings/advanced/integrations/api-keys'; import {Text} from '@tryghost/shade/primitives'; import {genStaffToken, getStaffToken} from '@tryghost/admin-x-framework/api/staff-token'; import {useConfirmation} from '@/settings/providers/confirmation-context'; diff --git a/apps/admin/src/settings/layout/settings-sections.tsx b/apps/admin/src/settings/layout/settings-sections.tsx index 8f29bc47c5b..5c65cf6ae90 100644 --- a/apps/admin/src/settings/layout/settings-sections.tsx +++ b/apps/admin/src/settings/layout/settings-sections.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import AdvancedSettings from '@/settings/app/components/settings/advanced/advanced-settings'; +import AdvancedSettings from '@/settings/advanced/advanced-settings'; import EmailSettings from '@/settings/email/email-settings'; import Emails from '@/settings/email/emails'; import GeneralSettings from '@/settings/general/general-settings'; diff --git a/apps/admin/src/settings/layout/sidebar.tsx b/apps/admin/src/settings/layout/sidebar.tsx index a65e189b33f..242bf642e19 100644 --- a/apps/admin/src/settings/layout/sidebar.tsx +++ b/apps/admin/src/settings/layout/sidebar.tsx @@ -9,7 +9,7 @@ import {useFocusContext} from '@tryghost/shade/app'; import {checkStripeEnabled, getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {searchKeywords as advancedSearchKeywords} from '@/settings/app/components/settings/advanced/advanced-settings'; +import {searchKeywords as advancedSearchKeywords} from '@/settings/advanced/search-keywords'; import {searchKeywords as emailSearchKeywords} from '@/settings/email/search-keywords'; import {searchKeywords as emailsSearchKeywords} from '@/settings/email/emails-search-keywords'; import {searchKeywords as generalSearchKeywords} from '@/settings/general/search-keywords'; diff --git a/apps/admin/src/settings/membership/membership-settings.tsx b/apps/admin/src/settings/membership/membership-settings.tsx index fac2823c8f7..fa503dd60c4 100644 --- a/apps/admin/src/settings/membership/membership-settings.tsx +++ b/apps/admin/src/settings/membership/membership-settings.tsx @@ -5,7 +5,7 @@ import MemberEmails from './member-emails'; import Portal from './portal'; import React from 'react'; import SearchableSection from '@/settings/components/searchable-section'; -import SpamFilters from '@/settings/app/components/settings/advanced/spam-filters'; +import SpamFilters from '@/settings/advanced/spam-filters'; import Tiers from './tiers'; import TipsAndDonations from '@/settings/growth/tips-and-donations'; import useFeatureFlag from '@/settings/hooks/use-feature-flag'; diff --git a/apps/admin/src/settings/providers/settings-app-context.ts b/apps/admin/src/settings/providers/settings-app-context.ts index ff10f5d1aa2..920829ba1c8 100644 --- a/apps/admin/src/settings/providers/settings-app-context.ts +++ b/apps/admin/src/settings/providers/settings-app-context.ts @@ -1,5 +1,5 @@ import {type ComponentId, type SearchService} from '@/settings/utils/search'; -import {type ZapierTemplate} from '@/settings/app/components/settings/advanced/integrations/zapier-modal'; +import {type ZapierTemplate} from '@/settings/advanced/integrations/zapier-modal'; import {createContext, useContext} from 'react'; import {officialThemes} from '@/settings/data/official-themes'; import {zapierTemplates} from '@/settings/data/zapier-templates'; diff --git a/apps/admin/src/settings/routes.tsx b/apps/admin/src/settings/routes.tsx index 1564ebe6a56..33452a6e369 100644 --- a/apps/admin/src/settings/routes.tsx +++ b/apps/admin/src/settings/routes.tsx @@ -29,16 +29,16 @@ export const settingsRouteChildren: RouteObject[] = [ {path: 'stripe-connect', lazy: lazyComponent(() => import('./membership/stripe/stripe-connect-modal'))}, {path: 'newsletters/new', lazy: lazyComponent(() => import('./email/newsletters/add-newsletter-modal'))}, {path: 'newsletters/:newsletterId', lazy: lazyComponent(() => import('./email/newsletters/newsletter-detail-modal'))}, - {path: 'history/view/:userId?', lazy: lazyComponent(() => import('./app/components/settings/advanced/history-modal'))}, - {path: 'integrations/new', lazy: lazyComponent(() => import('./app/components/settings/advanced/integrations/add-integration-modal'))}, - {path: 'integrations/contentapi', lazy: lazyComponent(() => import('./app/components/settings/advanced/integrations/content-api-modal'))}, - {path: 'integrations/zapier', lazy: lazyComponent(() => import('./app/components/settings/advanced/integrations/zapier-modal'))}, - {path: 'integrations/transistor', lazy: lazyComponent(() => import('./app/components/settings/advanced/integrations/transistor-modal'))}, - {path: 'integrations/slack', lazy: lazyComponent(() => import('./app/components/settings/advanced/integrations/slack-modal'))}, - {path: 'integrations/unsplash', lazy: lazyComponent(() => import('./app/components/settings/advanced/integrations/unsplash-modal'))}, - {path: 'integrations/firstpromoter', lazy: lazyComponent(() => import('./app/components/settings/advanced/integrations/first-promoter-modal'))}, - {path: 'integrations/pintura', lazy: lazyComponent(() => import('./app/components/settings/advanced/integrations/pintura-modal'))}, - {path: 'integrations/:integrationId', lazy: lazyComponent(() => import('./app/components/settings/advanced/integrations/custom-integration-modal'))}, + {path: 'history/view/:userId?', lazy: lazyComponent(() => import('./advanced/history-modal'))}, + {path: 'integrations/new', lazy: lazyComponent(() => import('./advanced/integrations/add-integration-modal'))}, + {path: 'integrations/contentapi', lazy: lazyComponent(() => import('./advanced/integrations/content-api-modal'))}, + {path: 'integrations/zapier', lazy: lazyComponent(() => import('./advanced/integrations/zapier-modal'))}, + {path: 'integrations/transistor', lazy: lazyComponent(() => import('./advanced/integrations/transistor-modal'))}, + {path: 'integrations/slack', lazy: lazyComponent(() => import('./advanced/integrations/slack-modal'))}, + {path: 'integrations/unsplash', lazy: lazyComponent(() => import('./advanced/integrations/unsplash-modal'))}, + {path: 'integrations/firstpromoter', lazy: lazyComponent(() => import('./advanced/integrations/first-promoter-modal'))}, + {path: 'integrations/pintura', lazy: lazyComponent(() => import('./advanced/integrations/pintura-modal'))}, + {path: 'integrations/:integrationId', lazy: lazyComponent(() => import('./advanced/integrations/custom-integration-modal'))}, {path: 'recommendations/add', lazy: lazyComponent(() => import('./growth/recommendations/add-recommendation-modal'))}, // The edit flow opens from the recommendations list with the loaded record // (never URL-driven); the legacy route only ever redirected back in effect. From 7030d434e150960faf8b9c9811ab3fa8c51e7033 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Wed, 19 Aug 2026 13:53:15 +0100 Subject: [PATCH 10/19] Fixed async gift checkout payment routing (#30106) ref https://linear.app/ghost/issue/BER-3851 Delayed payment methods can complete a Checkout Session before Stripe marks its payment as paid. Routing completed and asynchronous payment events through one guarded path prevents legacy gift purchases from being finalized early while leaving donation behavior unchanged. --- e2e/helpers/services/stripe/builders.ts | 1 + e2e/tests/public/portal-gifts.test.ts | 15 +++ .../webhook/checkout-session-event-service.js | 59 ++++++++-- .../services/stripe/webhook-controller.js | 9 +- .../server/services/stripe/webhook-manager.js | 2 + .../members/gift-subscriptions.test.js | 101 ++++++++++++++++++ .../checkout-session-event-service.test.js | 55 +++++++++- .../stripe/webhook-controller.test.js | 15 +++ 8 files changed, 243 insertions(+), 14 deletions(-) diff --git a/e2e/helpers/services/stripe/builders.ts b/e2e/helpers/services/stripe/builders.ts index cead71ef7a8..b38a657fd3a 100644 --- a/e2e/helpers/services/stripe/builders.ts +++ b/e2e/helpers/services/stripe/builders.ts @@ -345,6 +345,7 @@ export function buildGiftCheckoutCompletedEvent(opts: { id: opts.sessionId, object: 'checkout.session', mode: 'payment', + payment_status: 'paid', amount_total: opts.amount, currency: opts.currency, customer: opts.customerId ?? null, diff --git a/e2e/tests/public/portal-gifts.test.ts b/e2e/tests/public/portal-gifts.test.ts index d752c9e8cb5..11605440a73 100644 --- a/e2e/tests/public/portal-gifts.test.ts +++ b/e2e/tests/public/portal-gifts.test.ts @@ -1,6 +1,12 @@ import {FakeStripeCheckoutPage, PortalGiftPage} from '@/helpers/pages'; import {createPaidPortalTier, expect, test} from '@/helpers/playwright'; +interface GiftRedemptionResponse { + gifts: Array<{ + token: string; + }>; +} + test.describe('Ghost Public - Portal Gifts', () => { test.use({ labs: {giftSubCustomization: true}, @@ -43,6 +49,15 @@ test.describe('Ghost Public - Portal Gifts', () => { const checkoutSession = stripe!.getCheckoutSessions().at(-1); expect(checkoutSession).toBeDefined(); + const giftToken = checkoutSession!.response.metadata.gift_token; + expect(giftToken).toBeDefined(); + + const giftResponse = await page.request.get(`/members/api/gifts/${encodeURIComponent(giftToken!)}/redeem/`); + expect(giftResponse.ok()).toBe(true); + + const giftData = await giftResponse.json() as GiftRedemptionResponse; + expect(giftData.gifts[0]?.token).toBe(giftToken); + await page.goto(checkoutSession!.response.success_url); await portalGiftPage.waitForPortalToOpen(); diff --git a/ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js b/ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js index b0d01d09729..1302577de6e 100644 --- a/ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js +++ b/ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js @@ -14,8 +14,18 @@ function hasStripeMetadataKey(metadata, key) { return Object.prototype.hasOwnProperty.call(metadata || {}, key); } -function hasConflictingCheckoutFlowMetadata(metadata) { - return hasStripeMetadataKey(metadata, 'ghost_donation') && hasStripeMetadataKey(metadata, 'ghost_gift'); +function isGiftCheckoutSession(session) { + return isStripeMetadataTrue(session.metadata?.ghost_gift); +} + +function isDonationCheckoutSession(session) { + return isStripeMetadataTrue(session.metadata?.ghost_donation); +} + +// Matches the donation marker on key presence, not truthiness, so an ill-formed +// donation marker alongside a true gift marker still counts as a conflict. +function hasConflictingCheckoutFlowMetadata(session) { + return hasStripeMetadataKey(session.metadata, 'ghost_donation') && isGiftCheckoutSession(session); } function getStripeResourceId(resource) { @@ -65,8 +75,20 @@ module.exports = class CheckoutSessionEventService { * Handles a `checkout.session.completed` event * Delegates to the appropriate handler based on the session mode and metadata * @param {import('stripe').Stripe.Checkout.Session} session + * @param {import('stripe').Stripe.Event.Type} [eventType] */ - async handleEvent(session) { + async handleEvent(session, eventType = 'checkout.session.completed') { + if (eventType === 'checkout.session.async_payment_failed') { + return; + } + + if (eventType === 'checkout.session.async_payment_succeeded') { + if (session.mode === 'payment' && isGiftCheckoutSession(session)) { + await this.handlePaymentEvent(session); + } + return; + } + if (session.mode === 'setup') { await this.handleSetupEvent(session); } @@ -76,16 +98,33 @@ module.exports = class CheckoutSessionEventService { } if (session.mode === 'payment') { - if (hasConflictingCheckoutFlowMetadata(session.metadata)) { - logging.warn('Ignoring checkout session with conflicting payment flow metadata'); + await this.handlePaymentEvent(session); + } + } + + /** + * Routes a `payment` mode session to the donation or gift handler. Gift purchases + * may complete asynchronously, so their handler only runs once Stripe reports the + * session as paid, whichever event carried it. + * + * @param {import('stripe').Stripe.Checkout.Session} session + */ + async handlePaymentEvent(session) { + if (hasConflictingCheckoutFlowMetadata(session)) { + logging.warn('Ignoring checkout session with conflicting payment flow metadata'); + return; + } + + if (isGiftCheckoutSession(session)) { + if (session.payment_status !== 'paid') { return; } + await this.handleGiftEvent(session); + return; + } - if (isStripeMetadataTrue(session.metadata?.ghost_donation)) { - await this.handleDonationEvent(session); - } else if (isStripeMetadataTrue(session.metadata?.ghost_gift)) { - await this.handleGiftEvent(session); - } + if (isDonationCheckoutSession(session)) { + await this.handleDonationEvent(session); } } diff --git a/ghost/core/core/server/services/stripe/webhook-controller.js b/ghost/core/core/server/services/stripe/webhook-controller.js index c4be3ead2c7..fa8384105a5 100644 --- a/ghost/core/core/server/services/stripe/webhook-controller.js +++ b/ghost/core/core/server/services/stripe/webhook-controller.js @@ -21,6 +21,8 @@ module.exports = class WebhookController { 'customer.subscription.created': this.subscriptionEvent, 'invoice.payment_succeeded': this.invoiceEvent, 'checkout.session.completed': this.checkoutSessionEvent, + 'checkout.session.async_payment_succeeded': this.checkoutSessionEvent, + 'checkout.session.async_payment_failed': this.checkoutSessionEvent, 'charge.refunded': this.chargeRefundedEvent }; } @@ -119,7 +121,7 @@ module.exports = class WebhookController { return; } - await this.handlers[event.type].call(this, event.data.object); + await this.handlers[event.type].call(this, event.data.object, event.type); } /** @@ -143,10 +145,11 @@ module.exports = class WebhookController { /** * Delegates any `checkout.session.*` events to the `checkoutSessionEventService` * @param {import('stripe').Stripe.Checkout.Session} session + * @param {import('stripe').Stripe.Event.Type} eventType * @private */ - async checkoutSessionEvent(session) { - await this.checkoutSessionEventService.handleEvent(session); + async checkoutSessionEvent(session, eventType) { + await this.checkoutSessionEventService.handleEvent(session, eventType); } /** diff --git a/ghost/core/core/server/services/stripe/webhook-manager.js b/ghost/core/core/server/services/stripe/webhook-manager.js index 9a16d7febaf..863a6ac5d8f 100644 --- a/ghost/core/core/server/services/stripe/webhook-manager.js +++ b/ghost/core/core/server/services/stripe/webhook-manager.js @@ -43,6 +43,8 @@ module.exports = class WebhookManager { /** @type {WebhookEvent[]} */ static events = [ 'checkout.session.completed', + 'checkout.session.async_payment_succeeded', + 'checkout.session.async_payment_failed', 'customer.subscription.deleted', 'customer.subscription.updated', 'customer.subscription.created', diff --git a/ghost/core/test/e2e-api/members/gift-subscriptions.test.js b/ghost/core/test/e2e-api/members/gift-subscriptions.test.js index 5f06027da26..e53ce70a47f 100644 --- a/ghost/core/test/e2e-api/members/gift-subscriptions.test.js +++ b/ghost/core/test/e2e-api/members/gift-subscriptions.test.js @@ -179,6 +179,7 @@ describe('Gift Subscriptions', function () { object: { id: checkoutSession.id, mode: 'payment', + payment_status: 'paid', amount_total: paidTier.monthly_price, currency: paidTier.currency.toLowerCase(), customer: checkoutSession.customer, @@ -253,6 +254,7 @@ describe('Gift Subscriptions', function () { object: { id: checkoutSession.id, mode: 'payment', + payment_status: 'paid', amount_total: paidTier.yearly_price, currency: paidTier.currency.toLowerCase(), customer: checkoutSession.customer, @@ -277,6 +279,102 @@ describe('Gift Subscriptions', function () { assert.equal(gift.get('status'), 'purchased'); }); + it('Waits for async payment success before creating a gift', async function () { + const paidTier = await getPaidTier(); + + await membersAgent.post('/api/create-stripe-checkout-session/') + .body({ + type: 'gift', + tierId: paidTier.id, + cadence: 'month', + metadata: {} + }) + .expectStatus(200); + + const checkoutSession = getLatestCheckoutSession(); + const session = { + id: checkoutSession.id, + mode: 'payment', + payment_status: 'unpaid', + amount_total: paidTier.monthly_price, + currency: paidTier.currency.toLowerCase(), + customer: checkoutSession.customer, + customer_details: {email: 'async-gift-buyer@example.com'}, + metadata: toWebhookMetadata(checkoutSession.metadata), + payment_intent: 'pi_async_gift_test' + }; + + await stripeMocker.sendWebhook({ + type: 'checkout.session.completed', + data: {object: session} + }); + await DomainEvents.allSettled(); + + let gifts = await models.Gift.findAll({ + filter: `token:'${checkoutSession.metadata.gift_token}'` + }); + assert.equal(gifts.length, 0, 'Should not create a gift before payment succeeds'); + + await stripeMocker.sendWebhook({ + type: 'checkout.session.async_payment_succeeded', + data: { + object: { + ...session, + payment_status: 'paid' + } + } + }); + await DomainEvents.allSettled(); + + gifts = await models.Gift.findAll({ + filter: `token:'${checkoutSession.metadata.gift_token}'` + }); + assert.equal(gifts.length, 1, 'Should create exactly one gift after payment succeeds'); + }); + + it('Does not create a gift when async payment fails', async function () { + const paidTier = await getPaidTier(); + + await membersAgent.post('/api/create-stripe-checkout-session/') + .body({ + type: 'gift', + tierId: paidTier.id, + cadence: 'month', + metadata: {} + }) + .expectStatus(200); + + const checkoutSession = getLatestCheckoutSession(); + const session = { + id: checkoutSession.id, + mode: 'payment', + payment_status: 'unpaid', + amount_total: paidTier.monthly_price, + currency: paidTier.currency.toLowerCase(), + customer: checkoutSession.customer, + customer_details: {email: 'failed-async-gift-buyer@example.com'}, + metadata: toWebhookMetadata(checkoutSession.metadata), + payment_intent: 'pi_failed_async_gift_test' + }; + + await stripeMocker.sendWebhook({ + type: 'checkout.session.completed', + data: {object: session} + }); + await DomainEvents.allSettled(); + + await stripeMocker.sendWebhook({ + type: 'checkout.session.async_payment_failed', + data: {object: session} + }); + await DomainEvents.allSettled(); + + const gifts = await models.Gift.findAll({ + filter: `token:'${checkoutSession.metadata.gift_token}'` + }); + assert.equal(gifts.length, 0, 'Should not create a gift when payment fails'); + }); + it('traces a customized 3-month gift from checkout through redemption and member access', async function () { enableGiftCustomization(); @@ -307,6 +405,7 @@ describe('Gift Subscriptions', function () { object: { id: checkoutSession.id, mode: 'payment', + payment_status: 'paid', amount_total: expectedAmount, currency: paidTier.currency.toLowerCase(), customer: checkoutSession.customer, @@ -450,6 +549,7 @@ describe('Gift Subscriptions', function () { object: { id: checkoutSession.id, mode: 'payment', + payment_status: 'paid', amount_total: paidTier.monthly_price, currency: paidTier.currency.toLowerCase(), customer: checkoutSession.customer, @@ -506,6 +606,7 @@ describe('Gift Subscriptions', function () { object: { id: checkoutSession.id, mode: 'payment', + payment_status: 'paid', amount_total: expectedAmount, currency: paidTier.currency.toLowerCase(), customer: checkoutSession.customer, diff --git a/ghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js b/ghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js index 087c27fb63c..70964379428 100644 --- a/ghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js +++ b/ghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js @@ -109,7 +109,7 @@ describe('CheckoutSessionEventService', function () { it('should call handleGiftEvent if session mode is payment and session metadata ghost_gift is present', async function () { const service = createService(); - const session = {mode: 'payment', metadata: {ghost_gift: 'true'}}; + const session = {mode: 'payment', payment_status: 'paid', metadata: {ghost_gift: 'true'}}; const handleGiftEventStub = sinon.stub(service, 'handleGiftEvent'); await service.handleEvent(session); @@ -117,6 +117,59 @@ describe('CheckoutSessionEventService', function () { sinon.assert.calledWith(handleGiftEventStub, session); }); + it('waits for async payment success when a gift checkout is still unpaid', async function () { + const service = createService(); + const session = {mode: 'payment', payment_status: 'unpaid', metadata: {ghost_gift: 'true'}}; + const handleGiftEventStub = sinon.stub(service, 'handleGiftEvent'); + + await service.handleEvent(session); + sinon.assert.notCalled(handleGiftEventStub); + + await service.handleEvent({...session, payment_status: 'paid'}, 'checkout.session.async_payment_succeeded'); + sinon.assert.calledOnce(handleGiftEventStub); + }); + + it('ignores async payment success for a gift that Stripe still reports as unpaid', async function () { + const service = createService(); + const session = {mode: 'payment', payment_status: 'unpaid', metadata: {ghost_gift: 'true'}}; + const handleGiftEventStub = sinon.stub(service, 'handleGiftEvent'); + + await service.handleEvent(session, 'checkout.session.async_payment_succeeded'); + + sinon.assert.notCalled(handleGiftEventStub); + }); + + it('ignores async payment success with conflicting donation and gift markers', async function () { + const service = createService(); + const session = {mode: 'payment', payment_status: 'paid', metadata: {ghost_donation: 'true', ghost_gift: 'true'}}; + const handleDonationEventStub = sinon.stub(service, 'handleDonationEvent'); + const handleGiftEventStub = sinon.stub(service, 'handleGiftEvent'); + + await service.handleEvent(session, 'checkout.session.async_payment_succeeded'); + + sinon.assert.notCalled(handleDonationEventStub); + sinon.assert.notCalled(handleGiftEventStub); + }); + + it('does not handle donations on async payment success', async function () { + const service = createService(); + const session = {mode: 'payment', payment_status: 'paid', metadata: {ghost_donation: 'true'}}; + const handleDonationEventStub = sinon.stub(service, 'handleDonationEvent'); + + await service.handleEvent(session, 'checkout.session.async_payment_succeeded'); + + sinon.assert.notCalled(handleDonationEventStub); + }); + + it('ignores failed async gift payments', async function () { + const service = createService(); + const handleGiftEventStub = sinon.stub(service, 'handleGiftEvent'); + + await service.handleEvent({mode: 'payment', metadata: {ghost_gift: 'true'}}, 'checkout.session.async_payment_failed'); + + sinon.assert.notCalled(handleGiftEventStub); + }); + it('should ignore false gift metadata flags', async function () { const service = createService(); const session = {mode: 'payment', metadata: {ghost_gift: 'false'}}; diff --git a/ghost/core/test/unit/server/services/stripe/webhook-controller.test.js b/ghost/core/test/unit/server/services/stripe/webhook-controller.test.js index 0aa63147ebe..dd3a7867ef8 100644 --- a/ghost/core/test/unit/server/services/stripe/webhook-controller.test.js +++ b/ghost/core/test/unit/server/services/stripe/webhook-controller.test.js @@ -98,6 +98,21 @@ describe('WebhookController', function () { // expect(res.end.called).to.be.true; }); + for (const eventType of ['checkout.session.async_payment_succeeded', 'checkout.session.async_payment_failed']) { + it(`should handle ${eventType} event`, async function () { + const session = {customer: 'cust_123'}; + deps.webhookManager.parseWebhook.returns({ + type: eventType, + data: {object: session} + }); + + await controller.handle(req, res); + + sinon.assert.calledOnceWithExactly(deps.checkoutSessionEventService.handleEvent, session, eventType); + sinon.assert.calledWith(res.writeHead, 200); + }); + } + it('should handle customer subscription updated event', async function () { const event = { type: 'customer.subscription.updated', From 9b57105d786019098bfa2f6b866211d04d6bd623 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Wed, 19 Aug 2026 07:57:58 -0500 Subject: [PATCH 11/19] Cleaned up the admin-x naming left in the settings shell (#30117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref Tail of the settings integration: - `admin-x-settings-{content,sidebar,sidebar-scroller,scroller}` element ids → `settings-*`; the `admin-x-base admin-x-settings` wrapper class → `settings-app` (and the heading line-height rule in `index.css` follows). Nothing outside `apps/admin/src/settings` referenced any of them (checked Ember, e2e, shade, test-utils); no id collisions with Ember's DOM. - `SettingsAppProvider` typed every context field as an optional prop but overwrote `sortingState`/`setSortingState`/`offersShowArchived`/`setOffersShowArchived` after spreading them (the CodeRabbit note on #30113 — pre-existing). It now takes only `upgradeStatus`, the one prop its caller passes. --- apps/admin/src/index.css | 13 +++++++------ .../components/selectors/unsplash-selector.tsx | 2 +- .../src/settings/hooks/scroll-section-provider.tsx | 5 ++--- apps/admin/src/settings/layout/app.tsx | 2 +- apps/admin/src/settings/layout/main-content.tsx | 8 ++++---- apps/admin/src/settings/layout/sidebar.tsx | 4 ++-- .../settings/providers/settings-app-provider.tsx | 9 ++++----- 7 files changed, 21 insertions(+), 22 deletions(-) diff --git a/apps/admin/src/index.css b/apps/admin/src/index.css index 9ad4360f3bc..21e15fc7ed7 100644 --- a/apps/admin/src/index.css +++ b/apps/admin/src/index.css @@ -20,12 +20,13 @@ height: 100%; } -/* Keep admin-x heading line-height consistent with pre-migration settings render. */ -.admin-x-base h1, -.admin-x-base h2, -.admin-x-base h3, -.admin-x-base h4, -.admin-x-base h5 { +/* Settings' raw h1-h5 still expect the 1.25em admin-x heading line-height; Shade's base gives 1.5. + Remove once those headings carry an explicit leading. */ +.settings-app h1, +.settings-app h2, +.settings-app h3, +.settings-app h4, +.settings-app h5 { line-height: 1.25em; } diff --git a/apps/admin/src/settings/components/selectors/unsplash-selector.tsx b/apps/admin/src/settings/components/selectors/unsplash-selector.tsx index b5859212485..57d292e34c2 100644 --- a/apps/admin/src/settings/components/selectors/unsplash-selector.tsx +++ b/apps/admin/src/settings/components/selectors/unsplash-selector.tsx @@ -10,7 +10,7 @@ type UnsplashSelectorModalProps = { const UnsplashSelector : React.FC = ({unsplashProviderConfig, onClose, onImageInsert}) => { return ( - + { - const root = document.getElementById('admin-x-settings-scroller')!; + const root = document.getElementById('settings-scroller')!; const top = element.getBoundingClientRect().top + root.scrollTop; root.scrollTo({ @@ -14,8 +14,7 @@ const scrollToSection = (element: HTMLDivElement, doneInitialScroll: boolean) => }; const scrollSidebarNav = (navElement: HTMLLIElement, doneInitialScroll: boolean) => { - // const sidebar = document.getElementById('admin-x-settings-sidebar')!; - const sidebar = document.getElementById('admin-x-settings-sidebar-scroller')!; + const sidebar = document.getElementById('settings-sidebar-scroller')!; const bounds = navElement.getBoundingClientRect(); diff --git a/apps/admin/src/settings/layout/app.tsx b/apps/admin/src/settings/layout/app.tsx index 231560f5899..c448b5848bd 100644 --- a/apps/admin/src/settings/layout/app.tsx +++ b/apps/admin/src/settings/layout/app.tsx @@ -28,7 +28,7 @@ function SettingsLocationSync() { export function App({upgradeStatus}: AppProps) { return ( -
+
diff --git a/apps/admin/src/settings/layout/main-content.tsx b/apps/admin/src/settings/layout/main-content.tsx index 1bb803f521b..fc3492a51ac 100644 --- a/apps/admin/src/settings/layout/main-content.tsx +++ b/apps/admin/src/settings/layout/main-content.tsx @@ -19,7 +19,7 @@ const Page: React.FC<{children: ReactNode}> = ({children}) => {
-
+
{children}
; @@ -74,7 +74,7 @@ const MainContent: React.FC = () => { return (
-
+
Settings @@ -88,13 +88,13 @@ const MainContent: React.FC = () => { return ( -
+
-
+
diff --git a/apps/admin/src/settings/layout/sidebar.tsx b/apps/admin/src/settings/layout/sidebar.tsx index 242bf642e19..8bb348e8fe9 100644 --- a/apps/admin/src/settings/layout/sidebar.tsx +++ b/apps/admin/src/settings/layout/sidebar.tsx @@ -207,7 +207,7 @@ const Sidebar: React.FC = () => { setFilter(e.target.value); if (e.target.value) { - document.querySelector('.admin-x-settings')?.scrollTo({top: 0, left: 0}); + document.querySelector('.settings-app')?.scrollTo({top: 0, left: 0}); } }; @@ -243,7 +243,7 @@ const Sidebar: React.FC = () => {
-