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>
103 lines
4.2 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|