Files
roboco/panel/src/components/tg/approvals/video-post-detail.tsx
T
3c5ee46347 feat(tg): Mini App V6 — premium overhaul (#609)
* feat(tg): Mini App V6 — premium overhaul (design system, Chat parity, Metrics drilldown, CEO verbs)

Design system: native type with tabular-numeral heroes (mono demoted to
the wordmark), borderless elevated cards, floating dock, Telegram
window-chrome painting via the theme bridge; Inbox moves behind a header
bell with humanized notifications (UUIDs resolve to task names).

Chat: honest Mine/Fleet split — participant-scoped CEO threads with real
unread counts and mark-read, watched fleet threads with reply-as-CEO on
task-linked conversations (watch-only otherwise), markdown transcripts,
live pulse flashes, and a pinned Secretary live chat on the panel's SSE
session runtime.

Metrics: new tab with period-segmented spend hero, by-agent/team/model
breakdowns, delivery + efficiency health, and a per-agent drilldown over
usage time-series (agent_slug) + member scorecard.

Board: tg-native grouped pipeline replacing the MobileTaskBoard wrapper;
task sheet gains the CEO decide verbs (approve / request changes /
unblock).

Security: /api/dashboard router now require_panel_token-gated at router
level (mirrors /api/usage), closing unauthenticated metrics exposure.

* fix(tg): restore Share Tech Mono brand voice, Phosphor icon set, borderless avatars

The mono returns as the numeral/brand voice (.tg-display — heroes, stat
values, wordmark) while labels stay native sentence case. The hand-drawn
duotone glyphs and lucide feature icons are replaced by Phosphor (MIT):
duotone at rest via an IconContext at the shell, filled weight on the
dock's active tab; row glyph maps (board statuses, inbox kinds, approval
kinds, quick actions) all move over. Team avatar tiles drop their borders
— tint-only squircles.

* fix(tg): fleet avatar strip breathes — spaced tiles instead of overlap

* polish(tg): taste-skill audit pass — em-dash purge, one icon family, separator rationing

Applied the design-taste audit against the cockpit: every em-dash in
visible UI copy rewritten (periods/commas/colons), the remaining lucide
chrome (carets, arrows, send, close, spinners) moved to Phosphor so the
tg tree ships one icon family (send is the native paper-plane, carets
bold), the hand-rolled chevron SVG deleted, and metadata lines rationed
to a single middle-dot separator.

* polish(tg): pipeline chip strip scrolls without a visible scrollbar

* fix(tests): metrics observability fixture uses a relative timestamp

The hardcoded _T0 (2026-06-20) aged out of the service's 30-day window
exactly 30 days later, detonating the suite on every branch. Two days
back from now() stays inside every window (30d metrics, 7d scorecards)
permanently.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-20 17:29:22 +02:00

263 lines
7.5 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { videoApi, type VideoCut, type VideoPost } from "@/lib/api/video";
import { getErrorMessage } from "@/lib/api/client";
import { haptics } from "@/lib/telegram/webapp";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { PrimaryAction } from "./primary-action";
import { RejectForm } from "./reject-form";
import { cn } from "@/lib/utils";
const MAX_X_CAPTION_CHARS = 280;
const MAX_TIKTOK_CAPTION_CHARS = 2200;
const MIN_REJECT_CHARS = 4;
const CUT_LABELS: Record<VideoCut, string> = {
vertical: "9:16",
square: "1:1",
};
/**
* Rendered-cut player. The media route is authenticated, so a bare
* `<video src>` would 401 — fetch the MP4 as a blob through the authed
* client and play the object URL (the desktop queue's exact pattern).
*/
function CutPlayer({ post }: { post: VideoPost }) {
const paths = post.mp4_paths ?? {};
const [cut, setCut] = useState<VideoCut>(
paths.vertical ? "vertical" : "square",
);
// url === null means the fetch for that cut failed; a stale entry for a
// different cut is simply ignored in render, so no synchronous state
// reset is needed when the cut changes.
const [loaded, setLoaded] = useState<{
cut: VideoCut;
url: string | null;
} | null>(null);
// A stable string dep — depending on the paths object would re-run the
// effect (and refetch the MP4) on every render.
const mediaPath = paths[cut];
useEffect(() => {
if (!mediaPath) return;
let objectUrl: string | null = null;
let cancelled = false;
void videoApi
.getMediaBlob(post.task_id, cut)
.then((blob) => {
if (cancelled) return;
objectUrl = URL.createObjectURL(blob);
setLoaded({ cut, url: objectUrl });
})
.catch(() => {
if (!cancelled) setLoaded({ cut, url: null });
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [post.task_id, cut, mediaPath]);
const src = loaded?.cut === cut ? loaded.url : null;
const failed = loaded?.cut === cut && loaded.url === null;
return (
<div className="space-y-2">
<div className="flex gap-1.5">
{(Object.keys(CUT_LABELS) as VideoCut[]).map((c) => (
<Button
key={c}
size="sm"
variant={cut === c ? "default" : "outline"}
disabled={!paths[c]}
onClick={() => setCut(c)}
>
{CUT_LABELS[c]}
{!paths[c] && " (missing)"}
</Button>
))}
</div>
{src ? (
<video
src={src}
controls
playsInline
className={cn(
"w-full rounded-md bg-black",
cut === "vertical"
? "aspect-[9/16] max-h-[60dvh]"
: "aspect-square",
)}
/>
) : (
<div className="flex aspect-video items-center justify-center rounded-md border border-dashed text-xs text-muted-foreground">
{!mediaPath
? "This cut wasn't rendered"
: failed
? "Preview failed to load"
: "Loading preview…"}
</div>
)}
</div>
);
}
function CaptionEditor({
platform,
stored,
maxChars,
value,
onChange,
}: {
platform: string;
stored: string;
maxChars: number;
value: string | null;
onChange: (v: string | null) => void;
}) {
const editing = value !== null;
const caption = value ?? stored;
const overLimit = caption.length > maxChars;
return (
<div className="space-y-1">
<label className="flex items-center gap-2 text-xs font-medium">
<input
type="checkbox"
checked={editing}
onChange={(e) => onChange(e.target.checked ? stored : null)}
/>
Edit {platform} caption
</label>
<Textarea
value={caption}
disabled={!editing}
onChange={(e) => onChange(e.target.value)}
rows={3}
className={cn("text-sm", editing && overLimit && "border-rose-400/60")}
/>
<p
className={cn(
"text-right text-[11px] tabular-nums",
editing && overLimit ? "text-rose-400" : "text-muted-foreground",
)}
>
{caption.length} / {maxChars}
</p>
</div>
);
}
/** Focused video draft: cut-toggled player, per-platform caption edits,
* then post (X + TikTok as configured) or reject. */
export function VideoPostDetail({
post,
onDone,
}: {
post: VideoPost;
onDone: () => void;
}) {
const queryClient = useQueryClient();
const [xCaption, setXCaption] = useState<string | null>(null);
const [tiktokCaption, setTiktokCaption] = useState<string | null>(null);
const overLimit =
(xCaption !== null && xCaption.length > MAX_X_CAPTION_CHARS) ||
(tiktokCaption !== null && tiktokCaption.length > MAX_TIKTOK_CAPTION_CHARS);
const finish = (ok: boolean, message: string) => {
void queryClient.invalidateQueries({ queryKey: ["video", "posts"] });
if (ok) {
haptics.success();
toast.success(message);
onDone();
} else {
haptics.error();
toast.warning(message);
}
};
const approve = useMutation({
mutationFn: () =>
videoApi.approve(post.task_id, {
...(xCaption !== null && { x_caption: xCaption }),
...(tiktokCaption !== null && { tiktok_caption: tiktokCaption }),
}),
onSuccess: (result) =>
finish(
result.status === "posted" || result.status === "already_posted",
result.status === "posted" ? "Video posted." : result.detail,
),
onError: (err) => {
haptics.error();
toast.error(getErrorMessage(err));
},
});
const reject = useMutation({
mutationFn: (reason: string) => videoApi.reject(post.task_id, reason),
onSuccess: () =>
finish(true, "Draft rejected. Feedback goes back to the author."),
onError: (err) => {
haptics.error();
toast.error(getErrorMessage(err));
},
});
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant="secondary">{post.occasion}</Badge>
{post.platforms.map((p) => (
<Badge key={p} variant="outline">
{p}
</Badge>
))}
{post.render_status === "failed" && (
<span className="rounded-full bg-rose-500/15 px-2 py-0.5 text-xs font-medium text-rose-300">
render failed
</span>
)}
</div>
<CutPlayer post={post} />
{post.platforms.includes("x") && (
<CaptionEditor
platform="X"
stored={post.x_caption ?? ""}
maxChars={MAX_X_CAPTION_CHARS}
value={xCaption}
onChange={setXCaption}
/>
)}
{post.platforms.includes("tiktok") && (
<CaptionEditor
platform="TikTok"
stored={post.tiktok_caption ?? ""}
maxChars={MAX_TIKTOK_CAPTION_CHARS}
value={tiktokCaption}
onChange={setTiktokCaption}
/>
)}
<PrimaryAction
text="Post video"
disabled={overLimit}
loading={approve.isPending}
onClick={() => approve.mutate()}
/>
<RejectForm
minChars={MIN_REJECT_CHARS}
placeholder="What's wrong with this video?"
pending={reject.isPending}
onSubmit={(reason) => reject.mutate(reason)}
/>
</div>
);
}