mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user