From 45cf96a203f8cc4235e241072d73c692b25e29b0 Mon Sep 17 00:00:00 2001 From: Local Administrator Date: Thu, 4 Jun 2026 23:06:14 +0000 Subject: [PATCH] feat(admin): mobile-friendly admin shell + promote-on-social MVP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile admin chrome - New 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/. 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) --- src/app/admin/articles/page.tsx | 16 ++ src/app/admin/page.tsx | 3 + src/app/admin/promote/[slug]/page.tsx | 269 ++++++++++++++++++++++++++ src/components/admin/AdminShell.tsx | 202 +++++++++++++++++++ 4 files changed, 490 insertions(+) create mode 100644 src/app/admin/promote/[slug]/page.tsx create mode 100644 src/components/admin/AdminShell.tsx diff --git a/src/app/admin/articles/page.tsx b/src/app/admin/articles/page.tsx index ff55990..3b27bd9 100644 --- a/src/app/admin/articles/page.tsx +++ b/src/app/admin/articles/page.tsx @@ -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 ( +
{/* Notification */} {notification && ( @@ -748,6 +750,19 @@ export default function AdminArticlesPage() { + {/* Promote — opens /admin/promote/ share sheet */} + {article.status === 'published' && (article.slug || article.wp_slug) && ( + + + + + + + )} + {/* Submit for Review (draft → pending) */} {article.status === 'draft' && (
+
); } diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index fd45f8c..0bda404 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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 ( +
@@ -615,5 +617,6 @@ export default function AdminDashboardPage() {
+
); } diff --git a/src/app/admin/promote/[slug]/page.tsx b/src/app/admin/promote/[slug]/page.tsx new file mode 100644 index 0000000..b51459f --- /dev/null +++ b/src/app/admin/promote/[slug]/page.tsx @@ -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
(null); + const [err, setErr] = useState(null); + const [copied, setCopied] = useState(null); + const [caption, setCaption] = useState(""); + + 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 ( + +
+ {err ? ( +

{err}

+ ) : ( +
+
+

Loading article…

+
+ )} +
+ + ); + } + + 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 ( + +
+
+ {/* Article preview */} +
+ {article.featured_image && ( + + )} +
+

+ Promoting +

+

+ {article.title} +

+ {article.excerpt && ( +

+ {strip(article.excerpt)} +

+ )} + + {article.url} + +
+
+ + {/* Caption editor — copy-pastable for any platform */} +
+ +