Create an admin page
The news app is almost complete. We can write articles, view, edit, and delete them.
Our final job is to add an admin page that allows admin level users to publish articles when they are ready, and make them live on the site.
Set up the admin page
Section titled βSet up the admin pageβFor the time being, we will make this page available to everyone. We can grant access to admin
users only in the next guide.
-
Create the
Section titled βCreate the /admin-review routeβ/admin-review
routeThe first thing we need is a new parent route to display a list of all articles.
This is straightforward. Firstly, I need to create a new file inside
app/routes
calledadmin-review.tsx
: -
Add a
Section titled βAdd a loader functionβloader
functionIn
app/routes/admin-review.tsx
, Iβll set up aloader
function that returns theid
,title
andisPublished
values for each article:app/routes/admin-review.tsx import { json } from '@remix-run/node'import { prisma } from '~/utils/db.server.ts'export async function loader() {const allArticles = await prisma.article.findMany({select: { id: true, title: true, isPublished: true },})return json({ allArticles })} -
Parent user interface
Section titled βParent user interfaceβIβll then use the same layout as the
/users/$username/articles
page to display the list of articles.The only thing Iβve added (on line 43) is an indication of whether the article is published or not.
This will be a small βpillβ user interface element. It will show a green βPβ for βPublishedβ or a red βDβ for βDraftβ.
Still inside
app/routes/admin-review.tsx
, add the following code:app/routes/admin-review.tsx import { json } from '@remix-run/node'import { NavLink, Outlet, useLoaderData } from '@remix-run/react'import { prisma } from '~/utils/db.server.ts'import { cn } from '~/utils/misc.tsx'export async function loader() {const allArticles = await prisma.article.findMany({select: { id: true, title: true, isPublished: true },})return json({ allArticles })}interface StatusPillProps {isPublished: boolean}export function StatusPill({ isPublished }: StatusPillProps) {return (<divclassName={`rounded-full px-2 py-1 text-xs font-semibold ${isPublished ? 'bg-green-700 text-white' : 'bg-red-700 text-white'}`}>{isPublished ? 'P' : 'D'}</div>)}export default function ArticlesRoute() {const { allArticles } = useLoaderData<typeof loader>()const navLinkDefaultClassName ='line-clamp-2 block rounded-l-full py-2 pl-8 pr-6 text-base lg:text-xl'return (<main className="container flex h-full min-h-[750px] px-0 py-12 md:px-8"><div className="grid w-full grid-cols-4 bg-muted pl-2 md:container md:rounded-3xl md:pr-0"><div className="relative col-span-1"><div className="absolute inset-0 flex flex-col"><ul className="overflow-y-auto overflow-x-hidden py-12">{allArticles.map(article => (<likey={article.id}className="flex items-center gap-2 p-1 pr-0"><StatusPill isPublished={article.isPublished} /><NavLinkto={article.id}preventScrollResetprefetch="intent"className={({ isActive }) =>cn(navLinkDefaultClassName,isActive && 'w-full bg-accent',)}>{article.title}</NavLink></li>))}</ul></div></div><div className="relative col-span-3 bg-accent md:rounded-r-3xl"><Outlet /></div></div></main>)}Save your changes and check the
/admin-review
route in the browser.At first, of course, all the articles will show a red βDβ to signify βDraftβ:
As we update the articles, the status will change to a green βPβ for those that we mark as βPublishedβ.
-
Create a child index route
Section titled βCreate a child index routeβIβll also create a new child index route file at
app/routes/admin-review._index.tsx
:This will simply display a message asking the user to select an article to review.
Add the code below to the new file:
app/routes/admin-review._index.tsx export default function ArticlesIndexRoute() {return (<div className="container pt-12"><p className="text-body-md">Select an article to review...</p></div>)}Refresh your page in the browser.
Your
/admin-review
route should look something like this:This looks good, but of course if I click on any of the articles titles, Iβll get a 404 error because I havenβt created the child route yet.
Let fix that now.
-
Server-side code
Section titled βServer-side codeβIf I add a
.server
suffix to a route file name, this will make it a server route.Create a new file at
app/routes/__admin-review.$articleId.server.tsx
:Once set up, add the code below to it:
app/routes/__admin-review.$articleId.server.tsx import { parseWithZod } from '@conform-to/zod'import { invariantResponse } from '@epic-web/invariant'import {type LoaderFunctionArgs,type ActionFunctionArgs,} from '@remix-run/node'import { json } from '@remix-run/react'import { formatDistanceToNow } from 'date-fns'import { z } from 'zod'import { prisma } from '~/utils/db.server.js'import { redirectWithToast } from '~/utils/toast.server.js'export async function loader({ params }: LoaderFunctionArgs) {invariantResponse(params.articleId, 'No article ID provided', { status: 404 })const article = await prisma.article.findUnique({22 collapsed lineswhere: { id: params.articleId },select: {id: true,title: true,content: true,ownerId: true,updatedAt: true,publishedAt: true,isPublished: true,category: {select: {id: true,name: true,},},images: {select: {id: true,altText: true,},},},})10 collapsed linesinvariantResponse(article, 'Not found', { status: 404 })const updatedAtDate = new Date(article.updatedAt)const publishedAtDate = article.publishedAt? new Date(article.publishedAt): nullconst timeAgoUpdated = formatDistanceToNow(updatedAtDate)const timeAgoPublished = publishedAtDate? formatDistanceToNow(publishedAtDate): nullreturn json({article,timeAgoUpdated,timeAgoPublished,})}export const PublishArticleSchema = z.object({publish: z.optional(z.string()),unpublish: z.optional(z.string()),})export async function action({ request }: ActionFunctionArgs) {const formData = await request.formData()const submission = await parseWithZod(formData, {22 collapsed linesschema: PublishArticleSchema.superRefine(async (data, ctx) => {if (!data.publish && !data.unpublish) returnconst article = await prisma.article.findUnique({select: { id: true },where: { id: data.publish ?? data.unpublish },})if (!article) {ctx.addIssue({code: z.ZodIssueCode.custom,message: 'Article not found',})}}).transform(async data => {return {...data,publishedAt: new Date(),isPublished: data.publish ? true : false,}}),async: true,})if (submission.status !== 'success') {return json({ result: submission.reply() },{ status: submission.status === 'error' ? 400 : 200 },)}const { publish, unpublish } = submission.valueconst updatedArticle = await prisma.article.update({where: { id: publish ?? unpublish },data: {isPublished: publish ? true : false,publishedAt: publish ? new Date() : null,},})return redirectWithToast(`/admin-review/${updatedArticle.id}`, {type: 'success',title: 'Success',description: `The article has been ${publish ? 'published' : 'unpublished'}`,})} -
Create the
Section titled βCreate the /admin-review/$articleId routeβ/admin-review/$articleId
routeIβll now create a new child route that will display the full article, together with a button that will toggle between saying βPublishβ or βUnpublishβ, depending on the articleβs status.
Again, Iβll start by creating a new file at
app/routes/admin-review.$articleId.tsx
:Next, Iβll add the code.
This is a long snippet, so take the time to read through the explanation beneath it carefully and understand what it is doing.
If you need extra help understanding the code, just ask and we can go through it in class together.
Here is the full code for the
app/routes/admin-review.$articleId.tsx
route file:app/routes/admin-review/$articleId.tsx 11 collapsed linesimport { getFormProps, useForm } from '@conform-to/react'import { Form, useActionData, useLoaderData } from '@remix-run/react'import { GeneralErrorBoundary } from '~/components/ErrorBoundary.js'import { floatingToolbarClassName } from '~/components/floating-toolbar.tsx'import { ErrorList } from '~/components/forms.tsx'import { Icon } from '~/components/ui/icon.tsx'import { StatusButton } from '~/components/ui/status-button.tsx'import { getArticleImgSrc, useIsPending } from '~/utils/misc.tsx'import type { action, loader } from './__admin-review.$articleId.server'export { action, loader } from './__admin-review.$articleId.server'export default function AdminReviewRoute() {const data = useLoaderData<typeof loader>()return (<div className="absolute inset-0 flex flex-col px-10">55 collapsed lines<h2 className="mb-2 pt-12 text-h2 lg:mb-6">{data.article.title}</h2><div className="mb-4 flex justify-between gap-4"><p className="w-fit rounded-lg bg-card px-4 py-2 text-sm text-card-foreground">{data.article.category?.name ?? 'General News'}</p><pclassName={`w-fit rounded-lg ${data.article.isPublished ? 'bg-green-800' : 'bg-destructive'} px-4 py-2 text-xs font-bold text-card-foreground`}>{data.article.isPublished ? 'Published' : 'Awaiting review'}</p></div><div className="overflow-y-auto pb-24"><ul className="flex flex-wrap gap-5 py-5">{data.article.images.map(image => (<li key={image.id}><a href={getArticleImgSrc(image.id)}><imgsrc={getArticleImgSrc(image.id)}alt={image.altText ?? ''}className="h-32 w-32 rounded-lg object-cover"/></a></li>))}</ul><p className="whitespace-break-spaces text-sm md:text-lg">{data.article.content}</p></div><div className={floatingToolbarClassName}><span className="text-sm text-foreground/90 max-[524px]:hidden"><Icon name="clock" className="scale-125">{data.timeAgoUpdated} ago</Icon></span><spanclassName={`text-sm ${data.timeAgoPublished ? 'text-foreground/90' : 'text-red/90'} max-[524px]:hidden`}><Iconname={data.timeAgoPublished ? 'check' : 'update'}className="scale-125">{data.timeAgoPublished? `Published ${data.timeAgoPublished} ago`: 'Not published yet'}</Icon></span><div className="grid flex-1 grid-cols-2 justify-end gap-2 min-[525px]:flex md:gap-4"><ArticleStatusFormid={data.article.id}isPublished={data.article.isPublished}/></div></div></div>)}interface PublishArticleProps {id: stringisPublished: boolean}export function ArticleStatusForm({ id, isPublished }: PublishArticleProps) {const actionData = useActionData<typeof action>()const isPending = useIsPending()const [form] = useForm({id: 'set-article-status',lastResult: actionData?.result,})return (<Form method="POST" {...getFormProps(form)}>23 collapsed lines<inputtype="hidden"name={isPublished ? 'unpublish' : 'publish'}value={id}/><StatusButtontype="submit"name="intent"value="set-article-status"status={isPending ? 'pending' : form.status ?? 'idle'}disabled={isPending}className="w-full max-md:aspect-square max-md:px-0"><Iconname={isPublished ? 'cross-1' : 'check'}className="scale-125 max-md:scale-150"><span className="max-md:hidden">{isPublished ? 'Unpublish' : 'Publish'}</span></Icon></StatusButton><ErrorList errors={form.errors} id={form.errorId} /></Form>)}export function ErrorBoundary() {return (<GeneralErrorBoundarystatusHandlers={{403: () => <p>You are not allowed to do that</p>,404: ({ params }) => <p>No article with the id exists</p>,}}/>)} -
Final functionality
Section titled βFinal functionalityβRefresh the page in your browser.
If all is working well, you should now have a fully functioning admin review page that allows you to publish or unpublish articles:
Summary
Section titled βSummaryβIn this step, you have:
- Created a new
/admin-review
route that displays a list of articles that are still indraft
status. - Writte the server-side code to load articles and handle the publish/unpublish actions.
- Created a new child route
/admin-review/$articleId
that displays the full article and allows you to publish or unpublish it.