0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-18 02:21:47 -05:00

Merge branch 'main' into new-publish-flow-DES-594

This commit is contained in:
Sodbileg Gansukh 2024-08-01 17:12:08 +08:00 committed by GitHub
commit a5ff2b7822
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 1378 additions and 891 deletions

View file

@ -95,7 +95,7 @@ describe('ActivityPubAPI', function () {
expect(actual).toEqual(expected);
});
test('Returns an the items array when the inbox is not empty', async function () {
test('Returns all the items array when the inbox is not empty', async function () {
const fakeFetch = Fetch({
'https://auth.api/': {
response: JSONResponse({
@ -137,6 +137,49 @@ describe('ActivityPubAPI', function () {
expect(actual).toEqual(expected);
});
test('Returns an array when the items key is a single object', async function () {
const fakeFetch = Fetch({
'https://auth.api/': {
response: JSONResponse({
identities: [{
token: 'fake-token'
}]
})
},
'https://activitypub.api/.ghost/activitypub/inbox/index': {
response:
JSONResponse({
type: 'Collection',
items: {
type: 'Create',
object: {
type: 'Note'
}
}
})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
const actual = await api.getInbox();
const expected: Activity[] = [
{
type: 'Create',
object: {
type: 'Note'
}
}
];
expect(actual).toEqual(expected);
});
});
describe('getFollowing', function () {
@ -199,7 +242,7 @@ describe('ActivityPubAPI', function () {
expect(actual).toEqual(expected);
});
test('Returns an the items array when the following is not empty', async function () {
test('Returns all the items array when the following is not empty', async function () {
const fakeFetch = Fetch({
'https://auth.api/': {
response: JSONResponse({
@ -235,6 +278,43 @@ describe('ActivityPubAPI', function () {
expect(actual).toEqual(expected);
});
test('Returns an array when the items key is a single object', async function () {
const fakeFetch = Fetch({
'https://auth.api/': {
response: JSONResponse({
identities: [{
token: 'fake-token'
}]
})
},
'https://activitypub.api/.ghost/activitypub/following/index': {
response:
JSONResponse({
type: 'Collection',
items: {
type: 'Person'
}
})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
const actual = await api.getFollowing();
const expected: Activity[] = [
{
type: 'Person'
}
];
expect(actual).toEqual(expected);
});
});
describe('getFollowers', function () {
@ -297,7 +377,7 @@ describe('ActivityPubAPI', function () {
expect(actual).toEqual(expected);
});
test('Returns an the items array when the followers is not empty', async function () {
test('Returns all the items array when the followers is not empty', async function () {
const fakeFetch = Fetch({
'https://auth.api/': {
response: JSONResponse({
@ -333,6 +413,43 @@ describe('ActivityPubAPI', function () {
expect(actual).toEqual(expected);
});
test('Returns an array when the items key is a single object', async function () {
const fakeFetch = Fetch({
'https://auth.api/': {
response: JSONResponse({
identities: [{
token: 'fake-token'
}]
})
},
'https://activitypub.api/.ghost/activitypub/followers/index': {
response:
JSONResponse({
type: 'Collection',
items: {
type: 'Person'
}
})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
const actual = await api.getFollowers();
const expected: Activity[] = [
{
type: 'Person'
}
];
expect(actual).toEqual(expected);
});
});
describe('follow', function () {

View file

@ -45,7 +45,7 @@ export class ActivityPubAPI {
return [];
}
if ('items' in json) {
return Array.isArray(json?.items) ? json.items : [];
return Array.isArray(json.items) ? json.items : [json.items];
}
return [];
}
@ -60,7 +60,7 @@ export class ActivityPubAPI {
return [];
}
if ('items' in json) {
return Array.isArray(json?.items) ? json.items : [];
return Array.isArray(json.items) ? json.items : [json.items];
}
return [];
}
@ -86,7 +86,7 @@ export class ActivityPubAPI {
return [];
}
if ('items' in json) {
return Array.isArray(json?.items) ? json.items : [];
return Array.isArray(json.items) ? json.items : [json.items];
}
return [];
}

View file

@ -1,6 +1,7 @@
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 {ActivityPubAPI} from '../api/activitypub';
import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Avatar, Button, ButtonGroup, Heading, List, ListItem, Page, SelectOption, SettingValue, ViewContainer, ViewTab} from '@tryghost/admin-x-design-system';
@ -13,6 +14,13 @@ interface ViewArticleProps {
onBackToList: () => void;
}
type Activity = {
type: string,
object: {
type: string
}
}
function useBrowseInboxForUser(handle: string) {
const site = useBrowseSite();
const siteData = site.data?.site;
@ -84,59 +92,131 @@ const ActivityPubComponent: React.FC = () => {
setArticleContent(null);
};
const [selectedOption, setSelectedOption] = useState<SelectOption>({label: 'Inbox', value: 'inbox'});
const [selectedOption, setSelectedOption] = useState<SelectOption>({label: 'Feed', value: 'feed'});
const [selectedTab, setSelectedTab] = useState('inbox');
const inboxTabActivities = activities.filter((activity: Activity) => {
const isCreate = activity.type === 'Create' && ['Article', 'Note'].includes(activity.object.type);
const isAnnounce = activity.type === 'Announce' && activity.object.type === 'Note';
return isCreate || isAnnounce;
});
const activityTabActivities = activities.filter((activity: Activity) => activity.type === 'Create' && activity.object.type === 'Article');
const likeTabActivies = activities.filter((activity: Activity) => activity.type === 'Like');
const tabs: ViewTab[] = [
{
id: 'inbox',
title: 'Inbox',
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} 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}/>
contents: (
<div className='w-full'>
{inboxTabActivities.length > 0 ? (
<ul className='mx-auto flex max-w-[540px] flex-col py-8'>
{inboxTabActivities.reverse().map(activity => (
<li
key={activity.id}
data-test-view-article
onClick={() => handleViewContent(activity.object, activity.actor)}
>
<ObjectContentDisplay
actor={activity.actor}
layout={selectedOption.value}
object={activity.object}
/>
</li>
))}
</ul>
) : (
<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>
</div>}
</ul>
<Sidebar followersCount={followersCount} followingCount={followingCount} updateRoute={updateRoute} />
</div>
)}
</div>
)
},
{
id: 'activity',
title: 'Activity',
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?.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} />
</div>
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'>
{activityTabActivities.reverse().map(activity => (
<li
key={activity.id}
data-test-view-article
onClick={() => handleViewContent(activity.object, activity.actor)}
>
<ObjectContentDisplay
actor={activity.actor}
layout={selectedOption.value}
object={activity.object}
/>
</li>
))}
</ul>
</div>
)
},
{
id: 'likes',
title: 'Likes',
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} layout={selectedOption.value} object={activity.object} />
</li>
))}
</ul>
<Sidebar followersCount={followersCount} followingCount={followingCount} updateRoute={updateRoute} />
</div>
contents: (
<div className='grid grid-cols-6 items-start gap-8 pt-8'>
<List className='col-span-4'>
{likeTabActivies.reverse().map(activity => (
<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>
}
/>
))}
</List>
</div>
)
},
{
id: 'profile',
title: 'Profile',
contents: (
<div>
<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')}>
<span className='text-3xl font-bold leading-none' data-test-following-count>{followingCount}</span>
<span className='text-base leading-none text-grey-800 group-hover/stat:text-grey-900' data-test-following-modal>Following<span className='ml-1 opacity-0 transition-opacity group-hover/stat:opacity-100'>&rarr;</span></span>
</div>
<div className='group/stat flex cursor-pointer flex-col gap-1' onClick={() => updateRoute('/view-followers')}>
<span className='text-3xl font-bold leading-none' data-test-following-count>{followersCount}</span>
<span className='text-base leading-none text-grey-800 group-hover/stat:text-grey-900' data-test-followers-modal>Followers<span className='ml-1 opacity-0 transition-opacity group-hover/stat:opacity-100'>&rarr;</span></span>
</div>
</div>
</div>
</div>
)
}
];
@ -148,18 +228,18 @@ const ActivityPubComponent: React.FC = () => {
{
icon: 'listview',
size: 'sm',
iconColorClass: selectedOption.value === 'inbox' ? 'text-black' : 'text-grey-500',
iconColorClass: selectedOption.value === 'feed' ? 'text-black' : 'text-grey-500',
onClick: () => {
setSelectedOption({label: 'Inbox', value: 'inbox'});
setSelectedOption({label: 'Feed', value: 'feed'});
}
},
{
icon: 'cardview',
size: 'sm',
iconColorClass: selectedOption.value === 'feed' ? 'text-black' : 'text-grey-500',
iconColorClass: selectedOption.value === 'inbox' ? 'text-black' : 'text-grey-500',
onClick: () => {
setSelectedOption({label: 'Feed', value: 'feed'});
setSelectedOption({label: 'Inbox', value: 'inbox'});
}
}
]} clearBg={true} link outlineOnMobile />]}
@ -176,9 +256,9 @@ const ActivityPubComponent: React.FC = () => {
tabs={tabs}
title='ActivityPub'
toolbarBorder={true}
type='page'
type='page'
onTabChange={setSelectedTab}
>
>
</ViewContainer>
) : (
@ -189,35 +269,6 @@ 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 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')}>
<span className='text-3xl font-bold leading-none' data-test-following-count>{followingCount}</span>
<span className='text-base leading-none text-grey-800 group-hover/stat:text-grey-900' data-test-following-modal>Following<span className='ml-1 opacity-0 transition-opacity group-hover/stat:opacity-100'>&rarr;</span></span>
</div>
<div className='group/stat flex cursor-pointer flex-col gap-1' onClick={() => updateRoute('/view-followers')}>
<span className='text-3xl font-bold leading-none' data-test-following-count>{followersCount}</span>
<span className='text-base leading-none text-grey-800 group-hover/stat:text-grey-900' data-test-followers-modal>Followers<span className='ml-1 opacity-0 transition-opacity group-hover/stat:opacity-100'>&rarr;</span></span>
</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 site = useBrowseSite();
const siteData = site.data?.site;
@ -274,12 +325,66 @@ const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProp
const doc = parser.parseFromString(object.content || '', 'text/html');
const plainTextContent = doc.body.textContent;
let previewContent = '';
if (object.preview) {
const previewDoc = parser.parseFromString(object.preview.content || '', 'text/html');
previewContent = previewDoc.body.textContent || '';
} else if (object.type === 'Note') {
previewContent = plainTextContent || '';
}
const renderAttachment = () => {
let attachment;
if (object.image) {
attachment = object.image;
}
if (object.type === 'Note' && !attachment) {
attachment = object.attachment;
}
// const attachment = object.attachment;
if (!attachment) {
return null;
}
if (Array.isArray(attachment)) {
return (
<div className="attachment-gallery mt-2 grid auto-rows-[150px] grid-cols-2 gap-2">
{attachment.map((item, index) => (
<img key={item.url} alt={`attachment-${index}`} className='h-full w-full rounded-md object-cover' src={item.url} />
))}
</div>
);
}
switch (attachment.mediaType) {
case 'image/jpeg':
case 'image/png':
case 'image/gif':
return <img alt="attachment" className='mt-2 rounded-md outline outline-1 -outline-offset-1 outline-black/10' src={attachment.url} />;
case 'video/mp4':
case 'video/webm':
return <div className='relative mb-4 mt-2'>
<video className='h-[300px] w-full rounded object-cover' src={attachment.url} controls/>
</div>;
case 'audio/mpeg':
case 'audio/ogg':
return <div className='relative mb-4 mt-2 w-full'>
<audio className='w-full' src={attachment.url} controls/>
</div>;
default:
return null;
}
};
const timestamp =
new Date(object?.published ?? new Date()).toLocaleDateString('default', {year: 'numeric', month: 'short', day: '2-digit'}) + ', ' + new Date(object?.published ?? new Date()).toLocaleTimeString('default', {hour: '2-digit', minute: '2-digit'});
const [isClicked, setIsClicked] = useState(false);
const [isLiked, setIsLiked] = useState(false);
const handleLikeClick = (event: React.MouseEvent<HTMLElement> | undefined) => {
event?.stopPropagation();
setIsClicked(true);
@ -291,34 +396,28 @@ const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProp
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 className='group/article relative flex cursor-pointer items-start gap-4 pt-4'>
<img className='z-10 w-9 rounded' src={actor.icon?.url}/>
<div className='border-1 z-10 -mt-1 flex flex-col items-start justify-between border-b border-b-grey-200 pb-4' data-test-activity>
<div className='relative z-10 flex w-full overflow-visible text-[1.5rem]'>
<p className='mr-1 truncate whitespace-nowrap font-bold' data-test-activity-heading>{actor.name}</p>
<span className='truncate text-grey-700'>{getUsername(actor)}</span>
<span className='whitespace-nowrap text-grey-700 before:mx-1 before:content-["·"]'>{timestamp}</span>
</div>
<div className='relative z-10 w-full gap-4'>
<div className='flex flex-col'>
{object.name && <Heading className='mb-1 leading-tight' level={4} data-test-activity-heading>{object.name}</Heading>}
<p className='text-pretty text-[1.5rem] text-grey-900'>{plainTextContent}</p>
{/* <p className='text-pretty text-md text-grey-900'>{object.content}</p> */}
{renderAttachment()}
<div className='mt-3 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>
<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 className='absolute -inset-x-3 -inset-y-0 z-0 rounded transition-colors group-hover/article:bg-grey-100'></div>
</div>
)}
</>
@ -339,15 +438,15 @@ const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProp
<div className='flex w-full justify-between gap-4'>
<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-pretty text-md text-grey-800'>{object.preview?.content}</p>
<p className='mb-6 line-clamp-2 max-w-prose text-pretty text-md text-grey-800'>{previewContent}</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' height='140px' src={object.image} width='170px'/>
</div>}
{/* {image && <div className='relative min-w-[33%] grow'>
<img className='absolute h-full w-full rounded object-cover' height='140px' src={image} width='170px'/>
</div>} */}
</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> */}
@ -363,7 +462,7 @@ const ViewArticle: React.FC<ViewArticleProps> = ({object, onBackToList}) => {
const [isClicked, setIsClicked] = useState(false);
const [isLiked, setIsLiked] = useState(false);
const handleLikeClick = (event: React.MouseEvent<HTMLElement> | undefined) => {
event?.stopPropagation();
setIsClicked(true);
@ -381,7 +480,7 @@ const ViewArticle: React.FC<ViewArticleProps> = ({object, onBackToList}) => {
<div>
<Button icon='chevron-left' iconSize='xs' label='Inbox' data-test-back-button onClick={onBackToList}/>
</div>
<div className='flex items-center justify-between'>
<div className='flex items-center justify-between'>
</div>
<div className='flex items-center justify-end gap-2'>
<div className='flex flex-row-reverse items-center gap-3'>

View file

@ -71,7 +71,7 @@
"react-colorful": "5.6.1",
"react-hot-toast": "2.4.1",
"react-select": "5.8.0",
"tailwindcss": "3.4.5"
"tailwindcss": "3.4.7"
},
"peerDependencies": {
"react": "^18.2.0",

View file

@ -17,7 +17,7 @@ const DesignSystemApp: React.FC<DesignSystemAppProps> = ({darkMode, fetchKoenigL
return (
<div className={appClassName} {...props}>
<DesignSystemProvider fetchKoenigLexical={fetchKoenigLexical}>
<DesignSystemProvider darkMode={darkMode} fetchKoenigLexical={fetchKoenigLexical}>
{children}
</DesignSystemProvider>
</div>

View file

@ -13,6 +13,7 @@ export interface HtmlEditorProps {
placeholder?: string
nodes?: 'DEFAULT_NODES' | 'BASIC_NODES' | 'MINIMAL_NODES'
emojiPicker?: boolean;
darkMode?: boolean;
}
declare global {
@ -61,7 +62,8 @@ const KoenigWrapper: React.FC<HtmlEditorProps & { editor: EditorResource }> = ({
onBlur,
placeholder,
nodes,
emojiPicker = true
emojiPicker = true,
darkMode = false
}) => {
const onError = useCallback((error: unknown) => {
try {
@ -128,12 +130,12 @@ const KoenigWrapper: React.FC<HtmlEditorProps & { editor: EditorResource }> = ({
return (
<koenig.KoenigComposer
darkMode={darkMode}
nodes={koenig[nodes || 'DEFAULT_NODES']}
onError={onError}
>
<koenig.KoenigComposableEditor
className='koenig-lexical koenig-lexical-editor-input'
darkMode={false}
isSnippetsEnabled={false}
markdownTransformers={transformers[nodes || 'DEFAULT_NODES']}
placeholderClassName='koenig-lexical-editor-input-placeholder line-clamp-1'
@ -155,14 +157,14 @@ const HtmlEditor: React.FC<HtmlEditorProps & {
className,
...props
}) => {
const {fetchKoenigLexical} = useDesignSystem();
const {fetchKoenigLexical, darkMode} = useDesignSystem();
const editorResource = useMemo(() => loadKoenig(fetchKoenigLexical), [fetchKoenigLexical]);
return <div className={className || 'w-full'}>
<div className="koenig-react-editor w-full [&_*]:!font-inherit [&_*]:!text-inherit">
<ErrorBoundary name='editor'>
<Suspense fallback={<p className="koenig-react-editor-loading">Loading editor...</p>}>
<KoenigWrapper {...props} editor={editorResource} />
<KoenigWrapper {...props} darkMode={darkMode} editor={editorResource} />
</Suspense>
</ErrorBoundary>
</div>

View file

@ -9,12 +9,14 @@ interface DesignSystemContextType {
isAnyTextFieldFocused: boolean;
setFocusState: (value: boolean) => void;
fetchKoenigLexical: FetchKoenigLexical;
darkMode: boolean;
}
const DesignSystemContext = createContext<DesignSystemContextType>({
isAnyTextFieldFocused: false,
setFocusState: () => {},
fetchKoenigLexical: async () => {}
fetchKoenigLexical: async () => {},
darkMode: false
});
export const useDesignSystem = () => useContext(DesignSystemContext);
@ -29,10 +31,11 @@ export const useFocusContext = () => {
interface DesignSystemProviderProps {
fetchKoenigLexical: FetchKoenigLexical;
darkMode: boolean;
children: React.ReactNode;
}
const DesignSystemProvider: React.FC<DesignSystemProviderProps> = ({fetchKoenigLexical, children}) => {
const DesignSystemProvider: React.FC<DesignSystemProviderProps> = ({fetchKoenigLexical, darkMode, children}) => {
const [isAnyTextFieldFocused, setIsAnyTextFieldFocused] = useState(false);
const setFocusState = (value: boolean) => {
@ -40,7 +43,7 @@ const DesignSystemProvider: React.FC<DesignSystemProviderProps> = ({fetchKoenigL
};
return (
<DesignSystemContext.Provider value={{isAnyTextFieldFocused, setFocusState, fetchKoenigLexical}}>
<DesignSystemContext.Provider value={{isAnyTextFieldFocused, setFocusState, fetchKoenigLexical, darkMode}}>
<GlobalDirtyStateProvider>
<Toaster />
<NiceModal.Provider>

View file

@ -9,7 +9,7 @@ export type FollowItem = {
export type ObjectProperties = {
'@context': string | (string | object)[];
type: 'Article' | 'Link';
type: 'Article' | 'Link' | 'Note';
name: string;
content: string;
url?: string | undefined;

View file

@ -1,4 +1,5 @@
import MainContent from './MainContent';
import NiceModal from '@ebay/nice-modal-react';
import SettingsAppProvider, {OfficialTheme, UpgradeStatusType} from './components/providers/SettingsAppProvider';
import SettingsRouter, {loadModals, modalPaths} from './components/providers/SettingsRouter';
import {DesignSystemApp, DesignSystemAppProps} from '@tryghost/admin-x-design-system';
@ -18,12 +19,17 @@ function App({framework, designSystem, officialThemes, zapierTemplates, upgradeS
return (
<FrameworkProvider {...framework}>
<SettingsAppProvider officialThemes={officialThemes} upgradeStatus={upgradeStatus} zapierTemplates={zapierTemplates}>
<RoutingProvider basePath='settings' modals={{paths: modalPaths, load: loadModals}}>
<DesignSystemApp className='admin-x-settings' {...designSystem}>
<SettingsRouter />
<MainContent />
</DesignSystemApp>
</RoutingProvider>
{/* NOTE: we need to have an extra NiceModal.Provider here because the one inside DesignSystemApp
is loaded too late for possible modals in RoutingProvider, and it's quite hard to change it at
this point */}
<NiceModal.Provider>
<RoutingProvider basePath='settings' modals={{paths: modalPaths, load: loadModals}}>
<DesignSystemApp className='admin-x-settings' {...designSystem}>
<SettingsRouter />
<MainContent />
</DesignSystemApp>
</RoutingProvider>
</NiceModal.Provider>
</SettingsAppProvider>
</FrameworkProvider>
);

View file

@ -88,7 +88,7 @@ const TipsAndDonations: React.FC<{ keywords: string[] }> = ({keywords}) => {
const inputFields = (
<SettingGroupContent columns={1}>
<div className='flex max-w-[220px] items-end gap-[.6rem]'>
<div className='flex max-w-[180px] items-end gap-[.6rem]'>
<CurrencyField
error={!!errors.donationsSuggestedAmount}
hint={errors.donationsSuggestedAmount}
@ -132,7 +132,7 @@ const TipsAndDonations: React.FC<{ keywords: string[] }> = ({keywords}) => {
return (
<TopLevelGroup
description="Give your audience a one-time way to support your work, no membership required."
description="Give your audience a simple way to support your work with one-time payments."
isEditing={isEditing}
keywords={keywords}
navid='tips-and-donations'

View file

@ -44,16 +44,16 @@
},
"dependencies": {
"@headlessui/react": "1.7.19",
"@tiptap/core": "2.5.7",
"@tiptap/extension-blockquote": "2.5.7",
"@tiptap/extension-document": "2.5.7",
"@tiptap/extension-hard-break": "2.5.7",
"@tiptap/extension-link": "2.5.7",
"@tiptap/extension-paragraph": "2.5.7",
"@tiptap/extension-placeholder": "2.5.7",
"@tiptap/extension-text": "2.5.7",
"@tiptap/pm": "2.5.7",
"@tiptap/react": "2.5.7",
"@tiptap/core": "2.5.8",
"@tiptap/extension-blockquote": "2.5.8",
"@tiptap/extension-document": "2.5.8",
"@tiptap/extension-hard-break": "2.5.8",
"@tiptap/extension-link": "2.5.8",
"@tiptap/extension-paragraph": "2.5.8",
"@tiptap/extension-placeholder": "2.5.8",
"@tiptap/extension-text": "2.5.8",
"@tiptap/pm": "2.5.8",
"@tiptap/react": "2.5.8",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-string-replace": "1.1.1"
@ -75,7 +75,7 @@
"eslint-plugin-tailwindcss": "3.13.0",
"jsdom": "24.1.1",
"postcss": "8.4.39",
"tailwindcss": "3.4.5",
"tailwindcss": "3.4.7",
"vite": "4.5.3",
"vite-plugin-css-injected-by-js": "3.3.0",
"vite-plugin-svgr": "3.3.0",

View file

@ -9,7 +9,10 @@ export default function AccountEmailPage() {
useEffect(() => {
if (!member) {
onAction('switchPage', {
page: 'signin'
page: 'signin',
pageData: {
redirect: window.location.href // This includes the search/fragment of the URL (#/portal/account) which is missing from the default referer header
}
});
}
}, [member, onAction]);

View file

@ -112,6 +112,6 @@ describe('Account Email Page', () => {
newsletters: newsletterData
});
const {mockOnActionFn} = setup({site: siteData, member: null});
expect(mockOnActionFn).toHaveBeenCalledWith('switchPage', {page: 'signin'});
expect(mockOnActionFn).toHaveBeenCalledWith('switchPage', {page: 'signin', pageData: {redirect: window.location.href}});
});
});

View file

@ -254,33 +254,4 @@ describe('Newsletter Subscriptions', () => {
expect(newsletter2Toggle).toHaveClass('gh-portal-toggle-checked');
});
});
// describe('navigating straight to /portal/account/newsletters', () => {
// it('shows the newsletter management page when signed in', async () => {
// const {popupFrame, triggerButton, queryAllByText, popupIframeDocument} = await setup({
// site: FixtureSite.singleTier.onlyFreePlanWithoutStripe,
// member: FixtureMember.subbedToNewsletter,
// newsletters: Newsletters
// });
// const manageSubscriptionsButton = within(popupIframeDocument).queryByRole('button', {name: 'Manage'});
// await userEvent.click(manageSubscriptionsButton);
// const newsletter1 = within(popupIframeDocument).queryAllByText('Newsletter 1');
// expect(newsletter1).toBeInTheDocument();
// });
// it('redirects to the sign in page when not signed in', async () => {
// const {popupFrame, queryByTitle, popupIframeDocument} = await setup({
// site: FixtureSite.singleTier.onlyFreePlanWithoutStripe,
// member: FixtureMember.subbedToNewsletter,
// newsletters: Newsletters
// }, true);
// // console.log(`popupFrame`, popupFrame);
// // console.log(`queryByTitle`, queryByTitle);
// // console.log(`popupIframeDocument`, popupIframeDocument);
// });
// });
});

View file

@ -65,7 +65,7 @@
"rollup-plugin-node-builtins": "2.1.2",
"storybook": "7.6.20",
"stylelint": "15.10.3",
"tailwindcss": "3.4.5",
"tailwindcss": "3.4.7",
"vite": "4.5.3",
"vite-plugin-commonjs": "0.10.1",
"vite-plugin-svgr": "3.3.0",

View file

@ -1,5 +1,5 @@
import Component from '@glimmer/component';
import SelectionList from '../utils/selection-list';
import SelectionList from './posts-list/selection-list';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';

View file

@ -320,7 +320,7 @@ export default class KoenigLexicalEditor extends Component {
const donationLink = () => {
if (this.feature.tipsAndDonations && this.settings.donationsEnabled) {
return [{
label: 'Tip or donation',
label: 'Tips and donations',
value: '#/portal/support'
}];
}

View file

@ -216,11 +216,14 @@ export default class PostsContextMenu extends Component {
yield this.performBulkDestroy();
this.notifications.showNotification(this.#getToastMessage('deleted'), {type: 'success'});
const remainingModels = this.selectionList.infinityModel.content.filter((model) => {
return !deletedModels.includes(model);
});
// Deleteobjects method from infintiymodel is broken for all models except the first page, so we cannot use this
this.infinity.replace(this.selectionList.infinityModel, remainingModels);
for (const key in this.selectionList.infinityModel) {
const remainingModels = this.selectionList.infinityModel[key].content.filter((model) => {
return !deletedModels.includes(model);
});
// Deleteobjects method from infintiymodel is broken for all models except the first page, so we cannot use this
this.infinity.replace(this.selectionList.infinityModel[key], remainingModels);
}
this.selectionList.clearSelection({force: true});
return true;
}
@ -247,9 +250,7 @@ export default class PostsContextMenu extends Component {
}
}
// Remove posts that no longer match the filter
this.updateFilteredPosts();
return true;
}
@ -282,14 +283,16 @@ export default class PostsContextMenu extends Component {
]
});
const remainingModels = this.selectionList.infinityModel.content.filter((model) => {
if (!updatedModels.find(u => u.id === model.id)) {
return true;
}
return filterNql.queryJSON(model.serialize({includeId: true}));
});
// Deleteobjects method from infintiymodel is broken for all models except the first page, so we cannot use this
this.infinity.replace(this.selectionList.infinityModel, remainingModels);
for (const key in this.selectionList.infinityModel) {
const remainingModels = this.selectionList.infinityModel[key].content.filter((model) => {
if (!updatedModels.find(u => u.id === model.id)) {
return true;
}
return filterNql.queryJSON(model.serialize({includeId: true}));
});
// Deleteobjects method from infintiymodel is broken for all models except the first page, so we cannot use this
this.infinity.replace(this.selectionList.infinityModel[key], remainingModels);
}
this.selectionList.clearUnavailableItems();
}
@ -386,8 +389,10 @@ export default class PostsContextMenu extends Component {
const data = result[this.type === 'post' ? 'posts' : 'pages'][0];
const model = this.store.peekRecord(this.type, data.id);
// Update infinity list
this.selectionList.infinityModel.content.unshiftObject(model);
// Update infinity draft posts content - copied posts are always drafts
if (this.selectionList.infinityModel.draftInfinityModel) {
this.selectionList.infinityModel.draftInfinityModel.content.unshiftObject(model);
}
// Show notification
this.notifications.showNotification(this.#getToastMessage('duplicated'), {type: 'success'});

View file

@ -1,14 +1,39 @@
<MultiList::List @model={{@list}} class="posts-list gh-list {{unless @model "no-posts"}} feature-memberAttribution" as |list| >
{{#each @model as |post|}}
<list.item @id={{post.id}} class="gh-posts-list-item-group">
<PostsList::ListItem
@post={{post}}
data-test-post-id={{post.id}}
/>
</list.item>
{{!-- always order as scheduled, draft, remainder --}}
{{#if (or @model.scheduledInfinityModel (or @model.draftInfinityModel @model.publishedAndSentInfinityModel))}}
{{#if @model.scheduledInfinityModel}}
{{#each @model.scheduledInfinityModel as |post|}}
<list.item @id={{post.id}} class="gh-posts-list-item-group">
<PostsList::ListItem
@post={{post}}
data-test-post-id={{post.id}}
/>
</list.item>
{{/each}}
{{/if}}
{{#if (and @model.draftInfinityModel (or (not @model.scheduledInfinityModel) (and @model.scheduledInfinityModel @model.scheduledInfinityModel.reachedInfinity)))}}
{{#each @model.draftInfinityModel as |post|}}
<list.item @id={{post.id}} class="gh-posts-list-item-group">
<PostsList::ListItem
@post={{post}}
data-test-post-id={{post.id}}
/>
</list.item>
{{/each}}
{{/if}}
{{#if (and @model.publishedAndSentInfinityModel (and (or (not @model.scheduledInfinityModel) @model.scheduledInfinityModel.reachedInfinity) (or (not @model.draftInfinityModel) @model.draftInfinityModel.reachedInfinity)))}}
{{#each @model.publishedAndSentInfinityModel as |post|}}
<list.item @id={{post.id}} class="gh-posts-list-item-group">
<PostsList::ListItem
@post={{post}}
data-test-post-id={{post.id}}
/>
</list.item>
{{/each}}
{{/if}}
{{else}}
{{yield}}
{{/each}}
{{/if}}
</MultiList::List>
{{!-- The currently selected item or items are passed to the context menu --}}

View file

@ -18,7 +18,11 @@ export default class SelectionList {
#clearOnNextUnfreeze = false;
constructor(infinityModel) {
this.infinityModel = infinityModel ?? {content: []};
this.infinityModel = infinityModel ?? {
draftInfinityModel: {
content: []
}
};
}
freeze() {
@ -41,7 +45,12 @@ export default class SelectionList {
* Returns an NQL filter for all items, not the selection
*/
get allFilter() {
return this.infinityModel.extraParams?.filter ?? '';
const models = this.infinityModel;
// grab filter from the first key in the infinityModel object (they should all be identical)
for (const key in models) {
return models[key].extraParams?.allFilter ?? '';
}
return '';
}
/**
@ -81,10 +90,13 @@ export default class SelectionList {
* Keep in mind that when using CMD + A, we don't have all items in memory!
*/
get availableModels() {
const models = this.infinityModel;
const arr = [];
for (const item of this.infinityModel.content) {
if (this.isSelected(item.id)) {
arr.push(item);
for (const key in models) {
for (const item of models[key].content) {
if (this.isSelected(item.id)) {
arr.push(item);
}
}
}
return arr;
@ -102,7 +114,13 @@ export default class SelectionList {
if (!this.inverted) {
return this.selectedIds.size;
}
return Math.max((this.infinityModel.meta?.pagination?.total ?? 0) - this.selectedIds.size, 1);
const models = this.infinityModel;
let total;
for (const key in models) {
total += models[key].meta?.pagination?.total;
}
return Math.max((total ?? 0) - this.selectedIds.size, 1);
}
isSelected(id) {
@ -147,9 +165,12 @@ export default class SelectionList {
clearUnavailableItems() {
const newSelection = new Set();
for (const item of this.infinityModel.content) {
if (this.selectedIds.has(item.id)) {
newSelection.add(item.id);
const models = this.infinityModel;
for (const key in models) {
for (const item of models[key].content) {
if (this.selectedIds.has(item.id)) {
newSelection.add(item.id);
}
}
}
this.selectedIds = newSelection;
@ -181,37 +202,40 @@ export default class SelectionList {
// todo
let running = false;
for (const item of this.infinityModel.content) {
// Exlusing the last selected item
if (item.id === this.lastSelectedId || item.id === id) {
if (!running) {
running = true;
const models = this.infinityModel;
for (const key in models) {
for (const item of this.models[key].content) {
// Exlusing the last selected item
if (item.id === this.lastSelectedId || item.id === id) {
if (!running) {
running = true;
// Skip last selected on its own
if (item.id === this.lastSelectedId) {
continue;
}
} else {
// Still include id
if (item.id === id) {
this.lastShiftSelectionGroup.add(item.id);
if (this.inverted) {
this.selectedIds.delete(item.id);
} else {
this.selectedIds.add(item.id);
// Skip last selected on its own
if (item.id === this.lastSelectedId) {
continue;
}
}
break;
}
}
} else {
// Still include id
if (item.id === id) {
this.lastShiftSelectionGroup.add(item.id);
if (running) {
this.lastShiftSelectionGroup.add(item.id);
if (this.inverted) {
this.selectedIds.delete(item.id);
} else {
this.selectedIds.add(item.id);
if (this.inverted) {
this.selectedIds.delete(item.id);
} else {
this.selectedIds.add(item.id);
}
}
break;
}
}
if (running) {
this.lastShiftSelectionGroup.add(item.id);
if (this.inverted) {
this.selectedIds.delete(item.id);
} else {
this.selectedIds.add(item.id);
}
}
}
}

View file

@ -871,11 +871,11 @@ export default class LexicalEditorController extends Controller {
this.ui.updateDocumentTitle();
}
/*
/*
// sync the post slug with the post title, except when:
// - the user has already typed a custom slug, which should not be overwritten
// - the post has been published, so that published URLs are not broken
*/
*/
@enqueueTask
*generateSlugTask() {
const currentTitle = this.get('post.title');
@ -916,7 +916,7 @@ export default class LexicalEditorController extends Controller {
*backgroundLoaderTask() {
yield this.store.query('snippet', {limit: 'all'});
if (this.post.displayName === 'page' && this.feature.get('collections') && this.feature.get('collectionsCard')) {
if (this.post?.displayName === 'page' && this.feature.get('collections') && this.feature.get('collectionsCard')) {
yield this.store.query('collection', {limit: 'all'});
}

View file

@ -209,6 +209,10 @@ export default class MembersController extends Controller {
return uniqueColumns.splice(0, 2); // Maximum 2 columns
}
get isMultiFiltered() {
return this.isFiltered && this.filters.length >= 2;
}
includeTierQuery() {
const availableFilters = this.filters.length ? this.filters : this.softFilters;
return availableFilters.some((f) => {

View file

@ -40,4 +40,4 @@ export default class PagesController extends PostsController {
openEditor(page) {
this.router.transitionTo('lexical-editor.edit', 'page', page.get('id'));
}
}
}

View file

@ -1,5 +1,5 @@
import Controller from '@ember/controller';
import SelectionList from 'ghost-admin/utils/selection-list';
import SelectionList from 'ghost-admin/components/posts-list/selection-list';
import {DEFAULT_QUERY_PARAMS} from 'ghost-admin/helpers/reset-query-params';
import {action} from '@ember/object';
import {inject} from 'ghost-admin/decorators/inject';
@ -109,14 +109,6 @@ export default class PostsController extends Controller {
}
}
get postsInfinityModel() {
return this.model;
}
get totalPosts() {
return this.model.meta?.pagination?.total ?? 0;
}
get showingAll() {
const {type, author, tag, visibility} = this;

View file

@ -1,4 +1,5 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import RSVP from 'rsvp';
import {action} from '@ember/object';
import {assign} from '@ember/polyfills';
import {isBlank} from '@ember/utils';
@ -39,43 +40,54 @@ export default class PostsRoute extends AuthenticatedRoute {
model(params) {
const user = this.session.user;
let queryParams = {};
let filterParams = {tag: params.tag, visibility: params.visibility};
let paginationParams = {
perPageParam: 'limit',
totalPagesParam: 'meta.pagination.pages'
};
// type filters are actually mapping statuses
assign(filterParams, this._getTypeFilters(params.type));
if (params.type === 'featured') {
filterParams.featured = true;
}
// authors and contributors can only view their own posts
if (user.isAuthor) {
// authors can only view their own posts
filterParams.authors = user.slug;
} else if (user.isContributor) {
// Contributors can only view their own draft posts
filterParams.authors = user.slug;
// filterParams.status = 'draft';
// otherwise we need to filter by author if present
} else if (params.author) {
filterParams.authors = params.author;
}
let filter = this._filterString(filterParams);
if (!isBlank(filter)) {
queryParams.filter = filter;
}
if (!isBlank(params.order)) {
queryParams.order = params.order;
}
let perPage = this.perPage;
let paginationSettings = assign({perPage, startingPage: 1}, paginationParams, queryParams);
const filterStatuses = filterParams.status;
let queryParams = {allFilter: this._filterString({...filterParams})}; // pass along the parent filter so it's easier to apply the params filter to each infinity model
let models = {};
return this.infinity.model(this.modelName, paginationSettings);
if (filterStatuses.includes('scheduled')) {
let scheduledInfinityModelParams = {...queryParams, order: params.order || 'published_at desc', filter: this._filterString({...filterParams, status: 'scheduled'})};
models.scheduledInfinityModel = this.infinity.model(this.modelName, assign({perPage, startingPage: 1}, paginationParams, scheduledInfinityModelParams));
}
if (filterStatuses.includes('draft')) {
let draftInfinityModelParams = {...queryParams, order: params.order || 'updated_at desc', filter: this._filterString({...filterParams, status: 'draft'})};
models.draftInfinityModel = this.infinity.model(this.modelName, assign({perPage, startingPage: 1}, paginationParams, draftInfinityModelParams));
}
if (filterStatuses.includes('published') || filterStatuses.includes('sent')) {
let publishedAndSentInfinityModelParams;
if (filterStatuses.includes('published') && filterStatuses.includes('sent')) {
publishedAndSentInfinityModelParams = {...queryParams, order: params.order || 'published_at desc', filter: this._filterString({...filterParams, status: '[published,sent]'})};
} else {
publishedAndSentInfinityModelParams = {...queryParams, order: params.order || 'published_at desc', filter: this._filterString({...filterParams, status: filterStatuses.includes('published') ? 'published' : 'sent'})};
}
models.publishedAndSentInfinityModel = this.infinity.model(this.modelName, assign({perPage, startingPage: 1}, paginationParams, publishedAndSentInfinityModelParams));
}
return RSVP.hash(models);
}
// trigger a background load of all tags and authors for use in filter dropdowns
@ -120,6 +132,12 @@ export default class PostsRoute extends AuthenticatedRoute {
};
}
/**
* Returns an object containing the status filter based on the given type.
*
* @param {string} type - The type of filter to generate (draft, published, scheduled, sent).
* @returns {Object} - An object containing the status filter.
*/
_getTypeFilters(type) {
let status = '[draft,scheduled,published,sent]';

View file

@ -104,12 +104,14 @@
</button>
</li>
{{/if}}
<li class="divider"></li>
<li>
<button class="mr2" data-test-button="delete-selected" type="button" {{on "click" this.bulkDelete}}>
<span class="red">Delete selected members ({{this.members.length}})</span>
</button>
</li>
{{#unless this.isMultiFiltered}}
<li class="divider"></li>
<li>
<button class="mr2" data-test-button="delete-selected" type="button" {{on "click" this.bulkDelete}}>
<span class="red">Delete selected members ({{this.members.length}})</span>
</button>
</li>
{{/unless}}
{{/if}}
</GhDropdown>
</span>

View file

@ -28,7 +28,7 @@
<section class="view-container content-list">
<PostsList::List
@model={{this.postsInfinityModel}}
@model={{@model}}
@list={{this.selectionList}}
>
<li class="no-posts-box">
@ -41,7 +41,7 @@
</LinkTo>
{{else}}
<h4>No pages match the current filter</h4>
<LinkTo @route="pages" @query={{hash type=null author=null tag=null}} class="gh-btn">
<LinkTo @route="pages" @query={{hash visibility=null type=null author=null tag=null}} class="gh-btn">
<span>Show all pages</span>
</LinkTo>
{{/if}}
@ -49,11 +49,26 @@
</li>
</PostsList::List>
{{!-- only show one infinity loader wheel at a time - always order as scheduled, draft, remainder --}}
{{#if @model.scheduledInfinityModel}}
<GhInfinityLoader
@infinityModel={{this.postsInfinityModel}}
@infinityModel={{@model.scheduledInfinityModel}}
@scrollable=".gh-main"
@triggerOffset={{1000}} />
</section>
{{/if}}
{{#if (and @model.draftInfinityModel (or (not @model.scheduledInfinityModel) (and @model.scheduledInfinityModel @model.scheduledInfinityModel.reachedInfinity)))}}
<GhInfinityLoader
@infinityModel={{@model.draftInfinityModel}}
@scrollable=".gh-main"
@triggerOffset={{1000}} />
{{/if}}
{{#if (and @model.publishedAndSentInfinityModel (and (or (not @model.scheduledInfinityModel) @model.scheduledInfinityModel.reachedInfinity) (or (not @model.draftInfinityModel) @model.draftInfinityModel.reachedInfinity)))}}
<GhInfinityLoader
@infinityModel={{@model.publishedAndSentInfinityModel}}
@scrollable=".gh-main"
@triggerOffset={{1000}} />
{{/if}}
</section>
{{outlet}}
</section>

View file

@ -30,7 +30,7 @@
<section class="view-container content-list">
<PostsList::List
@model={{this.postsInfinityModel}}
@model={{@model}}
@list={{this.selectionList}}
>
<li class="no-posts-box" data-test-no-posts-box>
@ -43,7 +43,7 @@
</LinkTo>
{{else}}
<h4>No posts match the current filter</h4>
<LinkTo @route="posts" @query={{hash type=null author=null tag=null}} class="gh-btn" data-test-link="show-all">
<LinkTo @route="posts" @query={{hash type=null author=null tag=null visibility=null}} class="gh-btn" data-test-link="show-all">
<span>Show all posts</span>
</LinkTo>
{{/if}}
@ -51,12 +51,27 @@
</li>
</PostsList::List>
{{!-- only show one infinity loader wheel at a time - always order as scheduled, draft, remainder --}}
{{#if @model.scheduledInfinityModel}}
<GhInfinityLoader
@infinityModel={{this.postsInfinityModel}}
@infinityModel={{@model.scheduledInfinityModel}}
@scrollable=".gh-main"
@triggerOffset={{1000}} />
</section>
{{/if}}
{{#if (and @model.draftInfinityModel (or (not @model.scheduledInfinityModel) (and @model.scheduledInfinityModel @model.scheduledInfinityModel.reachedInfinity)))}}
<GhInfinityLoader
@infinityModel={{@model.draftInfinityModel}}
@scrollable=".gh-main"
@triggerOffset={{1000}} />
{{/if}}
{{#if (and @model.publishedAndSentInfinityModel (and (or (not @model.scheduledInfinityModel) @model.scheduledInfinityModel.reachedInfinity) (or (not @model.draftInfinityModel) @model.draftInfinityModel.reachedInfinity)))}}
<GhInfinityLoader
@infinityModel={{@model.publishedAndSentInfinityModel}}
@scrollable=".gh-main"
@triggerOffset={{1000}} />
{{/if}}
</section>
{{outlet}}
</section>

View file

@ -37,7 +37,6 @@ export default function mockPages(server) {
return pages.create(attrs);
});
// TODO: handle authors filter
server.get('/pages/', function ({pages}, {queryParams}) {
let {filter, page, limit} = queryParams;

View file

@ -23,7 +23,6 @@ function extractTags(postAttrs, tags) {
});
}
// TODO: handle authors filter
export function getPosts({posts}, {queryParams}) {
let {filter, page, limit} = queryParams;
@ -31,15 +30,27 @@ export function getPosts({posts}, {queryParams}) {
limit = +limit || 15;
let statusFilter = extractFilterParam('status', filter);
let authorsFilter = extractFilterParam('authors', filter);
let visibilityFilter = extractFilterParam('visibility', filter);
let collection = posts.all().filter((post) => {
let matchesStatus = true;
let matchesAuthors = true;
let matchesVisibility = true;
if (!isEmpty(statusFilter)) {
matchesStatus = statusFilter.includes(post.status);
}
return matchesStatus;
if (!isEmpty(authorsFilter)) {
matchesAuthors = authorsFilter.includes(post.authors.models[0].slug);
}
if (!isEmpty(visibilityFilter)) {
matchesVisibility = visibilityFilter.includes(post.visibility);
}
return matchesStatus && matchesAuthors && matchesVisibility;
});
return paginateModelCollection('posts', collection, page, limit);
@ -59,7 +70,6 @@ export default function mockPosts(server) {
return posts.create(attrs);
});
// TODO: handle authors filter
server.get('/posts/', getPosts);
server.get('/posts/:id/', function ({posts}, {params}) {
@ -100,6 +110,13 @@ export default function mockPosts(server) {
posts.find(ids).destroy();
});
server.post('/posts/:id/copy/', function ({posts}, {params}) {
let post = posts.find(params.id);
let attrs = post.attrs;
return posts.create(attrs);
});
server.put('/posts/bulk/', function ({tags}, {requestBody}) {
const bulk = JSON.parse(requestBody).bulk;
const action = bulk.action;
@ -115,7 +132,7 @@ export default function mockPosts(server) {
tags.create(tag);
}
});
// TODO: update the actual posts in the mock db
// TODO: update the actual posts in the mock db if wanting to write tests where we navigate around (refresh model)
// const postsToUpdate = posts.find(ids);
// getting the posts is fine, but within this we CANNOT manipulate them (???) not even iterate with .forEach
}

View file

@ -1,6 +1,6 @@
{
"name": "ghost-admin",
"version": "5.88.2",
"version": "5.88.3",
"description": "Ember.js admin client for Ghost",
"author": "Ghost Foundation",
"homepage": "http://ghost.org",

File diff suppressed because it is too large Load diff

View file

@ -143,6 +143,53 @@ describe('Acceptance: Members', function () {
.to.equal('example@domain.com');
});
/* NOTE: Bulk deletion is disabled temporarily when multiple filters are applied, due to a NQL limitation.
* Delete this test once we have fixed the root NQL limitation.
* See https://linear.app/tryghost/issue/ONC-203
*/
it('cannot bulk delete members if more than 1 filter is selected', async function () {
// Members with label
const labelOne = this.server.create('label');
const labelTwo = this.server.create('label');
this.server.createList('member', 2, {labels: [labelOne]});
this.server.createList('member', 2, {labels: [labelOne, labelTwo]});
await visit('/members');
expect(findAll('[data-test-member]').length).to.equal(4);
// The delete button should not be visible by default
await click('[data-test-button="members-actions"]');
expect(find('[data-test-button="delete-selected"]')).to.not.exist;
// Apply a single filter
await click('[data-test-button="members-filter-actions"]');
await fillIn('[data-test-members-filter="0"] [data-test-select="members-filter"]', 'label');
await click('.gh-member-label-input input');
await click(`[data-test-label-filter="${labelOne.name}"]`);
await click(`[data-test-button="members-apply-filter"]`);
expect(findAll('[data-test-member]').length).to.equal(4);
expect(currentURL()).to.equal(`/members?filter=label%3A%5B${labelOne.slug}%5D`);
await click('[data-test-button="members-actions"]');
expect(find('[data-test-button="delete-selected"]')).to.exist;
// Apply a second filter
await click('[data-test-button="members-filter-actions"]');
await click('[data-test-button="add-members-filter"]');
await fillIn('[data-test-members-filter="1"] [data-test-select="members-filter"]', 'label');
await click('[data-test-members-filter="1"] .gh-member-label-input input');
await click(`[data-test-members-filter="1"] [data-test-label-filter="${labelTwo.name}"]`);
await click(`[data-test-button="members-apply-filter"]`);
expect(findAll('[data-test-member]').length).to.equal(2);
expect(currentURL()).to.equal(`/members?filter=label%3A%5B${labelOne.slug}%5D%2Blabel%3A%5B${labelTwo.slug}%5D`);
await click('[data-test-button="members-actions"]');
expect(find('[data-test-button="delete-selected"]')).to.not.exist;
});
it('can bulk delete members', async function () {
// members to be kept
this.server.createList('member', 6);
@ -167,7 +214,7 @@ describe('Acceptance: Members', function () {
await click(`[data-test-button="members-apply-filter"]`);
expect(findAll('[data-test-member]').length).to.equal(5);
expect(currentURL()).to.equal('/members?filter=label%3A%5Blabel-0%5D');
expect(currentURL()).to.equal(`/members?filter=label%3A%5B${label.slug}%5D`);
await click('[data-test-button="members-actions"]');

View file

@ -1328,7 +1328,12 @@ describe('Acceptance: Members filtering', function () {
expect(find('[data-test-button="add-label-selected"]'), 'add label to selected button').to.exist;
expect(find('[data-test-button="remove-label-selected"]'), 'remove label from selected button').to.exist;
expect(find('[data-test-button="unsubscribe-selected"]'), 'unsubscribe selected button').to.exist;
expect(find('[data-test-button="delete-selected"]'), 'delete selected button').to.exist;
/* NOTE: Bulk deletion is disabled temporarily when multiple filters are applied, due to a NQL limitation.
* Re-enable following line once we have fixed the root NQL limitation.
* See https://linear.app/tryghost/issue/ONC-203
*/
// expect(find('[data-test-button="delete-selected"]'), 'delete selected button').to.exist;
// filter is active and has # of filters
expect(find('[data-test-button="members-filter-actions"] span'), 'filter button').to.have.class('gh-btn-label-green');

View file

@ -1,6 +1,6 @@
{
"name": "ghost",
"version": "5.88.2",
"version": "5.88.3",
"description": "The professional publishing platform",
"author": "Ghost Foundation",
"homepage": "https://ghost.org",

View file

@ -19,7 +19,7 @@
],
"devDependencies": {
"c8": "8.0.1",
"html-validate": "8.20.1",
"html-validate": "8.21.0",
"mocha": "10.2.0",
"should": "13.2.3",
"sinon": "15.2.0"

View file

@ -30,6 +30,6 @@
"mocha": "10.2.0"
},
"dependencies": {
"i18next": "23.12.1"
"i18next": "23.12.2"
}
}

View file

@ -151,6 +151,7 @@ module.exports = class EventRepository {
...options,
withRelated: ['member', 'newsletter'],
filter: 'custom:true',
useBasicCount: true,
mongoTransformer: chainTransformers(
// First set the filter manually
replaceCustomFilterTransformer(filter),
@ -193,6 +194,7 @@ module.exports = class EventRepository {
'stripeSubscription.stripePrice.stripeProduct.product'
],
filter: 'custom:true',
useBasicCount: true,
mongoTransformer: chainTransformers(
// First set the filter manually
replaceCustomFilterTransformer(filter),
@ -245,6 +247,7 @@ module.exports = class EventRepository {
...options,
withRelated: ['member'],
filter: 'custom:true',
useBasicCount: true,
mongoTransformer: chainTransformers(
// First set the filter manually
replaceCustomFilterTransformer(filter),
@ -277,6 +280,7 @@ module.exports = class EventRepository {
...options,
withRelated: ['member'],
filter: 'custom:true',
useBasicCount: true,
mongoTransformer: chainTransformers(
// First set the filter manually
replaceCustomFilterTransformer(filter),
@ -314,6 +318,7 @@ module.exports = class EventRepository {
'tagAttribution'
],
filter: 'subscriptionCreatedEvent.id:null+custom:true',
useBasicCount: true,
mongoTransformer: chainTransformers(
// First set the filter manually
replaceCustomFilterTransformer(filter),
@ -368,6 +373,7 @@ module.exports = class EventRepository {
'tagAttribution'
],
filter: 'member_id:-null+custom:true',
useBasicCount: true,
mongoTransformer: chainTransformers(
// First set the filter manually
replaceCustomFilterTransformer(filter),
@ -416,6 +422,7 @@ module.exports = class EventRepository {
...options,
withRelated: ['member', 'post', 'parent'],
filter: 'member_id:-null+custom:true',
useBasicCount: true,
mongoTransformer: chainTransformers(
// First set the filter manually
replaceCustomFilterTransformer(filter),
@ -449,6 +456,7 @@ module.exports = class EventRepository {
...options,
withRelated: ['member', 'link', 'link.post'],
filter: 'custom:true',
useBasicCount: true,
mongoTransformer: chainTransformers(
// First set the filter manually
replaceCustomFilterTransformer(filter),
@ -499,6 +507,7 @@ module.exports = class EventRepository {
...options,
withRelated: ['member'],
filter: 'custom:true',
useBasicCount: true,
mongoTransformer: chainTransformers(
// First set the filter manually
replaceCustomFilterTransformer(filter),
@ -537,6 +546,7 @@ module.exports = class EventRepository {
...options,
withRelated: ['member', 'post'],
filter: 'custom:true',
useBasicCount: true,
mongoTransformer: chainTransformers(
// First set the filter manually
replaceCustomFilterTransformer(filter),
@ -571,6 +581,7 @@ module.exports = class EventRepository {
...options,
withRelated: ['member', 'email'],
filter: filterStr,
useBasicCount: true,
mongoTransformer: chainTransformers(
// First set the filter manually
replaceCustomFilterTransformer(filter),
@ -613,6 +624,7 @@ module.exports = class EventRepository {
...options,
withRelated: ['member', 'email'],
filter: 'delivered_at:-null+custom:true',
useBasicCount: true,
mongoTransformer: chainTransformers(
// First set the filter manually
replaceCustomFilterTransformer(filter),
@ -655,6 +667,7 @@ module.exports = class EventRepository {
...options,
withRelated: ['member', 'email'],
filter: 'opened_at:-null+custom:true',
useBasicCount: true,
mongoTransformer: chainTransformers(
// First set the filter manually
replaceCustomFilterTransformer(filter),
@ -697,6 +710,7 @@ module.exports = class EventRepository {
...options,
withRelated: ['member', 'email'],
filter: 'custom:true',
useBasicCount: true,
mongoTransformer: chainTransformers(
// First set the filter manually
replaceCustomFilterTransformer(filter),
@ -730,6 +744,7 @@ module.exports = class EventRepository {
...options,
withRelated: ['member', 'email'],
filter: 'failed_at:-null+custom:true',
useBasicCount: true,
mongoTransformer: chainTransformers(
// First set the filter manually
replaceCustomFilterTransformer(filter),
@ -772,6 +787,7 @@ module.exports = class EventRepository {
...options,
withRelated: ['member'],
filter: 'custom:true',
useBasicCount: true,
mongoTransformer: chainTransformers(
// First set the filter manually
replaceCustomFilterTransformer(filter),

View file

@ -153,7 +153,8 @@ module.exports = class MembersCSVImporterStripeUtils {
await this._stripeAPIService.updateSubscriptionItemPrice(
stripeSubscription.id,
stripeSubscriptionItem.id,
newStripePrice.id
newStripePrice.id,
{prorationBehavior: 'none'}
);
stripePriceId = newStripePrice.id;
@ -167,7 +168,8 @@ module.exports = class MembersCSVImporterStripeUtils {
await this._stripeAPIService.updateSubscriptionItemPrice(
stripeSubscription.id,
stripeSubscriptionItem.id,
stripePriceId
stripePriceId,
{prorationBehavior: 'none'}
);
}
}

View file

@ -304,7 +304,8 @@ describe('MembersCSVImporterStripeUtils', function () {
stripeAPIServiceStub.updateSubscriptionItemPrice.calledWithExactly(
stripeCustomer.subscriptions.data[0].id,
stripeCustomerSubscriptionItem.id,
GHOST_PRODUCT_STRIPE_PRICE_ID
GHOST_PRODUCT_STRIPE_PRICE_ID,
{prorationBehavior: 'none'}
).should.be.true();
});
@ -346,7 +347,8 @@ describe('MembersCSVImporterStripeUtils', function () {
stripeAPIServiceStub.updateSubscriptionItemPrice.calledWithExactly(
stripeCustomer.subscriptions.data[0].id,
stripeCustomerSubscriptionItem.id,
NEW_STRIPE_PRICE_ID
NEW_STRIPE_PRICE_ID,
{prorationBehavior: 'none'}
).should.be.true();
});

View file

@ -28,7 +28,7 @@
"@tryghost/errors": "1.3.2",
"@tryghost/tpl": "0.1.30",
"csso": "5.0.5",
"terser": "5.31.1",
"terser": "5.31.3",
"tiny-glob": "0.2.9"
}
}

View file

@ -698,20 +698,23 @@ module.exports = class StripeAPI {
* @param {string} subscriptionId - The ID of the Subscription to modify
* @param {string} id - The ID of the SubscriptionItem
* @param {string} price - The ID of the new Price
* @param {object} [options={}] - Additional data to set on the subscription object
* @param {('always_invoice'|'create_prorations'|'none')} [options.prorationBehavior='always_invoice'] - The proration behavior to use. See [Stripe docs](https://docs.stripe.com/api/subscriptions/update#update_subscription-proration_behavior) for more info
* @param {string} [options.cancellationReason=null] - The user defined cancellation reason
*
* @returns {Promise<import('stripe').Stripe.Subscription>}
*/
async updateSubscriptionItemPrice(subscriptionId, id, price) {
async updateSubscriptionItemPrice(subscriptionId, id, price, options = {}) {
await this._rateLimitBucket.throttle();
const subscription = await this._stripe.subscriptions.update(subscriptionId, {
proration_behavior: 'always_invoice',
proration_behavior: options.prorationBehavior || 'always_invoice',
items: [{
id,
price
}],
cancel_at_period_end: false,
metadata: {
cancellation_reason: null
cancellation_reason: options.cancellationReason ?? null
}
});
return subscription;

132
yarn.lock
View file

@ -7563,66 +7563,66 @@
resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.5.1.tgz#27337d72046d5236b32fd977edee3f74c71d332f"
integrity sha512-UCcUKrUYGj7ClomOo2SpNVvx4/fkd/2BbIHDCle8A0ax+P3bU7yJwDBDrS6ZwdTMARWTGODX1hEsCcO+7beJjg==
"@tiptap/core@2.5.7":
version "2.5.7"
resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-2.5.7.tgz#681eefd198f9b7b8ad543ca29c56d46aab4919cf"
integrity sha512-8fBW+yBRSc2rEDOs6P+53kF0EAmSv17M4ruQBABo18Nt5qIyr/Uo4p+/E4NkV30bKgKI1zyq1dPeznDplSseqQ==
"@tiptap/core@2.5.8":
version "2.5.8"
resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-2.5.8.tgz#58de366b0d2acb0a6e67a4780de64d619ebd90fa"
integrity sha512-lkWCKyoAoMTxM137MoEsorG7tZ5MZU6O3wMRuZ0P9fcTRY5vd1NWncWuPzuGSJIpL20gwBQOsS6PaQSfR3xjlA==
"@tiptap/extension-blockquote@2.5.7":
version "2.5.7"
resolved "https://registry.yarnpkg.com/@tiptap/extension-blockquote/-/extension-blockquote-2.5.7.tgz#378921028a25f39d6a1dcebc86efb73fc4f19cce"
integrity sha512-cSnk5ViQgG6SgKnvJ5qaW47jl5qTN0oADXdcfyaY5XrbCPBGCVq1yRZlUtPU/J0YocZpjNLRRSMPVQ3wya5vtQ==
"@tiptap/extension-blockquote@2.5.8":
version "2.5.8"
resolved "https://registry.yarnpkg.com/@tiptap/extension-blockquote/-/extension-blockquote-2.5.8.tgz#95880f0b687790dbff85a1c9e83f2afd0011be67"
integrity sha512-P8vDiagtRrUfIewfCKrJe0ddDSjPgOTKzqoM1UXKS+MenT8C/wT4bjiwopAoWP6zMoV0TfHWXah9emllmCfXFA==
"@tiptap/extension-bubble-menu@^2.5.7":
version "2.5.7"
resolved "https://registry.yarnpkg.com/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.5.7.tgz#24c3f41d93022ed4bebe3610124bb114333c834d"
integrity sha512-gkuBuVGm5YPDRUG5Bscj6IYjDbzM7iJ2aXBGCM1rzuIiwT04twY51dKMIeseXa49uk/AQs/mqt3kGQjgSdSFAw==
"@tiptap/extension-bubble-menu@^2.5.8":
version "2.5.8"
resolved "https://registry.yarnpkg.com/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.5.8.tgz#e39b176c574b9fd2f59c6457724f3f22a22fb1b8"
integrity sha512-COmd1Azudu7i281emZFIESECe7FnvWiRoBoQBVjjWSyq5PVzwJaA3PAlnU7GyNZKtVXMZ4xbrckdyNQfDeVQDA==
dependencies:
tippy.js "^6.3.7"
"@tiptap/extension-document@2.5.7":
version "2.5.7"
resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.5.7.tgz#cfc608656ab9173334247baf8ddb93135b024260"
integrity sha512-tcK6aleya6pmC/ForF/y2PiwPhN5hK8JSm07pcWV9FmP2Qemx26GWS+1u1EzPDeTTbRBvk+9txHGcq9NYZem0Q==
"@tiptap/extension-document@2.5.8":
version "2.5.8"
resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.5.8.tgz#644f042f1d4a8d3f74af057477cc627da7b54dc7"
integrity sha512-r3rP4ihCJAdp3VRIeqd80etHx7jttzZaKNFX8hkQShHK6eTHwrR92VL0jDE4K+NOE3bxjMsOlYizJYWV042BtA==
"@tiptap/extension-floating-menu@^2.5.7":
version "2.5.7"
resolved "https://registry.yarnpkg.com/@tiptap/extension-floating-menu/-/extension-floating-menu-2.5.7.tgz#b97101d82629335f47663bb4ddbc9231985a2b80"
integrity sha512-tQjNNx0gPb7GxMiozcQ4R1Tl1znmlx/ZkbCF9rqxTzPTD4fnCliqBQAWjtHl98+D8+yEJBcB2DimtP7ztkv2mg==
"@tiptap/extension-floating-menu@^2.5.8":
version "2.5.8"
resolved "https://registry.yarnpkg.com/@tiptap/extension-floating-menu/-/extension-floating-menu-2.5.8.tgz#6af3fa169bf293ab79a671a7b60b5199992a9154"
integrity sha512-qsM6tCyRlXnI/gADrkO/2p0Tldu5aY96CnsXpZMaflMgsO577qhcXD0ReGg17uLXBzJa5xmV8qOik0Ptq3WEWg==
dependencies:
tippy.js "^6.3.7"
"@tiptap/extension-hard-break@2.5.7":
version "2.5.7"
resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-2.5.7.tgz#a832460a610f3ff6c3c4260562c8555e0d0734ec"
integrity sha512-Ki1JV2cz74wo4am8vIY6KWnfiFoE68RVQDIL0/29fNz1oZI46R4VV2Q5IvoVhetXcx7Qe9nTJVqy1vRS//Kcvg==
"@tiptap/extension-hard-break@2.5.8":
version "2.5.8"
resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-2.5.8.tgz#95288faad3408b91284d925c3e4dbab66029dd98"
integrity sha512-samZEL0EXzHSmMQ7KyLnfSxdDv3qSjia0JzelfCnFZS6LLcbwjrIjV8ZPxEhJ7UlZqroQdFxPegllkLHZj/MdQ==
"@tiptap/extension-link@2.5.7":
version "2.5.7"
resolved "https://registry.yarnpkg.com/@tiptap/extension-link/-/extension-link-2.5.7.tgz#91326304a3f4f5fdeb23a8f630d5ae4a1ab64287"
integrity sha512-rxvcdV8H/TiRhR2SZfLHp7hUp5hwBAhkc6PsXEWj8lekG4/5lXGwPSPxLtHMBRtOyeJpXTv9DY6nsCGZuz9x6A==
"@tiptap/extension-link@2.5.8":
version "2.5.8"
resolved "https://registry.yarnpkg.com/@tiptap/extension-link/-/extension-link-2.5.8.tgz#f9264afed09bd25c37668303151ab80ba82ef044"
integrity sha512-qfeWR7sG2V7bn8z0f3HMyoR68pFlxYJmLs9cbW30diE9/zKClYEd3zTMPCgJ9yMSagCj4PWkqksIuktAhyRqOQ==
dependencies:
linkifyjs "^4.1.0"
"@tiptap/extension-paragraph@2.5.7":
version "2.5.7"
resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.5.7.tgz#7ce35b365e8222fb8e93f5e7bcdc18ef73c32ac5"
integrity sha512-7zmDE43jv+GMTLuWrztA6oAnYLdUki5fUjYFn0h5FPRHQTuDoxsCD+hX0N/sGQVlc8zl1yn7EYbPNn9rHi7ECw==
"@tiptap/extension-paragraph@2.5.8":
version "2.5.8"
resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.5.8.tgz#5be7e7c4e5c19bd4f512c72d3dfc4e1e6d6dd876"
integrity sha512-AMfD3lfGSiomfkSE2tUourUjVahLtIfWUQew13NTPuWoxAXaSyoCGO0ULkiou/lO3JVUUUmF9+KJrAHWGIARdA==
"@tiptap/extension-placeholder@2.5.7":
version "2.5.7"
resolved "https://registry.yarnpkg.com/@tiptap/extension-placeholder/-/extension-placeholder-2.5.7.tgz#b01f695d370d72ce2efd81a2b9b61868290b1f36"
integrity sha512-Xjl0sCUlNyVq8HDrf+6n62gPEM3ymPr5d5t0zXjE+NPzfOeOARfiMXW2VB5QYFOsxnCd2MbZAeZ4+RY2sSVaZg==
"@tiptap/extension-placeholder@2.5.8":
version "2.5.8"
resolved "https://registry.yarnpkg.com/@tiptap/extension-placeholder/-/extension-placeholder-2.5.8.tgz#80fdf02133d94f41363f6fe28f5fc3ef09ac73c6"
integrity sha512-mvRl73OM5jBXVtDRLSTvp8/4+0mS2J2+ZcuiAHjABwEsZRCfJsiqty5NisOxSuy/AQtm8TK2kyt6ZCXQ2VRGig==
"@tiptap/extension-text@2.5.7":
version "2.5.7"
resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-2.5.7.tgz#bc436206ba16214383064672a6ebe3dd0464f368"
integrity sha512-vukhh2K/MsaIIs/UzIAwp44IVxTHPJcAhSsDnmJd4iPlkpjLt1eph77dfxv5awq78bj6mGvnCM0/0F6fW1C6/w==
"@tiptap/extension-text@2.5.8":
version "2.5.8"
resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-2.5.8.tgz#a9c4de33eec749c8c01d8bd81fb589f581c30dfc"
integrity sha512-CNkD51jRMdcYCqFVOkrnebqBQ6pCD3ZD5z9kO5bOC5UPZKZBkLsWdlrHGAVwosxcGxdJACbqJ0Nj+fMgIw4tNA==
"@tiptap/pm@2.5.7":
version "2.5.7"
resolved "https://registry.yarnpkg.com/@tiptap/pm/-/pm-2.5.7.tgz#9661d508fe34f7616b1078becc049baeff75d677"
integrity sha512-4Eb4vA4e4vesBAUmZgx+n3xjgJ58uRKKtnhFDJ3Gg+dfpXvtF8FcEwSIjHJsTlNJ8mSrzX/I7S157qPc5wZXVw==
"@tiptap/pm@2.5.8":
version "2.5.8"
resolved "https://registry.yarnpkg.com/@tiptap/pm/-/pm-2.5.8.tgz#b18afa77fdf69527b13614a05cfefc8b63e82224"
integrity sha512-CVhHaTG4QNHSkvuh6HHsUR4hE+nbUnk7z+VMUedaqPU8tNqkTwWGCMbiyTc+PCsz0T9Mni7vvBR+EXgEQ3+w4g==
dependencies:
prosemirror-changeset "^2.2.1"
prosemirror-collab "^1.3.1"
@ -7643,13 +7643,13 @@
prosemirror-transform "^1.9.0"
prosemirror-view "^1.33.9"
"@tiptap/react@2.5.7":
version "2.5.7"
resolved "https://registry.yarnpkg.com/@tiptap/react/-/react-2.5.7.tgz#0de723c468a37cf69bb1a9d7a38815137f156c71"
integrity sha512-QRMbo6eDtYHBwZ7ATFgKFWLlRZ/Q7NJrBS/Z6FW2lFhr1eM8UhOG6HMEMt/kibMJDJVi1FpXEavgaT75oe2BJg==
"@tiptap/react@2.5.8":
version "2.5.8"
resolved "https://registry.yarnpkg.com/@tiptap/react/-/react-2.5.8.tgz#d6bc68710f084fe0f02855376cf869f8ca2cf6fd"
integrity sha512-twUMm8HV7scUgR/E1hYS9N6JDtKPl7cgDiPjxTynNHc5S5f5Ecv4ns/BZRq3TMZ/JDrp4rghLvgq+ImQsLvPOA==
dependencies:
"@tiptap/extension-bubble-menu" "^2.5.7"
"@tiptap/extension-floating-menu" "^2.5.7"
"@tiptap/extension-bubble-menu" "^2.5.8"
"@tiptap/extension-floating-menu" "^2.5.8"
"@types/use-sync-external-store" "^0.0.6"
use-sync-external-store "^1.2.2"
@ -19524,10 +19524,10 @@ html-to-text@8.2.1:
minimist "^1.2.6"
selderee "^0.6.0"
html-validate@8.20.1:
version "8.20.1"
resolved "https://registry.yarnpkg.com/html-validate/-/html-validate-8.20.1.tgz#8cdd1fc32f4578efa5a9dea596cdd9bf0e26f805"
integrity sha512-EawDiHzvZtnbBIfxE90lvKOWqNsmZGqRXTy+utxlGo525Vqjowg+RK42q1AeJ6zm1AyVTFIDSah1eBe9tc6YHg==
html-validate@8.21.0:
version "8.21.0"
resolved "https://registry.yarnpkg.com/html-validate/-/html-validate-8.21.0.tgz#fcb8aa4d05d95c9b806bebf3d1be6836a1d8a196"
integrity sha512-f6uyHdNeul4f/E6TDaUrH8agrVmnG5VbWwmIhbkg+Vrz+To/2xxbc+soBKXqani1QSaA+5I12Qr7dQt/HVFJtw==
dependencies:
"@babel/code-frame" "^7.10.0"
"@html-validate/stylish" "^4.1.0"
@ -19802,10 +19802,10 @@ i18next-parser@8.13.0:
vinyl-fs "^4.0.0"
vue-template-compiler "^2.6.11"
i18next@23.12.1, i18next@^23.5.1:
version "23.12.1"
resolved "https://registry.yarnpkg.com/i18next/-/i18next-23.12.1.tgz#1cdb4d6dde62404e128ae1212af586d14c70d389"
integrity sha512-l4y291ZGRgUhKuqVSiqyuU2DDzxKStlIWSaoNBR4grYmh0X+pRYbFpTMs3CnJ5ECKbOI8sQcJ3PbTUfLgPRaMA==
i18next@23.12.2, i18next@^23.5.1:
version "23.12.2"
resolved "https://registry.yarnpkg.com/i18next/-/i18next-23.12.2.tgz#c5b44bb95e4d4a5908a51577fa06c63dc2f650a4"
integrity sha512-XIeh5V+bi8SJSWGL3jqbTEBW5oD6rbP5L+E7dVQh1MNTxxYef0x15rhJVcRb7oiuq4jLtgy2SD8eFlf6P2cmqg==
dependencies:
"@babel/runtime" "^7.23.2"
@ -30049,10 +30049,10 @@ table@^6.0.9, table@^6.8.1:
string-width "^4.2.3"
strip-ansi "^6.0.1"
tailwindcss@3.4.5:
version "3.4.5"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.5.tgz#0de2e92ed4d00fb015feb962fa0781605761724d"
integrity sha512-DlTxttYcogpDfx3tf/8jfnma1nfAYi2cBUYV2YNoPPecwmO3YGiFlOX9D8tGAu+EDF38ryBzvrDKU/BLMsUwbw==
tailwindcss@3.4.7:
version "3.4.7"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.7.tgz#6092f18767f5933f59375b9afe558e592fc77201"
integrity sha512-rxWZbe87YJb4OcSopb7up2Ba4U82BoiSGUdoDr3Ydrg9ckxFS/YWsvhN323GMcddgU65QRy7JndC7ahhInhvlQ==
dependencies:
"@alloc/quick-lru" "^5.2.0"
arg "^5.0.2"
@ -30228,10 +30228,10 @@ terser-webpack-plugin@^5.3.10:
serialize-javascript "^6.0.1"
terser "^5.26.0"
terser@5.31.1, terser@^5.26.0, terser@^5.7.0:
version "5.31.1"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.31.1.tgz#735de3c987dd671e95190e6b98cfe2f07f3cf0d4"
integrity sha512-37upzU1+viGvuFtBo9NPufCb9dwM0+l9hMxYyWfBA+fbwrPqNJAhbZ6W47bBFnZHKHTUBnMvi87434qq+qnxOg==
terser@5.31.3, terser@^5.26.0, terser@^5.7.0:
version "5.31.3"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.31.3.tgz#b24b7beb46062f4653f049eea4f0cd165d0f0c38"
integrity sha512-pAfYn3NIZLyZpa83ZKigvj6Rn9c/vd5KfYGX7cN1mnzqgDcxWvrU5ZtAfIKhEXz9nRecw4z3LXkjaq96/qZqAA==
dependencies:
"@jridgewell/source-map" "^0.3.3"
acorn "^8.8.2"