Spaces:
Runtime error
Runtime error
Commit ·
0bf4415
1
Parent(s): 93dfca2
LETS GOOOOOOOOOOOOOOOOOOOOOOOOO
Browse files- backend/app/pipeline/ingest.py +11 -5
- backend/app/services/tts.py +23 -0
- backend/app/worker.py +2 -0
- frontend/src/pages/Welcome.tsx +180 -34
- frontend/src/styles/pages.css +62 -0
backend/app/pipeline/ingest.py
CHANGED
|
@@ -578,30 +578,35 @@ def store_entries(
|
|
| 578 |
def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict:
|
| 579 |
source.sync_status = "syncing"
|
| 580 |
update_job(db, job, status="running", progress=0.02, message="Contacting your feed")
|
|
|
|
|
|
|
| 581 |
|
| 582 |
if source.feed_url.startswith(SITE_PREFIX):
|
| 583 |
update_job(db, job, message="Mapping your site")
|
| 584 |
entries = crawl_site(
|
| 585 |
-
source.feed_url.removeprefix(SITE_PREFIX),
|
| 586 |
on_found=lambda n: update_job(db, job, progress=0.05, message=f"Found {n} pages, reading them"),
|
| 587 |
on_post=lambda done, total: update_job(db, job, progress=0.05 + 0.05 * done / total,
|
| 588 |
message=f"Read {done} of {total} pages"),
|
| 589 |
)
|
| 590 |
title = None
|
| 591 |
else:
|
| 592 |
-
title, entries = fetch_feed(source.feed_url,
|
| 593 |
source.title = source.title or title
|
| 594 |
-
if source.platform == "substack" and len(entries) <
|
| 595 |
site = f"{urlparse(source.feed_url).scheme}://{urlparse(source.feed_url).netloc}"
|
| 596 |
update_job(db, job, progress=0.05, message="Reading your Substack archive")
|
| 597 |
try:
|
| 598 |
entries += fetch_substack_archive(
|
| 599 |
-
site, {e.url for e in entries},
|
| 600 |
on_page=lambda n: update_job(db, job, message=f"Found {len(entries) + n} posts in the archive"),
|
| 601 |
)
|
| 602 |
except (httpx.HTTPError, ValueError):
|
| 603 |
log.warning("substack archive fetch failed for %s", site, exc_info=True)
|
| 604 |
|
|
|
|
|
|
|
|
|
|
| 605 |
def progress(done: int, total: int, post_title: str) -> None:
|
| 606 |
update_job(db, job, progress=0.1 + 0.8 * done / total, message=f"{post_title[:80]} is in orbit")
|
| 607 |
|
|
@@ -612,7 +617,8 @@ def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict
|
|
| 612 |
source.sync_error = None
|
| 613 |
source.last_synced_at = datetime.now(UTC)
|
| 614 |
db.commit()
|
| 615 |
-
return {"indexed": indexed, "skipped": skipped, "found": len(entries), "
|
|
|
|
| 616 |
|
| 617 |
|
| 618 |
def entry_from_upload(filename: str, content_type: str, data: bytes) -> FeedEntry:
|
|
|
|
| 578 |
def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict:
|
| 579 |
source.sync_status = "syncing"
|
| 580 |
update_job(db, job, status="running", progress=0.02, message="Contacting your feed")
|
| 581 |
+
# Fetch one past the plan cap so we can tell the user their archive holds more than was indexed.
|
| 582 |
+
fetch_limit = max_posts + 1
|
| 583 |
|
| 584 |
if source.feed_url.startswith(SITE_PREFIX):
|
| 585 |
update_job(db, job, message="Mapping your site")
|
| 586 |
entries = crawl_site(
|
| 587 |
+
source.feed_url.removeprefix(SITE_PREFIX), fetch_limit,
|
| 588 |
on_found=lambda n: update_job(db, job, progress=0.05, message=f"Found {n} pages, reading them"),
|
| 589 |
on_post=lambda done, total: update_job(db, job, progress=0.05 + 0.05 * done / total,
|
| 590 |
message=f"Read {done} of {total} pages"),
|
| 591 |
)
|
| 592 |
title = None
|
| 593 |
else:
|
| 594 |
+
title, entries = fetch_feed(source.feed_url, fetch_limit)
|
| 595 |
source.title = source.title or title
|
| 596 |
+
if source.platform == "substack" and len(entries) < fetch_limit:
|
| 597 |
site = f"{urlparse(source.feed_url).scheme}://{urlparse(source.feed_url).netloc}"
|
| 598 |
update_job(db, job, progress=0.05, message="Reading your Substack archive")
|
| 599 |
try:
|
| 600 |
entries += fetch_substack_archive(
|
| 601 |
+
site, {e.url for e in entries}, fetch_limit,
|
| 602 |
on_page=lambda n: update_job(db, job, message=f"Found {len(entries) + n} posts in the archive"),
|
| 603 |
)
|
| 604 |
except (httpx.HTTPError, ValueError):
|
| 605 |
log.warning("substack archive fetch failed for %s", site, exc_info=True)
|
| 606 |
|
| 607 |
+
capped = len(entries) > max_posts
|
| 608 |
+
entries = entries[:max_posts]
|
| 609 |
+
|
| 610 |
def progress(done: int, total: int, post_title: str) -> None:
|
| 611 |
update_job(db, job, progress=0.1 + 0.8 * done / total, message=f"{post_title[:80]} is in orbit")
|
| 612 |
|
|
|
|
| 617 |
source.sync_error = None
|
| 618 |
source.last_synced_at = datetime.now(UTC)
|
| 619 |
db.commit()
|
| 620 |
+
return {"indexed": indexed, "skipped": skipped, "found": len(entries), "limit": max_posts, "capped": capped,
|
| 621 |
+
"changed": [str(i) for i in changed]}
|
| 622 |
|
| 623 |
|
| 624 |
def entry_from_upload(filename: str, content_type: str, data: bytes) -> FeedEntry:
|
backend/app/services/tts.py
CHANGED
|
@@ -289,6 +289,29 @@ def clone_voice(name: str, samples: list[tuple[str, bytes, str]], *, remove_back
|
|
| 289 |
return resp.json()["voice_id"]
|
| 290 |
|
| 291 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
def delete_voice(voice_id: str) -> None:
|
| 293 |
_request("DELETE", f"/voices/{voice_id}", attempts=2, timeout=30)
|
| 294 |
|
|
|
|
| 289 |
return resp.json()["voice_id"]
|
| 290 |
|
| 291 |
|
| 292 |
+
def design_voice(description: str) -> list[dict]:
|
| 293 |
+
"""Voice Design: three short previews of a new voice from a text description.
|
| 294 |
+
Each preview's generated_voice_id is temporary until saved with create_designed_voice."""
|
| 295 |
+
data = _request("POST", "/text-to-voice/design", attempts=2, timeout=90,
|
| 296 |
+
json={"voice_description": description[:1000], "auto_generate_text": True}).json()
|
| 297 |
+
return [
|
| 298 |
+
{"generated_voice_id": p["generated_voice_id"], "audio": base64.b64decode(p["audio_base_64"]),
|
| 299 |
+
"seconds": p.get("duration_secs")}
|
| 300 |
+
for p in data.get("previews", []) if p.get("generated_voice_id") and p.get("audio_base_64")
|
| 301 |
+
]
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def create_designed_voice(name: str, description: str, generated_voice_id: str) -> str:
|
| 305 |
+
"""Turns a Voice Design preview into a permanent voice on the account."""
|
| 306 |
+
resp = _request("POST", "/text-to-voice/create-voice-from-preview", attempts=2, timeout=30, json={
|
| 307 |
+
"voice_name": name[:100],
|
| 308 |
+
"voice_description": (description or name)[:1000],
|
| 309 |
+
"generated_voice_id": generated_voice_id,
|
| 310 |
+
"labels": {"source": "notestack"},
|
| 311 |
+
})
|
| 312 |
+
return resp.json()["voice_id"]
|
| 313 |
+
|
| 314 |
+
|
| 315 |
def delete_voice(voice_id: str) -> None:
|
| 316 |
_request("DELETE", f"/voices/{voice_id}", attempts=2, timeout=30)
|
| 317 |
|
backend/app/worker.py
CHANGED
|
@@ -91,6 +91,8 @@ def handle_ingest(db: Session, job: Job):
|
|
| 91 |
raise PermanentJobError(str(exc)) from exc
|
| 92 |
raise
|
| 93 |
_after_import(db, job, source.workspace_id, result.get("changed", []))
|
|
|
|
|
|
|
| 94 |
return Done(result, f"All posts in orbit: {result['indexed']} new or updated")
|
| 95 |
|
| 96 |
|
|
|
|
| 91 |
raise PermanentJobError(str(exc)) from exc
|
| 92 |
raise
|
| 93 |
_after_import(db, job, source.workspace_id, result.get("changed", []))
|
| 94 |
+
if result.get("capped"):
|
| 95 |
+
return Done(result, f"First {result['limit']} posts in orbit: your plan's limit. Upgrade to index the rest")
|
| 96 |
return Done(result, f"All posts in orbit: {result['indexed']} new or updated")
|
| 97 |
|
| 98 |
|
frontend/src/pages/Welcome.tsx
CHANGED
|
@@ -1,16 +1,25 @@
|
|
| 1 |
import { useEffect, useRef, useState, type FormEvent } from "react";
|
| 2 |
import { Link, Navigate, useNavigate } from "react-router-dom";
|
| 3 |
-
import { docsApi, notebooksApi, sourcesApi } from "../api/endpoints";
|
| 4 |
import type { Job, Source } from "../api/types";
|
| 5 |
import Logo from "../components/Logo";
|
| 6 |
import SkyCanvas from "../components/SkyCanvas";
|
| 7 |
-
import { errorMessage, JobProgress } from "../components/ui";
|
| 8 |
import { useAuth } from "../hooks/useAuth";
|
| 9 |
import { useJob } from "../hooks/useJob";
|
| 10 |
import { PENDING_SOURCE_KEY } from "./Landing";
|
| 11 |
|
| 12 |
type Step = "connect" | "indexing" | "goal" | "ready";
|
| 13 |
type Goal = "ask" | "audio" | "launch";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
const EXAMPLES = ["yourblog.com", "yourname.substack.com", "medium.com/@you"];
|
| 16 |
|
|
@@ -40,11 +49,25 @@ function takePending(): string {
|
|
| 40 |
}
|
| 41 |
}
|
| 42 |
|
| 43 |
-
/**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
export default function Welcome() {
|
| 45 |
const { user, loading } = useAuth();
|
| 46 |
const navigate = useNavigate();
|
| 47 |
const [step, setStep] = useState<Step>("connect");
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
const [url, setUrl] = useState(takePending);
|
| 49 |
const [busy, setBusy] = useState(false);
|
| 50 |
const [error, setError] = useState<string | null>(null);
|
|
@@ -56,27 +79,57 @@ export default function Welcome() {
|
|
| 56 |
|
| 57 |
const live = useJob(job, async (j) => {
|
| 58 |
if (j.status !== "done") return;
|
|
|
|
| 59 |
const list = await docsApi.list({ limit: 1 });
|
| 60 |
setPosts(list.total);
|
| 61 |
setStep((s) => (s === "indexing" ? "goal" : s === "goal" ? "goal" : "ready"));
|
| 62 |
});
|
| 63 |
|
| 64 |
-
const connect = async (value: string) => {
|
| 65 |
if (!value.trim()) return;
|
| 66 |
setBusy(true);
|
| 67 |
setError(null);
|
| 68 |
try {
|
| 69 |
-
const res = await sourcesApi.connect(value.trim());
|
|
|
|
| 70 |
setSource(res.source);
|
| 71 |
setJob(res.job);
|
| 72 |
setStep("indexing");
|
| 73 |
} catch (e) {
|
| 74 |
-
setError(errorMessage(e, "We could not reach that address."));
|
| 75 |
} finally {
|
| 76 |
setBusy(false);
|
| 77 |
}
|
| 78 |
};
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
// A URL pasted on the landing page starts connecting straight away.
|
| 81 |
useEffect(() => {
|
| 82 |
if (url && !autoStarted.current && user) {
|
|
@@ -91,6 +144,9 @@ export default function Welcome() {
|
|
| 91 |
|
| 92 |
const indexingDone = live?.status === "done";
|
| 93 |
const indexingFailed = live?.status === "failed";
|
|
|
|
|
|
|
|
|
|
| 94 |
const first = (user.name ?? user.email).split(/[ @]/)[0];
|
| 95 |
|
| 96 |
const finish = async (chosen: Goal | null) => {
|
|
@@ -109,7 +165,8 @@ export default function Welcome() {
|
|
| 109 |
|
| 110 |
const submit = (e: FormEvent) => {
|
| 111 |
e.preventDefault();
|
| 112 |
-
void
|
|
|
|
| 113 |
};
|
| 114 |
|
| 115 |
const stepIndex = { connect: 0, indexing: 1, goal: 2, ready: 3 }[step];
|
|
@@ -136,40 +193,93 @@ export default function Welcome() {
|
|
| 136 |
{step === "connect" && (
|
| 137 |
<section className="card welcome-card">
|
| 138 |
<p className="eyebrow">Welcome aboard, {first}</p>
|
| 139 |
-
<h1>Where
|
| 140 |
-
<p className="muted">
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
</p>
|
| 144 |
<form onSubmit={submit} className="welcome-form">
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
</button>
|
| 156 |
</form>
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
{ex}
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
|
|
|
|
|
|
| 165 |
{error && (
|
| 166 |
<p className="error-text" role="alert">
|
| 167 |
{error}
|
| 168 |
</p>
|
| 169 |
)}
|
| 170 |
-
<p className="muted small">
|
| 171 |
-
Writing in markdown or no feed? <Link to="/app/sources">Upload markdown, text, HTML or PDF files, or import single pages</Link> instead.
|
| 172 |
-
</p>
|
| 173 |
</section>
|
| 174 |
)}
|
| 175 |
|
|
@@ -181,7 +291,7 @@ export default function Welcome() {
|
|
| 181 |
{indexingFailed ? (
|
| 182 |
<div className="row">
|
| 183 |
<button className="btn btn-primary" onClick={() => setStep("connect")}>
|
| 184 |
-
Try
|
| 185 |
</button>
|
| 186 |
<Link className="btn" to="/app/sources">
|
| 187 |
Import posts another way
|
|
@@ -200,8 +310,26 @@ export default function Welcome() {
|
|
| 200 |
|
| 201 |
{step === "goal" && (
|
| 202 |
<section className="card welcome-card">
|
| 203 |
-
<p className="eyebrow">{indexingDone ? `${posts} posts indexed` : "Still indexing in the background"}</p>
|
| 204 |
<h1>What should we launch first?</h1>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
<div className="goal-grid">
|
| 206 |
{GOALS.map((g) => (
|
| 207 |
<button key={g.id} type="button" className={`goal${goal === g.id ? " on" : ""}`} onClick={() => setGoal(g.id)} aria-pressed={goal === g.id}>
|
|
@@ -224,6 +352,24 @@ export default function Welcome() {
|
|
| 224 |
<p className="eyebrow">Pre-flight check complete</p>
|
| 225 |
<h1>{indexingDone ? `${posts} posts are in orbit` : "Almost there"}</h1>
|
| 226 |
{!indexingDone && live && <JobProgress job={live} />}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
<p className="muted">
|
| 228 |
{goal === "ask" && "We will put your whole archive in a notebook so you can start asking right away."}
|
| 229 |
{goal === "audio" && "We will put your archive in a notebook with the audio studio ready."}
|
|
|
|
| 1 |
import { useEffect, useRef, useState, type FormEvent } from "react";
|
| 2 |
import { Link, Navigate, useNavigate } from "react-router-dom";
|
| 3 |
+
import { docsApi, jobsApi, notebooksApi, sourcesApi } from "../api/endpoints";
|
| 4 |
import type { Job, Source } from "../api/types";
|
| 5 |
import Logo from "../components/Logo";
|
| 6 |
import SkyCanvas from "../components/SkyCanvas";
|
| 7 |
+
import { errorMessage, JobProgress, Tabs } from "../components/ui";
|
| 8 |
import { useAuth } from "../hooks/useAuth";
|
| 9 |
import { useJob } from "../hooks/useJob";
|
| 10 |
import { PENDING_SOURCE_KEY } from "./Landing";
|
| 11 |
|
| 12 |
type Step = "connect" | "indexing" | "goal" | "ready";
|
| 13 |
type Goal = "ask" | "audio" | "launch";
|
| 14 |
+
type Mode = "feed" | "url" | "file";
|
| 15 |
+
|
| 16 |
+
const MODES: { id: Mode; label: string }[] = [
|
| 17 |
+
{ id: "feed", label: "Blog or newsletter" },
|
| 18 |
+
{ id: "url", label: "One article" },
|
| 19 |
+
{ id: "file", label: "Upload files" },
|
| 20 |
+
];
|
| 21 |
+
|
| 22 |
+
const FILE_ACCEPT = ".md,.markdown,.txt,.html,.htm,.pdf,text/markdown,text/plain,text/html,application/pdf";
|
| 23 |
|
| 24 |
const EXAMPLES = ["yourblog.com", "yourname.substack.com", "medium.com/@you"];
|
| 25 |
|
|
|
|
| 49 |
}
|
| 50 |
}
|
| 51 |
|
| 52 |
+
/** Resolves once every job has finished, so a multi-file upload only counts as indexed when all files are in. */
|
| 53 |
+
async function waitForJobs(ids: string[]): Promise<void> {
|
| 54 |
+
let pending = ids;
|
| 55 |
+
while (pending.length) {
|
| 56 |
+
const jobs = await Promise.all(pending.map((id) => jobsApi.get(id).catch(() => null)));
|
| 57 |
+
pending = pending.filter((_, i) => jobs[i] && jobs[i]!.status !== "done" && jobs[i]!.status !== "failed");
|
| 58 |
+
if (pending.length) await new Promise((r) => setTimeout(r, 1500));
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/** First run: connect a blog, import an article or upload files, watch it index, pick what to do first, land somewhere useful. */
|
| 63 |
export default function Welcome() {
|
| 64 |
const { user, loading } = useAuth();
|
| 65 |
const navigate = useNavigate();
|
| 66 |
const [step, setStep] = useState<Step>("connect");
|
| 67 |
+
const [mode, setMode] = useState<Mode>("feed");
|
| 68 |
+
const [files, setFiles] = useState<File[]>([]);
|
| 69 |
+
const [dragging, setDragging] = useState(false);
|
| 70 |
+
const earlierJobs = useRef<string[]>([]);
|
| 71 |
const [url, setUrl] = useState(takePending);
|
| 72 |
const [busy, setBusy] = useState(false);
|
| 73 |
const [error, setError] = useState<string | null>(null);
|
|
|
|
| 79 |
|
| 80 |
const live = useJob(job, async (j) => {
|
| 81 |
if (j.status !== "done") return;
|
| 82 |
+
await waitForJobs(earlierJobs.current);
|
| 83 |
const list = await docsApi.list({ limit: 1 });
|
| 84 |
setPosts(list.total);
|
| 85 |
setStep((s) => (s === "indexing" ? "goal" : s === "goal" ? "goal" : "ready"));
|
| 86 |
});
|
| 87 |
|
| 88 |
+
const connect = async (value: string, as: Mode = "feed") => {
|
| 89 |
if (!value.trim()) return;
|
| 90 |
setBusy(true);
|
| 91 |
setError(null);
|
| 92 |
try {
|
| 93 |
+
const res = as === "url" ? await sourcesApi.importUrl(value.trim()) : await sourcesApi.connect(value.trim());
|
| 94 |
+
earlierJobs.current = [];
|
| 95 |
setSource(res.source);
|
| 96 |
setJob(res.job);
|
| 97 |
setStep("indexing");
|
| 98 |
} catch (e) {
|
| 99 |
+
setError(errorMessage(e, as === "url" ? "We could not import that page." : "We could not reach that address."));
|
| 100 |
} finally {
|
| 101 |
setBusy(false);
|
| 102 |
}
|
| 103 |
};
|
| 104 |
|
| 105 |
+
const upload = async () => {
|
| 106 |
+
if (!files.length) return;
|
| 107 |
+
setBusy(true);
|
| 108 |
+
setError(null);
|
| 109 |
+
try {
|
| 110 |
+
// One import job per file: the last drives the progress bar, the rest are awaited before moving on.
|
| 111 |
+
const jobs: Job[] = [];
|
| 112 |
+
let res: Awaited<ReturnType<typeof sourcesApi.importFile>> | null = null;
|
| 113 |
+
for (const f of files) {
|
| 114 |
+
res = await sourcesApi.importFile(f);
|
| 115 |
+
jobs.push(res.job);
|
| 116 |
+
}
|
| 117 |
+
earlierJobs.current = jobs.slice(0, -1).map((j) => j.id);
|
| 118 |
+
setSource(res!.source);
|
| 119 |
+
setJob(res!.job);
|
| 120 |
+
setStep("indexing");
|
| 121 |
+
} catch (e) {
|
| 122 |
+
setError(errorMessage(e, "That upload did not go through."));
|
| 123 |
+
} finally {
|
| 124 |
+
setBusy(false);
|
| 125 |
+
}
|
| 126 |
+
};
|
| 127 |
+
|
| 128 |
+
const addFiles = (list: FileList | null) => {
|
| 129 |
+
const picked = Array.from(list ?? []);
|
| 130 |
+
if (picked.length) setFiles((cur) => [...cur, ...picked.filter((f) => !cur.some((c) => c.name === f.name && c.size === f.size))]);
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
// A URL pasted on the landing page starts connecting straight away.
|
| 134 |
useEffect(() => {
|
| 135 |
if (url && !autoStarted.current && user) {
|
|
|
|
| 144 |
|
| 145 |
const indexingDone = live?.status === "done";
|
| 146 |
const indexingFailed = live?.status === "failed";
|
| 147 |
+
// The ingest job reports when the plan's post cap cut the archive short.
|
| 148 |
+
const capped = live?.result?.capped === true;
|
| 149 |
+
const capLimit = Number(live?.result?.limit ?? posts);
|
| 150 |
const first = (user.name ?? user.email).split(/[ @]/)[0];
|
| 151 |
|
| 152 |
const finish = async (chosen: Goal | null) => {
|
|
|
|
| 165 |
|
| 166 |
const submit = (e: FormEvent) => {
|
| 167 |
e.preventDefault();
|
| 168 |
+
if (mode === "file") void upload();
|
| 169 |
+
else void connect(url, mode);
|
| 170 |
};
|
| 171 |
|
| 172 |
const stepIndex = { connect: 0, indexing: 1, goal: 2, ready: 3 }[step];
|
|
|
|
| 193 |
{step === "connect" && (
|
| 194 |
<section className="card welcome-card">
|
| 195 |
<p className="eyebrow">Welcome aboard, {first}</p>
|
| 196 |
+
<h1>Where does your writing live?</h1>
|
| 197 |
+
<p className="muted">Bring it in however suits you. We turn it into a research notebook you can question, hear and launch from.</p>
|
| 198 |
+
<Tabs<Mode>
|
| 199 |
+
tabs={MODES}
|
| 200 |
+
value={mode}
|
| 201 |
+
onChange={(m) => {
|
| 202 |
+
setMode(m);
|
| 203 |
+
setError(null);
|
| 204 |
+
}}
|
| 205 |
+
/>
|
| 206 |
+
<p className="muted small">
|
| 207 |
+
{mode === "feed" && "Substack, Ghost, WordPress, Medium or anything with a feed. We find the feed, pull in your archive and keep it in sync."}
|
| 208 |
+
{mode === "url" && "Any single article on the web. Add more later from Sources."}
|
| 209 |
+
{mode === "file" && "Markdown, text, HTML or PDF: drafts, exports and posts that never had a feed. Add as many as you like."}
|
| 210 |
</p>
|
| 211 |
<form onSubmit={submit} className="welcome-form">
|
| 212 |
+
{mode === "file" ? (
|
| 213 |
+
<label
|
| 214 |
+
className={`welcome-drop${dragging ? " over" : ""}`}
|
| 215 |
+
onDragOver={(e) => {
|
| 216 |
+
e.preventDefault();
|
| 217 |
+
setDragging(true);
|
| 218 |
+
}}
|
| 219 |
+
onDragLeave={() => setDragging(false)}
|
| 220 |
+
onDrop={(e) => {
|
| 221 |
+
e.preventDefault();
|
| 222 |
+
setDragging(false);
|
| 223 |
+
addFiles(e.dataTransfer.files);
|
| 224 |
+
}}
|
| 225 |
+
>
|
| 226 |
+
<input
|
| 227 |
+
type="file"
|
| 228 |
+
multiple
|
| 229 |
+
accept={FILE_ACCEPT}
|
| 230 |
+
onChange={(e) => {
|
| 231 |
+
addFiles(e.target.files);
|
| 232 |
+
e.target.value = "";
|
| 233 |
+
}}
|
| 234 |
+
aria-label="Files to upload"
|
| 235 |
+
/>
|
| 236 |
+
<strong>Drop files here or click to choose</strong>
|
| 237 |
+
<span className="mono muted small">.md · .txt · .html · .pdf</span>
|
| 238 |
+
</label>
|
| 239 |
+
) : (
|
| 240 |
+
<input
|
| 241 |
+
key={mode}
|
| 242 |
+
className="input"
|
| 243 |
+
autoFocus
|
| 244 |
+
placeholder={mode === "feed" ? "yourblog.com" : "https://example.com/my-essay"}
|
| 245 |
+
value={url}
|
| 246 |
+
onChange={(e) => setUrl(e.target.value)}
|
| 247 |
+
aria-label={mode === "feed" ? "Blog, newsletter or site address" : "Article URL"}
|
| 248 |
+
/>
|
| 249 |
+
)}
|
| 250 |
+
{mode === "file" && files.length > 0 && (
|
| 251 |
+
<ul className="welcome-files mono small">
|
| 252 |
+
{files.map((f) => (
|
| 253 |
+
<li key={`${f.name}-${f.size}`}>
|
| 254 |
+
<span>{f.name}</span>
|
| 255 |
+
<button type="button" className="link-btn small" onClick={() => setFiles(files.filter((x) => x !== f))} aria-label={`Remove ${f.name}`}>
|
| 256 |
+
Remove
|
| 257 |
+
</button>
|
| 258 |
+
</li>
|
| 259 |
+
))}
|
| 260 |
+
</ul>
|
| 261 |
+
)}
|
| 262 |
+
<button className="btn btn-primary" disabled={busy || (mode === "file" ? !files.length : !url.trim())}>
|
| 263 |
+
{mode === "feed" && (busy ? "Finding your feed..." : "Connect")}
|
| 264 |
+
{mode === "url" && (busy ? "Importing..." : "Import")}
|
| 265 |
+
{mode === "file" && (busy ? "Uploading..." : files.length === 1 ? "Upload 1 file" : `Upload ${files.length || ""} files`.replace(" ", " "))}
|
| 266 |
</button>
|
| 267 |
</form>
|
| 268 |
+
{mode === "feed" && (
|
| 269 |
+
<div className="welcome-examples mono muted">
|
| 270 |
+
Try:{" "}
|
| 271 |
+
{EXAMPLES.map((ex) => (
|
| 272 |
+
<button key={ex} type="button" className="chip" onClick={() => setUrl(ex)}>
|
| 273 |
+
{ex}
|
| 274 |
+
</button>
|
| 275 |
+
))}
|
| 276 |
+
</div>
|
| 277 |
+
)}
|
| 278 |
{error && (
|
| 279 |
<p className="error-text" role="alert">
|
| 280 |
{error}
|
| 281 |
</p>
|
| 282 |
)}
|
|
|
|
|
|
|
|
|
|
| 283 |
</section>
|
| 284 |
)}
|
| 285 |
|
|
|
|
| 291 |
{indexingFailed ? (
|
| 292 |
<div className="row">
|
| 293 |
<button className="btn btn-primary" onClick={() => setStep("connect")}>
|
| 294 |
+
Try again
|
| 295 |
</button>
|
| 296 |
<Link className="btn" to="/app/sources">
|
| 297 |
Import posts another way
|
|
|
|
| 310 |
|
| 311 |
{step === "goal" && (
|
| 312 |
<section className="card welcome-card">
|
| 313 |
+
<p className="eyebrow">{indexingDone ? (capped ? `${posts} posts indexed, free plan limit` : `${posts} posts indexed`) : "Still indexing in the background"}</p>
|
| 314 |
<h1>What should we launch first?</h1>
|
| 315 |
+
{capped && (
|
| 316 |
+
<div className="welcome-cap">
|
| 317 |
+
<div>
|
| 318 |
+
<strong>Your free plan indexed your newest {capLimit} posts</strong>
|
| 319 |
+
<p className="muted small">Your archive has more. Upgrade to bring every post into orbit, so answers, audio and Launch Kits draw on all of it.</p>
|
| 320 |
+
</div>
|
| 321 |
+
<button
|
| 322 |
+
type="button"
|
| 323 |
+
className="btn btn-small"
|
| 324 |
+
onClick={() => {
|
| 325 |
+
markOnboarded();
|
| 326 |
+
navigate("/app?upgrade=1", { replace: true });
|
| 327 |
+
}}
|
| 328 |
+
>
|
| 329 |
+
Index everything
|
| 330 |
+
</button>
|
| 331 |
+
</div>
|
| 332 |
+
)}
|
| 333 |
<div className="goal-grid">
|
| 334 |
{GOALS.map((g) => (
|
| 335 |
<button key={g.id} type="button" className={`goal${goal === g.id ? " on" : ""}`} onClick={() => setGoal(g.id)} aria-pressed={goal === g.id}>
|
|
|
|
| 352 |
<p className="eyebrow">Pre-flight check complete</p>
|
| 353 |
<h1>{indexingDone ? `${posts} posts are in orbit` : "Almost there"}</h1>
|
| 354 |
{!indexingDone && live && <JobProgress job={live} />}
|
| 355 |
+
{capped && (
|
| 356 |
+
<div className="welcome-cap">
|
| 357 |
+
<div>
|
| 358 |
+
<strong>Your free plan indexed your newest {capLimit} posts</strong>
|
| 359 |
+
<p className="muted small">Your archive has more. Upgrade to bring every post into orbit, so answers, audio and Launch Kits draw on all of it.</p>
|
| 360 |
+
</div>
|
| 361 |
+
<button
|
| 362 |
+
type="button"
|
| 363 |
+
className="btn btn-small"
|
| 364 |
+
onClick={() => {
|
| 365 |
+
markOnboarded();
|
| 366 |
+
navigate("/app?upgrade=1", { replace: true });
|
| 367 |
+
}}
|
| 368 |
+
>
|
| 369 |
+
Index everything
|
| 370 |
+
</button>
|
| 371 |
+
</div>
|
| 372 |
+
)}
|
| 373 |
<p className="muted">
|
| 374 |
{goal === "ask" && "We will put your whole archive in a notebook so you can start asking right away."}
|
| 375 |
{goal === "audio" && "We will put your archive in a notebook with the audio studio ready."}
|
frontend/src/styles/pages.css
CHANGED
|
@@ -1464,6 +1464,68 @@ select.input option {
|
|
| 1464 |
padding: 0 28px;
|
| 1465 |
}
|
| 1466 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1467 |
.welcome-examples {
|
| 1468 |
display: flex;
|
| 1469 |
flex-wrap: wrap;
|
|
|
|
| 1464 |
padding: 0 28px;
|
| 1465 |
}
|
| 1466 |
|
| 1467 |
+
.welcome-drop {
|
| 1468 |
+
flex: 1 1 100%;
|
| 1469 |
+
display: grid;
|
| 1470 |
+
place-items: center;
|
| 1471 |
+
gap: 4px;
|
| 1472 |
+
padding: 28px 16px;
|
| 1473 |
+
border: 1px dashed var(--line-strong);
|
| 1474 |
+
border-radius: var(--radius-sm);
|
| 1475 |
+
text-align: center;
|
| 1476 |
+
cursor: pointer;
|
| 1477 |
+
transition: border-color 0.2s, background 0.2s;
|
| 1478 |
+
}
|
| 1479 |
+
|
| 1480 |
+
.welcome-drop:hover,
|
| 1481 |
+
.welcome-drop.over,
|
| 1482 |
+
.welcome-drop:focus-within {
|
| 1483 |
+
border-color: var(--blue);
|
| 1484 |
+
background: var(--blue-05);
|
| 1485 |
+
}
|
| 1486 |
+
|
| 1487 |
+
.welcome-drop input {
|
| 1488 |
+
position: absolute;
|
| 1489 |
+
width: 1px;
|
| 1490 |
+
height: 1px;
|
| 1491 |
+
opacity: 0;
|
| 1492 |
+
}
|
| 1493 |
+
|
| 1494 |
+
.welcome-files {
|
| 1495 |
+
flex: 1 1 100%;
|
| 1496 |
+
list-style: none;
|
| 1497 |
+
margin: 0;
|
| 1498 |
+
padding: 0;
|
| 1499 |
+
display: grid;
|
| 1500 |
+
gap: 4px;
|
| 1501 |
+
}
|
| 1502 |
+
|
| 1503 |
+
.welcome-files li {
|
| 1504 |
+
display: flex;
|
| 1505 |
+
justify-content: space-between;
|
| 1506 |
+
gap: 12px;
|
| 1507 |
+
padding: 6px 10px;
|
| 1508 |
+
border: 1px solid var(--line);
|
| 1509 |
+
border-radius: var(--radius-sm);
|
| 1510 |
+
overflow-wrap: anywhere;
|
| 1511 |
+
}
|
| 1512 |
+
|
| 1513 |
+
.welcome-cap {
|
| 1514 |
+
display: flex;
|
| 1515 |
+
flex-wrap: wrap;
|
| 1516 |
+
align-items: center;
|
| 1517 |
+
justify-content: space-between;
|
| 1518 |
+
gap: 12px;
|
| 1519 |
+
padding: 14px 16px;
|
| 1520 |
+
border: 1px solid var(--blue-35);
|
| 1521 |
+
border-radius: var(--radius-sm);
|
| 1522 |
+
background: var(--blue-05);
|
| 1523 |
+
}
|
| 1524 |
+
|
| 1525 |
+
.welcome-cap p {
|
| 1526 |
+
margin: 4px 0 0;
|
| 1527 |
+
}
|
| 1528 |
+
|
| 1529 |
.welcome-examples {
|
| 1530 |
display: flex;
|
| 1531 |
flex-wrap: wrap;
|