Created Post analytics spike React app

This commit is contained in:
Peter Zimon 2024-11-11 17:31:07 +01:00
parent 7cfb755bbd
commit d83d83ab37
37 changed files with 7442 additions and 5 deletions

View File

@ -74,7 +74,7 @@ const COMMAND_TYPESCRIPT = {
env: {}
};
const adminXApps = '@tryghost/admin-x-demo,@tryghost/admin-x-settings,@tryghost/admin-x-activitypub';
const adminXApps = '@tryghost/admin-x-demo,@tryghost/admin-x-settings,@tryghost/admin-x-activitypub,@tryghost/post-analytics-spike';
const COMMANDS_ADMINX = [{
name: 'adminXDeps',

View File

@ -42,7 +42,7 @@
}
}
.admin-x-base {
.admin-x-settings {
opacity: 1;
animation-name: openAnimation;
animation-iteration-count: 1;

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{"version":3,"file":"post-analytics-spike.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}

View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Post Analytics Standalone</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/standalone.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,55 @@
{
"name": "@tryghost/post-analytics-spike",
"version": "0.0.0",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/TryGhost/Ghost/tree/main/apps/post-analytics"
},
"author": "Ghost Foundation",
"files": [
"LICENSE",
"README.md",
"dist/"
],
"main": "./dist/post-analytics-spike.umd.cjs",
"module": "./dist/post-analytics-spike.js",
"private": true,
"scripts": {
"dev": "vite build --watch",
"dev:start": "vite",
"build": "tsc && vite build",
"lint": "yarn run lint:code && yarn run lint:test",
"lint:code": "eslint --ext .js,.ts,.cjs,.tsx --cache src",
"lint:test": "eslint -c test/.eslintrc.cjs --ext .js,.ts,.cjs,.tsx --cache test",
"preview": "vite preview"
},
"devDependencies": {
"@testing-library/react": "14.3.1",
"@tryghost/admin-x-design-system": "0.0.0",
"@tryghost/admin-x-framework": "0.0.0",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"nx": {
"targets": {
"dev": {
"dependsOn": [
"^build"
]
},
"test:unit": {
"dependsOn": [
"^build"
]
},
"test:acceptance": {
"dependsOn": [
"^build"
]
}
}
}
}

View File

@ -0,0 +1,3 @@
import {adminXPlaywrightConfig} from '@tryghost/admin-x-framework/playwright';
export default adminXPlaywrightConfig();

View File

@ -0,0 +1 @@
module.exports = require('@tryghost/admin-x-design-system/postcss.config.cjs');

View File

@ -0,0 +1,30 @@
import MainContent from './MainContent';
import {DesignSystemApp, DesignSystemAppProps} from '@tryghost/admin-x-design-system';
import {FrameworkProvider, TopLevelFrameworkProps} from '@tryghost/admin-x-framework';
import {RoutingProvider} from '@tryghost/admin-x-framework/routing';
interface AppProps {
framework: TopLevelFrameworkProps;
designSystem: DesignSystemAppProps;
}
const modals = {
paths: {
'demo-modal': 'DemoModal'
},
load: async () => import('./components/modals')
};
const App: React.FC<AppProps> = ({framework, designSystem}) => {
return (
<FrameworkProvider {...framework}>
<RoutingProvider basePath='post-analytics-spike' modals={modals}>
<DesignSystemApp className='post-analytics-spike' {...designSystem}>
<MainContent />
</DesignSystemApp>
</RoutingProvider>
</FrameworkProvider>
);
};
export default App;

View File

@ -0,0 +1,147 @@
import {Avatar, Breadcrumbs, Button, Heading, Page, Toggle, ViewContainer} from '@tryghost/admin-x-design-system';
import {useRouting} from '@tryghost/admin-x-framework/routing';
const DetailPage: React.FC = () => {
const {updateRoute} = useRouting();
return (
<Page
breadCrumbs={
<Breadcrumbs
items={[
{
label: 'Members',
onClick: () => {
updateRoute('');
}
},
{
label: 'Emerson Vaccaro'
}
]}
onBack={() => {
updateRoute('');
}}
/>
}
fullBleedPage={false}
>
<ViewContainer
firstOnPage={false}
headerContent={
<div>
<Avatar bgColor='#A5D5F7' image='https://i.pravatar.cc/150' label='EV' labelColor='white' size='2xl' />
<Heading className='mt-2' level={1}>Emerson Vaccaro</Heading>
<div className=''>Colombus, OH</div>
</div>
}
primaryAction={
{
icon: 'ellipsis',
color: 'outline'
}
}
type='page'
>
<div className='grid grid-cols-3 border-b border-grey-200 pb-5 tablet:grid-cols-4'>
<div className='col-span-3 -ml-5 mb-5 hidden h-full gap-4 px-5 tablet:!visible tablet:col-span-1 tablet:mb-0 tablet:!flex tablet:flex-col tablet:gap-0'>
<span>Last seen on <strong>22 June 2023</strong></span>
<span className='tablet:mt-2'>Created on <strong>27 Jan 2021</strong></span>
</div>
<div className='flex h-full flex-col tablet:px-5'>
<Heading level={6}>Emails received</Heading>
<span className='mt-1 text-4xl font-bold leading-none'>181</span>
</div>
<div className='flex h-full flex-col tablet:px-5'>
<Heading level={6}>Emails opened</Heading>
<span className='mt-1 text-4xl font-bold leading-none'>104</span>
</div>
<div className='-mr-5 flex h-full flex-col tablet:px-5'>
<Heading level={6}>Average open rate</Heading>
<span className='mt-1 text-4xl font-bold leading-none'>57%</span>
</div>
</div>
<div className='grid grid-cols-2 items-baseline border-b border-grey-200 py-5 tablet:grid-cols-4'>
<div className='-ml-5 flex h-full flex-col gap-6 border-r border-grey-200 px-5'>
<div className='flex justify-between'>
<Heading level={5}>Member data</Heading>
<Button color='green' label='Edit' link />
</div>
<div>
<Heading level={6}>Name</Heading>
<div>Emerson Vaccaro</div>
</div>
<div>
<Heading level={6}>Email</Heading>
<div>emerson@vaccaro.com</div>
</div>
<div>
<Heading level={6}>Labels</Heading>
<div className='mt-2 flex gap-1'>
<div className='inline-block rounded-sm bg-grey-200 px-1.5 text-xs font-medium'>VIP</div>
<div className='inline-block rounded-sm bg-grey-200 px-1.5 text-xs font-medium'>Inner Circle</div>
</div>
</div>
<div>
<Heading level={6}>Notes</Heading>
<div className='text-grey-500'>No notes.</div>
</div>
</div>
<div className='flex h-full flex-col gap-6 border-grey-200 px-5 tablet:border-r'>
<Heading level={5}>Newsletters</Heading>
<div className='flex flex-col gap-3'>
<div className='flex items-center gap-2'>
<Toggle />
<span>Daily news</span>
</div>
<div className='flex items-center gap-2'>
<Toggle />
<span>Weekly roundup</span>
</div>
<div className='flex items-center gap-2'>
<Toggle checked />
<span>The Inner Circle</span>
</div>
<div className='mt-5 rounded border border-red p-4 text-sm text-red'>
This member cannot receive emails due to permanent failure (bounce).
</div>
</div>
</div>
<div className='-ml-5 flex h-full flex-col gap-6 border-r border-grey-200 px-5 pt-10 tablet:ml-0 tablet:pt-0'>
<Heading level={5}>Subscriptions</Heading>
<div className='flex items-center gap-3'>
<div className='flex h-16 w-16 flex-col items-center justify-center rounded-md bg-grey-200'>
<Heading level={5}>$5</Heading>
<span className='text-xs text-grey-700'>Yearly</span>
</div>
<div className='flex flex-col'>
<span className='font-semibold'>Gold</span>
<span className='text-sm text-grey-500'>Renews 21 Jan 2024</span>
</div>
</div>
</div>
<div className='-mr-5 flex h-full flex-col gap-6 px-5 pt-10 tablet:pt-0'>
<div className='flex justify-between'>
<Heading level={5}>Activity</Heading>
<Button color='green' label='View all' link />
</div>
<div className='flex flex-col text-sm'>
<span className='font-semibold'>Logged in</span>
<span className='text-sm text-grey-500'>13 days ago</span>
</div>
<div className='flex flex-col text-sm'>
<span className='font-semibold'>Subscribed to Daily News</span>
<span className='text-sm text-grey-500'>17 days ago</span>
</div>
<div className='flex flex-col text-sm'>
<span className='font-semibold'>Logged in</span>
<span className='text-sm text-grey-500'>21 days ago</span>
</div>
</div>
</div>
</ViewContainer>
</Page>
);
};
export default DetailPage;

View File

@ -0,0 +1,246 @@
import {Avatar, Button, ButtonGroup, DynamicTable, DynamicTableColumn, DynamicTableRow, Heading, Hint, Page, SortMenu, Tooltip, ViewContainer, showToast} from '@tryghost/admin-x-design-system';
import {useRouting} from '@tryghost/admin-x-framework/routing';
import {useState} from 'react';
const ListPage = () => {
const {updateRoute} = useRouting();
const [view, setView] = useState<string>('list');
const dummyActions = [
<Button label='Filter' onClick={() => {
showToast({message: 'Were you really expecting a filter? 😛'});
}} />,
<SortMenu
direction='desc'
items={[
{
id: 'date-added',
label: 'Date added',
selected: true
},
{
id: 'name',
label: 'Name'
},
{
id: 'redemptions',
label: 'Open Rate'
}
]}
position="start"
onDirectionChange={() => {}}
onSortChange={() => {}}
/>,
<Tooltip content="Search members">
<Button icon='magnifying-glass' size='sm' onClick={() => {
alert('Clicked search');
}} />
</Tooltip>,
<ButtonGroup buttons={[
{
icon: 'listview',
size: 'sm',
iconColorClass: (view === 'list' ? 'text-black' : 'text-grey-500'),
onClick: () => {
setView('list');
}
},
{
icon: 'cardview',
size: 'sm',
iconColorClass: (view === 'card' ? 'text-black' : 'text-grey-500'),
onClick: () => {
setView('card');
}
}
]} clearBg={false} link />
];
const testColumns: DynamicTableColumn[] = [
{
title: 'Member',
noWrap: true,
minWidth: '1%',
maxWidth: '1%'
},
{
title: 'Status'
},
{
title: 'Open rate'
},
{
title: 'Location',
noWrap: true
},
{
title: 'Created',
noWrap: true
},
{
title: 'Signed up on post',
noWrap: true,
maxWidth: '150px'
},
{
title: 'Newsletter'
},
{
title: 'Billing period'
},
{
title: 'Email sent'
},
{
title: '',
hidden: true,
disableRowClick: true
}
];
const testRows = (noOfRows: number) => {
const data: DynamicTableRow[] = [];
for (let i = 0; i < noOfRows; i++) {
data.push(
{
onClick: () => {
updateRoute('detail');
},
cells: [
(<div className='flex items-center gap-3 whitespace-nowrap pr-10'>
<Avatar image={`https://i.pravatar.cc/150?img=${i}`} />
<div>
{i % 3 === 0 && <div className='whitespace-nowrap text-md'>Jamie Larson</div>}
{i % 3 === 1 && <div className='whitespace-nowrap text-md'>Giana Septimus</div>}
{i % 3 === 2 && <div className='whitespace-nowrap text-md'>Zaire Bator</div>}
<div className='text-grey-700'>jamie@larson.com</div>
</div>
</div>),
'Free',
'40%',
'London, UK',
<div>
<div>22 June 2023</div>
<div className='text-grey-500'>5 months ago</div>
</div>,
'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
'Subscribed',
'Monthly',
'1,303',
<Button color='green' label='Edit' link onClick={() => {
alert('Clicked Edit in row:' + i);
}} />
]
}
);
}
return data;
};
const dummyCards = (noOfCards: number) => {
const cards = [];
for (let i = 0; i < noOfCards; i++) {
cards.push(
<div className='flex min-h-[20vh] cursor-pointer flex-col items-center gap-5 rounded-sm bg-grey-100 p-7 pt-9 transition-all hover:bg-grey-200' onClick={() => {
updateRoute('detail');
}}>
<Avatar image={`https://i.pravatar.cc/150?img=${i}`} size='xl' />
<div className='flex flex-col items-center'>
<Heading level={5}>
{i % 3 === 0 && 'Jamie Larson'}
{i % 3 === 1 && 'Giana Septimus'}
{i % 3 === 2 && 'Zaire Bator'}
</Heading>
<div className='mt-1 text-sm text-grey-700'>
{i % 3 === 0 && 'jamie@larson.com'}
{i % 3 === 1 && 'giana@septimus.com'}
{i % 3 === 2 && 'zaire@bator.com'}
</div>
</div>
<div className='flex w-full flex-col gap-4 border-t border-grey-300 pt-5'>
{i % 3 === 0 && (<>
<div className='flex gap-4'>
<div className='basis-1/2 text-center'>
<Heading level={6}>Open rate</Heading>
<div className='text-lg'>83%</div>
</div>
<div className='basis-1/2 text-center'>
<Heading level={6}>Click rate</Heading>
<div className='text-lg'>19%</div>
</div>
</div>
</>)}
{i % 3 === 1 && (<>
<div className='flex gap-4'>
<div className='basis-1/2 text-center'>
<Heading level={6}>Open rate</Heading>
<div className='text-lg'>68%</div>
</div>
<div className='basis-1/2 text-center'>
<Heading level={6}>Click rate</Heading>
<div className='text-lg'>21%</div>
</div>
</div>
</>)}
{i % 3 === 2 && (<>
<div className='flex gap-4'>
<div className='basis-1/2 text-center'>
<Heading level={6}>Open rate</Heading>
<div className='text-lg'>89%</div>
</div>
<div className='basis-1/2 text-center'>
<Heading level={6}>Click rate</Heading>
<div className='text-lg'>34%</div>
</div>
</div>
</>)}
</div>
</div>
);
}
return cards;
};
let contents = <></>;
switch (view) {
case 'list':
contents = <DynamicTable
cellClassName='text-sm'
columns={testColumns}
footer={
<Hint>30 members</Hint>
}
rows={testRows(30)}
stickyFooter
stickyHeader
/>;
break;
case 'card':
contents = <div className='grid grid-cols-4 gap-8 py-8'>{dummyCards(30)}</div>;
break;
}
const demoPage = (
<Page>
<ViewContainer
actions={dummyActions}
primaryAction={{
title: 'About',
onClick: () => {
updateRoute('demo-modal');
}
}}
title='AdminX Demo App'
toolbarBorder={view === 'card'}
type='page'
>
{contents}
</ViewContainer>
</Page>
);
return demoPage;
};
export default ListPage;

View File

@ -0,0 +1,15 @@
// import DetailPage from './DetailPage';
// import ListPage from './ListPage';
// import {useRouting} from '@tryghost/admin-x-framework/routing';
const MainContent = () => {
// const {route} = useRouting();
return (
<>
<h1>Post analytics spike</h1>
</>
);
};
export default MainContent;

View File

@ -0,0 +1,33 @@
import NiceModal from '@ebay/nice-modal-react';
import {Heading, Modal} from '@tryghost/admin-x-design-system';
import {useRouting} from '@tryghost/admin-x-framework/routing';
const DemoModal = NiceModal.create(() => {
const {updateRoute} = useRouting();
const modal = NiceModal.useModal();
return (
<Modal
afterClose={() => {
updateRoute('');
}}
cancelLabel=''
okLabel='Close'
size='sm'
title='About'
onOk={() => {
updateRoute('');
modal.remove();
}}
>
<div className='mt-3 flex flex-col gap-4'>
<p>{`You're looking at a React app inside Ghost Admin. It uses common AdminX framework and Design System packages, and works seamlessly with the current Admin's routing.`}</p>
<p>{`At the moment the look and feel follows the current Admin's style to blend in with existing pages. However the system is built in a very flexible way to allow easy updates in the future.`}</p>
<Heading className='-mb-2 mt-4' level={5}>Contents</Heading>
<p>{`The demo uses a mocked list of members — it's `}<strong>not</strong> {`the actual or future design of members in Ghost Admin. Instead, the pages showcase common design patterns like a list and detail, navigation, modals and toasts.`}</p>
</div>
</Modal>
);
});
export default DemoModal;

View File

@ -0,0 +1,9 @@
import DemoModal from './DemoModal';
import {ModalComponent} from '@tryghost/admin-x-framework/routing';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const modals = {DemoModal} satisfies {[key: string]: ModalComponent<any>};
export default modals;
export type ModalName = keyof typeof modals;

View File

@ -0,0 +1,6 @@
import './styles/index.css';
import App from './App.tsx';
export {
App as AdminXApp
};

View File

@ -0,0 +1,5 @@
import './styles/index.css';
import App from './App.tsx';
import renderStandaloneApp from '@tryghost/admin-x-framework/test/render';
renderStandaloneApp(App, {});

View File

@ -0,0 +1 @@
@import '@tryghost/admin-x-design-system/styles.css';

View File

@ -0,0 +1,6 @@
const adminXPreset = require('@tryghost/admin-x-design-system/tailwind.cjs');
module.exports = {
presets: [adminXPreset('.post-analytics-spike')],
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}', '../../node_modules/@tryghost/admin-x-design-system/es/**/*.{js,ts,jsx,tsx}']
};

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/ts-test'
]
};

View File

@ -0,0 +1,18 @@
import {expect, test} from '@playwright/test';
import {mockApi, responseFixtures} from '@tryghost/admin-x-framework/test/acceptance';
test.describe.skip('Demo', async () => {
test('Renders the list page', async ({page}) => {
await mockApi({page, requests: {
browseSettings: {
method: 'GET',
path: /^\/settings\/\?group=/,
response: responseFixtures.settings
}
}});
await page.goto('/');
await expect(page.locator('body')).toContainText('AdminX Demo App');
});
});

View File

@ -0,0 +1,10 @@
import ListPage from '../../src/ListPage';
import {render, screen} from '@testing-library/react';
describe.skip('Demo', function () {
it('renders a component', async function () {
render(<ListPage />);
expect(screen.getAllByRole('heading')[0].textContent).toEqual('AdminX Demo App');
});
});

View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "test"]
}

View File

@ -0,0 +1,10 @@
import adminXViteConfig from '@tryghost/admin-x-framework/vite';
import pkg from './package.json';
import {resolve} from 'path';
export default (function viteConfig() {
return adminXViteConfig({
packageName: pkg.name,
entry: resolve(__dirname, 'src/index.tsx')
});
});

View File

@ -51,6 +51,9 @@ export const importComponent = async (packageName) => {
}
const relativePath = packageName.replace('@tryghost/', '');
console.log('Relative path: ', relativePath);
const configKey = camelize(relativePath);
if (!config[`${configKey}Filename`] || !config[`${configKey}Hash`]) {
@ -58,19 +61,30 @@ export const importComponent = async (packageName) => {
}
const baseUrl = (config.cdnUrl ? `${config.cdnUrl}assets/` : ghostPaths().assetRootWithHost);
console.log('Base URL: ', baseUrl);
let url = new URL(`${baseUrl}${relativePath}/${config[`${configKey}Filename`]}?v=${config[`${configKey}Hash`]}`);
console.log('url: ', url);
const customUrl = config[`${configKey}CustomUrl`];
console.log('customUrl: ', customUrl);
if (customUrl) {
url = new URL(customUrl);
}
console.log('url: ', url);
if (url.protocol === 'http:') {
window[packageName] = await import(`http://${url.host}${url.pathname}${url.search}`);
} else {
window[packageName] = await import(`https://${url.host}${url.pathname}${url.search}`);
}
console.log('import URL: ', `http://${url.host}${url.pathname}${url.search}`);
return window[packageName];
};

View File

@ -0,0 +1 @@
<div {{react-render this.ReactComponent}}></div>

View File

@ -0,0 +1,8 @@
import AdminXComponent from './admin-x-component';
import {inject as service} from '@ember/service';
export default class AdminXDemo extends AdminXComponent {
@service upgradeStatus;
static packageName = '@tryghost/post-analytics-spike';
}

View File

@ -49,6 +49,9 @@
</a>
</li>
{{/if}}
<li>
<LinkTo @route="post-analytics-spike" @current-when="post-analytics-spike">{{svg-jar "chart"}}Post analytics spike</LinkTo>
</li>
</ul>
<ul class="gh-nav-list gh-nav-manage">
<li class="gh-nav-list-new relative">

View File

@ -0,0 +1,6 @@
import Controller from '@ember/controller';
import {inject as service} from '@ember/service';
export default class ActivitypubXController extends Controller {
@service upgradeStatus;
}

View File

@ -64,6 +64,10 @@ Router.map(function () {
this.route('activitypub-x', {path: '/*sub'});
});
this.route('post-analytics-spike', function () {
this.route('post-analytics-spike', {path: '/*sub'});
});
this.route('explore', function () {
// actual Ember route, not rendered in iframe
this.route('connect');

View File

@ -0,0 +1,3 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
export default class PostAnalyticsSpikeRoute extends AuthenticatedRoute {}

View File

@ -0,0 +1 @@
<AdminX::PostAnalyticsSpike />

View File

@ -6,7 +6,7 @@ const fs = require('fs');
const path = require('path');
const camelCase = require('lodash/camelCase');
const adminXApps = ['admin-x-demo', 'admin-x-settings', 'admin-x-activitypub'];
const adminXApps = ['admin-x-demo', 'admin-x-settings', 'admin-x-activitypub', 'post-analytics-spike'];
function generateHash(filePath) {
const fileContents = fs.readFileSync(filePath, 'utf8');

View File

@ -190,7 +190,8 @@
"projects": [
"@tryghost/admin-x-demo",
"@tryghost/admin-x-settings",
"@tryghost/admin-x-activitypub"
"@tryghost/admin-x-activitypub",
"@tryghost/post-analytics-spike"
],
"target": "build"
}
@ -207,7 +208,8 @@
"projects": [
"@tryghost/admin-x-demo",
"@tryghost/admin-x-settings",
"@tryghost/admin-x-activitypub"
"@tryghost/admin-x-activitypub",
"@tryghost/post-analytics-spike"
],
"target": "build"
}