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 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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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)} />
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user