mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-04-08 02:52:39 -05:00
Add reusable UI components to ActivityPub (#20758)
AP-348 ATM the top navigation and the article drawer components are missing for ActivityPub UI. They are both part of the next phase so we need to add them.
This commit is contained in:
parent
5ee67892dc
commit
108c9f60c8
24 changed files with 752 additions and 69 deletions
|
@ -11,8 +11,8 @@ interface AppProps {
|
|||
const modals = {
|
||||
paths: {
|
||||
'follow-site': 'FollowSite',
|
||||
'view-following': 'ViewFollowing',
|
||||
'view-followers': 'ViewFollowers'
|
||||
'profile/following': 'ViewFollowing',
|
||||
'profile/followers': 'ViewFollowers'
|
||||
},
|
||||
load: async () => import('./components/modals')
|
||||
};
|
||||
|
|
|
@ -1,7 +1,46 @@
|
|||
import ActivityPubComponent from './components/ListIndex';
|
||||
import Activities from './components/Activities';
|
||||
import Inbox from './components/Inbox';
|
||||
import Profile from './components/Profile';
|
||||
import Search from './components/Search';
|
||||
import {ActivityPubAPI} from './api/activitypub';
|
||||
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {useRouting} from '@tryghost/admin-x-framework/routing';
|
||||
|
||||
export function useBrowseInboxForUser(handle: string) {
|
||||
const site = useBrowseSite();
|
||||
const siteData = site.data?.site;
|
||||
const siteUrl = siteData?.url ?? window.location.origin;
|
||||
const api = new ActivityPubAPI(
|
||||
new URL(siteUrl),
|
||||
new URL('/ghost/api/admin/identities/', window.location.origin),
|
||||
handle
|
||||
);
|
||||
return useQuery({
|
||||
queryKey: [`inbox:${handle}`],
|
||||
async queryFn() {
|
||||
return api.getInbox();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const MainContent = () => {
|
||||
return <ActivityPubComponent />;
|
||||
const {route} = useRouting();
|
||||
const mainRoute = route.split('/')[0];
|
||||
switch (mainRoute) {
|
||||
case 'search':
|
||||
return <Search />;
|
||||
break;
|
||||
case 'activity':
|
||||
return <Activities />;
|
||||
break;
|
||||
case 'profile':
|
||||
return <Profile />;
|
||||
break;
|
||||
default:
|
||||
return <Inbox />;
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
export default MainContent;
|
||||
|
|
52
apps/admin-x-activitypub/src/components/Activities.tsx
Normal file
52
apps/admin-x-activitypub/src/components/Activities.tsx
Normal file
|
@ -0,0 +1,52 @@
|
|||
import APAvatar from './global/APAvatar';
|
||||
import ActivityItem from './activities/ActivityItem';
|
||||
import MainNavigation from './navigation/MainNavigation';
|
||||
import React from 'react';
|
||||
|
||||
interface ActivitiesProps {}
|
||||
|
||||
const Activities: React.FC<ActivitiesProps> = ({}) => {
|
||||
// const fakeAuthor =
|
||||
return (
|
||||
<>
|
||||
<MainNavigation />
|
||||
<div className='z-0 flex w-full flex-col items-center'>
|
||||
<div className='mt-8 flex w-full max-w-[560px] flex-col'>
|
||||
<ActivityItem>
|
||||
<APAvatar />
|
||||
<div>
|
||||
<div className='text-grey-600'><span className='font-bold text-black'>Lydia Mango</span> @username@domain.com</div>
|
||||
<div className='text-sm'>Followed you</div>
|
||||
</div>
|
||||
</ActivityItem>
|
||||
|
||||
<ActivityItem>
|
||||
<APAvatar />
|
||||
<div>
|
||||
<div className='text-grey-600'><span className='font-bold text-black'>Tiana Passaquindici Arcand</span> @username@domain.com</div>
|
||||
<div className='text-sm'>Followed you</div>
|
||||
</div>
|
||||
</ActivityItem>
|
||||
|
||||
<ActivityItem>
|
||||
<APAvatar />
|
||||
<div>
|
||||
<div className='text-grey-600'><span className='font-bold text-black'>Gretchen Press</span> @username@domain.com</div>
|
||||
<div className='text-sm'>Followed you</div>
|
||||
</div>
|
||||
</ActivityItem>
|
||||
|
||||
<ActivityItem>
|
||||
<APAvatar />
|
||||
<div>
|
||||
<div className='text-grey-600'><span className='font-bold text-black'>Leo Lubin</span> @username@domain.com</div>
|
||||
<div className='text-sm'>Followed you</div>
|
||||
</div>
|
||||
</ActivityItem>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Activities;
|
83
apps/admin-x-activitypub/src/components/Inbox.tsx
Normal file
83
apps/admin-x-activitypub/src/components/Inbox.tsx
Normal file
|
@ -0,0 +1,83 @@
|
|||
import ActivityPubWelcomeImage from '../assets/images/ap-welcome.png';
|
||||
import ArticleModal from './feed/ArticleModal';
|
||||
import FeedItem from './feed/FeedItem';
|
||||
import MainNavigation from './navigation/MainNavigation';
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import React, {useState} from 'react';
|
||||
import {Activity} from './activities/ActivityItem';
|
||||
import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
|
||||
import {Button, Heading} from '@tryghost/admin-x-design-system';
|
||||
import {useBrowseInboxForUser} from '../MainContent';
|
||||
|
||||
interface InboxProps {}
|
||||
|
||||
const Inbox: React.FC<InboxProps> = ({}) => {
|
||||
const {data: activities = []} = useBrowseInboxForUser('index');
|
||||
const [, setArticleContent] = useState<ObjectProperties | null>(null);
|
||||
const [, setArticleActor] = useState<ActorProperties | null>(null);
|
||||
|
||||
const inboxTabActivities = activities.filter((activity: Activity) => {
|
||||
const isCreate = activity.type === 'Create' && ['Article', 'Note'].includes(activity.object.type);
|
||||
const isAnnounce = activity.type === 'Announce' && activity.object.type === 'Note';
|
||||
|
||||
return isCreate || isAnnounce;
|
||||
});
|
||||
|
||||
const handleViewContent = (object: ObjectProperties, actor: ActorProperties) => {
|
||||
setArticleContent(object);
|
||||
setArticleActor(actor);
|
||||
NiceModal.show(ArticleModal, {
|
||||
object: object
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MainNavigation />
|
||||
<div className='z-0 flex w-full flex-col'>
|
||||
<div className='w-full'>
|
||||
{inboxTabActivities.length > 0 ? (
|
||||
<ul className='mx-auto flex max-w-[560px] flex-col py-8'>
|
||||
{inboxTabActivities.reverse().map(activity => (
|
||||
<li
|
||||
key={activity.id}
|
||||
data-test-view-article
|
||||
onClick={() => handleViewContent(activity.object, activity.actor)}
|
||||
>
|
||||
<FeedItem
|
||||
actor={activity.actor}
|
||||
layout='feed'
|
||||
object={activity.object}
|
||||
type={activity.type}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className='flex items-center justify-center text-center'>
|
||||
<div className='flex max-w-[32em] flex-col items-center justify-center gap-4'>
|
||||
<img
|
||||
alt='Ghost site logos'
|
||||
className='w-[220px]'
|
||||
src={ActivityPubWelcomeImage}
|
||||
/>
|
||||
<Heading className='text-balance' level={2}>
|
||||
Welcome to ActivityPub
|
||||
</Heading>
|
||||
<p className='text-pretty text-grey-800'>
|
||||
We’re so glad to have you on board! At the moment, you can follow other Ghost sites and enjoy their content right here inside Ghost.
|
||||
</p>
|
||||
<p className='text-pretty text-grey-800'>
|
||||
You can see all of the users on the right—find your favorite ones and give them a follow.
|
||||
</p>
|
||||
<Button color='green' label='Learn more' link={true} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Inbox;
|
72
apps/admin-x-activitypub/src/components/Profile.tsx
Normal file
72
apps/admin-x-activitypub/src/components/Profile.tsx
Normal file
|
@ -0,0 +1,72 @@
|
|||
import MainNavigation from './navigation/MainNavigation';
|
||||
import React from 'react';
|
||||
import {ActivityPubAPI} from '../api/activitypub';
|
||||
import {SettingValue} from '@tryghost/admin-x-design-system';
|
||||
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {useRouting} from '@tryghost/admin-x-framework/routing';
|
||||
|
||||
interface ProfileProps {}
|
||||
|
||||
function useFollowersCountForUser(handle: string) {
|
||||
const site = useBrowseSite();
|
||||
const siteData = site.data?.site;
|
||||
const siteUrl = siteData?.url ?? window.location.origin;
|
||||
const api = new ActivityPubAPI(
|
||||
new URL(siteUrl),
|
||||
new URL('/ghost/api/admin/identities/', window.location.origin),
|
||||
handle
|
||||
);
|
||||
return useQuery({
|
||||
queryKey: [`followersCount:${handle}`],
|
||||
async queryFn() {
|
||||
return api.getFollowersCount();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function useFollowingCountForUser(handle: string) {
|
||||
const site = useBrowseSite();
|
||||
const siteData = site.data?.site;
|
||||
const siteUrl = siteData?.url ?? window.location.origin;
|
||||
const api = new ActivityPubAPI(
|
||||
new URL(siteUrl),
|
||||
new URL('/ghost/api/admin/identities/', window.location.origin),
|
||||
handle
|
||||
);
|
||||
return useQuery({
|
||||
queryKey: [`followingCount:${handle}`],
|
||||
async queryFn() {
|
||||
return api.getFollowingCount();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const Profile: React.FC<ProfileProps> = ({}) => {
|
||||
const {updateRoute} = useRouting();
|
||||
const {data: followersCount = 0} = useFollowersCountForUser('index');
|
||||
const {data: followingCount = 0} = useFollowingCountForUser('index');
|
||||
|
||||
return (
|
||||
<>
|
||||
<MainNavigation />
|
||||
<div className='z-0 flex w-full flex-col items-center'>
|
||||
<div className='mx-auto mt-8 w-full max-w-[560px] rounded-xl bg-grey-50 p-6' id='ap-sidebar'>
|
||||
<div className='mb-4 border-b border-b-grey-200 pb-4'><SettingValue key={'your-username'} heading={'Your username'} value={'@index@localplaceholder.com'}/></div>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div className='group/stat flex cursor-pointer flex-col gap-1' onClick={() => updateRoute('/profile/following')}>
|
||||
<span className='text-3xl font-bold leading-none' data-test-following-count>{followingCount}</span>
|
||||
<span className='text-base leading-none text-grey-800 group-hover/stat:text-grey-900' data-test-following-modal>Following<span className='ml-1 opacity-0 transition-opacity group-hover/stat:opacity-100'>→</span></span>
|
||||
</div>
|
||||
<div className='group/stat flex cursor-pointer flex-col gap-1' onClick={() => updateRoute('/profile/followers')}>
|
||||
<span className='text-3xl font-bold leading-none' data-test-following-count>{followersCount}</span>
|
||||
<span className='text-base leading-none text-grey-800 group-hover/stat:text-grey-900' data-test-followers-modal>Followers<span className='ml-1 opacity-0 transition-opacity group-hover/stat:opacity-100'>→</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
18
apps/admin-x-activitypub/src/components/Search.tsx
Normal file
18
apps/admin-x-activitypub/src/components/Search.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import MainNavigation from './navigation/MainNavigation';
|
||||
import React from 'react';
|
||||
import {Icon} from '@tryghost/admin-x-design-system';
|
||||
|
||||
interface SearchProps {}
|
||||
|
||||
const Search: React.FC<SearchProps> = ({}) => {
|
||||
return (
|
||||
<>
|
||||
<MainNavigation />
|
||||
<div className='z-0 flex w-full flex-col items-center pt-8'>
|
||||
<div className='flex w-full max-w-[560px] items-center gap-2 rounded-full bg-grey-100 px-3 py-2 text-grey-500'><Icon name='magnifying-glass' size={18} />Search the Fediverse</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Search;
|
|
@ -22,7 +22,7 @@ type Activity = {
|
|||
}
|
||||
}
|
||||
|
||||
function useBrowseInboxForUser(handle: string) {
|
||||
export function useBrowseInboxForUser(handle: string) {
|
||||
const site = useBrowseSite();
|
||||
const siteData = site.data?.site;
|
||||
const siteUrl = siteData?.url ?? window.location.origin;
|
||||
|
@ -224,51 +224,53 @@ const ActivityPubComponent: React.FC = () => {
|
|||
];
|
||||
|
||||
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'});
|
||||
<>
|
||||
<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'});
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
{
|
||||
icon: 'cardview',
|
||||
size: 'sm',
|
||||
iconColorClass: selectedOption.value === 'inbox' ? 'text-black' : 'text-grey-500',
|
||||
]} clearBg={true} link outlineOnMobile />]}
|
||||
firstOnPage={true}
|
||||
primaryAction={{
|
||||
title: 'Follow',
|
||||
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>
|
||||
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} />
|
||||
)}
|
||||
) : (
|
||||
<ViewArticle object={articleContent} onBackToList={handleBackToList} />
|
||||
)}
|
||||
|
||||
</Page>
|
||||
</Page>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import React, {ReactNode} from 'react';
|
||||
|
||||
export type Activity = {
|
||||
type: string,
|
||||
object: {
|
||||
type: string
|
||||
}
|
||||
}
|
||||
|
||||
interface ActivityItemProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const ActivityItem: React.FC<ActivityItemProps> = ({children}) => {
|
||||
const childrenArray = React.Children.toArray(children);
|
||||
|
||||
return (
|
||||
<div className='flex w-full max-w-[560px] flex-col'>
|
||||
<div className='flex w-full items-center gap-3 border-b border-grey-100 py-4'>
|
||||
{childrenArray[0]}
|
||||
{childrenArray[1]}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityItem;
|
|
@ -0,0 +1,94 @@
|
|||
import MainHeader from '../navigation/MainHeader';
|
||||
import NiceModal, {useModal} from '@ebay/nice-modal-react';
|
||||
import React, {useEffect, useRef} from 'react';
|
||||
import articleBodyStyles from '../articleBodyStyles';
|
||||
import {Button, Modal} from '@tryghost/admin-x-design-system';
|
||||
import {ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
|
||||
import {renderAttachment} from './FeedItem';
|
||||
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
|
||||
|
||||
interface ArticleModalProps {
|
||||
object: ObjectProperties;
|
||||
}
|
||||
|
||||
const ArticleBody: React.FC<{heading: string, image: string|undefined, html: string}> = ({heading, image, html}) => {
|
||||
const site = useBrowseSite();
|
||||
const siteData = site.data?.site;
|
||||
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const cssContent = articleBodyStyles(siteData?.url.replace(/\/$/, ''));
|
||||
|
||||
const htmlContent = `
|
||||
<html>
|
||||
<head>
|
||||
${cssContent}
|
||||
</head>
|
||||
<body>
|
||||
<header class='gh-article-header gh-canvas'>
|
||||
<h1 class='gh-article-title is-title' data-test-article-heading>${heading}</h1>
|
||||
${image &&
|
||||
`<figure class='gh-article-image'>
|
||||
<img src='${image}' alt='${heading}' />
|
||||
</figure>`
|
||||
}
|
||||
</header>
|
||||
<div class='gh-content gh-canvas is-body'>
|
||||
${html}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (iframe) {
|
||||
iframe.srcdoc = htmlContent;
|
||||
}
|
||||
}, [htmlContent]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className={`h-[calc(100vh_-_3vmin_-_4.8rem_-_2px)]`}
|
||||
height='100%'
|
||||
id='gh-ap-article-iframe'
|
||||
title='Embedded Content'
|
||||
width='100%'
|
||||
>
|
||||
</iframe>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ArticleModal: React.FC<ArticleModalProps> = ({object}) => {
|
||||
const modal = useModal();
|
||||
return (
|
||||
<Modal
|
||||
align='right'
|
||||
animate={true}
|
||||
footer={<></>}
|
||||
height={'full'}
|
||||
padding={false}
|
||||
size='bleed'
|
||||
width={640}
|
||||
>
|
||||
<MainHeader>
|
||||
<div className='col-[3/4] flex items-center justify-end px-8'>
|
||||
<Button icon='close' size='sm' unstyled onClick={() => modal.remove()}/>
|
||||
</div>
|
||||
</MainHeader>
|
||||
<div className='mt-10 w-auto'>
|
||||
{object.type === 'Note' && (
|
||||
<div className='mx-auto max-w-[580px]'>
|
||||
{object.content && <div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.5rem] text-grey-900'></div>}
|
||||
{renderAttachment(object)}
|
||||
</div>)}
|
||||
{object.type === 'Article' && <ArticleBody heading={object.name} html={object.content} image={object?.image}/>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default NiceModal.create(ArticleModal);
|
179
apps/admin-x-activitypub/src/components/feed/FeedItem.tsx
Normal file
179
apps/admin-x-activitypub/src/components/feed/FeedItem.tsx
Normal file
|
@ -0,0 +1,179 @@
|
|||
import APAvatar from '../global/APAvatar';
|
||||
import React, {useState} from 'react';
|
||||
import getRelativeTimestamp from '../../utils/get-relative-timestamp';
|
||||
import getUsername from '../../utils/get-username';
|
||||
import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
|
||||
import {Button, Heading, Icon} from '@tryghost/admin-x-design-system';
|
||||
|
||||
export function renderAttachment(object: ObjectProperties) {
|
||||
let attachment;
|
||||
if (object.image) {
|
||||
attachment = object.image;
|
||||
}
|
||||
|
||||
if (object.type === 'Note' && !attachment) {
|
||||
attachment = object.attachment;
|
||||
}
|
||||
|
||||
if (!attachment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Array.isArray(attachment)) {
|
||||
const attachmentCount = attachment.length;
|
||||
|
||||
let gridClass = '';
|
||||
if (attachmentCount === 1) {
|
||||
gridClass = 'grid-cols-1'; // Single image, full width
|
||||
} else if (attachmentCount === 2) {
|
||||
gridClass = 'grid-cols-2'; // Two images, side by side
|
||||
} else if (attachmentCount === 3 || attachmentCount === 4) {
|
||||
gridClass = 'grid-cols-2'; // Three or four images, two per row
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`attachment-gallery mt-2 grid auto-rows-[150px] ${gridClass} gap-2`}>
|
||||
{attachment.map((item, index) => (
|
||||
<img key={item.url} alt={`attachment-${index}`} className={`h-full w-full rounded-md object-cover ${attachmentCount === 3 && index === 0 ? 'row-span-2' : ''}`} src={item.url} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (attachment.mediaType) {
|
||||
case 'image/jpeg':
|
||||
case 'image/png':
|
||||
case 'image/gif':
|
||||
return <img alt='attachment' className='mt-2 rounded-md outline outline-1 -outline-offset-1 outline-black/10' src={attachment.url} />;
|
||||
case 'video/mp4':
|
||||
case 'video/webm':
|
||||
return <div className='relative mb-4 mt-2'>
|
||||
<video className='h-[300px] w-full rounded object-cover' src={attachment.url}/>
|
||||
</div>;
|
||||
|
||||
case 'audio/mpeg':
|
||||
case 'audio/ogg':
|
||||
return <div className='relative mb-4 mt-2 w-full'>
|
||||
<audio className='w-full' src={attachment.url} controls/>
|
||||
</div>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface FeedItemProps {
|
||||
actor: ActorProperties;
|
||||
object: ObjectProperties;
|
||||
layout: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type}) => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(object.content || '', 'text/html');
|
||||
|
||||
const plainTextContent = doc.body.textContent;
|
||||
let previewContent = '';
|
||||
if (object.preview) {
|
||||
const previewDoc = parser.parseFromString(object.preview.content || '', 'text/html');
|
||||
previewContent = previewDoc.body.textContent || '';
|
||||
} else if (object.type === 'Note') {
|
||||
previewContent = plainTextContent || '';
|
||||
}
|
||||
|
||||
const timestamp =
|
||||
new Date(object?.published ?? new Date()).toLocaleDateString('default', {year: 'numeric', month: 'short', day: '2-digit'}) + ', ' + new Date(object?.published ?? new Date()).toLocaleTimeString('default', {hour: '2-digit', minute: '2-digit'});
|
||||
|
||||
const date = new Date(object?.published ?? new Date());
|
||||
|
||||
const [isClicked, setIsClicked] = useState(false);
|
||||
const [isLiked, setIsLiked] = useState(false);
|
||||
|
||||
const handleLikeClick = (event: React.MouseEvent<HTMLElement> | undefined) => {
|
||||
event?.stopPropagation();
|
||||
setIsClicked(true);
|
||||
setIsLiked(!isLiked);
|
||||
setTimeout(() => setIsClicked(false), 300); // Reset the animation class after 300ms
|
||||
};
|
||||
|
||||
let author = actor;
|
||||
if (type === 'Announce' && object.type === 'Note') {
|
||||
author = typeof object.attributedTo === 'object' ? object.attributedTo as ActorProperties : actor;
|
||||
}
|
||||
|
||||
if (layout === 'feed') {
|
||||
return (
|
||||
<>
|
||||
{object && (
|
||||
<div className='group/article relative cursor-pointer pt-4'>
|
||||
{(type === 'Announce' && object.type === 'Note') && <div className='z-10 mb-2 flex items-center gap-3 text-grey-700'>
|
||||
<div className='z-10 flex w-10 justify-end'><Icon colorClass='text-grey-700' name='reload' size={'sm'}></Icon></div>
|
||||
<span className='z-10'>{actor.name} reposted</span>
|
||||
</div>}
|
||||
<div className='flex items-start gap-3'>
|
||||
<APAvatar author={author} />
|
||||
<div className='border-1 z-10 -mt-1 flex w-full flex-col items-start justify-between border-b border-b-grey-200 pb-4' data-test-activity>
|
||||
<div className='relative z-10 mb-2 flex w-full flex-col overflow-visible text-[1.5rem]'>
|
||||
<div className='flex'>
|
||||
<span className='truncate whitespace-nowrap font-bold' data-test-activity-heading>{author.name}</span>
|
||||
<span className='whitespace-nowrap text-grey-700 before:mx-1 before:content-["·"]' title={`${timestamp}`}>{getRelativeTimestamp(date)}</span>
|
||||
</div>
|
||||
<div className='flex'>
|
||||
<span className='truncate text-grey-700'>{getUsername(author)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='relative z-10 w-full gap-4'>
|
||||
<div className='flex flex-col'>
|
||||
{object.name && <Heading className='mb-1 leading-tight' level={4} data-test-activity-heading>{object.name}</Heading>}
|
||||
<div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.5rem] text-grey-900'></div>
|
||||
{/* <p className='text-pretty text-md text-grey-900'>{object.content}</p> */}
|
||||
{renderAttachment(object)}
|
||||
<div className='mt-3 flex gap-2'>
|
||||
<Button className={`self-start text-grey-500 transition-all hover:text-grey-800 ${isClicked ? 'bump' : ''} ${isLiked ? 'ap-red-heart text-red *:!fill-red hover:text-red' : ''}`} hideLabel={true} icon='heart' id='like' size='md' unstyled={true} onClick={handleLikeClick}/>
|
||||
<span className={`text-grey-800 ${isLiked ? 'opacity-100' : 'opacity-0'}`}>1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='absolute -inset-x-3 -inset-y-0 z-0 rounded transition-colors group-hover/article:bg-grey-75'></div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else if (layout === 'inbox') {
|
||||
return (
|
||||
<>
|
||||
{object && (
|
||||
<div className='border-1 group/article relative z-10 flex cursor-pointer flex-col items-start justify-between border-b border-b-grey-200 py-5' data-test-activity>
|
||||
<div className='relative z-10 mb-3 grid w-full grid-cols-[20px_auto_1fr_auto] items-center gap-2 text-base'>
|
||||
<img className='w-5' src={actor.icon?.url}/>
|
||||
<span className='truncate font-semibold'>{actor.name}</span>
|
||||
{/* <span className='truncate text-grey-800'>{getUsername(actor)}</span> */}
|
||||
<span className='ml-auto text-right text-grey-800'>{timestamp}</span>
|
||||
</div>
|
||||
<div className='relative z-10 grid w-full grid-cols-[auto_170px] gap-4'>
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex w-full justify-between gap-4'>
|
||||
<Heading className='mb-1 line-clamp-2 leading-tight' level={5} data-test-activity-heading>{object.name}</Heading>
|
||||
</div>
|
||||
<p className='mb-6 line-clamp-2 max-w-prose text-pretty text-md text-grey-800'>{previewContent}</p>
|
||||
<div className='flex gap-2'>
|
||||
<Button className={`self-start text-grey-500 transition-all hover:text-grey-800 ${isClicked ? 'bump' : ''} ${isLiked ? 'ap-red-heart text-red *:!fill-red hover:text-red' : ''}`} hideLabel={true} icon='heart' id='like' size='md' unstyled={true} onClick={handleLikeClick}/>
|
||||
<span className={`text-grey-800 ${isLiked ? 'opacity-100' : 'opacity-0'}`}>1</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* {image && <div className='relative min-w-[33%] grow'>
|
||||
<img className='absolute h-full w-full rounded object-cover' height='140px' src={image} width='170px'/>
|
||||
</div>} */}
|
||||
</div>
|
||||
<div className='absolute -inset-x-3 -inset-y-1 z-0 rounded transition-colors group-hover/article:bg-grey-50'></div>
|
||||
{/* <div className='absolute inset-0 z-0 rounded from-white to-grey-50 transition-colors group-hover/article:bg-gradient-to-r'></div> */}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default FeedItem;
|
17
apps/admin-x-activitypub/src/components/global/APAvatar.tsx
Normal file
17
apps/admin-x-activitypub/src/components/global/APAvatar.tsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
import React from 'react';
|
||||
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
|
||||
import {Icon} from '@tryghost/admin-x-design-system';
|
||||
|
||||
interface APAvatarProps {
|
||||
author?: ActorProperties;
|
||||
}
|
||||
|
||||
const APAvatar: React.FC<APAvatarProps> = ({author}) => {
|
||||
return (
|
||||
<>
|
||||
{author && author!.icon?.url ? <img className='z-10 w-10 rounded' src={author!.icon?.url}/> : <div className='z-10 rounded bg-grey-100 p-[10px]'><Icon colorClass='text-grey-600' name='user' size={18} /></div>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default APAvatar;
|
|
@ -1,5 +1,5 @@
|
|||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import {ActivityPubAPI} from '../api/activitypub';
|
||||
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';
|
|
@ -1,6 +1,6 @@
|
|||
import FollowSite from './FollowSite';
|
||||
import ViewFollowers from './ViewFollowers';
|
||||
import ViewFollowing from './ViewFollowing';
|
||||
import FollowSite from './inbox/FollowSiteModal';
|
||||
import ViewFollowers from './profile/ViewFollowersModal';
|
||||
import ViewFollowing from './profile/ViewFollowingModal';
|
||||
import {ModalComponent} from '@tryghost/admin-x-framework/routing';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
import React, {ReactNode} from 'react';
|
||||
|
||||
interface MainHeaderProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const MainHeader: React.FC<MainHeaderProps> = ({children}) => {
|
||||
return (
|
||||
<div className='sticky top-0 z-50 border-b border-grey-200 bg-white py-8'>
|
||||
<div className='grid h-8 grid-cols-3'>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainHeader;
|
|
@ -0,0 +1,29 @@
|
|||
import MainHeader from './MainHeader';
|
||||
import React from 'react';
|
||||
import {Button} from '@tryghost/admin-x-design-system';
|
||||
import {useRouting} from '@tryghost/admin-x-framework/routing';
|
||||
|
||||
interface MainNavigationProps {}
|
||||
|
||||
const MainNavigation: React.FC<MainNavigationProps> = ({}) => {
|
||||
const {route, updateRoute} = useRouting();
|
||||
const mainRoute = route.split('/')[0];
|
||||
|
||||
return (
|
||||
<MainHeader>
|
||||
<div className='col-[2/3] flex items-center justify-center gap-9'>
|
||||
<Button icon='home' iconColorClass={mainRoute === '' ? 'text-black' : 'text-grey-500'} iconSize={18} unstyled onClick={() => updateRoute('')} />
|
||||
<Button icon='magnifying-glass' iconColorClass={mainRoute === 'search' ? 'text-black' : 'text-grey-500'} iconSize={18} unstyled onClick={() => updateRoute('search')} />
|
||||
<Button icon='bell' iconColorClass={mainRoute === 'activity' ? 'text-black' : 'text-grey-500'} iconSize={18} unstyled onClick={() => updateRoute('activity')} />
|
||||
<Button icon='user' iconColorClass={mainRoute === 'profile' ? 'text-black' : 'text-grey-500'} iconSize={18} unstyled onClick={() => updateRoute('profile')} />
|
||||
</div>
|
||||
<div className='col-[3/4] flex items-center justify-end px-8'>
|
||||
<Button color='black' icon='add' label="Follow" onClick={() => {
|
||||
updateRoute('follow-site');
|
||||
}} />
|
||||
</div>
|
||||
</MainHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainNavigation;
|
|
@ -1,6 +1,6 @@
|
|||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import getUsername from '../utils/get-username';
|
||||
import {ActivityPubAPI} from '../api/activitypub';
|
||||
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';
|
||||
|
@ -51,7 +51,7 @@ const ViewFollowersModal: React.FC<RoutingModalProps> = ({}) => {
|
|||
<Modal
|
||||
afterClose={() => {
|
||||
mutation.reset();
|
||||
updateRoute('');
|
||||
updateRoute('profile');
|
||||
}}
|
||||
cancelLabel=''
|
||||
footer={false}
|
|
@ -1,6 +1,6 @@
|
|||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import getUsername from '../utils/get-username';
|
||||
import {ActivityPubAPI} from '../api/activitypub';
|
||||
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';
|
||||
|
@ -32,7 +32,7 @@ const ViewFollowingModal: React.FC<RoutingModalProps> = ({}) => {
|
|||
return (
|
||||
<Modal
|
||||
afterClose={() => {
|
||||
updateRoute('');
|
||||
updateRoute('profile');
|
||||
}}
|
||||
cancelLabel=''
|
||||
footer={false}
|
1
apps/admin-x-design-system/src/assets/icons/bell.svg
Normal file
1
apps/admin-x-design-system/src/assets/icons/bell.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="Alarm-Bell--Streamline-Streamline--3.0" height="24" width="24"><desc>Alarm Bell Streamline Icon: https://streamlinehq.com</desc><defs></defs><title>alarm-bell</title><path d="M10 21.75a2.087 2.087 0 0 0 4.005 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="m12 3 0 -2.25" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="M12 3a7.5 7.5 0 0 1 7.5 7.5c0 7.046 1.5 8.25 1.5 8.25H3s1.5 -1.916 1.5 -8.25A7.5 7.5 0 0 1 12 3Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path></svg>
|
After Width: | Height: | Size: 734 B |
1
apps/admin-x-design-system/src/assets/icons/home.svg
Normal file
1
apps/admin-x-design-system/src/assets/icons/home.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="House-Entrance--Streamline-Streamline--3.0" height="24" width="24"><desc>House Entrance Streamline Icon: https://streamlinehq.com</desc><defs></defs><title>house-entrance</title><path d="M22.868 8.947 12 0.747l-10.878 8.2a1.177 1.177 0 0 0 -0.377 0.8v12.522a0.981 0.981 0 0 0 0.978 0.978h6.522V18a3.75 3.75 0 0 1 7.5 0v5.25h6.521a0.982 0.982 0 0 0 0.979 -0.978V9.747a1.181 1.181 0 0 0 -0.377 -0.8Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path></svg>
|
After Width: | Height: | Size: 576 B |
1
apps/admin-x-design-system/src/assets/icons/user.svg
Normal file
1
apps/admin-x-design-system/src/assets/icons/user.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg id="Single-Neutral--Streamline-Streamline--3.0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><desc>Single Neutral Streamline Icon: https://streamlinehq.com</desc><defs></defs><title>single-neutral</title><path d="M6.75 6a5.25 5.25 0 1 0 10.5 0 5.25 5.25 0 1 0 -10.5 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="M2.25 23.25a9.75 9.75 0 0 1 19.5 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path></svg>
|
After Width: | Height: | Size: 572 B |
|
@ -30,42 +30,50 @@ export interface IconProps {
|
|||
const Icon: React.FC<IconProps> = ({name, size = 'md', colorClass = '', className = ''}) => {
|
||||
const {ReactComponent: SvgComponent} = icons[`../assets/icons/${name}.svg`];
|
||||
|
||||
let styles = '';
|
||||
let classes = '';
|
||||
let styles = {};
|
||||
|
||||
if (!styles) {
|
||||
if (typeof size === 'number') {
|
||||
styles = {
|
||||
width: `${size}px`,
|
||||
height: `${size}px`
|
||||
};
|
||||
}
|
||||
|
||||
if (!classes) {
|
||||
switch (size) {
|
||||
case 'custom':
|
||||
break;
|
||||
case '2xs':
|
||||
styles = 'w-2 h-2';
|
||||
classes = 'w-2 h-2';
|
||||
break;
|
||||
case 'xs':
|
||||
styles = 'w-3 h-3';
|
||||
classes = 'w-3 h-3';
|
||||
break;
|
||||
case 'sm':
|
||||
styles = 'w-4 h-4';
|
||||
classes = 'w-4 h-4';
|
||||
break;
|
||||
case 'lg':
|
||||
styles = 'w-8 h-8';
|
||||
classes = 'w-8 h-8';
|
||||
break;
|
||||
case 'xl':
|
||||
styles = 'w-10 h-10';
|
||||
classes = 'w-10 h-10';
|
||||
break;
|
||||
|
||||
default:
|
||||
styles = 'w-5 h-5';
|
||||
classes = 'w-5 h-5';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
styles = clsx(
|
||||
styles,
|
||||
classes = clsx(
|
||||
classes,
|
||||
colorClass
|
||||
);
|
||||
|
||||
if (SvgComponent) {
|
||||
return (
|
||||
<SvgComponent className={`pointer-events-none ${styles} ${className}`} />
|
||||
<SvgComponent className={`pointer-events-none ${classes} ${className}`} style={styles} />
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
|
|
@ -203,6 +203,32 @@ export const CustomButtons: Story = {
|
|||
}
|
||||
};
|
||||
|
||||
export const RightDrawer: Story = {
|
||||
args: {
|
||||
size: 'bleed',
|
||||
align: 'right',
|
||||
animate: false,
|
||||
width: 600,
|
||||
footer: <></>,
|
||||
children: <>
|
||||
<p>This is a drawer style on the right</p>
|
||||
</>
|
||||
}
|
||||
};
|
||||
|
||||
export const LeftDrawer: Story = {
|
||||
args: {
|
||||
size: 'bleed',
|
||||
align: 'left',
|
||||
animate: false,
|
||||
width: 600,
|
||||
footer: <></>,
|
||||
children: <>
|
||||
<p>This is a drawer style on the right</p>
|
||||
</>
|
||||
}
|
||||
};
|
||||
|
||||
const longContent = (
|
||||
<>
|
||||
<p className='mb-6'>Esse ex officia ipsum et magna reprehenderit ullamco dolore cillum cupidatat ullamco culpa. In et irure irure est id cillum officia pariatur et proident. Nulla nulla dolore qui excepteur magna eu adipisicing mollit. Eiusmod eu irure cupidatat consequat consectetur irure.</p>
|
||||
|
|
|
@ -18,6 +18,7 @@ export interface ModalProps {
|
|||
size?: ModalSize;
|
||||
width?: 'full' | number;
|
||||
height?: 'full' | number;
|
||||
align?: 'center' | 'left' | 'right';
|
||||
|
||||
testId?: string;
|
||||
title?: string;
|
||||
|
@ -52,6 +53,7 @@ export const topLevelBackdropClasses = 'bg-[rgba(98,109,121,0.2)] backdrop-blur-
|
|||
|
||||
const Modal: React.FC<ModalProps> = ({
|
||||
size = 'md',
|
||||
align = 'center',
|
||||
width,
|
||||
height,
|
||||
testId,
|
||||
|
@ -188,10 +190,14 @@ const Modal: React.FC<ModalProps> = ({
|
|||
}
|
||||
|
||||
let modalClasses = clsx(
|
||||
'relative z-50 mx-auto flex max-h-[100%] w-full flex-col justify-between overflow-x-hidden bg-white dark:bg-black',
|
||||
'relative z-50 flex max-h-[100%] w-full flex-col justify-between overflow-x-hidden bg-white dark:bg-black',
|
||||
align === 'center' && 'mx-auto',
|
||||
align === 'left' && 'mr-auto',
|
||||
align === 'right' && 'ml-auto',
|
||||
size !== 'bleed' && 'rounded',
|
||||
formSheet ? 'shadow-md' : 'shadow-xl',
|
||||
(animate && !formSheet && !animationFinished) && 'animate-modal-in',
|
||||
(animate && !formSheet && !animationFinished && align === 'center') && 'animate-modal-in',
|
||||
(animate && !formSheet && !animationFinished && align === 'right') && 'animate-modal-in-from-right',
|
||||
(formSheet && !animationFinished) && 'animate-modal-in-reverse',
|
||||
scrolling ? 'overflow-y-auto' : 'overflow-y-hidden'
|
||||
);
|
||||
|
|
|
@ -166,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)'
|
||||
|
@ -192,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'
|
||||
},
|
||||
|
|
Loading…
Add table
Reference in a new issue