Merge branch 'main' into main

This commit is contained in:
David Darnes 2024-07-15 15:10:33 +01:00 committed by GitHub
commit 60a2b0689c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
213 changed files with 8494 additions and 6455 deletions

View File

@ -1,4 +1,4 @@
Copyright (c) 2013-2023 Ghost Foundation
Copyright (c) 2013-2024 Ghost Foundation
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation

View File

@ -17,7 +17,7 @@
<a href="https://twitter.com/ghost">Twitter</a>
<br /><br />
<a href="https://ghost.org/">
<img src="https://img.shields.io/badge/downloads-3M-brightgreen.svg" alt="Downloads" />
<img src="https://img.shields.io/badge/downloads-100M+-brightgreen.svg" alt="Downloads" />
</a>
<a href="https://github.com/TryGhost/Ghost/releases/">
<img src="https://img.shields.io/github/release/TryGhost/Ghost.svg" alt="Latest release" />
@ -82,7 +82,7 @@ For anyone wishing to contribute to Ghost or to hack/customize core files we rec
# Ghost sponsors
We'd like to extend big thanks to our sponsors and partners who make Ghost possible. If you're interested in sponsoring Ghost and supporting the project, please check out our profile on [GitHub sponsors](https://github.com/sponsors/TryGhost) :heart:
A big thanks to our sponsors and partners who make Ghost possible. If you're interested in sponsoring Ghost and supporting the project, please check out our profile on [GitHub sponsors](https://github.com/sponsors/TryGhost) :heart:
**[DigitalOcean](https://m.do.co/c/9ff29836d717)** • **[Fastly](https://www.fastly.com/)**
@ -90,12 +90,13 @@ We'd like to extend big thanks to our sponsors and partners who make Ghost possi
# Getting help
You can find answers to a huge variety of questions, along with a large community of helpful developers over on the [Ghost forum](https://forum.ghost.org/) - replies are generally very quick. **Ghost(Pro)** customers also have access to 24/7 email support.
Everyone can get help and support from a large community of developers over on the [Ghost forum](https://forum.ghost.org/). **Ghost(Pro)** customers have access to 24/7 email support.
To stay up to date with all the latest news and product updates, make sure you [subscribe to our blog](https://ghost.org/blog/) — or you can always follow us [on Twitter](https://twitter.com/Ghost), if you prefer your updates bite-sized and facetious. :saxophone::turtle:
To stay up to date with all the latest news and product updates, make sure you [subscribe to our changelog newsletter](https://ghost.org/changelog/) — or follow us [on Twitter](https://twitter.com/Ghost), if you prefer your updates bite-sized and facetious. :saxophone::turtle:
&nbsp;
# Copyright & license
Copyright (c) 2013-2023 Ghost Foundation - Released under the [MIT license](LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage.
Copyright (c) 2013-2024 Ghost Foundation - Released under the [MIT license](LICENSE).
Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage.

View File

@ -36,10 +36,13 @@
"@testing-library/react": "14.1.0",
"@tryghost/admin-x-design-system": "0.0.0",
"@tryghost/admin-x-framework": "0.0.0",
"@types/jest": "29.5.12",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"jest": "29.7.0",
"react": "18.3.1",
"react-dom": "18.3.1"
"react-dom": "18.3.1",
"ts-jest": "29.1.5"
},
"nx": {
"targets": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@ -1,5 +1,6 @@
import NiceModal from '@ebay/nice-modal-react';
import {Modal, TextField, showToast} from '@tryghost/admin-x-design-system';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import {useFollow} from '@tryghost/admin-x-framework/api/activitypub';
import {useQueryClient} from '@tryghost/admin-x-framework';
import {useRouting} from '@tryghost/admin-x-framework/routing';
@ -16,6 +17,9 @@ const FollowSite = NiceModal.create(() => {
const modal = NiceModal.useModal();
const mutation = useFollow();
const client = useQueryClient();
const site = useBrowseSite();
const siteData = site.data?.site;
const siteUrl = siteData?.url ?? window.location.origin;
// mutation.isPending
// mutation.isError
@ -30,8 +34,11 @@ const FollowSite = NiceModal.create(() => {
const handleFollow = async () => {
try {
const url = new URL(`.ghost/activitypub/actions/follow/${profileName}`, siteUrl);
await fetch(url, {
method: 'POST'
});
// Perform the mutation
await mutation.mutateAsync({username: profileName});
// If successful, set the success state to true
// setSuccess(true);
showToast({

View File

@ -1,9 +1,9 @@
// import NiceModal from '@ebay/nice-modal-react';
import React, {useState} from 'react';
import ActivityPubWelcomeImage from '../assets/images/ap-welcome.png';
import React, {useEffect, useRef, useState} from 'react';
import articleBodyStyles from './articleBodyStyles';
import getUsername from '../utils/get-username';
import {ActorProperties, ObjectProperties, useBrowseFollowersForUser, useBrowseFollowingForUser, useBrowseInboxForUser} from '@tryghost/admin-x-framework/api/activitypub';
import {Avatar, Button, Heading, List, ListItem, Page, SettingValue, ViewContainer, ViewTab} from '@tryghost/admin-x-design-system';
import {Avatar, Button, ButtonGroup, Heading, List, ListItem, Page, SelectOption, SettingValue, ViewContainer, ViewTab} from '@tryghost/admin-x-design-system';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import {useRouting} from '@tryghost/admin-x-framework/routing';
@ -16,7 +16,7 @@ const ActivityPubComponent: React.FC = () => {
const {updateRoute} = useRouting();
// TODO: Replace with actual user ID
const {data: {orderedItems: activities = []} = {}} = useBrowseInboxForUser('index');
const {data: {items: activities = []} = {}} = useBrowseInboxForUser('index');
const {data: {totalItems: followingCount = 0} = {}} = useBrowseFollowingForUser('index');
const {data: {totalItems: followersCount = 0} = {}} = useBrowseFollowersForUser('index');
@ -32,20 +32,30 @@ const ActivityPubComponent: React.FC = () => {
setArticleContent(null);
};
const [selectedOption, setSelectedOption] = useState<SelectOption>({label: 'Inbox', value: 'inbox'});
const [selectedTab, setSelectedTab] = useState('inbox');
const tabs: ViewTab[] = [
{
id: 'inbox',
title: 'Inbox',
contents: <div className='grid grid-cols-6 items-start gap-8'>
<ul className='order-2 col-span-6 flex flex-col lg:order-1 lg:col-span-4'>
{activities && activities.slice().reverse().map(activity => (
contents: <div className='grid grid-cols-6 items-start gap-8 pt-8'>
<ul className={`order-2 col-span-6 flex flex-col pb-8 lg:order-1 ${selectedOption.value === 'inbox' ? 'lg:col-span-4' : 'lg:col-span-3'}`}>
{activities && activities.some(activity => activity.type === 'Create' && activity.object.type === 'Article') ? (activities.slice().reverse().map(activity => (
activity.type === 'Create' && activity.object.type === 'Article' &&
<li key={activity.id} data-test-view-article onClick={() => handleViewContent(activity.object, activity.actor)}>
<ObjectContentDisplay actor={activity.actor} object={activity.object}/>
<ObjectContentDisplay actor={activity.actor} layout={selectedOption.value} object={activity.object}/>
</li>
))}
))) : <div className='flex items-center justify-center text-center'>
<div className='flex max-w-[32em] flex-col items-center justify-center gap-4'>
<img alt='Ghost site logos' className='w-[220px]' src={ActivityPubWelcomeImage}/>
<Heading className='text-balance' level={2}>Welcome to ActivityPub</Heading>
<p className='text-pretty text-grey-800'>Were so glad to have you on board! At the moment, you can follow other Ghost sites and enjoy their content right here inside Ghost.</p>
<p className='text-pretty text-grey-800'>You can see all of the users on the rightfind your favorite ones and give them a follow.</p>
<Button color='green' label='Learn more' link={true}/>
</div>
</div>}
</ul>
<Sidebar followersCount={followersCount} followingCount={followingCount} updateRoute={updateRoute} />
</div>
@ -53,9 +63,9 @@ const ActivityPubComponent: React.FC = () => {
{
id: 'activity',
title: 'Activity',
contents: <div className='grid grid-cols-6 items-start gap-8'><List className='col-span-4'>
contents: <div className='grid grid-cols-6 items-start gap-8 pt-8'><List className='col-span-4'>
{activities && activities.slice().reverse().map(activity => (
activity.type === 'Like' && <ListItem avatar={<Avatar image={activity.actor.icon} size='sm' />} id='list-item' title={<div><span className='font-medium'>{activity.actor.name}</span><span className='text-grey-800'> liked your post </span><span className='font-medium'>{activity.object.name}</span></div>}></ListItem>
activity.type === 'Like' && <ListItem avatar={<Avatar image={activity.actor.icon?.url} size='sm' />} id='list-item' title={<div><span className='font-medium'>{activity.actor.name}</span><span className='text-grey-800'> liked your post </span><span className='font-medium'>{activity.object.name}</span></div>}></ListItem>
))}
</List>
<Sidebar followersCount={followersCount} followingCount={followingCount} updateRoute={updateRoute} />
@ -64,12 +74,12 @@ const ActivityPubComponent: React.FC = () => {
{
id: 'likes',
title: 'Likes',
contents: <div className='grid grid-cols-6 items-start gap-8'>
contents: <div className='grid grid-cols-6 items-start gap-8 pt-8'>
<ul className='order-2 col-span-6 flex flex-col lg:order-1 lg:col-span-4'>
{activities && activities.slice().reverse().map(activity => (
activity.type === 'Create' && activity.object.type === 'Article' &&
<li key={activity.id} data-test-view-article onClick={() => handleViewContent(activity.object, activity.actor)}>
<ObjectContentDisplay actor={activity.actor} object={activity.object}/>
<ObjectContentDisplay actor={activity.actor} layout={selectedOption.value} object={activity.object} />
</li>
))}
</ul>
@ -82,6 +92,25 @@ const ActivityPubComponent: React.FC = () => {
<Page>
{!articleContent ? (
<ViewContainer
actions={[<ButtonGroup buttons={[
{
icon: 'listview',
size: 'sm',
iconColorClass: selectedOption.value === 'inbox' ? 'text-black' : 'text-grey-500',
onClick: () => {
setSelectedOption({label: 'Inbox', value: 'inbox'});
}
},
{
icon: 'cardview',
size: 'sm',
iconColorClass: selectedOption.value === 'feed' ? 'text-black' : 'text-grey-500',
onClick: () => {
setSelectedOption({label: 'Feed', value: 'feed'});
}
}
]} clearBg={true} link outlineOnMobile />]}
firstOnPage={true}
primaryAction={{
title: 'Follow',
@ -93,7 +122,8 @@ const ActivityPubComponent: React.FC = () => {
selectedTab={selectedTab}
stickyHeader={true}
tabs={tabs}
toolbarBorder={false}
title='ActivityPub'
toolbarBorder={true}
type='page'
onTabChange={setSelectedTab}
>
@ -108,7 +138,8 @@ const ActivityPubComponent: React.FC = () => {
};
const Sidebar: React.FC<{followingCount: number, followersCount: number, updateRoute: (route: string) => void}> = ({followingCount, followersCount, updateRoute}) => (
<div className='order-1 col-span-6 rounded-xl bg-grey-50 p-6 lg:order-2 lg:col-span-2' id="ap-sidebar">
<div className='order-1 col-span-6 flex flex-col gap-5 lg:order-2 lg:col-span-2 lg:col-start-5'>
<div className='rounded-xl bg-grey-50 p-6' id="ap-sidebar">
<div className='mb-4 border-b border-b-grey-200 pb-4'><SettingValue key={'your-username'} heading={'Your username'} value={'@index@localplaceholder.com'}/></div>
<div className='grid grid-cols-2 gap-4'>
<div className='group/stat flex cursor-pointer flex-col gap-1' onClick={() => updateRoute('/view-following')}>
@ -121,14 +152,26 @@ const Sidebar: React.FC<{followingCount: number, followersCount: number, updateR
</div>
</div>
</div>
<div className='rounded-xl bg-grey-50 p-6'>
<header className='mb-4 flex items-center justify-between'>
<Heading level={5}>Explore</Heading>
<Button label='View all' link={true}/>
</header>
<List>
<ListItem action={<Button color='grey' label='Follow' link={true} onClick={() => {}} />} avatar={<Avatar image={`https://ghost.org/favicon.ico`} size='sm' />} detail='829 followers' hideActions={true} title='404 Media' />
<ListItem action={<Button color='grey' label='Follow' link={true} onClick={() => {}} />} avatar={<Avatar image={`https://ghost.org/favicon.ico`} size='sm' />} detail='791 followers' hideActions={true} title='The Browser' />
<ListItem action={<Button color='grey' label='Follow' link={true} onClick={() => {}} />} avatar={<Avatar image={`https://ghost.org/favicon.ico`} size='sm' />} detail='854 followers' hideActions={true} title='Welcome to Hell World' />
</List>
</div>
</div>
);
const ArticleBody: React.FC<{heading: string, image: string|undefined, html: string}> = ({heading, image, html}) => {
// const dangerouslySetInnerHTML = {__html: html};
// const cssFile = '../index.css';
const site = useBrowseSite();
const siteData = site.data?.site;
const iframeRef = useRef<HTMLIFrameElement>(null);
const cssContent = articleBodyStyles(siteData?.url.replace(/\/$/, ''));
const htmlContent = `
@ -152,20 +195,29 @@ ${image &&
</html>
`;
useEffect(() => {
const iframe = iframeRef.current;
if (iframe) {
iframe.srcdoc = htmlContent;
}
}, [htmlContent]);
return (
<div>
<iframe
className='h-[calc(100vh_-_3vmin_-_4.8rem_-_2px)]'
ref={iframeRef}
className={`h-[calc(100vh_-_3vmin_-_4.8rem_-_2px)]`}
height="100%"
id="gh-ap-article-iframe"
srcDoc={htmlContent}
title="Embedded Content"
width="100%"
>
</iframe>
</div>
);
};
const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProperties }> = ({actor, object}) => {
const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProperties, layout: string }> = ({actor, object, layout}) => {
const parser = new DOMParser();
const doc = parser.parseFromString(object.content || '', 'text/html');
@ -183,37 +235,75 @@ const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProp
setTimeout(() => setIsClicked(false), 300); // Reset the animation class after 300ms
};
if (layout === 'feed') {
return (
<>
{object && (
<div className='border-1 group/article relative z-10 flex cursor-pointer flex-col items-start justify-between border-b border-b-grey-200 py-6' data-test-activity>
<div className='relative z-10 mb-3 flex w-full items-center gap-3'>
<img className='w-8' src={actor.icon?.url}/>
<div>
<p className='text-base font-bold' data-test-activity-heading>{actor.name}</p>
<div className='*:text-base *:text-grey-900'>
{/* <span className='truncate before:mx-1 before:content-["·"]'>{getUsername(actor)}</span> */}
<span>{timestamp}</span>
</div>
</div>
</div>
<div className='relative z-10 w-full gap-4'>
<div className='flex flex-col'>
{object.image && <div className='relative mb-4'>
<img className='h-[300px] w-full rounded object-cover' src={object.image}/>
</div>}
<Heading className='mb-1 leading-tight' level={4} data-test-activity-heading>{object.name}</Heading>
<p className='mb-4 line-clamp-3 max-w-prose text-pretty text-md text-grey-900'>{plainTextContent}</p>
<div className='flex gap-2'>
<Button className={`self-start text-grey-500 transition-all hover:text-grey-800 ${isClicked ? 'bump' : ''} ${isLiked ? 'ap-red-heart text-red *:!fill-red hover:text-red' : ''}`} hideLabel={true} icon='heart' id="like" size='md' unstyled={true} onClick={handleLikeClick}/>
<span className={`text-grey-800 ${isLiked ? 'opacity-100' : 'opacity-0'}`}>1</span>
</div>
</div>
</div>
<div className='absolute -inset-x-3 -inset-y-1 z-0 rounded transition-colors group-hover/article:bg-grey-100'></div>
{/* <div className='absolute inset-0 z-0 rounded from-white to-grey-50 transition-colors group-hover/article:bg-gradient-to-r'></div> */}
</div>
)}
</>
);
} else if (layout === 'inbox') {
return (
<>
{object && (
<div className='border-1 group/article relative z-10 flex cursor-pointer flex-col items-start justify-between border-b border-b-grey-200 py-5' data-test-activity>
<div className='relative z-10 mb-3 grid w-full grid-cols-[20px_auto_1fr_auto] items-center gap-2 text-base'>
<img className='w-5' src={actor.icon}/>
<img className='w-5' src={actor.icon?.url}/>
<span className='truncate font-semibold'>{actor.name}</span>
<span className='truncate text-grey-800'>{getUsername(actor)}</span>
{/* <span className='truncate text-grey-800'>{getUsername(actor)}</span> */}
<span className='ml-auto text-right text-grey-800'>{timestamp}</span>
</div>
<div className='relative z-10 grid w-full grid-cols-[auto_170px] gap-4'>
<div className='flex flex-col'>
<div className='flex w-full justify-between gap-4'>
<Heading className='mb-2 line-clamp-2 leading-tight' level={5} data-test-activity-heading>{object.name}</Heading>
<Heading className='mb-1 line-clamp-2 leading-tight' level={5} data-test-activity-heading>{object.name}</Heading>
</div>
<p className='mb-6 line-clamp-2 max-w-prose text-md text-grey-800'>{plainTextContent}</p>
<p className='mb-6 line-clamp-2 max-w-prose text-pretty text-md text-grey-800'>{object.preview?.content}</p>
<div className='flex gap-2'>
<Button className={`self-start text-grey-500 transition-all hover:text-grey-800 ${isClicked ? 'bump' : ''} ${isLiked ? 'ap-red-heart text-red *:!fill-red hover:text-red' : ''}`} hideLabel={true} icon='heart' id="like" size='md' unstyled={true} onClick={handleLikeClick}/>
<span className={`text-grey-800 ${isLiked ? 'opacity-100' : 'opacity-0'}`}>1</span>
</div>
</div>
{object.image && <div className='relative min-w-[33%] grow'>
<img className='absolute h-full w-full rounded object-cover' src={object.image}/>
<img className='absolute h-full w-full rounded object-cover' height='140px' src={object.image} width='170px'/>
</div>}
</div>
<div className='absolute -inset-x-3 inset-y-0 z-0 rounded transition-colors group-hover/article:bg-grey-50'></div>
<div className='absolute -inset-x-3 -inset-y-1 z-0 rounded transition-colors group-hover/article:bg-grey-50'></div>
{/* <div className='absolute inset-0 z-0 rounded from-white to-grey-50 transition-colors group-hover/article:bg-gradient-to-r'></div> */}
</div>
)}
</>
);
}
};
const ViewArticle: React.FC<ViewArticleProps> = ({object, onBackToList}) => {
@ -241,7 +331,7 @@ const ViewArticle: React.FC<ViewArticleProps> = ({object, onBackToList}) => {
</div>
<div className='flex items-center justify-between'>
</div>
<div className='flex justify-end'>
<div className='flex items-center justify-end gap-2'>
<div className='flex flex-row-reverse items-center gap-3'>
<Button className={`self-start text-grey-500 transition-all hover:text-grey-800 ${isClicked ? 'bump' : ''} ${isLiked ? 'ap-red-heart text-red *:!fill-red hover:text-red' : ''}`} hideLabel={true} icon='heart' id="like" size='md' unstyled={true} onClick={handleLikeClick}/>
<span className={`text-grey-800 ${isLiked ? 'opacity-100' : 'opacity-0'}`}>1</span>

View File

@ -1,8 +1,8 @@
import {} from '@tryghost/admin-x-framework/api/activitypub';
import NiceModal from '@ebay/nice-modal-react';
import getUsernameFromFollowing from '../utils/get-username-from-following';
import getUsername from '../utils/get-username';
import {Avatar, Button, List, ListItem, Modal} from '@tryghost/admin-x-design-system';
import {FollowingResponseData, useBrowseFollowersForUser, useUnfollow} from '@tryghost/admin-x-framework/api/activitypub';
import {FollowingResponseData, useBrowseFollowersForUser, useFollow} from '@tryghost/admin-x-framework/api/activitypub';
import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing';
interface ViewFollowersModalProps {
@ -13,10 +13,11 @@ interface ViewFollowersModalProps {
const ViewFollowersModal: React.FC<RoutingModalProps & ViewFollowersModalProps> = ({}) => {
const {updateRoute} = useRouting();
// const modal = NiceModal.useModal();
const mutation = useUnfollow();
const mutation = useFollow();
const {data: {orderedItems: followers = []} = {}} = useBrowseFollowersForUser('inbox');
const {data: {items = []} = {}} = useBrowseFollowersForUser('inbox');
const followers = Array.isArray(items) ? items : [items];
return (
<Modal
afterClose={() => {
@ -33,7 +34,7 @@ const ViewFollowersModal: React.FC<RoutingModalProps & ViewFollowersModalProps>
<div className='mt-3 flex flex-col gap-4 pb-12'>
<List>
{followers.map(item => (
<ListItem action={<Button color='grey' label='Follow back' link={true} onClick={() => mutation.mutate({username: item.username})} />} avatar={<Avatar image={item.icon} size='sm' />} detail={getUsernameFromFollowing(item)} id='list-item' title={item.name}></ListItem>
<ListItem action={<Button color='grey' label='Follow back' link={true} onClick={() => mutation.mutate({username: getUsername(item)})} />} avatar={<Avatar image={item.icon} size='sm' />} detail={getUsername(item)} id='list-item' title={item.name}></ListItem>
))}
</List>
</div>

View File

@ -1,6 +1,6 @@
import {} from '@tryghost/admin-x-framework/api/activitypub';
import NiceModal from '@ebay/nice-modal-react';
import getUsernameFromFollowing from '../utils/get-username-from-following';
import getUsername from '../utils/get-username';
import {Avatar, Button, List, ListItem, Modal} from '@tryghost/admin-x-design-system';
import {FollowingResponseData, useBrowseFollowingForUser, useUnfollow} from '@tryghost/admin-x-framework/api/activitypub';
import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing';
@ -14,8 +14,9 @@ const ViewFollowingModal: React.FC<RoutingModalProps & ViewFollowingModalProps>
const {updateRoute} = useRouting();
const mutation = useUnfollow();
const {data: {orderedItems: following = []} = {}} = useBrowseFollowingForUser('inbox');
const {data: {items = []} = {}} = useBrowseFollowingForUser('inbox');
const following = Array.isArray(items) ? items : [items];
return (
<Modal
afterClose={() => {
@ -32,7 +33,7 @@ const ViewFollowingModal: React.FC<RoutingModalProps & ViewFollowingModalProps>
<div className='mt-3 flex flex-col gap-4 pb-12'>
<List>
{following.map(item => (
<ListItem action={<Button color='grey' label='Unfollow' link={true} onClick={() => mutation.mutate({username: getUsernameFromFollowing(item)})} />} avatar={<Avatar image={item.icon} size='sm' />} detail={getUsernameFromFollowing(item)} id='list-item' title={item.name}></ListItem>
<ListItem action={<Button color='grey' label='Unfollow' link={true} onClick={() => mutation.mutate({username: getUsername(item)})} />} avatar={<Avatar image={item.icon} size='sm' />} detail={getUsername(item)} id='list-item' title={item.name}></ListItem>
))}
</List>
{/* <Table>

View File

@ -1,12 +0,0 @@
function getUsernameFromFollowing(followItem: {username: string; id: string|null;}) {
if (!followItem.username || !followItem.id) {
return '@unknown@unknown';
}
try {
return `@${followItem.username}@${(new URL(followItem.id)).hostname}`;
} catch (err) {
return '@unknown@unknown';
}
}
export default getUsernameFromFollowing;

View File

@ -4,6 +4,7 @@
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"skipLibCheck": true,
"types": ["vite/client", "jest"],
/* Bundler mode */
"moduleResolution": "bundler",

View File

@ -11,7 +11,8 @@
"scripts": {
"build": "concurrently \"vite build\" \"tsc -p tsconfig.declaration.json\"",
"prepare": "yarn build",
"test": "yarn test:types",
"test": "yarn test:unit && yarn test:types",
"test:unit": "yarn nx build && vitest run",
"test:types": "tsc --noEmit",
"lint:code": "eslint --ext .js,.ts,.cjs,.tsx src/ --cache",
"lint": "yarn lint:code && yarn lint:test",
@ -27,15 +28,16 @@
],
"devDependencies": {
"@codemirror/lang-html": "6.4.9",
"@storybook/addon-essentials": "7.6.19",
"@storybook/addon-interactions": "7.6.19",
"@storybook/addon-links": "7.6.19",
"@storybook/addon-essentials": "7.6.20",
"@storybook/addon-interactions": "7.6.20",
"@storybook/addon-links": "7.6.20",
"@storybook/addon-styling": "1.3.7",
"@storybook/blocks": "7.6.19",
"@storybook/react": "7.6.19",
"@storybook/blocks": "7.6.20",
"@storybook/react": "7.6.20",
"@storybook/react-vite": "7.6.4",
"@storybook/testing-library": "0.2.2",
"@testing-library/react": "14.1.0",
"@testing-library/react-hooks" : "8.0.1",
"@vitejs/plugin-react": "4.2.1",
"c8": "8.0.1",
"eslint-plugin-react-hooks": "4.6.0",
@ -43,11 +45,12 @@
"eslint-plugin-tailwindcss": "3.13.0",
"jsdom": "24.1.0",
"mocha": "10.2.0",
"chai": "4.3.8",
"react": "18.3.1",
"react-dom": "18.3.1",
"rollup-plugin-node-builtins": "2.1.2",
"sinon": "17.0.0",
"storybook": "7.6.19",
"storybook": "7.6.20",
"ts-node": "10.9.2",
"typescript": "5.4.5",
"vite": "4.5.3",
@ -60,10 +63,10 @@
"@sentry/react": "7.118.0",
"@tailwindcss/forms": "0.5.7",
"@tailwindcss/line-clamp": "0.4.4",
"@uiw/react-codemirror": "4.22.1",
"@uiw/react-codemirror": "4.23.0",
"autoprefixer": "10.4.19",
"clsx": "2.1.1",
"postcss": "8.4.38",
"postcss": "8.4.39",
"postcss-import": "16.1.0",
"react-colorful": "5.6.1",
"react-hot-toast": "2.4.1",

View File

@ -18,7 +18,6 @@ export interface TextAreaProps extends HTMLProps<HTMLTextAreaElement> {
error?: boolean;
placeholder?: string;
hint?: React.ReactNode;
clearBg?: boolean;
fontStyle?: FontStyles;
className?: string;
onChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;

View File

@ -179,8 +179,8 @@ const ViewContainer: React.FC<ViewContainerProps> = ({
toolbarContainerClassName = clsx(
'flex justify-between gap-5',
(type === 'page' && actions?.length) ? 'flex-col md:flex-row md:items-end' : 'items-end',
(firstOnPage && type === 'page') ? 'pb-3 tablet:pb-8' : (tabs?.length ? '' : 'pb-2'),
(type === 'page' && actions?.length) ? (tabs?.length ? 'flex-col md:flex-row md:items-start' : 'flex-col md:flex-row md:items-end') : 'items-end',
(firstOnPage && type === 'page' && !tabs?.length) ? 'pb-3 tablet:pb-8' : (tabs?.length ? '' : 'pb-2'),
toolbarBorder && 'border-b border-grey-200 dark:border-grey-900',
toolbarContainerClassName
);

View File

@ -27,6 +27,7 @@ export interface ModalProps {
cancelLabel?: string;
leftButtonProps?: ButtonProps;
buttonsDisabled?: boolean;
okDisabled?: boolean;
footer?: boolean | React.ReactNode;
header?: boolean;
padding?: boolean;
@ -62,6 +63,7 @@ const Modal: React.FC<ModalProps> = ({
header,
leftButtonProps,
buttonsDisabled,
okDisabled,
padding = true,
onOk,
okColor = 'black',
@ -179,7 +181,7 @@ const Modal: React.FC<ModalProps> = ({
color: okColor,
className: 'min-w-[80px]',
onClick: onOk,
disabled: buttonsDisabled,
disabled: buttonsDisabled || okDisabled,
loading: okLoading
});
}

View File

@ -1,5 +1,6 @@
import {arrayMove} from '@dnd-kit/sortable';
import {useEffect, useState} from 'react';
import _ from 'lodash';
export type SortableIndexedList<Item> = {
items: Array<{ item: Item; id: string }>;
@ -32,7 +33,7 @@ const useSortableIndexedList = <Item extends unknown>({items, setItems, blank, c
allItems.push(newItem);
}
if (JSON.stringify(allItems) !== JSON.stringify(items)) {
if (!_.isEqual(JSON.parse(JSON.stringify(allItems)), JSON.parse(JSON.stringify(items)))) {
setItems(allItems);
}
}, [editableItems, newItem, items, setItems, canAddNewItem]);

View File

@ -3,6 +3,6 @@ import assert from 'assert/strict';
describe('Hello world', function () {
it('Runs a test', function () {
// TODO: Write me!
assert.ok(require('../'));
assert.equal(1, 1);
});
});

View File

@ -0,0 +1,116 @@
import {expect} from 'chai';
import {renderHook, act} from '@testing-library/react-hooks';
import {usePagination, PaginationMeta, PaginationData} from '../../../src/hooks/usePagination';
describe('usePagination', function () {
const initialMeta: PaginationMeta = {
limit: 10,
pages: 5,
total: 50,
next: null,
prev: null
};
it('should initialize with the given meta and page', function () {
const {result} = renderHook(() => usePagination({
meta: initialMeta,
limit: 10,
page: 1,
setPage: () => {}
})
);
const expectedData: PaginationData = {
page: 1,
pages: initialMeta.pages,
total: initialMeta.total,
limit: initialMeta.limit,
setPage: result.current.setPage,
nextPage: result.current.nextPage,
prevPage: result.current.prevPage
};
expect(result.current).to.deep.equal(expectedData);
});
it('should update page correctly when nextPage and prevPage are called', function () {
let currentPage = 1;
const setPage = (newPage: number) => {
currentPage = newPage;
};
const {result} = renderHook(() => usePagination({
meta: initialMeta,
limit: 10,
page: currentPage,
setPage
})
);
act(() => {
result.current.nextPage();
});
expect(currentPage).to.equal(2);
act(() => {
result.current.prevPage();
});
expect(currentPage).to.equal(1);
});
it('should update page correctly when setPage is called', function () {
let currentPage = 3;
const setPage = (newPage: number) => {
currentPage = newPage;
};
const {result} = renderHook(() => usePagination({
meta: initialMeta,
limit: 10,
page: currentPage,
setPage
})
);
const newPage = 5;
act(() => {
result.current.setPage(newPage);
});
expect(currentPage).to.equal(newPage);
});
it('should handle edge cases where meta.pages < page when setting meta', function () {
let currentPage = 5;
const setPage = (newPage: number) => {
currentPage = newPage;
};
const {rerender} = renderHook(
({meta}) => usePagination({
meta,
limit: 10,
page: currentPage,
setPage
}),
{initialProps: {meta: initialMeta}}
);
const updatedMeta: PaginationMeta = {
limit: 10,
pages: 4,
total: 40,
next: null,
prev: null
};
act(() => {
rerender({meta: updatedMeta});
});
expect(currentPage).to.equal(4);
});
});

View File

@ -0,0 +1,150 @@
import {expect} from 'chai';
import {renderHook, act} from '@testing-library/react-hooks';
import useSortableIndexedList from '../../../src/hooks/useSortableIndexedList';
import sinon from 'sinon';
describe('useSortableIndexedList', function () {
// Mock initial items and blank item
const initialItems = [{name: 'Item 1'}, {name: 'Item 2'}];
const blankItem = {name: ''};
// Mock canAddNewItem function
const canAddNewItem = (item: { name: string }) => !!item.name;
it('should initialize with the given items', function () {
const setItems = sinon.spy();
const {result} = renderHook(() => useSortableIndexedList({
items: initialItems,
setItems,
blank: blankItem,
canAddNewItem
})
);
// Assert initial items setup correctly
expect(result.current.items).to.deep.equal(initialItems.map((item, index) => ({item, id: index.toString()})));
});
it('should add a new item', function () {
let items = initialItems;
const setItems = (newItems: any[]) => {
items = newItems;
};
const {result} = renderHook(() => useSortableIndexedList({
items,
setItems,
blank: blankItem,
canAddNewItem
})
);
act(() => {
result.current.setNewItem({name: 'New Item'});
result.current.addItem();
});
// Assert items updated correctly after adding new item
expect(items).to.deep.equal([...initialItems, {name: 'New Item'}]);
});
it('should update an item', function () {
let items = initialItems;
const setItems = (newItems: any[]) => {
items = newItems;
};
const {result} = renderHook(() => useSortableIndexedList({
items,
setItems,
blank: blankItem,
canAddNewItem
})
);
act(() => {
result.current.updateItem('0', {name: 'Updated Item 1'});
});
// Assert item updated correctly
expect(items[0]).to.deep.equal({name: 'Updated Item 1'});
});
it('should remove an item', function () {
let items = initialItems;
const setItems = (newItems: any[]) => {
items = newItems;
};
const {result} = renderHook(() => useSortableIndexedList({
items,
setItems,
blank: blankItem,
canAddNewItem
})
);
act(() => {
result.current.removeItem('0');
});
// Assert item removed correctly
expect(items).to.deep.equal([initialItems[1]]);
});
it('should move an item', function () {
let items = initialItems;
const setItems = (newItems: any[]) => {
items = newItems;
};
const {result} = renderHook(() => useSortableIndexedList({
items,
setItems,
blank: blankItem,
canAddNewItem
})
);
act(() => {
result.current.moveItem('0', '1');
});
// Assert item moved correctly
expect(items).to.deep.equal([initialItems[1], initialItems[0]]);
});
it('should not setItems for deeply equal items regardless of property order', function () {
const setItems = sinon.spy();
const initialItem = [{name: 'Item 1', url: 'http://example.com'}];
const blankItem1 = {name: '', url: ''};
const {rerender} = renderHook(
// eslint-disable-next-line
({items, setItems}) => useSortableIndexedList({
items,
setItems,
blank: blankItem1,
canAddNewItem
}),
{
initialProps: {
items: initialItem,
setItems
}
}
);
expect(setItems.callCount).to.equal(0);
// Re-render with items in different order but same content
rerender({
items: [{url: 'http://example.com', name: 'Item 1'}],
setItems
});
// Expect no additional calls because the items are deeply equal
expect(setItems.callCount).to.equal(0);
});
});

View File

@ -2,7 +2,7 @@ import {createMutation, createQueryWithId} from '../utils/api/hooks';
export type FollowItem = {
id: string;
username: string,
preferredUsername: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[x: string]: any
};
@ -63,7 +63,7 @@ export type InboxResponseData = {
summary: string;
type: 'OrderedCollection';
totalItems: number;
orderedItems: Activity[];
items: Activity[];
}
export type FollowingResponseData = {
@ -72,7 +72,7 @@ export type FollowingResponseData = {
summary: string;
type: string;
totalItems: number;
orderedItems: FollowItem[];
items: FollowItem[];
}
type FollowRequestProps = {
@ -82,19 +82,22 @@ type FollowRequestProps = {
export const useFollow = createMutation<object, FollowRequestProps>({
method: 'POST',
useActivityPub: true,
path: data => `/follow/${data.username}`
path: data => `/actions/follow/${data.username}`
});
export const useUnfollow = createMutation<object, FollowRequestProps>({
method: 'POST',
useActivityPub: true,
path: data => `/unfollow/${data.username}`
path: data => `/actions/unfollow/${data.username}`
});
// This is a frontend root, not using the Ghost admin API
export const useBrowseInboxForUser = createQueryWithId<InboxResponseData>({
dataType: 'InboxResponseData',
useActivityPub: true,
headers: {
Accept: 'application/activity+json'
},
path: id => `/inbox/${id}`
});
@ -102,6 +105,9 @@ export const useBrowseInboxForUser = createQueryWithId<InboxResponseData>({
export const useBrowseFollowingForUser = createQueryWithId<FollowingResponseData>({
dataType: 'FollowingResponseData',
useActivityPub: true,
headers: {
Accept: 'application/activity+json'
},
path: id => `/following/${id}`
});
@ -109,5 +115,8 @@ export const useBrowseFollowingForUser = createQueryWithId<FollowingResponseData
export const useBrowseFollowersForUser = createQueryWithId<FollowingResponseData>({
dataType: 'FollowingResponseData',
useActivityPub: true,
headers: {
Accept: 'application/activity+json'
},
path: id => `/followers/${id}`
});

View File

@ -7,7 +7,7 @@ const escapeNqlString = (value: string) => {
};
const useFilterableApi = <
Data extends {id: string} & {[Key in FilterKey]: string},
Data extends {id: string} & {[k in FilterKey]: string} & {[k: string]: unknown},
ResponseKey extends string = string,
FilterKey extends string = string
>({path, filterKey, responseKey, limit = 20}: {
@ -41,26 +41,27 @@ const useFilterableApi = <
return response[responseKey];
};
return {
loadData,
loadInitialValues: async (ids: string[]) => {
const loadInitialValues = async (values: string[], key: string) => {
await loadData('');
const data = [...(result.current.data || [])];
const missingIds = ids.filter(id => !result.current.data?.find(({id: dataId}) => dataId === id));
const missingValues = values.filter(value => !result.current.data?.find(item => item[key] === value));
if (missingIds.length) {
if (missingValues.length) {
const additionalData = await fetchApi<{meta?: Meta} & {[k in ResponseKey]: Data[]}>(apiUrl(path, {
filter: `id:[${missingIds.join(',')}]`,
filter: `${key}:[${missingValues.join(',')}]`,
limit: 'all'
}));
data.push(...additionalData[responseKey]);
}
return ids.map(id => data.find(({id: dataId}) => dataId === id)!);
}
return values.map(value => data.find(item => item[key] === value)!);
};
return {
loadData,
loadInitialValues
};
};

View File

@ -76,7 +76,6 @@ const defaultLabFlags = {
themeErrorsNotification: false,
outboundLinkTagging: false,
announcementBar: false,
signupForm: false,
members: false
};

View File

@ -12,7 +12,7 @@ export function getGhostPaths(): IGhostPaths {
const adminRoot = `${subdir}/ghost/`;
const assetRoot = `${subdir}/ghost/assets/`;
const apiRoot = `${subdir}/ghost/api/admin`;
const activityPubRoot = `${subdir}/activitypub`;
const activityPubRoot = `${subdir}/.ghost/activitypub`;
return {subdir, adminRoot, assetRoot, apiRoot, activityPubRoot};
}

View File

@ -39,7 +39,7 @@
"dependencies": {
"@codemirror/lang-html": "6.4.9",
"@tryghost/color-utils": "0.2.2",
"@tryghost/kg-unsplash-selector": "0.2.0",
"@tryghost/kg-unsplash-selector": "0.2.1",
"@tryghost/limit-service": "1.2.14",
"@tryghost/nql": "0.12.3",
"@tryghost/timezone-data": "0.4.3",

View File

@ -61,7 +61,7 @@ const MainContent: React.FC = () => {
if (isEditorUser(currentUser)) {
return (
<Page>
<div className='mx-auto w-full max-w-5xl px-[5vmin] tablet:mt-16 xl:mt-10' id="admin-x-settings-scroller">
<div className='mx-auto w-full max-w-5xl overflow-y-auto px-[5vmin] tablet:mt-16 xl:mt-10' id="admin-x-settings-scroller">
<Heading className='mb-[5vmin]'>Settings</Heading>
<Users highlight={false} keywords={[]} />
</div>

View File

@ -111,7 +111,6 @@ const Sidebar: React.FC = () => {
};
const hasTipsAndDonations = useFeatureFlag('tipsAndDonations');
const hasRecommendations = useFeatureFlag('recommendations');
const updateSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setFilter(e.target.value);
@ -143,7 +142,7 @@ const Sidebar: React.FC = () => {
unstyled
onChange={updateSearch}
/>
{filter ? <Button className='absolute right-3 top-3 p-1' icon='close' iconColorClass='text-grey-700 !w-[10px] !h-[10px]' size='sm' unstyled onClick={() => {
{filter ? <Button className='absolute top-3 p-1 sm:right-14 tablet:right-3' icon='close' iconColorClass='text-grey-700 !w-[10px] !h-[10px]' size='sm' unstyled onClick={() => {
setFilter('');
searchInputRef.current?.focus();
}} /> : <div className='absolute -right-1/2 top-[9px] hidden rounded border border-grey-400 bg-white px-1.5 py-0.5 text-2xs font-semibold uppercase tracking-wider text-grey-600 shadow-[0px_1px_#CED4D9] dark:border-grey-800 dark:bg-grey-900 dark:text-grey-500 dark:shadow-[0px_1px_#626D79] tablet:!visible tablet:right-3 tablet:!block'>/</div>}
@ -184,7 +183,7 @@ const Sidebar: React.FC = () => {
</SettingNavSection>
<SettingNavSection isVisible={checkVisible(Object.values(growthSearchKeywords).flat())} title="Growth">
{hasRecommendations && <NavItem icon='heart' keywords={growthSearchKeywords.recommendations} navid='recommendations' title="Recommendations" onClick={handleSectionClick} />}
<NavItem icon='heart' keywords={growthSearchKeywords.recommendations} navid='recommendations' title="Recommendations" onClick={handleSectionClick} />
<NavItem icon='emailfield' keywords={growthSearchKeywords.embedSignupForm} navid='embed-signup-form' title="Embeddable signup form" onClick={handleSectionClick} />
{hasStripeEnabled && <NavItem icon='discount' keywords={growthSearchKeywords.offers} navid='offers' title="Offers" onClick={handleSectionClick} />}
{hasTipsAndDonations && <NavItem icon='piggybank' keywords={growthSearchKeywords.tips} navid='tips-or-donations' title="Tips or donations" onClick={handleSectionClick} />}

View File

@ -13,11 +13,11 @@ const AmpModal = NiceModal.create(() => {
const {settings} = useGlobalData();
const [ampEnabled] = getSettingValues<boolean>(settings, ['amp']);
const [ampId] = getSettingValues<string>(settings, ['amp_gtag_id']);
const modal = NiceModal.useModal();
const [enabled, setEnabled] = useState(false);
const [trackingId, setTrackingId] = useState<string | null>('');
const {mutateAsync: editSettings} = useEditSettings();
const handleError = useHandleError();
const [okLabel, setOkLabel] = useState('Save');
const [enabled, setEnabled] = useState<boolean>(!!ampEnabled);
useEffect(() => {
setEnabled(ampEnabled || false);
@ -30,26 +30,36 @@ const AmpModal = NiceModal.create(() => {
{key: 'amp_gtag_id', value: trackingId}
];
try {
await editSettings(updates);
setOkLabel('Saving...');
await Promise.all([
editSettings(updates),
new Promise((resolve) => {
setTimeout(resolve, 1000);
})
]);
setOkLabel('Saved');
} catch (e) {
handleError(e);
} finally {
setTimeout(() => setOkLabel('Save'), 1000);
}
};
const isDirty = !(enabled === ampEnabled) || !(trackingId === ampId);
return (
<Modal
afterClose={() => {
updateRoute('integrations');
}}
dirty={!(enabled === ampEnabled) || !(trackingId === ampId)}
okColor='black'
okLabel='Save & close'
cancelLabel='Close'
dirty={isDirty}
okColor={okLabel === 'Saved' ? 'green' : 'black'}
okLabel={okLabel}
testId='amp-modal'
title=''
onOk={async () => {
await handleSave();
modal.remove();
updateRoute('integrations');
}}
>
<IntegrationHeader

View File

@ -28,7 +28,6 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in
await editIntegration(formState);
},
onSavedStateReset: () => {
modal.remove();
updateRoute('integrations');
},
onSaveError: handleError,
@ -82,9 +81,10 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in
updateRoute('integrations');
}}
buttonsDisabled={okProps.disabled}
cancelLabel='Close'
dirty={saveState === 'unsaved'}
okColor={okProps.color}
okLabel={okProps.label || 'Save & close'}
okLabel={okProps.label || 'Save'}
size='md'
testId='custom-integration-modal'
title={formState.name || 'Custom integration'}

View File

@ -10,18 +10,19 @@ import {useRouting} from '@tryghost/admin-x-framework/routing';
const FirstpromoterModal = NiceModal.create(() => {
const {updateRoute} = useRouting();
const modal = NiceModal.useModal();
const {settings} = useGlobalData();
const {mutateAsync: editSettings} = useEditSettings();
const handleError = useHandleError();
const [accountId, setAccountId] = useState<string | null>('');
const [enabled, setEnabled] = useState(false);
const [firstPromoterEnabled] = getSettingValues<boolean>(settings, ['firstpromoter']);
const [firstPromoterId] = getSettingValues<string>(settings, ['firstpromoter_id']);
const [okLabel, setOkLabel] = useState('Save');
const [enabled, setEnabled] = useState<boolean>(!!firstPromoterEnabled);
useEffect(() => {
setEnabled(firstPromoterEnabled || false);
setAccountId(firstPromoterId || null);
@ -38,8 +39,20 @@ const FirstpromoterModal = NiceModal.create(() => {
value: accountId
}
];
await editSettings(updates);
try {
setOkLabel('Saving...');
await Promise.all([
editSettings(updates),
new Promise((resolve) => {
setTimeout(resolve, 1000);
})
]);
setOkLabel('Saved');
} catch (e) {
handleError(e);
} finally {
setTimeout(() => setOkLabel('Save'), 1000);
}
};
return (
@ -47,16 +60,15 @@ const FirstpromoterModal = NiceModal.create(() => {
afterClose={() => {
updateRoute('integrations');
}}
cancelLabel='Close'
dirty={enabled !== firstPromoterEnabled || accountId !== firstPromoterId}
okColor='black'
okLabel='Save & close'
okColor={okLabel === 'Saved' ? 'green' : 'black'}
okLabel={okLabel}
testId='firstpromoter-modal'
title=''
onOk={async () => {
try {
await handleSave();
updateRoute('integrations');
modal.remove();
} catch (e) {
handleError(e);
}

View File

@ -12,8 +12,6 @@ import {useUploadFile} from '@tryghost/admin-x-framework/api/files';
const PinturaModal = NiceModal.create(() => {
const {updateRoute} = useRouting();
const modal = NiceModal.useModal();
const [enabled, setEnabled] = useState(false);
const [uploadingState, setUploadingState] = useState({
js: false,
css: false
@ -29,6 +27,29 @@ const PinturaModal = NiceModal.create(() => {
setEnabled(pinturaEnabled || false);
}, [pinturaEnabled]);
const [okLabel, setOkLabel] = useState('Save');
const [enabled, setEnabled] = useState<boolean>(!!pinturaEnabled);
const handleToggleChange = async () => {
const updates: Setting[] = [
{key: 'pintura', value: (enabled)}
];
try {
setOkLabel('Saving...');
await Promise.all([
editSettings(updates),
new Promise((resolve) => {
setTimeout(resolve, 1000);
})
]);
setOkLabel('Saved');
} catch (error) {
handleError(error);
} finally {
setTimeout(() => setOkLabel('Save'), 1000);
}
};
const jsUploadRef = useRef<HTMLInputElement>(null);
const cssUploadRef = useRef<HTMLInputElement>(null);
const triggerUpload = (form: string) => {
@ -70,23 +91,20 @@ const PinturaModal = NiceModal.create(() => {
}
};
const isDirty = !(enabled === pinturaEnabled);
return (
<Modal
afterClose={() => {
updateRoute('integrations');
}}
cancelLabel=''
okColor='black'
okLabel='Save'
cancelLabel='Close'
dirty={isDirty}
okColor={okLabel === 'Saved' ? 'green' : 'black'}
okLabel={okLabel}
testId='pintura-modal'
title=''
onOk={async () => {
modal.remove();
updateRoute('integrations');
await editSettings([
{key: 'pintura', value: enabled}
]);
}}
onOk={handleToggleChange}
>
<IntegrationHeader
detail='Advanced image editing'

View File

@ -10,9 +10,8 @@ import {useRouting} from '@tryghost/admin-x-framework/routing';
const SlackModal = NiceModal.create(() => {
const {updateRoute} = useRouting();
const modal = NiceModal.useModal();
const {localSettings, updateSetting, handleSave, validate, errors, clearError} = useSettingGroup({
const {localSettings, updateSetting, handleSave, validate, errors, clearError, okProps} = useSettingGroup({
onValidate: () => {
const newErrors: Record<string, string> = {};
@ -21,7 +20,8 @@ const SlackModal = NiceModal.create(() => {
}
return newErrors;
}
},
savingDelay: 500
});
const [slackUrl, slackUsername] = getSettingValues<string>(localSettings, ['slack_url', 'slack_username']);
@ -38,22 +38,22 @@ const SlackModal = NiceModal.create(() => {
}
};
const isDirty = localSettings.some(setting => setting.dirty);
return (
<Modal
afterClose={() => {
updateRoute('integrations');
}}
dirty={localSettings.some(setting => setting.dirty)}
okColor='black'
okLabel='Save & close'
cancelLabel='Close'
dirty={isDirty}
okColor={okProps.color}
okLabel={okProps.label || 'Save'}
testId='slack-modal'
title=''
onOk={async () => {
toast.remove();
if (await handleSave()) {
modal.remove();
updateRoute('integrations');
}
await handleSave();
}}
>
<IntegrationHeader

View File

@ -3,42 +3,58 @@ import NiceModal from '@ebay/nice-modal-react';
import {Form, Modal, Toggle} from '@tryghost/admin-x-design-system';
import {ReactComponent as Icon} from '../../../../assets/icons/unsplash.svg';
import {Setting, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings';
import {useEffect, useState} from 'react';
import {useGlobalData} from '../../../providers/GlobalDataProvider';
import {useHandleError} from '@tryghost/admin-x-framework/hooks';
import {useRouting} from '@tryghost/admin-x-framework/routing';
const UnsplashModal = NiceModal.create(() => {
const {updateRoute} = useRouting();
const modal = NiceModal.useModal();
const {settings} = useGlobalData();
const [unsplashEnabled] = getSettingValues<boolean>(settings, ['unsplash']);
const {mutateAsync: editSettings} = useEditSettings();
const handleError = useHandleError();
const [okLabel, setOkLabel] = useState('Save');
const [enabled, setEnabled] = useState<boolean>(!!unsplashEnabled);
const handleToggleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
useEffect(() => {
setEnabled(unsplashEnabled || false);
}, [unsplashEnabled]);
const handleToggleChange = async () => {
const updates: Setting[] = [
{key: 'unsplash', value: (e.target.checked)}
{key: 'unsplash', value: (enabled)}
];
try {
await editSettings(updates);
setOkLabel('Saving...');
await Promise.all([
editSettings(updates),
new Promise((resolve) => {
setTimeout(resolve, 1000);
})
]);
setOkLabel('Saved');
} catch (error) {
handleError(error);
} finally {
setTimeout(() => setOkLabel('Save'), 1000);
}
};
const isDirty = !(enabled === unsplashEnabled);
return (
<Modal
afterClose={() => {
updateRoute('integrations');
}}
okColor='black'
okLabel='Save & close'
cancelLabel='Close'
dirty={isDirty}
okColor={okLabel === 'Saved' ? 'green' : 'black'}
okLabel={okLabel}
testId='unsplash-modal'
title=''
onOk={() => {
modal.remove();
updateRoute('integrations');
}}
onOk={handleToggleChange}
>
<IntegrationHeader
detail='Beautiful, free photos'
@ -48,11 +64,13 @@ const UnsplashModal = NiceModal.create(() => {
<div className='mt-7'>
<Form marginBottom={false} grouped>
<Toggle
checked={unsplashEnabled}
checked={enabled}
direction='rtl'
hint={<>Enable <a className='text-green' href="https://unsplash.com" rel="noopener noreferrer" target="_blank">Unsplash</a> image integration for your posts</>}
label='Enable Unsplash'
onChange={handleToggleChange}
onChange={(e) => {
setEnabled(e.target.checked);
}}
/>
</Form>
</div>

View File

@ -15,10 +15,6 @@ const features = [{
title: 'Webmentions',
description: 'Allows viewing received mentions on the dashboard.',
flag: 'webmentions'
},{
title: 'Websockets',
description: <>Test out Websockets functionality at <code>/ghost/#/websockets</code>.</>,
flag: 'websockets'
},{
title: 'Stripe Automatic Tax (private beta)',
description: 'Use Stripe Automatic Tax at Stripe Checkout. Needs to be enabled in Stripe',
@ -60,10 +56,6 @@ const features = [{
description: '(Highly) Experimental support for ActivityPub.',
flag: 'ActivityPub'
},{
title: 'Excerpt in newsletter',
description: 'Showing excerpt in newsletter',
flag: 'newsletterExcerpt'
}, {
title: 'Content Visibility',
description: 'Enables content visibility in Emails',
flag: 'contentVisibility'

View File

@ -125,7 +125,7 @@ const DefaultRecipients: React.FC<{ keywords: string[] }> = ({keywords}) => {
const form = (
<SettingGroupContent columns={1}>
<Select
hint='Who should be able to subscribe to your site?'
hint='Who should receive your posts by default?'
options={RECIPIENT_FILTER_OPTIONS}
selectedOption={RECIPIENT_FILTER_OPTIONS.find(option => option.value === selectedOption)}
testId='default-recipients-select'

View File

@ -39,7 +39,7 @@ const AddNewsletterModal: React.FC<RoutingModalProps> = () => {
const newErrors: Record<string, string> = {};
if (!formState.name) {
newErrors.name = 'Name is required';
newErrors.name = 'A name is required for your newsletter';
}
return newErrors;

View File

@ -103,7 +103,6 @@ const Sidebar: React.FC<{
const {mutateAsync: uploadImage} = useUploadImage();
const [selectedTab, setSelectedTab] = useState('generalSettings');
const hasEmailCustomization = useFeatureFlag('emailCustomization');
const hasNewsletterExcerpt = useFeatureFlag('newsletterExcerpt');
const {localSettings} = useSettingGroup();
const [siteTitle] = getSettingValues(localSettings, ['title']) as string[];
const handleError = useHandleError();
@ -130,8 +129,8 @@ const Sidebar: React.FC<{
NiceModal.show(ConfirmationModal, {
title: 'Archive newsletter',
prompt: <>
<p>Your newsletter <strong>{newsletter.name}</strong> will no longer be visible to members or available as an option when publishing new posts.</p>
<p>Existing posts previously sent as this newsletter will remain unchanged.</p>
<div className="mb-6">Your newsletter <strong>{newsletter.name}</strong> will no longer be visible to members or available as an option when publishing new posts.</div>
<div>Existing posts previously sent as this newsletter will remain unchanged.</div>
</>,
okLabel: 'Archive',
okColor: 'red',
@ -418,7 +417,7 @@ const Sidebar: React.FC<{
onChange={color => updateNewsletter({title_color: color})}
/>}
<ToggleGroup gap='lg'>
{(hasNewsletterExcerpt && newsletter.show_post_title_section) &&
{newsletter.show_post_title_section &&
<Toggle
checked={newsletter.show_excerpt}
direction="rtl"
@ -547,17 +546,17 @@ const NewsletterDetailModalContent: React.FC<{newsletter: Newsletter; onlyOne: b
const newErrors: Record<string, string> = {};
if (!formState.name) {
newErrors.name = 'Name is required';
newErrors.name = 'A name is required for your newsletter';
}
if (formState.sender_email && !validator.isEmail(formState.sender_email)) {
newErrors.sender_email = 'Invalid email';
newErrors.sender_email = 'Enter a valid email address';
} else if (formState.sender_email && hasSendingDomain(config) && formState.sender_email.split('@')[1] !== sendingDomain(config)) {
newErrors.sender_email = `Email must end with @${sendingDomain(config)}`;
newErrors.sender_email = `Email address must end with @${sendingDomain(config)}`;
}
if (formState.sender_reply_to && !validator.isEmail(formState.sender_reply_to) && !['newsletter', 'support'].includes(formState.sender_reply_to)) {
newErrors.sender_reply_to = 'Invalid email';
newErrors.sender_reply_to = 'Enter a valid email address';
}
return newErrors;

View File

@ -77,7 +77,6 @@ const NewsletterPreviewContent: React.FC<{
const showHeader = headerIcon || headerTitle;
const {config} = useGlobalData();
const hasNewEmailAddresses = useFeatureFlag('newEmailAddresses');
const hasNewsletterExcerpt = useFeatureFlag('newsletterExcerpt');
const currentDate = new Date().toLocaleDateString('default', {
year: 'numeric',
@ -154,7 +153,7 @@ const NewsletterPreviewContent: React.FC<{
)} style={{color: titleColor}}>
Your email newsletter
</h2>
{(hasNewsletterExcerpt && showExcerpt) && (
{showExcerpt && (
<p className={excerptClasses}>A subtitle to highlight key points and engage your readers</p>
)}
<div className={clsx(

View File

@ -62,11 +62,11 @@ const useDefaultRecipientsOptions = (selectedOption: string, defaultEmailRecipie
const initSelectedSegments = async () => {
const filters = defaultEmailRecipientsFilter?.split(',') || [];
const tierIds: string[] = [], labelIds: string[] = [], offerIds: string[] = [];
const tierIds: string[] = [], labelSlugs: string[] = [], offerIds: string[] = [];
for (const filter of filters) {
if (filter.startsWith('label:')) {
labelIds.push(filter.replace('label:', ''));
labelSlugs.push(filter.replace('label:', ''));
} else if (filter.startsWith('offer_redemptions:')) {
offerIds.push(filter.replace('offer_redemptions:', ''));
} else if (isObjectId(filter)) {
@ -75,9 +75,9 @@ const useDefaultRecipientsOptions = (selectedOption: string, defaultEmailRecipie
}
const options = await Promise.all([
tiers.loadInitialValues(tierIds).then(data => data.map(tierOption)),
labels.loadInitialValues(labelIds).then(data => data.map(labelOption)),
offers.loadInitialValues(offerIds).then(data => data.map(offerOption))
tiers.loadInitialValues(tierIds, 'id').then(data => data.map(tierOption)),
labels.loadInitialValues(labelSlugs, 'slug').then(data => data.map(labelOption)),
offers.loadInitialValues(offerIds, 'id').then(data => data.map(offerOption))
]).then(results => results.flat());
setSelectedSegments(filters.map(filter => options.find(option => option.value === filter)!));

View File

@ -113,10 +113,6 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
onSave: async (values) => {
await updateUser?.(values);
},
onSavedStateReset: () => {
mainModal.remove();
navigateOnClose();
},
onSaveError: handleError
});
const setUserData = (newData: User) => updateForm(() => newData);
@ -353,9 +349,10 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
animate={canAccessSettings(currentUser)}
backDrop={canAccessSettings(currentUser)}
buttonsDisabled={okProps.disabled}
cancelLabel='Close'
dirty={saveState === 'unsaved'}
okColor={okProps.color}
okLabel={okProps.label || 'Save & close'}
okLabel={okProps.label || 'Save'}
size={canAccessSettings(currentUser) ? 'lg' : 'bleed'}
stickyFooter={true}
testId='user-detail-modal'

View File

@ -5,7 +5,6 @@ import {User, hasAdminAccess} from '@tryghost/admin-x-framework/api/users';
const EmailNotificationsInputs: React.FC<{ user: User; setUserData: (user: User) => void; }> = ({user, setUserData}) => {
const hasWebmentions = useFeatureFlag('webmentions');
const hasRecommendations = useFeatureFlag('recommendations');
return (
<SettingGroupContent>
@ -28,7 +27,7 @@ const EmailNotificationsInputs: React.FC<{ user: User; setUserData: (user: User)
setUserData?.({...user, mention_notifications: e.target.checked});
}}
/>}
{hasRecommendations && <Toggle
<Toggle
checked={user.recommendation_notifications}
direction='rtl'
hint='Every time another publisher recommends you to their audience'
@ -36,7 +35,7 @@ const EmailNotificationsInputs: React.FC<{ user: User; setUserData: (user: User)
onChange={(e) => {
setUserData?.({...user, recommendation_notifications: e.target.checked});
}}
/>}
/>
<Toggle
checked={user.free_member_signup_notification}
direction='rtl'

View File

@ -33,7 +33,7 @@ const BasicInputs: React.FC<UserDetailProps> = ({errors, clearError, user, setUs
onKeyDown={() => clearError('email')}
/>
<TextField
hint="https://example.com/author"
hint={`https://example.com/author/${user.slug}`}
maxLength={191}
title="Slug"
value={user.slug}

View File

@ -17,14 +17,12 @@ export const searchKeywords = {
const GrowthSettings: React.FC = () => {
const hasTipsAndDonations = useFeatureFlag('tipsAndDonations');
const hasRecommendations = useFeatureFlag('recommendations');
// const hasOffersLabs = useFeatureFlag('adminXOffers');
const {config, settings} = useGlobalData();
const hasStripeEnabled = checkStripeEnabled(settings || [], config || {});
return (
<SearchableSection keywords={Object.values(searchKeywords).flat()} title='Growth'>
{hasRecommendations && <Recommendations keywords={searchKeywords.recommendations} />}
<Recommendations keywords={searchKeywords.recommendations} />
<EmbedSignupForm keywords={searchKeywords.embedSignupForm} />
{hasStripeEnabled && <Offers keywords={searchKeywords.offers} />}
{hasTipsAndDonations && <TipsOrDonations keywords={searchKeywords.tips} />}

View File

@ -129,7 +129,6 @@ const EmbedSignupSidebar: React.FC<SidebarProps> = ({selectedLayout,
/>
<TextArea
className='text-grey-800'
clearBg={false}
fontStyle='mono'
hint={`Paste this code onto any website where you'd like your signup to appear.`}
title='Embed code'

View File

@ -27,10 +27,6 @@ const EditRecommendationModal: React.FC<RoutingModalProps & EditRecommendationMo
onSave: async (state) => {
await editRecommendation(state);
},
onSavedStateReset: () => {
modal.remove();
updateRoute('recommendations');
},
onSaveError: handleError,
onValidate: (state) => {
const newErrors = validateDescriptionForm(state);
@ -76,10 +72,10 @@ const EditRecommendationModal: React.FC<RoutingModalProps & EditRecommendationMo
animate={animate ?? true}
backDropClick={false}
buttonsDisabled={okProps.disabled}
cancelLabel={'Cancel'}
cancelLabel={'Close'}
leftButtonProps={leftButtonProps}
okColor={okProps.color}
okLabel={okProps.label || 'Save & close'}
okLabel={okProps.label || 'Save'}
size='sm'
testId='edit-recommendation-modal'
title={'Edit recommendation'}

View File

@ -109,7 +109,6 @@ const RecommendationDescriptionForm: React.FC<Props<EditOrAddRecommendation | Re
}}
/>
<TextArea
clearBg={true}
error={Boolean(errors.description)}
// Note: we don't show the error text here, because errors are related to the character count
hint={<>Max: <strong>200</strong> characters. You&#8217;ve used <strong className={descriptionLengthColor}>{descriptionLength}</strong></>}

View File

@ -1,4 +1,5 @@
import React, {FocusEventHandler, useEffect, useState} from 'react';
import validator from 'validator';
import {Form, TextField} from '@tryghost/admin-x-design-system';
import {SettingValue, getSettingValues} from '@tryghost/admin-x-framework/api/settings';
import {fullEmailAddress, getEmailDomain} from '@tryghost/admin-x-framework/api/site';
@ -19,8 +20,9 @@ const AccountPage: React.FC<{
let supportAddress = e.target.value;
if (!supportAddress) {
setError('members_support_address', 'Please enter an email address');
return;
setError('members_support_address', 'Enter an email address');
} else if (!validator.isEmail(supportAddress)) {
setError('members_support_address', 'Enter a valid email address');
} else {
setError('members_support_address', '');
}

View File

@ -1,5 +1,4 @@
import React, {useCallback, useEffect, useMemo} from 'react';
import useFeatureFlag from '../../../../hooks/useFeatureFlag';
import {CheckboxGroup, CheckboxProps, Form, HtmlField, Select, SelectOption, Toggle} from '@tryghost/admin-x-design-system';
import {Setting, SettingValue, checkStripeEnabled, getSettingValues} from '@tryghost/admin-x-framework/api/settings';
import {Tier, getPaidActiveTiers} from '@tryghost/admin-x-framework/api/tiers';
@ -14,7 +13,6 @@ const SignupOptions: React.FC<{
setError: (key: string, error: string | undefined) => void
}> = ({localSettings, updateSetting, localTiers, updateTier, errors, setError}) => {
const {config} = useGlobalData();
const hasPortalImprovements = useFeatureFlag('portalImprovements');
const [membersSignupAccess, portalName, portalSignupTermsHtml, portalSignupCheckboxRequired, portalPlansJson, portalDefaultPlan] = getSettingValues(
localSettings, ['members_signup_access', 'portal_name', 'portal_signup_terms_html', 'portal_signup_checkbox_required', 'portal_plans', 'portal_default_plan']
);
@ -52,7 +50,6 @@ const SignupOptions: React.FC<{
updateSetting('portal_plans', JSON.stringify(portalPlans));
// Check default plan is included
if (hasPortalImprovements) {
if (portalDefaultPlan === 'yearly') {
if (!portalPlans.includes('yearly') && portalPlans.includes('monthly')) {
updateSetting('portal_default_plan', 'monthly');
@ -63,7 +60,6 @@ const SignupOptions: React.FC<{
updateSetting('portal_default_plan', 'yearly');
}
}
}
};
// This is a bit unclear in current admin, maybe we should add a message if the settings are disabled?
@ -79,7 +75,7 @@ const SignupOptions: React.FC<{
tiersCheckboxes.push({
checked: (portalPlans.includes('free')),
disabled: isDisabled,
label: hasPortalImprovements ? tier.name : 'Free',
label: tier.name,
value: 'free',
onChange: (checked) => {
if (portalPlans.includes('free') && !checked) {
@ -158,7 +154,7 @@ const SignupOptions: React.FC<{
]}
title='Prices available at signup'
/>
{(hasPortalImprovements && portalPlans.includes('yearly') && portalPlans.includes('monthly')) &&
{(portalPlans.includes('yearly') && portalPlans.includes('monthly')) &&
<Select
options={defaultPlanOptions}
selectedOption={defaultPlanOptions.find(option => option.value === portalDefaultPlan)}

View File

@ -136,7 +136,7 @@ const Connect: React.FC = () => {
</div>
<StripeButton href={stripeConnectUrl} tag='a' target='_blank' />
<Heading className='mb-2 mt-8' level={6} grey>Step 2 <span className='text-black dark:text-white'>Paste secure key</span></Heading>
<TextArea clearBg={false} error={Boolean(error)} hint={error || undefined} placeholder='Paste your secure key here' onChange={onTokenChange}></TextArea>
<TextArea error={Boolean(error)} hint={error || undefined} placeholder='Paste your secure key here' onChange={onTokenChange}></TextArea>
{submitEnabled && <Button className='mt-5' color='green' label='Save Stripe settings' onClick={onSubmit} />}
</div>
);

View File

@ -1,7 +1,6 @@
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import NiceModal from '@ebay/nice-modal-react';
import React, {useEffect, useRef} from 'react';
import TierDetailPreview from './TierDetailPreview';
import useFeatureFlag from '../../../../hooks/useFeatureFlag';
import useSettingGroup from '../../../../hooks/useSettingGroup';
import {Button, ButtonProps, ConfirmationModal, CurrencyField, Form, Heading, Icon, Modal, Select, SortableList, TextField, Toggle, URLTextField, showToast, useSortableIndexedList} from '@tryghost/admin-x-design-system';
import {ErrorMessages, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks';
@ -17,7 +16,6 @@ export type TierFormState = Partial<Omit<Tier, 'trial_days'>> & {
const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
const isFreeTier = tier?.type === 'free';
const modal = useModal();
const {updateRoute} = useRouting();
const {mutateAsync: updateTier} = useEditTier();
const {mutateAsync: createTier} = useAddTier();
@ -26,8 +24,6 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
const handleError = useHandleError();
const {localSettings, siteData} = useSettingGroup();
const [portalPlansJson] = getSettingValues(localSettings, ['portal_plans']) as string[];
const hasPortalImprovements = useFeatureFlag('portalImprovements');
const allowNameChange = !isFreeTier || hasPortalImprovements;
const portalPlans = JSON.parse(portalPlansJson?.toString() || '[]') as string[];
const validators: {[key in keyof Tier]?: () => string | undefined} = {
@ -71,7 +67,7 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
} else {
await createTier(values);
}
if (isFreeTier && hasPortalImprovements) {
if (isFreeTier) {
// If we changed the visibility, we also need to update Portal settings in some situations
// Like the free tier is a special case, and should also be present/absent in portal_plans
const visible = formState.visibility === 'public';
@ -97,10 +93,6 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
}
}
},
onSavedStateReset: () => {
modal.remove();
updateRoute('tiers');
},
onSaveError: handleError
});
@ -185,10 +177,11 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
updateRoute('tiers');
}}
buttonsDisabled={okProps.disabled}
cancelLabel='Close'
dirty={saveState === 'unsaved'}
leftButtonProps={leftButtonProps}
okColor={okProps.color}
okLabel={okProps.label || 'Save & close'}
okLabel={okProps.label || 'Save'}
size='lg'
testId='tier-detail-modal'
title={(tier ? (tier.active ? 'Edit tier' : 'Edit archived tier') : 'New tier')}
@ -200,7 +193,7 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
<div className='-mb-8 mt-8 flex items-start gap-8'>
<div className='flex grow flex-col gap-8'>
<Form marginBottom={false} title='Basic' grouped>
{allowNameChange && <TextField
<TextField
autoComplete='off'
error={Boolean(errors.name)}
hint={errors.name}
@ -211,7 +204,7 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
autoFocus
onChange={e => updateForm(state => ({...state, name: e.target.value}))}
onKeyDown={() => clearError('name')}
/>}
/>
<TextField
autoComplete='off'
autoFocus={isFreeTier}

View File

@ -43,8 +43,9 @@ const NavigationModal = NiceModal.create(() => {
updateRoute('navigation');
}}
buttonsDisabled={saveState === 'saving'}
cancelLabel='Close'
dirty={localSettings.some(setting => setting.dirty)}
okLabel={saveState === 'saving' ? 'Saving...' : 'OK'}
okLabel={saveState === 'saving' ? 'Saving...' : 'Save'}
scrolling={true}
size='lg'
stickyFooter={true}

View File

@ -1,6 +1,8 @@
import InvalidThemeModal, {FatalErrors} from './InvalidThemeModal';
import NiceModal from '@ebay/nice-modal-react';
import React from 'react';
import {Button, ButtonProps, ConfirmationModal, List, ListItem, Menu, ModalPage, showToast} from '@tryghost/admin-x-design-system';
import {JSONError} from '@tryghost/admin-x-framework/errors';
import {Theme, isActiveTheme, isDefaultTheme, isDeletableTheme, isLegacyTheme, useActivateTheme, useDeleteTheme} from '@tryghost/admin-x-framework/api/themes';
import {downloadFile, getGhostPaths} from '@tryghost/admin-x-framework/helpers';
import {useHandleError} from '@tryghost/admin-x-framework/hooks';
@ -57,8 +59,27 @@ const ThemeActions: React.FC<ThemeActionProps> = ({
message: <div><span className='capitalize'>{theme.name}</span> is now your active theme</div>
});
} catch (e) {
let fatalErrors: FatalErrors | null = null;
if (e instanceof JSONError && e.response?.status === 422 && e.data?.errors) {
fatalErrors = (e.data.errors as any) as FatalErrors;
} else {
handleError(e);
}
let title = 'Invalid Theme';
let prompt = <>This theme is invalid and cannot be activated. Fix the following errors and re-upload the theme</>;
if (fatalErrors) {
NiceModal.show(InvalidThemeModal, {
title,
prompt,
fatalErrors,
onRetry: async (modal) => {
modal?.remove();
handleActivate();
}
});
}
}
};
const handleDownload = async () => {

View File

@ -31,7 +31,7 @@ const useNavigationEditor = ({items, setItems}: {
const list = useSortableIndexedList<Omit<EditableItem, 'id'>>({
items: items.map(item => ({...item, errors: {}})),
setItems: newItems => setItems(newItems.map(({url, label}) => ({url, label}))),
blank: {label: '', url: '/', errors: {}},
blank: {url: '/',label: '', errors: {}},
canAddNewItem: hasNewItem
});

View File

@ -51,7 +51,7 @@ test.describe('AMP integration', async () => {
const ampToggle = ampModal.getByRole('switch');
await ampToggle.click();
await ampModal.getByRole('button', {name: 'Cancel'}).click();
await ampModal.getByRole('button', {name: 'Close'}).click();
await expect(page.getByTestId('confirmation-modal')).toHaveText(/leave/i);

View File

@ -140,7 +140,7 @@ test.describe('Custom integrations', async () => {
await modal.getByLabel('Description').fill('Test description');
await modal.getByRole('button', {name: 'Cancel'}).click();
await modal.getByRole('button', {name: 'Close'}).click();
await expect(page.getByTestId('confirmation-modal')).toHaveText(/leave/i);
@ -190,7 +190,8 @@ test.describe('Custom integrations', async () => {
// Edit integration
await modal.getByLabel('Description').fill('Test description');
await modal.getByRole('button', {name: 'Save & close'}).click();
await modal.getByRole('button', {name: 'Save'}).click();
await modal.getByRole('button', {name: 'Close'}).click();
await expect(integrationsSection).toHaveText(/Test description/);

View File

@ -51,7 +51,7 @@ test.describe('First Promoter integration', async () => {
const fpToggle = fpModal.getByRole('switch');
await fpToggle.click();
await fpModal.getByRole('button', {name: 'Cancel'}).click();
await fpModal.getByRole('button', {name: 'Close'}).click();
await expect(page.getByTestId('confirmation-modal')).toHaveText(/leave/i);

View File

@ -23,14 +23,15 @@ test.describe('Slack integration', async () => {
// Failing validation
await slackModal.getByLabel('Webhook URL').fill('badurl');
await slackModal.getByRole('button', {name: 'Save & close'}).click();
await slackModal.getByRole('button', {name: 'Save'}).click();
await expect(slackModal).toContainText('The URL must be in a format like https://hooks.slack.com/services/<your personal key>');
// Successful save
await slackModal.getByLabel('Webhook URL').fill('https://hooks.slack.com/services/123456789/123456789/123456789');
await slackModal.getByLabel('Username').fill('My site');
await slackModal.getByRole('button', {name: 'Save & close'}).click();
await slackModal.getByRole('button', {name: 'Save'}).click();
await slackModal.getByRole('button', {name: 'Close'}).click();
await expect(slackModal).toHaveCount(0);
@ -59,7 +60,7 @@ test.describe('Slack integration', async () => {
await slackModal.getByLabel('Webhook URL').fill('https://hooks.slack.com/services/123456789/123456789/123456789');
await slackModal.getByRole('button', {name: 'Cancel'}).click();
await slackModal.getByRole('button', {name: 'Close'}).click();
await expect(page.getByTestId('confirmation-modal')).toHaveText(/leave/i);
@ -90,7 +91,7 @@ test.describe('Slack integration', async () => {
// Doesn't send the request when validation fails
await slackModal.getByLabel('Webhook URL').fill('badurl');
await slackModal.getByRole('button', {name: 'Save & close'}).click();
await slackModal.getByRole('button', {name: 'Save'}).click();
await expect(slackModal).toContainText('The URL must be in a format like https://hooks.slack.com/services/<your personal key>');
expect(lastApiRequests.testSlack).toBeUndefined();

View File

@ -21,6 +21,8 @@ test.describe('Unsplash integration', async () => {
const unsplashToggle = unsplashModal.getByRole('switch');
await unsplashToggle.click();
await unsplashModal.getByRole('button', {name: 'Save'}).click();
expect(lastApiRequests.editSettings?.body).toEqual({
settings: [
{key: 'unsplash', value: false}

View File

@ -104,4 +104,33 @@ test.describe('Default recipient settings', async () => {
]
});
});
test('renders existing default recipients filters correctly', async ({page}) => {
await mockApi({page, requests: {
...globalDataRequests,
browseTiers: {method: 'GET', path: '/tiers/?filter=&limit=20', response: responseFixtures.tiers},
browseLabels: {method: 'GET', path: '/labels/?filter=&limit=20', response: responseFixtures.labels},
browseOffers: {method: 'GET', path: '/offers/?filter=&limit=20', response: responseFixtures.offers},
browseSettings: {...globalDataRequests.browseSettings, response: updatedSettingsResponse([
{
key: 'editor_default_email_recipients',
value: 'filter'
},
{
key: 'editor_default_email_recipients_filter',
value: '645453f4d254799990dd0e22,label:first-label,offer_redemptions:6487ea6464fca78ec2fff5fe'
}
])}
}});
await page.goto('/');
const section = page.getByTestId('default-recipients');
await section.getByRole('button', {name: 'Edit'}).click();
await expect(section.getByText('Specific people')).toHaveCount(1);
await expect(section.getByText('Basic Supporter')).toHaveCount(1);
await expect(section.getByText('first-label')).toHaveCount(1);
await expect(section.getByText('First offer')).toHaveCount(1);
});
});

View File

@ -27,7 +27,7 @@ test.describe('Newsletter settings', async () => {
const modal = page.getByTestId('add-newsletter-modal');
await modal.getByRole('button', {name: 'Create'}).click();
await expect(modal).toHaveText(/Name is required/);
await expect(modal).toHaveText(/A name is required for your newsletter/);
// Shouldn't be necessary, but without these Playwright doesn't click Create the second time for some reason
await modal.getByRole('button', {name: 'Cancel'}).click();
@ -69,7 +69,7 @@ test.describe('Newsletter settings', async () => {
await modal.getByPlaceholder('Weekly Roundup').fill('');
await modal.getByRole('button', {name: 'Save'}).click();
await expect(modal).toHaveText(/Name is required/);
await expect(modal).toHaveText(/A name is required for your newsletter/);
await modal.getByPlaceholder('Weekly Roundup').fill('Updated newsletter');
@ -114,7 +114,7 @@ test.describe('Newsletter settings', async () => {
await modal.getByLabel('Sender email').fill('not-an-email');
await modal.getByRole('button', {name: 'Save'}).click();
await expect(modal).toHaveText(/Invalid email/);
await expect(modal).toHaveText(/Enter a valid email address/);
await modal.getByLabel('Sender email').fill('test@test.com');
await modal.getByRole('button', {name: 'Save'}).click();
@ -191,7 +191,7 @@ test.describe('Newsletter settings', async () => {
await replyToEmail.fill('not-an-email');
await modal.getByRole('button', {name: 'Save'}).click();
await expect(modal).toHaveText(/Invalid email/);
await expect(modal).toHaveText(/Enter a valid email address/);
await replyToEmail.fill('test@test.com');
await modal.getByRole('button', {name: 'Save'}).click();
@ -237,12 +237,12 @@ test.describe('Newsletter settings', async () => {
// Error case #1: add invalid email address
await senderEmail.fill('Harry Potter');
await modal.getByRole('button', {name: 'Save'}).click();
await expect(modal).toHaveText(/Invalid email/);
await expect(modal).toHaveText(/Enter a valid email address/);
// Error case #2: the sender email address doesn't match the custom sending domain
await senderEmail.fill('harry@potter.com');
await modal.getByRole('button', {name: 'Save'}).click();
await expect(modal).toHaveText(/Email must end with @customdomain.com/);
await expect(modal).toHaveText(/Email address must end with @customdomain.com/);
// But can have any address on the same domain, without verification
await senderEmail.fill('harry@customdomain.com');

View File

@ -35,23 +35,23 @@ test.describe('User profile', async () => {
// Validation failures
await modal.getByLabel('Full name').fill('');
await modal.getByRole('button', {name: 'Save & close'}).click();
await modal.getByRole('button', {name: 'Save'}).click();
await expect(modal).toContainText('Name is required');
await modal.getByLabel('Email').fill('test');
await modal.getByRole('button', {name: 'Save & close'}).click();
await modal.getByRole('button', {name: 'Save'}).click();
await expect(modal).toContainText('Enter a valid email address');
await modal.getByLabel('Location').fill(new Array(195).join('a'));
await modal.getByRole('button', {name: 'Save & close'}).click();
await modal.getByRole('button', {name: 'Save'}).click();
await expect(modal).toContainText('Location is too long');
await modal.getByLabel('Bio').fill(new Array(210).join('a'));
await modal.getByRole('button', {name: 'Save & close'}).click();
await modal.getByRole('button', {name: 'Save'}).click();
await expect(modal).toContainText('Bio is too long');
await modal.getByLabel('Website').fill('not-a-website');
await modal.getByRole('button', {name: 'Save & close'}).click();
await modal.getByRole('button', {name: 'Save'}).click();
await expect(modal).toContainText('Enter a valid URL');
const facebookInput = modal.getByLabel('Facebook profile');
@ -151,6 +151,7 @@ test.describe('User profile', async () => {
await modal.getByLabel('Full name').fill('New Admin');
await modal.getByLabel('Email').fill('newadmin@test.com');
await modal.getByLabel('Slug').fill('newadmin');
await expect(modal.getByText('https://example.com/author/newadmin')).toBeVisible();
await modal.getByLabel('Location').fill('some location');
await modal.getByLabel('Website').fill('https://example.com');
await modal.getByLabel('Facebook profile').fill('fb');
@ -165,9 +166,10 @@ test.describe('User profile', async () => {
await modal.getByLabel(/Paid member cancellations/).check();
await modal.getByLabel(/Milestones/).uncheck();
await modal.getByRole('button', {name: 'Save & close'}).click();
await modal.getByRole('button', {name: 'Save'}).click();
await expect(modal.getByRole('button', {name: 'Saved'})).toBeVisible();
await modal.getByRole('button', {name: 'Close'}).click();
await expect(listItem.getByText('New Admin')).toBeVisible();
await expect(listItem.getByText('newadmin@test.com')).toBeVisible();
@ -313,7 +315,7 @@ test.describe('User profile', async () => {
await modal.getByLabel('Full name').fill('Updated');
await modal.getByRole('button', {name: 'Cancel'}).click();
await modal.getByRole('button', {name: 'Close'}).click();
await expect(page.getByTestId('confirmation-modal')).toHaveText(/leave/i);
@ -373,7 +375,7 @@ test.describe('User profile', async () => {
await listItem.getByRole('button', {name: 'Edit'}).click();
await expect(modal.getByTestId('api-keys')).toBeHidden();
await modal.getByRole('button', {name: 'Cancel'}).click();
await modal.getByRole('button', {name: 'Close'}).click();
// Can see and regenerate your own staff token

View File

@ -69,10 +69,12 @@ test.describe('User roles', async () => {
await modal.locator('input[value=editor]').check();
await modal.getByRole('button', {name: 'Save & close'}).click();
await modal.getByRole('button', {name: 'Save'}).click();
await expect(modal.getByRole('button', {name: 'Saved'})).toBeVisible();
await modal.getByRole('button', {name: 'Close'}).click();
await expect(activeTab).toHaveText(/No authors found./);
await section.getByRole('tab', {name: 'Editors'}).click();
@ -146,6 +148,7 @@ test.describe('User roles', async () => {
await modal.getByLabel('Full name').fill('New name');
await modal.getByRole('button', {name: 'Save'}).click();
await modal.getByRole('button', {name: 'Close'}).click();
await expect(modal).toBeHidden();
});

View File

@ -1,12 +1,8 @@
import {expect, test} from '@playwright/test';
import {globalDataRequests} from '../../utils/acceptance';
import {mockApi, responseFixtures, toggleLabsFlag} from '@tryghost/admin-x-framework/test/acceptance';
import {mockApi, responseFixtures} from '@tryghost/admin-x-framework/test/acceptance';
test.describe('Recommendations', async () => {
test.beforeEach(async () => {
toggleLabsFlag('recommendations', true);
});
test('can view recommendations', async ({page}) => {
await mockApi({page, requests: {
...globalDataRequests,

View File

@ -18,7 +18,7 @@ test.describe('Tier settings', async () => {
const modal = page.getByTestId('tier-detail-modal');
await modal.getByRole('button', {name: 'Save & close'}).click();
await modal.getByRole('button', {name: 'Save'}).click();
await expect(modal).toHaveText(/Enter a name for the tier/);
await expect(modal).toHaveText(/Amount must be at least \$1/);
@ -51,7 +51,8 @@ test.describe('Tier settings', async () => {
browseTiers: {method: 'GET', path: '/tiers/', response: {tiers: [...responseFixtures.tiers.tiers, newTier]}}
}});
await modal.getByRole('button', {name: 'Save & close'}).click();
await modal.getByRole('button', {name: 'Save'}).click();
await modal.getByRole('button', {name: 'Close'}).click();
// await expect(section.getByTestId('tier-card').filter({hasText: /Plus/})).toHaveText(/Plus tier/);
// await expect(section.getByTestId('tier-card').filter({hasText: /Plus/})).toHaveText(/\$8\/month/);
@ -103,7 +104,7 @@ test.describe('Tier settings', async () => {
// Failing validations
await modal.getByLabel('Name').fill('');
await modal.getByRole('button', {name: 'Save & close'}).click();
await modal.getByRole('button', {name: 'Save'}).click();
await expect(modal).toHaveText(/Enter a name for the tier/);
@ -132,7 +133,8 @@ test.describe('Tier settings', async () => {
// Save changes
await modal.getByRole('button', {name: 'Save & close'}).click();
await modal.getByRole('button', {name: 'Save'}).click();
await modal.getByRole('button', {name: 'Close'}).click();
await expect(section.getByTestId('tier-card').filter({hasText: /Supporter/})).toHaveText(/Supporter updated/);
await expect(section.getByTestId('tier-card').filter({hasText: /Supporter/})).toHaveText(/Supporter description/);
@ -185,7 +187,8 @@ test.describe('Tier settings', async () => {
await modal.getByRole('button', {name: 'Add'}).click();
await modal.getByLabel('New benefit').fill('Second benefit');
await modal.getByRole('button', {name: 'Save & close'}).click();
await modal.getByRole('button', {name: 'Save'}).click();
await modal.getByRole('button', {name: 'Close'}).click();
await expect(section.getByTestId('tier-card').filter({hasText: /Free/})).toHaveText(/Free tier description/);

View File

@ -39,7 +39,7 @@ test.describe('Navigation settings', async () => {
await secondaryNavigationTab.getByTestId('new-navigation-item').getByLabel('URL').fill('https://google.com');
await secondaryNavigationTab.getByTestId('new-navigation-item').getByLabel('URL').blur();
await modal.getByRole('button', {name: 'OK'}).click();
await modal.getByRole('button', {name: 'Save'}).click();
await expect(modal).not.toBeVisible();
@ -68,7 +68,7 @@ test.describe('Navigation settings', async () => {
await primaryItem.getByLabel('URL').press('Backspace');
await primaryItem.getByLabel('URL').fill('google.com');
await modal.getByRole('button', {name: 'OK'}).click();
await modal.getByRole('button', {name: 'Save'}).click();
await expect(primaryItem.getByText('You must specify a label')).toHaveCount(1);
await expect(primaryItem.getByText('You must specify a valid URL or relative path')).toHaveCount(1);
@ -149,7 +149,7 @@ test.describe('Navigation settings', async () => {
await newItem.getByTestId('add-button').click();
await modal.getByRole('button', {name: 'Cancel'}).click();
await modal.getByRole('button', {name: 'Close'}).click();
await expect(page.getByTestId('confirmation-modal')).toHaveText(/leave/i);

View File

@ -168,7 +168,7 @@ test.describe('Theme settings', async () => {
expect(lastApiRequests.uploadTheme).toBeTruthy();
});
test('Limits uploading new themes', async ({page}) => {
test('Limits uploading new themes and redirect to /pro', async ({page}) => {
await mockApi({page, requests: {
...globalDataRequests,
...limitRequests,
@ -206,6 +206,18 @@ test.describe('Theme settings', async () => {
await modal.getByRole('button', {name: 'Upload theme'}).click();
await expect(page.getByTestId('limit-modal')).toHaveText(/Upgrade to enable custom themes/);
const limitModal = page.getByTestId('limit-modal');
await limitModal.getByRole('button', {name: 'Upgrade'}).click();
// The route should be updated
const newPageUrl = page.url();
const newPageUrlObject = new URL(newPageUrl);
const decodedUrl = decodeURIComponent(newPageUrlObject.pathname);
// expect the route to be updated to /pro
await expect(decodedUrl).toMatch(/\/\{\"route\":\"\/pro\",\"isExternal\":true\}$/);
});
test('Prevents overwriting the default theme', async ({page}) => {

View File

@ -12,6 +12,22 @@ Comments widget that is embedded at the bottom of posts in Ghost.
You can automatically start the comments dev server when developing Ghost by running Ghost (in root folder) via `yarn dev --all` or `yarn dev --comments`. This will host the comments JavaScript files, and makes sure that Ghost uses these locally hosted assets instead of the ones from the CDN.
## Release
A patch release can be rolled out instantly in production, whereas a minor/major release requires the Ghost monorepo to be updated and released. In either case, you need sufficient permissions to release `@tryghost` packages on NPM.
### Patch release
1. Run `yarn ship` and select a patch version when prompted
2. (Optional) Clear JsDelivr cache to get the new version out instantly ([docs](https://www.notion.so/ghost/How-to-clear-jsDelivr-CDN-cache-2930bdbac02946eca07ac23ab3199bfa?pvs=4)). Typically, you'll need to open `https://purge.jsdelivr.net/ghost/comments-ui@~${COMMENTS_UI_VERSION}/umd/comments-ui.min.js` and
`https://purge.jsdelivr.net/ghost/comments-ui@~${COMMENTS_UI_VERSION}/umd/main.css` in your browser, where `COMMENTS_UI_VERSION` is the latest minor version in `ghost/core/core/shared/config/defaults.json` ([code](https://github.com/TryGhost/Ghost/blob/0aef3d3beeebcd79a4bfd3ad27e0ac67554b5744/ghost/core/core/shared/config/defaults.json#L198))
### Minor / major release
1. Run `yarn ship` and select a minor or major version when prompted
2. Update the Comments UI version in `ghost/core/core/shared/config/defaults.json` to the new minor or major version ([code](https://github.com/TryGhost/Ghost/blob/0aef3d3beeebcd79a4bfd3ad27e0ac67554b5744/ghost/core/core/shared/config/defaults.json#L198))
3. Wait until a new version of Ghost is released
# Copyright & License
Copyright (c) 2013-2023 Ghost Foundation - Released under the [MIT license](LICENSE).
Copyright (c) 2013-2024 Ghost Foundation - Released under the [MIT license](LICENSE).

View File

@ -44,16 +44,16 @@
},
"dependencies": {
"@headlessui/react": "1.7.19",
"@tiptap/core": "2.4.0",
"@tiptap/extension-blockquote": "2.4.0",
"@tiptap/extension-document": "2.4.0",
"@tiptap/extension-hard-break": "2.4.0",
"@tiptap/extension-link": "2.4.0",
"@tiptap/extension-paragraph": "2.4.0",
"@tiptap/extension-placeholder": "2.4.0",
"@tiptap/extension-text": "2.4.0",
"@tiptap/pm": "2.4.0",
"@tiptap/react": "2.4.0",
"@tiptap/core": "2.5.0",
"@tiptap/extension-blockquote": "2.5.0",
"@tiptap/extension-document": "2.5.0",
"@tiptap/extension-hard-break": "2.5.0",
"@tiptap/extension-link": "2.5.0",
"@tiptap/extension-paragraph": "2.5.0",
"@tiptap/extension-placeholder": "2.5.0",
"@tiptap/extension-text": "2.5.0",
"@tiptap/pm": "2.5.0",
"@tiptap/react": "2.5.0",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-string-replace": "1.1.1"
@ -74,7 +74,7 @@
"eslint-plugin-react-refresh": "0.4.3",
"eslint-plugin-tailwindcss": "3.13.0",
"jsdom": "24.1.0",
"postcss": "8.4.38",
"postcss": "8.4.39",
"tailwindcss": "3.4.4",
"vite": "4.5.3",
"vite-plugin-css-injected-by-js": "3.3.0",

View File

@ -118,14 +118,12 @@ const AddDetailsPopup = (props: Props) => {
return (
<div className="shadow-modal relative h-screen w-screen overflow-hidden rounded-none bg-white p-[28px] text-center sm:h-auto sm:w-[720px] sm:rounded-xl sm:p-0" data-testid="profile-modal" onMouseDown={stopPropagation}>
<div className="flex">
{!isMobile() &&
<div className={`flex w-[40%] flex-col items-center justify-center bg-[#1C1C1C]`}>
<div className="mt-[-1px] flex flex-col gap-9">
<div className={`hidden w-[50%] flex-col items-center justify-center bg-[#1C1C1C] sm:block sm:p-8`}>
<div className="mt-[-1px] flex flex-col gap-9 text-left">
{renderExampleProfiles()}
</div>
</div>
}
<div className={`${isMobile() ? 'w-full' : 'w-[60%]'} p-0 sm:p-8`}>
<div className={`p-0 sm:p-8`}>
<h1 className="mb-1 text-center font-sans text-[24px] font-bold tracking-tight text-black sm:text-left">{t('Complete your profile')}<span className="hidden sm:inline">.</span></h1>
<p className="pr-0 text-center font-sans text-base leading-9 text-neutral-500 sm:pr-10 sm:text-left">{t('Add context to your comment, share your name and expertise to foster a healthy discussion.')}</p>
<section className="mt-8 text-left">

View File

@ -26,45 +26,49 @@ The script also adds custom class names to this element for open and close state
Refer the [docs](https://ghost.org/help/setup-members/#customize-portal-settings) to read about ways in which Portal can be customized for your site.
## Basic Setup
## Develop
This section is mostly relevant for core team only for active Portal development. Always use the unpkg link for testing/using latest released portal script.
Run Portal within the Ghost monorepo with:
```
yarn dev --portal
```
- Run `yarn start:dev` to start Portal in development mode
- Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
- To use the local Portal script in a local Ghost site
- Update `config.local.json` in Ghost repo to add "portal" config pointing to local dev server url as instructed on terminal.
- By default, this uses port `5368` for loading local Portal script on Ghost site. It's also possible to specify a custom port when running the script using - `--port=xxxx`.
Alternatively, use `yarn dev --all` to load Portal and other supported apps/services, see [dev.js](https://github.com/TryGhost/Ghost/blob/main/.github/scripts/dev.js) for more information.
## Available Scripts
---
In the project directory, you can also run:
To run Portal in a standalone fashion, use `yarn start` and open [http://localhost:3000](http://localhost:3000).
### `yarn start`
## Build
Runs the app in the development mode.<br />
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
To create a production minified bundle in `umd/portal.min.js`:
```
yarn build
```
The page will reload if you make edits.<br />
You will also see any lint errors in the console.
## Test
Start the portal server when developing Ghost by running Ghost (in root folder) via `yarn dev --all` or `yarn dev --portal`. This will host the portal JavaScript files, and makes sure that Ghost uses these locally hosted assets instead of the ones from the CDN.
To run tests in watch mode:
```
yarn test
```
### `yarn build`
## Release
Creates the production single minified bundle for external use in `umd/portal.min.js`. <br />
A patch release can be rolled out instantly in production, whereas a minor/major release requires the Ghost monorepo to be updated and released. In either case, you need sufficient permissions to release `@tryghost` packages on NPM.
### `yarn test`
### Patch release
Launches the test runner in the interactive watch mode.<br />
1. Run `yarn ship` and select a patch version when prompted
2. (Optional) Clear JsDelivr cache to get the new version out instantly ([docs](https://www.notion.so/ghost/How-to-clear-jsDelivr-CDN-cache-2930bdbac02946eca07ac23ab3199bfa?pvs=4)). Typically, you'll need to open `https://purge.jsdelivr.net/ghost/portal@~${PORTAL_VERSION}/umd/portal.min.js` and
`https://purge.jsdelivr.net/ghost/portal@~${PORTAL_VERSION}/umd/main.css` in your browser, where `PORTAL_VERSION` is the latest minor version in `ghost/core/core/shared/config/defaults.json` ([code](https://github.com/TryGhost/Ghost/blob/0aef3d3beeebcd79a4bfd3ad27e0ac67554b5744/ghost/core/core/shared/config/defaults.json#L185))
## Publish
### Minor / major release
Run `yarn ship` to publish new version of script.
1. Run `yarn ship` and select a minor or major version when prompted
2. Update the Portal version in `ghost/core/core/shared/config/defaults.json` to the new minor or major version ([code](https://github.com/TryGhost/Ghost/blob/0aef3d3beeebcd79a4bfd3ad27e0ac67554b5744/ghost/core/core/shared/config/defaults.json#L198))
3. Wait until a new version of Ghost is released
`yarn ship` is an alias for `npm publish`
# Copyright & License
- Builds the script with latest code using `yarn build` (prePublish)
- Publishes package on npm as `@tryghost/portal` and creates an unpkg link for script at https://unpkg.com/@tryghost/portal@VERSION
(Core team only)
Copyright (c) 2013-2024 Ghost Foundation - Released under the [MIT license](LICENSE).

View File

@ -32,6 +32,10 @@ const AccountWelcome = () => {
return null;
}
if (isComplimentary) {
return null;
}
if (subscriptionHasFreeTrial({sub: subscription})) {
const trialEnd = getDateString(subscription.trial_end_at);
return (

View File

@ -40,12 +40,12 @@
},
"devDependencies": {
"@playwright/test": "1.38.1",
"@storybook/addon-essentials": "7.6.19",
"@storybook/addon-interactions": "7.6.19",
"@storybook/addon-links": "7.6.19",
"@storybook/addon-essentials": "7.6.20",
"@storybook/addon-interactions": "7.6.20",
"@storybook/addon-links": "7.6.20",
"@storybook/addon-styling": "1.3.7",
"@storybook/blocks": "7.6.19",
"@storybook/react": "7.6.19",
"@storybook/blocks": "7.6.20",
"@storybook/react": "7.6.20",
"@storybook/react-vite": "7.6.4",
"@storybook/testing-library": "0.2.2",
"@tailwindcss/line-clamp": "0.4.4",
@ -59,11 +59,11 @@
"eslint-plugin-react-refresh": "0.4.3",
"eslint-plugin-tailwindcss": "3.13.0",
"jsdom": "24.1.0",
"postcss": "8.4.38",
"postcss": "8.4.39",
"postcss-import": "16.1.0",
"prop-types": "15.8.1",
"rollup-plugin-node-builtins": "2.1.2",
"storybook": "7.6.19",
"storybook": "7.6.20",
"stylelint": "15.10.3",
"tailwindcss": "3.4.4",
"vite": "4.5.3",

View File

@ -2,51 +2,52 @@
This is the home of the Ember.js-based Admin app that ships with [Ghost](https://github.com/tryghost/ghost).
## Running tests
## Test
Build and run tests once:
### Running tests in the browser
Run all tests in the browser by running `yarn dev` in the Ghost monorepo and visiting http://localhost:4200/tests. The code is hotloaded on change and you can filter which tests to run.
[Testing public documentation](https://ghost.notion.site/Testing-Ember-560cec6700fc4d37a58b3ba9febb4b4b)
---
Tip: You can use `await this.pauseTest()` in your tests to temporarily pause the execution of browser tests. Use the browser console to inspect and debug the DOM, then resume tests by running `resumeTest()` directly in the browser console ([docs](https://guides.emberjs.com/v3.28.0/testing/testing-application/#toc_debugging-your-tests))
### Running tests in the CLI
To build and run tests in the CLI, you can use:
```bash
TZ=UTC yarn test
```
_Note the `TZ=UTC` environment variable which is currently required to get tests working if your system timezone doesn't match UTC._
If you are serving the admin app (e.g., when running `yarn serve`, or when running `yarn dev` in the main Ghost project), you can also run the tests in your browser by going to http://localhost:4200/tests.
---
This has the additional benefit that you can use `await this.pauseTest()` in your tests to temporarily pause tests (best to also add `this.timeout(0);` to avoid timeouts). This allows you to inspect the DOM in your browser to debug tests. You can resume tests by running `resumeTest()` in your browser console.
[More information](https://guides.emberjs.com/v3.28.0/testing/testing-application/#toc_debugging-your-tests)
### Writing tests
When writing tests and not using the `http://localhost:4200/tests` browser tests, it can be easier to have a separate watching build that builds the project for the test environment (this drastically reduces the time you have to wait when running tests):
However, this is very slow when writing tests, as it requires the app to be rebuilt on every change. Instead, create a separate watching build with:
```bash
yarn build --environment=test -w -o="dist-test"
```
After that, you can easily run tests locally:
Run all tests:
```bash
TZ=UTC yarn test 1 --path="dist-test"
```
To have a cleaner output:
Then run tests with:
```bash
TZ=UTC yarn test 1 --reporter dot --path="dist-test"
```
This shows a dot (`.`) for every successful test, and `F` for every failed test. At the end, it will only show the output of the failed tests.
The `--reporter dot` shows a dot (`.`) for every successful test, and `F` for every failed test. It renders the output of the failed tests only.
---
To run a specific test file:
```bash
TZ=UTC yarn test 1 --reporter dot --path="dist-test" -mp=tests/acceptance/settings/newsletters-test.js
TZ=UTC yarn test 1 --reporter dot --path="dist-test" -mp=tests/unit/helpers/gh-count-characters-test.js
```
_Hint: you can easily copy the path of a test in VSCode by right clicking on the test file and choosing `Copy Relative Path`._
---
To have a full list of the available options, run
```bash
@ -55,4 +56,4 @@ ember exam --help
# Copyright & License
Copyright (c) 2013-2023 Ghost Foundation - Released under the [MIT license](LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage.
Copyright (c) 2013-2024 Ghost Foundation - Released under the [MIT license](LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage.

View File

@ -9,8 +9,8 @@ export default class GhAlert extends Component {
const typeMapping = {
success: 'green',
error: 'red',
warn: 'blue',
info: 'blue'
warn: 'black',
info: 'black'
};
const type = this.args.message.type;

View File

@ -46,11 +46,11 @@
@imageSrc={{@image}}
@saveImage={{fn this.saveImage uploader.setFiles}}
/>
<button type="button" class="image-action image-delete" title="Delete image" {{on "click" @clearImage}}>
<button type="button" class="image-action image-delete" data-tooltip="Delete" {{on "click" @clearImage}}>
{{svg-jar "koenig/kg-trash"}}
</button>
</div>
<div class="relative flex justify-between align-center">
<div class="gh-editor-feature-image-caption-container">
{{#if this.isEditingAlt}}
<input
type="text"

View File

@ -236,7 +236,7 @@ export default class GhKoenigEditorLexical extends Component {
// otherwise the browser will defocus the editor and the cursor will disappear
@action
focusEditor(event) {
if (!this.skipFocusEditor && event.target.classList.contains('gh-koenig-editor-pane')) {
if (!this.skipFocusEditor && event.target.classList.contains('gh-koenig-editor-pane') && this.editorAPI) {
let editorCanvas = this.editorAPI.editorInstance.getRootElement();
let {bottom} = editorCanvas.getBoundingClientRect();

View File

@ -143,25 +143,21 @@
{{else}}
{{#if (or (eq sub.price.nickname "Monthly") (eq sub.price.nickname "Yearly"))}}
{{else}}
<span class="gh-cp-membertier-pricelabel">{{sub.price.nickname}}</span><span class="gh-cp-membertier-renewal"> &ndash; </span>
<span class="gh-cp-membertier-pricelabel">{{sub.price.nickname}}</span>
{{/if}}
{{/if}}
{{#if sub.isComplimentary}}
{{#if sub.trialUntil}}
<span class="gh-cp-membertier-renewal"> &ndash; </span>
<span class="gh-cp-membertier-renewal">{{sub.validityDetails}}</span>
{{/if}}
{{#if sub.compExpiry}}
<span class="gh-cp-membertier-renewal">Expires {{sub.compExpiry}}</span>
{{/if}}
{{else}}
{{#if sub.hasEnded}}
<span class="gh-cp-membertier-renewal">Ended {{sub.validUntil}}</span>
{{else if sub.willEndSoon}}
<span class="gh-cp-membertier-renewal">Has access until {{sub.validUntil}}</span>
{{else if sub.trialUntil}}
<span class="gh-cp-membertier-renewal">Ends {{sub.trialUntil}}</span>
{{else}}
<span class="gh-cp-membertier-renewal">Renews {{sub.validUntil}}</span>
{{/if}}
<span class="gh-cp-membertier-renewal"> &ndash; </span>
<span class="gh-cp-membertier-renewal">{{sub.validityDetails}}</span>
{{/if}}
</div>
<Member::SubscriptionDetailBox @sub={{sub}} @index={{index}} />
</div>

View File

@ -1,8 +1,7 @@
import Component from '@glimmer/component';
import moment from 'moment-timezone';
import {action} from '@ember/object';
import {didCancel, task} from 'ember-concurrency';
import {getNonDecimal, getSymbol} from 'ghost-admin/utils/currency';
import {getSubscriptionData} from 'ghost-admin/utils/subscription-data';
import {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking';
@ -60,41 +59,9 @@ export default class extends Component {
return typeof value.id !== 'undefined' && self.findIndex(element => (element.tier_id || element.id) === (value.tier_id || value.id)) === index;
});
let subscriptionData = subscriptions.filter((sub) => {
return !!sub.price;
}).map((sub) => {
const periodEnded = sub.current_period_end && new Date(sub.current_period_end) < new Date();
const data = {
...sub,
attribution: {
...sub.attribution,
referrerSource: sub.attribution?.referrer_source || 'Unknown',
referrerMedium: sub.attribution?.referrer_medium || '-'
},
startDate: sub.start_date ? moment(sub.start_date).format('D MMM YYYY') : '-',
validUntil: sub.current_period_end ? moment(sub.current_period_end).format('D MMM YYYY') : '-',
hasEnded: sub.status === 'canceled' && periodEnded,
willEndSoon: sub.cancel_at_period_end || (sub.status === 'canceled' && !periodEnded),
cancellationReason: sub.cancellation_reason,
price: {
...sub.price,
currencySymbol: getSymbol(sub.price.currency),
nonDecimalAmount: getNonDecimal(sub.price.amount)
},
isComplimentary: !sub.id
};
if (sub.trial_end_at) {
const inTrialMode = moment(sub.trial_end_at).isAfter(new Date(), 'day');
if (inTrialMode) {
data.trialUntil = moment(sub.trial_end_at).format('D MMM YYYY');
}
}
let subsWithPrice = subscriptions.filter(sub => !!sub.price);
let subscriptionData = subsWithPrice.map(sub => getSubscriptionData(sub));
if (!sub.id && sub.tier?.expiry_at) {
data.compExpiry = moment(sub.tier.expiry_at).utc().format('D MMM YYYY');
}
return data;
});
return tiers.map((tier) => {
let tierSubscriptions = subscriptionData.filter((subscription) => {
return subscription?.price?.tier?.tier_id === (tier.tier_id || tier.id);

View File

@ -6,10 +6,10 @@
<button
type="button"
class="{{if @className @className "image-action image-edit"}}"
title="Edit image"
data-tooltip="Edit"
{{on "click" (fn this.handleClick uploader)}}
>
{{svg-jar "koenig/kg-edit"}}
{{svg-jar "koenig/kg-wand"}}
</button>
</GhUploader>
{{/if}}

View File

@ -448,6 +448,7 @@ export default class KoenigLexicalEditor extends Component {
fetchCollectionPosts,
fetchEmbed,
fetchLabels,
renderLabels: !this.session.user.isContributor,
feature: {
collectionsCard: this.feature.collectionsCard,
collections: this.feature.collections,
@ -461,7 +462,8 @@ export default class KoenigLexicalEditor extends Component {
membersEnabled: this.settings.membersSignupAccess === 'all',
searchLinks,
siteTitle: this.settings.title,
siteDescription: this.settings.description
siteDescription: this.settings.description,
siteUrl: this.config.getSiteUrl('/')
};
const cardConfig = Object.assign({}, defaultCardConfig, props.cardConfig, {pinturaConfig: this.pinturaConfig});

View File

@ -11,7 +11,8 @@ const ALL_EVENT_TYPES = [
{event: 'email_opened_event', icon: 'filter-dropdown-email-opened', name: 'Email opened', group: 'emails'},
{event: 'email_delivered_event', icon: 'filter-dropdown-email-received', name: 'Email received', group: 'emails'},
{event: 'email_complaint_event', icon: 'filter-dropdown-email-flagged-as-spam', name: 'Email flagged as spam', group: 'emails'},
{event: 'email_failed_event', icon: 'filter-dropdown-email-bounced', name: 'Email bounced', group: 'emails'}
{event: 'email_failed_event', icon: 'filter-dropdown-email-bounced', name: 'Email bounced', group: 'emails'},
{event: 'email_change_event', icon: 'filter-dropdown-email-address-changed', name: 'Email address changed', group: 'emails'}
];
export default class MembersActivityEventTypeFilter extends Component {

View File

@ -1,7 +1,6 @@
import {MATCH_RELATION_OPTIONS} from './relation-options';
export const SUBSCRIBED_FILTER = ({newsletters, feature, group}) => {
if (feature.filterEmailDisabled) {
export const SUBSCRIBED_FILTER = ({newsletters, group}) => {
return {
label: newsletters.length > 1 ? 'All newsletters' : 'Newsletter subscription',
name: 'subscribed',
@ -9,8 +8,6 @@ export const SUBSCRIBED_FILTER = ({newsletters, feature, group}) => {
relationOptions: MATCH_RELATION_OPTIONS,
valueType: 'options',
group: newsletters.length > 1 ? 'Newsletters' : group,
// Only show the filter for multiple newsletters if feature flag is enabled
feature: newsletters.length > 1 ? 'filterEmailDisabled' : undefined,
buildNqlFilter: (flt) => {
const relation = flt.relation;
const value = flt.value;
@ -94,65 +91,9 @@ export const SUBSCRIBED_FILTER = ({newsletters, feature, group}) => {
};
}
};
}
if (newsletters.length > 1) {
// Disable
// Only show the filter for multiple newsletters if feature flag is enabled
return [];
}
return {
label: 'Newsletter subscription',
name: 'subscribed',
columnLabel: 'Subscribed',
relationOptions: MATCH_RELATION_OPTIONS,
valueType: 'options',
group: group,
buildNqlFilter: (flt) => {
const relation = flt.relation;
const value = flt.value;
return (relation === 'is' && value === 'true') || (relation === 'is-not' && value === 'false')
? '(subscribed:true+email_disabled:0)'
: '(subscribed:false,email_disabled:1)';
},
parseNqlFilter: (flt) => {
const comparator = flt.$and || flt.$or;
if (!comparator || comparator.length !== 2) {
return;
}
if (comparator[0].subscribed === undefined || comparator[1].email_disabled === undefined) {
return;
}
const subscribed = comparator[0].subscribed;
return {
value: subscribed ? 'true' : 'false',
relation: 'is'
};
},
options: [
{label: 'Subscribed', name: 'true'},
{label: 'Unsubscribed', name: 'false'}
],
getColumnValue: (member, flt) => {
const relation = flt.relation;
const value = flt.value;
return {
text: (relation === 'is' && value === 'true') || (relation === 'is-not' && value === 'false')
? 'Subscribed'
: 'Unsubscribed'
};
}
};
};
export const NEWSLETTERS_FILTERS = ({newsletters, group, feature}) => {
export const NEWSLETTERS_FILTERS = ({newsletters, group}) => {
if (newsletters.length <= 1) {
return [];
}
@ -210,13 +151,11 @@ export const NEWSLETTERS_FILTERS = ({newsletters, group, feature}) => {
const relation = flt.relation;
const value = flt.value;
if (feature.filterEmailDisabled) {
if (member.emailSuppression && member.emailSuppression.suppressed) {
return {
text: 'Email disabled'
};
}
}
return {
text: (relation === 'is' && value === 'true') || (relation === 'is-not' && value === 'false')

View File

@ -1,8 +0,0 @@
<div class="gh-expandable-header">
<div>
<h4 class="gh-expandable-title">Counter</h4>
<p class="gh-expandable-description">Current counter value: <strong>{{this.counter}}</strong></p>
<p class="gh-expandable-description">This counter will reset when Ghost reboots.</p>
</div>
<button type="button" class="gh-btn" {{on "click" this.handleClick}} data-test-button="delete-all"><span>Add One</span></button>
</div>

View File

@ -1,31 +0,0 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking';
export default class Websockets extends Component {
@service('socket-io') socketIOService;
constructor(...args) {
super(...args);
// initialize connection
// TODO: ensure this works with subdirectories
let origin = window.location.origin; // this gives us host:port
let socket = this.socketIOService.socketFor(origin);
// add listener
socket.on('addCount', (value) => {
this.counter = value;
});
}
// button counter
@tracked counter = 0;
// handle button/event
@action handleClick() {
let socket = this.socketIOService.socketFor(origin);
this.counter = 1 + this.counter;
socket.emit('addCount', this.counter);
}
}

View File

@ -62,7 +62,10 @@ export default class ResetController extends Controller.extend(ValidationEngine)
password_reset: [{newPassword, ne2Password, token}]
}
});
this.notifications.showAlert(resp.password_reset[0].message, {type: 'warn', delayed: true, key: 'password.reset'});
this.notifications.showNotification(
resp.password_reset[0].message,
{type: 'info', delayed: true, key: 'password.reset'}
);
this.session.authenticate('authenticator:cookie', email, newPassword);
return true;
} catch (error) {

View File

@ -25,6 +25,7 @@ export default class SigninController extends Controller.extend(ValidationEngine
@tracked submitting = false;
@tracked loggingIn = false;
@tracked flowNotification = '';
@tracked flowErrors = '';
@tracked passwordResetEmailSent = false;
@ -123,21 +124,19 @@ export default class SigninController extends Controller.extend(ValidationEngine
let notifications = this.notifications;
this.flowErrors = '';
this.flowNotification = '';
// This is a bit dirty, but there's no other way to ensure the properties are set as well as 'forgotPassword'
this.hasValidated.addObject('identification');
try {
yield this.validate({property: 'forgotPassword'});
yield this.ajax.post(forgottenUrl, {data: {password_reset: [{email}]}});
notifications.showAlert(
'Please check your email for instructions.',
{type: 'info', key: 'forgot-password.send.success'}
);
this.flowNotification = 'An email with password reset instructions has been sent.';
return true;
} catch (error) {
// ValidationEngine throws "undefined" for failed validation
if (!error) {
return this.flowErrors = 'We need your email address to reset your password!';
return this.flowErrors = 'We need your email address to reset your password.';
}
if (isVersionMismatchError(error)) {

View File

@ -1,13 +0,0 @@
import Controller from '@ember/controller';
/* eslint-disable ghost/ember/alias-model-in-controller */
import classic from 'ember-classic-decorator';
import {inject as service} from '@ember/service';
@classic
export default class WebsocketsController extends Controller {
@service feature;
init() {
super.init(...arguments);
}
}

View File

@ -114,6 +114,10 @@ export default class ParseMemberEventHelper extends Helper {
icon = 'subscriptions';
}
if (event.type === 'email_change_event') {
icon = 'email-changed';
}
return 'event-' + icon;
}
@ -208,8 +212,15 @@ export default class ParseMemberEventHelper extends Helper {
return 'less like this';
}
if (event.type === 'email_change_event') {
if (event.data.from_email && event.data.to_email) {
return `Email address changed from ${event.data.from_email} to ${event.data.to_email}`;
}
return 'Email address changed';
}
if (event.type === 'donation_event') {
return `Made a one-time payment`;
return 'Made a one-time payment';
}
}
@ -330,7 +341,7 @@ export default class ParseMemberEventHelper extends Helper {
* Get internal route props for a clickable object
*/
getRoute(event) {
if (['comment_event', 'click_event', 'feedback_event'].includes(event.type)) {
if (['click_event', 'feedback_event'].includes(event.type)) {
if (event.data.post) {
return {
name: 'posts.analytics',

View File

@ -60,9 +60,6 @@ Router.map(function () {
this.route('activitypub-x', {path: '/*sub'});
});
// testing websockets
this.route('websockets');
this.route('explore', function () {
// actual Ember route, not rendered in iframe
this.route('connect');

View File

@ -185,6 +185,17 @@ export default Route.extend(ShortcutsRoute, {
release: `ghost@${this.config.version}`,
beforeSend,
ignoreErrors: [
// Browser autoplay policies (this regex covers a few)
/The play() request was interrupted/,
/The request is not allowed by the user agent or the platform in the current context/,
// Network errors that we don't control
/Server was unreachable/,
/NetworkError when attempting to fetch resource./,
/Failed to fetch/,
/Load failed/,
/The operation was aborted./,
// TransitionAborted errors surface from normal application behaviour
// - https://github.com/emberjs/ember.js/issues/12505
/^TransitionAborted$/,
@ -203,7 +214,7 @@ export default Route.extend(ShortcutsRoute, {
try {
// Session Replay on errors
// Docs: https://docs.sentry.io/platforms/javascript/session-replay
sentryConfig.replaysOnErrorSampleRate = 1.0;
sentryConfig.replaysOnErrorSampleRate = 0.5;
sentryConfig.integrations.push(
// Replace with `Sentry.replayIntegration()` once we've migrated to @sentry/ember 8.x
// Docs: https://docs.sentry.io/platforms/javascript/migration/v7-to-v8/#removal-of-sentryreplay-package

View File

@ -1,18 +0,0 @@
import AuthenticatedRoute from './authenticated';
import {inject as service} from '@ember/service';
// need this to be authenticated
export default class WebsocketRoute extends AuthenticatedRoute {
@service session;
@service router;
beforeModel() {
super.beforeModel(...arguments);
const user = this.session.user;
if (!user.isAdmin) {
return this.router.transitionTo('settings-x.settings-x', `staff/${user.slug}`);
}
}
}

View File

@ -3,6 +3,8 @@ import moment from 'moment-timezone';
import {task} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking';
import mergeStatsByDate from 'ghost-admin/utils/merge-stats-by-date';
/**
* @typedef MrrStat
* @type {Object}
@ -241,14 +243,16 @@ export default class DashboardStatsService extends Service {
return [];
}
const firstChartDay = moment().add(-this.chartDays, 'days').format('YYYY-MM-DD');
return this.memberAttributionStats.filter((stat) => {
if (this.chartDays === 'all') {
return true;
}
return stat.date >= moment().add(-this.chartDays, 'days').format('YYYY-MM-DD');
return stat.date >= firstChartDay;
}).reduce((acc, stat) => {
const statSource = stat.source ?? '';
const existingSource = acc.find(s => s.source === statSource);
const existingSource = acc.find(s => s.source.toLowerCase() === statSource.toLowerCase());
if (existingSource) {
existingSource.signups += stat.signups || 0;
existingSource.paidConversions += stat.paidConversions || 0;
@ -461,39 +465,7 @@ export default class DashboardStatsService extends Service {
}
}
function mergeDates(list, entry) {
const [current, ...rest] = list;
if (!current) {
return entry ? [entry] : [];
}
if (!entry) {
return mergeDates(rest, {
date: current.date,
count: current.count,
positiveDelta: current.positive_delta,
negativeDelta: current.negative_delta,
signups: current.signups,
cancellations: current.cancellations
});
}
if (current.date === entry.date) {
return mergeDates(rest, {
date: entry.date,
count: entry.count + current.count,
positiveDelta: entry.positiveDelta + current.positive_delta,
negativeDelta: entry.negativeDelta + current.negative_delta,
signups: entry.signups + current.signups,
cancellations: entry.cancellations + current.cancellations
});
}
return [entry].concat(mergeDates(list));
}
const subscriptionCountStats = mergeDates(result.stats);
const subscriptionCountStats = mergeStatsByDate(result.stats);
this.paidMembersByCadence = paidMembersByCadence;
this.paidMembersByTier = paidMembersByTier;

View File

@ -63,27 +63,22 @@ export default class FeatureService extends Service {
@feature('lexicalMultiplayer') lexicalMultiplayer;
@feature('audienceFeedback') audienceFeedback;
@feature('webmentions') webmentions;
@feature('websockets') websockets;
@feature('stripeAutomaticTax') stripeAutomaticTax;
@feature('emailCustomization') emailCustomization;
@feature('i18n') i18n;
@feature('announcementBar') announcementBar;
@feature('signupCard') signupCard;
@feature('signupForm') signupForm;
@feature('collections') collections;
@feature('mailEvents') mailEvents;
@feature('collectionsCard') collectionsCard;
@feature('importMemberTier') importMemberTier;
@feature('tipsAndDonations') tipsAndDonations;
@feature('recommendations') recommendations;
@feature('lexicalIndicators') lexicalIndicators;
@feature('filterEmailDisabled') filterEmailDisabled;
@feature('adminXDemo') adminXDemo;
@feature('portalImprovements') portalImprovements;
@feature('ActivityPub') ActivityPub;
@feature('internalLinking') internalLinking;
@feature('editorExcerpt') editorExcerpt;
@feature('newsletterExcerpt') newsletterExcerpt;
@feature('contentVisibility') contentVisibility;
_user = null;

View File

@ -312,8 +312,8 @@
/* ---------------------------------------------------------- */
.gh-alert-black {
border-bottom: color-mod(var(--darkgrey) lightness(-10%)) 1px solid;
background: var(--darkgrey);
border-bottom: 1px solid var(--black);
background: var(--black);
color: #fff;
}
.gh-alert-black a {

View File

@ -842,13 +842,12 @@
background: var(--white);
border-radius: var(--border-radius);
box-shadow: 0 1px 4px -1px rgba(0,0,0,.1);
}
.gh-post-analytics-resource {
flex: 1;
align-items: flex-start;
display: grid;
grid-template-columns: 2fr 3fr;
grid-gap: 24px;
min-width: 0;
}
.gh-post-analytics-resource .thumbnail {
@ -866,6 +865,7 @@
.gh-post-analytics-resource h3 {
font-size: 1.8rem;
font-weight: 700;
text-wrap: pretty;
}
.gh-post-analytics-box h4.gh-main-section-header.small {
@ -1534,7 +1534,7 @@
color: var(--green-d1);
}
@media screen and (max-width: 1080px) {
@media screen and (max-width: 1200px) {
.gh-post-analytics-box.resources {
flex-direction: column;
}

View File

@ -229,7 +229,7 @@
border-style: solid;
border-color: #e1e8ed;
color: #292f33;
font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 1.4rem;
line-height: 1.3em;
background: #fff;
@ -283,7 +283,7 @@
/* NEW editor
/* ---------------------------------------------------------- */
.gh-main > section.gh-editor-fullscreen {
.gh-main>section.gh-editor-fullscreen {
position: fixed;
top: 0;
left: 0;
@ -449,7 +449,7 @@
.gh-editor-feedback-dropdown {
min-width: 400px;
border-radius: 3px;
box-shadow: 0 0 0 1px rgba(0,0,0,.04), 0 8px 20px -3px rgba(0,0,0,.2);
box-shadow: 0 0 0 1px rgba(0, 0, 0, .04), 0 8px 20px -3px rgba(0, 0, 0, .2);
padding: 20px;
background-color: #fff;
background-clip: padding-box;
@ -553,13 +553,20 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone {
height: 4px;
}
.gh-editor-feature-image {
position: relative;
}
.gh-editor-feature-image img {
display: block;
}
.gh-editor-feature-image-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 100px;
background-image: linear-gradient(180deg,rgba(0,0,0,.2),transparent 40%,transparent);
bottom: 0;
background-image: linear-gradient(180deg, rgba(0, 0, 0, .2), transparent 40%, transparent);
transition: all .1s ease-in;
opacity: 0;
}
@ -581,7 +588,7 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone {
padding: 0;
background: var(--white);
color: var(--darkgrey);
border-radius: 4px;
border-radius: 6px;
transition: all .1s ease-in;
opacity: 0;
}
@ -604,7 +611,7 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone {
}
.gh-editor-feature-image .image-action svg path {
fill: var(--darkgrey);
stroke-width: 2;
}
.gh-editor-feature-image-add {
@ -677,12 +684,19 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone {
stroke: var(--midgrey-l2);
}
.gh-editor-feature-image-caption-container {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
padding: .8rem 0;
}
.gh-editor-feature-image-alttext,
.gh-editor-feature-image-caption {
width: 100%;
min-height: 24px;
margin: 0 0 1.2rem 0;
padding: 0;
margin: 0;
padding: 0 3.6rem 0 0;
outline: none;
border-width: 0;
border-style: none;
@ -1037,7 +1051,7 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone {
.gh-editor .editor-preview h3,
.gh-editor .editor-preview h4,
.gh-editor .editor-preview h5,
.gh-editor .editor-preview h6, {
.gh-editor .editor-preview h6 {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, "Open Sans", "Helvetica Neue", sans-serif;
}
@ -1058,7 +1072,7 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone {
width: 100%;
height: 100%;
border: 2px solid var(--blue);
background-color: rgba(255,255,255,0.6);
background-color: rgba(255, 255, 255, 0.6);
}
.gh-editor-drop-target .drop-target-message {
@ -1109,6 +1123,7 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone {
position: relative;
vertical-align: bottom;
}
.editor-toolbar .fa-check:before {
position: absolute;
right: 3px;
@ -1197,8 +1212,8 @@ figure {
left: -20px;
width: 20px;
height: 100%;
background: rgb(255,255,255);
background: linear-gradient(90deg, rgba(255,255,255,1) 0%, rgba(255,255,255,0) 100%);
background: rgb(255, 255, 255);
background: linear-gradient(90deg, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
z-index: 999;
opacity: 0;
transition: all 250ms ease-out;
@ -1211,22 +1226,22 @@ figure {
right: 0;
width: 20px;
height: 100%;
background: rgb(255,255,255);
background: linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,1) 100%);
background: rgb(255, 255, 255);
background: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%);
z-index: 999;
}
.ember-power-select-option[aria-current="true"] .kg-settings-link-url.scroller::before {
opacity: 1;
left: 0;
background: linear-gradient(90deg, rgba(244,245,245,1) 0%, rgba(244,245,245,0) 100%);
background: linear-gradient(90deg, rgba(244, 245, 245, 1) 0%, rgba(244, 245, 245, 0) 100%);
}
.ember-power-select-option[aria-current="true"] .kg-settings-link-url::after {
background: linear-gradient(90deg, rgba(244,245,245,0) 0%, rgba(244,245,245,1) 100%);
background: linear-gradient(90deg, rgba(244, 245, 245, 0) 0%, rgba(244, 245, 245, 1) 100%);
}
.kg-settings-link-url > span {
.kg-settings-link-url>span {
display: inline-block;
font-weight: 400;
font-size: 1.2rem;

View File

@ -271,7 +271,18 @@
.gh-setup .gh-flow-content .main-error {
margin-top: 16px;
color: var(--red);
font-size: 1.35rem;
font-size: 1.4rem;
line-height: 1.5;
text-align: center;
text-wrap: balance;
}
.gh-flow-content .main-notification,
.gh-setup .gh-flow-content .main-notification {
margin-top: 16px;
color: var(--black);
font-size: 1.4rem;
font-weight: 400;
line-height: 1.5;
text-align: center;
text-wrap: balance;

View File

@ -231,14 +231,14 @@ select.error {
.gh-input:focus,
.gh-input.focus {
outline: 0;
border-color: color-mod(var(--green)) !important;
border-color: var(--green);
box-shadow: inset 0 0 0 1px var(--green);
background: var(--white);
}
.error .gh-input:focus,
.error .gh-input.focus {
border-color: color-mod(var(--red)) !important;
border-color: var(--red);
box-shadow: inset 0 0 0 1px var(--red);
}

View File

@ -184,9 +184,9 @@ button, .btn-base {
bottom: calc(100% + 4px);
left: 50%;
white-space: nowrap;
padding: 3px 7px;
border-radius: 3px;
background-color: var(--darkgrey);
padding: 4px 10px;
border-radius: 6px;
background-color: var(--black);
color: var(--white);
content: attr(data-tooltip);
text-align: center;

View File

@ -12,7 +12,7 @@
</header>
</div>
{{else}}
<form id="login" method="post" class="gh-signin" novalidate="novalidate" {{on "submit" (perform this.validateAndAuthenticateTask)}}>
<form id="login" class="gh-signin" novalidate="novalidate" {{on "submit" (perform this.validateAndAuthenticateTask)}}>
<header>
<div class="gh-site-icon" style={{site-icon-style}}></div>
<h1>{{this.config.blogTitle}}</h1>
@ -73,7 +73,7 @@
data-test-button="sign-in" />
</form>
<p class="main-error">{{if this.flowErrors this.flowErrors}}&nbsp;</p>
<p class="{{if this.flowErrors "main-error" "main-notification"}}" data-test-flow-notification>{{if this.flowErrors this.flowErrors this.flowNotification}}&nbsp;</p>
{{/if}}
</section>
</div>

View File

@ -7,7 +7,7 @@
<h1>Create your account.</h1>
</header>
<form id="signup" class="gh-signup" method="post" novalidate="novalidate" {{on "submit" this.submit}}>
<form id="signup" class="gh-signup" novalidate="novalidate" {{on "submit" this.submit}}>
<GhFormGroup @errors={{this.signupDetails.errors}} @hasValidated={{this.signupDetails.hasValidated}} @property="name">
<label for="name">Full name</label>
<span class="gh-input-icon gh-icon-user">

View File

@ -1,29 +0,0 @@
<section class="gh-canvas">
<GhCanvasHeader class="gh-canvas-header">
<div class="flex flex-column">
<h2 class="gh-canvas-title" data-test-screen-title>
Testing Websockets
</h2>
</div>
</GhCanvasHeader>
{{#if (feature 'websockets')}}
<section class="view-container settings-debug">
<p class="gh-box gh-box-tip">{{svg-jar "idea"}}This is a testing ground for new or experimental features. They
may change, break or inexplicably disappear at any time.</p>
<div class="gh-main-section">
<h4 class="gh-main-section-header small bn">Secrets</h4>
<div class="gh-expandable">
<div class="gh-expandable-block">
<Websockets />
</div>
</div>
</div>
</section>
{{else}}
<section class="view-container settings-debug">
<p class="gh-box gh-box-alert">{{svg-jar "warning-stroke"}}This is a testing ground for new or experimental features. You need developer experiments
enabled to see the content here.
</p>
</section>
{{/if}}
</section>

Some files were not shown because too many files have changed in this diff Show More