Merge branch 'main' into optimizingPostBrowseQuery
10
.github/scripts/docker-compose.yml
vendored
@ -27,3 +27,13 @@ services:
|
||||
ports:
|
||||
- "6379:6379"
|
||||
restart: always
|
||||
jaeger:
|
||||
image: jaegertracing/all-in-one:1.58
|
||||
container_name: ghost-jaeger
|
||||
ports:
|
||||
- "4318:4318"
|
||||
- "16686:16686"
|
||||
- "9411:9411"
|
||||
restart: always
|
||||
environment:
|
||||
COLLECTOR_ZIPKIN_HOST_PORT: :9411
|
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
@ -66,9 +66,6 @@ typings/
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
|
||||
# direnv
|
||||
.direnv
|
||||
|
||||
# IDE
|
||||
.idea/*
|
||||
*.iml
|
||||
|
2
LICENSE
@ -1,4 +1,4 @@
|
||||
Copyright (c) 2013-2023 Ghost Foundation
|
||||
Copyright (c) 2013-2024 Ghost Foundation
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
|
11
README.md
@ -17,7 +17,7 @@
|
||||
<a href="https://twitter.com/ghost">Twitter</a>
|
||||
<br /><br />
|
||||
<a href="https://ghost.org/">
|
||||
<img src="https://img.shields.io/badge/downloads-3M-brightgreen.svg" alt="Downloads" />
|
||||
<img src="https://img.shields.io/badge/downloads-100M+-brightgreen.svg" alt="Downloads" />
|
||||
</a>
|
||||
<a href="https://github.com/TryGhost/Ghost/releases/">
|
||||
<img src="https://img.shields.io/github/release/TryGhost/Ghost.svg" alt="Latest release" />
|
||||
@ -82,7 +82,7 @@ For anyone wishing to contribute to Ghost or to hack/customize core files we rec
|
||||
|
||||
# Ghost sponsors
|
||||
|
||||
We'd like to extend big thanks to our sponsors and partners who make Ghost possible. If you're interested in sponsoring Ghost and supporting the project, please check out our profile on [GitHub sponsors](https://github.com/sponsors/TryGhost) :heart:
|
||||
A big thanks to our sponsors and partners who make Ghost possible. If you're interested in sponsoring Ghost and supporting the project, please check out our profile on [GitHub sponsors](https://github.com/sponsors/TryGhost) :heart:
|
||||
|
||||
**[DigitalOcean](https://m.do.co/c/9ff29836d717)** • **[Fastly](https://www.fastly.com/)**
|
||||
|
||||
@ -90,12 +90,13 @@ We'd like to extend big thanks to our sponsors and partners who make Ghost possi
|
||||
|
||||
# Getting help
|
||||
|
||||
You can find answers to a huge variety of questions, along with a large community of helpful developers over on the [Ghost forum](https://forum.ghost.org/) - replies are generally very quick. **Ghost(Pro)** customers also have access to 24/7 email support.
|
||||
Everyone can get help and support from a large community of developers over on the [Ghost forum](https://forum.ghost.org/). **Ghost(Pro)** customers have access to 24/7 email support.
|
||||
|
||||
To stay up to date with all the latest news and product updates, make sure you [subscribe to our blog](https://ghost.org/blog/) — or you can always follow us [on Twitter](https://twitter.com/Ghost), if you prefer your updates bite-sized and facetious. :saxophone::turtle:
|
||||
To stay up to date with all the latest news and product updates, make sure you [subscribe to our changelog newsletter](https://ghost.org/changelog/) — or follow us [on Twitter](https://twitter.com/Ghost), if you prefer your updates bite-sized and facetious. :saxophone::turtle:
|
||||
|
||||
|
||||
|
||||
# Copyright & license
|
||||
|
||||
Copyright (c) 2013-2023 Ghost Foundation - Released under the [MIT license](LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage.
|
||||
Copyright (c) 2013-2024 Ghost Foundation - Released under the [MIT license](LICENSE).
|
||||
Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage.
|
||||
|
@ -32,13 +32,17 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "14.1.0",
|
||||
"@playwright/test": "1.38.1",
|
||||
"@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",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"jest": "29.7.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
"react-dom": "18.3.1",
|
||||
"ts-jest": "29.1.5"
|
||||
},
|
||||
"nx": {
|
||||
"targets": {
|
||||
|
1928
apps/admin-x-activitypub/public/styles/reader.css
Normal file
@ -1,4 +1,4 @@
|
||||
import ListIndex from './components/ListIndex';
|
||||
import MainContent from './MainContent';
|
||||
import {DesignSystemApp, DesignSystemAppProps} from '@tryghost/admin-x-design-system';
|
||||
import {FrameworkProvider, TopLevelFrameworkProps} from '@tryghost/admin-x-framework';
|
||||
import {RoutingProvider} from '@tryghost/admin-x-framework/routing';
|
||||
@ -8,16 +8,25 @@ interface AppProps {
|
||||
designSystem: DesignSystemAppProps;
|
||||
}
|
||||
|
||||
const modals = {
|
||||
paths: {
|
||||
'follow-site': 'FollowSite',
|
||||
'profile/following': 'ViewFollowing',
|
||||
'profile/followers': 'ViewFollowers'
|
||||
},
|
||||
load: async () => import('./components/modals')
|
||||
};
|
||||
|
||||
const App: React.FC<AppProps> = ({framework, designSystem}) => {
|
||||
return (
|
||||
<FrameworkProvider {...framework}>
|
||||
<RoutingProvider basePath='activitypub'>
|
||||
<RoutingProvider basePath='activitypub' modals={modals}>
|
||||
<DesignSystemApp className='admin-x-activitypub' {...designSystem}>
|
||||
<ListIndex />
|
||||
<MainContent />
|
||||
</DesignSystemApp>
|
||||
</RoutingProvider>
|
||||
</FrameworkProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
export default App;
|
46
apps/admin-x-activitypub/src/MainContent.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
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 = () => {
|
||||
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
@ -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
@ -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');
|
||||
}
|
||||
}
|
BIN
apps/admin-x-activitypub/src/assets/images/ap-welcome.png
Normal file
After Width: | Height: | Size: 80 KiB |
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;
|
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,26 +0,0 @@
|
||||
const ListIndex = () => {
|
||||
return (
|
||||
<div className='mx-auto my-0 w-full max-w-3xl p-12'>
|
||||
<h1 className='mb-6 text-black'>ActivityPub Demo</h1>
|
||||
<div className='flex flex-col'>
|
||||
<div className='mb-4 flex flex-col'>
|
||||
<h2 className='mb-2 text-2xl text-black'>This is a post title</h2>
|
||||
<p className='mb-2 text-lg text-grey-950'>This is some very short post content</p>
|
||||
<p className='text-md text-grey-700'>Publish McPublisher</p>
|
||||
</div>
|
||||
<div className='mb-4 flex flex-col'>
|
||||
<h2 className='mb-2 text-2xl text-black'>This is a post title</h2>
|
||||
<p className='mb-2 text-lg text-grey-950'>This is some very short post content</p>
|
||||
<p className='text-md text-grey-700'>Publish McPublisher</p>
|
||||
</div>
|
||||
<div className='mb-4 flex flex-col'>
|
||||
<h2 className='mb-2 text-2xl text-black'>This is a post title</h2>
|
||||
<p className='mb-2 text-lg text-grey-950'>This is some very short post content</p>
|
||||
<p className='text-md text-grey-700'>Publish McPublisher</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListIndex;
|
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
@ -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;
|
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;
|
5911
apps/admin-x-activitypub/src/components/articleBodyStyles.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import MainHeader from '../navigation/MainHeader';
|
||||
import NiceModal, {useModal} from '@ebay/nice-modal-react';
|
||||
import React, {useEffect, useRef} from 'react';
|
||||
import articleBodyStyles from '../articleBodyStyles';
|
||||
import {Button, Modal} from '@tryghost/admin-x-design-system';
|
||||
import {ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
|
||||
import {renderAttachment} from './FeedItem';
|
||||
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
|
||||
|
||||
interface ArticleModalProps {
|
||||
object: ObjectProperties;
|
||||
}
|
||||
|
||||
const ArticleBody: React.FC<{heading: string, image: string|undefined, html: string}> = ({heading, image, html}) => {
|
||||
const site = useBrowseSite();
|
||||
const siteData = site.data?.site;
|
||||
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const cssContent = articleBodyStyles(siteData?.url.replace(/\/$/, ''));
|
||||
|
||||
const htmlContent = `
|
||||
<html>
|
||||
<head>
|
||||
${cssContent}
|
||||
</head>
|
||||
<body>
|
||||
<header class='gh-article-header gh-canvas'>
|
||||
<h1 class='gh-article-title is-title' data-test-article-heading>${heading}</h1>
|
||||
${image &&
|
||||
`<figure class='gh-article-image'>
|
||||
<img src='${image}' alt='${heading}' />
|
||||
</figure>`
|
||||
}
|
||||
</header>
|
||||
<div class='gh-content gh-canvas is-body'>
|
||||
${html}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (iframe) {
|
||||
iframe.srcdoc = htmlContent;
|
||||
}
|
||||
}, [htmlContent]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className={`h-[calc(100vh_-_3vmin_-_4.8rem_-_2px)]`}
|
||||
height='100%'
|
||||
id='gh-ap-article-iframe'
|
||||
title='Embedded Content'
|
||||
width='100%'
|
||||
>
|
||||
</iframe>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ArticleModal: React.FC<ArticleModalProps> = ({object}) => {
|
||||
const modal = useModal();
|
||||
return (
|
||||
<Modal
|
||||
align='right'
|
||||
animate={true}
|
||||
footer={<></>}
|
||||
height={'full'}
|
||||
padding={false}
|
||||
size='bleed'
|
||||
width={640}
|
||||
>
|
||||
<MainHeader>
|
||||
<div className='col-[3/4] flex items-center justify-end px-8'>
|
||||
<Button icon='close' size='sm' unstyled onClick={() => modal.remove()}/>
|
||||
</div>
|
||||
</MainHeader>
|
||||
<div className='mt-10 w-auto'>
|
||||
{object.type === 'Note' && (
|
||||
<div className='mx-auto max-w-[580px]'>
|
||||
{object.content && <div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.5rem] text-grey-900'></div>}
|
||||
{renderAttachment(object)}
|
||||
</div>)}
|
||||
{object.type === 'Article' && <ArticleBody heading={object.name} html={object.content} image={object?.image}/>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default NiceModal.create(ArticleModal);
|
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
@ -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;
|
11
apps/admin-x-activitypub/src/components/modals.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
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
|
||||
const modals = {FollowSite, ViewFollowing, ViewFollowers} satisfies {[key: string]: ModalComponent<any>};
|
||||
|
||||
export default modals;
|
||||
|
||||
export type ModalName = keyof typeof modals;
|
@ -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);
|
@ -0,0 +1,71 @@
|
||||
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 {useQuery} from '@tanstack/react-query';
|
||||
|
||||
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> = ({}) => {
|
||||
const {updateRoute} = useRouting();
|
||||
|
||||
const {data: items = []} = useFollowingForUser('index');
|
||||
|
||||
const following = Array.isArray(items) ? items : [items];
|
||||
return (
|
||||
<Modal
|
||||
afterClose={() => {
|
||||
updateRoute('profile');
|
||||
}}
|
||||
cancelLabel=''
|
||||
footer={false}
|
||||
okLabel=''
|
||||
size='md'
|
||||
title='Following'
|
||||
topRightContent='close'
|
||||
>
|
||||
<div className='mt-3 flex flex-col gap-4 pb-12'>
|
||||
<List>
|
||||
{following.map(item => (
|
||||
<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>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className='group flex items-center gap-3 hover:cursor-pointer'>
|
||||
<div className={`flex grow flex-col`}>
|
||||
<div className="mb-0.5 flex items-center gap-3">
|
||||
<img className='w-5' src='https://www.platformer.news/content/images/size/w256h256/2024/05/Logomark_Blue_800px.png'/>
|
||||
<span className='line-clamp-1 font-medium'>Platformer Platformer Platformer Platformer Platformer</span>
|
||||
<span className='line-clamp-1'>@index@platformerplatformerplatformerplatformer.news</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='w-[1%] whitespace-nowrap'><div className='mt-1 whitespace-nowrap text-right text-sm text-grey-700'>Unfollow</div></TableCell>
|
||||
</TableRow>
|
||||
</Table> */}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default NiceModal.create(ViewFollowingModal);
|
@ -1 +1,39 @@
|
||||
@import '@tryghost/admin-x-design-system/styles.css';
|
||||
|
||||
.admin-x-base.admin-x-activitypub {
|
||||
animation-name: none;
|
||||
}
|
||||
|
||||
@keyframes bump {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.bump {
|
||||
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
@ -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;
|
12
apps/admin-x-activitypub/src/utils/get-username.ts
Normal file
@ -0,0 +1,12 @@
|
||||
function getUsername(actor: {preferredUsername: string; id: string|null;}) {
|
||||
if (!actor.preferredUsername || !actor.id) {
|
||||
return '@unknown@unknown';
|
||||
}
|
||||
try {
|
||||
return `@${actor.preferredUsername}@${(new URL(actor.id)).hostname}`;
|
||||
} catch (err) {
|
||||
return '@unknown@unknown';
|
||||
}
|
||||
}
|
||||
|
||||
export default getUsername;
|
@ -5,6 +5,6 @@ test.describe('Demo', async () => {
|
||||
test('Renders the list page', async ({page}) => {
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.locator('body')).toContainText('ActivityPub Demo');
|
||||
await expect(page.locator('body')).toContainText('ActivityPub Inbox');
|
||||
});
|
||||
});
|
||||
|
52
apps/admin-x-activitypub/test/acceptance/listIndex.test.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import {expect, test} from '@playwright/test';
|
||||
import {mockApi, responseFixtures} from '@tryghost/admin-x-framework/test/acceptance';
|
||||
|
||||
test.describe('ListIndex', async () => {
|
||||
test('Renders the list page', async ({page}) => {
|
||||
const userId = 'index';
|
||||
await mockApi({
|
||||
page,
|
||||
requests: {
|
||||
useBrowseInboxForUser: {method: 'GET', path: `/inbox/${userId}`, response: responseFixtures.activitypubInbox},
|
||||
useBrowseFollowingForUser: {method: 'GET', path: `/following/${userId}`, response: responseFixtures.activitypubFollowing}
|
||||
},
|
||||
options: {useActivityPub: true}
|
||||
});
|
||||
|
||||
// Printing browser consol logs
|
||||
page.on('console', (msg) => {
|
||||
console.log(`Browser console log: ${msg.type()}: ${msg.text()}`); /* eslint-disable-line no-console */
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.locator('body')).toContainText('ActivityPub Inbox');
|
||||
|
||||
// following list
|
||||
const followingUser = await page.locator('[data-test-following] > li').textContent();
|
||||
await expect(followingUser).toEqual('@index@main.ghost.org');
|
||||
const followingCount = await page.locator('[data-test-following-count]').textContent();
|
||||
await expect(followingCount).toEqual('1');
|
||||
|
||||
// following button
|
||||
const followingList = await page.locator('[data-test-following-modal]');
|
||||
await expect(followingList).toBeVisible();
|
||||
|
||||
// activities
|
||||
const activity = await page.locator('[data-test-activity-heading]').textContent();
|
||||
await expect(activity).toEqual('Testing ActivityPub');
|
||||
|
||||
// click on article
|
||||
const articleBtn = await page.locator('[data-test-view-article]');
|
||||
await articleBtn.click();
|
||||
|
||||
// article is expanded
|
||||
const frameLocator = page.frameLocator('#gh-ap-article-iframe');
|
||||
const textElement = await frameLocator.locator('[data-test-article-heading]').innerText();
|
||||
expect(textElement).toContain('Testing ActivityPub');
|
||||
|
||||
// go back to list
|
||||
const backBtn = await page.locator('[data-test-back-button]');
|
||||
await backBtn.click();
|
||||
});
|
||||
});
|
@ -1,10 +0,0 @@
|
||||
import ListIndex from '../../src/components/ListIndex';
|
||||
import {render, screen} from '@testing-library/react';
|
||||
|
||||
describe('Demo', function () {
|
||||
it('renders a component', async function () {
|
||||
render(<ListIndex/>);
|
||||
|
||||
expect(screen.getAllByRole('heading')[0].textContent).toEqual('ActivityPub Demo');
|
||||
});
|
||||
});
|
@ -0,0 +1,36 @@
|
||||
import getUsername from '../../../src/utils/get-username';
|
||||
|
||||
describe('getUsername', function () {
|
||||
it('returns the formatted username', async function () {
|
||||
const user = {
|
||||
preferredUsername: 'index',
|
||||
id: 'https://www.platformer.news/'
|
||||
};
|
||||
|
||||
const result = getUsername(user);
|
||||
|
||||
expect(result).toBe('@index@www.platformer.news');
|
||||
});
|
||||
|
||||
it('returns a default username if the user object is missing data', async function () {
|
||||
const user = {
|
||||
preferredUsername: '',
|
||||
id: ''
|
||||
};
|
||||
|
||||
const result = getUsername(user);
|
||||
|
||||
expect(result).toBe('@unknown@unknown');
|
||||
});
|
||||
|
||||
it('returns a default username if url parsing fails', async function () {
|
||||
const user = {
|
||||
preferredUsername: 'index',
|
||||
id: 'not-a-url'
|
||||
};
|
||||
|
||||
const result = getUsername(user);
|
||||
|
||||
expect(result).toBe('@unknown@unknown');
|
||||
});
|
||||
});
|
@ -4,6 +4,7 @@
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"types": ["vite/client", "jest"],
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
|
@ -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",
|
||||
|
@ -27,7 +27,7 @@ const ListPage = () => {
|
||||
label: 'Open Rate'
|
||||
}
|
||||
]}
|
||||
position="left"
|
||||
position="start"
|
||||
onDirectionChange={() => {}}
|
||||
onSortChange={() => {}}
|
||||
/>,
|
||||
|
@ -11,7 +11,8 @@
|
||||
"scripts": {
|
||||
"build": "concurrently \"vite build\" \"tsc -p tsconfig.declaration.json\"",
|
||||
"prepare": "yarn build",
|
||||
"test": "yarn test:types",
|
||||
"test": "yarn test:unit && yarn test:types",
|
||||
"test:unit": "yarn nx build && vitest run",
|
||||
"test:types": "tsc --noEmit",
|
||||
"lint:code": "eslint --ext .js,.ts,.cjs,.tsx src/ --cache",
|
||||
"lint": "yarn lint:code && yarn lint:test",
|
||||
@ -27,27 +28,30 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"@codemirror/lang-html": "6.4.9",
|
||||
"@storybook/addon-essentials": "7.6.19",
|
||||
"@storybook/addon-interactions": "7.6.19",
|
||||
"@storybook/addon-links": "7.6.19",
|
||||
"@storybook/addon-essentials": "7.6.20",
|
||||
"@storybook/addon-interactions": "7.6.20",
|
||||
"@storybook/addon-links": "7.6.20",
|
||||
"@radix-ui/react-tooltip": "1.1.2",
|
||||
"@storybook/addon-styling": "1.3.7",
|
||||
"@storybook/blocks": "7.6.19",
|
||||
"@storybook/react": "7.6.19",
|
||||
"@storybook/blocks": "7.6.20",
|
||||
"@storybook/react": "7.6.20",
|
||||
"@storybook/react-vite": "7.6.4",
|
||||
"@storybook/testing-library": "0.2.2",
|
||||
"@testing-library/react": "14.1.0",
|
||||
"@testing-library/react": "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",
|
||||
"sinon": "17.0.0",
|
||||
"storybook": "7.6.19",
|
||||
"storybook": "7.6.20",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.4.5",
|
||||
"vite": "4.5.3",
|
||||
@ -57,18 +61,27 @@
|
||||
"@dnd-kit/core": "6.1.0",
|
||||
"@dnd-kit/sortable": "7.0.2",
|
||||
"@ebay/nice-modal-react": "1.2.13",
|
||||
"@sentry/react": "7.116.0",
|
||||
"@radix-ui/react-avatar": "1.1.0",
|
||||
"@radix-ui/react-checkbox": "1.1.1",
|
||||
"@radix-ui/react-form": "0.0.3",
|
||||
"@radix-ui/react-popover": "1.1.1",
|
||||
"@radix-ui/react-radio-group": "1.2.0",
|
||||
"@radix-ui/react-separator": "1.1.0",
|
||||
"@radix-ui/react-switch": "1.1.0",
|
||||
"@radix-ui/react-tabs": "1.1.0",
|
||||
"@radix-ui/react-tooltip": "1.1.2",
|
||||
"@sentry/react": "7.119.0",
|
||||
"@tailwindcss/forms": "0.5.7",
|
||||
"@tailwindcss/line-clamp": "0.4.4",
|
||||
"@uiw/react-codemirror": "4.22.1",
|
||||
"@uiw/react-codemirror": "4.23.0",
|
||||
"autoprefixer": "10.4.19",
|
||||
"clsx": "2.1.1",
|
||||
"postcss": "8.4.38",
|
||||
"postcss": "8.4.39",
|
||||
"postcss-import": "16.1.0",
|
||||
"react-colorful": "5.6.1",
|
||||
"react-hot-toast": "2.4.1",
|
||||
"react-select": "5.8.0",
|
||||
"tailwindcss": "3.4.3"
|
||||
"tailwindcss": "3.4.10"
|
||||
},
|
||||
"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
@ -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
@ -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 +1 @@
|
||||
<svg viewBox="-0.75 -0.75 24 24" xmlns="http://www.w3.org/2000/svg" height="24" width="24"><path d="M11.8640625 16.8684375a4.273125 4.273125 0 0 1 -5.6690625 2.041875h0a4.273125 4.273125 0 0 1 -2.041875 -5.6690625l1.2956249999999998 -2.7534375a4.2721875 4.2721875 0 0 1 5.668125 -2.041875h0a4.2590625 4.2590625 0 0 1 2.3540625 2.9915624999999997" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="M11.105625 5.7253125a4.273125 4.273125 0 0 1 5.6690625 -2.041875h0a4.273125 4.273125 0 0 1 2.041875 5.668125l-1.2956249999999998 2.7534375a4.273125 4.273125 0 0 1 -5.6690625 2.041875h0a4.2496875 4.2496875 0 0 1 -2.205 -2.4553125000000002" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path></svg>
|
||||
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" id="Hyperlink-Circle--Streamline-Ultimate" height="20" width="20"><desc>Hyperlink Circle Streamline Icon: https://streamlinehq.com</desc><path d="M10.426416666666666 16.262500000000003C9.295 18.64975 6.448083333333334 19.675166666666666 4.054333333333333 18.55766666666667H4.054333333333333C1.6670833333333335 17.42625 0.6416666666666667 14.579250000000002 1.75925 12.185500000000001L3.2155 9.090583333333333C4.3465 6.7035 7.193166666666667 5.678 9.586583333333333 6.7955000000000005H9.586583333333333C10.948333333333334 7.437916666666666 11.928416666666667 8.6835 12.232583333333334 10.158083333333334" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="M9.573916666666667 3.7375000000000003C10.705333333333334 1.3502500000000002 13.552333333333333 0.3248333333333333 15.946083333333334 1.442416666666667H15.946083333333334C18.33275 2.57375 19.358 5.4199166666666665 18.241166666666665 7.813416666666666L16.784833333333335 10.908333333333333C15.653416666666667 13.295583333333335 12.806500000000002 14.321 10.41275 13.203416666666666H10.41275C9.248583333333334 12.654916666666667 8.354916666666668 11.659916666666666 7.934333333333334 10.443666666666667" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path></svg>
|
||||
|
Before Width: | Height: | Size: 815 B After Width: | Height: | Size: 1.4 KiB |
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
@ -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
@ -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 |
@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import {ReactComponent as UserIcon} from '../assets/icons/single-user-fill.svg';
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||
|
||||
type AvatarSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
|
||||
@ -44,21 +45,17 @@ const Avatar: React.FC<AvatarProps> = ({image, label, labelColor, bgColor, size,
|
||||
break;
|
||||
}
|
||||
|
||||
if (image) {
|
||||
return (
|
||||
<img alt="" className={`inline-flex shrink-0 items-center justify-center rounded-full object-cover font-semibold ${avatarSize} ${className && className}`} src={image}/>
|
||||
);
|
||||
} else if (label) {
|
||||
return (
|
||||
<div className={`${labelColor && `text-${labelColor}`} inline-flex items-center justify-center rounded-full p-2 font-semibold ${avatarSize} ${className && className}`} style={bgColor ? {backgroundColor: bgColor} : {}}>{label}</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className={`inline-flex items-center justify-center overflow-hidden rounded-full bg-grey-100 p-1 font-semibold ${avatarSize} ${className && className}`}>
|
||||
<UserIcon className={`${fallbackPosition} h-full w-full text-grey-300`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AvatarPrimitive.Root className={`relative inline-flex select-none items-center justify-center overflow-hidden rounded-full align-middle ${avatarSize}`}>
|
||||
{image ?
|
||||
<AvatarPrimitive.Image className={`absolute z-20 h-full w-full object-cover ${className && className}`} src={image} /> :
|
||||
<span className={`${labelColor && `text-${labelColor}`} relative z-10 inline-flex h-full w-full items-center justify-center p-2 font-semibold ${className && className}`} style={bgColor ? {backgroundColor: bgColor} : {}}>{label}</span>
|
||||
}
|
||||
<AvatarPrimitive.Fallback asChild>
|
||||
<UserIcon className={`${fallbackPosition} absolute z-0 h-full w-full text-grey-300`} />
|
||||
</AvatarPrimitive.Fallback>
|
||||
</AvatarPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default Avatar;
|
||||
|
@ -30,7 +30,7 @@ export interface ButtonProps extends Omit<HTMLProps<HTMLButtonElement>, 'label'
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
const Button: React.FC<ButtonProps> = React.forwardRef(({
|
||||
testId,
|
||||
size = 'md',
|
||||
label = '',
|
||||
@ -51,7 +51,7 @@ const Button: React.FC<ButtonProps> = ({
|
||||
outlineOnMobile = false,
|
||||
onClick,
|
||||
...props
|
||||
}) => {
|
||||
}, ref) => {
|
||||
if (!color) {
|
||||
color = 'clear';
|
||||
}
|
||||
@ -155,9 +155,12 @@ const Button: React.FC<ButtonProps> = ({
|
||||
disabled: disabled,
|
||||
type: 'button',
|
||||
onClick: onClick,
|
||||
ref: ref,
|
||||
...props}, buttonChildren);
|
||||
|
||||
return buttonElement;
|
||||
};
|
||||
});
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export default Button;
|
||||
|
@ -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;
|
||||
|
@ -24,7 +24,7 @@ export const Default: Story = {
|
||||
args: {
|
||||
trigger: <Button color='black' label="Click"></Button>,
|
||||
items: items,
|
||||
position: 'left'
|
||||
position: 'start'
|
||||
},
|
||||
decorators: [
|
||||
ThisStory => (
|
||||
@ -37,7 +37,7 @@ export const Right: Story = {
|
||||
args: {
|
||||
trigger: <Button color='black' label="Click"></Button>,
|
||||
items: items,
|
||||
position: 'right'
|
||||
position: 'end'
|
||||
},
|
||||
decorators: [
|
||||
ThisStory => (
|
||||
|
@ -20,7 +20,7 @@ const Menu: React.FC<MenuProps> = ({
|
||||
trigger,
|
||||
triggerButtonProps,
|
||||
items,
|
||||
position = 'left'
|
||||
position = 'start'
|
||||
}) => {
|
||||
if (!trigger) {
|
||||
trigger = <Button icon='ellipsis' label='Menu' hideLabel {...triggerButtonProps} />;
|
||||
|
@ -32,3 +32,46 @@ export const Default: Story = {
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
export const CenterAlign: Story = {
|
||||
args: {
|
||||
position: 'center',
|
||||
trigger: (
|
||||
<Button color='grey' label='Open popover' />
|
||||
),
|
||||
children: (
|
||||
<div className='p-5 text-sm' style={{maxWidth: '320px'}}>
|
||||
This is a popover. You can put anything in it. The styling of the content defines how it will look at the end.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
export const RightAlign: Story = {
|
||||
args: {
|
||||
position: 'end',
|
||||
trigger: (
|
||||
<Button color='grey' label='Open popover' />
|
||||
),
|
||||
children: (
|
||||
<div className='p-5 text-sm' style={{maxWidth: '320px'}}>
|
||||
This is a popover. You can put anything in it. The styling of the content defines how it will look at the end.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
export const DismissOnClick: Story = {
|
||||
args: {
|
||||
position: 'start',
|
||||
trigger: (
|
||||
<Button color='grey' label='Open popover' />
|
||||
),
|
||||
children: (
|
||||
<div className='p-5 text-sm' style={{maxWidth: '320px'}}>
|
||||
Click here to close the popover.
|
||||
</div>
|
||||
),
|
||||
closeOnItemClick: true
|
||||
}
|
||||
};
|
||||
|
@ -1,8 +1,7 @@
|
||||
import clsx from 'clsx';
|
||||
import React, {useRef, useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import React, {useState} from 'react';
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
|
||||
export type PopoverPosition = 'left' | 'right';
|
||||
export type PopoverPosition = 'center' | 'end' | 'start' | undefined;
|
||||
|
||||
export interface PopoverProps {
|
||||
trigger: React.ReactNode;
|
||||
@ -11,55 +10,13 @@ export interface PopoverProps {
|
||||
closeOnItemClick?: boolean;
|
||||
}
|
||||
|
||||
const getOffsetPosition = (element: HTMLDivElement | null) => {
|
||||
// innerZoomElementWrapper fixes weird behaviour in Storybook - the preview container
|
||||
// uses transform which changes how position:fixed works and means getBoundingClientRect
|
||||
// won't return the right position
|
||||
return element?.closest('.innerZoomElementWrapper')?.getBoundingClientRect() || {x: 0, y: 0};
|
||||
};
|
||||
|
||||
const Popover: React.FC<PopoverProps> = ({
|
||||
trigger,
|
||||
children,
|
||||
position = 'left',
|
||||
position = 'start',
|
||||
closeOnItemClick
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [positionX, setPositionX] = useState(0);
|
||||
const [positionY, setPositionY] = useState(0);
|
||||
const triggerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleTriggerClick = () => {
|
||||
if (!open && triggerRef.current) {
|
||||
const parentRect = getOffsetPosition(triggerRef.current);
|
||||
const {x, y, width, height} = triggerRef.current.getBoundingClientRect();
|
||||
const relativeX = x - parentRect.x;
|
||||
const relativeY = y - parentRect.y;
|
||||
|
||||
const finalX = (position === 'left') ? relativeX : window.innerWidth - (relativeX + width);
|
||||
setOpen(true);
|
||||
setPositionX(finalX);
|
||||
setPositionY(relativeY + height);
|
||||
} else {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
top: `${positionY}px`
|
||||
};
|
||||
|
||||
if (position === 'left') {
|
||||
style.left = `${positionX}px`;
|
||||
} else {
|
||||
style.right = `${positionX}px`;
|
||||
}
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContentClick = () => {
|
||||
if (closeOnItemClick) {
|
||||
@ -67,30 +24,17 @@ const Popover: React.FC<PopoverProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
let className = '';
|
||||
|
||||
className = clsx(
|
||||
'fixed z-50 mt-2 origin-top-right rounded bg-white shadow-md ring-1 ring-[rgba(0,0,0,0.01)] focus:outline-none dark:bg-grey-900 dark:text-white',
|
||||
className
|
||||
);
|
||||
|
||||
const backdropClasses = clsx(
|
||||
'fixed inset-0 z-40',
|
||||
open ? 'block' : 'hidden'
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={triggerRef} onClick={handleTriggerClick}>
|
||||
{trigger}
|
||||
</div>
|
||||
{open && createPortal(<div className='fixed z-[9999] inline-block' onClick={handleContentClick}>
|
||||
<div className={backdropClasses} data-testid="popover-overlay" onClick={handleBackdropClick}></div>
|
||||
<div className={className} data-testid='popover-content' style={style}>
|
||||
{children}
|
||||
</div>
|
||||
</div>, triggerRef.current?.closest('.admin-x-base') || document.body)}
|
||||
</>
|
||||
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
|
||||
<PopoverPrimitive.Anchor asChild>
|
||||
<PopoverPrimitive.Trigger asChild>
|
||||
{trigger}
|
||||
</PopoverPrimitive.Trigger>
|
||||
</PopoverPrimitive.Anchor>
|
||||
<PopoverPrimitive.Content align={position} className="z-50 mt-2 origin-top-right rounded bg-white shadow-md ring-1 ring-[rgba(0,0,0,0.01)] focus:outline-none dark:bg-grey-900 dark:text-white" data-testid='popover-content' side="bottom" onClick={handleContentClick}>
|
||||
{children}
|
||||
</PopoverPrimitive.Content>
|
||||
</PopoverPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
|
||||
export interface SeparatorProps {
|
||||
className?: string;
|
||||
@ -8,7 +9,11 @@ const Separator: React.FC<SeparatorProps> = ({className}) => {
|
||||
if (!className) {
|
||||
className = 'border-grey-200 dark:border-grey-800';
|
||||
}
|
||||
return <hr className={className} />;
|
||||
return (
|
||||
<SeparatorPrimitive.Root asChild decorative>
|
||||
<hr className={className} />
|
||||
</SeparatorPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default Separator;
|
||||
|
@ -29,7 +29,7 @@ const SortMenu: React.FC<SortMenuProps> = ({
|
||||
onDirectionChange,
|
||||
trigger,
|
||||
triggerButtonProps,
|
||||
position = 'left'
|
||||
position = 'start'
|
||||
}) => {
|
||||
const [localItems, setLocalItems] = useState<SortItem[]>(items);
|
||||
const [localDirection, setLocalDirection] = useState<SortDirection>(direction || 'desc');
|
||||
|
@ -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>
|
||||
|
@ -29,6 +29,12 @@ const tabs = [
|
||||
{id: 'tab-6', title: 'Backstreet boys', contents: <div className='py-5'>Contents three</div>}
|
||||
];
|
||||
|
||||
const tabsWithIcons = [
|
||||
{id: 'tab-1', title: 'Some items', icon: 'at-sign', contents: <div className='py-5'>Contents one</div>},
|
||||
{id: 'tab-2', title: 'Lots of items', icon: 'hamburger', contents: <div className='py-5'>Contents two</div>},
|
||||
{id: 'tab-3', title: 'No items', icon: 'laptop', contents: <div className='py-5'>Contents three</div>}
|
||||
];
|
||||
|
||||
const tabsWithCounters = [
|
||||
{id: 'tab-1', title: 'Some items', counter: 4, contents: <div className='py-5'>Contents one</div>},
|
||||
{id: 'tab-2', title: 'Lots of items', counter: 12, contents: <div className='py-5'>Contents two</div>},
|
||||
@ -49,6 +55,12 @@ export const NoBorder: Story = {
|
||||
}
|
||||
};
|
||||
|
||||
export const WithIcon: Story = {
|
||||
args: {
|
||||
tabs: tabsWithIcons
|
||||
}
|
||||
};
|
||||
|
||||
export const WithCounter: Story = {
|
||||
args: {
|
||||
tabs: tabsWithCounters
|
||||
|
@ -1,9 +1,12 @@
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import Icon from './Icon';
|
||||
|
||||
export type Tab<ID = string> = {
|
||||
id: ID;
|
||||
title: string;
|
||||
icon?: string;
|
||||
counter?: number | null;
|
||||
tabWrapperClassName?: string;
|
||||
containerClassName?: string;
|
||||
@ -20,8 +23,8 @@ export interface TabButtonProps<ID = string> {
|
||||
id: ID,
|
||||
title: string;
|
||||
onClick?: (e:React.MouseEvent<HTMLButtonElement>) => void;
|
||||
selected: boolean;
|
||||
border?: boolean;
|
||||
icon?: string;
|
||||
counter?: number | null;
|
||||
}
|
||||
|
||||
@ -29,29 +32,26 @@ export const TabButton: React.FC<TabButtonProps> = ({
|
||||
id,
|
||||
title,
|
||||
onClick,
|
||||
selected,
|
||||
border,
|
||||
icon,
|
||||
counter
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
aria-selected={selected}
|
||||
<TabsPrimitive.Trigger
|
||||
className={clsx(
|
||||
'-m-b-px cursor-pointer appearance-none whitespace-nowrap py-1 text-sm transition-all after:invisible after:block after:h-px after:overflow-hidden after:font-bold after:text-transparent after:content-[attr(title)] dark:text-white',
|
||||
border && 'border-b-[3px]',
|
||||
selected && border ? 'border-black dark:border-white' : 'border-transparent hover:border-grey-500',
|
||||
selected && 'font-bold'
|
||||
'-m-b-px cursor-pointer appearance-none whitespace-nowrap py-1 text-md font-medium text-grey-700 transition-all after:invisible after:block after:h-px after:overflow-hidden after:font-bold after:text-transparent after:content-[attr(title)] data-[state=active]:font-bold data-[state=active]:text-black dark:text-white [&>span]:data-[state=active]:text-black',
|
||||
border && 'border-b-2 border-transparent hover:border-grey-500 data-[state=active]:border-black data-[state=active]:dark:border-white'
|
||||
)}
|
||||
id={id}
|
||||
role='tab'
|
||||
title={title}
|
||||
type="button"
|
||||
value={id}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon && <Icon className='mb-0.5 mr-1.5 inline' name={icon} size='sm' />}
|
||||
{title}
|
||||
{(typeof counter === 'number') && <span className='ml-1.5 rounded-full bg-grey-200 px-1.5 py-[2px] text-xs font-normal text-grey-800 dark:bg-grey-900 dark:text-grey-300'>{counter}</span>}
|
||||
</button>
|
||||
{(typeof counter === 'number') && <span className='ml-1.5 rounded-full bg-grey-200 px-1.5 py-[2px] text-xs font-medium text-grey-800 dark:bg-grey-900 dark:text-grey-300'>{counter}</span>}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
};
|
||||
|
||||
@ -71,7 +71,6 @@ export const TabList: React.FC<TabListProps> = ({
|
||||
handleTabChange,
|
||||
border,
|
||||
buttonBorder,
|
||||
selectedTab,
|
||||
topRightContent
|
||||
}) => {
|
||||
const containerClasses = clsx(
|
||||
@ -82,24 +81,26 @@ export const TabList: React.FC<TabListProps> = ({
|
||||
border && 'border-b border-grey-300 dark:border-grey-900'
|
||||
);
|
||||
return (
|
||||
<div className={containerClasses} role='tablist'>
|
||||
{tabs.map(tab => (
|
||||
<div>
|
||||
<TabButton
|
||||
border={buttonBorder}
|
||||
counter={tab.counter}
|
||||
id={tab.id}
|
||||
selected={selectedTab === tab.id}
|
||||
title={tab.title}
|
||||
onClick={handleTabChange}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{topRightContent !== null ?
|
||||
<div className='ml-auto'>{topRightContent}</div> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
<TabsPrimitive.List>
|
||||
<div className={containerClasses} role='tablist'>
|
||||
{tabs.map(tab => (
|
||||
<div>
|
||||
<TabButton
|
||||
border={buttonBorder}
|
||||
counter={tab.counter}
|
||||
icon={tab.icon}
|
||||
id={tab.id}
|
||||
title={tab.title}
|
||||
onClick={handleTabChange}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{topRightContent !== null ?
|
||||
<div className='ml-auto'>{topRightContent}</div> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
</TabsPrimitive.List>
|
||||
);
|
||||
};
|
||||
|
||||
@ -140,7 +141,7 @@ function TabView<ID extends string = string>({
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={containerClassName} data-testid={testId}>
|
||||
<TabsPrimitive.Root className={containerClassName} data-testid={testId} value={selectedTab}>
|
||||
<TabList
|
||||
border={border}
|
||||
buttonBorder={buttonBorder}
|
||||
@ -152,16 +153,12 @@ function TabView<ID extends string = string>({
|
||||
/>
|
||||
{tabs.map((tab) => {
|
||||
return (
|
||||
<>
|
||||
{tab.contents &&
|
||||
<div key={tab.id} className={`${selectedTab === tab.id ? 'block' : 'hidden'} ${tab.tabWrapperClassName}`} role='tabpanel'>
|
||||
<div className={tab.containerClassName}>{tab.contents}</div>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
<TabsPrimitive.Content className={tab.tabWrapperClassName} value={tab.id}>
|
||||
<div className={tab.containerClassName}>{tab.contents}</div>
|
||||
</TabsPrimitive.Content>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</TabsPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,7 +1,6 @@
|
||||
import type {Meta, StoryObj} from '@storybook/react';
|
||||
import {ReactNode} from 'react';
|
||||
|
||||
import {Toaster} from 'react-hot-toast';
|
||||
import Button from './Button';
|
||||
import {ShowToastProps, showToast} from './Toast';
|
||||
|
||||
@ -25,7 +24,6 @@ const meta = {
|
||||
tags: ['autodocs'],
|
||||
decorators: [(_story: () => ReactNode) => (
|
||||
<>
|
||||
<Toaster />
|
||||
{_story()}
|
||||
</>
|
||||
)]
|
||||
|
@ -36,7 +36,7 @@ export const Left: Story = {
|
||||
args: {
|
||||
content: 'Hello tooltip on the left',
|
||||
children: <Button color='outline' label="Hover me" />,
|
||||
origin: 'left'
|
||||
origin: 'start'
|
||||
}
|
||||
};
|
||||
|
||||
@ -52,7 +52,7 @@ export const Right: Story = {
|
||||
args: {
|
||||
content: 'Hello right tooltip',
|
||||
children: <Button color='outline' label="Hover me" />,
|
||||
origin: 'right'
|
||||
origin: 'end'
|
||||
}
|
||||
};
|
||||
|
||||
@ -61,7 +61,7 @@ export const Long: Story = {
|
||||
content: `You're the best evil son an evil dad could ever ask for.`,
|
||||
children: <Button color='outline' label="Hover me" />,
|
||||
size: 'md',
|
||||
origin: 'left'
|
||||
origin: 'start'
|
||||
}
|
||||
};
|
||||
|
||||
@ -71,4 +71,4 @@ export const OnText: Story = {
|
||||
children: 'Just hover me',
|
||||
origin: 'center'
|
||||
}
|
||||
};
|
||||
};
|
||||
|
@ -1,3 +1,4 @@
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
@ -7,29 +8,32 @@ export interface TooltipProps {
|
||||
children?: React.ReactNode;
|
||||
containerClassName?: string;
|
||||
tooltipClassName?: string;
|
||||
origin?: 'right' | 'center' | 'left'
|
||||
origin?: 'start' | 'center' | 'end'
|
||||
}
|
||||
|
||||
const Tooltip: React.FC<TooltipProps> = ({content, size = 'sm', children, containerClassName, tooltipClassName, origin = 'center'}) => {
|
||||
containerClassName = clsx(
|
||||
'group/tooltip relative',
|
||||
'will-change-[opacity]',
|
||||
containerClassName
|
||||
);
|
||||
|
||||
tooltipClassName = clsx(
|
||||
'absolute -mt-1 -translate-y-full whitespace-nowrap rounded-sm bg-black px-2 py-0.5 leading-normal text-white opacity-0 transition-all group-hover/tooltip:opacity-100 dark:bg-grey-950',
|
||||
'select-none rounded-sm bg-black px-2 py-0.5 leading-normal text-white will-change-[transform,opacity]',
|
||||
size === 'sm' && 'text-xs',
|
||||
size === 'md' && 'text-sm',
|
||||
origin === 'center' && 'left-1/2 -translate-x-1/2',
|
||||
origin === 'left' && 'left-0',
|
||||
origin === 'right' && 'right-0'
|
||||
size === 'md' && 'text-sm'
|
||||
);
|
||||
|
||||
return (
|
||||
<span className={containerClassName}>
|
||||
{children}
|
||||
<span className={tooltipClassName}>{content}</span>
|
||||
</span>
|
||||
<TooltipPrimitive.Provider delayDuration={0}>
|
||||
<TooltipPrimitive.Root>
|
||||
<TooltipPrimitive.Trigger className={containerClassName} onClick={event => event.preventDefault()}>
|
||||
{children}
|
||||
</TooltipPrimitive.Trigger>
|
||||
<TooltipPrimitive.Content align={origin} className={tooltipClassName} sideOffset={4} onPointerDownOutside={event => event.preventDefault()}>
|
||||
{content}
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Root>
|
||||
</TooltipPrimitive.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import Heading from '../Heading';
|
||||
import Hint from '../Hint';
|
||||
import React, {useEffect, useId, useState} from 'react';
|
||||
import React, {useId} from 'react';
|
||||
import Separator from '../Separator';
|
||||
|
||||
export interface CheckboxProps {
|
||||
@ -18,16 +19,9 @@ export interface CheckboxProps {
|
||||
|
||||
const Checkbox: React.FC<CheckboxProps> = ({title, label, value, onChange, disabled, error, hint, checked, separator}) => {
|
||||
const id = useId();
|
||||
const [isChecked, setIsChecked] = useState(checked);
|
||||
|
||||
useEffect(() => {
|
||||
setIsChecked(checked);
|
||||
}, [checked]);
|
||||
|
||||
const handleOnChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const checkedValue = event.target.checked;
|
||||
setIsChecked(checkedValue);
|
||||
onChange(checkedValue);
|
||||
const handleCheckedChange = (isChecked: boolean | 'indeterminate') => {
|
||||
onChange(isChecked === true);
|
||||
};
|
||||
|
||||
return (
|
||||
@ -35,15 +29,13 @@ const Checkbox: React.FC<CheckboxProps> = ({title, label, value, onChange, disab
|
||||
<div className={`flex flex-col gap-1 ${separator && 'pb-2'}`}>
|
||||
{title && <Heading grey={true} level={6}>{title}</Heading>}
|
||||
<label className={`flex cursor-pointer items-start ${title && '-mb-1 mt-1'}`} htmlFor={id}>
|
||||
<input
|
||||
checked={isChecked}
|
||||
className="relative float-left mt-[3px] h-4 w-4 appearance-none border-2 border-solid border-grey-200 bg-grey-200 outline-none checked:border-black checked:bg-black checked:after:absolute checked:after:-mt-px checked:after:ml-[3px] checked:after:block checked:after:h-[11px] checked:after:w-[6px] checked:after:rotate-45 checked:after:border-[2px] checked:after:border-l-0 checked:after:border-t-0 checked:after:border-solid checked:after:border-white checked:after:bg-transparent checked:after:content-[''] hover:cursor-pointer focus:shadow-none focus:transition-[border-color_0.2s] dark:border-grey-800 dark:bg-grey-800 dark:checked:border-green dark:checked:bg-green"
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
type='checkbox'
|
||||
value={value}
|
||||
onChange={handleOnChange}
|
||||
/>
|
||||
<CheckboxPrimitive.Root className="mt-0.5 flex h-4 w-4 cursor-pointer appearance-none items-center justify-center rounded-[3px] border border-solid border-grey-500 bg-white outline-none data-[state=checked]:border-black data-[state=indeterminate]:border-black data-[state=checked]:bg-black data-[state=indeterminate]:bg-black" defaultChecked={checked} disabled={disabled} id={id} value={value} onCheckedChange={handleCheckedChange}>
|
||||
<CheckboxPrimitive.Indicator>
|
||||
<svg fill="none" height="11" viewBox="0 0 10 11" width="10">
|
||||
<path d="M1 5.88889L4.6 9L9 1" stroke="white" strokeLinecap="round" strokeWidth="2"/>
|
||||
</svg>
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
<div className={`ml-2 flex flex-col ${hint && 'mb-2'}`}>
|
||||
<span className={`inline-block text-[1.425rem] dark:text-white ${hint && '-mb-1'}`}>{label}</span>
|
||||
{hint && <Hint color={error ? 'red' : ''}>{hint}</Hint>}
|
||||
|
@ -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>
|
||||
|
@ -2,6 +2,7 @@ import React from 'react';
|
||||
import Heading from '../Heading';
|
||||
import Hint from '../Hint';
|
||||
import Separator from '../Separator';
|
||||
import * as RadioPrimitive from '@radix-ui/react-radio-group';
|
||||
|
||||
export interface RadioOption {
|
||||
value: string;
|
||||
@ -21,25 +22,15 @@ export interface RadioProps {
|
||||
}
|
||||
|
||||
const Radio: React.FC<RadioProps> = ({id, title, options, onSelect, error, hint, selectedOption, separator}) => {
|
||||
const handleOptionChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onSelect(event.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<RadioPrimitive.Root defaultValue={selectedOption} name={id} onValueChange={onSelect}>
|
||||
<div className={`flex flex-col gap-2 ${separator && 'pb-2'}`}>
|
||||
{title && <Heading level={6}>{title}</Heading>}
|
||||
{options.map(option => (
|
||||
<label key={option.value} className={`flex cursor-pointer items-start ${title && '-mb-1 mt-1'}`} htmlFor={option.value}>
|
||||
<input
|
||||
checked={selectedOption === option.value}
|
||||
className="relative float-left mt-[3px] h-4 w-4 min-w-[16px] appearance-none rounded-full border-2 border-solid border-grey-300 after:absolute after:z-[1] after:block after:h-3 after:w-3 after:rounded-full after:content-[''] checked:border-green checked:after:absolute checked:after:left-1/2 checked:after:top-1/2 checked:after:h-[0.625rem] checked:after:w-[0.625rem] checked:after:rounded-full checked:after:border-green checked:after:bg-green checked:after:content-[''] checked:after:[transform:translate(-50%,-50%)] hover:cursor-pointer focus:shadow-none focus:outline-none focus:ring-0 checked:focus:border-green dark:border-grey-800 dark:text-white dark:checked:border-green dark:checked:after:border-green dark:checked:after:bg-green dark:checked:focus:border-green"
|
||||
id={option.value}
|
||||
name={id}
|
||||
type='radio'
|
||||
value={option.value}
|
||||
onChange={handleOptionChange}
|
||||
/>
|
||||
<RadioPrimitive.Item className="relative float-left mt-[3px] h-4 w-4 min-w-[16px] appearance-none rounded-full border-2 border-solid border-grey-300 hover:cursor-pointer focus:shadow-none focus:outline-none focus:ring-0 data-[state=checked]:border-green data-[state=checked]:focus:border-green dark:border-grey-800 dark:text-white dark:data-[state=checked]:border-green dark:data-[state=checked]:focus:border-green" id={option.value} value={option.value}>
|
||||
<RadioPrimitive.Indicator className="flex h-full w-full items-center justify-center after:block after:h-[6px] after:w-[6px] after:rounded-full after:border-green after:bg-green after:content-[''] dark:after:border-green dark:after:bg-green" />
|
||||
</RadioPrimitive.Item>
|
||||
<div className={`ml-2 flex flex-col ${option.hint && 'mb-2'}`}>
|
||||
<span className={`inline-block text-md dark:text-white ${option.hint && '-mb-1'}`}>{option.label}</span>
|
||||
{option.hint && <Hint>{option.hint}</Hint>}
|
||||
@ -49,7 +40,7 @@ const Radio: React.FC<RadioProps> = ({id, title, options, onSelect, error, hint,
|
||||
{hint && <Hint color={error ? 'red' : ''}>{hint}</Hint>}
|
||||
</div>
|
||||
{(separator || error) && <Separator className={error ? 'border-red' : ''} />}
|
||||
</div>
|
||||
</RadioPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -4,6 +4,7 @@ import clsx from 'clsx';
|
||||
import {useFocusContext} from '../../providers/DesignSystemProvider';
|
||||
import Heading from '../Heading';
|
||||
import Hint from '../Hint';
|
||||
import * as FormPrimitive from '@radix-ui/react-form';
|
||||
|
||||
type ResizeOptions = 'both' | 'vertical' | 'horizontal' | 'none';
|
||||
type FontStyles = 'sans' | 'mono';
|
||||
@ -18,7 +19,6 @@ export interface TextAreaProps extends HTMLProps<HTMLTextAreaElement> {
|
||||
error?: boolean;
|
||||
placeholder?: string;
|
||||
hint?: React.ReactNode;
|
||||
clearBg?: boolean;
|
||||
fontStyle?: FontStyles;
|
||||
className?: string;
|
||||
onChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
@ -81,23 +81,29 @@ const TextArea: React.FC<TextAreaProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col'>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className={styles}
|
||||
id={id}
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
value={value}
|
||||
onBlur={handleBlur}
|
||||
onChange={onChange}
|
||||
onFocus={handleFocus}
|
||||
{...props}>
|
||||
</textarea>
|
||||
{title && <Heading className={'order-1'} htmlFor={id} useLabelTag={true}>{title}</Heading>}
|
||||
{hint && <Hint className='order-3' color={error ? 'red' : ''}>{hint}</Hint>}
|
||||
</div>
|
||||
<FormPrimitive.Root asChild>
|
||||
<div className='flex flex-col'>
|
||||
<FormPrimitive.Field name={id} asChild>
|
||||
<FormPrimitive.Control asChild>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className={styles}
|
||||
id={id}
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
value={value}
|
||||
onBlur={handleBlur}
|
||||
onChange={onChange}
|
||||
onFocus={handleFocus}
|
||||
{...props}>
|
||||
</textarea>
|
||||
</FormPrimitive.Control>
|
||||
</FormPrimitive.Field>
|
||||
{title && <Heading className={'order-1'} htmlFor={id} useLabelTag={true}>{title}</Heading>}
|
||||
{hint && <Hint className='order-3' color={error ? 'red' : ''}>{hint}</Hint>}
|
||||
</div>
|
||||
</FormPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -3,6 +3,7 @@ import Hint from '../Hint';
|
||||
import React, {FocusEventHandler, useId} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {useFocusContext} from '../../providers/DesignSystemProvider';
|
||||
import * as FormPrimitive from '@radix-ui/react-form';
|
||||
|
||||
export type TextFieldProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
inputRef?: React.RefObject<HTMLInputElement>;
|
||||
@ -105,11 +106,15 @@ const TextField: React.FC<TextFieldProps> = ({
|
||||
{...props} />;
|
||||
|
||||
field = (
|
||||
<div className={fieldContainerClasses}>
|
||||
{inputField}
|
||||
{!unstyled && !clearBg && <div className={bgClasses ? bgClasses : ''}></div>}
|
||||
{rightPlaceholder && <span className={rightPlaceholderClasses || ''}>{rightPlaceholder}</span>}
|
||||
</div>
|
||||
<FormPrimitive.Field name={id} asChild>
|
||||
<FormPrimitive.Control asChild>
|
||||
<div className={fieldContainerClasses}>
|
||||
{inputField}
|
||||
{!unstyled && !clearBg && <div className={bgClasses ? bgClasses : ''}></div>}
|
||||
{rightPlaceholder && <span className={rightPlaceholderClasses || ''}>{rightPlaceholder}</span>}
|
||||
</div>
|
||||
</FormPrimitive.Control>
|
||||
</FormPrimitive.Field>
|
||||
);
|
||||
|
||||
hintClassName = clsx(
|
||||
@ -124,14 +129,20 @@ const TextField: React.FC<TextFieldProps> = ({
|
||||
|
||||
if (title || hint) {
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
{field}
|
||||
{title && <Heading className={hideTitle ? 'sr-only' : 'order-1'} htmlFor={id} useLabelTag={true}>{title}</Heading>}
|
||||
{hint && <Hint className={hintClassName} color={error ? 'red' : 'default'}>{hint}</Hint>}
|
||||
</div>
|
||||
<FormPrimitive.Root asChild>
|
||||
<div className={containerClassName}>
|
||||
{field}
|
||||
{title && <Heading className={hideTitle ? 'sr-only' : 'order-1'} htmlFor={id} useLabelTag={true}>{title}</Heading>}
|
||||
{hint && <Hint className={hintClassName} color={error ? 'red' : 'default'}>{hint}</Hint>}
|
||||
</div>
|
||||
</FormPrimitive.Root>
|
||||
);
|
||||
} else {
|
||||
return (field);
|
||||
return (
|
||||
<FormPrimitive.Root asChild>
|
||||
{field}
|
||||
</FormPrimitive.Root>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -2,6 +2,7 @@ import clsx from 'clsx';
|
||||
import React, {useId} from 'react';
|
||||
import {Heading6StylesGrey} from '../Heading';
|
||||
import Separator from '../Separator';
|
||||
import * as TogglePrimitive from '@radix-ui/react-switch';
|
||||
|
||||
type ToggleSizes = 'sm' | 'md' | 'lg';
|
||||
export type ToggleDirections = 'ltr' | 'rtl';
|
||||
@ -9,6 +10,7 @@ export type ToggleDirections = 'ltr' | 'rtl';
|
||||
export interface ToggleProps {
|
||||
checked?: boolean;
|
||||
disabled?: boolean;
|
||||
name?: string;
|
||||
error?: boolean;
|
||||
size?: ToggleSizes;
|
||||
label?: React.ReactNode;
|
||||
@ -33,25 +35,30 @@ const Toggle: React.FC<ToggleProps> = ({
|
||||
error,
|
||||
checked,
|
||||
disabled,
|
||||
name,
|
||||
onChange
|
||||
}) => {
|
||||
const id = useId();
|
||||
|
||||
let sizeStyles = '';
|
||||
let thumbSizeStyles = '';
|
||||
let labelStyles = '';
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
sizeStyles = ' h-3 w-5 after:h-2 after:w-2 checked:after:ml-[1.0rem]';
|
||||
sizeStyles = ' h-3 w-5';
|
||||
thumbSizeStyles = ' h-2 w-2 data-[state=checked]:translate-x-[10px]';
|
||||
labelStyles = 'mt-[-5.5px]';
|
||||
break;
|
||||
|
||||
case 'lg':
|
||||
sizeStyles = ' h-5 w-8 after:h-4 after:w-4 checked:after:ml-[1.4rem]';
|
||||
sizeStyles = ' h-5 w-8';
|
||||
thumbSizeStyles = ' h-4 w-4 data-[state=checked]:translate-x-[14px]';
|
||||
labelStyles = 'mt-[-1px]';
|
||||
break;
|
||||
|
||||
default:
|
||||
sizeStyles = ' min-w-[28px] h-4 w-7 after:h-3 after:w-3 checked:after:ml-[1.4rem]';
|
||||
sizeStyles = ' min-w-[28px] h-4 w-7';
|
||||
thumbSizeStyles = ' h-3 w-3 data-[state=checked]:translate-x-[14px]';
|
||||
labelStyles = 'mt-[-3px]';
|
||||
break;
|
||||
}
|
||||
@ -68,36 +75,42 @@ const Toggle: React.FC<ToggleProps> = ({
|
||||
let toggleBgClass;
|
||||
switch (toggleBg) {
|
||||
case 'stripetest':
|
||||
toggleBgClass = 'checked:bg-[#EC6803] dark:checked:bg-[#EC6803]';
|
||||
toggleBgClass = 'data-[state=checked]:bg-[#EC6803] dark:data-[state=checked]:bg-[#EC6803]';
|
||||
break;
|
||||
|
||||
case 'green':
|
||||
toggleBgClass = 'checked:bg-green';
|
||||
toggleBgClass = 'data-[state=checked]:bg-green';
|
||||
break;
|
||||
|
||||
default:
|
||||
toggleBgClass = 'checked:bg-black dark:checked:bg-green';
|
||||
toggleBgClass = 'data-[state=checked]:bg-black dark:data-[state=checked]:bg-green';
|
||||
break;
|
||||
}
|
||||
|
||||
const handleCheckedChange = (isChecked: boolean) => {
|
||||
if (onChange) {
|
||||
const event = {
|
||||
target: {checked: isChecked}
|
||||
} as React.ChangeEvent<HTMLInputElement>;
|
||||
onChange(event);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={`group flex items-start gap-2 dark:text-white ${direction === 'rtl' && 'justify-between'} ${separator && 'pb-2'}`}>
|
||||
<input checked={checked}
|
||||
className={clsx(
|
||||
toggleBgClass,
|
||||
'appearance-none rounded-full bg-grey-300 transition dark:bg-grey-800',
|
||||
`after:absolute after:ml-0.5 after:mt-0.5 after:rounded-full after:border-none after:bg-white after:transition-[background-color_0.2s,transform_0.2s] after:content-['']`,
|
||||
`checked:after:absolute checked:after:rounded-full checked:after:border-none checked:after:bg-white checked:after:transition-[background-color_0.2s,transform_0.2s] checked:after:content-['']`,
|
||||
'enabled:hover:cursor-pointer disabled:opacity-40 enabled:group-hover:opacity-80',
|
||||
sizeStyles,
|
||||
direction === 'rtl' && ' order-2'
|
||||
)}
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
onChange={onChange} />
|
||||
<TogglePrimitive.Root className={clsx(
|
||||
toggleBgClass,
|
||||
'appearance-none rounded-full bg-grey-300 transition duration-100 dark:bg-grey-800',
|
||||
'enabled:hover:cursor-pointer disabled:opacity-40 enabled:group-hover:opacity-80',
|
||||
sizeStyles,
|
||||
direction === 'rtl' && ' order-2'
|
||||
)} defaultChecked={checked} disabled={disabled} id={id} name={name} onCheckedChange={handleCheckedChange}>
|
||||
<TogglePrimitive.Thumb className={clsx(
|
||||
thumbSizeStyles,
|
||||
'block translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform'
|
||||
)} />
|
||||
</TogglePrimitive.Root>
|
||||
{label &&
|
||||
<label className={`flex grow flex-col hover:cursor-pointer ${direction === 'rtl' && 'order-1'} ${labelStyles}`} htmlFor={id}>
|
||||
{
|
||||
|
@ -179,8 +179,8 @@ const ViewContainer: React.FC<ViewContainerProps> = ({
|
||||
|
||||
toolbarContainerClassName = clsx(
|
||||
'flex justify-between gap-5',
|
||||
(type === 'page' && actions?.length) ? 'flex-col md:flex-row md:items-end' : 'items-end',
|
||||
(firstOnPage && type === 'page') ? 'pb-3 tablet:pb-8' : (tabs?.length ? '' : 'pb-2'),
|
||||
(type === 'page' && actions?.length) ? (tabs?.length ? 'flex-col md:flex-row md:items-start' : 'flex-col md:flex-row md:items-end') : 'items-end',
|
||||
(firstOnPage && type === 'page' && !tabs?.length) ? 'pb-3 tablet:pb-8' : (tabs?.length ? '' : 'pb-2'),
|
||||
toolbarBorder && 'border-b border-grey-200 dark:border-grey-900',
|
||||
toolbarContainerClassName
|
||||
);
|
||||
@ -251,7 +251,7 @@ const ViewContainer: React.FC<ViewContainerProps> = ({
|
||||
|
||||
return (
|
||||
<section className={mainContainerClassName}>
|
||||
{(title || actions || headerContent) && toolbar}
|
||||
{(title || actions || headerContent || tabs) && toolbar}
|
||||
<div className={contentWrapperClassName}>
|
||||
{mainContent}
|
||||
</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;
|
||||
@ -27,6 +28,7 @@ export interface ModalProps {
|
||||
cancelLabel?: string;
|
||||
leftButtonProps?: ButtonProps;
|
||||
buttonsDisabled?: boolean;
|
||||
okDisabled?: boolean;
|
||||
footer?: boolean | React.ReactNode;
|
||||
header?: boolean;
|
||||
padding?: boolean;
|
||||
@ -51,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,
|
||||
@ -62,6 +65,7 @@ const Modal: React.FC<ModalProps> = ({
|
||||
header,
|
||||
leftButtonProps,
|
||||
buttonsDisabled,
|
||||
okDisabled,
|
||||
padding = true,
|
||||
onOk,
|
||||
okColor = 'black',
|
||||
@ -179,17 +183,21 @@ const Modal: React.FC<ModalProps> = ({
|
||||
color: okColor,
|
||||
className: 'min-w-[80px]',
|
||||
onClick: onOk,
|
||||
disabled: buttonsDisabled,
|
||||
disabled: buttonsDisabled || okDisabled,
|
||||
loading: okLoading
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
);
|
||||
@ -206,7 +214,7 @@ const Modal: React.FC<ModalProps> = ({
|
||||
if (stickyHeader) {
|
||||
headerClasses = clsx(
|
||||
headerClasses,
|
||||
'sticky top-0 z-[200] -mb-4 bg-white !pb-4 dark:bg-black'
|
||||
'sticky top-0 z-[300] -mb-4 bg-white !pb-4 dark:bg-black'
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import {arrayMove} from '@dnd-kit/sortable';
|
||||
import {useEffect, useState} from 'react';
|
||||
import _ from 'lodash';
|
||||
|
||||
export type SortableIndexedList<Item> = {
|
||||
items: Array<{ item: Item; id: string }>;
|
||||
@ -32,7 +33,7 @@ const useSortableIndexedList = <Item extends unknown>({items, setItems, blank, c
|
||||
allItems.push(newItem);
|
||||
}
|
||||
|
||||
if (JSON.stringify(allItems) !== JSON.stringify(items)) {
|
||||
if (!_.isEqual(JSON.parse(JSON.stringify(allItems)), JSON.parse(JSON.stringify(items)))) {
|
||||
setItems(allItems);
|
||||
}
|
||||
}, [editableItems, newItem, items, setItems, canAddNewItem]);
|
||||
|
@ -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'
|
||||
},
|
||||
@ -270,7 +282,7 @@ module.exports = {
|
||||
base: '1.4rem',
|
||||
xs: '1.2rem',
|
||||
sm: '1.3rem',
|
||||
md: '1.40rem',
|
||||
md: '1.4rem',
|
||||
lg: '1.65rem',
|
||||
xl: '2rem',
|
||||
'2xl': '2.4rem',
|
||||
|
@ -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
@ -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.116.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",
|
||||
|
@ -78,7 +78,7 @@ export const useBrowseActions = createInfiniteQuery<ActionsList>({
|
||||
}
|
||||
});
|
||||
|
||||
const meta = pages.at(-1)!.meta;
|
||||
const meta = pages[pages.length - 1].meta;
|
||||
|
||||
return {
|
||||
actions: actions.reverse(),
|
||||
|
122
apps/admin-x-framework/src/api/activitypub.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import {createMutation, createQueryWithId} from '../utils/api/hooks';
|
||||
|
||||
export type FollowItem = {
|
||||
id: string;
|
||||
preferredUsername: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[x: string]: any
|
||||
};
|
||||
|
||||
export type ObjectProperties = {
|
||||
'@context': string | (string | object)[];
|
||||
type: 'Article' | 'Link' | 'Note';
|
||||
name: string;
|
||||
content: string;
|
||||
url?: string | undefined;
|
||||
attributedTo?: object | string | object[] | undefined;
|
||||
image?: string;
|
||||
published?: string;
|
||||
preview?: {type: string, content: string};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[x: string]: any;
|
||||
}
|
||||
|
||||
export type ActorProperties = {
|
||||
'@context': string | (string | object)[];
|
||||
attachment: object[];
|
||||
discoverable: boolean;
|
||||
featured: string;
|
||||
followers: string;
|
||||
following: string;
|
||||
id: string | null;
|
||||
image: string;
|
||||
inbox: string;
|
||||
manuallyApprovesFollowers: boolean;
|
||||
name: string;
|
||||
outbox: string;
|
||||
preferredUsername: string;
|
||||
publicKey: {
|
||||
id: string;
|
||||
owner: string;
|
||||
publicKeyPem: string;
|
||||
};
|
||||
published: string;
|
||||
summary: string;
|
||||
type: 'Person';
|
||||
url: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[x: string]: any;
|
||||
}
|
||||
|
||||
export type Activity = {
|
||||
'@context': string;
|
||||
id: string;
|
||||
type: string;
|
||||
actor: ActorProperties;
|
||||
object: ObjectProperties;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export type InboxResponseData = {
|
||||
'@context': string;
|
||||
id: string;
|
||||
summary: string;
|
||||
type: 'OrderedCollection';
|
||||
totalItems: number;
|
||||
items: Activity[];
|
||||
}
|
||||
|
||||
export type FollowingResponseData = {
|
||||
'@context': string;
|
||||
id: string;
|
||||
summary: string;
|
||||
type: string;
|
||||
totalItems: number;
|
||||
items: FollowItem[];
|
||||
}
|
||||
|
||||
type FollowRequestProps = {
|
||||
username: string
|
||||
}
|
||||
|
||||
export const useFollow = createMutation<object, FollowRequestProps>({
|
||||
method: 'POST',
|
||||
useActivityPub: true,
|
||||
path: data => `/actions/follow/${data.username}`
|
||||
});
|
||||
|
||||
export const useUnfollow = createMutation<object, FollowRequestProps>({
|
||||
method: 'POST',
|
||||
useActivityPub: true,
|
||||
path: data => `/actions/unfollow/${data.username}`
|
||||
});
|
||||
|
||||
// This is a frontend root, not using the Ghost admin API
|
||||
export const useBrowseInboxForUser = createQueryWithId<InboxResponseData>({
|
||||
dataType: 'InboxResponseData',
|
||||
useActivityPub: true,
|
||||
headers: {
|
||||
Accept: 'application/activity+json'
|
||||
},
|
||||
path: id => `/inbox/${id}`
|
||||
});
|
||||
|
||||
// This is a frontend root, not using the Ghost admin API
|
||||
export const useBrowseFollowingForUser = createQueryWithId<FollowingResponseData>({
|
||||
dataType: 'FollowingResponseData',
|
||||
useActivityPub: true,
|
||||
headers: {
|
||||
Accept: 'application/activity+json'
|
||||
},
|
||||
path: id => `/following/${id}`
|
||||
});
|
||||
|
||||
// This is a frontend root, not using the Ghost admin API
|
||||
export const useBrowseFollowersForUser = createQueryWithId<FollowingResponseData>({
|
||||
dataType: 'FollowingResponseData',
|
||||
useActivityPub: true,
|
||||
headers: {
|
||||
Accept: 'application/activity+json'
|
||||
},
|
||||
path: id => `/followers/${id}`
|
||||
});
|
@ -21,6 +21,7 @@ export type Newsletter = {
|
||||
show_header_title: boolean;
|
||||
title_font_category: string;
|
||||
title_alignment: string;
|
||||
show_excerpt: boolean;
|
||||
show_feature_image: boolean;
|
||||
body_font_category: string;
|
||||
footer_content: string | null;
|
||||
@ -60,7 +61,7 @@ export const useBrowseNewsletters = createInfiniteQuery<NewslettersResponseType
|
||||
returnData: (originalData) => {
|
||||
const {pages} = originalData as InfiniteData<NewslettersResponseType>;
|
||||
const newsletters = pages.flatMap(page => page.newsletters);
|
||||
const meta = pages.at(-1)!.meta;
|
||||
const meta = pages[pages.length - 1].meta;
|
||||
|
||||
return {
|
||||
newsletters: newsletters,
|
||||
|
@ -41,7 +41,7 @@ export const useBrowseTiers = createInfiniteQuery<TiersResponseType & {isEnd: bo
|
||||
returnData: (originalData) => {
|
||||
const {pages} = originalData as InfiniteData<TiersResponseType>;
|
||||
const tiers = pages.flatMap(page => page.tiers);
|
||||
const meta = pages.at(-1)!.meta;
|
||||
const meta = pages[pages.length - 1].meta;
|
||||
|
||||
return {
|
||||
tiers,
|
||||
|
@ -76,7 +76,7 @@ export const useBrowseUsers = createInfiniteQuery<UsersResponseType & {isEnd: bo
|
||||
returnData: (originalData) => {
|
||||
const {pages} = originalData as InfiniteData<UsersResponseType>;
|
||||
const users = pages.flatMap(page => page.users);
|
||||
const meta = pages.at(-1)!.meta;
|
||||
const meta = pages[pages.length - 1].meta;
|
||||
|
||||
return {
|
||||
users: users,
|
||||
|
@ -7,7 +7,7 @@ const escapeNqlString = (value: string) => {
|
||||
};
|
||||
|
||||
const useFilterableApi = <
|
||||
Data extends {id: string} & {[Key in FilterKey]: string},
|
||||
Data extends {id: string} & {[k in FilterKey]: string} & {[k: string]: unknown},
|
||||
ResponseKey extends string = string,
|
||||
FilterKey extends string = string
|
||||
>({path, filterKey, responseKey, limit = 20}: {
|
||||
@ -41,26 +41,27 @@ const useFilterableApi = <
|
||||
return response[responseKey];
|
||||
};
|
||||
|
||||
const loadInitialValues = async (values: string[], key: string) => {
|
||||
await loadData('');
|
||||
|
||||
const data = [...(result.current.data || [])];
|
||||
const missingValues = values.filter(value => !result.current.data?.find(item => item[key] === value));
|
||||
|
||||
if (missingValues.length) {
|
||||
const additionalData = await fetchApi<{meta?: Meta} & {[k in ResponseKey]: Data[]}>(apiUrl(path, {
|
||||
filter: `${key}:[${missingValues.join(',')}]`,
|
||||
limit: 'all'
|
||||
}));
|
||||
|
||||
data.push(...additionalData[responseKey]);
|
||||
}
|
||||
|
||||
return values.map(value => data.find(item => item[key] === value)!);
|
||||
};
|
||||
|
||||
return {
|
||||
loadData,
|
||||
|
||||
loadInitialValues: async (ids: string[]) => {
|
||||
await loadData('');
|
||||
|
||||
const data = [...(result.current.data || [])];
|
||||
const missingIds = ids.filter(id => !result.current.data?.find(({id: dataId}) => dataId === id));
|
||||
|
||||
if (missingIds.length) {
|
||||
const additionalData = await fetchApi<{meta?: Meta} & {[k in ResponseKey]: Data[]}>(apiUrl(path, {
|
||||
filter: `id:[${missingIds.join(',')}]`,
|
||||
limit: 'all'
|
||||
}));
|
||||
|
||||
data.push(...additionalData[responseKey]);
|
||||
}
|
||||
|
||||
return ids.map(id => data.find(({id: dataId}) => dataId === id)!);
|
||||
}
|
||||
loadInitialValues
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -16,6 +16,8 @@ import siteFixture from './responses/site.json';
|
||||
import themesFixture from './responses/themes.json';
|
||||
import tiersFixture from './responses/tiers.json';
|
||||
import usersFixture from './responses/users.json';
|
||||
import activitypubInboxFixture from './responses/activitypub/inbox.json';
|
||||
import activitypubFollowingFixture from './responses/activitypub/following.json';
|
||||
|
||||
import {ActionsResponseType} from '../api/actions';
|
||||
import {ConfigResponseType} from '../api/config';
|
||||
@ -63,7 +65,9 @@ export const responseFixtures = {
|
||||
themes: themesFixture as ThemesResponseType,
|
||||
newsletters: newslettersFixture as NewslettersResponseType,
|
||||
actions: actionsFixture as ActionsResponseType,
|
||||
latestPost: {posts: [{id: '1', url: `${siteFixture.site.url}/test-post/`}]}
|
||||
latestPost: {posts: [{id: '1', url: `${siteFixture.site.url}/test-post/`}]},
|
||||
activitypubInbox: activitypubInboxFixture,
|
||||
activitypubFollowing: activitypubFollowingFixture
|
||||
};
|
||||
|
||||
const defaultLabFlags = {
|
||||
@ -72,7 +76,6 @@ const defaultLabFlags = {
|
||||
themeErrorsNotification: false,
|
||||
outboundLinkTagging: false,
|
||||
announcementBar: false,
|
||||
signupForm: false,
|
||||
members: false
|
||||
};
|
||||
|
||||
@ -145,7 +148,7 @@ export const limitRequests = {
|
||||
browseNewslettersLimit: {method: 'GET', path: '/newsletters/?filter=status%3Aactive&limit=1', response: responseFixtures.newsletters}
|
||||
};
|
||||
|
||||
export async function mockApi<Requests extends Record<string, MockRequestConfig>>({page, requests}: {page: Page, requests: Requests}) {
|
||||
export async function mockApi<Requests extends Record<string, MockRequestConfig>>({page, requests, options = {}}: {page: Page, requests: Requests, options?: {useActivityPub?: boolean}}) {
|
||||
const lastApiRequests: {[key in keyof Requests]?: RequestRecord} = {};
|
||||
|
||||
const namedRequests = Object.entries(requests).reduce(
|
||||
@ -153,8 +156,11 @@ export async function mockApi<Requests extends Record<string, MockRequestConfig>
|
||||
[] as Array<MockRequestConfig & {name: keyof Requests}>
|
||||
);
|
||||
|
||||
await page.route(/\/ghost\/api\/admin\//, async (route) => {
|
||||
const apiPath = route.request().url().replace(/^.*\/ghost\/api\/admin/, '');
|
||||
const routeRegex = options?.useActivityPub ? /\/activitypub\// : /\/ghost\/api\/admin\//;
|
||||
const routeReplaceRegex = options.useActivityPub ? /^.*\/activitypub/ : /^.*\/ghost\/api\/admin/;
|
||||
|
||||
await page.route(routeRegex, async (route) => {
|
||||
const apiPath = route.request().url().replace(routeReplaceRegex, '');
|
||||
|
||||
const matchingMock = namedRequests.find((request) => {
|
||||
if (request.method !== route.request().method()) {
|
||||
|
@ -0,0 +1,13 @@
|
||||
{
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"id": "https://0a2e-129-222-88-174.ngrok-free.app/activitypub/following/deadbeefdeadbeefdeadbeef",
|
||||
"summary": "Following collection for index",
|
||||
"type": "Collection",
|
||||
"totalItems": 1,
|
||||
"items": [
|
||||
{
|
||||
"id": "https://main.ghost.org/activitypub/actor/deadbeefdeadbeefdeadbeef",
|
||||
"username": "@index@main.ghost.org"
|
||||
}
|
||||
]
|
||||
}
|
155
apps/admin-x-framework/src/test/responses/activitypub/inbox.json
Normal file
@ -0,0 +1,155 @@
|
||||
{
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"id": "https://example.com/activitypub/inbox/index",
|
||||
"summary": "Inbox for index",
|
||||
"type": "OrderedCollection",
|
||||
"totalItems": 2,
|
||||
"orderedItems": [
|
||||
{
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"id": "https://main.ghost.org/activitypub/activity/664cf007fd27b20001a76d72",
|
||||
"type": "Accept",
|
||||
"actor": {
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
"https://w3id.org/security/v1",
|
||||
{
|
||||
"featured": {
|
||||
"@id": "http://joinmastodon.org/ns#featured",
|
||||
"@type": "@id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"discoverable": {
|
||||
"@id": "http://joinmastodon.org/ns#discoverable",
|
||||
"@type": "@id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"manuallyApprovesFollowers": {
|
||||
"@id": "http://joinmastodon.org/ns#manuallyApprovesFollowers",
|
||||
"@type": "@id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "http://schema.org#",
|
||||
"PropertyValue": "schema:PropertyValue",
|
||||
"value": "schema:value"
|
||||
}
|
||||
],
|
||||
"type": "Person",
|
||||
"id": "https://main.ghost.org/activitypub/actor/index",
|
||||
"name": "The Main",
|
||||
"preferredUsername": "index",
|
||||
"summary": "The bio for the actor",
|
||||
"url": "https://main.ghost.org/activitypub/actor/index",
|
||||
"icon": "",
|
||||
"image": "",
|
||||
"published": "1970-01-01T00:00:00Z",
|
||||
"manuallyApprovesFollowers": false,
|
||||
"discoverable": true,
|
||||
"attachment": [
|
||||
{
|
||||
"type": "PropertyValue",
|
||||
"name": "Website",
|
||||
"value": "<a href='https://main.ghost.org/activitypub/'>main.ghost.org</a>"
|
||||
}
|
||||
],
|
||||
"following": "https://main.ghost.org/activitypub/following/index",
|
||||
"followers": "https://main.ghost.org/activitypub/followers/index",
|
||||
"inbox": "https://main.ghost.org/activitypub/inbox/index",
|
||||
"outbox": "https://main.ghost.org/activitypub/outbox/index",
|
||||
"featured": "https://main.ghost.org/activitypub/featured/index",
|
||||
"publicKey": {
|
||||
"id": "https://main.ghost.org/activitypub/actor/index#main-key",
|
||||
"owner": "https://main.ghost.org/activitypub/actor/index",
|
||||
"publicKeyPem": "-----BEGIN RSA PUBLIC KEY-----\nMIGJAoGBANRpUrwk7x7bJDddHmrYSWVw9enVPMFm5qAW7fTgoZ7x2PoJUIqy/bkqpXZ0SmZs\nsLO3UZm+yN/DqxioD8BnhhD0N8Ydv6+UniT7hE2tHvsMxQIq2jet1auSBZNFmUIWodsBxI/R\ntm+KwFBFk+P+MvVsGZ2K3Rkd4K0dv0/45dtXAgMBAAE=\n-----END RSA PUBLIC KEY-----\n"
|
||||
}
|
||||
},
|
||||
"object": {
|
||||
"id": "https://0a2e-129-222-88-174.ngrok-free.app/activitypub/activity/664cf0074daa2f8183ba6ea6",
|
||||
"type": "Follow"
|
||||
},
|
||||
"to": "https://0a2e-129-222-88-174.ngrok-free.app/activitypub/actor/index"
|
||||
},
|
||||
{
|
||||
"type": "Create",
|
||||
"actor": {
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
"https://w3id.org/security/v1",
|
||||
{
|
||||
"featured": {
|
||||
"@id": "http://joinmastodon.org/ns#featured",
|
||||
"@type": "@id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"discoverable": {
|
||||
"@id": "http://joinmastodon.org/ns#discoverable",
|
||||
"@type": "@id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"manuallyApprovesFollowers": {
|
||||
"@id": "http://joinmastodon.org/ns#manuallyApprovesFollowers",
|
||||
"@type": "@id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "http://schema.org#",
|
||||
"PropertyValue": "schema:PropertyValue",
|
||||
"value": "schema:value"
|
||||
}
|
||||
],
|
||||
"type": "Person",
|
||||
"id": "https://main.ghost.org/activitypub/actor/index",
|
||||
"name": "The Main",
|
||||
"preferredUsername": "index",
|
||||
"summary": "The bio for the actor",
|
||||
"url": "https://main.ghost.org/activitypub/actor/index",
|
||||
"icon": "",
|
||||
"image": "",
|
||||
"published": "1970-01-01T00:00:00Z",
|
||||
"manuallyApprovesFollowers": false,
|
||||
"discoverable": true,
|
||||
"attachment": [
|
||||
{
|
||||
"type": "PropertyValue",
|
||||
"name": "Website",
|
||||
"value": "<a href='https://main.ghost.org/activitypub/'>main.ghost.org</a>"
|
||||
}
|
||||
],
|
||||
"following": "https://main.ghost.org/activitypub/following/index",
|
||||
"followers": "https://main.ghost.org/activitypub/followers/index",
|
||||
"inbox": "https://main.ghost.org/activitypub/inbox/index",
|
||||
"outbox": "https://main.ghost.org/activitypub/outbox/index",
|
||||
"featured": "https://main.ghost.org/activitypub/featured/index",
|
||||
"publicKey": {
|
||||
"id": "https://main.ghost.org/activitypub/actor/index#main-key",
|
||||
"owner": "https://main.ghost.org/activitypub/actor/index",
|
||||
"publicKeyPem": "-----BEGIN RSA PUBLIC KEY-----\nMIGJAoGBANRpUrwk7x7bJDddHmrYSWVw9enVPMFm5qAW7fTgoZ7x2PoJUIqy/bkqpXZ0SmZs\nsLO3UZm+yN/DqxioD8BnhhD0N8Ydv6+UniT7hE2tHvsMxQIq2jet1auSBZNFmUIWodsBxI/R\ntm+KwFBFk+P+MvVsGZ2K3Rkd4K0dv0/45dtXAgMBAAE=\n-----END RSA PUBLIC KEY-----\n"
|
||||
}
|
||||
},
|
||||
"object": {
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"type": "Article",
|
||||
"id": "https://main.ghost.org/activitypub/article/my-article/",
|
||||
"name": "Testing ActivityPub",
|
||||
"content": "<p> Super long test </p>",
|
||||
"url": "https://main.ghost.org/my-article/",
|
||||
"image": "https://main.ghost.org/content/images/2021/08/ghost-logo.png",
|
||||
"published": "2024-05-09T00:00:00Z",
|
||||
"attributedTo": {
|
||||
"type": "Person",
|
||||
"name": "The Main"
|
||||
},
|
||||
"preview": {
|
||||
"type": "Link",
|
||||
"href": "https://main.ghost.org/my-article/",
|
||||
"name": "Testing ActivityPub"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
@ -19,6 +19,7 @@
|
||||
"show_header_title": true,
|
||||
"title_font_category": "serif",
|
||||
"title_alignment": "center",
|
||||
"show_excerpt": true,
|
||||
"show_feature_image": true,
|
||||
"body_font_category": "serif",
|
||||
"footer_content": "",
|
||||
|
@ -74,10 +74,6 @@ export const useFetchApi = () => {
|
||||
...options
|
||||
});
|
||||
|
||||
if (attempts !== 0 && sentryDSN) {
|
||||
Sentry.captureMessage('Request took multiple attempts', {extra: getErrorData()});
|
||||
}
|
||||
|
||||
return handleResponse(response) as ResponseData;
|
||||
} catch (error) {
|
||||
retryingMs = Date.now() - startTime;
|
||||
@ -115,10 +111,11 @@ export const useFetchApi = () => {
|
||||
};
|
||||
};
|
||||
|
||||
const {apiRoot} = getGhostPaths();
|
||||
const {apiRoot, activityPubRoot} = getGhostPaths();
|
||||
|
||||
export const apiUrl = (path: string, searchParams: Record<string, string> = {}) => {
|
||||
const url = new URL(`${apiRoot}${path}`, window.location.origin);
|
||||
export const apiUrl = (path: string, searchParams: Record<string, string> = {}, useActivityPub: boolean = false) => {
|
||||
const root = useActivityPub ? activityPubRoot : apiRoot;
|
||||
const url = new URL(`${root}${path}`, window.location.origin);
|
||||
url.search = new URLSearchParams(searchParams).toString();
|
||||
return url.toString();
|
||||
};
|
||||
|
@ -20,9 +20,11 @@ export interface Meta {
|
||||
interface QueryOptions<ResponseData> {
|
||||
dataType: string
|
||||
path: string
|
||||
headers?: Record<string, string>;
|
||||
defaultSearchParams?: Record<string, string>;
|
||||
permissions?: string[];
|
||||
returnData?: (originalData: unknown) => ResponseData;
|
||||
useActivityPub?: boolean;
|
||||
}
|
||||
|
||||
type QueryHookOptions<ResponseData> = UseQueryOptions<ResponseData> & {
|
||||
@ -31,14 +33,14 @@ type QueryHookOptions<ResponseData> = UseQueryOptions<ResponseData> & {
|
||||
};
|
||||
|
||||
export const createQuery = <ResponseData>(options: QueryOptions<ResponseData>) => ({searchParams, ...query}: QueryHookOptions<ResponseData> = {}): Omit<UseQueryResult<ResponseData>, 'data'> & {data: ResponseData | undefined} => {
|
||||
const url = apiUrl(options.path, searchParams || options.defaultSearchParams);
|
||||
const url = apiUrl(options.path, searchParams || options.defaultSearchParams, options?.useActivityPub);
|
||||
const fetchApi = useFetchApi();
|
||||
const handleError = useHandleError();
|
||||
|
||||
const result = useQuery<ResponseData>({
|
||||
enabled: options.permissions ? usePermission(options.permissions) : true,
|
||||
queryKey: [options.dataType, url],
|
||||
queryFn: () => fetchApi(url),
|
||||
queryFn: () => fetchApi(url, {...options}),
|
||||
...query
|
||||
});
|
||||
|
||||
@ -65,7 +67,7 @@ export const createPaginatedQuery = <ResponseData extends {meta?: Meta}>(options
|
||||
const paginatedSearchParams = searchParams || options.defaultSearchParams || {};
|
||||
paginatedSearchParams.page = page.toString();
|
||||
|
||||
const url = apiUrl(options.path, paginatedSearchParams);
|
||||
const url = apiUrl(options.path, paginatedSearchParams, options?.useActivityPub);
|
||||
const fetchApi = useFetchApi();
|
||||
const handleError = useHandleError();
|
||||
|
||||
@ -118,8 +120,8 @@ export const createInfiniteQuery = <ResponseData>(options: InfiniteQueryOptions<
|
||||
const nextPageParams = getNextPageParams || options.defaultNextPageParams || (() => ({}));
|
||||
|
||||
const result = useInfiniteQuery<ResponseData>({
|
||||
queryKey: [options.dataType, apiUrl(options.path, searchParams || options.defaultSearchParams)],
|
||||
queryFn: ({pageParam}) => fetchApi(apiUrl(options.path, pageParam || searchParams || options.defaultSearchParams)),
|
||||
queryKey: [options.dataType, apiUrl(options.path, searchParams || options.defaultSearchParams, options?.useActivityPub)],
|
||||
queryFn: ({pageParam}) => fetchApi(apiUrl(options.path, pageParam || searchParams || options.defaultSearchParams, options?.useActivityPub)),
|
||||
getNextPageParam: data => nextPageParams(data, searchParams || options.defaultSearchParams || {}),
|
||||
...query
|
||||
});
|
||||
@ -145,6 +147,7 @@ export const createQueryWithId = <ResponseData>(options: Omit<QueryOptions<Respo
|
||||
|
||||
interface MutationOptions<ResponseData, Payload> extends Omit<QueryOptions<ResponseData>, 'dataType' | 'path'>, Omit<RequestOptions, 'body'> {
|
||||
path: (payload: Payload) => string;
|
||||
headers?: Record<string, string>;
|
||||
body?: (payload: Payload) => FormData | object;
|
||||
searchParams?: (payload: Payload) => { [key: string]: string; };
|
||||
invalidateQueries?: { dataType: string; };
|
||||
@ -159,7 +162,7 @@ const mutate = <ResponseData, Payload>({fetchApi, path, payload, searchParams, o
|
||||
options: Omit<MutationOptions<ResponseData, Payload>, 'path'>
|
||||
}) => {
|
||||
const {defaultSearchParams, body, ...requestOptions} = options;
|
||||
const url = apiUrl(path, searchParams || defaultSearchParams);
|
||||
const url = apiUrl(path, searchParams || defaultSearchParams, options?.useActivityPub);
|
||||
const generatedBody = payload && body?.(payload);
|
||||
|
||||
let requestBody: string | FormData | undefined = undefined;
|
||||
|
@ -10,7 +10,7 @@ export const insertToQueryCache = <ResponseData>(field: string, recordsToInsert?
|
||||
|
||||
if (typeof currentData === 'object' && 'pages' in currentData) {
|
||||
const {pages} = currentData as InfiniteData<ResponseData>;
|
||||
const lastPage = pages.at(-1)!;
|
||||
const lastPage = pages[pages.length - 1];
|
||||
return {
|
||||
...currentData,
|
||||
pages: pages.slice(0, -1).concat({
|
||||
|
@ -22,10 +22,10 @@ export class APIError extends Error {
|
||||
errorOptions?: ErrorOptions
|
||||
) {
|
||||
if (!message && response && response.url.includes('/ghost/api/admin/')) {
|
||||
message = `${response.statusText}, cannot fetch ${response.url.replace(/.+\/ghost\/api\/admin\//, '').replace(/\W.*/, '').replace('_', ' ')}`;
|
||||
message = `Something went wrong while loading ${response.url.replace(/.+\/ghost\/api\/admin\//, '').replace(/\W.*/, '').replace('_', ' ')}, please try again.`;
|
||||
}
|
||||
|
||||
super(message || 'Unknown error', errorOptions);
|
||||
super(message || 'Something went wrong, please try again.', errorOptions);
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,13 +48,13 @@ export class VersionMismatchError extends JSONError {
|
||||
|
||||
export class ServerUnreachableError extends APIError {
|
||||
constructor(errorOptions?: ErrorOptions) {
|
||||
super(undefined, undefined, 'Server was unreachable', errorOptions);
|
||||
super(undefined, undefined, 'Something went wrong, please try again.', errorOptions);
|
||||
}
|
||||
}
|
||||
|
||||
export class TimeoutError extends APIError {
|
||||
constructor(errorOptions?: ErrorOptions) {
|
||||
super(undefined, undefined, 'Request timed out', errorOptions);
|
||||
super(undefined, undefined, 'Request timed out, please try again.', errorOptions);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3,6 +3,7 @@ export interface IGhostPaths {
|
||||
adminRoot: string;
|
||||
assetRoot: string;
|
||||
apiRoot: string;
|
||||
activityPubRoot: string;
|
||||
}
|
||||
|
||||
export function getGhostPaths(): IGhostPaths {
|
||||
@ -11,7 +12,8 @@ export function getGhostPaths(): IGhostPaths {
|
||||
const adminRoot = `${subdir}/ghost/`;
|
||||
const assetRoot = `${subdir}/ghost/assets/`;
|
||||
const apiRoot = `${subdir}/ghost/api/admin`;
|
||||
return {subdir, adminRoot, assetRoot, apiRoot};
|
||||
const activityPubRoot = `${subdir}/.ghost/activitypub`;
|
||||
return {subdir, adminRoot, assetRoot, apiRoot, activityPubRoot};
|
||||
}
|
||||
|
||||
export function downloadFile(url: string) {
|
||||
|
@ -60,12 +60,47 @@ describe('API hooks', function () {
|
||||
expect(mock.calls.length).toBe(1);
|
||||
expect(mock.calls[0]).toEqual(['http://localhost:3000/ghost/api/admin/test/', {
|
||||
credentials: 'include',
|
||||
dataType: 'test',
|
||||
headers: {
|
||||
'app-pragma': 'no-cache',
|
||||
'x-ghost-version': '5.x'
|
||||
},
|
||||
method: 'GET',
|
||||
mode: 'cors',
|
||||
path: '/test/',
|
||||
signal: expect.any(AbortSignal)
|
||||
}]);
|
||||
});
|
||||
});
|
||||
|
||||
it('can add custom headers', async function () {
|
||||
await withMockFetch({
|
||||
json: {test: 1}
|
||||
}, async (mock) => {
|
||||
const useTestQuery = createQuery({
|
||||
dataType: 'test',
|
||||
path: '/test/',
|
||||
headers: {'Content-Type': 'ALOHA'}
|
||||
});
|
||||
|
||||
const {result} = renderHook(() => useTestQuery(), {wrapper});
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
expect(result.current.data).toEqual({test: 1});
|
||||
|
||||
expect(mock.calls.length).toBe(1);
|
||||
expect(mock.calls[0]).toEqual(['http://localhost:3000/ghost/api/admin/test/', {
|
||||
credentials: 'include',
|
||||
dataType: 'test',
|
||||
headers: {
|
||||
'Content-Type': 'ALOHA',
|
||||
'app-pragma': 'no-cache',
|
||||
'x-ghost-version': '5.x'
|
||||
},
|
||||
method: 'GET',
|
||||
mode: 'cors',
|
||||
path: '/test/',
|
||||
signal: expect.any(AbortSignal)
|
||||
}]);
|
||||
});
|
||||
|
@ -39,7 +39,7 @@
|
||||
"dependencies": {
|
||||
"@codemirror/lang-html": "6.4.9",
|
||||
"@tryghost/color-utils": "0.2.2",
|
||||
"@tryghost/kg-unsplash-selector": "0.1.17",
|
||||
"@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,12 +49,12 @@
|
||||
},
|
||||
"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",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"@types/validator": "13.11.10",
|
||||
"@types/validator": "13.12.0",
|
||||
"@vitejs/plugin-react": "4.2.1",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"eslint-plugin-react-refresh": "0.4.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>
|
||||
);
|
||||
|
@ -61,7 +61,7 @@ const MainContent: React.FC = () => {
|
||||
if (isEditorUser(currentUser)) {
|
||||
return (
|
||||
<Page>
|
||||
<div className='mx-auto w-full max-w-5xl px-[5vmin] tablet:mt-16 xl:mt-10' id="admin-x-settings-scroller">
|
||||
<div className='mx-auto w-full max-w-5xl overflow-y-auto px-[5vmin] tablet:mt-16 xl:mt-10' id="admin-x-settings-scroller">
|
||||
<Heading className='mb-[5vmin]'>Settings</Heading>
|
||||
<Users highlight={false} keywords={[]} />
|
||||
</div>
|
||||
|