0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-08 02:52:39 -05:00

Post analytics prototype router (#22034)

ref https://linear.app/ghost/issue/DES-1082/router-prototype

- The current router from `admin-x-framework` looks super complex. This
PR is about testing React Router in the Post analytics prototype.
This commit is contained in:
Peter Zimon 2025-01-21 14:54:02 +01:00 committed by GitHub
parent 3211a146d4
commit 996c9d8c68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 255 additions and 36 deletions

View file

@ -26,8 +26,8 @@
},
"devDependencies": {
"@testing-library/react": "14.3.1",
"@tryghost/shade": "0.0.0",
"@tryghost/admin-x-framework": "0.0.0",
"@tryghost/shade": "0.0.0",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"react": "18.3.1",
@ -51,5 +51,8 @@
]
}
}
},
"dependencies": {
"react-router": "^7.1.3"
}
}
}

View file

@ -1,7 +1,7 @@
import PostAnalytics from './views/post-analytics/PostAnalytics';
import {FrameworkProvider, TopLevelFrameworkProps} from '@tryghost/admin-x-framework';
import {RoutingProvider} from '@tryghost/admin-x-framework/routing';
import {RouterProvider} from 'react-router';
import {ShadeApp, ShadeAppProps, SidebarProvider} from '@tryghost/shade';
import {router} from './routes';
interface AppProps {
framework: TopLevelFrameworkProps;
@ -11,13 +11,11 @@ interface AppProps {
const App: React.FC<AppProps> = ({framework, designSystem}) => {
return (
<FrameworkProvider {...framework}>
<RoutingProvider basePath='posts-x'>
<ShadeApp className='posts' {...designSystem}>
<SidebarProvider>
<PostAnalytics />
</SidebarProvider>
</ShadeApp>
</RoutingProvider>
<ShadeApp className='posts' {...designSystem}>
<SidebarProvider>
<RouterProvider router={router} />
</SidebarProvider>
</ShadeApp>
</FrameworkProvider>
);
};

View file

@ -1,4 +1,4 @@
import {Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger, H1, LucideIcon} from '@tryghost/shade';
import {Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger, H1, LucideIcon} from '@tryghost/shade';
interface headerProps {};
@ -22,7 +22,19 @@ const Header: React.FC<headerProps> = () => {
</BreadcrumbList>
</Breadcrumb>
<div className='flex items-center gap-2'>
<Button variant='outline'><LucideIcon.Share />Share</Button>
<Dialog>
<DialogTrigger asChild>
<Button variant='outline'><LucideIcon.Share />Share</Button>
</DialogTrigger>
<DialogContent className='max-w-2xl'>
<DialogHeader>
<DialogTitle>Share</DialogTitle>
<DialogDescription>
This is a dialog, lets see how it works.
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant='outline'><LucideIcon.Ellipsis /></Button>

30
apps/posts/src/routes.ts Normal file
View file

@ -0,0 +1,30 @@
import Newsletter from './views/post-analytics/components/Newsletter';
import Overview from './views/post-analytics/components/Overview';
import PostAnalytics from './views/post-analytics/PostAnalytics';
import {createHashRouter} from 'react-router';
export const BASE_PATH = '/posts-x';
export const ANALYTICS = `${BASE_PATH}/analytics`;
const postAnalyticsRoutes = [
{
path: `${BASE_PATH}/analytics/:postId`,
Component: PostAnalytics,
children: [
{
path: '',
Component: Overview
},
{
path: 'overview',
Component: Overview
},
{
path: 'newsletter',
Component: Newsletter
}
]
}
];
export const router = createHashRouter(postAnalyticsRoutes);

View file

@ -1,26 +1,44 @@
import Header from '../../components/Header';
import Newsletter from './components/Newsletter';
import Overview from './components/Overview';
import {LucideIcon, Page, Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade';
import {ANALYTICS} from '../../routes';
import {Outlet, useLocation, useNavigate, useParams} from 'react-router';
import {Page, Tabs, TabsList, TabsTrigger} from '@tryghost/shade';
interface postAnalyticsProps {};
const PostAnalytics: React.FC<postAnalyticsProps> = () => {
const navigate = useNavigate();
const {postId} = useParams();
const location = useLocation();
let currentTab = location.pathname.split('/').pop();
if (currentTab === postId || !currentTab) {
currentTab = 'overview';
}
const handleTabChange = (value: string) => {
if (value === 'overview') {
navigate(`${ANALYTICS}/${postId}`);
} else {
navigate(`${ANALYTICS}/${postId}/${value}`);
}
};
return (
<Page>
<Header />
<Tabs className='my-8 flex grow flex-col' defaultValue="overview" variant="button">
<Tabs
className='my-8 flex grow flex-col'
value={currentTab}
variant="underline"
onValueChange={handleTabChange}
>
<TabsList className='w-full'>
<TabsTrigger value="overview"><LucideIcon.Gauge /> Overview</TabsTrigger>
<TabsTrigger value="newsletter"><LucideIcon.Mail /> Newsletter</TabsTrigger>
<TabsTrigger value="web"><LucideIcon.Earth /> Web</TabsTrigger>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="newsletter">Newsletter</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<Overview />
</TabsContent>
<TabsContent className='mt-0 flex grow flex-col' value="newsletter">
<Newsletter />
</TabsContent>
<div>
<Outlet />
</div>
</Tabs>
</Page>
);

View file

@ -1,7 +1,7 @@
import OpenedList from './newsletter/OpenedList';
import React from 'react';
import SentList from './newsletter/SentList';
import {Badge} from '@tryghost/shade';
import {Badge, Card, CardContent} from '@tryghost/shade';
import {StatsTabItem, StatsTabTitle, StatsTabValue, StatsTabs, StatsTabsGroup} from './StatsTabs';
interface newsletterProps {};
@ -13,7 +13,7 @@ const Newsletter: React.FC<newsletterProps> = () => {
key: 'sent',
title: 'Sent',
value: '1,697',
badge: '',
badge: '100%',
content: <SentList />
},
{
@ -73,10 +73,12 @@ const Newsletter: React.FC<newsletterProps> = () => {
};
return (
<div className='grid grow grid-cols-[auto_300px] gap-8 py-5'>
<div className='border-t border-border'>
<Content />
</div>
<div className='grid grow grid-cols-[auto_300px] gap-6 py-6'>
<Card className='self-start'>
<CardContent>
<Content />
</CardContent>
</Card>
<div className='-mt-px flex basis-[300px] flex-col'>
<StatsTabs>
{tabs.map(group => (

View file

@ -6,7 +6,7 @@ interface statsTabsProps
extends React.HTMLAttributes<HTMLDivElement> {}
const StatsTabs: React.FC<statsTabsProps> = ({className, ...props}) => {
return <div className={cn('flex flex-col gap-8', className)} {...props} />;
return <div className={cn('flex flex-col gap-6', className)} {...props} />;
};
interface statsTabsGroupProps

View file

@ -35,7 +35,7 @@ const NewsletterPerformance: React.FC<NewsletterPerformanceProps> = (props) => {
<div className='grid grid-cols-3 py-5'>
<Metric className='pl-6'>
<MetricLabel>Sent</MetricLabel>
<MetricValue>1,697</MetricValue>
<MetricValue>1,697 <MetricPercentage>100%</MetricPercentage></MetricValue>
</Metric>
<Metric className='pl-6'>

View file

@ -0,0 +1,122 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import {X} from 'lucide-react';
import {cn} from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({className, ...props}, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({className, children, ...props}, ref) => (
<DialogPortal>
<div className='shade'>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</div>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({className, ...props}, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({className, ...props}, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription
};

View file

@ -76,7 +76,7 @@ const tabsTriggerVariants = cva(
variant: {
segmented: 'h-7 rounded-md text-sm font-medium data-[state=active]:shadow-md',
button: 'h-[34px] gap-1.5 rounded-md border border-input py-2 text-sm font-medium hover:bg-muted/50 data-[state=active]:bg-muted/70 data-[state=active]:font-semibold',
underline: 'relative h-[34px] px-0 text-md font-medium text-gray-700 data-[state=active]:font-semibold data-[state=active]:text-black data-[state=active]:after:absolute data-[state=active]:after:inset-x-0 data-[state=active]:after:bottom-[-5px] data-[state=active]:after:h-px data-[state=active]:after:bg-black data-[state=active]:after:content-[""]'
underline: 'relative h-[34px] px-0 text-md font-medium text-gray-700 data-[state=active]:font-semibold data-[state=active]:text-black data-[state=active]:after:absolute data-[state=active]:after:inset-x-0 data-[state=active]:after:bottom-[-5px] data-[state=active]:after:h-0.5 data-[state=active]:after:bg-black data-[state=active]:after:content-[""]'
}
},
defaultVariants: {

View file

@ -5,6 +5,7 @@ export * from './components/ui/breadcrumb';
export * from './components/ui/button';
export * from './components/ui/card';
export * from './components/ui/chart';
export * from './components/ui/dialog';
export * from './components/ui/dropdown-menu';
export * from './components/ui/input';
export * from './components/ui/separator';

View file

@ -164,7 +164,7 @@
{{/if}}
{{#if (feature "postsX")}}
<li>
<LinkTo @route="posts-x" @current-when="posts-x">{{svg-jar "chart"}}Post analytics</LinkTo>
<LinkTo @route="posts-x.analytics.123" @current-when="posts-x">{{svg-jar "chart"}}Post analytics</LinkTo>
</li>
{{/if}}
</ul>

View file

@ -54,6 +54,9 @@ Router.map(function () {
this.route('posts-x', function () {
this.route('posts-x', {path: '/*sub'});
this.route('analytics', function () {
this.route('123');
});
});
this.route('settings-x', {path: '/settings'}, function () {

View file

@ -8276,6 +8276,11 @@
resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d"
integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==
"@types/cookie@^0.6.0":
version "0.6.0"
resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.6.0.tgz#eac397f28bf1d6ae0ae081363eca2f425bedf0d5"
integrity sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==
"@types/cookiejar@^2.1.5":
version "2.1.5"
resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.5.tgz#14a3e83fa641beb169a2dd8422d91c3c345a9a78"
@ -13562,6 +13567,11 @@ cookie@0.7.2:
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7"
integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==
cookie@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.0.2.tgz#27360701532116bd3f1f9416929d176afe1e4610"
integrity sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==
cookie@~0.4.1:
version "0.4.2"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432"
@ -27580,6 +27590,16 @@ react-remove-scroll@^2.6.1:
use-callback-ref "^1.3.3"
use-sidecar "^1.1.2"
react-router@^7.1.3:
version "7.1.3"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.1.3.tgz#6c15c28838b799cb3058943e8e8015dbd6c16c7b"
integrity sha512-EezYymLY6Guk/zLQ2vRA8WvdUhWFEj5fcE3RfWihhxXBW7+cd1LsIiA3lmx+KCmneAGQuyBv820o44L2+TtkSA==
dependencies:
"@types/cookie" "^0.6.0"
cookie "^1.0.1"
set-cookie-parser "^2.6.0"
turbo-stream "2.4.0"
react-select@5.8.2:
version "5.8.2"
resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.8.2.tgz#0d7ccb1895d61aafcd090fbf65aa9e506225a854"
@ -28871,6 +28891,11 @@ set-blocking@^2.0.0, set-blocking@~2.0.0:
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==
set-cookie-parser@^2.6.0:
version "2.7.1"
resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz#3016f150072202dfbe90fadee053573cc89d2943"
integrity sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==
set-function-length@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449"
@ -31071,6 +31096,11 @@ tunnel@^0.0.6:
resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c"
integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==
turbo-stream@2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/turbo-stream/-/turbo-stream-2.4.0.tgz#1e4fca6725e90fa14ac4adb782f2d3759a5695f0"
integrity sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==
tweetnacl@^0.14.3, tweetnacl@~0.14.0:
version "0.14.5"
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"