Merge branch 'main' into main

This commit is contained in:
Peter Jidamva 2024-08-18 13:16:52 +03:00 committed by GitHub
commit 2a5437cb12
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
276 changed files with 10287 additions and 4413 deletions

2
.envrc
View File

@ -1,2 +0,0 @@
layout node
use flake .

View File

@ -38,6 +38,7 @@ jobs:
- [ ] Uses the correct utils
- [ ] Contains a minimal changeset
- [ ] Does not mix DDL/DML operations
- [ ] Tested in MySQL and SQLite
### Schema changes

3
.gitignore vendored
View File

@ -66,9 +66,6 @@ typings/
# dotenv environment variables file
.env
# direnv
.direnv
# IDE
.idea/*
*.iml

View File

@ -25,7 +25,7 @@
"lint": "yarn run lint:code && yarn run lint:test",
"lint:code": "eslint --ext .js,.ts,.cjs,.tsx --cache src",
"lint:test": "eslint -c test/.eslintrc.cjs --ext .js,.ts,.cjs,.tsx --cache test",
"test:unit": "yarn nx build && vitest run",
"test:unit": "vitest run",
"test:acceptance": "NODE_OPTIONS='--experimental-specifier-resolution=node --no-warnings' VITE_TEST=true playwright test",
"test:acceptance:slowmo": "TIMEOUT=100000 PLAYWRIGHT_SLOWMO=100 yarn test:acceptance --headed",
"test:acceptance:full": "ALL_BROWSERS=1 yarn test:acceptance",
@ -33,7 +33,7 @@
},
"devDependencies": {
"@playwright/test": "1.38.1",
"@testing-library/react": "14.1.0",
"@testing-library/react": "14.3.1",
"@tryghost/admin-x-design-system": "0.0.0",
"@tryghost/admin-x-framework": "0.0.0",
"@types/jest": "29.5.12",

View File

@ -11,8 +11,8 @@ interface AppProps {
const modals = {
paths: {
'follow-site': 'FollowSite',
'view-following': 'ViewFollowing',
'view-followers': 'ViewFollowers'
'profile/following': 'ViewFollowing',
'profile/followers': 'ViewFollowers'
},
load: async () => import('./components/modals')
};

View File

@ -1,7 +1,46 @@
import ActivityPubComponent from './components/ListIndex';
import Activities from './components/Activities';
import Inbox from './components/Inbox';
import Profile from './components/Profile';
import Search from './components/Search';
import {ActivityPubAPI} from './api/activitypub';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import {useQuery} from '@tanstack/react-query';
import {useRouting} from '@tryghost/admin-x-framework/routing';
export function useBrowseInboxForUser(handle: string) {
const site = useBrowseSite();
const siteData = site.data?.site;
const siteUrl = siteData?.url ?? window.location.origin;
const api = new ActivityPubAPI(
new URL(siteUrl),
new URL('/ghost/api/admin/identities/', window.location.origin),
handle
);
return useQuery({
queryKey: [`inbox:${handle}`],
async queryFn() {
return api.getInbox();
}
});
}
const MainContent = () => {
return <ActivityPubComponent />;
const {route} = useRouting();
const mainRoute = route.split('/')[0];
switch (mainRoute) {
case 'search':
return <Search />;
break;
case 'activity':
return <Activities />;
break;
case 'profile':
return <Profile />;
break;
default:
return <Inbox />;
break;
}
};
export default MainContent;

View File

@ -0,0 +1,483 @@
import {Activity, ActivityPubAPI} from './activitypub';
function NotFound() {
return new Response(null, {
status: 404
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function JSONResponse(data: any, contentType = 'application/json', status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: {
'Content-Type': contentType
}
});
}
type Spec = {
response: Response,
assert?: (resource: URL, init?: RequestInit) => Promise<void>
};
function Fetch(specs: Record<string, Spec>) {
return async function (resource: URL, init?: RequestInit): Promise<Response> {
const spec = specs[resource.href];
if (!spec) {
return NotFound();
}
if (spec.assert) {
await spec.assert(resource, init);
}
return spec.response;
};
}
describe('ActivityPubAPI', function () {
describe('getInbox', function () {
test('It passes the token to the inbox endpoint', async function () {
const fakeFetch = Fetch({
'https://auth.api/': {
response: JSONResponse({
identities: [{
token: 'fake-token'
}]
})
},
'https://activitypub.api/.ghost/activitypub/inbox/index': {
async assert(_resource, init) {
const headers = new Headers(init?.headers);
expect(headers.get('Authorization')).toContain('fake-token');
},
response: JSONResponse({
type: 'Collection',
items: []
})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
await api.getInbox();
});
test('Returns an empty array when the inbox is empty', 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: []
})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
const actual = await api.getInbox();
const expected: never[] = [];
expect(actual).toEqual(expected);
});
test('Returns all the items array when the inbox is not empty', 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);
});
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 () {
test('It passes the token to the following endpoint', async function () {
const fakeFetch = Fetch({
'https://auth.api/': {
response: JSONResponse({
identities: [{
token: 'fake-token'
}]
})
},
'https://activitypub.api/.ghost/activitypub/following/index': {
async assert(_resource, init) {
const headers = new Headers(init?.headers);
expect(headers.get('Authorization')).toContain('fake-token');
},
response: JSONResponse({
type: 'Collection',
items: []
})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
await api.getFollowing();
});
test('Returns an empty array when the following is empty', 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: []
})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
const actual = await api.getFollowing();
const expected: never[] = [];
expect(actual).toEqual(expected);
});
test('Returns all the items array when the following is not empty', 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);
});
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 () {
test('It passes the token to the followers endpoint', async function () {
const fakeFetch = Fetch({
'https://auth.api/': {
response: JSONResponse({
identities: [{
token: 'fake-token'
}]
})
},
'https://activitypub.api/.ghost/activitypub/followers/index': {
async assert(_resource, init) {
const headers = new Headers(init?.headers);
expect(headers.get('Authorization')).toContain('fake-token');
},
response: JSONResponse({
type: 'Collection',
items: []
})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
await api.getFollowers();
});
test('Returns an empty array when the followers is empty', 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: []
})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
const actual = await api.getFollowers();
const expected: never[] = [];
expect(actual).toEqual(expected);
});
test('Returns all the items array when the followers is not empty', 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);
});
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 () {
test('It passes the token to the follow endpoint', async function () {
const fakeFetch = Fetch({
'https://auth.api/': {
response: JSONResponse({
identities: [{
token: 'fake-token'
}]
})
},
'https://activitypub.api/.ghost/activitypub/actions/follow/@user@domain.com': {
async assert(_resource, init) {
const headers = new Headers(init?.headers);
expect(headers.get('Authorization')).toContain('fake-token');
},
response: JSONResponse({})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
await api.follow('@user@domain.com');
});
});
});

View File

@ -0,0 +1,109 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Actor = any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Activity = any;
export class ActivityPubAPI {
constructor(
private readonly apiUrl: URL,
private readonly authApiUrl: URL,
private readonly handle: string,
private readonly fetch: (resource: URL, init?: RequestInit) => Promise<Response> = window.fetch.bind(window)
) {}
private async getToken(): Promise<string | null> {
try {
const response = await this.fetch(this.authApiUrl);
const json = await response.json();
return json?.identities?.[0]?.token || null;
} catch (err) {
// TODO: Ping sentry?
return null;
}
}
private async fetchJSON(url: URL, method: 'GET' | 'POST' = 'GET'): Promise<object | null> {
const token = await this.getToken();
const response = await this.fetch(url, {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/activity+json'
}
});
const json = await response.json();
return json;
}
get inboxApiUrl() {
return new URL(`.ghost/activitypub/inbox/${this.handle}`, this.apiUrl);
}
async getInbox(): Promise<Activity[]> {
const json = await this.fetchJSON(this.inboxApiUrl);
if (json === null) {
return [];
}
if ('items' in json) {
return Array.isArray(json.items) ? json.items : [json.items];
}
return [];
}
get followingApiUrl() {
return new URL(`.ghost/activitypub/following/${this.handle}`, this.apiUrl);
}
async getFollowing(): Promise<Activity[]> {
const json = await this.fetchJSON(this.followingApiUrl);
if (json === null) {
return [];
}
if ('items' in json) {
return Array.isArray(json.items) ? json.items : [json.items];
}
return [];
}
async getFollowingCount(): Promise<number> {
const json = await this.fetchJSON(this.followingApiUrl);
if (json === null) {
return 0;
}
if ('totalItems' in json && typeof json.totalItems === 'number') {
return json.totalItems;
}
return 0;
}
get followersApiUrl() {
return new URL(`.ghost/activitypub/followers/${this.handle}`, this.apiUrl);
}
async getFollowers(): Promise<Activity[]> {
const json = await this.fetchJSON(this.followersApiUrl);
if (json === null) {
return [];
}
if ('items' in json) {
return Array.isArray(json.items) ? json.items : [json.items];
}
return [];
}
async getFollowersCount(): Promise<number> {
const json = await this.fetchJSON(this.followersApiUrl);
if (json === null) {
return 0;
}
if ('totalItems' in json && typeof json.totalItems === 'number') {
return json.totalItems;
}
return 0;
}
async follow(username: string): Promise<void> {
const url = new URL(`.ghost/activitypub/actions/follow/${username}`, this.apiUrl);
await this.fetchJSON(url, 'POST');
}
}

View File

@ -0,0 +1,52 @@
import APAvatar from './global/APAvatar';
import ActivityItem from './activities/ActivityItem';
import MainNavigation from './navigation/MainNavigation';
import React from 'react';
interface ActivitiesProps {}
const Activities: React.FC<ActivitiesProps> = ({}) => {
// const fakeAuthor =
return (
<>
<MainNavigation />
<div className='z-0 flex w-full flex-col items-center'>
<div className='mt-8 flex w-full max-w-[560px] flex-col'>
<ActivityItem>
<APAvatar />
<div>
<div className='text-grey-600'><span className='font-bold text-black'>Lydia Mango</span> @username@domain.com</div>
<div className='text-sm'>Followed you</div>
</div>
</ActivityItem>
<ActivityItem>
<APAvatar />
<div>
<div className='text-grey-600'><span className='font-bold text-black'>Tiana Passaquindici Arcand</span> @username@domain.com</div>
<div className='text-sm'>Followed you</div>
</div>
</ActivityItem>
<ActivityItem>
<APAvatar />
<div>
<div className='text-grey-600'><span className='font-bold text-black'>Gretchen Press</span> @username@domain.com</div>
<div className='text-sm'>Followed you</div>
</div>
</ActivityItem>
<ActivityItem>
<APAvatar />
<div>
<div className='text-grey-600'><span className='font-bold text-black'>Leo Lubin</span> @username@domain.com</div>
<div className='text-sm'>Followed you</div>
</div>
</ActivityItem>
</div>
</div>
</>
);
};
export default Activities;

View File

@ -1,92 +0,0 @@
import NiceModal from '@ebay/nice-modal-react';
import {Modal, TextField, showToast} from '@tryghost/admin-x-design-system';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import {useFollow} from '@tryghost/admin-x-framework/api/activitypub';
import {useQueryClient} from '@tryghost/admin-x-framework';
import {useRouting} from '@tryghost/admin-x-framework/routing';
import {useState} from 'react';
// const sleep = (ms: number) => (
// new Promise((resolve) => {
// setTimeout(resolve, ms);
// })
// );
const FollowSite = NiceModal.create(() => {
const {updateRoute} = useRouting();
const modal = NiceModal.useModal();
const mutation = useFollow();
const client = useQueryClient();
const site = useBrowseSite();
const siteData = site.data?.site;
const siteUrl = siteData?.url ?? window.location.origin;
// mutation.isPending
// mutation.isError
// mutation.isSuccess
// mutation.mutate({username: '@index@site.com'})
// mutation.reset();
// State to manage the text field value
const [profileName, setProfileName] = useState('');
// const [success, setSuccess] = useState(false);
const [errorMessage, setError] = useState(null);
const handleFollow = async () => {
try {
const url = new URL(`.ghost/activitypub/actions/follow/${profileName}`, siteUrl);
await fetch(url, {
method: 'POST'
});
// Perform the mutation
// If successful, set the success state to true
// setSuccess(true);
showToast({
message: 'Site followed',
type: 'success'
});
// // Because we don't return the new follower data from the API, we need to wait a bit to let it process and then update the query.
// // This is a dirty hack and should be replaced with a better solution.
// await sleep(2000);
modal.remove();
// Refetch the following data.
// At this point it might not be updated yet, but it will be eventually.
await client.refetchQueries({queryKey: ['FollowingResponseData'], type: 'active'});
updateRoute('');
} catch (error) {
// If there's an error, set the error state
setError(errorMessage);
}
};
return (
<Modal
afterClose={() => {
mutation.reset();
updateRoute('');
}}
cancelLabel='Cancel'
okLabel='Follow'
size='sm'
title='Follow a Ghost site'
onOk={handleFollow}
>
<div className='mt-3 flex flex-col gap-4'>
<TextField
autoFocus={true}
error={Boolean(errorMessage)}
hint={errorMessage}
placeholder='@username@hostname'
title='Profile name'
value={profileName}
data-test-new-follower
onChange={e => setProfileName(e.target.value)}
/>
</div>
</Modal>
);
});
export default FollowSite;

View File

@ -0,0 +1,83 @@
import ActivityPubWelcomeImage from '../assets/images/ap-welcome.png';
import ArticleModal from './feed/ArticleModal';
import FeedItem from './feed/FeedItem';
import MainNavigation from './navigation/MainNavigation';
import NiceModal from '@ebay/nice-modal-react';
import React, {useState} from 'react';
import {Activity} from './activities/ActivityItem';
import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, Heading} from '@tryghost/admin-x-design-system';
import {useBrowseInboxForUser} from '../MainContent';
interface InboxProps {}
const Inbox: React.FC<InboxProps> = ({}) => {
const {data: activities = []} = useBrowseInboxForUser('index');
const [, setArticleContent] = useState<ObjectProperties | null>(null);
const [, setArticleActor] = useState<ActorProperties | null>(null);
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 handleViewContent = (object: ObjectProperties, actor: ActorProperties) => {
setArticleContent(object);
setArticleActor(actor);
NiceModal.show(ArticleModal, {
object: object
});
};
return (
<>
<MainNavigation />
<div className='z-0 flex w-full flex-col'>
<div className='w-full'>
{inboxTabActivities.length > 0 ? (
<ul className='mx-auto flex max-w-[560px] flex-col py-8'>
{inboxTabActivities.reverse().map(activity => (
<li
key={activity.id}
data-test-view-article
onClick={() => handleViewContent(activity.object, activity.actor)}
>
<FeedItem
actor={activity.actor}
layout='feed'
object={activity.object}
type={activity.type}
/>
</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>
</div>
</>
);
};
export default Inbox;

View File

@ -1,350 +0,0 @@
// import NiceModal from '@ebay/nice-modal-react';
import ActivityPubWelcomeImage from '../assets/images/ap-welcome.png';
import React, {useEffect, useRef, useState} from 'react';
import articleBodyStyles from './articleBodyStyles';
import {ActorProperties, ObjectProperties, useBrowseFollowersForUser, useBrowseFollowingForUser, useBrowseInboxForUser} 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';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import {useRouting} from '@tryghost/admin-x-framework/routing';
interface ViewArticleProps {
object: ObjectProperties,
onBackToList: () => void;
}
const ActivityPubComponent: React.FC = () => {
const {updateRoute} = useRouting();
// TODO: Replace with actual user ID
const {data: {items: activities = []} = {}} = useBrowseInboxForUser('index');
const {data: {totalItems: followingCount = 0} = {}} = useBrowseFollowingForUser('index');
const {data: {totalItems: followersCount = 0} = {}} = useBrowseFollowersForUser('index');
const [articleContent, setArticleContent] = useState<ObjectProperties | null>(null);
const [, setArticleActor] = useState<ActorProperties | null>(null);
const handleViewContent = (object: ObjectProperties, actor: ActorProperties) => {
setArticleContent(object);
setArticleActor(actor);
};
const handleBackToList = () => {
setArticleContent(null);
};
const [selectedOption, setSelectedOption] = useState<SelectOption>({label: 'Inbox', value: 'inbox'});
const [selectedTab, setSelectedTab] = useState('inbox');
const tabs: ViewTab[] = [
{
id: 'inbox',
title: 'Inbox',
contents: <div className='grid grid-cols-6 items-start gap-8 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}/>
</div>
</div>}
</ul>
<Sidebar followersCount={followersCount} followingCount={followingCount} updateRoute={updateRoute} />
</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>
},
{
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>
}
];
return (
<Page>
{!articleContent ? (
<ViewContainer
actions={[<ButtonGroup buttons={[
{
icon: 'listview',
size: 'sm',
iconColorClass: selectedOption.value === 'inbox' ? 'text-black' : 'text-grey-500',
onClick: () => {
setSelectedOption({label: 'Inbox', value: 'inbox'});
}
},
{
icon: 'cardview',
size: 'sm',
iconColorClass: selectedOption.value === 'feed' ? 'text-black' : 'text-grey-500',
onClick: () => {
setSelectedOption({label: 'Feed', value: 'feed'});
}
}
]} clearBg={true} link outlineOnMobile />]}
firstOnPage={true}
primaryAction={{
title: 'Follow',
onClick: () => {
updateRoute('follow-site');
},
icon: 'add'
}}
selectedTab={selectedTab}
stickyHeader={true}
tabs={tabs}
title='ActivityPub'
toolbarBorder={true}
type='page'
onTabChange={setSelectedTab}
>
</ViewContainer>
) : (
<ViewArticle object={articleContent} onBackToList={handleBackToList} />
)}
</Page>
);
};
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;
const iframeRef = useRef<HTMLIFrameElement>(null);
const cssContent = articleBodyStyles(siteData?.url.replace(/\/$/, ''));
const htmlContent = `
<html>
<head>
${cssContent}
</head>
<body>
<header class="gh-article-header gh-canvas">
<h1 class="gh-article-title is-title" data-test-article-heading>${heading}</h1>
${image &&
`<figure class="gh-article-image">
<img src="${image}" alt="${heading}" />
</figure>`
}
</header>
<div class="gh-content gh-canvas is-body">
${html}
</div>
</body>
</html>
`;
useEffect(() => {
const iframe = iframeRef.current;
if (iframe) {
iframe.srcdoc = htmlContent;
}
}, [htmlContent]);
return (
<div>
<iframe
ref={iframeRef}
className={`h-[calc(100vh_-_3vmin_-_4.8rem_-_2px)]`}
height="100%"
id="gh-ap-article-iframe"
title="Embedded Content"
width="100%"
>
</iframe>
</div>
);
};
const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProperties, layout: string }> = ({actor, object, layout}) => {
const parser = new DOMParser();
const doc = parser.parseFromString(object.content || '', 'text/html');
const plainTextContent = doc.body.textContent;
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);
setIsLiked(!isLiked);
setTimeout(() => setIsClicked(false), 300); // Reset the animation class after 300ms
};
if (layout === 'feed') {
return (
<>
{object && (
<div className='border-1 group/article relative z-10 flex cursor-pointer flex-col items-start justify-between border-b border-b-grey-200 py-6' data-test-activity>
<div className='relative z-10 mb-3 flex w-full items-center gap-3'>
<img className='w-8' src={actor.icon?.url}/>
<div>
<p className='text-base font-bold' data-test-activity-heading>{actor.name}</p>
<div className='*:text-base *:text-grey-900'>
{/* <span className='truncate before:mx-1 before:content-["·"]'>{getUsername(actor)}</span> */}
<span>{timestamp}</span>
</div>
</div>
</div>
<div className='relative z-10 w-full gap-4'>
<div className='flex flex-col'>
{object.image && <div className='relative mb-4'>
<img className='h-[300px] w-full rounded object-cover' src={object.image}/>
</div>}
<Heading className='mb-1 leading-tight' level={4} data-test-activity-heading>{object.name}</Heading>
<p className='mb-4 line-clamp-3 max-w-prose text-pretty text-md text-grey-900'>{plainTextContent}</p>
<div className='flex gap-2'>
<Button className={`self-start text-grey-500 transition-all hover:text-grey-800 ${isClicked ? 'bump' : ''} ${isLiked ? 'ap-red-heart text-red *:!fill-red hover:text-red' : ''}`} hideLabel={true} icon='heart' id="like" size='md' unstyled={true} onClick={handleLikeClick}/>
<span className={`text-grey-800 ${isLiked ? 'opacity-100' : 'opacity-0'}`}>1</span>
</div>
</div>
</div>
<div className='absolute -inset-x-3 -inset-y-1 z-0 rounded transition-colors group-hover/article:bg-grey-100'></div>
{/* <div className='absolute inset-0 z-0 rounded from-white to-grey-50 transition-colors group-hover/article:bg-gradient-to-r'></div> */}
</div>
)}
</>
);
} else if (layout === 'inbox') {
return (
<>
{object && (
<div className='border-1 group/article relative z-10 flex cursor-pointer flex-col items-start justify-between border-b border-b-grey-200 py-5' data-test-activity>
<div className='relative z-10 mb-3 grid w-full grid-cols-[20px_auto_1fr_auto] items-center gap-2 text-base'>
<img className='w-5' src={actor.icon?.url}/>
<span className='truncate font-semibold'>{actor.name}</span>
{/* <span className='truncate text-grey-800'>{getUsername(actor)}</span> */}
<span className='ml-auto text-right text-grey-800'>{timestamp}</span>
</div>
<div className='relative z-10 grid w-full grid-cols-[auto_170px] gap-4'>
<div className='flex flex-col'>
<div className='flex w-full justify-between gap-4'>
<Heading className='mb-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>
<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>}
</div>
<div className='absolute -inset-x-3 -inset-y-1 z-0 rounded transition-colors group-hover/article:bg-grey-50'></div>
{/* <div className='absolute inset-0 z-0 rounded from-white to-grey-50 transition-colors group-hover/article:bg-gradient-to-r'></div> */}
</div>
)}
</>
);
}
};
const ViewArticle: React.FC<ViewArticleProps> = ({object, onBackToList}) => {
const {updateRoute} = useRouting();
const [isClicked, setIsClicked] = useState(false);
const [isLiked, setIsLiked] = useState(false);
const handleLikeClick = (event: React.MouseEvent<HTMLElement> | undefined) => {
event?.stopPropagation();
setIsClicked(true);
setIsLiked(!isLiked);
setTimeout(() => setIsClicked(false), 300); // Reset the animation class after 300ms
};
return (
<Page>
<ViewContainer
toolbarBorder={false}
type='page'
>
<div className='grid grid-cols-[1fr_minmax(320px,_700px)_1fr] gap-x-6 pb-4'>
<div>
<Button icon='chevron-left' iconSize='xs' label='Inbox' data-test-back-button onClick={onBackToList}/>
</div>
<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'>
<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>
<Button hideLabel={true} icon='arrow-top-right' iconSize='xs' label='Visit site' onClick={() => updateRoute('/')}/>
</div>
</div>
<div className='mx-[-4.8rem] mb-[-4.8rem] w-auto'>
<ArticleBody heading={object.name} html={object.content} image={object?.image}/>
</div>
</ViewContainer>
</Page>
);
};
export default ActivityPubComponent;

View File

@ -0,0 +1,72 @@
import MainNavigation from './navigation/MainNavigation';
import React from 'react';
import {ActivityPubAPI} from '../api/activitypub';
import {SettingValue} from '@tryghost/admin-x-design-system';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import {useQuery} from '@tanstack/react-query';
import {useRouting} from '@tryghost/admin-x-framework/routing';
interface ProfileProps {}
function useFollowersCountForUser(handle: string) {
const site = useBrowseSite();
const siteData = site.data?.site;
const siteUrl = siteData?.url ?? window.location.origin;
const api = new ActivityPubAPI(
new URL(siteUrl),
new URL('/ghost/api/admin/identities/', window.location.origin),
handle
);
return useQuery({
queryKey: [`followersCount:${handle}`],
async queryFn() {
return api.getFollowersCount();
}
});
}
function useFollowingCountForUser(handle: string) {
const site = useBrowseSite();
const siteData = site.data?.site;
const siteUrl = siteData?.url ?? window.location.origin;
const api = new ActivityPubAPI(
new URL(siteUrl),
new URL('/ghost/api/admin/identities/', window.location.origin),
handle
);
return useQuery({
queryKey: [`followingCount:${handle}`],
async queryFn() {
return api.getFollowingCount();
}
});
}
const Profile: React.FC<ProfileProps> = ({}) => {
const {updateRoute} = useRouting();
const {data: followersCount = 0} = useFollowersCountForUser('index');
const {data: followingCount = 0} = useFollowingCountForUser('index');
return (
<>
<MainNavigation />
<div className='z-0 flex w-full flex-col items-center'>
<div className='mx-auto mt-8 w-full max-w-[560px] 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('/profile/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('/profile/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>
</>
);
};
export default Profile;

View File

@ -0,0 +1,18 @@
import MainNavigation from './navigation/MainNavigation';
import React from 'react';
import {Icon} from '@tryghost/admin-x-design-system';
interface SearchProps {}
const Search: React.FC<SearchProps> = ({}) => {
return (
<>
<MainNavigation />
<div className='z-0 flex w-full flex-col items-center pt-8'>
<div className='flex w-full max-w-[560px] items-center gap-2 rounded-full bg-grey-100 px-3 py-2 text-grey-500'><Icon name='magnifying-glass' size={18} />Search the Fediverse</div>
</div>
</>
);
};
export default Search;

View File

@ -1,45 +0,0 @@
import {} from '@tryghost/admin-x-framework/api/activitypub';
import NiceModal from '@ebay/nice-modal-react';
import getUsername from '../utils/get-username';
import {Avatar, Button, List, ListItem, Modal} from '@tryghost/admin-x-design-system';
import {FollowingResponseData, useBrowseFollowersForUser, useFollow} from '@tryghost/admin-x-framework/api/activitypub';
import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing';
interface ViewFollowersModalProps {
following: FollowingResponseData[],
animate?: boolean
}
const ViewFollowersModal: React.FC<RoutingModalProps & ViewFollowersModalProps> = ({}) => {
const {updateRoute} = useRouting();
// const modal = NiceModal.useModal();
const mutation = useFollow();
const {data: {items = []} = {}} = useBrowseFollowersForUser('inbox');
const followers = Array.isArray(items) ? items : [items];
return (
<Modal
afterClose={() => {
mutation.reset();
updateRoute('');
}}
cancelLabel=''
footer={false}
okLabel=''
size='md'
title='Followers'
topRightContent='close'
>
<div className='mt-3 flex flex-col gap-4 pb-12'>
<List>
{followers.map(item => (
<ListItem action={<Button color='grey' label='Follow back' link={true} onClick={() => mutation.mutate({username: getUsername(item)})} />} avatar={<Avatar image={item.icon} size='sm' />} detail={getUsername(item)} id='list-item' title={item.name}></ListItem>
))}
</List>
</div>
</Modal>
);
};
export default NiceModal.create(ViewFollowersModal);

View File

@ -0,0 +1,538 @@
import ActivityPubWelcomeImage from '../assets/images/ap-welcome.png';
import React, {useEffect, useRef, useState} from 'react';
import articleBodyStyles from './articleBodyStyles';
import getRelativeTimestamp from '../utils/get-relative-timestamp';
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, Icon, List, ListItem, Page, SelectOption, SettingValue, ViewContainer, ViewTab} from '@tryghost/admin-x-design-system';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import {useQuery} from '@tanstack/react-query';
import {useRouting} from '@tryghost/admin-x-framework/routing';
interface ViewArticleProps {
object: ObjectProperties,
onBackToList: () => void;
}
type Activity = {
type: string,
object: {
type: string
}
}
export function useBrowseInboxForUser(handle: string) {
const site = useBrowseSite();
const siteData = site.data?.site;
const siteUrl = siteData?.url ?? window.location.origin;
const api = new ActivityPubAPI(
new URL(siteUrl),
new URL('/ghost/api/admin/identities/', window.location.origin),
handle
);
return useQuery({
queryKey: [`inbox:${handle}`],
async queryFn() {
return api.getInbox();
}
});
}
function useFollowersCountForUser(handle: string) {
const site = useBrowseSite();
const siteData = site.data?.site;
const siteUrl = siteData?.url ?? window.location.origin;
const api = new ActivityPubAPI(
new URL(siteUrl),
new URL('/ghost/api/admin/identities/', window.location.origin),
handle
);
return useQuery({
queryKey: [`followersCount:${handle}`],
async queryFn() {
return api.getFollowersCount();
}
});
}
function useFollowingCountForUser(handle: string) {
const site = useBrowseSite();
const siteData = site.data?.site;
const siteUrl = siteData?.url ?? window.location.origin;
const api = new ActivityPubAPI(
new URL(siteUrl),
new URL('/ghost/api/admin/identities/', window.location.origin),
handle
);
return useQuery({
queryKey: [`followingCount:${handle}`],
async queryFn() {
return api.getFollowingCount();
}
});
}
const ActivityPubComponent: React.FC = () => {
const {updateRoute} = useRouting();
// TODO: Replace with actual user ID
const {data: activities = []} = useBrowseInboxForUser('index');
const {data: followersCount = 0} = useFollowersCountForUser('index');
const {data: followingCount = 0} = useFollowingCountForUser('index');
const [articleContent, setArticleContent] = useState<ObjectProperties | null>(null);
const [, setArticleActor] = useState<ActorProperties | null>(null);
const handleViewContent = (object: ObjectProperties, actor: ActorProperties) => {
setArticleContent(object);
setArticleActor(actor);
};
const handleBackToList = () => {
setArticleContent(null);
};
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='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}
type={activity.type}
/>
</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>
)
},
{
id: 'activity',
title: 'Activity',
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}
type={activity.object.type}
/>
</li>
))}
</ul>
</div>
)
},
{
id: 'likes',
title: 'Likes',
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>
)
}
];
return (
<>
<Page>
{!articleContent ? (
<ViewContainer
actions={[<ButtonGroup buttons={[
{
icon: 'listview',
size: 'sm',
iconColorClass: selectedOption.value === 'feed' ? 'text-black' : 'text-grey-500',
onClick: () => {
setSelectedOption({label: 'Feed', value: 'feed'});
}
},
{
icon: 'cardview',
size: 'sm',
iconColorClass: selectedOption.value === 'inbox' ? 'text-black' : 'text-grey-500',
onClick: () => {
setSelectedOption({label: 'Inbox', value: 'inbox'});
}
}
]} clearBg={true} link outlineOnMobile />]}
firstOnPage={true}
primaryAction={{
title: 'Follow',
onClick: () => {
updateRoute('follow-site');
},
icon: 'add'
}}
selectedTab={selectedTab}
stickyHeader={true}
tabs={tabs}
title='ActivityPub'
toolbarBorder={true}
type='page'
onTabChange={setSelectedTab}
>
</ViewContainer>
) : (
<ViewArticle object={articleContent} onBackToList={handleBackToList} />
)}
</Page>
</>
);
};
const ArticleBody: React.FC<{heading: string, image: string|undefined, html: string}> = ({heading, image, html}) => {
const site = useBrowseSite();
const siteData = site.data?.site;
const iframeRef = useRef<HTMLIFrameElement>(null);
const cssContent = articleBodyStyles(siteData?.url.replace(/\/$/, ''));
const htmlContent = `
<html>
<head>
${cssContent}
</head>
<body>
<header class='gh-article-header gh-canvas'>
<h1 class='gh-article-title is-title' data-test-article-heading>${heading}</h1>
${image &&
`<figure class='gh-article-image'>
<img src='${image}' alt='${heading}' />
</figure>`
}
</header>
<div class='gh-content gh-canvas is-body'>
${html}
</div>
</body>
</html>
`;
useEffect(() => {
const iframe = iframeRef.current;
if (iframe) {
iframe.srcdoc = htmlContent;
}
}, [htmlContent]);
return (
<div>
<iframe
ref={iframeRef}
className={`h-[calc(100vh_-_3vmin_-_4.8rem_-_2px)]`}
height='100%'
id='gh-ap-article-iframe'
title='Embedded Content'
width='100%'
>
</iframe>
</div>
);
};
function renderAttachment(object: ObjectProperties) {
let attachment;
if (object.image) {
attachment = object.image;
}
if (object.type === 'Note' && !attachment) {
attachment = object.attachment;
}
if (!attachment) {
return null;
}
if (Array.isArray(attachment)) {
const attachmentCount = attachment.length;
let gridClass = '';
if (attachmentCount === 1) {
gridClass = 'grid-cols-1'; // Single image, full width
} else if (attachmentCount === 2) {
gridClass = 'grid-cols-2'; // Two images, side by side
} else if (attachmentCount === 3 || attachmentCount === 4) {
gridClass = 'grid-cols-2'; // Three or four images, two per row
}
return (
<div className={`attachment-gallery mt-2 grid auto-rows-[150px] ${gridClass} gap-2`}>
{attachment.map((item, index) => (
<img key={item.url} alt={`attachment-${index}`} className={`h-full w-full rounded-md object-cover ${attachmentCount === 3 && index === 0 ? 'row-span-2' : ''}`} 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}/>
</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 ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProperties, layout: string, type: string }> = ({actor, object, layout, type}) => {
const parser = new DOMParser();
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 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 date = new Date(object?.published ?? new Date());
const [isClicked, setIsClicked] = useState(false);
const [isLiked, setIsLiked] = useState(false);
const handleLikeClick = (event: React.MouseEvent<HTMLElement> | undefined) => {
event?.stopPropagation();
setIsClicked(true);
setIsLiked(!isLiked);
setTimeout(() => setIsClicked(false), 300); // Reset the animation class after 300ms
};
let author = actor;
if (type === 'Announce' && object.type === 'Note') {
author = typeof object.attributedTo === 'object' ? object.attributedTo as ActorProperties : actor;
}
if (layout === 'feed') {
return (
<>
{object && (
<div className='group/article relative cursor-pointer pt-4'>
{(type === 'Announce' && object.type === 'Note') && <div className='z-10 mb-2 flex items-center gap-3 text-grey-700'>
<div className='z-10 flex w-10 justify-end'><Icon colorClass='text-grey-700' name='reload' size={'sm'}></Icon></div>
<span className='z-10'>{actor.name} reposted</span>
</div>}
<div className='flex items-start gap-3'>
<img className='z-10 w-10 rounded' src={author.icon?.url}/>
<div className='border-1 z-10 -mt-1 flex w-full flex-col items-start justify-between border-b border-b-grey-200 pb-4' data-test-activity>
<div className='relative z-10 mb-2 flex w-full flex-col overflow-visible text-[1.5rem]'>
<div className='flex'>
<span className='truncate whitespace-nowrap font-bold' data-test-activity-heading>{author.name}</span>
<span className='whitespace-nowrap text-grey-700 before:mx-1 before:content-["·"]' title={`${timestamp}`}>{getRelativeTimestamp(date)}</span>
</div>
<div className='flex'>
<span className='truncate text-grey-700'>{getUsername(author)}</span>
</div>
</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>}
<div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.5rem] text-grey-900'></div>
{/* <p className='text-pretty text-md text-grey-900'>{object.content}</p> */}
{renderAttachment(object)}
<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>
<div className='absolute -inset-x-3 -inset-y-0 z-0 rounded transition-colors group-hover/article:bg-grey-100'></div>
</div>
)}
</>
);
} else if (layout === 'inbox') {
return (
<>
{object && (
<div className='border-1 group/article relative z-10 flex cursor-pointer flex-col items-start justify-between border-b border-b-grey-200 py-5' data-test-activity>
<div className='relative z-10 mb-3 grid w-full grid-cols-[20px_auto_1fr_auto] items-center gap-2 text-base'>
<img className='w-5' src={actor.icon?.url}/>
<span className='truncate font-semibold'>{actor.name}</span>
{/* <span className='truncate text-grey-800'>{getUsername(actor)}</span> */}
<span className='ml-auto text-right text-grey-800'>{timestamp}</span>
</div>
<div className='relative z-10 grid w-full grid-cols-[auto_170px] gap-4'>
<div className='flex flex-col'>
<div className='flex w-full justify-between gap-4'>
<Heading className='mb-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'>{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>
{/* {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> */}
</div>
)}
</>
);
}
};
const ViewArticle: React.FC<ViewArticleProps> = ({object, onBackToList}) => {
const {updateRoute} = useRouting();
const [isClicked, setIsClicked] = useState(false);
const [isLiked, setIsLiked] = useState(false);
const handleLikeClick = (event: React.MouseEvent<HTMLElement> | undefined) => {
event?.stopPropagation();
setIsClicked(true);
setIsLiked(!isLiked);
setTimeout(() => setIsClicked(false), 300); // Reset the animation class after 300ms
};
return (
<Page>
<ViewContainer
toolbarBorder={false}
type='page'
>
<div className='grid grid-cols-[1fr_minmax(320px,_700px)_1fr] gap-x-6 pb-4'>
<div>
<Button icon='chevron-left' iconSize='xs' label='Inbox' data-test-back-button onClick={onBackToList}/>
</div>
<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'>
<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>
<Button hideLabel={true} icon='arrow-top-right' iconSize='xs' label='Visit site' onClick={() => updateRoute('/')}/>
</div>
</div>
<div className='mx-[-4.8rem] mb-[-4.8rem] w-auto'>
{object.type === 'Note' && (
<div className='mx-auto max-w-[600px]'>
{object.content && <div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.5rem] text-grey-900'></div>}
{renderAttachment(object)}
</div>)}
{object.type === 'Article' && <ArticleBody heading={object.name} html={object.content} image={object?.image}/>}
</div>
</ViewContainer>
</Page>
);
};
export default ActivityPubComponent;

View File

@ -0,0 +1,27 @@
import React, {ReactNode} from 'react';
export type Activity = {
type: string,
object: {
type: string
}
}
interface ActivityItemProps {
children?: ReactNode;
}
const ActivityItem: React.FC<ActivityItemProps> = ({children}) => {
const childrenArray = React.Children.toArray(children);
return (
<div className='flex w-full max-w-[560px] flex-col'>
<div className='flex w-full items-center gap-3 border-b border-grey-100 py-4'>
{childrenArray[0]}
{childrenArray[1]}
</div>
</div>
);
};
export default ActivityItem;

View File

@ -0,0 +1,94 @@
import MainHeader from '../navigation/MainHeader';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React, {useEffect, useRef} from 'react';
import articleBodyStyles from '../articleBodyStyles';
import {Button, Modal} from '@tryghost/admin-x-design-system';
import {ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {renderAttachment} from './FeedItem';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
interface ArticleModalProps {
object: ObjectProperties;
}
const ArticleBody: React.FC<{heading: string, image: string|undefined, html: string}> = ({heading, image, html}) => {
const site = useBrowseSite();
const siteData = site.data?.site;
const iframeRef = useRef<HTMLIFrameElement>(null);
const cssContent = articleBodyStyles(siteData?.url.replace(/\/$/, ''));
const htmlContent = `
<html>
<head>
${cssContent}
</head>
<body>
<header class='gh-article-header gh-canvas'>
<h1 class='gh-article-title is-title' data-test-article-heading>${heading}</h1>
${image &&
`<figure class='gh-article-image'>
<img src='${image}' alt='${heading}' />
</figure>`
}
</header>
<div class='gh-content gh-canvas is-body'>
${html}
</div>
</body>
</html>
`;
useEffect(() => {
const iframe = iframeRef.current;
if (iframe) {
iframe.srcdoc = htmlContent;
}
}, [htmlContent]);
return (
<div>
<iframe
ref={iframeRef}
className={`h-[calc(100vh_-_3vmin_-_4.8rem_-_2px)]`}
height='100%'
id='gh-ap-article-iframe'
title='Embedded Content'
width='100%'
>
</iframe>
</div>
);
};
const ArticleModal: React.FC<ArticleModalProps> = ({object}) => {
const modal = useModal();
return (
<Modal
align='right'
animate={true}
footer={<></>}
height={'full'}
padding={false}
size='bleed'
width={640}
>
<MainHeader>
<div className='col-[3/4] flex items-center justify-end px-8'>
<Button icon='close' size='sm' unstyled onClick={() => modal.remove()}/>
</div>
</MainHeader>
<div className='mt-10 w-auto'>
{object.type === 'Note' && (
<div className='mx-auto max-w-[580px]'>
{object.content && <div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.5rem] text-grey-900'></div>}
{renderAttachment(object)}
</div>)}
{object.type === 'Article' && <ArticleBody heading={object.name} html={object.content} image={object?.image}/>}
</div>
</Modal>
);
};
export default NiceModal.create(ArticleModal);

View File

@ -0,0 +1,179 @@
import APAvatar from '../global/APAvatar';
import React, {useState} from 'react';
import getRelativeTimestamp from '../../utils/get-relative-timestamp';
import getUsername from '../../utils/get-username';
import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, Heading, Icon} from '@tryghost/admin-x-design-system';
export function renderAttachment(object: ObjectProperties) {
let attachment;
if (object.image) {
attachment = object.image;
}
if (object.type === 'Note' && !attachment) {
attachment = object.attachment;
}
if (!attachment) {
return null;
}
if (Array.isArray(attachment)) {
const attachmentCount = attachment.length;
let gridClass = '';
if (attachmentCount === 1) {
gridClass = 'grid-cols-1'; // Single image, full width
} else if (attachmentCount === 2) {
gridClass = 'grid-cols-2'; // Two images, side by side
} else if (attachmentCount === 3 || attachmentCount === 4) {
gridClass = 'grid-cols-2'; // Three or four images, two per row
}
return (
<div className={`attachment-gallery mt-2 grid auto-rows-[150px] ${gridClass} gap-2`}>
{attachment.map((item, index) => (
<img key={item.url} alt={`attachment-${index}`} className={`h-full w-full rounded-md object-cover ${attachmentCount === 3 && index === 0 ? 'row-span-2' : ''}`} 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}/>
</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;
}
}
interface FeedItemProps {
actor: ActorProperties;
object: ObjectProperties;
layout: string;
type: string;
}
const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type}) => {
const parser = new DOMParser();
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 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 date = new Date(object?.published ?? new Date());
const [isClicked, setIsClicked] = useState(false);
const [isLiked, setIsLiked] = useState(false);
const handleLikeClick = (event: React.MouseEvent<HTMLElement> | undefined) => {
event?.stopPropagation();
setIsClicked(true);
setIsLiked(!isLiked);
setTimeout(() => setIsClicked(false), 300); // Reset the animation class after 300ms
};
let author = actor;
if (type === 'Announce' && object.type === 'Note') {
author = typeof object.attributedTo === 'object' ? object.attributedTo as ActorProperties : actor;
}
if (layout === 'feed') {
return (
<>
{object && (
<div className='group/article relative cursor-pointer pt-4'>
{(type === 'Announce' && object.type === 'Note') && <div className='z-10 mb-2 flex items-center gap-3 text-grey-700'>
<div className='z-10 flex w-10 justify-end'><Icon colorClass='text-grey-700' name='reload' size={'sm'}></Icon></div>
<span className='z-10'>{actor.name} reposted</span>
</div>}
<div className='flex items-start gap-3'>
<APAvatar author={author} />
<div className='border-1 z-10 -mt-1 flex w-full flex-col items-start justify-between border-b border-b-grey-200 pb-4' data-test-activity>
<div className='relative z-10 mb-2 flex w-full flex-col overflow-visible text-[1.5rem]'>
<div className='flex'>
<span className='truncate whitespace-nowrap font-bold' data-test-activity-heading>{author.name}</span>
<span className='whitespace-nowrap text-grey-700 before:mx-1 before:content-["·"]' title={`${timestamp}`}>{getRelativeTimestamp(date)}</span>
</div>
<div className='flex'>
<span className='truncate text-grey-700'>{getUsername(author)}</span>
</div>
</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>}
<div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.5rem] text-grey-900'></div>
{/* <p className='text-pretty text-md text-grey-900'>{object.content}</p> */}
{renderAttachment(object)}
<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>
<div className='absolute -inset-x-3 -inset-y-0 z-0 rounded transition-colors group-hover/article:bg-grey-75'></div>
</div>
)}
</>
);
} else if (layout === 'inbox') {
return (
<>
{object && (
<div className='border-1 group/article relative z-10 flex cursor-pointer flex-col items-start justify-between border-b border-b-grey-200 py-5' data-test-activity>
<div className='relative z-10 mb-3 grid w-full grid-cols-[20px_auto_1fr_auto] items-center gap-2 text-base'>
<img className='w-5' src={actor.icon?.url}/>
<span className='truncate font-semibold'>{actor.name}</span>
{/* <span className='truncate text-grey-800'>{getUsername(actor)}</span> */}
<span className='ml-auto text-right text-grey-800'>{timestamp}</span>
</div>
<div className='relative z-10 grid w-full grid-cols-[auto_170px] gap-4'>
<div className='flex flex-col'>
<div className='flex w-full justify-between gap-4'>
<Heading className='mb-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'>{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>
{/* {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> */}
</div>
)}
</>
);
}
};
export default FeedItem;

View File

@ -0,0 +1,17 @@
import React from 'react';
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Icon} from '@tryghost/admin-x-design-system';
interface APAvatarProps {
author?: ActorProperties;
}
const APAvatar: React.FC<APAvatarProps> = ({author}) => {
return (
<>
{author && author!.icon?.url ? <img className='z-10 w-10 rounded' src={author!.icon?.url}/> : <div className='z-10 rounded bg-grey-100 p-[10px]'><Icon colorClass='text-grey-600' name='user' size={18} /></div>}
</>
);
};
export default APAvatar;

View File

@ -0,0 +1,75 @@
import NiceModal from '@ebay/nice-modal-react';
import {ActivityPubAPI} from '../../api/activitypub';
import {Modal, TextField, showToast} from '@tryghost/admin-x-design-system';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import {useMutation} from '@tanstack/react-query';
import {useRouting} from '@tryghost/admin-x-framework/routing';
import {useState} from 'react';
function useFollow(handle: string, onSuccess: () => void, onError: () => void) {
const site = useBrowseSite();
const siteData = site.data?.site;
const siteUrl = siteData?.url ?? window.location.origin;
const api = new ActivityPubAPI(
new URL(siteUrl),
new URL('/ghost/api/admin/identities/', window.location.origin),
handle
);
return useMutation({
async mutationFn(username: string) {
return api.follow(username);
},
onSuccess,
onError
});
}
const FollowSite = NiceModal.create(() => {
const {updateRoute} = useRouting();
const modal = NiceModal.useModal();
const [profileName, setProfileName] = useState('');
const [errorMessage, setError] = useState(null);
async function onSuccess() {
showToast({
message: 'Site followed',
type: 'success'
});
modal.remove();
updateRoute('');
}
async function onError() {
setError(errorMessage);
}
const mutation = useFollow('index', onSuccess, onError);
return (
<Modal
afterClose={() => {
mutation.reset();
updateRoute('');
}}
cancelLabel='Cancel'
okLabel='Follow'
size='sm'
title='Follow a Ghost site'
onOk={() => mutation.mutate(profileName)}
>
<div className='mt-3 flex flex-col gap-4'>
<TextField
autoFocus={true}
error={Boolean(errorMessage)}
hint={errorMessage}
placeholder='@username@hostname'
title='Profile name'
value={profileName}
data-test-new-follower
onChange={e => setProfileName(e.target.value)}
/>
</div>
</Modal>
);
});
export default FollowSite;

View File

@ -1,6 +1,6 @@
import FollowSite from './FollowSite';
import ViewFollowers from './ViewFollowers';
import ViewFollowing from './ViewFollowing';
import FollowSite from './inbox/FollowSiteModal';
import ViewFollowers from './profile/ViewFollowersModal';
import ViewFollowing from './profile/ViewFollowingModal';
import {ModalComponent} from '@tryghost/admin-x-framework/routing';
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@ -0,0 +1,17 @@
import React, {ReactNode} from 'react';
interface MainHeaderProps {
children?: ReactNode;
}
const MainHeader: React.FC<MainHeaderProps> = ({children}) => {
return (
<div className='sticky top-0 z-50 border-b border-grey-200 bg-white py-8'>
<div className='grid h-8 grid-cols-3'>
{children}
</div>
</div>
);
};
export default MainHeader;

View File

@ -0,0 +1,29 @@
import MainHeader from './MainHeader';
import React from 'react';
import {Button} from '@tryghost/admin-x-design-system';
import {useRouting} from '@tryghost/admin-x-framework/routing';
interface MainNavigationProps {}
const MainNavigation: React.FC<MainNavigationProps> = ({}) => {
const {route, updateRoute} = useRouting();
const mainRoute = route.split('/')[0];
return (
<MainHeader>
<div className='col-[2/3] flex items-center justify-center gap-9'>
<Button icon='home' iconColorClass={mainRoute === '' ? 'text-black' : 'text-grey-500'} iconSize={18} unstyled onClick={() => updateRoute('')} />
<Button icon='magnifying-glass' iconColorClass={mainRoute === 'search' ? 'text-black' : 'text-grey-500'} iconSize={18} unstyled onClick={() => updateRoute('search')} />
<Button icon='bell' iconColorClass={mainRoute === 'activity' ? 'text-black' : 'text-grey-500'} iconSize={18} unstyled onClick={() => updateRoute('activity')} />
<Button icon='user' iconColorClass={mainRoute === 'profile' ? 'text-black' : 'text-grey-500'} iconSize={18} unstyled onClick={() => updateRoute('profile')} />
</div>
<div className='col-[3/4] flex items-center justify-end px-8'>
<Button color='black' icon='add' label="Follow" onClick={() => {
updateRoute('follow-site');
}} />
</div>
</MainHeader>
);
};
export default MainNavigation;

View File

@ -0,0 +1,74 @@
import NiceModal from '@ebay/nice-modal-react';
import getUsername from '../../utils/get-username';
import {ActivityPubAPI} from '../../api/activitypub';
import {Avatar, Button, List, ListItem, Modal} from '@tryghost/admin-x-design-system';
import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import {useMutation, useQuery} from '@tanstack/react-query';
function useFollowersForUser(handle: string) {
const site = useBrowseSite();
const siteData = site.data?.site;
const siteUrl = siteData?.url ?? window.location.origin;
const api = new ActivityPubAPI(
new URL(siteUrl),
new URL('/ghost/api/admin/identities/', window.location.origin),
handle
);
return useQuery({
queryKey: [`followers:${handle}`],
async queryFn() {
return api.getFollowers();
}
});
}
function useFollow(handle: string) {
const site = useBrowseSite();
const siteData = site.data?.site;
const siteUrl = siteData?.url ?? window.location.origin;
const api = new ActivityPubAPI(
new URL(siteUrl),
new URL('/ghost/api/admin/identities/', window.location.origin),
handle
);
return useMutation({
async mutationFn(username: string) {
return api.follow(username);
}
});
}
const ViewFollowersModal: React.FC<RoutingModalProps> = ({}) => {
const {updateRoute} = useRouting();
// const modal = NiceModal.useModal();
const mutation = useFollow('index');
const {data: items = []} = useFollowersForUser('index');
const followers = Array.isArray(items) ? items : [items];
return (
<Modal
afterClose={() => {
mutation.reset();
updateRoute('profile');
}}
cancelLabel=''
footer={false}
okLabel=''
size='md'
title='Followers'
topRightContent='close'
>
<div className='mt-3 flex flex-col gap-4 pb-12'>
<List>
{followers.map(item => (
<ListItem action={<Button color='grey' label='Follow back' link={true} onClick={() => mutation.mutate(getUsername(item))} />} avatar={<Avatar image={item.icon} size='sm' />} detail={getUsername(item)} id='list-item' title={item.name}></ListItem>
))}
</List>
</div>
</Modal>
);
};
export default NiceModal.create(ViewFollowersModal);

View File

@ -1,27 +1,38 @@
import {} from '@tryghost/admin-x-framework/api/activitypub';
import NiceModal from '@ebay/nice-modal-react';
import getUsername from '../utils/get-username';
import getUsername from '../../utils/get-username';
import {ActivityPubAPI} from '../../api/activitypub';
import {Avatar, Button, List, ListItem, Modal} from '@tryghost/admin-x-design-system';
import {FollowingResponseData, useBrowseFollowingForUser, useUnfollow} from '@tryghost/admin-x-framework/api/activitypub';
import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import {useQuery} from '@tanstack/react-query';
interface ViewFollowingModalProps {
following: FollowingResponseData[],
animate?: boolean
function useFollowingForUser(handle: string) {
const site = useBrowseSite();
const siteData = site.data?.site;
const siteUrl = siteData?.url ?? window.location.origin;
const api = new ActivityPubAPI(
new URL(siteUrl),
new URL('/ghost/api/admin/identities/', window.location.origin),
handle
);
return useQuery({
queryKey: [`following:${handle}`],
async queryFn() {
return api.getFollowing();
}
});
}
const ViewFollowingModal: React.FC<RoutingModalProps & ViewFollowingModalProps> = ({}) => {
const ViewFollowingModal: React.FC<RoutingModalProps> = ({}) => {
const {updateRoute} = useRouting();
const mutation = useUnfollow();
const {data: {items = []} = {}} = useBrowseFollowingForUser('inbox');
const {data: items = []} = useFollowingForUser('index');
const following = Array.isArray(items) ? items : [items];
return (
<Modal
afterClose={() => {
mutation.reset();
updateRoute('');
updateRoute('profile');
}}
cancelLabel=''
footer={false}
@ -33,7 +44,7 @@ const ViewFollowingModal: React.FC<RoutingModalProps & ViewFollowingModalProps>
<div className='mt-3 flex flex-col gap-4 pb-12'>
<List>
{following.map(item => (
<ListItem action={<Button color='grey' label='Unfollow' link={true} onClick={() => mutation.mutate({username: getUsername(item)})} />} avatar={<Avatar image={item.icon} size='sm' />} detail={getUsername(item)} id='list-item' title={item.name}></ListItem>
<ListItem action={<Button color='grey' label='Unfollow' link={true} />} avatar={<Avatar image={item.icon} size='sm' />} detail={getUsername(item)} id='list-item' title={item.name}></ListItem>
))}
</List>
{/* <Table>

View File

@ -23,3 +23,17 @@ animation: bump 0.3s ease-in-out;
.ap-red-heart path {
fill: #F50B23;
}
.ap-note-content a {
color: rgb(236 72 153) !important;
}
.ap-note-content a:hover {
color: rgb(190, 25, 99) !important;
text-decoration: underline !important;
}
.ap-note-content p + p {
margin-top: 1.5rem !important;
}

View File

@ -0,0 +1,33 @@
export const getRelativeTimestamp = (date: Date): string => {
const now = new Date();
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
let interval = Math.floor(seconds / 31536000);
if (interval > 1) {
return `${interval}y`;
}
interval = Math.floor(seconds / 2592000);
if (interval > 1) {
return `${interval}m`;
}
interval = Math.floor(seconds / 86400);
if (interval >= 1) {
return `${interval}d`;
}
interval = Math.floor(seconds / 3600);
if (interval > 1) {
return `${interval}h`;
}
interval = Math.floor(seconds / 60);
if (interval > 1) {
return `${interval}m`;
}
return `${seconds} seconds`;
};
export default getRelativeTimestamp;

View File

@ -5,6 +5,14 @@ import {resolve} from 'path';
export default (function viteConfig() {
return adminXViteConfig({
packageName: pkg.name,
entry: resolve(__dirname, 'src/index.tsx')
entry: resolve(__dirname, 'src/index.tsx'),
overrides: {
test: {
include: [
'./test/unit/**/*',
'./src/**/*.test.ts'
]
}
}
});
});

View File

@ -32,7 +32,7 @@
"preview": "vite preview"
},
"devDependencies": {
"@testing-library/react": "14.1.0",
"@testing-library/react": "14.3.1",
"@tryghost/admin-x-design-system": "0.0.0",
"@tryghost/admin-x-framework": "0.0.0",
"@types/react": "18.3.3",

View File

@ -11,7 +11,8 @@
"scripts": {
"build": "concurrently \"vite build\" \"tsc -p tsconfig.declaration.json\"",
"prepare": "yarn build",
"test": "yarn test:types",
"test": "yarn test:unit && yarn test:types",
"test:unit": "yarn nx build && vitest run",
"test:types": "tsc --noEmit",
"lint:code": "eslint --ext .js,.ts,.cjs,.tsx src/ --cache",
"lint": "yarn lint:code && yarn lint:test",
@ -35,14 +36,16 @@
"@storybook/react": "7.6.20",
"@storybook/react-vite": "7.6.4",
"@storybook/testing-library": "0.2.2",
"@testing-library/react": "14.1.0",
"@testing-library/react": "14.3.1",
"@testing-library/react-hooks" : "8.0.1",
"@vitejs/plugin-react": "4.2.1",
"c8": "8.0.1",
"eslint-plugin-react-hooks": "4.6.0",
"eslint-plugin-react-refresh": "0.4.3",
"eslint-plugin-tailwindcss": "3.13.0",
"jsdom": "24.1.0",
"jsdom": "24.1.1",
"mocha": "10.2.0",
"chai": "4.3.8",
"react": "18.3.1",
"react-dom": "18.3.1",
"rollup-plugin-node-builtins": "2.1.2",
@ -57,7 +60,7 @@
"@dnd-kit/core": "6.1.0",
"@dnd-kit/sortable": "7.0.2",
"@ebay/nice-modal-react": "1.2.13",
"@sentry/react": "7.118.0",
"@sentry/react": "7.119.0",
"@tailwindcss/forms": "0.5.7",
"@tailwindcss/line-clamp": "0.4.4",
"@uiw/react-codemirror": "4.23.0",
@ -68,7 +71,7 @@
"react-colorful": "5.6.1",
"react-hot-toast": "2.4.1",
"react-select": "5.8.0",
"tailwindcss": "3.4.4"
"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

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="Alarm-Bell--Streamline-Streamline--3.0" height="24" width="24"><desc>Alarm Bell Streamline Icon: https://streamlinehq.com</desc><defs></defs><title>alarm-bell</title><path d="M10 21.75a2.087 2.087 0 0 0 4.005 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="m12 3 0 -2.25" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="M12 3a7.5 7.5 0 0 1 7.5 7.5c0 7.046 1.5 8.25 1.5 8.25H3s1.5 -1.916 1.5 -8.25A7.5 7.5 0 0 1 12 3Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path></svg>

After

Width:  |  Height:  |  Size: 734 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="House-Entrance--Streamline-Streamline--3.0" height="24" width="24"><desc>House Entrance Streamline Icon: https://streamlinehq.com</desc><defs></defs><title>house-entrance</title><path d="M22.868 8.947 12 0.747l-10.878 8.2a1.177 1.177 0 0 0 -0.377 0.8v12.522a0.981 0.981 0 0 0 0.978 0.978h6.522V18a3.75 3.75 0 0 1 7.5 0v5.25h6.521a0.982 0.982 0 0 0 0.979 -0.978V9.747a1.181 1.181 0 0 0 -0.377 -0.8Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path></svg>

After

Width:  |  Height:  |  Size: 576 B

View File

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" id="Button-Refresh-Arrows--Streamline-Ultimate.svg" height="24" width="24"><desc>Button Refresh Arrows Streamline Icon: https://streamlinehq.com</desc><path d="m5.25 14.248 0 4.5 -4.5 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="m18.75 9.748 0 -4.5 4.5 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="M19.032 5.245A9.752 9.752 0 0 1 8.246 21" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="M4.967 18.751A9.753 9.753 0 0 1 15.754 3" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path></svg>

After

Width:  |  Height:  |  Size: 819 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="Share-1--Streamline-Streamline--3.0.svg" height="24" width="24"><desc>Share 1 Streamline Icon: https://streamlinehq.com</desc><defs></defs><title>share-1</title><path d="M17.25 8.25h1.5a1.5 1.5 0 0 1 1.5 1.5v12a1.5 1.5 0 0 1 -1.5 1.5H5.25a1.5 1.5 0 0 1 -1.5 -1.5v-12a1.5 1.5 0 0 1 1.5 -1.5h1.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="m12 0.75 0 10.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="M8.25 4.5 12 0.75l3.75 3.75" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path></svg>

After

Width:  |  Height:  |  Size: 750 B

View File

@ -0,0 +1 @@
<svg id="Single-Neutral--Streamline-Streamline--3.0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><desc>Single Neutral Streamline Icon: https://streamlinehq.com</desc><defs></defs><title>single-neutral</title><path d="M6.75 6a5.25 5.25 0 1 0 10.5 0 5.25 5.25 0 1 0 -10.5 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="M2.25 23.25a9.75 9.75 0 0 1 19.5 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path></svg>

After

Width:  |  Height:  |  Size: 572 B

View File

@ -30,42 +30,50 @@ export interface IconProps {
const Icon: React.FC<IconProps> = ({name, size = 'md', colorClass = '', className = ''}) => {
const {ReactComponent: SvgComponent} = icons[`../assets/icons/${name}.svg`];
let styles = '';
let classes = '';
let styles = {};
if (!styles) {
if (typeof size === 'number') {
styles = {
width: `${size}px`,
height: `${size}px`
};
}
if (!classes) {
switch (size) {
case 'custom':
break;
case '2xs':
styles = 'w-2 h-2';
classes = 'w-2 h-2';
break;
case 'xs':
styles = 'w-3 h-3';
classes = 'w-3 h-3';
break;
case 'sm':
styles = 'w-4 h-4';
classes = 'w-4 h-4';
break;
case 'lg':
styles = 'w-8 h-8';
classes = 'w-8 h-8';
break;
case 'xl':
styles = 'w-10 h-10';
classes = 'w-10 h-10';
break;
default:
styles = 'w-5 h-5';
classes = 'w-5 h-5';
break;
}
}
styles = clsx(
styles,
classes = clsx(
classes,
colorClass
);
if (SvgComponent) {
return (
<SvgComponent className={`pointer-events-none ${styles} ${className}`} />
<SvgComponent className={`pointer-events-none ${classes} ${className}`} style={styles} />
);
}
return null;

View File

@ -139,7 +139,10 @@ const SortableList = <Item extends {id: string}>({
<div className={`${title && titleSeparator ? '-mt-2' : ''}`}>
<DndContext
collisionDetection={closestCenter}
onDragEnd={event => onMove(event.active.id as string, event.over?.id as string)}
onDragEnd={(event) => {
onMove(event.active.id as string, event.over?.id as string);
setDraggingId(null);
}}
onDragStart={event => setDraggingId(event.active.id as string)}
>
<Wrapper>

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

@ -203,6 +203,32 @@ export const CustomButtons: Story = {
}
};
export const RightDrawer: Story = {
args: {
size: 'bleed',
align: 'right',
animate: false,
width: 600,
footer: <></>,
children: <>
<p>This is a drawer style on the right</p>
</>
}
};
export const LeftDrawer: Story = {
args: {
size: 'bleed',
align: 'left',
animate: false,
width: 600,
footer: <></>,
children: <>
<p>This is a drawer style on the right</p>
</>
}
};
const longContent = (
<>
<p className='mb-6'>Esse ex officia ipsum et magna reprehenderit ullamco dolore cillum cupidatat ullamco culpa. In et irure irure est id cillum officia pariatur et proident. Nulla nulla dolore qui excepteur magna eu adipisicing mollit. Eiusmod eu irure cupidatat consequat consectetur irure.</p>

View File

@ -18,6 +18,7 @@ export interface ModalProps {
size?: ModalSize;
width?: 'full' | number;
height?: 'full' | number;
align?: 'center' | 'left' | 'right';
testId?: string;
title?: string;
@ -52,6 +53,7 @@ export const topLevelBackdropClasses = 'bg-[rgba(98,109,121,0.2)] backdrop-blur-
const Modal: React.FC<ModalProps> = ({
size = 'md',
align = 'center',
width,
height,
testId,
@ -188,10 +190,14 @@ const Modal: React.FC<ModalProps> = ({
}
let modalClasses = clsx(
'relative z-50 mx-auto flex max-h-[100%] w-full flex-col justify-between overflow-x-hidden bg-white dark:bg-black',
'relative z-50 flex max-h-[100%] w-full flex-col justify-between overflow-x-hidden bg-white dark:bg-black',
align === 'center' && 'mx-auto',
align === 'left' && 'mr-auto',
align === 'right' && 'ml-auto',
size !== 'bleed' && 'rounded',
formSheet ? 'shadow-md' : 'shadow-xl',
(animate && !formSheet && !animationFinished) && 'animate-modal-in',
(animate && !formSheet && !animationFinished && align === 'center') && 'animate-modal-in',
(animate && !formSheet && !animationFinished && align === 'right') && 'animate-modal-in-from-right',
(formSheet && !animationFinished) && 'animate-modal-in-reverse',
scrolling ? 'overflow-y-auto' : 'overflow-y-hidden'
);

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

@ -97,3 +97,4 @@
.gh-prose-links a {
color: #30CF43;
}

View File

@ -18,6 +18,7 @@ module.exports = {
colors: {
transparent: 'transparent',
current: 'currentColor',
accent: 'var(--accent-color, #ff0095)',
white: '#FFF',
black: '#15171A',
grey: {
@ -165,6 +166,16 @@ module.exports = {
transform: 'translateY(0px)'
}
},
modalInFromRight: {
'0%': {
transform: 'translateX(32px)',
opacity: '0'
},
'100%': {
transform: 'translateX(0px)',
opacity: '1'
}
},
modalInReverse: {
'0%': {
transform: 'translateY(-32px)'
@ -191,6 +202,7 @@ module.exports = {
'setting-highlight-fade-out': 'fadeOut 0.2s 1.4s ease forwards',
'modal-backdrop-in': 'fadeIn 0.15s ease forwards',
'modal-in': 'modalIn 0.25s ease forwards',
'modal-in-from-right': 'modalInFromRight 0.25s ease forwards',
'modal-in-reverse': 'modalInReverse 0.25s ease forwards',
spin: 'spin 1s linear infinite'
},

View File

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

View File

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

View File

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

View File

@ -68,12 +68,12 @@
"types"
],
"devDependencies": {
"@testing-library/react": "14.1.0",
"@testing-library/react": "14.3.1",
"@types/mocha": "10.0.1",
"c8": "8.0.1",
"eslint-plugin-react-hooks": "4.6.0",
"eslint-plugin-react-refresh": "0.4.3",
"jsdom": "24.1.0",
"jsdom": "24.1.1",
"mocha": "10.2.0",
"react": "18.3.1",
"react-dom": "18.3.1",
@ -82,7 +82,7 @@
"typescript": "5.4.5"
},
"dependencies": {
"@sentry/react": "7.118.0",
"@sentry/react": "7.119.0",
"@tanstack/react-query": "4.36.1",
"@tryghost/admin-x-design-system": "0.0.0",
"@types/react": "18.3.3",

View File

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

View File

@ -39,7 +39,7 @@
"dependencies": {
"@codemirror/lang-html": "6.4.9",
"@tryghost/color-utils": "0.2.2",
"@tryghost/kg-unsplash-selector": "0.2.1",
"@tryghost/kg-unsplash-selector": "0.2.3",
"@tryghost/limit-service": "1.2.14",
"@tryghost/nql": "0.12.3",
"@tryghost/timezone-data": "0.4.3",
@ -49,7 +49,7 @@
},
"devDependencies": {
"@playwright/test": "1.38.1",
"@testing-library/react": "14.1.0",
"@testing-library/react": "14.3.1",
"@tryghost/admin-x-design-system": "0.0.0",
"@tryghost/admin-x-framework": "0.0.0",
"@types/react": "18.3.3",

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}>
{/* 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

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

View File

@ -59,6 +59,14 @@ const features = [{
title: 'Content Visibility',
description: 'Enables content visibility in Emails',
flag: 'contentVisibility'
},{
title: 'Publish Flow — End Screen',
description: 'Enables improved publish flow',
flag: 'publishFlowEndScreen'
},{
title: 'Post Analytics — Refresh',
description: 'Adds a refresh button to the post analytics screen',
flag: 'postAnalyticsRefresh'
}];
const AlphaFeatures: React.FC = () => {

View File

@ -106,8 +106,15 @@ const Sidebar: React.FC<{
const {localSettings} = useSettingGroup();
const [siteTitle] = getSettingValues(localSettings, ['title']) as string[];
const handleError = useHandleError();
const {data: {newsletters: apiNewsletters} = {}} = useBrowseNewsletters();
let newsletterAddress = renderSenderEmail(newsletter, config, defaultEmailAddress);
const [newsletters, setNewsletters] = useState<Newsletter[]>(apiNewsletters || []);
const activeNewsletters = newsletters.filter(n => n.status === 'active');
useEffect(() => {
setNewsletters(apiNewsletters || []);
}, [apiNewsletters]);
const fontOptions: SelectOption[] = [
{value: 'serif', label: 'Elegant serif', className: 'font-serif'},
@ -129,8 +136,8 @@ const Sidebar: React.FC<{
NiceModal.show(ConfirmationModal, {
title: 'Archive newsletter',
prompt: <>
<p>Your newsletter <strong>{newsletter.name}</strong> will no longer be visible to members or available as an option when publishing new posts.</p>
<p>Existing posts previously sent as this newsletter will remain unchanged.</p>
<div className="mb-6">Your newsletter <strong>{newsletter.name}</strong> will no longer be visible to members or available as an option when publishing new posts.</div>
<div>Existing posts previously sent as this newsletter will remain unchanged.</div>
</>,
okLabel: 'Archive',
okColor: 'red',
@ -252,7 +259,7 @@ const Sidebar: React.FC<{
/>
</Form>
<div className='mb-5 mt-10'>
{newsletter.status === 'active' ? (!onlyOne && <Button color='red' label='Archive newsletter' link onClick={confirmStatusChange} />) : <Button color='green' label='Reactivate newsletter' link onClick={confirmStatusChange} />}
{newsletter.status === 'active' ? (!onlyOne && <Button color='red' disabled={activeNewsletters.length === 1} label='Archive newsletter' link onClick={confirmStatusChange}/>) : <Button color='green' label='Reactivate newsletter' link onClick={confirmStatusChange} />}
</div>
</>
},

View File

@ -3,13 +3,13 @@ import Offers from './Offers';
import React from 'react';
import Recommendations from './Recommendations';
import SearchableSection from '../../SearchableSection';
import TipsOrDonations from './TipsOrDonations';
import TipsAndDonations from './TipsAndDonations';
import useFeatureFlag from '../../../hooks/useFeatureFlag';
import {checkStripeEnabled} from '@tryghost/admin-x-framework/api/settings';
import {useGlobalData} from '../../providers/GlobalDataProvider';
export const searchKeywords = {
tips: ['growth', 'tip', 'donation', 'one time', 'payment'],
tips: ['growth', 'tips', 'donations', 'one time', 'payment'],
embedSignupForm: ['growth', 'embeddable signup form', 'embeddable form', 'embeddable sign up form', 'embeddable sign up'],
recommendations: ['growth', 'recommendations', 'recommend', 'blogroll'],
offers: ['growth', 'offers', 'discounts', 'coupons', 'promotions']
@ -25,7 +25,7 @@ const GrowthSettings: React.FC = () => {
<Recommendations keywords={searchKeywords.recommendations} />
<EmbedSignupForm keywords={searchKeywords.embedSignupForm} />
{hasStripeEnabled && <Offers keywords={searchKeywords.offers} />}
{hasTipsAndDonations && <TipsOrDonations keywords={searchKeywords.tips} />}
{hasTipsAndDonations && <TipsAndDonations keywords={searchKeywords.tips} />}
</SearchableSection>
);
};

View File

@ -13,7 +13,7 @@ const OfferContainer: React.FC<{offerTitle: string, tier: Tier, cadence: string,
{offerTitle, tier, cadence, redemptions, type, amount, currency, offerId, offerCode, goToOfferEdit}) => {
const {discountOffer} = getOfferDiscount(type, amount, cadence, currency || 'USD', tier);
return <div className='group flex h-full cursor-pointer flex-col justify-between gap-4 break-words rounded-sm border border-transparent bg-grey-100 p-5 transition-all hover:border-grey-100 hover:bg-grey-75 hover:shadow-sm dark:bg-grey-950 dark:hover:border-grey-800 min-[900px]:min-h-[187px]' onClick={() => goToOfferEdit(offerId)}>
<span className='text-[1.65rem] font-bold leading-tight tracking-tight'>{offerTitle}</span>
<span className='text-[1.65rem] font-bold leading-tight tracking-tight text-black dark:text-white'>{offerTitle}</span>
<div className='flex flex-col'>
<span className={`text-sm font-semibold uppercase`}>{discountOffer}</span>
<div className='flex gap-1 text-xs'>

View File

@ -0,0 +1,154 @@
import React, {useEffect, useState} from 'react';
import TopLevelGroup from '../../TopLevelGroup';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {Button, CurrencyField, Heading, Select, SettingGroupContent, confirmIfDirty, withErrorBoundary} from '@tryghost/admin-x-design-system';
import {currencySelectGroups, getSymbol, validateCurrencyAmount} from '../../../utils/currency';
import {getSettingValues} from '@tryghost/admin-x-framework/api/settings';
// Stripe doesn't allow amounts over 10,000 as a preset amount
const MAX_AMOUNT = 10_000;
const TipsAndDonations: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {
localSettings,
siteData,
updateSetting,
isEditing,
saveState,
handleSave,
handleCancel,
focusRef,
handleEditingChange,
errors,
validate,
clearError
} = useSettingGroup({
onValidate: () => {
return {
donationsSuggestedAmount: validateCurrencyAmount(suggestedAmountInCents, donationsCurrency, {maxAmount: MAX_AMOUNT})
};
}
});
const [donationsCurrency = 'USD', donationsSuggestedAmount = '0'] = getSettingValues<string>(
localSettings,
['donations_currency', 'donations_suggested_amount']
);
const suggestedAmountInCents = parseInt(donationsSuggestedAmount);
const suggestedAmountInDollars = suggestedAmountInCents / 100;
const donateUrl = `${siteData?.url.replace(/\/$/, '')}/#/portal/support`;
useEffect(() => {
validate();
}, [donationsCurrency]); // eslint-disable-line react-hooks/exhaustive-deps
const [copied, setCopied] = useState(false);
const copyDonateUrl = () => {
navigator.clipboard.writeText(donateUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const openPreview = () => {
confirmIfDirty(saveState === 'unsaved', () => window.open(donateUrl, '_blank'));
};
const values = (
<SettingGroupContent
columns={1}
values={[
{
heading: 'Suggested amount',
key: 'suggested-amount',
value: `${getSymbol(donationsCurrency)}${suggestedAmountInDollars}`
},
{
heading: '',
key: 'shareable-link',
value: (
<div className='w-100'>
<div className='flex items-center gap-2'>
<Heading level={6}>Shareable link</Heading>
</div>
<div className='w-100 group relative mt-0 flex items-center justify-between overflow-hidden border-b border-transparent pb-2 pt-1 hover:border-grey-300 dark:hover:border-grey-600'>
{donateUrl}
<div className='invisible flex gap-1 bg-white pl-1 group-hover:visible dark:bg-black'>
<Button color='clear' label={'Preview'} size='sm' onClick={openPreview} />
<Button color='light-grey' label={copied ? 'Copied' : 'Copy link'} size='sm' onClick={copyDonateUrl} />
</div>
</div>
</div>
)
}
]}
/>
);
const inputFields = (
<SettingGroupContent columns={1}>
<div className='flex max-w-[180px] items-end gap-[.6rem]'>
<CurrencyField
error={!!errors.donationsSuggestedAmount}
hint={errors.donationsSuggestedAmount}
inputRef={focusRef}
placeholder="5"
rightPlaceholder={(
<Select
border={false}
clearBg={true}
containerClassName='w-14'
fullWidth={false}
options={currencySelectGroups()}
selectedOption={currencySelectGroups().flatMap(group => group.options).find(option => option.value === donationsCurrency)}
title='Currency'
hideTitle
isSearchable
onSelect={option => updateSetting('donations_currency', option?.value || 'USD')}
/>
)}
title='Suggested amount'
valueInCents={parseInt(donationsSuggestedAmount)}
onBlur={validate}
onChange={cents => updateSetting('donations_suggested_amount', cents.toString())}
onKeyDown={() => clearError('donationsSuggestedAmount')}
/>
</div>
<div className='w-100'>
<div className='flex items-center gap-2'>
<Heading level={6}>Shareable link</Heading>
</div>
<div className='w-100 group relative mt-0 flex items-center justify-between overflow-hidden border-b border-transparent pb-2 pt-1 hover:border-grey-300 dark:hover:border-grey-600'>
{donateUrl}
<div className='invisible flex gap-1 bg-white pl-1 group-hover:visible dark:bg-black'>
<Button color='clear' label={'Preview'} size='sm' onClick={openPreview} />
<Button color='light-grey' label={copied ? 'Copied' : 'Copy link'} size='sm' onClick={copyDonateUrl} />
</div>
</div>
</div>
</SettingGroupContent>
);
return (
<TopLevelGroup
description="Give your audience a simple way to support your work with one-time payments."
isEditing={isEditing}
keywords={keywords}
navid='tips-and-donations'
saveState={saveState}
testId='tips-and-donations'
title="Tips & donations"
onCancel={handleCancel}
onEditingChange={handleEditingChange}
onSave={handleSave}
>
{isEditing ? inputFields : values}
<div className='items-center-mt-1 flex text-sm'>
All tips and donations are subject to Stripe&apos;s <a className='ml-1 text-green' href="https://ghost.org/help/tips-donations/" rel="noopener noreferrer" target="_blank"> tipping policy</a>.
</div>
</TopLevelGroup>
);
};
export default withErrorBoundary(TipsAndDonations, 'Tips & donations');

View File

@ -1,136 +0,0 @@
import React, {useEffect, useState} from 'react';
import TopLevelGroup from '../../TopLevelGroup';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {Button, CurrencyField, Heading, Select, SettingGroupContent, confirmIfDirty, withErrorBoundary} from '@tryghost/admin-x-design-system';
import {currencySelectGroups, getSymbol, validateCurrencyAmount} from '../../../utils/currency';
import {getSettingValues} from '@tryghost/admin-x-framework/api/settings';
// Stripe doesn't allow amounts over 10,000 as a preset amount
const MAX_AMOUNT = 10_000;
const TipsOrDonations: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {
localSettings,
siteData,
updateSetting,
isEditing,
saveState,
handleSave,
handleCancel,
focusRef,
handleEditingChange,
errors,
validate,
clearError
} = useSettingGroup({
onValidate: () => {
return {
donationsSuggestedAmount: validateCurrencyAmount(suggestedAmountInCents, donationsCurrency, {maxAmount: MAX_AMOUNT})
};
}
});
const [donationsCurrency = 'USD', donationsSuggestedAmount = '0'] = getSettingValues<string>(
localSettings,
['donations_currency', 'donations_suggested_amount']
);
const suggestedAmountInCents = parseInt(donationsSuggestedAmount);
const suggestedAmountInDollars = suggestedAmountInCents / 100;
const donateUrl = `${siteData?.url.replace(/\/$/, '')}/#/portal/support`;
useEffect(() => {
validate();
}, [donationsCurrency]); // eslint-disable-line react-hooks/exhaustive-deps
const [copied, setCopied] = useState(false);
const copyDonateUrl = () => {
navigator.clipboard.writeText(donateUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const openPreview = () => {
confirmIfDirty(saveState === 'unsaved', () => window.open(donateUrl, '_blank'));
};
const values = (
<SettingGroupContent
columns={2}
values={[
{
heading: 'Suggested amount',
key: 'suggested-amount',
value: `${getSymbol(donationsCurrency)}${suggestedAmountInDollars}`
},
{
heading: '',
key: 'sharable-link',
value: (
<div className='w-100'>
<div className='flex items-center gap-2'>
<Heading level={6}>Shareable link &mdash;</Heading>
<button className='text-xs tracking-wide text-green' type="button" onClick={openPreview}>Preview</button>
</div>
<div className='w-100 group relative -m-1 mt-0 overflow-hidden rounded p-1 hover:bg-grey-50 dark:hover:bg-grey-900'>
{donateUrl}
<div className='invisible absolute right-0 top-[50%] flex translate-y-[-50%] gap-1 bg-white pl-1 group-hover:visible dark:bg-black'>
<Button color='outline' label={copied ? 'Copied' : 'Copy'} size='sm' onClick={copyDonateUrl} />
</div>
</div>
</div>
)
}
]}
/>
);
const inputFields = (
<SettingGroupContent className='max-w-[180px]'>
<CurrencyField
error={!!errors.donationsSuggestedAmount}
hint={errors.donationsSuggestedAmount}
inputRef={focusRef}
placeholder="0"
rightPlaceholder={(
<Select
border={false}
containerClassName='w-14'
fullWidth={false}
options={currencySelectGroups()}
selectedOption={currencySelectGroups().flatMap(group => group.options).find(option => option.value === donationsCurrency)}
title='Currency'
hideTitle
isSearchable
onSelect={option => updateSetting('donations_currency', option?.value || 'USD')}
/>
)}
title='Suggested amount'
valueInCents={parseInt(donationsSuggestedAmount)}
onBlur={validate}
onChange={cents => updateSetting('donations_suggested_amount', cents.toString())}
onKeyDown={() => clearError('donationsSuggestedAmount')}
/>
</SettingGroupContent>
);
return (
<TopLevelGroup
description="Give your audience a one-time way to support your work, no membership required."
isEditing={isEditing}
keywords={keywords}
navid='tips-or-donations'
saveState={saveState}
testId='tips-or-donations'
title="Tips or donations"
onCancel={handleCancel}
onEditingChange={handleEditingChange}
onSave={handleSave}
>
{isEditing ? inputFields : values}
</TopLevelGroup>
);
};
export default withErrorBoundary(TipsOrDonations, 'Tips or donations');

View File

@ -162,8 +162,7 @@ const Connected: React.FC<{onClose?: () => void}> = ({onClose}) => {
// this.ghostPaths.url.api('/members/') + '?filter=status:paid&limit=0';
NiceModal.show(ConfirmationModal, {
title: 'Disconnect Stripe',
prompt: (hasActiveStripeSubscriptions ? 'Cannot disconnect while there are members with active Stripe subscriptions.' : <>You&lsquo;re about to disconnect your Stripe account {stripeConnectAccountName}
from this site. This will automatically turn off paid memberships on this site.</>),
prompt: (hasActiveStripeSubscriptions ? 'Cannot disconnect while there are members with active Stripe subscriptions.' : <>You&lsquo;re about to disconnect your Stripe account {stripeConnectAccountName} from this site. This will automatically turn off paid memberships on this site.</>),
okLabel: hasActiveStripeSubscriptions ? '' : 'Disconnect',
onOk: async (modal) => {
try {

View File

@ -22,8 +22,8 @@ export const TrialDaysLabel: React.FC<{size?: 'sm' | 'md'; trialDays: number;}>
return (
<span className={containerClassName}>
<span className="absolute inset-0 block rounded-full bg-pink opacity-20"></span>
<span className='dark:text-pink'>{trialDays} days free</span>
<span className="absolute inset-0 block rounded-full bg-accent opacity-20 dark:bg-pink"></span>
<span className="dark:text-pink">{trialDays} days free</span>
</span>
);
};
@ -96,7 +96,7 @@ const TierDetailPreview: React.FC<TierDetailPreviewProps> = ({tier, isFreeTier})
<div className='rounded-sm border border-grey-200 bg-white dark:border-transparent'>
<div className="flex-column relative flex min-h-[200px] w-full max-w-[420px] scale-90 items-start justify-stretch rounded bg-white p-4">
<div className="min-h-[56px] w-full">
<h4 className={`-mt-1 mb-0 w-full break-words text-lg font-semibold leading-tight text-pink ${!name && 'opacity-30'}`}>{name || (isFreeTier ? 'Free' : 'Bronze')}</h4>
<h4 className={`-mt-1 mb-0 w-full break-words text-lg font-semibold leading-tight text-accent ${!name && 'opacity-30'}`}>{name || (isFreeTier ? 'Free' : 'Bronze')}</h4>
<div className="mt-4 flex w-full flex-row flex-wrap items-end justify-between gap-x-1 gap-y-[10px]">
<div className={`flex flex-wrap text-black ${((showingYearly && tier?.yearly_price === undefined) || (!showingYearly && tier?.monthly_price === undefined)) && !isFreeTier ? 'opacity-30' : ''}`}>
<span className="self-start text-[2.7rem] font-bold uppercase leading-[1.115]">{currencySymbol}</span>

View File

@ -30,7 +30,7 @@ const TierCard: React.FC<TierCardProps> = ({tier}) => {
<div className='w-full grow' onClick={() => {
updateRoute({route: `tiers/${tier.id}`});
}}>
<div className='text-[1.65rem] font-bold leading-tight tracking-tight text-pink'>{tier.name}</div>
<div className='text-[1.65rem] font-bold leading-tight tracking-tight text-black dark:text-white'>{tier.name}</div>
<div className='mt-2 flex items-baseline'>
<span className="ml-1 translate-y-[-3px] text-md font-bold uppercase">{currencySymbol}</span>
<span className='text-xl font-bold tracking-tighter'>{numberWithCommas(currencyToDecimal(tier.monthly_price || 0))}</span>

View File

@ -80,7 +80,7 @@
},
"devDependencies": {
"@vitejs/plugin-react": "4.2.1",
"jsdom": "24.1.0",
"jsdom": "24.1.1",
"vite": "4.5.3",
"vite-plugin-svgr": "3.3.0",
"vitest": "0.34.3"

View File

@ -1,6 +1,6 @@
{
"name": "@tryghost/comments-ui",
"version": "0.17.1",
"version": "0.17.3",
"license": "MIT",
"repository": "git@github.com:TryGhost/comments-ui.git",
"author": "Ghost Foundation",
@ -44,16 +44,16 @@
},
"dependencies": {
"@headlessui/react": "1.7.19",
"@tiptap/core": "2.4.0",
"@tiptap/extension-blockquote": "2.4.0",
"@tiptap/extension-document": "2.4.0",
"@tiptap/extension-hard-break": "2.4.0",
"@tiptap/extension-link": "2.4.0",
"@tiptap/extension-paragraph": "2.4.0",
"@tiptap/extension-placeholder": "2.4.0",
"@tiptap/extension-text": "2.4.0",
"@tiptap/pm": "2.4.0",
"@tiptap/react": "2.4.0",
"@tiptap/core": "2.6.0",
"@tiptap/extension-blockquote": "2.6.0",
"@tiptap/extension-document": "2.6.1",
"@tiptap/extension-hard-break": "2.6.0",
"@tiptap/extension-link": "2.6.0",
"@tiptap/extension-paragraph": "2.6.0",
"@tiptap/extension-placeholder": "2.6.1",
"@tiptap/extension-text": "2.6.0",
"@tiptap/pm": "2.6.0",
"@tiptap/react": "2.6.1",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-string-replace": "1.1.1"
@ -62,7 +62,7 @@
"@playwright/test": "1.38.1",
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "12.1.5",
"@testing-library/user-event": "14.4.3",
"@testing-library/user-event": "14.5.2",
"@tryghost/i18n": "0.0.0",
"@vitejs/plugin-react": "4.2.1",
"@vitest/coverage-v8": "0.34.3",
@ -73,9 +73,9 @@
"eslint-plugin-react-hooks": "4.6.0",
"eslint-plugin-react-refresh": "0.4.3",
"eslint-plugin-tailwindcss": "3.13.0",
"jsdom": "24.1.0",
"jsdom": "24.1.1",
"postcss": "8.4.39",
"tailwindcss": "3.4.4",
"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

@ -1,6 +1,6 @@
{
"name": "@tryghost/portal",
"version": "2.37.8",
"version": "2.38.0",
"license": "MIT",
"repository": {
"type": "git",
@ -80,7 +80,7 @@
"devDependencies": {
"@babel/eslint-parser": "7.23.3",
"@doist/react-interpolate": "1.1.1",
"@sentry/react": "7.118.0",
"@sentry/react": "7.119.0",
"@sentry/tracing": "7.114.0",
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "12.1.5",
@ -91,7 +91,7 @@
"concurrently": "8.2.2",
"cross-fetch": "4.0.0",
"eslint-plugin-i18next": "6.0.3",
"jsdom": "24.1.0",
"jsdom": "24.1.1",
"react": "17.0.2",
"react-dom": "17.0.2",
"vite": "4.5.3",

View File

@ -46,7 +46,7 @@ function NewsletterPrefSection({newsletter, subscribedNewsletters, setSubscribed
const [showUpdated, setShowUpdated] = useState(false);
const [timeoutId, setTimeoutId] = useState(null);
return (
<section className='gh-portal-list-toggle-wrapper' data-test-toggle-wrapper>
<section className='gh-portal-list-toggle-wrapper' data-testid="toggle-wrapper">
<div className='gh-portal-list-detail'>
<h3>{newsletter.name}</h3>
<p>{newsletter?.description}</p>
@ -95,7 +95,7 @@ function CommentsSection({updateCommentNotifications, isCommentsEnabled, enableC
}
return (
<section className='gh-portal-list-toggle-wrapper' data-test-toggle-wrapper>
<section className='gh-portal-list-toggle-wrapper' data-testid="toggle-wrapper">
<div className='gh-portal-list-detail'>
<h3>{t('Comments')}</h3>
<p>{t('Get notified when someone replies to your comment')}</p>

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

@ -0,0 +1,117 @@
import {getSiteData, getNewslettersData, getMemberData} from '../../utils/fixtures-generator';
import {render, fireEvent} from '../../utils/test-utils';
import AccountEmailPage from './AccountEmailPage';
const setup = (overrides) => {
const {mockOnActionFn, context, ...utils} = render(
<AccountEmailPage />,
{
overrideContext: {
...overrides
}
}
);
const unsubscribeAllBtn = utils.getByText('Unsubscribe from all emails');
const closeBtn = utils.getByTestId('close-popup');
return {
unsubscribeAllBtn,
closeBtn,
mockOnActionFn,
context,
...utils
};
};
describe('Account Email Page', () => {
test('renders', () => {
const newsletterData = getNewslettersData({numOfNewsletters: 2});
const siteData = getSiteData({
newsletters: newsletterData,
member: getMemberData({newsletters: newsletterData})
});
const {unsubscribeAllBtn, getAllByTestId, getByText} = setup({site: siteData});
const unsubscribeBtns = getAllByTestId(`toggle-wrapper`);
expect(getByText('Email preferences')).toBeInTheDocument();
// one for each newsletter and one for comments
expect(unsubscribeBtns).toHaveLength(3);
expect(unsubscribeAllBtn).toBeInTheDocument();
});
test('can unsubscribe from all emails', async () => {
const newsletterData = getNewslettersData({numOfNewsletters: 2});
const siteData = getSiteData({
newsletters: newsletterData
});
const {mockOnActionFn, unsubscribeAllBtn, getAllByTestId} = setup({site: siteData, member: getMemberData({newsletters: newsletterData})});
let checkmarkContainers = getAllByTestId('checkmark-container');
// each newsletter should have the checked class (this is how we know they're enabled/subscribed to)
expect(checkmarkContainers[0]).toHaveClass('gh-portal-toggle-checked');
expect(checkmarkContainers[1]).toHaveClass('gh-portal-toggle-checked');
fireEvent.click(unsubscribeAllBtn);
expect(mockOnActionFn).toHaveBeenCalledTimes(2);
expect(mockOnActionFn).toHaveBeenCalledWith('showPopupNotification', {action: 'updated:success', message: 'Unsubscribed from all emails.'});
expect(mockOnActionFn).toHaveBeenLastCalledWith('updateNewsletterPreference', {newsletters: [], enableCommentNotifications: false});
checkmarkContainers = getAllByTestId('checkmark-container');
expect(checkmarkContainers).toHaveLength(3);
checkmarkContainers.forEach((newsletter) => {
// each newsletter htmlElement should not have the checked class
expect(newsletter).not.toHaveClass('gh-portal-toggle-checked');
});
});
test('unsubscribe all is disabled when no newsletters are subscribed to', async () => {
const siteData = getSiteData({
newsletters: getNewslettersData({numOfNewsletters: 2})
});
const {unsubscribeAllBtn} = setup({site: siteData, member: getMemberData()});
expect(unsubscribeAllBtn).toBeDisabled();
});
test('can update newsletter preferences', async () => {
const newsletterData = getNewslettersData({numOfNewsletters: 2});
const siteData = getSiteData({
newsletters: newsletterData
});
const {mockOnActionFn, getAllByTestId} = setup({site: siteData, member: getMemberData({newsletters: newsletterData})});
let checkmarkContainers = getAllByTestId('checkmark-container');
// each newsletter should have the checked class (this is how we know they're enabled/subscribed to)
expect(checkmarkContainers[0]).toHaveClass('gh-portal-toggle-checked');
let subscriptionToggles = getAllByTestId('switch-input');
fireEvent.click(subscriptionToggles[0]);
expect(mockOnActionFn).toHaveBeenCalledWith('updateNewsletterPreference', {newsletters: [{id: newsletterData[1].id}]});
fireEvent.click(subscriptionToggles[0]);
expect(mockOnActionFn).toHaveBeenCalledWith('updateNewsletterPreference', {newsletters: [{id: newsletterData[1].id}, {id: newsletterData[0].id}]});
});
test('can update comment notifications', async () => {
const siteData = getSiteData();
const {mockOnActionFn, getAllByTestId} = setup({site: siteData, member: getMemberData()});
let subscriptionToggles = getAllByTestId('switch-input');
fireEvent.click(subscriptionToggles[0]);
expect(mockOnActionFn).toHaveBeenCalledWith('updateNewsletterPreference', {enableCommentNotifications: true});
fireEvent.click(subscriptionToggles[0]);
expect(mockOnActionFn).toHaveBeenCalledWith('updateNewsletterPreference', {enableCommentNotifications: false});
});
test('displays help for members with email suppressions', async () => {
const newsletterData = getNewslettersData({numOfNewsletters: 2});
const siteData = getSiteData({
newsletters: newsletterData
});
const {getByText} = setup({site: siteData, member: getMemberData({newsletters: newsletterData, email_suppressions: {suppressed: false}})});
expect(getByText('Not receiving emails?')).toBeInTheDocument();
expect(getByText('Get help →')).toBeInTheDocument();
});
test('redirects to signin page if no member', async () => {
const newsletterData = getNewslettersData({numOfNewsletters: 2});
const siteData = getSiteData({
newsletters: newsletterData
});
const {mockOnActionFn} = setup({site: siteData, member: null});
expect(mockOnActionFn).toHaveBeenCalledWith('switchPage', {page: 'signin', pageData: {redirect: window.location.href}});
});
});

View File

@ -1,6 +1,7 @@
import {render, fireEvent} from '../../../utils/test-utils';
import AccountHomePage from './AccountHomePage';
import {site} from '../../../utils/fixtures';
import {getSiteData} from '../../../utils/fixtures-generator';
const setup = (overrides) => {
const {mockOnActionFn, ...utils} = render(
@ -21,7 +22,8 @@ const setup = (overrides) => {
describe('Account Home Page', () => {
test('renders', () => {
const {logoutBtn, utils} = setup();
const siteData = getSiteData({commentsEnabled: 'off'});
const {logoutBtn, utils} = setup({site: siteData});
expect(logoutBtn).toBeInTheDocument();
expect(utils.queryByText('You\'re currently not receiving emails')).not.toBeInTheDocument();
expect(utils.queryByText('Email newsletter')).toBeInTheDocument();
@ -46,4 +48,11 @@ describe('Account Home Page', () => {
fireEvent.click(manageBtn);
expect(mockOnActionFn).toHaveBeenCalledWith('switchPage', {lastPage: 'accountHome', page: 'accountEmail'});
});
test('hides Newsletter toggle if newsletters are disabled', () => {
const siteData = getSiteData({editorDefaultEmailRecipients: 'disabled'});
const {logoutBtn, utils} = setup({site: siteData});
expect(logoutBtn).toBeInTheDocument();
expect(utils.queryByText('Email newsletter')).not.toBeInTheDocument();
});
});

View File

@ -1,6 +1,6 @@
import AppContext from '../../../../AppContext';
import {useContext} from 'react';
import {hasCommentsEnabled, hasMultipleNewsletters, isEmailSuppressed} from '../../../../utils/helpers';
import {hasCommentsEnabled, hasMultipleNewsletters, isEmailSuppressed, hasNewsletterSendingEnabled} from '../../../../utils/helpers';
import PaidAccountActions from './PaidAccountActions';
import EmailNewsletterAction from './EmailNewsletterAction';
@ -19,6 +19,8 @@ const AccountActions = () => {
const showEmailPreferences = hasMultipleNewsletters({site}) || hasCommentsEnabled({site}) || isEmailSuppressed({member});
const showEmailUnsubscribe = hasNewsletterSendingEnabled({site});
return (
<div>
<div className='gh-portal-list'>
@ -40,7 +42,13 @@ const AccountActions = () => {
{
showEmailPreferences
? <EmailPreferencesAction />
: <EmailNewsletterAction />
: <></>
}
{
showEmailUnsubscribe && !showEmailPreferences
? <EmailNewsletterAction />
: <></>
}
</div>

View File

@ -11,7 +11,7 @@ function NewsletterPrefSection({newsletter, subscribedNewsletters, setSubscribed
});
if (newsletter.paid) {
return (
<section className='gh-portal-list-toggle-wrapper' data-test-toggle-wrapper>
<section className='gh-portal-list-toggle-wrapper' data-testid="toggle-wrapper">
<div className='gh-portal-list-detail gh-portal-list-big'>
<h3>{newsletter.name}</h3>
<p>{newsletter.description}</p>
@ -23,7 +23,7 @@ function NewsletterPrefSection({newsletter, subscribedNewsletters, setSubscribed
);
}
return (
<section className='gh-portal-list-toggle-wrapper' data-test-toggle-wrapper>
<section className='gh-portal-list-toggle-wrapper' data-testid="toggle-wrapper">
<div className='gh-portal-list-detail gh-portal-list-big'>
<h3>{newsletter.name}</h3>
<p>{newsletter.description}</p>

View File

@ -160,7 +160,7 @@ describe('Newsletter Subscriptions', () => {
fireEvent.click(unsubscribeAllButton);
expect(ghostApi.member.update).toHaveBeenCalledWith({newsletters: []});
expect(ghostApi.member.update).toHaveBeenCalledWith({newsletters: [], enableCommentNotifications: false});
// Verify the local state shows the newsletter as unsubscribed
let newsletterToggles = within(popupIframeDocument).queryAllByTestId('checkmark-container');
let newsletter1Toggle = newsletterToggles[0];

View File

@ -39,6 +39,7 @@ export function getSiteData({
portalButtonSignupText: portal_button_signup_text = 'Subscribe now',
portalButtonStyle: portal_button_style = 'icon-and-text',
membersSupportAddress: members_support_address = 'support@example.com',
editorDefaultEmailRecipients: editor_default_email_recipients = 'visibility',
newsletters = [],
commentsEnabled,
recommendations = [],
@ -66,10 +67,11 @@ export function getSiteData({
portal_button_signup_text,
portal_button_style,
members_support_address,
comments_enabled: !!commentsEnabled,
comments_enabled: commentsEnabled !== 'off',
newsletters,
recommendations,
recommendations_enabled: !!recommendationsEnabled
recommendations_enabled: !!recommendationsEnabled,
editor_default_email_recipients
};
}

View File

@ -86,6 +86,10 @@ export function getNewsletterFromUuid({site, uuid}) {
});
}
export function hasNewsletterSendingEnabled({site}) {
return site?.editor_default_email_recipients === 'visibility';
}
export function allowCompMemberUpgrade({member}) {
return member?.subscriptions?.[0]?.tier?.expiry_at !== undefined;
}

View File

@ -1,6 +1,6 @@
{
"name": "@tryghost/signup-form",
"version": "0.1.4",
"version": "0.1.5",
"license": "MIT",
"repository": {
"type": "git",
@ -58,14 +58,14 @@
"eslint-plugin-react-hooks": "4.6.0",
"eslint-plugin-react-refresh": "0.4.3",
"eslint-plugin-tailwindcss": "3.13.0",
"jsdom": "24.1.0",
"jsdom": "24.1.1",
"postcss": "8.4.39",
"postcss-import": "16.1.0",
"prop-types": "15.8.1",
"rollup-plugin-node-builtins": "2.1.2",
"storybook": "7.6.20",
"stylelint": "15.10.3",
"tailwindcss": "3.4.4",
"tailwindcss": "3.4.7",
"vite": "4.5.3",
"vite-plugin-commonjs": "0.10.1",
"vite-plugin-svgr": "3.3.0",

View File

@ -82,10 +82,10 @@
]
},
"devDependencies": {
"@testing-library/jest-dom": "5.16.5",
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "12.1.5",
"@vitejs/plugin-react": "4.2.1",
"jsdom": "24.1.0",
"jsdom": "24.1.1",
"nock": "13.3.3",
"vite": "4.5.3",
"vite-plugin-svgr": "3.3.0",

View File

@ -1,43 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1708501555,
"narHash": "sha256-zJaF0RkdIPbh8LTmnpW/E7tZYpqIE+MePzlWwUNob4c=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b50a77c03d640716296021ad58950b1bb0345799",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"systems": "systems"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View File

@ -1,49 +0,0 @@
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
systems.url = "github:nix-systems/default";
};
outputs = {
systems,
nixpkgs,
...
} @ inputs: let
yarn_overlay = final: prev: {
yarn = prev.yarn.overrideAttrs(finalAttrs: prevAttrs: {
# This is to make sure that yarn runs the correct node version
# https://github.com/NixOS/nixpkgs/issues/145634#issuecomment-1627476963
installPhase = prevAttrs.installPhase + ''
ln -fs $out/libexec/yarn/bin/yarn $out/bin/yarn
ln -fs $out/libexec/yarn/bin/yarn.js $out/bin/yarn.js
ln -fs $out/libexec/yarn/bin/yarn $out/bin/yarnpkg
'';
});
};
# This gives us a central place to set the node version
node_overlay = final: prev: {
nodejs = prev.nodejs-18_x;
};
eachSystem = f:
nixpkgs.lib.genAttrs (import systems) (
system:
f ((nixpkgs.legacyPackages.${system}.extend yarn_overlay).extend node_overlay)
);
in {
devShells = eachSystem (pkgs: {
default = pkgs.mkShell {
buildInputs = with pkgs; [
nodejs
yarn
];
shellHook = ''
echo "node `${pkgs.nodejs}/bin/node --version`"
'';
};
});
};
}

View File

@ -24,6 +24,6 @@
"sinon": "15.2.0"
},
"dependencies": {
"@tryghost/errors": "1.3.2"
"@tryghost/errors": "1.3.5"
}
}

View File

@ -12,7 +12,7 @@ Run all tests in the browser by running `yarn dev` in the Ghost monorepo and vis
---
Tip: You can use `await this.pauseTest()` in your tests to temporarily pause the execution of browser tests. Use the browser console to inspect and debug the DOM, then resume tests by running `resumeTest()` directly in the browser console ([docs](https://guides.emberjs.com/v3.28.0/testing/testing-application/#toc_debugging-your-tests))
Tip: You can use `this.timeout(0); await this.pauseTest();` in your tests to temporarily pause the execution of browser tests. Use the browser console to inspect and debug the DOM, then resume tests by running `resumeTest()` directly in the browser console ([docs](https://guides.emberjs.com/v3.28.0/testing/testing-application/#toc_debugging-your-tests))
### Running tests in the CLI

View File

@ -45,12 +45,14 @@
@close={{@close}}
/>
{{else if this.isComplete}}
{{#unless (feature "publishFlowEndScreen")}}
<Editor::Modals::PublishFlow::Complete
@publishOptions={{@data.publishOptions}}
@recipientType={{this.recipientType}}
@postCount={{this.postCount}}
@close={{@close}}
/>
{{/unless}}
{{else}}
<Editor::Modals::PublishFlow::Options
@publishOptions={{@data.publishOptions}}

View File

@ -13,6 +13,7 @@ function isString(str) {
export default class PublishFlowOptions extends Component {
@service settings;
@service feature;
@tracked errorMessage;
@ -91,6 +92,15 @@ export default class PublishFlowOptions extends Component {
try {
yield this.args.saveTask.perform();
if (this.feature.publishFlowEndScreen) {
if (this.args.publishOptions.isScheduled) {
localStorage.setItem('ghost-last-scheduled-post', this.args.publishOptions.post.id);
window.location.href = '/ghost/#/posts?type=scheduled';
} else {
localStorage.setItem('ghost-last-published-post', this.args.publishOptions.post.id);
window.location.href = `/ghost/#/posts/analytics/${this.args.publishOptions.post.id}`;
}
}
} catch (e) {
if (e === undefined && this.args.publishOptions.post.errors.length !== 0) {
// validation error

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

@ -138,26 +138,8 @@
{{/if}}
</h3>
<div>
{{#if sub.trialUntil}}
<span class="gh-cp-membertier-pricelabel">Free trial </span>
{{else}}
{{#if (or (eq sub.price.nickname "Monthly") (eq sub.price.nickname "Yearly"))}}
{{else}}
<span class="gh-cp-membertier-pricelabel">{{sub.price.nickname}}</span>
{{/if}}
{{/if}}
{{#if sub.trialUntil}}
<span class="gh-cp-membertier-renewal"> &ndash; </span>
<span class="gh-cp-membertier-pricelabel">{{sub.priceLabel}}</span>
<span class="gh-cp-membertier-renewal">{{sub.validityDetails}}</span>
{{/if}}
{{#if sub.compExpiry}}
<span class="gh-cp-membertier-renewal"> &ndash; </span>
<span class="gh-cp-membertier-renewal">{{sub.validityDetails}}</span>
{{/if}}
</div>
<Member::SubscriptionDetailBox @sub={{sub}} @index={{index}} />
</div>

View File

@ -9,7 +9,7 @@
<p class="gh-members-empty-secondary-cta">Have members already? <LinkTo @route="member.new">Add them manually</LinkTo> or <LinkTo @route="members.import">import from CSV</LinkTo></p>
{{else}}
<p>Memberships have been disabled. Adjust your Subscription Access settings to start adding members.</p>
<LinkTo @route="settings-x.settings-x" @model="access" class="gh-btn gh-btn-green">
<LinkTo @route="settings-x.settings-x" @model="members" class="gh-btn gh-btn-green">
<span>Membership settings</span>
</LinkTo>
{{/if}}

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'
}];
}
@ -441,6 +441,16 @@ export default class KoenigLexicalEditor extends Component {
}
};
const checkStripeEnabled = () => {
const hasDirectKeys = !!(this.settings.stripeSecretKey && this.settings.stripePublishableKey);
const hasConnectKeys = !!(this.settings.stripeConnectSecretKey && this.settings.stripeConnectPublishableKey);
if (this.config.stripeDirect) {
return hasDirectKeys;
}
return hasDirectKeys || hasConnectKeys;
};
const defaultCardConfig = {
unsplash: this.settings.unsplash ? unsplashConfig.defaultHeaders : null,
tenor: this.config.tenor?.googleApiKey ? this.config.tenor : null,
@ -452,8 +462,6 @@ export default class KoenigLexicalEditor extends Component {
feature: {
collectionsCard: this.feature.collectionsCard,
collections: this.feature.collections,
internalLinking: this.feature.internalLinking,
internalLinkingAtLinks: this.feature.internalLinking,
contentVisibility: this.feature.contentVisibility
},
deprecated: { // todo fix typo
@ -463,7 +471,8 @@ export default class KoenigLexicalEditor extends Component {
searchLinks,
siteTitle: this.settings.title,
siteDescription: this.settings.description,
siteUrl: this.config.getSiteUrl('/')
siteUrl: this.config.getSiteUrl('/'),
stripeEnabled: checkStripeEnabled() // returns a boolean
};
const cardConfig = Object.assign({}, defaultCardConfig, props.cardConfig, {pinturaConfig: this.pinturaConfig});

View File

@ -137,6 +137,10 @@ class Filter {
return this.properties.options ?? [];
}
get group() {
return this.properties.group;
}
get isValid() {
if (Array.isArray(this.value)) {
return !!this.value.length;

View File

@ -0,0 +1,152 @@
<div class="modal-content">
{{#if this.post.featureImage}}
<figure class="modal-image">
<img src="{{this.post.featureImage}}" alt="{{this.post.title}}">
</figure>
{{else if this.post.twitterImage}}
<figure class="modal-image">
<img src="{{this.post.twitterImage}}" alt="{{this.post.title}}">
</figure>
{{else if this.post.ogImage}}
<figure class="modal-image">
<img src="{{this.post.ogImage}}" alt="{{this.post.title}}">
</figure>
{{/if}}
<header class="modal-header">
<h1>
{{#if this.post.isScheduled}}
<span>All set!</span>
{{else}}
<span>
{{#if this.showPostCount}}
Boom! It's out there.
{{else}}
Your post is out there.
{{/if}}
</span>
<span>
{{#if this.post.emailOnly}}
Your email has been sent.
{{else}}
{{#if this.showPostCount}}
That's {{format-number this.postCount}} {{gh-pluralize this.postCount "post" without-count=true}} published.
{{else}}
Share it with the world!
{{/if}}
{{/if}}
</span>
{{/if}}
</h1>
</header>
<button type="button" class="close" title="Close" {{on "click" @close}}>{{svg-jar "close"}}<span class="hidden">Close</span></button>
<div class="modal-body">
{{#if (and this.post.isPublished (not this.post.emailOnly))}}
{{#if this.showPostCount}}
Keep up the good work. Now, share your post with the world!
{{else}}
Spread the word to your audience and increase your reach.
{{/if}}
{{else}}
{{#if this.post.isSent}}
It
{{else}}
{{if this.post.emailOnly "Your email" "Your post"}}
{{/if}}
{{if this.post.isScheduled "will be" "was"}}
{{#if this.post.emailOnly}}
sent to
{{else if this.post.willEmail}}
published on your site, and sent to
{{else}}
published on your site
{{/if}}
{{#if (or this.post.hasEmail this.post.willEmail)}}
{{#let (members-count-fetcher query=(hash filter=this.post.fullRecipientFilter)) as |countFetcher|}}
<strong class="nowrap">
{{if (eq @recipientType "all") "all"}}
{{format-number countFetcher.count}}
{{!-- @recipientType = free/paid/all/specific --}}
{{if (not-eq @recipientType "all") @recipientType}}
{{gh-pluralize countFetcher.count "subscriber" without-count=true}}
</strong>
of <strong>{{this.post.newsletter.name}}</strong>
{{/let}}
{{/if}}
{{#let (moment-site-tz this.post.publishedAtUTC) as |publishedAt|}}
on
{{moment-format publishedAt "D MMM YYYY"}}
at
{{moment-format publishedAt "HH:mm"}}.
{{/let}}
{{/if}}
</div>
<footer class="modal-footer">
{{#if (and this.post.isPublished (not this.post.emailOnly))}}
<button
class="gh-btn twitter"
type="button"
{{on "click" this.handleTwitter}}
{{on "mousedown" (optional this.noop)}}
>
<span>{{svg-jar "social-x"}}</span>
</button>
<button
class="gh-btn threads"
type="button"
{{on "click" this.handleThreads}}
{{on "mousedown" (optional this.noop)}}
>
<span>{{svg-jar "social-threads"}}</span>
</button>
<button
class="gh-btn facebook"
type="button"
{{on "click" this.handleFacebook}}
{{on "mousedown" (optional this.noop)}}
>
<span>{{svg-jar "social-facebook"}}</span>
</button>
<button
class="gh-btn linkedin"
type="button"
{{on "click" this.handleLinkedIn}}
{{on "mousedown" (optional this.noop)}}
>
<span>{{svg-jar "social-linkedin"}}</span>
</button>
<GhTaskButton
@buttonText="Copy link"
@task={{this.handleCopyLink}}
@showIcon={{true}}
@successText="Link copied"
@class="gh-btn gh-btn-primary gh-btn-icon copy-link" />
{{else}}
{{#if (and this.post.isScheduled (not this.post.emailOnly))}}
<GhTaskButton
@buttonText="Copy preview link"
@task={{this.handleCopyPreviewLink}}
@successText="Link copied"
@class="gh-btn gh-btn-icon copy-preview-link" />
{{/if}}
<button
class="gh-btn gh-btn-primary dismiss"
type="button"
{{on "click" @close}}
{{on "mousedown" (optional this.noop)}}
>
<span>OK</span>
</button>
{{/if}}
</footer>
</div>

View File

@ -0,0 +1,95 @@
import Component from '@glimmer/component';
import copyTextToClipboard from 'ghost-admin/utils/copy-text-to-clipboard';
import {action} from '@ember/object';
import {capitalize} from '@ember/string';
import {inject as service} from '@ember/service';
import {task, timeout} from 'ember-concurrency';
export default class PostSuccessModal extends Component {
@service store;
@service router;
@service notifications;
static modalOptions = {
className: 'fullscreen-modal-wide fullscreen-modal-action modal-post-success'
};
get post() {
return this.args.data.post;
}
get postCount() {
return this.args.data.postCount;
}
get showPostCount() {
return this.args.data.showPostCount;
}
@action
handleTwitter() {
window.open(`https://twitter.com/intent/tweet?url=${encodeURI(this.post.url)}`, '_blank');
}
@action
handleThreads() {
window.open(`https://threads.net/intent/post?text=${encodeURI(this.post.url)}`, '_blank');
}
@action
handleFacebook() {
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURI(this.post.url)}`, '_blank');
}
@action
handleLinkedIn() {
window.open(`http://www.linkedin.com/shareArticle?mini=true&url=${encodeURI(this.post.url)}`, '_blank');
}
@action
viewInBrowser() {
window.open(this.post.url, '_blank');
}
@task
*handleCopyLink() {
copyTextToClipboard(this.post.url);
yield timeout(1000);
return true;
}
@task
*handleCopyPreviewLink() {
copyTextToClipboard(this.post.previewUrl);
yield timeout(1000);
return true;
}
@task
*revertToDraftTask() {
const currentPost = this.post;
const originalStatus = currentPost.status;
const originalPublishedAtUTC = currentPost.publishedAtUTC;
try {
if (currentPost.isScheduled) {
currentPost.publishedAtUTC = null;
}
currentPost.status = 'draft';
currentPost.emailOnly = false;
yield currentPost.save();
this.router.transitionTo('lexical-editor.edit', 'post', currentPost.id);
const postType = capitalize(currentPost.displayName);
this.notifications.showNotification(`${postType} reverted to a draft.`, {type: 'success'});
return true;
} catch (e) {
currentPost.status = originalStatus;
currentPost.publishedAtUTC = originalPublishedAtUTC;
throw e;
}
}
}

View File

@ -1,11 +1,26 @@
<ul class="gh-posts-context-menu dropdown-menu dropdown-triangle-top-left">
{{#if this.canUnpublishSelection}}
{{#if this.canCopySelection}}
<li>
<button class="mr2" type="button" {{on "click" this.copyPostLink}}>
<span>{{svg-jar "link"}}Copy link to post</span>
</button>
</li>
{{/if}}
<li>
<button class="mr2" type="button" {{on "click" this.unpublishPosts}}>
<span>{{svg-jar "undo"}}Unpublish</span>
</button>
</li>
{{else}}
{{#if this.canCopySelection}}
<li>
<button class="mr2" type="button" {{on "click" this.copyPreviewLink}}>
<span>{{svg-jar "link"}}Copy preview link</span>
</button>
</li>
{{/if}}
{{/if}}
{{#if this.canFeatureSelection}}
{{#if this.shouldFeatureSelection }}

View File

@ -3,11 +3,12 @@ import Component from '@glimmer/component';
import DeletePostsModal from './modals/delete-posts';
import EditPostsAccessModal from './modals/edit-posts-access';
import UnpublishPostsModal from './modals/unpublish-posts';
import copyTextToClipboard from 'ghost-admin/utils/copy-text-to-clipboard';
import nql from '@tryghost/nql';
import {action} from '@ember/object';
import {capitalizeFirstLetter} from 'ghost-admin/helpers/capitalize-first-letter';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
import {task, timeout} from 'ember-concurrency';
/**
* @tryghost/tpl doesn't work in admin yet (Safari)
@ -43,6 +44,12 @@ const messages = {
duplicated: {
single: '{Type} duplicated',
multiple: '{count} {type}s duplicated'
},
copiedPostUrl: {
single: 'Post link copied'
},
copiedPreviewUrl: {
single: 'Preview link copied'
}
};
@ -74,6 +81,16 @@ export default class PostsContextMenu extends Component {
return tpl(messages[type].multiple, {count: this.selectionList.count, type: this.type, Type: capitalizeFirstLetter(this.type)});
}
@action
async copyPostLink() {
this.menu.performTask(this.copyPostLinkTask);
}
@action
async copyPreviewLink() {
this.menu.performTask(this.copyPreviewLinkTask);
}
@action
async featurePosts() {
this.menu.performTask(this.featurePostsTask);
@ -216,11 +233,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) => {
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, remainingModels);
this.infinity.replace(this.selectionList.infinityModel[key], remainingModels);
}
this.selectionList.clearSelection({force: true});
return true;
}
@ -247,9 +267,7 @@ export default class PostsContextMenu extends Component {
}
}
// Remove posts that no longer match the filter
this.updateFilteredPosts();
return true;
}
@ -282,14 +300,16 @@ export default class PostsContextMenu extends Component {
]
});
const remainingModels = this.selectionList.infinityModel.content.filter((model) => {
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, remainingModels);
this.infinity.replace(this.selectionList.infinityModel[key], remainingModels);
}
this.selectionList.clearUnavailableItems();
}
@ -386,8 +406,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'});
@ -398,6 +420,22 @@ export default class PostsContextMenu extends Component {
return true;
}
@task
*copyPostLinkTask() {
copyTextToClipboard(this.selectionList.availableModels[0].url);
this.notifications.showNotification(this.#getToastMessage('copiedPostUrl'), {type: 'success'});
yield timeout(1000);
return true;
}
@task
*copyPreviewLinkTask() {
copyTextToClipboard(this.selectionList.availableModels[0].url);
this.notifications.showNotification(this.#getToastMessage('copiedPreviewUrl'), {type: 'success'});
yield timeout(1000);
return true;
}
async performBulkDestroy() {
const filter = this.selectionList.filter;
let bulkUpdateUrl = this.ghostPaths.url.api(this.type === 'post' ? 'posts' : 'pages') + `?filter=${encodeURIComponent(filter)}`;

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|}}
{{!-- 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

@ -1,7 +1,39 @@
import Component from '@glimmer/component';
import PostSuccessModal from '../modal-post-success';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
export default class PostsList extends Component {
@service store;
@service modals;
@service feature;
latestScheduledPost = null;
constructor() {
super(...arguments);
if (this.feature.publishFlowEndScreen) {
this.checkPublishFlowModal();
}
}
async checkPublishFlowModal() {
if (localStorage.getItem('ghost-last-scheduled-post')) {
await this.getLatestScheduledPost.perform();
this.modals.open(PostSuccessModal, {
post: this.latestScheduledPost
});
localStorage.removeItem('ghost-last-scheduled-post');
}
}
get list() {
return this.args.list;
}
@task
*getLatestScheduledPost() {
const result = yield this.store.query('post', {filter: `id:${localStorage.getItem('ghost-last-scheduled-post')}`, limit: 1});
this.latestScheduledPost = result.toArray()[0];
}
}

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,12 +90,15 @@ 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) {
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,11 +165,14 @@ export default class SelectionList {
clearUnavailableItems() {
const newSelection = new Set();
for (const item of this.infinityModel.content) {
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,7 +202,9 @@ export default class SelectionList {
// todo
let running = false;
for (const item of this.infinityModel.content) {
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) {
@ -215,6 +238,7 @@ export default class SelectionList {
}
}
}
}
// Force update
// eslint-disable-next-line no-self-assign

View File

@ -34,9 +34,61 @@
{{moment-format publishedAt "HH:mm"}}
{{/let}}
</div>
{{#if (feature "publishFlowEndScreen")}}
<div style="display: flex; gap: 8px;">
{{#if (feature "postAnalyticsRefresh")}}
<button type="button" class="gh-post-list-cta edit" {{on "click" this.fetchPostTask.perform}}>
{{svg-jar "reload" title="Refresh post analytics"}}<span>Refresh</span>
</button>
{{/if}}
{{#unless this.post.emailOnly}}
<button type="button" class="gh-post-list-cta edit share" {{on "click" this.togglePublishFlowModal}}>
{{svg-jar "share" title="Share post"}}<span>Share</span>
</button>
{{/unless}}
<span class="dropdown">
<GhDropdownButton
@dropdownName="analytics-actions-menu"
@classNames="gh-post-list-cta edit gh-btn-icon icon-only gh-btn-action-icon"
@title="Analytics Actions"
data-test-button="analytics-actions"
>
<span>
{{svg-jar "dotdotdot"}}
<span class="hidden">Actions</span>
</span>
</GhDropdownButton>
<GhDropdown
@name="analytics-actions-menu"
@tagName="ul"
@classNames="gh-analytics-actions-menu dropdown-menu dropdown-triangle-top-right"
@closeOnClick={{true}}
>
<li>
<LinkTo class="edit-post" @route="lexical-editor.edit" @models={{array this.post.displayName this.post.id}}>Edit post</LinkTo>
</li>
<li>
<a class="view-browser" href="{{this.post.url}}" target="_blank" rel="noopener noreferrer">View in browser</a>
</li>
<li>
<button
type="button"
class="delete-post mr2"
{{on "click" this.confirmDeleteMember}}
data-test-button="delete-post"
>
<span class="red">Delete post</span>
</button>
</li>
</GhDropdown>
</span>
</div>
{{else}}
<LinkTo @route="lexical-editor.edit" @models={{array this.post.displayName this.post.id}} class="gh-post-list-cta edit" title="">
{{svg-jar "pen" title=""}}<span>Edit post</span>
</LinkTo>
{{/if}}
</div>
</div>
</GhCanvasHeader>

View File

@ -1,4 +1,6 @@
import Component from '@glimmer/component';
import DeletePostModal from '../modals/delete-post';
import PostSuccessModal from '../modal-post-success';
import {action} from '@ember/object';
import {didCancel, task} from 'ember-concurrency';
import {inject as service} from '@ember/service';
@ -24,6 +26,9 @@ export default class Analytics extends Component {
@service utils;
@service feature;
@service store;
@service router;
@service modals;
@service notifications;
@tracked sources = null;
@tracked links = null;
@ -31,12 +36,47 @@ export default class Analytics extends Component {
@tracked sortColumn = 'signups';
@tracked showSuccess;
@tracked updateLinkId;
@tracked _post = null;
@tracked postCount = null;
@tracked showPostCount = false;
displayOptions = DISPLAY_OPTIONS;
constructor() {
super(...arguments);
if (this.feature.publishFlowEndScreen) {
this.checkPublishFlowModal();
}
}
openPublishFlowModal() {
this.modals.open(PostSuccessModal, {
post: this.post,
postCount: this.postCount,
showPostCount: this.showPostCount
});
}
async checkPublishFlowModal() {
if (localStorage.getItem('ghost-last-published-post')) {
await this.fetchPostCountTask.perform();
this.showPostCount = true;
this.openPublishFlowModal();
localStorage.removeItem('ghost-last-published-post');
}
}
get post() {
if (this.feature.publishFlowEndScreen) {
return this._post ?? this.args.post;
}
return this.args.post;
}
set post(value) {
this._post = value;
}
get allowedDisplayOptions() {
if (!this.hasPaidConversionData) {
return this.displayOptions.filter(d => d.value === 'signups');
@ -142,6 +182,19 @@ export default class Analytics extends Component {
}
}
@action
togglePublishFlowModal() {
this.showPostCount = false;
this.openPublishFlowModal();
}
@action
confirmDeleteMember() {
this.modals.open(DeletePostModal, {
post: this.post
});
}
updateLinkData(linksData) {
let updatedLinks;
if (this.links?.length) {
@ -302,6 +355,29 @@ export default class Analytics extends Component {
this.mentions = yield this.store.query('mention', {limit: 5, order: 'created_at desc', filter});
}
@task
*fetchPostCountTask() {
if (!this.post.emailOnly) {
const result = yield this.store.query('post', {filter: 'status:published', limit: 1});
let count = result.meta.pagination.total;
this.postCount = count;
}
}
@task
*fetchPostTask() {
const result = yield this.store.query('post', {filter: `id:${this.post.id}`, limit: 1});
this.post = result.toArray()[0];
if (this.post.email) {
this.notifications.showNotification('Post analytics refreshing', {
description: 'It can take up to five minutes for all data to show.',
type: 'success'
});
}
}
get showLinks() {
return this.post.showEmailClickAnalytics;
}

View File

@ -27,10 +27,6 @@ export default class ApplicationController extends Controller {
return this.config.clientExtensions?.script;
}
get cacheBuster() {
return Date.now();
}
get showNavMenu() {
let {router, session, ui} = this;

View File

@ -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,46 @@ export default class MembersController extends Controller {
return uniqueColumns.splice(0, 2); // Maximum 2 columns
}
/* Due to a limitation with NQL when multiple member filters are used in combination, we currently have a safeguard around member bulk deletion.
* Member bulk deletion is not permitted when:
* 1) Multiple newsletters exist, and 2 or more newsletter filters are in use
* 2) If any of the following Stripe filters are used, even once:
* - Billing period
* - Stripe subscription status
* - Paid start date
* - Next billing date
* - Subscription started on post/page
* - Offers
*
* See issue https://linear.app/tryghost/issue/ENG-1484 for more context
*/
get isBulkDeletePermitted() {
if (!this.isFiltered) {
return false;
}
const newsletterFilters = this.filters.filter(f => f.group === 'Newsletters');
if (newsletterFilters && newsletterFilters.length >= 2) {
return false;
}
const stripeFilters = this.filters.filter(f => [
'subscriptions.plan_interval',
'subscriptions.status',
'subscriptions.start_date',
'subscriptions.current_period_end',
'conversion',
'offer_redemptions'
].includes(f.type));
if (stripeFilters && stripeFilters.length >= 1) {
return false;
}
return true;
}
includeTierQuery() {
const availableFilters = this.filters.length ? this.filters : this.softFilters;
return availableFilters.some((f) => {

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';
@ -85,14 +85,6 @@ export default class PostsController extends Controller {
Object.assign(this, DEFAULT_QUERY_PARAMS.posts);
}
get postsInfinityModel() {
return this.model;
}
get totalPosts() {
return this.model.meta?.pagination?.total ?? 0;
}
get showingAll() {
const {type, author, tag, visibility} = this;

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