mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-17 23:44:39 -05:00
Improved loading state of main screens in ActivityPub (#22158)
ref AP-654 - Improved the skeleton component of shadcn - Used the skeleton component in the main five screens - It minimizes the layout shift
This commit is contained in:
parent
b2c08fc44a
commit
c36ab82325
8 changed files with 267 additions and 175 deletions
|
@ -10,7 +10,7 @@ import Separator from './global/Separator';
|
|||
import getName from '../utils/get-name';
|
||||
import getUsername from '../utils/get-username';
|
||||
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
|
||||
import {Button} from '@tryghost/shade';
|
||||
import {Button, Skeleton} from '@tryghost/shade';
|
||||
import {
|
||||
GET_ACTIVITIES_QUERY_KEY_FEED,
|
||||
GET_ACTIVITIES_QUERY_KEY_INBOX,
|
||||
|
@ -48,7 +48,7 @@ const Inbox: React.FC<InboxProps> = ({layout}) => {
|
|||
|
||||
const {data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading} = getActivitiesQuery;
|
||||
|
||||
const activities = (data?.pages.flatMap(page => page.data) ?? [])
|
||||
const activities = (data?.pages.flatMap(page => page.data) ?? Array.from({length: 5}, (_, index) => ({id: `placeholder-${index}`, object: {}})))
|
||||
// If there somehow are duplicate activities, filter them out so the list rendering doesn't break
|
||||
.filter((activity, index, self) => index === self.findIndex(a => a.id === activity.id))
|
||||
// Filter out replies
|
||||
|
@ -59,7 +59,7 @@ const Inbox: React.FC<InboxProps> = ({layout}) => {
|
|||
// Initialise suggested profiles
|
||||
const {suggestedProfilesQuery} = useSuggestedProfilesForUser('index', 3);
|
||||
const {data: suggestedData, isLoading: isLoadingSuggested} = suggestedProfilesQuery;
|
||||
const suggested = suggestedData || [];
|
||||
const suggested = suggestedData || Array(3).fill({actor: {}});
|
||||
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
|
@ -93,11 +93,7 @@ const Inbox: React.FC<InboxProps> = ({layout}) => {
|
|||
<MainNavigation page={layout}/>
|
||||
<div className='z-0 mb-5 flex w-full flex-col'>
|
||||
<div className='w-full px-8'>
|
||||
{isLoading ? (
|
||||
<div className='flex flex-col items-center justify-center space-y-4 text-center'>
|
||||
<LoadingIndicator size='lg' />
|
||||
</div>
|
||||
) : activities.length > 0 ? (
|
||||
{activities.length > 0 ? (
|
||||
<>
|
||||
<div className={`mx-auto flex min-h-[calc(100dvh_-_117px)] items-start gap-11`}>
|
||||
<div className='flex w-full min-w-0 flex-col items-center'>
|
||||
|
@ -117,6 +113,7 @@ const Inbox: React.FC<InboxProps> = ({layout}) => {
|
|||
<FeedItem
|
||||
actor={activity.actor}
|
||||
commentCount={activity.object.replyCount ?? 0}
|
||||
isLoading={isLoading}
|
||||
layout={layout}
|
||||
object={activity.object}
|
||||
repostCount={activity.object.repostCount ?? 0}
|
||||
|
@ -142,31 +139,27 @@ const Inbox: React.FC<InboxProps> = ({layout}) => {
|
|||
<h2 className='mb-1.5 text-lg font-semibold'>This is your {layout === 'inbox' ? 'inbox' : 'feed'}</h2>
|
||||
<p className='mb-6 text-gray-700'>You'll find {layout === 'inbox' ? 'long-form content' : 'short posts and updates'} from the accounts you follow here.</p>
|
||||
<h2 className='mb-1 text-lg font-semibold'>You might also like</h2>
|
||||
{isLoadingSuggested ? (
|
||||
<LoadingIndicator size="sm" />
|
||||
) : (
|
||||
<ul className='grow'>
|
||||
{suggested.map((profile, index) => {
|
||||
const actor = profile.actor;
|
||||
return (
|
||||
<React.Fragment key={actor.id}>
|
||||
<li key={actor.id}>
|
||||
<ActivityItem
|
||||
onClick={() => handleProfileClick(actor)}
|
||||
>
|
||||
<APAvatar author={actor} />
|
||||
<div className='flex min-w-0 flex-col'>
|
||||
<span className='block w-full truncate font-semibold text-black'>{getName(actor)}</span>
|
||||
<span className='block w-full truncate text-sm text-gray-600'>{getUsername(actor)}</span>
|
||||
</div>
|
||||
</ActivityItem>
|
||||
</li>
|
||||
{index < suggested.length - 1 && <Separator />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<ul className='grow'>
|
||||
{suggested.map((profile, index) => {
|
||||
const actor = profile.actor;
|
||||
return (
|
||||
<React.Fragment key={actor.id}>
|
||||
<li key={actor.id}>
|
||||
<ActivityItem
|
||||
onClick={() => handleProfileClick(actor)}
|
||||
>
|
||||
{!isLoadingSuggested ? <APAvatar author={actor} /> : <Skeleton className='z-10 h-10 w-10' />}
|
||||
<div className='flex min-w-0 flex-col'>
|
||||
<span className='block w-full truncate font-semibold text-black'>{!isLoadingSuggested ? getName(actor) : <Skeleton className='w-24' />}</span>
|
||||
<span className='block w-full truncate text-sm text-gray-600'>{!isLoadingSuggested ? getUsername(actor) : <Skeleton className='w-40' />}</span>
|
||||
</div>
|
||||
</ActivityItem>
|
||||
</li>
|
||||
{index < suggested.length - 1 && <Separator />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<Button className='mt-2 w-full' variant='outline' onClick={() => updateRoute('search')}>Explore →</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import React, {useEffect, useRef} from 'react';
|
||||
import {Skeleton} from '@tryghost/shade';
|
||||
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import {Activity, ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
|
||||
|
@ -237,7 +238,7 @@ const Notifications: React.FC<NotificationsProps> = () => {
|
|||
});
|
||||
|
||||
return groupActivities(filtered);
|
||||
}) ?? []);
|
||||
}) ?? Array(5).fill({actors: [{}]}));
|
||||
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
|
@ -305,11 +306,6 @@ const Notifications: React.FC<NotificationsProps> = () => {
|
|||
<>
|
||||
<MainNavigation page='notifications'/>
|
||||
<div className='z-0 flex w-full flex-col items-center'>
|
||||
{
|
||||
isLoading && (<div className='mt-8 flex flex-col items-center justify-center space-y-4 text-center'>
|
||||
<LoadingIndicator size='lg' />
|
||||
</div>)
|
||||
}
|
||||
{
|
||||
isLoading === false && groupedActivities.length === 0 && (
|
||||
<div className='mt-8'>
|
||||
|
@ -320,7 +316,7 @@ const Notifications: React.FC<NotificationsProps> = () => {
|
|||
)
|
||||
}
|
||||
{
|
||||
(isLoading === false && groupedActivities.length > 0) && (
|
||||
(groupedActivities.length > 0) && (
|
||||
<>
|
||||
<div className='my-8 flex w-full max-w-[560px] flex-col'>
|
||||
{groupedActivities.map((group, index) => (
|
||||
|
@ -329,14 +325,15 @@ const Notifications: React.FC<NotificationsProps> = () => {
|
|||
className='hover:bg-gray-100'
|
||||
onClick={() => handleActivityClick(group, index)}
|
||||
>
|
||||
<NotificationItem.Icon type={getActivityBadge(group)} />
|
||||
{!isLoading ? <NotificationItem.Icon type={getActivityBadge(group)} /> : <Skeleton className='rounded-full' containerClassName='flex h-10 w-10' />}
|
||||
<NotificationItem.Avatars>
|
||||
<div className='flex flex-col'>
|
||||
<div className='mt-0.5 flex items-center gap-1.5'>
|
||||
{!openStates[group.id || `${group.type}_${index}`] && group.actors.slice(0, maxAvatars).map(actor => (
|
||||
{!openStates[group.id || `${group.type}_${index}`] && group.actors.slice(0, maxAvatars).map((actor: ActorProperties) => (
|
||||
<APAvatar
|
||||
key={actor.id}
|
||||
author={actor}
|
||||
isLoading={isLoading}
|
||||
size='notification'
|
||||
/>
|
||||
))}
|
||||
|
@ -365,7 +362,7 @@ const Notifications: React.FC<NotificationsProps> = () => {
|
|||
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${openStates[group.id || `${group.type}_${index}`] ? 'mb-2 max-h-[1384px] opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||
{openStates[group.id || `${group.type}_${index}`] && group.actors.length > 1 && (
|
||||
<div className='flex flex-col gap-2 pt-4'>
|
||||
{group.actors.map(actor => (
|
||||
{group.actors.map((actor: ActorProperties) => (
|
||||
<div
|
||||
key={actor.id}
|
||||
className='flex items-center hover:opacity-80'
|
||||
|
@ -383,7 +380,13 @@ const Notifications: React.FC<NotificationsProps> = () => {
|
|||
</NotificationItem.Avatars>
|
||||
<NotificationItem.Content>
|
||||
<div className='line-clamp-2 text-pretty text-black'>
|
||||
<NotificationGroupDescription group={group} />
|
||||
{!isLoading ?
|
||||
<NotificationGroupDescription group={group} /> :
|
||||
<>
|
||||
<Skeleton />
|
||||
<Skeleton className='w-full max-w-60' />
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
{(
|
||||
(group.type === ACTIVITY_TYPE.CREATE && group.object?.inReplyTo) ||
|
||||
|
|
|
@ -3,6 +3,7 @@ import React, {useEffect, useRef, useState} from 'react';
|
|||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
|
||||
import {Button, Heading, List, LoadingIndicator, NoValueLabel, Tab, TabView} from '@tryghost/admin-x-design-system';
|
||||
import {Skeleton} from '@tryghost/shade';
|
||||
|
||||
import {
|
||||
type AccountFollowsQueryResult,
|
||||
|
@ -85,14 +86,37 @@ const useInfiniteScrollTab = <TData,>({useDataHook, emptyStateLabel, emptyStateI
|
|||
)
|
||||
);
|
||||
|
||||
const placeholderPosts = Array(4).fill({object: {}, actor: {}});
|
||||
|
||||
const LoadingState = () => (
|
||||
<>
|
||||
<div ref={loadMoreRef} className='h-1'></div>
|
||||
{
|
||||
(isLoading || isFetchingNextPage) && (
|
||||
<div className='mt-6 flex flex-col items-center justify-center space-y-4 text-center'>
|
||||
<LoadingIndicator size='md' />
|
||||
</div>
|
||||
!isLoading ?
|
||||
<div className='mt-6 flex flex-col items-center justify-center space-y-4 text-center'>
|
||||
<LoadingIndicator size='md' />
|
||||
</div> :
|
||||
<ul>
|
||||
{placeholderPosts.map((activity, index) => (
|
||||
<li
|
||||
key={activity.id}
|
||||
className=''
|
||||
data-test-view-article
|
||||
>
|
||||
<FeedItem
|
||||
actor={activity.actor}
|
||||
isLoading={true}
|
||||
layout='feed'
|
||||
object={activity.object}
|
||||
type={activity.type}
|
||||
onClick={() => handleViewContent(activity, false)}
|
||||
onCommentClick={() => handleViewContent(activity, true)}
|
||||
/>
|
||||
{index < placeholderPosts.length - 1 && <Separator />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
</>
|
||||
|
@ -353,74 +377,73 @@ const Profile: React.FC<ProfileProps> = ({}) => {
|
|||
return (
|
||||
<>
|
||||
<MainNavigation page='profile' />
|
||||
{isLoadingAccount ? (
|
||||
<div className='flex h-[calc(100vh-8rem)] items-center justify-center'>
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
) : (
|
||||
<div className='z-0 mx-auto mt-8 flex w-full max-w-[580px] flex-col items-center pb-16'>
|
||||
<div className='mx-auto w-full'>
|
||||
{account?.bannerImageUrl && (
|
||||
<div className='h-[200px] w-full overflow-hidden rounded-lg bg-gradient-to-tr from-gray-200 to-gray-100'>
|
||||
<img
|
||||
alt={account?.name}
|
||||
className='h-full w-full object-cover'
|
||||
src={account?.bannerImageUrl}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={`${account?.bannerImageUrl && '-mt-12'} px-4`}>
|
||||
<div className='flex items-end justify-between'>
|
||||
<div className='rounded-xl outline outline-4 outline-white'>
|
||||
<APAvatar
|
||||
author={account && {
|
||||
icon: {
|
||||
url: account?.avatarUrl
|
||||
},
|
||||
name: account?.name,
|
||||
handle: account?.handle
|
||||
}}
|
||||
size='lg'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Heading className='mt-4' level={3}>{account?.name}</Heading>
|
||||
<span className='mt-1 text-[1.5rem] text-gray-800'>
|
||||
<span>{account?.handle}</span>
|
||||
</span>
|
||||
{(account?.bio || customFields.length > 0) && (
|
||||
<div ref={contentRef} className={`ap-profile-content transition-max-height relative text-[1.5rem] duration-300 ease-in-out [&>p]:mb-3 ${isExpanded ? 'max-h-none pb-7' : 'max-h-[160px] overflow-hidden'} relative`}>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{__html: account?.bio ?? ''}}
|
||||
className='ap-profile-content mt-3 text-[1.5rem] [&>p]:mb-3'
|
||||
/>
|
||||
{customFields.map(customField => (
|
||||
<span key={customField.name} className='mt-3 line-clamp-1 flex flex-col text-[1.5rem]'>
|
||||
<span className={`text-xs font-semibold`}>{customField.name}</span>
|
||||
<span dangerouslySetInnerHTML={{__html: customField.value}} className='ap-profile-content truncate'/>
|
||||
</span>
|
||||
))}
|
||||
{!isExpanded && isOverflowing && (
|
||||
<div className='absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-white via-white/90 via-60% to-transparent' />
|
||||
)}
|
||||
{isOverflowing && <Button
|
||||
className='absolute bottom-0 text-pink'
|
||||
label={isExpanded ? 'Show less' : 'Show all'}
|
||||
link={true}
|
||||
onClick={toggleExpand}
|
||||
/>}
|
||||
</div>
|
||||
)}
|
||||
<TabView<ProfileTab>
|
||||
containerClassName='mt-6'
|
||||
selectedTab={selectedTab}
|
||||
tabs={tabs}
|
||||
onTabChange={setSelectedTab}
|
||||
<div className='z-0 mx-auto mt-8 flex w-full max-w-[580px] flex-col items-center pb-16'>
|
||||
<div className='mx-auto w-full'>
|
||||
{account?.bannerImageUrl && (
|
||||
<div className='h-[200px] w-full overflow-hidden rounded-lg bg-gradient-to-tr from-gray-200 to-gray-100'>
|
||||
<img
|
||||
alt={account?.name}
|
||||
className='h-full w-full object-cover'
|
||||
src={account?.bannerImageUrl}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={`${account?.bannerImageUrl && '-mt-12'} px-4`}>
|
||||
<div className='flex items-end justify-between'>
|
||||
<div className='rounded-xl outline outline-4 outline-white'>
|
||||
<APAvatar
|
||||
author={account && {
|
||||
icon: {
|
||||
url: account?.avatarUrl
|
||||
},
|
||||
name: account?.name,
|
||||
handle: account?.handle
|
||||
}}
|
||||
size='lg'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Heading className='mt-4' level={3}>{!isLoadingAccount ? account?.name : <Skeleton className='w-32' />}</Heading>
|
||||
<span className='mt-1 text-[1.5rem] text-gray-800'>
|
||||
<span>{!isLoadingAccount ? account?.handle : <Skeleton className='w-full max-w-56' />}</span>
|
||||
</span>
|
||||
{(account?.bio || customFields.length > 0 || isLoadingAccount) && (
|
||||
<div ref={contentRef} className={`ap-profile-content transition-max-height relative text-[1.5rem] duration-300 ease-in-out [&>p]:mb-3 ${isExpanded ? 'max-h-none pb-7' : 'max-h-[160px] overflow-hidden'} relative`}>
|
||||
<div className='ap-profile-content mt-3 text-[1.5rem] [&>p]:mb-3'>
|
||||
{!isLoadingAccount ?
|
||||
<div dangerouslySetInnerHTML={{__html: account?.bio ?? ''}} /> :
|
||||
<>
|
||||
<Skeleton />
|
||||
<Skeleton className='w-full max-w-48' />
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
{customFields.map(customField => (
|
||||
<span key={customField.name} className='mt-3 line-clamp-1 flex flex-col text-[1.5rem]'>
|
||||
<span className={`text-xs font-semibold`}>{customField.name}</span>
|
||||
<span dangerouslySetInnerHTML={{__html: customField.value}} className='ap-profile-content truncate'/>
|
||||
</span>
|
||||
))}
|
||||
{!isExpanded && isOverflowing && (
|
||||
<div className='absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-white via-white/90 via-60% to-transparent' />
|
||||
)}
|
||||
{isOverflowing && <Button
|
||||
className='absolute bottom-0 text-pink'
|
||||
label={isExpanded ? 'Show less' : 'Show all'}
|
||||
link={true}
|
||||
onClick={toggleExpand}
|
||||
/>}
|
||||
</div>
|
||||
)}
|
||||
<TabView<ProfileTab>
|
||||
containerClassName='mt-6'
|
||||
selectedTab={selectedTab}
|
||||
tabs={tabs}
|
||||
onTabChange={setSelectedTab}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import {Skeleton} from '@tryghost/shade';
|
||||
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import {Button, Icon, LoadingIndicator, NoValueLabel, TextField} from '@tryghost/admin-x-design-system';
|
||||
|
@ -95,9 +96,10 @@ const SearchResults: React.FC<SearchResultsProps> = ({results, onUpdate}) => {
|
|||
interface SuggestedProfileProps {
|
||||
profile: Profile;
|
||||
update: (id: string, updated: Partial<Profile>) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const SuggestedProfile: React.FC<SuggestedProfileProps> = ({profile, update}) => {
|
||||
const SuggestedProfile: React.FC<SuggestedProfileProps> = ({profile, update, isLoading}) => {
|
||||
const onFollow = () => {
|
||||
update(profile.actor.id, {
|
||||
isFollowing: true,
|
||||
|
@ -120,18 +122,23 @@ const SuggestedProfile: React.FC<SuggestedProfileProps> = ({profile, update}) =>
|
|||
}}
|
||||
>
|
||||
<APAvatar author={profile.actor}/>
|
||||
<div className='flex flex-col'>
|
||||
<span className='font-semibold text-black'>{profile.actor.name}</span>
|
||||
<span className='text-sm text-gray-700'>{profile.handle}</span>
|
||||
<div className='flex grow flex-col'>
|
||||
<span className='font-semibold text-black'>{!isLoading ? profile.actor.name : <Skeleton className='w-full max-w-64' />}</span>
|
||||
<span className='text-sm text-gray-700'>{!isLoading ? profile.handle : <Skeleton className='w-24' />}</span>
|
||||
</div>
|
||||
<FollowButton
|
||||
className='ml-auto'
|
||||
following={profile.isFollowing}
|
||||
handle={profile.handle}
|
||||
type='secondary'
|
||||
onFollow={onFollow}
|
||||
onUnfollow={onUnfollow}
|
||||
/>
|
||||
{!isLoading ?
|
||||
<FollowButton
|
||||
className='ml-auto'
|
||||
following={profile.isFollowing}
|
||||
handle={profile.handle}
|
||||
type='secondary'
|
||||
onFollow={onFollow}
|
||||
onUnfollow={onUnfollow}
|
||||
/> :
|
||||
<div className='inline-flex items-center'>
|
||||
<Skeleton className='w-12' />
|
||||
</div>
|
||||
}
|
||||
</ActivityItem>
|
||||
);
|
||||
};
|
||||
|
@ -148,15 +155,11 @@ const SuggestedProfiles: React.FC<SuggestedProfilesProps> = ({profiles, isLoadin
|
|||
<span className='mb-1 flex w-full max-w-[560px] font-semibold'>
|
||||
Suggested accounts
|
||||
</span>
|
||||
{isLoading && (
|
||||
<div className='p-4'>
|
||||
<LoadingIndicator size='md'/>
|
||||
</div>
|
||||
)}
|
||||
{profiles.map((profile, index) => {
|
||||
return (
|
||||
<React.Fragment key={profile.actor.id}>
|
||||
<SuggestedProfile
|
||||
isLoading={isLoading}
|
||||
profile={profile}
|
||||
update={onUpdate}
|
||||
/>
|
||||
|
@ -174,7 +177,13 @@ const Search: React.FC<SearchProps> = ({}) => {
|
|||
// Initialise suggested profiles
|
||||
const {suggestedProfilesQuery, updateSuggestedProfile} = useSuggestedProfilesForUser('index', 6);
|
||||
const {data: suggestedProfilesData, isLoading: isLoadingSuggestedProfiles} = suggestedProfilesQuery;
|
||||
const suggestedProfiles = suggestedProfilesData || [];
|
||||
const suggestedProfiles = suggestedProfilesData || Array(5).fill({
|
||||
actor: {},
|
||||
handle: '',
|
||||
followerCount: 0,
|
||||
followingCount: 0,
|
||||
isFollowing: false
|
||||
});
|
||||
|
||||
// Initialise search query
|
||||
const queryInputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
|
||||
import {Button, Heading, Icon, Menu, MenuItem, showToast} from '@tryghost/admin-x-design-system';
|
||||
import {Skeleton} from '@tryghost/shade';
|
||||
|
||||
import APAvatar from '../global/APAvatar';
|
||||
|
||||
|
@ -109,12 +110,16 @@ export function renderFeedAttachment(object: ObjectProperties, layout: string) {
|
|||
}
|
||||
}
|
||||
|
||||
function renderInboxAttachment(object: ObjectProperties) {
|
||||
function renderInboxAttachment(object: ObjectProperties, isLoading: boolean | undefined) {
|
||||
const attachment = getAttachment(object);
|
||||
|
||||
const videoAttachmentStyles = 'ml-8 md:ml-9 shrink-0 rounded-md h-[91px] w-[121px] relative';
|
||||
const imageAttachmentStyles = clsx('object-cover outline outline-1 -outline-offset-1 outline-black/[0.05]', videoAttachmentStyles);
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className={`${imageAttachmentStyles} outline-0`} />;
|
||||
}
|
||||
|
||||
if (!attachment) {
|
||||
return null;
|
||||
}
|
||||
|
@ -170,13 +175,14 @@ interface FeedItemProps {
|
|||
repostCount?: number;
|
||||
showHeader?: boolean;
|
||||
last?: boolean;
|
||||
isLoading?: boolean;
|
||||
onClick?: () => void;
|
||||
onCommentClick: () => void;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, commentCount = 0, repostCount = 0, showHeader = true, last, onClick: onClickHandler = noop, onCommentClick}) => {
|
||||
const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, commentCount = 0, repostCount = 0, showHeader = true, last, isLoading, onClick: onClickHandler = noop, onCommentClick}) => {
|
||||
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'});
|
||||
|
||||
|
@ -190,7 +196,7 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
|
|||
if (element) {
|
||||
setIsTruncated(element.scrollHeight > element.clientHeight);
|
||||
}
|
||||
}, [object.content]);
|
||||
}, [object?.content]);
|
||||
|
||||
const onLikeClick = () => {
|
||||
// Do API req or smth
|
||||
|
@ -266,21 +272,23 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
|
|||
</div>}
|
||||
<div className={`border-1 flex flex-col gap-2.5`} data-test-activity>
|
||||
<div className='relative z-30 flex min-w-0 items-center gap-3'>
|
||||
<APAvatar author={author}/>
|
||||
<APAvatar author={author} />
|
||||
<div className='flex min-w-0 flex-col gap-0.5'>
|
||||
<span className='min-w-0 truncate break-all font-semibold leading-[normal] hover:underline'
|
||||
data-test-activity-heading
|
||||
onClick={e => handleProfileClick(author, e)}
|
||||
>
|
||||
{author.name}
|
||||
{!isLoading ? author.name : <Skeleton className='w-24' />}
|
||||
</span>
|
||||
<div className='flex w-full text-gray-700'>
|
||||
<span className='truncate leading-tight hover:underline'
|
||||
onClick={e => handleProfileClick(author, e)}
|
||||
>
|
||||
{getUsername(author)}
|
||||
{!isLoading ? getUsername(author) : <Skeleton className='w-56' />}
|
||||
</span>
|
||||
<div className='ml-1 leading-tight before:mr-1 before:content-["·"]' title={`${timestamp}`}>{renderTimestamp(object)}</div>
|
||||
<div className={`ml-1 leading-tight before:mr-1 ${!isLoading && 'before:content-["·"]'}`} title={`${timestamp}`}>
|
||||
{!isLoading ? renderTimestamp(object) : <Skeleton className='w-4' />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Menu items={menuItems} open={menuIsOpen} position='end' setOpen={setMenuIsOpen} trigger={UserMenuTrigger}/>
|
||||
|
@ -296,11 +304,15 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
|
|||
</div>
|
||||
</div> :
|
||||
<div className='relative'>
|
||||
<div
|
||||
dangerouslySetInnerHTML={({__html: object.content ?? ''})}
|
||||
ref={contentRef}
|
||||
className='ap-note-content line-clamp-[10] text-pretty leading-[1.4285714286] tracking-[-0.006em] text-gray-900'
|
||||
></div>
|
||||
<div className='ap-note-content line-clamp-[10] text-pretty leading-[1.4285714286] tracking-[-0.006em] text-gray-900'>
|
||||
{!isLoading ?
|
||||
<div dangerouslySetInnerHTML={{
|
||||
__html: object.content ?? ''
|
||||
}} ref={contentRef} />
|
||||
:
|
||||
<Skeleton count={2} />
|
||||
}
|
||||
</div>
|
||||
{isTruncated && (
|
||||
<button className='mt-1 text-blue-600' type='button'>Show more</button>
|
||||
)}
|
||||
|
@ -309,15 +321,18 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
|
|||
}
|
||||
</div>
|
||||
<div className='space-between relative z-[30] ml-[-7px] mt-1 flex'>
|
||||
<FeedItemStats
|
||||
commentCount={commentCount}
|
||||
layout={layout}
|
||||
likeCount={1}
|
||||
object={object}
|
||||
repostCount={repostCount}
|
||||
onCommentClick={onCommentClick}
|
||||
onLikeClick={onLikeClick}
|
||||
/>
|
||||
{!isLoading ?
|
||||
<FeedItemStats
|
||||
commentCount={commentCount}
|
||||
layout={layout}
|
||||
likeCount={1}
|
||||
object={object}
|
||||
repostCount={repostCount}
|
||||
onCommentClick={onCommentClick}
|
||||
onLikeClick={onLikeClick}
|
||||
/> :
|
||||
<Skeleton className='ml-2 w-18' />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -439,29 +454,44 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
|
|||
<>
|
||||
{object && (
|
||||
<div className='group/article relative -mx-6 -my-px flex min-h-[112px] min-w-0 cursor-pointer items-center justify-between rounded-lg p-6 hover:bg-gray-75' data-layout='inbox' data-object-id={object.id} onClick={onClick}>
|
||||
<div className='min-w-0'>
|
||||
<div className='w-full min-w-0'>
|
||||
<div className='z-10 mb-1.5 flex w-full min-w-0 items-center gap-1.5 text-sm group-hover/article:border-transparent'>
|
||||
<APAvatar author={author} size='2xs'/>
|
||||
<span className='min-w-0 truncate break-all font-semibold text-gray-900 hover:underline'
|
||||
title={getUsername(author)}
|
||||
data-test-activity-heading
|
||||
onClick={e => handleProfileClick(author, e)}
|
||||
>{author.name}
|
||||
</span>
|
||||
{(type === 'Announce') && <span className='z-10 flex items-center gap-1 text-gray-700'><Icon colorClass='text-gray-700 shrink-0' name='reload' size={'sm'}></Icon><span className='hover:underline' title={getUsername(actor)} onClick={e => handleProfileClick(actor, e)}>{actor.name}</span> reposted</span>}
|
||||
<span className='shrink-0 whitespace-nowrap text-gray-600 before:mr-1 before:content-["·"]' title={`${timestamp}`}>{renderTimestamp(object)}</span>
|
||||
{!isLoading ?
|
||||
<>
|
||||
<APAvatar author={author} size='2xs' />
|
||||
<span className='min-w-0 truncate break-all font-semibold text-gray-900 hover:underline'
|
||||
title={getUsername(author)}
|
||||
data-test-activity-heading
|
||||
onClick={e => handleProfileClick(author, e)}
|
||||
>{author.name}
|
||||
</span>
|
||||
{(type === 'Announce') && <span className='z-10 flex items-center gap-1 text-gray-700'><Icon colorClass='text-gray-700 shrink-0' name='reload' size={'sm'}></Icon><span className='hover:underline' title={getUsername(actor)} onClick={e => handleProfileClick(actor, e)}>{actor.name}</span> reposted</span>}
|
||||
<span className='shrink-0 whitespace-nowrap text-gray-600 before:mr-1 before:content-["·"]' title={`${timestamp}`}>{renderTimestamp(object)}</span>
|
||||
</> :
|
||||
<Skeleton className='w-24' />
|
||||
}
|
||||
</div>
|
||||
<div className='flex'>
|
||||
<div className='flex min-h-[73px] w-full min-w-0 flex-col items-start justify-start gap-1'>
|
||||
<Heading className='w-full max-w-[600px] text-pretty text-[1.6rem] font-semibold leading-tight' level={5} data-test-activity-heading>
|
||||
{object.name ? object.name : (
|
||||
{isLoading ? <Skeleton className='w-full max-w-96' /> : (object.name ? object.name : (
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: stripHtml(object.content || '')
|
||||
}}></span>
|
||||
)}
|
||||
))}
|
||||
</Heading>
|
||||
<div dangerouslySetInnerHTML={({__html: stripHtml(object.preview?.content ?? object.content ?? '')})} className='ap-note-content line-clamp-2 w-full max-w-[600px] text-pretty text-base leading-normal text-gray-800'></div>
|
||||
<span className='mt-1 shrink-0 whitespace-nowrap text-sm leading-none text-gray-600'>{object.content && `${getReadingTime(object.content)}`}</span>
|
||||
<div className='ap-note-content line-clamp-2 w-full max-w-[600px] text-pretty text-base leading-normal text-gray-800'>
|
||||
{!isLoading ?
|
||||
<div dangerouslySetInnerHTML={{
|
||||
__html: stripHtml(object.preview?.content ?? object.content ?? '')
|
||||
}} />
|
||||
:
|
||||
<Skeleton count={2} />
|
||||
}
|
||||
</div>
|
||||
<span className='mt-1 shrink-0 whitespace-nowrap text-sm leading-none text-gray-600'>
|
||||
{!isLoading ? (object.content && `${getReadingTime(object.content)}`) : <Skeleton className='w-16' />}
|
||||
</span>
|
||||
</div>
|
||||
<div className='invisible absolute right-4 top-1/2 z-[49] flex -translate-y-1/2 flex-col rounded-full bg-white p-1 shadow-md group-hover/article:visible'>
|
||||
<FeedItemStats
|
||||
|
@ -477,7 +507,7 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderInboxAttachment(object)}
|
||||
{renderInboxAttachment(object, isLoading)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -5,6 +5,7 @@ import clsx from 'clsx';
|
|||
import getUsername from '../../utils/get-username';
|
||||
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
|
||||
import {Icon} from '@tryghost/admin-x-design-system';
|
||||
import {Skeleton} from '@tryghost/shade';
|
||||
|
||||
type AvatarSize = '2xs' | 'xs' | 'sm' | 'lg' | 'notification';
|
||||
|
||||
|
@ -17,9 +18,10 @@ interface APAvatarProps {
|
|||
handle?: string;
|
||||
} | undefined;
|
||||
size?: AvatarSize;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const APAvatar: React.FC<APAvatarProps> = ({author, size}) => {
|
||||
const APAvatar: React.FC<APAvatarProps> = ({author, size, isLoading = false}) => {
|
||||
let iconSize = 18;
|
||||
let containerClass = `shrink-0 items-center justify-center overflow-hidden relative z-10 flex ${size === 'lg' ? '' : 'hover:opacity-80 cursor-pointer'}`;
|
||||
let imageClass = 'z-10 object-cover';
|
||||
|
@ -29,10 +31,6 @@ const APAvatar: React.FC<APAvatarProps> = ({author, size}) => {
|
|||
setIconUrl(author?.icon?.url);
|
||||
}, [author?.icon?.url]);
|
||||
|
||||
if (!author) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (size) {
|
||||
case '2xs':
|
||||
iconSize = 10;
|
||||
|
@ -63,6 +61,10 @@ const APAvatar: React.FC<APAvatarProps> = ({author, size}) => {
|
|||
break;
|
||||
}
|
||||
|
||||
if (!author || isLoading) {
|
||||
return <Skeleton className={imageClass} containerClassName={containerClass} />;
|
||||
}
|
||||
|
||||
if (!iconUrl) {
|
||||
containerClass = clsx(containerClass, 'bg-gray-100');
|
||||
}
|
||||
|
|
17
apps/shade/src/components/ui/skeleton.stories.tsx
Normal file
17
apps/shade/src/components/ui/skeleton.stories.tsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
import type {Meta, StoryObj} from '@storybook/react';
|
||||
import {Skeleton} from './skeleton';
|
||||
|
||||
const meta = {
|
||||
title: 'Components / Skeleton',
|
||||
component: Skeleton,
|
||||
tags: ['autodocs']
|
||||
} satisfies Meta<typeof Skeleton>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Skeleton>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
style: {width: 160, height: 16}
|
||||
}
|
||||
};
|
|
@ -1,14 +1,29 @@
|
|||
import React from 'react';
|
||||
import {cn} from '@/lib/utils';
|
||||
|
||||
interface SkeletonProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
containerClassName?: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
function Skeleton({
|
||||
containerClassName,
|
||||
count = 1,
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
}: SkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('animate-pulse rounded-md bg-primary/10', className)}
|
||||
{...props}
|
||||
/>
|
||||
<span className={containerClassName}>
|
||||
{Array.from({length: count}).map(() => (
|
||||
<React.Fragment key={`skeleton-${crypto.randomUUID()}`}>
|
||||
<span
|
||||
className={cn('inline-flex w-full leading-none animate-pulse rounded-[2px] bg-primary/10', className)}
|
||||
{...props}
|
||||
>‌</span>
|
||||
<br />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue