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:
parent
482cf4253e
commit
6dc35c8c8b
@ -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 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). */
|
||||
function TikTokFields({ platform, type, options, setOption, caption }) {
|
||||
function TikTokFields({ platform, type, options, setOption, caption, media = [] }) {
|
||||
const [creator, setCreator] = 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]);
|
||||
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 organic = Boolean(options.discloseCommercial && options.brandOrganic);
|
||||
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>}
|
||||
</>}
|
||||
</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 && (
|
||||
<Field label="How to post">
|
||||
@ -140,8 +164,8 @@ function TikTokFields({ platform, type, options, setOption, caption }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformFields({ platform, type, options, setOption, caption }) {
|
||||
if (platform.id === "tiktok") return <TikTokFields platform={platform} type={type} options={options} setOption={setOption} caption={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} media={media} />;
|
||||
const fields = platform.fields.filter((f) => !f.onlyFor || f.onlyFor.includes(type));
|
||||
const own = options.caption;
|
||||
const limit = platform.limits?.caption;
|
||||
@ -185,7 +209,7 @@ function PlatformFields({ platform, type, options, setOption, caption }) {
|
||||
* value: { platforms: string[], options: { [platform]: {...} } }, onChange(next)
|
||||
* 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 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] });
|
||||
@ -207,7 +231,7 @@ export default function PlatformPicker({ client, type, caption, value, onChange,
|
||||
})}
|
||||
</div>
|
||||
{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>
|
||||
);
|
||||
@ -217,6 +241,9 @@ export default function PlatformPicker({ client, type, caption, value, onChange,
|
||||
export function selectionFor(value, available, type) {
|
||||
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 options = Object.fromEntries(platforms.filter((p) => value.options[p]).map((p) => [p, value.options[p]]));
|
||||
return { platforms, options };
|
||||
const problems = platforms.map((p) => value.options[p]?._problem).filter(Boolean);
|
||||
// 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 };
|
||||
}
|
||||
|
||||
@ -229,6 +229,7 @@ function ScheduleDialog({ item, clientName, onClose, onDone }) {
|
||||
const submit = async () => {
|
||||
setBusy(true); setError(null);
|
||||
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) } });
|
||||
onDone();
|
||||
} catch (e) { setError(e); setBusy(false); }
|
||||
@ -242,7 +243,7 @@ function ScheduleDialog({ item, clientName, onClose, onDone }) {
|
||||
</Field>
|
||||
</div>
|
||||
<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("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} />
|
||||
|
||||
@ -137,7 +137,8 @@ function Compose({ clients, onClose, onSaved }) {
|
||||
const save = async () => {
|
||||
setSaving(true); setError(null);
|
||||
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: {
|
||||
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}`,
|
||||
@ -170,7 +171,15 @@ function Compose({ clients, onClose, onSaved }) {
|
||||
{f.type !== "TEXT" && (
|
||||
<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">
|
||||
{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">
|
||||
<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])} />
|
||||
@ -181,7 +190,7 @@ function Compose({ clients, onClose, onSaved }) {
|
||||
<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)} />
|
||||
</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") && (
|
||||
<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)} />
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user