Refactored renderAttachment to keep it DRY

ref https://linear.app/tryghost/issue/MOM-282/render-notes-in-the-frontend
This commit is contained in:
Djordje Vlaisavljevic 2024-08-14 16:08:51 +01:00
parent ee5a49077d
commit 04b600b0b8

View File

@ -1,6 +1,7 @@
import ActivityPubWelcomeImage from '../assets/images/ap-welcome.png'; import ActivityPubWelcomeImage from '../assets/images/ap-welcome.png';
import React, {useEffect, useRef, useState} from 'react'; import React, {useEffect, useRef, useState} from 'react';
import articleBodyStyles from './articleBodyStyles'; import articleBodyStyles from './articleBodyStyles';
import getRelativeTimestamp from '../utils/get-relative-timestamp';
import getUsername from '../utils/get-username'; import getUsername from '../utils/get-username';
import {ActivityPubAPI} from '../api/activitypub'; import {ActivityPubAPI} from '../api/activitypub';
import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
@ -204,7 +205,7 @@ const ActivityPubComponent: React.FC = () => {
title: 'Profile', title: 'Profile',
contents: ( contents: (
<div> <div>
<div className='rounded-xl bg-grey-50 p-6' id="ap-sidebar"> <div className='rounded-xl bg-grey-50 p-6' id='ap-sidebar'>
<div className='mb-4 border-b border-b-grey-200 pb-4'><SettingValue key={'your-username'} heading={'Your username'} value={'@index@localplaceholder.com'}/></div> <div className='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='grid grid-cols-2 gap-4'>
<div className='group/stat flex cursor-pointer flex-col gap-1' onClick={() => updateRoute('/view-following')}> <div className='group/stat flex cursor-pointer flex-col gap-1' onClick={() => updateRoute('/view-following')}>
@ -285,15 +286,15 @@ const ArticleBody: React.FC<{heading: string, image: string|undefined, html: str
${cssContent} ${cssContent}
</head> </head>
<body> <body>
<header class="gh-article-header gh-canvas"> <header class='gh-article-header gh-canvas'>
<h1 class="gh-article-title is-title" data-test-article-heading>${heading}</h1> <h1 class='gh-article-title is-title' data-test-article-heading>${heading}</h1>
${image && ${image &&
`<figure class="gh-article-image"> `<figure class='gh-article-image'>
<img src="${image}" alt="${heading}" /> <img src='${image}' alt='${heading}' />
</figure>` </figure>`
} }
</header> </header>
<div class="gh-content gh-canvas is-body"> <div class='gh-content gh-canvas is-body'>
${html} ${html}
</div> </div>
</body> </body>
@ -312,16 +313,72 @@ ${image &&
<iframe <iframe
ref={iframeRef} ref={iframeRef}
className={`h-[calc(100vh_-_3vmin_-_4.8rem_-_2px)]`} className={`h-[calc(100vh_-_3vmin_-_4.8rem_-_2px)]`}
height="100%" height='100%'
id="gh-ap-article-iframe" id='gh-ap-article-iframe'
title="Embedded Content" title='Embedded Content'
width="100%" width='100%'
> >
</iframe> </iframe>
</div> </div>
); );
}; };
function renderAttachment(object: ObjectProperties) {
let attachment;
if (object.image) {
attachment = object.image;
}
if (object.type === 'Note' && !attachment) {
attachment = object.attachment;
}
if (!attachment) {
return null;
}
if (Array.isArray(attachment)) {
const attachmentCount = attachment.length;
let gridClass = '';
if (attachmentCount === 1) {
gridClass = 'grid-cols-1'; // Single image, full width
} else if (attachmentCount === 2) {
gridClass = 'grid-cols-2'; // Two images, side by side
} else if (attachmentCount === 3 || attachmentCount === 4) {
gridClass = 'grid-cols-2'; // Three or four images, two per row
}
return (
<div className={`attachment-gallery mt-2 grid auto-rows-[150px] ${gridClass} gap-2`}>
{attachment.map((item, index) => (
<img key={item.url} alt={`attachment-${index}`} className={`h-full w-full rounded-md object-cover ${attachmentCount === 3 && index === 0 ? 'row-span-2' : ''}`} src={item.url} />
))}
</div>
);
}
switch (attachment.mediaType) {
case 'image/jpeg':
case 'image/png':
case 'image/gif':
return <img alt='attachment' className='mt-2 rounded-md outline outline-1 -outline-offset-1 outline-black/10' src={attachment.url} />;
case 'video/mp4':
case 'video/webm':
return <div className='relative mb-4 mt-2'>
<video className='h-[300px] w-full rounded object-cover' src={attachment.url}/>
</div>;
case 'audio/mpeg':
case 'audio/ogg':
return <div className='relative mb-4 mt-2 w-full'>
<audio className='w-full' src={attachment.url} controls/>
</div>;
default:
return null;
}
}
const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProperties, layout: string, type: string }> = ({actor, object, layout, type}) => { const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProperties, layout: string, type: string }> = ({actor, object, layout, type}) => {
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(object.content || '', 'text/html'); const doc = parser.parseFromString(object.content || '', 'text/html');
@ -335,55 +392,11 @@ const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProp
previewContent = plainTextContent || ''; previewContent = plainTextContent || '';
} }
const renderAttachment = () => {
let attachment;
if (object.image) {
attachment = object.image;
}
if (object.type === 'Note' && !attachment) {
attachment = object.attachment;
}
// const attachment = object.attachment;
if (!attachment) {
return null;
}
if (Array.isArray(attachment)) {
return (
<div className="attachment-gallery mt-2 grid auto-rows-[150px] grid-cols-2 gap-2">
{attachment.map((item, index) => (
<img key={item.url} alt={`attachment-${index}`} className='h-full w-full rounded-md object-cover' 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} controls/>
</div>;
case 'audio/mpeg':
case 'audio/ogg':
return <div className='relative mb-4 mt-2 w-full'>
<audio className='w-full' src={attachment.url} controls/>
</div>;
default:
return null;
}
};
const timestamp = 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'}); 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 [isClicked, setIsClicked] = useState(false);
const [isLiked, setIsLiked] = useState(false); const [isLiked, setIsLiked] = useState(false);
@ -404,18 +417,20 @@ const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProp
<> <>
{object && ( {object && (
<div className='group/article relative cursor-pointer pt-4'> <div className='group/article relative cursor-pointer pt-4'>
{(type === 'Announce' && object.type === 'Note') && <div className='z-10 mb-2 flex items-center gap-4 text-grey-700'> {(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> <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> <span className='z-10'>{actor.name} reposted</span>
</div>} </div>}
<div className='flex items-start gap-4'> <div className='flex items-start gap-3'>
<img className='z-10 w-10 rounded' src={author.icon?.url}/> <img className='z-10 w-10 rounded' src={author.icon?.url}/>
<div className='border-1 z-10 -mt-1 flex flex-col items-start justify-between border-b border-b-grey-200 pb-4' data-test-activity> <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='relative z-10 mb-2 flex w-full flex-col overflow-visible text-[1.5rem]'>
<span className='mr-1 truncate whitespace-nowrap font-bold' data-test-activity-heading>{author.name}</span> <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'> <div className='flex'>
<span className='truncate text-grey-700'>{getUsername(author)}</span> <span className='truncate text-grey-700'>{getUsername(author)}</span>
<span className='whitespace-nowrap text-grey-700 before:mx-1 before:content-["·"]'>{timestamp}</span>
</div> </div>
</div> </div>
<div className='relative z-10 w-full gap-4'> <div className='relative z-10 w-full gap-4'>
@ -423,9 +438,9 @@ const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProp
{object.name && <Heading className='mb-1 leading-tight' level={4} data-test-activity-heading>{object.name}</Heading>} {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> <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> */} {/* <p className='text-pretty text-md text-grey-900'>{object.content}</p> */}
{renderAttachment()} {renderAttachment(object)}
<div className='mt-3 flex gap-2'> <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}/> <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> <span className={`text-grey-800 ${isLiked ? 'opacity-100' : 'opacity-0'}`}>1</span>
</div> </div>
</div> </div>
@ -455,7 +470,7 @@ const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProp
</div> </div>
<p className='mb-6 line-clamp-2 max-w-prose text-pretty text-md text-grey-800'>{previewContent}</p> <p className='mb-6 line-clamp-2 max-w-prose text-pretty text-md text-grey-800'>{previewContent}</p>
<div className='flex gap-2'> <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}/> <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> <span className={`text-grey-800 ${isLiked ? 'opacity-100' : 'opacity-0'}`}>1</span>
</div> </div>
</div> </div>
@ -499,14 +514,19 @@ const ViewArticle: React.FC<ViewArticleProps> = ({object, onBackToList}) => {
</div> </div>
<div className='flex items-center justify-end gap-2'> <div className='flex items-center justify-end gap-2'>
<div className='flex flex-row-reverse items-center gap-3'> <div className='flex flex-row-reverse items-center gap-3'>
<Button className={`self-start text-grey-500 transition-all hover:text-grey-800 ${isClicked ? 'bump' : ''} ${isLiked ? 'ap-red-heart text-red *:!fill-red hover:text-red' : ''}`} hideLabel={true} icon='heart' id="like" size='md' unstyled={true} onClick={handleLikeClick}/> <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> <span className={`text-grey-800 ${isLiked ? 'opacity-100' : 'opacity-0'}`}>1</span>
</div> </div>
<Button hideLabel={true} icon='arrow-top-right' iconSize='xs' label='Visit site' onClick={() => updateRoute('/')}/> <Button hideLabel={true} icon='arrow-top-right' iconSize='xs' label='Visit site' onClick={() => updateRoute('/')}/>
</div> </div>
</div> </div>
<div className='mx-[-4.8rem] mb-[-4.8rem] w-auto'> <div className='mx-[-4.8rem] mb-[-4.8rem] w-auto'>
<ArticleBody heading={object.name} html={object.content} image={object?.image}/> {object.type === 'Note' && (
<div className='mx-auto max-w-[600px]'>
{object.content && <div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.5rem] text-grey-900'></div>}
{renderAttachment(object)}
</div>)}
{object.type === 'Article' && <ArticleBody heading={object.name} html={object.content} image={object?.image}/>}
</div> </div>
</ViewContainer> </ViewContainer>
</Page> </Page>