quorlasocial/dashboard/web/src/PlatformPicker.jsx
metatroncubeswdev 6dc35c8c8b 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>
2026-10-22 20:50:34 -04:00

250 lines
16 KiB
JavaScript

// "Post to" picker shared by New post (Publishing) and Schedule (Content library):
// Instagram/Facebook from the Meta link, plus every platform connected for the client, each with its own fields.
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "./api.js";
import { cx, Field } from "./ui.jsx";
const FORMAT_LABEL = { TEXT: "text", IMAGE: "image", CAROUSEL: "multi-image", REELS: "video", STORY: "story" };
/** Platforms the client can post to: [{ id, name, meta, formats, fields, limits, conn }] */
export function useClientPlatforms(client) {
const [state, setState] = useState({ loading: true, list: [] });
useEffect(() => {
if (!client) { setState({ loading: false, list: [] }); return; }
let live = true;
setState((s) => ({ ...s, loading: true }));
Promise.all([api(`/api/clients/${client}`), api("/api/platforms"), api(`/api/connections?client=${encodeURIComponent(client)}`)])
.then(([c, p, k]) => {
if (!live) return;
const info = c.client;
const list = [];
if (info.igId) list.push({ id: "instagram", name: "Instagram", meta: true, formats: ["IMAGE", "REELS", "CAROUSEL", "STORY"], fields: [], limits: { caption: 2200 } });
if (info.pageId) list.push({ id: "facebook", name: "Facebook", meta: true, formats: ["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"], fields: [], limits: {} });
for (const pl of p.platforms) {
const conn = k.connections.find((x) => x.platform === pl.id && x.status === "connected");
if (conn) list.push({ ...pl, conn });
}
setState({ loading: false, list });
})
.catch(() => live && setState({ loading: false, list: [] }));
return () => { live = false; };
}, [client]);
return state;
}
/** Default selection for a client: its Meta platforms. */
export const defaultPlatforms = (list) => list.filter((p) => p.meta).map((p) => p.id);
function TargetSelect({ conn, value, onChange, id }) {
const [opts, setOpts] = useState(null);
useEffect(() => { api(`/api/connections/${conn.id}/targets`).then((r) => setOpts(r.targets)).catch(() => setOpts([])); }, [conn.id]);
return (
<select id={id} className="input" value={value || ""} onChange={(e) => onChange(e.target.value)}>
<option value="" disabled>{opts === null ? "Loading…" : opts.length ? "Choose…" : "None found"}</option>
{(opts || []).map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
);
}
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, 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) => (
<label htmlFor={`opt-tiktok-${key}`} className={cx("flex items-center gap-2 text-[13px]", disabled && "text-faint")}>
<input id={`opt-tiktok-${key}`} type="checkbox" disabled={disabled} checked={!disabled && Boolean(options[key])} onChange={(e) => setOption(key, e.target.checked)} />
{label}{disabled && note && <span className="text-[12px]">({note})</span>}
</label>
);
const limit = platform.limits?.caption;
const used = (options.caption ?? caption ?? "").length;
return (
<div className="rounded-lg border border-line p-3 space-y-3">
<div className="flex items-center gap-2 text-[13px]">
<span className="size-2.5 rounded-full" style={{ background: platform.color }} aria-hidden="true" />
<span className="font-semibold">TikTok</span>
{creator && <>
<span className="text-muted">· posting to</span>
{creator.avatar && <img src={creator.avatar} alt="" className="size-5 rounded-full" />}
<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">{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">
<select id="opt-tiktok-mode" className="input" value={options.mode || "direct"} onChange={(e) => setOption("mode", e.target.value)}>
<option value="direct">Post directly</option>
<option value="inbox">Send to the TikTok app to finish</option>
</select>
</Field>
)}
{creator && options.mode === "inbox" && (
<p className="text-[12.5px] text-ink-2">Quorla uploads the {type === "REELS" ? "video" : "photos"} to the account's TikTok inbox. Someone opens TikTok, chooses who can see it, edits if needed and taps Post there.</p>
)}
{creator && options.mode !== "inbox" && (
<>
<div className="grid grid-cols-2 gap-3">
<Field label="Who can view this post" hint={branded ? "Branded content can't be private." : undefined}>
<select id="opt-tiktok-privacy" className="input" value={options.privacy || ""} onChange={(e) => setOption("privacy", e.target.value)}>
<option value="" disabled>Choose…</option>
{creator.privacyOptions.map((p) => <option key={p} value={p} disabled={branded && p === "SELF_ONLY"}>{TT_PRIVACY[p] || p}</option>)}
</select>
</Field>
{!video && (
<Field label="Title (optional)" hint="Up to 90 characters.">
<input id="opt-tiktok-title" className="input" maxLength={90} value={options.title || ""} onChange={(e) => setOption("title", e.target.value)} />
</Field>
)}
</div>
<div className="flex flex-wrap gap-x-5 gap-y-1.5">
{check("allowComments", "Allow comments", creator.commentDisabled, "off in this account's TikTok settings")}
{video && check("allowDuet", "Allow Duet", creator.duetDisabled, "off in TikTok settings")}
{video && check("allowStitch", "Allow Stitch", creator.stitchDisabled, "off in TikTok settings")}
</div>
{video && creator.maxVideoSeconds && <div className="text-[12px] text-muted">This account can post videos up to {Math.floor(creator.maxVideoSeconds / 60)} min {creator.maxVideoSeconds % 60 ? `${creator.maxVideoSeconds % 60} s` : ""}.</div>}
<div className="rounded-md bg-canvas p-2.5 space-y-2">
<label htmlFor="opt-tiktok-discloseCommercial" className="flex items-center gap-2 text-[13px] font-medium">
<input id="opt-tiktok-discloseCommercial" type="checkbox" checked={Boolean(options.discloseCommercial)} onChange={(e) => setOption("discloseCommercial", e.target.checked)} />
Disclose commercial content
</label>
<p className="text-[12px] text-muted">Turn on if this post promotes the client's own business, a product or a third party.</p>
{options.discloseCommercial && (
<div className="pl-6 space-y-1.5">
{check("brandOrganic", "Your brand: you're promoting yourself or your own business")}
{check("brandedContent", "Branded content: you're promoting another brand or a third party", options.privacy === "SELF_ONLY", "not available for “Only me”")}
{(organic || branded) && <div className="text-[12px] text-ink-2">The post will be labelled “{branded ? "Paid partnership" : "Promotional content"}”.</div>}
{!organic && !branded && <div className="text-[12px] text-warn">Choose at least one option.</div>}
</div>
)}
</div>
</>
)}
<Field label="TikTok caption (optional)" hint={`Leave empty to use the main caption. ${used}/${limit} characters.`}>
<textarea id="opt-tiktok-caption" className={cx("input h-16 py-2", used > limit && "border-bad")} value={options.caption ?? ""} onChange={(e) => setOption("caption", e.target.value || undefined)} />
</Field>
<p className="text-[12px] text-muted">
By posting, you agree to TikTok's <a className="underline" href="https://www.tiktok.com/legal/page/global/music-usage-confirmation/en" target="_blank" rel="noreferrer">Music Usage Confirmation</a>
{branded && <> and <a className="underline" href="https://www.tiktok.com/legal/page/global/bc-policy/en" target="_blank" rel="noreferrer">Branded Content Policy</a></>}.
{" "}After it's published, the post can take a few minutes to appear on the profile.
</p>
</div>
);
}
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;
const used = (own ?? caption ?? "").length;
return (
<div className="rounded-lg border border-line p-3 space-y-3">
<div className="text-[12.5px] font-semibold flex items-center gap-2">
<span className="size-2.5 rounded-full" style={{ background: platform.color }} aria-hidden="true" />{platform.name}
{platform.conn?.target && <span className="font-normal text-muted">· {platform.conn.target.name}</span>}
</div>
<div className="grid grid-cols-2 gap-3">
{fields.map((f) => {
const id = `opt-${platform.id}-${f.key}`;
const v = options[f.key] ?? f.default ?? "";
if (f.type === "checkbox") {
return <label key={f.key} htmlFor={id} className="flex items-center gap-2 text-[13px] col-span-1"><input id={id} type="checkbox" checked={Boolean(v)} onChange={(e) => setOption(f.key, e.target.checked)} />{f.label}</label>;
}
return (
<div key={f.key} className={cx(f.type === "text" && "col-span-2")}>
<Field label={`${f.label}${f.required ? "" : " (optional)"}`} hint={f.hint}>
{f.type === "select" && f.optionsFrom === "targets" ? <TargetSelect id={id} conn={platform.conn} value={v} onChange={(x) => setOption(f.key, x)} />
: f.type === "select" ? (
<select id={id} className="input" value={v} onChange={(e) => setOption(f.key, e.target.value)}>
{!f.default && f.default !== "" && <option value="" disabled>Choose…</option>}
{f.options.map(([val, label]) => <option key={val} value={val}>{label}</option>)}
</select>
) : <input id={id} className="input" value={v} maxLength={f.max} onChange={(e) => setOption(f.key, e.target.value)} />}
</Field>
</div>
);
})}
</div>
<Field label={`${platform.name} caption (optional)`} hint={`Leave empty to use the main caption.${limit ? ` ${used}/${limit} characters.` : ""}`}>
<textarea id={`opt-${platform.id}-caption`} className={cx("input h-16 py-2", limit && used > limit && "border-bad")} value={own ?? ""} onChange={(e) => setOption("caption", e.target.value || undefined)} />
</Field>
</div>
);
}
/**
* 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, 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] });
const setOption = (pid) => (k, v) => onChange({ ...value, options: { ...value.options, [pid]: { ...(value.options[pid] || {}), [k]: v } } });
if (loading) return <div className="text-[13px] text-muted">Loading platforms…</div>;
if (!list.length) return <div className="text-[13px] text-muted">This client has no connected accounts yet. <Link className="text-brand-deep underline" to={`/connections?client=${client}`}>Connect one</Link>.</div>;
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-x-4 gap-y-2 text-[13px]">
{list.map((p) => {
const ok = p.formats.includes(type);
return (
<label key={p.id} className={cx("flex items-center gap-1.5", !ok && "text-faint")} title={ok ? undefined : `${p.name} doesn't take ${FORMAT_LABEL[type]} posts`}>
<input type="checkbox" id={`plat-${p.id}`} disabled={!ok} checked={ok && value.platforms.includes(p.id)} onChange={() => toggle(p.id)} /> {p.name}
</label>
);
})}
</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} media={media} options={value.options[id] || {}} setOption={setOption(id)} />
))}
</div>
);
}
/** Only the platforms that accept this format, with options for those platforms. */
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 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 };
}