nexus/ui/src/components/SocialPostPanel.tsx

172 lines
5.4 KiB
TypeScript

import { useState } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Progress } from "@/components/ui/progress";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useContentJob } from "@/hooks/useContentJob";
import { getContentJobAsset } from "@/api/contentJobs";
import { SocialPostResult } from "./SocialPostResult";
export const PLATFORM_CHAR_LIMITS: Record<string, number> = {
"twitter-x": 280,
"linkedin": 3000,
"instagram-caption": 2200,
"instagram-carousel": 300,
};
const PLATFORM_OPTIONS = [
{ value: "twitter-x", label: "Twitter/X" },
{ value: "linkedin", label: "LinkedIn" },
{ value: "instagram-caption", label: "Instagram Caption" },
{ value: "instagram-carousel", label: "Instagram Carousel" },
];
export type SocialPostBundle = {
type: "social-post-bundle";
platform: string;
post: string;
hashtags: string[];
slides?: string[];
charLimit: number;
};
interface SocialPostPanelProps {
companyId: string;
}
export function SocialPostPanel({ companyId }: SocialPostPanelProps) {
const [prompt, setPrompt] = useState("");
const [platform, setPlatform] = useState("twitter-x");
const [charCount, setCharCount] = useState(0);
const [bundle, setBundle] = useState<SocialPostBundle | null>(null);
const job = useContentJob(companyId);
const charLimit = PLATFORM_CHAR_LIMITS[platform] ?? 280;
const isOverLimit = charCount > charLimit;
const isGenerating = job.status === "queued" || job.status === "running";
const isIdle = job.status === "idle";
async function handleSubmit() {
if (!prompt.trim() || isGenerating) return;
setBundle(null);
await job.submit("social-post", { prompt, platform });
}
function handlePromptChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
setPrompt(e.target.value);
setCharCount(e.target.value.length);
}
// Fetch asset when job completes
if (job.status === "done" && job.resultAssetId && !bundle) {
void getContentJobAsset(companyId, job.resultAssetId).then(async (assetUrl) => {
const res = await fetch(assetUrl);
const text = await res.text();
try {
const parsed = JSON.parse(text) as SocialPostBundle;
setBundle(parsed);
} catch {
// ignore parse error — will show empty state
}
});
}
return (
<Card className="w-full">
<CardHeader>
<CardTitle className="text-xl font-semibold">Generate Post</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label htmlFor="social-prompt" className="text-sm font-medium">
Describe your topic
</label>
<Textarea
id="social-prompt"
rows={4}
placeholder="Describe the topic or paste existing content to adapt..."
value={prompt}
onChange={handlePromptChange}
disabled={isGenerating}
/>
<p
className={`text-xs text-right ${isOverLimit ? "text-destructive" : "text-muted-foreground"}`}
aria-live="polite"
>
{charCount} / {charLimit}{isOverLimit ? " — over limit" : ""}
</p>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="social-platform" className="text-sm font-medium">
Platform
</label>
<Select value={platform} onValueChange={setPlatform} disabled={isGenerating}>
<SelectTrigger id="social-platform" className="w-full">
<SelectValue placeholder="Select platform" />
</SelectTrigger>
<SelectContent>
{PLATFORM_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
type="button"
onClick={() => void handleSubmit()}
disabled={isGenerating || !prompt.trim()}
className="w-full"
>
{isGenerating ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" aria-hidden="true" />
Generating...
</>
) : (
"Generate Post"
)}
</Button>
{isGenerating && (
<Progress
value={job.progress}
role="progressbar"
aria-valuenow={job.progress}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Post generation progress"
/>
)}
{job.status === "failed" && job.errorMessage && (
<p className="text-sm text-destructive">
Generation failed {job.errorMessage}. Try again.
</p>
)}
{bundle ? (
<SocialPostResult bundle={bundle} />
) : isIdle ? (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<p className="text-xl font-semibold">No post yet</p>
<p className="text-sm text-muted-foreground">
Describe your topic and choose a platform to generate a ready-to-publish post.
</p>
</div>
) : null}
</CardContent>
</Card>
);
}