0
Fork 0
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:
Peter Zimon 2024-08-15 12:28:08 +02:00 committed by GitHub
parent 5ee67892dc
commit 108c9f60c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 752 additions and 69 deletions

View file

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

View file

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

View file

@ -0,0 +1,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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

After

Width:  |  Height:  |  Size: 734 B

View file

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

After

Width:  |  Height:  |  Size: 576 B

View file

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

After

Width:  |  Height:  |  Size: 572 B

View file

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

View file

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

View file

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

View file

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