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:
2026-06-04 23:06:14 +00:00
parent a8dc457742
commit 45cf96a203
4 changed files with 490 additions and 0 deletions

View File

@@ -6,6 +6,7 @@ import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import AppImage from '@/components/ui/AppImage'; import AppImage from '@/components/ui/AppImage';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import AdminShell from '@/components/admin/AdminShell';
const RichTextEditor = dynamic(() => import('@/components/RichTextEditor'), { ssr: false }); const RichTextEditor = dynamic(() => import('@/components/RichTextEditor'), { ssr: false });
@@ -390,6 +391,7 @@ export default function AdminArticlesPage() {
} }
return ( return (
<AdminShell title="Articles">
<div className="min-h-screen bg-[#0a0a0a] text-[#cccccc]"> <div className="min-h-screen bg-[#0a0a0a] text-[#cccccc]">
{/* Notification */} {/* Notification */}
{notification && ( {notification && (
@@ -748,6 +750,19 @@ export default function AdminArticlesPage() {
</svg> </svg>
</button> </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) */} {/* Submit for Review (draft → pending) */}
{article.status === 'draft' && ( {article.status === 'draft' && (
<button <button
@@ -1102,5 +1117,6 @@ export default function AdminArticlesPage() {
)} )}
</div> </div>
</div> </div>
</AdminShell>
); );
} }

View File

@@ -4,6 +4,7 @@ import React, { useState, useEffect, useCallback } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import AdminShell from '@/components/admin/AdminShell';
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -249,6 +250,7 @@ export default function AdminDashboardPage() {
if (!user) return null; if (!user) return null;
return ( return (
<AdminShell title="Dashboard">
<div className="min-h-screen bg-[#0a0a0a]"> <div className="min-h-screen bg-[#0a0a0a]">
<div className="max-w-7xl mx-auto px-4 py-8"> <div className="max-w-7xl mx-auto px-4 py-8">
@@ -615,5 +617,6 @@ export default function AdminDashboardPage() {
</div> </div>
</div> </div>
</div> </div>
</AdminShell>
); );
} }

View 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>
);
}

View 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}
</>
);
}