mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(panel): tooltip sweep — projects, products, social, KB, business, settings, notifications
All 34 feature flags get verified one-line tips; secret inputs state the write-only contract; KB index types get canonical descriptions; switch tips ride Labels so Radix data-state stays intact. Also fixes the scorecard SectionLabel swallowing props, which made tooltips on it silently inert.
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import {
|
||||
AtSign,
|
||||
ChevronDown,
|
||||
@@ -30,16 +31,31 @@ import {
|
||||
|
||||
const HISTORY_LIMIT = 50;
|
||||
const PLATFORM_LABELS: Record<string, string> = { x: "X", tiktok: "TikTok" };
|
||||
const VIDEO_HINT =
|
||||
"Rendered by the video pipeline — from a release, a feature spotlight, or an on-demand request";
|
||||
|
||||
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_post")
|
||||
return {
|
||||
label: "X post",
|
||||
icon: Rocket,
|
||||
hint: "Drafted automatically when a release publishes",
|
||||
};
|
||||
if (source === "x_feature")
|
||||
return { label: "Feature spotlight", icon: Sparkles };
|
||||
return { label: "X reply", icon: AtSign };
|
||||
return {
|
||||
label: "Feature spotlight",
|
||||
icon: Sparkles,
|
||||
hint: "Drafted periodically by the Head of Marketing's feature-spotlight sweep",
|
||||
};
|
||||
return {
|
||||
label: "X reply",
|
||||
icon: AtSign,
|
||||
hint: "Drafted automatically in reply to a meaningful mention on X",
|
||||
};
|
||||
}
|
||||
|
||||
// One unified row: X entries link the posted tweet / show the reject reason;
|
||||
@@ -48,9 +64,13 @@ function xKindMeta(source: XPostHistoryEntry["source"]) {
|
||||
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>
|
||||
<HelpTip label="Approved and successfully posted">
|
||||
<Badge className="bg-green-600 hover:bg-green-600">Posted</Badge>
|
||||
</HelpTip>
|
||||
) : (
|
||||
<Badge variant="destructive">Rejected</Badge>
|
||||
<HelpTip label="Rejected — this draft was never posted">
|
||||
<Badge variant="destructive">Rejected</Badge>
|
||||
</HelpTip>
|
||||
);
|
||||
const timestamp = (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
@@ -63,8 +83,12 @@ function UnifiedHistoryRow({ row }: { row: UnifiedRow }) {
|
||||
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>
|
||||
<HelpTip label={meta.hint}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<meta.icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{meta.label}</span>
|
||||
</span>
|
||||
</HelpTip>
|
||||
{statusBadge}
|
||||
{timestamp}
|
||||
</div>
|
||||
@@ -92,10 +116,16 @@ function UnifiedHistoryRow({ row }: { row: UnifiedRow }) {
|
||||
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>
|
||||
<HelpTip label={VIDEO_HINT}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Film className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">Video</span>
|
||||
</span>
|
||||
</HelpTip>
|
||||
{row.entry.occasion && (
|
||||
<Badge variant="outline">{row.entry.occasion}</Badge>
|
||||
<HelpTip label="The occasion/event this video was drafted for">
|
||||
<Badge variant="outline">{row.entry.occasion}</Badge>
|
||||
</HelpTip>
|
||||
)}
|
||||
{statusBadge}
|
||||
{timestamp}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
derivePipelineStage,
|
||||
pipelineStageColor,
|
||||
pipelineStageLabel,
|
||||
type PipelineStage,
|
||||
} from "./video-pipeline-utils";
|
||||
import { RerenderControl } from "@/components/dashboard/video-rerender-control";
|
||||
import {
|
||||
@@ -19,8 +20,27 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { Film } from "lucide-react";
|
||||
|
||||
// Per-stage explanation for the stage chip — lives here (not in
|
||||
// video-pipeline-utils.ts) since that file is pure derivation logic with its
|
||||
// own dedicated unit tests.
|
||||
function stageHint(stage: PipelineStage): string {
|
||||
switch (stage.kind) {
|
||||
case "authoring":
|
||||
return "A developer is building this video's composition";
|
||||
case "in_review":
|
||||
return "The authoring PR is in QA / PR / PM review before assembly";
|
||||
case "awaiting_approval":
|
||||
return "Rendered and waiting on your approval — open the task to review";
|
||||
case "rendering":
|
||||
return "The renderer is producing the 9:16 and 1:1 cuts, retrying on failure";
|
||||
case "render_failed":
|
||||
return "The renderer gave up after all retries — re-render to try again";
|
||||
}
|
||||
}
|
||||
|
||||
// One row: title + occasion + a colored stage chip. The stage chip is
|
||||
// derived (never fetched) from status + render_status/render_attempts —
|
||||
// see video-pipeline-utils.ts, unit-tested directly there. Only the
|
||||
@@ -40,10 +60,16 @@ function PipelineRow({ item }: { item: VideoPipelineItem }) {
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border p-3 text-sm">
|
||||
<Film className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="font-medium">{item.title}</span>
|
||||
{item.occasion && <Badge variant="outline">{item.occasion}</Badge>}
|
||||
<Badge className={`${pipelineStageColor(stage)} text-white`}>
|
||||
{pipelineStageLabel(stage)}
|
||||
</Badge>
|
||||
{item.occasion && (
|
||||
<HelpTip label="The occasion/event this video was drafted for">
|
||||
<Badge variant="outline">{item.occasion}</Badge>
|
||||
</HelpTip>
|
||||
)}
|
||||
<HelpTip label={stageHint(stage)}>
|
||||
<Badge className={`${pipelineStageColor(stage)} text-white`}>
|
||||
{pipelineStageLabel(stage)}
|
||||
</Badge>
|
||||
</HelpTip>
|
||||
{stage.kind === "awaiting_approval" && (
|
||||
<Link
|
||||
href={`/tasks/${item.task_id}`}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { ProjectSelector } from "@/components/projects/project-selector";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import { RerenderControl } from "@/components/dashboard/video-rerender-control";
|
||||
@@ -47,7 +48,21 @@ const REQUEST_PLATFORMS = ["x", "tiktok"] as const;
|
||||
// Only one source reaches this queue today; a function (not a literal)
|
||||
// mirrors XPostQueue's sourceMeta pattern and costs nothing to extend later.
|
||||
function sourceMeta() {
|
||||
return { label: "Video", icon: Film };
|
||||
return {
|
||||
label: "Video",
|
||||
icon: Film,
|
||||
hint: "Rendered by the video pipeline — from a release, a feature spotlight, or an on-demand request",
|
||||
};
|
||||
}
|
||||
|
||||
// Explains what Approve does, or why it's disabled — mirrors x-post-queue's
|
||||
// approveHint. Always non-empty (see that function's comment for why: a
|
||||
// null/string toggle on HelpTip's label unmounts the wrapped child).
|
||||
function approveHint(approving: boolean, overLimit: boolean): string {
|
||||
if (approving) return "Already posting this draft";
|
||||
if (overLimit)
|
||||
return "An edited caption is over its platform's character limit — trim to enable";
|
||||
return "Post this draft to the selected platforms";
|
||||
}
|
||||
|
||||
function describeExecuteResult(result: VideoPostExecuteResult): string {
|
||||
@@ -201,9 +216,17 @@ function VideoPostRow({
|
||||
return (
|
||||
<div className="rounded-lg border p-4 transition-colors hover:bg-muted/50">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<meta.icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{meta.label}</span>
|
||||
{post.occasion && <Badge variant="outline">{post.occasion}</Badge>}
|
||||
<HelpTip label={meta.hint}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<meta.icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{meta.label}</span>
|
||||
</span>
|
||||
</HelpTip>
|
||||
{post.occasion && (
|
||||
<HelpTip label="The occasion/event this video was drafted for">
|
||||
<Badge variant="outline">{post.occasion}</Badge>
|
||||
</HelpTip>
|
||||
)}
|
||||
{canRerender && (
|
||||
<div className="ml-auto">
|
||||
<RerenderControl authoringTaskId={post.source_task_id as string} />
|
||||
@@ -222,30 +245,40 @@ function VideoPostRow({
|
||||
|
||||
<div className="mb-3 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={cut === "vertical" ? "default" : "outline"}
|
||||
disabled={!post.mp4_paths?.vertical}
|
||||
title={
|
||||
post.mp4_paths?.vertical ? undefined : "9:16 hasn't rendered yet"
|
||||
<HelpTip
|
||||
label={
|
||||
post.mp4_paths?.vertical
|
||||
? "Preview the 9:16 cut"
|
||||
: "9:16 hasn't rendered yet"
|
||||
}
|
||||
onClick={() => setCut("vertical")}
|
||||
>
|
||||
9:16{!post.mp4_paths?.vertical && " (missing)"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={cut === "square" ? "default" : "outline"}
|
||||
disabled={!post.mp4_paths?.square}
|
||||
title={
|
||||
post.mp4_paths?.square ? undefined : "1:1 hasn't rendered yet"
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={cut === "vertical" ? "default" : "outline"}
|
||||
disabled={!post.mp4_paths?.vertical}
|
||||
onClick={() => setCut("vertical")}
|
||||
>
|
||||
9:16{!post.mp4_paths?.vertical && " (missing)"}
|
||||
</Button>
|
||||
</HelpTip>
|
||||
<HelpTip
|
||||
label={
|
||||
post.mp4_paths?.square
|
||||
? "Preview the 1:1 cut"
|
||||
: "1:1 hasn't rendered yet"
|
||||
}
|
||||
onClick={() => setCut("square")}
|
||||
>
|
||||
1:1{!post.mp4_paths?.square && " (missing)"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={cut === "square" ? "default" : "outline"}
|
||||
disabled={!post.mp4_paths?.square}
|
||||
onClick={() => setCut("square")}
|
||||
>
|
||||
1:1{!post.mp4_paths?.square && " (missing)"}
|
||||
</Button>
|
||||
</HelpTip>
|
||||
</div>
|
||||
{post.mp4_paths?.[cut] ? (
|
||||
<video
|
||||
@@ -272,9 +305,11 @@ function VideoPostRow({
|
||||
checked={editX}
|
||||
onCheckedChange={(c) => setEditX(c === true)}
|
||||
/>
|
||||
<Label htmlFor={`${post.task_id}-x-edit`} className="text-sm">
|
||||
Edit X caption
|
||||
</Label>
|
||||
<HelpTip label="Uncheck to keep posting the caption already saved on this draft instead of your edit">
|
||||
<Label htmlFor={`${post.task_id}-x-edit`} className="text-sm">
|
||||
Edit X caption
|
||||
</Label>
|
||||
</HelpTip>
|
||||
</div>
|
||||
<Textarea
|
||||
value={xCaption}
|
||||
@@ -283,11 +318,13 @@ function VideoPostRow({
|
||||
rows={2}
|
||||
className={xOverLimit ? "border-destructive" : undefined}
|
||||
/>
|
||||
<p
|
||||
className={`text-right text-xs ${xOverLimit ? "text-destructive" : "text-muted-foreground"}`}
|
||||
>
|
||||
{xCaption.length}/{MAX_X_CAPTION_CHARS}
|
||||
</p>
|
||||
<HelpTip label={`X's per-post character limit (${MAX_X_CAPTION_CHARS})`}>
|
||||
<p
|
||||
className={`text-right text-xs ${xOverLimit ? "text-destructive" : "text-muted-foreground"}`}
|
||||
>
|
||||
{xCaption.length}/{MAX_X_CAPTION_CHARS}
|
||||
</p>
|
||||
</HelpTip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -299,12 +336,14 @@ function VideoPostRow({
|
||||
checked={editTiktok}
|
||||
onCheckedChange={(c) => setEditTiktok(c === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`${post.task_id}-tiktok-edit`}
|
||||
className="text-sm"
|
||||
>
|
||||
Edit TikTok caption
|
||||
</Label>
|
||||
<HelpTip label="Uncheck to keep posting the caption already saved on this draft instead of your edit">
|
||||
<Label
|
||||
htmlFor={`${post.task_id}-tiktok-edit`}
|
||||
className="text-sm"
|
||||
>
|
||||
Edit TikTok caption
|
||||
</Label>
|
||||
</HelpTip>
|
||||
</div>
|
||||
<Textarea
|
||||
value={tiktokCaption}
|
||||
@@ -313,11 +352,15 @@ function VideoPostRow({
|
||||
rows={2}
|
||||
className={tiktokOverLimit ? "border-destructive" : undefined}
|
||||
/>
|
||||
<p
|
||||
className={`text-right text-xs ${tiktokOverLimit ? "text-destructive" : "text-muted-foreground"}`}
|
||||
<HelpTip
|
||||
label={`TikTok's caption character limit (${MAX_TIKTOK_CAPTION_CHARS})`}
|
||||
>
|
||||
{tiktokCaption.length}/{MAX_TIKTOK_CAPTION_CHARS}
|
||||
</p>
|
||||
<p
|
||||
className={`text-right text-xs ${tiktokOverLimit ? "text-destructive" : "text-muted-foreground"}`}
|
||||
>
|
||||
{tiktokCaption.length}/{MAX_TIKTOK_CAPTION_CHARS}
|
||||
</p>
|
||||
</HelpTip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -332,15 +375,17 @@ function VideoPostRow({
|
||||
<XCircle className="mr-1 h-4 w-4" />
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
disabled={approving || overLimit}
|
||||
onClick={handleApprove}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-4 w-4" />
|
||||
Approve & post
|
||||
</Button>
|
||||
<HelpTip label={approveHint(approving, overLimit)}>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
disabled={approving || overLimit}
|
||||
onClick={handleApprove}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-4 w-4" />
|
||||
Approve & post
|
||||
</Button>
|
||||
</HelpTip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -408,6 +453,21 @@ function RequestVideoDialog({
|
||||
brief.trim().length > 0 &&
|
||||
platforms.length > 0;
|
||||
|
||||
// Explains what Request does, or why it's disabled — the four canSubmit
|
||||
// conditions checked in order, plus the in-flight case canSubmit doesn't
|
||||
// cover. Always non-empty (see approveHint's comment for why).
|
||||
const requestHint = requestMutation.isPending
|
||||
? "Request already in flight"
|
||||
: !effectiveProjectId
|
||||
? "Pick a project first"
|
||||
: occasion.trim().length === 0
|
||||
? "Give this video an occasion"
|
||||
: brief.trim().length === 0
|
||||
? "Give this video a brief"
|
||||
: platforms.length === 0
|
||||
? "Pick at least one platform"
|
||||
: "Open a video-authoring task for this brief";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
@@ -476,12 +536,14 @@ function RequestVideoDialog({
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => requestMutation.mutate()}
|
||||
disabled={!canSubmit || requestMutation.isPending}
|
||||
>
|
||||
{requestMutation.isPending ? "Requesting..." : "Request"}
|
||||
</Button>
|
||||
<HelpTip label={requestHint}>
|
||||
<Button
|
||||
onClick={() => requestMutation.mutate()}
|
||||
disabled={!canSubmit || requestMutation.isPending}
|
||||
>
|
||||
{requestMutation.isPending ? "Requesting..." : "Request"}
|
||||
</Button>
|
||||
</HelpTip>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -85,29 +86,45 @@ export function RerenderControl({
|
||||
),
|
||||
});
|
||||
|
||||
// Always non-empty: HelpTip unmounts/remounts its child when `label`
|
||||
// toggles between falsy and truthy, which would lose the button's DOM
|
||||
// identity right as the mutation settles (mirrors x-post-queue's
|
||||
// approveHint comment).
|
||||
const rerenderHint = rerenderMutation.isPending
|
||||
? "Re-render in progress"
|
||||
: rerenderMutation.isError
|
||||
? `Re-render failed: ${
|
||||
rerenderMutation.error instanceof Error
|
||||
? rerenderMutation.error.message
|
||||
: "unknown error"
|
||||
} — click to retry`
|
||||
: "Discards the current render and queues a fresh one from the same composition";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={rerenderMutation.isPending}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
className={
|
||||
rerenderMutation.isError
|
||||
? "border-destructive text-destructive"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-1 h-4 w-4 ${rerenderMutation.isPending ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{rerenderMutation.isPending
|
||||
? "Re-rendering..."
|
||||
: rerenderMutation.isError
|
||||
? "Retry re-render"
|
||||
: "Re-render"}
|
||||
</Button>
|
||||
<HelpTip label={rerenderHint}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={rerenderMutation.isPending}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
className={
|
||||
rerenderMutation.isError
|
||||
? "border-destructive text-destructive"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-1 h-4 w-4 ${rerenderMutation.isPending ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{rerenderMutation.isPending
|
||||
? "Re-rendering..."
|
||||
: rerenderMutation.isError
|
||||
? "Retry re-render"
|
||||
: "Re-render"}
|
||||
</Button>
|
||||
</HelpTip>
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { AtSign, CheckCircle2, Rocket, Sparkles, XCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -30,10 +31,42 @@ const MAX_TWEET_CHARS = 280;
|
||||
const _MIN_REASON_CHARS = 4;
|
||||
|
||||
function sourceMeta(source: XPost["source"]) {
|
||||
if (source === "x_post") return { label: "Release post", icon: Rocket };
|
||||
if (source === "x_post")
|
||||
return {
|
||||
label: "Release post",
|
||||
icon: Rocket,
|
||||
hint: "Drafted automatically when a release publishes",
|
||||
};
|
||||
if (source === "x_feature")
|
||||
return { label: "Feature spotlight", icon: Sparkles };
|
||||
return { label: "Mention reply", icon: AtSign };
|
||||
return {
|
||||
label: "Feature spotlight",
|
||||
icon: Sparkles,
|
||||
hint: "Drafted periodically by the Head of Marketing's feature-spotlight sweep",
|
||||
};
|
||||
return {
|
||||
label: "Mention reply",
|
||||
icon: AtSign,
|
||||
hint: "Drafted automatically in reply to a meaningful mention on X",
|
||||
};
|
||||
}
|
||||
|
||||
// Explains what Approve does, or why it's disabled — surfaced on the button
|
||||
// itself so the CEO doesn't have to guess between "already posting", "over
|
||||
// the limit", or "empty". Always returns a non-empty string (never null):
|
||||
// HelpTip renders a bare child vs. a Tooltip-wrapped one depending on
|
||||
// truthiness, and toggling that branch on a live-changing condition (like
|
||||
// `approving`) unmounts/remounts the child, losing any DOM reference a
|
||||
// caller captured before the state flip.
|
||||
function approveHint(
|
||||
approving: boolean,
|
||||
overLimit: boolean,
|
||||
bodyEmpty: boolean,
|
||||
): string {
|
||||
if (approving) return "Already posting this draft";
|
||||
if (overLimit)
|
||||
return `Over X's ${MAX_TWEET_CHARS}-character limit — trim the draft to enable`;
|
||||
if (bodyEmpty) return "Draft body is empty";
|
||||
return "Post this draft to X";
|
||||
}
|
||||
|
||||
function describeExecuteResult(result: XPostExecuteResult): string {
|
||||
@@ -69,8 +102,12 @@ function XPostRow({
|
||||
return (
|
||||
<div className="rounded-lg border p-4 transition-colors hover:bg-muted/50">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<meta.icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{meta.label}</span>
|
||||
<HelpTip label={meta.hint}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<meta.icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{meta.label}</span>
|
||||
</span>
|
||||
</HelpTip>
|
||||
{post.release_version && (
|
||||
<Badge variant="outline">v{post.release_version}</Badge>
|
||||
)}
|
||||
@@ -92,13 +129,15 @@ function XPostRow({
|
||||
rows={3}
|
||||
className={overLimit ? "border-destructive" : undefined}
|
||||
/>
|
||||
<p
|
||||
className={`mt-1 text-right text-xs ${
|
||||
overLimit ? "text-destructive" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{body.length}/{MAX_TWEET_CHARS}
|
||||
</p>
|
||||
<HelpTip label={`X's per-post character limit (${MAX_TWEET_CHARS})`}>
|
||||
<p
|
||||
className={`mt-1 text-right text-xs ${
|
||||
overLimit ? "text-destructive" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{body.length}/{MAX_TWEET_CHARS}
|
||||
</p>
|
||||
</HelpTip>
|
||||
|
||||
<div className="mt-2 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
|
||||
<Button
|
||||
@@ -110,15 +149,19 @@ function XPostRow({
|
||||
<XCircle className="mr-1 h-4 w-4" />
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
disabled={approving || overLimit || body.trim().length === 0}
|
||||
onClick={() => onApprove(post.task_id, body)}
|
||||
<HelpTip
|
||||
label={approveHint(approving, overLimit, body.trim().length === 0)}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-4 w-4" />
|
||||
Approve & post
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
disabled={approving || overLimit || body.trim().length === 0}
|
||||
onClick={() => onApprove(post.task_id, body)}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-4 w-4" />
|
||||
Approve & post
|
||||
</Button>
|
||||
</HelpTip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user