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>
  );
}