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:
commit
a5ff2b7822
43 changed files with 1378 additions and 891 deletions
|
@ -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 () {
|
||||
|
|
|
@ -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 [];
|
||||
}
|
||||
|
|
|
@ -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'>We’re 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 right—find 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'>
|
||||
We’re 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 right—find 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'>→</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'>→</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'>→</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'>→</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'>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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}});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
||||
// });
|
||||
// });
|
||||
});
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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'
|
||||
}];
|
||||
}
|
||||
|
|
|
@ -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'});
|
||||
|
|
|
@ -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 --}}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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'});
|
||||
}
|
||||
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -40,4 +40,4 @@ export default class PagesController extends PostsController {
|
|||
openEditor(page) {
|
||||
this.router.transitionTo('lexical-editor.edit', 'page', page.get('id'));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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]';
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
@ -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"]');
|
||||
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -30,6 +30,6 @@
|
|||
"mocha": "10.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18next": "23.12.1"
|
||||
"i18next": "23.12.2"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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'}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
132
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Add table
Reference in a new issue