yap-blog/src/app/admin/(panel)/layout.tsx
matt b7d473e7e5 Add RSS feed, sitemap, robots.txt, and search-engine metadata
A new "Site URL" setting (validated, trailing-slash-normalized, with
SITE_URL env fallback) anchors every absolute URL. On top of it:

- /feed.xml — RSS 2.0 with the 20 newest published posts: excerpt
  description, full sanitized HTML in content:encoded (relative image
  and link URLs rewritten to absolute, since readers resolve nothing),
  categories from tags, dc:creator, and a self atom:link. Autodiscovery
  <link> on every public page and a footer link.
- /sitemap.xml — home, post list, every published post and page
  (lastmod from updatedAt), and publicly visible tags. Rendered per
  request like the rest of the site so it never goes stale; drafts
  never appear.
- /robots.txt — allow all, disallow /admin/ and /api/, sitemap pointer.
  Admin pages also carry noindex meta as a second layer.
- Page metadata: metadataBase + canonical URLs everywhere, Open Graph
  (article type with published/modified times, author, and tags on
  posts; og:image + summary_large_image card when there's a featured
  image), and BlogPosting JSON-LD on post pages.

Gotcha encoded in lib/seo.ts: Next merges metadata shallowly, so pages
setting alternates.canonical alone would wipe the layout's RSS
autodiscovery entry — pageAlternates() always sets both.

Backups gain settings.siteUrl (export v4; older files still import).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 13:59:04 -04:00

103 lines
4.2 KiB
TypeScript

import Link from "next/link";
import { logoutAction } from "@/actions/auth";
import { requireUser } from "@/lib/auth/dal";
import { countCommentsByStatus } from "@/lib/services/comments";
import { getSettings } from "@/lib/services/settings";
// Belt to robots.txt's suspenders: even a stray crawler that reaches an
// admin URL is told not to index it.
export const metadata = { robots: { index: false, follow: false } };
const navLinkClasses =
"rounded-md px-2.5 py-1.5 text-sm font-medium text-ink transition-colors hover:bg-background hover:text-ink-strong";
/**
* Every route in this group is server-guarded: the layout redirects
* anonymous visitors, each page calls requireAdmin() again (defense in
* depth), and every mutating server action re-checks on its own.
*/
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const user = await requireUser();
const isAdmin = user.role === "admin";
const canModerate = user.permissions.approveComments;
const [settings, commentCounts] = await Promise.all([
getSettings(),
canModerate
? countCommentsByStatus(isAdmin ? undefined : { postAuthorId: user.id })
: { pending: 0, approved: 0 },
]);
return (
<>
<header className="border-b border-edge bg-surface">
<div className="container-site flex flex-wrap items-center justify-between gap-x-6 gap-y-2 py-3">
<div className="flex min-w-0 flex-wrap items-center gap-x-6 gap-y-2">
<Link
href="/admin"
className="font-semibold text-ink-bright transition-colors hover:text-link"
>
{settings.siteTitle}
<span className="font-normal text-ink-muted"> · Admin</span>
</Link>
<nav aria-label="Admin sections">
{/* Authors only manage posts; everything else is the admin's. */}
<ul className="flex items-center gap-1">
{isAdmin && <li><Link href="/admin" className={navLinkClasses}>Dashboard</Link></li>}
<li><Link href="/admin/posts" className={navLinkClasses}>Posts</Link></li>
{isAdmin && (
<li><Link href="/admin/pages" className={navLinkClasses}>Pages</Link></li>
)}
{canModerate && (
<li>
<Link href="/admin/comments" className={navLinkClasses}>
Comments
{commentCounts.pending > 0 && (
<span className="ml-1.5 inline-flex min-w-5 items-center justify-center rounded-full bg-warning/20 px-1.5 py-0.5 text-xs font-semibold text-warning">
{commentCounts.pending}
</span>
)}
</Link>
</li>
)}
{isAdmin && (
<>
<li><Link href="/admin/users" className={navLinkClasses}>Users</Link></li>
<li><Link href="/admin/settings" className={navLinkClasses}>Settings</Link></li>
</>
)}
</ul>
</nav>
</div>
<div className="flex items-center gap-3 text-sm">
<Link href="/" className="text-ink-muted transition-colors hover:text-link">
View site
</Link>
<span aria-hidden="true" className="text-edge-strong">|</span>
<span className="text-ink-muted">
Signed in as{" "}
<Link
href="/admin/account"
className="text-ink-strong underline-offset-4 transition-colors hover:text-link hover:underline"
title="Account settings"
>
{user.username}
</Link>
</span>
<form action={logoutAction}>
<button
type="submit"
className="rounded-md border border-edge px-3 py-1.5 font-medium text-ink transition-colors hover:border-edge-strong hover:text-ink-strong"
>
Log out
</button>
</form>
</div>
</div>
</header>
<main id="main" className="container-site flex-1 py-8">
{children}
</main>
</>
);
}