Spaces:
Runtime error
Runtime error
Commit ·
bcfc897
1
Parent(s): 1e1198f
error messages added and loaders fixed
Browse files- backend/app/pipeline/launchkit.py +12 -1
- backend/app/services/blog2video.py +1 -1
- backend/app/services/renderer.py +8 -0
- backend/tests/test_buildout.py +12 -0
- backend/tests/test_videos.py +1 -1
- frontend/src/components/ui.tsx +25 -2
- frontend/src/components/video/EditorPanels.tsx +4 -4
- frontend/src/components/voice/YourVoices.tsx +1 -1
- frontend/src/pages/Archive.tsx +1 -1
- frontend/src/pages/LaunchKit.tsx +1 -1
- frontend/src/pages/Launchpad.tsx +2 -2
- frontend/src/pages/NotebookView.tsx +1 -1
- frontend/src/pages/Notebooks.tsx +1 -1
- frontend/src/pages/Resurface.tsx +1 -1
- frontend/src/pages/Settings.tsx +1 -1
- frontend/src/pages/Sources.tsx +2 -1
- frontend/src/pages/TopicMap.tsx +1 -1
- frontend/src/pages/VideoCreate.tsx +5 -5
- frontend/src/pages/VideoEditor.tsx +5 -5
- frontend/src/pages/VideoTemplateEditor.tsx +1 -1
- frontend/src/pages/VideoTemplates.tsx +1 -1
- frontend/src/pages/Videos.tsx +1 -1
- frontend/src/pages/VoiceProfile.tsx +1 -1
- frontend/src/styles/pages.css +16 -13
- renderer/package.json +1 -1
backend/app/pipeline/launchkit.py
CHANGED
|
@@ -1,16 +1,22 @@
|
|
| 1 |
"""Launch Kit: one post in, everything needed to launch it out, in the writer's voice and grounded in
|
| 2 |
the post's own claims."""
|
| 3 |
|
|
|
|
|
|
|
| 4 |
from sqlalchemy.orm import Session
|
| 5 |
|
|
|
|
| 6 |
from app.corpus import INDEX, Corpus
|
| 7 |
from app.llm import run
|
| 8 |
from app.llm.signatures import CarouselSlides, ExtractClaims, HookGenerator, PickQuotes, PlatformAdapter, SeoPack
|
| 9 |
from app.models import Artifact, Document, Job
|
| 10 |
from app.pipeline.generate import NothingToDo
|
| 11 |
from app.pipeline.passages import passages_for, tools_for, verify_refs, voice_text
|
|
|
|
| 12 |
from app.services.jobs import create_job, update_job
|
| 13 |
|
|
|
|
|
|
|
| 14 |
PLATFORMS = {
|
| 15 |
"x_thread": ("X thread", "5 to 9 posts, each under 270 characters. Post 1 is the hook and stands alone. "
|
| 16 |
"No hashtags except at most one at the end. The last post links to the article with {url}."),
|
|
@@ -94,8 +100,13 @@ def build_launch_kit(db: Session, job: Job, artifact: Artifact) -> dict:
|
|
| 94 |
artifact.status = "ready"
|
| 95 |
db.commit()
|
| 96 |
|
| 97 |
-
# Quote cards render as their own artifacts, so a missing renderer does not sink the kit.
|
|
|
|
| 98 |
card_ids = []
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
for q in quotes:
|
| 100 |
card = Artifact(workspace_id=ws, document_id=doc.id, type="quote_card", status="pending",
|
| 101 |
content_json={"title": f"Quote: {doc.title}", "parent_id": str(artifact.id), **q})
|
|
|
|
| 1 |
"""Launch Kit: one post in, everything needed to launch it out, in the writer's voice and grounded in
|
| 2 |
the post's own claims."""
|
| 3 |
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
from sqlalchemy.orm import Session
|
| 7 |
|
| 8 |
+
from app.config import settings
|
| 9 |
from app.corpus import INDEX, Corpus
|
| 10 |
from app.llm import run
|
| 11 |
from app.llm.signatures import CarouselSlides, ExtractClaims, HookGenerator, PickQuotes, PlatformAdapter, SeoPack
|
| 12 |
from app.models import Artifact, Document, Job
|
| 13 |
from app.pipeline.generate import NothingToDo
|
| 14 |
from app.pipeline.passages import passages_for, tools_for, verify_refs, voice_text
|
| 15 |
+
from app.services import renderer
|
| 16 |
from app.services.jobs import create_job, update_job
|
| 17 |
|
| 18 |
+
log = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
PLATFORMS = {
|
| 21 |
"x_thread": ("X thread", "5 to 9 posts, each under 270 characters. Post 1 is the hook and stands alone. "
|
| 22 |
"No hashtags except at most one at the end. The last post links to the article with {url}."),
|
|
|
|
| 100 |
artifact.status = "ready"
|
| 101 |
db.commit()
|
| 102 |
|
| 103 |
+
# Quote cards render as their own artifacts, so a missing renderer does not sink the kit. With the renderer
|
| 104 |
+
# down they would only fail, so skip them: the kit keeps its quotes as text either way.
|
| 105 |
card_ids = []
|
| 106 |
+
if quotes and not renderer.available():
|
| 107 |
+
log.warning("Renderer unreachable at %s: launch kit %s made without quote cards",
|
| 108 |
+
settings.renderer_url, artifact.id)
|
| 109 |
+
quotes = []
|
| 110 |
for q in quotes:
|
| 111 |
card = Artifact(workspace_id=ws, document_id=doc.id, type="quote_card", status="pending",
|
| 112 |
content_json={"title": f"Quote: {doc.title}", "parent_id": str(artifact.id), **q})
|
backend/app/services/blog2video.py
CHANGED
|
@@ -85,7 +85,7 @@ class Denied(B2VError):
|
|
| 85 |
|
| 86 |
|
| 87 |
UNAVAILABLE = "Video creation is temporarily unavailable. Try again later."
|
| 88 |
-
NOT_RESPONDING = "The video service is
|
| 89 |
|
| 90 |
|
| 91 |
def configured() -> bool:
|
|
|
|
| 85 |
|
| 86 |
|
| 87 |
UNAVAILABLE = "Video creation is temporarily unavailable. Try again later."
|
| 88 |
+
NOT_RESPONDING = "The video service is being updated. Please try again in a while."
|
| 89 |
|
| 90 |
|
| 91 |
def configured() -> bool:
|
backend/app/services/renderer.py
CHANGED
|
@@ -17,6 +17,14 @@ class PermanentJobError(RuntimeError):
|
|
| 17 |
"""Fail the job now; retrying will not help."""
|
| 18 |
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
def request_render(job: Job, artifact: Artifact, composition: str, props: dict | None = None,
|
| 21 |
stills: list[dict] | None = None) -> None:
|
| 22 |
"""Start a render. `stills` renders one PNG per props dict (carousels) instead of a single output."""
|
|
|
|
| 17 |
"""Fail the job now; retrying will not help."""
|
| 18 |
|
| 19 |
|
| 20 |
+
def available() -> bool:
|
| 21 |
+
"""Whether the render service answers right now (a quick health check, not a promise for later)."""
|
| 22 |
+
try:
|
| 23 |
+
return httpx.get(f"{settings.renderer_url}/health", timeout=2).status_code == 200
|
| 24 |
+
except httpx.HTTPError:
|
| 25 |
+
return False
|
| 26 |
+
|
| 27 |
+
|
| 28 |
def request_render(job: Job, artifact: Artifact, composition: str, props: dict | None = None,
|
| 29 |
stills: list[dict] | None = None) -> None:
|
| 30 |
"""Start a render. `stills` renders one PNG per props dict (carousels) instead of a single output."""
|
backend/tests/test_buildout.py
CHANGED
|
@@ -542,7 +542,10 @@ def test_launch_kit(client, auth, run_jobs, feed, llm, monkeypatch, db_session):
|
|
| 542 |
"PickQuotes": {"quotes": [{"quote": "Charging more made my readers take the work seriously.",
|
| 543 |
"source": ref}]},
|
| 544 |
})
|
|
|
|
|
|
|
| 545 |
monkeypatch.setattr(media, "request_render", lambda *a, **k: None)
|
|
|
|
| 546 |
art = client.post("/api/artifacts/generate", json={"type": "launch_kit", "document_id": doc["id"]},
|
| 547 |
headers=auth).json()
|
| 548 |
run_jobs()
|
|
@@ -563,6 +566,15 @@ def test_launch_kit(client, auth, run_jobs, feed, llm, monkeypatch, db_session):
|
|
| 563 |
|
| 564 |
listing = client.get("/api/artifacts", params={"type": "launch_kit"}, headers=auth).json()
|
| 565 |
assert listing["total"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 566 |
assert client.delete(f"/api/artifacts/{art['id']}", headers=auth).status_code == 200
|
| 567 |
|
| 568 |
|
|
|
|
| 542 |
"PickQuotes": {"quotes": [{"quote": "Charging more made my readers take the work seriously.",
|
| 543 |
"source": ref}]},
|
| 544 |
})
|
| 545 |
+
from app.services import renderer
|
| 546 |
+
|
| 547 |
monkeypatch.setattr(media, "request_render", lambda *a, **k: None)
|
| 548 |
+
monkeypatch.setattr(renderer, "available", lambda: True)
|
| 549 |
art = client.post("/api/artifacts/generate", json={"type": "launch_kit", "document_id": doc["id"]},
|
| 550 |
headers=auth).json()
|
| 551 |
run_jobs()
|
|
|
|
| 566 |
|
| 567 |
listing = client.get("/api/artifacts", params={"type": "launch_kit"}, headers=auth).json()
|
| 568 |
assert listing["total"] == 1
|
| 569 |
+
|
| 570 |
+
# Renderer down: the kit keeps its quotes as text and makes no quote cards that could only fail.
|
| 571 |
+
monkeypatch.setattr(renderer, "available", lambda: False)
|
| 572 |
+
art = client.post("/api/artifacts/generate", json={"type": "launch_kit", "document_id": doc["id"]},
|
| 573 |
+
headers=auth).json()
|
| 574 |
+
run_jobs()
|
| 575 |
+
kit = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
|
| 576 |
+
assert kit["status"] == "ready"
|
| 577 |
+
assert kit["content"]["quotes"] and kit["content"]["quote_card_ids"] == []
|
| 578 |
assert client.delete(f"/api/artifacts/{art['id']}", headers=auth).status_code == 200
|
| 579 |
|
| 580 |
|
backend/tests/test_videos.py
CHANGED
|
@@ -440,7 +440,7 @@ def test_slow_catalog_is_a_clean_error_not_a_500(client, b2v, owner, db_session)
|
|
| 440 |
b2v.queue("GET", "/api/templates", (0, httpx.ReadTimeout("slow")))
|
| 441 |
r = client.post("/api/videos", json={"url": "https://ada.example.com/p/a", "template": "geometric"},
|
| 442 |
headers=headers)
|
| 443 |
-
assert r.status_code == 502 and "
|
| 444 |
assert not creates(b2v) and used(db_session, ws) == 0
|
| 445 |
|
| 446 |
|
|
|
|
| 440 |
b2v.queue("GET", "/api/templates", (0, httpx.ReadTimeout("slow")))
|
| 441 |
r = client.post("/api/videos", json={"url": "https://ada.example.com/p/a", "template": "geometric"},
|
| 442 |
headers=headers)
|
| 443 |
+
assert r.status_code == 502 and "being updated" in r.json()["detail"]
|
| 444 |
assert not creates(b2v) and used(db_session, ws) == 0
|
| 445 |
|
| 446 |
|
frontend/src/components/ui.tsx
CHANGED
|
@@ -8,6 +8,24 @@ export function errorMessage(e: unknown, fallback = "Something went wrong. Try a
|
|
| 8 |
return e instanceof ApiError ? e.message : e instanceof Error && e.message ? e.message : fallback;
|
| 9 |
}
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
/** Launch progress bar for a job. */
|
| 12 |
export function JobProgress({ job, compact = false }: { job: Job; compact?: boolean }) {
|
| 13 |
const pct = Math.round((job.status === "done" ? 1 : job.progress) * 100);
|
|
@@ -216,8 +234,12 @@ export function EmptyState({ title, body, action }: { title: string; body?: stri
|
|
| 216 |
);
|
| 217 |
}
|
| 218 |
|
| 219 |
-
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
<div className="loading mono muted" role="status">
|
| 222 |
<div className="orbit-loader">
|
| 223 |
<span />
|
|
@@ -225,6 +247,7 @@ export function Loading({ label = "Loading" }: { label?: string }) {
|
|
| 225 |
{label}...
|
| 226 |
</div>
|
| 227 |
);
|
|
|
|
| 228 |
}
|
| 229 |
|
| 230 |
export function StatusPill({ status }: { status: string }) {
|
|
|
|
| 8 |
return e instanceof ApiError ? e.message : e instanceof Error && e.message ? e.message : fallback;
|
| 9 |
}
|
| 10 |
|
| 11 |
+
/** Matches blog2video.NOT_RESPONDING on the backend: the video service is down or redeploying. */
|
| 12 |
+
export const VIDEO_UPDATING = "The video service is being updated";
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* An inline error. The "video service is being updated" message is a status, not a mistake: it is shown centered
|
| 16 |
+
* and in bold instead, and with `page` it fills the page under the header.
|
| 17 |
+
*/
|
| 18 |
+
export function ErrorText({ message, page = false }: { message: string; page?: boolean }) {
|
| 19 |
+
if (message.startsWith(VIDEO_UPDATING)) {
|
| 20 |
+
return (
|
| 21 |
+
<div className={`service-notice${page ? " page" : ""}`} role="status">
|
| 22 |
+
<strong>{message}</strong>
|
| 23 |
+
</div>
|
| 24 |
+
);
|
| 25 |
+
}
|
| 26 |
+
return <p className="error-text">{message}</p>;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
/** Launch progress bar for a job. */
|
| 30 |
export function JobProgress({ job, compact = false }: { job: Job; compact?: boolean }) {
|
| 31 |
const pct = Math.round((job.status === "done" ? 1 : job.progress) * 100);
|
|
|
|
| 234 |
);
|
| 235 |
}
|
| 236 |
|
| 237 |
+
/**
|
| 238 |
+
* Spinner with its label underneath, centered. `center="page"` puts it in the middle of the space under a page
|
| 239 |
+
* header; `center="full"` in the middle of the window, for a page that has no header yet.
|
| 240 |
+
*/
|
| 241 |
+
export function Loading({ label = "Loading", center }: { label?: string; center?: "page" | "full" }) {
|
| 242 |
+
const loader = (
|
| 243 |
<div className="loading mono muted" role="status">
|
| 244 |
<div className="orbit-loader">
|
| 245 |
<span />
|
|
|
|
| 247 |
{label}...
|
| 248 |
</div>
|
| 249 |
);
|
| 250 |
+
return center ? <div className={`loading-center${center === "full" ? " full" : ""}`}>{loader}</div> : loader;
|
| 251 |
}
|
| 252 |
|
| 253 |
export function StatusPill({ status }: { status: string }) {
|
frontend/src/components/video/EditorPanels.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import type {
|
|
| 7 |
VideoScene,
|
| 8 |
} from "../../api/types";
|
| 9 |
import { ChevronIcon, TrashIcon } from "../icons/Icons";
|
| 10 |
-
import { ConfirmButton, errorMessage } from "../ui";
|
| 11 |
import { Premium } from "./parts";
|
| 12 |
import { DeleteAssetModal, RemoveMediaModal } from "./SceneMedia";
|
| 13 |
import { Voiceovers } from "./Voiceovers";
|
|
@@ -77,7 +77,7 @@ export function ScriptPanel(p: PanelProps) {
|
|
| 77 |
Try again
|
| 78 |
</button>
|
| 79 |
</div>
|
| 80 |
-
{error && <
|
| 81 |
{preview && <pre className="vw-pre">{JSON.stringify(preview, null, 2)}</pre>}
|
| 82 |
</section>
|
| 83 |
);
|
|
@@ -137,7 +137,7 @@ export function ChatPanel(p: PanelProps) {
|
|
| 137 |
{lines.length === 0 && <p className="muted">Ask for changes, like "make scene 2 shorter" or "use warmer colors".</p>}
|
| 138 |
{lines.map((l, i) => <p key={i} className={`vw-chat-line ${l.role}`}>{l.text}</p>)}
|
| 139 |
</div>
|
| 140 |
-
{error && <
|
| 141 |
<div className="row">
|
| 142 |
<input className="input" value={message} maxLength={4000} disabled={busy || p.disabled} placeholder="Ask for a change"
|
| 143 |
onChange={(e) => setMessage(e.target.value)} onKeyDown={(e) => e.key === "Enter" && send()} />
|
|
@@ -247,7 +247,7 @@ export function FramesPanel(p: PanelProps) {
|
|
| 247 |
<button className="btn btn-small" disabled={busy} onClick={load}>{busy ? "Rendering..." : "Show frame"}</button>
|
| 248 |
{src && <a className="btn btn-small" href={src} download={`frame-${frame}.png`}>Download</a>}
|
| 249 |
</div>
|
| 250 |
-
{error && <
|
| 251 |
{src && <img className="vw-still" src={src} alt={`Frame ${frame}`} />}
|
| 252 |
</section>
|
| 253 |
);
|
|
|
|
| 7 |
VideoScene,
|
| 8 |
} from "../../api/types";
|
| 9 |
import { ChevronIcon, TrashIcon } from "../icons/Icons";
|
| 10 |
+
import { ConfirmButton, ErrorText, errorMessage } from "../ui";
|
| 11 |
import { Premium } from "./parts";
|
| 12 |
import { DeleteAssetModal, RemoveMediaModal } from "./SceneMedia";
|
| 13 |
import { Voiceovers } from "./Voiceovers";
|
|
|
|
| 77 |
Try again
|
| 78 |
</button>
|
| 79 |
</div>
|
| 80 |
+
{error && <ErrorText message={error} />}
|
| 81 |
{preview && <pre className="vw-pre">{JSON.stringify(preview, null, 2)}</pre>}
|
| 82 |
</section>
|
| 83 |
);
|
|
|
|
| 137 |
{lines.length === 0 && <p className="muted">Ask for changes, like "make scene 2 shorter" or "use warmer colors".</p>}
|
| 138 |
{lines.map((l, i) => <p key={i} className={`vw-chat-line ${l.role}`}>{l.text}</p>)}
|
| 139 |
</div>
|
| 140 |
+
{error && <ErrorText message={error} />}
|
| 141 |
<div className="row">
|
| 142 |
<input className="input" value={message} maxLength={4000} disabled={busy || p.disabled} placeholder="Ask for a change"
|
| 143 |
onChange={(e) => setMessage(e.target.value)} onKeyDown={(e) => e.key === "Enter" && send()} />
|
|
|
|
| 247 |
<button className="btn btn-small" disabled={busy} onClick={load}>{busy ? "Rendering..." : "Show frame"}</button>
|
| 248 |
{src && <a className="btn btn-small" href={src} download={`frame-${frame}.png`}>Download</a>}
|
| 249 |
</div>
|
| 250 |
+
{error && <ErrorText message={error} />}
|
| 251 |
{src && <img className="vw-still" src={src} alt={`Frame ${frame}`} />}
|
| 252 |
</section>
|
| 253 |
);
|
frontend/src/components/voice/YourVoices.tsx
CHANGED
|
@@ -69,7 +69,7 @@ export function YourVoices({ reloadKey = 0, onAdd }: {
|
|
| 69 |
<button className="btn btn-primary" onClick={onAdd}>Add voices</button>
|
| 70 |
</div>
|
| 71 |
{error && <p className="error-text">{error}</p>}
|
| 72 |
-
{!voices && !error && <div className="loading-center inline
|
| 73 |
|
| 74 |
{voices && (
|
| 75 |
<>
|
|
|
|
| 69 |
<button className="btn btn-primary" onClick={onAdd}>Add voices</button>
|
| 70 |
</div>
|
| 71 |
{error && <p className="error-text">{error}</p>}
|
| 72 |
+
{!voices && !error && <div className="loading-center inline"><Loading label="Loading voices" /></div>}
|
| 73 |
|
| 74 |
{voices && (
|
| 75 |
<>
|
frontend/src/pages/Archive.tsx
CHANGED
|
@@ -63,7 +63,7 @@ function MadeList({ filter }: { filter: Filter }) {
|
|
| 63 |
<div className="row end wrap">
|
| 64 |
<input className="input input-sm search" placeholder={`Search ${total} items by post or notebook`} value={q} onChange={(e) => setQ(e.target.value)} aria-label="Search" />
|
| 65 |
</div>
|
| 66 |
-
{!items && <div className="loading-center
|
| 67 |
{items?.length === 0 && <EmptyState title="Nothing here yet" body="Summaries, audio, videos and launch kits you create show up here."
|
| 68 |
action={
|
| 69 |
<Link className="btn btn-primary" to="/app/studio">
|
|
|
|
| 63 |
<div className="row end wrap">
|
| 64 |
<input className="input input-sm search" placeholder={`Search ${total} items by post or notebook`} value={q} onChange={(e) => setQ(e.target.value)} aria-label="Search" />
|
| 65 |
</div>
|
| 66 |
+
{!items && <div className="loading-center"><Loading label="Loading your Library" /></div>}
|
| 67 |
{items?.length === 0 && <EmptyState title="Nothing here yet" body="Summaries, audio, videos and launch kits you create show up here."
|
| 68 |
action={
|
| 69 |
<Link className="btn btn-primary" to="/app/studio">
|
frontend/src/pages/LaunchKit.tsx
CHANGED
|
@@ -252,7 +252,7 @@ export function LaunchKitPage() {
|
|
| 252 |
<div className="page-wrap">
|
| 253 |
<BackArrow fallback="/app/launchpad/kits" />
|
| 254 |
{error && <p className="error-text">{error}</p>}
|
| 255 |
-
{!kit && !error && <div className="loading-center full
|
| 256 |
{kit && <KitView key={kit.id} kit={kit} onDeleted={() => navigate("/app/launchpad/kits", { replace: true })} />}
|
| 257 |
</div>
|
| 258 |
);
|
|
|
|
| 252 |
<div className="page-wrap">
|
| 253 |
<BackArrow fallback="/app/launchpad/kits" />
|
| 254 |
{error && <p className="error-text">{error}</p>}
|
| 255 |
+
{!kit && !error && <div className="loading-center full"><Loading label="Opening the kit" /></div>}
|
| 256 |
{kit && <KitView key={kit.id} kit={kit} onDeleted={() => navigate("/app/launchpad/kits", { replace: true })} />}
|
| 257 |
</div>
|
| 258 |
);
|
frontend/src/pages/Launchpad.tsx
CHANGED
|
@@ -253,7 +253,7 @@ function ConnectionsPanel({ accounts, onConnect, onChanged }: {
|
|
| 253 |
<aside className="card stack lp-connections" aria-label="Connected accounts">
|
| 254 |
<h2>Connections</h2>
|
| 255 |
<p className="muted small">Posts go out through these accounts at their scheduled time.</p>
|
| 256 |
-
{!accounts && <div className="loading-center inline
|
| 257 |
{accounts && CONNECT.map((c) => {
|
| 258 |
const a = accounts.accounts.find((x) => x.platform === c.id);
|
| 259 |
const days = a?.expires_at ? Math.max(0, Math.ceil((new Date(a.expires_at).getTime() - Date.now()) / 864e5)) : null;
|
|
@@ -426,7 +426,7 @@ export default function Launchpad() {
|
|
| 426 |
</div>
|
| 427 |
<Tabs tabs={[{ id: "month", label: "Month" }, { id: "agenda", label: "Agenda" }]} value={view} onChange={setView} />
|
| 428 |
</div>
|
| 429 |
-
{!items && <div className="loading-center
|
| 430 |
{items && items.length === 0 && (
|
| 431 |
<div className="lp-empty">
|
| 432 |
<p className="muted">Nothing scheduled this month.</p>
|
|
|
|
| 253 |
<aside className="card stack lp-connections" aria-label="Connected accounts">
|
| 254 |
<h2>Connections</h2>
|
| 255 |
<p className="muted small">Posts go out through these accounts at their scheduled time.</p>
|
| 256 |
+
{!accounts && <div className="loading-center inline"><Loading label="Loading your connections" /></div>}
|
| 257 |
{accounts && CONNECT.map((c) => {
|
| 258 |
const a = accounts.accounts.find((x) => x.platform === c.id);
|
| 259 |
const days = a?.expires_at ? Math.max(0, Math.ceil((new Date(a.expires_at).getTime() - Date.now()) / 864e5)) : null;
|
|
|
|
| 426 |
</div>
|
| 427 |
<Tabs tabs={[{ id: "month", label: "Month" }, { id: "agenda", label: "Agenda" }]} value={view} onChange={setView} />
|
| 428 |
</div>
|
| 429 |
+
{!items && <div className="loading-center lp-cal-loading"><Loading label="Loading your calendar" /></div>}
|
| 430 |
{items && items.length === 0 && (
|
| 431 |
<div className="lp-empty">
|
| 432 |
<p className="muted">Nothing scheduled this month.</p>
|
frontend/src/pages/NotebookView.tsx
CHANGED
|
@@ -440,7 +440,7 @@ export default function NotebookView() {
|
|
| 440 |
}, [nb]);
|
| 441 |
|
| 442 |
if (loadError) return <p className="error-text">{loadError}</p>;
|
| 443 |
-
if (!nb) return <Loading label="Opening notebook" />;
|
| 444 |
|
| 445 |
const cite = (c: Citation) => {
|
| 446 |
if (c.kind === "chat") setQuoted(c);
|
|
|
|
| 440 |
}, [nb]);
|
| 441 |
|
| 442 |
if (loadError) return <p className="error-text">{loadError}</p>;
|
| 443 |
+
if (!nb) return <Loading label="Opening notebook" center="full" />;
|
| 444 |
|
| 445 |
const cite = (c: Citation) => {
|
| 446 |
if (c.kind === "chat") setQuoted(c);
|
frontend/src/pages/Notebooks.tsx
CHANGED
|
@@ -136,7 +136,7 @@ export default function Notebooks() {
|
|
| 136 |
New notebook
|
| 137 |
</button>
|
| 138 |
</PageHeader>
|
| 139 |
-
{!notebooks && <Loading />}
|
| 140 |
<section className="nb-grid">
|
| 141 |
{notebooks?.map((n) => (
|
| 142 |
<article key={n.id} className={`card nb-card${n.is_archive ? " nb-archive" : ""}`}>
|
|
|
|
| 136 |
New notebook
|
| 137 |
</button>
|
| 138 |
</PageHeader>
|
| 139 |
+
{!notebooks && <Loading center="page" />}
|
| 140 |
<section className="nb-grid">
|
| 141 |
{notebooks?.map((n) => (
|
| 142 |
<article key={n.id} className={`card nb-card${n.is_archive ? " nb-archive" : ""}`}>
|
frontend/src/pages/Resurface.tsx
CHANGED
|
@@ -56,7 +56,7 @@ export function ResurfaceIdeas({ onScheduled }: { onScheduled?: () => void }) {
|
|
| 56 |
const live = useJob(job, () => load());
|
| 57 |
const running = live && (live.status === "queued" || live.status === "running");
|
| 58 |
|
| 59 |
-
if (!data) return <Loading />;
|
| 60 |
const items = [...data.on_this_day, ...data.evergreen.filter((e) => !data.on_this_day.some((o) => o.id === e.id))];
|
| 61 |
|
| 62 |
return (
|
|
|
|
| 56 |
const live = useJob(job, () => load());
|
| 57 |
const running = live && (live.status === "queued" || live.status === "running");
|
| 58 |
|
| 59 |
+
if (!data) return <Loading center="full" />;
|
| 60 |
const items = [...data.on_this_day, ...data.evergreen.filter((e) => !data.on_this_day.some((o) => o.id === e.id))];
|
| 61 |
|
| 62 |
return (
|
frontend/src/pages/Settings.tsx
CHANGED
|
@@ -74,7 +74,7 @@ export default function Settings() {
|
|
| 74 |
}
|
| 75 |
};
|
| 76 |
|
| 77 |
-
if (!s) return error ? <p className="error-text">{error}</p> : <Loading />;
|
| 78 |
const u = s.usage;
|
| 79 |
// Fields save on their own when the writer leaves them, so there are no Save buttons.
|
| 80 |
const saveProfile = () => {
|
|
|
|
| 74 |
}
|
| 75 |
};
|
| 76 |
|
| 77 |
+
if (!s) return error ? <p className="error-text">{error}</p> : <Loading center="full" />;
|
| 78 |
const u = s.usage;
|
| 79 |
// Fields save on their own when the writer leaves them, so there are no Save buttons.
|
| 80 |
const saveProfile = () => {
|
frontend/src/pages/Sources.tsx
CHANGED
|
@@ -227,8 +227,9 @@ export default function Sources() {
|
|
| 227 |
<AddSource onAdded={added} />
|
| 228 |
{error && <p className="error-text">{error}</p>}
|
| 229 |
|
|
|
|
|
|
|
| 230 |
<section className="grid-cards">
|
| 231 |
-
{!sources && <Loading />}
|
| 232 |
{sources?.map((s) => {
|
| 233 |
const job = jobs[s.id];
|
| 234 |
const active = job && (job.status === "queued" || job.status === "running");
|
|
|
|
| 227 |
<AddSource onAdded={added} />
|
| 228 |
{error && <p className="error-text">{error}</p>}
|
| 229 |
|
| 230 |
+
{/* Outside the card grid, so the loader is centered across the page rather than in the first column */}
|
| 231 |
+
{!sources && <Loading center="page" />}
|
| 232 |
<section className="grid-cards">
|
|
|
|
| 233 |
{sources?.map((s) => {
|
| 234 |
const job = jobs[s.id];
|
| 235 |
const active = job && (job.status === "queued" || job.status === "running");
|
frontend/src/pages/TopicMap.tsx
CHANGED
|
@@ -504,7 +504,7 @@ export default function TopicMap() {
|
|
| 504 |
</PageHeader>
|
| 505 |
{live && (running || live.status === "failed") && <JobProgress job={live} />}
|
| 506 |
{error && <p className="error-text">{error}</p>}
|
| 507 |
-
{!data && <Loading />}
|
| 508 |
{data && data.nodes.length === 0 && !running && (
|
| 509 |
<EmptyState
|
| 510 |
title="No constellations yet"
|
|
|
|
| 504 |
</PageHeader>
|
| 505 |
{live && (running || live.status === "failed") && <JobProgress job={live} />}
|
| 506 |
{error && <p className="error-text">{error}</p>}
|
| 507 |
+
{!data && <Loading center="page" />}
|
| 508 |
{data && data.nodes.length === 0 && !running && (
|
| 509 |
<EmptyState
|
| 510 |
title="No constellations yet"
|
frontend/src/pages/VideoCreate.tsx
CHANGED
|
@@ -16,7 +16,7 @@ import type {
|
|
| 16 |
} from "../api/types";
|
| 17 |
import { Dropdown } from "../components/Dropdown";
|
| 18 |
import { CheckIcon, SparkleIcon } from "../components/icons/Icons";
|
| 19 |
-
import { errorMessage, Loading, PageHeader } from "../components/ui";
|
| 20 |
import { SourcePicker, type VideoSource } from "../components/video/SourcePicker";
|
| 21 |
import { loadArchiveId, sourceCache } from "../components/video/sourceCache";
|
| 22 |
import { PlayButton, TemplateThumb, useAudio } from "../components/video/parts";
|
|
@@ -248,11 +248,11 @@ export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal
|
|
| 248 |
return (
|
| 249 |
<div className={inModal ? "vw-in-modal" : "page-wrap"}>
|
| 250 |
{!inModal && <PageHeader eyebrow="Videos" title="New video" />}
|
| 251 |
-
<
|
| 252 |
</div>
|
| 253 |
);
|
| 254 |
}
|
| 255 |
-
if (!config) return <Loading label="Loading" />;
|
| 256 |
if (!config.configured) {
|
| 257 |
return (
|
| 258 |
<div className={inModal ? "vw-in-modal" : "page-wrap"}>
|
|
@@ -371,7 +371,7 @@ export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal
|
|
| 371 |
</div>
|
| 372 |
)}
|
| 373 |
|
| 374 |
-
{error && <
|
| 375 |
<div className="vw-nav">
|
| 376 |
{/* Waits for the focus suggestions (shown, or none to show), so they are never skipped past */}
|
| 377 |
<button className="btn btn-primary vw-next" disabled={!sourceReady || shownTopics === null}
|
|
@@ -487,7 +487,7 @@ export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal
|
|
| 487 |
)}
|
| 488 |
</div>
|
| 489 |
|
| 490 |
-
{error && <
|
| 491 |
{left === 0 && (
|
| 492 |
<p className="notice">
|
| 493 |
You have used all your videos{quota?.resets_at ? " this period" : " on your plan"}.{" "}
|
|
|
|
| 16 |
} from "../api/types";
|
| 17 |
import { Dropdown } from "../components/Dropdown";
|
| 18 |
import { CheckIcon, SparkleIcon } from "../components/icons/Icons";
|
| 19 |
+
import { ErrorText, errorMessage, Loading, PageHeader } from "../components/ui";
|
| 20 |
import { SourcePicker, type VideoSource } from "../components/video/SourcePicker";
|
| 21 |
import { loadArchiveId, sourceCache } from "../components/video/sourceCache";
|
| 22 |
import { PlayButton, TemplateThumb, useAudio } from "../components/video/parts";
|
|
|
|
| 248 |
return (
|
| 249 |
<div className={inModal ? "vw-in-modal" : "page-wrap"}>
|
| 250 |
{!inModal && <PageHeader eyebrow="Videos" title="New video" />}
|
| 251 |
+
<ErrorText message={loadError} page />
|
| 252 |
</div>
|
| 253 |
);
|
| 254 |
}
|
| 255 |
+
if (!config) return <Loading label="Loading" center="full" />;
|
| 256 |
if (!config.configured) {
|
| 257 |
return (
|
| 258 |
<div className={inModal ? "vw-in-modal" : "page-wrap"}>
|
|
|
|
| 371 |
</div>
|
| 372 |
)}
|
| 373 |
|
| 374 |
+
{error && <ErrorText message={error} />}
|
| 375 |
<div className="vw-nav">
|
| 376 |
{/* Waits for the focus suggestions (shown, or none to show), so they are never skipped past */}
|
| 377 |
<button className="btn btn-primary vw-next" disabled={!sourceReady || shownTopics === null}
|
|
|
|
| 487 |
)}
|
| 488 |
</div>
|
| 489 |
|
| 490 |
+
{error && <ErrorText message={error} />}
|
| 491 |
{left === 0 && (
|
| 492 |
<p className="notice">
|
| 493 |
You have used all your videos{quota?.resets_at ? " this period" : " on your plan"}.{" "}
|
frontend/src/pages/VideoEditor.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { ApiError } from "../api/client";
|
|
| 4 |
import { videoEditApi, videosApi, type LayoutInfo, type LayoutSchema } from "../api/endpoints";
|
| 5 |
import type { LegacyVideo, ScriptScene, VideoCatalog, VideoConfig, VideoJobState, VideoProject, VideoStatus } from "../api/types";
|
| 6 |
import { CheckIcon, DownloadIcon, SparkleIcon } from "../components/icons/Icons";
|
| 7 |
-
import { ConfirmButton, errorMessage, Loading, Modal, PageHeader, StatusPill, Tabs } from "../components/ui";
|
| 8 |
import { ImagesPanel, VoicePanel, type PanelProps } from "../components/video/EditorPanels";
|
| 9 |
import { LookPanel } from "../components/video/SettingsPanel";
|
| 10 |
import { SceneList } from "../components/video/SceneList";
|
|
@@ -157,7 +157,7 @@ export default function VideoEditor() {
|
|
| 157 |
() =>
|
| 158 |
videosApi.get(id).then((p) => {
|
| 159 |
setProject(p);
|
| 160 |
-
setError((err) => (err?.startsWith(
|
| 161 |
}).catch((e) => {
|
| 162 |
if (e instanceof ApiError && e.status === 404) setNotFound(true);
|
| 163 |
else setError(errorMessage(e));
|
|
@@ -382,7 +382,7 @@ export default function VideoEditor() {
|
|
| 382 |
{failed && (
|
| 383 |
<section className="card stack">
|
| 384 |
<h2>This video could not be made</h2>
|
| 385 |
-
<
|
| 386 |
<p className="muted">It was not charged against your videos.</p>
|
| 387 |
<div className="row">
|
| 388 |
<Link to="/app/videos/new" className="btn btn-primary">Try again</Link>
|
|
@@ -415,7 +415,7 @@ export default function VideoEditor() {
|
|
| 415 |
</section>
|
| 416 |
)}
|
| 417 |
|
| 418 |
-
{error && <
|
| 419 |
{/* A background job (not a render, which shows its own progress) takes the editor's place until it ends */}
|
| 420 |
{fullScreenJob && running && <JobProgress label={running.label} name={title} state={job.data ?? null} />}
|
| 421 |
|
|
@@ -582,7 +582,7 @@ function ScriptReview({ id, premium, locked, onApproved }: { id: string; premium
|
|
| 582 |
</div>
|
| 583 |
</div>
|
| 584 |
))}
|
| 585 |
-
{error && <
|
| 586 |
<div className="row">
|
| 587 |
<button className="btn btn-primary" onClick={approve} disabled={!!busy || !scenes}>
|
| 588 |
{busy === "approve" ? "Starting..." : "Generate Video"}
|
|
|
|
| 4 |
import { videoEditApi, videosApi, type LayoutInfo, type LayoutSchema } from "../api/endpoints";
|
| 5 |
import type { LegacyVideo, ScriptScene, VideoCatalog, VideoConfig, VideoJobState, VideoProject, VideoStatus } from "../api/types";
|
| 6 |
import { CheckIcon, DownloadIcon, SparkleIcon } from "../components/icons/Icons";
|
| 7 |
+
import { ConfirmButton, ErrorText, errorMessage, Loading, Modal, PageHeader, StatusPill, Tabs, VIDEO_UPDATING } from "../components/ui";
|
| 8 |
import { ImagesPanel, VoicePanel, type PanelProps } from "../components/video/EditorPanels";
|
| 9 |
import { LookPanel } from "../components/video/SettingsPanel";
|
| 10 |
import { SceneList } from "../components/video/SceneList";
|
|
|
|
| 157 |
() =>
|
| 158 |
videosApi.get(id).then((p) => {
|
| 159 |
setProject(p);
|
| 160 |
+
setError((err) => (err?.startsWith(VIDEO_UPDATING) ? null : err));
|
| 161 |
}).catch((e) => {
|
| 162 |
if (e instanceof ApiError && e.status === 404) setNotFound(true);
|
| 163 |
else setError(errorMessage(e));
|
|
|
|
| 382 |
{failed && (
|
| 383 |
<section className="card stack">
|
| 384 |
<h2>This video could not be made</h2>
|
| 385 |
+
<ErrorText message={status?.error ?? "Generation failed."} />
|
| 386 |
<p className="muted">It was not charged against your videos.</p>
|
| 387 |
<div className="row">
|
| 388 |
<Link to="/app/videos/new" className="btn btn-primary">Try again</Link>
|
|
|
|
| 415 |
</section>
|
| 416 |
)}
|
| 417 |
|
| 418 |
+
{error && <ErrorText message={error} />}
|
| 419 |
{/* A background job (not a render, which shows its own progress) takes the editor's place until it ends */}
|
| 420 |
{fullScreenJob && running && <JobProgress label={running.label} name={title} state={job.data ?? null} />}
|
| 421 |
|
|
|
|
| 582 |
</div>
|
| 583 |
</div>
|
| 584 |
))}
|
| 585 |
+
{error && <ErrorText message={error} />}
|
| 586 |
<div className="row">
|
| 587 |
<button className="btn btn-primary" onClick={approve} disabled={!!busy || !scenes}>
|
| 588 |
{busy === "approve" ? "Starting..." : "Generate Video"}
|
frontend/src/pages/VideoTemplateEditor.tsx
CHANGED
|
@@ -97,7 +97,7 @@ export default function VideoTemplateEditor() {
|
|
| 97 |
</div>
|
| 98 |
);
|
| 99 |
}
|
| 100 |
-
if (!tpl) return error ? <p className="error-text">{error}</p> : <Loading label="Loading template" />;
|
| 101 |
|
| 102 |
const premium = !!config?.premium;
|
| 103 |
const sceneKeys = ["intro", ...(tpl.content_codes ?? []).map((_, i) => `content_${i}`), "outro"];
|
|
|
|
| 97 |
</div>
|
| 98 |
);
|
| 99 |
}
|
| 100 |
+
if (!tpl) return error ? <p className="error-text">{error}</p> : <Loading label="Loading template" center="full" />;
|
| 101 |
|
| 102 |
const premium = !!config?.premium;
|
| 103 |
const sceneKeys = ["intro", ...(tpl.content_codes ?? []).map((_, i) => `content_${i}`), "outro"];
|
frontend/src/pages/VideoTemplates.tsx
CHANGED
|
@@ -25,7 +25,7 @@ export default function VideoTemplates() {
|
|
| 25 |
</p>
|
| 26 |
)}
|
| 27 |
{error && <p className="error-text">{error}</p>}
|
| 28 |
-
{!data && !error && <Loading label="Loading templates" />}
|
| 29 |
{data?.templates.length === 0 && (
|
| 30 |
<EmptyState title="No templates yet"
|
| 31 |
body="Make a template from your website, a brand document or a description. Your videos then match your brand."
|
|
|
|
| 25 |
</p>
|
| 26 |
)}
|
| 27 |
{error && <p className="error-text">{error}</p>}
|
| 28 |
+
{!data && !error && <Loading label="Loading templates" center="page" />}
|
| 29 |
{data?.templates.length === 0 && (
|
| 30 |
<EmptyState title="No templates yet"
|
| 31 |
body="Make a template from your website, a brand document or a description. Your videos then match your brand."
|
frontend/src/pages/Videos.tsx
CHANGED
|
@@ -94,7 +94,7 @@ export default function Videos() {
|
|
| 94 |
</div>
|
| 95 |
{notice && <p className="notice">{notice}</p>}
|
| 96 |
{error && <p className="error-text">{error}</p>}
|
| 97 |
-
{!videos && !error && <div className="loading-center
|
| 98 |
{videos?.length === 0 && (
|
| 99 |
<EmptyState
|
| 100 |
title="No videos yet"
|
|
|
|
| 94 |
</div>
|
| 95 |
{notice && <p className="notice">{notice}</p>}
|
| 96 |
{error && <p className="error-text">{error}</p>}
|
| 97 |
+
{!videos && !error && <div className="loading-center"><Loading label="Loading videos" /></div>}
|
| 98 |
{videos?.length === 0 && (
|
| 99 |
<EmptyState
|
| 100 |
title="No videos yet"
|
frontend/src/pages/VoiceProfile.tsx
CHANGED
|
@@ -488,7 +488,7 @@ export default function VoiceProfile() {
|
|
| 488 |
loadVoices(); // a finished clone is saved to Your voices as "My voice"
|
| 489 |
});
|
| 490 |
|
| 491 |
-
if (!state) return <div className="loading-center
|
| 492 |
const building = build && (build.status === "queued" || build.status === "running");
|
| 493 |
const cloneRunning = clone && (clone.status === "queued" || clone.status === "running");
|
| 494 |
const showClone = state.clone.status !== "none" || Boolean(clone);
|
|
|
|
| 488 |
loadVoices(); // a finished clone is saved to Your voices as "My voice"
|
| 489 |
});
|
| 490 |
|
| 491 |
+
if (!state) return <div className="loading-center"><Loading label="Loading your voice" /></div>;
|
| 492 |
const building = build && (build.status === "queued" || build.status === "running");
|
| 493 |
const cloneRunning = clone && (clone.status === "queued" || clone.status === "running");
|
| 494 |
const showClone = state.clone.status !== "none" || Boolean(clone);
|
frontend/src/styles/pages.css
CHANGED
|
@@ -446,11 +446,15 @@ select.input option {
|
|
| 446 |
height: 4px;
|
| 447 |
}
|
| 448 |
|
|
|
|
| 449 |
.loading {
|
| 450 |
display: flex;
|
|
|
|
| 451 |
align-items: center;
|
| 452 |
-
|
|
|
|
| 453 |
padding: 24px 0;
|
|
|
|
| 454 |
}
|
| 455 |
|
| 456 |
/* A page's first load: the loader in the middle of the space under the header */
|
|
@@ -475,10 +479,17 @@ select.input option {
|
|
| 475 |
min-height: 200px;
|
| 476 |
}
|
| 477 |
|
| 478 |
-
/* The
|
| 479 |
-
.
|
| 480 |
-
|
| 481 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
}
|
| 483 |
|
| 484 |
.empty-state h3 {
|
|
@@ -4339,14 +4350,6 @@ select.input,
|
|
| 4339 |
min-height: calc(4.5 * 64px + 4 * 6px);
|
| 4340 |
}
|
| 4341 |
|
| 4342 |
-
/* These loaders (Library, Create a launch kit, opening a kit): the text under the spinner, centered */
|
| 4343 |
-
.lp-loading .loading,
|
| 4344 |
-
.lk-loading .loading,
|
| 4345 |
-
.loading-center.stacked .loading {
|
| 4346 |
-
flex-direction: column;
|
| 4347 |
-
text-align: center;
|
| 4348 |
-
}
|
| 4349 |
-
|
| 4350 |
.lp-loading.fill {
|
| 4351 |
min-height: max(calc(4.5 * 64px + 4 * 6px), calc(100dvh - 340px));
|
| 4352 |
}
|
|
|
|
| 446 |
height: 4px;
|
| 447 |
}
|
| 448 |
|
| 449 |
+
/* Every loader: the spinner with its label underneath, both centered */
|
| 450 |
.loading {
|
| 451 |
display: flex;
|
| 452 |
+
flex-direction: column;
|
| 453 |
align-items: center;
|
| 454 |
+
justify-content: center;
|
| 455 |
+
gap: 16px;
|
| 456 |
padding: 24px 0;
|
| 457 |
+
text-align: center;
|
| 458 |
}
|
| 459 |
|
| 460 |
/* A page's first load: the loader in the middle of the space under the header */
|
|
|
|
| 479 |
min-height: 200px;
|
| 480 |
}
|
| 481 |
|
| 482 |
+
/* "The video service is being updated": a centered status line, not an error */
|
| 483 |
+
.service-notice {
|
| 484 |
+
display: grid;
|
| 485 |
+
place-items: center;
|
| 486 |
+
padding: 24px 0;
|
| 487 |
+
text-align: center;
|
| 488 |
+
color: var(--white);
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
.service-notice.page {
|
| 492 |
+
min-height: 60vh;
|
| 493 |
}
|
| 494 |
|
| 495 |
.empty-state h3 {
|
|
|
|
| 4350 |
min-height: calc(4.5 * 64px + 4 * 6px);
|
| 4351 |
}
|
| 4352 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4353 |
.lp-loading.fill {
|
| 4354 |
min-height: max(calc(4.5 * 64px + 4 * 6px), calc(100dvh - 340px));
|
| 4355 |
}
|
renderer/package.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
| 5 |
"type": "module",
|
| 6 |
"scripts": {
|
| 7 |
"studio": "remotion studio src/index.ts",
|
| 8 |
-
"start": "tsx --env-file-if-exists=../.env server/index.ts",
|
| 9 |
"typecheck": "tsc --noEmit"
|
| 10 |
},
|
| 11 |
"dependencies": {
|
|
|
|
| 5 |
"type": "module",
|
| 6 |
"scripts": {
|
| 7 |
"studio": "remotion studio src/index.ts",
|
| 8 |
+
"start": "tsx --env-file-if-exists=../.env --env-file-if-exists=../backend/.env server/index.ts",
|
| 9 |
"typecheck": "tsc --noEmit"
|
| 10 |
},
|
| 11 |
"dependencies": {
|