Arslan1997 commited on
Commit
19e319e
·
2 Parent(s): e2552a13c705c5

Merge branch 'main' of https://github.com/FireBird-Technologies/NotestackAI

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. backend/alembic/versions/0004_archive_notebook.py +24 -0
  2. backend/app/models/content.py +2 -0
  3. backend/app/pipeline/ingest.py +81 -15
  4. backend/app/routers/artifacts.py +10 -0
  5. backend/app/routers/notebooks.py +32 -3
  6. backend/app/routers/sources.py +20 -2
  7. backend/app/routers/voice.py +79 -0
  8. backend/app/services/billing.py +17 -1
  9. backend/app/services/plans.py +1 -1
  10. backend/app/services/tts.py +23 -0
  11. backend/app/worker.py +18 -3
  12. backend/tests/test_locked_posts.py +91 -0
  13. backend/tests/test_simplified_flows.py +47 -0
  14. frontend/index.html +9 -0
  15. frontend/public/_redirects +5 -0
  16. frontend/seo/prerender.ts +269 -0
  17. frontend/seo/sitemap.ts +5 -1
  18. frontend/src/App.tsx +3 -0
  19. frontend/src/api/endpoints.ts +9 -0
  20. frontend/src/api/types.ts +13 -1
  21. frontend/src/components/AppShell.tsx +20 -16
  22. frontend/src/components/PricingTiers.tsx +1 -1
  23. frontend/src/components/PublicChrome.tsx +10 -0
  24. frontend/src/components/Reader.tsx +16 -0
  25. frontend/src/content/blogPosts.ts +1 -0
  26. frontend/src/content/missionPosts.ts +6 -5
  27. frontend/src/content/notebooklmAlternative.ts +79 -0
  28. frontend/src/content/tools.ts +225 -0
  29. frontend/src/lib/podcastScript.ts +85 -0
  30. frontend/src/pages/Archive.tsx +12 -14
  31. frontend/src/pages/AuthCallback.tsx +1 -1
  32. frontend/src/pages/AuthPage.tsx +3 -5
  33. frontend/src/pages/Blog.tsx +1 -1
  34. frontend/src/pages/LaunchKit.tsx +131 -61
  35. frontend/src/pages/Launchpad.tsx +49 -47
  36. frontend/src/pages/MissionControl.tsx +224 -127
  37. frontend/src/pages/NotebookLMAlternative.tsx +5 -78
  38. frontend/src/pages/NotebookView.tsx +157 -105
  39. frontend/src/pages/Notebooks.tsx +104 -50
  40. frontend/src/pages/Resurface.tsx +44 -53
  41. frontend/src/pages/Settings.tsx +32 -58
  42. frontend/src/pages/Sources.tsx +149 -50
  43. frontend/src/pages/Studio.tsx +113 -125
  44. frontend/src/pages/Tools.tsx +222 -0
  45. frontend/src/pages/TopicMap.tsx +76 -21
  46. frontend/src/pages/VoiceProfile.tsx +477 -161
  47. frontend/src/pages/Welcome.tsx +173 -35
  48. frontend/src/styles/app.css +2 -2
  49. frontend/src/styles/pages.css +956 -0
  50. frontend/src/styles/tools.css +115 -0
backend/alembic/versions/0004_archive_notebook.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """notebooks.is_archive: the workspace's always-present "All posts" notebook
2
+
3
+ Revision ID: 0004_archive_notebook
4
+ Revises: 0003_billing_events
5
+ Create Date: 2026-09-28
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0004_archive_notebook"
11
+ down_revision = "0003_billing_events"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def upgrade() -> None:
17
+ # 0001 builds from the live models, so a fresh database may already have the column.
18
+ columns = {c["name"] for c in sa.inspect(op.get_bind()).get_columns("notebooks")}
19
+ if "is_archive" not in columns:
20
+ op.add_column("notebooks", sa.Column("is_archive", sa.Boolean(), nullable=False, server_default=sa.false()))
21
+
22
+
23
+ def downgrade() -> None:
24
+ op.drop_column("notebooks", "is_archive")
backend/app/models/content.py CHANGED
@@ -65,6 +65,8 @@ class Notebook(IdMixin, TimestampMixin, Base):
65
  title: Mapped[str] = mapped_column(String(300))
66
  description: Mapped[str | None] = mapped_column(Text)
67
  summary: Mapped[str | None] = mapped_column(Text)
 
 
68
 
69
 
70
  class NotebookDocument(Base):
 
65
  title: Mapped[str] = mapped_column(String(300))
66
  description: Mapped[str | None] = mapped_column(Text)
67
  summary: Mapped[str | None] = mapped_column(Text)
68
+ # The workspace's "All posts" notebook: created on demand, kept in step with every indexed post.
69
+ is_archive: Mapped[bool] = mapped_column(Boolean, default=False)
70
 
71
 
72
  class NotebookDocument(Base):
backend/app/pipeline/ingest.py CHANGED
@@ -418,8 +418,15 @@ def _plain_links(url: str) -> list[str]:
418
  return links
419
 
420
 
421
- def crawl_site(url: str, limit: int, on_found=None, on_post=None) -> list[FeedEntry]:
422
- """Find the posts on a site with no feed and read each one."""
 
 
 
 
 
 
 
423
  if firecrawl.enabled():
424
  links = firecrawl.map_site(url, max(limit * 4, 100))
425
  else:
@@ -437,20 +444,24 @@ def crawl_site(url: str, limit: int, on_found=None, on_post=None) -> list[FeedEn
437
  except (FeedNotFound, httpx.HTTPError, firecrawl.FirecrawlError):
438
  return None
439
 
 
440
  entries: list[FeedEntry] = []
441
  with ThreadPoolExecutor(max_workers=4) as pool:
442
- for i, entry in enumerate(pool.map(read, urls)):
443
  if entry:
444
  entries.append(entry)
445
  if on_post:
446
- on_post(i + 1, len(urls))
 
447
  if not entries:
448
  raise FeedNotFound(f"We found {len(urls)} pages on {urlparse(url).netloc} but none had article text.")
449
  return entries
450
 
451
 
452
- def fetch_substack_archive(site: str, known_urls: set[str], limit: int, on_page=None) -> list[FeedEntry]:
453
- """Posts beyond the ~20 the RSS feed carries, through Substack's public archive API."""
 
 
454
  out: list[FeedEntry] = []
455
  offset = 0
456
  with httpx.Client(follow_redirects=True, timeout=30, headers=UA) as client:
@@ -468,6 +479,10 @@ def fetch_substack_archive(site: str, known_urls: set[str], limit: int, on_page=
468
  continue
469
  if len(out) + len(known_urls) >= limit:
470
  break
 
 
 
 
471
  post = client.get(f"{site}/api/v1/posts/{item['slug']}")
472
  if post.status_code >= 400:
473
  continue
@@ -556,7 +571,7 @@ def store_entries(
556
  )
557
  doc.clean_text = "\n\n".join(f"## {h}\n{t}" if h else t for h, t in sections)
558
  doc.metadata_json = {
559
- **(doc.metadata_json or {}),
560
  "headings": [h for h, _ in sections if h],
561
  "words": len(doc.clean_text.split()),
562
  }
@@ -575,44 +590,95 @@ def store_entries(
575
  return indexed, skipped, changed
576
 
577
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
 
608
- update_job(db, job, progress=0.1, message=f"Indexing {len(entries)} posts")
609
- indexed, skipped, changed = store_entries(db, source, entries, progress)
 
 
 
 
 
610
 
611
  source.sync_status = "ok"
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:
 
418
  return links
419
 
420
 
421
+ def _title_from_url(u: str) -> str:
422
+ slug = urlparse(u).path.rstrip("/").rsplit("/", 1)[-1]
423
+ slug = re.sub(r"\.[a-z0-9]+$", "", slug)
424
+ return slug.replace("-", " ").replace("_", " ").strip().capitalize() or urlparse(u).netloc
425
+
426
+
427
+ def crawl_site(url: str, limit: int, on_found=None, on_post=None, read_limit: int | None = None) -> list[FeedEntry]:
428
+ """Find the posts on a site with no feed and read each one. Past read_limit, pages are only listed (title from the
429
+ URL, no html): they show the writer what the rest of their archive holds without paying to read it."""
430
  if firecrawl.enabled():
431
  links = firecrawl.map_site(url, max(limit * 4, 100))
432
  else:
 
444
  except (FeedNotFound, httpx.HTTPError, firecrawl.FirecrawlError):
445
  return None
446
 
447
+ to_read = urls if read_limit is None else urls[:read_limit]
448
  entries: list[FeedEntry] = []
449
  with ThreadPoolExecutor(max_workers=4) as pool:
450
+ for i, entry in enumerate(pool.map(read, to_read)):
451
  if entry:
452
  entries.append(entry)
453
  if on_post:
454
+ on_post(i + 1, len(to_read))
455
+ entries += [FeedEntry(title=_title_from_url(u), url=u, published_at=None, html="") for u in urls[len(to_read):]]
456
  if not entries:
457
  raise FeedNotFound(f"We found {len(urls)} pages on {urlparse(url).netloc} but none had article text.")
458
  return entries
459
 
460
 
461
+ def fetch_substack_archive(site: str, known_urls: set[str], limit: int, on_page=None,
462
+ body_budget: int | None = None) -> list[FeedEntry]:
463
+ """Posts beyond the ~20 the RSS feed carries, through Substack's public archive API. Only the first body_budget
464
+ posts get their body fetched; the rest are listed from the archive metadata alone (title, date, link)."""
465
  out: list[FeedEntry] = []
466
  offset = 0
467
  with httpx.Client(follow_redirects=True, timeout=30, headers=UA) as client:
 
479
  continue
480
  if len(out) + len(known_urls) >= limit:
481
  break
482
+ if body_budget is not None and len(out) >= body_budget:
483
+ out.append(FeedEntry(title=item.get("title") or "Untitled", url=url,
484
+ published_at=_parse_date(item.get("post_date")), html=""))
485
+ continue
486
  post = client.get(f"{site}/api/v1/posts/{item['slug']}")
487
  if post.status_code >= 400:
488
  continue
 
571
  )
572
  doc.clean_text = "\n\n".join(f"## {h}\n{t}" if h else t for h, t in sections)
573
  doc.metadata_json = {
574
+ **{k: v for k, v in (doc.metadata_json or {}).items() if k != "locked"},
575
  "headings": [h for h, _ in sections if h],
576
  "words": len(doc.clean_text.split()),
577
  }
 
590
  return indexed, skipped, changed
591
 
592
 
593
+ # A sync discovers up to SCAN_CAP posts (or the plan's own limit, if higher) but only indexes the plan's
594
+ # indexed_posts. The rest are listed, not indexed: title, date and link only, no text and no corpus file. They exist
595
+ # to show the writer how much of their archive is waiting behind an upgrade. Upgrading re-syncs and indexes them.
596
+ SCAN_CAP = 200
597
+
598
+
599
+ def _newest_first(entries: list[FeedEntry]) -> list[FeedEntry]:
600
+ """Dated posts newest first; undated ones (site crawls) keep their discovery order after them."""
601
+ dated = sorted((e for e in entries if e.published_at), key=lambda e: e.published_at.timestamp(), reverse=True)
602
+ return dated + [e for e in entries if not e.published_at]
603
+
604
+
605
+ def store_locked(db: Session, source: Source, entries: list[FeedEntry]) -> tuple[int, set[str]]:
606
+ """List posts beyond the plan limit without indexing them: title, date and link only. Returns (count, corpus paths
607
+ to remove): a post that was indexed before (a newer post pushed it out, or the plan went down) loses its file."""
608
+ locked = 0
609
+ remove: set[str] = set()
610
+ for entry in entries:
611
+ doc = db.scalar(select(Document).where(Document.source_id == source.id, Document.url == entry.url))
612
+ if not doc:
613
+ doc = Document(id=uuid.uuid4(), workspace_id=source.workspace_id, source_id=source.id, url=entry.url)
614
+ db.add(doc)
615
+ if doc.path:
616
+ remove.add(doc.path)
617
+ doc.path = None
618
+ doc.title = entry.title
619
+ doc.published_at = entry.published_at
620
+ doc.content_hash = None # indexed from scratch once the plan allows it
621
+ doc.clean_text = ""
622
+ doc.metadata_json = {"locked": True}
623
+ locked += 1
624
+ db.commit()
625
+ return locked, remove
626
+
627
+
628
  def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict:
629
  source.sync_status = "syncing"
630
  update_job(db, job, status="running", progress=0.02, message="Contacting your feed")
631
+ fetch_limit = max(SCAN_CAP, max_posts)
632
 
633
  if source.feed_url.startswith(SITE_PREFIX):
634
  update_job(db, job, message="Mapping your site")
635
  entries = crawl_site(
636
+ source.feed_url.removeprefix(SITE_PREFIX), fetch_limit,
637
  on_found=lambda n: update_job(db, job, progress=0.05, message=f"Found {n} pages, reading them"),
638
  on_post=lambda done, total: update_job(db, job, progress=0.05 + 0.05 * done / total,
639
  message=f"Read {done} of {total} pages"),
640
+ read_limit=max_posts,
641
  )
642
  title = None
643
  else:
644
+ title, entries = fetch_feed(source.feed_url, fetch_limit)
645
  source.title = source.title or title
646
+ if source.platform == "substack" and len(entries) < fetch_limit:
647
  site = f"{urlparse(source.feed_url).scheme}://{urlparse(source.feed_url).netloc}"
648
  update_job(db, job, progress=0.05, message="Reading your Substack archive")
649
  try:
650
  entries += fetch_substack_archive(
651
+ site, {e.url for e in entries}, fetch_limit,
652
  on_page=lambda n: update_job(db, job, message=f"Found {len(entries) + n} posts in the archive"),
653
+ body_budget=max(0, max_posts - len(entries)),
654
  )
655
  except (httpx.HTTPError, ValueError):
656
  log.warning("substack archive fetch failed for %s", site, exc_info=True)
657
 
658
+ entries = _newest_first(entries)
659
+ available, beyond = entries[:max_posts], entries[max_posts:]
660
+
661
  def progress(done: int, total: int, post_title: str) -> None:
662
+ update_job(db, job, progress=0.1 + 0.7 * done / total, message=f"{post_title[:80]} is in orbit")
663
+
664
+ update_job(db, job, progress=0.1, message=f"Indexing {len(available)} posts")
665
+ indexed, skipped, changed = store_entries(db, source, available, progress)
666
 
667
+ locked = 0
668
+ if beyond:
669
+ update_job(db, job, progress=0.85,
670
+ message=f"Found {len(beyond)} more posts. Your plan indexes the latest {max_posts}")
671
+ locked, remove = store_locked(db, source, beyond)
672
+ if remove:
673
+ Corpus(source.workspace_id).write_files({INDEX: rebuild_index(db, source.workspace_id)}, remove=remove)
674
 
675
  source.sync_status = "ok"
676
  source.sync_error = None
677
  source.last_synced_at = datetime.now(UTC)
678
  db.commit()
679
+ return {"indexed": indexed, "skipped": skipped, "found": len(entries), "limit": max_posts,
680
+ "capped": locked > 0, "locked": locked, "available": len(available),
681
+ "changed": [str(i) for i in changed]}
682
 
683
 
684
  def entry_from_upload(filename: str, content_type: str, data: bytes) -> FeedEntry:
backend/app/routers/artifacts.py CHANGED
@@ -7,10 +7,12 @@ from sqlalchemy import func, or_, select
7
 
8
  from app.auth import Ctx, get_ctx
9
  from app.models import Artifact, CalendarItem, Document, Job, Notebook
 
10
  from app.services.artifacts import latest_jobs, serialize_artifact, start_artifact
11
  from app.services.jobs import create_job, serialize_job
12
  from app.services.renderer import COMPOSITIONS
13
  from app.services.storage import storage
 
14
  from app.services.usage import check_limit
15
 
16
  router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])
@@ -27,6 +29,7 @@ class GenerateIn(BaseModel):
27
  type: Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit"]
28
  notebook_id: uuid.UUID | None = None
29
  document_id: uuid.UUID | None = None
 
30
  # audio_overview
31
  format: Literal["deep_dive", "brief", "debate"] = "deep_dive"
32
  minutes: int = Field(6, ge=1, le=30)
@@ -56,6 +59,11 @@ def _target_title(ctx: Ctx, notebook_id: uuid.UUID | None, document_id: uuid.UUI
56
  Document.workspace_id == ctx.workspace.id))
57
  if not doc:
58
  raise HTTPException(404, "Post not found")
 
 
 
 
 
59
  return doc.title
60
  if notebook_id:
61
  nb = ctx.db.scalar(select(Notebook).where(Notebook.id == notebook_id,
@@ -69,6 +77,8 @@ def _target_title(ctx: Ctx, notebook_id: uuid.UUID | None, document_id: uuid.UUI
69
  @router.post("/generate")
70
  def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
71
  """One entry point for every generated artifact."""
 
 
72
  params: dict = {}
73
  content: dict = {}
74
  if body.type == "video" and body.style == "audiogram":
 
7
 
8
  from app.auth import Ctx, get_ctx
9
  from app.models import Artifact, CalendarItem, Document, Job, Notebook
10
+ from app.routers.notebooks import ensure_archive_notebook
11
  from app.services.artifacts import latest_jobs, serialize_artifact, start_artifact
12
  from app.services.jobs import create_job, serialize_job
13
  from app.services.renderer import COMPOSITIONS
14
  from app.services.storage import storage
15
+ from app.services.plans import effective_plan, plan_limit_error
16
  from app.services.usage import check_limit
17
 
18
  router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])
 
29
  type: Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit"]
30
  notebook_id: uuid.UUID | None = None
31
  document_id: uuid.UUID | None = None
32
+ archive: bool = False # no notebook or post picked: use the "All posts" notebook
33
  # audio_overview
34
  format: Literal["deep_dive", "brief", "debate"] = "deep_dive"
35
  minutes: int = Field(6, ge=1, le=30)
 
59
  Document.workspace_id == ctx.workspace.id))
60
  if not doc:
61
  raise HTTPException(404, "Post not found")
62
+ if not doc.path:
63
+ plan = effective_plan(ctx.db, ctx.workspace)
64
+ raise plan_limit_error(plan, "indexed_posts",
65
+ f"This post is not indexed. Your plan indexes your latest {plan.indexed_posts} "
66
+ "posts; upgrade to use your whole archive.")
67
  return doc.title
68
  if notebook_id:
69
  nb = ctx.db.scalar(select(Notebook).where(Notebook.id == notebook_id,
 
77
  @router.post("/generate")
78
  def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
79
  """One entry point for every generated artifact."""
80
+ if body.archive and not body.notebook_id and not body.document_id:
81
+ body.notebook_id = ensure_archive_notebook(ctx).id
82
  params: dict = {}
83
  content: dict = {}
84
  if body.type == "video" and body.style == "audiogram":
backend/app/routers/notebooks.py CHANGED
@@ -52,7 +52,9 @@ def _get(ctx: Ctx, notebook_id: uuid.UUID) -> Notebook:
52
 
53
  def _add_docs(ctx: Ctx, nb: Notebook, ids: list[uuid.UUID]) -> int:
54
  valid = set(ctx.db.scalars(
55
- select(Document.id).where(Document.id.in_(ids), Document.workspace_id == ctx.workspace.id)
 
 
56
  ).all())
57
  existing = set(ctx.db.scalars(select(NotebookDocument.document_id).where(NotebookDocument.notebook_id == nb.id)))
58
  for doc_id in valid - existing:
@@ -70,6 +72,29 @@ def create_notebook(body: NotebookIn, ctx: Ctx = Depends(get_ctx)):
70
  return {"id": str(nb.id), "title": nb.title, "added": added}
71
 
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  @router.post("/from-topic/{topic_id}")
74
  def notebook_from_topic(topic_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
75
  topic = ctx.db.scalar(select(Topic).where(Topic.id == topic_id, Topic.workspace_id == ctx.workspace.id))
@@ -87,7 +112,8 @@ def notebook_from_topic(topic_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
87
  @router.get("")
88
  def list_notebooks(ctx: Ctx = Depends(get_ctx)):
89
  rows = ctx.db.scalars(
90
- select(Notebook).where(Notebook.workspace_id == ctx.workspace.id).order_by(Notebook.updated_at.desc())
 
91
  ).all()
92
  ids = [n.id for n in rows]
93
  doc_counts = dict(ctx.db.execute(
@@ -103,7 +129,7 @@ def list_notebooks(ctx: Ctx = Depends(get_ctx)):
103
  return [
104
  {"id": str(n.id), "title": n.title, "description": n.description, "summary": n.summary,
105
  "document_count": doc_counts.get(n.id, 0), "chat_count": chat_counts.get(n.id, 0),
106
- "artifact_count": artifact_counts.get(n.id, 0),
107
  "updated_at": n.updated_at.isoformat() if n.updated_at else None}
108
  for n in rows
109
  ]
@@ -112,6 +138,8 @@ def list_notebooks(ctx: Ctx = Depends(get_ctx)):
112
  @router.get("/{notebook_id}")
113
  def get_notebook(notebook_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
114
  nb = _get(ctx, notebook_id)
 
 
115
  docs = ctx.db.scalars(
116
  select(Document)
117
  .join(NotebookDocument, NotebookDocument.document_id == Document.id)
@@ -123,6 +151,7 @@ def get_notebook(notebook_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
123
  "title": nb.title,
124
  "description": nb.description,
125
  "summary": nb.summary,
 
126
  "documents": [serialize_document(d) for d in docs],
127
  }
128
 
 
52
 
53
  def _add_docs(ctx: Ctx, nb: Notebook, ids: list[uuid.UUID]) -> int:
54
  valid = set(ctx.db.scalars(
55
+ # Locked posts (beyond the plan's post limit) have no corpus file and cannot join a notebook.
56
+ select(Document.id).where(Document.id.in_(ids), Document.workspace_id == ctx.workspace.id,
57
+ Document.path.is_not(None))
58
  ).all())
59
  existing = set(ctx.db.scalars(select(NotebookDocument.document_id).where(NotebookDocument.notebook_id == nb.id)))
60
  for doc_id in valid - existing:
 
72
  return {"id": str(nb.id), "title": nb.title, "added": added}
73
 
74
 
75
+ ARCHIVE_TITLE = "All posts"
76
+
77
+
78
+ def ensure_archive_notebook(ctx: Ctx) -> Notebook:
79
+ """The workspace's "All posts" notebook, topped up with every indexed post (locked posts are skipped)."""
80
+ nb = ctx.db.scalar(select(Notebook).where(Notebook.workspace_id == ctx.workspace.id, Notebook.is_archive.is_(True)))
81
+ if not nb:
82
+ nb = Notebook(workspace_id=ctx.workspace.id, title=ARCHIVE_TITLE, is_archive=True,
83
+ description="Every indexed post in your archive, kept up to date.")
84
+ ctx.db.add(nb)
85
+ ctx.db.flush()
86
+ ids = list(ctx.db.scalars(select(Document.id).where(Document.workspace_id == ctx.workspace.id)))
87
+ _add_docs(ctx, nb, ids)
88
+ ctx.db.commit()
89
+ return nb
90
+
91
+
92
+ @router.post("/archive")
93
+ def archive_notebook(ctx: Ctx = Depends(get_ctx)):
94
+ nb = ensure_archive_notebook(ctx)
95
+ return {"id": str(nb.id), "title": nb.title}
96
+
97
+
98
  @router.post("/from-topic/{topic_id}")
99
  def notebook_from_topic(topic_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
100
  topic = ctx.db.scalar(select(Topic).where(Topic.id == topic_id, Topic.workspace_id == ctx.workspace.id))
 
112
  @router.get("")
113
  def list_notebooks(ctx: Ctx = Depends(get_ctx)):
114
  rows = ctx.db.scalars(
115
+ select(Notebook).where(Notebook.workspace_id == ctx.workspace.id)
116
+ .order_by(Notebook.is_archive.desc(), Notebook.updated_at.desc())
117
  ).all()
118
  ids = [n.id for n in rows]
119
  doc_counts = dict(ctx.db.execute(
 
129
  return [
130
  {"id": str(n.id), "title": n.title, "description": n.description, "summary": n.summary,
131
  "document_count": doc_counts.get(n.id, 0), "chat_count": chat_counts.get(n.id, 0),
132
+ "artifact_count": artifact_counts.get(n.id, 0), "is_archive": n.is_archive,
133
  "updated_at": n.updated_at.isoformat() if n.updated_at else None}
134
  for n in rows
135
  ]
 
138
  @router.get("/{notebook_id}")
139
  def get_notebook(notebook_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
140
  nb = _get(ctx, notebook_id)
141
+ if nb.is_archive: # posts synced since it was made join on open
142
+ ensure_archive_notebook(ctx)
143
  docs = ctx.db.scalars(
144
  select(Document)
145
  .join(NotebookDocument, NotebookDocument.document_id == Document.id)
 
151
  "title": nb.title,
152
  "description": nb.description,
153
  "summary": nb.summary,
154
+ "is_archive": nb.is_archive,
155
  "documents": [serialize_document(d) for d in docs],
156
  }
157
 
backend/app/routers/sources.py CHANGED
@@ -24,7 +24,7 @@ class UploadImportIn(BaseModel):
24
  upload_id: uuid.UUID
25
 
26
 
27
- def serialize_source(source: Source, doc_count: int = 0) -> dict:
28
  return {
29
  "id": str(source.id),
30
  "feed_url": source.feed_url,
@@ -35,6 +35,8 @@ def serialize_source(source: Source, doc_count: int = 0) -> dict:
35
  "sync_error": source.sync_error,
36
  "last_synced_at": source.last_synced_at.isoformat() if source.last_synced_at else None,
37
  "document_count": doc_count,
 
 
38
  "is_imports": source.feed_url == IMPORTS_FEED,
39
  }
40
 
@@ -91,7 +93,12 @@ def list_sources(ctx: Ctx = Depends(get_ctx)):
91
  .group_by(Source.id)
92
  .order_by(Source.created_at)
93
  ).all()
94
- return [serialize_source(s, n) for s, n in rows]
 
 
 
 
 
95
 
96
 
97
  @router.post("/{source_id}/sync")
@@ -151,6 +158,11 @@ def import_upload(body: UploadImportIn, ctx: Ctx = Depends(get_ctx)):
151
  documents_router = APIRouter(prefix="/api/documents", tags=["documents"])
152
 
153
 
 
 
 
 
 
154
  def serialize_document(d: Document, source: Source | None = None) -> dict:
155
  meta = d.metadata_json or {}
156
  return {
@@ -163,6 +175,7 @@ def serialize_document(d: Document, source: Source | None = None) -> dict:
163
  "published_at": d.published_at.isoformat() if d.published_at else None,
164
  "words": meta.get("words") or len((d.clean_text or "").split()),
165
  "evergreen_score": d.evergreen_score,
 
166
  }
167
 
168
 
@@ -198,6 +211,11 @@ def get_document(document_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
198
  if not doc:
199
  raise HTTPException(404, "Post not found")
200
  source = ctx.db.get(Source, doc.source_id) if doc.source_id else None
 
 
 
 
 
201
  lines: list[str] = []
202
  if doc.path:
203
  corpus = Corpus(ctx.workspace.id)
 
24
  upload_id: uuid.UUID
25
 
26
 
27
+ def serialize_source(source: Source, doc_count: int = 0, locked_count: int = 0) -> dict:
28
  return {
29
  "id": str(source.id),
30
  "feed_url": source.feed_url,
 
35
  "sync_error": source.sync_error,
36
  "last_synced_at": source.last_synced_at.isoformat() if source.last_synced_at else None,
37
  "document_count": doc_count,
38
+ # Found but beyond the plan's post limit: listed, not indexed until the workspace upgrades.
39
+ "locked_count": locked_count,
40
  "is_imports": source.feed_url == IMPORTS_FEED,
41
  }
42
 
 
93
  .group_by(Source.id)
94
  .order_by(Source.created_at)
95
  ).all()
96
+ locked = dict(ctx.db.execute(
97
+ select(Document.source_id, func.count(Document.id))
98
+ .where(Document.workspace_id == ctx.workspace.id, Document.path.is_(None))
99
+ .group_by(Document.source_id)
100
+ ).all())
101
+ return [serialize_source(s, n, locked.get(s.id, 0)) for s, n in rows]
102
 
103
 
104
  @router.post("/{source_id}/sync")
 
158
  documents_router = APIRouter(prefix="/api/documents", tags=["documents"])
159
 
160
 
161
+ def is_locked(d: Document) -> bool:
162
+ """Past the plan's post limit (see ingest.store_locked): listed with title and link only, not indexed."""
163
+ return not d.path and bool((d.metadata_json or {}).get("locked"))
164
+
165
+
166
  def serialize_document(d: Document, source: Source | None = None) -> dict:
167
  meta = d.metadata_json or {}
168
  return {
 
175
  "published_at": d.published_at.isoformat() if d.published_at else None,
176
  "words": meta.get("words") or len((d.clean_text or "").split()),
177
  "evergreen_score": d.evergreen_score,
178
+ "locked": is_locked(d),
179
  }
180
 
181
 
 
211
  if not doc:
212
  raise HTTPException(404, "Post not found")
213
  source = ctx.db.get(Source, doc.source_id) if doc.source_id else None
214
+ if is_locked(doc):
215
+ # Listed, not indexed: there is no text to show until the workspace upgrades.
216
+ plan = effective_plan(ctx.db, ctx.workspace)
217
+ return {**serialize_document(doc, source), "lines": [f"# {doc.title}"],
218
+ "preview": True, "limit": plan.indexed_posts}
219
  lines: list[str] = []
220
  if doc.path:
221
  corpus = Corpus(ctx.workspace.id)
backend/app/routers/voice.py CHANGED
@@ -57,6 +57,23 @@ class PreviewIn(BaseModel):
57
  delivery: DeliveryIn | None = None
58
 
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  class LibraryAddIn(BaseModel):
61
  public_owner_id: str = Field(min_length=5, max_length=100)
62
  voice_id: str = Field(min_length=5, max_length=60)
@@ -116,6 +133,8 @@ def _serialize(ctx: Ctx) -> dict:
116
  "error": job.error if job and job.status == "failed" else None,
117
  "preview_url": storage.presign_get(preview_key) if preview_key else None,
118
  },
 
 
119
  "tts_configured": bool(settings.elevenlabs_api_key),
120
  "model": tts.model_id(),
121
  "models": tts.MODELS,
@@ -146,6 +165,11 @@ def update_voice(body: ProfileIn, ctx: Ctx = Depends(get_ctx)):
146
 
147
  @router.post("/build")
148
  def build(body: BuildIn, ctx: Ctx = Depends(get_ctx)):
 
 
 
 
 
149
  job = create_job(ctx.db, ctx.workspace.id, "voice_profile",
150
  {"document_ids": [str(i) for i in body.document_ids]}, max_attempts=2)
151
  return serialize_job(job)
@@ -211,6 +235,61 @@ async def preview(body: PreviewIn, ctx: Ctx = Depends(get_ctx)):
211
  return {"url": storage.presign_get(key), "seconds": round(tts.mp3_seconds(clip), 1)}
212
 
213
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  @router.get("/reading-script")
215
  def reading_script(ctx: Ctx = Depends(get_ctx)):
216
  """About two minutes of the writer's own prose to read aloud for the clone (their words, their rhythm)."""
 
57
  delivery: DeliveryIn | None = None
58
 
59
 
60
+ class DesignIn(BaseModel):
61
+ """Either picked options (Build from options) or a free description (Describe in your own words)."""
62
+ prompt: str | None = Field(None, max_length=1000)
63
+ gender: str = Field("", max_length=20)
64
+ age: str = Field("", max_length=20)
65
+ persona: str = Field("", max_length=30)
66
+ pace: str = Field("", max_length=20)
67
+ accent: str = Field("", max_length=40)
68
+
69
+
70
+ class DesignSaveIn(BaseModel):
71
+ generated_voice_id: str = Field(min_length=5, max_length=100)
72
+ name: str = Field(min_length=1, max_length=60)
73
+ description: str = Field("", max_length=1000)
74
+ use_as: str | None = None # "host_a" | "host_b"
75
+
76
+
77
  class LibraryAddIn(BaseModel):
78
  public_owner_id: str = Field(min_length=5, max_length=100)
79
  voice_id: str = Field(min_length=5, max_length=60)
 
133
  "error": job.error if job and job.status == "failed" else None,
134
  "preview_url": storage.presign_get(preview_key) if preview_key else None,
135
  },
136
+ # Voices this workspace generated with Voice Design (the ElevenLabs account is shared across workspaces).
137
+ "custom_voices": voices.get("custom") or [],
138
  "tts_configured": bool(settings.elevenlabs_api_key),
139
  "model": tts.model_id(),
140
  "models": tts.MODELS,
 
165
 
166
  @router.post("/build")
167
  def build(body: BuildIn, ctx: Ctx = Depends(get_ctx)):
168
+ if not body.document_ids: # the automatic build after a sync may already be running; follow that one
169
+ pending = ctx.db.scalar(select(Job).where(Job.workspace_id == ctx.workspace.id, Job.kind == "voice_profile",
170
+ Job.status.in_(("queued", "running"))).limit(1))
171
+ if pending:
172
+ return serialize_job(pending)
173
  job = create_job(ctx.db, ctx.workspace.id, "voice_profile",
174
  {"document_ids": [str(i) for i in body.document_ids]}, max_attempts=2)
175
  return serialize_job(job)
 
235
  return {"url": storage.presign_get(key), "seconds": round(tts.mp3_seconds(clip), 1)}
236
 
237
 
238
+ def _design_description(body: DesignIn) -> str:
239
+ if body.prompt and body.prompt.strip():
240
+ text = body.prompt.strip()
241
+ if len(text) < 20:
242
+ raise HTTPException(400, "Describe the voice in at least 20 characters.")
243
+ return text
244
+ parts = [f"A {body.age} {body.gender} voice".replace(" ", " ") if body.gender or body.age else "A voice"]
245
+ if body.accent:
246
+ parts.append(f"with a {body.accent} accent")
247
+ sentence = " ".join(parts) + "."
248
+ if body.persona:
249
+ sentence += f" Sounds {body.persona}."
250
+ if body.pace:
251
+ sentence += f" Speaks at a {body.pace} pace."
252
+ return sentence + " Clear studio quality, suited to podcasts and narration."
253
+
254
+
255
+ @router.post("/design")
256
+ async def design(body: DesignIn, ctx: Ctx = Depends(get_ctx)):
257
+ """Three previews of a new voice. Nothing is saved until the writer picks one."""
258
+ if not settings.elevenlabs_api_key:
259
+ raise HTTPException(503, "Set ELEVENLABS_API_KEY in .env to generate voices.")
260
+ description = _design_description(body)
261
+ try:
262
+ previews = await run_in_threadpool(tts.design_voice, description)
263
+ except tts.TTSError as exc:
264
+ raise HTTPException(502, str(exc)) from exc
265
+ out = []
266
+ for p in previews:
267
+ key = f"ws/{ctx.workspace.id}/voice-previews/{uuid.uuid4().hex}.mp3"
268
+ storage.put_bytes(key, p["audio"], "audio/mpeg")
269
+ out.append({"generated_voice_id": p["generated_voice_id"], "url": storage.presign_get(key), "seconds": p["seconds"]})
270
+ record_usage(ctx.db, workspace_id=ctx.workspace.id, kind="voice_design", provider="elevenlabs",
271
+ quantity=len(out), unit="previews")
272
+ return {"description": description, "previews": out}
273
+
274
+
275
+ @router.post("/design/save")
276
+ async def save_design(body: DesignSaveIn, ctx: Ctx = Depends(get_ctx)):
277
+ try:
278
+ voice_id = await run_in_threadpool(tts.create_designed_voice, body.name, body.description,
279
+ body.generated_voice_id)
280
+ except tts.TTSError as exc:
281
+ raise HTTPException(502, str(exc)) from exc
282
+ vp = _ensure_profile(ctx)
283
+ voices = dict(vp.host_voices or {})
284
+ voices["custom"] = [*(voices.get("custom") or []),
285
+ {"voice_id": voice_id, "name": body.name, "description": body.description[:300]}]
286
+ if body.use_as in ("host_a", "host_b"):
287
+ voices[body.use_as] = voice_id
288
+ vp.host_voices = voices
289
+ ctx.db.commit()
290
+ return {"voice_id": voice_id, **_serialize(ctx)}
291
+
292
+
293
  @router.get("/reading-script")
294
  def reading_script(ctx: Ctx = Depends(get_ctx)):
295
  """About two minutes of the writer's own prose to read aloud for the clone (their words, their rhythm)."""
backend/app/services/billing.py CHANGED
@@ -112,7 +112,7 @@ def build_nudges(db: Session, workspace: Workspace, plan: Plan, meters: list[dic
112
  "Open Launchpad", "/app/launchpad", priority=35))
113
  out.append(_nudge(f"act-resurface-{month}", "action", "Old posts, new orbit",
114
  "Resurfacing finds evergreen posts worth sharing again this month.", "Find evergreen posts",
115
- "/app/resurface", priority=20))
116
  return sorted(out, key=lambda n: -n["priority"])
117
 
118
 
@@ -146,6 +146,8 @@ def apply_subscription(db: Session, workspace_id, *, plan: str, status: str, pro
146
  if plan not in PLANS:
147
  raise ValueError(f"Unknown plan {plan}")
148
  sub = db.query(Subscription).filter_by(workspace_id=workspace_id).one_or_none()
 
 
149
  if not sub:
150
  sub = Subscription(workspace_id=workspace_id)
151
  db.add(sub)
@@ -154,9 +156,23 @@ def apply_subscription(db: Session, workspace_id, *, plan: str, status: str, pro
154
  sub.provider_subscription_id = subscription_id or sub.provider_subscription_id
155
  sub.current_period_end = current_period_end
156
  db.commit()
 
 
157
  return sub
158
 
159
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  def return_urls() -> tuple[str, str]:
161
  """(success_url, cancel_url) for the hosted checkout page."""
162
  base = settings.frontend_url.rstrip("/")
 
112
  "Open Launchpad", "/app/launchpad", priority=35))
113
  out.append(_nudge(f"act-resurface-{month}", "action", "Old posts, new orbit",
114
  "Resurfacing finds evergreen posts worth sharing again this month.", "Find evergreen posts",
115
+ "/app/launchpad#ideas", priority=20))
116
  return sorted(out, key=lambda n: -n["priority"])
117
 
118
 
 
146
  if plan not in PLANS:
147
  raise ValueError(f"Unknown plan {plan}")
148
  sub = db.query(Subscription).filter_by(workspace_id=workspace_id).one_or_none()
149
+ workspace = db.get(Workspace, workspace_id)
150
+ before = effective_plan(db, workspace).indexed_posts if workspace else None
151
  if not sub:
152
  sub = Subscription(workspace_id=workspace_id)
153
  db.add(sub)
 
156
  sub.provider_subscription_id = subscription_id or sub.provider_subscription_id
157
  sub.current_period_end = current_period_end
158
  db.commit()
159
+ if workspace and effective_plan(db, workspace).indexed_posts != before:
160
+ resync_sources(db, workspace_id)
161
  return sub
162
 
163
 
164
+ def resync_sources(db: Session, workspace_id) -> None:
165
+ """The post limit changed: re-sync every feed so locked posts unlock on upgrade (or re-lock on downgrade)."""
166
+ from app.pipeline.ingest import IMPORTS_FEED
167
+ from app.services.jobs import create_job
168
+
169
+ for source in db.scalars(select(Source).where(Source.workspace_id == workspace_id,
170
+ Source.feed_url != IMPORTS_FEED)):
171
+ source.sync_status = "pending"
172
+ create_job(db, workspace_id, "ingest", {"source_id": str(source.id)})
173
+ db.commit()
174
+
175
+
176
  def return_urls() -> tuple[str, str]:
177
  """(success_url, cancel_url) for the hosted checkout page."""
178
  base = settings.frontend_url.rstrip("/")
backend/app/services/plans.py CHANGED
@@ -41,7 +41,7 @@ PLANS: dict[str, Plan] = {
41
  voice_cloning=False,
42
  brand_kit=False,
43
  features=(
44
- "1 source, your 5 latest posts",
45
  "Grounded research chat with citations",
46
  "3 min of audio overviews a month",
47
  "1 min of video a month",
 
41
  voice_cloning=False,
42
  brand_kit=False,
43
  features=(
44
+ "1 source, your latest 5 posts indexed",
45
  "Grounded research chat with citations",
46
  "3 min of audio overviews a month",
47
  "1 min of video a month",
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
@@ -67,10 +67,22 @@ HANDED_OFF = object() # the job continues elsewhere (renderer) and reports back
67
  # Handlers: (db, job) -> Done | HANDED_OFF. Raise to trigger a retry.
68
 
69
 
 
 
 
 
 
70
  def _after_import(db: Session, job: Job, workspace_id: uuid.UUID, changed: list[str]) -> None:
71
- """New or changed posts get mapped into the topic constellation automatically."""
72
- if changed and settings.llm_api_key:
73
- create_job(db, workspace_id, "topics", {"document_ids": changed}, max_attempts=2)
 
 
 
 
 
 
 
74
 
75
 
76
  def handle_ingest(db: Session, job: Job):
@@ -91,6 +103,9 @@ 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
 
 
67
  # Handlers: (db, job) -> Done | HANDED_OFF. Raise to trigger a retry.
68
 
69
 
70
+ def _pending(db: Session, workspace_id: uuid.UUID, kind: str) -> bool:
71
+ return db.scalar(select(Job.id).where(Job.workspace_id == workspace_id, Job.kind == kind,
72
+ Job.status.in_(("queued", "running"))).limit(1)) is not None
73
+
74
+
75
  def _after_import(db: Session, job: Job, workspace_id: uuid.UUID, changed: list[str]) -> None:
76
+ """New or changed posts are mapped into the topic constellation and scored for resurfacing, and the
77
+ first import builds the writing voice, so none of these need a button press."""
78
+ if not changed or not settings.llm_api_key:
79
+ return
80
+ create_job(db, workspace_id, "topics", {"document_ids": changed}, max_attempts=2)
81
+ if not _pending(db, workspace_id, "resurface_scan"):
82
+ create_job(db, workspace_id, "resurface_scan", {}, max_attempts=2)
83
+ vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == workspace_id))
84
+ if not (vp and vp.profile_json) and not _pending(db, workspace_id, "voice_profile"):
85
+ create_job(db, workspace_id, "voice_profile", {"document_ids": []}, max_attempts=2)
86
 
87
 
88
  def handle_ingest(db: Session, job: Job):
 
103
  raise PermanentJobError(str(exc)) from exc
104
  raise
105
  _after_import(db, job, source.workspace_id, result.get("changed", []))
106
+ if result.get("locked"):
107
+ return Done(result, f"Found {result['found']} posts. Your latest {result['available']} are indexed; "
108
+ f"upgrade to index the other {result['locked']}")
109
  return Done(result, f"All posts in orbit: {result['indexed']} new or updated")
110
 
111
 
backend/tests/test_locked_posts.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Posts past the plan's indexed_posts are listed, not indexed: no text, no corpus file, indexed on a bigger plan."""
2
+
3
+ import uuid
4
+ from datetime import UTC, datetime, timedelta
5
+
6
+ from sqlalchemy import select
7
+
8
+ from app.models import Document, Job, Source, Workspace
9
+ from app.pipeline import ingest
10
+ from app.pipeline.ingest import FeedEntry, ingest_source
11
+ from app.routers.sources import is_locked
12
+
13
+
14
+ def _entries(n: int) -> list[FeedEntry]:
15
+ start = datetime(2026, 1, 1, tzinfo=UTC)
16
+ # Oldest first on purpose: ingest must pick the newest posts regardless of feed order.
17
+ return [
18
+ FeedEntry(title=f"Post {i}", url=f"https://ada.example/p/{i}", published_at=start + timedelta(days=i),
19
+ html=f"<h2>Point {i}</h2><p>Paragraph for post number {i} with enough words to keep.</p>")
20
+ for i in range(n)
21
+ ]
22
+
23
+
24
+ def _setup(db, monkeypatch, n: int):
25
+ monkeypatch.setattr(ingest, "fetch_feed", lambda url, limit: ("Ada", _entries(n)[:limit]))
26
+ ws = Workspace(name="w", owner_id=uuid.uuid4())
27
+ db.add(ws)
28
+ db.flush()
29
+ source = Source(workspace_id=ws.id, feed_url="https://ada.example/feed", platform="rss", title="Ada")
30
+ db.add(source)
31
+ db.flush()
32
+ job = Job(workspace_id=ws.id, kind="ingest", params={"source_id": str(source.id)})
33
+ db.add(job)
34
+ db.commit()
35
+ return ws, source, job
36
+
37
+
38
+ def _docs(db, source):
39
+ return {d.title: d for d in db.scalars(select(Document).where(Document.source_id == source.id))}
40
+
41
+
42
+ def test_lists_every_post_but_indexes_only_the_latest(db_session, monkeypatch):
43
+ ws, source, job = _setup(db_session, monkeypatch, 12)
44
+ result = ingest_source(db_session, source, job, max_posts=5)
45
+
46
+ assert result["found"] == 12 and result["available"] == 5 and result["locked"] == 7 and result["capped"]
47
+ docs = _docs(db_session, source)
48
+ assert len(docs) == 12
49
+ available = {t for t, d in docs.items() if d.path}
50
+ assert available == {f"Post {i}" for i in range(7, 12)} # the five newest
51
+ locked = [d for d in docs.values() if not d.path]
52
+ assert len(locked) == 7
53
+ assert all(is_locked(d) and d.clean_text == "" and d.title for d in locked)
54
+
55
+
56
+ def test_upgrade_unlocks_and_newer_posts_relock_the_oldest(db_session, monkeypatch):
57
+ ws, source, job = _setup(db_session, monkeypatch, 12)
58
+ ingest_source(db_session, source, job, max_posts=5)
59
+
60
+ ingest_source(db_session, source, job, max_posts=500)
61
+ docs = _docs(db_session, source)
62
+ assert all(d.path for d in docs.values())
63
+ assert not any(is_locked(d) for d in docs.values())
64
+
65
+ ingest_source(db_session, source, job, max_posts=5)
66
+ docs = _docs(db_session, source)
67
+ assert sum(1 for d in docs.values() if d.path) == 5
68
+ assert docs["Post 0"].path is None and is_locked(docs["Post 0"])
69
+
70
+
71
+ def test_scan_is_capped_per_sync(db_session, monkeypatch):
72
+ ws, source, job = _setup(db_session, monkeypatch, ingest.SCAN_CAP + 50)
73
+ result = ingest_source(db_session, source, job, max_posts=5)
74
+ assert result["found"] == ingest.SCAN_CAP
75
+
76
+
77
+ def test_crawl_reads_only_the_pages_it_will_index(monkeypatch):
78
+ read = []
79
+ urls = [f"https://ada.example/posts/post-{i}" for i in range(20)]
80
+ monkeypatch.setattr(ingest, "_plain_links", lambda url: urls)
81
+ monkeypatch.setattr(ingest, "_post_urls", lambda links, url, limit: links[:limit])
82
+
83
+ def fake_extract(u):
84
+ read.append(u)
85
+ return FeedEntry(title=u, url=u, published_at=None, html="<p>body text for the page</p>")
86
+
87
+ monkeypatch.setattr(ingest, "extract_article", fake_extract)
88
+ entries = ingest.crawl_site("https://ada.example", 200, read_limit=5)
89
+ assert len(read) == 5 and len(entries) == 20
90
+ assert all(e.html == "" for e in entries[5:])
91
+ assert entries[5].title == "Post 5"
backend/tests/test_simplified_flows.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One-click flows: the "All posts" notebook, whole-archive generation, and jobs that run after a sync."""
2
+
3
+ from sqlalchemy import select
4
+
5
+ from app.config import settings
6
+ from app.models import Job
7
+ from tests.test_buildout import auth, connect, docs, feed, llm # noqa: F401 (fixtures)
8
+
9
+
10
+ def test_archive_notebook_is_created_once_and_listed_first(client, auth, run_jobs, feed):
11
+ connect(client, auth, run_jobs)
12
+ client.post("/api/notebooks", json={"title": "Newer"}, headers=auth)
13
+ first = client.post("/api/notebooks/archive", headers=auth).json()
14
+ again = client.post("/api/notebooks/archive", headers=auth).json()
15
+ assert first["id"] == again["id"] and first["title"] == "All posts"
16
+
17
+ listed = client.get("/api/notebooks", headers=auth).json()
18
+ assert listed[0]["id"] == first["id"] and listed[0]["is_archive"]
19
+ indexed = [d for d in docs(client, auth) if d["path"]]
20
+ assert listed[0]["document_count"] == len(indexed) > 0
21
+
22
+ nb = client.get(f"/api/notebooks/{first['id']}", headers=auth).json()
23
+ assert nb["is_archive"] and len(nb["documents"]) == len(indexed)
24
+
25
+
26
+ def test_generate_from_whole_archive_uses_the_archive_notebook(client, auth, run_jobs, feed):
27
+ connect(client, auth, run_jobs)
28
+ art = client.post("/api/artifacts/generate", json={"type": "summary", "archive": True}, headers=auth).json()
29
+ archive = client.post("/api/notebooks/archive", headers=auth).json()
30
+ assert art["notebook_id"] == archive["id"]
31
+
32
+
33
+ def test_sync_queues_resurfacing_and_writing_voice_once(client, auth, feed, db_session, session_factory,
34
+ monkeypatch):
35
+ from app.services.jobs import claim_next
36
+ from app.worker import run_job
37
+
38
+ monkeypatch.setattr(settings, "llm_api_key", "test-key")
39
+ client.post("/api/sources", json={"url": "ada.example.com"}, headers=auth)
40
+ with session_factory() as db:
41
+ job_id = claim_next(db, "test")
42
+ run_job(job_id, session_factory=session_factory) # the ingest job only; follow-ups stay queued
43
+ db_session.expire_all()
44
+ kinds = [j.kind for j in db_session.scalars(select(Job).where(Job.status == "queued"))]
45
+ assert kinds.count("resurface_scan") == 1
46
+ assert kinds.count("voice_profile") == 1
47
+ assert kinds.count("topics") == 1
frontend/index.html CHANGED
@@ -1,6 +1,15 @@
1
  <!doctype html>
2
  <html lang="en">
3
  <head>
 
 
 
 
 
 
 
 
 
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <meta name="theme-color" content="#000000" />
 
1
  <!doctype html>
2
  <html lang="en">
3
  <head>
4
+ <!-- Google tag (gtag.js) -->
5
+ <script async src="https://www.googletagmanager.com/gtag/js?id=G-GG0C3Y983W"></script>
6
+ <script>
7
+ window.dataLayer = window.dataLayer || [];
8
+ function gtag(){dataLayer.push(arguments);}
9
+ gtag('js', new Date());
10
+
11
+ gtag('config', 'G-GG0C3Y983W');
12
+ </script>
13
  <meta charset="UTF-8" />
14
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
15
  <meta name="theme-color" content="#000000" />
frontend/public/_redirects ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # Signed in and auth routes get the empty app shell (noindex), not the prerendered home page.
2
+ /app/* /app 200
3
+ /auth /app 200
4
+ /auth/* /app 200
5
+ /welcome /app 200
frontend/seo/prerender.ts ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { readFile, mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { Plugin, ResolvedConfig } from "vite";
4
+ import { blogPosts } from "../src/content/blogPosts";
5
+ import { NOTEBOOKLM_ALT_FAQ, NOTEBOOKLM_ALT_META, NOTEBOOKLM_ALT_ROWS } from "../src/content/notebooklmAlternative";
6
+ import type { BlogPost } from "../src/content/seoTypes";
7
+ import { tools, toolsHub, type ToolDef } from "../src/content/tools";
8
+
9
+ /**
10
+ * Writes a static HTML file per public route after `vite build`, so crawlers that do not run JavaScript still get the
11
+ * page's title, description, canonical, headings, copy and links. The React app replaces #root on load.
12
+ *
13
+ * Files are written as <route>.html, not <route>/index.html: Cloudflare Pages serves /blogs/x from blogs/x.html with no
14
+ * redirect, while a folder index would 308 to /blogs/x/ and fight the canonical (which has no trailing slash).
15
+ */
16
+
17
+ type Rendered = { path: string; title: string; description: string; body: string; jsonLd?: object[]; ogType?: string };
18
+
19
+ const esc = (s: string) =>
20
+ s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
21
+
22
+ /** Post copy supports [text](url) links and **bold**; render them as real anchors. */
23
+ const inline = (text: string) =>
24
+ esc(text)
25
+ .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, label: string, href: string) => `<a href="${href}">${label}</a>`)
26
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
27
+
28
+ const plain = (text: string) => text.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/\*\*([^*]+)\*\*/g, "$1");
29
+
30
+ const faqLd = (items: { q: string; a: string }[]) => ({
31
+ "@context": "https://schema.org",
32
+ "@type": "FAQPage",
33
+ mainEntity: items.map((f) => ({ "@type": "Question", name: f.q, acceptedAnswer: { "@type": "Answer", text: plain(f.a) } })),
34
+ });
35
+
36
+ const faqHtml = (items: { q: string; a: string }[]) =>
37
+ items.length
38
+ ? `<section><h2>Questions</h2>${items.map((f) => `<h3>${esc(f.q)}</h3><p>${inline(f.a)}</p>`).join("")}</section>`
39
+ : "";
40
+
41
+ const nav = () =>
42
+ `<header><nav aria-label="Main"><a href="/">Notestack</a> <a href="/notebooklm-alternative">Notestack vs NotebookLM</a> <a href="/tools">Free tools</a> <a href="/pricing">Pricing</a> <a href="/blogs">Blog</a></nav></header>`;
43
+
44
+ const footer = () =>
45
+ `<footer><nav aria-label="Footer"><h2>Free tools</h2><ul>${tools
46
+ .map((t) => `<li><a href="/tools/${t.slug}">${esc(t.heroTitle)}</a></li>`)
47
+ .join("")}</ul><h2>More from Firebird Technologies</h2><ul><li><a href="https://blog2video.app">Blog2Video: blog to video</a></li><li><a href="https://pdf2vid.com">PDF2Video: PDF to video</a></li><li><a href="https://bloghub.app">BlogHub: blog and newsletter directory</a></li></ul></nav><p>&copy; Firebird Technologies</p></footer>`;
48
+
49
+ const page = (main: string) => `${nav()}<main>${main}</main>${footer()}`;
50
+
51
+ function pathTitle(p: string) {
52
+ const post = blogPosts.find((b) => `/blogs/${b.slug}` === p);
53
+ if (post) return post.title;
54
+ const tool = tools.find((t) => `/tools/${t.slug}` === p);
55
+ if (tool) return tool.heroTitle;
56
+ if (p === "/notebooklm-alternative") return "Notestack vs NotebookLM";
57
+ return p;
58
+ }
59
+
60
+ function renderPost(post: BlogPost): Rendered {
61
+ const sections = post.sections
62
+ .map(
63
+ (s) =>
64
+ `<section><h2>${esc(s.heading)}</h2>${s.paragraphs.map((p) => `<p>${inline(p)}</p>`).join("")}${
65
+ s.bullets?.length ? `<ul>${s.bullets.map((b) => `<li>${inline(b)}</li>`).join("")}</ul>` : ""
66
+ }${s.callout ? `<p>${inline(s.callout)}</p>` : ""}</section>`,
67
+ )
68
+ .join("");
69
+ const mission = post.mission ? `<p><strong>${esc(post.mission.question)}</strong> ${inline(post.mission.answer)}</p>` : "";
70
+ const related = post.relatedPaths.length
71
+ ? `<nav aria-label="Related"><h2>Related</h2><ul>${post.relatedPaths
72
+ .map((p) => `<li><a href="${p}">${esc(pathTitle(p))}</a></li>`)
73
+ .join("")}</ul></nav>`
74
+ : "";
75
+ const faq = post.faq.map((f) => ({ q: f.question, a: f.answer }));
76
+ return {
77
+ path: `/blogs/${post.slug}`,
78
+ title: `${post.title} | Notestack`,
79
+ description: post.description,
80
+ ogType: "article",
81
+ body: page(
82
+ `<article><p>${esc(post.heroEyebrow)}</p><h1>${esc(post.title)}</h1><p>${esc(post.heroDescription)}</p><time datetime="${post.publishedAt}">${post.publishedAt}</time>${mission}${sections}${faqHtml(faq)}</article>${related}`,
83
+ ),
84
+ jsonLd: [
85
+ {
86
+ "@context": "https://schema.org",
87
+ "@type": "BlogPosting",
88
+ headline: post.title,
89
+ description: post.description,
90
+ datePublished: post.publishedAt,
91
+ author: { "@type": "Organization", name: "Notestack" },
92
+ publisher: { "@type": "Organization", name: "Firebird Technologies" },
93
+ },
94
+ ...(faq.length ? [faqLd(faq)] : []),
95
+ ],
96
+ };
97
+ }
98
+
99
+ function renderTool(tool: ToolDef): Rendered {
100
+ const faq = tool.faq.map((f) => ({ q: f.question, a: f.answer }));
101
+ const sections = tool.sections
102
+ .map(
103
+ (s) =>
104
+ `<section><h2>${esc(s.heading)}</h2>${s.paragraphs.map((p) => `<p>${inline(p)}</p>`).join("")}${
105
+ s.bullets?.length ? `<ul>${s.bullets.map((b) => `<li>${esc(b)}</li>`).join("")}</ul>` : ""
106
+ }</section>`,
107
+ )
108
+ .join("");
109
+ const next = encodeURIComponent(`/tools/${tool.slug}`);
110
+ return {
111
+ path: `/tools/${tool.slug}`,
112
+ title: tool.metaTitle,
113
+ description: tool.description,
114
+ body: page(
115
+ `<article><p>${esc(tool.eyebrow)}</p><h1>${esc(tool.heroTitle)}</h1><p>${esc(tool.heroDescription)}</p><p><a href="/auth?mode=signup&amp;next=${next}">Sign up free to use the ${esc(
116
+ tool.eyebrow.toLowerCase(),
117
+ )}</a></p>${sections}${faqHtml(faq)}</article><nav aria-label="Related"><h2>Keep exploring</h2><ul>${tool.relatedPaths
118
+ .map((p) => `<li><a href="${p}">${esc(pathTitle(p))}</a></li>`)
119
+ .join("")}</ul></nav>`,
120
+ ),
121
+ jsonLd: [
122
+ {
123
+ "@context": "https://schema.org",
124
+ "@type": "SoftwareApplication",
125
+ name: tool.heroTitle,
126
+ applicationCategory: "MultimediaApplication",
127
+ operatingSystem: "Web",
128
+ description: tool.description,
129
+ offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
130
+ publisher: { "@type": "Organization", name: "Notestack" },
131
+ },
132
+ faqLd(faq),
133
+ ],
134
+ };
135
+ }
136
+
137
+ function routes(): Rendered[] {
138
+ const posts = [...blogPosts].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
139
+ const altFaq = NOTEBOOKLM_ALT_FAQ.map((f) => ({ q: f.q, a: f.a }));
140
+ return [
141
+ {
142
+ path: "/",
143
+ title: "Notestack: your knowledge, in orbit | NotebookLM alternative for writers",
144
+ description:
145
+ "Notestack turns any blog, newsletter, site or markdown files into a grounded research notebook, audio overviews, videos and a launch kit, all in your own voice.",
146
+ body: page(
147
+ `<h1>Your knowledge, in orbit.</h1><p>Notestack turns your blog, newsletter, site or markdown files into a research notebook you can question, podcasts you can publish and launch kits for every platform. Every word traced back to what you actually wrote.</p><p><a href="/auth?mode=signup">Start free</a> or see <a href="/notebooklm-alternative">Notestack vs NotebookLM</a>.</p><h2>Free tools</h2><ul>${tools
148
+ .map((t) => `<li><a href="/tools/${t.slug}">${esc(t.heroTitle)}</a>: ${esc(t.description)}</li>`)
149
+ .join("")}</ul><h2>Field notes</h2><ul>${posts
150
+ .map((p) => `<li><a href="/blogs/${p.slug}">${esc(p.title)}</a></li>`)
151
+ .join("")}</ul>`,
152
+ ),
153
+ jsonLd: [
154
+ {
155
+ "@context": "https://schema.org",
156
+ "@type": "SoftwareApplication",
157
+ name: "Notestack",
158
+ applicationCategory: "BusinessApplication",
159
+ operatingSystem: "Web",
160
+ description: "The NotebookLM alternative for writers: a grounded research notebook built from your own archive.",
161
+ offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
162
+ publisher: { "@type": "Organization", name: "Firebird Technologies" },
163
+ },
164
+ ],
165
+ },
166
+ {
167
+ path: "/notebooklm-alternative",
168
+ title: NOTEBOOKLM_ALT_META.title,
169
+ description: NOTEBOOKLM_ALT_META.description,
170
+ body: page(
171
+ `<article><p>NotebookLM alternative</p><h1>Notestack vs NotebookLM: the NotebookLM alternative for writers</h1><p>NotebookLM is great for a pile of documents. Notestack is built for a body of work: it syncs your blog, newsletter, site or markdown, answers with citations to the exact lines you wrote, and turns your knowledge into audio, video and launch posts.</p><p><a href="/auth?mode=signup">Start free</a></p><section><h2>Notestack vs NotebookLM</h2><table><thead><tr><th></th><th>Notestack</th><th>NotebookLM</th></tr></thead><tbody>${NOTEBOOKLM_ALT_ROWS.map(
172
+ (r) => `<tr><th>${esc(r.feature)}</th><td>${esc(r.notestack)}</td><td>${esc(r.notebooklm)}</td></tr>`,
173
+ ).join("")}</tbody></table></section><section><h2>When NotebookLM is still the right call</h2><p>If you are studying someone else's documents for a one off project, want a free tool from Google, or live in Google Drive, NotebookLM is excellent. Notestack is the NotebookLM alternative when the sources are your own writing and the goal is to reuse and publish it.</p></section><section><h2>Pair it with the right publishing tool</h2><ul><li><a href="https://blog2video.app/blog-to-video">Blog2Video</a>: turn blog posts, URLs and scripts into finished videos.</li><li><a href="https://pdf2vid.com">PDF2Video</a>: turn any PDF, report, paper or deck into a narrated video.</li><li><a href="https://bloghub.app/submit-your-newsletter">BlogHub</a>: list your blog or newsletter in a free directory.</li></ul></section>${faqHtml(
174
+ altFaq,
175
+ )}<p>Comparing more tools? Read our guide to <a href="/blogs/notebooklm-alternatives">NotebookLM alternatives</a> and <a href="/blogs/notebooklm-vs-chatgpt">NotebookLM vs ChatGPT</a>.</p></article>`,
176
+ ),
177
+ jsonLd: [faqLd(altFaq)],
178
+ },
179
+ {
180
+ path: "/pricing",
181
+ title: "Pricing | Notestack",
182
+ description:
183
+ "Notestack pricing: Free ($0), Writer ($24.99 a month, $18.99 billed annually) and Studio ($48.99 a month, $36.99 billed annually). Annual billing saves 24%.",
184
+ body: page(
185
+ `<h1>Pick your trajectory</h1><ul><li><h2>Free</h2><p>$0. 1 source, your latest 5 posts indexed, 3 audio minutes, 1 video minute, 2 Launch Kits a month.</p></li><li><h2>Writer</h2><p>$24.99 a month ($18.99 a month billed annually). 3 sources, 500 posts, 60 audio minutes, 30 video minutes, 50 Launch Kits.</p></li><li><h2>Studio</h2><p>$48.99 a month ($36.99 a month billed annually). 10 sources, 5,000 posts, 240 audio minutes, 120 video minutes, unlimited Launch Kits.</p></li></ul><p><a href="/auth?mode=signup">Start free</a></p>`,
186
+ ),
187
+ },
188
+ {
189
+ path: "/blogs",
190
+ title: "Blog | Notestack",
191
+ description: "Field notes on research, deep writing and turning your archive into audio, video and launches.",
192
+ body: page(
193
+ `<h1>Every post is a search for an answer</h1>${posts
194
+ .map(
195
+ (p) =>
196
+ `<article><h2><a href="/blogs/${p.slug}">${esc(p.title)}</a></h2><p>${esc(p.description)}</p><time datetime="${p.publishedAt}">${p.publishedAt}</time></article>`,
197
+ )
198
+ .join("")}`,
199
+ ),
200
+ },
201
+ {
202
+ path: "/tools",
203
+ title: toolsHub.metaTitle,
204
+ description: toolsHub.description,
205
+ body: page(
206
+ `<h1>${esc(toolsHub.heroTitle)}</h1><p>${esc(toolsHub.heroDescription)}</p><ul>${tools
207
+ .map((t) => `<li><h2><a href="/tools/${t.slug}">${esc(t.heroTitle)}</a></h2><p>${esc(t.description)}</p></li>`)
208
+ .join("")}</ul>`,
209
+ ),
210
+ },
211
+ ...tools.map(renderTool),
212
+ ...posts.filter((p) => !p.canonicalPath).map(renderPost),
213
+ ];
214
+ }
215
+
216
+ /** Strips the head tags this plugin owns, so every route gets exactly one of each. */
217
+ function sanitize(template: string) {
218
+ return template
219
+ .replace(/<title>[\s\S]*?<\/title>\s*/gi, "")
220
+ .replace(/<meta\s+name="description"[^>]*>\s*/gi, "")
221
+ .replace(/<meta\s+property="og:(title|description|url|type)"[^>]*>\s*/gi, "")
222
+ .replace(/<meta\s+name="twitter:(title|description)"[^>]*>\s*/gi, "")
223
+ .replace(/<link\s+rel="canonical"[^>]*>\s*/gi, "");
224
+ }
225
+
226
+ function head(r: Rendered, origin: string) {
227
+ const url = `${origin}${r.path === "/" ? "/" : r.path}`;
228
+ const tags = [
229
+ `<title>${esc(r.title)}</title>`,
230
+ `<meta name="description" content="${esc(r.description)}" />`,
231
+ `<link rel="canonical" href="${url}" />`,
232
+ `<meta property="og:type" content="${r.ogType ?? "website"}" />`,
233
+ `<meta property="og:title" content="${esc(r.title)}" />`,
234
+ `<meta property="og:description" content="${esc(r.description)}" />`,
235
+ `<meta property="og:url" content="${url}" />`,
236
+ `<meta name="twitter:title" content="${esc(r.title)}" />`,
237
+ `<meta name="twitter:description" content="${esc(r.description)}" />`,
238
+ ...(r.jsonLd ?? []).map((ld) => `<script type="application/ld+json">${JSON.stringify(ld).replace(/</g, "\\u003c")}</script>`),
239
+ ];
240
+ return tags.join("\n ");
241
+ }
242
+
243
+ export function prerenderPlugin(siteUrl: string): Plugin {
244
+ const origin = siteUrl.replace(/\/+$/, "");
245
+ let config: ResolvedConfig;
246
+ return {
247
+ name: "notestack-prerender",
248
+ apply: "build",
249
+ configResolved(c) {
250
+ config = c;
251
+ },
252
+ async closeBundle() {
253
+ const outDir = path.resolve(config.root, config.build.outDir);
254
+ const template = sanitize(await readFile(path.join(outDir, "index.html"), "utf8"));
255
+ const all = routes();
256
+ for (const r of all) {
257
+ const html = template
258
+ .replace("</head>", ` ${head(r, origin)}\n </head>`)
259
+ .replace('<div id="root"></div>', `<div id="root">${r.body}</div>`);
260
+ const file = r.path === "/" ? path.join(outDir, "index.html") : path.join(outDir, `${r.path.slice(1)}.html`);
261
+ await mkdir(path.dirname(file), { recursive: true });
262
+ await writeFile(file, html, "utf8");
263
+ }
264
+ // Signed in and unknown routes fall back to an app shell with no page content (see public/_redirects).
265
+ await writeFile(path.join(outDir, "app.html"), template.replace("</head>", ` <meta name="robots" content="noindex" />\n <title>Notestack</title>\n </head>`), "utf8");
266
+ config.logger.info(`prerendered ${all.length} public routes`);
267
+ },
268
+ };
269
+ }
frontend/seo/sitemap.ts CHANGED
@@ -1,5 +1,6 @@
1
  import type { Plugin } from "vite";
2
  import { blogPosts } from "../src/content/blogPosts";
 
3
 
4
  /**
5
  * sitemap.xml and robots.txt, generated from src/content/blogPosts.ts on every build (and served live
@@ -13,6 +14,8 @@ const STATIC_PAGES: { path: string; priority: string; changefreq: string }[] = [
13
  { path: "/notebooklm-alternative", priority: "0.9", changefreq: "monthly" },
14
  { path: "/pricing", priority: "0.8", changefreq: "monthly" },
15
  { path: "/blogs", priority: "0.9", changefreq: "weekly" },
 
 
16
  ];
17
 
18
  const escapeXml = (s: string) =>
@@ -93,7 +96,7 @@ differs from NotebookLM.
93
  SEO pack, carousel).
94
  - **Publishing:** a Launchpad calendar that auto posts to X, LinkedIn and Bluesky, plus a topic map of rising and
95
  dormant themes and evergreen resurfacing of old posts.
96
- - **Pricing (USD):** Free: 1 source, first 5 posts indexed, 3 audio minutes, 1 video minute, 2 launch kits a month.
97
  Writer: $24.99 a month ($18.99 a month billed annually): 3 sources, 500 posts, 60 audio minutes, 30 video minutes,
98
  50 launch kits. Studio: $48.99 a month ($36.99 a month billed annually): 10 sources, 5,000 posts, 240 audio
99
  minutes, 120 video minutes, unlimited launch kits. Annual billing saves 24%.
@@ -106,6 +109,7 @@ differs from NotebookLM.
106
  - [Notestack vs NotebookLM](${base}/notebooklm-alternative): side by side comparison and FAQ
107
  - [Pricing](${base}/pricing): plans and limits
108
  - [Blog](${base}/blogs): guides on NotebookLM alternatives, AI for research and AI tools for writers
 
109
 
110
  ## Blog
111
 
 
1
  import type { Plugin } from "vite";
2
  import { blogPosts } from "../src/content/blogPosts";
3
+ import { tools } from "../src/content/tools";
4
 
5
  /**
6
  * sitemap.xml and robots.txt, generated from src/content/blogPosts.ts on every build (and served live
 
14
  { path: "/notebooklm-alternative", priority: "0.9", changefreq: "monthly" },
15
  { path: "/pricing", priority: "0.8", changefreq: "monthly" },
16
  { path: "/blogs", priority: "0.9", changefreq: "weekly" },
17
+ { path: "/tools", priority: "0.8", changefreq: "monthly" },
18
+ ...tools.map((t) => ({ path: `/tools/${t.slug}`, priority: "0.8", changefreq: "monthly" })),
19
  ];
20
 
21
  const escapeXml = (s: string) =>
 
96
  SEO pack, carousel).
97
  - **Publishing:** a Launchpad calendar that auto posts to X, LinkedIn and Bluesky, plus a topic map of rising and
98
  dormant themes and evergreen resurfacing of old posts.
99
+ - **Pricing (USD):** Free: 1 source, latest 5 posts indexed, 3 audio minutes, 1 video minute, 2 launch kits a month.
100
  Writer: $24.99 a month ($18.99 a month billed annually): 3 sources, 500 posts, 60 audio minutes, 30 video minutes,
101
  50 launch kits. Studio: $48.99 a month ($36.99 a month billed annually): 10 sources, 5,000 posts, 240 audio
102
  minutes, 120 video minutes, unlimited launch kits. Annual billing saves 24%.
 
109
  - [Notestack vs NotebookLM](${base}/notebooklm-alternative): side by side comparison and FAQ
110
  - [Pricing](${base}/pricing): plans and limits
111
  - [Blog](${base}/blogs): guides on NotebookLM alternatives, AI for research and AI tools for writers
112
+ - [Free tools](${base}/tools): ${tools.map((t) => `[${t.heroTitle}](${base}/tools/${t.slug})`).join(", ")} (free with sign in)
113
 
114
  ## Blog
115
 
frontend/src/App.tsx CHANGED
@@ -19,6 +19,7 @@ import Settings from "./pages/Settings";
19
  import Sources from "./pages/Sources";
20
  import Studio from "./pages/Studio";
21
  import TopicMap from "./pages/TopicMap";
 
22
  import VoiceProfile from "./pages/VoiceProfile";
23
  import Welcome from "./pages/Welcome";
24
 
@@ -43,6 +44,8 @@ export default function App() {
43
  <Route path="/" element={<Landing />} />
44
  <Route path="/pricing" element={<PricingPage />} />
45
  <Route path="/notebooklm-alternative" element={<NotebookLMAlternative />} />
 
 
46
  <Route path="/blogs" element={<Blog />} />
47
  <Route path="/blogs/:slug" element={<BlogPostPage />} />
48
  <Route path="/auth" element={<AuthPage />} />
 
19
  import Sources from "./pages/Sources";
20
  import Studio from "./pages/Studio";
21
  import TopicMap from "./pages/TopicMap";
22
+ import { ToolPage, ToolsHub } from "./pages/Tools";
23
  import VoiceProfile from "./pages/VoiceProfile";
24
  import Welcome from "./pages/Welcome";
25
 
 
44
  <Route path="/" element={<Landing />} />
45
  <Route path="/pricing" element={<PricingPage />} />
46
  <Route path="/notebooklm-alternative" element={<NotebookLMAlternative />} />
47
+ <Route path="/tools" element={<ToolsHub />} />
48
+ <Route path="/tools/:slug" element={<ToolPage />} />
49
  <Route path="/blogs" element={<Blog />} />
50
  <Route path="/blogs/:slug" element={<BlogPostPage />} />
51
  <Route path="/auth" element={<AuthPage />} />
frontend/src/api/endpoints.ts CHANGED
@@ -24,6 +24,8 @@ import type {
24
  Usage,
25
  Voice,
26
  Delivery,
 
 
27
  VoiceProfileData,
28
  VoiceState,
29
  } from "./types";
@@ -54,6 +56,8 @@ export const notebooksApi = {
54
  get: (id: string) => api<Notebook>(`/api/notebooks/${id}`),
55
  create: (title: string, document_ids: string[] = [], description?: string) =>
56
  post<{ id: string; title: string; added: number }>("/api/notebooks", { title, document_ids, description }),
 
 
57
  fromTopic: (topicId: string) => post<{ id: string; title: string }>(`/api/notebooks/from-topic/${topicId}`),
58
  update: (id: string, body: { title?: string; description?: string }) => patch(`/api/notebooks/${id}`, body),
59
  remove: (id: string) => del(`/api/notebooks/${id}`),
@@ -70,6 +74,8 @@ export type GenerateBody = {
70
  type: ArtifactType;
71
  notebook_id?: string;
72
  document_id?: string;
 
 
73
  format?: "deep_dive" | "brief" | "debate";
74
  minutes?: number;
75
  style?: "short" | "explainer" | "audiogram";
@@ -129,6 +135,9 @@ export const voiceApi = {
129
  readingScript: () => api<{ title: string; text: string; words: number }>("/api/voice/reading-script"),
130
  quota: () => api<Quota>("/api/voice/quota"),
131
  revoke: () => del<VoiceState>("/api/voice/consent"),
 
 
 
132
  };
133
 
134
  export type ItemBody = {
 
24
  Usage,
25
  Voice,
26
  Delivery,
27
+ VoiceDesignInput,
28
+ VoiceDesignPreview,
29
  VoiceProfileData,
30
  VoiceState,
31
  } from "./types";
 
56
  get: (id: string) => api<Notebook>(`/api/notebooks/${id}`),
57
  create: (title: string, document_ids: string[] = [], description?: string) =>
58
  post<{ id: string; title: string; added: number }>("/api/notebooks", { title, document_ids, description }),
59
+ /** The "All posts" notebook, created on first use. */
60
+ archive: () => post<{ id: string; title: string }>("/api/notebooks/archive"),
61
  fromTopic: (topicId: string) => post<{ id: string; title: string }>(`/api/notebooks/from-topic/${topicId}`),
62
  update: (id: string, body: { title?: string; description?: string }) => patch(`/api/notebooks/${id}`, body),
63
  remove: (id: string) => del(`/api/notebooks/${id}`),
 
74
  type: ArtifactType;
75
  notebook_id?: string;
76
  document_id?: string;
77
+ /** No notebook or post: use the "All posts" notebook. */
78
+ archive?: boolean;
79
  format?: "deep_dive" | "brief" | "debate";
80
  minutes?: number;
81
  style?: "short" | "explainer" | "audiogram";
 
135
  readingScript: () => api<{ title: string; text: string; words: number }>("/api/voice/reading-script"),
136
  quota: () => api<Quota>("/api/voice/quota"),
137
  revoke: () => del<VoiceState>("/api/voice/consent"),
138
+ design: (input: VoiceDesignInput) => post<{ description: string; previews: VoiceDesignPreview[] }>("/api/voice/design", input),
139
+ saveDesign: (body: { generated_voice_id: string; name: string; description: string; use_as?: "host_a" | "host_b" }) =>
140
+ post<VoiceState & { voice_id: string }>("/api/voice/design/save", body),
141
  };
142
 
143
  export type ItemBody = {
frontend/src/api/types.ts CHANGED
@@ -25,6 +25,8 @@ export type Source = {
25
  sync_error: string | null;
26
  last_synced_at: string | null;
27
  document_count: number;
 
 
28
  is_imports: boolean;
29
  };
30
 
@@ -38,9 +40,11 @@ export type Doc = {
38
  published_at: string | null;
39
  words: number;
40
  evergreen_score: number | null;
 
 
41
  };
42
 
43
- export type DocDetail = Doc & { lines: string[] };
44
 
45
  export type Page<T> = { total: number; items: T[] };
46
 
@@ -52,6 +56,8 @@ export type NotebookSummary = {
52
  document_count: number;
53
  chat_count: number;
54
  artifact_count: number;
 
 
55
  updated_at: string | null;
56
  };
57
 
@@ -60,6 +66,7 @@ export type Notebook = {
60
  title: string;
61
  description: string | null;
62
  summary: string | null;
 
63
  documents: Doc[];
64
  };
65
 
@@ -195,9 +202,14 @@ export type VoiceState = {
195
  error: string | null;
196
  preview_url: string | null;
197
  };
 
 
198
  tts_configured: boolean;
199
  };
200
 
 
 
 
201
  export type Delivery = { stability: number; similarity_boost: number; style: number; speed: number; use_speaker_boost?: boolean };
202
 
203
  export type Voice = {
 
25
  sync_error: string | null;
26
  last_synced_at: string | null;
27
  document_count: number;
28
+ /** Found but past the plan's post limit: listed, not indexed until the workspace upgrades. */
29
+ locked_count: number;
30
  is_imports: boolean;
31
  };
32
 
 
40
  published_at: string | null;
41
  words: number;
42
  evergreen_score: number | null;
43
+ /** Found but past the plan's post limit: title and link only, not indexed until the workspace upgrades. */
44
+ locked: boolean;
45
  };
46
 
47
+ export type DocDetail = Doc & { lines: string[]; preview?: boolean; limit?: number };
48
 
49
  export type Page<T> = { total: number; items: T[] };
50
 
 
56
  document_count: number;
57
  chat_count: number;
58
  artifact_count: number;
59
+ /** The always-present "All posts" notebook. */
60
+ is_archive: boolean;
61
  updated_at: string | null;
62
  };
63
 
 
66
  title: string;
67
  description: string | null;
68
  summary: string | null;
69
+ is_archive: boolean;
70
  documents: Doc[];
71
  };
72
 
 
202
  error: string | null;
203
  preview_url: string | null;
204
  };
205
+ /** Voices this workspace generated with Voice Design. */
206
+ custom_voices: { voice_id: string; name: string; description?: string }[];
207
  tts_configured: boolean;
208
  };
209
 
210
+ export type VoiceDesignInput = { prompt?: string; gender?: string; age?: string; persona?: string; pace?: string; accent?: string };
211
+ export type VoiceDesignPreview = { generated_voice_id: string; url: string; seconds: number | null };
212
+
213
  export type Delivery = { stability: number; similarity_boost: number; style: number; speed: number; use_speaker_boost?: boolean };
214
 
215
  export type Voice = {
frontend/src/components/AppShell.tsx CHANGED
@@ -8,31 +8,25 @@ import SkyCanvas from "./SkyCanvas";
8
  import UpgradeModal from "./UpgradeModal";
9
  import {
10
  AsteroidIcon,
11
- CometIcon,
12
- ConstellationIcon,
13
  HelmetIcon,
14
  LaunchpadIcon,
15
  LaunchWindowIcon,
16
  OrbitIcon,
17
  PlanetIcon,
18
  RadarIcon,
19
- RocketIcon,
20
  SatelliteDishIcon,
21
  } from "./icons/Icons";
22
 
23
  // Theme in the icon, clarity in the label: the plain label always shows.
24
- const NAV = [
25
- { to: "/app", label: "Mission Control", icon: RadarIcon, end: true },
 
 
26
  { to: "/app/notebooks", label: "Notebooks", icon: PlanetIcon },
27
- { to: "/app/sources", label: "Sources", icon: SatelliteDishIcon },
28
- { to: "/app/map", label: "Topic map", icon: ConstellationIcon },
29
- { to: "/app/voice", label: "Voice profile", icon: HelmetIcon },
30
- { to: "/app/studio", label: "Video and audio", icon: LaunchWindowIcon },
31
- { to: "/app/launch-kit", label: "Launch Kit", icon: RocketIcon },
32
- { to: "/app/launchpad", label: "Launchpad", icon: LaunchpadIcon },
33
- { to: "/app/archive", label: "Archive", icon: AsteroidIcon },
34
- { to: "/app/resurface", label: "Resurfacing", icon: CometIcon },
35
- { to: "/app/settings", label: "Settings", icon: OrbitIcon },
36
  ];
37
 
38
  const COLLAPSED_KEY = "ns_sidebar_collapsed";
@@ -95,14 +89,24 @@ export default function AppShell() {
95
  </button>
96
  </div>
97
  <nav aria-label="App">
98
- {NAV.map(({ to, label, icon: Icon, end }) => (
99
- <NavLink key={to} to={to} end={end} title={collapsed ? label : undefined} className={({ isActive }) => `side-link${isActive ? " active" : ""}`}>
 
 
 
 
 
 
100
  <Icon />
101
  <span className="side-label">{label}</span>
102
  </NavLink>
103
  ))}
104
  </nav>
105
  <FuelGauge collapsed={collapsed} />
 
 
 
 
106
  <div className="sidebar-user">
107
  <span className="mono muted side-label">{user.email}</span>
108
  <button className="link-btn" onClick={logout} title="Sign out">
 
8
  import UpgradeModal from "./UpgradeModal";
9
  import {
10
  AsteroidIcon,
 
 
11
  HelmetIcon,
12
  LaunchpadIcon,
13
  LaunchWindowIcon,
14
  OrbitIcon,
15
  PlanetIcon,
16
  RadarIcon,
 
17
  SatelliteDishIcon,
18
  } from "./icons/Icons";
19
 
20
  // Theme in the icon, clarity in the label: the plain label always shows.
21
+ // Seven stops: related pages share one entry (`also` keeps it lit on them).
22
+ const NAV: { to: string; label: string; icon: typeof RadarIcon; end?: boolean; also?: string[] }[] = [
23
+ { to: "/app", label: "Home", icon: RadarIcon, end: true },
24
+ { to: "/app/sources", label: "Sources", icon: SatelliteDishIcon, also: ["/app/map"] },
25
  { to: "/app/notebooks", label: "Notebooks", icon: PlanetIcon },
26
+ { to: "/app/studio", label: "Create", icon: LaunchWindowIcon, also: ["/app/launch-kit"] },
27
+ { to: "/app/launchpad", label: "Launchpad", icon: LaunchpadIcon, also: ["/app/resurface"] },
28
+ { to: "/app/archive", label: "Library", icon: AsteroidIcon },
29
+ { to: "/app/voice", label: "Voice", icon: HelmetIcon },
 
 
 
 
 
30
  ];
31
 
32
  const COLLAPSED_KEY = "ns_sidebar_collapsed";
 
89
  </button>
90
  </div>
91
  <nav aria-label="App">
92
+ {NAV.map(({ to, label, icon: Icon, end, also }) => (
93
+ <NavLink
94
+ key={to}
95
+ to={to}
96
+ end={end}
97
+ title={collapsed ? label : undefined}
98
+ className={({ isActive }) => `side-link${isActive || also?.some((p) => location.pathname.startsWith(p)) ? " active" : ""}`}
99
+ >
100
  <Icon />
101
  <span className="side-label">{label}</span>
102
  </NavLink>
103
  ))}
104
  </nav>
105
  <FuelGauge collapsed={collapsed} />
106
+ <NavLink to="/app/settings" title={collapsed ? "Settings" : undefined} className={({ isActive }) => `side-link side-settings${isActive ? " active" : ""}`}>
107
+ <OrbitIcon />
108
+ <span className="side-label">Settings</span>
109
+ </NavLink>
110
  <div className="sidebar-user">
111
  <span className="mono muted side-label">{user.email}</span>
112
  <button className="link-btn" onClick={logout} title="Sign out">
frontend/src/components/PricingTiers.tsx CHANGED
@@ -26,7 +26,7 @@ const FALLBACK: Plan[] = [
26
  tagline: "For trying Notestack on your archive",
27
  price_monthly_usd: 0,
28
  features: [
29
- "1 source, your 5 latest posts",
30
  "Grounded research chat with citations",
31
  "3 min of audio overviews a month",
32
  "1 min of video a month",
 
26
  tagline: "For trying Notestack on your archive",
27
  price_monthly_usd: 0,
28
  features: [
29
+ "1 source, your latest 5 posts indexed",
30
  "Grounded research chat with citations",
31
  "3 min of audio overviews a month",
32
  "1 min of video a month",
frontend/src/components/PublicChrome.tsx CHANGED
@@ -1,6 +1,7 @@
1
  import { Link, NavLink } from "react-router-dom";
2
  import { useAuth } from "../hooks/useAuth";
3
  import { blogPosts } from "../content/blogPosts";
 
4
  import Logo from "./Logo";
5
 
6
  export function PublicNav() {
@@ -14,6 +15,7 @@ export function PublicNav() {
14
  <nav className="nav-links" aria-label="Main">
15
  <NavLink to="/#features">Features</NavLink>
16
  <NavLink to="/pricing">Pricing</NavLink>
 
17
  <NavLink to="/blogs">Blog</NavLink>
18
  </nav>
19
  {user ? (
@@ -52,6 +54,14 @@ export function PublicFooter() {
52
  <Link to="/blogs">Blog</Link>
53
  <Link to="/auth">Sign in</Link>
54
  </div>
 
 
 
 
 
 
 
 
55
  <div>
56
  <p className="eyebrow">From the blog</p>
57
  {blogPosts.slice(0, 3).map((p) => (
 
1
  import { Link, NavLink } from "react-router-dom";
2
  import { useAuth } from "../hooks/useAuth";
3
  import { blogPosts } from "../content/blogPosts";
4
+ import { tools } from "../content/tools";
5
  import Logo from "./Logo";
6
 
7
  export function PublicNav() {
 
15
  <nav className="nav-links" aria-label="Main">
16
  <NavLink to="/#features">Features</NavLink>
17
  <NavLink to="/pricing">Pricing</NavLink>
18
+ <NavLink to="/tools">Free tools</NavLink>
19
  <NavLink to="/blogs">Blog</NavLink>
20
  </nav>
21
  {user ? (
 
54
  <Link to="/blogs">Blog</Link>
55
  <Link to="/auth">Sign in</Link>
56
  </div>
57
+ <div>
58
+ <p className="eyebrow">Free tools</p>
59
+ {tools.map((t) => (
60
+ <Link key={t.slug} to={`/tools/${t.slug}`}>
61
+ {t.eyebrow}
62
+ </Link>
63
+ ))}
64
+ </div>
65
  <div>
66
  <p className="eyebrow">From the blog</p>
67
  {blogPosts.slice(0, 3).map((p) => (
frontend/src/components/Reader.tsx CHANGED
@@ -1,6 +1,7 @@
1
  import { useEffect, useRef, useState } from "react";
2
  import { docsApi } from "../api/endpoints";
3
  import type { DocDetail } from "../api/types";
 
4
  import { Drawer, formatDate, Loading } from "./ui";
5
 
6
  /** Reads a post from the corpus with the exact line numbers citations point at. */
@@ -16,6 +17,7 @@ export function Reader({
16
  const [doc, setDoc] = useState<DocDetail | null>(null);
17
  const [error, setError] = useState<string | null>(null);
18
  const first = useRef<HTMLDivElement>(null);
 
19
 
20
  useEffect(() => {
21
  setDoc(null);
@@ -69,6 +71,20 @@ export function Reader({
69
  );
70
  })}
71
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  </article>
73
  )}
74
  </Drawer>
 
1
  import { useEffect, useRef, useState } from "react";
2
  import { docsApi } from "../api/endpoints";
3
  import type { DocDetail } from "../api/types";
4
+ import { useUpgrade } from "../hooks/useUpgrade";
5
  import { Drawer, formatDate, Loading } from "./ui";
6
 
7
  /** Reads a post from the corpus with the exact line numbers citations point at. */
 
17
  const [doc, setDoc] = useState<DocDetail | null>(null);
18
  const [error, setError] = useState<string | null>(null);
19
  const first = useRef<HTMLDivElement>(null);
20
+ const { openUpgrade } = useUpgrade();
21
 
22
  useEffect(() => {
23
  setDoc(null);
 
71
  );
72
  })}
73
  </div>
74
+ {doc.preview && (
75
+ <div className="welcome-cap locked-cap">
76
+ <div>
77
+ <strong>This post is not indexed</strong>
78
+ <p className="muted small">
79
+ Your plan indexes your latest {doc.limit ?? 5} posts. Upgrade to index this one and the rest of your
80
+ archive, so you can ask about it and turn it into audio, video and Launch Kits.
81
+ </p>
82
+ </div>
83
+ <button type="button" className="btn btn-small btn-primary" onClick={() => openUpgrade("indexed_posts")}>
84
+ Upgrade to index it
85
+ </button>
86
+ </div>
87
+ )}
88
  </article>
89
  )}
90
  </Drawer>
frontend/src/content/blogPosts.ts CHANGED
@@ -46,6 +46,7 @@ export const blogPosts: BlogPost[] = [
46
  paragraphs: [
47
  "Paste your Substack URL. Pick a post or build a notebook of related posts. Choose a format: deep dive, brief or debate. Notestack writes the script, you review it, and ElevenLabs voices it.",
48
  "Publish the file to your podcast feed, attach it to the original post, or cut a 60 second audiogram for social.",
 
49
  ],
50
  },
51
  ],
 
46
  paragraphs: [
47
  "Paste your Substack URL. Pick a post or build a notebook of related posts. Choose a format: deep dive, brief or debate. Notestack writes the script, you review it, and ElevenLabs voices it.",
48
  "Publish the file to your podcast feed, attach it to the original post, or cut a 60 second audiogram for social.",
49
+ "Want to see the shape of the episode before you connect anything? Paste a post into the free [AI podcast generator](/tools/ai-podcast-generator) for a two host script, or use the [podcast script generator](/tools/podcast-script-generator) if you plan to record it yourself.",
50
  ],
51
  },
52
  ],
frontend/src/content/missionPosts.ts CHANGED
@@ -77,6 +77,7 @@ export const missionPosts: BlogPost[] = [
77
  {
78
  heading: "For audio and video overviews",
79
  paragraphs: [
 
80
  "Audio overviews are the feature people miss most. Notestack makes two host audio overviews from a post or a notebook, with every line tied back to a passage, and you can pick from ElevenLabs voices or clone your own with consent.",
81
  "Video is where dedicated tools win. If your source is a report, paper or deck, [PDF2Video](https://pdf2vid.com) turns any PDF into a narrated video. If it is a blog post, URL or script, [Blog2Video](https://blog2video.app) turns it into a finished video without you touching an editor. Both are a better fit than a notebook when the video itself is the deliverable.",
82
  ],
@@ -86,7 +87,7 @@ export const missionPosts: BlogPost[] = [
86
  heading: "How to switch without losing your work",
87
  paragraphs: [
88
  "Export what you can first: your notes, saved answers and the list of sources. Then rebuild around the job you named at the start rather than recreating every notebook one to one.",
89
- "If you are a writer, the fastest path is to connect your publication and let the archive rebuild itself. Once it is indexed, list it on [BlogHub](https://bloghub.app), a free blog and newsletter directory, so new readers can find the work you are now researching and repurposing.",
90
  ],
91
  },
92
  ],
@@ -173,7 +174,7 @@ export const missionPosts: BlogPost[] = [
173
  heading: "Stage four: share what you found",
174
  paragraphs: [
175
  "Research that stays in a document rarely gets read. Turn the dense parts into formats people actually consume. [PDF2Video](https://pdf2vid.com) turns a paper, report or slide deck into a narrated video, which is often the fastest way to brief a team or a class.",
176
- "For findings you have written up as a post, [Blog2Video](https://blog2video.app) makes a video from the URL, and an audio overview gives listeners the two host version while you keep every line grounded in the source.",
177
  ],
178
  },
179
  {
@@ -274,7 +275,7 @@ export const missionPosts: BlogPost[] = [
274
  heading: "Distribution without the busywork",
275
  paragraphs: [
276
  "Deep posts deserve a long life, and that is where AI saves the most time. A Launch Kit turns one post into a thread, a LinkedIn post, Substack Notes and a carousel, written from claims the post actually makes.",
277
- "Video extends the reach further: [Blog2Video](https://blog2video.app) turns a finished post into a video for YouTube or Shorts. And make sure new readers can find you at all by listing your publication on [BlogHub](https://bloghub.app), a free directory of blogs and newsletters.",
278
  ],
279
  },
280
  {
@@ -302,7 +303,7 @@ export const missionPosts: BlogPost[] = [
302
  {
303
  question: "How do I grow readers for a newsletter?",
304
  answer:
305
- "Repurpose each post into formats people discover on other platforms, and list your publication on [BlogHub](https://bloghub.app) so readers browsing for new blogs and newsletters can find it.",
306
  },
307
  {
308
  question: "Can AI turn my blog posts into videos?",
@@ -348,7 +349,7 @@ export const missionPosts: BlogPost[] = [
348
  heading: "Where ChatGPT wins",
349
  paragraphs: [
350
  "ChatGPT is the better thinking partner when you do not have sources yet. It explains unfamiliar concepts, suggests angles, drafts outlines and argues the other side.",
351
- "Use it early and loosely. Anything factual that survives into your final draft should be checked against a real source.",
352
  ],
353
  bullets: ["Brainstorming and outlining", "Explaining concepts you are new to", "Stress testing an argument"],
354
  },
 
77
  {
78
  heading: "For audio and video overviews",
79
  paragraphs: [
80
+ "For a quick start, the free [AI podcast generator](/tools/ai-podcast-generator) turns any post into a two host script, and [PDF to podcast](/tools/pdf-to-podcast) does the same for papers and reports.",
81
  "Audio overviews are the feature people miss most. Notestack makes two host audio overviews from a post or a notebook, with every line tied back to a passage, and you can pick from ElevenLabs voices or clone your own with consent.",
82
  "Video is where dedicated tools win. If your source is a report, paper or deck, [PDF2Video](https://pdf2vid.com) turns any PDF into a narrated video. If it is a blog post, URL or script, [Blog2Video](https://blog2video.app) turns it into a finished video without you touching an editor. Both are a better fit than a notebook when the video itself is the deliverable.",
83
  ],
 
87
  heading: "How to switch without losing your work",
88
  paragraphs: [
89
  "Export what you can first: your notes, saved answers and the list of sources. Then rebuild around the job you named at the start rather than recreating every notebook one to one.",
90
+ "If you are a writer, the fastest path is to connect your publication and let the archive rebuild itself. Once it is indexed, list it on [BlogHub](https://bloghub.app/submit-your-newsletter), a free blog and newsletter directory, so new readers can find the work you are now researching and repurposing.",
91
  ],
92
  },
93
  ],
 
174
  heading: "Stage four: share what you found",
175
  paragraphs: [
176
  "Research that stays in a document rarely gets read. Turn the dense parts into formats people actually consume. [PDF2Video](https://pdf2vid.com) turns a paper, report or slide deck into a narrated video, which is often the fastest way to brief a team or a class.",
177
+ "For findings you have written up as a post, [Blog2Video](https://blog2video.app/url-to-video) makes a video from the URL, and an audio overview gives listeners the two host version while you keep every line grounded in the source.",
178
  ],
179
  },
180
  {
 
275
  heading: "Distribution without the busywork",
276
  paragraphs: [
277
  "Deep posts deserve a long life, and that is where AI saves the most time. A Launch Kit turns one post into a thread, a LinkedIn post, Substack Notes and a carousel, written from claims the post actually makes.",
278
+ "Video extends the reach further: [Blog2Video](https://blog2video.app/blog-to-shorts) turns a finished post into a video for YouTube or Shorts. And make sure new readers can find you at all by listing your publication on [BlogHub](https://bloghub.app/submit-your-newsletter), a free directory of blogs and newsletters.",
279
  ],
280
  },
281
  {
 
303
  {
304
  question: "How do I grow readers for a newsletter?",
305
  answer:
306
+ "Repurpose each post into formats people discover on other platforms, and list your publication on [BlogHub](https://bloghub.app/submit-your-newsletter) so readers browsing for new blogs and newsletters can find it. For more places to share your work, see this list of [article submission sites](https://bloghub.app/blogs/article-submission-sites).",
307
  },
308
  {
309
  question: "Can AI turn my blog posts into videos?",
 
349
  heading: "Where ChatGPT wins",
350
  paragraphs: [
351
  "ChatGPT is the better thinking partner when you do not have sources yet. It explains unfamiliar concepts, suggests angles, drafts outlines and argues the other side.",
352
+ "Use it early and loosely. Anything factual that survives into your final draft should be checked against a real source. And if you are wondering whether it can go further and produce media, here is the honest answer to [can ChatGPT make videos](https://blog2video.app/blogs/can-chatgpt-make-videos).",
353
  ],
354
  bullets: ["Brainstorming and outlining", "Explaining concepts you are new to", "Stress testing an argument"],
355
  },
frontend/src/content/notebooklmAlternative.ts ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Copy for /notebooklm-alternative, shared by the React page and the build time prerender (seo/prerender.ts).
2
+
3
+ export const NOTEBOOKLM_ALT_META = {
4
+ title: "Notestack vs NotebookLM: The NotebookLM Alternative for Writers | Notestack",
5
+ description:
6
+ "Notestack vs NotebookLM, side by side. Notestack is the NotebookLM alternative for writers, bloggers and newsletters: sync your archive, get answers cited to the exact line, and turn your writing into audio, video and launch posts.",
7
+ };
8
+
9
+ export const NOTEBOOKLM_ALT_ROWS: { feature: string; notestack: string; notebooklm: string }[] = [
10
+ {
11
+ feature: "Getting your writing in",
12
+ notestack: "Syncs any blog, newsletter or site feed (Substack, Ghost, WordPress, Medium, RSS), plus markdown, text, HTML and PDF uploads",
13
+ notebooklm: "Add sources one at a time: PDFs, Docs, web pages, videos",
14
+ },
15
+ {
16
+ feature: "Stays in sync",
17
+ notestack: "New posts arrive automatically on every sync",
18
+ notebooklm: "Re-add sources by hand when they change",
19
+ },
20
+ {
21
+ feature: "Citations",
22
+ notestack: "Exact line ranges, read back and verified before you see them",
23
+ notebooklm: "Passage level citations to the source",
24
+ },
25
+ {
26
+ feature: "When the answer is not in your sources",
27
+ notestack: "Says so instead of guessing; unverifiable claims are dropped",
28
+ notebooklm: "Grounded in sources, with passage citations",
29
+ },
30
+ {
31
+ feature: "Chat memory",
32
+ notestack: "Follow ups resolved from the conversation; off topic questions answered instantly",
33
+ notebooklm: "Conversation within a notebook",
34
+ },
35
+ {
36
+ feature: "Audio overviews",
37
+ notestack: "Two hosts, ElevenLabs voices, delivery controls, or your own consented voice clone",
38
+ notebooklm: "Two host audio overviews with Google voices",
39
+ },
40
+ {
41
+ feature: "Video",
42
+ notestack: "9:16 shorts, 16:9 explainers and 1:1 audiograms with captions and your brand",
43
+ notebooklm: "Video overviews",
44
+ },
45
+ {
46
+ feature: "Publishing",
47
+ notestack: "Launch Kits (threads, LinkedIn, Notes, Bluesky, SEO, carousels, quote cards) and a scheduling calendar",
48
+ notebooklm: "Not a publishing tool",
49
+ },
50
+ {
51
+ feature: "Your voice",
52
+ notestack: "Voice profile learned from your posts, used for every draft",
53
+ notebooklm: "General purpose writing",
54
+ },
55
+ {
56
+ feature: "Seeing your archive",
57
+ notestack: "Topic map with rising and dormant topics, plus evergreen resurfacing",
58
+ notebooklm: "Mind maps per notebook",
59
+ },
60
+ ];
61
+
62
+ export const NOTEBOOKLM_ALT_FAQ = [
63
+ {
64
+ q: "What is the best NotebookLM alternative?",
65
+ a: "For writers, bloggers and newsletters, Notestack is the ultimate NotebookLM alternative: it syncs your whole archive, cites the exact lines behind every answer, and turns what you wrote into audio, video and launch posts. For academic papers, discovery tools like Elicit or Consensus pair well with it.",
66
+ },
67
+ {
68
+ q: "Is Notestack free?",
69
+ a: "Yes, there is a free plan to try it on your latest posts. Paid plans add more sources, more posts, more audio and video minutes and voice cloning.",
70
+ },
71
+ {
72
+ q: "Can Notestack make NotebookLM style audio overviews?",
73
+ a: "Yes. It writes a two host conversation grounded in your posts and voices it with ElevenLabs, with every line tied to the passage it came from. You can choose the voices or clone your own with consent.",
74
+ },
75
+ {
76
+ q: "What if my writing is not on Substack?",
77
+ a: "Notestack works with any blog, newsletter or site that has a feed, single article URLs, and uploaded markdown, text, HTML or PDF files.",
78
+ },
79
+ ];
frontend/src/content/tools.ts ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Free tools at /tools/<slug>. Copy is shared by the React page and the build time prerender (seo/prerender.ts).
2
+ // Every tool widget requires sign in; the copy below stays public so the page can rank.
3
+ // Volumes are US Google Ads, Sep 2026. No em dashes (npm run check:copy).
4
+
5
+ export type ToolSection = { heading: string; paragraphs: string[]; bullets?: string[] };
6
+
7
+ export type ToolDef = {
8
+ slug: string;
9
+ /** <title> and meta description. */
10
+ metaTitle: string;
11
+ description: string;
12
+ eyebrow: string;
13
+ heroTitle: string;
14
+ heroDescription: string;
15
+ primaryKeyword: string;
16
+ /** What the widget asks for. */
17
+ inputLabel: string;
18
+ inputPlaceholder: string;
19
+ /** Optional line under the input (inline [text](url) links allowed). */
20
+ inputHint?: string;
21
+ sections: ToolSection[];
22
+ faq: { question: string; answer: string }[];
23
+ relatedPaths: string[];
24
+ };
25
+
26
+ export const toolsHub = {
27
+ path: "/tools",
28
+ metaTitle: "Free AI Podcast Tools for Writers | Notestack",
29
+ description:
30
+ "Free tools that turn your writing into podcasts: an AI podcast generator, a PDF to podcast converter and a two host podcast script generator.",
31
+ heroTitle: "Free tools for turning writing into audio",
32
+ heroDescription:
33
+ "Every tool works on words you already wrote. Sign in free, paste a post or a PDF's text, and get a two host script you can voice in Notestack.",
34
+ };
35
+
36
+ export const tools: ToolDef[] = [
37
+ {
38
+ slug: "ai-podcast-generator",
39
+ metaTitle: "Free AI Podcast Generator: Turn Any Post Into a Two Host Show | Notestack",
40
+ description:
41
+ "Free AI podcast generator for writers. Paste a blog post or newsletter and get a two host podcast script grounded in your words, then voice it as a full audio overview in Notestack.",
42
+ eyebrow: "AI podcast generator",
43
+ heroTitle: "AI podcast generator for writers",
44
+ heroDescription:
45
+ "Paste a blog post, newsletter or article and get a two host podcast script built only from what you wrote. Voice it as a finished audio overview in one click.",
46
+ primaryKeyword: "ai podcast generator",
47
+ inputLabel: "Paste your post",
48
+ inputPlaceholder: "Paste a blog post, newsletter issue or article. The script is built only from this text.",
49
+ sections: [
50
+ {
51
+ heading: "How the AI podcast generator works",
52
+ paragraphs: [
53
+ "Most AI podcast generators write a new show and hope it matches your ideas. This one starts from your text. It picks the sentences that carry your argument, then stages them as a conversation between two hosts: one who guides, one who asks the questions a listener would ask.",
54
+ "Because every host line comes from a passage you wrote, nothing is invented. In the full Notestack app each line keeps a citation back to the exact source lines, so you can check the show before anyone hears it.",
55
+ ],
56
+ },
57
+ {
58
+ heading: "From script to finished episode",
59
+ paragraphs: [
60
+ "The free tool gives you the script. To hear it, connect your blog or newsletter to Notestack and generate an audio overview: two natural voices, delivery controls, and optional consented voice cloning so one host can sound like you. The free plan includes audio minutes to try it.",
61
+ "Want a video instead of audio? [Blog2Video](https://blog2video.app/blog-to-video) turns the same post into a narrated video, and [PDF2Video](https://pdf2vid.com) does the same for reports and papers.",
62
+ ],
63
+ bullets: [
64
+ "Two hosts, grounded in your own post",
65
+ "Runtime estimate before you record anything",
66
+ "Copy or download the script as text",
67
+ "Voice it in Notestack with two host audio overviews",
68
+ ],
69
+ },
70
+ {
71
+ heading: "Why writers use a podcast generator",
72
+ paragraphs: [
73
+ "A post that took a week to write gets read once. A two host episode of the same post reaches people on walks, commutes and at the gym, and it keeps your archive working long after publish day. It is the fastest way to add audio to a newsletter without booking a guest or sitting at a microphone.",
74
+ "If you are comparing tools, our guide to the [best NotebookLM alternatives](/blogs/notebooklm-alternatives) covers the other audio overview options, and [Notestack vs NotebookLM](/notebooklm-alternative) explains the difference side by side.",
75
+ ],
76
+ },
77
+ ],
78
+ faq: [
79
+ {
80
+ question: "Is the AI podcast generator free?",
81
+ answer:
82
+ "Yes. Sign in with a free Notestack account and generate as many scripts as you like. Turning a script into audio uses the audio minutes included in every plan, free plan included.",
83
+ },
84
+ {
85
+ question: "Does it make up content?",
86
+ answer:
87
+ "No. The script is assembled only from sentences in the text you paste. The hosts reframe and question your points, but the substance is always yours.",
88
+ },
89
+ {
90
+ question: "How is this different from NotebookLM audio overviews?",
91
+ answer:
92
+ "NotebookLM works on sources you upload one at a time. Notestack syncs your whole blog or newsletter, keeps citations to the exact lines, and can voice one host in your own cloned voice with consent.",
93
+ },
94
+ {
95
+ question: "How long will my episode be?",
96
+ answer:
97
+ "The tool estimates runtime at about 150 spoken words per minute. A 1,500 word post usually becomes a six to nine minute episode.",
98
+ },
99
+ ],
100
+ relatedPaths: ["/tools/pdf-to-podcast", "/tools/podcast-script-generator", "/blogs/turn-substack-archive-into-podcast", "/notebooklm-alternative"],
101
+ },
102
+ {
103
+ slug: "pdf-to-podcast",
104
+ metaTitle: "PDF to Podcast: Turn Any PDF Into a Two Host Podcast Free | Notestack",
105
+ description:
106
+ "Turn a PDF, research paper or report into a two host podcast. Paste the PDF's text or upload the file to Notestack, and get an audio overview grounded in the document.",
107
+ eyebrow: "PDF to podcast",
108
+ heroTitle: "Turn any PDF into a podcast",
109
+ heroDescription:
110
+ "Research papers, reports, ebooks and slide decks become a two host conversation you can listen to. Every line stays tied to the page it came from.",
111
+ primaryKeyword: "pdf to podcast",
112
+ inputLabel: "Paste the text from your PDF",
113
+ inputPlaceholder: "Paste the text of your PDF here. The script is built only from this text.",
114
+ inputHint:
115
+ "Need the text first? Pull it out with the free [PDF to text tool](https://pdf2vid.com/tools/pdf-to-text), or upload the PDF straight into a Notestack notebook.",
116
+ sections: [
117
+ {
118
+ heading: "How PDF to podcast works",
119
+ paragraphs: [
120
+ "Paste the text from your PDF and the tool pulls out the sentences that carry the document: the claim, the evidence and the conclusion. It stages them as a two host conversation, with one host explaining and the other asking what a listener would ask.",
121
+ "In the Notestack app you can skip the copy and paste. Upload the PDF into a notebook and generate a full audio overview, with every host line cited to the page and lines it came from.",
122
+ ],
123
+ },
124
+ {
125
+ heading: "Which PDFs make the best podcasts",
126
+ paragraphs: [
127
+ "Documents with an argument work best: research papers, white papers, annual reports, long reads and ebook chapters. Pure reference material such as manuals or spreadsheets makes a thinner show.",
128
+ ],
129
+ bullets: [
130
+ "Research papers: the hosts walk through the question, method and finding",
131
+ "Reports: turn the executive summary into a ten minute briefing",
132
+ "Ebooks: one episode per chapter becomes a series",
133
+ "Slide decks: the narration fills in what the slides leave out",
134
+ ],
135
+ },
136
+ {
137
+ heading: "Podcast or video?",
138
+ paragraphs: [
139
+ "Audio suits people who listen on the move. If your audience watches instead, [PDF2Video](https://pdf2vid.com) turns the same PDF into a narrated video with slides, and its [PDF to audio](https://pdf2vid.com/tools/pdf-to-audio) tool reads a document aloud word for word. A podcast is different: two hosts talk the ideas through.",
140
+ "Researching across many PDFs? Read our guide to the [best AI for research](/blogs/best-ai-for-research).",
141
+ ],
142
+ },
143
+ ],
144
+ faq: [
145
+ {
146
+ question: "Can I turn a PDF into a podcast for free?",
147
+ answer:
148
+ "Yes. Sign in free, paste the PDF's text and get a two host script. Voicing it in Notestack uses the audio minutes included in the free plan.",
149
+ },
150
+ {
151
+ question: "Does it work with scanned PDFs?",
152
+ answer:
153
+ "The tool needs selectable text. If your PDF is a scan, run it through OCR first, then paste the text.",
154
+ },
155
+ {
156
+ question: "Is PDF to podcast the same as PDF to audio?",
157
+ answer:
158
+ "No. PDF to audio reads the document aloud. PDF to podcast turns it into a conversation between two hosts who explain and question the ideas, which is easier to follow for long or technical documents.",
159
+ },
160
+ ],
161
+ relatedPaths: ["/tools/ai-podcast-generator", "/tools/podcast-script-generator", "/blogs/best-ai-for-research", "/notebooklm-alternative"],
162
+ },
163
+ {
164
+ slug: "podcast-script-generator",
165
+ metaTitle: "Free Podcast Script Generator: Two Host Scripts From Your Writing | Notestack",
166
+ description:
167
+ "Free podcast script generator. Paste your notes, post or article and get a formatted two host podcast script with an intro, segments, questions and an outro, built only from your words.",
168
+ eyebrow: "Podcast script generator",
169
+ heroTitle: "Podcast script generator",
170
+ heroDescription:
171
+ "Turn notes, a post or an article into a ready to record two host script: cold open, segments, the questions your listeners would ask, and a clean outro.",
172
+ primaryKeyword: "podcast script generator",
173
+ inputLabel: "Paste your notes or post",
174
+ inputPlaceholder: "Paste notes, an outline or a full post. The script is built only from this text.",
175
+ sections: [
176
+ {
177
+ heading: "What you get",
178
+ paragraphs: [
179
+ "A script in the standard two host format: a cold open that states the big idea, segments built from your key points, host questions that set up each point, and an outro that lands the takeaway. Each line is labelled with its speaker so you can record straight from it.",
180
+ ],
181
+ bullets: [
182
+ "Speaker labelled lines for Host A and Host B",
183
+ "Segments ordered the way your text builds its argument",
184
+ "Estimated runtime at a natural speaking pace",
185
+ "Plain text you can copy into any teleprompter or doc",
186
+ ],
187
+ },
188
+ {
189
+ heading: "Record it, or let Notestack voice it",
190
+ paragraphs: [
191
+ "Record the script yourself with a co host, or generate a voiced audio overview in Notestack with two natural voices. Writers who publish every week connect their newsletter once, and every new post can become an episode.",
192
+ "Turning a whole back catalog into a show? Read [how to turn your Substack archive into a podcast](/blogs/turn-substack-archive-into-podcast).",
193
+ ],
194
+ },
195
+ {
196
+ heading: "Tips for a better script",
197
+ paragraphs: ["The generator follows your text, so the clearer the source, the better the show."],
198
+ bullets: [
199
+ "Paste the full post, not a summary: the hosts need your specifics",
200
+ "Keep one idea per paragraph so segments split cleanly",
201
+ "Edit the questions to sound like your real audience",
202
+ "Aim for 800 to 2,000 words for a five to fifteen minute episode",
203
+ ],
204
+ },
205
+ ],
206
+ faq: [
207
+ {
208
+ question: "Is the podcast script generator free?",
209
+ answer: "Yes. It is free with a Notestack account. Sign in and generate as many scripts as you need.",
210
+ },
211
+ {
212
+ question: "Can I use the script commercially?",
213
+ answer: "Yes. The script is built from your own text, so it is yours to record, publish and monetize.",
214
+ },
215
+ {
216
+ question: "Does it support one host shows?",
217
+ answer:
218
+ "The generator writes for two hosts, the format that keeps long ideas easy to follow. For a solo show, read Host A's lines and turn Host B's questions into your own transitions.",
219
+ },
220
+ ],
221
+ relatedPaths: ["/tools/ai-podcast-generator", "/tools/pdf-to-podcast", "/blogs/substack-launch-kit", "/blogs/ai-tools-for-writers"],
222
+ },
223
+ ];
224
+
225
+ export const getTool = (slug: string) => tools.find((t) => t.slug === slug);
frontend/src/lib/podcastScript.ts ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Builds a two host podcast script from pasted text, entirely in the browser. Every substantive line is a sentence
2
+ // from the source; the hosts only add framing and questions around it, so nothing is invented.
3
+
4
+ export type ScriptLine = { host: "A" | "B"; text: string };
5
+ export type PodcastScript = { lines: ScriptLine[]; words: number; minutes: number; sourceSentences: number };
6
+
7
+ const WORDS_PER_MINUTE = 150;
8
+
9
+ const STOP = new Set(
10
+ "a an and are as at be but by for from has have he her his i if in into is it its of on or our she so than that the their them then there these they this to was we were what when which who will with you your not can do does did just also more most very about over out up".split(
11
+ " ",
12
+ ),
13
+ );
14
+
15
+ const QUESTIONS = [
16
+ "So where does it start?",
17
+ "Okay, and why does that matter?",
18
+ "What is the part people usually miss?",
19
+ "Can you make that concrete?",
20
+ "So what changes because of that?",
21
+ "Is there a catch?",
22
+ "How does that play out in practice?",
23
+ "And where does that leave us?",
24
+ ];
25
+
26
+ const words = (s: string) => s.trim().split(/\s+/).filter(Boolean);
27
+
28
+ function sentences(text: string): string[] {
29
+ return text
30
+ .replace(/\r/g, "")
31
+ .split(/\n{2,}|(?<=[.!?])\s+(?=[A-Z0-9"'(])/)
32
+ .map((s) => s.replace(/\s+/g, " ").trim())
33
+ .filter((s) => {
34
+ const n = words(s).length;
35
+ return n >= 6 && n <= 60 && /[a-z]/i.test(s);
36
+ });
37
+ }
38
+
39
+ /** Scores sentences by how many of the text's recurring terms they carry, with a small lead bias. */
40
+ function pickPoints(all: string[]): string[] {
41
+ const freq = new Map<string, number>();
42
+ for (const s of all)
43
+ for (const w of words(s.toLowerCase().replace(/[^a-z0-9\s]/g, " ")))
44
+ if (w.length > 3 && !STOP.has(w)) freq.set(w, (freq.get(w) ?? 0) + 1);
45
+ const scored = all.map((s, i) => {
46
+ const terms = words(s.toLowerCase().replace(/[^a-z0-9\s]/g, " ")).filter((w) => freq.has(w));
47
+ const score = terms.reduce((sum, w) => sum + Math.min(freq.get(w) ?? 0, 5), 0) / Math.sqrt(words(s).length);
48
+ return { s, i, score: score + (i === 0 ? 2 : 0) + (i === all.length - 1 ? 1 : 0) };
49
+ });
50
+ return scored
51
+ .sort((a, b) => b.score - a.score)
52
+ // Roughly one point per three source sentences, so longer posts make longer episodes.
53
+ .slice(0, Math.min(16, Math.max(5, Math.round(all.length / 3))))
54
+ .sort((a, b) => a.i - b.i)
55
+ .map((x) => x.s);
56
+ }
57
+
58
+ export function buildPodcastScript(text: string): PodcastScript | null {
59
+ const all = sentences(text);
60
+ if (all.length < 3) return null;
61
+ const points = pickPoints(all);
62
+ const [opening, ...body] = points;
63
+ const closing = body.length > 2 ? body.pop()! : undefined;
64
+
65
+ const lines: ScriptLine[] = [
66
+ { host: "A", text: "Welcome back. Today we are walking through one piece of writing, and staying close to what it actually says." },
67
+ { host: "B", text: "Good, because I want the argument, not a summary. What is the big idea?" },
68
+ { host: "A", text: opening },
69
+ ];
70
+ body.forEach((point, i) => {
71
+ lines.push({ host: "B", text: QUESTIONS[i % QUESTIONS.length] });
72
+ lines.push({ host: "A", text: point });
73
+ });
74
+ if (closing) {
75
+ lines.push({ host: "B", text: "If someone remembers one thing from this, what should it be?" });
76
+ lines.push({ host: "A", text: closing });
77
+ }
78
+ lines.push({ host: "B", text: "That is the episode. The full piece goes deeper, so read it if this landed for you." });
79
+
80
+ const count = lines.reduce((n, l) => n + words(l.text).length, 0);
81
+ return { lines, words: count, minutes: count / WORDS_PER_MINUTE, sourceSentences: all.length };
82
+ }
83
+
84
+ export const scriptToText = (script: PodcastScript) =>
85
+ script.lines.map((l) => `HOST ${l.host}: ${l.text}`).join("\n\n");
frontend/src/pages/Archive.tsx CHANGED
@@ -1,4 +1,5 @@
1
  import { useCallback, useEffect, useState } from "react";
 
2
  import { artifactsApi } from "../api/endpoints";
3
  import type { Artifact } from "../api/types";
4
  import { ArtifactCard } from "../components/ArtifactCard";
@@ -10,7 +11,6 @@ const PAGE = 30;
10
 
11
  export default function Archive() {
12
  const [filter, setFilter] = useState<Filter>("all");
13
- const [status, setStatus] = useState<"" | "ready" | "failed">("");
14
  const [q, setQ] = useState("");
15
  const [items, setItems] = useState<Artifact[] | null>(null);
16
  const [total, setTotal] = useState(0);
@@ -19,12 +19,12 @@ export default function Archive() {
19
  const load = useCallback(
20
  (offset = 0) =>
21
  artifactsApi
22
- .list({ type: filter === "all" ? undefined : filter, status: status || undefined, q, limit: PAGE, offset })
23
  .then((p) => {
24
  setTotal(p.total);
25
  setItems((cur) => (offset ? [...(cur ?? []), ...p.items] : p.items));
26
  }),
27
- [filter, status, q],
28
  );
29
 
30
  useEffect(() => {
@@ -34,7 +34,7 @@ export default function Archive() {
34
 
35
  return (
36
  <div className="page-wrap">
37
- <PageHeader eyebrow="Archive" title="Everything you have launched" />
38
  <div className="row between wrap">
39
  <Tabs<Filter>
40
  tabs={[
@@ -48,18 +48,16 @@ export default function Archive() {
48
  value={filter}
49
  onChange={setFilter}
50
  />
51
- <div className="row">
52
- <select className="input input-sm" value={status} onChange={(e) => setStatus(e.target.value as typeof status)} aria-label="Status">
53
- <option value="">Any status</option>
54
- <option value="ready">Ready</option>
55
- <option value="failed">Failed</option>
56
- </select>
57
- <input className="input input-sm search" placeholder="Search by post or notebook" value={q} onChange={(e) => setQ(e.target.value)} aria-label="Search" />
58
- </div>
59
  </div>
60
- <p className="mono muted">{total} artifacts</p>
61
  {!items && <Loading />}
62
- {items?.length === 0 && <EmptyState title="Nothing here yet" body="Summaries, audio, videos and launch kits you create show up here." />}
 
 
 
 
 
 
63
  <div className="gallery">
64
  {items?.map((a) => (
65
  <ArtifactCard
 
1
  import { useCallback, useEffect, useState } from "react";
2
+ import { Link } from "react-router-dom";
3
  import { artifactsApi } from "../api/endpoints";
4
  import type { Artifact } from "../api/types";
5
  import { ArtifactCard } from "../components/ArtifactCard";
 
11
 
12
  export default function Archive() {
13
  const [filter, setFilter] = useState<Filter>("all");
 
14
  const [q, setQ] = useState("");
15
  const [items, setItems] = useState<Artifact[] | null>(null);
16
  const [total, setTotal] = useState(0);
 
19
  const load = useCallback(
20
  (offset = 0) =>
21
  artifactsApi
22
+ .list({ type: filter === "all" ? undefined : filter, q, limit: PAGE, offset })
23
  .then((p) => {
24
  setTotal(p.total);
25
  setItems((cur) => (offset ? [...(cur ?? []), ...p.items] : p.items));
26
  }),
27
+ [filter, q],
28
  );
29
 
30
  useEffect(() => {
 
34
 
35
  return (
36
  <div className="page-wrap">
37
+ <PageHeader eyebrow="Library" title="Everything you have made" />
38
  <div className="row between wrap">
39
  <Tabs<Filter>
40
  tabs={[
 
48
  value={filter}
49
  onChange={setFilter}
50
  />
51
+ <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" />
 
 
 
 
 
 
 
52
  </div>
 
53
  {!items && <Loading />}
54
+ {items?.length === 0 && <EmptyState title="Nothing here yet" body="Summaries, audio, videos and launch kits you create show up here."
55
+ action={
56
+ <Link className="btn btn-primary" to="/app/studio">
57
+ Create something
58
+ </Link>
59
+ }
60
+ />}
61
  <div className="gallery">
62
  {items?.map((a) => (
63
  <ArtifactCard
frontend/src/pages/AuthCallback.tsx CHANGED
@@ -24,7 +24,7 @@ export default function AuthCallback() {
24
  .redeemTicket(ticket)
25
  .then((result) => {
26
  complete(result);
27
- navigate(result.created ? "/welcome" : next, { replace: true });
28
  })
29
  .catch(() => {
30
  const error = new URLSearchParams({ error: "Google sign in expired. Try again." });
 
24
  .redeemTicket(ticket)
25
  .then((result) => {
26
  complete(result);
27
+ navigate(result.created && !next.startsWith("/tools/") ? "/welcome" : next, { replace: true });
28
  })
29
  .catch(() => {
30
  const error = new URLSearchParams({ error: "Google sign in expired. Try again." });
frontend/src/pages/AuthPage.tsx CHANGED
@@ -63,7 +63,7 @@ function GoogleButton({ onCredential }: { onCredential: (c: string) => void }) {
63
  }, [onCredential]);
64
 
65
  if (!GOOGLE_CLIENT_ID) {
66
- return <p className="mono muted auth-hint">Set GOOGLE_CLIENT_ID in .env to enable Google sign in.</p>;
67
  }
68
  return <div ref={ref} className="google-btn" />;
69
  }
@@ -85,7 +85,8 @@ export default function AuthPage() {
85
  const next = safeNext(params.get("next"));
86
 
87
  useEffect(() => {
88
- if (user) navigate(isNew ? "/welcome" : next, { replace: true });
 
89
  }, [user, navigate, next, isNew]);
90
 
91
  useEffect(() => {
@@ -170,9 +171,6 @@ export default function AuthPage() {
170
  <>
171
  {providers?.google === "redirect" && <GoogleRedirectButton next={next} />}
172
  {providers?.google === "popup" && <GoogleButton onCredential={onGoogle} />}
173
- {providers && !providers.google && (
174
- <p className="mono muted auth-hint">Set GOOGLE_CLIENT_ID in .env to enable Google sign in.</p>
175
- )}
176
  {!providers && <div className="google-btn" />}
177
  <div className="auth-divider mono">
178
  <span>or with email</span>
 
63
  }, [onCredential]);
64
 
65
  if (!GOOGLE_CLIENT_ID) {
66
+ return null;
67
  }
68
  return <div ref={ref} className="google-btn" />;
69
  }
 
85
  const next = safeNext(params.get("next"));
86
 
87
  useEffect(() => {
88
+ // New accounts get onboarding, except people who signed up to use a free tool: send them straight back to it.
89
+ if (user) navigate(isNew && !next.startsWith("/tools/") ? "/welcome" : next, { replace: true });
90
  }, [user, navigate, next, isNew]);
91
 
92
  useEffect(() => {
 
171
  <>
172
  {providers?.google === "redirect" && <GoogleRedirectButton next={next} />}
173
  {providers?.google === "popup" && <GoogleButton onCredential={onGoogle} />}
 
 
 
174
  {!providers && <div className="google-btn" />}
175
  <div className="auth-divider mono">
176
  <span>or with email</span>
frontend/src/pages/Blog.tsx CHANGED
@@ -45,7 +45,7 @@ const missionOf = (post: BlogPost): Mission =>
45
  const INLINE = /(\[[^\]]+\]\([^)\s]+\))|(\*\*[^*]+\*\*)/g;
46
 
47
  /** Inline [text](url) links and **bold** inside post copy. External links open in a new tab and stay followed. */
48
- function Inline({ text }: { text: string }) {
49
  const out: ReactNode[] = [];
50
  let last = 0;
51
  let i = 0;
 
45
  const INLINE = /(\[[^\]]+\]\([^)\s]+\))|(\*\*[^*]+\*\*)/g;
46
 
47
  /** Inline [text](url) links and **bold** inside post copy. External links open in a new tab and stay followed. */
48
+ export function Inline({ text }: { text: string }) {
49
  const out: ReactNode[] = [];
50
  let last = 0;
51
  let i = 0;
frontend/src/pages/LaunchKit.tsx CHANGED
@@ -1,15 +1,15 @@
1
- import { useCallback, useEffect, useState } from "react";
2
  import { useSearchParams } from "react-router-dom";
3
- import { artifactsApi } from "../api/endpoints";
4
- import type { Artifact, LaunchKitContent, Platform } from "../api/types";
5
  import { ArtifactCard } from "../components/ArtifactCard";
6
  import { DocPicker } from "../components/DocPicker";
7
  import { ScheduleModal } from "../components/ScheduleModal";
8
- import { ConfirmButton, CopyButton, EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader, StatusPill, Tabs } from "../components/ui";
9
  import { useJob } from "../hooks/useJob";
10
 
11
  type PostKey = keyof LaunchKitContent["posts"];
12
- type Tab = "hooks" | PostKey | "seo" | "carousel" | "quotes" | "claims";
13
 
14
  const PLATFORM_OF: Record<PostKey, { platform: Platform; label: string; limit: number; thread: boolean }> = {
15
  x_thread: { platform: "x", label: "X thread", limit: 280, thread: true },
@@ -175,7 +175,8 @@ function QuotesTab({ kit }: { kit: Artifact }) {
175
 
176
  function KitView({ kit: initial, onDeleted }: { kit: Artifact; onDeleted: () => void }) {
177
  const [kit, setKit] = useState(initial);
178
- const [tab, setTab] = useState<Tab>("hooks");
 
179
  const job = useJob(initial.job, () => artifactsApi.get(initial.id).then(setKit));
180
  useEffect(() => setKit(initial), [initial]);
181
  const c = kit.content as LaunchKitContent;
@@ -220,16 +221,23 @@ function KitView({ kit: initial, onDeleted }: { kit: Artifact; onDeleted: () =>
220
  <>
221
  <Tabs<Tab>
222
  tabs={[
 
223
  { id: "hooks", label: "Hooks", count: c.hooks?.length },
224
- ...(Object.keys(PLATFORM_OF) as PostKey[]).map((k) => ({ id: k as Tab, label: PLATFORM_OF[k].label })),
225
  { id: "seo", label: "SEO" },
226
- { id: "carousel", label: "Carousel", count: c.carousel?.length },
227
- { id: "quotes", label: "Quotes", count: c.quotes?.length },
228
- { id: "claims", label: "Claims", count: c.claims?.length },
229
  ]}
230
  value={tab}
231
  onChange={setTab}
232
  />
 
 
 
 
 
 
 
 
 
233
  {tab === "hooks" && (
234
  <ol className="hooks">
235
  {(c.hooks ?? []).map((h, i) => (
@@ -243,7 +251,7 @@ function KitView({ kit: initial, onDeleted }: { kit: Artifact; onDeleted: () =>
243
  ))}
244
  </ol>
245
  )}
246
- {tab in PLATFORM_OF && <PostsEditor kit={kit} keyName={tab as PostKey} onSave={(p) => savePosts(tab as PostKey, p)} />}
247
  {tab === "seo" && (
248
  <dl className="seo">
249
  <dt>Title options</dt>
@@ -287,9 +295,17 @@ function KitView({ kit: initial, onDeleted }: { kit: Artifact; onDeleted: () =>
287
  </dd>
288
  </dl>
289
  )}
290
- {tab === "carousel" && <CarouselTab kit={kit} />}
291
- {tab === "quotes" && <QuotesTab kit={kit} />}
292
- {tab === "claims" && (
 
 
 
 
 
 
 
 
293
  <ol className="claims">
294
  {(c.claims ?? []).map((cl, i) => (
295
  <li key={i}>
@@ -305,6 +321,7 @@ function KitView({ kit: initial, onDeleted }: { kit: Artifact; onDeleted: () =>
305
  </li>
306
  ))}
307
  </ol>
 
308
  )}
309
  </>
310
  )}
@@ -312,91 +329,144 @@ function KitView({ kit: initial, onDeleted }: { kit: Artifact; onDeleted: () =>
312
  );
313
  }
314
 
 
315
  export default function LaunchKit() {
316
  const [params, setParams] = useSearchParams();
317
  const [kits, setKits] = useState<Artifact[] | null>(null);
318
- const [post, setPost] = useState<string[]>(params.get("post") ? [params.get("post")!] : []);
 
 
319
  const [active, setActive] = useState<Artifact | null>(null);
320
- const [busy, setBusy] = useState(false);
321
  const [error, setError] = useState<string | null>(null);
 
322
 
323
  const load = useCallback(() => artifactsApi.list({ type: "launch_kit", limit: 100 }).then((p) => setKits(p.items)), []);
324
  useEffect(() => {
325
  load();
 
326
  }, [load]);
327
  useEffect(() => {
328
  const id = params.get("kit");
329
  if (id && active?.id !== id) artifactsApi.get(id).then(setActive, () => setActive(null));
330
- }, [params, active?.id]);
 
 
331
 
332
- const generate = async () => {
333
- if (!post[0]) return;
334
- setBusy(true);
335
  setError(null);
336
  try {
337
- const kit = await artifactsApi.generate({ type: "launch_kit", document_id: post[0] });
338
  setKits((k) => [kit, ...(k ?? [])]);
339
  setActive(kit);
340
  setParams({ kit: kit.id });
341
  } catch (e) {
342
  setError(errorMessage(e));
343
  } finally {
344
- setBusy(false);
 
 
 
 
 
 
 
 
 
345
  }
 
 
 
 
 
 
346
  };
347
 
348
  return (
349
  <div className="page-wrap">
350
- <PageHeader eyebrow="Launch Kit" title="Everything you need to launch a post" />
351
- <div className="kit-layout">
352
- <aside className="stack">
353
- <section className="card stack">
354
- <h2>New kit</h2>
355
- <p className="muted">Hooks, a thread, LinkedIn, Notes, Bluesky, SEO, a carousel and quote cards, in your voice and grounded in the post.</p>
356
- <DocPicker selected={post} onChange={setPost} single />
357
- {error && <p className="error-text">{error}</p>}
358
- <button className="btn btn-primary" disabled={!post.length || busy} onClick={generate}>
359
- {busy ? "Launching..." : "Generate Launch Kit"}
360
- </button>
361
- </section>
362
- <section className="card">
363
- <h2>Kits</h2>
364
- {!kits && <Loading />}
365
  <ul className="kit-list">
366
  {kits?.map((k) => (
367
  <li key={k.id}>
368
- <button
369
- className={`link-btn${active?.id === k.id ? " active" : ""}`}
370
- onClick={() => {
371
- setActive(k);
372
- setParams({ kit: k.id });
373
- }}
374
- >
375
  {(k.content.post_title as string | undefined) ?? k.title}
376
  </button>
377
  <span className="mono muted"> {k.status === "ready" ? formatDate(k.created_at) : k.status}</span>
378
  </li>
379
  ))}
380
- {kits?.length === 0 && <li className="muted">No kits yet.</li>}
381
  </ul>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
382
  </section>
383
- </aside>
384
- <div>
385
- {active ? (
386
- <KitView
387
- key={active.id}
388
- kit={active}
389
- onDeleted={() => {
390
- setActive(null);
391
- setParams({});
392
- load();
393
- }}
394
- />
395
- ) : (
396
- <EmptyState title="Pick a post to launch" body="Or open a kit you made before." />
397
  )}
398
- </div>
399
- </div>
400
  </div>
401
  );
402
  }
 
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
  import { useSearchParams } from "react-router-dom";
3
+ import { artifactsApi, docsApi } from "../api/endpoints";
4
+ import type { Artifact, Doc, LaunchKitContent, Platform } from "../api/types";
5
  import { ArtifactCard } from "../components/ArtifactCard";
6
  import { DocPicker } from "../components/DocPicker";
7
  import { ScheduleModal } from "../components/ScheduleModal";
8
+ import { ConfirmButton, CopyButton, errorMessage, formatDate, JobProgress, Loading, PageHeader, StatusPill, Tabs } from "../components/ui";
9
  import { useJob } from "../hooks/useJob";
10
 
11
  type PostKey = keyof LaunchKitContent["posts"];
12
+ type Tab = "posts" | "hooks" | "visuals" | "seo";
13
 
14
  const PLATFORM_OF: Record<PostKey, { platform: Platform; label: string; limit: number; thread: boolean }> = {
15
  x_thread: { platform: "x", label: "X thread", limit: 280, thread: true },
 
175
 
176
  function KitView({ kit: initial, onDeleted }: { kit: Artifact; onDeleted: () => void }) {
177
  const [kit, setKit] = useState(initial);
178
+ const [tab, setTab] = useState<Tab>("posts");
179
+ const [platform, setPlatform] = useState<PostKey>("x_thread");
180
  const job = useJob(initial.job, () => artifactsApi.get(initial.id).then(setKit));
181
  useEffect(() => setKit(initial), [initial]);
182
  const c = kit.content as LaunchKitContent;
 
221
  <>
222
  <Tabs<Tab>
223
  tabs={[
224
+ { id: "posts", label: "Posts" },
225
  { id: "hooks", label: "Hooks", count: c.hooks?.length },
226
+ { id: "visuals", label: "Visuals", count: (c.carousel?.length ?? 0) + (c.quotes?.length ?? 0) },
227
  { id: "seo", label: "SEO" },
 
 
 
228
  ]}
229
  value={tab}
230
  onChange={setTab}
231
  />
232
+ {tab === "posts" && (
233
+ <div className="gen-chips" role="tablist" aria-label="Platform">
234
+ {(Object.keys(PLATFORM_OF) as PostKey[]).map((k) => (
235
+ <button key={k} type="button" role="tab" aria-selected={platform === k} className={`chip${platform === k ? " on" : ""}`} onClick={() => setPlatform(k)}>
236
+ {PLATFORM_OF[k].label}
237
+ </button>
238
+ ))}
239
+ </div>
240
+ )}
241
  {tab === "hooks" && (
242
  <ol className="hooks">
243
  {(c.hooks ?? []).map((h, i) => (
 
251
  ))}
252
  </ol>
253
  )}
254
+ {tab === "posts" && <PostsEditor kit={kit} keyName={platform} onSave={(p) => savePosts(platform, p)} />}
255
  {tab === "seo" && (
256
  <dl className="seo">
257
  <dt>Title options</dt>
 
295
  </dd>
296
  </dl>
297
  )}
298
+ {tab === "visuals" && (
299
+ <>
300
+ <h3>Carousel</h3>
301
+ <CarouselTab kit={kit} />
302
+ <h3>Quotes</h3>
303
+ <QuotesTab kit={kit} />
304
+ </>
305
+ )}
306
+ {tab === "posts" && (c.claims?.length ?? 0) > 0 && (
307
+ <details className="delivery">
308
+ <summary className="mono muted small">Fact check: {c.claims?.length} claims traced to the post</summary>
309
  <ol className="claims">
310
  {(c.claims ?? []).map((cl, i) => (
311
  <li key={i}>
 
321
  </li>
322
  ))}
323
  </ol>
324
+ </details>
325
  )}
326
  </>
327
  )}
 
329
  );
330
  }
331
 
332
+ /** Pick a post card (one click generates), or reopen an earlier kit from the rail. */
333
  export default function LaunchKit() {
334
  const [params, setParams] = useSearchParams();
335
  const [kits, setKits] = useState<Artifact[] | null>(null);
336
+ const [newest, setNewest] = useState<Doc[] | null>(null);
337
+ const [post, setPost] = useState<string[]>([]);
338
+ const [searching, setSearching] = useState(false);
339
  const [active, setActive] = useState<Artifact | null>(null);
340
+ const [busy, setBusy] = useState<string | null>(null);
341
  const [error, setError] = useState<string | null>(null);
342
+ const autoStarted = useRef(false);
343
 
344
  const load = useCallback(() => artifactsApi.list({ type: "launch_kit", limit: 100 }).then((p) => setKits(p.items)), []);
345
  useEffect(() => {
346
  load();
347
+ docsApi.list({ limit: 24 }).then((p) => setNewest(p.items.filter((d) => !d.locked).slice(0, 6)));
348
  }, [load]);
349
  useEffect(() => {
350
  const id = params.get("kit");
351
  if (id && active?.id !== id) artifactsApi.get(id).then(setActive, () => setActive(null));
352
+ if (!id && active) setActive(null);
353
+ // eslint-disable-next-line react-hooks/exhaustive-deps
354
+ }, [params]);
355
 
356
+ const generate = async (documentId: string) => {
357
+ setBusy(documentId);
 
358
  setError(null);
359
  try {
360
+ const kit = await artifactsApi.generate({ type: "launch_kit", document_id: documentId });
361
  setKits((k) => [kit, ...(k ?? [])]);
362
  setActive(kit);
363
  setParams({ kit: kit.id });
364
  } catch (e) {
365
  setError(errorMessage(e));
366
  } finally {
367
+ setBusy(null);
368
+ }
369
+ };
370
+
371
+ // ?post=<id> (from Resurfacing or a post page) starts the kit straight away.
372
+ useEffect(() => {
373
+ const id = params.get("post");
374
+ if (id && !autoStarted.current) {
375
+ autoStarted.current = true;
376
+ void generate(id);
377
  }
378
+ // eslint-disable-next-line react-hooks/exhaustive-deps
379
+ }, []);
380
+
381
+ const open = (k: Artifact) => {
382
+ setActive(k);
383
+ setParams({ kit: k.id });
384
  };
385
 
386
  return (
387
  <div className="page-wrap">
388
+ <PageHeader eyebrow="Launch Kit" title="Everything you need to launch a post">
389
+ {active && (
390
+ <button className="btn btn-primary" onClick={() => setParams({})}>
391
+ New kit
392
+ </button>
393
+ )}
394
+ </PageHeader>
395
+ {error && <p className="error-text">{error}</p>}
396
+ {active ? (
397
+ <div className="kit-layout kit-open">
398
+ <aside className="card kit-rail">
399
+ <p className="eyebrow">Your kits</p>
 
 
 
400
  <ul className="kit-list">
401
  {kits?.map((k) => (
402
  <li key={k.id}>
403
+ <button className={`link-btn${active.id === k.id ? " active" : ""}`} onClick={() => open(k)}>
 
 
 
 
 
 
404
  {(k.content.post_title as string | undefined) ?? k.title}
405
  </button>
406
  <span className="mono muted"> {k.status === "ready" ? formatDate(k.created_at) : k.status}</span>
407
  </li>
408
  ))}
 
409
  </ul>
410
+ </aside>
411
+ <KitView
412
+ key={active.id}
413
+ kit={active}
414
+ onDeleted={() => {
415
+ setParams({});
416
+ load();
417
+ }}
418
+ />
419
+ </div>
420
+ ) : (
421
+ <>
422
+ <section className="card stack">
423
+ <div className="row between">
424
+ <h2>Pick a post to launch</h2>
425
+ <button className="link-btn small" onClick={() => setSearching(!searching)}>
426
+ {searching ? "Show newest posts" : "Search all posts"}
427
+ </button>
428
+ </div>
429
+ <p className="muted">Hooks, a thread, LinkedIn, Notes, Bluesky, SEO, a carousel and quote cards, in your voice and grounded in the post.</p>
430
+ {searching ? (
431
+ <>
432
+ <DocPicker selected={post} onChange={setPost} single />
433
+ <div className="row end">
434
+ <button className="btn btn-primary" disabled={!post.length || busy !== null} onClick={() => generate(post[0])}>
435
+ {busy ? "Launching..." : "Generate Launch Kit"}
436
+ </button>
437
+ </div>
438
+ </>
439
+ ) : (
440
+ <div className="post-cards">
441
+ {!newest && <Loading />}
442
+ {newest?.length === 0 && <p className="muted">No indexed posts yet. Connect a source first.</p>}
443
+ {newest?.map((d) => (
444
+ <button key={d.id} type="button" className="post-card" disabled={busy !== null} onClick={() => generate(d.id)}>
445
+ <span className="mono muted small">{formatDate(d.published_at)}</span>
446
+ <strong className="clamp-3">{d.title}</strong>
447
+ <span className="post-card-go">{busy === d.id ? "Launching..." : "Launch this post"}</span>
448
+ </button>
449
+ ))}
450
+ </div>
451
+ )}
452
  </section>
453
+ {kits && kits.length > 0 && (
454
+ <section className="card">
455
+ <h2>Earlier kits</h2>
456
+ <ul className="kit-list">
457
+ {kits.map((k) => (
458
+ <li key={k.id}>
459
+ <button className="link-btn" onClick={() => open(k)}>
460
+ {(k.content.post_title as string | undefined) ?? k.title}
461
+ </button>
462
+ <span className="mono muted"> {k.status === "ready" ? formatDate(k.created_at) : k.status}</span>
463
+ </li>
464
+ ))}
465
+ </ul>
466
+ </section>
467
  )}
468
+ </>
469
+ )}
470
  </div>
471
  );
472
  }
frontend/src/pages/Launchpad.tsx CHANGED
@@ -1,9 +1,10 @@
1
  import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
2
- import { useSearchParams } from "react-router-dom";
3
  import { launchpadApi } from "../api/endpoints";
4
  import type { CalendarItem, SocialAccounts } from "../api/types";
5
  import { PLATFORMS, ScheduleModal, toLocalInput } from "../components/ScheduleModal";
6
  import { ConfirmButton, CopyButton, errorMessage, formatDate, Loading, Modal, PageHeader, StatusPill, Tabs } from "../components/ui";
 
7
 
8
  const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
9
 
@@ -289,55 +290,42 @@ export default function Launchpad() {
289
  {notice && <p className="notice">{notice}</p>}
290
  {error && <p className="error-text">{error}</p>}
291
 
292
- <section className="card accounts">
293
- <div className="row between">
294
- <h2>Connected accounts</h2>
295
- <div className="row">
296
- <button className="btn btn-small" onClick={() => connect("x")} disabled={!accounts?.available.x} title={accounts?.available.x ? "" : "Set X_CLIENT_ID and X_CLIENT_SECRET"}>
297
- Connect X
298
- </button>
299
- <button className="btn btn-small" onClick={() => connect("linkedin")} disabled={!accounts?.available.linkedin} title={accounts?.available.linkedin ? "" : "Set LINKEDIN_CLIENT_ID and LINKEDIN_CLIENT_SECRET"}>
300
- Connect LinkedIn
301
- </button>
302
- <button className="btn btn-small" onClick={() => setBluesky(true)}>
303
- Connect Bluesky
304
- </button>
305
- </div>
306
- </div>
307
- <ul className="account-list">
308
- {accounts?.accounts.map((a) => (
309
- <li key={a.id} className="account">
310
- {a.avatar_url && <img src={a.avatar_url} alt="" width={28} height={28} />}
311
- <span>
312
- <strong>{a.platform_label}</strong> <span className="mono muted">{a.handle}</span>
313
- </span>
314
- {a.status !== "active" && <StatusPill status={a.status === "expired" ? "failed" : a.status} />}
315
- <ConfirmButton
316
- onConfirm={async () => {
317
- await launchpadApi.disconnect(a.id);
318
- setAccounts(await launchpadApi.accounts());
319
- }}
320
- >
321
- Disconnect
322
- </ConfirmButton>
323
- </li>
324
- ))}
325
- {accounts?.accounts.length === 0 && <li className="muted">No accounts yet. Posts for unconnected platforms and Substack Notes arrive as email reminders.</li>}
326
- </ul>
327
- {accounts && (!accounts.available.x || !accounts.available.linkedin) && (
328
- <details className="muted small">
329
- <summary>Setting up X or LinkedIn</summary>
330
- <p>
331
- Create a developer app, then add these redirect URIs and put the client id and secret in .env:
332
- </p>
333
- <ul className="mono">
334
- <li>X: {accounts.redirect_uris.x}</li>
335
- <li>LinkedIn: {accounts.redirect_uris.linkedin}</li>
336
- </ul>
337
- </details>
338
  )}
 
 
 
 
 
 
339
  </section>
340
 
 
341
  <section className="card">
342
  <div className="row between cal-head">
343
  <div className="row">
@@ -352,6 +340,14 @@ export default function Launchpad() {
352
  <Tabs tabs={[{ id: "month", label: "Month" }, { id: "agenda", label: "Agenda" }]} value={view} onChange={setView} />
353
  </div>
354
  {!items && <Loading />}
 
 
 
 
 
 
 
 
355
  {items && view === "month" && (
356
  <div className="cal-grid">
357
  {WEEKDAYS.map((d) => (
@@ -396,6 +392,12 @@ export default function Launchpad() {
396
  {items && <p className="mono muted small">{upcoming.length} upcoming in view</p>}
397
  </section>
398
 
 
 
 
 
 
 
399
  {open && <ItemModal item={open} accounts={accounts} onClose={() => setOpen(null)} onChanged={changed} />}
400
  {composing && <ScheduleModal platform="x" posts={[""]} onClose={() => setComposing(false)} onScheduled={() => load()} />}
401
  {bluesky && <BlueskyModal onClose={() => setBluesky(false)} onConnected={setAccounts} />}
 
1
  import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
2
+ import { Link, useSearchParams } from "react-router-dom";
3
  import { launchpadApi } from "../api/endpoints";
4
  import type { CalendarItem, SocialAccounts } from "../api/types";
5
  import { PLATFORMS, ScheduleModal, toLocalInput } from "../components/ScheduleModal";
6
  import { ConfirmButton, CopyButton, errorMessage, formatDate, Loading, Modal, PageHeader, StatusPill, Tabs } from "../components/ui";
7
+ import { ResurfaceIdeas } from "./Resurface";
8
 
9
  const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
10
 
 
290
  {notice && <p className="notice">{notice}</p>}
291
  {error && <p className="error-text">{error}</p>}
292
 
293
+ <section className="lp-accounts" aria-label="Connected accounts">
294
+ {accounts?.accounts.map((a) => (
295
+ <span key={a.id} className={`lp-chip${a.status !== "active" ? " warn" : ""}`}>
296
+ {a.avatar_url && <img src={a.avatar_url} alt="" width={18} height={18} />}
297
+ <strong>{a.platform_label}</strong> <span className="mono muted">{a.handle}</span>
298
+ {a.status !== "active" && <StatusPill status={a.status === "expired" ? "failed" : a.status} />}
299
+ <ConfirmButton
300
+ className="link-btn small"
301
+ onConfirm={async () => {
302
+ await launchpadApi.disconnect(a.id);
303
+ setAccounts(await launchpadApi.accounts());
304
+ }}
305
+ >
306
+ ×
307
+ </ConfirmButton>
308
+ </span>
309
+ ))}
310
+ {accounts?.available.x && !accounts.accounts.some((a) => a.platform === "x") && (
311
+ <button className="lp-chip add" onClick={() => connect("x")}>
312
+ + X
313
+ </button>
314
+ )}
315
+ {accounts?.available.linkedin && !accounts.accounts.some((a) => a.platform === "linkedin") && (
316
+ <button className="lp-chip add" onClick={() => connect("linkedin")}>
317
+ + LinkedIn
318
+ </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  )}
320
+ {!accounts?.accounts.some((a) => a.platform === "bluesky") && (
321
+ <button className="lp-chip add" onClick={() => setBluesky(true)}>
322
+ + Bluesky
323
+ </button>
324
+ )}
325
+ {accounts && accounts.accounts.length === 0 && <span className="muted small">Not connected? Posts arrive as email reminders instead.</span>}
326
  </section>
327
 
328
+ <div className="lp-layout">
329
  <section className="card">
330
  <div className="row between cal-head">
331
  <div className="row">
 
340
  <Tabs tabs={[{ id: "month", label: "Month" }, { id: "agenda", label: "Agenda" }]} value={view} onChange={setView} />
341
  </div>
342
  {!items && <Loading />}
343
+ {items && items.length === 0 && (
344
+ <div className="lp-empty">
345
+ <p className="muted">Nothing scheduled this month.</p>
346
+ <Link className="btn btn-small" to="/app/launch-kit">
347
+ Schedule your latest Launch Kit
348
+ </Link>
349
+ </div>
350
+ )}
351
  {items && view === "month" && (
352
  <div className="cal-grid">
353
  {WEEKDAYS.map((d) => (
 
392
  {items && <p className="mono muted small">{upcoming.length} upcoming in view</p>}
393
  </section>
394
 
395
+ <aside className="card stack lp-ideas" id="ideas">
396
+ <h2>Ideas worth resharing</h2>
397
+ <ResurfaceIdeas onScheduled={() => load()} />
398
+ </aside>
399
+ </div>
400
+
401
  {open && <ItemModal item={open} accounts={accounts} onClose={() => setOpen(null)} onChanged={changed} />}
402
  {composing && <ScheduleModal platform="x" posts={[""]} onClose={() => setComposing(false)} onScheduled={() => load()} />}
403
  {bluesky && <BlueskyModal onClose={() => setBluesky(false)} onConnected={setAccounts} />}
frontend/src/pages/MissionControl.tsx CHANGED
@@ -1,10 +1,10 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
2
  import { Link, useNavigate } from "react-router-dom";
3
  import { api } from "../api/client";
4
- import { artifactsApi, jobsApi, launchpadApi, notebooksApi, sourcesApi, voiceApi } from "../api/endpoints";
5
- import type { Artifact, CalendarItem, Job, NotebookSummary, Source } from "../api/types";
6
- import { CheckIcon } from "../components/icons/Icons";
7
- import { formatDate, JobProgress, StatusPill } from "../components/ui";
8
  import { useAuth } from "../hooks/useAuth";
9
  import { useJobMap } from "../hooks/useJob";
10
  import { AddSource } from "./Sources";
@@ -26,9 +26,9 @@ const JOB_LABELS: Record<string, string> = {
26
  import_url: "Importing an article",
27
  import_upload: "Importing a file",
28
  topics: "Mapping topics",
29
- voice_profile: "Building your voice profile",
30
  voice_clone: "Cloning your voice",
31
- resurface_scan: "Scoring evergreen posts",
32
  summary: "Writing a summary",
33
  audio_overview: "Recording an audio overview",
34
  video: "Making a video",
@@ -38,26 +38,31 @@ const JOB_LABELS: Record<string, string> = {
38
  render: "Rendering",
39
  };
40
 
 
41
  export default function MissionControl() {
42
  const { user } = useAuth();
43
- const [sources, setSources] = useState<Source[]>([]);
44
  const [notebooks, setNotebooks] = useState<NotebookSummary[]>([]);
45
  const [recent, setRecent] = useState<Artifact[]>([]);
46
  const [upcoming, setUpcoming] = useState<CalendarItem[]>([]);
 
 
47
  const [plan, setPlan] = useState<Plan | null>(null);
48
- const [hasVoice, setHasVoice] = useState(false);
 
49
  const [error, setError] = useState<string | null>(null);
50
  const started = useRef(false);
51
  const navigate = useNavigate();
52
 
53
  const refresh = useCallback(async () => {
54
- const [s, n, p, a, c, v] = await Promise.all([
55
  sourcesApi.list(),
56
  notebooksApi.list(),
57
  api<{ plan: Plan }>("/api/billing/me"),
58
- artifactsApi.list({ limit: 6 }),
59
  launchpadApi.items({ start: new Date().toISOString(), status: "scheduled,draft" }),
60
- voiceApi.get(),
 
61
  ]);
62
  setSources(s);
63
  // First visit with nothing connected: run the guided setup once.
@@ -69,13 +74,14 @@ export default function MissionControl() {
69
  setPlan(p.plan);
70
  setRecent(a.items);
71
  setUpcoming(c.slice(0, 5));
72
- setHasVoice(Boolean(v.profile));
 
73
  }, [navigate]);
74
 
75
  const { jobs, watch } = useJobMap(() => refresh());
76
 
77
  useEffect(() => {
78
- refresh().catch(() => setError("Could not reach Mission Control. Is the API running?"));
79
  jobsApi.active().then((active) => active.forEach((j) => watch(j.id, j)));
80
  if (started.current) return;
81
  started.current = true;
@@ -92,45 +98,141 @@ export default function MissionControl() {
92
  watch(res.job.id, res.job);
93
  refresh();
94
  },
95
- (e) => setError(e instanceof Error ? e.message : "Could not add that source."),
96
  );
97
  }
98
  // eslint-disable-next-line react-hooks/exhaustive-deps
99
  }, [refresh]);
100
 
101
- const totalDocs = sources.reduce((n, s) => n + s.document_count, 0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  const activeJobs = Object.values(jobs).filter((j: Job) => j.status === "queued" || j.status === "running" || j.status === "failed");
103
- const checklist = [
104
- { done: sources.length > 0 && totalDocs > 0, label: "Connect your archive", to: "/app/sources" },
105
- { done: hasVoice, label: "Build your voice profile", to: "/app/voice" },
106
- { done: notebooks.length > 0, label: "Open a research notebook", to: "/app/notebooks" },
107
- { done: recent.some((a) => a.type === "launch_kit"), label: "Make your first Launch Kit", to: "/app/launch-kit" },
108
- { done: upcoming.length > 0, label: "Schedule a launch", to: "/app/launchpad" },
109
- ];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
 
111
  return (
112
  <div className="mc">
113
  <header className="mc-head">
114
  <div>
115
  <p className="eyebrow">Mission Control</p>
116
- <h1>Good to see you, {(user?.name ?? user?.email ?? "").split(/[ @]/)[0]}</h1>
117
  </div>
118
  {plan && <span className="badge">{plan.name} plan</span>}
119
  </header>
120
  {error && <p className="error-text">{error}</p>}
121
 
122
- {checklist.some((c) => !c.done) && (
123
- <section className="card checklist">
124
- <p className="eyebrow">Pre-flight checklist</p>
125
- <ol>
126
- {checklist.map((c) => (
127
- <li key={c.label} className={c.done ? "done" : ""}>
128
- <span className="check-dot">{c.done && <CheckIcon size={14} />}</span>
129
- <Link to={c.to}>{c.label}</Link>
130
- </li>
131
- ))}
132
- </ol>
133
  </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  )}
135
 
136
  {activeJobs.length > 0 && (
@@ -145,102 +247,97 @@ export default function MissionControl() {
145
  </section>
146
  )}
147
 
148
- <section className="mc-grid">
149
- <div className="card mc-card mc-sources">
150
- <div className="row between">
151
- <h2>Sources</h2>
152
- <Link to="/app/sources" className="mono muted small-link">
153
- Manage
154
- </Link>
155
- </div>
156
- <AddSource
157
- onAdded={(source, job) => {
158
- setSources((list) => [...list.filter((s) => s.id !== source.id), source]);
159
- watch(job.id, job);
160
- }}
161
- />
162
- <ul className="source-list">
163
- {sources.map((s) => (
164
- <li key={s.id} className="source-row">
165
- <div className="source-title">
166
- <strong>{s.title ?? s.feed_url}</strong>
167
- <span className="mono muted">
168
- {s.is_imports ? "imports" : s.platform} · {s.document_count} posts
169
- </span>
170
- </div>
171
- <StatusPill status={s.sync_status} />
172
- </li>
173
- ))}
174
- {sources.length === 0 && <li className="muted">No sources yet. A lone satellite, waiting for signal.</li>}
175
- </ul>
176
- </div>
177
-
178
- <div className="card mc-card">
179
- <div className="row between">
180
- <h2>Notebooks</h2>
181
- <Link to="/app/notebooks" className="mono muted small-link">
182
- All
183
- </Link>
184
- </div>
185
- <p className="muted">{totalDocs} posts indexed across your sources.</p>
186
- <ul className="nb-list">
187
- {notebooks.slice(0, 6).map((n) => (
188
- <li key={n.id}>
189
- <Link to={`/app/notebooks/${n.id}`} className="nb-link">
190
- {n.title}
191
- </Link>
192
- <span className="mono muted"> {n.document_count} posts</span>
193
- </li>
194
- ))}
195
- </ul>
196
- <Link className="btn" to="/app/notebooks">
197
- {notebooks.length ? "Open notebooks" : "Create a notebook"}
198
  </Link>
199
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
200
 
201
- <div className="card mc-card">
202
- <div className="row between">
203
- <h2>Recent launches</h2>
204
- <Link to="/app/archive" className="mono muted small-link">
205
- Archive
206
- </Link>
207
- </div>
208
- {recent.length === 0 && <p className="muted">Audio overviews, videos and launch kits will show up here.</p>}
209
- <ul className="recent-list">
210
- {recent.map((a) => (
211
- <li key={a.id}>
212
- <Link to={a.type === "launch_kit" ? `/app/launch-kit?kit=${a.id}` : a.notebook_id ? `/app/notebooks/${a.notebook_id}` : "/app/archive"}>
213
- {a.title}
214
  </Link>
215
- <span className="row">
216
- <span className="mono muted">{formatDate(a.created_at)}</span>
217
- <StatusPill status={a.status} />
218
- </span>
219
- </li>
220
- ))}
221
- </ul>
222
- </div>
223
-
224
- <div className="card mc-card">
225
- <div className="row between">
226
- <h2>Launchpad</h2>
227
- <Link to="/app/launchpad" className="mono muted small-link">
228
- Calendar
229
- </Link>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  </div>
231
- {upcoming.length === 0 && <p className="muted">Nothing scheduled. Quiet moon tonight.</p>}
232
- <ul className="recent-list">
233
- {upcoming.map((i) => (
234
- <li key={i.id}>
235
- <Link to={`/app/launchpad?item=${i.id}`}>
236
- <strong>{i.platform_label}</strong> <span className="muted">{i.content.slice(0, 70)}</span>
237
- </Link>
238
- <span className="mono muted">{formatDate(i.scheduled_at, true)}</span>
239
- </li>
240
- ))}
241
- </ul>
242
- </div>
243
- </section>
244
  </div>
245
  );
246
  }
 
1
+ import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
2
  import { Link, useNavigate } from "react-router-dom";
3
  import { api } from "../api/client";
4
+ import { artifactsApi, docsApi, jobsApi, launchpadApi, notebooksApi, resurfaceApi, sourcesApi, type GenerateBody } from "../api/endpoints";
5
+ import type { Artifact, CalendarItem, Doc, Job, NotebookSummary, ResurfaceItem, Source } from "../api/types";
6
+ import { LaunchWindowIcon, PlanetIcon, RocketIcon, TelescopeIcon } from "../components/icons/Icons";
7
+ import { errorMessage, formatDate, JobProgress, StatusPill } from "../components/ui";
8
  import { useAuth } from "../hooks/useAuth";
9
  import { useJobMap } from "../hooks/useJob";
10
  import { AddSource } from "./Sources";
 
26
  import_url: "Importing an article",
27
  import_upload: "Importing a file",
28
  topics: "Mapping topics",
29
+ voice_profile: "Building your writing voice",
30
  voice_clone: "Cloning your voice",
31
+ resurface_scan: "Finding posts worth resharing",
32
  summary: "Writing a summary",
33
  audio_overview: "Recording an audio overview",
34
  video: "Making a video",
 
38
  render: "Rendering",
39
  };
40
 
41
+ /** Home: ask the archive, one-click creation, and only the next thing worth doing. */
42
  export default function MissionControl() {
43
  const { user } = useAuth();
44
+ const [sources, setSources] = useState<Source[] | null>(null);
45
  const [notebooks, setNotebooks] = useState<NotebookSummary[]>([]);
46
  const [recent, setRecent] = useState<Artifact[]>([]);
47
  const [upcoming, setUpcoming] = useState<CalendarItem[]>([]);
48
+ const [newest, setNewest] = useState<Doc | null>(null);
49
+ const [reshare, setReshare] = useState<ResurfaceItem[]>([]);
50
  const [plan, setPlan] = useState<Plan | null>(null);
51
+ const [question, setQuestion] = useState("");
52
+ const [busy, setBusy] = useState<string | null>(null);
53
  const [error, setError] = useState<string | null>(null);
54
  const started = useRef(false);
55
  const navigate = useNavigate();
56
 
57
  const refresh = useCallback(async () => {
58
+ const [s, n, p, a, c, d, r] = await Promise.all([
59
  sourcesApi.list(),
60
  notebooksApi.list(),
61
  api<{ plan: Plan }>("/api/billing/me"),
62
+ artifactsApi.list({ limit: 8 }),
63
  launchpadApi.items({ start: new Date().toISOString(), status: "scheduled,draft" }),
64
+ docsApi.list({ limit: 20 }),
65
+ resurfaceApi.get().catch(() => null),
66
  ]);
67
  setSources(s);
68
  // First visit with nothing connected: run the guided setup once.
 
74
  setPlan(p.plan);
75
  setRecent(a.items);
76
  setUpcoming(c.slice(0, 5));
77
+ setNewest(d.items.find((x) => !x.locked) ?? null);
78
+ setReshare((r?.evergreen ?? []).slice(0, 3));
79
  }, [navigate]);
80
 
81
  const { jobs, watch } = useJobMap(() => refresh());
82
 
83
  useEffect(() => {
84
+ refresh().catch(() => setError("Could not load your dashboard. Refresh to try again."));
85
  jobsApi.active().then((active) => active.forEach((j) => watch(j.id, j)));
86
  if (started.current) return;
87
  started.current = true;
 
98
  watch(res.job.id, res.job);
99
  refresh();
100
  },
101
+ (e) => setError(errorMessage(e, "Could not add that source.")),
102
  );
103
  }
104
  // eslint-disable-next-line react-hooks/exhaustive-deps
105
  }, [refresh]);
106
 
107
+ const run = async (key: string, fn: () => Promise<void>) => {
108
+ setBusy(key);
109
+ setError(null);
110
+ try {
111
+ await fn();
112
+ } catch (e) {
113
+ setError(errorMessage(e));
114
+ } finally {
115
+ setBusy(null);
116
+ }
117
+ };
118
+
119
+ const ask = (e: FormEvent) => {
120
+ e.preventDefault();
121
+ void run("ask", async () => {
122
+ const nb = await notebooksApi.archive();
123
+ navigate(`/app/notebooks/${nb.id}${question.trim() ? `?q=${encodeURIComponent(question.trim())}` : ""}`);
124
+ });
125
+ };
126
+ const create = (key: string, body: GenerateBody) =>
127
+ run(key, async () => {
128
+ await artifactsApi.generate(body);
129
+ navigate("/app/studio");
130
+ });
131
+ const launch = (doc: Doc) =>
132
+ run(`kit-${doc.id}`, async () => {
133
+ const kit = await artifactsApi.generate({ type: "launch_kit", document_id: doc.id });
134
+ navigate(`/app/launch-kit?kit=${kit.id}`);
135
+ });
136
+
137
+ const totalDocs = (sources ?? []).reduce((n, s) => n + s.document_count, 0);
138
+ const hasPosts = totalDocs > 0;
139
  const activeJobs = Object.values(jobs).filter((j: Job) => j.status === "queued" || j.status === "running" || j.status === "failed");
140
+ const lastKit = recent.find((a) => a.type === "launch_kit" && a.status === "ready");
141
+ const next: { text: string; action: ReactNode } | null = !hasPosts
142
+ ? null
143
+ : !lastKit && newest
144
+ ? {
145
+ text: `Launch your newest post, "${newest.title}", with hooks, threads and SEO in your voice.`,
146
+ action: (
147
+ <button className="btn btn-primary" disabled={busy !== null} onClick={() => launch(newest)}>
148
+ {busy === `kit-${newest.id}` ? "Launching..." : "Generate Launch Kit"}
149
+ </button>
150
+ ),
151
+ }
152
+ : lastKit && upcoming.length === 0
153
+ ? {
154
+ text: `Your Launch Kit for "${lastKit.title}" is ready. Put it on the calendar.`,
155
+ action: (
156
+ <Link className="btn btn-primary" to={`/app/launch-kit?kit=${lastKit.id}`}>
157
+ Schedule it
158
+ </Link>
159
+ ),
160
+ }
161
+ : null;
162
+
163
+ const first = (user?.name ?? user?.email ?? "").split(/[ @]/)[0];
164
 
165
  return (
166
  <div className="mc">
167
  <header className="mc-head">
168
  <div>
169
  <p className="eyebrow">Mission Control</p>
170
+ <h1>Good to see you, {first}</h1>
171
  </div>
172
  {plan && <span className="badge">{plan.name} plan</span>}
173
  </header>
174
  {error && <p className="error-text">{error}</p>}
175
 
176
+ {sources && !hasPosts ? (
177
+ <section className="card mc-hero stack">
178
+ <h2>Bring in your writing</h2>
179
+ <p className="muted">Connect a blog or newsletter, import an article or drop in files. Everything else starts from here.</p>
180
+ <AddSource
181
+ onAdded={(source, job) => {
182
+ setSources((list) => [...(list ?? []).filter((s) => s.id !== source.id), source]);
183
+ watch(job.id, job);
184
+ }}
185
+ />
 
186
  </section>
187
+ ) : (
188
+ <>
189
+ <section className="card mc-hero">
190
+ <form onSubmit={ask} className="mc-ask">
191
+ <TelescopeIcon size={22} />
192
+ <input
193
+ className="input"
194
+ value={question}
195
+ onChange={(e) => setQuestion(e.target.value)}
196
+ placeholder="Ask anything about your writing: what have I said about pricing?"
197
+ aria-label="Ask your archive"
198
+ />
199
+ <button className="btn btn-primary" disabled={busy !== null}>
200
+ {busy === "ask" ? "Opening..." : "Ask"}
201
+ </button>
202
+ </form>
203
+ <div className="mc-actions">
204
+ <button className="mc-action" disabled={busy !== null || !hasPosts} onClick={() => create("audio", { type: "audio_overview", archive: true, format: "deep_dive", minutes: 6 })}>
205
+ <PlanetIcon size={28} />
206
+ <strong>{busy === "audio" ? "Starting..." : "Audio overview"}</strong>
207
+ <span className="muted small">Two hosts, 6 minutes, your whole archive</span>
208
+ </button>
209
+ <button className="mc-action" disabled={busy !== null || !hasPosts} onClick={() => create("video", { type: "video", style: "short", archive: true })}>
210
+ <LaunchWindowIcon size={28} />
211
+ <strong>{busy === "video" ? "Starting..." : "Short video"}</strong>
212
+ <span className="muted small">Vertical 9:16 with captions</span>
213
+ </button>
214
+ <button className="mc-action" disabled={busy !== null || !newest} onClick={() => newest && launch(newest)}>
215
+ <RocketIcon size={28} />
216
+ <strong>{newest && busy === `kit-${newest.id}` ? "Launching..." : "Launch newest post"}</strong>
217
+ <span className="muted small clamp-1">{newest ? newest.title : "No indexed posts yet"}</span>
218
+ </button>
219
+ <Link className="mc-action" to="/app/studio">
220
+ <span className="mc-more" aria-hidden="true">
221
+ +
222
+ </span>
223
+ <strong>More formats</strong>
224
+ <span className="muted small">Explainers, quote cards, audiograms</span>
225
+ </Link>
226
+ </div>
227
+ </section>
228
+
229
+ {next && (
230
+ <section className="mc-next">
231
+ <p>{next.text}</p>
232
+ {next.action}
233
+ </section>
234
+ )}
235
+ </>
236
  )}
237
 
238
  {activeJobs.length > 0 && (
 
247
  </section>
248
  )}
249
 
250
+ {hasPosts && (
251
+ <section className="mc-stats">
252
+ <Link to="/app/sources" className="mc-stat">
253
+ <strong>{totalDocs}</strong>
254
+ <span className="muted small">posts in {sources?.length} {sources?.length === 1 ? "source" : "sources"}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  </Link>
256
+ <Link to="/app/notebooks" className="mc-stat">
257
+ <strong>{notebooks.length}</strong>
258
+ <span className="muted small">notebooks</span>
259
+ </Link>
260
+ <Link to="/app/archive" className="mc-stat">
261
+ <strong>{recent.length}</strong>
262
+ <span className="muted small">recent launches</span>
263
+ </Link>
264
+ <Link to="/app/launchpad" className="mc-stat">
265
+ <strong>{upcoming.length}</strong>
266
+ <span className="muted small">scheduled</span>
267
+ </Link>
268
+ </section>
269
+ )}
270
 
271
+ {(recent.length > 0 || upcoming.length > 0 || reshare.length > 0) && (
272
+ <section className="mc-grid">
273
+ {recent.length > 0 && (
274
+ <div className="card mc-card">
275
+ <div className="row between">
276
+ <h2>Recent launches</h2>
277
+ <Link to="/app/archive" className="mono muted small-link">
278
+ Library
 
 
 
 
 
279
  </Link>
280
+ </div>
281
+ <ul className="recent-list">
282
+ {recent.map((a) => (
283
+ <li key={a.id}>
284
+ <Link to={a.type === "launch_kit" ? `/app/launch-kit?kit=${a.id}` : a.notebook_id ? `/app/notebooks/${a.notebook_id}` : "/app/archive"}>{a.title}</Link>
285
+ <span className="row">
286
+ <span className="mono muted">{formatDate(a.created_at)}</span>
287
+ <StatusPill status={a.status} />
288
+ </span>
289
+ </li>
290
+ ))}
291
+ </ul>
292
+ </div>
293
+ )}
294
+ <div className="stack">
295
+ {reshare.length > 0 && (
296
+ <div className="card mc-card">
297
+ <div className="row between">
298
+ <h2>Worth resharing</h2>
299
+ <Link to="/app/launchpad" className="mono muted small-link">
300
+ More ideas
301
+ </Link>
302
+ </div>
303
+ <ul className="recent-list">
304
+ {reshare.map((i) => (
305
+ <li key={i.id}>
306
+ <span>
307
+ <strong>{i.title}</strong>
308
+ {i.angle && <span className="muted small"> · {i.angle}</span>}
309
+ </span>
310
+ <button className="btn btn-small" disabled={busy !== null} onClick={() => launch(i)}>
311
+ {busy === `kit-${i.id}` ? "Launching..." : "Launch Kit"}
312
+ </button>
313
+ </li>
314
+ ))}
315
+ </ul>
316
+ </div>
317
+ )}
318
+ {upcoming.length > 0 && (
319
+ <div className="card mc-card">
320
+ <div className="row between">
321
+ <h2>Coming up</h2>
322
+ <Link to="/app/launchpad" className="mono muted small-link">
323
+ Calendar
324
+ </Link>
325
+ </div>
326
+ <ul className="recent-list">
327
+ {upcoming.map((i) => (
328
+ <li key={i.id}>
329
+ <Link to={`/app/launchpad?item=${i.id}`}>
330
+ <strong>{i.platform_label}</strong> <span className="muted">{i.content.slice(0, 70)}</span>
331
+ </Link>
332
+ <span className="mono muted">{formatDate(i.scheduled_at, true)}</span>
333
+ </li>
334
+ ))}
335
+ </ul>
336
+ </div>
337
+ )}
338
  </div>
339
+ </section>
340
+ )}
 
 
 
 
 
 
 
 
 
 
 
341
  </div>
342
  );
343
  }
frontend/src/pages/NotebookLMAlternative.tsx CHANGED
@@ -3,86 +3,13 @@ import SkyCanvas from "../components/SkyCanvas";
3
  import { PublicFooter, PublicNav } from "../components/PublicChrome";
4
  import { useMeta } from "./Blog";
5
  import "../styles/blog.css";
 
6
 
7
- /** SEO landing page: "notebooklm alternative" (590 searches/mo in the US, keyword difficulty 0, Sep 2026). */
8
-
9
- const ROWS: { feature: string; notestack: string; notebooklm: string }[] = [
10
- {
11
- feature: "Getting your writing in",
12
- notestack: "Syncs any blog, newsletter or site feed (Substack, Ghost, WordPress, Medium, RSS), plus markdown, text, HTML and PDF uploads",
13
- notebooklm: "Add sources one at a time: PDFs, Docs, web pages, videos",
14
- },
15
- {
16
- feature: "Stays in sync",
17
- notestack: "New posts arrive automatically on every sync",
18
- notebooklm: "Re-add sources by hand when they change",
19
- },
20
- {
21
- feature: "Citations",
22
- notestack: "Exact line ranges, read back and verified before you see them",
23
- notebooklm: "Passage level citations to the source",
24
- },
25
- {
26
- feature: "When the answer is not in your sources",
27
- notestack: "Says so instead of guessing; unverifiable claims are dropped",
28
- notebooklm: "Grounded in sources, with passage citations",
29
- },
30
- {
31
- feature: "Chat memory",
32
- notestack: "Follow ups resolved from the conversation; off topic questions answered instantly",
33
- notebooklm: "Conversation within a notebook",
34
- },
35
- {
36
- feature: "Audio overviews",
37
- notestack: "Two hosts, ElevenLabs voices, delivery controls, or your own consented voice clone",
38
- notebooklm: "Two host audio overviews with Google voices",
39
- },
40
- {
41
- feature: "Video",
42
- notestack: "9:16 shorts, 16:9 explainers and 1:1 audiograms with captions and your brand",
43
- notebooklm: "Video overviews",
44
- },
45
- {
46
- feature: "Publishing",
47
- notestack: "Launch Kits (threads, LinkedIn, Notes, Bluesky, SEO, carousels, quote cards) and a scheduling calendar",
48
- notebooklm: "Not a publishing tool",
49
- },
50
- {
51
- feature: "Your voice",
52
- notestack: "Voice profile learned from your posts, used for every draft",
53
- notebooklm: "General purpose writing",
54
- },
55
- {
56
- feature: "Seeing your archive",
57
- notestack: "Topic map with rising and dormant topics, plus evergreen resurfacing",
58
- notebooklm: "Mind maps per notebook",
59
- },
60
- ];
61
-
62
- const FAQ = [
63
- {
64
- q: "What is the best NotebookLM alternative?",
65
- a: "For writers, bloggers and newsletters, Notestack is the ultimate NotebookLM alternative: it syncs your whole archive, cites the exact lines behind every answer, and turns what you wrote into audio, video and launch posts. For academic papers, discovery tools like Elicit or Consensus pair well with it.",
66
- },
67
- {
68
- q: "Is Notestack free?",
69
- a: "Yes, there is a free plan to try it on your latest posts. Paid plans add more sources, more posts, more audio and video minutes and voice cloning.",
70
- },
71
- {
72
- q: "Can Notestack make NotebookLM style audio overviews?",
73
- a: "Yes. It writes a two host conversation grounded in your posts and voices it with ElevenLabs, with every line tied to the passage it came from. You can choose the voices or clone your own with consent.",
74
- },
75
- {
76
- q: "What if my writing is not on Substack?",
77
- a: "Notestack works with any blog, newsletter or site that has a feed, single article URLs, and uploaded markdown, text, HTML or PDF files.",
78
- },
79
- ];
80
 
81
  export default function NotebookLMAlternative() {
82
- useMeta(
83
- "The Ultimate NotebookLM Alternative for Writers | Notestack",
84
- "Notestack is the ultimate NotebookLM alternative for writers, bloggers and newsletters: sync your archive, get answers cited to the exact line, and turn your writing into audio, video and launch posts.",
85
- );
86
  const faqLd = {
87
  "@context": "https://schema.org",
88
  "@type": "FAQPage",
@@ -112,7 +39,7 @@ export default function NotebookLMAlternative() {
112
  <span className="mono tx-label">Incoming query</span>
113
  <p className="tx-query">Is there a NotebookLM built for my own writing?</p>
114
  </div>
115
- <h1 className="post-title">The ultimate NotebookLM alternative for writers</h1>
116
  <p className="post-lede muted">
117
  NotebookLM is great for a pile of documents. Notestack is built for a body of work: it syncs your blog, newsletter, site or
118
  markdown, answers with citations to the exact lines you wrote, and turns your knowledge into audio, video and launch posts.
 
3
  import { PublicFooter, PublicNav } from "../components/PublicChrome";
4
  import { useMeta } from "./Blog";
5
  import "../styles/blog.css";
6
+ import { NOTEBOOKLM_ALT_FAQ as FAQ, NOTEBOOKLM_ALT_META, NOTEBOOKLM_ALT_ROWS as ROWS } from "../content/notebooklmAlternative";
7
 
8
+ /** Comparison landing page: "Notestack vs NotebookLM". The /blogs/notebooklm-alternatives listicle owns the plural
9
+ * "notebooklm alternatives" query (590/mo US, KD 0, Sep 2026) and links here, so the two do not compete. */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  export default function NotebookLMAlternative() {
12
+ useMeta(NOTEBOOKLM_ALT_META.title, NOTEBOOKLM_ALT_META.description);
 
 
 
13
  const faqLd = {
14
  "@context": "https://schema.org",
15
  "@type": "FAQPage",
 
39
  <span className="mono tx-label">Incoming query</span>
40
  <p className="tx-query">Is there a NotebookLM built for my own writing?</p>
41
  </div>
42
+ <h1 className="post-title">Notestack vs NotebookLM: the NotebookLM alternative for writers</h1>
43
  <p className="post-lede muted">
44
  NotebookLM is great for a pile of documents. Notestack is built for a body of work: it syncs your blog, newsletter, site or
45
  markdown, answers with citations to the exact lines you wrote, and turns your knowledge into audio, video and launch posts.
frontend/src/pages/NotebookView.tsx CHANGED
@@ -1,5 +1,5 @@
1
  import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
2
- import { Link, useParams } from "react-router-dom";
3
  import { artifactsApi, notebooksApi, type GenerateBody } from "../api/endpoints";
4
  import { streamSSE } from "../api/stream";
5
  import type { Artifact, ChatSummary, Citation, Notebook } from "../api/types";
@@ -8,15 +8,19 @@ import { Markdown } from "../components/Markdown";
8
  import { DocPicker } from "../components/DocPicker";
9
  import { TelescopeIcon } from "../components/icons/Icons";
10
  import { Reader } from "../components/Reader";
11
- import { ConfirmButton, errorMessage, formatDate, Loading, Modal } from "../components/ui";
12
 
13
  type Turn = { role: "user" | "assistant"; text: string; citations?: Citation[]; status?: string; steps?: string[] };
14
 
 
 
 
 
 
15
  function StudioPanel({ notebookId, disabled }: { notebookId: string; disabled: boolean }) {
16
  const [artifacts, setArtifacts] = useState<Artifact[] | null>(null);
17
  const [format, setFormat] = useState<"deep_dive" | "brief" | "debate">("deep_dive");
18
  const [minutes, setMinutes] = useState(6);
19
- const [style, setStyle] = useState<"short" | "explainer">("short");
20
  const [error, setError] = useState<string | null>(null);
21
  const [busy, setBusy] = useState(false);
22
  const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null);
@@ -40,19 +44,32 @@ function StudioPanel({ notebookId, disabled }: { notebookId: string; disabled: b
40
  };
41
 
42
  const cite = (c: Citation) => c.document_id && setReading({ id: c.document_id, start: c.line_start, end: c.line_end });
 
43
 
44
  return (
45
- <aside className="card nbv-pane nbv-studio">
46
- <p className="eyebrow">Studio</p>
47
- <div className="studio-buttons">
48
- <button className="btn btn-small" disabled={disabled || busy} onClick={() => make({ type: "summary" })}>
 
 
 
 
 
 
 
 
 
 
 
49
  Summary
50
  </button>
51
- <button className="btn btn-small" disabled={disabled || busy} onClick={() => make({ type: "quote_card" })}>
52
  Quote card
53
  </button>
54
  </div>
55
- <div className="studio-group">
 
56
  <div className="row">
57
  <select className="input input-sm" value={format} onChange={(e) => setFormat(e.target.value as typeof format)} aria-label="Audio format">
58
  <option value="deep_dive">Deep dive</option>
@@ -67,19 +84,7 @@ function StudioPanel({ notebookId, disabled }: { notebookId: string; disabled: b
67
  ))}
68
  </select>
69
  </div>
70
- <button className="btn btn-small btn-primary" disabled={disabled || busy} onClick={() => make({ type: "audio_overview", format, minutes })}>
71
- Audio overview
72
- </button>
73
- </div>
74
- <div className="studio-group">
75
- <select className="input input-sm" value={style} onChange={(e) => setStyle(e.target.value as typeof style)} aria-label="Video style">
76
- <option value="short">Short, vertical 9:16</option>
77
- <option value="explainer">Explainer, 16:9</option>
78
- </select>
79
- <button className="btn btn-small" disabled={disabled || busy} onClick={() => make({ type: "video", style })}>
80
- Video
81
- </button>
82
- </div>
83
  {disabled && <p className="muted small">Add posts to this notebook to start creating.</p>}
84
  {error && <p className="error-text">{error}</p>}
85
  <div className="studio-list">
@@ -101,7 +106,7 @@ function StudioPanel({ notebookId, disabled }: { notebookId: string; disabled: b
101
  ))}
102
  </div>
103
  {reading && <Reader documentId={reading.id} highlight={{ start: reading.start, end: reading.end }} onClose={() => setReading(null)} />}
104
- </aside>
105
  );
106
  }
107
 
@@ -120,6 +125,9 @@ export default function NotebookView() {
120
  const [reading, setReading] = useState<{ id: string; start?: number; end?: number } | null>(null);
121
  const [editingTitle, setEditingTitle] = useState(false);
122
  const [title, setTitle] = useState("");
 
 
 
123
  const bottom = useRef<HTMLDivElement>(null);
124
 
125
  const load = useCallback(
@@ -152,9 +160,8 @@ export default function NotebookView() {
152
  setTurns(msgs.map((m) => ({ role: m.role, text: m.text, citations: m.citations })));
153
  };
154
 
155
- const ask = async (e: FormEvent) => {
156
- e.preventDefault();
157
- const question = q.trim();
158
  if (!question || busy) return;
159
  setQ("");
160
  setBusy(true);
@@ -189,6 +196,16 @@ export default function NotebookView() {
189
  }
190
  };
191
 
 
 
 
 
 
 
 
 
 
 
192
  if (loadError) return <p className="error-text">{loadError}</p>;
193
  if (!nb) return <Loading label="Opening notebook" />;
194
 
@@ -197,90 +214,73 @@ export default function NotebookView() {
197
 
198
  return (
199
  <div className="nbv">
200
- <aside className="card nbv-pane">
201
- <Link to="/app/notebooks" className="mono muted small-link">
202
- All notebooks
203
- </Link>
204
- {editingTitle ? (
205
- <form
206
- onSubmit={async (e) => {
207
- e.preventDefault();
208
- await notebooksApi.update(nb.id, { title });
209
- setEditingTitle(false);
210
- load();
211
- }}
212
- >
213
- <input className="input input-sm" autoFocus value={title} onChange={(e) => setTitle(e.target.value)} onBlur={() => setEditingTitle(false)} aria-label="Notebook title" />
214
- </form>
215
- ) : (
216
- <h2 className="nbv-title" onDoubleClick={() => setEditingTitle(true)} title="Double click to rename">
217
- {nb.title}
218
- </h2>
219
- )}
220
- <div className="row between">
221
- <p className="eyebrow">{nb.documents.length} posts</p>
222
- <button className="btn btn-small" onClick={() => setAdding(true)}>
223
- Add posts
224
- </button>
225
- </div>
226
- <ul className="nbv-docs">
227
- {nb.documents.map((d) => (
228
- <li key={d.id} className="nbv-doc">
229
- <button className="link-btn" onClick={() => setReading({ id: d.id })}>
230
- {d.title}
231
- </button>
232
- <span className="mono muted">{formatDate(d.published_at)}</span>
233
- <button
234
- className="icon-btn nbv-remove"
235
- aria-label={`Remove ${d.title}`}
236
- title="Remove from notebook"
237
- onClick={async () => {
238
- await notebooksApi.removeDoc(nb.id, d.id);
239
  load();
240
  }}
241
  >
242
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
243
- <path d="M6 6l12 12M18 6L6 18" />
244
- </svg>
245
- </button>
246
- </li>
247
- ))}
248
- </ul>
249
- </aside>
250
-
251
- <section className="card nbv-pane nbv-chat">
252
- <div className="chat-bar">
253
- <select
254
- className="input input-sm"
255
- value={chatId ?? ""}
256
- onChange={(e) => (e.target.value ? openChat(e.target.value) : (setChatId(null), setTurns([])))}
257
- aria-label="Chat history"
258
- >
259
- <option value="">New chat</option>
260
- {chats.map((c) => (
261
- <option key={c.id} value={c.id}>
262
- {(c.title ?? "Chat").slice(0, 60)} · {formatDate(c.updated_at)}
263
- </option>
264
- ))}
265
- </select>
266
- {chatId && (
267
- <ConfirmButton
268
- onConfirm={async () => {
269
- await notebooksApi.removeChat(chatId);
270
- setChatId(null);
271
- setTurns([]);
272
- loadChats();
273
- }}
274
- >
275
- Delete chat
276
- </ConfirmButton>
277
- )}
 
278
  </div>
279
  <div className="nbv-turns">
280
  {turns.length === 0 && (
281
- <div className="empty-state small">
282
  <TelescopeIcon size={32} />
283
- <p className="muted">Ask anything about these posts. Answers cite the passage they came from.</p>
 
 
 
 
 
 
 
 
 
284
  </div>
285
  )}
286
  {turns.map((t, i) => (
@@ -307,7 +307,13 @@ export default function NotebookView() {
307
  ))}
308
  <div ref={bottom} />
309
  </div>
310
- <form onSubmit={ask} className="inline-form">
 
 
 
 
 
 
311
  <input
312
  className="input"
313
  value={q}
@@ -322,7 +328,53 @@ export default function NotebookView() {
322
  </form>
323
  </section>
324
 
325
- <StudioPanel notebookId={nb.id} disabled={nb.documents.length === 0} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
 
327
  {adding && (
328
  <Modal title="Add posts" onClose={() => setAdding(false)} wide>
 
1
  import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
2
+ import { Link, useParams, useSearchParams } from "react-router-dom";
3
  import { artifactsApi, notebooksApi, type GenerateBody } from "../api/endpoints";
4
  import { streamSSE } from "../api/stream";
5
  import type { Artifact, ChatSummary, Citation, Notebook } from "../api/types";
 
8
  import { DocPicker } from "../components/DocPicker";
9
  import { TelescopeIcon } from "../components/icons/Icons";
10
  import { Reader } from "../components/Reader";
11
+ import { ConfirmButton, errorMessage, formatDate, Loading, Modal, Tabs } from "../components/ui";
12
 
13
  type Turn = { role: "user" | "assistant"; text: string; citations?: Citation[]; status?: string; steps?: string[] };
14
 
15
+ type Pane = "create" | "posts";
16
+
17
+ const STARTERS = ["What are the strongest ideas across these posts?", "Where do I contradict myself?", "Which post is most worth updating, and why?"];
18
+
19
+ /** The Create tab: one click per format with sensible defaults, options folded away. */
20
  function StudioPanel({ notebookId, disabled }: { notebookId: string; disabled: boolean }) {
21
  const [artifacts, setArtifacts] = useState<Artifact[] | null>(null);
22
  const [format, setFormat] = useState<"deep_dive" | "brief" | "debate">("deep_dive");
23
  const [minutes, setMinutes] = useState(6);
 
24
  const [error, setError] = useState<string | null>(null);
25
  const [busy, setBusy] = useState(false);
26
  const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null);
 
44
  };
45
 
46
  const cite = (c: Citation) => c.document_id && setReading({ id: c.document_id, start: c.line_start, end: c.line_end });
47
+ const off = disabled || busy;
48
 
49
  return (
50
+ <div className="stack">
51
+ <div className="nbv-make">
52
+ <button className="nbv-make-main" disabled={off} onClick={() => make({ type: "audio_overview", format, minutes })}>
53
+ <strong>Audio overview</strong>
54
+ <span className="muted small">
55
+ {format === "deep_dive" ? "Deep dive" : format === "brief" ? "Brief" : "Debate"} · {minutes} min
56
+ </span>
57
+ </button>
58
+ <button disabled={off} onClick={() => make({ type: "video", style: "short" })}>
59
+ Short video
60
+ </button>
61
+ <button disabled={off} onClick={() => make({ type: "video", style: "explainer" })}>
62
+ Explainer
63
+ </button>
64
+ <button disabled={off} onClick={() => make({ type: "summary" })}>
65
  Summary
66
  </button>
67
+ <button disabled={off} onClick={() => make({ type: "quote_card" })}>
68
  Quote card
69
  </button>
70
  </div>
71
+ <details className="delivery">
72
+ <summary className="mono muted small">Audio options</summary>
73
  <div className="row">
74
  <select className="input input-sm" value={format} onChange={(e) => setFormat(e.target.value as typeof format)} aria-label="Audio format">
75
  <option value="deep_dive">Deep dive</option>
 
84
  ))}
85
  </select>
86
  </div>
87
+ </details>
 
 
 
 
 
 
 
 
 
 
 
 
88
  {disabled && <p className="muted small">Add posts to this notebook to start creating.</p>}
89
  {error && <p className="error-text">{error}</p>}
90
  <div className="studio-list">
 
106
  ))}
107
  </div>
108
  {reading && <Reader documentId={reading.id} highlight={{ start: reading.start, end: reading.end }} onClose={() => setReading(null)} />}
109
+ </div>
110
  );
111
  }
112
 
 
125
  const [reading, setReading] = useState<{ id: string; start?: number; end?: number } | null>(null);
126
  const [editingTitle, setEditingTitle] = useState(false);
127
  const [title, setTitle] = useState("");
128
+ const [pane, setPane] = useState<Pane>("create");
129
+ const [params, setParams] = useSearchParams();
130
+ const asked = useRef(false);
131
  const bottom = useRef<HTMLDivElement>(null);
132
 
133
  const load = useCallback(
 
160
  setTurns(msgs.map((m) => ({ role: m.role, text: m.text, citations: m.citations })));
161
  };
162
 
163
+ const ask = async (text: string) => {
164
+ const question = text.trim();
 
165
  if (!question || busy) return;
166
  setQ("");
167
  setBusy(true);
 
196
  }
197
  };
198
 
199
+ // A question passed in the link (from the Home ask box) is asked as soon as the notebook opens.
200
+ useEffect(() => {
201
+ const pending = params.get("q");
202
+ if (!nb || !pending || asked.current || !nb.documents.length) return;
203
+ asked.current = true;
204
+ setParams({}, { replace: true });
205
+ void ask(pending);
206
+ // eslint-disable-next-line react-hooks/exhaustive-deps
207
+ }, [nb]);
208
+
209
  if (loadError) return <p className="error-text">{loadError}</p>;
210
  if (!nb) return <Loading label="Opening notebook" />;
211
 
 
214
 
215
  return (
216
  <div className="nbv">
217
+ <section className="card nbv-pane nbv-chat">
218
+ <div className="chat-bar">
219
+ <div className="nbv-titlebar">
220
+ <Link to="/app/notebooks" className="mono muted small-link">
221
+ Notebooks /
222
+ </Link>
223
+ {editingTitle ? (
224
+ <form
225
+ onSubmit={async (e) => {
226
+ e.preventDefault();
227
+ await notebooksApi.update(nb.id, { title });
228
+ setEditingTitle(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  load();
230
  }}
231
  >
232
+ <input className="input input-sm" autoFocus value={title} onChange={(e) => setTitle(e.target.value)} onBlur={() => setEditingTitle(false)} aria-label="Notebook title" />
233
+ </form>
234
+ ) : (
235
+ <h2 className="nbv-title" onDoubleClick={() => setEditingTitle(true)} title="Double click to rename">
236
+ {nb.title}
237
+ </h2>
238
+ )}
239
+ </div>
240
+ <div className="row">
241
+ {(chats.length > 0 || chatId) && (
242
+ <select
243
+ className="input input-sm"
244
+ value={chatId ?? ""}
245
+ onChange={(e) => (e.target.value ? openChat(e.target.value) : (setChatId(null), setTurns([])))}
246
+ aria-label="Chat history"
247
+ >
248
+ <option value="">New chat</option>
249
+ {chats.map((c) => (
250
+ <option key={c.id} value={c.id}>
251
+ {(c.title ?? "Chat").slice(0, 60)} · {formatDate(c.updated_at)}
252
+ </option>
253
+ ))}
254
+ </select>
255
+ )}
256
+ {chatId && (
257
+ <ConfirmButton
258
+ onConfirm={async () => {
259
+ await notebooksApi.removeChat(chatId);
260
+ setChatId(null);
261
+ setTurns([]);
262
+ loadChats();
263
+ }}
264
+ >
265
+ Delete chat
266
+ </ConfirmButton>
267
+ )}
268
+ </div>
269
  </div>
270
  <div className="nbv-turns">
271
  {turns.length === 0 && (
272
+ <div className="nbv-empty">
273
  <TelescopeIcon size={32} />
274
+ <p className="muted">Ask anything about {nb.documents.length} posts. Answers cite the passage they came from.</p>
275
+ {nb.documents.length > 0 && (
276
+ <div className="nbv-starters">
277
+ {STARTERS.map((s) => (
278
+ <button key={s} type="button" className="nbv-starter" onClick={() => ask(s)}>
279
+ {s}
280
+ </button>
281
+ ))}
282
+ </div>
283
+ )}
284
  </div>
285
  )}
286
  {turns.map((t, i) => (
 
307
  ))}
308
  <div ref={bottom} />
309
  </div>
310
+ <form
311
+ onSubmit={(e: FormEvent) => {
312
+ e.preventDefault();
313
+ void ask(q);
314
+ }}
315
+ className="inline-form nbv-ask"
316
+ >
317
  <input
318
  className="input"
319
  value={q}
 
328
  </form>
329
  </section>
330
 
331
+ <aside className="card nbv-pane nbv-side">
332
+ <Tabs<Pane>
333
+ tabs={[
334
+ { id: "create", label: "Create" },
335
+ { id: "posts", label: "Posts", count: nb.documents.length },
336
+ ]}
337
+ value={pane}
338
+ onChange={setPane}
339
+ />
340
+ {pane === "create" && <StudioPanel notebookId={nb.id} disabled={nb.documents.length === 0} />}
341
+ {pane === "posts" && (
342
+ <>
343
+ {nb.is_archive ? (
344
+ <p className="muted small">Every indexed post. New posts join automatically.</p>
345
+ ) : (
346
+ <button className="btn btn-small" onClick={() => setAdding(true)}>
347
+ Add posts
348
+ </button>
349
+ )}
350
+ <ul className="nbv-docs">
351
+ {nb.documents.map((d) => (
352
+ <li key={d.id} className="nbv-doc">
353
+ <button className="link-btn" onClick={() => setReading({ id: d.id })}>
354
+ {d.title}
355
+ </button>
356
+ <span className="mono muted">{formatDate(d.published_at)}</span>
357
+ {!nb.is_archive && (
358
+ <button
359
+ className="icon-btn nbv-remove"
360
+ aria-label={`Remove ${d.title}`}
361
+ title="Remove from notebook"
362
+ onClick={async () => {
363
+ await notebooksApi.removeDoc(nb.id, d.id);
364
+ load();
365
+ }}
366
+ >
367
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
368
+ <path d="M6 6l12 12M18 6L6 18" />
369
+ </svg>
370
+ </button>
371
+ )}
372
+ </li>
373
+ ))}
374
+ </ul>
375
+ </>
376
+ )}
377
+ </aside>
378
 
379
  {adding && (
380
  <Modal title="Add posts" onClose={() => setAdding(false)} wide>
frontend/src/pages/Notebooks.tsx CHANGED
@@ -1,23 +1,41 @@
1
  import { useCallback, useEffect, useState, type FormEvent } from "react";
2
  import { Link, useNavigate } from "react-router-dom";
3
- import { notebooksApi } from "../api/endpoints";
4
- import type { NotebookSummary } from "../api/types";
5
  import { DocPicker } from "../components/DocPicker";
6
- import { ConfirmButton, EmptyState, errorMessage, formatDate, Loading, Modal, PageHeader } from "../components/ui";
 
7
 
 
8
  export function NewNotebookModal({ onClose, initialDocs = [] }: { onClose: () => void; initialDocs?: string[] }) {
9
- const [title, setTitle] = useState("");
 
10
  const [selected, setSelected] = useState<string[]>(initialDocs);
 
11
  const [busy, setBusy] = useState(false);
12
  const [error, setError] = useState<string | null>(null);
13
  const navigate = useNavigate();
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  const submit = async (e: FormEvent) => {
16
  e.preventDefault();
17
  setBusy(true);
18
  setError(null);
19
  try {
20
- const nb = await notebooksApi.create(title.trim() || "Untitled notebook", selected);
21
  navigate(`/app/notebooks/${nb.id}`);
22
  } catch (err) {
23
  setError(errorMessage(err));
@@ -29,19 +47,43 @@ export function NewNotebookModal({ onClose, initialDocs = [] }: { onClose: () =>
29
  <Modal title="New notebook" onClose={onClose} wide>
30
  <form onSubmit={submit} className="stack">
31
  <label className="field">
32
- <span>Title</span>
33
- <input className="input input-sm" autoFocus value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Pricing and paid tiers" />
34
  </label>
35
- <div className="field">
36
- <span>Posts in this notebook</span>
37
  <DocPicker selected={selected} onChange={setSelected} />
38
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  {error && <p className="error-text">{error}</p>}
40
- <div className="row end">
41
- <button type="button" className="btn" onClick={onClose}>
42
- Cancel
43
  </button>
44
- <button className="btn btn-primary" disabled={busy}>
45
  {busy ? "Creating..." : `Create with ${selected.length} posts`}
46
  </button>
47
  </div>
@@ -50,6 +92,31 @@ export function NewNotebookModal({ onClose, initialDocs = [] }: { onClose: () =>
50
  );
51
  }
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  export default function Notebooks() {
54
  const [notebooks, setNotebooks] = useState<NotebookSummary[] | null>(null);
55
  const [creating, setCreating] = useState(false);
@@ -58,7 +125,8 @@ export default function Notebooks() {
58
 
59
  const load = useCallback(() => notebooksApi.list().then(setNotebooks), []);
60
  useEffect(() => {
61
- load();
 
62
  }, [load]);
63
 
64
  return (
@@ -68,24 +136,10 @@ export default function Notebooks() {
68
  New notebook
69
  </button>
70
  </PageHeader>
71
- <p className="muted lede">
72
- A notebook is a set of posts you can question, summarize and turn into audio, video and launch kits. Every answer cites the lines it came from.
73
- </p>
74
  {!notebooks && <Loading />}
75
- {notebooks?.length === 0 && (
76
- <EmptyState
77
- title="No notebooks yet"
78
- body="Group posts by theme, series or year. You can also make one from a topic on the Topic map."
79
- action={
80
- <button className="btn btn-primary" onClick={() => setCreating(true)}>
81
- Create your first notebook
82
- </button>
83
- }
84
- />
85
- )}
86
- <section className="grid-cards">
87
  {notebooks?.map((n) => (
88
- <article key={n.id} className="card nb-card">
89
  {renaming === n.id ? (
90
  <form
91
  className="inline-form"
@@ -101,37 +155,37 @@ export default function Notebooks() {
101
  </form>
102
  ) : (
103
  <Link to={`/app/notebooks/${n.id}`} className="nb-card-link">
 
104
  <h3>{n.title}</h3>
105
  {(n.description || n.summary) && <p className="muted clamp-3">{n.description || n.summary}</p>}
 
 
 
 
 
106
  </Link>
107
  )}
108
- <p className="mono muted">
109
- {n.document_count} posts · {n.chat_count} chats · {n.artifact_count} artifacts · {formatDate(n.updated_at)}
110
- </p>
111
- <footer className="row">
112
- <Link className="btn btn-small" to={`/app/notebooks/${n.id}`}>
113
- Open
114
- </Link>
115
- <button
116
- className="btn btn-small"
117
- onClick={() => {
118
  setRenaming(n.id);
119
  setName(n.title);
120
  }}
121
- >
122
- Rename
123
- </button>
124
- <ConfirmButton
125
- onConfirm={async () => {
126
  await notebooksApi.remove(n.id);
127
  load();
128
  }}
129
- >
130
- Delete
131
- </ConfirmButton>
132
- </footer>
133
  </article>
134
  ))}
 
 
 
 
 
 
 
135
  </section>
136
  {creating && <NewNotebookModal onClose={() => setCreating(false)} />}
137
  </div>
 
1
  import { useCallback, useEffect, useState, type FormEvent } from "react";
2
  import { Link, useNavigate } from "react-router-dom";
3
+ import { docsApi, notebooksApi } from "../api/endpoints";
4
+ import type { Doc, NotebookSummary } from "../api/types";
5
  import { DocPicker } from "../components/DocPicker";
6
+ import { PlanetIcon } from "../components/icons/Icons";
7
+ import { ConfirmButton, errorMessage, formatDate, Loading, Modal, PageHeader } from "../components/ui";
8
 
9
+ /** New notebook from a topic: typing it finds and pre-selects the matching posts; hand picking is the fallback. */
10
  export function NewNotebookModal({ onClose, initialDocs = [] }: { onClose: () => void; initialDocs?: string[] }) {
11
+ const [topic, setTopic] = useState("");
12
+ const [matches, setMatches] = useState<Doc[]>([]);
13
  const [selected, setSelected] = useState<string[]>(initialDocs);
14
+ const [manual, setManual] = useState(initialDocs.length > 0);
15
  const [busy, setBusy] = useState(false);
16
  const [error, setError] = useState<string | null>(null);
17
  const navigate = useNavigate();
18
 
19
+ useEffect(() => {
20
+ if (manual || topic.trim().length < 2) {
21
+ if (!manual) setMatches([]);
22
+ return;
23
+ }
24
+ const t = setTimeout(async () => {
25
+ const page = await docsApi.list({ q: topic.trim(), limit: 50 });
26
+ const found = page.items.filter((d) => !d.locked);
27
+ setMatches(found);
28
+ setSelected(found.map((d) => d.id));
29
+ }, 250);
30
+ return () => clearTimeout(t);
31
+ }, [topic, manual]);
32
+
33
  const submit = async (e: FormEvent) => {
34
  e.preventDefault();
35
  setBusy(true);
36
  setError(null);
37
  try {
38
+ const nb = await notebooksApi.create(topic.trim() || "Untitled notebook", selected);
39
  navigate(`/app/notebooks/${nb.id}`);
40
  } catch (err) {
41
  setError(errorMessage(err));
 
47
  <Modal title="New notebook" onClose={onClose} wide>
48
  <form onSubmit={submit} className="stack">
49
  <label className="field">
50
+ <span>What is it about?</span>
51
+ <input className="input" autoFocus value={topic} onChange={(e) => setTopic(e.target.value)} placeholder="Pricing, paid tiers, 2024 essays..." />
52
  </label>
53
+ {manual ? (
 
54
  <DocPicker selected={selected} onChange={setSelected} />
55
+ ) : (
56
+ <>
57
+ {topic.trim().length >= 2 && (
58
+ <p className="muted small">
59
+ {matches.length ? `${matches.length} posts mention it. Untick any that do not belong.` : "No posts mention that yet."}
60
+ </p>
61
+ )}
62
+ {matches.length > 0 && (
63
+ <ul className="picker-list nb-matches">
64
+ {matches.map((d) => (
65
+ <li key={d.id}>
66
+ <label className={`picker-row${selected.includes(d.id) ? " on" : ""}`}>
67
+ <input
68
+ type="checkbox"
69
+ checked={selected.includes(d.id)}
70
+ onChange={(e) => setSelected(e.target.checked ? [...selected, d.id] : selected.filter((x) => x !== d.id))}
71
+ />
72
+ <span className="picker-title">{d.title}</span>
73
+ <span className="mono muted picker-meta">{formatDate(d.published_at)}</span>
74
+ </label>
75
+ </li>
76
+ ))}
77
+ </ul>
78
+ )}
79
+ </>
80
+ )}
81
  {error && <p className="error-text">{error}</p>}
82
+ <div className="row between">
83
+ <button type="button" className="link-btn small" onClick={() => setManual(!manual)}>
84
+ {manual ? "Find posts by topic instead" : "Pick posts by hand"}
85
  </button>
86
+ <button className="btn btn-primary" disabled={busy || !selected.length}>
87
  {busy ? "Creating..." : `Create with ${selected.length} posts`}
88
  </button>
89
  </div>
 
92
  );
93
  }
94
 
95
+ function CardMenu({ onRename, onDelete }: { onRename: () => void; onDelete: () => Promise<void> }) {
96
+ const [open, setOpen] = useState(false);
97
+ return (
98
+ <div className="card-menu">
99
+ <button type="button" className="icon-btn" aria-label="Notebook options" aria-expanded={open} onClick={() => setOpen(!open)}>
100
+ ⋯
101
+ </button>
102
+ {open && (
103
+ <div className="card-menu-list" onMouseLeave={() => setOpen(false)}>
104
+ <button
105
+ type="button"
106
+ onClick={() => {
107
+ setOpen(false);
108
+ onRename();
109
+ }}
110
+ >
111
+ Rename
112
+ </button>
113
+ <ConfirmButton onConfirm={onDelete}>Delete</ConfirmButton>
114
+ </div>
115
+ )}
116
+ </div>
117
+ );
118
+ }
119
+
120
  export default function Notebooks() {
121
  const [notebooks, setNotebooks] = useState<NotebookSummary[] | null>(null);
122
  const [creating, setCreating] = useState(false);
 
125
 
126
  const load = useCallback(() => notebooksApi.list().then(setNotebooks), []);
127
  useEffect(() => {
128
+ // "All posts" always exists, so asking the whole archive never needs setup.
129
+ notebooksApi.archive().finally(load);
130
  }, [load]);
131
 
132
  return (
 
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" : ""}`}>
143
  {renaming === n.id ? (
144
  <form
145
  className="inline-form"
 
155
  </form>
156
  ) : (
157
  <Link to={`/app/notebooks/${n.id}`} className="nb-card-link">
158
+ {n.is_archive && <PlanetIcon size={26} />}
159
  <h3>{n.title}</h3>
160
  {(n.description || n.summary) && <p className="muted clamp-3">{n.description || n.summary}</p>}
161
+ <p className="mono muted">
162
+ {n.document_count} posts
163
+ {n.chat_count ? ` · ${n.chat_count} chats` : ""}
164
+ {n.artifact_count ? ` · ${n.artifact_count} made` : ""} · {formatDate(n.updated_at)}
165
+ </p>
166
  </Link>
167
  )}
168
+ {!n.is_archive && renaming !== n.id && (
169
+ <CardMenu
170
+ onRename={() => {
 
 
 
 
 
 
 
171
  setRenaming(n.id);
172
  setName(n.title);
173
  }}
174
+ onDelete={async () => {
 
 
 
 
175
  await notebooksApi.remove(n.id);
176
  load();
177
  }}
178
+ />
179
+ )}
 
 
180
  </article>
181
  ))}
182
+ <button type="button" className="card nb-card nb-new" onClick={() => setCreating(true)}>
183
+ <span className="mc-more" aria-hidden="true">
184
+ +
185
+ </span>
186
+ <strong>New notebook</strong>
187
+ <span className="muted small">Group posts by theme, series or year</span>
188
+ </button>
189
  </section>
190
  {creating && <NewNotebookModal onClose={() => setCreating(false)} />}
191
  </div>
frontend/src/pages/Resurface.tsx CHANGED
@@ -1,10 +1,10 @@
1
  import { useEffect, useState } from "react";
2
- import { useNavigate } from "react-router-dom";
3
  import { resurfaceApi } from "../api/endpoints";
4
  import type { Job, ResurfaceItem } from "../api/types";
5
  import { Reader } from "../components/Reader";
6
  import { ScheduleModal } from "../components/ScheduleModal";
7
- import { EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader } from "../components/ui";
8
  import { useJob } from "../hooks/useJob";
9
 
10
  type Data = Awaited<ReturnType<typeof resurfaceApi.get>>;
@@ -12,7 +12,7 @@ type Data = Awaited<ReturnType<typeof resurfaceApi.get>>;
12
  function Row({ item, onRead, onSchedule }: { item: ResurfaceItem; onRead: () => void; onSchedule: () => void }) {
13
  const navigate = useNavigate();
14
  return (
15
- <li className="card resurface-row">
16
  <div className="row between">
17
  <button className="link-btn doc-title" onClick={onRead}>
18
  {item.title}
@@ -23,16 +23,15 @@ function Row({ item, onRead, onSchedule }: { item: ResurfaceItem; onRead: () =>
23
  </span>
24
  )}
25
  </div>
26
- <p className="mono muted">
27
  {formatDate(item.published_at)}
28
  {item.years_ago ? ` · ${item.years_ago} year${item.years_ago > 1 ? "s" : ""} ago today` : ""}
29
  {item.last_resurfaced_at ? ` · reshared ${formatDate(item.last_resurfaced_at)}` : ""}
30
  </p>
31
  {item.angle && <p className="angle">{item.angle}</p>}
32
- {item.reason && <p className="muted small">{item.reason}</p>}
33
  <div className="row">
34
  <button className="btn btn-small btn-primary" onClick={() => navigate(`/app/launch-kit?post=${item.id}`)}>
35
- Make a Launch Kit
36
  </button>
37
  <button className="btn btn-small" onClick={onSchedule}>
38
  Schedule a reshare
@@ -42,7 +41,8 @@ function Row({ item, onRead, onSchedule }: { item: ResurfaceItem; onRead: () =>
42
  );
43
  }
44
 
45
- export default function Resurface() {
 
46
  const [data, setData] = useState<Data | null>(null);
47
  const [job, setJob] = useState<Job | null>(null);
48
  const [reading, setReading] = useState<string | null>(null);
@@ -56,54 +56,37 @@ export default function Resurface() {
56
  const live = useJob(job, () => load());
57
  const running = live && (live.status === "queued" || live.status === "running");
58
 
59
- const scan = async () => {
60
- setError(null);
61
- try {
62
- setJob(await resurfaceApi.scan());
63
- } catch (e) {
64
- setError(errorMessage(e));
65
- }
66
- };
67
 
68
  return (
69
- <div className="page-wrap">
70
- <PageHeader eyebrow="Resurfacing" title="Old posts worth a second orbit">
71
- <button className="btn btn-primary" disabled={Boolean(running)} onClick={scan}>
72
- {data?.unscored ? `Score ${data.unscored} new posts` : "Rescan"}
73
- </button>
74
- </PageHeader>
75
- <p className="muted lede">
76
- Notestack scores how timeless each post is, then ranks the old, evergreen ones you have not reshared in 90 days.
77
- </p>
78
- {live && (running || live.status === "failed") && <JobProgress job={live} />}
79
  {error && <p className="error-text">{error}</p>}
80
- {!data && <Loading />}
81
- {data && (
82
- <div className="two-col">
83
- <section className="stack">
84
- <h2>Evergreen picks</h2>
85
- {data.evergreen.length === 0 && (
86
- <EmptyState
87
- title={data.total_posts ? "Nothing scored yet" : "No posts yet"}
88
- body={data.total_posts ? "Run a scan to find your evergreen posts." : "Connect a source first."}
89
- />
90
- )}
91
- <ul className="stack">
92
- {data.evergreen.map((i) => (
93
- <Row key={i.id} item={i} onRead={() => setReading(i.id)} onSchedule={() => setScheduling(i)} />
94
- ))}
95
- </ul>
96
- </section>
97
- <section className="stack">
98
- <h2>On this day</h2>
99
- {data.on_this_day.length === 0 && <p className="muted">Nothing published on this date in past years.</p>}
100
- <ul className="stack">
101
- {data.on_this_day.map((i) => (
102
- <Row key={i.id} item={i} onRead={() => setReading(i.id)} onSchedule={() => setScheduling(i)} />
103
- ))}
104
- </ul>
105
- </section>
106
- </div>
107
  )}
108
  {reading && <Reader documentId={reading} onClose={() => setReading(null)} />}
109
  {scheduling && (
@@ -112,9 +95,17 @@ export default function Resurface() {
112
  posts={[`${scheduling.angle ?? scheduling.title}\n\n${scheduling.url.startsWith("http") ? scheduling.url : ""}`.trim()]}
113
  documentId={scheduling.id}
114
  onClose={() => setScheduling(null)}
115
- onScheduled={() => load()}
 
 
 
116
  />
117
  )}
118
  </div>
119
  );
120
  }
 
 
 
 
 
 
1
  import { useEffect, useState } from "react";
2
+ import { Navigate, useNavigate } from "react-router-dom";
3
  import { resurfaceApi } from "../api/endpoints";
4
  import type { Job, ResurfaceItem } from "../api/types";
5
  import { Reader } from "../components/Reader";
6
  import { ScheduleModal } from "../components/ScheduleModal";
7
+ import { errorMessage, formatDate, JobProgress, Loading } from "../components/ui";
8
  import { useJob } from "../hooks/useJob";
9
 
10
  type Data = Awaited<ReturnType<typeof resurfaceApi.get>>;
 
12
  function Row({ item, onRead, onSchedule }: { item: ResurfaceItem; onRead: () => void; onSchedule: () => void }) {
13
  const navigate = useNavigate();
14
  return (
15
+ <li className="idea">
16
  <div className="row between">
17
  <button className="link-btn doc-title" onClick={onRead}>
18
  {item.title}
 
23
  </span>
24
  )}
25
  </div>
26
+ <p className="mono muted small">
27
  {formatDate(item.published_at)}
28
  {item.years_ago ? ` · ${item.years_ago} year${item.years_ago > 1 ? "s" : ""} ago today` : ""}
29
  {item.last_resurfaced_at ? ` · reshared ${formatDate(item.last_resurfaced_at)}` : ""}
30
  </p>
31
  {item.angle && <p className="angle">{item.angle}</p>}
 
32
  <div className="row">
33
  <button className="btn btn-small btn-primary" onClick={() => navigate(`/app/launch-kit?post=${item.id}`)}>
34
+ Launch Kit
35
  </button>
36
  <button className="btn btn-small" onClick={onSchedule}>
37
  Schedule a reshare
 
41
  );
42
  }
43
 
44
+ /** Old posts worth a second orbit. Scoring runs after every sync, so there is nothing to press first. */
45
+ export function ResurfaceIdeas({ onScheduled }: { onScheduled?: () => void }) {
46
  const [data, setData] = useState<Data | null>(null);
47
  const [job, setJob] = useState<Job | null>(null);
48
  const [reading, setReading] = useState<string | null>(null);
 
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 (
63
+ <div className="stack">
64
+ {live && (running || live.status === "failed") && <JobProgress job={live} compact />}
 
 
 
 
 
 
 
 
65
  {error && <p className="error-text">{error}</p>}
66
+ {items.length === 0 && (
67
+ <p className="muted small">
68
+ {data.total_posts ? "No reshare ideas yet. They appear after your posts are scored." : "Connect a source to get reshare ideas."}
69
+ </p>
70
+ )}
71
+ <ul className="stack">
72
+ {items.slice(0, 8).map((i) => (
73
+ <Row key={i.id} item={i} onRead={() => setReading(i.id)} onSchedule={() => setScheduling(i)} />
74
+ ))}
75
+ </ul>
76
+ {data.unscored > 0 && !running && (
77
+ <button
78
+ className="link-btn small"
79
+ onClick={async () => {
80
+ setError(null);
81
+ try {
82
+ setJob(await resurfaceApi.scan());
83
+ } catch (e) {
84
+ setError(errorMessage(e));
85
+ }
86
+ }}
87
+ >
88
+ Score {data.unscored} new posts now
89
+ </button>
 
 
 
90
  )}
91
  {reading && <Reader documentId={reading} onClose={() => setReading(null)} />}
92
  {scheduling && (
 
95
  posts={[`${scheduling.angle ?? scheduling.title}\n\n${scheduling.url.startsWith("http") ? scheduling.url : ""}`.trim()]}
96
  documentId={scheduling.id}
97
  onClose={() => setScheduling(null)}
98
+ onScheduled={() => {
99
+ load();
100
+ onScheduled?.();
101
+ }}
102
  />
103
  )}
104
  </div>
105
  );
106
  }
107
+
108
+ /** The old Resurfacing page now lives inside Launchpad. */
109
+ export default function Resurface() {
110
+ return <Navigate to="/app/launchpad#ideas" replace />;
111
+ }
frontend/src/pages/Settings.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState, type ReactNode } from "react";
2
  import { useNavigate } from "react-router-dom";
3
  import { authApi } from "../api/auth";
4
  import { tokens, uploadFile } from "../api/client";
@@ -27,10 +27,13 @@ function Meter({ label, used, limit, unit }: { label: string; used: number; limi
27
  );
28
  }
29
 
30
- function Section({ title, children }: { title: string; children: ReactNode }) {
31
  return (
32
  <section className="card stack">
33
- <h2>{title}</h2>
 
 
 
34
  {children}
35
  </section>
36
  );
@@ -44,7 +47,6 @@ export default function Settings() {
44
  const [accent, setAccent] = useState("#217cff");
45
  const [saved, setSaved] = useState<string | null>(null);
46
  const [error, setError] = useState<string | null>(null);
47
- const logoRef = useRef<HTMLInputElement>(null);
48
  const [sounds, setSounds] = useState(soundsEnabled);
49
  const { logout } = useAuth();
50
  const upgrade = useUpgrade();
@@ -75,6 +77,13 @@ export default function Settings() {
75
 
76
  if (!s) return error ? <p className="error-text">{error}</p> : <Loading />;
77
  const u = s.usage;
 
 
 
 
 
 
 
78
 
79
  return (
80
  <div className="page-wrap">
@@ -82,58 +91,54 @@ export default function Settings() {
82
  {error && <p className="error-text">{error}</p>}
83
  <div className="two-col">
84
  <div className="stack">
85
- <Section title="Profile">
86
  <label className="field">
87
  <span>Name</span>
88
- <input className="input input-sm" value={name} onChange={(e) => setName(e.target.value)} />
89
  </label>
90
  <p className="mono muted">
91
  {s.user.email} · signed in with {s.user.auth_provider === "google" ? "Google" : "email and password"}
92
  </p>
93
  <label className="field">
94
  <span>Workspace name</span>
95
- <input className="input input-sm" value={workspace} onChange={(e) => setWorkspace(e.target.value)} />
96
  </label>
97
- <div className="row end">
98
- <button className="btn btn-primary btn-small" onClick={() => save({ name, workspace_name: workspace }, "profile")}>
99
- {saved === "profile" ? "Saved" : "Save"}
100
- </button>
101
- </div>
102
  </Section>
103
 
104
- <Section title="Brand kit">
105
  <p className="muted">Used on videos, quote cards and carousels.</p>
106
  <label className="field">
107
  <span>Name on renders</span>
108
- <input className="input input-sm" placeholder="Your publication" value={brandName} onChange={(e) => setBrandName(e.target.value)} />
109
  </label>
110
  <label className="field">
111
  <span>Accent color</span>
112
  <div className="row">
113
- <input type="color" value={accent} onChange={(e) => setAccent(e.target.value)} aria-label="Accent color" className="color" />
114
- <input className="input input-sm mono" value={accent} onChange={(e) => setAccent(e.target.value)} />
115
  </div>
116
  </label>
117
  <div className="field">
118
  <span>Logo</span>
119
  <div className="row">
120
  {s.brand.logo_url && <img src={s.brand.logo_url} alt="Logo" className="logo-preview" />}
121
- <input ref={logoRef} type="file" accept="image/png,image/jpeg,image/webp,image/svg+xml" className="input file-input" aria-label="Logo file" />
122
- <button
123
- className="btn btn-small"
124
- onClick={async () => {
125
- const file = logoRef.current?.files?.[0];
 
 
 
126
  if (!file) return;
127
  try {
128
  const up = await uploadFile(file);
129
  await save({ logo_upload_id: up.upload_id }, "logo");
130
- } catch (e) {
131
- setError(errorMessage(e));
132
  }
133
  }}
134
- >
135
- Upload
136
- </button>
137
  {s.brand.logo_url && (
138
  <button className="btn btn-small" onClick={() => save({ logo_upload_id: "" }, "logo")}>
139
  Remove
@@ -141,11 +146,6 @@ export default function Settings() {
141
  )}
142
  </div>
143
  </div>
144
- <div className="row end">
145
- <button className="btn btn-primary btn-small" onClick={() => save({ brand_name: brandName, brand_accent: accent }, "brand")}>
146
- {saved === "brand" ? "Saved" : "Save brand"}
147
- </button>
148
- </div>
149
  </Section>
150
 
151
  <Section title="Privacy and email">
@@ -198,36 +198,10 @@ export default function Settings() {
198
  )}
199
  {upgrade.error && !upgrade.modal && <p className="error-text">{upgrade.error}</p>}
200
  <p className="mono muted small">
201
- {s.plan.sources} sources · {s.plan.indexed_posts.toLocaleString()} indexed posts · {u.used.llm_tokens.toLocaleString()} LLM tokens this month
202
  </p>
203
  </Section>
204
 
205
- <Section title="Connections">
206
- <ul className="integrations mono">
207
- <li>
208
- LLM <span className={s.integrations.llm ? "ok" : "off"}>{s.integrations.llm ? "configured" : "set LLM_API_KEY"}</span>
209
- </li>
210
- <li>
211
- ElevenLabs <span className={s.integrations.elevenlabs ? "ok" : "off"}>{s.integrations.elevenlabs ? "configured" : "set ELEVENLABS_API_KEY"}</span>
212
- </li>
213
- <li>
214
- X app <span className={s.integrations.x ? "ok" : "off"}>{s.integrations.x ? "configured" : "set X_CLIENT_ID"}</span>
215
- </li>
216
- <li>
217
- LinkedIn app <span className={s.integrations.linkedin ? "ok" : "off"}>{s.integrations.linkedin ? "configured" : "set LINKEDIN_CLIENT_ID"}</span>
218
- </li>
219
- <li>
220
- Storage <span className="ok">{s.integrations.storage === "local" ? "local disk" : "Cloudflare R2"}</span>
221
- </li>
222
- <li>
223
- Renderer <span className="muted">{s.integrations.renderer}</span>
224
- </li>
225
- </ul>
226
- <button className="btn btn-small" onClick={() => navigate("/app/launchpad")}>
227
- Manage social accounts
228
- </button>
229
- </Section>
230
-
231
  <Section title="Account">
232
  <div className="row">
233
  <button
 
1
+ import { useEffect, useState, type ReactNode } from "react";
2
  import { useNavigate } from "react-router-dom";
3
  import { authApi } from "../api/auth";
4
  import { tokens, uploadFile } from "../api/client";
 
27
  );
28
  }
29
 
30
+ function Section({ title, saved, children }: { title: string; saved?: boolean; children: ReactNode }) {
31
  return (
32
  <section className="card stack">
33
+ <div className="row between">
34
+ <h2>{title}</h2>
35
+ {saved && <span className="mono muted small">Saved</span>}
36
+ </div>
37
  {children}
38
  </section>
39
  );
 
47
  const [accent, setAccent] = useState("#217cff");
48
  const [saved, setSaved] = useState<string | null>(null);
49
  const [error, setError] = useState<string | null>(null);
 
50
  const [sounds, setSounds] = useState(soundsEnabled);
51
  const { logout } = useAuth();
52
  const upgrade = useUpgrade();
 
77
 
78
  if (!s) return error ? <p className="error-text">{error}</p> : <Loading />;
79
  const u = s.usage;
80
+ // Fields save on their own when the writer leaves them, so there are no Save buttons.
81
+ const saveProfile = () => {
82
+ if (name !== (s.user.name ?? "") || workspace !== s.workspace.name) void save({ name, workspace_name: workspace }, "profile");
83
+ };
84
+ const saveBrand = (next = { brandName, accent }) => {
85
+ if (next.brandName !== (s.brand.name ?? "") || next.accent !== s.brand.accent) void save({ brand_name: next.brandName, brand_accent: next.accent }, "brand");
86
+ };
87
 
88
  return (
89
  <div className="page-wrap">
 
91
  {error && <p className="error-text">{error}</p>}
92
  <div className="two-col">
93
  <div className="stack">
94
+ <Section title="Profile" saved={saved === "profile"}>
95
  <label className="field">
96
  <span>Name</span>
97
+ <input className="input input-sm" value={name} onChange={(e) => setName(e.target.value)} onBlur={saveProfile} />
98
  </label>
99
  <p className="mono muted">
100
  {s.user.email} · signed in with {s.user.auth_provider === "google" ? "Google" : "email and password"}
101
  </p>
102
  <label className="field">
103
  <span>Workspace name</span>
104
+ <input className="input input-sm" value={workspace} onChange={(e) => setWorkspace(e.target.value)} onBlur={saveProfile} />
105
  </label>
 
 
 
 
 
106
  </Section>
107
 
108
+ <Section title="Brand kit" saved={saved === "brand" || saved === "logo"}>
109
  <p className="muted">Used on videos, quote cards and carousels.</p>
110
  <label className="field">
111
  <span>Name on renders</span>
112
+ <input className="input input-sm" placeholder="Your publication" value={brandName} onChange={(e) => setBrandName(e.target.value)} onBlur={() => saveBrand()} />
113
  </label>
114
  <label className="field">
115
  <span>Accent color</span>
116
  <div className="row">
117
+ <input type="color" value={accent} onChange={(e) => setAccent(e.target.value)} onBlur={() => saveBrand()} aria-label="Accent color" className="color" />
118
+ <input className="input input-sm mono" value={accent} onChange={(e) => setAccent(e.target.value)} onBlur={() => saveBrand()} aria-label="Accent hex" />
119
  </div>
120
  </label>
121
  <div className="field">
122
  <span>Logo</span>
123
  <div className="row">
124
  {s.brand.logo_url && <img src={s.brand.logo_url} alt="Logo" className="logo-preview" />}
125
+ <input
126
+ type="file"
127
+ accept="image/png,image/jpeg,image/webp,image/svg+xml"
128
+ className="input file-input"
129
+ aria-label="Logo file"
130
+ onChange={async (e) => {
131
+ const file = e.target.files?.[0];
132
+ e.target.value = "";
133
  if (!file) return;
134
  try {
135
  const up = await uploadFile(file);
136
  await save({ logo_upload_id: up.upload_id }, "logo");
137
+ } catch (err) {
138
+ setError(errorMessage(err));
139
  }
140
  }}
141
+ />
 
 
142
  {s.brand.logo_url && (
143
  <button className="btn btn-small" onClick={() => save({ logo_upload_id: "" }, "logo")}>
144
  Remove
 
146
  )}
147
  </div>
148
  </div>
 
 
 
 
 
149
  </Section>
150
 
151
  <Section title="Privacy and email">
 
198
  )}
199
  {upgrade.error && !upgrade.modal && <p className="error-text">{upgrade.error}</p>}
200
  <p className="mono muted small">
201
+ {s.plan.sources} sources · {s.plan.indexed_posts.toLocaleString()} indexed posts
202
  </p>
203
  </Section>
204
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  <Section title="Account">
206
  <div className="row">
207
  <button
frontend/src/pages/Sources.tsx CHANGED
@@ -1,80 +1,137 @@
1
  import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
 
2
  import { docsApi, jobsApi, sourcesApi } from "../api/endpoints";
3
  import type { Doc, Job, Source } from "../api/types";
4
  import { Reader } from "../components/Reader";
5
- import { ConfirmButton, EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader, StatusPill, Tabs } from "../components/ui";
6
  import { useJobMap } from "../hooks/useJob";
7
-
8
- type AddMode = "feed" | "url" | "file";
9
 
10
  const PAGE = 50;
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  export function AddSource({ onAdded }: { onAdded: (source: Source, job: Job) => void }) {
13
- const [mode, setMode] = useState<AddMode>("feed");
14
  const [value, setValue] = useState("");
 
15
  const [busy, setBusy] = useState(false);
16
  const [error, setError] = useState<string | null>(null);
17
  const fileRef = useRef<HTMLInputElement>(null);
 
18
 
19
  const submit = async (e: FormEvent) => {
20
  e.preventDefault();
 
21
  setError(null);
22
  setBusy(true);
23
  try {
24
- if (mode === "file") {
25
- const file = fileRef.current?.files?.[0];
26
- if (!file) throw new Error("Choose a file first.");
27
- const res = await sourcesApi.importFile(file);
28
- onAdded(res.source, res.job);
29
- if (fileRef.current) fileRef.current.value = "";
30
- } else {
31
- if (!value.trim()) return;
32
- const res = mode === "feed" ? await sourcesApi.connect(value.trim()) : await sourcesApi.importUrl(value.trim());
33
- onAdded(res.source, res.job);
34
- setValue("");
35
- }
36
  } catch (err) {
37
- setError(errorMessage(err, "Could not add that."));
38
  } finally {
39
  setBusy(false);
40
  }
41
  };
42
 
 
 
 
 
 
 
 
 
 
 
 
43
  return (
44
  <div className="card add-source">
45
- <Tabs<AddMode>
46
- tabs={[
47
- { id: "feed", label: "Connect a feed" },
48
- { id: "url", label: "Import one URL" },
49
- { id: "file", label: "Upload a file" },
50
- ]}
51
- value={mode}
52
- onChange={(m) => {
53
- setMode(m);
54
- setError(null);
55
- }}
56
- />
57
- <p className="muted">
58
- {mode === "feed" && "Any blog, newsletter or site: Substack, Ghost, WordPress, Medium or anything with an RSS feed. We find the feed and pull in your archive."}
59
- {mode === "url" && "One article from anywhere on the web. It lands in Imported posts."}
60
- {mode === "file" && "Markdown, text, HTML or PDF. Handy for drafts and posts that never had a feed."}
61
- </p>
62
- <form onSubmit={submit} className="inline-form">
63
- {mode === "file" ? (
64
- <input ref={fileRef} className="input file-input" type="file" accept=".md,.markdown,.txt,.html,.htm,.pdf,text/markdown,text/plain,text/html,application/pdf" aria-label="File" />
65
- ) : (
66
- <input
67
- className="input"
68
- placeholder={mode === "feed" ? "yourblog.com" : "https://example.com/my-essay"}
69
- value={value}
70
- onChange={(e) => setValue(e.target.value)}
71
- aria-label={mode === "feed" ? "Feed or site URL" : "Article URL"}
72
- />
73
- )}
74
- <button className="btn btn-primary" type="submit" disabled={busy}>
75
- {busy ? (mode === "feed" ? "Finding feed..." : "Starting...") : mode === "feed" ? "Connect" : "Import"}
76
  </button>
 
77
  </form>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  {error && (
79
  <p className="error-text" role="alert">
80
  {error}
@@ -86,6 +143,7 @@ export function AddSource({ onAdded }: { onAdded: (source: Source, job: Job) =>
86
 
87
  export default function Sources() {
88
  const [sources, setSources] = useState<Source[] | null>(null);
 
89
  const [docs, setDocs] = useState<Doc[]>([]);
90
  const [total, setTotal] = useState(0);
91
  const [q, setQ] = useState("");
@@ -113,7 +171,7 @@ export default function Sources() {
113
  });
114
 
115
  useEffect(() => {
116
- loadSources().catch(() => setError("Could not reach the API. Is it running?"));
117
  }, [loadSources]);
118
 
119
  useEffect(() => {
@@ -142,8 +200,29 @@ export default function Sources() {
142
  watch(source.id, job);
143
  };
144
 
 
 
145
  return (
146
- <div className="page-wrap">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  <PageHeader eyebrow="Sources" title="Your knowledge, in orbit" />
148
  <AddSource onAdded={added} />
149
  {error && <p className="error-text">{error}</p>}
@@ -160,6 +239,7 @@ export default function Sources() {
160
  <strong>{s.title ?? s.feed_url}</strong>
161
  <span className="mono muted">
162
  {s.is_imports ? "imports" : s.platform} · {s.document_count} posts
 
163
  {s.last_synced_at ? ` · synced ${formatDate(s.last_synced_at, true)}` : ""}
164
  </span>
165
  </div>
@@ -171,6 +251,20 @@ export default function Sources() {
171
  </a>
172
  )}
173
  {job && (active || job.status === "failed") && <JobProgress job={job} compact />}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  {s.sync_status === "error" && s.sync_error && !active && <p className="error-text">{s.sync_error}</p>}
175
  <footer className="row">
176
  {!s.is_imports && (
@@ -222,6 +316,11 @@ export default function Sources() {
222
  <button className="link-btn doc-title" onClick={() => setReading(d.id)}>
223
  {d.title}
224
  </button>
 
 
 
 
 
225
  <span className="mono muted">
226
  {d.source_title ?? ""} · {formatDate(d.published_at)} · {d.words} words
227
  </span>
 
1
  import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
2
+ import { NavLink } from "react-router-dom";
3
  import { docsApi, jobsApi, sourcesApi } from "../api/endpoints";
4
  import type { Doc, Job, Source } from "../api/types";
5
  import { Reader } from "../components/Reader";
6
+ import { ConfirmButton, EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader, StatusPill } from "../components/ui";
7
  import { useJobMap } from "../hooks/useJob";
8
+ import { useUpgrade } from "../hooks/useUpgrade";
 
9
 
10
  const PAGE = 50;
11
 
12
+ /** Sources and the Topic map share one sidebar entry; this strip switches between them. */
13
+ export function SourcesNav() {
14
+ return (
15
+ <nav className="tabs" aria-label="Sources views">
16
+ <NavLink to="/app/sources" className={({ isActive }) => `tab${isActive ? " active" : ""}`}>
17
+ Posts
18
+ </NavLink>
19
+ <NavLink to="/app/map" className={({ isActive }) => `tab${isActive ? " active" : ""}`}>
20
+ Topic map
21
+ </NavLink>
22
+ </nav>
23
+ );
24
+ }
25
+
26
+ export const FILE_ACCEPT = ".md,.markdown,.txt,.html,.htm,.pdf,text/markdown,text/plain,text/html,application/pdf";
27
+
28
+ /** A bare domain, a Substack or a feed URL is a whole archive; a deep link is one article. */
29
+ export function looksLikeArticle(raw: string): boolean {
30
+ const v = raw.trim();
31
+ if (!v) return false;
32
+ try {
33
+ const u = new URL(v.includes("://") ? v : `https://${v}`);
34
+ const path = u.pathname.replace(/\/+$/, "");
35
+ if (!path || /(feed|rss|atom)(\.xml)?$/i.test(path) || /^\/@[^/]+$/.test(path)) return false;
36
+ const parts = path.split("/").filter(Boolean);
37
+ return /\/p\/|\/posts?\/|\/\d{4}\/|\.html?$/i.test(path) || parts.length >= 2 || path.length > 24;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ /** Imports each file as its own post. Returns how many failed. */
44
+ export async function importFiles(files: File[], onAdded: (source: Source, job: Job) => void): Promise<number> {
45
+ let failed = 0;
46
+ for (const f of files) {
47
+ try {
48
+ const res = await sourcesApi.importFile(f);
49
+ onAdded(res.source, res.job);
50
+ } catch {
51
+ failed += 1;
52
+ }
53
+ }
54
+ return failed;
55
+ }
56
+
57
+ /** One box for everything: paste a link (feed or article is worked out for you) or add files. */
58
  export function AddSource({ onAdded }: { onAdded: (source: Source, job: Job) => void }) {
 
59
  const [value, setValue] = useState("");
60
+ const [override, setOverride] = useState<"feed" | "url" | null>(null);
61
  const [busy, setBusy] = useState(false);
62
  const [error, setError] = useState<string | null>(null);
63
  const fileRef = useRef<HTMLInputElement>(null);
64
+ const mode = override ?? (looksLikeArticle(value) ? "url" : "feed");
65
 
66
  const submit = async (e: FormEvent) => {
67
  e.preventDefault();
68
+ if (!value.trim()) return;
69
  setError(null);
70
  setBusy(true);
71
  try {
72
+ const res = mode === "feed" ? await sourcesApi.connect(value.trim()) : await sourcesApi.importUrl(value.trim());
73
+ onAdded(res.source, res.job);
74
+ setValue("");
75
+ setOverride(null);
 
 
 
 
 
 
 
 
76
  } catch (err) {
77
+ setError(errorMessage(err, mode === "feed" ? "We could not find a feed there." : "We could not import that page."));
78
  } finally {
79
  setBusy(false);
80
  }
81
  };
82
 
83
+ const upload = async (files: FileList | null) => {
84
+ const list = Array.from(files ?? []);
85
+ if (!list.length) return;
86
+ setError(null);
87
+ setBusy(true);
88
+ const failed = await importFiles(list, onAdded);
89
+ if (failed) setError(`${failed} of ${list.length} files could not be uploaded.`);
90
+ setBusy(false);
91
+ if (fileRef.current) fileRef.current.value = "";
92
+ };
93
+
94
  return (
95
  <div className="card add-source">
96
+ <form onSubmit={submit} className="add-smart">
97
+ <input
98
+ className="input"
99
+ placeholder="Paste a blog, newsletter or article link"
100
+ value={value}
101
+ onChange={(e) => {
102
+ setValue(e.target.value);
103
+ setOverride(null);
104
+ }}
105
+ aria-label="Blog, newsletter or article link"
106
+ />
107
+ <button className="btn btn-primary" type="submit" disabled={busy || !value.trim()}>
108
+ {busy ? "Working..." : mode === "feed" ? "Connect" : "Import article"}
109
+ </button>
110
+ <span className="muted small">or</span>
111
+ <button type="button" className="btn" disabled={busy} onClick={() => fileRef.current?.click()}>
112
+ Upload files
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  </button>
114
+ <input ref={fileRef} type="file" multiple accept={FILE_ACCEPT} hidden onChange={(e) => upload(e.target.files)} aria-label="Files to upload" />
115
  </form>
116
+ <p className="muted small">
117
+ {!value.trim() && "Substack, Ghost, WordPress, Medium or any site with a feed. Markdown, text, HTML and PDF files work too; drop them anywhere on this page."}
118
+ {value.trim() && mode === "feed" && (
119
+ <>
120
+ We will pull in the whole archive and keep it in sync.{" "}
121
+ <button type="button" className="link-btn small" onClick={() => setOverride("url")}>
122
+ Just this one page instead
123
+ </button>
124
+ </>
125
+ )}
126
+ {value.trim() && mode === "url" && (
127
+ <>
128
+ Looks like a single article, so we will import just this page.{" "}
129
+ <button type="button" className="link-btn small" onClick={() => setOverride("feed")}>
130
+ Connect the whole site instead
131
+ </button>
132
+ </>
133
+ )}
134
+ </p>
135
  {error && (
136
  <p className="error-text" role="alert">
137
  {error}
 
143
 
144
  export default function Sources() {
145
  const [sources, setSources] = useState<Source[] | null>(null);
146
+ const { openUpgrade } = useUpgrade();
147
  const [docs, setDocs] = useState<Doc[]>([]);
148
  const [total, setTotal] = useState(0);
149
  const [q, setQ] = useState("");
 
171
  });
172
 
173
  useEffect(() => {
174
+ loadSources().catch(() => setError("Could not load your sources. Refresh to try again."));
175
  }, [loadSources]);
176
 
177
  useEffect(() => {
 
200
  watch(source.id, job);
201
  };
202
 
203
+ const [dropping, setDropping] = useState(false);
204
+
205
  return (
206
+ <div
207
+ className={`page-wrap${dropping ? " drop-over" : ""}`}
208
+ onDragOver={(e) => {
209
+ if (!e.dataTransfer.types.includes("Files")) return;
210
+ e.preventDefault();
211
+ setDropping(true);
212
+ }}
213
+ onDragLeave={(e) => {
214
+ if (e.currentTarget === e.target) setDropping(false);
215
+ }}
216
+ onDrop={async (e) => {
217
+ if (!e.dataTransfer.files.length) return;
218
+ e.preventDefault();
219
+ setDropping(false);
220
+ const files = Array.from(e.dataTransfer.files);
221
+ const failed = await importFiles(files, added);
222
+ setError(failed ? `${failed} of ${files.length} files could not be uploaded.` : null);
223
+ }}
224
+ >
225
+ <SourcesNav />
226
  <PageHeader eyebrow="Sources" title="Your knowledge, in orbit" />
227
  <AddSource onAdded={added} />
228
  {error && <p className="error-text">{error}</p>}
 
239
  <strong>{s.title ?? s.feed_url}</strong>
240
  <span className="mono muted">
241
  {s.is_imports ? "imports" : s.platform} · {s.document_count} posts
242
+ {s.locked_count > 0 ? ` found, ${s.document_count - s.locked_count} indexed` : ""}
243
  {s.last_synced_at ? ` · synced ${formatDate(s.last_synced_at, true)}` : ""}
244
  </span>
245
  </div>
 
251
  </a>
252
  )}
253
  {job && (active || job.status === "failed") && <JobProgress job={job} compact />}
254
+ {s.locked_count > 0 && !active && (
255
+ <div className="welcome-cap locked-cap">
256
+ <div>
257
+ <strong>{s.locked_count} posts not indexed</strong>
258
+ <p className="muted small">
259
+ Your plan only indexes your latest posts. Upgrade to index the rest so answers, audio and Launch Kits
260
+ can use your whole archive.
261
+ </p>
262
+ </div>
263
+ <button type="button" className="btn btn-small btn-primary" onClick={() => openUpgrade("indexed_posts")}>
264
+ Index all {s.document_count}
265
+ </button>
266
+ </div>
267
+ )}
268
  {s.sync_status === "error" && s.sync_error && !active && <p className="error-text">{s.sync_error}</p>}
269
  <footer className="row">
270
  {!s.is_imports && (
 
316
  <button className="link-btn doc-title" onClick={() => setReading(d.id)}>
317
  {d.title}
318
  </button>
319
+ {d.locked && (
320
+ <span className="mono muted small" title="Upgrade to index this post">
321
+ Not indexed
322
+ </span>
323
+ )}
324
  <span className="mono muted">
325
  {d.source_title ?? ""} · {formatDate(d.published_at)} · {d.words} words
326
  </span>
frontend/src/pages/Studio.tsx CHANGED
@@ -1,61 +1,54 @@
1
  import { useCallback, useEffect, useState } from "react";
 
2
  import { artifactsApi, notebooksApi, type GenerateBody } from "../api/endpoints";
3
  import type { Artifact, NotebookSummary } from "../api/types";
4
  import { ArtifactCard } from "../components/ArtifactCard";
5
  import { DocPicker } from "../components/DocPicker";
 
6
  import { Reader } from "../components/Reader";
7
- import { EmptyState, errorMessage, Loading, PageHeader, Tabs } from "../components/ui";
8
 
9
- type Kind = "audio" | "short" | "explainer" | "audiogram" | "quote";
10
 
11
- const KINDS: { id: Kind; label: string; blurb: string }[] = [
12
- { id: "audio", label: "Audio overview", blurb: "Two hosts talk through your posts, every line grounded in what you wrote." },
13
- { id: "short", label: "Short 9:16", blurb: "A vertical video with a hook in the first two seconds, narration and captions." },
14
- { id: "explainer", label: "Explainer 16:9", blurb: "A title card and narrated scenes for YouTube or your post." },
15
- { id: "audiogram", label: "Audiogram 1:1", blurb: "A square waveform video of an audio overview, with the live transcript." },
16
- { id: "quote", label: "Quote card", blurb: "Your most quotable line, verified against the post, as a 1080 square." },
17
  ];
18
 
 
 
 
 
19
  export default function Studio() {
20
  const [kind, setKind] = useState<Kind>("audio");
21
- const [target, setTarget] = useState<"notebook" | "post">("notebook");
22
  const [notebooks, setNotebooks] = useState<NotebookSummary[]>([]);
23
- const [notebookId, setNotebookId] = useState("");
24
  const [postIds, setPostIds] = useState<string[]>([]);
25
  const [format, setFormat] = useState<"deep_dive" | "brief" | "debate">("deep_dive");
26
  const [minutes, setMinutes] = useState(6);
27
- const [audioId, setAudioId] = useState("");
28
- const [gallery, setGallery] = useState<Artifact[] | null>(null);
29
- const [filter, setFilter] = useState<"all" | "audio_overview" | "video" | "quote_card,carousel">("all");
30
  const [busy, setBusy] = useState(false);
31
  const [error, setError] = useState<string | null>(null);
32
  const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null);
33
 
34
  const load = useCallback(
35
- () =>
36
- artifactsApi
37
- .list({ type: filter === "all" ? "audio_overview,video,quote_card,carousel" : filter, limit: 60 })
38
- .then((p) => setGallery(p.items)),
39
- [filter],
40
  );
41
 
42
  useEffect(() => {
43
- notebooksApi.list().then((n) => {
44
- setNotebooks(n);
45
- setNotebookId((cur) => cur || n[0]?.id || "");
46
- });
47
- }, []);
48
- useEffect(() => {
49
  load();
50
  }, [load]);
51
 
52
- const audios = (gallery ?? []).filter((a) => a.type === "audio_overview" && a.status === "ready");
53
-
54
  const create = async () => {
55
  setError(null);
56
- const where: Partial<GenerateBody> = target === "notebook" ? { notebook_id: notebookId } : { document_id: postIds[0] };
57
- if (kind !== "audiogram" && !where.notebook_id && !where.document_id) {
58
- setError(target === "notebook" ? "Pick a notebook." : "Pick a post.");
 
59
  return;
60
  }
61
  const body: GenerateBody =
@@ -63,17 +56,11 @@ export default function Studio() {
63
  ? { type: "audio_overview", format, minutes, ...where }
64
  : kind === "quote"
65
  ? { type: "quote_card", ...where }
66
- : kind === "audiogram"
67
- ? { type: "video", style: "audiogram", audio_artifact_id: audioId }
68
- : { type: "video", style: kind, ...where };
69
- if (kind === "audiogram" && !audioId) {
70
- setError("Pick an audio overview first.");
71
- return;
72
- }
73
  setBusy(true);
74
  try {
75
  const a = await artifactsApi.generate(body);
76
- setGallery((g) => [a, ...(g ?? [])]);
77
  } catch (e) {
78
  setError(errorMessage(e));
79
  } finally {
@@ -85,108 +72,109 @@ export default function Studio() {
85
 
86
  return (
87
  <div className="page-wrap">
88
- <PageHeader eyebrow="Video and audio" title="Studio" />
89
  <section className="card stack studio-maker">
90
- <Tabs<Kind> tabs={KINDS.map((k) => ({ id: k.id, label: k.label }))} value={kind} onChange={setKind} />
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  <p className="muted">{meta.blurb}</p>
92
- {kind === "audiogram" ? (
 
93
  <label className="field">
94
- <span>Audio overview</span>
95
- <select className="input input-sm" value={audioId} onChange={(e) => setAudioId(e.target.value)}>
96
- <option value="">Pick one</option>
97
- {audios.map((a) => (
98
- <option key={a.id} value={a.id}>
99
- {a.title}
100
- </option>
101
- ))}
102
- </select>
103
- {audios.length === 0 && <span className="muted small">Make an audio overview first.</span>}
104
- </label>
105
- ) : (
106
- <>
107
- <div className="row">
108
- <label className="check">
109
- <input type="radio" checked={target === "notebook"} onChange={() => setTarget("notebook")} /> From a notebook
110
- </label>
111
- <label className="check">
112
- <input type="radio" checked={target === "post"} onChange={() => setTarget("post")} /> From one post
113
- </label>
114
- </div>
115
- {target === "notebook" ? (
116
- notebooks.length ? (
117
- <select className="input input-sm" value={notebookId} onChange={(e) => setNotebookId(e.target.value)} aria-label="Notebook">
118
  {notebooks.map((n) => (
119
  <option key={n.id} value={n.id}>
120
  {n.title} ({n.document_count} posts)
121
  </option>
122
  ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  </select>
124
- ) : (
125
- <p className="muted">No notebooks yet. Switch to one post, or create a notebook first.</p>
126
- )
127
- ) : (
128
- <DocPicker selected={postIds} onChange={setPostIds} single />
129
- )}
130
- </>
131
- )}
132
- {kind === "audio" && (
133
- <div className="row">
134
- <label className="field">
135
- <span>Format</span>
136
- <select className="input input-sm" value={format} onChange={(e) => setFormat(e.target.value as typeof format)}>
137
- <option value="deep_dive">Deep dive</option>
138
- <option value="brief">Brief</option>
139
- <option value="debate">Debate</option>
140
- </select>
141
- </label>
142
- <label className="field">
143
- <span>Length</span>
144
- <select className="input input-sm" value={minutes} onChange={(e) => setMinutes(Number(e.target.value))}>
145
- {[3, 6, 10, 15, 20].map((m) => (
146
- <option key={m} value={m}>
147
- {m} minutes
148
- </option>
149
- ))}
150
- </select>
151
- </label>
152
- </div>
153
- )}
154
- {error && <p className="error-text">{error}</p>}
155
- <div className="row end">
156
- <button className="btn btn-primary" onClick={create} disabled={busy}>
157
  {busy ? "Launching..." : `Create ${meta.label.toLowerCase()}`}
158
  </button>
159
  </div>
 
 
160
  </section>
161
 
162
- <section className="stack">
163
- <div className="row between">
164
- <h2>Renders</h2>
165
- <Tabs
166
- tabs={[
167
- { id: "all", label: "All" },
168
- { id: "audio_overview", label: "Audio" },
169
- { id: "video", label: "Video" },
170
- { id: "quote_card,carousel", label: "Stills" },
171
- ]}
172
- value={filter}
173
- onChange={setFilter}
174
- />
175
- </div>
176
- {!gallery && <Loading />}
177
- {gallery?.length === 0 && <EmptyState title="Nothing rendered yet" body="Your audio overviews, videos and cards will land here." />}
178
- <div className="gallery">
179
- {gallery?.map((a) => (
180
- <ArtifactCard
181
- key={a.id}
182
- artifact={a}
183
- onCite={(c) => c.document_id && setReading({ id: c.document_id, start: c.line_start, end: c.line_end })}
184
- onRemoved={(id) => setGallery((g) => (g ?? []).filter((x) => x.id !== id))}
185
- onChanged={(next) => setGallery((g) => (g ?? []).map((x) => (x.id === next.id ? next : x)))}
186
- />
187
- ))}
188
- </div>
189
- </section>
 
 
 
 
 
 
 
 
 
 
190
  {reading && <Reader documentId={reading.id} highlight={{ start: reading.start, end: reading.end }} onClose={() => setReading(null)} />}
191
  </div>
192
  );
 
1
  import { useCallback, useEffect, useState } from "react";
2
+ import { Link } from "react-router-dom";
3
  import { artifactsApi, notebooksApi, type GenerateBody } from "../api/endpoints";
4
  import type { Artifact, NotebookSummary } from "../api/types";
5
  import { ArtifactCard } from "../components/ArtifactCard";
6
  import { DocPicker } from "../components/DocPicker";
7
+ import { LaunchWindowIcon, PlanetIcon, RocketIcon, SparkleIcon } from "../components/icons/Icons";
8
  import { Reader } from "../components/Reader";
9
+ import { errorMessage, PageHeader } from "../components/ui";
10
 
11
+ type Kind = "audio" | "short" | "explainer" | "quote";
12
 
13
+ const KINDS: { id: Kind; label: string; blurb: string; icon: typeof PlanetIcon }[] = [
14
+ { id: "audio", label: "Audio overview", blurb: "Two hosts talk through your posts, every line grounded in what you wrote.", icon: PlanetIcon },
15
+ { id: "short", label: "Short video", blurb: "Vertical 9:16 with a hook in the first two seconds, narration and captions.", icon: LaunchWindowIcon },
16
+ { id: "explainer", label: "Explainer", blurb: "16:9 title card and narrated scenes for YouTube or your post.", icon: SparkleIcon },
17
+ { id: "quote", label: "Quote card", blurb: "Your most quotable line, verified against the post, as a 1080 square.", icon: RocketIcon },
 
18
  ];
19
 
20
+ const ARCHIVE = "archive";
21
+ const ONE_POST = "post";
22
+
23
+ /** Create: pick a format, say what it is about (the whole archive by default), press Create. */
24
  export default function Studio() {
25
  const [kind, setKind] = useState<Kind>("audio");
26
+ const [about, setAbout] = useState(ARCHIVE);
27
  const [notebooks, setNotebooks] = useState<NotebookSummary[]>([]);
 
28
  const [postIds, setPostIds] = useState<string[]>([]);
29
  const [format, setFormat] = useState<"deep_dive" | "brief" | "debate">("deep_dive");
30
  const [minutes, setMinutes] = useState(6);
31
+ const [recent, setRecent] = useState<Artifact[] | null>(null);
 
 
32
  const [busy, setBusy] = useState(false);
33
  const [error, setError] = useState<string | null>(null);
34
  const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null);
35
 
36
  const load = useCallback(
37
+ () => artifactsApi.list({ type: "audio_overview,video,quote_card", limit: 6 }).then((p) => setRecent(p.items)),
38
+ [],
 
 
 
39
  );
40
 
41
  useEffect(() => {
42
+ notebooksApi.list().then((n) => setNotebooks(n.filter((x) => !x.is_archive)));
 
 
 
 
 
43
  load();
44
  }, [load]);
45
 
 
 
46
  const create = async () => {
47
  setError(null);
48
+ const where: Partial<GenerateBody> =
49
+ about === ARCHIVE ? { archive: true } : about === ONE_POST ? { document_id: postIds[0] } : { notebook_id: about };
50
+ if (about === ONE_POST && !postIds[0]) {
51
+ setError("Pick a post.");
52
  return;
53
  }
54
  const body: GenerateBody =
 
56
  ? { type: "audio_overview", format, minutes, ...where }
57
  : kind === "quote"
58
  ? { type: "quote_card", ...where }
59
+ : { type: "video", style: kind, ...where };
 
 
 
 
 
 
60
  setBusy(true);
61
  try {
62
  const a = await artifactsApi.generate(body);
63
+ setRecent((g) => [a, ...(g ?? [])].slice(0, 6));
64
  } catch (e) {
65
  setError(errorMessage(e));
66
  } finally {
 
72
 
73
  return (
74
  <div className="page-wrap">
75
+ <PageHeader eyebrow="Create" title="Make something from your writing" />
76
  <section className="card stack studio-maker">
77
+ <div className="format-grid">
78
+ {KINDS.map((k) => (
79
+ <button key={k.id} type="button" className={`format-tile${kind === k.id ? " on" : ""}`} aria-pressed={kind === k.id} onClick={() => setKind(k.id)}>
80
+ <k.icon size={30} />
81
+ <strong>{k.label}</strong>
82
+ </button>
83
+ ))}
84
+ <Link to="/app/launch-kit" className="format-tile">
85
+ <span className="mc-more" aria-hidden="true">
86
+ +
87
+ </span>
88
+ <strong>Launch Kit</strong>
89
+ </Link>
90
+ </div>
91
  <p className="muted">{meta.blurb}</p>
92
+
93
+ <div className="studio-row">
94
  <label className="field">
95
+ <span>What is it about?</span>
96
+ <select className="input" value={about} onChange={(e) => setAbout(e.target.value)}>
97
+ <option value={ARCHIVE}>My whole archive</option>
98
+ {notebooks.length > 0 && (
99
+ <optgroup label="A notebook">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  {notebooks.map((n) => (
101
  <option key={n.id} value={n.id}>
102
  {n.title} ({n.document_count} posts)
103
  </option>
104
  ))}
105
+ </optgroup>
106
+ )}
107
+ <option value={ONE_POST}>One post...</option>
108
+ </select>
109
+ </label>
110
+ {kind === "audio" && (
111
+ <>
112
+ <label className="field">
113
+ <span>Format</span>
114
+ <select className="input" value={format} onChange={(e) => setFormat(e.target.value as typeof format)}>
115
+ <option value="deep_dive">Deep dive</option>
116
+ <option value="brief">Brief</option>
117
+ <option value="debate">Debate</option>
118
+ </select>
119
+ </label>
120
+ <label className="field">
121
+ <span>Length</span>
122
+ <select className="input" value={minutes} onChange={(e) => setMinutes(Number(e.target.value))}>
123
+ {[3, 6, 10, 15, 20].map((m) => (
124
+ <option key={m} value={m}>
125
+ {m} minutes
126
+ </option>
127
+ ))}
128
  </select>
129
+ </label>
130
+ </>
131
+ )}
132
+ <button className="btn btn-primary studio-go" onClick={create} disabled={busy}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  {busy ? "Launching..." : `Create ${meta.label.toLowerCase()}`}
134
  </button>
135
  </div>
136
+ {about === ONE_POST && <DocPicker selected={postIds} onChange={setPostIds} single />}
137
+ {error && <p className="error-text">{error}</p>}
138
  </section>
139
 
140
+ {recent && recent.length > 0 && (
141
+ <section className="stack">
142
+ <div className="row between">
143
+ <h2>Latest</h2>
144
+ <Link to="/app/archive" className="mono muted small-link">
145
+ Everything in Library
146
+ </Link>
147
+ </div>
148
+ <div className="gallery">
149
+ {recent.map((a) => (
150
+ <ArtifactCard
151
+ key={a.id}
152
+ artifact={a}
153
+ onCite={(c) => c.document_id && setReading({ id: c.document_id, start: c.line_start, end: c.line_end })}
154
+ onRemoved={(id) => setRecent((g) => (g ?? []).filter((x) => x.id !== id))}
155
+ onChanged={(next) => setRecent((g) => (g ?? []).map((x) => (x.id === next.id ? next : x)))}
156
+ actions={(art) =>
157
+ art.type === "audio_overview" && art.status === "ready" ? (
158
+ <button
159
+ className="btn btn-small"
160
+ onClick={async () => {
161
+ try {
162
+ const v = await artifactsApi.generate({ type: "video", style: "audiogram", audio_artifact_id: art.id });
163
+ setRecent((g) => [v, ...(g ?? [])].slice(0, 6));
164
+ } catch (e) {
165
+ setError(errorMessage(e));
166
+ }
167
+ }}
168
+ >
169
+ Make audiogram
170
+ </button>
171
+ ) : null
172
+ }
173
+ />
174
+ ))}
175
+ </div>
176
+ </section>
177
+ )}
178
  {reading && <Reader documentId={reading.id} highlight={{ start: reading.start, end: reading.end }} onClose={() => setReading(null)} />}
179
  </div>
180
  );
frontend/src/pages/Tools.tsx ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMemo, useState } from "react";
2
+ import { Link, useParams } from "react-router-dom";
3
+ import SkyCanvas from "../components/SkyCanvas";
4
+ import { PublicFooter, PublicNav } from "../components/PublicChrome";
5
+ import { getTool, tools, toolsHub, type ToolDef } from "../content/tools";
6
+ import { useAuth } from "../hooks/useAuth";
7
+ import { buildPodcastScript, scriptToText } from "../lib/podcastScript";
8
+ import { Inline, useMeta } from "./Blog";
9
+ import "../styles/blog.css";
10
+ import "../styles/tools.css";
11
+
12
+ const pathTitle = (path: string) => {
13
+ const tool = tools.find((t) => `/tools/${t.slug}` === path);
14
+ if (tool) return tool.heroTitle;
15
+ if (path === "/notebooklm-alternative") return "Notestack vs NotebookLM";
16
+ return path.replace(/^\/blogs\//, "").replace(/-/g, " ").replace(/^./, (c) => c.toUpperCase());
17
+ };
18
+
19
+ export function ToolsHub() {
20
+ useMeta(toolsHub.metaTitle, toolsHub.description);
21
+ return (
22
+ <div className="page">
23
+ <SkyCanvas intensity={0.7} travel />
24
+ <PublicNav />
25
+ <main className="container post mission alt-page">
26
+ <header className="mission-head">
27
+ <p className="eyebrow">Free tools</p>
28
+ <h1 className="post-title">{toolsHub.heroTitle}</h1>
29
+ <p className="post-lede muted">{toolsHub.heroDescription}</p>
30
+ </header>
31
+ <div className="alt-grid">
32
+ {tools.map((t) => (
33
+ <Link key={t.slug} to={`/tools/${t.slug}`} className="card alt-sister">
34
+ <h2 className="tool-card-title">{t.heroTitle}</h2>
35
+ <p className="muted">{t.description}</p>
36
+ </Link>
37
+ ))}
38
+ </div>
39
+ </main>
40
+ <PublicFooter />
41
+ </div>
42
+ );
43
+ }
44
+
45
+ /** Every free tool requires sign in: signed out visitors see the pitch and a sign in link that returns them here. */
46
+ function SignInGate({ tool }: { tool: ToolDef }) {
47
+ const next = encodeURIComponent(`/tools/${tool.slug}`);
48
+ return (
49
+ <div className="card tool-gate">
50
+ <h2>Sign in to use the {tool.eyebrow.toLowerCase()}</h2>
51
+ <p className="muted">It is free. Create an account in seconds and come straight back to this tool.</p>
52
+ <div className="alt-ctas">
53
+ <Link to={`/auth?mode=signup&next=${next}`} className="btn btn-primary">
54
+ Sign up free
55
+ </Link>
56
+ <Link to={`/auth?next=${next}`} className="btn">
57
+ Sign in
58
+ </Link>
59
+ </div>
60
+ </div>
61
+ );
62
+ }
63
+
64
+ function PodcastScriptWidget({ tool }: { tool: ToolDef }) {
65
+ const [text, setText] = useState("");
66
+ const [copied, setCopied] = useState(false);
67
+ const script = useMemo(() => buildPodcastScript(text), [text]);
68
+ const inputWords = text.trim() ? text.trim().split(/\s+/).length : 0;
69
+
70
+ const copy = async () => {
71
+ if (!script) return;
72
+ await navigator.clipboard.writeText(scriptToText(script));
73
+ setCopied(true);
74
+ setTimeout(() => setCopied(false), 1500);
75
+ };
76
+ const download = () => {
77
+ if (!script) return;
78
+ const url = URL.createObjectURL(new Blob([scriptToText(script)], { type: "text/plain" }));
79
+ const a = document.createElement("a");
80
+ a.href = url;
81
+ a.download = `${tool.slug}-script.txt`;
82
+ a.click();
83
+ URL.revokeObjectURL(url);
84
+ };
85
+
86
+ return (
87
+ <div className="tool-widget">
88
+ <label className="field">
89
+ <span>
90
+ {tool.inputLabel} <span className="muted mono small">{inputWords} words</span>
91
+ </span>
92
+ <textarea
93
+ className="input tool-input"
94
+ value={text}
95
+ onChange={(e) => setText(e.target.value)}
96
+ placeholder={tool.inputPlaceholder}
97
+ rows={10}
98
+ />
99
+ </label>
100
+ {tool.inputHint && (
101
+ <p className="muted small">
102
+ <Inline text={tool.inputHint} />
103
+ </p>
104
+ )}
105
+ {text.trim() && !script && <p className="muted">Paste a little more: the script needs at least three full sentences.</p>}
106
+ {script && (
107
+ <div className="card tool-output">
108
+ <div className="tool-output-head">
109
+ <span className="mono small muted">
110
+ {script.lines.length} lines · about {Math.max(1, Math.round(script.minutes))} min · built from {script.sourceSentences} of your
111
+ sentences
112
+ </span>
113
+ <span className="tool-actions">
114
+ <button type="button" className="btn" onClick={copy}>
115
+ {copied ? "Copied" : "Copy"}
116
+ </button>
117
+ <button type="button" className="btn" onClick={download}>
118
+ Download
119
+ </button>
120
+ </span>
121
+ </div>
122
+ <ol className="tool-script">
123
+ {script.lines.map((l, i) => (
124
+ <li key={i} className={`host-${l.host}`}>
125
+ <span className="mono host-label">Host {l.host}</span>
126
+ <p>{l.text}</p>
127
+ </li>
128
+ ))}
129
+ </ol>
130
+ <div className="tool-next">
131
+ <p className="muted">Hear it with two natural voices, grounded in your whole archive.</p>
132
+ <Link to="/app/studio" className="btn btn-primary">
133
+ Voice it in Studio
134
+ </Link>
135
+ </div>
136
+ </div>
137
+ )}
138
+ </div>
139
+ );
140
+ }
141
+
142
+ export function ToolPage() {
143
+ const { slug = "" } = useParams();
144
+ const tool = getTool(slug);
145
+ const { user, loading } = useAuth();
146
+ useMeta(tool?.metaTitle ?? "Not found | Notestack", tool?.description ?? "");
147
+
148
+ if (!tool) {
149
+ return (
150
+ <div className="page">
151
+ <PublicNav />
152
+ <main className="container section">
153
+ <h1 className="section-title">Tool not found</h1>
154
+ <Link to="/tools">See all free tools</Link>
155
+ </main>
156
+ </div>
157
+ );
158
+ }
159
+
160
+ const faqLd = {
161
+ "@context": "https://schema.org",
162
+ "@type": "FAQPage",
163
+ mainEntity: tool.faq.map((f) => ({ "@type": "Question", name: f.question, acceptedAnswer: { "@type": "Answer", text: f.answer } })),
164
+ };
165
+
166
+ return (
167
+ <div className="page">
168
+ <SkyCanvas intensity={0.7} travel />
169
+ <PublicNav />
170
+ <main className="container post mission alt-page">
171
+ <script type="application/ld+json">{JSON.stringify(faqLd)}</script>
172
+ <header className="mission-head">
173
+ <p className="eyebrow">{tool.eyebrow}</p>
174
+ <h1 className="post-title">{tool.heroTitle}</h1>
175
+ <p className="post-lede muted">{tool.heroDescription}</p>
176
+ </header>
177
+
178
+ <section className="alt-section">{loading ? null : user ? <PodcastScriptWidget tool={tool} /> : <SignInGate tool={tool} />}</section>
179
+
180
+ {tool.sections.map((s) => (
181
+ <section key={s.heading} className="alt-section">
182
+ <h2>{s.heading}</h2>
183
+ {s.paragraphs.map((p, i) => (
184
+ <p key={i} className="muted">
185
+ <Inline text={p} />
186
+ </p>
187
+ ))}
188
+ {s.bullets && (
189
+ <ul>
190
+ {s.bullets.map((b) => (
191
+ <li key={b}>{b}</li>
192
+ ))}
193
+ </ul>
194
+ )}
195
+ </section>
196
+ ))}
197
+
198
+ <section className="alt-section debrief">
199
+ <h2>Questions</h2>
200
+ {tool.faq.map((f) => (
201
+ <details key={f.question} className="faq">
202
+ <summary>{f.question}</summary>
203
+ <p className="muted">{f.answer}</p>
204
+ </details>
205
+ ))}
206
+ </section>
207
+
208
+ <nav className="alt-section" aria-label="Related">
209
+ <h2>Keep exploring</h2>
210
+ <ul>
211
+ {tool.relatedPaths.map((p) => (
212
+ <li key={p}>
213
+ <Link to={p}>{pathTitle(p)}</Link>
214
+ </li>
215
+ ))}
216
+ </ul>
217
+ </nav>
218
+ </main>
219
+ <PublicFooter />
220
+ </div>
221
+ );
222
+ }
frontend/src/pages/TopicMap.tsx CHANGED
@@ -9,7 +9,8 @@ import {
9
  type RefObject,
10
  } from "react";
11
  import { Link, useNavigate } from "react-router-dom";
12
- import { notebooksApi, topicsApi } from "../api/endpoints";
 
13
  import type { Job, TopicDetail, TopicMap as TopicMapData, TopicNode } from "../api/types";
14
  import { Reader } from "../components/Reader";
15
  import { EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader, Tabs } from "../components/ui";
@@ -299,6 +300,9 @@ export default function TopicMap() {
299
  const [data, setData] = useState<TopicMapData | null>(null);
300
  const [job, setJob] = useState<Job | null>(null);
301
  const [selected, setSelected] = useState<TopicDetail | null>(null);
 
 
 
302
  const [hover, setHover] = useState<string | null>(null);
303
  const [mode, setMode] = useState<ColorMode>("recency");
304
  const [reading, setReading] = useState<string | null>(null);
@@ -458,6 +462,7 @@ export default function TopicMap() {
458
 
459
  return (
460
  <div className="page-wrap">
 
461
  <PageHeader eyebrow="Topic map" title="The constellations in your archive">
462
  <Tabs<ColorMode>
463
  tabs={[
@@ -467,12 +472,35 @@ export default function TopicMap() {
467
  value={mode}
468
  onChange={setMode}
469
  />
470
- <button className="btn btn-small" disabled={Boolean(running)} onClick={() => rebuild(false)}>
471
- {data?.has_untagged_posts ? "Map new posts" : "Refresh"}
472
- </button>
473
- <button className="btn btn-small" disabled={Boolean(running)} onClick={() => rebuild(true)}>
474
- Rebuild
475
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
  </PageHeader>
477
  {live && (running || live.status === "failed") && <JobProgress job={live} />}
478
  {error && <p className="error-text">{error}</p>}
@@ -489,7 +517,7 @@ export default function TopicMap() {
489
  />
490
  )}
491
  {data && data.nodes.length > 0 && (
492
- <div className="map-layout">
493
  <div className="card map-canvas">
494
  <div className="map-controls">
495
  <button className="icon-btn" onClick={() => zoomAt(0.75)} aria-label="Zoom in" title="Zoom in">
@@ -524,7 +552,8 @@ export default function TopicMap() {
524
  )}
525
  </div>
526
 
527
- <div className="map-legend mono" aria-label="Legend">
 
528
  <span>
529
  <i className="lg-size" /> size = posts
530
  </span>
@@ -545,7 +574,7 @@ export default function TopicMap() {
545
  <span>
546
  <i className="lg-line" /> lines = shared posts
547
  </span>
548
- </div>
549
 
550
  <svg
551
  ref={svgRef}
@@ -713,6 +742,7 @@ export default function TopicMap() {
713
  </svg>
714
  </div>
715
 
 
716
  <aside className="card map-side">
717
  {!selected && (
718
  <>
@@ -771,7 +801,7 @@ export default function TopicMap() {
771
  {data.insights.dormant.length === 0 && <li className="muted small">Every topic has been visited lately.</li>}
772
  </ul>
773
  {data.insights.dormant.length > 0 && (
774
- <Link to="/app/resurface" className="small-link mono">
775
  Resurface old posts
776
  </Link>
777
  )}
@@ -802,7 +832,7 @@ export default function TopicMap() {
802
  {selected && (
803
  <>
804
  <button className="link-btn mono muted small" onClick={() => setSelected(null)}>
805
- Back to briefing
806
  </button>
807
  <div className="row between">
808
  <h2>{selected.name}</h2>
@@ -851,15 +881,39 @@ export default function TopicMap() {
851
  </div>
852
  )}
853
 
854
- <button
855
- className="btn btn-small btn-primary"
856
- onClick={async () => {
857
- const nb = await notebooksApi.fromTopic(selected.id);
858
- navigate(`/app/notebooks/${nb.id}`);
859
- }}
860
- >
861
- Make a notebook from this topic
862
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
863
  <ul className="doc-list">
864
  {selected.posts.map((d) => (
865
  <li key={d.id} className="doc-row">
@@ -873,6 +927,7 @@ export default function TopicMap() {
873
  </>
874
  )}
875
  </aside>
 
876
  </div>
877
  )}
878
  {reading && <Reader documentId={reading} onClose={() => setReading(null)} />}
 
9
  type RefObject,
10
  } from "react";
11
  import { Link, useNavigate } from "react-router-dom";
12
+ import { artifactsApi, notebooksApi, topicsApi } from "../api/endpoints";
13
+ import { SourcesNav } from "./Sources";
14
  import type { Job, TopicDetail, TopicMap as TopicMapData, TopicNode } from "../api/types";
15
  import { Reader } from "../components/Reader";
16
  import { EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader, Tabs } from "../components/ui";
 
300
  const [data, setData] = useState<TopicMapData | null>(null);
301
  const [job, setJob] = useState<Job | null>(null);
302
  const [selected, setSelected] = useState<TopicDetail | null>(null);
303
+ const [briefing, setBriefing] = useState(false);
304
+ const [menu, setMenu] = useState(false);
305
+ const [acting, setActing] = useState<string | null>(null);
306
  const [hover, setHover] = useState<string | null>(null);
307
  const [mode, setMode] = useState<ColorMode>("recency");
308
  const [reading, setReading] = useState<string | null>(null);
 
462
 
463
  return (
464
  <div className="page-wrap">
465
+ <SourcesNav />
466
  <PageHeader eyebrow="Topic map" title="The constellations in your archive">
467
  <Tabs<ColorMode>
468
  tabs={[
 
472
  value={mode}
473
  onChange={setMode}
474
  />
475
+ {data && data.nodes.length > 0 && (
476
+ <button className={`btn btn-small${briefing ? " btn-primary" : ""}`} onClick={() => setBriefing(!briefing)} aria-pressed={briefing}>
477
+ Briefing
478
+ </button>
479
+ )}
480
+ {data?.has_untagged_posts && (
481
+ <button className="btn btn-small" disabled={Boolean(running)} onClick={() => rebuild(false)}>
482
+ Map new posts
483
+ </button>
484
+ )}
485
+ <div className="card-menu map-menu">
486
+ <button type="button" className="icon-btn" aria-label="Map options" aria-expanded={menu} onClick={() => setMenu(!menu)}>
487
+ ⋯
488
+ </button>
489
+ {menu && (
490
+ <div className="card-menu-list" onMouseLeave={() => setMenu(false)}>
491
+ <button
492
+ type="button"
493
+ disabled={Boolean(running)}
494
+ onClick={() => {
495
+ setMenu(false);
496
+ rebuild(true);
497
+ }}
498
+ >
499
+ Rebuild the whole map
500
+ </button>
501
+ </div>
502
+ )}
503
+ </div>
504
  </PageHeader>
505
  {live && (running || live.status === "failed") && <JobProgress job={live} />}
506
  {error && <p className="error-text">{error}</p>}
 
517
  />
518
  )}
519
  {data && data.nodes.length > 0 && (
520
+ <div className={`map-layout${selected || briefing ? "" : " map-full"}`}>
521
  <div className="card map-canvas">
522
  <div className="map-controls">
523
  <button className="icon-btn" onClick={() => zoomAt(0.75)} aria-label="Zoom in" title="Zoom in">
 
552
  )}
553
  </div>
554
 
555
+ <details className="map-legend mono" aria-label="Legend">
556
+ <summary>Legend</summary>
557
  <span>
558
  <i className="lg-size" /> size = posts
559
  </span>
 
574
  <span>
575
  <i className="lg-line" /> lines = shared posts
576
  </span>
577
+ </details>
578
 
579
  <svg
580
  ref={svgRef}
 
742
  </svg>
743
  </div>
744
 
745
+ {(selected || briefing) && (
746
  <aside className="card map-side">
747
  {!selected && (
748
  <>
 
801
  {data.insights.dormant.length === 0 && <li className="muted small">Every topic has been visited lately.</li>}
802
  </ul>
803
  {data.insights.dormant.length > 0 && (
804
+ <Link to="/app/launchpad#ideas" className="small-link mono">
805
  Resurface old posts
806
  </Link>
807
  )}
 
832
  {selected && (
833
  <>
834
  <button className="link-btn mono muted small" onClick={() => setSelected(null)}>
835
+ {briefing ? "Back to briefing" : "Close"}
836
  </button>
837
  <div className="row between">
838
  <h2>{selected.name}</h2>
 
881
  </div>
882
  )}
883
 
884
+ <div className="topic-actions">
885
+ <button
886
+ className="btn btn-small btn-primary"
887
+ disabled={acting !== null}
888
+ onClick={async () => {
889
+ setActing("audio");
890
+ try {
891
+ const nb = await notebooksApi.fromTopic(selected.id);
892
+ await artifactsApi.generate({ type: "audio_overview", notebook_id: nb.id, format: "deep_dive", minutes: 6 });
893
+ navigate(`/app/notebooks/${nb.id}`);
894
+ } finally {
895
+ setActing(null);
896
+ }
897
+ }}
898
+ >
899
+ {acting === "audio" ? "Starting..." : "Audio about this"}
900
+ </button>
901
+ {selected.posts[0] && (
902
+ <button className="btn btn-small" disabled={acting !== null} onClick={() => navigate(`/app/launch-kit?post=${selected.posts[0].id}`)}>
903
+ Launch Kit for the latest post
904
+ </button>
905
+ )}
906
+ <button
907
+ className="btn btn-small"
908
+ disabled={acting !== null}
909
+ onClick={async () => {
910
+ const nb = await notebooksApi.fromTopic(selected.id);
911
+ navigate(`/app/notebooks/${nb.id}`);
912
+ }}
913
+ >
914
+ Open as notebook
915
+ </button>
916
+ </div>
917
  <ul className="doc-list">
918
  {selected.posts.map((d) => (
919
  <li key={d.id} className="doc-row">
 
927
  </>
928
  )}
929
  </aside>
930
+ )}
931
  </div>
932
  )}
933
  {reading && <Reader documentId={reading} onClose={() => setReading(null)} />}
frontend/src/pages/VoiceProfile.tsx CHANGED
@@ -1,9 +1,10 @@
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
  import { voiceApi } from "../api/endpoints";
3
- import type { Delivery, Job, LibraryVoice, Quota, Voice, VoiceProfileData, VoiceState } from "../api/types";
4
  import { DocPicker } from "../components/DocPicker";
5
- import { ConfirmButton, errorMessage, formatDate, JobProgress, Loading, Modal, PageHeader } from "../components/ui";
6
  import { useJob } from "../hooks/useJob";
 
7
 
8
  type Host = "host_a" | "host_b";
9
 
@@ -22,8 +23,6 @@ const SLIDERS: { key: keyof Delivery; label: string; min: number; max: number; s
22
  { key: "speed", label: "Speed", min: 0.7, max: 1.2, step: 0.05, hint: "1.0 is natural pace" },
23
  ];
24
 
25
- const HOST_LABEL: Record<Host, string> = { host_a: "Host A, leads and narrates videos", host_b: "Host B, the co-host" };
26
-
27
  /** Plays one URL at a time across the page. */
28
  function usePlayer() {
29
  const audio = useRef<HTMLAudioElement | null>(null);
@@ -50,83 +49,159 @@ function fmtSeconds(s: number) {
50
 
51
  // Speaking voices
52
 
53
- function HostCard({
54
- host,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  state,
56
  voices,
57
  onSaved,
58
  onBrowse,
 
59
  }: {
60
- host: Host;
61
  state: VoiceState;
62
  voices: Voice[];
63
  onSaved: (s: VoiceState) => void;
64
  onBrowse: (host: Host) => void;
 
65
  }) {
 
66
  const [delivery, setDelivery] = useState<Delivery>(state.delivery[host]);
67
- const [busy, setBusy] = useState(false);
68
- const [error, setError] = useState<string | null>(null);
69
  const [dirty, setDirty] = useState(false);
 
 
70
  const { play, playing } = usePlayer();
71
  const current = state.host_voices[host];
72
- const groups = voices.reduce<Record<string, Voice[]>>((acc, v) => {
73
- const k = v.category ?? "other";
74
- (acc[k] ??= []).push(v);
75
- return acc;
76
- }, {});
77
 
78
  useEffect(() => {
79
  setDelivery(state.delivery[host]);
80
  setDirty(false);
81
  }, [state.delivery, host]);
82
 
83
- const preview = async () => {
84
- setBusy(true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  setError(null);
86
  try {
87
- const { url } = await voiceApi.preview(current, { delivery });
88
- play(url, "preview");
89
  } catch (e) {
90
  setError(errorMessage(e));
91
  } finally {
92
- setBusy(false);
 
 
 
 
 
 
 
 
 
 
93
  }
94
  };
95
 
96
  return (
97
- <div className="host-card">
98
- <div className="row between">
99
- <strong>{HOST_LABEL[host]}</strong>
100
- <button className="link-btn small" onClick={() => onBrowse(host)}>
101
- Browse voice library
102
- </button>
103
- </div>
104
- <div className="row">
105
- <select
106
- className="input input-sm"
107
- value={current}
108
- onChange={async (e) => onSaved(await voiceApi.update({ host_voices: { [host]: e.target.value } }))}
109
- aria-label={`${HOST_LABEL[host]} voice`}
110
- >
111
- {Object.entries(groups).map(([cat, list]) => (
112
- <optgroup key={cat} label={cat}>
113
- {list.map((v) => (
114
- <option key={v.voice_id} value={v.voice_id}>
115
- {v.name}
116
- {v.labels?.accent ? `, ${v.labels.accent}` : ""}
117
- {v.labels?.gender ? `, ${v.labels.gender}` : ""}
118
- </option>
119
- ))}
120
- </optgroup>
121
- ))}
122
- {!voices.some((v) => v.voice_id === current) && <option value={current}>Current voice</option>}
123
- </select>
124
- <button className="btn btn-small" onClick={preview} disabled={busy || !state.tts_configured}>
125
- {busy ? "Rendering..." : playing ? "Stop" : "Preview"}
126
- </button>
127
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  <details className="delivery">
129
- <summary className="mono muted small">Delivery settings</summary>
130
  {SLIDERS.map((s) => (
131
  <label key={s.key} className="slider" title={s.hint}>
132
  <span className="row between">
@@ -163,11 +238,210 @@ function HostCard({
163
  </div>
164
  )}
165
  </details>
166
- {error && <p className="error-text">{error}</p>}
 
 
 
 
 
167
  </div>
168
  );
169
  }
170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  // Voice library (commercially licensed voices from ElevenLabs)
172
 
173
  function LibraryModal({ host, onClose, onAdded }: { host: Host; onClose: () => void; onAdded: (s: VoiceState) => void }) {
@@ -460,19 +734,31 @@ function CloneModal({ state, onClose, onDone }: { state: VoiceState; onClose: ()
460
  );
461
  }
462
 
 
 
 
 
 
 
 
 
 
 
463
  export default function VoiceProfile() {
464
  const [state, setState] = useState<VoiceState | null>(null);
465
  const [draft, setDraft] = useState<VoiceProfileData | null>(null);
466
  const [voices, setVoices] = useState<Voice[]>([]);
467
- const [quota, setQuota] = useState<Quota>({});
468
  const [picking, setPicking] = useState(false);
469
  const [samples, setSamples] = useState<string[]>([]);
470
  const [buildJob, setBuildJob] = useState<Job | null>(null);
471
  const [cloneJob, setCloneJob] = useState<Job | null>(null);
472
  const [cloning, setCloning] = useState(false);
473
  const [browsing, setBrowsing] = useState<Host | null>(null);
 
474
  const [saved, setSaved] = useState(false);
475
  const [error, setError] = useState<string | null>(null);
 
476
  const { play, playing } = usePlayer();
477
 
478
  const load = () =>
@@ -480,13 +766,31 @@ export default function VoiceProfile() {
480
  setState(s);
481
  setDraft(s.profile);
482
  setSamples(s.sample_doc_ids);
 
 
483
  });
484
  const loadVoices = () => voiceApi.voices().then(setVoices, () => setVoices([]));
485
 
 
 
 
 
 
 
 
 
 
 
486
  useEffect(() => {
487
- load();
 
 
 
 
 
 
488
  loadVoices();
489
- voiceApi.quota().then(setQuota, () => setQuota({}));
490
  }, []);
491
 
492
  const build = useJob(buildJob, () => load());
@@ -495,9 +799,11 @@ export default function VoiceProfile() {
495
  loadVoices();
496
  });
497
 
498
- if (!state) return <Loading />;
499
  const building = build && (build.status === "queued" || build.status === "running");
500
  const cloneRunning = clone && (clone.status === "queued" || clone.status === "running");
 
 
501
 
502
  const saveProfile = async () => {
503
  setError(null);
@@ -510,111 +816,113 @@ export default function VoiceProfile() {
510
  }
511
  };
512
 
513
- const used = quota.character_count ?? 0;
514
- const limit = quota.character_limit ?? 0;
515
-
516
  return (
517
- <div className="page-wrap">
518
- <PageHeader eyebrow="Voice profile" title="How you sound, on the page and out loud">
519
- <button className="btn btn-primary" disabled={Boolean(building)} onClick={() => setPicking(true)}>
520
- {state.profile ? "Rebuild writing voice" : "Build my writing voice"}
521
- </button>
522
- </PageHeader>
523
- {build && (building || build.status === "failed") && <JobProgress job={build} />}
 
 
 
 
 
 
 
 
 
524
  {error && <p className="error-text">{error}</p>}
525
 
526
- <div className="two-col">
527
- <section className="card stack">
528
- <h2>Writing voice</h2>
529
- <p className="muted small">Launch Kits, carousels, hooks and scripts are written in this voice. Edit anything that does not sound like you.</p>
530
- {!draft && <p className="muted">No profile yet. Build one from 5 to 10 posts that feel most like you.</p>}
 
 
 
 
 
 
 
 
 
 
 
531
  {draft && (
532
  <>
533
  <label className="field">
534
- <span>Summary</span>
535
- <textarea className="textarea" rows={3} value={draft.summary} onChange={(e) => setDraft({ ...draft, summary: e.target.value })} />
536
- </label>
537
- <label className="field">
538
- <span>Sentence length</span>
539
- <select className="input input-sm" value={draft.sentence_length} onChange={(e) => setDraft({ ...draft, sentence_length: e.target.value as VoiceProfileData["sentence_length"] })}>
540
- {["short", "medium", "long", "varied"].map((v) => (
541
- <option key={v}>{v}</option>
542
- ))}
543
- </select>
544
  </label>
545
- {LIST_FIELDS.map((f) => (
546
- <label key={f.key} className="field">
547
- <span>{f.label}</span>
548
- <input
549
- className="input input-sm"
550
- placeholder={f.hint}
551
- value={(draft[f.key] as string[]).join(", ")}
552
- onChange={(e) => setDraft({ ...draft, [f.key]: e.target.value.split(",").map((x) => x.trim()).filter(Boolean) })}
553
- />
554
  </label>
555
- ))}
556
- <div className="row between">
557
- <span className="mono muted small">
558
- Built {formatDate(state.updated_at, true)} from {state.sample_doc_ids.length} posts
559
- </span>
560
- <button className="btn btn-primary btn-small" onClick={saveProfile}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
561
  {saved ? "Saved" : "Save changes"}
562
  </button>
 
 
 
563
  </div>
564
  </>
565
  )}
566
  </section>
 
567
 
568
- <div className="stack">
569
- <section className="card stack">
570
- <div className="row between">
571
- <h2>Speaking voices</h2>
572
- {limit > 0 && (
573
- <span className="mono muted small" title="ElevenLabs characters this billing period">
574
- {used.toLocaleString()} / {limit.toLocaleString()} chars
575
- </span>
576
- )}
577
- </div>
578
- {!state.tts_configured && <p className="muted">Set ELEVENLABS_API_KEY in .env to generate audio.</p>}
579
- <p className="muted small">
580
- Audio overviews use both hosts; videos are narrated by Host A. Model: {state.models[state.model] ?? state.model}.
581
- </p>
582
- {(["host_a", "host_b"] as Host[]).map((h) => (
583
- <HostCard key={h} host={h} state={state} voices={voices} onSaved={setState} onBrowse={setBrowsing} />
584
- ))}
585
- </section>
586
-
587
- <section className="card stack">
588
- <h2>Your own voice</h2>
589
- {!state.clone.allowed && <p className="muted">Voice cloning is on the Writer and Studio plans.</p>}
590
- {quota.can_use_instant_voice_cloning === false && (
591
- <p className="muted small">Your ElevenLabs plan does not include instant voice cloning. Upgrade it at elevenlabs.io.</p>
592
- )}
593
- {state.clone.allowed && (state.clone.status === "none" || state.clone.status === "failed") && !cloneRunning && (
594
- <>
595
- <p className="muted">
596
- Record one to three minutes of your own writing, read aloud. Your voice becomes Host A, so audio overviews and videos sound like you.
597
- </p>
598
- {state.clone.status === "failed" && state.clone.error && <p className="error-text">{state.clone.error}</p>}
599
- <button className="btn btn-primary" onClick={() => setCloning(true)} disabled={!state.tts_configured}>
600
- Clone my voice
601
- </button>
602
- </>
603
- )}
604
- {clone && (cloneRunning || clone.status === "failed") && <JobProgress job={clone} />}
605
- {state.clone.status === "processing" && !clone && <p className="muted">Your voice is being created.</p>}
606
- {state.clone.status === "ready" && (
607
- <>
608
- <p className="muted">Your voice is live and set as Host A. Adjust its delivery above.</p>
609
- <div className="row">
610
- {state.clone.preview_url && (
611
- <button className="btn btn-small" onClick={() => play(state.clone.preview_url!, "clone")}>
612
- {playing === "clone" ? "Stop" : "Hear my voice"}
613
- </button>
614
- )}
615
  <button className="btn btn-small" onClick={() => setCloning(true)}>
616
- Record again
617
  </button>
 
 
618
  <ConfirmButton
619
  confirmLabel="Revoke and delete my voice?"
620
  onConfirm={async () => {
@@ -624,35 +932,43 @@ export default function VoiceProfile() {
624
  >
625
  Revoke consent
626
  </ConfirmButton>
627
- </div>
628
- </>
629
- )}
630
- </section>
631
- </div>
632
- </div>
 
 
 
 
 
 
633
 
634
  {picking && (
635
  <Modal title="Pick 5 to 10 posts that sound most like you" onClose={() => setPicking(false)} wide>
636
  <DocPicker selected={samples} onChange={setSamples} max={10} />
637
  <p className="muted small">Leave empty to let Notestack pick your longest posts.</p>
638
  <div className="row end">
639
- <button
640
- className="btn btn-primary"
641
- onClick={async () => {
642
- setPicking(false);
643
- try {
644
- setBuildJob(await voiceApi.build(samples));
645
- } catch (e) {
646
- setError(errorMessage(e));
647
- }
648
- }}
649
- >
650
  Build profile
651
  </button>
652
  </div>
653
  </Modal>
654
  )}
655
  {cloning && <CloneModal state={state} onClose={() => setCloning(false)} onDone={setCloneJob} />}
 
 
 
 
 
 
 
 
 
 
 
 
656
  {browsing && (
657
  <LibraryModal
658
  host={browsing}
 
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
  import { voiceApi } from "../api/endpoints";
3
+ import type { Delivery, Job, LibraryVoice, Voice, VoiceDesignInput, VoiceDesignPreview, VoiceProfileData, VoiceState } from "../api/types";
4
  import { DocPicker } from "../components/DocPicker";
5
+ import { ConfirmButton, errorMessage, formatDate, JobProgress, Loading, Modal, PageHeader, Tabs } from "../components/ui";
6
  import { useJob } from "../hooks/useJob";
7
+ import { useUpgrade } from "../hooks/useUpgrade";
8
 
9
  type Host = "host_a" | "host_b";
10
 
 
23
  { key: "speed", label: "Speed", min: 0.7, max: 1.2, step: 0.05, hint: "1.0 is natural pace" },
24
  ];
25
 
 
 
26
  /** Plays one URL at a time across the page. */
27
  function usePlayer() {
28
  const audio = useRef<HTMLAudioElement | null>(null);
 
49
 
50
  // Speaking voices
51
 
52
+ type Icon = "sun" | "book" | "mic" | "spark" | "wave" | "person";
53
+
54
+ /** The four ElevenLabs premade voices offered up front. Each has a glyph so they read at a glance. */
55
+ const PREBUILT: { voice_id: string; name: string; tag: string; icon: Icon }[] = [
56
+ { voice_id: "EXAVITQu4vr4xnSDxMaL", name: "Sarah", tag: "Warm and reassuring", icon: "sun" },
57
+ { voice_id: "JBFqnCBsd6RMkjVDRZzb", name: "George", tag: "British storyteller", icon: "book" },
58
+ { voice_id: "nPczCjzI2devNBz1zQrb", name: "Brian", tag: "Deep narrator", icon: "mic" },
59
+ { voice_id: "cgSgspJ2msm6clMCkdW9", name: "Jessica", tag: "Bright and lively", icon: "spark" },
60
+ ];
61
+
62
+ const ICON_PATHS: Record<Icon, string> = {
63
+ sun: "M12 4V2m0 20v-2m8-8h2M2 12h2m13.66-5.66 1.41-1.41M4.93 19.07l1.41-1.41m0-11.32L4.93 4.93m14.14 14.14-1.41-1.41M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z",
64
+ book: "M4 5.5A2.5 2.5 0 0 1 6.5 3H20v15H6.5A2.5 2.5 0 0 0 4 20.5v-15Zm0 15A2.5 2.5 0 0 0 6.5 23H20v-5",
65
+ mic: "M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Zm7 9a7 7 0 0 1-14 0m7 7v4m-4 0h8",
66
+ spark: "M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3Zm7 11 .8 2.2L22 17l-2.2.8L19 20l-.8-2.2L16 17l2.2-.8L19 14Z",
67
+ wave: "M3 12h2m3-5v10m4-13v16m4-11v6m4-3h1",
68
+ person: "M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 9a7 7 0 0 1 14 0",
69
+ };
70
+
71
+ function VoiceIcon({ icon }: { icon: Icon }) {
72
+ return (
73
+ <span className="voice-icon" aria-hidden="true">
74
+ <svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
75
+ <path d={ICON_PATHS[icon]} />
76
+ </svg>
77
+ </span>
78
+ );
79
+ }
80
+
81
+ type Tile = { voice_id: string; name: string; tag: string; icon: Icon; preview_url?: string | null };
82
+
83
+ function VoicePicker({
84
  state,
85
  voices,
86
  onSaved,
87
  onBrowse,
88
+ onGenerate,
89
  }: {
 
90
  state: VoiceState;
91
  voices: Voice[];
92
  onSaved: (s: VoiceState) => void;
93
  onBrowse: (host: Host) => void;
94
+ onGenerate: (host: Host) => void;
95
  }) {
96
+ const [host, setHost] = useState<Host>("host_a");
97
  const [delivery, setDelivery] = useState<Delivery>(state.delivery[host]);
 
 
98
  const [dirty, setDirty] = useState(false);
99
+ const [loading, setLoading] = useState<string | null>(null);
100
+ const [error, setError] = useState<string | null>(null);
101
  const { play, playing } = usePlayer();
102
  const current = state.host_voices[host];
103
+ const other: Host = host === "host_a" ? "host_b" : "host_a";
104
+ const byId = new Map(voices.map((v) => [v.voice_id, v]));
 
 
 
105
 
106
  useEffect(() => {
107
  setDelivery(state.delivery[host]);
108
  setDirty(false);
109
  }, [state.delivery, host]);
110
 
111
+ // Prebuilt first, then the writer's own voices, then whatever else a host is set to (e.g. a library voice).
112
+ const tiles: Tile[] = PREBUILT.map((p) => ({ ...p, preview_url: byId.get(p.voice_id)?.preview_url }));
113
+ if (state.clone.status === "ready" && state.clone.voice_id) {
114
+ tiles.push({ voice_id: state.clone.voice_id, name: "My voice", tag: "Your clone", icon: "person", preview_url: state.clone.preview_url });
115
+ }
116
+ for (const c of state.custom_voices) tiles.push({ voice_id: c.voice_id, name: c.name, tag: "Generated", icon: "wave" });
117
+ for (const id of [state.host_voices.host_a, state.host_voices.host_b]) {
118
+ if (!tiles.some((t) => t.voice_id === id)) {
119
+ const v = byId.get(id);
120
+ tiles.push({ voice_id: id, name: v?.name ?? "Current voice", tag: v?.labels?.accent ?? "Library voice", icon: "wave", preview_url: v?.preview_url });
121
+ }
122
+ }
123
+
124
+ const listen = async (t: Tile) => {
125
+ if (playing === t.voice_id || t.preview_url) return play(t.preview_url ?? "", t.voice_id);
126
+ setLoading(t.voice_id);
127
  setError(null);
128
  try {
129
+ const { url } = await voiceApi.preview(t.voice_id, { host });
130
+ play(url, t.voice_id);
131
  } catch (e) {
132
  setError(errorMessage(e));
133
  } finally {
134
+ setLoading(null);
135
+ }
136
+ };
137
+
138
+ const choose = async (voice_id: string) => {
139
+ if (voice_id === current) return;
140
+ setError(null);
141
+ try {
142
+ onSaved(await voiceApi.update({ host_voices: { [host]: voice_id } }));
143
+ } catch (e) {
144
+ setError(errorMessage(e));
145
  }
146
  };
147
 
148
  return (
149
+ <div className="stack">
150
+ <Tabs<Host>
151
+ tabs={[
152
+ { id: "host_a", label: "Host A · leads" },
153
+ { id: "host_b", label: "Host B · co-host" },
154
+ ]}
155
+ value={host}
156
+ onChange={setHost}
157
+ />
158
+ <ul className="voice-grid" aria-label={`Voices for ${host === "host_a" ? "Host A" : "Host B"}`}>
159
+ {tiles.map((t) => {
160
+ const selected = t.voice_id === current;
161
+ const usedByOther = t.voice_id === state.host_voices[other];
162
+ return (
163
+ <li key={t.voice_id} className={`voice-tile${selected ? " on" : ""}`}>
164
+ <button type="button" className="voice-pick" onClick={() => choose(t.voice_id)} aria-pressed={selected}>
165
+ <VoiceIcon icon={t.icon} />
166
+ <strong>{t.name}</strong>
167
+ <span className="muted small">{t.tag}</span>
168
+ </button>
169
+ {selected && <span className="voice-badge mono">{host === "host_a" ? "Host A" : "Host B"}</span>}
170
+ {!selected && usedByOther && <span className="voice-badge dim mono">{other === "host_a" ? "Host A" : "Host B"}</span>}
171
+ <button
172
+ type="button"
173
+ className="icon-btn voice-play"
174
+ onClick={() => listen(t)}
175
+ disabled={loading !== null || (!t.preview_url && !state.tts_configured)}
176
+ aria-label={playing === t.voice_id ? `Stop ${t.name}` : `Play ${t.name}`}
177
+ title={playing === t.voice_id ? "Stop" : "Listen"}
178
+ >
179
+ {loading === t.voice_id ? (
180
+ "…"
181
+ ) : (
182
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
183
+ {playing === t.voice_id ? <path d="M6 5h4v14H6zM14 5h4v14h-4z" /> : <path d="M7 4.5v15l13-7.5z" />}
184
+ </svg>
185
+ )}
186
+ </button>
187
+ </li>
188
+ );
189
+ })}
190
+ <li className="voice-tile voice-new">
191
+ <button type="button" className="voice-pick" onClick={() => onGenerate(host)} aria-label="Add a voice">
192
+ <span className="voice-icon plus" aria-hidden="true">
193
+ <svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
194
+ <path d="M12 5v14M5 12h14" />
195
+ </svg>
196
+ </span>
197
+ <strong>Add a voice</strong>
198
+ <span className="muted small">Generate or clone your own</span>
199
+ </button>
200
+ </li>
201
+ </ul>
202
+ {error && <p className="error-text">{error}</p>}
203
  <details className="delivery">
204
+ <summary className="mono muted small">Delivery settings for {host === "host_a" ? "Host A" : "Host B"}</summary>
205
  {SLIDERS.map((s) => (
206
  <label key={s.key} className="slider" title={s.hint}>
207
  <span className="row between">
 
238
  </div>
239
  )}
240
  </details>
241
+ <p className="muted small">
242
+ Want something else?{" "}
243
+ <button className="link-btn small" onClick={() => onBrowse(host)}>
244
+ Browse the full voice library
245
+ </button>
246
+ </p>
247
  </div>
248
  );
249
  }
250
 
251
+ // Generating a new voice: build from options, describe it, or clone your own
252
+
253
+ type GenMode = "options" | "prompt" | "clone";
254
+
255
+ const GEN_OPTIONS: { key: keyof Omit<VoiceDesignInput, "prompt">; label: string; values: string[] }[] = [
256
+ { key: "gender", label: "Gender", values: ["female", "male", "neutral"] },
257
+ { key: "age", label: "Age", values: ["young", "middle-aged", "older"] },
258
+ { key: "persona", label: "Personality", values: ["warm", "professional", "calm", "friendly", "confident", "energetic"] },
259
+ { key: "pace", label: "Pace", values: ["slow", "measured", "brisk"] },
260
+ { key: "accent", label: "Accent", values: ["American", "British", "Australian", "Irish", "Scottish", "Indian", "Canadian", "South African"] },
261
+ ];
262
+
263
+ const PROMPT_IDEAS = [
264
+ "A calm, low British voice for late night essays",
265
+ "An upbeat young American podcaster who smiles while talking",
266
+ "A thoughtful older narrator with a soft Irish lilt",
267
+ ];
268
+
269
+ function GenerateVoiceModal({
270
+ host,
271
+ state,
272
+ onClose,
273
+ onSaved,
274
+ onClone,
275
+ }: {
276
+ host: Host;
277
+ state: VoiceState;
278
+ onClose: () => void;
279
+ onSaved: (s: VoiceState) => void;
280
+ onClone: () => void;
281
+ }) {
282
+ const [mode, setMode] = useState<GenMode>("options");
283
+ const [opts, setOpts] = useState<VoiceDesignInput>({ gender: "female", age: "middle-aged", persona: "warm", pace: "measured", accent: "American" });
284
+ const [prompt, setPrompt] = useState("");
285
+ const [result, setResult] = useState<{ description: string; previews: VoiceDesignPreview[] } | null>(null);
286
+ const [picked, setPicked] = useState<string | null>(null);
287
+ const [name, setName] = useState("");
288
+ const [busy, setBusy] = useState<"generate" | "save" | null>(null);
289
+ const [error, setError] = useState<string | null>(null);
290
+ const { play, playing } = usePlayer();
291
+ const { openUpgrade } = useUpgrade();
292
+ const hostLabel = host === "host_a" ? "Host A" : "Host B";
293
+
294
+ const generate = async () => {
295
+ setBusy("generate");
296
+ setError(null);
297
+ setResult(null);
298
+ setPicked(null);
299
+ try {
300
+ const res = await voiceApi.design(mode === "prompt" ? { prompt } : opts);
301
+ setResult(res);
302
+ setPicked(res.previews[0]?.generated_voice_id ?? null);
303
+ } catch (e) {
304
+ setError(errorMessage(e));
305
+ } finally {
306
+ setBusy(null);
307
+ }
308
+ };
309
+
310
+ const save = async () => {
311
+ if (!result || !picked) return;
312
+ setBusy("save");
313
+ setError(null);
314
+ try {
315
+ onSaved(await voiceApi.saveDesign({ generated_voice_id: picked, name: name.trim(), description: result.description, use_as: host }));
316
+ onClose();
317
+ } catch (e) {
318
+ setError(errorMessage(e));
319
+ } finally {
320
+ setBusy(null);
321
+ }
322
+ };
323
+
324
+ return (
325
+ <Modal title="Generate a new voice" onClose={onClose} wide>
326
+ <div className="stack">
327
+ <Tabs<GenMode>
328
+ tabs={[
329
+ { id: "options", label: "Build from options" },
330
+ { id: "prompt", label: "Describe it" },
331
+ { id: "clone", label: "Clone my voice" },
332
+ ]}
333
+ value={mode}
334
+ onChange={(m) => {
335
+ setMode(m);
336
+ setResult(null);
337
+ setError(null);
338
+ }}
339
+ />
340
+
341
+ {mode === "options" && (
342
+ <div className="gen-options">
343
+ {GEN_OPTIONS.map((g) => (
344
+ <div key={g.key} className="gen-row">
345
+ <span className="mono muted small">{g.label}</span>
346
+ <div className="gen-chips">
347
+ {g.values.map((v) => (
348
+ <button key={v} type="button" className={`chip${opts[g.key] === v ? " on" : ""}`} aria-pressed={opts[g.key] === v} onClick={() => setOpts({ ...opts, [g.key]: v })}>
349
+ {v}
350
+ </button>
351
+ ))}
352
+ </div>
353
+ </div>
354
+ ))}
355
+ </div>
356
+ )}
357
+
358
+ {mode === "prompt" && (
359
+ <>
360
+ <label className="field">
361
+ <span>Describe the voice you want</span>
362
+ <textarea className="textarea" rows={3} maxLength={1000} placeholder="A calm, low British voice for late night essays" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
363
+ </label>
364
+ <div className="row between">
365
+ <div className="gen-chips">
366
+ {PROMPT_IDEAS.map((p) => (
367
+ <button key={p} type="button" className="chip" onClick={() => setPrompt(p)}>
368
+ {p}
369
+ </button>
370
+ ))}
371
+ </div>
372
+ <span className="mono muted small">{prompt.trim().length} / 1000</span>
373
+ </div>
374
+ </>
375
+ )}
376
+
377
+ {mode === "clone" &&
378
+ (state.clone.allowed ? (
379
+ <>
380
+ <p className="muted">Read a passage from your own writing for one to three minutes. Your voice becomes Host A, so every overview and video sounds like you.</p>
381
+ <div className="row">
382
+ <button
383
+ className="btn btn-primary"
384
+ disabled={!state.tts_configured}
385
+ onClick={() => {
386
+ onClose();
387
+ onClone();
388
+ }}
389
+ >
390
+ {state.clone.status === "ready" ? "Record my voice again" : "Start recording"}
391
+ </button>
392
+ </div>
393
+ </>
394
+ ) : (
395
+ <div className="vp-upgrade">
396
+ <div>
397
+ <strong>Voice cloning is on the Writer and Studio plans</strong>
398
+ <p className="muted small">Record a couple of minutes and every audio overview and video is narrated by you.</p>
399
+ </div>
400
+ <button className="btn btn-primary" onClick={() => openUpgrade("writer")}>
401
+ Upgrade to clone my voice
402
+ </button>
403
+ </div>
404
+ ))}
405
+
406
+ {mode !== "clone" && (
407
+ <div className="row end">
408
+ <button className="btn btn-primary" onClick={generate} disabled={busy !== null || !state.tts_configured || (mode === "prompt" && prompt.trim().length < 20)}>
409
+ {busy === "generate" ? "Generating previews..." : result ? "Generate again" : "Generate previews"}
410
+ </button>
411
+ </div>
412
+ )}
413
+
414
+ {result && mode !== "clone" && (
415
+ <div className="stack">
416
+ <p className="eyebrow">Pick the one you like</p>
417
+ <ul className="gen-previews">
418
+ {result.previews.map((p, i) => (
419
+ <li key={p.generated_voice_id} className={picked === p.generated_voice_id ? "on" : ""}>
420
+ <label className="check">
421
+ <input type="radio" name="gen-preview" checked={picked === p.generated_voice_id} onChange={() => setPicked(p.generated_voice_id)} />
422
+ Option {i + 1}
423
+ </label>
424
+ <button type="button" className="btn btn-small" onClick={() => play(p.url, p.generated_voice_id)}>
425
+ {playing === p.generated_voice_id ? "Stop" : "Play"}
426
+ </button>
427
+ </li>
428
+ ))}
429
+ </ul>
430
+ <div className="row">
431
+ <input className="input input-sm" placeholder="Name it, e.g. Late night narrator" maxLength={60} value={name} onChange={(e) => setName(e.target.value)} aria-label="Voice name" />
432
+ <button className="btn btn-primary" onClick={save} disabled={busy !== null || !picked || !name.trim()}>
433
+ {busy === "save" ? "Saving..." : `Save and use as ${hostLabel}`}
434
+ </button>
435
+ </div>
436
+ </div>
437
+ )}
438
+ {!state.tts_configured && <p className="muted small">Voice generation is unavailable right now.</p>}
439
+ {error && <p className="error-text">{error}</p>}
440
+ </div>
441
+ </Modal>
442
+ );
443
+ }
444
+
445
  // Voice library (commercially licensed voices from ElevenLabs)
446
 
447
  function LibraryModal({ host, onClose, onAdded }: { host: Host; onClose: () => void; onAdded: (s: VoiceState) => void }) {
 
734
  );
735
  }
736
 
737
+ // The page: two steps. The writing voice builds itself, then the writer picks the two hosts.
738
+ // Cloning lives in the "+ Add a voice" menu; a clone's status shows under the voice cards.
739
+
740
+ type Step = "writing" | "hosts";
741
+
742
+ const STEPS: { id: Step; label: string }[] = [
743
+ { id: "writing", label: "Writing voice" },
744
+ { id: "hosts", label: "Speaking voices" },
745
+ ];
746
+
747
  export default function VoiceProfile() {
748
  const [state, setState] = useState<VoiceState | null>(null);
749
  const [draft, setDraft] = useState<VoiceProfileData | null>(null);
750
  const [voices, setVoices] = useState<Voice[]>([]);
751
+ const [step, setStep] = useState<Step | null>(null);
752
  const [picking, setPicking] = useState(false);
753
  const [samples, setSamples] = useState<string[]>([]);
754
  const [buildJob, setBuildJob] = useState<Job | null>(null);
755
  const [cloneJob, setCloneJob] = useState<Job | null>(null);
756
  const [cloning, setCloning] = useState(false);
757
  const [browsing, setBrowsing] = useState<Host | null>(null);
758
+ const [generating, setGenerating] = useState<Host | null>(null);
759
  const [saved, setSaved] = useState(false);
760
  const [error, setError] = useState<string | null>(null);
761
+ const autoBuilt = useRef(false);
762
  const { play, playing } = usePlayer();
763
 
764
  const load = () =>
 
766
  setState(s);
767
  setDraft(s.profile);
768
  setSamples(s.sample_doc_ids);
769
+ setStep((cur) => cur ?? (s.profile ? "hosts" : "writing"));
770
+ return s;
771
  });
772
  const loadVoices = () => voiceApi.voices().then(setVoices, () => setVoices([]));
773
 
774
+ const startBuild = async (ids: string[]) => {
775
+ setPicking(false);
776
+ setError(null);
777
+ try {
778
+ setBuildJob(await voiceApi.build(ids));
779
+ } catch (e) {
780
+ setError(errorMessage(e));
781
+ }
782
+ };
783
+
784
  useEffect(() => {
785
+ // No writing voice yet: build one from the longest posts straight away instead of asking.
786
+ load().then((s) => {
787
+ if (!s.profile && !autoBuilt.current) {
788
+ autoBuilt.current = true;
789
+ void startBuild([]);
790
+ }
791
+ });
792
  loadVoices();
793
+ // eslint-disable-next-line react-hooks/exhaustive-deps
794
  }, []);
795
 
796
  const build = useJob(buildJob, () => load());
 
799
  loadVoices();
800
  });
801
 
802
+ if (!state || !step) return <Loading />;
803
  const building = build && (build.status === "queued" || build.status === "running");
804
  const cloneRunning = clone && (clone.status === "queued" || clone.status === "running");
805
+ const done: Record<Step, boolean> = { writing: Boolean(state.profile), hosts: Boolean(state.profile) && step === "hosts" };
806
+ const showClone = state.clone.status !== "none" || Boolean(clone);
807
 
808
  const saveProfile = async () => {
809
  setError(null);
 
816
  }
817
  };
818
 
 
 
 
819
  return (
820
+ <div className="page-wrap vp">
821
+ <PageHeader eyebrow="Voice profile" title="How you sound, on the page and out loud" />
822
+
823
+ <ol className="vp-steps mono" aria-label="Voice profile steps">
824
+ {STEPS.map((s, i) => (
825
+ <li key={s.id} className={s.id === step ? "current" : done[s.id] ? "done" : ""}>
826
+ <button type="button" onClick={() => setStep(s.id)} aria-current={s.id === step ? "step" : undefined}>
827
+ <span className="vp-dot" />
828
+ <span>
829
+ {i + 1}. {s.label}
830
+ {done[s.id] && s.id !== step ? " ✓" : ""}
831
+ </span>
832
+ </button>
833
+ </li>
834
+ ))}
835
+ </ol>
836
  {error && <p className="error-text">{error}</p>}
837
 
838
+ {step === "writing" && (
839
+ <section className="card stack vp-card">
840
+ <h2>How you write</h2>
841
+ <p className="muted">Launch Kits, carousels, hooks and scripts are all written in this voice, so they read like you wrote them.</p>
842
+ {build && (building || build.status === "failed") && <JobProgress job={build} />}
843
+ {!draft && !building && (
844
+ <div className="row">
845
+ <button className="btn btn-primary" onClick={() => startBuild([])}>
846
+ Build my writing voice
847
+ </button>
848
+ <button className="link-btn small" onClick={() => setPicking(true)}>
849
+ or pick the posts yourself
850
+ </button>
851
+ </div>
852
+ )}
853
+
854
  {draft && (
855
  <>
856
  <label className="field">
857
+ <span>In a sentence, you sound like this</span>
858
+ <textarea className="textarea" rows={7} value={draft.summary} onChange={(e) => setDraft({ ...draft, summary: e.target.value })} />
 
 
 
 
 
 
 
 
859
  </label>
860
+ <details className="delivery">
861
+ <summary className="mono muted small">Fine-tune tone, phrases and habits</summary>
862
+ <label className="field">
863
+ <span>Sentence length</span>
864
+ <select className="input input-sm" value={draft.sentence_length} onChange={(e) => setDraft({ ...draft, sentence_length: e.target.value as VoiceProfileData["sentence_length"] })}>
865
+ {["short", "medium", "long", "varied"].map((v) => (
866
+ <option key={v}>{v}</option>
867
+ ))}
868
+ </select>
869
  </label>
870
+ {LIST_FIELDS.map((f) => (
871
+ <label key={f.key} className="field">
872
+ <span>{f.label}</span>
873
+ <input
874
+ className="input input-sm"
875
+ placeholder={f.hint}
876
+ value={(draft[f.key] as string[]).join(", ")}
877
+ onChange={(e) => setDraft({ ...draft, [f.key]: e.target.value.split(",").map((x) => x.trim()).filter(Boolean) })}
878
+ />
879
+ </label>
880
+ ))}
881
+ </details>
882
+ <p className="mono muted small">
883
+ Built {formatDate(state.updated_at, true)} from {state.sample_doc_ids.length} posts ·{" "}
884
+ <button className="link-btn small" disabled={Boolean(building)} onClick={() => setPicking(true)}>
885
+ Rebuild from other posts
886
+ </button>
887
+ </p>
888
+ <div className="row end">
889
+ <button className="btn btn-small" onClick={saveProfile}>
890
  {saved ? "Saved" : "Save changes"}
891
  </button>
892
+ <button className="btn btn-primary" onClick={() => setStep("hosts")}>
893
+ Next: pick your hosts
894
+ </button>
895
  </div>
896
  </>
897
  )}
898
  </section>
899
+ )}
900
 
901
+ {step === "hosts" && (
902
+ <section className="card stack vp-card">
903
+ <h2>Who reads your work aloud</h2>
904
+ <p className="muted">Audio overviews are a conversation between two hosts, and videos are narrated by Host A. Tap a voice to use it, or add your own.</p>
905
+ {!state.tts_configured && <p className="muted">Voice previews are unavailable right now.</p>}
906
+ <VoicePicker state={state} voices={voices} onSaved={setState} onBrowse={setBrowsing} onGenerate={setGenerating} />
907
+
908
+ {showClone && (
909
+ <div className="vp-clone">
910
+ <strong>Your cloned voice</strong>
911
+ {clone && (cloneRunning || clone.status === "failed") && <JobProgress job={clone} compact />}
912
+ {state.clone.status === "processing" && !clone && <p className="muted small">Your voice is being created. It becomes Host A when ready.</p>}
913
+ {state.clone.status === "failed" && state.clone.error && !cloneRunning && <p className="error-text">{state.clone.error}</p>}
914
+ <div className="row">
915
+ {state.clone.status === "ready" && state.clone.preview_url && (
916
+ <button className="btn btn-small" onClick={() => play(state.clone.preview_url!, "clone")}>
917
+ {playing === "clone" ? "Stop" : "Hear my voice"}
918
+ </button>
919
+ )}
920
+ {!cloneRunning && state.clone.allowed && (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
921
  <button className="btn btn-small" onClick={() => setCloning(true)}>
922
+ {state.clone.status === "failed" ? "Try again" : "Record again"}
923
  </button>
924
+ )}
925
+ {state.clone.status !== "none" && (
926
  <ConfirmButton
927
  confirmLabel="Revoke and delete my voice?"
928
  onConfirm={async () => {
 
932
  >
933
  Revoke consent
934
  </ConfirmButton>
935
+ )}
936
+ </div>
937
+ </div>
938
+ )}
939
+
940
+ <div className="row">
941
+ <button className="btn btn-small" onClick={() => setStep("writing")}>
942
+ Back to writing voice
943
+ </button>
944
+ </div>
945
+ </section>
946
+ )}
947
 
948
  {picking && (
949
  <Modal title="Pick 5 to 10 posts that sound most like you" onClose={() => setPicking(false)} wide>
950
  <DocPicker selected={samples} onChange={setSamples} max={10} />
951
  <p className="muted small">Leave empty to let Notestack pick your longest posts.</p>
952
  <div className="row end">
953
+ <button className="btn btn-primary" onClick={() => startBuild(samples)}>
 
 
 
 
 
 
 
 
 
 
954
  Build profile
955
  </button>
956
  </div>
957
  </Modal>
958
  )}
959
  {cloning && <CloneModal state={state} onClose={() => setCloning(false)} onDone={setCloneJob} />}
960
+ {generating && (
961
+ <GenerateVoiceModal
962
+ host={generating}
963
+ state={state}
964
+ onClose={() => setGenerating(null)}
965
+ onSaved={(s) => {
966
+ setState(s);
967
+ loadVoices();
968
+ }}
969
+ onClone={() => setCloning(true)}
970
+ />
971
+ )}
972
  {browsing && (
973
  <LibraryModal
974
  host={browsing}
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,34 @@ 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 +190,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 +218,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
 
@@ -178,10 +313,11 @@ export default function Welcome() {
178
  <p className="eyebrow">{source?.title ?? "Your archive"}</p>
179
  <h1>{indexingFailed ? "That did not land" : "Bringing your posts into orbit"}</h1>
180
  {live && <JobProgress job={live} />}
 
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 +336,9 @@ 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}>
@@ -222,8 +359,9 @@ export default function Welcome() {
222
  {step === "ready" && (
223
  <section className="card welcome-card">
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 lists the whole archive but indexes only the plan's limit; the rest waits behind an upgrade.
148
+ const locked = Number(live?.result?.locked ?? 0);
149
+ const capped = indexingDone && locked > 0;
150
+ const available = Number(live?.result?.available ?? posts);
151
+ const found = Number(live?.result?.found ?? posts);
152
+ const lockedNotice = capped ? (
153
+ <div className="welcome-cap">
154
+ <div>
155
+ <strong>
156
+ We found {found} posts. The free plan indexed only your latest {available}.
157
+ </strong>
158
+ <p className="muted small">
159
+ The other {locked} are sitting in your archive, unread. Until you upgrade, answers, audio overviews and Launch
160
+ Kits can only use {available} posts. Upgrade and all {found} are indexed automatically.
161
+ </p>
162
+ </div>
163
+ <button
164
+ type="button"
165
+ className="btn btn-small"
166
+ onClick={() => {
167
+ markOnboarded();
168
+ navigate("/app?upgrade=1", { replace: true });
169
+ }}
170
+ >
171
+ Index all {found} posts
172
+ </button>
173
+ </div>
174
+ ) : null;
175
  const first = (user.name ?? user.email).split(/[ @]/)[0];
176
 
177
  const finish = async (chosen: Goal | null) => {
 
190
 
191
  const submit = (e: FormEvent) => {
192
  e.preventDefault();
193
+ if (mode === "file") void upload();
194
+ else void connect(url, mode);
195
  };
196
 
197
  const stepIndex = { connect: 0, indexing: 1, goal: 2, ready: 3 }[step];
 
218
  {step === "connect" && (
219
  <section className="card welcome-card">
220
  <p className="eyebrow">Welcome aboard, {first}</p>
221
+ <h1>Where does your writing live?</h1>
222
+ <p className="muted">Bring it in however suits you. We turn it into a research notebook you can question, hear and launch from.</p>
223
+ <Tabs<Mode>
224
+ tabs={MODES}
225
+ value={mode}
226
+ onChange={(m) => {
227
+ setMode(m);
228
+ setError(null);
229
+ }}
230
+ />
231
+ <p className="muted small">
232
+ {mode === "feed" && "Substack, Ghost, WordPress, Medium or anything with a feed. We find the feed, pull in your archive and keep it in sync."}
233
+ {mode === "url" && "Any single article on the web. Add more later from Sources."}
234
+ {mode === "file" && "Markdown, text, HTML or PDF: drafts, exports and posts that never had a feed. Add as many as you like."}
235
  </p>
236
  <form onSubmit={submit} className="welcome-form">
237
+ {mode === "file" ? (
238
+ <label
239
+ className={`welcome-drop${dragging ? " over" : ""}`}
240
+ onDragOver={(e) => {
241
+ e.preventDefault();
242
+ setDragging(true);
243
+ }}
244
+ onDragLeave={() => setDragging(false)}
245
+ onDrop={(e) => {
246
+ e.preventDefault();
247
+ setDragging(false);
248
+ addFiles(e.dataTransfer.files);
249
+ }}
250
+ >
251
+ <input
252
+ type="file"
253
+ multiple
254
+ accept={FILE_ACCEPT}
255
+ onChange={(e) => {
256
+ addFiles(e.target.files);
257
+ e.target.value = "";
258
+ }}
259
+ aria-label="Files to upload"
260
+ />
261
+ <strong>Drop files here or click to choose</strong>
262
+ <span className="mono muted small">.md · .txt · .html · .pdf</span>
263
+ </label>
264
+ ) : (
265
+ <input
266
+ key={mode}
267
+ className="input"
268
+ autoFocus
269
+ placeholder={mode === "feed" ? "yourblog.com" : "https://example.com/my-essay"}
270
+ value={url}
271
+ onChange={(e) => setUrl(e.target.value)}
272
+ aria-label={mode === "feed" ? "Blog, newsletter or site address" : "Article URL"}
273
+ />
274
+ )}
275
+ {mode === "file" && files.length > 0 && (
276
+ <ul className="welcome-files mono small">
277
+ {files.map((f) => (
278
+ <li key={`${f.name}-${f.size}`}>
279
+ <span>{f.name}</span>
280
+ <button type="button" className="link-btn small" onClick={() => setFiles(files.filter((x) => x !== f))} aria-label={`Remove ${f.name}`}>
281
+ Remove
282
+ </button>
283
+ </li>
284
+ ))}
285
+ </ul>
286
+ )}
287
+ <button className="btn btn-primary" disabled={busy || (mode === "file" ? !files.length : !url.trim())}>
288
+ {mode === "feed" && (busy ? "Finding your feed..." : "Connect")}
289
+ {mode === "url" && (busy ? "Importing..." : "Import")}
290
+ {mode === "file" && (busy ? "Uploading..." : files.length === 1 ? "Upload 1 file" : `Upload ${files.length || ""} files`.replace(" ", " "))}
291
  </button>
292
  </form>
293
+ {mode === "feed" && (
294
+ <div className="welcome-examples mono muted">
295
+ Try:{" "}
296
+ {EXAMPLES.map((ex) => (
297
+ <button key={ex} type="button" className="chip" onClick={() => setUrl(ex)}>
298
+ {ex}
299
+ </button>
300
+ ))}
301
+ </div>
302
+ )}
303
  {error && (
304
  <p className="error-text" role="alert">
305
  {error}
306
  </p>
307
  )}
 
 
 
308
  </section>
309
  )}
310
 
 
313
  <p className="eyebrow">{source?.title ?? "Your archive"}</p>
314
  <h1>{indexingFailed ? "That did not land" : "Bringing your posts into orbit"}</h1>
315
  {live && <JobProgress job={live} />}
316
+ {lockedNotice}
317
  {indexingFailed ? (
318
  <div className="row">
319
  <button className="btn btn-primary" onClick={() => setStep("connect")}>
320
+ Try again
321
  </button>
322
  <Link className="btn" to="/app/sources">
323
  Import posts another way
 
336
 
337
  {step === "goal" && (
338
  <section className="card welcome-card">
339
+ <p className="eyebrow">{indexingDone ? (capped ? `${available} of ${found} posts indexed` : `${posts} posts indexed`) : "Still indexing in the background"}</p>
340
  <h1>What should we launch first?</h1>
341
+ {lockedNotice}
342
  <div className="goal-grid">
343
  {GOALS.map((g) => (
344
  <button key={g.id} type="button" className={`goal${goal === g.id ? " on" : ""}`} onClick={() => setGoal(g.id)} aria-pressed={goal === g.id}>
 
359
  {step === "ready" && (
360
  <section className="card welcome-card">
361
  <p className="eyebrow">Pre-flight check complete</p>
362
+ <h1>{indexingDone ? `${capped ? available : posts} posts are in orbit` : "Almost there"}</h1>
363
  {!indexingDone && live && <JobProgress job={live} />}
364
+ {lockedNotice}
365
  <p className="muted">
366
  {goal === "ask" && "We will put your whole archive in a notebook so you can start asking right away."}
367
  {goal === "audio" && "We will put your archive in a notebook with the audio studio ready."}
frontend/src/styles/app.css CHANGED
@@ -1046,8 +1046,8 @@
1046
  display: grid;
1047
  justify-items: center;
1048
  text-align: center;
1049
- gap: 14px;
1050
- padding: 80px 16px;
1051
  }
1052
 
1053
  .empty-state.small {
 
1046
  display: grid;
1047
  justify-items: center;
1048
  text-align: center;
1049
+ gap: 12px;
1050
+ padding: 40px 16px;
1051
  }
1052
 
1053
  .empty-state.small {
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;
@@ -1867,8 +1929,16 @@ select.input option {
1867
  color: var(--white);
1868
  }
1869
 
 
 
 
 
 
 
1870
  .map-controls .icon-btn.mono {
1871
  font-size: 0.65rem;
 
 
1872
  }
1873
 
1874
  .map-hint {
@@ -1881,6 +1951,264 @@ select.input option {
1881
  }
1882
 
1883
  /* Voice page */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1884
  .host-card {
1885
  display: grid;
1886
  gap: 10px;
@@ -2378,3 +2706,631 @@ select.input,
2378
  grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
2379
  }
2380
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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;
 
1929
  color: var(--white);
1930
  }
1931
 
1932
+ .map-controls .icon-btn {
1933
+ padding: 0;
1934
+ line-height: 1;
1935
+ }
1936
+
1937
+ /* Trailing letter-spacing from .mono pushes "1:1" off centre in the round button. */
1938
  .map-controls .icon-btn.mono {
1939
  font-size: 0.65rem;
1940
+ letter-spacing: 0;
1941
+ font-variant-numeric: tabular-nums;
1942
  }
1943
 
1944
  .map-hint {
 
1951
  }
1952
 
1953
  /* Voice page */
1954
+ .vp .vp-card {
1955
+ animation: welcome-in 0.35s ease both;
1956
+ }
1957
+
1958
+ .vp-steps {
1959
+ list-style: none;
1960
+ margin: 0;
1961
+ padding: 0;
1962
+ display: grid;
1963
+ grid-template-columns: repeat(2, 1fr);
1964
+ gap: 8px;
1965
+ font-size: 0.75rem;
1966
+ }
1967
+
1968
+ .vp-steps button {
1969
+ display: grid;
1970
+ gap: 8px;
1971
+ width: 100%;
1972
+ padding: 0;
1973
+ border: 0;
1974
+ background: none;
1975
+ color: var(--text-3);
1976
+ font: inherit;
1977
+ text-align: left;
1978
+ cursor: pointer;
1979
+ }
1980
+
1981
+ .vp-steps li.done button {
1982
+ color: var(--text-2);
1983
+ }
1984
+
1985
+ .vp-steps li.current button {
1986
+ color: var(--white);
1987
+ }
1988
+
1989
+ .vp-dot {
1990
+ height: 3px;
1991
+ border-radius: 999px;
1992
+ background: var(--line-strong);
1993
+ transition: background 0.3s, box-shadow 0.3s;
1994
+ }
1995
+
1996
+ .vp-steps li.done .vp-dot {
1997
+ background: var(--blue-60);
1998
+ }
1999
+
2000
+ .vp-steps li.current .vp-dot {
2001
+ background: var(--blue);
2002
+ box-shadow: var(--glow-sm);
2003
+ }
2004
+
2005
+ .voice-grid {
2006
+ list-style: none;
2007
+ margin: 0;
2008
+ padding: 0;
2009
+ display: grid;
2010
+ grid-template-columns: repeat(4, minmax(0, 1fr));
2011
+ gap: 12px;
2012
+ }
2013
+
2014
+ @media (max-width: 720px) {
2015
+ .voice-grid {
2016
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2017
+ }
2018
+ }
2019
+
2020
+ .voice-tile {
2021
+ position: relative;
2022
+ border: 1px solid var(--line);
2023
+ border-radius: var(--radius);
2024
+ transition: border-color 0.2s, box-shadow 0.2s, background 0.2s, transform 0.2s;
2025
+ }
2026
+
2027
+ .voice-tile:hover {
2028
+ border-color: var(--line-strong);
2029
+ transform: translateY(-2px);
2030
+ }
2031
+
2032
+ .voice-tile.on {
2033
+ border-color: var(--blue);
2034
+ background: var(--blue-05);
2035
+ box-shadow: var(--glow-sm);
2036
+ }
2037
+
2038
+ .voice-pick {
2039
+ width: 100%;
2040
+ display: grid;
2041
+ justify-items: center;
2042
+ gap: 4px;
2043
+ padding: 26px 12px 18px;
2044
+ border: 0;
2045
+ background: none;
2046
+ color: var(--white);
2047
+ font: inherit;
2048
+ text-align: center;
2049
+ cursor: pointer;
2050
+ }
2051
+
2052
+ .voice-pick strong {
2053
+ margin-top: 10px;
2054
+ font-size: 1rem;
2055
+ }
2056
+
2057
+ .voice-pick strong,
2058
+ .voice-pick span.small {
2059
+ max-width: 100%;
2060
+ overflow: hidden;
2061
+ text-overflow: ellipsis;
2062
+ white-space: nowrap;
2063
+ }
2064
+
2065
+ .voice-icon {
2066
+ display: grid;
2067
+ place-items: center;
2068
+ width: 76px;
2069
+ height: 76px;
2070
+ border-radius: 999px;
2071
+ border: 1px solid var(--blue-35);
2072
+ color: var(--blue);
2073
+ background: var(--blue-05);
2074
+ transition: background 0.2s, color 0.2s;
2075
+ }
2076
+
2077
+ .voice-tile.on .voice-icon {
2078
+ background: var(--blue);
2079
+ border-color: var(--blue);
2080
+ color: var(--white);
2081
+ box-shadow: var(--glow-sm);
2082
+ }
2083
+
2084
+ .voice-badge {
2085
+ position: absolute;
2086
+ top: 10px;
2087
+ left: 10px;
2088
+ padding: 1px 7px;
2089
+ border-radius: 999px;
2090
+ background: var(--blue);
2091
+ color: var(--white);
2092
+ font-size: 0.625rem;
2093
+ letter-spacing: 0.04em;
2094
+ pointer-events: none;
2095
+ }
2096
+
2097
+ .voice-badge.dim {
2098
+ background: var(--line-strong);
2099
+ color: var(--text-2);
2100
+ }
2101
+
2102
+ .voice-play {
2103
+ position: absolute;
2104
+ top: 8px;
2105
+ right: 8px;
2106
+ padding: 0;
2107
+ border-color: var(--line-strong);
2108
+ }
2109
+
2110
+ .voice-new {
2111
+ border-style: dashed;
2112
+ border-color: var(--line-strong);
2113
+ }
2114
+
2115
+ .voice-new:hover {
2116
+ border-color: var(--blue-60);
2117
+ }
2118
+
2119
+ .voice-icon.plus {
2120
+ border-style: dashed;
2121
+ background: transparent;
2122
+ }
2123
+
2124
+ .voice-new:hover .voice-icon.plus {
2125
+ color: var(--white);
2126
+ border-color: var(--blue);
2127
+ }
2128
+
2129
+ .gen-options {
2130
+ display: grid;
2131
+ gap: 12px;
2132
+ }
2133
+
2134
+ .gen-row {
2135
+ display: grid;
2136
+ gap: 6px;
2137
+ }
2138
+
2139
+ .gen-chips {
2140
+ display: flex;
2141
+ flex-wrap: wrap;
2142
+ gap: 6px;
2143
+ }
2144
+
2145
+ .gen-chips .chip {
2146
+ padding: 5px 12px;
2147
+ background: transparent;
2148
+ color: var(--text-2);
2149
+ font: inherit;
2150
+ font-size: 0.8125rem;
2151
+ cursor: pointer;
2152
+ text-transform: capitalize;
2153
+ }
2154
+
2155
+ .gen-chips .chip:hover {
2156
+ color: var(--white);
2157
+ }
2158
+
2159
+ .gen-chips .chip.on {
2160
+ background: var(--blue);
2161
+ border-color: var(--blue);
2162
+ color: var(--white);
2163
+ }
2164
+
2165
+ .gen-previews {
2166
+ list-style: none;
2167
+ margin: 0;
2168
+ padding: 0;
2169
+ display: grid;
2170
+ gap: 8px;
2171
+ }
2172
+
2173
+ .gen-previews li {
2174
+ display: flex;
2175
+ align-items: center;
2176
+ justify-content: space-between;
2177
+ gap: 12px;
2178
+ padding: 10px 12px;
2179
+ border: 1px solid var(--line);
2180
+ border-radius: var(--radius-sm);
2181
+ }
2182
+
2183
+ .gen-previews li.on {
2184
+ border-color: var(--blue);
2185
+ background: var(--blue-05);
2186
+ }
2187
+
2188
+ .vp-clone {
2189
+ display: grid;
2190
+ gap: 8px;
2191
+ padding: 14px 16px;
2192
+ border: 1px solid var(--line);
2193
+ border-radius: var(--radius-sm);
2194
+ }
2195
+
2196
+ .vp-upgrade {
2197
+ display: flex;
2198
+ flex-wrap: wrap;
2199
+ align-items: center;
2200
+ justify-content: space-between;
2201
+ gap: 12px;
2202
+ padding: 16px;
2203
+ border: 1px solid var(--blue-35);
2204
+ border-radius: var(--radius-sm);
2205
+ background: var(--blue-05);
2206
+ }
2207
+
2208
+ .vp-upgrade p {
2209
+ margin: 4px 0 0;
2210
+ }
2211
+
2212
  .host-card {
2213
  display: grid;
2214
  gap: 10px;
 
2706
  grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
2707
  }
2708
  }
2709
+
2710
+ /* Simplified layouts (2026-09-28): bigger components, fewer steps */
2711
+
2712
+ .clamp-1 {
2713
+ max-width: 100%;
2714
+ overflow: hidden;
2715
+ text-overflow: ellipsis;
2716
+ white-space: nowrap;
2717
+ }
2718
+
2719
+ /* Mission Control */
2720
+ .mc-hero {
2721
+ display: grid;
2722
+ gap: 18px;
2723
+ padding: clamp(18px, 3vw, 28px);
2724
+ }
2725
+
2726
+ .mc-ask {
2727
+ display: flex;
2728
+ align-items: center;
2729
+ gap: 12px;
2730
+ color: var(--blue);
2731
+ }
2732
+
2733
+ .mc-ask .input {
2734
+ flex: 1;
2735
+ min-width: 0;
2736
+ height: 54px;
2737
+ font-size: 1.05rem;
2738
+ }
2739
+
2740
+ .mc-ask .btn {
2741
+ height: 54px;
2742
+ padding: 0 28px;
2743
+ }
2744
+
2745
+ .mc-actions {
2746
+ display: grid;
2747
+ grid-template-columns: repeat(4, minmax(0, 1fr));
2748
+ gap: 12px;
2749
+ }
2750
+
2751
+ @media (max-width: 900px) {
2752
+ .mc-actions {
2753
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2754
+ }
2755
+ }
2756
+
2757
+ .mc-action {
2758
+ display: grid;
2759
+ justify-items: start;
2760
+ align-content: start;
2761
+ gap: 6px;
2762
+ min-height: 132px;
2763
+ padding: 18px;
2764
+ border: 1px solid var(--line);
2765
+ border-radius: var(--radius-sm);
2766
+ background: transparent;
2767
+ color: var(--white);
2768
+ font: inherit;
2769
+ text-align: left;
2770
+ text-decoration: none;
2771
+ cursor: pointer;
2772
+ transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
2773
+ }
2774
+
2775
+ .mc-action svg {
2776
+ color: var(--blue);
2777
+ margin-bottom: 6px;
2778
+ }
2779
+
2780
+ .mc-action:hover:not(:disabled) {
2781
+ border-color: var(--blue-60);
2782
+ box-shadow: var(--glow-sm);
2783
+ transform: translateY(-2px);
2784
+ }
2785
+
2786
+ .mc-action:disabled {
2787
+ opacity: 0.5;
2788
+ cursor: default;
2789
+ }
2790
+
2791
+ .mc-more {
2792
+ display: grid;
2793
+ place-items: center;
2794
+ width: 28px;
2795
+ height: 28px;
2796
+ margin-bottom: 6px;
2797
+ border: 1px dashed var(--blue-60);
2798
+ border-radius: 999px;
2799
+ color: var(--blue);
2800
+ font-size: 1.1rem;
2801
+ line-height: 1;
2802
+ }
2803
+
2804
+ .mc-next {
2805
+ display: flex;
2806
+ flex-wrap: wrap;
2807
+ align-items: center;
2808
+ justify-content: space-between;
2809
+ gap: 12px;
2810
+ padding: 14px 18px;
2811
+ border: 1px solid var(--blue-35);
2812
+ border-radius: var(--radius-sm);
2813
+ background: var(--blue-05);
2814
+ }
2815
+
2816
+ .mc-next p {
2817
+ margin: 0;
2818
+ flex: 1 1 320px;
2819
+ }
2820
+
2821
+ .mc-stats {
2822
+ display: grid;
2823
+ grid-template-columns: repeat(4, minmax(0, 1fr));
2824
+ gap: 12px;
2825
+ }
2826
+
2827
+ @media (max-width: 720px) {
2828
+ .mc-stats {
2829
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2830
+ }
2831
+ }
2832
+
2833
+ .mc-stat {
2834
+ display: grid;
2835
+ gap: 2px;
2836
+ padding: 14px 18px;
2837
+ border: 1px solid var(--line);
2838
+ border-radius: var(--radius-sm);
2839
+ color: var(--white);
2840
+ text-decoration: none;
2841
+ transition: border-color 0.2s;
2842
+ }
2843
+
2844
+ .mc-stat:hover {
2845
+ border-color: var(--line-strong);
2846
+ }
2847
+
2848
+ .mc-stat strong {
2849
+ font: 600 1.6rem var(--font-display, inherit);
2850
+ }
2851
+
2852
+ /* Notebook view: chat takes the room, one side panel with Create / Posts tabs */
2853
+ @media (min-width: 1100px) {
2854
+ .nbv {
2855
+ grid-template-columns: minmax(0, 1fr) minmax(340px, 420px);
2856
+ }
2857
+ }
2858
+
2859
+ .nbv-titlebar {
2860
+ display: flex;
2861
+ align-items: baseline;
2862
+ gap: 8px;
2863
+ min-width: 0;
2864
+ }
2865
+
2866
+ .nbv-titlebar .nbv-title {
2867
+ margin: 0;
2868
+ overflow: hidden;
2869
+ text-overflow: ellipsis;
2870
+ white-space: nowrap;
2871
+ }
2872
+
2873
+ .nbv-empty {
2874
+ display: grid;
2875
+ justify-items: center;
2876
+ gap: 12px;
2877
+ padding: 48px 16px;
2878
+ text-align: center;
2879
+ color: var(--blue);
2880
+ }
2881
+
2882
+ .nbv-starters {
2883
+ display: grid;
2884
+ gap: 8px;
2885
+ width: min(100%, 520px);
2886
+ }
2887
+
2888
+ .nbv-starter {
2889
+ padding: 12px 16px;
2890
+ border: 1px solid var(--line-strong);
2891
+ border-radius: var(--radius-sm);
2892
+ background: transparent;
2893
+ color: var(--text);
2894
+ font: inherit;
2895
+ text-align: left;
2896
+ cursor: pointer;
2897
+ transition: border-color 0.2s, background 0.2s;
2898
+ }
2899
+
2900
+ .nbv-starter:hover {
2901
+ border-color: var(--blue-60);
2902
+ background: var(--blue-05);
2903
+ }
2904
+
2905
+ .nbv-ask .input {
2906
+ height: 52px;
2907
+ font-size: 1rem;
2908
+ }
2909
+
2910
+ .nbv-ask .btn {
2911
+ height: 52px;
2912
+ padding: 0 24px;
2913
+ }
2914
+
2915
+ .nbv-make {
2916
+ display: grid;
2917
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2918
+ gap: 8px;
2919
+ }
2920
+
2921
+ .nbv-make button {
2922
+ padding: 12px;
2923
+ border: 1px solid var(--line);
2924
+ border-radius: var(--radius-sm);
2925
+ background: transparent;
2926
+ color: var(--white);
2927
+ font: 500 0.875rem var(--font-body);
2928
+ text-align: left;
2929
+ cursor: pointer;
2930
+ transition: border-color 0.2s, box-shadow 0.2s;
2931
+ }
2932
+
2933
+ .nbv-make button:hover:not(:disabled) {
2934
+ border-color: var(--blue-60);
2935
+ }
2936
+
2937
+ .nbv-make button:disabled {
2938
+ opacity: 0.5;
2939
+ cursor: default;
2940
+ }
2941
+
2942
+ .nbv-make .nbv-make-main {
2943
+ grid-column: 1 / -1;
2944
+ display: grid;
2945
+ gap: 2px;
2946
+ padding: 16px;
2947
+ border-color: var(--blue-35);
2948
+ background: var(--blue-05);
2949
+ }
2950
+
2951
+ .nbv-make .nbv-make-main strong {
2952
+ font-size: 1rem;
2953
+ }
2954
+
2955
+ /* Notebooks: big clickable cards, options in a menu */
2956
+ .nb-grid {
2957
+ display: grid;
2958
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
2959
+ gap: 14px;
2960
+ }
2961
+
2962
+ .nb-grid .nb-card {
2963
+ position: relative;
2964
+ min-height: 170px;
2965
+ }
2966
+
2967
+ .nb-grid .nb-card-link {
2968
+ height: 100%;
2969
+ align-content: start;
2970
+ color: var(--white);
2971
+ }
2972
+
2973
+ .nb-card-link svg {
2974
+ color: var(--blue);
2975
+ }
2976
+
2977
+ .nb-archive {
2978
+ border-color: var(--blue-35);
2979
+ background: var(--blue-05);
2980
+ }
2981
+
2982
+ .nb-new {
2983
+ display: grid;
2984
+ justify-items: start;
2985
+ align-content: center;
2986
+ gap: 4px;
2987
+ border-style: dashed;
2988
+ border-color: var(--line-strong);
2989
+ color: var(--white);
2990
+ font: inherit;
2991
+ text-align: left;
2992
+ cursor: pointer;
2993
+ }
2994
+
2995
+ .nb-new:hover {
2996
+ border-color: var(--blue-60);
2997
+ }
2998
+
2999
+ .card-menu {
3000
+ position: absolute;
3001
+ top: 10px;
3002
+ right: 10px;
3003
+ }
3004
+
3005
+ .card-menu-list {
3006
+ position: absolute;
3007
+ top: 34px;
3008
+ right: 0;
3009
+ z-index: 5;
3010
+ display: grid;
3011
+ min-width: 140px;
3012
+ padding: 6px;
3013
+ border: 1px solid var(--line-strong);
3014
+ border-radius: var(--radius-sm);
3015
+ background: var(--black, #000);
3016
+ box-shadow: var(--glow-sm);
3017
+ }
3018
+
3019
+ .card-menu-list button {
3020
+ padding: 8px 10px;
3021
+ border: 0;
3022
+ border-radius: 6px;
3023
+ background: none;
3024
+ color: var(--text);
3025
+ font: inherit;
3026
+ text-align: left;
3027
+ cursor: pointer;
3028
+ }
3029
+
3030
+ .card-menu-list button:hover {
3031
+ background: var(--blue-10);
3032
+ }
3033
+
3034
+ .nb-matches {
3035
+ max-height: 320px;
3036
+ overflow: auto;
3037
+ }
3038
+
3039
+ /* Create (Studio): format tiles and one row of choices */
3040
+ .format-grid {
3041
+ display: grid;
3042
+ grid-template-columns: repeat(5, minmax(0, 1fr));
3043
+ gap: 12px;
3044
+ }
3045
+
3046
+ @media (max-width: 900px) {
3047
+ .format-grid {
3048
+ grid-template-columns: repeat(2, minmax(0, 1fr));
3049
+ }
3050
+ }
3051
+
3052
+ .format-tile {
3053
+ display: grid;
3054
+ justify-items: center;
3055
+ align-content: center;
3056
+ gap: 10px;
3057
+ min-height: 120px;
3058
+ padding: 16px 10px;
3059
+ border: 1px solid var(--line);
3060
+ border-radius: var(--radius-sm);
3061
+ background: transparent;
3062
+ color: var(--white);
3063
+ font: inherit;
3064
+ text-align: center;
3065
+ text-decoration: none;
3066
+ cursor: pointer;
3067
+ transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
3068
+ }
3069
+
3070
+ .format-tile svg {
3071
+ color: var(--blue);
3072
+ }
3073
+
3074
+ .format-tile:hover {
3075
+ border-color: var(--line-strong);
3076
+ }
3077
+
3078
+ .format-tile.on {
3079
+ border-color: var(--blue);
3080
+ background: var(--blue-05);
3081
+ box-shadow: var(--glow-sm);
3082
+ }
3083
+
3084
+ .studio-row {
3085
+ display: flex;
3086
+ flex-wrap: wrap;
3087
+ align-items: flex-end;
3088
+ gap: 12px;
3089
+ }
3090
+
3091
+ .studio-row .field {
3092
+ flex: 1 1 200px;
3093
+ }
3094
+
3095
+ .studio-row .field:first-child {
3096
+ flex: 2 1 280px;
3097
+ }
3098
+
3099
+ .studio-go {
3100
+ height: 44px;
3101
+ padding: 0 26px;
3102
+ }
3103
+
3104
+ /* Launch Kit: post cards to start, a thin rail of kits once one is open */
3105
+ @media (min-width: 1100px) {
3106
+ .kit-layout.kit-open {
3107
+ grid-template-columns: minmax(200px, 260px) minmax(0, 1fr);
3108
+ }
3109
+ }
3110
+
3111
+ .kit-rail {
3112
+ display: grid;
3113
+ gap: 10px;
3114
+ align-content: start;
3115
+ }
3116
+
3117
+ .post-cards {
3118
+ display: grid;
3119
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
3120
+ gap: 12px;
3121
+ }
3122
+
3123
+ .post-card {
3124
+ display: grid;
3125
+ align-content: space-between;
3126
+ gap: 10px;
3127
+ min-height: 150px;
3128
+ padding: 18px;
3129
+ border: 1px solid var(--line);
3130
+ border-radius: var(--radius-sm);
3131
+ background: transparent;
3132
+ color: var(--white);
3133
+ font: inherit;
3134
+ text-align: left;
3135
+ cursor: pointer;
3136
+ transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
3137
+ }
3138
+
3139
+ .post-card strong {
3140
+ font-size: 1.02rem;
3141
+ line-height: 1.35;
3142
+ }
3143
+
3144
+ .post-card-go {
3145
+ color: var(--blue);
3146
+ font-size: 0.8125rem;
3147
+ font-weight: 600;
3148
+ }
3149
+
3150
+ .post-card:hover:not(:disabled) {
3151
+ border-color: var(--blue-60);
3152
+ box-shadow: var(--glow-sm);
3153
+ transform: translateY(-2px);
3154
+ }
3155
+
3156
+ .post-card:disabled {
3157
+ opacity: 0.6;
3158
+ cursor: default;
3159
+ }
3160
+
3161
+ /* Launchpad: account chips in one line, calendar first, ideas beside it */
3162
+ .lp-accounts {
3163
+ display: flex;
3164
+ flex-wrap: wrap;
3165
+ align-items: center;
3166
+ gap: 8px;
3167
+ }
3168
+
3169
+ .lp-chip {
3170
+ display: inline-flex;
3171
+ align-items: center;
3172
+ gap: 6px;
3173
+ padding: 5px 12px;
3174
+ border: 1px solid var(--line-strong);
3175
+ border-radius: 999px;
3176
+ background: transparent;
3177
+ color: var(--white);
3178
+ font: inherit;
3179
+ font-size: 0.8125rem;
3180
+ }
3181
+
3182
+ .lp-chip img {
3183
+ border-radius: 999px;
3184
+ }
3185
+
3186
+ .lp-chip.warn {
3187
+ border-color: #ff8a3d;
3188
+ }
3189
+
3190
+ .lp-chip.add {
3191
+ border-style: dashed;
3192
+ color: var(--text-2);
3193
+ cursor: pointer;
3194
+ }
3195
+
3196
+ .lp-chip.add:hover {
3197
+ border-color: var(--blue);
3198
+ color: var(--white);
3199
+ }
3200
+
3201
+ .lp-layout {
3202
+ display: grid;
3203
+ gap: 16px;
3204
+ align-items: start;
3205
+ }
3206
+
3207
+ @media (min-width: 1200px) {
3208
+ .lp-layout {
3209
+ grid-template-columns: minmax(0, 1fr) minmax(300px, 380px);
3210
+ }
3211
+ }
3212
+
3213
+ .lp-ideas {
3214
+ align-content: start;
3215
+ }
3216
+
3217
+ .lp-ideas .idea {
3218
+ display: grid;
3219
+ gap: 6px;
3220
+ padding-bottom: 12px;
3221
+ border-bottom: 1px solid var(--line);
3222
+ }
3223
+
3224
+ .lp-ideas .idea:last-child {
3225
+ border-bottom: 0;
3226
+ }
3227
+
3228
+ .lp-empty {
3229
+ display: flex;
3230
+ flex-wrap: wrap;
3231
+ align-items: center;
3232
+ justify-content: space-between;
3233
+ gap: 12px;
3234
+ margin-bottom: 12px;
3235
+ padding: 12px 14px;
3236
+ border: 1px dashed var(--line-strong);
3237
+ border-radius: var(--radius-sm);
3238
+ }
3239
+
3240
+ .lp-empty p {
3241
+ margin: 0;
3242
+ }
3243
+
3244
+ /* Topic map: full width until a topic or the briefing is open */
3245
+ @media (min-width: 1100px) {
3246
+ .map-layout.map-full {
3247
+ grid-template-columns: minmax(0, 1fr);
3248
+ }
3249
+ }
3250
+
3251
+ .map-menu {
3252
+ position: relative;
3253
+ top: auto;
3254
+ right: auto;
3255
+ }
3256
+
3257
+ details.map-legend summary {
3258
+ cursor: pointer;
3259
+ list-style: none;
3260
+ }
3261
+
3262
+ details.map-legend:not([open]) {
3263
+ padding: 4px 10px;
3264
+ }
3265
+
3266
+ details.map-legend[open] summary {
3267
+ margin-bottom: 4px;
3268
+ }
3269
+
3270
+ .topic-actions {
3271
+ display: grid;
3272
+ gap: 8px;
3273
+ }
3274
+
3275
+ /* Sources: one smart input, files drop anywhere */
3276
+ .add-smart {
3277
+ display: flex;
3278
+ flex-wrap: wrap;
3279
+ align-items: center;
3280
+ gap: 10px;
3281
+ }
3282
+
3283
+ .add-smart .input {
3284
+ flex: 1 1 320px;
3285
+ height: 50px;
3286
+ font-size: 1rem;
3287
+ }
3288
+
3289
+ .add-smart .btn {
3290
+ height: 50px;
3291
+ padding: 0 22px;
3292
+ }
3293
+
3294
+ .page-wrap.drop-over {
3295
+ outline: 2px dashed var(--blue);
3296
+ outline-offset: 8px;
3297
+ border-radius: var(--radius);
3298
+ }
3299
+
3300
+ /* Sidebar: seven stops, Settings pinned to the bottom */
3301
+ @media (min-width: 900px) {
3302
+ .sidebar .side-settings {
3303
+ margin-top: auto;
3304
+ }
3305
+
3306
+ .sidebar .side-settings ~ .sidebar-user {
3307
+ margin-top: 0;
3308
+ }
3309
+ }
3310
+
3311
+ @media (max-width: 899px) {
3312
+ .sidebar {
3313
+ position: relative;
3314
+ }
3315
+
3316
+ .sidebar .side-settings {
3317
+ position: absolute;
3318
+ top: 14px;
3319
+ right: 16px;
3320
+ padding: 8px;
3321
+ }
3322
+
3323
+ .sidebar .side-settings .side-label {
3324
+ display: none;
3325
+ }
3326
+ }
3327
+
3328
+ @media (min-width: 900px) {
3329
+ .shell.collapsed .side-settings {
3330
+ display: none;
3331
+ }
3332
+ }
3333
+
3334
+ .tabs a.tab {
3335
+ text-decoration: none;
3336
+ }
frontend/src/styles/tools.css ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Free tools (/tools/*). Builds on blog.css (.alt-page, .alt-section, .alt-grid). */
2
+
3
+ .tool-card-title {
4
+ font-size: 1.15rem;
5
+ margin: 0 0 0.5rem;
6
+ }
7
+
8
+ .tool-gate {
9
+ text-align: center;
10
+ padding: 2rem 1.5rem;
11
+ }
12
+
13
+ .tool-gate h2 {
14
+ margin-top: 0;
15
+ }
16
+
17
+ .tool-gate .alt-ctas {
18
+ justify-content: center;
19
+ }
20
+
21
+ .tool-widget {
22
+ display: grid;
23
+ gap: 1rem;
24
+ }
25
+
26
+ /* Flat, opaque writing surface: overrides the pill shaped, see-through .input so the starfield does not show
27
+ through and the box edge is obvious. */
28
+ .tool-input {
29
+ display: block;
30
+ width: 100%;
31
+ height: auto;
32
+ min-height: 14rem;
33
+ padding: 14px 16px;
34
+ resize: vertical;
35
+ border-radius: 8px;
36
+ border: 1px solid rgba(255, 255, 255, 0.28);
37
+ background: #0d0f14;
38
+ box-shadow: none;
39
+ color: var(--text);
40
+ font: 400 1rem/1.6 var(--font-body);
41
+ }
42
+
43
+ .tool-input:hover {
44
+ border-color: rgba(255, 255, 255, 0.4);
45
+ }
46
+
47
+ .tool-input:focus {
48
+ border-color: var(--blue);
49
+ box-shadow: none;
50
+ }
51
+
52
+ .tool-input::placeholder {
53
+ color: var(--text-2);
54
+ }
55
+
56
+ .tool-output {
57
+ display: grid;
58
+ gap: 1rem;
59
+ }
60
+
61
+ .tool-output-head {
62
+ display: flex;
63
+ flex-wrap: wrap;
64
+ gap: 0.75rem;
65
+ align-items: center;
66
+ justify-content: space-between;
67
+ }
68
+
69
+ .tool-actions {
70
+ display: flex;
71
+ gap: 0.5rem;
72
+ }
73
+
74
+ .tool-script {
75
+ list-style: none;
76
+ margin: 0;
77
+ padding: 0;
78
+ display: grid;
79
+ gap: 0.85rem;
80
+ }
81
+
82
+ .tool-script li {
83
+ display: grid;
84
+ grid-template-columns: 5.5rem 1fr;
85
+ gap: 0.75rem;
86
+ }
87
+
88
+ .tool-script p {
89
+ margin: 0;
90
+ }
91
+
92
+ .tool-script .host-B p {
93
+ opacity: 0.8;
94
+ }
95
+
96
+ .host-label {
97
+ font-size: 0.75rem;
98
+ opacity: 0.7;
99
+ padding-top: 0.2rem;
100
+ }
101
+
102
+ .tool-next {
103
+ display: flex;
104
+ flex-wrap: wrap;
105
+ gap: 1rem;
106
+ align-items: center;
107
+ justify-content: space-between;
108
+ }
109
+
110
+ @media (max-width: 560px) {
111
+ .tool-script li {
112
+ grid-template-columns: 1fr;
113
+ gap: 0.2rem;
114
+ }
115
+ }