diff --git a/dashboard/web/src/PlatformPicker.jsx b/dashboard/web/src/PlatformPicker.jsx index 2218753..28eafd5 100644 --- a/dashboard/web/src/PlatformPicker.jsx +++ b/dashboard/web/src/PlatformPicker.jsx @@ -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 }) { {creator.nickname}{creator.username && @{creator.username}} } - {error &&
Couldn't load this TikTok account's settings: {error.message}. Reconnect TikTok on the Connections page.
} + {error &&
{problem} If it says the sign-in expired, reconnect TikTok on the Connections page.
} + {media.length > 0 && ( +
+
Preview of what will be posted
+
+ {media.map((m) => isVideoFile(m) + ?
+ {tooLong &&
{problem}
} +
+ )} {!creator && !error &&
Loading the account's TikTok settings…
} {creator && ( @@ -140,8 +164,8 @@ function TikTokFields({ platform, type, options, setOption, caption }) { ); } -function PlatformFields({ platform, type, options, setOption, caption }) { - if (platform.id === "tiktok") return ; +function PlatformFields({ platform, type, options, setOption, caption, media }) { + if (platform.id === "tiktok") return ; 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, })} {value.platforms.filter((id) => byId[id] && !byId[id].meta && byId[id].formats.includes(type)).map((id) => ( - + ))} ); @@ -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 }; } diff --git a/dashboard/web/src/pages/Library.jsx b/dashboard/web/src/pages/Library.jsx index 6ad97db..2cdc408 100644 --- a/dashboard/web/src/pages/Library.jsx +++ b/dashboard/web/src/pages/Library.jsx @@ -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 }) {