Merge branch 'main' into optimizingPostBrowseQuery

This commit is contained in:
Princi Vershwal 2024-08-20 12:26:57 +05:30 committed by GitHub
commit 961cfc0027
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
603 changed files with 32351 additions and 13600 deletions

2
.envrc
View File

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

View File

@ -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

View File

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

3
.gitignore vendored
View File

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

View File

@ -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

View File

@ -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:
&nbsp;
# 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.

View File

@ -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": {

File diff suppressed because it is too large Load Diff

View 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,12 +8,21 @@ 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>

View 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;

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

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

View File

@ -0,0 +1,83 @@
import ActivityPubWelcomeImage from '../assets/images/ap-welcome.png';
import ArticleModal from './feed/ArticleModal';
import FeedItem from './feed/FeedItem';
import MainNavigation from './navigation/MainNavigation';
import NiceModal from '@ebay/nice-modal-react';
import React, {useState} from 'react';
import {Activity} from './activities/ActivityItem';
import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, Heading} from '@tryghost/admin-x-design-system';
import {useBrowseInboxForUser} from '../MainContent';
interface InboxProps {}
const Inbox: React.FC<InboxProps> = ({}) => {
const {data: activities = []} = useBrowseInboxForUser('index');
const [, setArticleContent] = useState<ObjectProperties | null>(null);
const [, setArticleActor] = useState<ActorProperties | null>(null);
const inboxTabActivities = activities.filter((activity: Activity) => {
const isCreate = activity.type === 'Create' && ['Article', 'Note'].includes(activity.object.type);
const isAnnounce = activity.type === 'Announce' && activity.object.type === 'Note';
return isCreate || isAnnounce;
});
const handleViewContent = (object: ObjectProperties, actor: ActorProperties) => {
setArticleContent(object);
setArticleActor(actor);
NiceModal.show(ArticleModal, {
object: object
});
};
return (
<>
<MainNavigation />
<div className='z-0 flex w-full flex-col'>
<div className='w-full'>
{inboxTabActivities.length > 0 ? (
<ul className='mx-auto flex max-w-[560px] flex-col py-8'>
{inboxTabActivities.reverse().map(activity => (
<li
key={activity.id}
data-test-view-article
onClick={() => handleViewContent(activity.object, activity.actor)}
>
<FeedItem
actor={activity.actor}
layout='feed'
object={activity.object}
type={activity.type}
/>
</li>
))}
</ul>
) : (
<div className='flex items-center justify-center text-center'>
<div className='flex max-w-[32em] flex-col items-center justify-center gap-4'>
<img
alt='Ghost site logos'
className='w-[220px]'
src={ActivityPubWelcomeImage}
/>
<Heading className='text-balance' level={2}>
Welcome to ActivityPub
</Heading>
<p className='text-pretty text-grey-800'>
Were so glad to have you on board! At the moment, you can follow other Ghost sites and enjoy their content right here inside Ghost.
</p>
<p className='text-pretty text-grey-800'>
You can see all of the users on the rightfind your favorite ones and give them a follow.
</p>
<Button color='green' label='Learn more' link={true} />
</div>
</div>
)}
</div>
</div>
</>
);
};
export default Inbox;

View File

@ -1,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;

View File

@ -0,0 +1,72 @@
import MainNavigation from './navigation/MainNavigation';
import React from 'react';
import {ActivityPubAPI} from '../api/activitypub';
import {SettingValue} from '@tryghost/admin-x-design-system';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import {useQuery} from '@tanstack/react-query';
import {useRouting} from '@tryghost/admin-x-framework/routing';
interface ProfileProps {}
function useFollowersCountForUser(handle: string) {
const site = useBrowseSite();
const siteData = site.data?.site;
const siteUrl = siteData?.url ?? window.location.origin;
const api = new ActivityPubAPI(
new URL(siteUrl),
new URL('/ghost/api/admin/identities/', window.location.origin),
handle
);
return useQuery({
queryKey: [`followersCount:${handle}`],
async queryFn() {
return api.getFollowersCount();
}
});
}
function useFollowingCountForUser(handle: string) {
const site = useBrowseSite();
const siteData = site.data?.site;
const siteUrl = siteData?.url ?? window.location.origin;
const api = new ActivityPubAPI(
new URL(siteUrl),
new URL('/ghost/api/admin/identities/', window.location.origin),
handle
);
return useQuery({
queryKey: [`followingCount:${handle}`],
async queryFn() {
return api.getFollowingCount();
}
});
}
const Profile: React.FC<ProfileProps> = ({}) => {
const {updateRoute} = useRouting();
const {data: followersCount = 0} = useFollowersCountForUser('index');
const {data: followingCount = 0} = useFollowingCountForUser('index');
return (
<>
<MainNavigation />
<div className='z-0 flex w-full flex-col items-center'>
<div className='mx-auto mt-8 w-full max-w-[560px] rounded-xl bg-grey-50 p-6' id='ap-sidebar'>
<div className='mb-4 border-b border-b-grey-200 pb-4'><SettingValue key={'your-username'} heading={'Your username'} value={'@index@localplaceholder.com'}/></div>
<div className='grid grid-cols-2 gap-4'>
<div className='group/stat flex cursor-pointer flex-col gap-1' onClick={() => updateRoute('/profile/following')}>
<span className='text-3xl font-bold leading-none' data-test-following-count>{followingCount}</span>
<span className='text-base leading-none text-grey-800 group-hover/stat:text-grey-900' data-test-following-modal>Following<span className='ml-1 opacity-0 transition-opacity group-hover/stat:opacity-100'>&rarr;</span></span>
</div>
<div className='group/stat flex cursor-pointer flex-col gap-1' onClick={() => updateRoute('/profile/followers')}>
<span className='text-3xl font-bold leading-none' data-test-following-count>{followersCount}</span>
<span className='text-base leading-none text-grey-800 group-hover/stat:text-grey-900' data-test-followers-modal>Followers<span className='ml-1 opacity-0 transition-opacity group-hover/stat:opacity-100'>&rarr;</span></span>
</div>
</div>
</div>
</div>
</>
);
};
export default Profile;

View File

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

View File

@ -0,0 +1,538 @@
import ActivityPubWelcomeImage from '../assets/images/ap-welcome.png';
import React, {useEffect, useRef, useState} from 'react';
import articleBodyStyles from './articleBodyStyles';
import getRelativeTimestamp from '../utils/get-relative-timestamp';
import getUsername from '../utils/get-username';
import {ActivityPubAPI} from '../api/activitypub';
import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Avatar, Button, ButtonGroup, Heading, Icon, List, ListItem, Page, SelectOption, SettingValue, ViewContainer, ViewTab} from '@tryghost/admin-x-design-system';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import {useQuery} from '@tanstack/react-query';
import {useRouting} from '@tryghost/admin-x-framework/routing';
interface ViewArticleProps {
object: ObjectProperties,
onBackToList: () => void;
}
type Activity = {
type: string,
object: {
type: string
}
}
export function useBrowseInboxForUser(handle: string) {
const site = useBrowseSite();
const siteData = site.data?.site;
const siteUrl = siteData?.url ?? window.location.origin;
const api = new ActivityPubAPI(
new URL(siteUrl),
new URL('/ghost/api/admin/identities/', window.location.origin),
handle
);
return useQuery({
queryKey: [`inbox:${handle}`],
async queryFn() {
return api.getInbox();
}
});
}
function useFollowersCountForUser(handle: string) {
const site = useBrowseSite();
const siteData = site.data?.site;
const siteUrl = siteData?.url ?? window.location.origin;
const api = new ActivityPubAPI(
new URL(siteUrl),
new URL('/ghost/api/admin/identities/', window.location.origin),
handle
);
return useQuery({
queryKey: [`followersCount:${handle}`],
async queryFn() {
return api.getFollowersCount();
}
});
}
function useFollowingCountForUser(handle: string) {
const site = useBrowseSite();
const siteData = site.data?.site;
const siteUrl = siteData?.url ?? window.location.origin;
const api = new ActivityPubAPI(
new URL(siteUrl),
new URL('/ghost/api/admin/identities/', window.location.origin),
handle
);
return useQuery({
queryKey: [`followingCount:${handle}`],
async queryFn() {
return api.getFollowingCount();
}
});
}
const ActivityPubComponent: React.FC = () => {
const {updateRoute} = useRouting();
// TODO: Replace with actual user ID
const {data: activities = []} = useBrowseInboxForUser('index');
const {data: followersCount = 0} = useFollowersCountForUser('index');
const {data: followingCount = 0} = useFollowingCountForUser('index');
const [articleContent, setArticleContent] = useState<ObjectProperties | null>(null);
const [, setArticleActor] = useState<ActorProperties | null>(null);
const handleViewContent = (object: ObjectProperties, actor: ActorProperties) => {
setArticleContent(object);
setArticleActor(actor);
};
const handleBackToList = () => {
setArticleContent(null);
};
const [selectedOption, setSelectedOption] = useState<SelectOption>({label: 'Feed', value: 'feed'});
const [selectedTab, setSelectedTab] = useState('inbox');
const inboxTabActivities = activities.filter((activity: Activity) => {
const isCreate = activity.type === 'Create' && ['Article', 'Note'].includes(activity.object.type);
const isAnnounce = activity.type === 'Announce' && activity.object.type === 'Note';
return isCreate || isAnnounce;
});
const activityTabActivities = activities.filter((activity: Activity) => activity.type === 'Create' && activity.object.type === 'Article');
const likeTabActivies = activities.filter((activity: Activity) => activity.type === 'Like');
const tabs: ViewTab[] = [
{
id: 'inbox',
title: 'Inbox',
contents: (
<div className='w-full'>
{inboxTabActivities.length > 0 ? (
<ul className='mx-auto flex max-w-[540px] flex-col py-8'>
{inboxTabActivities.reverse().map(activity => (
<li
key={activity.id}
data-test-view-article
onClick={() => handleViewContent(activity.object, activity.actor)}
>
<ObjectContentDisplay
actor={activity.actor}
layout={selectedOption.value}
object={activity.object}
type={activity.type}
/>
</li>
))}
</ul>
) : (
<div className='flex items-center justify-center text-center'>
<div className='flex max-w-[32em] flex-col items-center justify-center gap-4'>
<img
alt='Ghost site logos'
className='w-[220px]'
src={ActivityPubWelcomeImage}
/>
<Heading className='text-balance' level={2}>
Welcome to ActivityPub
</Heading>
<p className='text-pretty text-grey-800'>
Were so glad to have you on board! At the moment, you can follow other Ghost sites and enjoy their content right here inside Ghost.
</p>
<p className='text-pretty text-grey-800'>
You can see all of the users on the rightfind your favorite ones and give them a follow.
</p>
<Button color='green' label='Learn more' link={true} />
</div>
</div>
)}
</div>
)
},
{
id: 'activity',
title: 'Activity',
contents: (
<div className='grid grid-cols-6 items-start gap-8 pt-8'>
<ul className='order-2 col-span-6 flex flex-col lg:order-1 lg:col-span-4'>
{activityTabActivities.reverse().map(activity => (
<li
key={activity.id}
data-test-view-article
onClick={() => handleViewContent(activity.object, activity.actor)}
>
<ObjectContentDisplay
actor={activity.actor}
layout={selectedOption.value}
object={activity.object}
type={activity.object.type}
/>
</li>
))}
</ul>
</div>
)
},
{
id: 'likes',
title: 'Likes',
contents: (
<div className='grid grid-cols-6 items-start gap-8 pt-8'>
<List className='col-span-4'>
{likeTabActivies.reverse().map(activity => (
<ListItem
avatar={<Avatar image={activity.actor.icon?.url} size='sm' />}
id='list-item'
title={
<div>
<span className='font-medium'>{activity.actor.name}</span>
<span className='text-grey-800'> liked your post </span>
<span className='font-medium'>{activity.object.name}</span>
</div>
}
/>
))}
</List>
</div>
)
},
{
id: 'profile',
title: 'Profile',
contents: (
<div>
<div className='rounded-xl bg-grey-50 p-6' id='ap-sidebar'>
<div className='mb-4 border-b border-b-grey-200 pb-4'><SettingValue key={'your-username'} heading={'Your username'} value={'@index@localplaceholder.com'}/></div>
<div className='grid grid-cols-2 gap-4'>
<div className='group/stat flex cursor-pointer flex-col gap-1' onClick={() => updateRoute('/view-following')}>
<span className='text-3xl font-bold leading-none' data-test-following-count>{followingCount}</span>
<span className='text-base leading-none text-grey-800 group-hover/stat:text-grey-900' data-test-following-modal>Following<span className='ml-1 opacity-0 transition-opacity group-hover/stat:opacity-100'>&rarr;</span></span>
</div>
<div className='group/stat flex cursor-pointer flex-col gap-1' onClick={() => updateRoute('/view-followers')}>
<span className='text-3xl font-bold leading-none' data-test-following-count>{followersCount}</span>
<span className='text-base leading-none text-grey-800 group-hover/stat:text-grey-900' data-test-followers-modal>Followers<span className='ml-1 opacity-0 transition-opacity group-hover/stat:opacity-100'>&rarr;</span></span>
</div>
</div>
</div>
</div>
)
}
];
return (
<>
<Page>
{!articleContent ? (
<ViewContainer
actions={[<ButtonGroup buttons={[
{
icon: 'listview',
size: 'sm',
iconColorClass: selectedOption.value === 'feed' ? 'text-black' : 'text-grey-500',
onClick: () => {
setSelectedOption({label: 'Feed', value: 'feed'});
}
},
{
icon: 'cardview',
size: 'sm',
iconColorClass: selectedOption.value === 'inbox' ? 'text-black' : 'text-grey-500',
onClick: () => {
setSelectedOption({label: 'Inbox', value: 'inbox'});
}
}
]} clearBg={true} link outlineOnMobile />]}
firstOnPage={true}
primaryAction={{
title: 'Follow',
onClick: () => {
updateRoute('follow-site');
},
icon: 'add'
}}
selectedTab={selectedTab}
stickyHeader={true}
tabs={tabs}
title='ActivityPub'
toolbarBorder={true}
type='page'
onTabChange={setSelectedTab}
>
</ViewContainer>
) : (
<ViewArticle object={articleContent} onBackToList={handleBackToList} />
)}
</Page>
</>
);
};
const ArticleBody: React.FC<{heading: string, image: string|undefined, html: string}> = ({heading, image, html}) => {
const site = useBrowseSite();
const siteData = site.data?.site;
const iframeRef = useRef<HTMLIFrameElement>(null);
const cssContent = articleBodyStyles(siteData?.url.replace(/\/$/, ''));
const htmlContent = `
<html>
<head>
${cssContent}
</head>
<body>
<header class='gh-article-header gh-canvas'>
<h1 class='gh-article-title is-title' data-test-article-heading>${heading}</h1>
${image &&
`<figure class='gh-article-image'>
<img src='${image}' alt='${heading}' />
</figure>`
}
</header>
<div class='gh-content gh-canvas is-body'>
${html}
</div>
</body>
</html>
`;
useEffect(() => {
const iframe = iframeRef.current;
if (iframe) {
iframe.srcdoc = htmlContent;
}
}, [htmlContent]);
return (
<div>
<iframe
ref={iframeRef}
className={`h-[calc(100vh_-_3vmin_-_4.8rem_-_2px)]`}
height='100%'
id='gh-ap-article-iframe'
title='Embedded Content'
width='100%'
>
</iframe>
</div>
);
};
function renderAttachment(object: ObjectProperties) {
let attachment;
if (object.image) {
attachment = object.image;
}
if (object.type === 'Note' && !attachment) {
attachment = object.attachment;
}
if (!attachment) {
return null;
}
if (Array.isArray(attachment)) {
const attachmentCount = attachment.length;
let gridClass = '';
if (attachmentCount === 1) {
gridClass = 'grid-cols-1'; // Single image, full width
} else if (attachmentCount === 2) {
gridClass = 'grid-cols-2'; // Two images, side by side
} else if (attachmentCount === 3 || attachmentCount === 4) {
gridClass = 'grid-cols-2'; // Three or four images, two per row
}
return (
<div className={`attachment-gallery mt-2 grid auto-rows-[150px] ${gridClass} gap-2`}>
{attachment.map((item, index) => (
<img key={item.url} alt={`attachment-${index}`} className={`h-full w-full rounded-md object-cover ${attachmentCount === 3 && index === 0 ? 'row-span-2' : ''}`} src={item.url} />
))}
</div>
);
}
switch (attachment.mediaType) {
case 'image/jpeg':
case 'image/png':
case 'image/gif':
return <img alt='attachment' className='mt-2 rounded-md outline outline-1 -outline-offset-1 outline-black/10' src={attachment.url} />;
case 'video/mp4':
case 'video/webm':
return <div className='relative mb-4 mt-2'>
<video className='h-[300px] w-full rounded object-cover' src={attachment.url}/>
</div>;
case 'audio/mpeg':
case 'audio/ogg':
return <div className='relative mb-4 mt-2 w-full'>
<audio className='w-full' src={attachment.url} controls/>
</div>;
default:
return null;
}
}
const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProperties, layout: string, type: string }> = ({actor, object, layout, type}) => {
const parser = new DOMParser();
const doc = parser.parseFromString(object.content || '', 'text/html');
const plainTextContent = doc.body.textContent;
let previewContent = '';
if (object.preview) {
const previewDoc = parser.parseFromString(object.preview.content || '', 'text/html');
previewContent = previewDoc.body.textContent || '';
} else if (object.type === 'Note') {
previewContent = plainTextContent || '';
}
const timestamp =
new Date(object?.published ?? new Date()).toLocaleDateString('default', {year: 'numeric', month: 'short', day: '2-digit'}) + ', ' + new Date(object?.published ?? new Date()).toLocaleTimeString('default', {hour: '2-digit', minute: '2-digit'});
const date = new Date(object?.published ?? new Date());
const [isClicked, setIsClicked] = useState(false);
const [isLiked, setIsLiked] = useState(false);
const handleLikeClick = (event: React.MouseEvent<HTMLElement> | undefined) => {
event?.stopPropagation();
setIsClicked(true);
setIsLiked(!isLiked);
setTimeout(() => setIsClicked(false), 300); // Reset the animation class after 300ms
};
let author = actor;
if (type === 'Announce' && object.type === 'Note') {
author = typeof object.attributedTo === 'object' ? object.attributedTo as ActorProperties : actor;
}
if (layout === 'feed') {
return (
<>
{object && (
<div className='group/article relative cursor-pointer pt-4'>
{(type === 'Announce' && object.type === 'Note') && <div className='z-10 mb-2 flex items-center gap-3 text-grey-700'>
<div className='z-10 flex w-10 justify-end'><Icon colorClass='text-grey-700' name='reload' size={'sm'}></Icon></div>
<span className='z-10'>{actor.name} reposted</span>
</div>}
<div className='flex items-start gap-3'>
<img className='z-10 w-10 rounded' src={author.icon?.url}/>
<div className='border-1 z-10 -mt-1 flex w-full flex-col items-start justify-between border-b border-b-grey-200 pb-4' data-test-activity>
<div className='relative z-10 mb-2 flex w-full flex-col overflow-visible text-[1.5rem]'>
<div className='flex'>
<span className='truncate whitespace-nowrap font-bold' data-test-activity-heading>{author.name}</span>
<span className='whitespace-nowrap text-grey-700 before:mx-1 before:content-["·"]' title={`${timestamp}`}>{getRelativeTimestamp(date)}</span>
</div>
<div className='flex'>
<span className='truncate text-grey-700'>{getUsername(author)}</span>
</div>
</div>
<div className='relative z-10 w-full gap-4'>
<div className='flex flex-col'>
{object.name && <Heading className='mb-1 leading-tight' level={4} data-test-activity-heading>{object.name}</Heading>}
<div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.5rem] text-grey-900'></div>
{/* <p className='text-pretty text-md text-grey-900'>{object.content}</p> */}
{renderAttachment(object)}
<div className='mt-3 flex gap-2'>
<Button className={`self-start text-grey-500 transition-all hover:text-grey-800 ${isClicked ? 'bump' : ''} ${isLiked ? 'ap-red-heart text-red *:!fill-red hover:text-red' : ''}`} hideLabel={true} icon='heart' id='like' size='md' unstyled={true} onClick={handleLikeClick}/>
<span className={`text-grey-800 ${isLiked ? 'opacity-100' : 'opacity-0'}`}>1</span>
</div>
</div>
</div>
</div>
</div>
<div className='absolute -inset-x-3 -inset-y-0 z-0 rounded transition-colors group-hover/article:bg-grey-100'></div>
</div>
)}
</>
);
} else if (layout === 'inbox') {
return (
<>
{object && (
<div className='border-1 group/article relative z-10 flex cursor-pointer flex-col items-start justify-between border-b border-b-grey-200 py-5' data-test-activity>
<div className='relative z-10 mb-3 grid w-full grid-cols-[20px_auto_1fr_auto] items-center gap-2 text-base'>
<img className='w-5' src={actor.icon?.url}/>
<span className='truncate font-semibold'>{actor.name}</span>
{/* <span className='truncate text-grey-800'>{getUsername(actor)}</span> */}
<span className='ml-auto text-right text-grey-800'>{timestamp}</span>
</div>
<div className='relative z-10 grid w-full grid-cols-[auto_170px] gap-4'>
<div className='flex flex-col'>
<div className='flex w-full justify-between gap-4'>
<Heading className='mb-1 line-clamp-2 leading-tight' level={5} data-test-activity-heading>{object.name}</Heading>
</div>
<p className='mb-6 line-clamp-2 max-w-prose text-pretty text-md text-grey-800'>{previewContent}</p>
<div className='flex gap-2'>
<Button className={`self-start text-grey-500 transition-all hover:text-grey-800 ${isClicked ? 'bump' : ''} ${isLiked ? 'ap-red-heart text-red *:!fill-red hover:text-red' : ''}`} hideLabel={true} icon='heart' id='like' size='md' unstyled={true} onClick={handleLikeClick}/>
<span className={`text-grey-800 ${isLiked ? 'opacity-100' : 'opacity-0'}`}>1</span>
</div>
</div>
{/* {image && <div className='relative min-w-[33%] grow'>
<img className='absolute h-full w-full rounded object-cover' height='140px' src={image} width='170px'/>
</div>} */}
</div>
<div className='absolute -inset-x-3 -inset-y-1 z-0 rounded transition-colors group-hover/article:bg-grey-50'></div>
{/* <div className='absolute inset-0 z-0 rounded from-white to-grey-50 transition-colors group-hover/article:bg-gradient-to-r'></div> */}
</div>
)}
</>
);
}
};
const ViewArticle: React.FC<ViewArticleProps> = ({object, onBackToList}) => {
const {updateRoute} = useRouting();
const [isClicked, setIsClicked] = useState(false);
const [isLiked, setIsLiked] = useState(false);
const handleLikeClick = (event: React.MouseEvent<HTMLElement> | undefined) => {
event?.stopPropagation();
setIsClicked(true);
setIsLiked(!isLiked);
setTimeout(() => setIsClicked(false), 300); // Reset the animation class after 300ms
};
return (
<Page>
<ViewContainer
toolbarBorder={false}
type='page'
>
<div className='grid grid-cols-[1fr_minmax(320px,_700px)_1fr] gap-x-6 pb-4'>
<div>
<Button icon='chevron-left' iconSize='xs' label='Inbox' data-test-back-button onClick={onBackToList}/>
</div>
<div className='flex items-center justify-between'>
</div>
<div className='flex items-center justify-end gap-2'>
<div className='flex flex-row-reverse items-center gap-3'>
<Button className={`self-start text-grey-500 transition-all hover:text-grey-800 ${isClicked ? 'bump' : ''} ${isLiked ? 'ap-red-heart text-red *:!fill-red hover:text-red' : ''}`} hideLabel={true} icon='heart' id='like' size='md' unstyled={true} onClick={handleLikeClick}/>
<span className={`text-grey-800 ${isLiked ? 'opacity-100' : 'opacity-0'}`}>1</span>
</div>
<Button hideLabel={true} icon='arrow-top-right' iconSize='xs' label='Visit site' onClick={() => updateRoute('/')}/>
</div>
</div>
<div className='mx-[-4.8rem] mb-[-4.8rem] w-auto'>
{object.type === 'Note' && (
<div className='mx-auto max-w-[600px]'>
{object.content && <div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.5rem] text-grey-900'></div>}
{renderAttachment(object)}
</div>)}
{object.type === 'Article' && <ArticleBody heading={object.name} html={object.content} image={object?.image}/>}
</div>
</ViewContainer>
</Page>
);
};
export default ActivityPubComponent;

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -0,0 +1,179 @@
import APAvatar from '../global/APAvatar';
import React, {useState} from 'react';
import getRelativeTimestamp from '../../utils/get-relative-timestamp';
import getUsername from '../../utils/get-username';
import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, Heading, Icon} from '@tryghost/admin-x-design-system';
export function renderAttachment(object: ObjectProperties) {
let attachment;
if (object.image) {
attachment = object.image;
}
if (object.type === 'Note' && !attachment) {
attachment = object.attachment;
}
if (!attachment) {
return null;
}
if (Array.isArray(attachment)) {
const attachmentCount = attachment.length;
let gridClass = '';
if (attachmentCount === 1) {
gridClass = 'grid-cols-1'; // Single image, full width
} else if (attachmentCount === 2) {
gridClass = 'grid-cols-2'; // Two images, side by side
} else if (attachmentCount === 3 || attachmentCount === 4) {
gridClass = 'grid-cols-2'; // Three or four images, two per row
}
return (
<div className={`attachment-gallery mt-2 grid auto-rows-[150px] ${gridClass} gap-2`}>
{attachment.map((item, index) => (
<img key={item.url} alt={`attachment-${index}`} className={`h-full w-full rounded-md object-cover ${attachmentCount === 3 && index === 0 ? 'row-span-2' : ''}`} src={item.url} />
))}
</div>
);
}
switch (attachment.mediaType) {
case 'image/jpeg':
case 'image/png':
case 'image/gif':
return <img alt='attachment' className='mt-2 rounded-md outline outline-1 -outline-offset-1 outline-black/10' src={attachment.url} />;
case 'video/mp4':
case 'video/webm':
return <div className='relative mb-4 mt-2'>
<video className='h-[300px] w-full rounded object-cover' src={attachment.url}/>
</div>;
case 'audio/mpeg':
case 'audio/ogg':
return <div className='relative mb-4 mt-2 w-full'>
<audio className='w-full' src={attachment.url} controls/>
</div>;
default:
return null;
}
}
interface FeedItemProps {
actor: ActorProperties;
object: ObjectProperties;
layout: string;
type: string;
}
const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type}) => {
const parser = new DOMParser();
const doc = parser.parseFromString(object.content || '', 'text/html');
const plainTextContent = doc.body.textContent;
let previewContent = '';
if (object.preview) {
const previewDoc = parser.parseFromString(object.preview.content || '', 'text/html');
previewContent = previewDoc.body.textContent || '';
} else if (object.type === 'Note') {
previewContent = plainTextContent || '';
}
const timestamp =
new Date(object?.published ?? new Date()).toLocaleDateString('default', {year: 'numeric', month: 'short', day: '2-digit'}) + ', ' + new Date(object?.published ?? new Date()).toLocaleTimeString('default', {hour: '2-digit', minute: '2-digit'});
const date = new Date(object?.published ?? new Date());
const [isClicked, setIsClicked] = useState(false);
const [isLiked, setIsLiked] = useState(false);
const handleLikeClick = (event: React.MouseEvent<HTMLElement> | undefined) => {
event?.stopPropagation();
setIsClicked(true);
setIsLiked(!isLiked);
setTimeout(() => setIsClicked(false), 300); // Reset the animation class after 300ms
};
let author = actor;
if (type === 'Announce' && object.type === 'Note') {
author = typeof object.attributedTo === 'object' ? object.attributedTo as ActorProperties : actor;
}
if (layout === 'feed') {
return (
<>
{object && (
<div className='group/article relative cursor-pointer pt-4'>
{(type === 'Announce' && object.type === 'Note') && <div className='z-10 mb-2 flex items-center gap-3 text-grey-700'>
<div className='z-10 flex w-10 justify-end'><Icon colorClass='text-grey-700' name='reload' size={'sm'}></Icon></div>
<span className='z-10'>{actor.name} reposted</span>
</div>}
<div className='flex items-start gap-3'>
<APAvatar author={author} />
<div className='border-1 z-10 -mt-1 flex w-full flex-col items-start justify-between border-b border-b-grey-200 pb-4' data-test-activity>
<div className='relative z-10 mb-2 flex w-full flex-col overflow-visible text-[1.5rem]'>
<div className='flex'>
<span className='truncate whitespace-nowrap font-bold' data-test-activity-heading>{author.name}</span>
<span className='whitespace-nowrap text-grey-700 before:mx-1 before:content-["·"]' title={`${timestamp}`}>{getRelativeTimestamp(date)}</span>
</div>
<div className='flex'>
<span className='truncate text-grey-700'>{getUsername(author)}</span>
</div>
</div>
<div className='relative z-10 w-full gap-4'>
<div className='flex flex-col'>
{object.name && <Heading className='mb-1 leading-tight' level={4} data-test-activity-heading>{object.name}</Heading>}
<div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.5rem] text-grey-900'></div>
{/* <p className='text-pretty text-md text-grey-900'>{object.content}</p> */}
{renderAttachment(object)}
<div className='mt-3 flex gap-2'>
<Button className={`self-start text-grey-500 transition-all hover:text-grey-800 ${isClicked ? 'bump' : ''} ${isLiked ? 'ap-red-heart text-red *:!fill-red hover:text-red' : ''}`} hideLabel={true} icon='heart' id='like' size='md' unstyled={true} onClick={handleLikeClick}/>
<span className={`text-grey-800 ${isLiked ? 'opacity-100' : 'opacity-0'}`}>1</span>
</div>
</div>
</div>
</div>
</div>
<div className='absolute -inset-x-3 -inset-y-0 z-0 rounded transition-colors group-hover/article:bg-grey-75'></div>
</div>
)}
</>
);
} else if (layout === 'inbox') {
return (
<>
{object && (
<div className='border-1 group/article relative z-10 flex cursor-pointer flex-col items-start justify-between border-b border-b-grey-200 py-5' data-test-activity>
<div className='relative z-10 mb-3 grid w-full grid-cols-[20px_auto_1fr_auto] items-center gap-2 text-base'>
<img className='w-5' src={actor.icon?.url}/>
<span className='truncate font-semibold'>{actor.name}</span>
{/* <span className='truncate text-grey-800'>{getUsername(actor)}</span> */}
<span className='ml-auto text-right text-grey-800'>{timestamp}</span>
</div>
<div className='relative z-10 grid w-full grid-cols-[auto_170px] gap-4'>
<div className='flex flex-col'>
<div className='flex w-full justify-between gap-4'>
<Heading className='mb-1 line-clamp-2 leading-tight' level={5} data-test-activity-heading>{object.name}</Heading>
</div>
<p className='mb-6 line-clamp-2 max-w-prose text-pretty text-md text-grey-800'>{previewContent}</p>
<div className='flex gap-2'>
<Button className={`self-start text-grey-500 transition-all hover:text-grey-800 ${isClicked ? 'bump' : ''} ${isLiked ? 'ap-red-heart text-red *:!fill-red hover:text-red' : ''}`} hideLabel={true} icon='heart' id='like' size='md' unstyled={true} onClick={handleLikeClick}/>
<span className={`text-grey-800 ${isLiked ? 'opacity-100' : 'opacity-0'}`}>1</span>
</div>
</div>
{/* {image && <div className='relative min-w-[33%] grow'>
<img className='absolute h-full w-full rounded object-cover' height='140px' src={image} width='170px'/>
</div>} */}
</div>
<div className='absolute -inset-x-3 -inset-y-1 z-0 rounded transition-colors group-hover/article:bg-grey-50'></div>
{/* <div className='absolute inset-0 z-0 rounded from-white to-grey-50 transition-colors group-hover/article:bg-gradient-to-r'></div> */}
</div>
)}
</>
);
}
};
export default FeedItem;

View File

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

View File

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

View File

@ -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;

View File

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

View File

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

View File

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

View File

@ -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);

View File

@ -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;
}

View File

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

View File

@ -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;

View File

@ -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');
});
});

View 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();
});
});

View File

@ -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');
});
});

View File

@ -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');
});
});

View File

@ -4,6 +4,7 @@
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"skipLibCheck": true,
"types": ["vite/client", "jest"],
/* Bundler mode */
"moduleResolution": "bundler",

View File

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

View File

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

View File

@ -27,7 +27,7 @@ const ListPage = () => {
label: 'Open Rate'
}
]}
position="left"
position="start"
onDirectionChange={() => {}}
onSortChange={() => {}}
/>,

View File

@ -11,7 +11,8 @@
"scripts": {
"build": "concurrently \"vite build\" \"tsc -p tsconfig.declaration.json\"",
"prepare": "yarn build",
"test": "yarn test:types",
"test": "yarn test:unit && yarn test:types",
"test:unit": "yarn nx build && vitest run",
"test:types": "tsc --noEmit",
"lint:code": "eslint --ext .js,.ts,.cjs,.tsx src/ --cache",
"lint": "yarn lint:code && yarn lint:test",
@ -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",

View File

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

View File

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

After

Width:  |  Height:  |  Size: 734 B

View File

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

After

Width:  |  Height:  |  Size: 576 B

View File

@ -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

View File

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

After

Width:  |  Height:  |  Size: 819 B

View File

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

After

Width:  |  Height:  |  Size: 750 B

View File

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

After

Width:  |  Height:  |  Size: 572 B

View File

@ -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>
);
<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;

View File

@ -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;

View File

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

View File

@ -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 => (

View File

@ -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} />;

View File

@ -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
}
};

View File

@ -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}>
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
<PopoverPrimitive.Anchor asChild>
<PopoverPrimitive.Trigger asChild>
{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}>
</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}
</div>
</div>, triggerRef.current?.closest('.admin-x-base') || document.body)}
</>
</PopoverPrimitive.Content>
</PopoverPrimitive.Root>
);
};

View File

@ -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;

View File

@ -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');

View File

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

View File

@ -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

View File

@ -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,14 +81,15 @@ export const TabList: React.FC<TabListProps> = ({
border && 'border-b border-grey-300 dark:border-grey-900'
);
return (
<TabsPrimitive.List>
<div className={containerClasses} role='tablist'>
{tabs.map(tab => (
<div>
<TabButton
border={buttonBorder}
counter={tab.counter}
icon={tab.icon}
id={tab.id}
selected={selectedTab === tab.id}
title={tab.title}
onClick={handleTabChange}
/>
@ -100,6 +100,7 @@ export const TabList: React.FC<TabListProps> = ({
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'>
<TabsPrimitive.Content className={tab.tabWrapperClassName} value={tab.id}>
<div className={tab.containerClassName}>{tab.contents}</div>
</div>
}
</>
</TabsPrimitive.Content>
);
})}
</section>
</TabsPrimitive.Root>
);
};

View File

@ -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()}
</>
)]

View File

@ -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'
}
};

View File

@ -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}>
<TooltipPrimitive.Provider delayDuration={0}>
<TooltipPrimitive.Root>
<TooltipPrimitive.Trigger className={containerClassName} onClick={event => event.preventDefault()}>
{children}
<span className={tooltipClassName}>{content}</span>
</span>
</TooltipPrimitive.Trigger>
<TooltipPrimitive.Content align={origin} className={tooltipClassName} sideOffset={4} onPointerDownOutside={event => event.preventDefault()}>
{content}
</TooltipPrimitive.Content>
</TooltipPrimitive.Root>
</TooltipPrimitive.Provider>
);
};

View File

@ -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>}

View File

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

View File

@ -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>
);
};

View File

@ -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,7 +81,10 @@ const TextArea: React.FC<TextAreaProps> = ({
}
return (
<FormPrimitive.Root asChild>
<div className='flex flex-col'>
<FormPrimitive.Field name={id} asChild>
<FormPrimitive.Control asChild>
<textarea
ref={inputRef}
className={styles}
@ -95,9 +98,12 @@ const TextArea: React.FC<TextAreaProps> = ({
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>
);
};

View File

@ -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 = (
<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 (
<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>
);
}
};

View File

@ -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(
<TogglePrimitive.Root 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-['']`,
'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'
)}
disabled={disabled}
id={id}
role="switch"
type="checkbox"
onChange={onChange} />
)} 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}>
{

View File

@ -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>

View File

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

View File

@ -18,6 +18,7 @@ export interface ModalProps {
size?: ModalSize;
width?: 'full' | number;
height?: 'full' | number;
align?: 'center' | 'left' | 'right';
testId?: string;
title?: string;
@ -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'
);
}

View File

@ -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]);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -68,12 +68,12 @@
"types"
],
"devDependencies": {
"@testing-library/react": "14.1.0",
"@testing-library/react": "14.3.1",
"@types/mocha": "10.0.1",
"c8": "8.0.1",
"eslint-plugin-react-hooks": "4.6.0",
"eslint-plugin-react-refresh": "0.4.3",
"jsdom": "24.1.0",
"jsdom": "24.1.1",
"mocha": "10.2.0",
"react": "18.3.1",
"react-dom": "18.3.1",
@ -82,7 +82,7 @@
"typescript": "5.4.5"
},
"dependencies": {
"@sentry/react": "7.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",

View File

@ -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(),

View 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}`
});

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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];
};
return {
loadData,
loadInitialValues: async (ids: string[]) => {
const loadInitialValues = async (values: string[], key: string) => {
await loadData('');
const data = [...(result.current.data || [])];
const missingIds = ids.filter(id => !result.current.data?.find(({id: dataId}) => dataId === id));
const missingValues = values.filter(value => !result.current.data?.find(item => item[key] === value));
if (missingIds.length) {
if (missingValues.length) {
const additionalData = await fetchApi<{meta?: Meta} & {[k in ResponseKey]: Data[]}>(apiUrl(path, {
filter: `id:[${missingIds.join(',')}]`,
filter: `${key}:[${missingValues.join(',')}]`,
limit: 'all'
}));
data.push(...additionalData[responseKey]);
}
return ids.map(id => data.find(({id: dataId}) => dataId === id)!);
}
return values.map(value => data.find(item => item[key] === value)!);
};
return {
loadData,
loadInitialValues
};
};

View File

@ -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()) {

View File

@ -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"
}
]
}

View 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"
}
}
}
]
}

View File

@ -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": "",

View File

@ -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();
};

View File

@ -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;

View File

@ -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({

View File

@ -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);
}
}

View File

@ -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) {

View File

@ -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)
}]);
});

View File

@ -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",

View File

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

View File

@ -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>

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