TikTok post screen: media preview, max video length check, can't-post state

- New post shows image/video previews; the TikTok panel shows a preview of what will be posted
- Videos longer than the account's TikTok limit (creator_info) block saving with a clear message
- If TikTok's creator info says the account can't post now, the panel shows TikTok's message and saving is blocked

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-10-22 20:50:34 -04:00
parent 482cf4253e
commit 6dc35c8c8b
3 changed files with 49 additions and 12 deletions

View File

@ -49,12 +49,25 @@ function TargetSelect({ conn, value, onChange, id }) {
const TT_PRIVACY = { PUBLIC_TO_EVERYONE: "Everyone", MUTUAL_FOLLOW_FRIENDS: "Friends", FOLLOWER_OF_CREATOR: "Followers", SELF_ONLY: "Only me" }; const TT_PRIVACY = { PUBLIC_TO_EVERYONE: "Everyone", MUTUAL_FOLLOW_FRIENDS: "Friends", FOLLOWER_OF_CREATOR: "Followers", SELF_ONLY: "Only me" };
const mediaSrc = (m) => (/^https?:/.test(m) ? m : `/api/schedule/media?path=${encodeURIComponent(m)}`);
const isVideoFile = (m) => /\.(mp4|mov|m4v|webm)$/i.test(String(m).split("?")[0]);
const fmtSecs = (s) => `${Math.floor(s / 60)} min${s % 60 ? ` ${Math.round(s % 60)} s` : ""}`;
/** TikTok's post screen, following its Content Posting API guidelines (creator info, no default audience, disclosure, consent). */ /** TikTok's post screen, following its Content Posting API guidelines (creator info, no default audience, disclosure, consent). */
function TikTokFields({ platform, type, options, setOption, caption }) { function TikTokFields({ platform, type, options, setOption, caption, media = [] }) {
const [creator, setCreator] = useState(null); const [creator, setCreator] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [videoSecs, setVideoSecs] = useState(null);
useEffect(() => { api(`/api/connections/${platform.conn.id}/creator-info`).then((r) => setCreator(r.creator)).catch(setError); }, [platform.conn.id]); useEffect(() => { api(`/api/connections/${platform.conn.id}/creator-info`).then((r) => setCreator(r.creator)).catch(setError); }, [platform.conn.id]);
const video = type === "REELS"; const video = type === "REELS";
const firstVideo = media.find(isVideoFile);
useEffect(() => { setVideoSecs(null); }, [firstVideo]);
const tooLong = Boolean(video && creator?.maxVideoSeconds && videoSecs && videoSecs > creator.maxVideoSeconds);
// Problems that must stop the post being saved (read by selectionFor through the _problem option).
const problem = error ? `TikTok says this account can't post right now: ${error.message}`
: tooLong ? `This video is ${fmtSecs(videoSecs)}; this TikTok account can post videos up to ${fmtSecs(creator.maxVideoSeconds)}. Trim it or untick TikTok.`
: undefined;
useEffect(() => { if ((options._problem || undefined) !== problem) setOption("_problem", problem); }, [problem]); // eslint-disable-line react-hooks/exhaustive-deps
const branded = Boolean(options.discloseCommercial && options.brandedContent); const branded = Boolean(options.discloseCommercial && options.brandedContent);
const organic = Boolean(options.discloseCommercial && options.brandOrganic); const organic = Boolean(options.discloseCommercial && options.brandOrganic);
const check = (key, label, disabled, note) => ( const check = (key, label, disabled, note) => (
@ -77,7 +90,18 @@ function TikTokFields({ platform, type, options, setOption, caption }) {
<span className="font-medium">{creator.nickname}</span>{creator.username && <span className="text-muted">@{creator.username}</span>} <span className="font-medium">{creator.nickname}</span>{creator.username && <span className="text-muted">@{creator.username}</span>}
</>} </>}
</div> </div>
{error && <div className="text-[12.5px] text-bad">Couldn't load this TikTok account's settings: {error.message}. Reconnect TikTok on the Connections page.</div>} {error && <div className="text-[12.5px] text-bad">{problem} If it says the sign-in expired, reconnect TikTok on the Connections page.</div>}
{media.length > 0 && (
<div>
<div className="text-[12px] text-muted mb-1.5">Preview of what will be posted</div>
<div className="flex gap-2 overflow-x-auto">
{media.map((m) => isVideoFile(m)
? <video key={m} src={mediaSrc(m)} className="h-40 rounded-md bg-black" controls muted preload="metadata" onLoadedMetadata={(e) => { if (m === firstVideo) setVideoSecs(e.currentTarget.duration); }} />
: <img key={m} src={mediaSrc(m)} alt="" className="h-40 rounded-md object-cover" />)}
</div>
{tooLong && <div className="text-[12.5px] text-bad mt-1.5">{problem}</div>}
</div>
)}
{!creator && !error && <div className="text-[12.5px] text-muted">Loading the account's TikTok settings…</div>} {!creator && !error && <div className="text-[12.5px] text-muted">Loading the account's TikTok settings…</div>}
{creator && ( {creator && (
<Field label="How to post"> <Field label="How to post">
@ -140,8 +164,8 @@ function TikTokFields({ platform, type, options, setOption, caption }) {
); );
} }
function PlatformFields({ platform, type, options, setOption, caption }) { function PlatformFields({ platform, type, options, setOption, caption, media }) {
if (platform.id === "tiktok") return <TikTokFields platform={platform} type={type} options={options} setOption={setOption} caption={caption} />; if (platform.id === "tiktok") return <TikTokFields platform={platform} type={type} options={options} setOption={setOption} caption={caption} media={media} />;
const fields = platform.fields.filter((f) => !f.onlyFor || f.onlyFor.includes(type)); const fields = platform.fields.filter((f) => !f.onlyFor || f.onlyFor.includes(type));
const own = options.caption; const own = options.caption;
const limit = platform.limits?.caption; const limit = platform.limits?.caption;
@ -185,7 +209,7 @@ function PlatformFields({ platform, type, options, setOption, caption }) {
* value: { platforms: string[], options: { [platform]: {...} } }, onChange(next) * value: { platforms: string[], options: { [platform]: {...} } }, onChange(next)
* type: the post format (IMAGE, REELS, CAROUSEL, STORY, TEXT) * type: the post format (IMAGE, REELS, CAROUSEL, STORY, TEXT)
*/ */
export default function PlatformPicker({ client, type, caption, value, onChange, available }) { export default function PlatformPicker({ client, type, caption, value, onChange, available, media = [] }) {
const { loading, list } = available; const { loading, list } = available;
const byId = useMemo(() => Object.fromEntries(list.map((p) => [p.id, p])), [list]); const byId = useMemo(() => Object.fromEntries(list.map((p) => [p.id, p])), [list]);
const toggle = (id) => onChange({ ...value, platforms: value.platforms.includes(id) ? value.platforms.filter((x) => x !== id) : [...value.platforms, id] }); const toggle = (id) => onChange({ ...value, platforms: value.platforms.includes(id) ? value.platforms.filter((x) => x !== id) : [...value.platforms, id] });
@ -207,7 +231,7 @@ export default function PlatformPicker({ client, type, caption, value, onChange,
})} })}
</div> </div>
{value.platforms.filter((id) => byId[id] && !byId[id].meta && byId[id].formats.includes(type)).map((id) => ( {value.platforms.filter((id) => byId[id] && !byId[id].meta && byId[id].formats.includes(type)).map((id) => (
<PlatformFields key={id} platform={byId[id]} type={type} caption={caption} options={value.options[id] || {}} setOption={setOption(id)} /> <PlatformFields key={id} platform={byId[id]} type={type} caption={caption} media={media} options={value.options[id] || {}} setOption={setOption(id)} />
))} ))}
</div> </div>
); );
@ -217,6 +241,9 @@ export default function PlatformPicker({ client, type, caption, value, onChange,
export function selectionFor(value, available, type) { export function selectionFor(value, available, type) {
const ok = new Set(available.list.filter((p) => p.formats.includes(type)).map((p) => p.id)); const ok = new Set(available.list.filter((p) => p.formats.includes(type)).map((p) => p.id));
const platforms = value.platforms.filter((p) => ok.has(p)); const platforms = value.platforms.filter((p) => ok.has(p));
const options = Object.fromEntries(platforms.filter((p) => value.options[p]).map((p) => [p, value.options[p]])); const problems = platforms.map((p) => value.options[p]?._problem).filter(Boolean);
return { platforms, options }; // Keys starting with "_" are screen-only state; don't send them.
const clean = (o) => Object.fromEntries(Object.entries(o).filter(([k]) => !k.startsWith("_")));
const options = Object.fromEntries(platforms.filter((p) => value.options[p]).map((p) => [p, clean(value.options[p])]));
return { platforms, options, problems };
} }

View File

@ -229,6 +229,7 @@ function ScheduleDialog({ item, clientName, onClose, onDone }) {
const submit = async () => { const submit = async () => {
setBusy(true); setError(null); setBusy(true); setError(null);
try { try {
if (chosen.problems.length) throw new Error(chosen.problems[0]);
await api(`/api/library/${item.id}/schedule`, { method: "POST", body: { platforms: chosen.platforms, options: chosen.options, localTime: `${f.date} ${f.time}`, caption: f.caption, fbCaption: f.fbCaption || undefined, collaborators: f.collaborators.split(/[\s,]+/).map((x) => x.replace(/^@/, "")).filter(Boolean) } }); await api(`/api/library/${item.id}/schedule`, { method: "POST", body: { platforms: chosen.platforms, options: chosen.options, localTime: `${f.date} ${f.time}`, caption: f.caption, fbCaption: f.fbCaption || undefined, collaborators: f.collaborators.split(/[\s,]+/).map((x) => x.replace(/^@/, "")).filter(Boolean) } });
onDone(); onDone();
} catch (e) { setError(e); setBusy(false); } } catch (e) { setError(e); setBusy(false); }
@ -242,7 +243,7 @@ function ScheduleDialog({ item, clientName, onClose, onDone }) {
</Field> </Field>
</div> </div>
<Field label="Caption"><textarea className="input h-28" value={f.caption} onChange={(e) => set("caption", e.target.value)} /></Field> <Field label="Caption"><textarea className="input h-28" value={f.caption} onChange={(e) => set("caption", e.target.value)} /></Field>
<Field label="Post to"><PlatformPicker client={item.client} type={item.format} caption={f.caption} value={sel} onChange={setSel} available={available} /></Field> <Field label="Post to"><PlatformPicker client={item.client} type={item.format} caption={f.caption} value={sel} onChange={setSel} available={available} media={item.files} /></Field>
{sel.platforms.includes("facebook") && <Field label="Facebook caption (optional)"><textarea className="input h-16" value={f.fbCaption} onChange={(e) => set("fbCaption", e.target.value)} /></Field>} {sel.platforms.includes("facebook") && <Field label="Facebook caption (optional)"><textarea className="input h-16" value={f.fbCaption} onChange={(e) => set("fbCaption", e.target.value)} /></Field>}
{sel.platforms.includes("instagram") && item.format !== "STORY" && <Field label="Collab invites (optional)"><input className="input" placeholder="@username" value={f.collaborators} onChange={(e) => set("collaborators", e.target.value)} /></Field>} {sel.platforms.includes("instagram") && item.format !== "STORY" && <Field label="Collab invites (optional)"><input className="input" placeholder="@username" value={f.collaborators} onChange={(e) => set("collaborators", e.target.value)} /></Field>}
<ErrorNote error={error} /> <ErrorNote error={error} />

View File

@ -137,7 +137,8 @@ function Compose({ clients, onClose, onSaved }) {
const save = async () => { const save = async () => {
setSaving(true); setError(null); setSaving(true); setError(null);
try { try {
const { platforms, options } = selectionFor(sel, available, f.type); const { platforms, options, problems } = selectionFor(sel, available, f.type);
if (problems.length) throw new Error(problems[0]);
await api("/api/schedule", { method: "POST", body: { await api("/api/schedule", { method: "POST", body: {
client: f.client, platforms, options, type: f.type, media: f.media, caption: f.caption, fbCaption: f.fbCaption || undefined, client: f.client, platforms, options, type: f.type, media: f.media, caption: f.caption, fbCaption: f.fbCaption || undefined,
collaborators: f.collaborators.split(/[\s,]+/).map((x) => x.replace(/^@/, "")).filter(Boolean), localTime: `${f.date} ${f.time}`, collaborators: f.collaborators.split(/[\s,]+/).map((x) => x.replace(/^@/, "")).filter(Boolean), localTime: `${f.date} ${f.time}`,
@ -170,7 +171,15 @@ function Compose({ clients, onClose, onSaved }) {
{f.type !== "TEXT" && ( {f.type !== "TEXT" && (
<Field label="Media" hint="JPG/PNG for images, MP4/MOV for Reels and video stories. Carousel: 2–10 files in order."> <Field label="Media" hint="JPG/PNG for images, MP4/MOV for Reels and video stories. Carousel: 2–10 files in order.">
<div className="flex flex-wrap gap-2 items-center"> <div className="flex flex-wrap gap-2 items-center">
{f.media.map((m, i) => <span key={m} className="text-[12px] bg-canvas border border-line rounded px-2 py-1">{i + 1}. {m.split(/[\\/]/).pop().replace(/^\d+-/, "")} <button className="text-bad ml-1" onClick={() => set("media", f.media.filter((x) => x !== m))}>×</button></span>)} {f.media.map((m, i) => (
<span key={m} className="relative inline-flex flex-col items-center gap-1 text-[11.5px] text-muted">
{/\.(mp4|mov|m4v|webm)$/i.test(m)
? <video src={`/api/schedule/media?path=${encodeURIComponent(m)}`} className="h-24 rounded-md bg-black" muted preload="metadata" />
: <img src={`/api/schedule/media?path=${encodeURIComponent(m)}`} alt="" className="h-24 rounded-md object-cover" />}
<span>{i + 1}. {m.split(/[\\/]/).pop().replace(/^\d+-/, "").slice(0, 24)}</span>
<button type="button" aria-label="Remove file" className="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-white border border-line text-bad leading-none" onClick={() => set("media", f.media.filter((x) => x !== m))}>×</button>
</span>
))}
<label className="inline-flex items-center gap-1.5 h-8 px-3 rounded-md border border-line-strong text-[13px] cursor-pointer hover:bg-canvas"> <label className="inline-flex items-center gap-1.5 h-8 px-3 rounded-md border border-line-strong text-[13px] cursor-pointer hover:bg-canvas">
<Upload className="size-3.5" /> {uploading ? "Uploading…" : "Add files"} <Upload className="size-3.5" /> {uploading ? "Uploading…" : "Add files"}
<input id="c-files" type="file" className="hidden" multiple accept="image/jpeg,image/png,video/mp4,video/quicktime" onChange={(e) => upload([...e.target.files])} /> <input id="c-files" type="file" className="hidden" multiple accept="image/jpeg,image/png,video/mp4,video/quicktime" onChange={(e) => upload([...e.target.files])} />
@ -181,7 +190,7 @@ function Compose({ clients, onClose, onSaved }) {
<Field label="Caption" hint="Plain text works best for search. Hashtags at the end."> <Field label="Caption" hint="Plain text works best for search. Hashtags at the end.">
<textarea id="c-caption" className="input w-full h-28 py-2" value={f.caption} onChange={(e) => set("caption", e.target.value)} /> <textarea id="c-caption" className="input w-full h-28 py-2" value={f.caption} onChange={(e) => set("caption", e.target.value)} />
</Field> </Field>
<Field label="Post to"><PlatformPicker client={f.client} type={f.type} caption={f.caption} value={sel} onChange={setSel} available={available} /></Field> <Field label="Post to"><PlatformPicker client={f.client} type={f.type} caption={f.caption} value={sel} onChange={setSel} available={available} media={f.media} /></Field>
{sel.platforms.includes("facebook") && ( {sel.platforms.includes("facebook") && (
<Field label="Facebook caption (optional)" hint="Leave empty to reuse the Instagram caption."> <Field label="Facebook caption (optional)" hint="Leave empty to reuse the Instagram caption.">
<textarea id="c-fbcaption" className="input w-full h-20 py-2" value={f.fbCaption} onChange={(e) => set("fbCaption", e.target.value)} /> <textarea id="c-fbcaption" className="input w-full h-20 py-2" value={f.fbCaption} onChange={(e) => set("fbCaption", e.target.value)} />