Arslan1997 commited on
Commit
0bf4415
·
1 Parent(s): 93dfca2

LETS GOOOOOOOOOOOOOOOOOOOOOOOOO

Browse files
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), max_posts,
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, max_posts)
593
  source.title = source.title or title
594
- if source.platform == "substack" and len(entries) < max_posts:
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}, max_posts,
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), "changed": [str(i) for i in changed]}
 
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
- /** First run: connect a blog, watch it index, pick what to do first, land somewhere useful. */
 
 
 
 
 
 
 
 
 
 
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 connect(url);
 
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 do you publish?</h1>
140
- <p className="muted">
141
- Paste any blog, newsletter or site: Substack, Ghost, WordPress, Medium or anything with a feed. We find the feed, pull in your archive and turn it into a research notebook you can
142
- question, hear and launch from.
 
 
 
 
 
 
 
 
 
 
143
  </p>
144
  <form onSubmit={submit} className="welcome-form">
145
- <input
146
- className="input"
147
- autoFocus
148
- placeholder="yourblog.com"
149
- value={url}
150
- onChange={(e) => setUrl(e.target.value)}
151
- aria-label="Blog, newsletter or site address"
152
- />
153
- <button className="btn btn-primary" disabled={busy || !url.trim()}>
154
- {busy ? "Finding your feed..." : "Connect"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  </button>
156
  </form>
157
- <div className="welcome-examples mono muted">
158
- Try:{" "}
159
- {EXAMPLES.map((ex) => (
160
- <button key={ex} type="button" className="chip" onClick={() => setUrl(ex)}>
161
- {ex}
162
- </button>
163
- ))}
164
- </div>
 
 
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 another address
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;