Arslan1997 commited on
Commit
3c705c5
·
1 Parent(s): ba40d17

LETS GOOOOOOOOOOOOOOOOOOOOOOOOO

Browse files
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/routers/artifacts.py CHANGED
@@ -7,6 +7,7 @@ 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
@@ -28,6 +29,7 @@ class GenerateIn(BaseModel):
28
  type: Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit"]
29
  notebook_id: uuid.UUID | None = None
30
  document_id: uuid.UUID | None = None
 
31
  # audio_overview
32
  format: Literal["deep_dive", "brief", "debate"] = "deep_dive"
33
  minutes: int = Field(6, ge=1, le=30)
@@ -75,6 +77,8 @@ def _target_title(ctx: Ctx, notebook_id: uuid.UUID | None, document_id: uuid.UUI
75
  @router.post("/generate")
76
  def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
77
  """One entry point for every generated artifact."""
 
 
78
  params: dict = {}
79
  content: dict = {}
80
  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
 
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)
 
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
@@ -72,6 +72,29 @@ def create_notebook(body: NotebookIn, ctx: Ctx = Depends(get_ctx)):
72
  return {"id": str(nb.id), "title": nb.title, "added": added}
73
 
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  @router.post("/from-topic/{topic_id}")
76
  def notebook_from_topic(topic_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
77
  topic = ctx.db.scalar(select(Topic).where(Topic.id == topic_id, Topic.workspace_id == ctx.workspace.id))
@@ -89,7 +112,8 @@ def notebook_from_topic(topic_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
89
  @router.get("")
90
  def list_notebooks(ctx: Ctx = Depends(get_ctx)):
91
  rows = ctx.db.scalars(
92
- select(Notebook).where(Notebook.workspace_id == ctx.workspace.id).order_by(Notebook.updated_at.desc())
 
93
  ).all()
94
  ids = [n.id for n in rows]
95
  doc_counts = dict(ctx.db.execute(
@@ -105,7 +129,7 @@ def list_notebooks(ctx: Ctx = Depends(get_ctx)):
105
  return [
106
  {"id": str(n.id), "title": n.title, "description": n.description, "summary": n.summary,
107
  "document_count": doc_counts.get(n.id, 0), "chat_count": chat_counts.get(n.id, 0),
108
- "artifact_count": artifact_counts.get(n.id, 0),
109
  "updated_at": n.updated_at.isoformat() if n.updated_at else None}
110
  for n in rows
111
  ]
@@ -114,6 +138,8 @@ def list_notebooks(ctx: Ctx = Depends(get_ctx)):
114
  @router.get("/{notebook_id}")
115
  def get_notebook(notebook_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
116
  nb = _get(ctx, notebook_id)
 
 
117
  docs = ctx.db.scalars(
118
  select(Document)
119
  .join(NotebookDocument, NotebookDocument.document_id == Document.id)
@@ -125,6 +151,7 @@ def get_notebook(notebook_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
125
  "title": nb.title,
126
  "description": nb.description,
127
  "summary": nb.summary,
 
128
  "documents": [serialize_document(d) for d in docs],
129
  }
130
 
 
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/voice.py CHANGED
@@ -165,6 +165,11 @@ def update_voice(body: ProfileIn, ctx: Ctx = Depends(get_ctx)):
165
 
166
  @router.post("/build")
167
  def build(body: BuildIn, ctx: Ctx = Depends(get_ctx)):
 
 
 
 
 
168
  job = create_job(ctx.db, ctx.workspace.id, "voice_profile",
169
  {"document_ids": [str(i) for i in body.document_ids]}, max_attempts=2)
170
  return serialize_job(job)
 
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)
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
 
 
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
 
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):
 
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):
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/src/api/endpoints.ts CHANGED
@@ -56,6 +56,8 @@ export const notebooksApi = {
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
  fromTopic: (topicId: string) => post<{ id: string; title: string }>(`/api/notebooks/from-topic/${topicId}`),
60
  update: (id: string, body: { title?: string; description?: string }) => patch(`/api/notebooks/${id}`, body),
61
  remove: (id: string) => del(`/api/notebooks/${id}`),
@@ -72,6 +74,8 @@ export type GenerateBody = {
72
  type: ArtifactType;
73
  notebook_id?: string;
74
  document_id?: string;
 
 
75
  format?: "deep_dive" | "brief" | "debate";
76
  minutes?: number;
77
  style?: "short" | "explainer" | "audiogram";
 
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";
frontend/src/api/types.ts CHANGED
@@ -56,6 +56,8 @@ export type NotebookSummary = {
56
  document_count: number;
57
  chat_count: number;
58
  artifact_count: number;
 
 
59
  updated_at: string | null;
60
  };
61
 
@@ -64,6 +66,7 @@ export type Notebook = {
64
  title: string;
65
  description: string | null;
66
  summary: string | null;
 
67
  documents: Doc[];
68
  };
69
 
 
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
 
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/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/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
  }
@@ -171,9 +171,6 @@ export default function AuthPage() {
171
  <>
172
  {providers?.google === "redirect" && <GoogleRedirectButton next={next} />}
173
  {providers?.google === "popup" && <GoogleButton onCredential={onGoogle} />}
174
- {providers && !providers.google && (
175
- <p className="mono muted auth-hint">Set GOOGLE_CLIENT_ID in .env to enable Google sign in.</p>
176
- )}
177
  {!providers && <div className="google-btn" />}
178
  <div className="auth-divider mono">
179
  <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
  }
 
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/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/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,81 +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
  import { useUpgrade } from "../hooks/useUpgrade";
8
 
9
- type AddMode = "feed" | "url" | "file";
10
-
11
  const PAGE = 50;
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  export function AddSource({ onAdded }: { onAdded: (source: Source, job: Job) => void }) {
14
- const [mode, setMode] = useState<AddMode>("feed");
15
  const [value, setValue] = useState("");
 
16
  const [busy, setBusy] = useState(false);
17
  const [error, setError] = useState<string | null>(null);
18
  const fileRef = useRef<HTMLInputElement>(null);
 
19
 
20
  const submit = async (e: FormEvent) => {
21
  e.preventDefault();
 
22
  setError(null);
23
  setBusy(true);
24
  try {
25
- if (mode === "file") {
26
- const file = fileRef.current?.files?.[0];
27
- if (!file) throw new Error("Choose a file first.");
28
- const res = await sourcesApi.importFile(file);
29
- onAdded(res.source, res.job);
30
- if (fileRef.current) fileRef.current.value = "";
31
- } else {
32
- if (!value.trim()) return;
33
- const res = mode === "feed" ? await sourcesApi.connect(value.trim()) : await sourcesApi.importUrl(value.trim());
34
- onAdded(res.source, res.job);
35
- setValue("");
36
- }
37
  } catch (err) {
38
- setError(errorMessage(err, "Could not add that."));
39
  } finally {
40
  setBusy(false);
41
  }
42
  };
43
 
 
 
 
 
 
 
 
 
 
 
 
44
  return (
45
  <div className="card add-source">
46
- <Tabs<AddMode>
47
- tabs={[
48
- { id: "feed", label: "Connect a feed" },
49
- { id: "url", label: "Import one URL" },
50
- { id: "file", label: "Upload a file" },
51
- ]}
52
- value={mode}
53
- onChange={(m) => {
54
- setMode(m);
55
- setError(null);
56
- }}
57
- />
58
- <p className="muted">
59
- {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."}
60
- {mode === "url" && "One article from anywhere on the web. It lands in Imported posts."}
61
- {mode === "file" && "Markdown, text, HTML or PDF. Handy for drafts and posts that never had a feed."}
62
- </p>
63
- <form onSubmit={submit} className="inline-form">
64
- {mode === "file" ? (
65
- <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" />
66
- ) : (
67
- <input
68
- className="input"
69
- placeholder={mode === "feed" ? "yourblog.com" : "https://example.com/my-essay"}
70
- value={value}
71
- onChange={(e) => setValue(e.target.value)}
72
- aria-label={mode === "feed" ? "Feed or site URL" : "Article URL"}
73
- />
74
- )}
75
- <button className="btn btn-primary" type="submit" disabled={busy}>
76
- {busy ? (mode === "feed" ? "Finding feed..." : "Starting...") : mode === "feed" ? "Connect" : "Import"}
77
  </button>
 
78
  </form>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  {error && (
80
  <p className="error-text" role="alert">
81
  {error}
@@ -115,7 +171,7 @@ export default function Sources() {
115
  });
116
 
117
  useEffect(() => {
118
- loadSources().catch(() => setError("Could not reach the API. Is it running?"));
119
  }, [loadSources]);
120
 
121
  useEffect(() => {
@@ -144,8 +200,29 @@ export default function Sources() {
144
  watch(source.id, job);
145
  };
146
 
 
 
147
  return (
148
- <div className="page-wrap">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  <PageHeader eyebrow="Sources" title="Your knowledge, in orbit" />
150
  <AddSource onAdded={added} />
151
  {error && <p className="error-text">{error}</p>}
 
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}
 
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>}
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/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,6 +1,6 @@
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
  import { voiceApi } from "../api/endpoints";
3
- import type { Delivery, Job, LibraryVoice, Quota, 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";
@@ -435,7 +435,7 @@ function GenerateVoiceModal({
435
  </div>
436
  </div>
437
  )}
438
- {!state.tts_configured && <p className="muted small">Set ELEVENLABS_API_KEY in .env to generate voices.</p>}
439
  {error && <p className="error-text">{error}</p>}
440
  </div>
441
  </Modal>
@@ -734,21 +734,20 @@ function CloneModal({ state, onClose, onDone }: { state: VoiceState; onClose: ()
734
  );
735
  }
736
 
737
- // The page: a three step flow. Writing voice first, then the two hosts, and cloning last.
 
738
 
739
- type Step = "writing" | "hosts" | "clone";
740
 
741
  const STEPS: { id: Step; label: string }[] = [
742
  { id: "writing", label: "Writing voice" },
743
  { id: "hosts", label: "Speaking voices" },
744
- { id: "clone", label: "Your own voice" },
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 [quota, setQuota] = useState<Quota>({});
752
  const [step, setStep] = useState<Step | null>(null);
753
  const [picking, setPicking] = useState(false);
754
  const [samples, setSamples] = useState<string[]>([]);
@@ -759,8 +758,8 @@ export default function VoiceProfile() {
759
  const [generating, setGenerating] = useState<Host | null>(null);
760
  const [saved, setSaved] = useState(false);
761
  const [error, setError] = useState<string | null>(null);
 
762
  const { play, playing } = usePlayer();
763
- const { openUpgrade } = useUpgrade();
764
 
765
  const load = () =>
766
  voiceApi.get().then((s) => {
@@ -768,13 +767,30 @@ export default function VoiceProfile() {
768
  setDraft(s.profile);
769
  setSamples(s.sample_doc_ids);
770
  setStep((cur) => cur ?? (s.profile ? "hosts" : "writing"));
 
771
  });
772
  const loadVoices = () => voiceApi.voices().then(setVoices, () => setVoices([]));
773
 
 
 
 
 
 
 
 
 
 
 
774
  useEffect(() => {
775
- load();
 
 
 
 
 
 
776
  loadVoices();
777
- voiceApi.quota().then(setQuota, () => setQuota({}));
778
  }, []);
779
 
780
  const build = useJob(buildJob, () => load());
@@ -786,22 +802,8 @@ export default function VoiceProfile() {
786
  if (!state || !step) return <Loading />;
787
  const building = build && (build.status === "queued" || build.status === "running");
788
  const cloneRunning = clone && (clone.status === "queued" || clone.status === "running");
789
- const stepIndex = STEPS.findIndex((s) => s.id === step);
790
- const done: Record<Step, boolean> = {
791
- writing: Boolean(state.profile),
792
- hosts: Boolean(state.profile) && stepIndex > 1,
793
- clone: state.clone.status === "ready",
794
- };
795
-
796
- const startBuild = async (ids: string[]) => {
797
- setPicking(false);
798
- setError(null);
799
- try {
800
- setBuildJob(await voiceApi.build(ids));
801
- } catch (e) {
802
- setError(errorMessage(e));
803
- }
804
- };
805
 
806
  const saveProfile = async () => {
807
  setError(null);
@@ -835,20 +837,16 @@ export default function VoiceProfile() {
835
 
836
  {step === "writing" && (
837
  <section className="card stack vp-card">
838
- <p className="eyebrow">Step 1 of 3</p>
839
- <h2>Teach Notestack how you write</h2>
840
  <p className="muted">Launch Kits, carousels, hooks and scripts are all written in this voice, so they read like you wrote them.</p>
841
  {build && (building || build.status === "failed") && <JobProgress job={build} />}
842
-
843
  {!draft && !building && (
844
- <div className="vp-choices">
845
- <button className="vp-choice" onClick={() => startBuild([])}>
846
- <strong>Let Notestack choose</strong>
847
- <span className="muted small">We read your longest posts. One click, about a minute.</span>
848
  </button>
849
- <button className="vp-choice" onClick={() => setPicking(true)}>
850
- <strong>I'll pick my posts</strong>
851
- <span className="muted small">Choose 5 to 10 posts that sound most like you.</span>
852
  </button>
853
  </div>
854
  )}
@@ -902,103 +900,46 @@ export default function VoiceProfile() {
902
 
903
  {step === "hosts" && (
904
  <section className="card stack vp-card">
905
- <p className="eyebrow">Step 2 of 3</p>
906
- <h2>Pick who reads your work aloud</h2>
907
- <p className="muted">
908
- Audio overviews are a conversation between two hosts, and videos are narrated by Host A. Tap a voice to use it, press play to hear it, or generate your own.
909
- </p>
910
- {!state.tts_configured && <p className="muted">Set ELEVENLABS_API_KEY in .env to generate audio.</p>}
911
  <VoicePicker state={state} voices={voices} onSaved={setState} onBrowse={setBrowsing} onGenerate={setGenerating} />
912
- <div className="row between">
913
- <button className="btn btn-small" onClick={() => setStep("writing")}>
914
- Back
915
- </button>
916
- <button className="btn btn-primary" onClick={() => setStep("clone")}>
917
- Last step: use your own voice
918
- </button>
919
- </div>
920
- </section>
921
- )}
922
-
923
- {step === "clone" && (
924
- <section className="card stack vp-card">
925
- <p className="eyebrow">Step 3 of 3</p>
926
- <h2>Make Host A sound exactly like you</h2>
927
-
928
- {!state.clone.allowed && (
929
- <>
930
- <p className="muted">Clone your voice and every audio overview and video you make is narrated by you, not a stock voice.</p>
931
- <ol className="vp-how">
932
- <li>
933
- <strong>Read for 1 to 3 minutes</strong>
934
- <span className="muted small">We give you a passage from your own writing.</span>
935
- </li>
936
- <li>
937
- <strong>We build your voice</strong>
938
- <span className="muted small">Takes a few minutes. Only you can use it.</span>
939
- </li>
940
- <li>
941
- <strong>Everything sounds like you</strong>
942
- <span className="muted small">Your voice becomes Host A across overviews and videos.</span>
943
- </li>
944
- </ol>
945
- <div className="vp-upgrade">
946
- <div>
947
- <strong>Voice cloning is on the Writer and Studio plans</strong>
948
- <p className="muted small">Your writing voice and hosts are all set. Upgrade whenever you want to add your own.</p>
949
- </div>
950
- <button className="btn btn-primary" onClick={() => openUpgrade("writer")}>
951
- Upgrade to clone my voice
952
- </button>
953
- </div>
954
- </>
955
- )}
956
 
957
- {state.clone.allowed && quota.can_use_instant_voice_cloning === false && (
958
- <p className="muted small">Your ElevenLabs plan does not include instant voice cloning. Upgrade it at elevenlabs.io.</p>
959
- )}
960
- {state.clone.allowed && (state.clone.status === "none" || state.clone.status === "failed") && !cloneRunning && (
961
- <>
962
- <p className="muted">
963
- Record one to three minutes of your own writing, read aloud. Your voice becomes Host A, so audio overviews and videos sound like you.
964
- </p>
965
- {state.clone.status === "failed" && state.clone.error && <p className="error-text">{state.clone.error}</p>}
966
  <div className="row">
967
- <button className="btn btn-primary" onClick={() => setCloning(true)} disabled={!state.tts_configured}>
968
- Clone my voice
969
- </button>
970
- </div>
971
- </>
972
- )}
973
- {clone && (cloneRunning || clone.status === "failed") && <JobProgress job={clone} />}
974
- {state.clone.status === "processing" && !clone && <p className="muted">Your voice is being created.</p>}
975
- {state.clone.status === "ready" && (
976
- <>
977
- <p className="muted">Your voice is live and set as Host A. Adjust its delivery in step 2.</p>
978
- <div className="row">
979
- {state.clone.preview_url && (
980
  <button className="btn btn-small" onClick={() => play(state.clone.preview_url!, "clone")}>
981
  {playing === "clone" ? "Stop" : "Hear my voice"}
982
  </button>
983
  )}
984
- <button className="btn btn-small" onClick={() => setCloning(true)}>
985
- Record again
986
- </button>
987
- <ConfirmButton
988
- confirmLabel="Revoke and delete my voice?"
989
- onConfirm={async () => {
990
- setState(await voiceApi.revoke());
991
- loadVoices();
992
- }}
993
- >
994
- Revoke consent
995
- </ConfirmButton>
 
 
 
 
996
  </div>
997
- </>
998
  )}
 
999
  <div className="row">
1000
- <button className="btn btn-small" onClick={() => setStep("hosts")}>
1001
- Back
1002
  </button>
1003
  </div>
1004
  </section>
 
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";
 
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>
 
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[]>([]);
 
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 = () =>
765
  voiceApi.get().then((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());
 
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);
 
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
  )}
 
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 () => {
929
+ setState(await voiceApi.revoke());
930
+ loadVoices();
931
+ }}
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>
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
@@ -1952,17 +1952,15 @@ select.input option {
1952
 
1953
  /* Voice page */
1954
  .vp .vp-card {
1955
- max-width: 760px;
1956
  animation: welcome-in 0.35s ease both;
1957
  }
1958
 
1959
  .vp-steps {
1960
  list-style: none;
1961
- margin: 0 0 20px;
1962
  padding: 0;
1963
- max-width: 760px;
1964
  display: grid;
1965
- grid-template-columns: repeat(3, 1fr);
1966
  gap: 8px;
1967
  font-size: 0.75rem;
1968
  }
@@ -2004,32 +2002,6 @@ select.input option {
2004
  box-shadow: var(--glow-sm);
2005
  }
2006
 
2007
- .vp-choices {
2008
- display: grid;
2009
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
2010
- gap: 12px;
2011
- }
2012
-
2013
- .vp-choice {
2014
- display: grid;
2015
- gap: 6px;
2016
- padding: 18px;
2017
- border: 1px solid var(--line-strong);
2018
- border-radius: var(--radius-sm);
2019
- background: transparent;
2020
- color: var(--white);
2021
- font: inherit;
2022
- text-align: left;
2023
- cursor: pointer;
2024
- transition: border-color 0.2s, box-shadow 0.2s;
2025
- }
2026
-
2027
- .vp-choice:hover,
2028
- .vp-choice:focus-visible {
2029
- border-color: var(--blue-60);
2030
- box-shadow: var(--glow-sm);
2031
- }
2032
-
2033
  .voice-grid {
2034
  list-style: none;
2035
  margin: 0;
@@ -2213,29 +2185,12 @@ select.input option {
2213
  background: var(--blue-05);
2214
  }
2215
 
2216
- .vp-how {
2217
- list-style: none;
2218
- margin: 0;
2219
- padding: 0;
2220
  display: grid;
2221
- grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
2222
- gap: 12px;
2223
- counter-reset: vp-how;
2224
- }
2225
-
2226
- .vp-how li {
2227
- display: grid;
2228
- gap: 4px;
2229
- padding: 14px;
2230
  border: 1px solid var(--line);
2231
  border-radius: var(--radius-sm);
2232
- counter-increment: vp-how;
2233
- }
2234
-
2235
- .vp-how li::before {
2236
- content: counter(vp-how);
2237
- font: 600 0.75rem var(--font-mono);
2238
- color: var(--blue);
2239
  }
2240
 
2241
  .vp-upgrade {
@@ -2751,3 +2706,631 @@ select.input,
2751
  grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
2752
  }
2753
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
  }
 
2002
  box-shadow: var(--glow-sm);
2003
  }
2004
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2005
  .voice-grid {
2006
  list-style: none;
2007
  margin: 0;
 
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 {
 
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
+ }