mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* 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>
263 lines
7.5 KiB
TypeScript
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>
|
|
);
|
|
}
|