feat(admin): mobile-friendly admin shell + promote-on-social MVP
Mobile admin chrome
- New <AdminShell> component: sticky dark top bar w/ hamburger that opens
a right-side drawer listing every admin section grouped by area
(Content / Editorial / Companies / Newsletter / AdOps / Accounting /
People). Closes on route change, Esc, or backdrop tap. Body scroll
locks while open.
- Wrapped /admin (dashboard) and /admin/articles with the shell. Other
admin pages still render their own layouts — they'll get the wrapper
in follow-ups; the shell sits ABOVE them so nothing breaks today.
Promote-story MVP (share URLs, no OAuth yet)
- New /admin/promote/[slug] page: per-article promote sheet w/ one button
per platform. Each uses that platform's standard share URL:
• LinkedIn → /sharing/share-offsite/?url=
• X → /intent/tweet?text=…&url=…&hashtags=…
• Facebook → /sharer/sharer.php?u=…
• Instagram → copies caption to clipboard + opens instagram:// camera
• Email → mailto: with subject + body pre-filled
On iPhone each button opens the platform's native app via universal
link. No OAuth, no API keys, works tonight.
- New "Promote" action button on every published article row in
/admin/articles — links to /admin/promote/<slug>.
Next session
- Meta OAuth (Facebook Page + IG Business) — Ryan to register the Meta
App in Business Suite first, then we wire one-tap server-side posting.
- LinkedIn OAuth + Marketing API.
- X paid API ($100/mo Basic decision pending).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import AppImage from '@/components/ui/AppImage';
|
||||
import dynamic from 'next/dynamic';
|
||||
import AdminShell from '@/components/admin/AdminShell';
|
||||
|
||||
const RichTextEditor = dynamic(() => import('@/components/RichTextEditor'), { ssr: false });
|
||||
|
||||
@@ -390,6 +391,7 @@ export default function AdminArticlesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell title="Articles">
|
||||
<div className="min-h-screen bg-[#0a0a0a] text-[#cccccc]">
|
||||
{/* Notification */}
|
||||
{notification && (
|
||||
@@ -748,6 +750,19 @@ export default function AdminArticlesPage() {
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Promote — opens /admin/promote/<slug> share sheet */}
|
||||
{article.status === 'published' && (article.slug || article.wp_slug) && (
|
||||
<Link
|
||||
href={`/admin/promote/${article.slug || article.wp_slug}`}
|
||||
title="Promote on social"
|
||||
className="p-1.5 text-[#555] hover:text-[#1D4ED8] transition-colors rounded hover:bg-[#FFFFFF]">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
||||
</svg>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Submit for Review (draft → pending) */}
|
||||
{article.status === 'draft' && (
|
||||
<button
|
||||
@@ -1102,5 +1117,6 @@ export default function AdminArticlesPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import AdminShell from '@/components/admin/AdminShell';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -249,6 +250,7 @@ export default function AdminDashboardPage() {
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<AdminShell title="Dashboard">
|
||||
<div className="min-h-screen bg-[#0a0a0a]">
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
|
||||
@@ -615,5 +617,6 @@ export default function AdminDashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
|
||||
269
src/app/admin/promote/[slug]/page.tsx
Normal file
269
src/app/admin/promote/[slug]/page.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* /admin/promote/[slug]
|
||||
*
|
||||
* Per-article promote sheet. Opens a single screen with one button per
|
||||
* social platform. Each button uses that platform's standard share URL
|
||||
* (LinkedIn Sharing, X Web Intent, Facebook Sharer) — no OAuth, no API
|
||||
* tokens. On iPhone the button opens the platform's native app via the
|
||||
* universal link.
|
||||
*
|
||||
* Instagram has no URL share API, so we render a "Copy caption + open IG"
|
||||
* affordance: caption goes to clipboard, IG opens via instagram:// (falls
|
||||
* back to web), and you paste in the composer.
|
||||
*
|
||||
* This page is the MVP. The follow-up commits will:
|
||||
* • Add a distribute.social_posts table to track what was promoted
|
||||
* • Replace each share URL with a server-side API POST once OAuth is wired
|
||||
* • Add caption auto-generation (LLM summary of the article)
|
||||
*
|
||||
* Until then this works tonight from your phone.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import AdminShell from "@/components/admin/AdminShell";
|
||||
|
||||
interface Article {
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string | null;
|
||||
url: string;
|
||||
featured_image: string | null;
|
||||
}
|
||||
|
||||
const SITE = "https://avbeat.com";
|
||||
|
||||
function strip(html: string | null | undefined, max = 260): string {
|
||||
if (!html) return "";
|
||||
const text = html.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
|
||||
return text.length > max ? text.slice(0, max - 1).trimEnd() + "…" : text;
|
||||
}
|
||||
|
||||
function shareUrls(a: Article) {
|
||||
const url = a.url;
|
||||
const text = `${a.title}`;
|
||||
const hashtags = "AVBeat,proAV,InfoComm";
|
||||
return {
|
||||
linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`,
|
||||
x: `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(url)}&hashtags=${encodeURIComponent(hashtags)}`,
|
||||
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`,
|
||||
email: `mailto:?subject=${encodeURIComponent(text)}&body=${encodeURIComponent(strip(a.excerpt) + "\n\n" + url)}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default function PromotePage() {
|
||||
const params = useParams<{ slug: string }>();
|
||||
const router = useRouter();
|
||||
const { user, loading } = useAuth();
|
||||
const [article, setArticle] = useState<Article | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const [caption, setCaption] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
router.push("/login?next=" + encodeURIComponent(window.location.pathname));
|
||||
}
|
||||
}, [user, loading, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!params?.slug) return;
|
||||
// Hit the public posts API to get title/excerpt/image. Falls back to a
|
||||
// stub if the article isn't surfaced there (e.g., draft, archived).
|
||||
fetch(`/api/public/posts?slug=${encodeURIComponent(params.slug)}`)
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((d) => {
|
||||
const item = (d?.items || []).find((x: any) => x.slug === params.slug) || d?.item;
|
||||
if (item) {
|
||||
const url = `${SITE}/news/${item.slug}`;
|
||||
const a: Article = {
|
||||
slug: item.slug,
|
||||
title: item.title || item.slug,
|
||||
excerpt: item.excerpt || null,
|
||||
url,
|
||||
featured_image: item.image || item.featured_image || null,
|
||||
};
|
||||
setArticle(a);
|
||||
setCaption(`${a.title}\n\n${strip(a.excerpt)}\n\n${a.url}`);
|
||||
} else {
|
||||
// Stub — minimum required to share by URL alone
|
||||
const url = `${SITE}/news/${params.slug}`;
|
||||
setArticle({ slug: params.slug, title: params.slug, excerpt: null, url, featured_image: null });
|
||||
setCaption(`Check this out — ${url}`);
|
||||
}
|
||||
})
|
||||
.catch((e) => setErr(e.message || "Failed to load article"));
|
||||
}, [params?.slug]);
|
||||
|
||||
if (!user || !article) {
|
||||
return (
|
||||
<AdminShell title="Promote">
|
||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center px-4">
|
||||
{err ? (
|
||||
<p className="text-red-400 text-sm">{err}</p>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-6 h-6 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-[#555] text-sm font-body">Loading article…</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
|
||||
const urls = shareUrls(article);
|
||||
|
||||
function copyToClipboard(text: string, label: string) {
|
||||
navigator.clipboard?.writeText(text)
|
||||
.then(() => {
|
||||
setCopied(label);
|
||||
setTimeout(() => setCopied(null), 1500);
|
||||
})
|
||||
.catch(() => setErr("Couldn't copy. Long-press the caption box to copy manually."));
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell title="Promote">
|
||||
<main className="min-h-screen bg-[#0a0a0a]">
|
||||
<div className="max-w-2xl mx-auto px-4 py-6">
|
||||
{/* Article preview */}
|
||||
<section className="bg-[#FFFFFF] border border-[#1a1f2a] rounded-lg overflow-hidden mb-5">
|
||||
{article.featured_image && (
|
||||
<img
|
||||
src={article.featured_image}
|
||||
alt=""
|
||||
className="w-full h-40 object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<p className="text-[10px] font-mono uppercase tracking-widest text-[#1D4ED8] mb-1">
|
||||
Promoting
|
||||
</p>
|
||||
<h2 className="font-display text-lg font-bold text-white leading-tight">
|
||||
{article.title}
|
||||
</h2>
|
||||
{article.excerpt && (
|
||||
<p className="text-[#888] text-sm mt-2 line-clamp-3 font-body">
|
||||
{strip(article.excerpt)}
|
||||
</p>
|
||||
)}
|
||||
<a
|
||||
href={article.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#1D4ED8] text-xs font-mono mt-3 inline-block hover:underline break-all"
|
||||
>
|
||||
{article.url}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Caption editor — copy-pastable for any platform */}
|
||||
<section className="mb-6">
|
||||
<label className="block text-[10px] font-mono uppercase tracking-widest text-[#1D4ED8] mb-2">
|
||||
Caption
|
||||
</label>
|
||||
<textarea
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full bg-[#FFFFFF] border border-[#1a1f2a] rounded-lg px-3 py-2 text-white text-sm font-body focus:outline-none focus:border-[#1D4ED8]"
|
||||
placeholder="Caption shown on platforms that accept one…"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(caption, "caption")}
|
||||
className="mt-2 text-xs text-[#1D4ED8] hover:underline font-body"
|
||||
>
|
||||
{copied === "caption" ? "✓ Copied" : "Copy caption to clipboard"}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* Platform buttons */}
|
||||
<section className="space-y-3 mb-6">
|
||||
<a
|
||||
href={urls.linkedin}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between w-full bg-[#0A66C2] hover:bg-[#004182] text-white font-body font-semibold text-sm rounded-lg px-4 py-3.5 transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.063 2.063 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
|
||||
LinkedIn
|
||||
</span>
|
||||
<span className="text-xs opacity-80">Share</span>
|
||||
</a>
|
||||
<a
|
||||
href={urls.x}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between w-full bg-black hover:bg-[#1a1a1a] text-white font-body font-semibold text-sm rounded-lg px-4 py-3.5 transition-colors border border-[#333]"
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||||
X (Twitter)
|
||||
</span>
|
||||
<span className="text-xs opacity-80">Post</span>
|
||||
</a>
|
||||
<a
|
||||
href={urls.facebook}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between w-full bg-[#1877F2] hover:bg-[#0d65d9] text-white font-body font-semibold text-sm rounded-lg px-4 py-3.5 transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
||||
Facebook
|
||||
</span>
|
||||
<span className="text-xs opacity-80">Share</span>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
copyToClipboard(caption, "instagram");
|
||||
window.location.href = "instagram://camera";
|
||||
setTimeout(() => {
|
||||
if (document.visibilityState === "visible") {
|
||||
window.open("https://www.instagram.com/", "_blank");
|
||||
}
|
||||
}, 600);
|
||||
}}
|
||||
className="flex items-center justify-between w-full bg-gradient-to-r from-[#833ab4] via-[#fd1d1d] to-[#fcb045] hover:opacity-90 text-white font-body font-semibold text-sm rounded-lg px-4 py-3.5 transition-opacity"
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/></svg>
|
||||
Instagram
|
||||
</span>
|
||||
<span className="text-xs opacity-80">
|
||||
{copied === "instagram" ? "Caption copied · opening IG…" : "Copy + open"}
|
||||
</span>
|
||||
</button>
|
||||
<a
|
||||
href={urls.email}
|
||||
className="flex items-center justify-between w-full bg-[#FFFFFF] hover:bg-[#1f2937] text-white font-body font-semibold text-sm rounded-lg px-4 py-3.5 transition-colors border border-[#222]"
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
||||
Email
|
||||
</span>
|
||||
<span className="text-xs opacity-80">Compose</span>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<p className="text-xs text-[#555] font-body leading-relaxed">
|
||||
Each button opens the platform's share sheet pre-filled with the
|
||||
article URL and caption. Instagram has no URL-share API — its
|
||||
button copies the caption to your clipboard and opens the IG app
|
||||
so you can paste it into a new post or story. One-tap server-side
|
||||
posting via LinkedIn / X / Meta APIs is coming next; the buttons
|
||||
above will stop opening external sheets and post silently
|
||||
instead.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
202
src/components/admin/AdminShell.tsx
Normal file
202
src/components/admin/AdminShell.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* AdminShell — shared admin chrome that wraps every /admin/* page so the
|
||||
* site is usable from a phone. Renders:
|
||||
*
|
||||
* • Dark top bar (matches existing bg-[#0a0a0a] admin theme) with
|
||||
* hamburger trigger on the right.
|
||||
* • Slide-down drawer listing every admin section. Tap a link, drawer
|
||||
* closes, you're there. Keeps the existing per-page layouts intact
|
||||
* so we can roll it out page-by-page without breaking anything.
|
||||
*
|
||||
* Usage:
|
||||
* import AdminShell from "@/components/admin/AdminShell";
|
||||
* …
|
||||
* return (
|
||||
* <AdminShell title="Articles">
|
||||
* <YourExistingPageContent />
|
||||
* </AdminShell>
|
||||
* );
|
||||
*
|
||||
* The shell adds a sticky top bar at min-h-12 so existing content below
|
||||
* doesn't overlap.
|
||||
*/
|
||||
import React, { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
const SECTIONS = [
|
||||
{ group: "Content", items: [
|
||||
{ href: "/admin", label: "Dashboard" },
|
||||
{ href: "/admin/articles", label: "Articles" },
|
||||
{ href: "/admin/review-queue", label: "Review Queue" },
|
||||
{ href: "/admin/import", label: "WP Import" },
|
||||
{ href: "/admin/audit-log", label: "Audit Log" },
|
||||
]},
|
||||
{ group: "Editorial", items: [
|
||||
{ href: "/admin/forum-dashboard", label: "Forum Dashboard" },
|
||||
{ href: "/admin/forum-moderation", label: "Forum Moderation" },
|
||||
{ href: "/admin/forum-seed", label: "Forum Seed" },
|
||||
{ href: "/admin/banned-terms", label: "Banned Terms" },
|
||||
{ href: "/admin/notifications", label: "Notifications" },
|
||||
{ href: "/admin/content-status", label: "Content Status" },
|
||||
]},
|
||||
{ group: "Companies", items: [
|
||||
{ href: "/admin/company-coverage", label: "Company Coverage" },
|
||||
{ href: "/admin/company-tracker", label: "Company Tracker" },
|
||||
]},
|
||||
{ group: "Newsletter", items: [
|
||||
{ href: "/admin/newsletter", label: "Newsletter" },
|
||||
{ href: "/admin/newsletter/templates", label: "Templates" },
|
||||
{ href: "/admin/newsletter/campaign-editor", label: "Campaign Editor" },
|
||||
{ href: "/admin/newsletter/analytics", label: "Analytics" },
|
||||
{ href: "/admin/email-analytics", label: "Email Analytics" },
|
||||
]},
|
||||
{ group: "AdOps", items: [
|
||||
{ href: "/admin/adops", label: "AdOps" },
|
||||
{ href: "/admin/adops/products", label: "Products" },
|
||||
{ href: "/admin/adops/ios", label: "IOs" },
|
||||
{ href: "/admin/adops/notifications", label: "AdOps Notifs" },
|
||||
{ href: "/admin/banners", label: "Banners" },
|
||||
]},
|
||||
{ group: "Accounting", items: [
|
||||
{ href: "/admin/accounting", label: "Accounting" },
|
||||
{ href: "/admin/accounting/clients", label: "Clients" },
|
||||
{ href: "/admin/accounting/invoices", label: "Invoices" },
|
||||
{ href: "/admin/accounting/expenses", label: "Expenses" },
|
||||
{ href: "/admin/accounting/reports", label: "Reports" },
|
||||
]},
|
||||
{ group: "People", items: [
|
||||
{ href: "/admin/users", label: "Users" },
|
||||
{ href: "/admin/wp-users", label: "WP Users" },
|
||||
{ href: "/admin/roles", label: "Roles" },
|
||||
{ href: "/admin/suspensions", label: "Suspensions" },
|
||||
]},
|
||||
];
|
||||
|
||||
export default function AdminShell({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
// Close drawer on route change
|
||||
useEffect(() => { setOpen(false); }, [pathname]);
|
||||
// Close drawer with Escape
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
// Lock body scroll while drawer open
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sticky top bar */}
|
||||
<header className="sticky top-0 z-40 bg-[#0a0a0a] border-b border-[#222]">
|
||||
<div className="max-w-7xl mx-auto px-4 h-12 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Link
|
||||
href="/admin"
|
||||
className="text-[10px] font-mono uppercase tracking-widest text-[#1D4ED8] hover:text-white transition-colors flex-shrink-0"
|
||||
>
|
||||
AV BEAT · ADMIN
|
||||
</Link>
|
||||
{title && (
|
||||
<>
|
||||
<span className="text-[#333]">/</span>
|
||||
<span className="font-body font-semibold text-white truncate">{title}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-label={open ? "Close menu" : "Open menu"}
|
||||
aria-expanded={open}
|
||||
aria-controls="admin-drawer"
|
||||
className="-mr-2 p-2 text-white hover:text-[#1D4ED8] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1D4ED8] rounded"
|
||||
>
|
||||
{open ? (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<path strokeLinecap="round" d="M6 6l12 12M6 18L18 6" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<path strokeLinecap="round" d="M4 7h16M4 12h16M4 17h16" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Drawer overlay */}
|
||||
{open && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
onClick={() => setOpen(false)}
|
||||
className="fixed inset-0 z-30 bg-black/60 backdrop-blur-sm"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Drawer */}
|
||||
<nav
|
||||
id="admin-drawer"
|
||||
aria-label="Admin sections"
|
||||
aria-hidden={!open}
|
||||
className={`fixed top-12 right-0 bottom-0 z-40 w-[280px] max-w-[85vw] bg-[#0a0a0a] border-l border-[#222] overflow-y-auto transform transition-transform duration-200 ${
|
||||
open ? "translate-x-0" : "translate-x-full pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
<div className="py-3">
|
||||
{SECTIONS.map((sec) => (
|
||||
<div key={sec.group} className="mb-2">
|
||||
<div className="px-4 py-1.5 text-[10px] font-mono font-bold text-[#1D4ED8] uppercase tracking-widest">
|
||||
{sec.group}
|
||||
</div>
|
||||
{sec.items.map((item) => {
|
||||
const active = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`block px-4 py-2.5 text-sm font-body transition-colors ${
|
||||
active
|
||||
? "bg-[#1D4ED8]/10 text-[#1D4ED8] font-semibold"
|
||||
: "text-[#cbd5e1] hover:bg-[#FFFFFF] hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
<div className="border-t border-[#222] mt-2 pt-2">
|
||||
<Link
|
||||
href="/"
|
||||
className="block px-4 py-2.5 text-sm font-body text-[#888] hover:text-[#1D4ED8] transition-colors"
|
||||
>
|
||||
← Back to site
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user