172 lines
5.4 KiB
TypeScript
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>
|
|
);
|
|
}
|