import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useNavigate } from "react-router-dom";
import { ApiError } from "../../api/client";
import { artifactsApi, launchpadApi, videoEditApi, videosApi } from "../../api/endpoints";
import type { PostableArtifact } from "../../api/types";
import { ArrowRightIcon, DownloadIcon, FilmIcon, HeadphonesIcon, SlidesIcon, SparkleIcon, UploadIcon } from "../icons/Icons";
import { errorMessage, formatDate, Loading, Modal } from "../ui";
import { finished, jobPercent } from "../video/jobState";
/** X's video limit for standard accounts (2:20): longer videos go to LinkedIn only. */
export const X_VIDEO_SECONDS = 140;
export const fmtDuration = (s: number) => `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, "0")}`;
/** "4 images", "Video 1:12", "Text": what the post carries. */
export function mediaLabel(a: PostableArtifact): string {
if (a.type === "upload") {
return a.media === "video" ? `Your video${a.duration_s ? ` ${fmtDuration(a.duration_s)}` : ""}` : "Your image";
}
if (a.media === "audio") return `Audio${a.duration_s ? ` ${fmtDuration(a.duration_s)}` : ""} · download only`;
if (a.media === "video" && a.rendering) return "Rendering: wait for it to finish";
if (a.media === "video" && a.needs_render) return "Not rendered yet: render it first";
if (a.media === "video") return a.duration_s ? `Video ${fmtDuration(a.duration_s)}` : "Video";
if (a.type === "slide_deck") {
// X takes 4 images and LinkedIn 20 (backend app/services/social/media.py MAX_IMAGES): longer decks are cut there.
const n = a.media_count;
const cut = n > 20 ? " · X posts the first 4, LinkedIn the first 20" : n > 4 ? " · X posts the first 4" : "";
return `${n} slide${n === 1 ? "" : "s"} as images${cut}`;
}
if (a.media === "image") return a.media_count === 1 ? "Image" : `${a.media_count} images`;
return "Text";
}
function Thumb({ a }: { a: PostableArtifact }) {
if (a.thumb_url) return ;
return (
{a.media === "video" ? : a.media === "audio" ?
: a.type === "slide_deck" ? : }
);
}
/** Look at an uploaded image or video before posting it. */
export function MediaPreviewModal({ a, onClose }: { a: PostableArtifact; onClose: () => void }) {
const [failed, setFailed] = useState(false);
return (
{failed || !a.view_url ? (
Couldn't load this file.
) : a.media === "video" ? (
{mediaLabel(a)}
);
}
/** An audio overview someone tried to schedule: X and LinkedIn take no audio files, so it is offered to download
* instead. Used by the Launchpad's list and the Library. */
export function AudioOnlyModal({ title, downloadUrl, onClose }: { title: string; downloadUrl: string | null | undefined; onClose: () => void }) {
return (
LinkedIn does not support posting audio files, so {title} can't be scheduled. Download it now
to use it elsewhere, like a podcast app, your newsletter or a website.
{downloadUrl ? (
Download
) : (
The file isn't available right now. Try again later.
)}
);
}
/** The attached item: thumbnail, title, what it carries, and Change / Remove. An uploaded file's thumbnail opens its
* preview. */
export function AttachmentChip({ a, onChange, onRemove }: {
a: PostableArtifact;
onChange?: () => void;
onRemove?: () => void;
}) {
const [previewing, setPreviewing] = useState(false);
return (
);
}
type RenderStep = "confirm" | "rendering" | "done" | "failed";
/** The furthest each video's render has got, kept across opens of the pop-up: blog2video can answer 0% for a while
* (preparing, or a poll that misses its progress), and reopening must not drop the bar back to 0. */
const renderPct = new Map();
/** Render a video that has no MP4 yet, from the Library list: asks first, then shows the render's progress here.
* Opened on a video already rendering, it says to wait and shows that render's progress. Closing it mid-render
* leaves the render running (the row shows Rendering). */
export function RenderVideoModal({ video, onClose, onRendered }: {
video: PostableArtifact;
onClose: () => void;
/** The MP4 is made: schedule it (the picker reloads the item and opens the composer with it). */
onRendered: (id: string, schedule: boolean) => void;
}) {
const waiting = video.rendering; // opened on a render already under way: scheduling waits for it
const [step, setStep] = useState(waiting ? "rendering" : "confirm");
const [pct, setPct] = useState(() => renderPct.get(video.id) ?? null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
// The latest callback, so a parent re-render doesn't restart the polling below.
const onRenderedRef = useRef(onRendered);
onRenderedRef.current = onRendered;
// While rendering: blog2video's progress for the bar. When it says the job stopped (and every few polls anyway,
// in case blog2video lost track of the render), /status (which also moves the video to ready with its MP4) says
// how it ended.
useEffect(() => {
if (step !== "rendering") return;
let stopped = false;
let timer: ReturnType | undefined;
let polls = 0;
const tick = async () => {
try {
const s = await videoEditApi.renderStatus(video.id);
if (stopped) return;
const now = s?.progress_unknown ? null : jobPercent(s);
if (now !== null) renderPct.set(video.id, Math.max(renderPct.get(video.id) ?? 0, now));
setPct(renderPct.get(video.id) ?? null);
if (finished(s) || ++polls % 3 === 0) {
const st = await videosApi.status(video.id);
if (stopped) return;
if (st.artifact_status !== "rendering") {
renderPct.delete(video.id);
if (st.video_url && !st.error) {
setStep("done");
onRenderedRef.current(video.id, false); // the list shows it as ready behind the modal
} else {
setError(st.error ?? "The render stopped before the MP4 was made.");
setStep("failed");
}
return;
}
}
} catch {
// a missed poll: try again on the next one
}
if (!stopped) timer = setTimeout(tick, 4000);
};
void tick();
return () => {
stopped = true;
clearTimeout(timer);
};
}, [step, video.id]);
const render = async () => {
setBusy(true);
setError(null);
try {
await videoEditApi.render(video.id);
renderPct.delete(video.id); // a new run starts from 0
setPct(null);
setStep("rendering");
} catch (e) {
setError(e instanceof ApiError && e.status === 409 ? "Another change is still running on this video, try again in a moment."
: errorMessage(e));
} finally {
setBusy(false);
}
};
const title = step === "done" ? "Video rendered" : step === "failed" ? "Render failed"
: waiting ? "Still rendering" : step === "rendering" ? "Rendering your video" : "Render this video?";
return (
{video.title}
{step === "confirm" && (
It has no MP4 yet, so it can't be posted. Render it now; it takes a few minutes and you can close this while it runs.
)}
{step === "rendering" && (
<>
{waiting ? "Wait for the render to complete before scheduling it." : "You can close this: the render keeps going and the video shows as Rendering until it's done."}
);
}
type MenuItem = { label: string; onSelect: () => void; danger?: boolean };
/** A row's ⋮ button and its menu. The menu is drawn over everything (a portal, placed from the button), so the
* scrolling list and the pop-up around it never cut it off; it opens upwards near the bottom of the screen. */
function RowMenu({ label, items }: { label: string; items: MenuItem[] }) {
const [open, setOpen] = useState(false);
const [at, setAt] = useState<{ top: number; left: number; up: boolean } | null>(null);
const button = useRef(null);
const menu = useRef(null);
useLayoutEffect(() => {
if (!open || !button.current) return;
const r = button.current.getBoundingClientRect();
const up = r.bottom + 8 + items.length * 40 > window.innerHeight;
setAt({ top: up ? r.top - 6 : r.bottom + 6, left: r.right, up });
}, [open, items.length]);
useEffect(() => {
if (!open) return;
const close = () => setOpen(false);
const outside = (e: MouseEvent) => {
const t = e.target as Node;
if (!menu.current?.contains(t) && !button.current?.contains(t)) close();
};
// Escape closes only the menu, not the pop-up the picker sits in.
const key = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
e.stopPropagation();
close();
button.current?.focus();
};
document.addEventListener("mousedown", outside);
document.addEventListener("keydown", key, true);
window.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
menu.current?.querySelector("[role=menuitem]")?.focus();
return () => {
document.removeEventListener("mousedown", outside);
document.removeEventListener("keydown", key, true);
window.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
};
}, [open, at]);
return (
<>
{open && at && createPortal(
{items.map((it) => (
))}
,
document.body,
)}
>
);
}
/** The warning before deleting, by kind: what goes, and what happens to the posts that carry it. */
function deleteWording(a: PostableArtifact): { title: string; body: string } {
if (a.type === "upload") {
return { title: "Delete this upload?", body: "The file is deleted for good. Posts on your calendar that carry it are removed too." };
}
if (a.type === "launch_kit") {
return { title: "Delete this launch kit?",
body: "Its LinkedIn post and hooks are deleted for good. Posts already scheduled from it stay on your calendar." };
}
if (a.media === "video") {
return { title: "Delete this video?",
body: "It's deleted here and on the video service, for good. Posts on your calendar that carry it are removed too." };
}
return { title: "Delete this item?", body: "It's deleted for good. Posts on your calendar that carry it are removed too." };
}
type Kind = "all" | "kits" | "video" | "decks" | "audio" | "uploads";
const KINDS: { id: Kind; label: string }[] = [
{ id: "all", label: "All" },
{ id: "kits", label: "Launch kits" },
{ id: "video", label: "Videos" },
{ id: "decks", label: "Slide decks" },
{ id: "audio", label: "Audio" },
{ id: "uploads", label: "Uploads" },
];
/** Which rows a tab shows: Videos is only the ones the video service made (uploaded videos are under Uploads). */
const matches = (a: PostableArtifact, kind: Kind) =>
kind === "all" || (kind === "kits" ? a.type === "launch_kit" : kind === "uploads" ? a.type === "upload"
: kind === "decks" ? a.type === "slide_deck" : kind === "audio" ? a.type === "audio_overview" : a.editable_video);
/** Saves a file from its (presigned, attachment) link. */
function download(url: string) {
const link = document.createElement("a");
link.href = url;
link.download = "";
document.body.appendChild(link);
link.click();
link.remove();
}
/** What can be uploaded to post, and LinkedIn's size limits for it (checked again by the backend). */
const UPLOAD_ACCEPT = "image/png,image/jpeg,image/gif,video/mp4";
const MB = 1024 * 1024;
function uploadProblem(file: File): string | null {
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
const video = file.type === "video/mp4" || ext === "mp4";
const image = ["image/png", "image/jpeg", "image/gif"].includes(file.type) || ["png", "jpg", "jpeg", "gif"].includes(ext);
if (!video && !image) return "LinkedIn takes JPG, PNG or GIF images and MP4 videos.";
if (image && file.size > 36 * MB) return `This image is ${Math.round(file.size / MB)} MB; LinkedIn takes up to 36 MB.`;
if (video && file.size > 300 * MB) return `This video is ${Math.round(file.size / MB)} MB; uploads are limited to 300 MB.`;
if (video && file.size < 75 * 1024) return "This video is too small for LinkedIn (at least 75 KB).";
return null;
}
/** A video file's length in seconds, read by the browser (null if it can't tell). */
function videoDuration(file: File): Promise {
return new Promise((resolve) => {
const url = URL.createObjectURL(file);
const v = document.createElement("video");
v.preload = "metadata";
const done = (d: number | null) => {
URL.revokeObjectURL(url);
resolve(d);
};
v.onloadedmetadata = () => done(Number.isFinite(v.duration) ? v.duration : null);
v.onerror = () => done(null);
v.src = url;
});
}
/** Pick something made to attach to a post (audio is never offered: X and LinkedIn take no audio files), in a
* pop-up (the composer, editing a scheduled post). */
export function AttachPicker({ onPick, onCreateKit, onClose }: {
onPick: (a: PostableArtifact) => void;
onCreateKit?: () => void;
onClose: () => void;
}) {
return (
);
}
/** What can go out with a post: everything postable in the Library, your Launch Kits, or your videos (tabs), and
* searchable; with a link to create a new launch kit when onCreateKit is given. onPickKit, when given, takes the
* Launch Kits instead of onPick (the Schedule a launch page opens the kit, to schedule its posts from there). A video
* with no MP4 yet (or one rendering) opens the render pop-up instead. fill: the rows take the screen's height. */
export function LibraryPicker({ onPick, onPickKit, onCreateKit, fill = false, onReschedule, onDuplicate, refreshKey = 0, openingId }: {
onPick: (a: PostableArtifact) => void;
onPickKit?: (a: PostableArtifact) => void;
onCreateKit?: () => void;
fill?: boolean;
/** With these (the Schedule a launch page), an item already scheduled says Reschedule and opens that post
* (`itemId`), and its menu can schedule it again (a copy of that post). Without them (the composer's picker), every
* item is simply picked. */
onReschedule?: (a: PostableArtifact, itemId: string) => void;
onDuplicate?: (a: PostableArtifact, itemId: string) => void;
/** Changed by the page to load the list again (a post was rescheduled, moved or deleted). */
refreshKey?: number;
/** The item whose scheduled post is being opened: its row shows a spinner and takes no clicks meanwhile. */
openingId?: string | null;
}) {
const [items, setItems] = useState(null);
const [kind, setKind] = useState("all");
const [q, setQ] = useState("");
const [error, setError] = useState(null);
const [rendering, setRendering] = useState(null); // the video in the render pop-up
const navigate = useNavigate();
const [uploading, setUploading] = useState(null); // the file name being uploaded
const [dragging, setDragging] = useState(false); // a file is held over the drop area
const [renaming, setRenaming] = useState<{ id: string; title: string; busy?: boolean } | null>(null);
const [previewing, setPreviewing] = useState(null); // the upload being looked at
const [audio, setAudio] = useState(null); // an audio overview: offered to download, not posted
const [deleting, setDeleting] = useState(null); // the item in the delete pop-up
const [deleteBusy, setDeleteBusy] = useState(false);
const [deleteError, setDeleteError] = useState(null);
/** Delete an item after its warning: an upload (record and stored file too), a video (also on blog2video), a kit... */
const deleteItem = async () => {
if (!deleting) return;
setDeleteBusy(true);
setDeleteError(null);
try {
await artifactsApi.remove(deleting.id);
setItems((list) => list?.filter((x) => x.id !== deleting.id) ?? list);
setDeleting(null);
} catch (e) {
setDeleteError(errorMessage(e));
} finally {
setDeleteBusy(false);
}
};
/** An uploaded file's name, as the picker and the composer show it (the file itself keeps its name). */
const saveName = async () => {
if (!renaming) return;
const title = renaming.title.trim();
if (!title) return setError("Give it a name.");
setRenaming({ ...renaming, busy: true });
try {
await artifactsApi.update(renaming.id, { title });
setItems((list) => list?.map((x) => (x.id === renaming.id ? { ...x, title } : x)) ?? list);
setRenaming(null);
setError(null);
} catch (e) {
setError(errorMessage(e));
setRenaming((cur) => cur && { ...cur, busy: false });
}
};
const fileInput = useRef(null);
/** A file from the computer: uploaded, made postable, then picked like anything else. */
const upload = async (file: File) => {
const problem = uploadProblem(file);
if (problem) return setError(problem);
setError(null);
setUploading(file.name);
try {
const seconds = file.type === "video/mp4" || file.name.toLowerCase().endsWith(".mp4") ? await videoDuration(file) : null;
const item = await launchpadApi.uploadToPost(file, seconds ?? undefined);
void load().catch(() => undefined);
onPick(item);
} catch (e) {
setError(errorMessage(e));
} finally {
setUploading(null);
}
};
const load = useCallback(() => launchpadApi.postable(q).then((list) => {
setItems(list);
return list;
}), [q, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const t = setTimeout(() => {
load().catch((e) => setError(errorMessage(e)));
}, 200);
return () => clearTimeout(t);
}, [load]);
// Videos rendering (from here, the editor, or another tab): check on them, and reload once one is done.
const renderingIds = useMemo(() => (items ?? []).filter((a) => a.rendering).map((a) => a.id).join(","), [items]);
useEffect(() => {
if (!renderingIds) return;
const t = setInterval(async () => {
const ids = renderingIds.split(",");
const states = await Promise.all(ids.map((id) => videosApi.status(id).catch(() => null)));
if (states.some((s) => s && s.artifact_status !== "rendering")) void load().catch(() => undefined);
}, 8000);
return () => clearInterval(t);
}, [renderingIds, load]);
const rendered = useCallback((id: string, schedule: boolean) => {
void load().then((list) => {
if (!schedule) return;
const found = list.find((a) => a.id === id);
setRendering(null);
if (found && !found.needs_render) onPick(found);
}).catch((e) => setError(errorMessage(e)));
}, [load, onPick]);
// Kits only on the Schedule a launch page (they open the kit); as an attachment they are never offered.
const kits = Boolean(onPickKit);
const shown = useMemo(() => (items ?? []).filter((a) => (kits || a.type !== "launch_kit") && matches(a, kind)),
[items, kind, kits]);
return (
{/* A LinkedIn post and hooks from one of your posts: its own pages */}
{onCreateKit && (
)}
{/* Uploads: a drop area for a file from the computer, above the files uploaded before */}
{kind === "uploads" && (
!uploading && fileInput.current?.click()}
onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && !uploading && fileInput.current?.click()}
onDragOver={(e) => {
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
setDragging(true);
}}
onDragLeave={(e) => e.currentTarget === e.target && setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer.files[0];
if (file && !uploading) void upload(file);
}}>
{uploading ? : }
{uploading ? (
Uploading {uploading}...
) : (
<>
Drop a file here, or browse your computerJPG, PNG or GIF up to 36 MB · MP4 video up to 300 MB
>
)}
)}
{/* Outside the drop area, so its click doesn't bubble back into the area's own click */}
{
const file = e.target.files?.[0];
e.target.value = ""; // the same file can be picked again
if (file) void upload(file);
}} />
setQ(e.target.value)} aria-label="Search" />
{error &&
{error}
}
{!items && !error && (
)}
{items && shown.length === 0 && (
{kind === "kits" ? "No launch kits yet: create a new one."
: kind === "uploads" ? "Nothing uploaded yet." : kind === "decks" ? "No slide decks yet: make one from a notebook."
: kind === "audio" ? "No audio overviews yet: make one from a notebook."
: "Nothing to attach here yet."}
)}
{shown.map((a) => {
const kit = a.type === "launch_kit";
// Already on the calendar (and this list offers it): open that post rather than start another one.
const next = onReschedule && !kit && a.media !== "audio" ? a.scheduled?.[0] : undefined;
const opening = openingId === a.id;
const go = () => opening ? undefined : (a.media === "audio" ? setAudio(a) : a.needs_render || a.rendering ? setRendering(a)
: next ? onReschedule!(a, next.id) : kit && onPickKit ? onPickKit(a) : onPick(a));
if (renaming?.id === a.id) {
return (