Spaces:
Runtime error
Runtime error
File size: 6,806 Bytes
070c931 e1222dc 070c931 bdd1640 070c931 e1222dc 070c931 e1222dc 070c931 bcfc897 070c931 bdd1640 070c931 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 | import { useEffect, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { videosApi } from "../api/endpoints";
import type { VideoListItem, VideoQuota } from "../api/types";
import { TrashIcon } from "../components/icons/Icons";
import { EmptyState, errorMessage, formatDate, Loading, Modal } from "../components/ui";
import { useUpgrade } from "../hooks/useUpgrade";
import { PostButton } from "../components/launchpad/PostButton";
const JOB_LABELS: Record<string, string> = {
regenerating: "Changing template",
voice_regenerating: "Changing voice",
language_regenerating: "Translating",
script_regenerating: "Refreshing script",
};
/** blog2video's status, as a label and a dot color. */
function statusOf(v: VideoListItem): { label: string; tone: "done" | "ready" | "busy" | "review" | "failed" } {
const raw = v.b2v_status ?? "";
if (v.status === "failed" || raw === "failed" || raw === "error") return { label: "Failed", tone: "failed" };
// An edit job on a finished video (blog2video's status, kept on the artifact too). Before "Complete": a video
// rendered earlier keeps its MP4 link while it is being changed.
const job = JOB_LABELS[raw] ?? JOB_LABELS[v.status];
if (job) return { label: job, tone: "busy" };
if (raw === "done" || v.url) return { label: "Complete", tone: "done" };
if (raw === "generated" || v.status === "ready") return { label: "Generated", tone: "ready" };
if (raw === "awaiting_script_review") return { label: "Review script", tone: "review" };
if (raw === "awaiting_stock_footage_review") return { label: "Review footage", tone: "review" };
if (raw === "rendering" || v.status === "rendering") return { label: "Rendering", tone: "busy" };
return { label: "Generating", tone: "busy" };
}
function shortUrl(url: string | null | undefined): string | null {
if (!url) return null;
const s = url.replace(/^https?:\/\//, "");
return s.length > 34 ? `${s.slice(0, 34)}…` : s;
}
export default function Videos() {
const [videos, setVideos] = useState<VideoListItem[] | null>(null);
const [quota, setQuota] = useState<VideoQuota | null>(null);
// The video in the delete modal, and that delete's progress and error.
const [deleting, setDeleting] = useState<VideoListItem | null>(null);
const [busy, setBusy] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const { openUpgrade, status } = useUpgrade();
const notice = (useLocation().state as { notice?: string } | null)?.notice;
useEffect(() => {
videosApi.list().then(setVideos).catch((e) => setError(errorMessage(e)));
videosApi.quota().then(setQuota).catch(() => undefined);
}, []);
function askDelete(v: VideoListItem) {
setDeleteError(null);
setDeleting(v);
}
/** Stays open (and loading) until the delete finishes; a failure shows in the modal so it can be retried. */
async function confirmDelete() {
if (!deleting) return;
const id = deleting.id;
setBusy(true);
setDeleteError(null);
try {
await videosApi.remove(id);
setVideos((v) => (v ?? []).filter((x) => x.id !== id));
setDeleting(null);
} catch (e) {
setDeleteError(errorMessage(e));
} finally {
setBusy(false);
}
}
const out = !!quota && quota.used >= quota.limit;
return (
// The Library's Videos tab: the allowance on the left, New video on the right, then the list.
<div className="stack">
<div className="row between wrap lib-tab-head">
<p className="mono muted">
{quota && <>
{quota.used} of {quota.limit} video{quota.limit === 1 ? "" : "s"} used
{quota.resets_at ? ` this period, refills ${formatDate(quota.resets_at)}` : " on your plan"}
</>}
</p>
{out && status?.can_upgrade ? (
<button className="btn btn-primary" onClick={() => openUpgrade()}>Get more videos</button>
) : (
<Link to="/app/videos/new" className="btn btn-primary">New video</Link>
)}
</div>
{notice && <p className="notice">{notice}</p>}
{error && <p className="error-text">{error}</p>}
{!videos && !error && <div className="loading-center"><Loading label="Loading videos" /></div>}
{videos?.length === 0 && (
<EmptyState
title="No videos yet"
body="Turn a post or any article link into a narrated video, then edit it scene by scene."
action={<Link to="/app/videos/new" className="btn btn-primary">Make your first video</Link>}
/>
)}
{videos && videos.length > 0 && (
<ul className="vw-list">
{videos.map((v) => {
const st = statusOf(v);
const source = shortUrl(v.source_url);
return (
<li key={v.id} className="vw-list-row">
<Link to={`/app/videos/${v.id}`} className="vw-list-main">
<span className="vw-list-title">
<strong>{v.title}</strong>
<span className={`vw-dot ${st.tone}`} aria-hidden="true" />
<span className="muted">{st.label}</span>
</span>
<span className="vw-list-meta muted">
{source && <span>{source}</span>}
{v.scenes ? <span>{v.scenes} scene{v.scenes === 1 ? "" : "s"}</span> : null}
{v.created_at && <span>{formatDate(v.created_at)}</span>}
</span>
</Link>
{st.tone === "done" && <PostButton artifactId={v.id} />}
<button type="button" className="icon-btn vw-list-delete" aria-label={`Delete ${v.title}`} title="Delete"
onClick={() => askDelete(v)}>
<TrashIcon size={18} />
</button>
</li>
);
})}
</ul>
)}
{deleting && (
// While the delete runs, Escape, the backdrop and the close button do nothing.
<Modal title="Delete this video?" onClose={busy ? () => undefined : () => setDeleting(null)}>
<div className="stack">
<p>"<strong>{deleting.title}</strong>" will be deleted permanently. This can't be undone.</p>
{deleteError && <p className="error-text">{deleteError}</p>}
<div className="vw-nav">
<button className="btn" onClick={() => setDeleting(null)} disabled={busy}>Cancel</button>
<button className="btn btn-danger vw-delete-confirm" onClick={confirmDelete} disabled={busy}>
{busy ? "Deleting..." : "Delete video"}
</button>
</div>
</div>
</Modal>
)}
</div>
);
}
|