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 }) {
-
+
{sel.platforms.includes("facebook") && }
{sel.platforms.includes("instagram") && item.format !== "STORY" && set("collaborators", e.target.value)} />}
diff --git a/dashboard/web/src/pages/Publishing.jsx b/dashboard/web/src/pages/Publishing.jsx
index 477e927..c40b699 100644
--- a/dashboard/web/src/pages/Publishing.jsx
+++ b/dashboard/web/src/pages/Publishing.jsx
@@ -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" && (
- {f.media.map((m, i) =>
{i + 1}. {m.split(/[\\/]/).pop().replace(/^\d+-/, "")} )}
+ {f.media.map((m, i) => (
+
+ {/\.(mp4|mov|m4v|webm)$/i.test(m)
+ ?
+ :
}
+ {i + 1}. {m.split(/[\\/]/).pop().replace(/^\d+-/, "").slice(0, 24)}
+
+
+ ))}