Merge branch 'main' into main
This commit is contained in:
commit
2a5437cb12
1
.github/workflows/migration-review.yml
vendored
1
.github/workflows/migration-review.yml
vendored
@ -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
3
.gitignore
vendored
@ -66,9 +66,6 @@ typings/
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
|
||||
# direnv
|
||||
.direnv
|
||||
|
||||
# IDE
|
||||
.idea/*
|
||||
*.iml
|
||||
|
@ -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",
|
||||
|
@ -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')
|
||||
};
|
||||
|
@ -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;
|
||||
|
483
apps/admin-x-activitypub/src/api/activitypub.test.ts
Normal file
483
apps/admin-x-activitypub/src/api/activitypub.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
109
apps/admin-x-activitypub/src/api/activitypub.ts
Normal file
109
apps/admin-x-activitypub/src/api/activitypub.ts
Normal 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');
|
||||
}
|
||||
}
|
52
apps/admin-x-activitypub/src/components/Activities.tsx
Normal file
52
apps/admin-x-activitypub/src/components/Activities.tsx
Normal 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;
|
@ -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;
|
83
apps/admin-x-activitypub/src/components/Inbox.tsx
Normal file
83
apps/admin-x-activitypub/src/components/Inbox.tsx
Normal 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'>
|
||||
We’re so glad to have you on board! At the moment, you can follow other Ghost sites and enjoy their content right here inside Ghost.
|
||||
</p>
|
||||
<p className='text-pretty text-grey-800'>
|
||||
You can see all of the users on the right—find your favorite ones and give them a follow.
|
||||
</p>
|
||||
<Button color='green' label='Learn more' link={true} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Inbox;
|
@ -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'>We’re so glad to have you on board! At the moment, you can follow other Ghost sites and enjoy their content right here inside Ghost.</p>
|
||||
<p className='text-pretty text-grey-800'>You can see all of the users on the right—find your favorite ones and give them a follow.</p>
|
||||
<Button color='green' label='Learn more' link={true}/>
|
||||
</div>
|
||||
</div>}
|
||||
</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'>→</span></span>
|
||||
</div>
|
||||
<div className='group/stat flex cursor-pointer flex-col gap-1' onClick={() => updateRoute('/view-followers')}>
|
||||
<span className='text-3xl font-bold leading-none' data-test-following-count>{followersCount}</span>
|
||||
<span className='text-base leading-none text-grey-800 group-hover/stat:text-grey-900' data-test-followers-modal>Followers<span className='ml-1 opacity-0 transition-opacity group-hover/stat:opacity-100'>→</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='rounded-xl bg-grey-50 p-6'>
|
||||
<header className='mb-4 flex items-center justify-between'>
|
||||
<Heading level={5}>Explore</Heading>
|
||||
<Button label='View all' link={true}/>
|
||||
</header>
|
||||
<List>
|
||||
<ListItem action={<Button color='grey' label='Follow' link={true} onClick={() => {}} />} avatar={<Avatar image={`https://ghost.org/favicon.ico`} size='sm' />} detail='829 followers' hideActions={true} title='404 Media' />
|
||||
<ListItem action={<Button color='grey' label='Follow' link={true} onClick={() => {}} />} avatar={<Avatar image={`https://ghost.org/favicon.ico`} size='sm' />} detail='791 followers' hideActions={true} title='The Browser' />
|
||||
<ListItem action={<Button color='grey' label='Follow' link={true} onClick={() => {}} />} avatar={<Avatar image={`https://ghost.org/favicon.ico`} size='sm' />} detail='854 followers' hideActions={true} title='Welcome to Hell World' />
|
||||
</List>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ArticleBody: React.FC<{heading: string, image: string|undefined, html: string}> = ({heading, image, html}) => {
|
||||
const site = useBrowseSite();
|
||||
const siteData = site.data?.site;
|
||||
|
||||
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;
|
72
apps/admin-x-activitypub/src/components/Profile.tsx
Normal file
72
apps/admin-x-activitypub/src/components/Profile.tsx
Normal 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'>→</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'>→</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
18
apps/admin-x-activitypub/src/components/Search.tsx
Normal file
18
apps/admin-x-activitypub/src/components/Search.tsx
Normal 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;
|
@ -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);
|
538
apps/admin-x-activitypub/src/components/_ObsoleteListIndex.tsx
Normal file
538
apps/admin-x-activitypub/src/components/_ObsoleteListIndex.tsx
Normal 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'>
|
||||
We’re so glad to have you on board! At the moment, you can follow other Ghost sites and enjoy their content right here inside Ghost.
|
||||
</p>
|
||||
<p className='text-pretty text-grey-800'>
|
||||
You can see all of the users on the right—find your favorite ones and give them a follow.
|
||||
</p>
|
||||
<Button color='green' label='Learn more' link={true} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
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'>→</span></span>
|
||||
</div>
|
||||
<div className='group/stat flex cursor-pointer flex-col gap-1' onClick={() => updateRoute('/view-followers')}>
|
||||
<span className='text-3xl font-bold leading-none' data-test-following-count>{followersCount}</span>
|
||||
<span className='text-base leading-none text-grey-800 group-hover/stat:text-grey-900' data-test-followers-modal>Followers<span className='ml-1 opacity-0 transition-opacity group-hover/stat:opacity-100'>→</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
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;
|
@ -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;
|
@ -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);
|
179
apps/admin-x-activitypub/src/components/feed/FeedItem.tsx
Normal file
179
apps/admin-x-activitypub/src/components/feed/FeedItem.tsx
Normal 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;
|
17
apps/admin-x-activitypub/src/components/global/APAvatar.tsx
Normal file
17
apps/admin-x-activitypub/src/components/global/APAvatar.tsx
Normal 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;
|
@ -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;
|
@ -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
|
||||
|
@ -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;
|
@ -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;
|
@ -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);
|
@ -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>
|
@ -22,4 +22,18 @@ 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;
|
||||
}
|
||||
|
||||
|
33
apps/admin-x-activitypub/src/utils/get-relative-timestamp.ts
Normal file
33
apps/admin-x-activitypub/src/utils/get-relative-timestamp.ts
Normal 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;
|
@ -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'
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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>
|
||||
|
1
apps/admin-x-design-system/src/assets/icons/bell.svg
Normal file
1
apps/admin-x-design-system/src/assets/icons/bell.svg
Normal 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 |
1
apps/admin-x-design-system/src/assets/icons/home.svg
Normal file
1
apps/admin-x-design-system/src/assets/icons/home.svg
Normal 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 |
1
apps/admin-x-design-system/src/assets/icons/reload.svg
Normal file
1
apps/admin-x-design-system/src/assets/icons/reload.svg
Normal 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 |
1
apps/admin-x-design-system/src/assets/icons/share.svg
Normal file
1
apps/admin-x-design-system/src/assets/icons/share.svg
Normal 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 |
1
apps/admin-x-design-system/src/assets/icons/user.svg
Normal file
1
apps/admin-x-design-system/src/assets/icons/user.svg
Normal 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 |
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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'
|
||||
);
|
||||
|
@ -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>
|
||||
|
@ -96,4 +96,5 @@
|
||||
/* Prose classes are for formatting arbitrary HTML that comes from the API */
|
||||
.gh-prose-links a {
|
||||
color: #30CF43;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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'
|
||||
},
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
116
apps/admin-x-design-system/test/unit/hooks/usePagination.test.ts
Normal file
116
apps/admin-x-design-system/test/unit/hooks/usePagination.test.ts
Normal 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);
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
@ -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",
|
||||
|
@ -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};
|
||||
|
@ -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",
|
||||
|
@ -1,4 +1,5 @@
|
||||
import MainContent from './MainContent';
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import SettingsAppProvider, {OfficialTheme, UpgradeStatusType} from './components/providers/SettingsAppProvider';
|
||||
import SettingsRouter, {loadModals, modalPaths} from './components/providers/SettingsRouter';
|
||||
import {DesignSystemApp, DesignSystemAppProps} from '@tryghost/admin-x-design-system';
|
||||
@ -18,12 +19,17 @@ function App({framework, designSystem, officialThemes, zapierTemplates, upgradeS
|
||||
return (
|
||||
<FrameworkProvider {...framework}>
|
||||
<SettingsAppProvider officialThemes={officialThemes} upgradeStatus={upgradeStatus} zapierTemplates={zapierTemplates}>
|
||||
<RoutingProvider basePath='settings' modals={{paths: modalPaths, load: loadModals}}>
|
||||
<DesignSystemApp className='admin-x-settings' {...designSystem}>
|
||||
<SettingsRouter />
|
||||
<MainContent />
|
||||
</DesignSystemApp>
|
||||
</RoutingProvider>
|
||||
{/* NOTE: we need to have an extra NiceModal.Provider here because the one inside DesignSystemApp
|
||||
is loaded too late for possible modals in RoutingProvider, and it's quite hard to change it at
|
||||
this point */}
|
||||
<NiceModal.Provider>
|
||||
<RoutingProvider basePath='settings' modals={{paths: modalPaths, load: loadModals}}>
|
||||
<DesignSystemApp className='admin-x-settings' {...designSystem}>
|
||||
<SettingsRouter />
|
||||
<MainContent />
|
||||
</DesignSystemApp>
|
||||
</RoutingProvider>
|
||||
</NiceModal.Provider>
|
||||
</SettingsAppProvider>
|
||||
</FrameworkProvider>
|
||||
);
|
||||
|
@ -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">
|
||||
|
@ -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 = () => {
|
||||
|
@ -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>
|
||||
</>
|
||||
},
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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'>
|
||||
|
@ -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'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');
|
@ -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 —</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');
|
@ -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‘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‘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 {
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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>
|
||||
|
@ -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]);
|
||||
|
117
apps/portal/src/components/pages/AccountEmailPage.test.js
Normal file
117
apps/portal/src/components/pages/AccountEmailPage.test.js
Normal 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}});
|
||||
});
|
||||
});
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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];
|
||||
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
43
flake.lock
43
flake.lock
@ -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
|
||||
}
|
49
flake.nix
49
flake.nix
@ -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`"
|
||||
'';
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
@ -24,6 +24,6 @@
|
||||
"sinon": "15.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tryghost/errors": "1.3.2"
|
||||
"@tryghost/errors": "1.3.5"
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -45,12 +45,14 @@
|
||||
@close={{@close}}
|
||||
/>
|
||||
{{else if this.isComplete}}
|
||||
<Editor::Modals::PublishFlow::Complete
|
||||
@publishOptions={{@data.publishOptions}}
|
||||
@recipientType={{this.recipientType}}
|
||||
@postCount={{this.postCount}}
|
||||
@close={{@close}}
|
||||
/>
|
||||
{{#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}}
|
||||
|
@ -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
|
||||
|
@ -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';
|
||||
|
@ -111,122 +111,104 @@
|
||||
<div class="gh-main-section-content grey gh-member-tier-container" data-test-tier={{tier.id}}>
|
||||
<div class="gh-main-content-card gh-cp-membertier gh-cp-membertier-attribution gh-membertier-subscription {{if (gt tier.subscriptions.length 1) "multiple-subs" ""}}">
|
||||
{{#each tier.subscriptions as |sub index|}}
|
||||
<div class="gh-tier-card-header flex items-center">
|
||||
<div class="gh-tier-card-price">
|
||||
<div class="flex items-start">
|
||||
<span class="currency-symbol">{{sub.price.currencySymbol}}</span>
|
||||
<span class="amount">{{format-number sub.price.nonDecimalAmount}}</span>
|
||||
<div class="gh-tier-card-header flex items-center">
|
||||
<div class="gh-tier-card-price">
|
||||
<div class="flex items-start">
|
||||
<span class="currency-symbol">{{sub.price.currencySymbol}}</span>
|
||||
<span class="amount">{{format-number sub.price.nonDecimalAmount}}</span>
|
||||
</div>
|
||||
<div class="period">{{if (eq sub.price.interval "year") "yearly" "monthly"}}</div>
|
||||
</div>
|
||||
<div class="period">{{if (eq sub.price.interval "year") "yearly" "monthly"}}</div>
|
||||
</div>
|
||||
<div style="margin-left: 16px; flex-grow: 1;">
|
||||
<h3 class="gh-membertier-name" data-test-text="tier-name" style="align-items:center !important; justify-content:flex-start !important;">
|
||||
{{tier.name}}
|
||||
{{#if (eq sub.status "canceled")}}
|
||||
<span class="gh-badge archived" data-test-text="member-subscription-status">Canceled</span>
|
||||
{{else if sub.cancel_at_period_end}}
|
||||
<span class="gh-badge archived" data-test-text="member-subscription-status">Canceled</span>
|
||||
{{else if sub.compExpiry}}
|
||||
<span class="gh-badge active" data-test-text="member-subscription-status">Active</span>
|
||||
{{else if sub.trialUntil}}
|
||||
<span class="gh-badge active" data-test-text="member-subscription-status">Active</span>
|
||||
{{else}}
|
||||
<span class="gh-badge active" data-test-text="member-subscription-status">Active</span>
|
||||
{{/if}}
|
||||
{{#if (gt tier.subscriptions.length 1)}}
|
||||
<span class="gh-membertier-subcount">{{tier.subscriptions.length}} subscriptions</span>
|
||||
{{/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"))}}
|
||||
<div style="margin-left: 16px; flex-grow: 1;">
|
||||
<h3 class="gh-membertier-name" data-test-text="tier-name" style="align-items:center !important; justify-content:flex-start !important;">
|
||||
{{tier.name}}
|
||||
{{#if (eq sub.status "canceled")}}
|
||||
<span class="gh-badge archived" data-test-text="member-subscription-status">Canceled</span>
|
||||
{{else if sub.cancel_at_period_end}}
|
||||
<span class="gh-badge archived" data-test-text="member-subscription-status">Canceled</span>
|
||||
{{else if sub.compExpiry}}
|
||||
<span class="gh-badge active" data-test-text="member-subscription-status">Active</span>
|
||||
{{else if sub.trialUntil}}
|
||||
<span class="gh-badge active" data-test-text="member-subscription-status">Active</span>
|
||||
{{else}}
|
||||
<span class="gh-cp-membertier-pricelabel">{{sub.price.nickname}}</span>
|
||||
<span class="gh-badge active" data-test-text="member-subscription-status">Active</span>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
{{#if sub.trialUntil}}
|
||||
<span class="gh-cp-membertier-renewal"> – </span>
|
||||
{{#if (gt tier.subscriptions.length 1)}}
|
||||
<span class="gh-membertier-subcount">{{tier.subscriptions.length}} subscriptions</span>
|
||||
{{/if}}
|
||||
</h3>
|
||||
<div>
|
||||
<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"> – </span>
|
||||
<span class="gh-cp-membertier-renewal">{{sub.validityDetails}}</span>
|
||||
{{/if}}
|
||||
|
||||
|
||||
</div>
|
||||
<Member::SubscriptionDetailBox @sub={{sub}} @index={{index}} />
|
||||
</div>
|
||||
<Member::SubscriptionDetailBox @sub={{sub}} @index={{index}} />
|
||||
</div>
|
||||
{{#if sub.isComplimentary}}
|
||||
<span class="action-menu">
|
||||
<GhDropdownButton
|
||||
@dropdownName="subscription-menu-complimentary"
|
||||
@classNames="gh-btn gh-btn-outline gh-btn-icon fill gh-btn-subscription-action icon-only"
|
||||
@title="Actions"
|
||||
data-test-button="subscription-actions"
|
||||
>
|
||||
<span>
|
||||
{{svg-jar "dotdotdot"}}
|
||||
<span class="hidden">Subscription menu</span>
|
||||
</span>
|
||||
</GhDropdownButton>
|
||||
<GhDropdown
|
||||
@name="subscription-menu-complimentary"
|
||||
@tagName="ul"
|
||||
@classNames="tier-actions-menu dropdown-menu dropdown-align-right"
|
||||
>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
{{on "click" (fn this.removeComplimentary (or tier.id tier.tier_id))}}
|
||||
data-test-button="remove-complimentary"
|
||||
>
|
||||
<span class="red">Remove complimentary subscription</span>
|
||||
</button>
|
||||
</li>
|
||||
</GhDropdown>
|
||||
</span>
|
||||
{{else}}
|
||||
<span class="action-menu">
|
||||
<GhDropdownButton @dropdownName="subscription-menu-{{sub.id}}" @classNames="gh-btn gh-btn-outline gh-btn-icon fill gh-btn-subscription-action icon-only" @title="Actions">
|
||||
<span>
|
||||
{{svg-jar "dotdotdot"}}
|
||||
<span class="hidden">Subscription menu</span>
|
||||
</span>
|
||||
</GhDropdownButton>
|
||||
<GhDropdown @name="subscription-menu-{{sub.id}}" @tagName="ul" @classNames="tier-actions-menu dropdown-menu dropdown-align-right">
|
||||
<li>
|
||||
<a href="https://dashboard.stripe.com/customers/{{sub.customer.id}}" target="_blank" rel="noopener noreferrer">
|
||||
View Stripe customer
|
||||
</a>
|
||||
</li>
|
||||
<li class="divider"></li>
|
||||
<li>
|
||||
<a href="https://dashboard.stripe.com/subscriptions/{{sub.id}}" target="_blank" rel="noopener noreferrer">
|
||||
View Stripe subscription
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
{{#if (not-eq sub.status "canceled")}}
|
||||
{{#if sub.cancel_at_period_end}}
|
||||
<button type="button" {{on "click" (fn this.continueSubscription sub.id)}}>
|
||||
<span>Continue subscription</span>
|
||||
</button>
|
||||
{{else}}
|
||||
<button type="button" {{on "click" (fn this.cancelSubscription sub.id)}}>
|
||||
<span class="red">Cancel subscription</span>
|
||||
{{#if sub.isComplimentary}}
|
||||
<span class="action-menu">
|
||||
<GhDropdownButton
|
||||
@dropdownName="subscription-menu-complimentary"
|
||||
@classNames="gh-btn gh-btn-outline gh-btn-icon fill gh-btn-subscription-action icon-only"
|
||||
@title="Actions"
|
||||
data-test-button="subscription-actions"
|
||||
>
|
||||
<span>
|
||||
{{svg-jar "dotdotdot"}}
|
||||
<span class="hidden">Subscription menu</span>
|
||||
</span>
|
||||
</GhDropdownButton>
|
||||
<GhDropdown
|
||||
@name="subscription-menu-complimentary"
|
||||
@tagName="ul"
|
||||
@classNames="tier-actions-menu dropdown-menu dropdown-align-right"
|
||||
>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
{{on "click" (fn this.removeComplimentary (or tier.id tier.tier_id))}}
|
||||
data-test-button="remove-complimentary"
|
||||
>
|
||||
<span class="red">Remove complimentary subscription</span>
|
||||
</button>
|
||||
</li>
|
||||
</GhDropdown>
|
||||
</span>
|
||||
{{else}}
|
||||
<span class="action-menu">
|
||||
<GhDropdownButton @dropdownName="subscription-menu-{{sub.id}}" @classNames="gh-btn gh-btn-outline gh-btn-icon fill gh-btn-subscription-action icon-only" @title="Actions">
|
||||
<span>
|
||||
{{svg-jar "dotdotdot"}}
|
||||
<span class="hidden">Subscription menu</span>
|
||||
</span>
|
||||
</GhDropdownButton>
|
||||
<GhDropdown @name="subscription-menu-{{sub.id}}" @tagName="ul" @classNames="tier-actions-menu dropdown-menu dropdown-align-right">
|
||||
<li>
|
||||
<a href="https://dashboard.stripe.com/customers/{{sub.customer.id}}" target="_blank" rel="noopener noreferrer">
|
||||
View Stripe customer
|
||||
</a>
|
||||
</li>
|
||||
<li class="divider"></li>
|
||||
<li>
|
||||
<a href="https://dashboard.stripe.com/subscriptions/{{sub.id}}" target="_blank" rel="noopener noreferrer">
|
||||
View Stripe subscription
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
{{#if (not-eq sub.status "canceled")}}
|
||||
{{#if sub.cancel_at_period_end}}
|
||||
<button type="button" {{on "click" (fn this.continueSubscription sub.id)}}>
|
||||
<span>Continue subscription</span>
|
||||
</button>
|
||||
{{else}}
|
||||
<button type="button" {{on "click" (fn this.cancelSubscription sub.id)}}>
|
||||
<span class="red">Cancel subscription</span>
|
||||
</button>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</li>
|
||||
</GhDropdown>
|
||||
</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
</li>
|
||||
</GhDropdown>
|
||||
</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/each}}
|
||||
|
||||
{{#if (eq tier.subscriptions.length 0)}}
|
||||
|
@ -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}}
|
||||
|
@ -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});
|
||||
|
||||
|
@ -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;
|
||||
|
@ -1,8 +1,8 @@
|
||||
import {DATE_RELATION_OPTIONS} from './relation-options';
|
||||
|
||||
export const CREATED_AT_FILTER = {
|
||||
label: 'Created',
|
||||
name: 'created_at',
|
||||
valueType: 'date',
|
||||
label: 'Created',
|
||||
name: 'created_at',
|
||||
valueType: 'date',
|
||||
relationOptions: DATE_RELATION_OPTIONS
|
||||
};
|
||||
|
@ -1,10 +1,10 @@
|
||||
import {MATCH_RELATION_OPTIONS} from './relation-options';
|
||||
|
||||
export const EMAIL_CLICKED_FILTER = {
|
||||
label: 'Clicked email',
|
||||
name: 'clicked_links.post_id',
|
||||
valueType: 'string',
|
||||
resource: 'email',
|
||||
label: 'Clicked email',
|
||||
name: 'clicked_links.post_id',
|
||||
valueType: 'string',
|
||||
resource: 'email',
|
||||
relationOptions: MATCH_RELATION_OPTIONS,
|
||||
columnLabel: 'Clicked email',
|
||||
setting: 'emailTrackClicks',
|
||||
|
@ -2,10 +2,10 @@ import {NUMBER_RELATION_OPTIONS} from './relation-options';
|
||||
import {formatNumber} from 'ghost-admin/helpers/format-number';
|
||||
|
||||
export const EMAIL_COUNT_FILTER = {
|
||||
label: 'Emails sent (all time)',
|
||||
name: 'email_count',
|
||||
columnLabel: 'Email count',
|
||||
valueType: 'number',
|
||||
label: 'Emails sent (all time)',
|
||||
name: 'email_count',
|
||||
columnLabel: 'Email count',
|
||||
valueType: 'number',
|
||||
relationOptions: NUMBER_RELATION_OPTIONS,
|
||||
getColumnValue: (member) => {
|
||||
return {
|
||||
|
@ -1,9 +1,9 @@
|
||||
import {NUMBER_RELATION_OPTIONS} from './relation-options';
|
||||
|
||||
export const EMAIL_OPEN_RATE_FILTER = {
|
||||
label: 'Open rate (all time)',
|
||||
name: 'email_open_rate',
|
||||
valueType: 'number',
|
||||
label: 'Open rate (all time)',
|
||||
name: 'email_open_rate',
|
||||
valueType: 'number',
|
||||
setting: 'emailTrackOpens',
|
||||
relationOptions: NUMBER_RELATION_OPTIONS
|
||||
};
|
||||
|
@ -2,10 +2,10 @@ import {NUMBER_RELATION_OPTIONS} from './relation-options';
|
||||
import {formatNumber} from 'ghost-admin/helpers/format-number';
|
||||
|
||||
export const EMAIL_OPENED_COUNT_FILTER = {
|
||||
label: 'Emails opened (all time)',
|
||||
name: 'email_opened_count',
|
||||
columnLabel: 'Email opened count',
|
||||
valueType: 'number',
|
||||
label: 'Emails opened (all time)',
|
||||
name: 'email_opened_count',
|
||||
columnLabel: 'Email opened count',
|
||||
valueType: 'number',
|
||||
relationOptions: NUMBER_RELATION_OPTIONS,
|
||||
getColumnValue: (member) => {
|
||||
return {
|
||||
|
@ -1,10 +1,10 @@
|
||||
import {MATCH_RELATION_OPTIONS} from './relation-options';
|
||||
|
||||
export const EMAIL_OPENED_FILTER = {
|
||||
label: 'Opened email',
|
||||
name: 'opened_emails.post_id',
|
||||
valueType: 'string',
|
||||
resource: 'email',
|
||||
label: 'Opened email',
|
||||
name: 'opened_emails.post_id',
|
||||
valueType: 'string',
|
||||
resource: 'email',
|
||||
relationOptions: MATCH_RELATION_OPTIONS,
|
||||
columnLabel: 'Opened email',
|
||||
setting: 'emailTrackOpens',
|
||||
|
@ -1,8 +1,8 @@
|
||||
import {CONTAINS_RELATION_OPTIONS} from './relation-options';
|
||||
|
||||
export const EMAIL_FILTER = {
|
||||
label: 'Email',
|
||||
label: 'Email',
|
||||
name: 'email',
|
||||
valueType: 'string',
|
||||
valueType: 'string',
|
||||
relationOptions: CONTAINS_RELATION_OPTIONS
|
||||
};
|
||||
|
@ -1,10 +1,10 @@
|
||||
import {MATCH_RELATION_OPTIONS} from './relation-options';
|
||||
|
||||
export const LABEL_FILTER = {
|
||||
label: 'Label',
|
||||
name: 'label',
|
||||
valueType: 'array',
|
||||
columnLabel: 'Label',
|
||||
label: 'Label',
|
||||
name: 'label',
|
||||
valueType: 'array',
|
||||
columnLabel: 'Label',
|
||||
relationOptions: MATCH_RELATION_OPTIONS,
|
||||
getColumnValue: (member) => {
|
||||
return {
|
||||
|
@ -2,10 +2,10 @@ import {DATE_RELATION_OPTIONS} from './relation-options';
|
||||
import {getDateColumnValue} from './columns/date-column';
|
||||
|
||||
export const LAST_SEEN_FILTER = {
|
||||
label: 'Last seen',
|
||||
name: 'last_seen_at',
|
||||
valueType: 'date',
|
||||
columnLabel: 'Last seen at',
|
||||
label: 'Last seen',
|
||||
name: 'last_seen_at',
|
||||
valueType: 'date',
|
||||
columnLabel: 'Last seen at',
|
||||
relationOptions: DATE_RELATION_OPTIONS,
|
||||
getColumnValue: (member, filter) => {
|
||||
return getDateColumnValue(member.lastSeenAtUTC, filter);
|
||||
|
@ -1,8 +1,8 @@
|
||||
import {CONTAINS_RELATION_OPTIONS} from './relation-options';
|
||||
|
||||
export const NAME_FILTER = {
|
||||
label: 'Name',
|
||||
name: 'name',
|
||||
valueType: 'string',
|
||||
label: 'Name',
|
||||
name: 'name',
|
||||
valueType: 'string',
|
||||
relationOptions: CONTAINS_RELATION_OPTIONS
|
||||
};
|
||||
|
@ -1,7 +1,7 @@
|
||||
import {MATCH_RELATION_OPTIONS} from './relation-options';
|
||||
|
||||
export const OFFERS_FILTER = {
|
||||
label: 'Offers',
|
||||
label: 'Offers',
|
||||
name: 'offer_redemptions',
|
||||
group: 'Subscription',
|
||||
relationOptions: MATCH_RELATION_OPTIONS,
|
||||
|
@ -1,10 +1,10 @@
|
||||
import {MATCH_RELATION_OPTIONS} from './relation-options';
|
||||
|
||||
export const SIGNUP_ATTRIBUTION_FILTER = {
|
||||
label: 'Signed up on post/page',
|
||||
name: 'signup',
|
||||
valueType: 'string',
|
||||
resource: 'post',
|
||||
label: 'Signed up on post/page',
|
||||
name: 'signup',
|
||||
valueType: 'string',
|
||||
resource: 'post',
|
||||
relationOptions: MATCH_RELATION_OPTIONS,
|
||||
columnLabel: 'Signed up on',
|
||||
setting: 'membersTrackSources',
|
||||
|
@ -1,8 +1,8 @@
|
||||
import {MATCH_RELATION_OPTIONS} from './relation-options';
|
||||
|
||||
export const STATUS_FILTER = {
|
||||
label: 'Member status',
|
||||
name: 'status',
|
||||
label: 'Member status',
|
||||
name: 'status',
|
||||
relationOptions: MATCH_RELATION_OPTIONS,
|
||||
valueType: 'options',
|
||||
options: [
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user