feat: Social page — aggregated post queues + X/video history (#345)

* feat(api): x/video post history endpoints

Approved or rejected drafts vanished from both queues permanently --
the listers exclude terminal statuses and no history surface existed,
so a posted tweet or video was only findable in the raw task list.
GET /x/posts/history and GET /video/posts/history (CEO-gated, bounded)
return acted-on drafts newest-first with the posted platform ids and
reject reasons from the draft markers. Route tests assert by identity,
not emptiness: approve/reject commits the whole session, so prior
tests' rows legitimately persist in the shared test DB.

* feat(panel): Social page aggregating post queues and history

New dashboard page composing the X and video post queues with one
unified history section beneath them -- both platforms interleaved
newest-first, kind and outcome badges, posted X ids linking to the
live tweet, reject reasons shown. The command center's two full queue
cards become a compact pending-counts card linking to the page, so the
queues have one home instead of duplicated surfaces.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-09 00:44:51 +02:00
committed by GitHub
co-authored by Renn F
parent 5886336259
commit f0b6390189
26 changed files with 1289 additions and 30 deletions
@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { XPostHistoryEntry } from "@/lib/api/x";
import type { VideoPostHistoryEntry } from "@/lib/api/video";
// The queues have their own dedicated test files (x-post-queue.test.tsx,
// video-post-queue.test.tsx) — stub them here so this page test only checks
// composition + the unified history, not their internals.
vi.mock("@/components/dashboard/x-post-queue", () => ({
XPostQueue: () => <div>XPostQueueStub</div>,
}));
vi.mock("@/components/dashboard/video-post-queue", () => ({
VideoPostQueue: () => <div>VideoPostQueueStub</div>,
}));
const { listXHistory, listVideoHistory } = vi.hoisted(() => ({
listXHistory: vi.fn(
async () =>
[
{
task_id: "x-9",
source: "x_post",
title: "X post: release v0.16.0",
status: "completed",
body: "RoboCo v0.16.0 shipped!",
char_count: 23,
tweet_id: "555",
acted_at: "2026-07-01T00:00:00Z",
},
] as XPostHistoryEntry[],
),
listVideoHistory: vi.fn(
async () =>
[
{
task_id: "v-9",
source: "video_post",
title: "Video: spotlight",
status: "cancelled",
occasion: "spotlight",
script: "script",
platforms: ["tiktok"],
posted: {},
reject_reason: "off-brand",
acted_at: "2026-06-30T00:00:00Z",
},
] as VideoPostHistoryEntry[],
),
}));
vi.mock("@/lib/api", () => ({
xApi: { listHistory: listXHistory },
videoApi: { listHistory: listVideoHistory },
}));
import SocialPage from "../page";
function withQueryClient(
client = new QueryClient({
defaultOptions: { queries: { retry: false } },
}),
) {
return { client };
}
describe("SocialPage", () => {
beforeEach(() => {
listXHistory.mockClear();
listVideoHistory.mockClear();
});
afterEach(() => {
vi.clearAllMocks();
});
it("renders the page title and both queues", () => {
const { client } = withQueryClient();
render(
<QueryClientProvider client={client}>
<SocialPage />
</QueryClientProvider>,
);
expect(screen.getByRole("heading", { name: "Social" })).toBeInTheDocument();
expect(screen.getByText("XPostQueueStub")).toBeInTheDocument();
expect(screen.getByText("VideoPostQueueStub")).toBeInTheDocument();
});
it("renders the unified history collapsed by default, then shows posted + rejected rows on expand", async () => {
const { client } = withQueryClient();
render(
<QueryClientProvider client={client}>
<SocialPage />
</QueryClientProvider>,
);
expect(screen.getByText("History")).toBeInTheDocument();
expect(listXHistory).not.toHaveBeenCalled();
expect(listVideoHistory).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: /Show history/ }));
await waitFor(() => expect(listXHistory).toHaveBeenCalled());
await waitFor(() => expect(listVideoHistory).toHaveBeenCalled());
expect(await screen.findByText("Posted")).toBeInTheDocument();
expect(screen.getByText("Rejected")).toBeInTheDocument();
expect(screen.getByText(/off-brand/)).toBeInTheDocument();
});
});
+23
View File
@@ -0,0 +1,23 @@
"use client";
import { XPostQueue } from "@/components/dashboard/x-post-queue";
import { VideoPostQueue } from "@/components/dashboard/video-post-queue";
import { SocialHistorySection } from "@/components/dashboard/social-history-section";
export default function SocialPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Social</h1>
<p className="text-muted-foreground">
X and video drafts awaiting your approval, plus everything already
posted or rejected.
</p>
</div>
<XPostQueue />
<VideoPostQueue />
<SocialHistorySection />
</div>
);
}
@@ -0,0 +1,91 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
// Shallow-render the composition: mock every hook + child component so this
// test only checks that the social summary card replaced the two full X/
// video queues — each child's own behavior is covered by its own test file.
vi.mock("@/hooks/use-dashboard", () => ({
useCeoOverview: () => ({
data: undefined,
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
useAuditorFlags: () => ({
data: undefined,
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
useRecentActivity: () => ({
data: undefined,
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
}));
vi.mock("@/hooks/use-tasks", () => ({
useTasks: () => ({
data: undefined,
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
}));
vi.mock("../team-health-cards", () => ({
TeamHealthCards: () => <div>TeamHealthCardsStub</div>,
}));
vi.mock("../key-metrics-panel", () => ({
KeyMetricsPanel: () => <div>KeyMetricsPanelStub</div>,
}));
vi.mock("../auditor-alerts-panel", () => ({
AuditorAlertsPanel: () => <div>AuditorAlertsPanelStub</div>,
}));
vi.mock("../active-blockers-panel", () => ({
ActiveBlockersPanel: () => <div>ActiveBlockersPanelStub</div>,
}));
vi.mock("../recent-activity-feed", () => ({
RecentActivityFeed: () => <div>RecentActivityFeedStub</div>,
}));
vi.mock("../quick-actions-bar", () => ({
QuickActionsBar: () => <div>QuickActionsBarStub</div>,
}));
vi.mock("../ceo-approval-queue", () => ({
CeoApprovalQueue: () => <div>CeoApprovalQueueStub</div>,
}));
vi.mock("../pr-review-queue", () => ({
PrReviewQueue: () => <div>PrReviewQueueStub</div>,
}));
vi.mock("../release-proposal-card", () => ({
ReleaseProposalCard: () => <div>ReleaseProposalCardStub</div>,
}));
vi.mock("../playbook-review-queue", () => ({
PlaybookReviewQueue: () => <div>PlaybookReviewQueueStub</div>,
}));
vi.mock("../social-summary-card", () => ({
SocialSummaryCard: () => <div>SocialSummaryCardStub</div>,
}));
vi.mock("../roadmap-review-queue", () => ({
RoadmapReviewQueue: () => <div>RoadmapReviewQueueStub</div>,
}));
vi.mock("../strategy-signals-panel", () => ({
StrategySignalsPanel: () => <div>StrategySignalsPanelStub</div>,
}));
vi.mock("../usage-overview-panel", () => ({
UsageOverviewPanel: () => <div>UsageOverviewPanelStub</div>,
}));
vi.mock("../scorecard-overview-panel", () => ({
ScorecardOverviewPanel: () => <div>ScorecardOverviewPanelStub</div>,
}));
import { CommandCenter } from "../command-center";
describe("CommandCenter", () => {
it("renders the social summary card instead of the full X/video queues", () => {
render(<CommandCenter />);
expect(screen.getByText("SocialSummaryCardStub")).toBeInTheDocument();
expect(screen.queryByText("XPostQueueStub")).not.toBeInTheDocument();
expect(screen.queryByText("VideoPostQueueStub")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { XPostHistoryEntry } from "@/lib/api/x";
import type { VideoPostHistoryEntry } from "@/lib/api/video";
const { listXHistory, listVideoHistory } = vi.hoisted(() => ({
listXHistory: vi.fn(
async () =>
[
{
task_id: "x-9",
source: "x_post",
title: "X post: release v0.16.0",
status: "completed",
body: "RoboCo v0.16.0 shipped!",
char_count: 23,
tweet_id: "555",
acted_at: "2026-07-01T00:00:00Z",
},
{
task_id: "x-10",
source: "x_reply",
title: "X reply: mention m2",
status: "cancelled",
body: "Not our voice",
char_count: 13,
reject_reason: "tone mismatch",
acted_at: "2026-06-29T00:00:00Z",
},
] as XPostHistoryEntry[],
),
listVideoHistory: vi.fn(
async () =>
[
{
task_id: "v-9",
source: "video_post",
title: "Video: release v0.18.0",
status: "completed",
occasion: "release",
script: "old script",
platforms: ["x", "tiktok"],
posted: { x: "xid777", tiktok: "tt-abc" },
acted_at: "2026-06-30T00:00:00Z",
},
] as VideoPostHistoryEntry[],
),
}));
vi.mock("@/lib/api", () => ({
xApi: { listHistory: listXHistory },
videoApi: { listHistory: listVideoHistory },
}));
import { SocialHistorySection } from "../social-history-section";
function withQueryClient(ui: ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
}
describe("SocialHistorySection", () => {
beforeEach(() => {
listXHistory.mockClear();
listVideoHistory.mockClear();
});
afterEach(() => {
vi.clearAllMocks();
});
it("is collapsed by default and fetches neither source", async () => {
render(withQueryClient(<SocialHistorySection />));
expect(await screen.findByText("History")).toBeInTheDocument();
expect(listXHistory).not.toHaveBeenCalled();
expect(listVideoHistory).not.toHaveBeenCalled();
expect(screen.queryByText("tone mismatch")).not.toBeInTheDocument();
});
it("lazy-fetches both sources on expand and interleaves them newest-first", async () => {
render(withQueryClient(<SocialHistorySection />));
fireEvent.click(screen.getByRole("button", { name: /Show history/ }));
await waitFor(() => expect(listXHistory).toHaveBeenCalledWith(50));
await waitFor(() => expect(listVideoHistory).toHaveBeenCalledWith(50));
expect(await screen.findByText("X post")).toBeInTheDocument();
expect(screen.getByText("Video")).toBeInTheDocument();
expect(screen.getByText("X reply")).toBeInTheDocument();
// Newest acted_at first: x-9 (07-01) > video v-9 (06-30) > x-10 (06-29).
const rows = screen.getAllByText(/^(X post|X reply|Video)$/);
expect(rows.map((el) => el.textContent)).toEqual([
"X post",
"Video",
"X reply",
]);
});
it("renders a posted X row linking the tweet and a rejected row with the reason", async () => {
render(withQueryClient(<SocialHistorySection />));
fireEvent.click(screen.getByRole("button", { name: /Show history/ }));
await screen.findByText("X post");
const link = screen.getByRole("link", { name: "View on X" });
expect(link).toHaveAttribute("href", "https://x.com/i/status/555");
expect(screen.getByText(/tone mismatch/)).toBeInTheDocument();
});
it("renders a posted video row with both platform ids (X links out, TikTok shows the raw id)", async () => {
render(withQueryClient(<SocialHistorySection />));
fireEvent.click(screen.getByRole("button", { name: /Show history/ }));
await screen.findByText("Video");
const videoLink = screen.getByRole("link", { name: /xid777/ });
expect(videoLink).toHaveAttribute("href", "https://x.com/i/status/xid777");
expect(screen.getByText(/tt-abc/)).toBeInTheDocument();
});
it("shows an empty state when neither source has acted-on drafts", async () => {
listXHistory.mockResolvedValueOnce([]);
listVideoHistory.mockResolvedValueOnce([]);
render(withQueryClient(<SocialHistorySection />));
fireEvent.click(screen.getByRole("button", { name: /Show history/ }));
expect(
await screen.findByText("No acted-on drafts yet."),
).toBeInTheDocument();
});
});
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { XPost } from "@/lib/api/x";
import type { VideoPost } from "@/lib/api/video";
const { listXPosts, listVideoPosts } = vi.hoisted(() => ({
listXPosts: vi.fn(
async () =>
[
{
task_id: "x-1",
source: "x_post",
title: "X post",
status: "pending",
body: "body",
char_count: 4,
},
{
task_id: "x-2",
source: "x_reply",
title: "X reply",
status: "pending",
body: "body2",
char_count: 5,
},
] as XPost[],
),
listVideoPosts: vi.fn(
async () =>
[
{
task_id: "v-1",
source: "video_post",
title: "Video",
status: "pending",
occasion: "release",
script: "script",
platforms: ["x"],
},
] as VideoPost[],
),
}));
vi.mock("@/lib/api", () => ({
xApi: { listPosts: listXPosts },
videoApi: { listPosts: listVideoPosts },
}));
import { SocialSummaryCard } from "../social-summary-card";
function withQueryClient(ui: ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
}
describe("SocialSummaryCard", () => {
beforeEach(() => {
listXPosts.mockClear();
listVideoPosts.mockClear();
});
afterEach(() => {
vi.clearAllMocks();
});
it("shows the pending draft counts for both X and video and a total badge", async () => {
render(withQueryClient(<SocialSummaryCard />));
expect(await screen.findByText("2 X drafts")).toBeInTheDocument();
expect(screen.getByText("1 video draft")).toBeInTheDocument();
expect(screen.getByText("3")).toBeInTheDocument();
});
it("links to /social", async () => {
render(withQueryClient(<SocialSummaryCard />));
await screen.findByText("2 X drafts");
const link = screen.getByRole("link", { name: /Open Social/ });
expect(link).toHaveAttribute("href", "/social");
});
it("renders zero counts and no total badge when both queues are empty", async () => {
listXPosts.mockResolvedValueOnce([]);
listVideoPosts.mockResolvedValueOnce([]);
render(withQueryClient(<SocialSummaryCard />));
expect(await screen.findByText("0 X drafts")).toBeInTheDocument();
expect(screen.getByText("0 video drafts")).toBeInTheDocument();
expect(screen.queryByText("0")).not.toBeInTheDocument();
});
});
@@ -68,7 +68,9 @@ describe("XPostQueue", () => {
render(withQueryClient(<XPostQueue />));
expect(await screen.findByText("Release post")).toBeInTheDocument();
expect(await screen.findByText("Mention reply")).toBeInTheDocument();
expect(screen.getByDisplayValue("RoboCo v0.17.0 just shipped!")).toBeInTheDocument();
expect(
screen.getByDisplayValue("RoboCo v0.17.0 just shipped!"),
).toBeInTheDocument();
});
it("renders a feature-spotlight draft with its own label and badge", async () => {
@@ -103,13 +105,20 @@ describe("XPostQueue", () => {
fireEvent.click(approveButtons[0]);
await waitFor(() =>
expect(approve).toHaveBeenCalledWith("x-1", "RoboCo v0.17.0 just shipped!"),
expect(approve).toHaveBeenCalledWith(
"x-1",
"RoboCo v0.17.0 just shipped!",
),
);
await waitFor(() => expect(approveButtons[0]).toBeDisabled());
expect(approveButtons[1]).not.toBeDisabled();
resolveApproveRef.current?.({ status: "posted", tweet_id: "1", detail: "ok" });
resolveApproveRef.current?.({
status: "posted",
tweet_id: "1",
detail: "ok",
});
await waitFor(() => expect(approveButtons[0]).not.toBeDisabled());
});
@@ -16,8 +16,7 @@ import { CeoApprovalQueue } from "./ceo-approval-queue";
import { PrReviewQueue } from "./pr-review-queue";
import { ReleaseProposalCard } from "./release-proposal-card";
import { PlaybookReviewQueue } from "./playbook-review-queue";
import { XPostQueue } from "./x-post-queue";
import { VideoPostQueue } from "./video-post-queue";
import { SocialSummaryCard } from "./social-summary-card";
import { RoadmapReviewQueue } from "./roadmap-review-queue";
import { StrategySignalsPanel } from "./strategy-signals-panel";
import type { Activity } from "./activity-item";
@@ -121,14 +120,10 @@ export function CommandCenter() {
<PlaybookReviewQueue />
</div>
{/* X post/reply queue (hidden when no drafts) */}
{/* Social (X + video) summary the full queues + unified history live
on /social, avoiding a duplicated surface here. */}
<div className="order-4 md:order-none">
<XPostQueue />
</div>
{/* Video post queue (always visible — carries the on-demand request action) */}
<div className="order-4 md:order-none">
<VideoPostQueue />
<SocialSummaryCard />
</div>
{/* Board roadmap queue (hidden when no cycle authored) */}
@@ -0,0 +1,208 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { xApi, videoApi } from "@/lib/api";
import type { XPostHistoryEntry } from "@/lib/api/x";
import type { VideoPostHistoryEntry } from "@/lib/api/video";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
AtSign,
ChevronDown,
Film,
History,
Rocket,
Sparkles,
} from "lucide-react";
const HISTORY_LIMIT = 50;
const PLATFORM_LABELS: Record<string, string> = { x: "X", tiktok: "TikTok" };
type UnifiedRow =
| { kind: "x"; entry: XPostHistoryEntry }
| { kind: "video"; entry: VideoPostHistoryEntry };
function xKindMeta(source: XPostHistoryEntry["source"]) {
if (source === "x_post") return { label: "X post", icon: Rocket };
if (source === "x_feature")
return { label: "Feature spotlight", icon: Sparkles };
return { label: "X reply", icon: AtSign };
}
// One unified row: X entries link the posted tweet / show the reject reason;
// video entries list each platform's posted id (X id links out, TikTok's
// inbox upload has no public URL so just the raw id is shown).
function UnifiedHistoryRow({ row }: { row: UnifiedRow }) {
const posted = row.entry.status === "completed";
const statusBadge = posted ? (
<Badge className="bg-green-600 hover:bg-green-600">Posted</Badge>
) : (
<Badge variant="destructive">Rejected</Badge>
);
const timestamp = (
<span className="ml-auto text-xs text-muted-foreground">
{new Date(row.entry.acted_at).toLocaleString()}
</span>
);
if (row.kind === "x") {
const meta = xKindMeta(row.entry.source);
return (
<div className="rounded-lg border p-3 text-sm">
<div className="mb-1 flex flex-wrap items-center gap-2">
<meta.icon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{meta.label}</span>
{statusBadge}
{timestamp}
</div>
<p className="line-clamp-2 text-muted-foreground">{row.entry.body}</p>
{posted && row.entry.tweet_id && (
<a
href={`https://x.com/i/status/${row.entry.tweet_id}`}
target="_blank"
rel="noreferrer"
className="text-xs text-primary underline"
>
View on X
</a>
)}
{!posted && row.entry.reject_reason && (
<p className="mt-1 text-xs text-muted-foreground">
Reason: {row.entry.reject_reason}
</p>
)}
</div>
);
}
const platformIds = Object.entries(row.entry.posted);
return (
<div className="rounded-lg border p-3 text-sm">
<div className="mb-1 flex flex-wrap items-center gap-2">
<Film className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Video</span>
{row.entry.occasion && (
<Badge variant="outline">{row.entry.occasion}</Badge>
)}
{statusBadge}
{timestamp}
</div>
{posted && platformIds.length > 0 && (
<div className="flex flex-wrap gap-3 text-xs">
{platformIds.map(([platform, id]) =>
platform === "x" ? (
<a
key={platform}
href={`https://x.com/i/status/${id}`}
target="_blank"
rel="noreferrer"
className="text-primary underline"
>
{PLATFORM_LABELS[platform] ?? platform}: {id}
</a>
) : (
<span key={platform} className="text-muted-foreground">
{PLATFORM_LABELS[platform] ?? platform}: {id}
</span>
),
)}
</div>
)}
{!posted && row.entry.reject_reason && (
<p className="mt-1 text-xs text-muted-foreground">
Reason: {row.entry.reject_reason}
</p>
)}
</div>
);
}
// Unified, collapsed-by-default history across X and video — merged client-
// side, newest-acted first. Lazy-fetched only once expanded (both sources in
// parallel); fixed 50-row-per-source limit (server default), no "show more".
export function SocialHistorySection({ className }: { className?: string }) {
const [open, setOpen] = useState(false);
const { data: xHistory, isLoading: xLoading } = useQuery({
queryKey: ["x", "posts", "history"],
queryFn: () => xApi.listHistory(HISTORY_LIMIT),
enabled: open,
});
const { data: videoHistory, isLoading: videoLoading } = useQuery({
queryKey: ["video", "posts", "history"],
queryFn: () => videoApi.listHistory(HISTORY_LIMIT),
enabled: open,
});
const isLoading = open && (xLoading || videoLoading);
const rows: UnifiedRow[] = [
...(xHistory ?? []).map((entry): UnifiedRow => ({ kind: "x", entry })),
...(videoHistory ?? []).map((entry): UnifiedRow => ({
kind: "video",
entry,
})),
].sort(
(a, b) =>
new Date(b.entry.acted_at).getTime() -
new Date(a.entry.acted_at).getTime(),
);
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<History className="h-5 w-5" />
History
</CardTitle>
<CardDescription>
Every posted or rejected draft across X and video newest first.
</CardDescription>
</CardHeader>
<CardContent>
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="w-full justify-between"
>
<span>{open ? "Hide" : "Show"} history</span>
<ChevronDown
className={`h-4 w-4 transition-transform ${open ? "rotate-180" : ""}`}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 pt-2">
{isLoading && (
<p className="text-sm text-muted-foreground">Loading...</p>
)}
{!isLoading && rows.length === 0 && (
<p className="text-sm text-muted-foreground">
No acted-on drafts yet.
</p>
)}
{rows.map((row) => (
<UnifiedHistoryRow
key={`${row.kind}-${row.entry.task_id}`}
row={row}
/>
))}
</CollapsibleContent>
</Collapsible>
</CardContent>
</Card>
);
}
@@ -0,0 +1,67 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { xApi, videoApi } from "@/lib/api";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Share2 } from "lucide-react";
// Compact stand-in for the full X/video queues on the command center — the
// full queues (plus the unified history) moved to /social to avoid a
// duplicated surface. Reuses the same queries the queues themselves run
// (["x","posts"] / ["video","posts"]), so react-query dedups the fetch once
// /social's own queues are also mounted.
export function SocialSummaryCard({ className }: { className?: string }) {
const { data: xPosts } = useQuery({
queryKey: ["x", "posts"],
queryFn: () => xApi.listPosts(),
refetchInterval: 30000,
});
const { data: videoPosts } = useQuery({
queryKey: ["video", "posts"],
queryFn: () => videoApi.listPosts(),
refetchInterval: 30000,
});
const xCount = xPosts?.length ?? 0;
const videoCount = videoPosts?.length ?? 0;
const total = xCount + videoCount;
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Share2 className="h-5 w-5" />
Social
{total > 0 && <Badge variant="secondary">{total}</Badge>}
</CardTitle>
<CardDescription>
Held X and video drafts awaiting your approval.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap items-center justify-between gap-3">
<div className="flex gap-4 text-sm text-muted-foreground">
<span>
{xCount} X draft{xCount === 1 ? "" : "s"}
</span>
<span>
{videoCount} video draft{videoCount === 1 ? "" : "s"}
</span>
</div>
<Link href="/social" prefetch={false}>
<Button variant="outline" size="sm">
Open Social
</Button>
</Link>
</CardContent>
</Card>
);
}
@@ -374,7 +374,8 @@ function RequestVideoDialog({
// CEO queue for held video_post drafts (rendered clips from release/
// spotlight/on-demand triggers). Hidden while loading; shows an empty-state
// card (with the on-demand request action) when there are no drafts yet —
// mirrors XPostQueue.
// mirrors XPostQueue. Posted/rejected drafts move to the unified history on
// /social (SocialHistorySection).
export function VideoPostQueue({ className }: { className?: string }) {
const queryClient = useQueryClient();
const [rejecting, setRejecting] = useState<VideoPost | null>(null);
@@ -125,7 +125,8 @@ function XPostRow({
}
// CEO queue for held X drafts (release posts + mention replies). Hidden when
// empty (mirrors the release-proposal + playbook-review queues).
// empty (mirrors the release-proposal + playbook-review queues). Posted/
// rejected drafts move to the unified history on /social (SocialHistorySection).
export function XPostQueue({ className }: { className?: string }) {
const queryClient = useQueryClient();
const [rejecting, setRejecting] = useState<XPost | null>(null);
@@ -197,13 +198,12 @@ export function XPostQueue({ className }: { className?: string }) {
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Rocket className="h-5 w-5" />
X Post Queue
<Rocket className="h-5 w-5" />X Post Queue
</CardTitle>
<CardDescription>
Drafted release announcements, feature spotlights, and mention
replies (if enabled) land here for you to edit, approve, or
reject. Nothing posts on its own.
replies (if enabled) land here for you to edit, approve, or reject.
Nothing posts on its own.
</CardDescription>
</CardHeader>
<CardContent>
@@ -222,8 +222,7 @@ export function XPostQueue({ className }: { className?: string }) {
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Rocket className="h-5 w-5" />
X Post Queue
<Rocket className="h-5 w-5" />X Post Queue
<Badge variant="secondary">{posts.length}</Badge>
</CardTitle>
<CardDescription>
+2
View File
@@ -23,6 +23,7 @@ import {
Sparkles,
Building2,
Radio,
Share2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -32,6 +33,7 @@ export const navItems = [
// Dashboard
{ title: "Overview", href: "/overview", icon: LayoutDashboard },
{ title: "Business", href: "/business", icon: Building2 },
{ title: "Social", href: "/social", icon: Share2 },
// Work Management
{ title: "Tasks", href: "/tasks", icon: ListTodo },
+7 -1
View File
@@ -28,15 +28,21 @@ export type {
XPost,
XMentionRef,
XPostExecuteResult,
XPostHistoryEntry,
XCredentialsStatus,
} from "./x";
export { roadmapApi } from "./roadmap";
export type { RoadmapCycle, RoadmapItem, RoadmapItemActionResult } from "./roadmap";
export type {
RoadmapCycle,
RoadmapItem,
RoadmapItemActionResult,
} from "./roadmap";
export { videoApi, videoMediaUrl } from "./video";
export type {
VideoCut,
VideoPost,
VideoPostExecuteResult,
VideoPostHistoryEntry,
VideoRequestResult,
TikTokCredentialsStatus,
} from "./video";
+26 -4
View File
@@ -30,6 +30,22 @@ export interface VideoPostExecuteResult {
detail: string;
}
// One acted-on draft (posted or rejected) — the CEO's history view.
export interface VideoPostHistoryEntry {
task_id: string;
source: string; // "video_post"
title: string;
status: string; // "completed" | "cancelled"
occasion: string;
script: string;
platforms: string[];
x_caption?: string | null;
tiktok_caption?: string | null;
reject_reason?: string | null;
posted: Record<string, string>; // platform -> posted id
acted_at: string;
}
export interface VideoRequestResult {
status: string; // "opened" | "disabled" | "not_opened"
task_id?: string | null;
@@ -56,6 +72,15 @@ export const videoApi = {
const { data } = await api.get<VideoPost[]>("/video/posts");
return data;
},
// Posted or rejected drafts, newest-acted-first. Fixed default limit (50);
// no "load more" — pass a higher limit if the panel ever needs it.
listHistory: async (limit = 50): Promise<VideoPostHistoryEntry[]> => {
const { data } = await api.get<VideoPostHistoryEntry[]>(
"/video/posts/history",
{ params: { limit } },
);
return data;
},
// Fetches the rendered cut as a Blob via axios (carrying the auth headers
// a plain <video src> GET can't) so the caller can drive <video> off an
// object URL instead of pointing it at the route directly.
@@ -88,10 +113,7 @@ export const videoApi = {
brief: string;
platforms: string[];
}): Promise<VideoRequestResult> => {
const { data } = await api.post<VideoRequestResult>(
"/video/request",
body,
);
const { data } = await api.post<VideoRequestResult>("/video/request", body);
return data;
},
getCredentialsStatus: async (): Promise<TikTokCredentialsStatus> => {
+24
View File
@@ -37,6 +37,22 @@ export interface XPostExecuteResult {
detail: string;
}
// One acted-on draft (posted or rejected) — the CEO's history view.
export interface XPostHistoryEntry {
task_id: string;
source: "x_post" | "x_reply" | "x_feature";
title: string;
status: string; // "completed" | "cancelled"
body: string;
char_count: number;
release_version?: string | null;
mention?: XMentionRef | null;
feature?: XFeatureRef | null;
tweet_id?: string | null;
reject_reason?: string | null;
acted_at: string;
}
export interface XCredentialsStatus {
has_credentials: boolean;
}
@@ -46,6 +62,14 @@ export const xApi = {
const { data } = await api.get<XPost[]>("/x/posts");
return data;
},
// Posted or rejected drafts, newest-acted-first. Fixed default limit (50);
// no "load more" — pass a higher limit if the panel ever needs it.
listHistory: async (limit = 50): Promise<XPostHistoryEntry[]> => {
const { data } = await api.get<XPostHistoryEntry[]>("/x/posts/history", {
params: { limit },
});
return data;
},
approve: async (
taskId: string,
editedBody?: string,