diff --git a/backend/alembic/versions/0004_archive_notebook.py b/backend/alembic/versions/0004_archive_notebook.py new file mode 100644 index 0000000000000000000000000000000000000000..bfe13009872aea2e01fa2ece1067746f70800e9d --- /dev/null +++ b/backend/alembic/versions/0004_archive_notebook.py @@ -0,0 +1,24 @@ +"""notebooks.is_archive: the workspace's always-present "All posts" notebook + +Revision ID: 0004_archive_notebook +Revises: 0003_billing_events +Create Date: 2026-09-28 +""" +import sqlalchemy as sa +from alembic import op + +revision = "0004_archive_notebook" +down_revision = "0003_billing_events" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 0001 builds from the live models, so a fresh database may already have the column. + columns = {c["name"] for c in sa.inspect(op.get_bind()).get_columns("notebooks")} + if "is_archive" not in columns: + op.add_column("notebooks", sa.Column("is_archive", sa.Boolean(), nullable=False, server_default=sa.false())) + + +def downgrade() -> None: + op.drop_column("notebooks", "is_archive") diff --git a/backend/app/models/content.py b/backend/app/models/content.py index 80953435fad301fffb96552a70e3da568f4b906f..dff2ab28d94b6d96f4de0321975a569fa0297466 100644 --- a/backend/app/models/content.py +++ b/backend/app/models/content.py @@ -65,6 +65,8 @@ class Notebook(IdMixin, TimestampMixin, Base): title: Mapped[str] = mapped_column(String(300)) description: Mapped[str | None] = mapped_column(Text) summary: Mapped[str | None] = mapped_column(Text) + # The workspace's "All posts" notebook: created on demand, kept in step with every indexed post. + is_archive: Mapped[bool] = mapped_column(Boolean, default=False) class NotebookDocument(Base): diff --git a/backend/app/pipeline/ingest.py b/backend/app/pipeline/ingest.py index 563f512dfbfc70d77c160cdffd3278c6a81bfd3b..d38f3aea14616f0b23c6dc8462234002058b16df 100644 --- a/backend/app/pipeline/ingest.py +++ b/backend/app/pipeline/ingest.py @@ -418,8 +418,15 @@ def _plain_links(url: str) -> list[str]: return links -def crawl_site(url: str, limit: int, on_found=None, on_post=None) -> list[FeedEntry]: - """Find the posts on a site with no feed and read each one.""" +def _title_from_url(u: str) -> str: + slug = urlparse(u).path.rstrip("/").rsplit("/", 1)[-1] + slug = re.sub(r"\.[a-z0-9]+$", "", slug) + return slug.replace("-", " ").replace("_", " ").strip().capitalize() or urlparse(u).netloc + + +def crawl_site(url: str, limit: int, on_found=None, on_post=None, read_limit: int | None = None) -> list[FeedEntry]: + """Find the posts on a site with no feed and read each one. Past read_limit, pages are only listed (title from the + URL, no html): they show the writer what the rest of their archive holds without paying to read it.""" if firecrawl.enabled(): links = firecrawl.map_site(url, max(limit * 4, 100)) else: @@ -437,20 +444,24 @@ def crawl_site(url: str, limit: int, on_found=None, on_post=None) -> list[FeedEn except (FeedNotFound, httpx.HTTPError, firecrawl.FirecrawlError): return None + to_read = urls if read_limit is None else urls[:read_limit] entries: list[FeedEntry] = [] with ThreadPoolExecutor(max_workers=4) as pool: - for i, entry in enumerate(pool.map(read, urls)): + for i, entry in enumerate(pool.map(read, to_read)): if entry: entries.append(entry) if on_post: - on_post(i + 1, len(urls)) + on_post(i + 1, len(to_read)) + entries += [FeedEntry(title=_title_from_url(u), url=u, published_at=None, html="") for u in urls[len(to_read):]] if not entries: raise FeedNotFound(f"We found {len(urls)} pages on {urlparse(url).netloc} but none had article text.") return entries -def fetch_substack_archive(site: str, known_urls: set[str], limit: int, on_page=None) -> list[FeedEntry]: - """Posts beyond the ~20 the RSS feed carries, through Substack's public archive API.""" +def fetch_substack_archive(site: str, known_urls: set[str], limit: int, on_page=None, + body_budget: int | None = None) -> list[FeedEntry]: + """Posts beyond the ~20 the RSS feed carries, through Substack's public archive API. Only the first body_budget + posts get their body fetched; the rest are listed from the archive metadata alone (title, date, link).""" out: list[FeedEntry] = [] offset = 0 with httpx.Client(follow_redirects=True, timeout=30, headers=UA) as client: @@ -468,6 +479,10 @@ def fetch_substack_archive(site: str, known_urls: set[str], limit: int, on_page= continue if len(out) + len(known_urls) >= limit: break + if body_budget is not None and len(out) >= body_budget: + out.append(FeedEntry(title=item.get("title") or "Untitled", url=url, + published_at=_parse_date(item.get("post_date")), html="")) + continue post = client.get(f"{site}/api/v1/posts/{item['slug']}") if post.status_code >= 400: continue @@ -556,7 +571,7 @@ def store_entries( ) doc.clean_text = "\n\n".join(f"## {h}\n{t}" if h else t for h, t in sections) doc.metadata_json = { - **(doc.metadata_json or {}), + **{k: v for k, v in (doc.metadata_json or {}).items() if k != "locked"}, "headings": [h for h, _ in sections if h], "words": len(doc.clean_text.split()), } @@ -575,44 +590,95 @@ def store_entries( return indexed, skipped, changed +# A sync discovers up to SCAN_CAP posts (or the plan's own limit, if higher) but only indexes the plan's +# indexed_posts. The rest are listed, not indexed: title, date and link only, no text and no corpus file. They exist +# to show the writer how much of their archive is waiting behind an upgrade. Upgrading re-syncs and indexes them. +SCAN_CAP = 200 + + +def _newest_first(entries: list[FeedEntry]) -> list[FeedEntry]: + """Dated posts newest first; undated ones (site crawls) keep their discovery order after them.""" + dated = sorted((e for e in entries if e.published_at), key=lambda e: e.published_at.timestamp(), reverse=True) + return dated + [e for e in entries if not e.published_at] + + +def store_locked(db: Session, source: Source, entries: list[FeedEntry]) -> tuple[int, set[str]]: + """List posts beyond the plan limit without indexing them: title, date and link only. Returns (count, corpus paths + to remove): a post that was indexed before (a newer post pushed it out, or the plan went down) loses its file.""" + locked = 0 + remove: set[str] = set() + for entry in entries: + doc = db.scalar(select(Document).where(Document.source_id == source.id, Document.url == entry.url)) + if not doc: + doc = Document(id=uuid.uuid4(), workspace_id=source.workspace_id, source_id=source.id, url=entry.url) + db.add(doc) + if doc.path: + remove.add(doc.path) + doc.path = None + doc.title = entry.title + doc.published_at = entry.published_at + doc.content_hash = None # indexed from scratch once the plan allows it + doc.clean_text = "" + doc.metadata_json = {"locked": True} + locked += 1 + db.commit() + return locked, remove + + def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict: source.sync_status = "syncing" update_job(db, job, status="running", progress=0.02, message="Contacting your feed") + fetch_limit = max(SCAN_CAP, max_posts) if source.feed_url.startswith(SITE_PREFIX): update_job(db, job, message="Mapping your site") entries = crawl_site( - source.feed_url.removeprefix(SITE_PREFIX), max_posts, + source.feed_url.removeprefix(SITE_PREFIX), fetch_limit, on_found=lambda n: update_job(db, job, progress=0.05, message=f"Found {n} pages, reading them"), on_post=lambda done, total: update_job(db, job, progress=0.05 + 0.05 * done / total, message=f"Read {done} of {total} pages"), + read_limit=max_posts, ) title = None else: - title, entries = fetch_feed(source.feed_url, max_posts) + title, entries = fetch_feed(source.feed_url, fetch_limit) source.title = source.title or title - if source.platform == "substack" and len(entries) < max_posts: + if source.platform == "substack" and len(entries) < fetch_limit: site = f"{urlparse(source.feed_url).scheme}://{urlparse(source.feed_url).netloc}" update_job(db, job, progress=0.05, message="Reading your Substack archive") try: entries += fetch_substack_archive( - site, {e.url for e in entries}, max_posts, + site, {e.url for e in entries}, fetch_limit, on_page=lambda n: update_job(db, job, message=f"Found {len(entries) + n} posts in the archive"), + body_budget=max(0, max_posts - len(entries)), ) except (httpx.HTTPError, ValueError): log.warning("substack archive fetch failed for %s", site, exc_info=True) + entries = _newest_first(entries) + available, beyond = entries[:max_posts], entries[max_posts:] + def progress(done: int, total: int, post_title: str) -> None: - update_job(db, job, progress=0.1 + 0.8 * done / total, message=f"{post_title[:80]} is in orbit") + update_job(db, job, progress=0.1 + 0.7 * done / total, message=f"{post_title[:80]} is in orbit") + + update_job(db, job, progress=0.1, message=f"Indexing {len(available)} posts") + indexed, skipped, changed = store_entries(db, source, available, progress) - update_job(db, job, progress=0.1, message=f"Indexing {len(entries)} posts") - indexed, skipped, changed = store_entries(db, source, entries, progress) + locked = 0 + if beyond: + update_job(db, job, progress=0.85, + message=f"Found {len(beyond)} more posts. Your plan indexes the latest {max_posts}") + locked, remove = store_locked(db, source, beyond) + if remove: + Corpus(source.workspace_id).write_files({INDEX: rebuild_index(db, source.workspace_id)}, remove=remove) source.sync_status = "ok" source.sync_error = None source.last_synced_at = datetime.now(UTC) db.commit() - return {"indexed": indexed, "skipped": skipped, "found": len(entries), "changed": [str(i) for i in changed]} + return {"indexed": indexed, "skipped": skipped, "found": len(entries), "limit": max_posts, + "capped": locked > 0, "locked": locked, "available": len(available), + "changed": [str(i) for i in changed]} def entry_from_upload(filename: str, content_type: str, data: bytes) -> FeedEntry: diff --git a/backend/app/routers/artifacts.py b/backend/app/routers/artifacts.py index f32d1befb8456fb31d18bbd9b820ef5a5a4896ee..58ad74cc9448b6013802d86ed86795bc967c5210 100644 --- a/backend/app/routers/artifacts.py +++ b/backend/app/routers/artifacts.py @@ -7,10 +7,12 @@ from sqlalchemy import func, or_, select from app.auth import Ctx, get_ctx from app.models import Artifact, CalendarItem, Document, Job, Notebook +from app.routers.notebooks import ensure_archive_notebook from app.services.artifacts import latest_jobs, serialize_artifact, start_artifact from app.services.jobs import create_job, serialize_job from app.services.renderer import COMPOSITIONS from app.services.storage import storage +from app.services.plans import effective_plan, plan_limit_error from app.services.usage import check_limit router = APIRouter(prefix="/api/artifacts", tags=["artifacts"]) @@ -27,6 +29,7 @@ class GenerateIn(BaseModel): type: Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit"] notebook_id: uuid.UUID | None = None document_id: uuid.UUID | None = None + archive: bool = False # no notebook or post picked: use the "All posts" notebook # audio_overview format: Literal["deep_dive", "brief", "debate"] = "deep_dive" minutes: int = Field(6, ge=1, le=30) @@ -56,6 +59,11 @@ def _target_title(ctx: Ctx, notebook_id: uuid.UUID | None, document_id: uuid.UUI Document.workspace_id == ctx.workspace.id)) if not doc: raise HTTPException(404, "Post not found") + if not doc.path: + plan = effective_plan(ctx.db, ctx.workspace) + raise plan_limit_error(plan, "indexed_posts", + f"This post is not indexed. Your plan indexes your latest {plan.indexed_posts} " + "posts; upgrade to use your whole archive.") return doc.title if notebook_id: nb = ctx.db.scalar(select(Notebook).where(Notebook.id == notebook_id, @@ -69,6 +77,8 @@ def _target_title(ctx: Ctx, notebook_id: uuid.UUID | None, document_id: uuid.UUI @router.post("/generate") def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)): """One entry point for every generated artifact.""" + if body.archive and not body.notebook_id and not body.document_id: + body.notebook_id = ensure_archive_notebook(ctx).id params: dict = {} content: dict = {} if body.type == "video" and body.style == "audiogram": diff --git a/backend/app/routers/notebooks.py b/backend/app/routers/notebooks.py index bc7f41e782f2e36b423a0181f814dbc2d3aaaed3..977f65c06574fd144a595179676abbb48a3eeefc 100644 --- a/backend/app/routers/notebooks.py +++ b/backend/app/routers/notebooks.py @@ -52,7 +52,9 @@ def _get(ctx: Ctx, notebook_id: uuid.UUID) -> Notebook: def _add_docs(ctx: Ctx, nb: Notebook, ids: list[uuid.UUID]) -> int: valid = set(ctx.db.scalars( - select(Document.id).where(Document.id.in_(ids), Document.workspace_id == ctx.workspace.id) + # Locked posts (beyond the plan's post limit) have no corpus file and cannot join a notebook. + select(Document.id).where(Document.id.in_(ids), Document.workspace_id == ctx.workspace.id, + Document.path.is_not(None)) ).all()) existing = set(ctx.db.scalars(select(NotebookDocument.document_id).where(NotebookDocument.notebook_id == nb.id))) for doc_id in valid - existing: @@ -70,6 +72,29 @@ def create_notebook(body: NotebookIn, ctx: Ctx = Depends(get_ctx)): return {"id": str(nb.id), "title": nb.title, "added": added} +ARCHIVE_TITLE = "All posts" + + +def ensure_archive_notebook(ctx: Ctx) -> Notebook: + """The workspace's "All posts" notebook, topped up with every indexed post (locked posts are skipped).""" + nb = ctx.db.scalar(select(Notebook).where(Notebook.workspace_id == ctx.workspace.id, Notebook.is_archive.is_(True))) + if not nb: + nb = Notebook(workspace_id=ctx.workspace.id, title=ARCHIVE_TITLE, is_archive=True, + description="Every indexed post in your archive, kept up to date.") + ctx.db.add(nb) + ctx.db.flush() + ids = list(ctx.db.scalars(select(Document.id).where(Document.workspace_id == ctx.workspace.id))) + _add_docs(ctx, nb, ids) + ctx.db.commit() + return nb + + +@router.post("/archive") +def archive_notebook(ctx: Ctx = Depends(get_ctx)): + nb = ensure_archive_notebook(ctx) + return {"id": str(nb.id), "title": nb.title} + + @router.post("/from-topic/{topic_id}") def notebook_from_topic(topic_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)): topic = ctx.db.scalar(select(Topic).where(Topic.id == topic_id, Topic.workspace_id == ctx.workspace.id)) @@ -87,7 +112,8 @@ def notebook_from_topic(topic_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)): @router.get("") def list_notebooks(ctx: Ctx = Depends(get_ctx)): rows = ctx.db.scalars( - select(Notebook).where(Notebook.workspace_id == ctx.workspace.id).order_by(Notebook.updated_at.desc()) + select(Notebook).where(Notebook.workspace_id == ctx.workspace.id) + .order_by(Notebook.is_archive.desc(), Notebook.updated_at.desc()) ).all() ids = [n.id for n in rows] doc_counts = dict(ctx.db.execute( @@ -103,7 +129,7 @@ def list_notebooks(ctx: Ctx = Depends(get_ctx)): return [ {"id": str(n.id), "title": n.title, "description": n.description, "summary": n.summary, "document_count": doc_counts.get(n.id, 0), "chat_count": chat_counts.get(n.id, 0), - "artifact_count": artifact_counts.get(n.id, 0), + "artifact_count": artifact_counts.get(n.id, 0), "is_archive": n.is_archive, "updated_at": n.updated_at.isoformat() if n.updated_at else None} for n in rows ] @@ -112,6 +138,8 @@ def list_notebooks(ctx: Ctx = Depends(get_ctx)): @router.get("/{notebook_id}") def get_notebook(notebook_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)): nb = _get(ctx, notebook_id) + if nb.is_archive: # posts synced since it was made join on open + ensure_archive_notebook(ctx) docs = ctx.db.scalars( select(Document) .join(NotebookDocument, NotebookDocument.document_id == Document.id) @@ -123,6 +151,7 @@ def get_notebook(notebook_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)): "title": nb.title, "description": nb.description, "summary": nb.summary, + "is_archive": nb.is_archive, "documents": [serialize_document(d) for d in docs], } diff --git a/backend/app/routers/sources.py b/backend/app/routers/sources.py index 702db44e4224d6633abb9b3add4648d76a24bd79..575228765aa571f10d1280ac1d0b9889e7051112 100644 --- a/backend/app/routers/sources.py +++ b/backend/app/routers/sources.py @@ -24,7 +24,7 @@ class UploadImportIn(BaseModel): upload_id: uuid.UUID -def serialize_source(source: Source, doc_count: int = 0) -> dict: +def serialize_source(source: Source, doc_count: int = 0, locked_count: int = 0) -> dict: return { "id": str(source.id), "feed_url": source.feed_url, @@ -35,6 +35,8 @@ def serialize_source(source: Source, doc_count: int = 0) -> dict: "sync_error": source.sync_error, "last_synced_at": source.last_synced_at.isoformat() if source.last_synced_at else None, "document_count": doc_count, + # Found but beyond the plan's post limit: listed, not indexed until the workspace upgrades. + "locked_count": locked_count, "is_imports": source.feed_url == IMPORTS_FEED, } @@ -91,7 +93,12 @@ def list_sources(ctx: Ctx = Depends(get_ctx)): .group_by(Source.id) .order_by(Source.created_at) ).all() - return [serialize_source(s, n) for s, n in rows] + locked = dict(ctx.db.execute( + select(Document.source_id, func.count(Document.id)) + .where(Document.workspace_id == ctx.workspace.id, Document.path.is_(None)) + .group_by(Document.source_id) + ).all()) + return [serialize_source(s, n, locked.get(s.id, 0)) for s, n in rows] @router.post("/{source_id}/sync") @@ -151,6 +158,11 @@ def import_upload(body: UploadImportIn, ctx: Ctx = Depends(get_ctx)): documents_router = APIRouter(prefix="/api/documents", tags=["documents"]) +def is_locked(d: Document) -> bool: + """Past the plan's post limit (see ingest.store_locked): listed with title and link only, not indexed.""" + return not d.path and bool((d.metadata_json or {}).get("locked")) + + def serialize_document(d: Document, source: Source | None = None) -> dict: meta = d.metadata_json or {} return { @@ -163,6 +175,7 @@ def serialize_document(d: Document, source: Source | None = None) -> dict: "published_at": d.published_at.isoformat() if d.published_at else None, "words": meta.get("words") or len((d.clean_text or "").split()), "evergreen_score": d.evergreen_score, + "locked": is_locked(d), } @@ -198,6 +211,11 @@ def get_document(document_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)): if not doc: raise HTTPException(404, "Post not found") source = ctx.db.get(Source, doc.source_id) if doc.source_id else None + if is_locked(doc): + # Listed, not indexed: there is no text to show until the workspace upgrades. + plan = effective_plan(ctx.db, ctx.workspace) + return {**serialize_document(doc, source), "lines": [f"# {doc.title}"], + "preview": True, "limit": plan.indexed_posts} lines: list[str] = [] if doc.path: corpus = Corpus(ctx.workspace.id) diff --git a/backend/app/routers/voice.py b/backend/app/routers/voice.py index 6d8da11b48017c0c55b2b0f8209fdcaf9823b578..4778b5907df74daea4436e7b5ac7d88dd2bf6f54 100644 --- a/backend/app/routers/voice.py +++ b/backend/app/routers/voice.py @@ -57,6 +57,23 @@ class PreviewIn(BaseModel): delivery: DeliveryIn | None = None +class DesignIn(BaseModel): + """Either picked options (Build from options) or a free description (Describe in your own words).""" + prompt: str | None = Field(None, max_length=1000) + gender: str = Field("", max_length=20) + age: str = Field("", max_length=20) + persona: str = Field("", max_length=30) + pace: str = Field("", max_length=20) + accent: str = Field("", max_length=40) + + +class DesignSaveIn(BaseModel): + generated_voice_id: str = Field(min_length=5, max_length=100) + name: str = Field(min_length=1, max_length=60) + description: str = Field("", max_length=1000) + use_as: str | None = None # "host_a" | "host_b" + + class LibraryAddIn(BaseModel): public_owner_id: str = Field(min_length=5, max_length=100) voice_id: str = Field(min_length=5, max_length=60) @@ -116,6 +133,8 @@ def _serialize(ctx: Ctx) -> dict: "error": job.error if job and job.status == "failed" else None, "preview_url": storage.presign_get(preview_key) if preview_key else None, }, + # Voices this workspace generated with Voice Design (the ElevenLabs account is shared across workspaces). + "custom_voices": voices.get("custom") or [], "tts_configured": bool(settings.elevenlabs_api_key), "model": tts.model_id(), "models": tts.MODELS, @@ -146,6 +165,11 @@ def update_voice(body: ProfileIn, ctx: Ctx = Depends(get_ctx)): @router.post("/build") def build(body: BuildIn, ctx: Ctx = Depends(get_ctx)): + if not body.document_ids: # the automatic build after a sync may already be running; follow that one + pending = ctx.db.scalar(select(Job).where(Job.workspace_id == ctx.workspace.id, Job.kind == "voice_profile", + Job.status.in_(("queued", "running"))).limit(1)) + if pending: + return serialize_job(pending) job = create_job(ctx.db, ctx.workspace.id, "voice_profile", {"document_ids": [str(i) for i in body.document_ids]}, max_attempts=2) return serialize_job(job) @@ -211,6 +235,61 @@ async def preview(body: PreviewIn, ctx: Ctx = Depends(get_ctx)): return {"url": storage.presign_get(key), "seconds": round(tts.mp3_seconds(clip), 1)} +def _design_description(body: DesignIn) -> str: + if body.prompt and body.prompt.strip(): + text = body.prompt.strip() + if len(text) < 20: + raise HTTPException(400, "Describe the voice in at least 20 characters.") + return text + parts = [f"A {body.age} {body.gender} voice".replace(" ", " ") if body.gender or body.age else "A voice"] + if body.accent: + parts.append(f"with a {body.accent} accent") + sentence = " ".join(parts) + "." + if body.persona: + sentence += f" Sounds {body.persona}." + if body.pace: + sentence += f" Speaks at a {body.pace} pace." + return sentence + " Clear studio quality, suited to podcasts and narration." + + +@router.post("/design") +async def design(body: DesignIn, ctx: Ctx = Depends(get_ctx)): + """Three previews of a new voice. Nothing is saved until the writer picks one.""" + if not settings.elevenlabs_api_key: + raise HTTPException(503, "Set ELEVENLABS_API_KEY in .env to generate voices.") + description = _design_description(body) + try: + previews = await run_in_threadpool(tts.design_voice, description) + except tts.TTSError as exc: + raise HTTPException(502, str(exc)) from exc + out = [] + for p in previews: + key = f"ws/{ctx.workspace.id}/voice-previews/{uuid.uuid4().hex}.mp3" + storage.put_bytes(key, p["audio"], "audio/mpeg") + out.append({"generated_voice_id": p["generated_voice_id"], "url": storage.presign_get(key), "seconds": p["seconds"]}) + record_usage(ctx.db, workspace_id=ctx.workspace.id, kind="voice_design", provider="elevenlabs", + quantity=len(out), unit="previews") + return {"description": description, "previews": out} + + +@router.post("/design/save") +async def save_design(body: DesignSaveIn, ctx: Ctx = Depends(get_ctx)): + try: + voice_id = await run_in_threadpool(tts.create_designed_voice, body.name, body.description, + body.generated_voice_id) + except tts.TTSError as exc: + raise HTTPException(502, str(exc)) from exc + vp = _ensure_profile(ctx) + voices = dict(vp.host_voices or {}) + voices["custom"] = [*(voices.get("custom") or []), + {"voice_id": voice_id, "name": body.name, "description": body.description[:300]}] + if body.use_as in ("host_a", "host_b"): + voices[body.use_as] = voice_id + vp.host_voices = voices + ctx.db.commit() + return {"voice_id": voice_id, **_serialize(ctx)} + + @router.get("/reading-script") def reading_script(ctx: Ctx = Depends(get_ctx)): """About two minutes of the writer's own prose to read aloud for the clone (their words, their rhythm).""" diff --git a/backend/app/services/billing.py b/backend/app/services/billing.py index 6bfef4d53503a623c74f22bf69bfd9a64d4c3658..0597b75dc7dff20caced186a5fc3775c33919e6f 100644 --- a/backend/app/services/billing.py +++ b/backend/app/services/billing.py @@ -112,7 +112,7 @@ def build_nudges(db: Session, workspace: Workspace, plan: Plan, meters: list[dic "Open Launchpad", "/app/launchpad", priority=35)) out.append(_nudge(f"act-resurface-{month}", "action", "Old posts, new orbit", "Resurfacing finds evergreen posts worth sharing again this month.", "Find evergreen posts", - "/app/resurface", priority=20)) + "/app/launchpad#ideas", priority=20)) return sorted(out, key=lambda n: -n["priority"]) @@ -146,6 +146,8 @@ def apply_subscription(db: Session, workspace_id, *, plan: str, status: str, pro if plan not in PLANS: raise ValueError(f"Unknown plan {plan}") sub = db.query(Subscription).filter_by(workspace_id=workspace_id).one_or_none() + workspace = db.get(Workspace, workspace_id) + before = effective_plan(db, workspace).indexed_posts if workspace else None if not sub: sub = Subscription(workspace_id=workspace_id) db.add(sub) @@ -154,9 +156,23 @@ def apply_subscription(db: Session, workspace_id, *, plan: str, status: str, pro sub.provider_subscription_id = subscription_id or sub.provider_subscription_id sub.current_period_end = current_period_end db.commit() + if workspace and effective_plan(db, workspace).indexed_posts != before: + resync_sources(db, workspace_id) return sub +def resync_sources(db: Session, workspace_id) -> None: + """The post limit changed: re-sync every feed so locked posts unlock on upgrade (or re-lock on downgrade).""" + from app.pipeline.ingest import IMPORTS_FEED + from app.services.jobs import create_job + + for source in db.scalars(select(Source).where(Source.workspace_id == workspace_id, + Source.feed_url != IMPORTS_FEED)): + source.sync_status = "pending" + create_job(db, workspace_id, "ingest", {"source_id": str(source.id)}) + db.commit() + + def return_urls() -> tuple[str, str]: """(success_url, cancel_url) for the hosted checkout page.""" base = settings.frontend_url.rstrip("/") diff --git a/backend/app/services/plans.py b/backend/app/services/plans.py index d45f043d14c625f7535fadab629e630d4805da1e..fcacf0faf6685a8c56e29a74e7b61fdfc9e9992e 100644 --- a/backend/app/services/plans.py +++ b/backend/app/services/plans.py @@ -41,7 +41,7 @@ PLANS: dict[str, Plan] = { voice_cloning=False, brand_kit=False, features=( - "1 source, your 5 latest posts", + "1 source, your latest 5 posts indexed", "Grounded research chat with citations", "3 min of audio overviews a month", "1 min of video a month", diff --git a/backend/app/services/tts.py b/backend/app/services/tts.py index 9a8ddf331c3769c46c863d6cffdfa56fb32225f2..ddede9e4d73e7fdfd6827b5e033a4de031840e8f 100644 --- a/backend/app/services/tts.py +++ b/backend/app/services/tts.py @@ -289,6 +289,29 @@ def clone_voice(name: str, samples: list[tuple[str, bytes, str]], *, remove_back return resp.json()["voice_id"] +def design_voice(description: str) -> list[dict]: + """Voice Design: three short previews of a new voice from a text description. + Each preview's generated_voice_id is temporary until saved with create_designed_voice.""" + data = _request("POST", "/text-to-voice/design", attempts=2, timeout=90, + json={"voice_description": description[:1000], "auto_generate_text": True}).json() + return [ + {"generated_voice_id": p["generated_voice_id"], "audio": base64.b64decode(p["audio_base_64"]), + "seconds": p.get("duration_secs")} + for p in data.get("previews", []) if p.get("generated_voice_id") and p.get("audio_base_64") + ] + + +def create_designed_voice(name: str, description: str, generated_voice_id: str) -> str: + """Turns a Voice Design preview into a permanent voice on the account.""" + resp = _request("POST", "/text-to-voice/create-voice-from-preview", attempts=2, timeout=30, json={ + "voice_name": name[:100], + "voice_description": (description or name)[:1000], + "generated_voice_id": generated_voice_id, + "labels": {"source": "notestack"}, + }) + return resp.json()["voice_id"] + + def delete_voice(voice_id: str) -> None: _request("DELETE", f"/voices/{voice_id}", attempts=2, timeout=30) diff --git a/backend/app/worker.py b/backend/app/worker.py index 51b2a8d66ddc65cfb605163f66561d7e7508794d..f99a74b996c2a9549182a42efeea4b89d6c76a37 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -67,10 +67,22 @@ HANDED_OFF = object() # the job continues elsewhere (renderer) and reports back # Handlers: (db, job) -> Done | HANDED_OFF. Raise to trigger a retry. +def _pending(db: Session, workspace_id: uuid.UUID, kind: str) -> bool: + return db.scalar(select(Job.id).where(Job.workspace_id == workspace_id, Job.kind == kind, + Job.status.in_(("queued", "running"))).limit(1)) is not None + + def _after_import(db: Session, job: Job, workspace_id: uuid.UUID, changed: list[str]) -> None: - """New or changed posts get mapped into the topic constellation automatically.""" - if changed and settings.llm_api_key: - create_job(db, workspace_id, "topics", {"document_ids": changed}, max_attempts=2) + """New or changed posts are mapped into the topic constellation and scored for resurfacing, and the + first import builds the writing voice, so none of these need a button press.""" + if not changed or not settings.llm_api_key: + return + create_job(db, workspace_id, "topics", {"document_ids": changed}, max_attempts=2) + if not _pending(db, workspace_id, "resurface_scan"): + create_job(db, workspace_id, "resurface_scan", {}, max_attempts=2) + vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == workspace_id)) + if not (vp and vp.profile_json) and not _pending(db, workspace_id, "voice_profile"): + create_job(db, workspace_id, "voice_profile", {"document_ids": []}, max_attempts=2) def handle_ingest(db: Session, job: Job): @@ -91,6 +103,9 @@ def handle_ingest(db: Session, job: Job): raise PermanentJobError(str(exc)) from exc raise _after_import(db, job, source.workspace_id, result.get("changed", [])) + if result.get("locked"): + return Done(result, f"Found {result['found']} posts. Your latest {result['available']} are indexed; " + f"upgrade to index the other {result['locked']}") return Done(result, f"All posts in orbit: {result['indexed']} new or updated") diff --git a/backend/tests/test_locked_posts.py b/backend/tests/test_locked_posts.py new file mode 100644 index 0000000000000000000000000000000000000000..008bd2aec247caba88fd8a616d009f452200eceb --- /dev/null +++ b/backend/tests/test_locked_posts.py @@ -0,0 +1,91 @@ +"""Posts past the plan's indexed_posts are listed, not indexed: no text, no corpus file, indexed on a bigger plan.""" + +import uuid +from datetime import UTC, datetime, timedelta + +from sqlalchemy import select + +from app.models import Document, Job, Source, Workspace +from app.pipeline import ingest +from app.pipeline.ingest import FeedEntry, ingest_source +from app.routers.sources import is_locked + + +def _entries(n: int) -> list[FeedEntry]: + start = datetime(2026, 1, 1, tzinfo=UTC) + # Oldest first on purpose: ingest must pick the newest posts regardless of feed order. + return [ + FeedEntry(title=f"Post {i}", url=f"https://ada.example/p/{i}", published_at=start + timedelta(days=i), + html=f"

Point {i}

Paragraph for post number {i} with enough words to keep.

") + for i in range(n) + ] + + +def _setup(db, monkeypatch, n: int): + monkeypatch.setattr(ingest, "fetch_feed", lambda url, limit: ("Ada", _entries(n)[:limit])) + ws = Workspace(name="w", owner_id=uuid.uuid4()) + db.add(ws) + db.flush() + source = Source(workspace_id=ws.id, feed_url="https://ada.example/feed", platform="rss", title="Ada") + db.add(source) + db.flush() + job = Job(workspace_id=ws.id, kind="ingest", params={"source_id": str(source.id)}) + db.add(job) + db.commit() + return ws, source, job + + +def _docs(db, source): + return {d.title: d for d in db.scalars(select(Document).where(Document.source_id == source.id))} + + +def test_lists_every_post_but_indexes_only_the_latest(db_session, monkeypatch): + ws, source, job = _setup(db_session, monkeypatch, 12) + result = ingest_source(db_session, source, job, max_posts=5) + + assert result["found"] == 12 and result["available"] == 5 and result["locked"] == 7 and result["capped"] + docs = _docs(db_session, source) + assert len(docs) == 12 + available = {t for t, d in docs.items() if d.path} + assert available == {f"Post {i}" for i in range(7, 12)} # the five newest + locked = [d for d in docs.values() if not d.path] + assert len(locked) == 7 + assert all(is_locked(d) and d.clean_text == "" and d.title for d in locked) + + +def test_upgrade_unlocks_and_newer_posts_relock_the_oldest(db_session, monkeypatch): + ws, source, job = _setup(db_session, monkeypatch, 12) + ingest_source(db_session, source, job, max_posts=5) + + ingest_source(db_session, source, job, max_posts=500) + docs = _docs(db_session, source) + assert all(d.path for d in docs.values()) + assert not any(is_locked(d) for d in docs.values()) + + ingest_source(db_session, source, job, max_posts=5) + docs = _docs(db_session, source) + assert sum(1 for d in docs.values() if d.path) == 5 + assert docs["Post 0"].path is None and is_locked(docs["Post 0"]) + + +def test_scan_is_capped_per_sync(db_session, monkeypatch): + ws, source, job = _setup(db_session, monkeypatch, ingest.SCAN_CAP + 50) + result = ingest_source(db_session, source, job, max_posts=5) + assert result["found"] == ingest.SCAN_CAP + + +def test_crawl_reads_only_the_pages_it_will_index(monkeypatch): + read = [] + urls = [f"https://ada.example/posts/post-{i}" for i in range(20)] + monkeypatch.setattr(ingest, "_plain_links", lambda url: urls) + monkeypatch.setattr(ingest, "_post_urls", lambda links, url, limit: links[:limit]) + + def fake_extract(u): + read.append(u) + return FeedEntry(title=u, url=u, published_at=None, html="

body text for the page

") + + monkeypatch.setattr(ingest, "extract_article", fake_extract) + entries = ingest.crawl_site("https://ada.example", 200, read_limit=5) + assert len(read) == 5 and len(entries) == 20 + assert all(e.html == "" for e in entries[5:]) + assert entries[5].title == "Post 5" diff --git a/backend/tests/test_simplified_flows.py b/backend/tests/test_simplified_flows.py new file mode 100644 index 0000000000000000000000000000000000000000..d60dc10acf5903deab8a976d4a0bbe40c9751146 --- /dev/null +++ b/backend/tests/test_simplified_flows.py @@ -0,0 +1,47 @@ +"""One-click flows: the "All posts" notebook, whole-archive generation, and jobs that run after a sync.""" + +from sqlalchemy import select + +from app.config import settings +from app.models import Job +from tests.test_buildout import auth, connect, docs, feed, llm # noqa: F401 (fixtures) + + +def test_archive_notebook_is_created_once_and_listed_first(client, auth, run_jobs, feed): + connect(client, auth, run_jobs) + client.post("/api/notebooks", json={"title": "Newer"}, headers=auth) + first = client.post("/api/notebooks/archive", headers=auth).json() + again = client.post("/api/notebooks/archive", headers=auth).json() + assert first["id"] == again["id"] and first["title"] == "All posts" + + listed = client.get("/api/notebooks", headers=auth).json() + assert listed[0]["id"] == first["id"] and listed[0]["is_archive"] + indexed = [d for d in docs(client, auth) if d["path"]] + assert listed[0]["document_count"] == len(indexed) > 0 + + nb = client.get(f"/api/notebooks/{first['id']}", headers=auth).json() + assert nb["is_archive"] and len(nb["documents"]) == len(indexed) + + +def test_generate_from_whole_archive_uses_the_archive_notebook(client, auth, run_jobs, feed): + connect(client, auth, run_jobs) + art = client.post("/api/artifacts/generate", json={"type": "summary", "archive": True}, headers=auth).json() + archive = client.post("/api/notebooks/archive", headers=auth).json() + assert art["notebook_id"] == archive["id"] + + +def test_sync_queues_resurfacing_and_writing_voice_once(client, auth, feed, db_session, session_factory, + monkeypatch): + from app.services.jobs import claim_next + from app.worker import run_job + + monkeypatch.setattr(settings, "llm_api_key", "test-key") + client.post("/api/sources", json={"url": "ada.example.com"}, headers=auth) + with session_factory() as db: + job_id = claim_next(db, "test") + run_job(job_id, session_factory=session_factory) # the ingest job only; follow-ups stay queued + db_session.expire_all() + kinds = [j.kind for j in db_session.scalars(select(Job).where(Job.status == "queued"))] + assert kinds.count("resurface_scan") == 1 + assert kinds.count("voice_profile") == 1 + assert kinds.count("topics") == 1 diff --git a/frontend/index.html b/frontend/index.html index 8ce8a789d70f4af61ee44a64e3b44b0381e895dc..f47e99317b7e535b763b83dbb8bab7cb5ce0fd58 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,6 +1,15 @@ + + + diff --git a/frontend/public/_redirects b/frontend/public/_redirects new file mode 100644 index 0000000000000000000000000000000000000000..61e4a382f245470aaf1f83b0685daf85b230b033 --- /dev/null +++ b/frontend/public/_redirects @@ -0,0 +1,5 @@ +# Signed in and auth routes get the empty app shell (noindex), not the prerendered home page. +/app/* /app 200 +/auth /app 200 +/auth/* /app 200 +/welcome /app 200 diff --git a/frontend/seo/prerender.ts b/frontend/seo/prerender.ts new file mode 100644 index 0000000000000000000000000000000000000000..c9a79b46603f56dfc5479ee953b6fedddf53bfdc --- /dev/null +++ b/frontend/seo/prerender.ts @@ -0,0 +1,269 @@ +import { readFile, mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { Plugin, ResolvedConfig } from "vite"; +import { blogPosts } from "../src/content/blogPosts"; +import { NOTEBOOKLM_ALT_FAQ, NOTEBOOKLM_ALT_META, NOTEBOOKLM_ALT_ROWS } from "../src/content/notebooklmAlternative"; +import type { BlogPost } from "../src/content/seoTypes"; +import { tools, toolsHub, type ToolDef } from "../src/content/tools"; + +/** + * Writes a static HTML file per public route after `vite build`, so crawlers that do not run JavaScript still get the + * page's title, description, canonical, headings, copy and links. The React app replaces #root on load. + * + * Files are written as .html, not /index.html: Cloudflare Pages serves /blogs/x from blogs/x.html with no + * redirect, while a folder index would 308 to /blogs/x/ and fight the canonical (which has no trailing slash). + */ + +type Rendered = { path: string; title: string; description: string; body: string; jsonLd?: object[]; ogType?: string }; + +const esc = (s: string) => + s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + +/** Post copy supports [text](url) links and **bold**; render them as real anchors. */ +const inline = (text: string) => + esc(text) + .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, label: string, href: string) => `${label}`) + .replace(/\*\*([^*]+)\*\*/g, "$1"); + +const plain = (text: string) => text.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/\*\*([^*]+)\*\*/g, "$1"); + +const faqLd = (items: { q: string; a: string }[]) => ({ + "@context": "https://schema.org", + "@type": "FAQPage", + mainEntity: items.map((f) => ({ "@type": "Question", name: f.q, acceptedAnswer: { "@type": "Answer", text: plain(f.a) } })), +}); + +const faqHtml = (items: { q: string; a: string }[]) => + items.length + ? `

Questions

${items.map((f) => `

${esc(f.q)}

${inline(f.a)}

`).join("")}
` + : ""; + +const nav = () => + `
`; + +const footer = () => + ``; + +const page = (main: string) => `${nav()}
${main}
${footer()}`; + +function pathTitle(p: string) { + const post = blogPosts.find((b) => `/blogs/${b.slug}` === p); + if (post) return post.title; + const tool = tools.find((t) => `/tools/${t.slug}` === p); + if (tool) return tool.heroTitle; + if (p === "/notebooklm-alternative") return "Notestack vs NotebookLM"; + return p; +} + +function renderPost(post: BlogPost): Rendered { + const sections = post.sections + .map( + (s) => + `

${esc(s.heading)}

${s.paragraphs.map((p) => `

${inline(p)}

`).join("")}${ + s.bullets?.length ? `
    ${s.bullets.map((b) => `
  • ${inline(b)}
  • `).join("")}
` : "" + }${s.callout ? `

${inline(s.callout)}

` : ""}
`, + ) + .join(""); + const mission = post.mission ? `

${esc(post.mission.question)} ${inline(post.mission.answer)}

` : ""; + const related = post.relatedPaths.length + ? `` + : ""; + const faq = post.faq.map((f) => ({ q: f.question, a: f.answer })); + return { + path: `/blogs/${post.slug}`, + title: `${post.title} | Notestack`, + description: post.description, + ogType: "article", + body: page( + `

${esc(post.heroEyebrow)}

${esc(post.title)}

${esc(post.heroDescription)}

${mission}${sections}${faqHtml(faq)}
${related}`, + ), + jsonLd: [ + { + "@context": "https://schema.org", + "@type": "BlogPosting", + headline: post.title, + description: post.description, + datePublished: post.publishedAt, + author: { "@type": "Organization", name: "Notestack" }, + publisher: { "@type": "Organization", name: "Firebird Technologies" }, + }, + ...(faq.length ? [faqLd(faq)] : []), + ], + }; +} + +function renderTool(tool: ToolDef): Rendered { + const faq = tool.faq.map((f) => ({ q: f.question, a: f.answer })); + const sections = tool.sections + .map( + (s) => + `

${esc(s.heading)}

${s.paragraphs.map((p) => `

${inline(p)}

`).join("")}${ + s.bullets?.length ? `
    ${s.bullets.map((b) => `
  • ${esc(b)}
  • `).join("")}
` : "" + }
`, + ) + .join(""); + const next = encodeURIComponent(`/tools/${tool.slug}`); + return { + path: `/tools/${tool.slug}`, + title: tool.metaTitle, + description: tool.description, + body: page( + ``, + ), + jsonLd: [ + { + "@context": "https://schema.org", + "@type": "SoftwareApplication", + name: tool.heroTitle, + applicationCategory: "MultimediaApplication", + operatingSystem: "Web", + description: tool.description, + offers: { "@type": "Offer", price: "0", priceCurrency: "USD" }, + publisher: { "@type": "Organization", name: "Notestack" }, + }, + faqLd(faq), + ], + }; +} + +function routes(): Rendered[] { + const posts = [...blogPosts].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)); + const altFaq = NOTEBOOKLM_ALT_FAQ.map((f) => ({ q: f.q, a: f.a })); + return [ + { + path: "/", + title: "Notestack: your knowledge, in orbit | NotebookLM alternative for writers", + description: + "Notestack turns any blog, newsletter, site or markdown files into a grounded research notebook, audio overviews, videos and a launch kit, all in your own voice.", + body: page( + `

Your knowledge, in orbit.

Notestack turns your blog, newsletter, site or markdown files into a research notebook you can question, podcasts you can publish and launch kits for every platform. Every word traced back to what you actually wrote.

Start free or see Notestack vs NotebookLM.

Free tools

Field notes

`, + ), + jsonLd: [ + { + "@context": "https://schema.org", + "@type": "SoftwareApplication", + name: "Notestack", + applicationCategory: "BusinessApplication", + operatingSystem: "Web", + description: "The NotebookLM alternative for writers: a grounded research notebook built from your own archive.", + offers: { "@type": "Offer", price: "0", priceCurrency: "USD" }, + publisher: { "@type": "Organization", name: "Firebird Technologies" }, + }, + ], + }, + { + path: "/notebooklm-alternative", + title: NOTEBOOKLM_ALT_META.title, + description: NOTEBOOKLM_ALT_META.description, + body: page( + `

NotebookLM alternative

Notestack vs NotebookLM: the NotebookLM alternative for writers

NotebookLM is great for a pile of documents. Notestack is built for a body of work: it syncs your blog, newsletter, site or markdown, answers with citations to the exact lines you wrote, and turns your knowledge into audio, video and launch posts.

Start free

Notestack vs NotebookLM

${NOTEBOOKLM_ALT_ROWS.map( + (r) => ``, + ).join("")}
NotestackNotebookLM
${esc(r.feature)}${esc(r.notestack)}${esc(r.notebooklm)}

When NotebookLM is still the right call

If you are studying someone else's documents for a one off project, want a free tool from Google, or live in Google Drive, NotebookLM is excellent. Notestack is the NotebookLM alternative when the sources are your own writing and the goal is to reuse and publish it.

Pair it with the right publishing tool

  • Blog2Video: turn blog posts, URLs and scripts into finished videos.
  • PDF2Video: turn any PDF, report, paper or deck into a narrated video.
  • BlogHub: list your blog or newsletter in a free directory.
${faqHtml( + altFaq, + )}

Comparing more tools? Read our guide to NotebookLM alternatives and NotebookLM vs ChatGPT.

`, + ), + jsonLd: [faqLd(altFaq)], + }, + { + path: "/pricing", + title: "Pricing | Notestack", + description: + "Notestack pricing: Free ($0), Writer ($24.99 a month, $18.99 billed annually) and Studio ($48.99 a month, $36.99 billed annually). Annual billing saves 24%.", + body: page( + `

Pick your trajectory

Start free

`, + ), + }, + { + path: "/blogs", + title: "Blog | Notestack", + description: "Field notes on research, deep writing and turning your archive into audio, video and launches.", + body: page( + `

Every post is a search for an answer

${posts + .map( + (p) => + ``, + ) + .join("")}`, + ), + }, + { + path: "/tools", + title: toolsHub.metaTitle, + description: toolsHub.description, + body: page( + `

${esc(toolsHub.heroTitle)}

${esc(toolsHub.heroDescription)}

`, + ), + }, + ...tools.map(renderTool), + ...posts.filter((p) => !p.canonicalPath).map(renderPost), + ]; +} + +/** Strips the head tags this plugin owns, so every route gets exactly one of each. */ +function sanitize(template: string) { + return template + .replace(/[\s\S]*?<\/title>\s*/gi, "") + .replace(/<meta\s+name="description"[^>]*>\s*/gi, "") + .replace(/<meta\s+property="og:(title|description|url|type)"[^>]*>\s*/gi, "") + .replace(/<meta\s+name="twitter:(title|description)"[^>]*>\s*/gi, "") + .replace(/<link\s+rel="canonical"[^>]*>\s*/gi, ""); +} + +function head(r: Rendered, origin: string) { + const url = `${origin}${r.path === "/" ? "/" : r.path}`; + const tags = [ + `<title>${esc(r.title)}`, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ...(r.jsonLd ?? []).map((ld) => ``), + ]; + return tags.join("\n "); +} + +export function prerenderPlugin(siteUrl: string): Plugin { + const origin = siteUrl.replace(/\/+$/, ""); + let config: ResolvedConfig; + return { + name: "notestack-prerender", + apply: "build", + configResolved(c) { + config = c; + }, + async closeBundle() { + const outDir = path.resolve(config.root, config.build.outDir); + const template = sanitize(await readFile(path.join(outDir, "index.html"), "utf8")); + const all = routes(); + for (const r of all) { + const html = template + .replace("", ` ${head(r, origin)}\n `) + .replace('
', `
${r.body}
`); + const file = r.path === "/" ? path.join(outDir, "index.html") : path.join(outDir, `${r.path.slice(1)}.html`); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, html, "utf8"); + } + // Signed in and unknown routes fall back to an app shell with no page content (see public/_redirects). + await writeFile(path.join(outDir, "app.html"), template.replace("", ` \n Notestack\n `), "utf8"); + config.logger.info(`prerendered ${all.length} public routes`); + }, + }; +} diff --git a/frontend/seo/sitemap.ts b/frontend/seo/sitemap.ts index 7889b3095d96a52089d8fb5fd013e772d5bffa46..0c048fcae5d07d246f5153ae5ab887f1c3e54770 100644 --- a/frontend/seo/sitemap.ts +++ b/frontend/seo/sitemap.ts @@ -1,5 +1,6 @@ import type { Plugin } from "vite"; import { blogPosts } from "../src/content/blogPosts"; +import { tools } from "../src/content/tools"; /** * sitemap.xml and robots.txt, generated from src/content/blogPosts.ts on every build (and served live @@ -13,6 +14,8 @@ const STATIC_PAGES: { path: string; priority: string; changefreq: string }[] = [ { path: "/notebooklm-alternative", priority: "0.9", changefreq: "monthly" }, { path: "/pricing", priority: "0.8", changefreq: "monthly" }, { path: "/blogs", priority: "0.9", changefreq: "weekly" }, + { path: "/tools", priority: "0.8", changefreq: "monthly" }, + ...tools.map((t) => ({ path: `/tools/${t.slug}`, priority: "0.8", changefreq: "monthly" })), ]; const escapeXml = (s: string) => @@ -93,7 +96,7 @@ differs from NotebookLM. SEO pack, carousel). - **Publishing:** a Launchpad calendar that auto posts to X, LinkedIn and Bluesky, plus a topic map of rising and dormant themes and evergreen resurfacing of old posts. -- **Pricing (USD):** Free: 1 source, first 5 posts indexed, 3 audio minutes, 1 video minute, 2 launch kits a month. +- **Pricing (USD):** Free: 1 source, latest 5 posts indexed, 3 audio minutes, 1 video minute, 2 launch kits a month. Writer: $24.99 a month ($18.99 a month billed annually): 3 sources, 500 posts, 60 audio minutes, 30 video minutes, 50 launch kits. Studio: $48.99 a month ($36.99 a month billed annually): 10 sources, 5,000 posts, 240 audio minutes, 120 video minutes, unlimited launch kits. Annual billing saves 24%. @@ -106,6 +109,7 @@ differs from NotebookLM. - [Notestack vs NotebookLM](${base}/notebooklm-alternative): side by side comparison and FAQ - [Pricing](${base}/pricing): plans and limits - [Blog](${base}/blogs): guides on NotebookLM alternatives, AI for research and AI tools for writers +- [Free tools](${base}/tools): ${tools.map((t) => `[${t.heroTitle}](${base}/tools/${t.slug})`).join(", ")} (free with sign in) ## Blog diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8679fa59d77002addf0384877fddde9a07634b9f..de7e8bf26f90508e9127f9626b858d754648cfb6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,6 +19,7 @@ import Settings from "./pages/Settings"; import Sources from "./pages/Sources"; import Studio from "./pages/Studio"; import TopicMap from "./pages/TopicMap"; +import { ToolPage, ToolsHub } from "./pages/Tools"; import VoiceProfile from "./pages/VoiceProfile"; import Welcome from "./pages/Welcome"; @@ -43,6 +44,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index e3bb4fc11f32f8ab11c0b6f947bfa4f4a192f920..69cc9266331675f9647c0d2d818b1edae0c1210c 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -24,6 +24,8 @@ import type { Usage, Voice, Delivery, + VoiceDesignInput, + VoiceDesignPreview, VoiceProfileData, VoiceState, } from "./types"; @@ -54,6 +56,8 @@ export const notebooksApi = { get: (id: string) => api(`/api/notebooks/${id}`), create: (title: string, document_ids: string[] = [], description?: string) => post<{ id: string; title: string; added: number }>("/api/notebooks", { title, document_ids, description }), + /** The "All posts" notebook, created on first use. */ + archive: () => post<{ id: string; title: string }>("/api/notebooks/archive"), fromTopic: (topicId: string) => post<{ id: string; title: string }>(`/api/notebooks/from-topic/${topicId}`), update: (id: string, body: { title?: string; description?: string }) => patch(`/api/notebooks/${id}`, body), remove: (id: string) => del(`/api/notebooks/${id}`), @@ -70,6 +74,8 @@ export type GenerateBody = { type: ArtifactType; notebook_id?: string; document_id?: string; + /** No notebook or post: use the "All posts" notebook. */ + archive?: boolean; format?: "deep_dive" | "brief" | "debate"; minutes?: number; style?: "short" | "explainer" | "audiogram"; @@ -129,6 +135,9 @@ export const voiceApi = { readingScript: () => api<{ title: string; text: string; words: number }>("/api/voice/reading-script"), quota: () => api("/api/voice/quota"), revoke: () => del("/api/voice/consent"), + design: (input: VoiceDesignInput) => post<{ description: string; previews: VoiceDesignPreview[] }>("/api/voice/design", input), + saveDesign: (body: { generated_voice_id: string; name: string; description: string; use_as?: "host_a" | "host_b" }) => + post("/api/voice/design/save", body), }; export type ItemBody = { diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index de66383ae6dd4262e911733dcb6744894c8ea80c..8ebffd72257d179dd2217dea06ff146eb1cb5d36 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -25,6 +25,8 @@ export type Source = { sync_error: string | null; last_synced_at: string | null; document_count: number; + /** Found but past the plan's post limit: listed, not indexed until the workspace upgrades. */ + locked_count: number; is_imports: boolean; }; @@ -38,9 +40,11 @@ export type Doc = { published_at: string | null; words: number; evergreen_score: number | null; + /** Found but past the plan's post limit: title and link only, not indexed until the workspace upgrades. */ + locked: boolean; }; -export type DocDetail = Doc & { lines: string[] }; +export type DocDetail = Doc & { lines: string[]; preview?: boolean; limit?: number }; export type Page = { total: number; items: T[] }; @@ -52,6 +56,8 @@ export type NotebookSummary = { document_count: number; chat_count: number; artifact_count: number; + /** The always-present "All posts" notebook. */ + is_archive: boolean; updated_at: string | null; }; @@ -60,6 +66,7 @@ export type Notebook = { title: string; description: string | null; summary: string | null; + is_archive: boolean; documents: Doc[]; }; @@ -195,9 +202,14 @@ export type VoiceState = { error: string | null; preview_url: string | null; }; + /** Voices this workspace generated with Voice Design. */ + custom_voices: { voice_id: string; name: string; description?: string }[]; tts_configured: boolean; }; +export type VoiceDesignInput = { prompt?: string; gender?: string; age?: string; persona?: string; pace?: string; accent?: string }; +export type VoiceDesignPreview = { generated_voice_id: string; url: string; seconds: number | null }; + export type Delivery = { stability: number; similarity_boost: number; style: number; speed: number; use_speaker_boost?: boolean }; export type Voice = { diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index a2df107fa1c6dacee6f9491fe882ee0a5172bf90..ea234698b665d2666d738b88b223c677aa742ecb 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -8,31 +8,25 @@ import SkyCanvas from "./SkyCanvas"; import UpgradeModal from "./UpgradeModal"; import { AsteroidIcon, - CometIcon, - ConstellationIcon, HelmetIcon, LaunchpadIcon, LaunchWindowIcon, OrbitIcon, PlanetIcon, RadarIcon, - RocketIcon, SatelliteDishIcon, } from "./icons/Icons"; // Theme in the icon, clarity in the label: the plain label always shows. -const NAV = [ - { to: "/app", label: "Mission Control", icon: RadarIcon, end: true }, +// Seven stops: related pages share one entry (`also` keeps it lit on them). +const NAV: { to: string; label: string; icon: typeof RadarIcon; end?: boolean; also?: string[] }[] = [ + { to: "/app", label: "Home", icon: RadarIcon, end: true }, + { to: "/app/sources", label: "Sources", icon: SatelliteDishIcon, also: ["/app/map"] }, { to: "/app/notebooks", label: "Notebooks", icon: PlanetIcon }, - { to: "/app/sources", label: "Sources", icon: SatelliteDishIcon }, - { to: "/app/map", label: "Topic map", icon: ConstellationIcon }, - { to: "/app/voice", label: "Voice profile", icon: HelmetIcon }, - { to: "/app/studio", label: "Video and audio", icon: LaunchWindowIcon }, - { to: "/app/launch-kit", label: "Launch Kit", icon: RocketIcon }, - { to: "/app/launchpad", label: "Launchpad", icon: LaunchpadIcon }, - { to: "/app/archive", label: "Archive", icon: AsteroidIcon }, - { to: "/app/resurface", label: "Resurfacing", icon: CometIcon }, - { to: "/app/settings", label: "Settings", icon: OrbitIcon }, + { to: "/app/studio", label: "Create", icon: LaunchWindowIcon, also: ["/app/launch-kit"] }, + { to: "/app/launchpad", label: "Launchpad", icon: LaunchpadIcon, also: ["/app/resurface"] }, + { to: "/app/archive", label: "Library", icon: AsteroidIcon }, + { to: "/app/voice", label: "Voice", icon: HelmetIcon }, ]; const COLLAPSED_KEY = "ns_sidebar_collapsed"; @@ -95,14 +89,24 @@ export default function AppShell() { + `side-link side-settings${isActive ? " active" : ""}`}> + + Settings +
{user.email}
+
+

Free tools

+ {tools.map((t) => ( + + {t.eyebrow} + + ))} +

From the blog

{blogPosts.slice(0, 3).map((p) => ( diff --git a/frontend/src/components/Reader.tsx b/frontend/src/components/Reader.tsx index a1ab985ba532f7b5ca749320ab2e4edf2b686fba..42406479ce95e01ee2664d4e91878ffcb332002c 100644 --- a/frontend/src/components/Reader.tsx +++ b/frontend/src/components/Reader.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { docsApi } from "../api/endpoints"; import type { DocDetail } from "../api/types"; +import { useUpgrade } from "../hooks/useUpgrade"; import { Drawer, formatDate, Loading } from "./ui"; /** Reads a post from the corpus with the exact line numbers citations point at. */ @@ -16,6 +17,7 @@ export function Reader({ const [doc, setDoc] = useState(null); const [error, setError] = useState(null); const first = useRef(null); + const { openUpgrade } = useUpgrade(); useEffect(() => { setDoc(null); @@ -69,6 +71,20 @@ export function Reader({ ); })}
+ {doc.preview && ( +
+
+ This post is not indexed +

+ Your plan indexes your latest {doc.limit ?? 5} posts. Upgrade to index this one and the rest of your + archive, so you can ask about it and turn it into audio, video and Launch Kits. +

+
+ +
+ )} )} diff --git a/frontend/src/content/blogPosts.ts b/frontend/src/content/blogPosts.ts index fb0ae3f473512f9a2174c0cae83f276b0f9bce05..cea407251fe2a64d4b82d492a169f10f01228376 100644 --- a/frontend/src/content/blogPosts.ts +++ b/frontend/src/content/blogPosts.ts @@ -46,6 +46,7 @@ export const blogPosts: BlogPost[] = [ paragraphs: [ "Paste your Substack URL. Pick a post or build a notebook of related posts. Choose a format: deep dive, brief or debate. Notestack writes the script, you review it, and ElevenLabs voices it.", "Publish the file to your podcast feed, attach it to the original post, or cut a 60 second audiogram for social.", + "Want to see the shape of the episode before you connect anything? Paste a post into the free [AI podcast generator](/tools/ai-podcast-generator) for a two host script, or use the [podcast script generator](/tools/podcast-script-generator) if you plan to record it yourself.", ], }, ], diff --git a/frontend/src/content/missionPosts.ts b/frontend/src/content/missionPosts.ts index 8b568aef58364261b6409bc4172b0ca42f687134..bc859d5cf9d00ef07c3c622ceefd704449af70ed 100644 --- a/frontend/src/content/missionPosts.ts +++ b/frontend/src/content/missionPosts.ts @@ -77,6 +77,7 @@ export const missionPosts: BlogPost[] = [ { heading: "For audio and video overviews", paragraphs: [ + "For a quick start, the free [AI podcast generator](/tools/ai-podcast-generator) turns any post into a two host script, and [PDF to podcast](/tools/pdf-to-podcast) does the same for papers and reports.", "Audio overviews are the feature people miss most. Notestack makes two host audio overviews from a post or a notebook, with every line tied back to a passage, and you can pick from ElevenLabs voices or clone your own with consent.", "Video is where dedicated tools win. If your source is a report, paper or deck, [PDF2Video](https://pdf2vid.com) turns any PDF into a narrated video. If it is a blog post, URL or script, [Blog2Video](https://blog2video.app) turns it into a finished video without you touching an editor. Both are a better fit than a notebook when the video itself is the deliverable.", ], @@ -86,7 +87,7 @@ export const missionPosts: BlogPost[] = [ heading: "How to switch without losing your work", paragraphs: [ "Export what you can first: your notes, saved answers and the list of sources. Then rebuild around the job you named at the start rather than recreating every notebook one to one.", - "If you are a writer, the fastest path is to connect your publication and let the archive rebuild itself. Once it is indexed, list it on [BlogHub](https://bloghub.app), a free blog and newsletter directory, so new readers can find the work you are now researching and repurposing.", + "If you are a writer, the fastest path is to connect your publication and let the archive rebuild itself. Once it is indexed, list it on [BlogHub](https://bloghub.app/submit-your-newsletter), a free blog and newsletter directory, so new readers can find the work you are now researching and repurposing.", ], }, ], @@ -173,7 +174,7 @@ export const missionPosts: BlogPost[] = [ heading: "Stage four: share what you found", paragraphs: [ "Research that stays in a document rarely gets read. Turn the dense parts into formats people actually consume. [PDF2Video](https://pdf2vid.com) turns a paper, report or slide deck into a narrated video, which is often the fastest way to brief a team or a class.", - "For findings you have written up as a post, [Blog2Video](https://blog2video.app) makes a video from the URL, and an audio overview gives listeners the two host version while you keep every line grounded in the source.", + "For findings you have written up as a post, [Blog2Video](https://blog2video.app/url-to-video) makes a video from the URL, and an audio overview gives listeners the two host version while you keep every line grounded in the source.", ], }, { @@ -274,7 +275,7 @@ export const missionPosts: BlogPost[] = [ heading: "Distribution without the busywork", paragraphs: [ "Deep posts deserve a long life, and that is where AI saves the most time. A Launch Kit turns one post into a thread, a LinkedIn post, Substack Notes and a carousel, written from claims the post actually makes.", - "Video extends the reach further: [Blog2Video](https://blog2video.app) turns a finished post into a video for YouTube or Shorts. And make sure new readers can find you at all by listing your publication on [BlogHub](https://bloghub.app), a free directory of blogs and newsletters.", + "Video extends the reach further: [Blog2Video](https://blog2video.app/blog-to-shorts) turns a finished post into a video for YouTube or Shorts. And make sure new readers can find you at all by listing your publication on [BlogHub](https://bloghub.app/submit-your-newsletter), a free directory of blogs and newsletters.", ], }, { @@ -302,7 +303,7 @@ export const missionPosts: BlogPost[] = [ { question: "How do I grow readers for a newsletter?", answer: - "Repurpose each post into formats people discover on other platforms, and list your publication on [BlogHub](https://bloghub.app) so readers browsing for new blogs and newsletters can find it.", + "Repurpose each post into formats people discover on other platforms, and list your publication on [BlogHub](https://bloghub.app/submit-your-newsletter) so readers browsing for new blogs and newsletters can find it. For more places to share your work, see this list of [article submission sites](https://bloghub.app/blogs/article-submission-sites).", }, { question: "Can AI turn my blog posts into videos?", @@ -348,7 +349,7 @@ export const missionPosts: BlogPost[] = [ heading: "Where ChatGPT wins", paragraphs: [ "ChatGPT is the better thinking partner when you do not have sources yet. It explains unfamiliar concepts, suggests angles, drafts outlines and argues the other side.", - "Use it early and loosely. Anything factual that survives into your final draft should be checked against a real source.", + "Use it early and loosely. Anything factual that survives into your final draft should be checked against a real source. And if you are wondering whether it can go further and produce media, here is the honest answer to [can ChatGPT make videos](https://blog2video.app/blogs/can-chatgpt-make-videos).", ], bullets: ["Brainstorming and outlining", "Explaining concepts you are new to", "Stress testing an argument"], }, diff --git a/frontend/src/content/notebooklmAlternative.ts b/frontend/src/content/notebooklmAlternative.ts new file mode 100644 index 0000000000000000000000000000000000000000..21805f33a57e5cfb0261d0b7090a14e399882f89 --- /dev/null +++ b/frontend/src/content/notebooklmAlternative.ts @@ -0,0 +1,79 @@ +// Copy for /notebooklm-alternative, shared by the React page and the build time prerender (seo/prerender.ts). + +export const NOTEBOOKLM_ALT_META = { + title: "Notestack vs NotebookLM: The NotebookLM Alternative for Writers | Notestack", + description: + "Notestack vs NotebookLM, side by side. Notestack is the NotebookLM alternative for writers, bloggers and newsletters: sync your archive, get answers cited to the exact line, and turn your writing into audio, video and launch posts.", +}; + +export const NOTEBOOKLM_ALT_ROWS: { feature: string; notestack: string; notebooklm: string }[] = [ + { + feature: "Getting your writing in", + notestack: "Syncs any blog, newsletter or site feed (Substack, Ghost, WordPress, Medium, RSS), plus markdown, text, HTML and PDF uploads", + notebooklm: "Add sources one at a time: PDFs, Docs, web pages, videos", + }, + { + feature: "Stays in sync", + notestack: "New posts arrive automatically on every sync", + notebooklm: "Re-add sources by hand when they change", + }, + { + feature: "Citations", + notestack: "Exact line ranges, read back and verified before you see them", + notebooklm: "Passage level citations to the source", + }, + { + feature: "When the answer is not in your sources", + notestack: "Says so instead of guessing; unverifiable claims are dropped", + notebooklm: "Grounded in sources, with passage citations", + }, + { + feature: "Chat memory", + notestack: "Follow ups resolved from the conversation; off topic questions answered instantly", + notebooklm: "Conversation within a notebook", + }, + { + feature: "Audio overviews", + notestack: "Two hosts, ElevenLabs voices, delivery controls, or your own consented voice clone", + notebooklm: "Two host audio overviews with Google voices", + }, + { + feature: "Video", + notestack: "9:16 shorts, 16:9 explainers and 1:1 audiograms with captions and your brand", + notebooklm: "Video overviews", + }, + { + feature: "Publishing", + notestack: "Launch Kits (threads, LinkedIn, Notes, Bluesky, SEO, carousels, quote cards) and a scheduling calendar", + notebooklm: "Not a publishing tool", + }, + { + feature: "Your voice", + notestack: "Voice profile learned from your posts, used for every draft", + notebooklm: "General purpose writing", + }, + { + feature: "Seeing your archive", + notestack: "Topic map with rising and dormant topics, plus evergreen resurfacing", + notebooklm: "Mind maps per notebook", + }, +]; + +export const NOTEBOOKLM_ALT_FAQ = [ + { + q: "What is the best NotebookLM alternative?", + a: "For writers, bloggers and newsletters, Notestack is the ultimate NotebookLM alternative: it syncs your whole archive, cites the exact lines behind every answer, and turns what you wrote into audio, video and launch posts. For academic papers, discovery tools like Elicit or Consensus pair well with it.", + }, + { + q: "Is Notestack free?", + a: "Yes, there is a free plan to try it on your latest posts. Paid plans add more sources, more posts, more audio and video minutes and voice cloning.", + }, + { + q: "Can Notestack make NotebookLM style audio overviews?", + a: "Yes. It writes a two host conversation grounded in your posts and voices it with ElevenLabs, with every line tied to the passage it came from. You can choose the voices or clone your own with consent.", + }, + { + q: "What if my writing is not on Substack?", + a: "Notestack works with any blog, newsletter or site that has a feed, single article URLs, and uploaded markdown, text, HTML or PDF files.", + }, +]; diff --git a/frontend/src/content/tools.ts b/frontend/src/content/tools.ts new file mode 100644 index 0000000000000000000000000000000000000000..e62f5b27404951b296d08ea9cb28e9c1a554feb1 --- /dev/null +++ b/frontend/src/content/tools.ts @@ -0,0 +1,225 @@ +// Free tools at /tools/. Copy is shared by the React page and the build time prerender (seo/prerender.ts). +// Every tool widget requires sign in; the copy below stays public so the page can rank. +// Volumes are US Google Ads, Sep 2026. No em dashes (npm run check:copy). + +export type ToolSection = { heading: string; paragraphs: string[]; bullets?: string[] }; + +export type ToolDef = { + slug: string; + /** and meta description. */ + metaTitle: string; + description: string; + eyebrow: string; + heroTitle: string; + heroDescription: string; + primaryKeyword: string; + /** What the widget asks for. */ + inputLabel: string; + inputPlaceholder: string; + /** Optional line under the input (inline [text](url) links allowed). */ + inputHint?: string; + sections: ToolSection[]; + faq: { question: string; answer: string }[]; + relatedPaths: string[]; +}; + +export const toolsHub = { + path: "/tools", + metaTitle: "Free AI Podcast Tools for Writers | Notestack", + description: + "Free tools that turn your writing into podcasts: an AI podcast generator, a PDF to podcast converter and a two host podcast script generator.", + heroTitle: "Free tools for turning writing into audio", + heroDescription: + "Every tool works on words you already wrote. Sign in free, paste a post or a PDF's text, and get a two host script you can voice in Notestack.", +}; + +export const tools: ToolDef[] = [ + { + slug: "ai-podcast-generator", + metaTitle: "Free AI Podcast Generator: Turn Any Post Into a Two Host Show | Notestack", + description: + "Free AI podcast generator for writers. Paste a blog post or newsletter and get a two host podcast script grounded in your words, then voice it as a full audio overview in Notestack.", + eyebrow: "AI podcast generator", + heroTitle: "AI podcast generator for writers", + heroDescription: + "Paste a blog post, newsletter or article and get a two host podcast script built only from what you wrote. Voice it as a finished audio overview in one click.", + primaryKeyword: "ai podcast generator", + inputLabel: "Paste your post", + inputPlaceholder: "Paste a blog post, newsletter issue or article. The script is built only from this text.", + sections: [ + { + heading: "How the AI podcast generator works", + paragraphs: [ + "Most AI podcast generators write a new show and hope it matches your ideas. This one starts from your text. It picks the sentences that carry your argument, then stages them as a conversation between two hosts: one who guides, one who asks the questions a listener would ask.", + "Because every host line comes from a passage you wrote, nothing is invented. In the full Notestack app each line keeps a citation back to the exact source lines, so you can check the show before anyone hears it.", + ], + }, + { + heading: "From script to finished episode", + paragraphs: [ + "The free tool gives you the script. To hear it, connect your blog or newsletter to Notestack and generate an audio overview: two natural voices, delivery controls, and optional consented voice cloning so one host can sound like you. The free plan includes audio minutes to try it.", + "Want a video instead of audio? [Blog2Video](https://blog2video.app/blog-to-video) turns the same post into a narrated video, and [PDF2Video](https://pdf2vid.com) does the same for reports and papers.", + ], + bullets: [ + "Two hosts, grounded in your own post", + "Runtime estimate before you record anything", + "Copy or download the script as text", + "Voice it in Notestack with two host audio overviews", + ], + }, + { + heading: "Why writers use a podcast generator", + paragraphs: [ + "A post that took a week to write gets read once. A two host episode of the same post reaches people on walks, commutes and at the gym, and it keeps your archive working long after publish day. It is the fastest way to add audio to a newsletter without booking a guest or sitting at a microphone.", + "If you are comparing tools, our guide to the [best NotebookLM alternatives](/blogs/notebooklm-alternatives) covers the other audio overview options, and [Notestack vs NotebookLM](/notebooklm-alternative) explains the difference side by side.", + ], + }, + ], + faq: [ + { + question: "Is the AI podcast generator free?", + answer: + "Yes. Sign in with a free Notestack account and generate as many scripts as you like. Turning a script into audio uses the audio minutes included in every plan, free plan included.", + }, + { + question: "Does it make up content?", + answer: + "No. The script is assembled only from sentences in the text you paste. The hosts reframe and question your points, but the substance is always yours.", + }, + { + question: "How is this different from NotebookLM audio overviews?", + answer: + "NotebookLM works on sources you upload one at a time. Notestack syncs your whole blog or newsletter, keeps citations to the exact lines, and can voice one host in your own cloned voice with consent.", + }, + { + question: "How long will my episode be?", + answer: + "The tool estimates runtime at about 150 spoken words per minute. A 1,500 word post usually becomes a six to nine minute episode.", + }, + ], + relatedPaths: ["/tools/pdf-to-podcast", "/tools/podcast-script-generator", "/blogs/turn-substack-archive-into-podcast", "/notebooklm-alternative"], + }, + { + slug: "pdf-to-podcast", + metaTitle: "PDF to Podcast: Turn Any PDF Into a Two Host Podcast Free | Notestack", + description: + "Turn a PDF, research paper or report into a two host podcast. Paste the PDF's text or upload the file to Notestack, and get an audio overview grounded in the document.", + eyebrow: "PDF to podcast", + heroTitle: "Turn any PDF into a podcast", + heroDescription: + "Research papers, reports, ebooks and slide decks become a two host conversation you can listen to. Every line stays tied to the page it came from.", + primaryKeyword: "pdf to podcast", + inputLabel: "Paste the text from your PDF", + inputPlaceholder: "Paste the text of your PDF here. The script is built only from this text.", + inputHint: + "Need the text first? Pull it out with the free [PDF to text tool](https://pdf2vid.com/tools/pdf-to-text), or upload the PDF straight into a Notestack notebook.", + sections: [ + { + heading: "How PDF to podcast works", + paragraphs: [ + "Paste the text from your PDF and the tool pulls out the sentences that carry the document: the claim, the evidence and the conclusion. It stages them as a two host conversation, with one host explaining and the other asking what a listener would ask.", + "In the Notestack app you can skip the copy and paste. Upload the PDF into a notebook and generate a full audio overview, with every host line cited to the page and lines it came from.", + ], + }, + { + heading: "Which PDFs make the best podcasts", + paragraphs: [ + "Documents with an argument work best: research papers, white papers, annual reports, long reads and ebook chapters. Pure reference material such as manuals or spreadsheets makes a thinner show.", + ], + bullets: [ + "Research papers: the hosts walk through the question, method and finding", + "Reports: turn the executive summary into a ten minute briefing", + "Ebooks: one episode per chapter becomes a series", + "Slide decks: the narration fills in what the slides leave out", + ], + }, + { + heading: "Podcast or video?", + paragraphs: [ + "Audio suits people who listen on the move. If your audience watches instead, [PDF2Video](https://pdf2vid.com) turns the same PDF into a narrated video with slides, and its [PDF to audio](https://pdf2vid.com/tools/pdf-to-audio) tool reads a document aloud word for word. A podcast is different: two hosts talk the ideas through.", + "Researching across many PDFs? Read our guide to the [best AI for research](/blogs/best-ai-for-research).", + ], + }, + ], + faq: [ + { + question: "Can I turn a PDF into a podcast for free?", + answer: + "Yes. Sign in free, paste the PDF's text and get a two host script. Voicing it in Notestack uses the audio minutes included in the free plan.", + }, + { + question: "Does it work with scanned PDFs?", + answer: + "The tool needs selectable text. If your PDF is a scan, run it through OCR first, then paste the text.", + }, + { + question: "Is PDF to podcast the same as PDF to audio?", + answer: + "No. PDF to audio reads the document aloud. PDF to podcast turns it into a conversation between two hosts who explain and question the ideas, which is easier to follow for long or technical documents.", + }, + ], + relatedPaths: ["/tools/ai-podcast-generator", "/tools/podcast-script-generator", "/blogs/best-ai-for-research", "/notebooklm-alternative"], + }, + { + slug: "podcast-script-generator", + metaTitle: "Free Podcast Script Generator: Two Host Scripts From Your Writing | Notestack", + description: + "Free podcast script generator. Paste your notes, post or article and get a formatted two host podcast script with an intro, segments, questions and an outro, built only from your words.", + eyebrow: "Podcast script generator", + heroTitle: "Podcast script generator", + heroDescription: + "Turn notes, a post or an article into a ready to record two host script: cold open, segments, the questions your listeners would ask, and a clean outro.", + primaryKeyword: "podcast script generator", + inputLabel: "Paste your notes or post", + inputPlaceholder: "Paste notes, an outline or a full post. The script is built only from this text.", + sections: [ + { + heading: "What you get", + paragraphs: [ + "A script in the standard two host format: a cold open that states the big idea, segments built from your key points, host questions that set up each point, and an outro that lands the takeaway. Each line is labelled with its speaker so you can record straight from it.", + ], + bullets: [ + "Speaker labelled lines for Host A and Host B", + "Segments ordered the way your text builds its argument", + "Estimated runtime at a natural speaking pace", + "Plain text you can copy into any teleprompter or doc", + ], + }, + { + heading: "Record it, or let Notestack voice it", + paragraphs: [ + "Record the script yourself with a co host, or generate a voiced audio overview in Notestack with two natural voices. Writers who publish every week connect their newsletter once, and every new post can become an episode.", + "Turning a whole back catalog into a show? Read [how to turn your Substack archive into a podcast](/blogs/turn-substack-archive-into-podcast).", + ], + }, + { + heading: "Tips for a better script", + paragraphs: ["The generator follows your text, so the clearer the source, the better the show."], + bullets: [ + "Paste the full post, not a summary: the hosts need your specifics", + "Keep one idea per paragraph so segments split cleanly", + "Edit the questions to sound like your real audience", + "Aim for 800 to 2,000 words for a five to fifteen minute episode", + ], + }, + ], + faq: [ + { + question: "Is the podcast script generator free?", + answer: "Yes. It is free with a Notestack account. Sign in and generate as many scripts as you need.", + }, + { + question: "Can I use the script commercially?", + answer: "Yes. The script is built from your own text, so it is yours to record, publish and monetize.", + }, + { + question: "Does it support one host shows?", + answer: + "The generator writes for two hosts, the format that keeps long ideas easy to follow. For a solo show, read Host A's lines and turn Host B's questions into your own transitions.", + }, + ], + relatedPaths: ["/tools/ai-podcast-generator", "/tools/pdf-to-podcast", "/blogs/substack-launch-kit", "/blogs/ai-tools-for-writers"], + }, +]; + +export const getTool = (slug: string) => tools.find((t) => t.slug === slug); diff --git a/frontend/src/lib/podcastScript.ts b/frontend/src/lib/podcastScript.ts new file mode 100644 index 0000000000000000000000000000000000000000..e3a697d27e3e0d5ccd24c52a48b99740560729d6 --- /dev/null +++ b/frontend/src/lib/podcastScript.ts @@ -0,0 +1,85 @@ +// Builds a two host podcast script from pasted text, entirely in the browser. Every substantive line is a sentence +// from the source; the hosts only add framing and questions around it, so nothing is invented. + +export type ScriptLine = { host: "A" | "B"; text: string }; +export type PodcastScript = { lines: ScriptLine[]; words: number; minutes: number; sourceSentences: number }; + +const WORDS_PER_MINUTE = 150; + +const STOP = new Set( + "a an and are as at be but by for from has have he her his i if in into is it its of on or our she so than that the their them then there these they this to was we were what when which who will with you your not can do does did just also more most very about over out up".split( + " ", + ), +); + +const QUESTIONS = [ + "So where does it start?", + "Okay, and why does that matter?", + "What is the part people usually miss?", + "Can you make that concrete?", + "So what changes because of that?", + "Is there a catch?", + "How does that play out in practice?", + "And where does that leave us?", +]; + +const words = (s: string) => s.trim().split(/\s+/).filter(Boolean); + +function sentences(text: string): string[] { + return text + .replace(/\r/g, "") + .split(/\n{2,}|(?<=[.!?])\s+(?=[A-Z0-9"'(])/) + .map((s) => s.replace(/\s+/g, " ").trim()) + .filter((s) => { + const n = words(s).length; + return n >= 6 && n <= 60 && /[a-z]/i.test(s); + }); +} + +/** Scores sentences by how many of the text's recurring terms they carry, with a small lead bias. */ +function pickPoints(all: string[]): string[] { + const freq = new Map<string, number>(); + for (const s of all) + for (const w of words(s.toLowerCase().replace(/[^a-z0-9\s]/g, " "))) + if (w.length > 3 && !STOP.has(w)) freq.set(w, (freq.get(w) ?? 0) + 1); + const scored = all.map((s, i) => { + const terms = words(s.toLowerCase().replace(/[^a-z0-9\s]/g, " ")).filter((w) => freq.has(w)); + const score = terms.reduce((sum, w) => sum + Math.min(freq.get(w) ?? 0, 5), 0) / Math.sqrt(words(s).length); + return { s, i, score: score + (i === 0 ? 2 : 0) + (i === all.length - 1 ? 1 : 0) }; + }); + return scored + .sort((a, b) => b.score - a.score) + // Roughly one point per three source sentences, so longer posts make longer episodes. + .slice(0, Math.min(16, Math.max(5, Math.round(all.length / 3)))) + .sort((a, b) => a.i - b.i) + .map((x) => x.s); +} + +export function buildPodcastScript(text: string): PodcastScript | null { + const all = sentences(text); + if (all.length < 3) return null; + const points = pickPoints(all); + const [opening, ...body] = points; + const closing = body.length > 2 ? body.pop()! : undefined; + + const lines: ScriptLine[] = [ + { host: "A", text: "Welcome back. Today we are walking through one piece of writing, and staying close to what it actually says." }, + { host: "B", text: "Good, because I want the argument, not a summary. What is the big idea?" }, + { host: "A", text: opening }, + ]; + body.forEach((point, i) => { + lines.push({ host: "B", text: QUESTIONS[i % QUESTIONS.length] }); + lines.push({ host: "A", text: point }); + }); + if (closing) { + lines.push({ host: "B", text: "If someone remembers one thing from this, what should it be?" }); + lines.push({ host: "A", text: closing }); + } + lines.push({ host: "B", text: "That is the episode. The full piece goes deeper, so read it if this landed for you." }); + + const count = lines.reduce((n, l) => n + words(l.text).length, 0); + return { lines, words: count, minutes: count / WORDS_PER_MINUTE, sourceSentences: all.length }; +} + +export const scriptToText = (script: PodcastScript) => + script.lines.map((l) => `HOST ${l.host}: ${l.text}`).join("\n\n"); diff --git a/frontend/src/pages/Archive.tsx b/frontend/src/pages/Archive.tsx index f9f31572b50e95255a5f4300ed9c4fd86232cd17..813a1fecf912ae99d0f6f81833eab35cf26a2cb8 100644 --- a/frontend/src/pages/Archive.tsx +++ b/frontend/src/pages/Archive.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from "react"; +import { Link } from "react-router-dom"; import { artifactsApi } from "../api/endpoints"; import type { Artifact } from "../api/types"; import { ArtifactCard } from "../components/ArtifactCard"; @@ -10,7 +11,6 @@ const PAGE = 30; export default function Archive() { const [filter, setFilter] = useState<Filter>("all"); - const [status, setStatus] = useState<"" | "ready" | "failed">(""); const [q, setQ] = useState(""); const [items, setItems] = useState<Artifact[] | null>(null); const [total, setTotal] = useState(0); @@ -19,12 +19,12 @@ export default function Archive() { const load = useCallback( (offset = 0) => artifactsApi - .list({ type: filter === "all" ? undefined : filter, status: status || undefined, q, limit: PAGE, offset }) + .list({ type: filter === "all" ? undefined : filter, q, limit: PAGE, offset }) .then((p) => { setTotal(p.total); setItems((cur) => (offset ? [...(cur ?? []), ...p.items] : p.items)); }), - [filter, status, q], + [filter, q], ); useEffect(() => { @@ -34,7 +34,7 @@ export default function Archive() { return ( <div className="page-wrap"> - <PageHeader eyebrow="Archive" title="Everything you have launched" /> + <PageHeader eyebrow="Library" title="Everything you have made" /> <div className="row between wrap"> <Tabs<Filter> tabs={[ @@ -48,18 +48,16 @@ export default function Archive() { value={filter} onChange={setFilter} /> - <div className="row"> - <select className="input input-sm" value={status} onChange={(e) => setStatus(e.target.value as typeof status)} aria-label="Status"> - <option value="">Any status</option> - <option value="ready">Ready</option> - <option value="failed">Failed</option> - </select> - <input className="input input-sm search" placeholder="Search by post or notebook" value={q} onChange={(e) => setQ(e.target.value)} aria-label="Search" /> - </div> + <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" /> </div> - <p className="mono muted">{total} artifacts</p> {!items && <Loading />} - {items?.length === 0 && <EmptyState title="Nothing here yet" body="Summaries, audio, videos and launch kits you create show up here." />} + {items?.length === 0 && <EmptyState title="Nothing here yet" body="Summaries, audio, videos and launch kits you create show up here." + action={ + <Link className="btn btn-primary" to="/app/studio"> + Create something + </Link> + } + />} <div className="gallery"> {items?.map((a) => ( <ArtifactCard diff --git a/frontend/src/pages/AuthCallback.tsx b/frontend/src/pages/AuthCallback.tsx index 378c3e753b27540905144671f0a46b9a361af608..0e24b969c58f65386725d9ec08b34ae91260c022 100644 --- a/frontend/src/pages/AuthCallback.tsx +++ b/frontend/src/pages/AuthCallback.tsx @@ -24,7 +24,7 @@ export default function AuthCallback() { .redeemTicket(ticket) .then((result) => { complete(result); - navigate(result.created ? "/welcome" : next, { replace: true }); + navigate(result.created && !next.startsWith("/tools/") ? "/welcome" : next, { replace: true }); }) .catch(() => { const error = new URLSearchParams({ error: "Google sign in expired. Try again." }); diff --git a/frontend/src/pages/AuthPage.tsx b/frontend/src/pages/AuthPage.tsx index aae650944aae165c2e580c9144bceb3a02cc8574..db3a9569b82f020d7d9d2019e8cb6795a05fd7ee 100644 --- a/frontend/src/pages/AuthPage.tsx +++ b/frontend/src/pages/AuthPage.tsx @@ -63,7 +63,7 @@ function GoogleButton({ onCredential }: { onCredential: (c: string) => void }) { }, [onCredential]); if (!GOOGLE_CLIENT_ID) { - return <p className="mono muted auth-hint">Set GOOGLE_CLIENT_ID in .env to enable Google sign in.</p>; + return null; } return <div ref={ref} className="google-btn" />; } @@ -85,7 +85,8 @@ export default function AuthPage() { const next = safeNext(params.get("next")); useEffect(() => { - if (user) navigate(isNew ? "/welcome" : next, { replace: true }); + // New accounts get onboarding, except people who signed up to use a free tool: send them straight back to it. + if (user) navigate(isNew && !next.startsWith("/tools/") ? "/welcome" : next, { replace: true }); }, [user, navigate, next, isNew]); useEffect(() => { @@ -170,9 +171,6 @@ export default function AuthPage() { <> {providers?.google === "redirect" && <GoogleRedirectButton next={next} />} {providers?.google === "popup" && <GoogleButton onCredential={onGoogle} />} - {providers && !providers.google && ( - <p className="mono muted auth-hint">Set GOOGLE_CLIENT_ID in .env to enable Google sign in.</p> - )} {!providers && <div className="google-btn" />} <div className="auth-divider mono"> <span>or with email</span> diff --git a/frontend/src/pages/Blog.tsx b/frontend/src/pages/Blog.tsx index e6e4e56e7e7d58178f3016289a43215b6a6b62ad..8fbd25ff95da7b7f17b37b14e1b0622d6cc965f2 100644 --- a/frontend/src/pages/Blog.tsx +++ b/frontend/src/pages/Blog.tsx @@ -45,7 +45,7 @@ const missionOf = (post: BlogPost): Mission => const INLINE = /(\[[^\]]+\]\([^)\s]+\))|(\*\*[^*]+\*\*)/g; /** Inline [text](url) links and **bold** inside post copy. External links open in a new tab and stay followed. */ -function Inline({ text }: { text: string }) { +export function Inline({ text }: { text: string }) { const out: ReactNode[] = []; let last = 0; let i = 0; diff --git a/frontend/src/pages/LaunchKit.tsx b/frontend/src/pages/LaunchKit.tsx index 27354d8b3d057d3b771d3c95b6af69d2d1c10320..0b6e9a816fb71a2750de4f5a10c8b7e41baeb63a 100644 --- a/frontend/src/pages/LaunchKit.tsx +++ b/frontend/src/pages/LaunchKit.tsx @@ -1,15 +1,15 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; -import { artifactsApi } from "../api/endpoints"; -import type { Artifact, LaunchKitContent, Platform } from "../api/types"; +import { artifactsApi, docsApi } from "../api/endpoints"; +import type { Artifact, Doc, LaunchKitContent, Platform } from "../api/types"; import { ArtifactCard } from "../components/ArtifactCard"; import { DocPicker } from "../components/DocPicker"; import { ScheduleModal } from "../components/ScheduleModal"; -import { ConfirmButton, CopyButton, EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader, StatusPill, Tabs } from "../components/ui"; +import { ConfirmButton, CopyButton, errorMessage, formatDate, JobProgress, Loading, PageHeader, StatusPill, Tabs } from "../components/ui"; import { useJob } from "../hooks/useJob"; type PostKey = keyof LaunchKitContent["posts"]; -type Tab = "hooks" | PostKey | "seo" | "carousel" | "quotes" | "claims"; +type Tab = "posts" | "hooks" | "visuals" | "seo"; const PLATFORM_OF: Record<PostKey, { platform: Platform; label: string; limit: number; thread: boolean }> = { x_thread: { platform: "x", label: "X thread", limit: 280, thread: true }, @@ -175,7 +175,8 @@ function QuotesTab({ kit }: { kit: Artifact }) { function KitView({ kit: initial, onDeleted }: { kit: Artifact; onDeleted: () => void }) { const [kit, setKit] = useState(initial); - const [tab, setTab] = useState<Tab>("hooks"); + const [tab, setTab] = useState<Tab>("posts"); + const [platform, setPlatform] = useState<PostKey>("x_thread"); const job = useJob(initial.job, () => artifactsApi.get(initial.id).then(setKit)); useEffect(() => setKit(initial), [initial]); const c = kit.content as LaunchKitContent; @@ -220,16 +221,23 @@ function KitView({ kit: initial, onDeleted }: { kit: Artifact; onDeleted: () => <> <Tabs<Tab> tabs={[ + { id: "posts", label: "Posts" }, { id: "hooks", label: "Hooks", count: c.hooks?.length }, - ...(Object.keys(PLATFORM_OF) as PostKey[]).map((k) => ({ id: k as Tab, label: PLATFORM_OF[k].label })), + { id: "visuals", label: "Visuals", count: (c.carousel?.length ?? 0) + (c.quotes?.length ?? 0) }, { id: "seo", label: "SEO" }, - { id: "carousel", label: "Carousel", count: c.carousel?.length }, - { id: "quotes", label: "Quotes", count: c.quotes?.length }, - { id: "claims", label: "Claims", count: c.claims?.length }, ]} value={tab} onChange={setTab} /> + {tab === "posts" && ( + <div className="gen-chips" role="tablist" aria-label="Platform"> + {(Object.keys(PLATFORM_OF) as PostKey[]).map((k) => ( + <button key={k} type="button" role="tab" aria-selected={platform === k} className={`chip${platform === k ? " on" : ""}`} onClick={() => setPlatform(k)}> + {PLATFORM_OF[k].label} + </button> + ))} + </div> + )} {tab === "hooks" && ( <ol className="hooks"> {(c.hooks ?? []).map((h, i) => ( @@ -243,7 +251,7 @@ function KitView({ kit: initial, onDeleted }: { kit: Artifact; onDeleted: () => ))} </ol> )} - {tab in PLATFORM_OF && <PostsEditor kit={kit} keyName={tab as PostKey} onSave={(p) => savePosts(tab as PostKey, p)} />} + {tab === "posts" && <PostsEditor kit={kit} keyName={platform} onSave={(p) => savePosts(platform, p)} />} {tab === "seo" && ( <dl className="seo"> <dt>Title options</dt> @@ -287,9 +295,17 @@ function KitView({ kit: initial, onDeleted }: { kit: Artifact; onDeleted: () => </dd> </dl> )} - {tab === "carousel" && <CarouselTab kit={kit} />} - {tab === "quotes" && <QuotesTab kit={kit} />} - {tab === "claims" && ( + {tab === "visuals" && ( + <> + <h3>Carousel</h3> + <CarouselTab kit={kit} /> + <h3>Quotes</h3> + <QuotesTab kit={kit} /> + </> + )} + {tab === "posts" && (c.claims?.length ?? 0) > 0 && ( + <details className="delivery"> + <summary className="mono muted small">Fact check: {c.claims?.length} claims traced to the post</summary> <ol className="claims"> {(c.claims ?? []).map((cl, i) => ( <li key={i}> @@ -305,6 +321,7 @@ function KitView({ kit: initial, onDeleted }: { kit: Artifact; onDeleted: () => </li> ))} </ol> + </details> )} </> )} @@ -312,91 +329,144 @@ function KitView({ kit: initial, onDeleted }: { kit: Artifact; onDeleted: () => ); } +/** Pick a post card (one click generates), or reopen an earlier kit from the rail. */ export default function LaunchKit() { const [params, setParams] = useSearchParams(); const [kits, setKits] = useState<Artifact[] | null>(null); - const [post, setPost] = useState<string[]>(params.get("post") ? [params.get("post")!] : []); + const [newest, setNewest] = useState<Doc[] | null>(null); + const [post, setPost] = useState<string[]>([]); + const [searching, setSearching] = useState(false); const [active, setActive] = useState<Artifact | null>(null); - const [busy, setBusy] = useState(false); + const [busy, setBusy] = useState<string | null>(null); const [error, setError] = useState<string | null>(null); + const autoStarted = useRef(false); const load = useCallback(() => artifactsApi.list({ type: "launch_kit", limit: 100 }).then((p) => setKits(p.items)), []); useEffect(() => { load(); + docsApi.list({ limit: 24 }).then((p) => setNewest(p.items.filter((d) => !d.locked).slice(0, 6))); }, [load]); useEffect(() => { const id = params.get("kit"); if (id && active?.id !== id) artifactsApi.get(id).then(setActive, () => setActive(null)); - }, [params, active?.id]); + if (!id && active) setActive(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [params]); - const generate = async () => { - if (!post[0]) return; - setBusy(true); + const generate = async (documentId: string) => { + setBusy(documentId); setError(null); try { - const kit = await artifactsApi.generate({ type: "launch_kit", document_id: post[0] }); + const kit = await artifactsApi.generate({ type: "launch_kit", document_id: documentId }); setKits((k) => [kit, ...(k ?? [])]); setActive(kit); setParams({ kit: kit.id }); } catch (e) { setError(errorMessage(e)); } finally { - setBusy(false); + setBusy(null); + } + }; + + // ?post=<id> (from Resurfacing or a post page) starts the kit straight away. + useEffect(() => { + const id = params.get("post"); + if (id && !autoStarted.current) { + autoStarted.current = true; + void generate(id); } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const open = (k: Artifact) => { + setActive(k); + setParams({ kit: k.id }); }; return ( <div className="page-wrap"> - <PageHeader eyebrow="Launch Kit" title="Everything you need to launch a post" /> - <div className="kit-layout"> - <aside className="stack"> - <section className="card stack"> - <h2>New kit</h2> - <p className="muted">Hooks, a thread, LinkedIn, Notes, Bluesky, SEO, a carousel and quote cards, in your voice and grounded in the post.</p> - <DocPicker selected={post} onChange={setPost} single /> - {error && <p className="error-text">{error}</p>} - <button className="btn btn-primary" disabled={!post.length || busy} onClick={generate}> - {busy ? "Launching..." : "Generate Launch Kit"} - </button> - </section> - <section className="card"> - <h2>Kits</h2> - {!kits && <Loading />} + <PageHeader eyebrow="Launch Kit" title="Everything you need to launch a post"> + {active && ( + <button className="btn btn-primary" onClick={() => setParams({})}> + New kit + </button> + )} + </PageHeader> + {error && <p className="error-text">{error}</p>} + {active ? ( + <div className="kit-layout kit-open"> + <aside className="card kit-rail"> + <p className="eyebrow">Your kits</p> <ul className="kit-list"> {kits?.map((k) => ( <li key={k.id}> - <button - className={`link-btn${active?.id === k.id ? " active" : ""}`} - onClick={() => { - setActive(k); - setParams({ kit: k.id }); - }} - > + <button className={`link-btn${active.id === k.id ? " active" : ""}`} onClick={() => open(k)}> {(k.content.post_title as string | undefined) ?? k.title} </button> <span className="mono muted"> {k.status === "ready" ? formatDate(k.created_at) : k.status}</span> </li> ))} - {kits?.length === 0 && <li className="muted">No kits yet.</li>} </ul> + </aside> + <KitView + key={active.id} + kit={active} + onDeleted={() => { + setParams({}); + load(); + }} + /> + </div> + ) : ( + <> + <section className="card stack"> + <div className="row between"> + <h2>Pick a post to launch</h2> + <button className="link-btn small" onClick={() => setSearching(!searching)}> + {searching ? "Show newest posts" : "Search all posts"} + </button> + </div> + <p className="muted">Hooks, a thread, LinkedIn, Notes, Bluesky, SEO, a carousel and quote cards, in your voice and grounded in the post.</p> + {searching ? ( + <> + <DocPicker selected={post} onChange={setPost} single /> + <div className="row end"> + <button className="btn btn-primary" disabled={!post.length || busy !== null} onClick={() => generate(post[0])}> + {busy ? "Launching..." : "Generate Launch Kit"} + </button> + </div> + </> + ) : ( + <div className="post-cards"> + {!newest && <Loading />} + {newest?.length === 0 && <p className="muted">No indexed posts yet. Connect a source first.</p>} + {newest?.map((d) => ( + <button key={d.id} type="button" className="post-card" disabled={busy !== null} onClick={() => generate(d.id)}> + <span className="mono muted small">{formatDate(d.published_at)}</span> + <strong className="clamp-3">{d.title}</strong> + <span className="post-card-go">{busy === d.id ? "Launching..." : "Launch this post"}</span> + </button> + ))} + </div> + )} </section> - </aside> - <div> - {active ? ( - <KitView - key={active.id} - kit={active} - onDeleted={() => { - setActive(null); - setParams({}); - load(); - }} - /> - ) : ( - <EmptyState title="Pick a post to launch" body="Or open a kit you made before." /> + {kits && kits.length > 0 && ( + <section className="card"> + <h2>Earlier kits</h2> + <ul className="kit-list"> + {kits.map((k) => ( + <li key={k.id}> + <button className="link-btn" onClick={() => open(k)}> + {(k.content.post_title as string | undefined) ?? k.title} + </button> + <span className="mono muted"> {k.status === "ready" ? formatDate(k.created_at) : k.status}</span> + </li> + ))} + </ul> + </section> )} - </div> - </div> + </> + )} </div> ); } diff --git a/frontend/src/pages/Launchpad.tsx b/frontend/src/pages/Launchpad.tsx index a511e2462c78732bfe01da9a287b0ffeb5785151..d0a4c57bfe94b25418f75a542c9eb78c320afc60 100644 --- a/frontend/src/pages/Launchpad.tsx +++ b/frontend/src/pages/Launchpad.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react"; -import { useSearchParams } from "react-router-dom"; +import { Link, useSearchParams } from "react-router-dom"; import { launchpadApi } from "../api/endpoints"; import type { CalendarItem, SocialAccounts } from "../api/types"; import { PLATFORMS, ScheduleModal, toLocalInput } from "../components/ScheduleModal"; import { ConfirmButton, CopyButton, errorMessage, formatDate, Loading, Modal, PageHeader, StatusPill, Tabs } from "../components/ui"; +import { ResurfaceIdeas } from "./Resurface"; const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; @@ -289,55 +290,42 @@ export default function Launchpad() { {notice && <p className="notice">{notice}</p>} {error && <p className="error-text">{error}</p>} - <section className="card accounts"> - <div className="row between"> - <h2>Connected accounts</h2> - <div className="row"> - <button className="btn btn-small" onClick={() => connect("x")} disabled={!accounts?.available.x} title={accounts?.available.x ? "" : "Set X_CLIENT_ID and X_CLIENT_SECRET"}> - Connect X - </button> - <button className="btn btn-small" onClick={() => connect("linkedin")} disabled={!accounts?.available.linkedin} title={accounts?.available.linkedin ? "" : "Set LINKEDIN_CLIENT_ID and LINKEDIN_CLIENT_SECRET"}> - Connect LinkedIn - </button> - <button className="btn btn-small" onClick={() => setBluesky(true)}> - Connect Bluesky - </button> - </div> - </div> - <ul className="account-list"> - {accounts?.accounts.map((a) => ( - <li key={a.id} className="account"> - {a.avatar_url && <img src={a.avatar_url} alt="" width={28} height={28} />} - <span> - <strong>{a.platform_label}</strong> <span className="mono muted">{a.handle}</span> - </span> - {a.status !== "active" && <StatusPill status={a.status === "expired" ? "failed" : a.status} />} - <ConfirmButton - onConfirm={async () => { - await launchpadApi.disconnect(a.id); - setAccounts(await launchpadApi.accounts()); - }} - > - Disconnect - </ConfirmButton> - </li> - ))} - {accounts?.accounts.length === 0 && <li className="muted">No accounts yet. Posts for unconnected platforms and Substack Notes arrive as email reminders.</li>} - </ul> - {accounts && (!accounts.available.x || !accounts.available.linkedin) && ( - <details className="muted small"> - <summary>Setting up X or LinkedIn</summary> - <p> - Create a developer app, then add these redirect URIs and put the client id and secret in .env: - </p> - <ul className="mono"> - <li>X: {accounts.redirect_uris.x}</li> - <li>LinkedIn: {accounts.redirect_uris.linkedin}</li> - </ul> - </details> + <section className="lp-accounts" aria-label="Connected accounts"> + {accounts?.accounts.map((a) => ( + <span key={a.id} className={`lp-chip${a.status !== "active" ? " warn" : ""}`}> + {a.avatar_url && <img src={a.avatar_url} alt="" width={18} height={18} />} + <strong>{a.platform_label}</strong> <span className="mono muted">{a.handle}</span> + {a.status !== "active" && <StatusPill status={a.status === "expired" ? "failed" : a.status} />} + <ConfirmButton + className="link-btn small" + onConfirm={async () => { + await launchpadApi.disconnect(a.id); + setAccounts(await launchpadApi.accounts()); + }} + > + × + </ConfirmButton> + </span> + ))} + {accounts?.available.x && !accounts.accounts.some((a) => a.platform === "x") && ( + <button className="lp-chip add" onClick={() => connect("x")}> + + X + </button> + )} + {accounts?.available.linkedin && !accounts.accounts.some((a) => a.platform === "linkedin") && ( + <button className="lp-chip add" onClick={() => connect("linkedin")}> + + LinkedIn + </button> )} + {!accounts?.accounts.some((a) => a.platform === "bluesky") && ( + <button className="lp-chip add" onClick={() => setBluesky(true)}> + + Bluesky + </button> + )} + {accounts && accounts.accounts.length === 0 && <span className="muted small">Not connected? Posts arrive as email reminders instead.</span>} </section> + <div className="lp-layout"> <section className="card"> <div className="row between cal-head"> <div className="row"> @@ -352,6 +340,14 @@ export default function Launchpad() { <Tabs tabs={[{ id: "month", label: "Month" }, { id: "agenda", label: "Agenda" }]} value={view} onChange={setView} /> </div> {!items && <Loading />} + {items && items.length === 0 && ( + <div className="lp-empty"> + <p className="muted">Nothing scheduled this month.</p> + <Link className="btn btn-small" to="/app/launch-kit"> + Schedule your latest Launch Kit + </Link> + </div> + )} {items && view === "month" && ( <div className="cal-grid"> {WEEKDAYS.map((d) => ( @@ -396,6 +392,12 @@ export default function Launchpad() { {items && <p className="mono muted small">{upcoming.length} upcoming in view</p>} </section> + <aside className="card stack lp-ideas" id="ideas"> + <h2>Ideas worth resharing</h2> + <ResurfaceIdeas onScheduled={() => load()} /> + </aside> + </div> + {open && <ItemModal item={open} accounts={accounts} onClose={() => setOpen(null)} onChanged={changed} />} {composing && <ScheduleModal platform="x" posts={[""]} onClose={() => setComposing(false)} onScheduled={() => load()} />} {bluesky && <BlueskyModal onClose={() => setBluesky(false)} onConnected={setAccounts} />} diff --git a/frontend/src/pages/MissionControl.tsx b/frontend/src/pages/MissionControl.tsx index 8296e54b06c5fb54708d6171fcb99c125a058ca1..9520c732be09a579e86ea54797dae1a805a66139 100644 --- a/frontend/src/pages/MissionControl.tsx +++ b/frontend/src/pages/MissionControl.tsx @@ -1,10 +1,10 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react"; import { Link, useNavigate } from "react-router-dom"; import { api } from "../api/client"; -import { artifactsApi, jobsApi, launchpadApi, notebooksApi, sourcesApi, voiceApi } from "../api/endpoints"; -import type { Artifact, CalendarItem, Job, NotebookSummary, Source } from "../api/types"; -import { CheckIcon } from "../components/icons/Icons"; -import { formatDate, JobProgress, StatusPill } from "../components/ui"; +import { artifactsApi, docsApi, jobsApi, launchpadApi, notebooksApi, resurfaceApi, sourcesApi, type GenerateBody } from "../api/endpoints"; +import type { Artifact, CalendarItem, Doc, Job, NotebookSummary, ResurfaceItem, Source } from "../api/types"; +import { LaunchWindowIcon, PlanetIcon, RocketIcon, TelescopeIcon } from "../components/icons/Icons"; +import { errorMessage, formatDate, JobProgress, StatusPill } from "../components/ui"; import { useAuth } from "../hooks/useAuth"; import { useJobMap } from "../hooks/useJob"; import { AddSource } from "./Sources"; @@ -26,9 +26,9 @@ const JOB_LABELS: Record<string, string> = { import_url: "Importing an article", import_upload: "Importing a file", topics: "Mapping topics", - voice_profile: "Building your voice profile", + voice_profile: "Building your writing voice", voice_clone: "Cloning your voice", - resurface_scan: "Scoring evergreen posts", + resurface_scan: "Finding posts worth resharing", summary: "Writing a summary", audio_overview: "Recording an audio overview", video: "Making a video", @@ -38,26 +38,31 @@ const JOB_LABELS: Record<string, string> = { render: "Rendering", }; +/** Home: ask the archive, one-click creation, and only the next thing worth doing. */ export default function MissionControl() { const { user } = useAuth(); - const [sources, setSources] = useState<Source[]>([]); + const [sources, setSources] = useState<Source[] | null>(null); const [notebooks, setNotebooks] = useState<NotebookSummary[]>([]); const [recent, setRecent] = useState<Artifact[]>([]); const [upcoming, setUpcoming] = useState<CalendarItem[]>([]); + const [newest, setNewest] = useState<Doc | null>(null); + const [reshare, setReshare] = useState<ResurfaceItem[]>([]); const [plan, setPlan] = useState<Plan | null>(null); - const [hasVoice, setHasVoice] = useState(false); + const [question, setQuestion] = useState(""); + const [busy, setBusy] = useState<string | null>(null); const [error, setError] = useState<string | null>(null); const started = useRef(false); const navigate = useNavigate(); const refresh = useCallback(async () => { - const [s, n, p, a, c, v] = await Promise.all([ + const [s, n, p, a, c, d, r] = await Promise.all([ sourcesApi.list(), notebooksApi.list(), api<{ plan: Plan }>("/api/billing/me"), - artifactsApi.list({ limit: 6 }), + artifactsApi.list({ limit: 8 }), launchpadApi.items({ start: new Date().toISOString(), status: "scheduled,draft" }), - voiceApi.get(), + docsApi.list({ limit: 20 }), + resurfaceApi.get().catch(() => null), ]); setSources(s); // First visit with nothing connected: run the guided setup once. @@ -69,13 +74,14 @@ export default function MissionControl() { setPlan(p.plan); setRecent(a.items); setUpcoming(c.slice(0, 5)); - setHasVoice(Boolean(v.profile)); + setNewest(d.items.find((x) => !x.locked) ?? null); + setReshare((r?.evergreen ?? []).slice(0, 3)); }, [navigate]); const { jobs, watch } = useJobMap(() => refresh()); useEffect(() => { - refresh().catch(() => setError("Could not reach Mission Control. Is the API running?")); + refresh().catch(() => setError("Could not load your dashboard. Refresh to try again.")); jobsApi.active().then((active) => active.forEach((j) => watch(j.id, j))); if (started.current) return; started.current = true; @@ -92,45 +98,141 @@ export default function MissionControl() { watch(res.job.id, res.job); refresh(); }, - (e) => setError(e instanceof Error ? e.message : "Could not add that source."), + (e) => setError(errorMessage(e, "Could not add that source.")), ); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [refresh]); - const totalDocs = sources.reduce((n, s) => n + s.document_count, 0); + const run = async (key: string, fn: () => Promise<void>) => { + setBusy(key); + setError(null); + try { + await fn(); + } catch (e) { + setError(errorMessage(e)); + } finally { + setBusy(null); + } + }; + + const ask = (e: FormEvent) => { + e.preventDefault(); + void run("ask", async () => { + const nb = await notebooksApi.archive(); + navigate(`/app/notebooks/${nb.id}${question.trim() ? `?q=${encodeURIComponent(question.trim())}` : ""}`); + }); + }; + const create = (key: string, body: GenerateBody) => + run(key, async () => { + await artifactsApi.generate(body); + navigate("/app/studio"); + }); + const launch = (doc: Doc) => + run(`kit-${doc.id}`, async () => { + const kit = await artifactsApi.generate({ type: "launch_kit", document_id: doc.id }); + navigate(`/app/launch-kit?kit=${kit.id}`); + }); + + const totalDocs = (sources ?? []).reduce((n, s) => n + s.document_count, 0); + const hasPosts = totalDocs > 0; const activeJobs = Object.values(jobs).filter((j: Job) => j.status === "queued" || j.status === "running" || j.status === "failed"); - const checklist = [ - { done: sources.length > 0 && totalDocs > 0, label: "Connect your archive", to: "/app/sources" }, - { done: hasVoice, label: "Build your voice profile", to: "/app/voice" }, - { done: notebooks.length > 0, label: "Open a research notebook", to: "/app/notebooks" }, - { done: recent.some((a) => a.type === "launch_kit"), label: "Make your first Launch Kit", to: "/app/launch-kit" }, - { done: upcoming.length > 0, label: "Schedule a launch", to: "/app/launchpad" }, - ]; + const lastKit = recent.find((a) => a.type === "launch_kit" && a.status === "ready"); + const next: { text: string; action: ReactNode } | null = !hasPosts + ? null + : !lastKit && newest + ? { + text: `Launch your newest post, "${newest.title}", with hooks, threads and SEO in your voice.`, + action: ( + <button className="btn btn-primary" disabled={busy !== null} onClick={() => launch(newest)}> + {busy === `kit-${newest.id}` ? "Launching..." : "Generate Launch Kit"} + </button> + ), + } + : lastKit && upcoming.length === 0 + ? { + text: `Your Launch Kit for "${lastKit.title}" is ready. Put it on the calendar.`, + action: ( + <Link className="btn btn-primary" to={`/app/launch-kit?kit=${lastKit.id}`}> + Schedule it + </Link> + ), + } + : null; + + const first = (user?.name ?? user?.email ?? "").split(/[ @]/)[0]; return ( <div className="mc"> <header className="mc-head"> <div> <p className="eyebrow">Mission Control</p> - <h1>Good to see you, {(user?.name ?? user?.email ?? "").split(/[ @]/)[0]}</h1> + <h1>Good to see you, {first}</h1> </div> {plan && <span className="badge">{plan.name} plan</span>} </header> {error && <p className="error-text">{error}</p>} - {checklist.some((c) => !c.done) && ( - <section className="card checklist"> - <p className="eyebrow">Pre-flight checklist</p> - <ol> - {checklist.map((c) => ( - <li key={c.label} className={c.done ? "done" : ""}> - <span className="check-dot">{c.done && <CheckIcon size={14} />}</span> - <Link to={c.to}>{c.label}</Link> - </li> - ))} - </ol> + {sources && !hasPosts ? ( + <section className="card mc-hero stack"> + <h2>Bring in your writing</h2> + <p className="muted">Connect a blog or newsletter, import an article or drop in files. Everything else starts from here.</p> + <AddSource + onAdded={(source, job) => { + setSources((list) => [...(list ?? []).filter((s) => s.id !== source.id), source]); + watch(job.id, job); + }} + /> </section> + ) : ( + <> + <section className="card mc-hero"> + <form onSubmit={ask} className="mc-ask"> + <TelescopeIcon size={22} /> + <input + className="input" + value={question} + onChange={(e) => setQuestion(e.target.value)} + placeholder="Ask anything about your writing: what have I said about pricing?" + aria-label="Ask your archive" + /> + <button className="btn btn-primary" disabled={busy !== null}> + {busy === "ask" ? "Opening..." : "Ask"} + </button> + </form> + <div className="mc-actions"> + <button className="mc-action" disabled={busy !== null || !hasPosts} onClick={() => create("audio", { type: "audio_overview", archive: true, format: "deep_dive", minutes: 6 })}> + <PlanetIcon size={28} /> + <strong>{busy === "audio" ? "Starting..." : "Audio overview"}</strong> + <span className="muted small">Two hosts, 6 minutes, your whole archive</span> + </button> + <button className="mc-action" disabled={busy !== null || !hasPosts} onClick={() => create("video", { type: "video", style: "short", archive: true })}> + <LaunchWindowIcon size={28} /> + <strong>{busy === "video" ? "Starting..." : "Short video"}</strong> + <span className="muted small">Vertical 9:16 with captions</span> + </button> + <button className="mc-action" disabled={busy !== null || !newest} onClick={() => newest && launch(newest)}> + <RocketIcon size={28} /> + <strong>{newest && busy === `kit-${newest.id}` ? "Launching..." : "Launch newest post"}</strong> + <span className="muted small clamp-1">{newest ? newest.title : "No indexed posts yet"}</span> + </button> + <Link className="mc-action" to="/app/studio"> + <span className="mc-more" aria-hidden="true"> + + + </span> + <strong>More formats</strong> + <span className="muted small">Explainers, quote cards, audiograms</span> + </Link> + </div> + </section> + + {next && ( + <section className="mc-next"> + <p>{next.text}</p> + {next.action} + </section> + )} + </> )} {activeJobs.length > 0 && ( @@ -145,102 +247,97 @@ export default function MissionControl() { </section> )} - <section className="mc-grid"> - <div className="card mc-card mc-sources"> - <div className="row between"> - <h2>Sources</h2> - <Link to="/app/sources" className="mono muted small-link"> - Manage - </Link> - </div> - <AddSource - onAdded={(source, job) => { - setSources((list) => [...list.filter((s) => s.id !== source.id), source]); - watch(job.id, job); - }} - /> - <ul className="source-list"> - {sources.map((s) => ( - <li key={s.id} className="source-row"> - <div className="source-title"> - <strong>{s.title ?? s.feed_url}</strong> - <span className="mono muted"> - {s.is_imports ? "imports" : s.platform} · {s.document_count} posts - </span> - </div> - <StatusPill status={s.sync_status} /> - </li> - ))} - {sources.length === 0 && <li className="muted">No sources yet. A lone satellite, waiting for signal.</li>} - </ul> - </div> - - <div className="card mc-card"> - <div className="row between"> - <h2>Notebooks</h2> - <Link to="/app/notebooks" className="mono muted small-link"> - All - </Link> - </div> - <p className="muted">{totalDocs} posts indexed across your sources.</p> - <ul className="nb-list"> - {notebooks.slice(0, 6).map((n) => ( - <li key={n.id}> - <Link to={`/app/notebooks/${n.id}`} className="nb-link"> - {n.title} - </Link> - <span className="mono muted"> {n.document_count} posts</span> - </li> - ))} - </ul> - <Link className="btn" to="/app/notebooks"> - {notebooks.length ? "Open notebooks" : "Create a notebook"} + {hasPosts && ( + <section className="mc-stats"> + <Link to="/app/sources" className="mc-stat"> + <strong>{totalDocs}</strong> + <span className="muted small">posts in {sources?.length} {sources?.length === 1 ? "source" : "sources"}</span> </Link> - </div> + <Link to="/app/notebooks" className="mc-stat"> + <strong>{notebooks.length}</strong> + <span className="muted small">notebooks</span> + </Link> + <Link to="/app/archive" className="mc-stat"> + <strong>{recent.length}</strong> + <span className="muted small">recent launches</span> + </Link> + <Link to="/app/launchpad" className="mc-stat"> + <strong>{upcoming.length}</strong> + <span className="muted small">scheduled</span> + </Link> + </section> + )} - <div className="card mc-card"> - <div className="row between"> - <h2>Recent launches</h2> - <Link to="/app/archive" className="mono muted small-link"> - Archive - </Link> - </div> - {recent.length === 0 && <p className="muted">Audio overviews, videos and launch kits will show up here.</p>} - <ul className="recent-list"> - {recent.map((a) => ( - <li key={a.id}> - <Link to={a.type === "launch_kit" ? `/app/launch-kit?kit=${a.id}` : a.notebook_id ? `/app/notebooks/${a.notebook_id}` : "/app/archive"}> - {a.title} + {(recent.length > 0 || upcoming.length > 0 || reshare.length > 0) && ( + <section className="mc-grid"> + {recent.length > 0 && ( + <div className="card mc-card"> + <div className="row between"> + <h2>Recent launches</h2> + <Link to="/app/archive" className="mono muted small-link"> + Library </Link> - <span className="row"> - <span className="mono muted">{formatDate(a.created_at)}</span> - <StatusPill status={a.status} /> - </span> - </li> - ))} - </ul> - </div> - - <div className="card mc-card"> - <div className="row between"> - <h2>Launchpad</h2> - <Link to="/app/launchpad" className="mono muted small-link"> - Calendar - </Link> + </div> + <ul className="recent-list"> + {recent.map((a) => ( + <li key={a.id}> + <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> + <span className="row"> + <span className="mono muted">{formatDate(a.created_at)}</span> + <StatusPill status={a.status} /> + </span> + </li> + ))} + </ul> + </div> + )} + <div className="stack"> + {reshare.length > 0 && ( + <div className="card mc-card"> + <div className="row between"> + <h2>Worth resharing</h2> + <Link to="/app/launchpad" className="mono muted small-link"> + More ideas + </Link> + </div> + <ul className="recent-list"> + {reshare.map((i) => ( + <li key={i.id}> + <span> + <strong>{i.title}</strong> + {i.angle && <span className="muted small"> · {i.angle}</span>} + </span> + <button className="btn btn-small" disabled={busy !== null} onClick={() => launch(i)}> + {busy === `kit-${i.id}` ? "Launching..." : "Launch Kit"} + </button> + </li> + ))} + </ul> + </div> + )} + {upcoming.length > 0 && ( + <div className="card mc-card"> + <div className="row between"> + <h2>Coming up</h2> + <Link to="/app/launchpad" className="mono muted small-link"> + Calendar + </Link> + </div> + <ul className="recent-list"> + {upcoming.map((i) => ( + <li key={i.id}> + <Link to={`/app/launchpad?item=${i.id}`}> + <strong>{i.platform_label}</strong> <span className="muted">{i.content.slice(0, 70)}</span> + </Link> + <span className="mono muted">{formatDate(i.scheduled_at, true)}</span> + </li> + ))} + </ul> + </div> + )} </div> - {upcoming.length === 0 && <p className="muted">Nothing scheduled. Quiet moon tonight.</p>} - <ul className="recent-list"> - {upcoming.map((i) => ( - <li key={i.id}> - <Link to={`/app/launchpad?item=${i.id}`}> - <strong>{i.platform_label}</strong> <span className="muted">{i.content.slice(0, 70)}</span> - </Link> - <span className="mono muted">{formatDate(i.scheduled_at, true)}</span> - </li> - ))} - </ul> - </div> - </section> + </section> + )} </div> ); } diff --git a/frontend/src/pages/NotebookLMAlternative.tsx b/frontend/src/pages/NotebookLMAlternative.tsx index 5e28ff815de91590ca5e94725f258dd7c22d886d..55da26ebaaa18c83da7389ddcb03f6958e70f18d 100644 --- a/frontend/src/pages/NotebookLMAlternative.tsx +++ b/frontend/src/pages/NotebookLMAlternative.tsx @@ -3,86 +3,13 @@ import SkyCanvas from "../components/SkyCanvas"; import { PublicFooter, PublicNav } from "../components/PublicChrome"; import { useMeta } from "./Blog"; import "../styles/blog.css"; +import { NOTEBOOKLM_ALT_FAQ as FAQ, NOTEBOOKLM_ALT_META, NOTEBOOKLM_ALT_ROWS as ROWS } from "../content/notebooklmAlternative"; -/** SEO landing page: "notebooklm alternative" (590 searches/mo in the US, keyword difficulty 0, Sep 2026). */ - -const ROWS: { feature: string; notestack: string; notebooklm: string }[] = [ - { - feature: "Getting your writing in", - notestack: "Syncs any blog, newsletter or site feed (Substack, Ghost, WordPress, Medium, RSS), plus markdown, text, HTML and PDF uploads", - notebooklm: "Add sources one at a time: PDFs, Docs, web pages, videos", - }, - { - feature: "Stays in sync", - notestack: "New posts arrive automatically on every sync", - notebooklm: "Re-add sources by hand when they change", - }, - { - feature: "Citations", - notestack: "Exact line ranges, read back and verified before you see them", - notebooklm: "Passage level citations to the source", - }, - { - feature: "When the answer is not in your sources", - notestack: "Says so instead of guessing; unverifiable claims are dropped", - notebooklm: "Grounded in sources, with passage citations", - }, - { - feature: "Chat memory", - notestack: "Follow ups resolved from the conversation; off topic questions answered instantly", - notebooklm: "Conversation within a notebook", - }, - { - feature: "Audio overviews", - notestack: "Two hosts, ElevenLabs voices, delivery controls, or your own consented voice clone", - notebooklm: "Two host audio overviews with Google voices", - }, - { - feature: "Video", - notestack: "9:16 shorts, 16:9 explainers and 1:1 audiograms with captions and your brand", - notebooklm: "Video overviews", - }, - { - feature: "Publishing", - notestack: "Launch Kits (threads, LinkedIn, Notes, Bluesky, SEO, carousels, quote cards) and a scheduling calendar", - notebooklm: "Not a publishing tool", - }, - { - feature: "Your voice", - notestack: "Voice profile learned from your posts, used for every draft", - notebooklm: "General purpose writing", - }, - { - feature: "Seeing your archive", - notestack: "Topic map with rising and dormant topics, plus evergreen resurfacing", - notebooklm: "Mind maps per notebook", - }, -]; - -const FAQ = [ - { - q: "What is the best NotebookLM alternative?", - a: "For writers, bloggers and newsletters, Notestack is the ultimate NotebookLM alternative: it syncs your whole archive, cites the exact lines behind every answer, and turns what you wrote into audio, video and launch posts. For academic papers, discovery tools like Elicit or Consensus pair well with it.", - }, - { - q: "Is Notestack free?", - a: "Yes, there is a free plan to try it on your latest posts. Paid plans add more sources, more posts, more audio and video minutes and voice cloning.", - }, - { - q: "Can Notestack make NotebookLM style audio overviews?", - a: "Yes. It writes a two host conversation grounded in your posts and voices it with ElevenLabs, with every line tied to the passage it came from. You can choose the voices or clone your own with consent.", - }, - { - q: "What if my writing is not on Substack?", - a: "Notestack works with any blog, newsletter or site that has a feed, single article URLs, and uploaded markdown, text, HTML or PDF files.", - }, -]; +/** Comparison landing page: "Notestack vs NotebookLM". The /blogs/notebooklm-alternatives listicle owns the plural + * "notebooklm alternatives" query (590/mo US, KD 0, Sep 2026) and links here, so the two do not compete. */ export default function NotebookLMAlternative() { - useMeta( - "The Ultimate NotebookLM Alternative for Writers | Notestack", - "Notestack is the ultimate NotebookLM alternative for writers, bloggers and newsletters: sync your archive, get answers cited to the exact line, and turn your writing into audio, video and launch posts.", - ); + useMeta(NOTEBOOKLM_ALT_META.title, NOTEBOOKLM_ALT_META.description); const faqLd = { "@context": "https://schema.org", "@type": "FAQPage", @@ -112,7 +39,7 @@ export default function NotebookLMAlternative() { <span className="mono tx-label">Incoming query</span> <p className="tx-query">Is there a NotebookLM built for my own writing?</p> </div> - <h1 className="post-title">The ultimate NotebookLM alternative for writers</h1> + <h1 className="post-title">Notestack vs NotebookLM: the NotebookLM alternative for writers</h1> <p className="post-lede muted"> NotebookLM is great for a pile of documents. Notestack is built for a body of work: it syncs your blog, newsletter, site or markdown, answers with citations to the exact lines you wrote, and turns your knowledge into audio, video and launch posts. diff --git a/frontend/src/pages/NotebookView.tsx b/frontend/src/pages/NotebookView.tsx index 9cefb5deeaf4c3bbbdf6caae5ac8bec52cb4d2df..0f3621ae9f328bdabd6b7926f123a6ae60741e48 100644 --- a/frontend/src/pages/NotebookView.tsx +++ b/frontend/src/pages/NotebookView.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState, type FormEvent } from "react"; -import { Link, useParams } from "react-router-dom"; +import { Link, useParams, useSearchParams } from "react-router-dom"; import { artifactsApi, notebooksApi, type GenerateBody } from "../api/endpoints"; import { streamSSE } from "../api/stream"; import type { Artifact, ChatSummary, Citation, Notebook } from "../api/types"; @@ -8,15 +8,19 @@ import { Markdown } from "../components/Markdown"; import { DocPicker } from "../components/DocPicker"; import { TelescopeIcon } from "../components/icons/Icons"; import { Reader } from "../components/Reader"; -import { ConfirmButton, errorMessage, formatDate, Loading, Modal } from "../components/ui"; +import { ConfirmButton, errorMessage, formatDate, Loading, Modal, Tabs } from "../components/ui"; type Turn = { role: "user" | "assistant"; text: string; citations?: Citation[]; status?: string; steps?: string[] }; +type Pane = "create" | "posts"; + +const STARTERS = ["What are the strongest ideas across these posts?", "Where do I contradict myself?", "Which post is most worth updating, and why?"]; + +/** The Create tab: one click per format with sensible defaults, options folded away. */ function StudioPanel({ notebookId, disabled }: { notebookId: string; disabled: boolean }) { const [artifacts, setArtifacts] = useState<Artifact[] | null>(null); const [format, setFormat] = useState<"deep_dive" | "brief" | "debate">("deep_dive"); const [minutes, setMinutes] = useState(6); - const [style, setStyle] = useState<"short" | "explainer">("short"); const [error, setError] = useState<string | null>(null); const [busy, setBusy] = useState(false); const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null); @@ -40,19 +44,32 @@ function StudioPanel({ notebookId, disabled }: { notebookId: string; disabled: b }; const cite = (c: Citation) => c.document_id && setReading({ id: c.document_id, start: c.line_start, end: c.line_end }); + const off = disabled || busy; return ( - <aside className="card nbv-pane nbv-studio"> - <p className="eyebrow">Studio</p> - <div className="studio-buttons"> - <button className="btn btn-small" disabled={disabled || busy} onClick={() => make({ type: "summary" })}> + <div className="stack"> + <div className="nbv-make"> + <button className="nbv-make-main" disabled={off} onClick={() => make({ type: "audio_overview", format, minutes })}> + <strong>Audio overview</strong> + <span className="muted small"> + {format === "deep_dive" ? "Deep dive" : format === "brief" ? "Brief" : "Debate"} · {minutes} min + </span> + </button> + <button disabled={off} onClick={() => make({ type: "video", style: "short" })}> + Short video + </button> + <button disabled={off} onClick={() => make({ type: "video", style: "explainer" })}> + Explainer + </button> + <button disabled={off} onClick={() => make({ type: "summary" })}> Summary </button> - <button className="btn btn-small" disabled={disabled || busy} onClick={() => make({ type: "quote_card" })}> + <button disabled={off} onClick={() => make({ type: "quote_card" })}> Quote card </button> </div> - <div className="studio-group"> + <details className="delivery"> + <summary className="mono muted small">Audio options</summary> <div className="row"> <select className="input input-sm" value={format} onChange={(e) => setFormat(e.target.value as typeof format)} aria-label="Audio format"> <option value="deep_dive">Deep dive</option> @@ -67,19 +84,7 @@ function StudioPanel({ notebookId, disabled }: { notebookId: string; disabled: b ))} </select> </div> - <button className="btn btn-small btn-primary" disabled={disabled || busy} onClick={() => make({ type: "audio_overview", format, minutes })}> - Audio overview - </button> - </div> - <div className="studio-group"> - <select className="input input-sm" value={style} onChange={(e) => setStyle(e.target.value as typeof style)} aria-label="Video style"> - <option value="short">Short, vertical 9:16</option> - <option value="explainer">Explainer, 16:9</option> - </select> - <button className="btn btn-small" disabled={disabled || busy} onClick={() => make({ type: "video", style })}> - Video - </button> - </div> + </details> {disabled && <p className="muted small">Add posts to this notebook to start creating.</p>} {error && <p className="error-text">{error}</p>} <div className="studio-list"> @@ -101,7 +106,7 @@ function StudioPanel({ notebookId, disabled }: { notebookId: string; disabled: b ))} </div> {reading && <Reader documentId={reading.id} highlight={{ start: reading.start, end: reading.end }} onClose={() => setReading(null)} />} - </aside> + </div> ); } @@ -120,6 +125,9 @@ export default function NotebookView() { const [reading, setReading] = useState<{ id: string; start?: number; end?: number } | null>(null); const [editingTitle, setEditingTitle] = useState(false); const [title, setTitle] = useState(""); + const [pane, setPane] = useState<Pane>("create"); + const [params, setParams] = useSearchParams(); + const asked = useRef(false); const bottom = useRef<HTMLDivElement>(null); const load = useCallback( @@ -152,9 +160,8 @@ export default function NotebookView() { setTurns(msgs.map((m) => ({ role: m.role, text: m.text, citations: m.citations }))); }; - const ask = async (e: FormEvent) => { - e.preventDefault(); - const question = q.trim(); + const ask = async (text: string) => { + const question = text.trim(); if (!question || busy) return; setQ(""); setBusy(true); @@ -189,6 +196,16 @@ export default function NotebookView() { } }; + // A question passed in the link (from the Home ask box) is asked as soon as the notebook opens. + useEffect(() => { + const pending = params.get("q"); + if (!nb || !pending || asked.current || !nb.documents.length) return; + asked.current = true; + setParams({}, { replace: true }); + void ask(pending); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nb]); + if (loadError) return <p className="error-text">{loadError}</p>; if (!nb) return <Loading label="Opening notebook" />; @@ -197,90 +214,73 @@ export default function NotebookView() { return ( <div className="nbv"> - <aside className="card nbv-pane"> - <Link to="/app/notebooks" className="mono muted small-link"> - All notebooks - </Link> - {editingTitle ? ( - <form - onSubmit={async (e) => { - e.preventDefault(); - await notebooksApi.update(nb.id, { title }); - setEditingTitle(false); - load(); - }} - > - <input className="input input-sm" autoFocus value={title} onChange={(e) => setTitle(e.target.value)} onBlur={() => setEditingTitle(false)} aria-label="Notebook title" /> - </form> - ) : ( - <h2 className="nbv-title" onDoubleClick={() => setEditingTitle(true)} title="Double click to rename"> - {nb.title} - </h2> - )} - <div className="row between"> - <p className="eyebrow">{nb.documents.length} posts</p> - <button className="btn btn-small" onClick={() => setAdding(true)}> - Add posts - </button> - </div> - <ul className="nbv-docs"> - {nb.documents.map((d) => ( - <li key={d.id} className="nbv-doc"> - <button className="link-btn" onClick={() => setReading({ id: d.id })}> - {d.title} - </button> - <span className="mono muted">{formatDate(d.published_at)}</span> - <button - className="icon-btn nbv-remove" - aria-label={`Remove ${d.title}`} - title="Remove from notebook" - onClick={async () => { - await notebooksApi.removeDoc(nb.id, d.id); + <section className="card nbv-pane nbv-chat"> + <div className="chat-bar"> + <div className="nbv-titlebar"> + <Link to="/app/notebooks" className="mono muted small-link"> + Notebooks / + </Link> + {editingTitle ? ( + <form + onSubmit={async (e) => { + e.preventDefault(); + await notebooksApi.update(nb.id, { title }); + setEditingTitle(false); load(); }} > - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6"> - <path d="M6 6l12 12M18 6L6 18" /> - </svg> - </button> - </li> - ))} - </ul> - </aside> - - <section className="card nbv-pane nbv-chat"> - <div className="chat-bar"> - <select - className="input input-sm" - value={chatId ?? ""} - onChange={(e) => (e.target.value ? openChat(e.target.value) : (setChatId(null), setTurns([])))} - aria-label="Chat history" - > - <option value="">New chat</option> - {chats.map((c) => ( - <option key={c.id} value={c.id}> - {(c.title ?? "Chat").slice(0, 60)} · {formatDate(c.updated_at)} - </option> - ))} - </select> - {chatId && ( - <ConfirmButton - onConfirm={async () => { - await notebooksApi.removeChat(chatId); - setChatId(null); - setTurns([]); - loadChats(); - }} - > - Delete chat - </ConfirmButton> - )} + <input className="input input-sm" autoFocus value={title} onChange={(e) => setTitle(e.target.value)} onBlur={() => setEditingTitle(false)} aria-label="Notebook title" /> + </form> + ) : ( + <h2 className="nbv-title" onDoubleClick={() => setEditingTitle(true)} title="Double click to rename"> + {nb.title} + </h2> + )} + </div> + <div className="row"> + {(chats.length > 0 || chatId) && ( + <select + className="input input-sm" + value={chatId ?? ""} + onChange={(e) => (e.target.value ? openChat(e.target.value) : (setChatId(null), setTurns([])))} + aria-label="Chat history" + > + <option value="">New chat</option> + {chats.map((c) => ( + <option key={c.id} value={c.id}> + {(c.title ?? "Chat").slice(0, 60)} · {formatDate(c.updated_at)} + </option> + ))} + </select> + )} + {chatId && ( + <ConfirmButton + onConfirm={async () => { + await notebooksApi.removeChat(chatId); + setChatId(null); + setTurns([]); + loadChats(); + }} + > + Delete chat + </ConfirmButton> + )} + </div> </div> <div className="nbv-turns"> {turns.length === 0 && ( - <div className="empty-state small"> + <div className="nbv-empty"> <TelescopeIcon size={32} /> - <p className="muted">Ask anything about these posts. Answers cite the passage they came from.</p> + <p className="muted">Ask anything about {nb.documents.length} posts. Answers cite the passage they came from.</p> + {nb.documents.length > 0 && ( + <div className="nbv-starters"> + {STARTERS.map((s) => ( + <button key={s} type="button" className="nbv-starter" onClick={() => ask(s)}> + {s} + </button> + ))} + </div> + )} </div> )} {turns.map((t, i) => ( @@ -307,7 +307,13 @@ export default function NotebookView() { ))} <div ref={bottom} /> </div> - <form onSubmit={ask} className="inline-form"> + <form + onSubmit={(e: FormEvent) => { + e.preventDefault(); + void ask(q); + }} + className="inline-form nbv-ask" + > <input className="input" value={q} @@ -322,7 +328,53 @@ export default function NotebookView() { </form> </section> - <StudioPanel notebookId={nb.id} disabled={nb.documents.length === 0} /> + <aside className="card nbv-pane nbv-side"> + <Tabs<Pane> + tabs={[ + { id: "create", label: "Create" }, + { id: "posts", label: "Posts", count: nb.documents.length }, + ]} + value={pane} + onChange={setPane} + /> + {pane === "create" && <StudioPanel notebookId={nb.id} disabled={nb.documents.length === 0} />} + {pane === "posts" && ( + <> + {nb.is_archive ? ( + <p className="muted small">Every indexed post. New posts join automatically.</p> + ) : ( + <button className="btn btn-small" onClick={() => setAdding(true)}> + Add posts + </button> + )} + <ul className="nbv-docs"> + {nb.documents.map((d) => ( + <li key={d.id} className="nbv-doc"> + <button className="link-btn" onClick={() => setReading({ id: d.id })}> + {d.title} + </button> + <span className="mono muted">{formatDate(d.published_at)}</span> + {!nb.is_archive && ( + <button + className="icon-btn nbv-remove" + aria-label={`Remove ${d.title}`} + title="Remove from notebook" + onClick={async () => { + await notebooksApi.removeDoc(nb.id, d.id); + load(); + }} + > + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6"> + <path d="M6 6l12 12M18 6L6 18" /> + </svg> + </button> + )} + </li> + ))} + </ul> + </> + )} + </aside> {adding && ( <Modal title="Add posts" onClose={() => setAdding(false)} wide> diff --git a/frontend/src/pages/Notebooks.tsx b/frontend/src/pages/Notebooks.tsx index f7cfd0a5b10f65ea5c97c08734ffdce89c3fc236..cec74ffedac34b864fdb9d48c176ffae4e8ca5c2 100644 --- a/frontend/src/pages/Notebooks.tsx +++ b/frontend/src/pages/Notebooks.tsx @@ -1,23 +1,41 @@ import { useCallback, useEffect, useState, type FormEvent } from "react"; import { Link, useNavigate } from "react-router-dom"; -import { notebooksApi } from "../api/endpoints"; -import type { NotebookSummary } from "../api/types"; +import { docsApi, notebooksApi } from "../api/endpoints"; +import type { Doc, NotebookSummary } from "../api/types"; import { DocPicker } from "../components/DocPicker"; -import { ConfirmButton, EmptyState, errorMessage, formatDate, Loading, Modal, PageHeader } from "../components/ui"; +import { PlanetIcon } from "../components/icons/Icons"; +import { ConfirmButton, errorMessage, formatDate, Loading, Modal, PageHeader } from "../components/ui"; +/** New notebook from a topic: typing it finds and pre-selects the matching posts; hand picking is the fallback. */ export function NewNotebookModal({ onClose, initialDocs = [] }: { onClose: () => void; initialDocs?: string[] }) { - const [title, setTitle] = useState(""); + const [topic, setTopic] = useState(""); + const [matches, setMatches] = useState<Doc[]>([]); const [selected, setSelected] = useState<string[]>(initialDocs); + const [manual, setManual] = useState(initialDocs.length > 0); const [busy, setBusy] = useState(false); const [error, setError] = useState<string | null>(null); const navigate = useNavigate(); + useEffect(() => { + if (manual || topic.trim().length < 2) { + if (!manual) setMatches([]); + return; + } + const t = setTimeout(async () => { + const page = await docsApi.list({ q: topic.trim(), limit: 50 }); + const found = page.items.filter((d) => !d.locked); + setMatches(found); + setSelected(found.map((d) => d.id)); + }, 250); + return () => clearTimeout(t); + }, [topic, manual]); + const submit = async (e: FormEvent) => { e.preventDefault(); setBusy(true); setError(null); try { - const nb = await notebooksApi.create(title.trim() || "Untitled notebook", selected); + const nb = await notebooksApi.create(topic.trim() || "Untitled notebook", selected); navigate(`/app/notebooks/${nb.id}`); } catch (err) { setError(errorMessage(err)); @@ -29,19 +47,43 @@ export function NewNotebookModal({ onClose, initialDocs = [] }: { onClose: () => <Modal title="New notebook" onClose={onClose} wide> <form onSubmit={submit} className="stack"> <label className="field"> - <span>Title</span> - <input className="input input-sm" autoFocus value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Pricing and paid tiers" /> + <span>What is it about?</span> + <input className="input" autoFocus value={topic} onChange={(e) => setTopic(e.target.value)} placeholder="Pricing, paid tiers, 2024 essays..." /> </label> - <div className="field"> - <span>Posts in this notebook</span> + {manual ? ( <DocPicker selected={selected} onChange={setSelected} /> - </div> + ) : ( + <> + {topic.trim().length >= 2 && ( + <p className="muted small"> + {matches.length ? `${matches.length} posts mention it. Untick any that do not belong.` : "No posts mention that yet."} + </p> + )} + {matches.length > 0 && ( + <ul className="picker-list nb-matches"> + {matches.map((d) => ( + <li key={d.id}> + <label className={`picker-row${selected.includes(d.id) ? " on" : ""}`}> + <input + type="checkbox" + checked={selected.includes(d.id)} + onChange={(e) => setSelected(e.target.checked ? [...selected, d.id] : selected.filter((x) => x !== d.id))} + /> + <span className="picker-title">{d.title}</span> + <span className="mono muted picker-meta">{formatDate(d.published_at)}</span> + </label> + </li> + ))} + </ul> + )} + </> + )} {error && <p className="error-text">{error}</p>} - <div className="row end"> - <button type="button" className="btn" onClick={onClose}> - Cancel + <div className="row between"> + <button type="button" className="link-btn small" onClick={() => setManual(!manual)}> + {manual ? "Find posts by topic instead" : "Pick posts by hand"} </button> - <button className="btn btn-primary" disabled={busy}> + <button className="btn btn-primary" disabled={busy || !selected.length}> {busy ? "Creating..." : `Create with ${selected.length} posts`} </button> </div> @@ -50,6 +92,31 @@ export function NewNotebookModal({ onClose, initialDocs = [] }: { onClose: () => ); } +function CardMenu({ onRename, onDelete }: { onRename: () => void; onDelete: () => Promise<void> }) { + const [open, setOpen] = useState(false); + return ( + <div className="card-menu"> + <button type="button" className="icon-btn" aria-label="Notebook options" aria-expanded={open} onClick={() => setOpen(!open)}> + ⋯ + </button> + {open && ( + <div className="card-menu-list" onMouseLeave={() => setOpen(false)}> + <button + type="button" + onClick={() => { + setOpen(false); + onRename(); + }} + > + Rename + </button> + <ConfirmButton onConfirm={onDelete}>Delete</ConfirmButton> + </div> + )} + </div> + ); +} + export default function Notebooks() { const [notebooks, setNotebooks] = useState<NotebookSummary[] | null>(null); const [creating, setCreating] = useState(false); @@ -58,7 +125,8 @@ export default function Notebooks() { const load = useCallback(() => notebooksApi.list().then(setNotebooks), []); useEffect(() => { - load(); + // "All posts" always exists, so asking the whole archive never needs setup. + notebooksApi.archive().finally(load); }, [load]); return ( @@ -68,24 +136,10 @@ export default function Notebooks() { New notebook </button> </PageHeader> - <p className="muted lede"> - 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. - </p> {!notebooks && <Loading />} - {notebooks?.length === 0 && ( - <EmptyState - title="No notebooks yet" - body="Group posts by theme, series or year. You can also make one from a topic on the Topic map." - action={ - <button className="btn btn-primary" onClick={() => setCreating(true)}> - Create your first notebook - </button> - } - /> - )} - <section className="grid-cards"> + <section className="nb-grid"> {notebooks?.map((n) => ( - <article key={n.id} className="card nb-card"> + <article key={n.id} className={`card nb-card${n.is_archive ? " nb-archive" : ""}`}> {renaming === n.id ? ( <form className="inline-form" @@ -101,37 +155,37 @@ export default function Notebooks() { </form> ) : ( <Link to={`/app/notebooks/${n.id}`} className="nb-card-link"> + {n.is_archive && <PlanetIcon size={26} />} <h3>{n.title}</h3> {(n.description || n.summary) && <p className="muted clamp-3">{n.description || n.summary}</p>} + <p className="mono muted"> + {n.document_count} posts + {n.chat_count ? ` · ${n.chat_count} chats` : ""} + {n.artifact_count ? ` · ${n.artifact_count} made` : ""} · {formatDate(n.updated_at)} + </p> </Link> )} - <p className="mono muted"> - {n.document_count} posts · {n.chat_count} chats · {n.artifact_count} artifacts · {formatDate(n.updated_at)} - </p> - <footer className="row"> - <Link className="btn btn-small" to={`/app/notebooks/${n.id}`}> - Open - </Link> - <button - className="btn btn-small" - onClick={() => { + {!n.is_archive && renaming !== n.id && ( + <CardMenu + onRename={() => { setRenaming(n.id); setName(n.title); }} - > - Rename - </button> - <ConfirmButton - onConfirm={async () => { + onDelete={async () => { await notebooksApi.remove(n.id); load(); }} - > - Delete - </ConfirmButton> - </footer> + /> + )} </article> ))} + <button type="button" className="card nb-card nb-new" onClick={() => setCreating(true)}> + <span className="mc-more" aria-hidden="true"> + + + </span> + <strong>New notebook</strong> + <span className="muted small">Group posts by theme, series or year</span> + </button> </section> {creating && <NewNotebookModal onClose={() => setCreating(false)} />} </div> diff --git a/frontend/src/pages/Resurface.tsx b/frontend/src/pages/Resurface.tsx index de512b5125f86550b5060b5d513d2f539c1f6ae2..b4db6acda66a61552be98e676e9074e7bc754ff6 100644 --- a/frontend/src/pages/Resurface.tsx +++ b/frontend/src/pages/Resurface.tsx @@ -1,10 +1,10 @@ import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { Navigate, useNavigate } from "react-router-dom"; import { resurfaceApi } from "../api/endpoints"; import type { Job, ResurfaceItem } from "../api/types"; import { Reader } from "../components/Reader"; import { ScheduleModal } from "../components/ScheduleModal"; -import { EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader } from "../components/ui"; +import { errorMessage, formatDate, JobProgress, Loading } from "../components/ui"; import { useJob } from "../hooks/useJob"; type Data = Awaited<ReturnType<typeof resurfaceApi.get>>; @@ -12,7 +12,7 @@ type Data = Awaited<ReturnType<typeof resurfaceApi.get>>; function Row({ item, onRead, onSchedule }: { item: ResurfaceItem; onRead: () => void; onSchedule: () => void }) { const navigate = useNavigate(); return ( - <li className="card resurface-row"> + <li className="idea"> <div className="row between"> <button className="link-btn doc-title" onClick={onRead}> {item.title} @@ -23,16 +23,15 @@ function Row({ item, onRead, onSchedule }: { item: ResurfaceItem; onRead: () => </span> )} </div> - <p className="mono muted"> + <p className="mono muted small"> {formatDate(item.published_at)} {item.years_ago ? ` · ${item.years_ago} year${item.years_ago > 1 ? "s" : ""} ago today` : ""} {item.last_resurfaced_at ? ` · reshared ${formatDate(item.last_resurfaced_at)}` : ""} </p> {item.angle && <p className="angle">{item.angle}</p>} - {item.reason && <p className="muted small">{item.reason}</p>} <div className="row"> <button className="btn btn-small btn-primary" onClick={() => navigate(`/app/launch-kit?post=${item.id}`)}> - Make a Launch Kit + Launch Kit </button> <button className="btn btn-small" onClick={onSchedule}> Schedule a reshare @@ -42,7 +41,8 @@ function Row({ item, onRead, onSchedule }: { item: ResurfaceItem; onRead: () => ); } -export default function Resurface() { +/** Old posts worth a second orbit. Scoring runs after every sync, so there is nothing to press first. */ +export function ResurfaceIdeas({ onScheduled }: { onScheduled?: () => void }) { const [data, setData] = useState<Data | null>(null); const [job, setJob] = useState<Job | null>(null); const [reading, setReading] = useState<string | null>(null); @@ -56,54 +56,37 @@ export default function Resurface() { const live = useJob(job, () => load()); const running = live && (live.status === "queued" || live.status === "running"); - const scan = async () => { - setError(null); - try { - setJob(await resurfaceApi.scan()); - } catch (e) { - setError(errorMessage(e)); - } - }; + if (!data) return <Loading />; + const items = [...data.on_this_day, ...data.evergreen.filter((e) => !data.on_this_day.some((o) => o.id === e.id))]; return ( - <div className="page-wrap"> - <PageHeader eyebrow="Resurfacing" title="Old posts worth a second orbit"> - <button className="btn btn-primary" disabled={Boolean(running)} onClick={scan}> - {data?.unscored ? `Score ${data.unscored} new posts` : "Rescan"} - </button> - </PageHeader> - <p className="muted lede"> - Notestack scores how timeless each post is, then ranks the old, evergreen ones you have not reshared in 90 days. - </p> - {live && (running || live.status === "failed") && <JobProgress job={live} />} + <div className="stack"> + {live && (running || live.status === "failed") && <JobProgress job={live} compact />} {error && <p className="error-text">{error}</p>} - {!data && <Loading />} - {data && ( - <div className="two-col"> - <section className="stack"> - <h2>Evergreen picks</h2> - {data.evergreen.length === 0 && ( - <EmptyState - title={data.total_posts ? "Nothing scored yet" : "No posts yet"} - body={data.total_posts ? "Run a scan to find your evergreen posts." : "Connect a source first."} - /> - )} - <ul className="stack"> - {data.evergreen.map((i) => ( - <Row key={i.id} item={i} onRead={() => setReading(i.id)} onSchedule={() => setScheduling(i)} /> - ))} - </ul> - </section> - <section className="stack"> - <h2>On this day</h2> - {data.on_this_day.length === 0 && <p className="muted">Nothing published on this date in past years.</p>} - <ul className="stack"> - {data.on_this_day.map((i) => ( - <Row key={i.id} item={i} onRead={() => setReading(i.id)} onSchedule={() => setScheduling(i)} /> - ))} - </ul> - </section> - </div> + {items.length === 0 && ( + <p className="muted small"> + {data.total_posts ? "No reshare ideas yet. They appear after your posts are scored." : "Connect a source to get reshare ideas."} + </p> + )} + <ul className="stack"> + {items.slice(0, 8).map((i) => ( + <Row key={i.id} item={i} onRead={() => setReading(i.id)} onSchedule={() => setScheduling(i)} /> + ))} + </ul> + {data.unscored > 0 && !running && ( + <button + className="link-btn small" + onClick={async () => { + setError(null); + try { + setJob(await resurfaceApi.scan()); + } catch (e) { + setError(errorMessage(e)); + } + }} + > + Score {data.unscored} new posts now + </button> )} {reading && <Reader documentId={reading} onClose={() => setReading(null)} />} {scheduling && ( @@ -112,9 +95,17 @@ export default function Resurface() { posts={[`${scheduling.angle ?? scheduling.title}\n\n${scheduling.url.startsWith("http") ? scheduling.url : ""}`.trim()]} documentId={scheduling.id} onClose={() => setScheduling(null)} - onScheduled={() => load()} + onScheduled={() => { + load(); + onScheduled?.(); + }} /> )} </div> ); } + +/** The old Resurfacing page now lives inside Launchpad. */ +export default function Resurface() { + return <Navigate to="/app/launchpad#ideas" replace />; +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index a861e7faf82da4d970395617910e60caf058bacb..b7cec5e5385d72961a2e3fbdad44a6875cfee69f 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { useNavigate } from "react-router-dom"; import { authApi } from "../api/auth"; import { tokens, uploadFile } from "../api/client"; @@ -27,10 +27,13 @@ function Meter({ label, used, limit, unit }: { label: string; used: number; limi ); } -function Section({ title, children }: { title: string; children: ReactNode }) { +function Section({ title, saved, children }: { title: string; saved?: boolean; children: ReactNode }) { return ( <section className="card stack"> - <h2>{title}</h2> + <div className="row between"> + <h2>{title}</h2> + {saved && <span className="mono muted small">Saved</span>} + </div> {children} </section> ); @@ -44,7 +47,6 @@ export default function Settings() { const [accent, setAccent] = useState("#217cff"); const [saved, setSaved] = useState<string | null>(null); const [error, setError] = useState<string | null>(null); - const logoRef = useRef<HTMLInputElement>(null); const [sounds, setSounds] = useState(soundsEnabled); const { logout } = useAuth(); const upgrade = useUpgrade(); @@ -75,6 +77,13 @@ export default function Settings() { if (!s) return error ? <p className="error-text">{error}</p> : <Loading />; const u = s.usage; + // Fields save on their own when the writer leaves them, so there are no Save buttons. + const saveProfile = () => { + if (name !== (s.user.name ?? "") || workspace !== s.workspace.name) void save({ name, workspace_name: workspace }, "profile"); + }; + const saveBrand = (next = { brandName, accent }) => { + if (next.brandName !== (s.brand.name ?? "") || next.accent !== s.brand.accent) void save({ brand_name: next.brandName, brand_accent: next.accent }, "brand"); + }; return ( <div className="page-wrap"> @@ -82,58 +91,54 @@ export default function Settings() { {error && <p className="error-text">{error}</p>} <div className="two-col"> <div className="stack"> - <Section title="Profile"> + <Section title="Profile" saved={saved === "profile"}> <label className="field"> <span>Name</span> - <input className="input input-sm" value={name} onChange={(e) => setName(e.target.value)} /> + <input className="input input-sm" value={name} onChange={(e) => setName(e.target.value)} onBlur={saveProfile} /> </label> <p className="mono muted"> {s.user.email} · signed in with {s.user.auth_provider === "google" ? "Google" : "email and password"} </p> <label className="field"> <span>Workspace name</span> - <input className="input input-sm" value={workspace} onChange={(e) => setWorkspace(e.target.value)} /> + <input className="input input-sm" value={workspace} onChange={(e) => setWorkspace(e.target.value)} onBlur={saveProfile} /> </label> - <div className="row end"> - <button className="btn btn-primary btn-small" onClick={() => save({ name, workspace_name: workspace }, "profile")}> - {saved === "profile" ? "Saved" : "Save"} - </button> - </div> </Section> - <Section title="Brand kit"> + <Section title="Brand kit" saved={saved === "brand" || saved === "logo"}> <p className="muted">Used on videos, quote cards and carousels.</p> <label className="field"> <span>Name on renders</span> - <input className="input input-sm" placeholder="Your publication" value={brandName} onChange={(e) => setBrandName(e.target.value)} /> + <input className="input input-sm" placeholder="Your publication" value={brandName} onChange={(e) => setBrandName(e.target.value)} onBlur={() => saveBrand()} /> </label> <label className="field"> <span>Accent color</span> <div className="row"> - <input type="color" value={accent} onChange={(e) => setAccent(e.target.value)} aria-label="Accent color" className="color" /> - <input className="input input-sm mono" value={accent} onChange={(e) => setAccent(e.target.value)} /> + <input type="color" value={accent} onChange={(e) => setAccent(e.target.value)} onBlur={() => saveBrand()} aria-label="Accent color" className="color" /> + <input className="input input-sm mono" value={accent} onChange={(e) => setAccent(e.target.value)} onBlur={() => saveBrand()} aria-label="Accent hex" /> </div> </label> <div className="field"> <span>Logo</span> <div className="row"> {s.brand.logo_url && <img src={s.brand.logo_url} alt="Logo" className="logo-preview" />} - <input ref={logoRef} type="file" accept="image/png,image/jpeg,image/webp,image/svg+xml" className="input file-input" aria-label="Logo file" /> - <button - className="btn btn-small" - onClick={async () => { - const file = logoRef.current?.files?.[0]; + <input + type="file" + accept="image/png,image/jpeg,image/webp,image/svg+xml" + className="input file-input" + aria-label="Logo file" + onChange={async (e) => { + const file = e.target.files?.[0]; + e.target.value = ""; if (!file) return; try { const up = await uploadFile(file); await save({ logo_upload_id: up.upload_id }, "logo"); - } catch (e) { - setError(errorMessage(e)); + } catch (err) { + setError(errorMessage(err)); } }} - > - Upload - </button> + /> {s.brand.logo_url && ( <button className="btn btn-small" onClick={() => save({ logo_upload_id: "" }, "logo")}> Remove @@ -141,11 +146,6 @@ export default function Settings() { )} </div> </div> - <div className="row end"> - <button className="btn btn-primary btn-small" onClick={() => save({ brand_name: brandName, brand_accent: accent }, "brand")}> - {saved === "brand" ? "Saved" : "Save brand"} - </button> - </div> </Section> <Section title="Privacy and email"> @@ -198,36 +198,10 @@ export default function Settings() { )} {upgrade.error && !upgrade.modal && <p className="error-text">{upgrade.error}</p>} <p className="mono muted small"> - {s.plan.sources} sources · {s.plan.indexed_posts.toLocaleString()} indexed posts · {u.used.llm_tokens.toLocaleString()} LLM tokens this month + {s.plan.sources} sources · {s.plan.indexed_posts.toLocaleString()} indexed posts </p> </Section> - <Section title="Connections"> - <ul className="integrations mono"> - <li> - LLM <span className={s.integrations.llm ? "ok" : "off"}>{s.integrations.llm ? "configured" : "set LLM_API_KEY"}</span> - </li> - <li> - ElevenLabs <span className={s.integrations.elevenlabs ? "ok" : "off"}>{s.integrations.elevenlabs ? "configured" : "set ELEVENLABS_API_KEY"}</span> - </li> - <li> - X app <span className={s.integrations.x ? "ok" : "off"}>{s.integrations.x ? "configured" : "set X_CLIENT_ID"}</span> - </li> - <li> - LinkedIn app <span className={s.integrations.linkedin ? "ok" : "off"}>{s.integrations.linkedin ? "configured" : "set LINKEDIN_CLIENT_ID"}</span> - </li> - <li> - Storage <span className="ok">{s.integrations.storage === "local" ? "local disk" : "Cloudflare R2"}</span> - </li> - <li> - Renderer <span className="muted">{s.integrations.renderer}</span> - </li> - </ul> - <button className="btn btn-small" onClick={() => navigate("/app/launchpad")}> - Manage social accounts - </button> - </Section> - <Section title="Account"> <div className="row"> <button diff --git a/frontend/src/pages/Sources.tsx b/frontend/src/pages/Sources.tsx index 992d6161a279aa55c136d206aa9981d43cb08899..db60fbf9ce7c0da9c45e4e6f52401330ab2e019c 100644 --- a/frontend/src/pages/Sources.tsx +++ b/frontend/src/pages/Sources.tsx @@ -1,80 +1,137 @@ import { useCallback, useEffect, useRef, useState, type FormEvent } from "react"; +import { NavLink } from "react-router-dom"; import { docsApi, jobsApi, sourcesApi } from "../api/endpoints"; import type { Doc, Job, Source } from "../api/types"; import { Reader } from "../components/Reader"; -import { ConfirmButton, EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader, StatusPill, Tabs } from "../components/ui"; +import { ConfirmButton, EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader, StatusPill } from "../components/ui"; import { useJobMap } from "../hooks/useJob"; - -type AddMode = "feed" | "url" | "file"; +import { useUpgrade } from "../hooks/useUpgrade"; const PAGE = 50; +/** Sources and the Topic map share one sidebar entry; this strip switches between them. */ +export function SourcesNav() { + return ( + <nav className="tabs" aria-label="Sources views"> + <NavLink to="/app/sources" className={({ isActive }) => `tab${isActive ? " active" : ""}`}> + Posts + </NavLink> + <NavLink to="/app/map" className={({ isActive }) => `tab${isActive ? " active" : ""}`}> + Topic map + </NavLink> + </nav> + ); +} + +export const FILE_ACCEPT = ".md,.markdown,.txt,.html,.htm,.pdf,text/markdown,text/plain,text/html,application/pdf"; + +/** A bare domain, a Substack or a feed URL is a whole archive; a deep link is one article. */ +export function looksLikeArticle(raw: string): boolean { + const v = raw.trim(); + if (!v) return false; + try { + const u = new URL(v.includes("://") ? v : `https://${v}`); + const path = u.pathname.replace(/\/+$/, ""); + if (!path || /(feed|rss|atom)(\.xml)?$/i.test(path) || /^\/@[^/]+$/.test(path)) return false; + const parts = path.split("/").filter(Boolean); + return /\/p\/|\/posts?\/|\/\d{4}\/|\.html?$/i.test(path) || parts.length >= 2 || path.length > 24; + } catch { + return false; + } +} + +/** Imports each file as its own post. Returns how many failed. */ +export async function importFiles(files: File[], onAdded: (source: Source, job: Job) => void): Promise<number> { + let failed = 0; + for (const f of files) { + try { + const res = await sourcesApi.importFile(f); + onAdded(res.source, res.job); + } catch { + failed += 1; + } + } + return failed; +} + +/** One box for everything: paste a link (feed or article is worked out for you) or add files. */ export function AddSource({ onAdded }: { onAdded: (source: Source, job: Job) => void }) { - const [mode, setMode] = useState<AddMode>("feed"); const [value, setValue] = useState(""); + const [override, setOverride] = useState<"feed" | "url" | null>(null); const [busy, setBusy] = useState(false); const [error, setError] = useState<string | null>(null); const fileRef = useRef<HTMLInputElement>(null); + const mode = override ?? (looksLikeArticle(value) ? "url" : "feed"); const submit = async (e: FormEvent) => { e.preventDefault(); + if (!value.trim()) return; setError(null); setBusy(true); try { - if (mode === "file") { - const file = fileRef.current?.files?.[0]; - if (!file) throw new Error("Choose a file first."); - const res = await sourcesApi.importFile(file); - onAdded(res.source, res.job); - if (fileRef.current) fileRef.current.value = ""; - } else { - if (!value.trim()) return; - const res = mode === "feed" ? await sourcesApi.connect(value.trim()) : await sourcesApi.importUrl(value.trim()); - onAdded(res.source, res.job); - setValue(""); - } + const res = mode === "feed" ? await sourcesApi.connect(value.trim()) : await sourcesApi.importUrl(value.trim()); + onAdded(res.source, res.job); + setValue(""); + setOverride(null); } catch (err) { - setError(errorMessage(err, "Could not add that.")); + setError(errorMessage(err, mode === "feed" ? "We could not find a feed there." : "We could not import that page.")); } finally { setBusy(false); } }; + const upload = async (files: FileList | null) => { + const list = Array.from(files ?? []); + if (!list.length) return; + setError(null); + setBusy(true); + const failed = await importFiles(list, onAdded); + if (failed) setError(`${failed} of ${list.length} files could not be uploaded.`); + setBusy(false); + if (fileRef.current) fileRef.current.value = ""; + }; + return ( <div className="card add-source"> - <Tabs<AddMode> - tabs={[ - { id: "feed", label: "Connect a feed" }, - { id: "url", label: "Import one URL" }, - { id: "file", label: "Upload a file" }, - ]} - value={mode} - onChange={(m) => { - setMode(m); - setError(null); - }} - /> - <p className="muted"> - {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."} - {mode === "url" && "One article from anywhere on the web. It lands in Imported posts."} - {mode === "file" && "Markdown, text, HTML or PDF. Handy for drafts and posts that never had a feed."} - </p> - <form onSubmit={submit} className="inline-form"> - {mode === "file" ? ( - <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" /> - ) : ( - <input - className="input" - placeholder={mode === "feed" ? "yourblog.com" : "https://example.com/my-essay"} - value={value} - onChange={(e) => setValue(e.target.value)} - aria-label={mode === "feed" ? "Feed or site URL" : "Article URL"} - /> - )} - <button className="btn btn-primary" type="submit" disabled={busy}> - {busy ? (mode === "feed" ? "Finding feed..." : "Starting...") : mode === "feed" ? "Connect" : "Import"} + <form onSubmit={submit} className="add-smart"> + <input + className="input" + placeholder="Paste a blog, newsletter or article link" + value={value} + onChange={(e) => { + setValue(e.target.value); + setOverride(null); + }} + aria-label="Blog, newsletter or article link" + /> + <button className="btn btn-primary" type="submit" disabled={busy || !value.trim()}> + {busy ? "Working..." : mode === "feed" ? "Connect" : "Import article"} + </button> + <span className="muted small">or</span> + <button type="button" className="btn" disabled={busy} onClick={() => fileRef.current?.click()}> + Upload files </button> + <input ref={fileRef} type="file" multiple accept={FILE_ACCEPT} hidden onChange={(e) => upload(e.target.files)} aria-label="Files to upload" /> </form> + <p className="muted small"> + {!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."} + {value.trim() && mode === "feed" && ( + <> + We will pull in the whole archive and keep it in sync.{" "} + <button type="button" className="link-btn small" onClick={() => setOverride("url")}> + Just this one page instead + </button> + </> + )} + {value.trim() && mode === "url" && ( + <> + Looks like a single article, so we will import just this page.{" "} + <button type="button" className="link-btn small" onClick={() => setOverride("feed")}> + Connect the whole site instead + </button> + </> + )} + </p> {error && ( <p className="error-text" role="alert"> {error} @@ -86,6 +143,7 @@ export function AddSource({ onAdded }: { onAdded: (source: Source, job: Job) => export default function Sources() { const [sources, setSources] = useState<Source[] | null>(null); + const { openUpgrade } = useUpgrade(); const [docs, setDocs] = useState<Doc[]>([]); const [total, setTotal] = useState(0); const [q, setQ] = useState(""); @@ -113,7 +171,7 @@ export default function Sources() { }); useEffect(() => { - loadSources().catch(() => setError("Could not reach the API. Is it running?")); + loadSources().catch(() => setError("Could not load your sources. Refresh to try again.")); }, [loadSources]); useEffect(() => { @@ -142,8 +200,29 @@ export default function Sources() { watch(source.id, job); }; + const [dropping, setDropping] = useState(false); + return ( - <div className="page-wrap"> + <div + className={`page-wrap${dropping ? " drop-over" : ""}`} + onDragOver={(e) => { + if (!e.dataTransfer.types.includes("Files")) return; + e.preventDefault(); + setDropping(true); + }} + onDragLeave={(e) => { + if (e.currentTarget === e.target) setDropping(false); + }} + onDrop={async (e) => { + if (!e.dataTransfer.files.length) return; + e.preventDefault(); + setDropping(false); + const files = Array.from(e.dataTransfer.files); + const failed = await importFiles(files, added); + setError(failed ? `${failed} of ${files.length} files could not be uploaded.` : null); + }} + > + <SourcesNav /> <PageHeader eyebrow="Sources" title="Your knowledge, in orbit" /> <AddSource onAdded={added} /> {error && <p className="error-text">{error}</p>} @@ -160,6 +239,7 @@ export default function Sources() { <strong>{s.title ?? s.feed_url}</strong> <span className="mono muted"> {s.is_imports ? "imports" : s.platform} · {s.document_count} posts + {s.locked_count > 0 ? ` found, ${s.document_count - s.locked_count} indexed` : ""} {s.last_synced_at ? ` · synced ${formatDate(s.last_synced_at, true)}` : ""} </span> </div> @@ -171,6 +251,20 @@ export default function Sources() { </a> )} {job && (active || job.status === "failed") && <JobProgress job={job} compact />} + {s.locked_count > 0 && !active && ( + <div className="welcome-cap locked-cap"> + <div> + <strong>{s.locked_count} posts not indexed</strong> + <p className="muted small"> + Your plan only indexes your latest posts. Upgrade to index the rest so answers, audio and Launch Kits + can use your whole archive. + </p> + </div> + <button type="button" className="btn btn-small btn-primary" onClick={() => openUpgrade("indexed_posts")}> + Index all {s.document_count} + </button> + </div> + )} {s.sync_status === "error" && s.sync_error && !active && <p className="error-text">{s.sync_error}</p>} <footer className="row"> {!s.is_imports && ( @@ -222,6 +316,11 @@ export default function Sources() { <button className="link-btn doc-title" onClick={() => setReading(d.id)}> {d.title} </button> + {d.locked && ( + <span className="mono muted small" title="Upgrade to index this post"> + Not indexed + </span> + )} <span className="mono muted"> {d.source_title ?? ""} · {formatDate(d.published_at)} · {d.words} words </span> diff --git a/frontend/src/pages/Studio.tsx b/frontend/src/pages/Studio.tsx index 7956b6ec3f1ae223de2cfa43eebee2f6241740b6..d6b48415ee3d33f46d19e2118078fa97077f333e 100644 --- a/frontend/src/pages/Studio.tsx +++ b/frontend/src/pages/Studio.tsx @@ -1,61 +1,54 @@ import { useCallback, useEffect, useState } from "react"; +import { Link } from "react-router-dom"; import { artifactsApi, notebooksApi, type GenerateBody } from "../api/endpoints"; import type { Artifact, NotebookSummary } from "../api/types"; import { ArtifactCard } from "../components/ArtifactCard"; import { DocPicker } from "../components/DocPicker"; +import { LaunchWindowIcon, PlanetIcon, RocketIcon, SparkleIcon } from "../components/icons/Icons"; import { Reader } from "../components/Reader"; -import { EmptyState, errorMessage, Loading, PageHeader, Tabs } from "../components/ui"; +import { errorMessage, PageHeader } from "../components/ui"; -type Kind = "audio" | "short" | "explainer" | "audiogram" | "quote"; +type Kind = "audio" | "short" | "explainer" | "quote"; -const KINDS: { id: Kind; label: string; blurb: string }[] = [ - { id: "audio", label: "Audio overview", blurb: "Two hosts talk through your posts, every line grounded in what you wrote." }, - { id: "short", label: "Short 9:16", blurb: "A vertical video with a hook in the first two seconds, narration and captions." }, - { id: "explainer", label: "Explainer 16:9", blurb: "A title card and narrated scenes for YouTube or your post." }, - { id: "audiogram", label: "Audiogram 1:1", blurb: "A square waveform video of an audio overview, with the live transcript." }, - { id: "quote", label: "Quote card", blurb: "Your most quotable line, verified against the post, as a 1080 square." }, +const KINDS: { id: Kind; label: string; blurb: string; icon: typeof PlanetIcon }[] = [ + { id: "audio", label: "Audio overview", blurb: "Two hosts talk through your posts, every line grounded in what you wrote.", icon: PlanetIcon }, + { id: "short", label: "Short video", blurb: "Vertical 9:16 with a hook in the first two seconds, narration and captions.", icon: LaunchWindowIcon }, + { id: "explainer", label: "Explainer", blurb: "16:9 title card and narrated scenes for YouTube or your post.", icon: SparkleIcon }, + { id: "quote", label: "Quote card", blurb: "Your most quotable line, verified against the post, as a 1080 square.", icon: RocketIcon }, ]; +const ARCHIVE = "archive"; +const ONE_POST = "post"; + +/** Create: pick a format, say what it is about (the whole archive by default), press Create. */ export default function Studio() { const [kind, setKind] = useState<Kind>("audio"); - const [target, setTarget] = useState<"notebook" | "post">("notebook"); + const [about, setAbout] = useState(ARCHIVE); const [notebooks, setNotebooks] = useState<NotebookSummary[]>([]); - const [notebookId, setNotebookId] = useState(""); const [postIds, setPostIds] = useState<string[]>([]); const [format, setFormat] = useState<"deep_dive" | "brief" | "debate">("deep_dive"); const [minutes, setMinutes] = useState(6); - const [audioId, setAudioId] = useState(""); - const [gallery, setGallery] = useState<Artifact[] | null>(null); - const [filter, setFilter] = useState<"all" | "audio_overview" | "video" | "quote_card,carousel">("all"); + const [recent, setRecent] = useState<Artifact[] | null>(null); const [busy, setBusy] = useState(false); const [error, setError] = useState<string | null>(null); const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null); const load = useCallback( - () => - artifactsApi - .list({ type: filter === "all" ? "audio_overview,video,quote_card,carousel" : filter, limit: 60 }) - .then((p) => setGallery(p.items)), - [filter], + () => artifactsApi.list({ type: "audio_overview,video,quote_card", limit: 6 }).then((p) => setRecent(p.items)), + [], ); useEffect(() => { - notebooksApi.list().then((n) => { - setNotebooks(n); - setNotebookId((cur) => cur || n[0]?.id || ""); - }); - }, []); - useEffect(() => { + notebooksApi.list().then((n) => setNotebooks(n.filter((x) => !x.is_archive))); load(); }, [load]); - const audios = (gallery ?? []).filter((a) => a.type === "audio_overview" && a.status === "ready"); - const create = async () => { setError(null); - const where: Partial<GenerateBody> = target === "notebook" ? { notebook_id: notebookId } : { document_id: postIds[0] }; - if (kind !== "audiogram" && !where.notebook_id && !where.document_id) { - setError(target === "notebook" ? "Pick a notebook." : "Pick a post."); + const where: Partial<GenerateBody> = + about === ARCHIVE ? { archive: true } : about === ONE_POST ? { document_id: postIds[0] } : { notebook_id: about }; + if (about === ONE_POST && !postIds[0]) { + setError("Pick a post."); return; } const body: GenerateBody = @@ -63,17 +56,11 @@ export default function Studio() { ? { type: "audio_overview", format, minutes, ...where } : kind === "quote" ? { type: "quote_card", ...where } - : kind === "audiogram" - ? { type: "video", style: "audiogram", audio_artifact_id: audioId } - : { type: "video", style: kind, ...where }; - if (kind === "audiogram" && !audioId) { - setError("Pick an audio overview first."); - return; - } + : { type: "video", style: kind, ...where }; setBusy(true); try { const a = await artifactsApi.generate(body); - setGallery((g) => [a, ...(g ?? [])]); + setRecent((g) => [a, ...(g ?? [])].slice(0, 6)); } catch (e) { setError(errorMessage(e)); } finally { @@ -85,108 +72,109 @@ export default function Studio() { return ( <div className="page-wrap"> - <PageHeader eyebrow="Video and audio" title="Studio" /> + <PageHeader eyebrow="Create" title="Make something from your writing" /> <section className="card stack studio-maker"> - <Tabs<Kind> tabs={KINDS.map((k) => ({ id: k.id, label: k.label }))} value={kind} onChange={setKind} /> + <div className="format-grid"> + {KINDS.map((k) => ( + <button key={k.id} type="button" className={`format-tile${kind === k.id ? " on" : ""}`} aria-pressed={kind === k.id} onClick={() => setKind(k.id)}> + <k.icon size={30} /> + <strong>{k.label}</strong> + </button> + ))} + <Link to="/app/launch-kit" className="format-tile"> + <span className="mc-more" aria-hidden="true"> + + + </span> + <strong>Launch Kit</strong> + </Link> + </div> <p className="muted">{meta.blurb}</p> - {kind === "audiogram" ? ( + + <div className="studio-row"> <label className="field"> - <span>Audio overview</span> - <select className="input input-sm" value={audioId} onChange={(e) => setAudioId(e.target.value)}> - <option value="">Pick one</option> - {audios.map((a) => ( - <option key={a.id} value={a.id}> - {a.title} - </option> - ))} - </select> - {audios.length === 0 && <span className="muted small">Make an audio overview first.</span>} - </label> - ) : ( - <> - <div className="row"> - <label className="check"> - <input type="radio" checked={target === "notebook"} onChange={() => setTarget("notebook")} /> From a notebook - </label> - <label className="check"> - <input type="radio" checked={target === "post"} onChange={() => setTarget("post")} /> From one post - </label> - </div> - {target === "notebook" ? ( - notebooks.length ? ( - <select className="input input-sm" value={notebookId} onChange={(e) => setNotebookId(e.target.value)} aria-label="Notebook"> + <span>What is it about?</span> + <select className="input" value={about} onChange={(e) => setAbout(e.target.value)}> + <option value={ARCHIVE}>My whole archive</option> + {notebooks.length > 0 && ( + <optgroup label="A notebook"> {notebooks.map((n) => ( <option key={n.id} value={n.id}> {n.title} ({n.document_count} posts) </option> ))} + </optgroup> + )} + <option value={ONE_POST}>One post...</option> + </select> + </label> + {kind === "audio" && ( + <> + <label className="field"> + <span>Format</span> + <select className="input" value={format} onChange={(e) => setFormat(e.target.value as typeof format)}> + <option value="deep_dive">Deep dive</option> + <option value="brief">Brief</option> + <option value="debate">Debate</option> + </select> + </label> + <label className="field"> + <span>Length</span> + <select className="input" value={minutes} onChange={(e) => setMinutes(Number(e.target.value))}> + {[3, 6, 10, 15, 20].map((m) => ( + <option key={m} value={m}> + {m} minutes + </option> + ))} </select> - ) : ( - <p className="muted">No notebooks yet. Switch to one post, or create a notebook first.</p> - ) - ) : ( - <DocPicker selected={postIds} onChange={setPostIds} single /> - )} - </> - )} - {kind === "audio" && ( - <div className="row"> - <label className="field"> - <span>Format</span> - <select className="input input-sm" value={format} onChange={(e) => setFormat(e.target.value as typeof format)}> - <option value="deep_dive">Deep dive</option> - <option value="brief">Brief</option> - <option value="debate">Debate</option> - </select> - </label> - <label className="field"> - <span>Length</span> - <select className="input input-sm" value={minutes} onChange={(e) => setMinutes(Number(e.target.value))}> - {[3, 6, 10, 15, 20].map((m) => ( - <option key={m} value={m}> - {m} minutes - </option> - ))} - </select> - </label> - </div> - )} - {error && <p className="error-text">{error}</p>} - <div className="row end"> - <button className="btn btn-primary" onClick={create} disabled={busy}> + </label> + </> + )} + <button className="btn btn-primary studio-go" onClick={create} disabled={busy}> {busy ? "Launching..." : `Create ${meta.label.toLowerCase()}`} </button> </div> + {about === ONE_POST && <DocPicker selected={postIds} onChange={setPostIds} single />} + {error && <p className="error-text">{error}</p>} </section> - <section className="stack"> - <div className="row between"> - <h2>Renders</h2> - <Tabs - tabs={[ - { id: "all", label: "All" }, - { id: "audio_overview", label: "Audio" }, - { id: "video", label: "Video" }, - { id: "quote_card,carousel", label: "Stills" }, - ]} - value={filter} - onChange={setFilter} - /> - </div> - {!gallery && <Loading />} - {gallery?.length === 0 && <EmptyState title="Nothing rendered yet" body="Your audio overviews, videos and cards will land here." />} - <div className="gallery"> - {gallery?.map((a) => ( - <ArtifactCard - key={a.id} - artifact={a} - onCite={(c) => c.document_id && setReading({ id: c.document_id, start: c.line_start, end: c.line_end })} - onRemoved={(id) => setGallery((g) => (g ?? []).filter((x) => x.id !== id))} - onChanged={(next) => setGallery((g) => (g ?? []).map((x) => (x.id === next.id ? next : x)))} - /> - ))} - </div> - </section> + {recent && recent.length > 0 && ( + <section className="stack"> + <div className="row between"> + <h2>Latest</h2> + <Link to="/app/archive" className="mono muted small-link"> + Everything in Library + </Link> + </div> + <div className="gallery"> + {recent.map((a) => ( + <ArtifactCard + key={a.id} + artifact={a} + onCite={(c) => c.document_id && setReading({ id: c.document_id, start: c.line_start, end: c.line_end })} + onRemoved={(id) => setRecent((g) => (g ?? []).filter((x) => x.id !== id))} + onChanged={(next) => setRecent((g) => (g ?? []).map((x) => (x.id === next.id ? next : x)))} + actions={(art) => + art.type === "audio_overview" && art.status === "ready" ? ( + <button + className="btn btn-small" + onClick={async () => { + try { + const v = await artifactsApi.generate({ type: "video", style: "audiogram", audio_artifact_id: art.id }); + setRecent((g) => [v, ...(g ?? [])].slice(0, 6)); + } catch (e) { + setError(errorMessage(e)); + } + }} + > + Make audiogram + </button> + ) : null + } + /> + ))} + </div> + </section> + )} {reading && <Reader documentId={reading.id} highlight={{ start: reading.start, end: reading.end }} onClose={() => setReading(null)} />} </div> ); diff --git a/frontend/src/pages/Tools.tsx b/frontend/src/pages/Tools.tsx new file mode 100644 index 0000000000000000000000000000000000000000..29f0afb5b58b8a8bf244df07bbfe30a48e19dfc0 --- /dev/null +++ b/frontend/src/pages/Tools.tsx @@ -0,0 +1,222 @@ +import { useMemo, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import SkyCanvas from "../components/SkyCanvas"; +import { PublicFooter, PublicNav } from "../components/PublicChrome"; +import { getTool, tools, toolsHub, type ToolDef } from "../content/tools"; +import { useAuth } from "../hooks/useAuth"; +import { buildPodcastScript, scriptToText } from "../lib/podcastScript"; +import { Inline, useMeta } from "./Blog"; +import "../styles/blog.css"; +import "../styles/tools.css"; + +const pathTitle = (path: string) => { + const tool = tools.find((t) => `/tools/${t.slug}` === path); + if (tool) return tool.heroTitle; + if (path === "/notebooklm-alternative") return "Notestack vs NotebookLM"; + return path.replace(/^\/blogs\//, "").replace(/-/g, " ").replace(/^./, (c) => c.toUpperCase()); +}; + +export function ToolsHub() { + useMeta(toolsHub.metaTitle, toolsHub.description); + return ( + <div className="page"> + <SkyCanvas intensity={0.7} travel /> + <PublicNav /> + <main className="container post mission alt-page"> + <header className="mission-head"> + <p className="eyebrow">Free tools</p> + <h1 className="post-title">{toolsHub.heroTitle}</h1> + <p className="post-lede muted">{toolsHub.heroDescription}</p> + </header> + <div className="alt-grid"> + {tools.map((t) => ( + <Link key={t.slug} to={`/tools/${t.slug}`} className="card alt-sister"> + <h2 className="tool-card-title">{t.heroTitle}</h2> + <p className="muted">{t.description}</p> + </Link> + ))} + </div> + </main> + <PublicFooter /> + </div> + ); +} + +/** Every free tool requires sign in: signed out visitors see the pitch and a sign in link that returns them here. */ +function SignInGate({ tool }: { tool: ToolDef }) { + const next = encodeURIComponent(`/tools/${tool.slug}`); + return ( + <div className="card tool-gate"> + <h2>Sign in to use the {tool.eyebrow.toLowerCase()}</h2> + <p className="muted">It is free. Create an account in seconds and come straight back to this tool.</p> + <div className="alt-ctas"> + <Link to={`/auth?mode=signup&next=${next}`} className="btn btn-primary"> + Sign up free + </Link> + <Link to={`/auth?next=${next}`} className="btn"> + Sign in + </Link> + </div> + </div> + ); +} + +function PodcastScriptWidget({ tool }: { tool: ToolDef }) { + const [text, setText] = useState(""); + const [copied, setCopied] = useState(false); + const script = useMemo(() => buildPodcastScript(text), [text]); + const inputWords = text.trim() ? text.trim().split(/\s+/).length : 0; + + const copy = async () => { + if (!script) return; + await navigator.clipboard.writeText(scriptToText(script)); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + const download = () => { + if (!script) return; + const url = URL.createObjectURL(new Blob([scriptToText(script)], { type: "text/plain" })); + const a = document.createElement("a"); + a.href = url; + a.download = `${tool.slug}-script.txt`; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( + <div className="tool-widget"> + <label className="field"> + <span> + {tool.inputLabel} <span className="muted mono small">{inputWords} words</span> + </span> + <textarea + className="input tool-input" + value={text} + onChange={(e) => setText(e.target.value)} + placeholder={tool.inputPlaceholder} + rows={10} + /> + </label> + {tool.inputHint && ( + <p className="muted small"> + <Inline text={tool.inputHint} /> + </p> + )} + {text.trim() && !script && <p className="muted">Paste a little more: the script needs at least three full sentences.</p>} + {script && ( + <div className="card tool-output"> + <div className="tool-output-head"> + <span className="mono small muted"> + {script.lines.length} lines · about {Math.max(1, Math.round(script.minutes))} min · built from {script.sourceSentences} of your + sentences + </span> + <span className="tool-actions"> + <button type="button" className="btn" onClick={copy}> + {copied ? "Copied" : "Copy"} + </button> + <button type="button" className="btn" onClick={download}> + Download + </button> + </span> + </div> + <ol className="tool-script"> + {script.lines.map((l, i) => ( + <li key={i} className={`host-${l.host}`}> + <span className="mono host-label">Host {l.host}</span> + <p>{l.text}</p> + </li> + ))} + </ol> + <div className="tool-next"> + <p className="muted">Hear it with two natural voices, grounded in your whole archive.</p> + <Link to="/app/studio" className="btn btn-primary"> + Voice it in Studio + </Link> + </div> + </div> + )} + </div> + ); +} + +export function ToolPage() { + const { slug = "" } = useParams(); + const tool = getTool(slug); + const { user, loading } = useAuth(); + useMeta(tool?.metaTitle ?? "Not found | Notestack", tool?.description ?? ""); + + if (!tool) { + return ( + <div className="page"> + <PublicNav /> + <main className="container section"> + <h1 className="section-title">Tool not found</h1> + <Link to="/tools">See all free tools</Link> + </main> + </div> + ); + } + + const faqLd = { + "@context": "https://schema.org", + "@type": "FAQPage", + mainEntity: tool.faq.map((f) => ({ "@type": "Question", name: f.question, acceptedAnswer: { "@type": "Answer", text: f.answer } })), + }; + + return ( + <div className="page"> + <SkyCanvas intensity={0.7} travel /> + <PublicNav /> + <main className="container post mission alt-page"> + <script type="application/ld+json">{JSON.stringify(faqLd)}</script> + <header className="mission-head"> + <p className="eyebrow">{tool.eyebrow}</p> + <h1 className="post-title">{tool.heroTitle}</h1> + <p className="post-lede muted">{tool.heroDescription}</p> + </header> + + <section className="alt-section">{loading ? null : user ? <PodcastScriptWidget tool={tool} /> : <SignInGate tool={tool} />}</section> + + {tool.sections.map((s) => ( + <section key={s.heading} className="alt-section"> + <h2>{s.heading}</h2> + {s.paragraphs.map((p, i) => ( + <p key={i} className="muted"> + <Inline text={p} /> + </p> + ))} + {s.bullets && ( + <ul> + {s.bullets.map((b) => ( + <li key={b}>{b}</li> + ))} + </ul> + )} + </section> + ))} + + <section className="alt-section debrief"> + <h2>Questions</h2> + {tool.faq.map((f) => ( + <details key={f.question} className="faq"> + <summary>{f.question}</summary> + <p className="muted">{f.answer}</p> + </details> + ))} + </section> + + <nav className="alt-section" aria-label="Related"> + <h2>Keep exploring</h2> + <ul> + {tool.relatedPaths.map((p) => ( + <li key={p}> + <Link to={p}>{pathTitle(p)}</Link> + </li> + ))} + </ul> + </nav> + </main> + <PublicFooter /> + </div> + ); +} diff --git a/frontend/src/pages/TopicMap.tsx b/frontend/src/pages/TopicMap.tsx index 4206de49dd930137e4853350b267c81d22873655..0d7df93a18467473a35a42c2244e75e20372ac68 100644 --- a/frontend/src/pages/TopicMap.tsx +++ b/frontend/src/pages/TopicMap.tsx @@ -9,7 +9,8 @@ import { type RefObject, } from "react"; import { Link, useNavigate } from "react-router-dom"; -import { notebooksApi, topicsApi } from "../api/endpoints"; +import { artifactsApi, notebooksApi, topicsApi } from "../api/endpoints"; +import { SourcesNav } from "./Sources"; import type { Job, TopicDetail, TopicMap as TopicMapData, TopicNode } from "../api/types"; import { Reader } from "../components/Reader"; import { EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader, Tabs } from "../components/ui"; @@ -299,6 +300,9 @@ export default function TopicMap() { const [data, setData] = useState<TopicMapData | null>(null); const [job, setJob] = useState<Job | null>(null); const [selected, setSelected] = useState<TopicDetail | null>(null); + const [briefing, setBriefing] = useState(false); + const [menu, setMenu] = useState(false); + const [acting, setActing] = useState<string | null>(null); const [hover, setHover] = useState<string | null>(null); const [mode, setMode] = useState<ColorMode>("recency"); const [reading, setReading] = useState<string | null>(null); @@ -458,6 +462,7 @@ export default function TopicMap() { return ( <div className="page-wrap"> + <SourcesNav /> <PageHeader eyebrow="Topic map" title="The constellations in your archive"> <Tabs<ColorMode> tabs={[ @@ -467,12 +472,35 @@ export default function TopicMap() { value={mode} onChange={setMode} /> - <button className="btn btn-small" disabled={Boolean(running)} onClick={() => rebuild(false)}> - {data?.has_untagged_posts ? "Map new posts" : "Refresh"} - </button> - <button className="btn btn-small" disabled={Boolean(running)} onClick={() => rebuild(true)}> - Rebuild - </button> + {data && data.nodes.length > 0 && ( + <button className={`btn btn-small${briefing ? " btn-primary" : ""}`} onClick={() => setBriefing(!briefing)} aria-pressed={briefing}> + Briefing + </button> + )} + {data?.has_untagged_posts && ( + <button className="btn btn-small" disabled={Boolean(running)} onClick={() => rebuild(false)}> + Map new posts + </button> + )} + <div className="card-menu map-menu"> + <button type="button" className="icon-btn" aria-label="Map options" aria-expanded={menu} onClick={() => setMenu(!menu)}> + ⋯ + </button> + {menu && ( + <div className="card-menu-list" onMouseLeave={() => setMenu(false)}> + <button + type="button" + disabled={Boolean(running)} + onClick={() => { + setMenu(false); + rebuild(true); + }} + > + Rebuild the whole map + </button> + </div> + )} + </div> </PageHeader> {live && (running || live.status === "failed") && <JobProgress job={live} />} {error && <p className="error-text">{error}</p>} @@ -489,7 +517,7 @@ export default function TopicMap() { /> )} {data && data.nodes.length > 0 && ( - <div className="map-layout"> + <div className={`map-layout${selected || briefing ? "" : " map-full"}`}> <div className="card map-canvas"> <div className="map-controls"> <button className="icon-btn" onClick={() => zoomAt(0.75)} aria-label="Zoom in" title="Zoom in"> @@ -524,7 +552,8 @@ export default function TopicMap() { )} </div> - <div className="map-legend mono" aria-label="Legend"> + <details className="map-legend mono" aria-label="Legend"> + <summary>Legend</summary> <span> <i className="lg-size" /> size = posts </span> @@ -545,7 +574,7 @@ export default function TopicMap() { <span> <i className="lg-line" /> lines = shared posts </span> - </div> + </details> <svg ref={svgRef} @@ -713,6 +742,7 @@ export default function TopicMap() { </svg> </div> + {(selected || briefing) && ( <aside className="card map-side"> {!selected && ( <> @@ -771,7 +801,7 @@ export default function TopicMap() { {data.insights.dormant.length === 0 && <li className="muted small">Every topic has been visited lately.</li>} </ul> {data.insights.dormant.length > 0 && ( - <Link to="/app/resurface" className="small-link mono"> + <Link to="/app/launchpad#ideas" className="small-link mono"> Resurface old posts </Link> )} @@ -802,7 +832,7 @@ export default function TopicMap() { {selected && ( <> <button className="link-btn mono muted small" onClick={() => setSelected(null)}> - Back to briefing + {briefing ? "Back to briefing" : "Close"} </button> <div className="row between"> <h2>{selected.name}</h2> @@ -851,15 +881,39 @@ export default function TopicMap() { </div> )} - <button - className="btn btn-small btn-primary" - onClick={async () => { - const nb = await notebooksApi.fromTopic(selected.id); - navigate(`/app/notebooks/${nb.id}`); - }} - > - Make a notebook from this topic - </button> + <div className="topic-actions"> + <button + className="btn btn-small btn-primary" + disabled={acting !== null} + onClick={async () => { + setActing("audio"); + try { + const nb = await notebooksApi.fromTopic(selected.id); + await artifactsApi.generate({ type: "audio_overview", notebook_id: nb.id, format: "deep_dive", minutes: 6 }); + navigate(`/app/notebooks/${nb.id}`); + } finally { + setActing(null); + } + }} + > + {acting === "audio" ? "Starting..." : "Audio about this"} + </button> + {selected.posts[0] && ( + <button className="btn btn-small" disabled={acting !== null} onClick={() => navigate(`/app/launch-kit?post=${selected.posts[0].id}`)}> + Launch Kit for the latest post + </button> + )} + <button + className="btn btn-small" + disabled={acting !== null} + onClick={async () => { + const nb = await notebooksApi.fromTopic(selected.id); + navigate(`/app/notebooks/${nb.id}`); + }} + > + Open as notebook + </button> + </div> <ul className="doc-list"> {selected.posts.map((d) => ( <li key={d.id} className="doc-row"> @@ -873,6 +927,7 @@ export default function TopicMap() { </> )} </aside> + )} </div> )} {reading && <Reader documentId={reading} onClose={() => setReading(null)} />} diff --git a/frontend/src/pages/VoiceProfile.tsx b/frontend/src/pages/VoiceProfile.tsx index 973dae37d0b1953fc7af5f541c6f20688c3bbc08..de42ad93ac99505e7e73fb404fbabcc7ad763b5b 100644 --- a/frontend/src/pages/VoiceProfile.tsx +++ b/frontend/src/pages/VoiceProfile.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { voiceApi } from "../api/endpoints"; -import type { Delivery, Job, LibraryVoice, Quota, Voice, VoiceProfileData, VoiceState } from "../api/types"; +import type { Delivery, Job, LibraryVoice, Voice, VoiceDesignInput, VoiceDesignPreview, VoiceProfileData, VoiceState } from "../api/types"; import { DocPicker } from "../components/DocPicker"; -import { ConfirmButton, errorMessage, formatDate, JobProgress, Loading, Modal, PageHeader } from "../components/ui"; +import { ConfirmButton, errorMessage, formatDate, JobProgress, Loading, Modal, PageHeader, Tabs } from "../components/ui"; import { useJob } from "../hooks/useJob"; +import { useUpgrade } from "../hooks/useUpgrade"; type Host = "host_a" | "host_b"; @@ -22,8 +23,6 @@ const SLIDERS: { key: keyof Delivery; label: string; min: number; max: number; s { key: "speed", label: "Speed", min: 0.7, max: 1.2, step: 0.05, hint: "1.0 is natural pace" }, ]; -const HOST_LABEL: Record<Host, string> = { host_a: "Host A, leads and narrates videos", host_b: "Host B, the co-host" }; - /** Plays one URL at a time across the page. */ function usePlayer() { const audio = useRef<HTMLAudioElement | null>(null); @@ -50,83 +49,159 @@ function fmtSeconds(s: number) { // Speaking voices -function HostCard({ - host, +type Icon = "sun" | "book" | "mic" | "spark" | "wave" | "person"; + +/** The four ElevenLabs premade voices offered up front. Each has a glyph so they read at a glance. */ +const PREBUILT: { voice_id: string; name: string; tag: string; icon: Icon }[] = [ + { voice_id: "EXAVITQu4vr4xnSDxMaL", name: "Sarah", tag: "Warm and reassuring", icon: "sun" }, + { voice_id: "JBFqnCBsd6RMkjVDRZzb", name: "George", tag: "British storyteller", icon: "book" }, + { voice_id: "nPczCjzI2devNBz1zQrb", name: "Brian", tag: "Deep narrator", icon: "mic" }, + { voice_id: "cgSgspJ2msm6clMCkdW9", name: "Jessica", tag: "Bright and lively", icon: "spark" }, +]; + +const ICON_PATHS: Record<Icon, string> = { + sun: "M12 4V2m0 20v-2m8-8h2M2 12h2m13.66-5.66 1.41-1.41M4.93 19.07l1.41-1.41m0-11.32L4.93 4.93m14.14 14.14-1.41-1.41M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z", + book: "M4 5.5A2.5 2.5 0 0 1 6.5 3H20v15H6.5A2.5 2.5 0 0 0 4 20.5v-15Zm0 15A2.5 2.5 0 0 0 6.5 23H20v-5", + mic: "M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Zm7 9a7 7 0 0 1-14 0m7 7v4m-4 0h8", + spark: "M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3Zm7 11 .8 2.2L22 17l-2.2.8L19 20l-.8-2.2L16 17l2.2-.8L19 14Z", + wave: "M3 12h2m3-5v10m4-13v16m4-11v6m4-3h1", + person: "M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 9a7 7 0 0 1 14 0", +}; + +function VoiceIcon({ icon }: { icon: Icon }) { + return ( + <span className="voice-icon" aria-hidden="true"> + <svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"> + <path d={ICON_PATHS[icon]} /> + </svg> + </span> + ); +} + +type Tile = { voice_id: string; name: string; tag: string; icon: Icon; preview_url?: string | null }; + +function VoicePicker({ state, voices, onSaved, onBrowse, + onGenerate, }: { - host: Host; state: VoiceState; voices: Voice[]; onSaved: (s: VoiceState) => void; onBrowse: (host: Host) => void; + onGenerate: (host: Host) => void; }) { + const [host, setHost] = useState<Host>("host_a"); const [delivery, setDelivery] = useState<Delivery>(state.delivery[host]); - const [busy, setBusy] = useState(false); - const [error, setError] = useState<string | null>(null); const [dirty, setDirty] = useState(false); + const [loading, setLoading] = useState<string | null>(null); + const [error, setError] = useState<string | null>(null); const { play, playing } = usePlayer(); const current = state.host_voices[host]; - const groups = voices.reduce<Record<string, Voice[]>>((acc, v) => { - const k = v.category ?? "other"; - (acc[k] ??= []).push(v); - return acc; - }, {}); + const other: Host = host === "host_a" ? "host_b" : "host_a"; + const byId = new Map(voices.map((v) => [v.voice_id, v])); useEffect(() => { setDelivery(state.delivery[host]); setDirty(false); }, [state.delivery, host]); - const preview = async () => { - setBusy(true); + // Prebuilt first, then the writer's own voices, then whatever else a host is set to (e.g. a library voice). + const tiles: Tile[] = PREBUILT.map((p) => ({ ...p, preview_url: byId.get(p.voice_id)?.preview_url })); + if (state.clone.status === "ready" && state.clone.voice_id) { + tiles.push({ voice_id: state.clone.voice_id, name: "My voice", tag: "Your clone", icon: "person", preview_url: state.clone.preview_url }); + } + for (const c of state.custom_voices) tiles.push({ voice_id: c.voice_id, name: c.name, tag: "Generated", icon: "wave" }); + for (const id of [state.host_voices.host_a, state.host_voices.host_b]) { + if (!tiles.some((t) => t.voice_id === id)) { + const v = byId.get(id); + tiles.push({ voice_id: id, name: v?.name ?? "Current voice", tag: v?.labels?.accent ?? "Library voice", icon: "wave", preview_url: v?.preview_url }); + } + } + + const listen = async (t: Tile) => { + if (playing === t.voice_id || t.preview_url) return play(t.preview_url ?? "", t.voice_id); + setLoading(t.voice_id); setError(null); try { - const { url } = await voiceApi.preview(current, { delivery }); - play(url, "preview"); + const { url } = await voiceApi.preview(t.voice_id, { host }); + play(url, t.voice_id); } catch (e) { setError(errorMessage(e)); } finally { - setBusy(false); + setLoading(null); + } + }; + + const choose = async (voice_id: string) => { + if (voice_id === current) return; + setError(null); + try { + onSaved(await voiceApi.update({ host_voices: { [host]: voice_id } })); + } catch (e) { + setError(errorMessage(e)); } }; return ( - <div className="host-card"> - <div className="row between"> - <strong>{HOST_LABEL[host]}</strong> - <button className="link-btn small" onClick={() => onBrowse(host)}> - Browse voice library - </button> - </div> - <div className="row"> - <select - className="input input-sm" - value={current} - onChange={async (e) => onSaved(await voiceApi.update({ host_voices: { [host]: e.target.value } }))} - aria-label={`${HOST_LABEL[host]} voice`} - > - {Object.entries(groups).map(([cat, list]) => ( - <optgroup key={cat} label={cat}> - {list.map((v) => ( - <option key={v.voice_id} value={v.voice_id}> - {v.name} - {v.labels?.accent ? `, ${v.labels.accent}` : ""} - {v.labels?.gender ? `, ${v.labels.gender}` : ""} - </option> - ))} - </optgroup> - ))} - {!voices.some((v) => v.voice_id === current) && <option value={current}>Current voice</option>} - </select> - <button className="btn btn-small" onClick={preview} disabled={busy || !state.tts_configured}> - {busy ? "Rendering..." : playing ? "Stop" : "Preview"} - </button> - </div> + <div className="stack"> + <Tabs<Host> + tabs={[ + { id: "host_a", label: "Host A · leads" }, + { id: "host_b", label: "Host B · co-host" }, + ]} + value={host} + onChange={setHost} + /> + <ul className="voice-grid" aria-label={`Voices for ${host === "host_a" ? "Host A" : "Host B"}`}> + {tiles.map((t) => { + const selected = t.voice_id === current; + const usedByOther = t.voice_id === state.host_voices[other]; + return ( + <li key={t.voice_id} className={`voice-tile${selected ? " on" : ""}`}> + <button type="button" className="voice-pick" onClick={() => choose(t.voice_id)} aria-pressed={selected}> + <VoiceIcon icon={t.icon} /> + <strong>{t.name}</strong> + <span className="muted small">{t.tag}</span> + </button> + {selected && <span className="voice-badge mono">{host === "host_a" ? "Host A" : "Host B"}</span>} + {!selected && usedByOther && <span className="voice-badge dim mono">{other === "host_a" ? "Host A" : "Host B"}</span>} + <button + type="button" + className="icon-btn voice-play" + onClick={() => listen(t)} + disabled={loading !== null || (!t.preview_url && !state.tts_configured)} + aria-label={playing === t.voice_id ? `Stop ${t.name}` : `Play ${t.name}`} + title={playing === t.voice_id ? "Stop" : "Listen"} + > + {loading === t.voice_id ? ( + "…" + ) : ( + <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> + {playing === t.voice_id ? <path d="M6 5h4v14H6zM14 5h4v14h-4z" /> : <path d="M7 4.5v15l13-7.5z" />} + </svg> + )} + </button> + </li> + ); + })} + <li className="voice-tile voice-new"> + <button type="button" className="voice-pick" onClick={() => onGenerate(host)} aria-label="Add a voice"> + <span className="voice-icon plus" aria-hidden="true"> + <svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"> + <path d="M12 5v14M5 12h14" /> + </svg> + </span> + <strong>Add a voice</strong> + <span className="muted small">Generate or clone your own</span> + </button> + </li> + </ul> + {error && <p className="error-text">{error}</p>} <details className="delivery"> - <summary className="mono muted small">Delivery settings</summary> + <summary className="mono muted small">Delivery settings for {host === "host_a" ? "Host A" : "Host B"}</summary> {SLIDERS.map((s) => ( <label key={s.key} className="slider" title={s.hint}> <span className="row between"> @@ -163,11 +238,210 @@ function HostCard({ </div> )} </details> - {error && <p className="error-text">{error}</p>} + <p className="muted small"> + Want something else?{" "} + <button className="link-btn small" onClick={() => onBrowse(host)}> + Browse the full voice library + </button> + </p> </div> ); } +// Generating a new voice: build from options, describe it, or clone your own + +type GenMode = "options" | "prompt" | "clone"; + +const GEN_OPTIONS: { key: keyof Omit<VoiceDesignInput, "prompt">; label: string; values: string[] }[] = [ + { key: "gender", label: "Gender", values: ["female", "male", "neutral"] }, + { key: "age", label: "Age", values: ["young", "middle-aged", "older"] }, + { key: "persona", label: "Personality", values: ["warm", "professional", "calm", "friendly", "confident", "energetic"] }, + { key: "pace", label: "Pace", values: ["slow", "measured", "brisk"] }, + { key: "accent", label: "Accent", values: ["American", "British", "Australian", "Irish", "Scottish", "Indian", "Canadian", "South African"] }, +]; + +const PROMPT_IDEAS = [ + "A calm, low British voice for late night essays", + "An upbeat young American podcaster who smiles while talking", + "A thoughtful older narrator with a soft Irish lilt", +]; + +function GenerateVoiceModal({ + host, + state, + onClose, + onSaved, + onClone, +}: { + host: Host; + state: VoiceState; + onClose: () => void; + onSaved: (s: VoiceState) => void; + onClone: () => void; +}) { + const [mode, setMode] = useState<GenMode>("options"); + const [opts, setOpts] = useState<VoiceDesignInput>({ gender: "female", age: "middle-aged", persona: "warm", pace: "measured", accent: "American" }); + const [prompt, setPrompt] = useState(""); + const [result, setResult] = useState<{ description: string; previews: VoiceDesignPreview[] } | null>(null); + const [picked, setPicked] = useState<string | null>(null); + const [name, setName] = useState(""); + const [busy, setBusy] = useState<"generate" | "save" | null>(null); + const [error, setError] = useState<string | null>(null); + const { play, playing } = usePlayer(); + const { openUpgrade } = useUpgrade(); + const hostLabel = host === "host_a" ? "Host A" : "Host B"; + + const generate = async () => { + setBusy("generate"); + setError(null); + setResult(null); + setPicked(null); + try { + const res = await voiceApi.design(mode === "prompt" ? { prompt } : opts); + setResult(res); + setPicked(res.previews[0]?.generated_voice_id ?? null); + } catch (e) { + setError(errorMessage(e)); + } finally { + setBusy(null); + } + }; + + const save = async () => { + if (!result || !picked) return; + setBusy("save"); + setError(null); + try { + onSaved(await voiceApi.saveDesign({ generated_voice_id: picked, name: name.trim(), description: result.description, use_as: host })); + onClose(); + } catch (e) { + setError(errorMessage(e)); + } finally { + setBusy(null); + } + }; + + return ( + <Modal title="Generate a new voice" onClose={onClose} wide> + <div className="stack"> + <Tabs<GenMode> + tabs={[ + { id: "options", label: "Build from options" }, + { id: "prompt", label: "Describe it" }, + { id: "clone", label: "Clone my voice" }, + ]} + value={mode} + onChange={(m) => { + setMode(m); + setResult(null); + setError(null); + }} + /> + + {mode === "options" && ( + <div className="gen-options"> + {GEN_OPTIONS.map((g) => ( + <div key={g.key} className="gen-row"> + <span className="mono muted small">{g.label}</span> + <div className="gen-chips"> + {g.values.map((v) => ( + <button key={v} type="button" className={`chip${opts[g.key] === v ? " on" : ""}`} aria-pressed={opts[g.key] === v} onClick={() => setOpts({ ...opts, [g.key]: v })}> + {v} + </button> + ))} + </div> + </div> + ))} + </div> + )} + + {mode === "prompt" && ( + <> + <label className="field"> + <span>Describe the voice you want</span> + <textarea className="textarea" rows={3} maxLength={1000} placeholder="A calm, low British voice for late night essays" value={prompt} onChange={(e) => setPrompt(e.target.value)} /> + </label> + <div className="row between"> + <div className="gen-chips"> + {PROMPT_IDEAS.map((p) => ( + <button key={p} type="button" className="chip" onClick={() => setPrompt(p)}> + {p} + </button> + ))} + </div> + <span className="mono muted small">{prompt.trim().length} / 1000</span> + </div> + </> + )} + + {mode === "clone" && + (state.clone.allowed ? ( + <> + <p className="muted">Read a passage from your own writing for one to three minutes. Your voice becomes Host A, so every overview and video sounds like you.</p> + <div className="row"> + <button + className="btn btn-primary" + disabled={!state.tts_configured} + onClick={() => { + onClose(); + onClone(); + }} + > + {state.clone.status === "ready" ? "Record my voice again" : "Start recording"} + </button> + </div> + </> + ) : ( + <div className="vp-upgrade"> + <div> + <strong>Voice cloning is on the Writer and Studio plans</strong> + <p className="muted small">Record a couple of minutes and every audio overview and video is narrated by you.</p> + </div> + <button className="btn btn-primary" onClick={() => openUpgrade("writer")}> + Upgrade to clone my voice + </button> + </div> + ))} + + {mode !== "clone" && ( + <div className="row end"> + <button className="btn btn-primary" onClick={generate} disabled={busy !== null || !state.tts_configured || (mode === "prompt" && prompt.trim().length < 20)}> + {busy === "generate" ? "Generating previews..." : result ? "Generate again" : "Generate previews"} + </button> + </div> + )} + + {result && mode !== "clone" && ( + <div className="stack"> + <p className="eyebrow">Pick the one you like</p> + <ul className="gen-previews"> + {result.previews.map((p, i) => ( + <li key={p.generated_voice_id} className={picked === p.generated_voice_id ? "on" : ""}> + <label className="check"> + <input type="radio" name="gen-preview" checked={picked === p.generated_voice_id} onChange={() => setPicked(p.generated_voice_id)} /> + Option {i + 1} + </label> + <button type="button" className="btn btn-small" onClick={() => play(p.url, p.generated_voice_id)}> + {playing === p.generated_voice_id ? "Stop" : "Play"} + </button> + </li> + ))} + </ul> + <div className="row"> + <input className="input input-sm" placeholder="Name it, e.g. Late night narrator" maxLength={60} value={name} onChange={(e) => setName(e.target.value)} aria-label="Voice name" /> + <button className="btn btn-primary" onClick={save} disabled={busy !== null || !picked || !name.trim()}> + {busy === "save" ? "Saving..." : `Save and use as ${hostLabel}`} + </button> + </div> + </div> + )} + {!state.tts_configured && <p className="muted small">Voice generation is unavailable right now.</p>} + {error && <p className="error-text">{error}</p>} + </div> + </Modal> + ); +} + // Voice library (commercially licensed voices from ElevenLabs) function LibraryModal({ host, onClose, onAdded }: { host: Host; onClose: () => void; onAdded: (s: VoiceState) => void }) { @@ -460,19 +734,31 @@ function CloneModal({ state, onClose, onDone }: { state: VoiceState; onClose: () ); } +// The page: two steps. The writing voice builds itself, then the writer picks the two hosts. +// Cloning lives in the "+ Add a voice" menu; a clone's status shows under the voice cards. + +type Step = "writing" | "hosts"; + +const STEPS: { id: Step; label: string }[] = [ + { id: "writing", label: "Writing voice" }, + { id: "hosts", label: "Speaking voices" }, +]; + export default function VoiceProfile() { const [state, setState] = useState<VoiceState | null>(null); const [draft, setDraft] = useState<VoiceProfileData | null>(null); const [voices, setVoices] = useState<Voice[]>([]); - const [quota, setQuota] = useState<Quota>({}); + const [step, setStep] = useState<Step | null>(null); const [picking, setPicking] = useState(false); const [samples, setSamples] = useState<string[]>([]); const [buildJob, setBuildJob] = useState<Job | null>(null); const [cloneJob, setCloneJob] = useState<Job | null>(null); const [cloning, setCloning] = useState(false); const [browsing, setBrowsing] = useState<Host | null>(null); + const [generating, setGenerating] = useState<Host | null>(null); const [saved, setSaved] = useState(false); const [error, setError] = useState<string | null>(null); + const autoBuilt = useRef(false); const { play, playing } = usePlayer(); const load = () => @@ -480,13 +766,31 @@ export default function VoiceProfile() { setState(s); setDraft(s.profile); setSamples(s.sample_doc_ids); + setStep((cur) => cur ?? (s.profile ? "hosts" : "writing")); + return s; }); const loadVoices = () => voiceApi.voices().then(setVoices, () => setVoices([])); + const startBuild = async (ids: string[]) => { + setPicking(false); + setError(null); + try { + setBuildJob(await voiceApi.build(ids)); + } catch (e) { + setError(errorMessage(e)); + } + }; + useEffect(() => { - load(); + // No writing voice yet: build one from the longest posts straight away instead of asking. + load().then((s) => { + if (!s.profile && !autoBuilt.current) { + autoBuilt.current = true; + void startBuild([]); + } + }); loadVoices(); - voiceApi.quota().then(setQuota, () => setQuota({})); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const build = useJob(buildJob, () => load()); @@ -495,9 +799,11 @@ export default function VoiceProfile() { loadVoices(); }); - if (!state) return <Loading />; + if (!state || !step) return <Loading />; const building = build && (build.status === "queued" || build.status === "running"); const cloneRunning = clone && (clone.status === "queued" || clone.status === "running"); + const done: Record<Step, boolean> = { writing: Boolean(state.profile), hosts: Boolean(state.profile) && step === "hosts" }; + const showClone = state.clone.status !== "none" || Boolean(clone); const saveProfile = async () => { setError(null); @@ -510,111 +816,113 @@ export default function VoiceProfile() { } }; - const used = quota.character_count ?? 0; - const limit = quota.character_limit ?? 0; - return ( - <div className="page-wrap"> - <PageHeader eyebrow="Voice profile" title="How you sound, on the page and out loud"> - <button className="btn btn-primary" disabled={Boolean(building)} onClick={() => setPicking(true)}> - {state.profile ? "Rebuild writing voice" : "Build my writing voice"} - </button> - </PageHeader> - {build && (building || build.status === "failed") && <JobProgress job={build} />} + <div className="page-wrap vp"> + <PageHeader eyebrow="Voice profile" title="How you sound, on the page and out loud" /> + + <ol className="vp-steps mono" aria-label="Voice profile steps"> + {STEPS.map((s, i) => ( + <li key={s.id} className={s.id === step ? "current" : done[s.id] ? "done" : ""}> + <button type="button" onClick={() => setStep(s.id)} aria-current={s.id === step ? "step" : undefined}> + <span className="vp-dot" /> + <span> + {i + 1}. {s.label} + {done[s.id] && s.id !== step ? " ✓" : ""} + </span> + </button> + </li> + ))} + </ol> {error && <p className="error-text">{error}</p>} - <div className="two-col"> - <section className="card stack"> - <h2>Writing voice</h2> - <p className="muted small">Launch Kits, carousels, hooks and scripts are written in this voice. Edit anything that does not sound like you.</p> - {!draft && <p className="muted">No profile yet. Build one from 5 to 10 posts that feel most like you.</p>} + {step === "writing" && ( + <section className="card stack vp-card"> + <h2>How you write</h2> + <p className="muted">Launch Kits, carousels, hooks and scripts are all written in this voice, so they read like you wrote them.</p> + {build && (building || build.status === "failed") && <JobProgress job={build} />} + {!draft && !building && ( + <div className="row"> + <button className="btn btn-primary" onClick={() => startBuild([])}> + Build my writing voice + </button> + <button className="link-btn small" onClick={() => setPicking(true)}> + or pick the posts yourself + </button> + </div> + )} + {draft && ( <> <label className="field"> - <span>Summary</span> - <textarea className="textarea" rows={3} value={draft.summary} onChange={(e) => setDraft({ ...draft, summary: e.target.value })} /> - </label> - <label className="field"> - <span>Sentence length</span> - <select className="input input-sm" value={draft.sentence_length} onChange={(e) => setDraft({ ...draft, sentence_length: e.target.value as VoiceProfileData["sentence_length"] })}> - {["short", "medium", "long", "varied"].map((v) => ( - <option key={v}>{v}</option> - ))} - </select> + <span>In a sentence, you sound like this</span> + <textarea className="textarea" rows={7} value={draft.summary} onChange={(e) => setDraft({ ...draft, summary: e.target.value })} /> </label> - {LIST_FIELDS.map((f) => ( - <label key={f.key} className="field"> - <span>{f.label}</span> - <input - className="input input-sm" - placeholder={f.hint} - value={(draft[f.key] as string[]).join(", ")} - onChange={(e) => setDraft({ ...draft, [f.key]: e.target.value.split(",").map((x) => x.trim()).filter(Boolean) })} - /> + <details className="delivery"> + <summary className="mono muted small">Fine-tune tone, phrases and habits</summary> + <label className="field"> + <span>Sentence length</span> + <select className="input input-sm" value={draft.sentence_length} onChange={(e) => setDraft({ ...draft, sentence_length: e.target.value as VoiceProfileData["sentence_length"] })}> + {["short", "medium", "long", "varied"].map((v) => ( + <option key={v}>{v}</option> + ))} + </select> </label> - ))} - <div className="row between"> - <span className="mono muted small"> - Built {formatDate(state.updated_at, true)} from {state.sample_doc_ids.length} posts - </span> - <button className="btn btn-primary btn-small" onClick={saveProfile}> + {LIST_FIELDS.map((f) => ( + <label key={f.key} className="field"> + <span>{f.label}</span> + <input + className="input input-sm" + placeholder={f.hint} + value={(draft[f.key] as string[]).join(", ")} + onChange={(e) => setDraft({ ...draft, [f.key]: e.target.value.split(",").map((x) => x.trim()).filter(Boolean) })} + /> + </label> + ))} + </details> + <p className="mono muted small"> + Built {formatDate(state.updated_at, true)} from {state.sample_doc_ids.length} posts ·{" "} + <button className="link-btn small" disabled={Boolean(building)} onClick={() => setPicking(true)}> + Rebuild from other posts + </button> + </p> + <div className="row end"> + <button className="btn btn-small" onClick={saveProfile}> {saved ? "Saved" : "Save changes"} </button> + <button className="btn btn-primary" onClick={() => setStep("hosts")}> + Next: pick your hosts + </button> </div> </> )} </section> + )} - <div className="stack"> - <section className="card stack"> - <div className="row between"> - <h2>Speaking voices</h2> - {limit > 0 && ( - <span className="mono muted small" title="ElevenLabs characters this billing period"> - {used.toLocaleString()} / {limit.toLocaleString()} chars - </span> - )} - </div> - {!state.tts_configured && <p className="muted">Set ELEVENLABS_API_KEY in .env to generate audio.</p>} - <p className="muted small"> - Audio overviews use both hosts; videos are narrated by Host A. Model: {state.models[state.model] ?? state.model}. - </p> - {(["host_a", "host_b"] as Host[]).map((h) => ( - <HostCard key={h} host={h} state={state} voices={voices} onSaved={setState} onBrowse={setBrowsing} /> - ))} - </section> - - <section className="card stack"> - <h2>Your own voice</h2> - {!state.clone.allowed && <p className="muted">Voice cloning is on the Writer and Studio plans.</p>} - {quota.can_use_instant_voice_cloning === false && ( - <p className="muted small">Your ElevenLabs plan does not include instant voice cloning. Upgrade it at elevenlabs.io.</p> - )} - {state.clone.allowed && (state.clone.status === "none" || state.clone.status === "failed") && !cloneRunning && ( - <> - <p className="muted"> - Record one to three minutes of your own writing, read aloud. Your voice becomes Host A, so audio overviews and videos sound like you. - </p> - {state.clone.status === "failed" && state.clone.error && <p className="error-text">{state.clone.error}</p>} - <button className="btn btn-primary" onClick={() => setCloning(true)} disabled={!state.tts_configured}> - Clone my voice - </button> - </> - )} - {clone && (cloneRunning || clone.status === "failed") && <JobProgress job={clone} />} - {state.clone.status === "processing" && !clone && <p className="muted">Your voice is being created.</p>} - {state.clone.status === "ready" && ( - <> - <p className="muted">Your voice is live and set as Host A. Adjust its delivery above.</p> - <div className="row"> - {state.clone.preview_url && ( - <button className="btn btn-small" onClick={() => play(state.clone.preview_url!, "clone")}> - {playing === "clone" ? "Stop" : "Hear my voice"} - </button> - )} + {step === "hosts" && ( + <section className="card stack vp-card"> + <h2>Who reads your work aloud</h2> + <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> + {!state.tts_configured && <p className="muted">Voice previews are unavailable right now.</p>} + <VoicePicker state={state} voices={voices} onSaved={setState} onBrowse={setBrowsing} onGenerate={setGenerating} /> + + {showClone && ( + <div className="vp-clone"> + <strong>Your cloned voice</strong> + {clone && (cloneRunning || clone.status === "failed") && <JobProgress job={clone} compact />} + {state.clone.status === "processing" && !clone && <p className="muted small">Your voice is being created. It becomes Host A when ready.</p>} + {state.clone.status === "failed" && state.clone.error && !cloneRunning && <p className="error-text">{state.clone.error}</p>} + <div className="row"> + {state.clone.status === "ready" && state.clone.preview_url && ( + <button className="btn btn-small" onClick={() => play(state.clone.preview_url!, "clone")}> + {playing === "clone" ? "Stop" : "Hear my voice"} + </button> + )} + {!cloneRunning && state.clone.allowed && ( <button className="btn btn-small" onClick={() => setCloning(true)}> - Record again + {state.clone.status === "failed" ? "Try again" : "Record again"} </button> + )} + {state.clone.status !== "none" && ( <ConfirmButton confirmLabel="Revoke and delete my voice?" onConfirm={async () => { @@ -624,35 +932,43 @@ export default function VoiceProfile() { > Revoke consent </ConfirmButton> - </div> - </> - )} - </section> - </div> - </div> + )} + </div> + </div> + )} + + <div className="row"> + <button className="btn btn-small" onClick={() => setStep("writing")}> + Back to writing voice + </button> + </div> + </section> + )} {picking && ( <Modal title="Pick 5 to 10 posts that sound most like you" onClose={() => setPicking(false)} wide> <DocPicker selected={samples} onChange={setSamples} max={10} /> <p className="muted small">Leave empty to let Notestack pick your longest posts.</p> <div className="row end"> - <button - className="btn btn-primary" - onClick={async () => { - setPicking(false); - try { - setBuildJob(await voiceApi.build(samples)); - } catch (e) { - setError(errorMessage(e)); - } - }} - > + <button className="btn btn-primary" onClick={() => startBuild(samples)}> Build profile </button> </div> </Modal> )} {cloning && <CloneModal state={state} onClose={() => setCloning(false)} onDone={setCloneJob} />} + {generating && ( + <GenerateVoiceModal + host={generating} + state={state} + onClose={() => setGenerating(null)} + onSaved={(s) => { + setState(s); + loadVoices(); + }} + onClone={() => setCloning(true)} + /> + )} {browsing && ( <LibraryModal host={browsing} diff --git a/frontend/src/pages/Welcome.tsx b/frontend/src/pages/Welcome.tsx index 58d7c7a2bad294b421271285984e6c17d1f0a960..cd87cda7b142e0a44b00e12f3c9e19ef8152db73 100644 --- a/frontend/src/pages/Welcome.tsx +++ b/frontend/src/pages/Welcome.tsx @@ -1,16 +1,25 @@ import { useEffect, useRef, useState, type FormEvent } from "react"; import { Link, Navigate, useNavigate } from "react-router-dom"; -import { docsApi, notebooksApi, sourcesApi } from "../api/endpoints"; +import { docsApi, jobsApi, notebooksApi, sourcesApi } from "../api/endpoints"; import type { Job, Source } from "../api/types"; import Logo from "../components/Logo"; import SkyCanvas from "../components/SkyCanvas"; -import { errorMessage, JobProgress } from "../components/ui"; +import { errorMessage, JobProgress, Tabs } from "../components/ui"; import { useAuth } from "../hooks/useAuth"; import { useJob } from "../hooks/useJob"; import { PENDING_SOURCE_KEY } from "./Landing"; type Step = "connect" | "indexing" | "goal" | "ready"; type Goal = "ask" | "audio" | "launch"; +type Mode = "feed" | "url" | "file"; + +const MODES: { id: Mode; label: string }[] = [ + { id: "feed", label: "Blog or newsletter" }, + { id: "url", label: "One article" }, + { id: "file", label: "Upload files" }, +]; + +const FILE_ACCEPT = ".md,.markdown,.txt,.html,.htm,.pdf,text/markdown,text/plain,text/html,application/pdf"; const EXAMPLES = ["yourblog.com", "yourname.substack.com", "medium.com/@you"]; @@ -40,11 +49,25 @@ function takePending(): string { } } -/** First run: connect a blog, watch it index, pick what to do first, land somewhere useful. */ +/** Resolves once every job has finished, so a multi-file upload only counts as indexed when all files are in. */ +async function waitForJobs(ids: string[]): Promise<void> { + let pending = ids; + while (pending.length) { + const jobs = await Promise.all(pending.map((id) => jobsApi.get(id).catch(() => null))); + pending = pending.filter((_, i) => jobs[i] && jobs[i]!.status !== "done" && jobs[i]!.status !== "failed"); + if (pending.length) await new Promise((r) => setTimeout(r, 1500)); + } +} + +/** First run: connect a blog, import an article or upload files, watch it index, pick what to do first, land somewhere useful. */ export default function Welcome() { const { user, loading } = useAuth(); const navigate = useNavigate(); const [step, setStep] = useState<Step>("connect"); + const [mode, setMode] = useState<Mode>("feed"); + const [files, setFiles] = useState<File[]>([]); + const [dragging, setDragging] = useState(false); + const earlierJobs = useRef<string[]>([]); const [url, setUrl] = useState(takePending); const [busy, setBusy] = useState(false); const [error, setError] = useState<string | null>(null); @@ -56,27 +79,57 @@ export default function Welcome() { const live = useJob(job, async (j) => { if (j.status !== "done") return; + await waitForJobs(earlierJobs.current); const list = await docsApi.list({ limit: 1 }); setPosts(list.total); setStep((s) => (s === "indexing" ? "goal" : s === "goal" ? "goal" : "ready")); }); - const connect = async (value: string) => { + const connect = async (value: string, as: Mode = "feed") => { if (!value.trim()) return; setBusy(true); setError(null); try { - const res = await sourcesApi.connect(value.trim()); + const res = as === "url" ? await sourcesApi.importUrl(value.trim()) : await sourcesApi.connect(value.trim()); + earlierJobs.current = []; setSource(res.source); setJob(res.job); setStep("indexing"); } catch (e) { - setError(errorMessage(e, "We could not reach that address.")); + setError(errorMessage(e, as === "url" ? "We could not import that page." : "We could not reach that address.")); + } finally { + setBusy(false); + } + }; + + const upload = async () => { + if (!files.length) return; + setBusy(true); + setError(null); + try { + // One import job per file: the last drives the progress bar, the rest are awaited before moving on. + const jobs: Job[] = []; + let res: Awaited<ReturnType<typeof sourcesApi.importFile>> | null = null; + for (const f of files) { + res = await sourcesApi.importFile(f); + jobs.push(res.job); + } + earlierJobs.current = jobs.slice(0, -1).map((j) => j.id); + setSource(res!.source); + setJob(res!.job); + setStep("indexing"); + } catch (e) { + setError(errorMessage(e, "That upload did not go through.")); } finally { setBusy(false); } }; + const addFiles = (list: FileList | null) => { + const picked = Array.from(list ?? []); + if (picked.length) setFiles((cur) => [...cur, ...picked.filter((f) => !cur.some((c) => c.name === f.name && c.size === f.size))]); + }; + // A URL pasted on the landing page starts connecting straight away. useEffect(() => { if (url && !autoStarted.current && user) { @@ -91,6 +144,34 @@ export default function Welcome() { const indexingDone = live?.status === "done"; const indexingFailed = live?.status === "failed"; + // The ingest job lists the whole archive but indexes only the plan's limit; the rest waits behind an upgrade. + const locked = Number(live?.result?.locked ?? 0); + const capped = indexingDone && locked > 0; + const available = Number(live?.result?.available ?? posts); + const found = Number(live?.result?.found ?? posts); + const lockedNotice = capped ? ( + <div className="welcome-cap"> + <div> + <strong> + We found {found} posts. The free plan indexed only your latest {available}. + </strong> + <p className="muted small"> + The other {locked} are sitting in your archive, unread. Until you upgrade, answers, audio overviews and Launch + Kits can only use {available} posts. Upgrade and all {found} are indexed automatically. + </p> + </div> + <button + type="button" + className="btn btn-small" + onClick={() => { + markOnboarded(); + navigate("/app?upgrade=1", { replace: true }); + }} + > + Index all {found} posts + </button> + </div> + ) : null; const first = (user.name ?? user.email).split(/[ @]/)[0]; const finish = async (chosen: Goal | null) => { @@ -109,7 +190,8 @@ export default function Welcome() { const submit = (e: FormEvent) => { e.preventDefault(); - void connect(url); + if (mode === "file") void upload(); + else void connect(url, mode); }; const stepIndex = { connect: 0, indexing: 1, goal: 2, ready: 3 }[step]; @@ -136,40 +218,93 @@ export default function Welcome() { {step === "connect" && ( <section className="card welcome-card"> <p className="eyebrow">Welcome aboard, {first}</p> - <h1>Where do you publish?</h1> - <p className="muted"> - Paste any blog, newsletter or site: Substack, Ghost, WordPress, Medium or anything with a feed. We find the feed, pull in your archive and turn it into a research notebook you can - question, hear and launch from. + <h1>Where does your writing live?</h1> + <p className="muted">Bring it in however suits you. We turn it into a research notebook you can question, hear and launch from.</p> + <Tabs<Mode> + tabs={MODES} + value={mode} + onChange={(m) => { + setMode(m); + setError(null); + }} + /> + <p className="muted small"> + {mode === "feed" && "Substack, Ghost, WordPress, Medium or anything with a feed. We find the feed, pull in your archive and keep it in sync."} + {mode === "url" && "Any single article on the web. Add more later from Sources."} + {mode === "file" && "Markdown, text, HTML or PDF: drafts, exports and posts that never had a feed. Add as many as you like."} </p> <form onSubmit={submit} className="welcome-form"> - <input - className="input" - autoFocus - placeholder="yourblog.com" - value={url} - onChange={(e) => setUrl(e.target.value)} - aria-label="Blog, newsletter or site address" - /> - <button className="btn btn-primary" disabled={busy || !url.trim()}> - {busy ? "Finding your feed..." : "Connect"} + {mode === "file" ? ( + <label + className={`welcome-drop${dragging ? " over" : ""}`} + onDragOver={(e) => { + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setDragging(false); + addFiles(e.dataTransfer.files); + }} + > + <input + type="file" + multiple + accept={FILE_ACCEPT} + onChange={(e) => { + addFiles(e.target.files); + e.target.value = ""; + }} + aria-label="Files to upload" + /> + <strong>Drop files here or click to choose</strong> + <span className="mono muted small">.md · .txt · .html · .pdf</span> + </label> + ) : ( + <input + key={mode} + className="input" + autoFocus + placeholder={mode === "feed" ? "yourblog.com" : "https://example.com/my-essay"} + value={url} + onChange={(e) => setUrl(e.target.value)} + aria-label={mode === "feed" ? "Blog, newsletter or site address" : "Article URL"} + /> + )} + {mode === "file" && files.length > 0 && ( + <ul className="welcome-files mono small"> + {files.map((f) => ( + <li key={`${f.name}-${f.size}`}> + <span>{f.name}</span> + <button type="button" className="link-btn small" onClick={() => setFiles(files.filter((x) => x !== f))} aria-label={`Remove ${f.name}`}> + Remove + </button> + </li> + ))} + </ul> + )} + <button className="btn btn-primary" disabled={busy || (mode === "file" ? !files.length : !url.trim())}> + {mode === "feed" && (busy ? "Finding your feed..." : "Connect")} + {mode === "url" && (busy ? "Importing..." : "Import")} + {mode === "file" && (busy ? "Uploading..." : files.length === 1 ? "Upload 1 file" : `Upload ${files.length || ""} files`.replace(" ", " "))} </button> </form> - <div className="welcome-examples mono muted"> - Try:{" "} - {EXAMPLES.map((ex) => ( - <button key={ex} type="button" className="chip" onClick={() => setUrl(ex)}> - {ex} - </button> - ))} - </div> + {mode === "feed" && ( + <div className="welcome-examples mono muted"> + Try:{" "} + {EXAMPLES.map((ex) => ( + <button key={ex} type="button" className="chip" onClick={() => setUrl(ex)}> + {ex} + </button> + ))} + </div> + )} {error && ( <p className="error-text" role="alert"> {error} </p> )} - <p className="muted small"> - Writing in markdown or no feed? <Link to="/app/sources">Upload markdown, text, HTML or PDF files, or import single pages</Link> instead. - </p> </section> )} @@ -178,10 +313,11 @@ export default function Welcome() { <p className="eyebrow">{source?.title ?? "Your archive"}</p> <h1>{indexingFailed ? "That did not land" : "Bringing your posts into orbit"}</h1> {live && <JobProgress job={live} />} + {lockedNotice} {indexingFailed ? ( <div className="row"> <button className="btn btn-primary" onClick={() => setStep("connect")}> - Try another address + Try again </button> <Link className="btn" to="/app/sources"> Import posts another way @@ -200,8 +336,9 @@ export default function Welcome() { {step === "goal" && ( <section className="card welcome-card"> - <p className="eyebrow">{indexingDone ? `${posts} posts indexed` : "Still indexing in the background"}</p> + <p className="eyebrow">{indexingDone ? (capped ? `${available} of ${found} posts indexed` : `${posts} posts indexed`) : "Still indexing in the background"}</p> <h1>What should we launch first?</h1> + {lockedNotice} <div className="goal-grid"> {GOALS.map((g) => ( <button key={g.id} type="button" className={`goal${goal === g.id ? " on" : ""}`} onClick={() => setGoal(g.id)} aria-pressed={goal === g.id}> @@ -222,8 +359,9 @@ export default function Welcome() { {step === "ready" && ( <section className="card welcome-card"> <p className="eyebrow">Pre-flight check complete</p> - <h1>{indexingDone ? `${posts} posts are in orbit` : "Almost there"}</h1> + <h1>{indexingDone ? `${capped ? available : posts} posts are in orbit` : "Almost there"}</h1> {!indexingDone && live && <JobProgress job={live} />} + {lockedNotice} <p className="muted"> {goal === "ask" && "We will put your whole archive in a notebook so you can start asking right away."} {goal === "audio" && "We will put your archive in a notebook with the audio studio ready."} diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 7f64687fd31ed78642fc4d39db3336f9ee96fe37..0fe8ad328cbbd012a26be8bd478c926e688c95d0 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -1046,8 +1046,8 @@ display: grid; justify-items: center; text-align: center; - gap: 14px; - padding: 80px 16px; + gap: 12px; + padding: 40px 16px; } .empty-state.small { diff --git a/frontend/src/styles/pages.css b/frontend/src/styles/pages.css index 5b387e371496d11445dc7bcfb6369aaceaab3c58..241ede2fdc4356cc1ea100ff54c6812ccc869db4 100644 --- a/frontend/src/styles/pages.css +++ b/frontend/src/styles/pages.css @@ -1464,6 +1464,68 @@ select.input option { padding: 0 28px; } +.welcome-drop { + flex: 1 1 100%; + display: grid; + place-items: center; + gap: 4px; + padding: 28px 16px; + border: 1px dashed var(--line-strong); + border-radius: var(--radius-sm); + text-align: center; + cursor: pointer; + transition: border-color 0.2s, background 0.2s; +} + +.welcome-drop:hover, +.welcome-drop.over, +.welcome-drop:focus-within { + border-color: var(--blue); + background: var(--blue-05); +} + +.welcome-drop input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; +} + +.welcome-files { + flex: 1 1 100%; + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 4px; +} + +.welcome-files li { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 6px 10px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + overflow-wrap: anywhere; +} + +.welcome-cap { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border: 1px solid var(--blue-35); + border-radius: var(--radius-sm); + background: var(--blue-05); +} + +.welcome-cap p { + margin: 4px 0 0; +} + .welcome-examples { display: flex; flex-wrap: wrap; @@ -1867,8 +1929,16 @@ select.input option { color: var(--white); } +.map-controls .icon-btn { + padding: 0; + line-height: 1; +} + +/* Trailing letter-spacing from .mono pushes "1:1" off centre in the round button. */ .map-controls .icon-btn.mono { font-size: 0.65rem; + letter-spacing: 0; + font-variant-numeric: tabular-nums; } .map-hint { @@ -1881,6 +1951,264 @@ select.input option { } /* Voice page */ +.vp .vp-card { + animation: welcome-in 0.35s ease both; +} + +.vp-steps { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; + font-size: 0.75rem; +} + +.vp-steps button { + display: grid; + gap: 8px; + width: 100%; + padding: 0; + border: 0; + background: none; + color: var(--text-3); + font: inherit; + text-align: left; + cursor: pointer; +} + +.vp-steps li.done button { + color: var(--text-2); +} + +.vp-steps li.current button { + color: var(--white); +} + +.vp-dot { + height: 3px; + border-radius: 999px; + background: var(--line-strong); + transition: background 0.3s, box-shadow 0.3s; +} + +.vp-steps li.done .vp-dot { + background: var(--blue-60); +} + +.vp-steps li.current .vp-dot { + background: var(--blue); + box-shadow: var(--glow-sm); +} + +.voice-grid { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +@media (max-width: 720px) { + .voice-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.voice-tile { + position: relative; + border: 1px solid var(--line); + border-radius: var(--radius); + transition: border-color 0.2s, box-shadow 0.2s, background 0.2s, transform 0.2s; +} + +.voice-tile:hover { + border-color: var(--line-strong); + transform: translateY(-2px); +} + +.voice-tile.on { + border-color: var(--blue); + background: var(--blue-05); + box-shadow: var(--glow-sm); +} + +.voice-pick { + width: 100%; + display: grid; + justify-items: center; + gap: 4px; + padding: 26px 12px 18px; + border: 0; + background: none; + color: var(--white); + font: inherit; + text-align: center; + cursor: pointer; +} + +.voice-pick strong { + margin-top: 10px; + font-size: 1rem; +} + +.voice-pick strong, +.voice-pick span.small { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.voice-icon { + display: grid; + place-items: center; + width: 76px; + height: 76px; + border-radius: 999px; + border: 1px solid var(--blue-35); + color: var(--blue); + background: var(--blue-05); + transition: background 0.2s, color 0.2s; +} + +.voice-tile.on .voice-icon { + background: var(--blue); + border-color: var(--blue); + color: var(--white); + box-shadow: var(--glow-sm); +} + +.voice-badge { + position: absolute; + top: 10px; + left: 10px; + padding: 1px 7px; + border-radius: 999px; + background: var(--blue); + color: var(--white); + font-size: 0.625rem; + letter-spacing: 0.04em; + pointer-events: none; +} + +.voice-badge.dim { + background: var(--line-strong); + color: var(--text-2); +} + +.voice-play { + position: absolute; + top: 8px; + right: 8px; + padding: 0; + border-color: var(--line-strong); +} + +.voice-new { + border-style: dashed; + border-color: var(--line-strong); +} + +.voice-new:hover { + border-color: var(--blue-60); +} + +.voice-icon.plus { + border-style: dashed; + background: transparent; +} + +.voice-new:hover .voice-icon.plus { + color: var(--white); + border-color: var(--blue); +} + +.gen-options { + display: grid; + gap: 12px; +} + +.gen-row { + display: grid; + gap: 6px; +} + +.gen-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.gen-chips .chip { + padding: 5px 12px; + background: transparent; + color: var(--text-2); + font: inherit; + font-size: 0.8125rem; + cursor: pointer; + text-transform: capitalize; +} + +.gen-chips .chip:hover { + color: var(--white); +} + +.gen-chips .chip.on { + background: var(--blue); + border-color: var(--blue); + color: var(--white); +} + +.gen-previews { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 8px; +} + +.gen-previews li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); +} + +.gen-previews li.on { + border-color: var(--blue); + background: var(--blue-05); +} + +.vp-clone { + display: grid; + gap: 8px; + padding: 14px 16px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); +} + +.vp-upgrade { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px; + border: 1px solid var(--blue-35); + border-radius: var(--radius-sm); + background: var(--blue-05); +} + +.vp-upgrade p { + margin: 4px 0 0; +} + .host-card { display: grid; gap: 10px; @@ -2378,3 +2706,631 @@ select.input, grid-template-columns: minmax(0, 1fr) minmax(280px, 360px); } } + +/* Simplified layouts (2026-09-28): bigger components, fewer steps */ + +.clamp-1 { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Mission Control */ +.mc-hero { + display: grid; + gap: 18px; + padding: clamp(18px, 3vw, 28px); +} + +.mc-ask { + display: flex; + align-items: center; + gap: 12px; + color: var(--blue); +} + +.mc-ask .input { + flex: 1; + min-width: 0; + height: 54px; + font-size: 1.05rem; +} + +.mc-ask .btn { + height: 54px; + padding: 0 28px; +} + +.mc-actions { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +@media (max-width: 900px) { + .mc-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.mc-action { + display: grid; + justify-items: start; + align-content: start; + gap: 6px; + min-height: 132px; + padding: 18px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: transparent; + color: var(--white); + font: inherit; + text-align: left; + text-decoration: none; + cursor: pointer; + transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s; +} + +.mc-action svg { + color: var(--blue); + margin-bottom: 6px; +} + +.mc-action:hover:not(:disabled) { + border-color: var(--blue-60); + box-shadow: var(--glow-sm); + transform: translateY(-2px); +} + +.mc-action:disabled { + opacity: 0.5; + cursor: default; +} + +.mc-more { + display: grid; + place-items: center; + width: 28px; + height: 28px; + margin-bottom: 6px; + border: 1px dashed var(--blue-60); + border-radius: 999px; + color: var(--blue); + font-size: 1.1rem; + line-height: 1; +} + +.mc-next { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 18px; + border: 1px solid var(--blue-35); + border-radius: var(--radius-sm); + background: var(--blue-05); +} + +.mc-next p { + margin: 0; + flex: 1 1 320px; +} + +.mc-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +@media (max-width: 720px) { + .mc-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.mc-stat { + display: grid; + gap: 2px; + padding: 14px 18px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + color: var(--white); + text-decoration: none; + transition: border-color 0.2s; +} + +.mc-stat:hover { + border-color: var(--line-strong); +} + +.mc-stat strong { + font: 600 1.6rem var(--font-display, inherit); +} + +/* Notebook view: chat takes the room, one side panel with Create / Posts tabs */ +@media (min-width: 1100px) { + .nbv { + grid-template-columns: minmax(0, 1fr) minmax(340px, 420px); + } +} + +.nbv-titlebar { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; +} + +.nbv-titlebar .nbv-title { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nbv-empty { + display: grid; + justify-items: center; + gap: 12px; + padding: 48px 16px; + text-align: center; + color: var(--blue); +} + +.nbv-starters { + display: grid; + gap: 8px; + width: min(100%, 520px); +} + +.nbv-starter { + padding: 12px 16px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + background: transparent; + color: var(--text); + font: inherit; + text-align: left; + cursor: pointer; + transition: border-color 0.2s, background 0.2s; +} + +.nbv-starter:hover { + border-color: var(--blue-60); + background: var(--blue-05); +} + +.nbv-ask .input { + height: 52px; + font-size: 1rem; +} + +.nbv-ask .btn { + height: 52px; + padding: 0 24px; +} + +.nbv-make { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.nbv-make button { + padding: 12px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: transparent; + color: var(--white); + font: 500 0.875rem var(--font-body); + text-align: left; + cursor: pointer; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.nbv-make button:hover:not(:disabled) { + border-color: var(--blue-60); +} + +.nbv-make button:disabled { + opacity: 0.5; + cursor: default; +} + +.nbv-make .nbv-make-main { + grid-column: 1 / -1; + display: grid; + gap: 2px; + padding: 16px; + border-color: var(--blue-35); + background: var(--blue-05); +} + +.nbv-make .nbv-make-main strong { + font-size: 1rem; +} + +/* Notebooks: big clickable cards, options in a menu */ +.nb-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 14px; +} + +.nb-grid .nb-card { + position: relative; + min-height: 170px; +} + +.nb-grid .nb-card-link { + height: 100%; + align-content: start; + color: var(--white); +} + +.nb-card-link svg { + color: var(--blue); +} + +.nb-archive { + border-color: var(--blue-35); + background: var(--blue-05); +} + +.nb-new { + display: grid; + justify-items: start; + align-content: center; + gap: 4px; + border-style: dashed; + border-color: var(--line-strong); + color: var(--white); + font: inherit; + text-align: left; + cursor: pointer; +} + +.nb-new:hover { + border-color: var(--blue-60); +} + +.card-menu { + position: absolute; + top: 10px; + right: 10px; +} + +.card-menu-list { + position: absolute; + top: 34px; + right: 0; + z-index: 5; + display: grid; + min-width: 140px; + padding: 6px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + background: var(--black, #000); + box-shadow: var(--glow-sm); +} + +.card-menu-list button { + padding: 8px 10px; + border: 0; + border-radius: 6px; + background: none; + color: var(--text); + font: inherit; + text-align: left; + cursor: pointer; +} + +.card-menu-list button:hover { + background: var(--blue-10); +} + +.nb-matches { + max-height: 320px; + overflow: auto; +} + +/* Create (Studio): format tiles and one row of choices */ +.format-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +@media (max-width: 900px) { + .format-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.format-tile { + display: grid; + justify-items: center; + align-content: center; + gap: 10px; + min-height: 120px; + padding: 16px 10px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: transparent; + color: var(--white); + font: inherit; + text-align: center; + text-decoration: none; + cursor: pointer; + transition: border-color 0.2s, box-shadow 0.2s, background 0.2s; +} + +.format-tile svg { + color: var(--blue); +} + +.format-tile:hover { + border-color: var(--line-strong); +} + +.format-tile.on { + border-color: var(--blue); + background: var(--blue-05); + box-shadow: var(--glow-sm); +} + +.studio-row { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 12px; +} + +.studio-row .field { + flex: 1 1 200px; +} + +.studio-row .field:first-child { + flex: 2 1 280px; +} + +.studio-go { + height: 44px; + padding: 0 26px; +} + +/* Launch Kit: post cards to start, a thin rail of kits once one is open */ +@media (min-width: 1100px) { + .kit-layout.kit-open { + grid-template-columns: minmax(200px, 260px) minmax(0, 1fr); + } +} + +.kit-rail { + display: grid; + gap: 10px; + align-content: start; +} + +.post-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 12px; +} + +.post-card { + display: grid; + align-content: space-between; + gap: 10px; + min-height: 150px; + padding: 18px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: transparent; + color: var(--white); + font: inherit; + text-align: left; + cursor: pointer; + transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s; +} + +.post-card strong { + font-size: 1.02rem; + line-height: 1.35; +} + +.post-card-go { + color: var(--blue); + font-size: 0.8125rem; + font-weight: 600; +} + +.post-card:hover:not(:disabled) { + border-color: var(--blue-60); + box-shadow: var(--glow-sm); + transform: translateY(-2px); +} + +.post-card:disabled { + opacity: 0.6; + cursor: default; +} + +/* Launchpad: account chips in one line, calendar first, ideas beside it */ +.lp-accounts { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.lp-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + border: 1px solid var(--line-strong); + border-radius: 999px; + background: transparent; + color: var(--white); + font: inherit; + font-size: 0.8125rem; +} + +.lp-chip img { + border-radius: 999px; +} + +.lp-chip.warn { + border-color: #ff8a3d; +} + +.lp-chip.add { + border-style: dashed; + color: var(--text-2); + cursor: pointer; +} + +.lp-chip.add:hover { + border-color: var(--blue); + color: var(--white); +} + +.lp-layout { + display: grid; + gap: 16px; + align-items: start; +} + +@media (min-width: 1200px) { + .lp-layout { + grid-template-columns: minmax(0, 1fr) minmax(300px, 380px); + } +} + +.lp-ideas { + align-content: start; +} + +.lp-ideas .idea { + display: grid; + gap: 6px; + padding-bottom: 12px; + border-bottom: 1px solid var(--line); +} + +.lp-ideas .idea:last-child { + border-bottom: 0; +} + +.lp-empty { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + padding: 12px 14px; + border: 1px dashed var(--line-strong); + border-radius: var(--radius-sm); +} + +.lp-empty p { + margin: 0; +} + +/* Topic map: full width until a topic or the briefing is open */ +@media (min-width: 1100px) { + .map-layout.map-full { + grid-template-columns: minmax(0, 1fr); + } +} + +.map-menu { + position: relative; + top: auto; + right: auto; +} + +details.map-legend summary { + cursor: pointer; + list-style: none; +} + +details.map-legend:not([open]) { + padding: 4px 10px; +} + +details.map-legend[open] summary { + margin-bottom: 4px; +} + +.topic-actions { + display: grid; + gap: 8px; +} + +/* Sources: one smart input, files drop anywhere */ +.add-smart { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +.add-smart .input { + flex: 1 1 320px; + height: 50px; + font-size: 1rem; +} + +.add-smart .btn { + height: 50px; + padding: 0 22px; +} + +.page-wrap.drop-over { + outline: 2px dashed var(--blue); + outline-offset: 8px; + border-radius: var(--radius); +} + +/* Sidebar: seven stops, Settings pinned to the bottom */ +@media (min-width: 900px) { + .sidebar .side-settings { + margin-top: auto; + } + + .sidebar .side-settings ~ .sidebar-user { + margin-top: 0; + } +} + +@media (max-width: 899px) { + .sidebar { + position: relative; + } + + .sidebar .side-settings { + position: absolute; + top: 14px; + right: 16px; + padding: 8px; + } + + .sidebar .side-settings .side-label { + display: none; + } +} + +@media (min-width: 900px) { + .shell.collapsed .side-settings { + display: none; + } +} + +.tabs a.tab { + text-decoration: none; +} diff --git a/frontend/src/styles/tools.css b/frontend/src/styles/tools.css new file mode 100644 index 0000000000000000000000000000000000000000..b5e373f60bdbcafc9043e8452524c2ce813f06d9 --- /dev/null +++ b/frontend/src/styles/tools.css @@ -0,0 +1,115 @@ +/* Free tools (/tools/*). Builds on blog.css (.alt-page, .alt-section, .alt-grid). */ + +.tool-card-title { + font-size: 1.15rem; + margin: 0 0 0.5rem; +} + +.tool-gate { + text-align: center; + padding: 2rem 1.5rem; +} + +.tool-gate h2 { + margin-top: 0; +} + +.tool-gate .alt-ctas { + justify-content: center; +} + +.tool-widget { + display: grid; + gap: 1rem; +} + +/* Flat, opaque writing surface: overrides the pill shaped, see-through .input so the starfield does not show + through and the box edge is obvious. */ +.tool-input { + display: block; + width: 100%; + height: auto; + min-height: 14rem; + padding: 14px 16px; + resize: vertical; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.28); + background: #0d0f14; + box-shadow: none; + color: var(--text); + font: 400 1rem/1.6 var(--font-body); +} + +.tool-input:hover { + border-color: rgba(255, 255, 255, 0.4); +} + +.tool-input:focus { + border-color: var(--blue); + box-shadow: none; +} + +.tool-input::placeholder { + color: var(--text-2); +} + +.tool-output { + display: grid; + gap: 1rem; +} + +.tool-output-head { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + justify-content: space-between; +} + +.tool-actions { + display: flex; + gap: 0.5rem; +} + +.tool-script { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.85rem; +} + +.tool-script li { + display: grid; + grid-template-columns: 5.5rem 1fr; + gap: 0.75rem; +} + +.tool-script p { + margin: 0; +} + +.tool-script .host-B p { + opacity: 0.8; +} + +.host-label { + font-size: 0.75rem; + opacity: 0.7; + padding-top: 0.2rem; +} + +.tool-next { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: center; + justify-content: space-between; +} + +@media (max-width: 560px) { + .tool-script li { + grid-template-columns: 1fr; + gap: 0.2rem; + } +} diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 93e5559b1e9fb9c9a3196c6e60f4eb8246802c69..113e151a17a729dfa264dc345d1eee2903bb0055 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/stream.ts","./src/api/types.ts","./src/components/appshell.tsx","./src/components/artifactcard.tsx","./src/components/docpicker.tsx","./src/components/featureshowcase.tsx","./src/components/logo.tsx","./src/components/markdown.tsx","./src/components/nudges.tsx","./src/components/pricingtiers.tsx","./src/components/publicchrome.tsx","./src/components/reader.tsx","./src/components/schedulemodal.tsx","./src/components/skycanvas.tsx","./src/components/upgrademodal.tsx","./src/components/ui.tsx","./src/components/icons/icons.tsx","./src/content/blogposts.ts","./src/content/missionposts.ts","./src/content/seotypes.ts","./src/hooks/useauth.tsx","./src/hooks/usejob.ts","./src/hooks/useupgrade.tsx","./src/lib/highlight.tsx","./src/lib/sound.ts","./src/pages/archive.tsx","./src/pages/authcallback.tsx","./src/pages/authpage.tsx","./src/pages/blog.tsx","./src/pages/landing.tsx","./src/pages/launchkit.tsx","./src/pages/launchpad.tsx","./src/pages/missioncontrol.tsx","./src/pages/notebooklmalternative.tsx","./src/pages/notebookview.tsx","./src/pages/notebooks.tsx","./src/pages/resurface.tsx","./src/pages/settings.tsx","./src/pages/sources.tsx","./src/pages/studio.tsx","./src/pages/topicmap.tsx","./src/pages/voiceprofile.tsx","./src/pages/welcome.tsx"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/stream.ts","./src/api/types.ts","./src/components/appshell.tsx","./src/components/artifactcard.tsx","./src/components/docpicker.tsx","./src/components/featureshowcase.tsx","./src/components/logo.tsx","./src/components/markdown.tsx","./src/components/nudges.tsx","./src/components/pricingtiers.tsx","./src/components/publicchrome.tsx","./src/components/reader.tsx","./src/components/schedulemodal.tsx","./src/components/skycanvas.tsx","./src/components/upgrademodal.tsx","./src/components/ui.tsx","./src/components/icons/icons.tsx","./src/content/blogposts.ts","./src/content/missionposts.ts","./src/content/notebooklmalternative.ts","./src/content/seotypes.ts","./src/content/tools.ts","./src/hooks/useauth.tsx","./src/hooks/usejob.ts","./src/hooks/useupgrade.tsx","./src/lib/highlight.tsx","./src/lib/podcastscript.ts","./src/lib/sound.ts","./src/pages/archive.tsx","./src/pages/authcallback.tsx","./src/pages/authpage.tsx","./src/pages/blog.tsx","./src/pages/landing.tsx","./src/pages/launchkit.tsx","./src/pages/launchpad.tsx","./src/pages/missioncontrol.tsx","./src/pages/notebooklmalternative.tsx","./src/pages/notebookview.tsx","./src/pages/notebooks.tsx","./src/pages/resurface.tsx","./src/pages/settings.tsx","./src/pages/sources.tsx","./src/pages/studio.tsx","./src/pages/tools.tsx","./src/pages/topicmap.tsx","./src/pages/voiceprofile.tsx","./src/pages/welcome.tsx"],"version":"5.6.3"} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 262ee0fe5c94662eecb657d14bba0c4a26ad9136..1b848f54e7fdb13ca43da2916e43ddec66c5fcfb 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import { prerenderPlugin } from "./seo/prerender"; import { sitemapPlugin } from "./seo/sitemap"; // Public origin for sitemap.xml and robots.txt. Set SITE_URL in the deploy environment. @@ -9,6 +10,8 @@ export default defineConfig({ plugins: [ react(), sitemapPlugin(SITE_URL), + // Static HTML per public route for crawlers that do not run JavaScript. Must run after site-url fills index.html. + prerenderPlugin(SITE_URL), // Link preview tags need absolute URLs: fill %SITE_URL% in index.html. { name: "site-url", transformIndexHtml: (html) => html.replaceAll("%SITE_URL%", SITE_URL.replace(/\/$/, "")) }, ],