Spaces:
Runtime error
Runtime error
Merge branch 'main' of https://github.com/FireBird-Technologies/NotestackAI
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- backend/alembic/versions/0004_archive_notebook.py +24 -0
- backend/app/models/content.py +2 -0
- backend/app/pipeline/ingest.py +81 -15
- backend/app/routers/artifacts.py +10 -0
- backend/app/routers/notebooks.py +32 -3
- backend/app/routers/sources.py +20 -2
- backend/app/routers/voice.py +79 -0
- backend/app/services/billing.py +17 -1
- backend/app/services/plans.py +1 -1
- backend/app/services/tts.py +23 -0
- backend/app/worker.py +18 -3
- backend/tests/test_locked_posts.py +91 -0
- backend/tests/test_simplified_flows.py +47 -0
- frontend/index.html +9 -0
- frontend/public/_redirects +5 -0
- frontend/seo/prerender.ts +269 -0
- frontend/seo/sitemap.ts +5 -1
- frontend/src/App.tsx +3 -0
- frontend/src/api/endpoints.ts +9 -0
- frontend/src/api/types.ts +13 -1
- frontend/src/components/AppShell.tsx +20 -16
- frontend/src/components/PricingTiers.tsx +1 -1
- frontend/src/components/PublicChrome.tsx +10 -0
- frontend/src/components/Reader.tsx +16 -0
- frontend/src/content/blogPosts.ts +1 -0
- frontend/src/content/missionPosts.ts +6 -5
- frontend/src/content/notebooklmAlternative.ts +79 -0
- frontend/src/content/tools.ts +225 -0
- frontend/src/lib/podcastScript.ts +85 -0
- frontend/src/pages/Archive.tsx +12 -14
- frontend/src/pages/AuthCallback.tsx +1 -1
- frontend/src/pages/AuthPage.tsx +3 -5
- frontend/src/pages/Blog.tsx +1 -1
- frontend/src/pages/LaunchKit.tsx +131 -61
- frontend/src/pages/Launchpad.tsx +49 -47
- frontend/src/pages/MissionControl.tsx +224 -127
- frontend/src/pages/NotebookLMAlternative.tsx +5 -78
- frontend/src/pages/NotebookView.tsx +157 -105
- frontend/src/pages/Notebooks.tsx +104 -50
- frontend/src/pages/Resurface.tsx +44 -53
- frontend/src/pages/Settings.tsx +32 -58
- frontend/src/pages/Sources.tsx +149 -50
- frontend/src/pages/Studio.tsx +113 -125
- frontend/src/pages/Tools.tsx +222 -0
- frontend/src/pages/TopicMap.tsx +76 -21
- frontend/src/pages/VoiceProfile.tsx +477 -161
- frontend/src/pages/Welcome.tsx +173 -35
- frontend/src/styles/app.css +2 -2
- frontend/src/styles/pages.css +956 -0
- frontend/src/styles/tools.css +115 -0
backend/alembic/versions/0004_archive_notebook.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""notebooks.is_archive: the workspace's always-present "All posts" notebook
|
| 2 |
+
|
| 3 |
+
Revision ID: 0004_archive_notebook
|
| 4 |
+
Revises: 0003_billing_events
|
| 5 |
+
Create Date: 2026-09-28
|
| 6 |
+
"""
|
| 7 |
+
import sqlalchemy as sa
|
| 8 |
+
from alembic import op
|
| 9 |
+
|
| 10 |
+
revision = "0004_archive_notebook"
|
| 11 |
+
down_revision = "0003_billing_events"
|
| 12 |
+
branch_labels = None
|
| 13 |
+
depends_on = None
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def upgrade() -> None:
|
| 17 |
+
# 0001 builds from the live models, so a fresh database may already have the column.
|
| 18 |
+
columns = {c["name"] for c in sa.inspect(op.get_bind()).get_columns("notebooks")}
|
| 19 |
+
if "is_archive" not in columns:
|
| 20 |
+
op.add_column("notebooks", sa.Column("is_archive", sa.Boolean(), nullable=False, server_default=sa.false()))
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def downgrade() -> None:
|
| 24 |
+
op.drop_column("notebooks", "is_archive")
|
backend/app/models/content.py
CHANGED
|
@@ -65,6 +65,8 @@ class Notebook(IdMixin, TimestampMixin, Base):
|
|
| 65 |
title: Mapped[str] = mapped_column(String(300))
|
| 66 |
description: Mapped[str | None] = mapped_column(Text)
|
| 67 |
summary: Mapped[str | None] = mapped_column(Text)
|
|
|
|
|
|
|
| 68 |
|
| 69 |
|
| 70 |
class NotebookDocument(Base):
|
|
|
|
| 65 |
title: Mapped[str] = mapped_column(String(300))
|
| 66 |
description: Mapped[str | None] = mapped_column(Text)
|
| 67 |
summary: Mapped[str | None] = mapped_column(Text)
|
| 68 |
+
# The workspace's "All posts" notebook: created on demand, kept in step with every indexed post.
|
| 69 |
+
is_archive: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 70 |
|
| 71 |
|
| 72 |
class NotebookDocument(Base):
|
backend/app/pipeline/ingest.py
CHANGED
|
@@ -418,8 +418,15 @@ def _plain_links(url: str) -> list[str]:
|
|
| 418 |
return links
|
| 419 |
|
| 420 |
|
| 421 |
-
def
|
| 422 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
if firecrawl.enabled():
|
| 424 |
links = firecrawl.map_site(url, max(limit * 4, 100))
|
| 425 |
else:
|
|
@@ -437,20 +444,24 @@ def crawl_site(url: str, limit: int, on_found=None, on_post=None) -> list[FeedEn
|
|
| 437 |
except (FeedNotFound, httpx.HTTPError, firecrawl.FirecrawlError):
|
| 438 |
return None
|
| 439 |
|
|
|
|
| 440 |
entries: list[FeedEntry] = []
|
| 441 |
with ThreadPoolExecutor(max_workers=4) as pool:
|
| 442 |
-
for i, entry in enumerate(pool.map(read,
|
| 443 |
if entry:
|
| 444 |
entries.append(entry)
|
| 445 |
if on_post:
|
| 446 |
-
on_post(i + 1, len(
|
|
|
|
| 447 |
if not entries:
|
| 448 |
raise FeedNotFound(f"We found {len(urls)} pages on {urlparse(url).netloc} but none had article text.")
|
| 449 |
return entries
|
| 450 |
|
| 451 |
|
| 452 |
-
def fetch_substack_archive(site: str, known_urls: set[str], limit: int, on_page=None
|
| 453 |
-
|
|
|
|
|
|
|
| 454 |
out: list[FeedEntry] = []
|
| 455 |
offset = 0
|
| 456 |
with httpx.Client(follow_redirects=True, timeout=30, headers=UA) as client:
|
|
@@ -468,6 +479,10 @@ def fetch_substack_archive(site: str, known_urls: set[str], limit: int, on_page=
|
|
| 468 |
continue
|
| 469 |
if len(out) + len(known_urls) >= limit:
|
| 470 |
break
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
post = client.get(f"{site}/api/v1/posts/{item['slug']}")
|
| 472 |
if post.status_code >= 400:
|
| 473 |
continue
|
|
@@ -556,7 +571,7 @@ def store_entries(
|
|
| 556 |
)
|
| 557 |
doc.clean_text = "\n\n".join(f"## {h}\n{t}" if h else t for h, t in sections)
|
| 558 |
doc.metadata_json = {
|
| 559 |
-
**(doc.metadata_json or {}),
|
| 560 |
"headings": [h for h, _ in sections if h],
|
| 561 |
"words": len(doc.clean_text.split()),
|
| 562 |
}
|
|
@@ -575,44 +590,95 @@ def store_entries(
|
|
| 575 |
return indexed, skipped, changed
|
| 576 |
|
| 577 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 578 |
def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict:
|
| 579 |
source.sync_status = "syncing"
|
| 580 |
update_job(db, job, status="running", progress=0.02, message="Contacting your feed")
|
|
|
|
| 581 |
|
| 582 |
if source.feed_url.startswith(SITE_PREFIX):
|
| 583 |
update_job(db, job, message="Mapping your site")
|
| 584 |
entries = crawl_site(
|
| 585 |
-
source.feed_url.removeprefix(SITE_PREFIX),
|
| 586 |
on_found=lambda n: update_job(db, job, progress=0.05, message=f"Found {n} pages, reading them"),
|
| 587 |
on_post=lambda done, total: update_job(db, job, progress=0.05 + 0.05 * done / total,
|
| 588 |
message=f"Read {done} of {total} pages"),
|
|
|
|
| 589 |
)
|
| 590 |
title = None
|
| 591 |
else:
|
| 592 |
-
title, entries = fetch_feed(source.feed_url,
|
| 593 |
source.title = source.title or title
|
| 594 |
-
if source.platform == "substack" and len(entries) <
|
| 595 |
site = f"{urlparse(source.feed_url).scheme}://{urlparse(source.feed_url).netloc}"
|
| 596 |
update_job(db, job, progress=0.05, message="Reading your Substack archive")
|
| 597 |
try:
|
| 598 |
entries += fetch_substack_archive(
|
| 599 |
-
site, {e.url for e in entries},
|
| 600 |
on_page=lambda n: update_job(db, job, message=f"Found {len(entries) + n} posts in the archive"),
|
|
|
|
| 601 |
)
|
| 602 |
except (httpx.HTTPError, ValueError):
|
| 603 |
log.warning("substack archive fetch failed for %s", site, exc_info=True)
|
| 604 |
|
|
|
|
|
|
|
|
|
|
| 605 |
def progress(done: int, total: int, post_title: str) -> None:
|
| 606 |
-
update_job(db, job, progress=0.1 + 0.
|
|
|
|
|
|
|
|
|
|
| 607 |
|
| 608 |
-
|
| 609 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 610 |
|
| 611 |
source.sync_status = "ok"
|
| 612 |
source.sync_error = None
|
| 613 |
source.last_synced_at = datetime.now(UTC)
|
| 614 |
db.commit()
|
| 615 |
-
return {"indexed": indexed, "skipped": skipped, "found": len(entries), "
|
|
|
|
|
|
|
| 616 |
|
| 617 |
|
| 618 |
def entry_from_upload(filename: str, content_type: str, data: bytes) -> FeedEntry:
|
|
|
|
| 418 |
return links
|
| 419 |
|
| 420 |
|
| 421 |
+
def _title_from_url(u: str) -> str:
|
| 422 |
+
slug = urlparse(u).path.rstrip("/").rsplit("/", 1)[-1]
|
| 423 |
+
slug = re.sub(r"\.[a-z0-9]+$", "", slug)
|
| 424 |
+
return slug.replace("-", " ").replace("_", " ").strip().capitalize() or urlparse(u).netloc
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def crawl_site(url: str, limit: int, on_found=None, on_post=None, read_limit: int | None = None) -> list[FeedEntry]:
|
| 428 |
+
"""Find the posts on a site with no feed and read each one. Past read_limit, pages are only listed (title from the
|
| 429 |
+
URL, no html): they show the writer what the rest of their archive holds without paying to read it."""
|
| 430 |
if firecrawl.enabled():
|
| 431 |
links = firecrawl.map_site(url, max(limit * 4, 100))
|
| 432 |
else:
|
|
|
|
| 444 |
except (FeedNotFound, httpx.HTTPError, firecrawl.FirecrawlError):
|
| 445 |
return None
|
| 446 |
|
| 447 |
+
to_read = urls if read_limit is None else urls[:read_limit]
|
| 448 |
entries: list[FeedEntry] = []
|
| 449 |
with ThreadPoolExecutor(max_workers=4) as pool:
|
| 450 |
+
for i, entry in enumerate(pool.map(read, to_read)):
|
| 451 |
if entry:
|
| 452 |
entries.append(entry)
|
| 453 |
if on_post:
|
| 454 |
+
on_post(i + 1, len(to_read))
|
| 455 |
+
entries += [FeedEntry(title=_title_from_url(u), url=u, published_at=None, html="") for u in urls[len(to_read):]]
|
| 456 |
if not entries:
|
| 457 |
raise FeedNotFound(f"We found {len(urls)} pages on {urlparse(url).netloc} but none had article text.")
|
| 458 |
return entries
|
| 459 |
|
| 460 |
|
| 461 |
+
def fetch_substack_archive(site: str, known_urls: set[str], limit: int, on_page=None,
|
| 462 |
+
body_budget: int | None = None) -> list[FeedEntry]:
|
| 463 |
+
"""Posts beyond the ~20 the RSS feed carries, through Substack's public archive API. Only the first body_budget
|
| 464 |
+
posts get their body fetched; the rest are listed from the archive metadata alone (title, date, link)."""
|
| 465 |
out: list[FeedEntry] = []
|
| 466 |
offset = 0
|
| 467 |
with httpx.Client(follow_redirects=True, timeout=30, headers=UA) as client:
|
|
|
|
| 479 |
continue
|
| 480 |
if len(out) + len(known_urls) >= limit:
|
| 481 |
break
|
| 482 |
+
if body_budget is not None and len(out) >= body_budget:
|
| 483 |
+
out.append(FeedEntry(title=item.get("title") or "Untitled", url=url,
|
| 484 |
+
published_at=_parse_date(item.get("post_date")), html=""))
|
| 485 |
+
continue
|
| 486 |
post = client.get(f"{site}/api/v1/posts/{item['slug']}")
|
| 487 |
if post.status_code >= 400:
|
| 488 |
continue
|
|
|
|
| 571 |
)
|
| 572 |
doc.clean_text = "\n\n".join(f"## {h}\n{t}" if h else t for h, t in sections)
|
| 573 |
doc.metadata_json = {
|
| 574 |
+
**{k: v for k, v in (doc.metadata_json or {}).items() if k != "locked"},
|
| 575 |
"headings": [h for h, _ in sections if h],
|
| 576 |
"words": len(doc.clean_text.split()),
|
| 577 |
}
|
|
|
|
| 590 |
return indexed, skipped, changed
|
| 591 |
|
| 592 |
|
| 593 |
+
# A sync discovers up to SCAN_CAP posts (or the plan's own limit, if higher) but only indexes the plan's
|
| 594 |
+
# indexed_posts. The rest are listed, not indexed: title, date and link only, no text and no corpus file. They exist
|
| 595 |
+
# to show the writer how much of their archive is waiting behind an upgrade. Upgrading re-syncs and indexes them.
|
| 596 |
+
SCAN_CAP = 200
|
| 597 |
+
|
| 598 |
+
|
| 599 |
+
def _newest_first(entries: list[FeedEntry]) -> list[FeedEntry]:
|
| 600 |
+
"""Dated posts newest first; undated ones (site crawls) keep their discovery order after them."""
|
| 601 |
+
dated = sorted((e for e in entries if e.published_at), key=lambda e: e.published_at.timestamp(), reverse=True)
|
| 602 |
+
return dated + [e for e in entries if not e.published_at]
|
| 603 |
+
|
| 604 |
+
|
| 605 |
+
def store_locked(db: Session, source: Source, entries: list[FeedEntry]) -> tuple[int, set[str]]:
|
| 606 |
+
"""List posts beyond the plan limit without indexing them: title, date and link only. Returns (count, corpus paths
|
| 607 |
+
to remove): a post that was indexed before (a newer post pushed it out, or the plan went down) loses its file."""
|
| 608 |
+
locked = 0
|
| 609 |
+
remove: set[str] = set()
|
| 610 |
+
for entry in entries:
|
| 611 |
+
doc = db.scalar(select(Document).where(Document.source_id == source.id, Document.url == entry.url))
|
| 612 |
+
if not doc:
|
| 613 |
+
doc = Document(id=uuid.uuid4(), workspace_id=source.workspace_id, source_id=source.id, url=entry.url)
|
| 614 |
+
db.add(doc)
|
| 615 |
+
if doc.path:
|
| 616 |
+
remove.add(doc.path)
|
| 617 |
+
doc.path = None
|
| 618 |
+
doc.title = entry.title
|
| 619 |
+
doc.published_at = entry.published_at
|
| 620 |
+
doc.content_hash = None # indexed from scratch once the plan allows it
|
| 621 |
+
doc.clean_text = ""
|
| 622 |
+
doc.metadata_json = {"locked": True}
|
| 623 |
+
locked += 1
|
| 624 |
+
db.commit()
|
| 625 |
+
return locked, remove
|
| 626 |
+
|
| 627 |
+
|
| 628 |
def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict:
|
| 629 |
source.sync_status = "syncing"
|
| 630 |
update_job(db, job, status="running", progress=0.02, message="Contacting your feed")
|
| 631 |
+
fetch_limit = max(SCAN_CAP, max_posts)
|
| 632 |
|
| 633 |
if source.feed_url.startswith(SITE_PREFIX):
|
| 634 |
update_job(db, job, message="Mapping your site")
|
| 635 |
entries = crawl_site(
|
| 636 |
+
source.feed_url.removeprefix(SITE_PREFIX), fetch_limit,
|
| 637 |
on_found=lambda n: update_job(db, job, progress=0.05, message=f"Found {n} pages, reading them"),
|
| 638 |
on_post=lambda done, total: update_job(db, job, progress=0.05 + 0.05 * done / total,
|
| 639 |
message=f"Read {done} of {total} pages"),
|
| 640 |
+
read_limit=max_posts,
|
| 641 |
)
|
| 642 |
title = None
|
| 643 |
else:
|
| 644 |
+
title, entries = fetch_feed(source.feed_url, fetch_limit)
|
| 645 |
source.title = source.title or title
|
| 646 |
+
if source.platform == "substack" and len(entries) < fetch_limit:
|
| 647 |
site = f"{urlparse(source.feed_url).scheme}://{urlparse(source.feed_url).netloc}"
|
| 648 |
update_job(db, job, progress=0.05, message="Reading your Substack archive")
|
| 649 |
try:
|
| 650 |
entries += fetch_substack_archive(
|
| 651 |
+
site, {e.url for e in entries}, fetch_limit,
|
| 652 |
on_page=lambda n: update_job(db, job, message=f"Found {len(entries) + n} posts in the archive"),
|
| 653 |
+
body_budget=max(0, max_posts - len(entries)),
|
| 654 |
)
|
| 655 |
except (httpx.HTTPError, ValueError):
|
| 656 |
log.warning("substack archive fetch failed for %s", site, exc_info=True)
|
| 657 |
|
| 658 |
+
entries = _newest_first(entries)
|
| 659 |
+
available, beyond = entries[:max_posts], entries[max_posts:]
|
| 660 |
+
|
| 661 |
def progress(done: int, total: int, post_title: str) -> None:
|
| 662 |
+
update_job(db, job, progress=0.1 + 0.7 * done / total, message=f"{post_title[:80]} is in orbit")
|
| 663 |
+
|
| 664 |
+
update_job(db, job, progress=0.1, message=f"Indexing {len(available)} posts")
|
| 665 |
+
indexed, skipped, changed = store_entries(db, source, available, progress)
|
| 666 |
|
| 667 |
+
locked = 0
|
| 668 |
+
if beyond:
|
| 669 |
+
update_job(db, job, progress=0.85,
|
| 670 |
+
message=f"Found {len(beyond)} more posts. Your plan indexes the latest {max_posts}")
|
| 671 |
+
locked, remove = store_locked(db, source, beyond)
|
| 672 |
+
if remove:
|
| 673 |
+
Corpus(source.workspace_id).write_files({INDEX: rebuild_index(db, source.workspace_id)}, remove=remove)
|
| 674 |
|
| 675 |
source.sync_status = "ok"
|
| 676 |
source.sync_error = None
|
| 677 |
source.last_synced_at = datetime.now(UTC)
|
| 678 |
db.commit()
|
| 679 |
+
return {"indexed": indexed, "skipped": skipped, "found": len(entries), "limit": max_posts,
|
| 680 |
+
"capped": locked > 0, "locked": locked, "available": len(available),
|
| 681 |
+
"changed": [str(i) for i in changed]}
|
| 682 |
|
| 683 |
|
| 684 |
def entry_from_upload(filename: str, content_type: str, data: bytes) -> FeedEntry:
|
backend/app/routers/artifacts.py
CHANGED
|
@@ -7,10 +7,12 @@ from sqlalchemy import func, or_, select
|
|
| 7 |
|
| 8 |
from app.auth import Ctx, get_ctx
|
| 9 |
from app.models import Artifact, CalendarItem, Document, Job, Notebook
|
|
|
|
| 10 |
from app.services.artifacts import latest_jobs, serialize_artifact, start_artifact
|
| 11 |
from app.services.jobs import create_job, serialize_job
|
| 12 |
from app.services.renderer import COMPOSITIONS
|
| 13 |
from app.services.storage import storage
|
|
|
|
| 14 |
from app.services.usage import check_limit
|
| 15 |
|
| 16 |
router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])
|
|
@@ -27,6 +29,7 @@ class GenerateIn(BaseModel):
|
|
| 27 |
type: Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit"]
|
| 28 |
notebook_id: uuid.UUID | None = None
|
| 29 |
document_id: uuid.UUID | None = None
|
|
|
|
| 30 |
# audio_overview
|
| 31 |
format: Literal["deep_dive", "brief", "debate"] = "deep_dive"
|
| 32 |
minutes: int = Field(6, ge=1, le=30)
|
|
@@ -56,6 +59,11 @@ def _target_title(ctx: Ctx, notebook_id: uuid.UUID | None, document_id: uuid.UUI
|
|
| 56 |
Document.workspace_id == ctx.workspace.id))
|
| 57 |
if not doc:
|
| 58 |
raise HTTPException(404, "Post not found")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
return doc.title
|
| 60 |
if notebook_id:
|
| 61 |
nb = ctx.db.scalar(select(Notebook).where(Notebook.id == notebook_id,
|
|
@@ -69,6 +77,8 @@ def _target_title(ctx: Ctx, notebook_id: uuid.UUID | None, document_id: uuid.UUI
|
|
| 69 |
@router.post("/generate")
|
| 70 |
def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
|
| 71 |
"""One entry point for every generated artifact."""
|
|
|
|
|
|
|
| 72 |
params: dict = {}
|
| 73 |
content: dict = {}
|
| 74 |
if body.type == "video" and body.style == "audiogram":
|
|
|
|
| 7 |
|
| 8 |
from app.auth import Ctx, get_ctx
|
| 9 |
from app.models import Artifact, CalendarItem, Document, Job, Notebook
|
| 10 |
+
from app.routers.notebooks import ensure_archive_notebook
|
| 11 |
from app.services.artifacts import latest_jobs, serialize_artifact, start_artifact
|
| 12 |
from app.services.jobs import create_job, serialize_job
|
| 13 |
from app.services.renderer import COMPOSITIONS
|
| 14 |
from app.services.storage import storage
|
| 15 |
+
from app.services.plans import effective_plan, plan_limit_error
|
| 16 |
from app.services.usage import check_limit
|
| 17 |
|
| 18 |
router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])
|
|
|
|
| 29 |
type: Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit"]
|
| 30 |
notebook_id: uuid.UUID | None = None
|
| 31 |
document_id: uuid.UUID | None = None
|
| 32 |
+
archive: bool = False # no notebook or post picked: use the "All posts" notebook
|
| 33 |
# audio_overview
|
| 34 |
format: Literal["deep_dive", "brief", "debate"] = "deep_dive"
|
| 35 |
minutes: int = Field(6, ge=1, le=30)
|
|
|
|
| 59 |
Document.workspace_id == ctx.workspace.id))
|
| 60 |
if not doc:
|
| 61 |
raise HTTPException(404, "Post not found")
|
| 62 |
+
if not doc.path:
|
| 63 |
+
plan = effective_plan(ctx.db, ctx.workspace)
|
| 64 |
+
raise plan_limit_error(plan, "indexed_posts",
|
| 65 |
+
f"This post is not indexed. Your plan indexes your latest {plan.indexed_posts} "
|
| 66 |
+
"posts; upgrade to use your whole archive.")
|
| 67 |
return doc.title
|
| 68 |
if notebook_id:
|
| 69 |
nb = ctx.db.scalar(select(Notebook).where(Notebook.id == notebook_id,
|
|
|
|
| 77 |
@router.post("/generate")
|
| 78 |
def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
|
| 79 |
"""One entry point for every generated artifact."""
|
| 80 |
+
if body.archive and not body.notebook_id and not body.document_id:
|
| 81 |
+
body.notebook_id = ensure_archive_notebook(ctx).id
|
| 82 |
params: dict = {}
|
| 83 |
content: dict = {}
|
| 84 |
if body.type == "video" and body.style == "audiogram":
|
backend/app/routers/notebooks.py
CHANGED
|
@@ -52,7 +52,9 @@ def _get(ctx: Ctx, notebook_id: uuid.UUID) -> Notebook:
|
|
| 52 |
|
| 53 |
def _add_docs(ctx: Ctx, nb: Notebook, ids: list[uuid.UUID]) -> int:
|
| 54 |
valid = set(ctx.db.scalars(
|
| 55 |
-
|
|
|
|
|
|
|
| 56 |
).all())
|
| 57 |
existing = set(ctx.db.scalars(select(NotebookDocument.document_id).where(NotebookDocument.notebook_id == nb.id)))
|
| 58 |
for doc_id in valid - existing:
|
|
@@ -70,6 +72,29 @@ def create_notebook(body: NotebookIn, ctx: Ctx = Depends(get_ctx)):
|
|
| 70 |
return {"id": str(nb.id), "title": nb.title, "added": added}
|
| 71 |
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
@router.post("/from-topic/{topic_id}")
|
| 74 |
def notebook_from_topic(topic_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 75 |
topic = ctx.db.scalar(select(Topic).where(Topic.id == topic_id, Topic.workspace_id == ctx.workspace.id))
|
|
@@ -87,7 +112,8 @@ def notebook_from_topic(topic_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
|
| 87 |
@router.get("")
|
| 88 |
def list_notebooks(ctx: Ctx = Depends(get_ctx)):
|
| 89 |
rows = ctx.db.scalars(
|
| 90 |
-
select(Notebook).where(Notebook.workspace_id == ctx.workspace.id)
|
|
|
|
| 91 |
).all()
|
| 92 |
ids = [n.id for n in rows]
|
| 93 |
doc_counts = dict(ctx.db.execute(
|
|
@@ -103,7 +129,7 @@ def list_notebooks(ctx: Ctx = Depends(get_ctx)):
|
|
| 103 |
return [
|
| 104 |
{"id": str(n.id), "title": n.title, "description": n.description, "summary": n.summary,
|
| 105 |
"document_count": doc_counts.get(n.id, 0), "chat_count": chat_counts.get(n.id, 0),
|
| 106 |
-
"artifact_count": artifact_counts.get(n.id, 0),
|
| 107 |
"updated_at": n.updated_at.isoformat() if n.updated_at else None}
|
| 108 |
for n in rows
|
| 109 |
]
|
|
@@ -112,6 +138,8 @@ def list_notebooks(ctx: Ctx = Depends(get_ctx)):
|
|
| 112 |
@router.get("/{notebook_id}")
|
| 113 |
def get_notebook(notebook_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 114 |
nb = _get(ctx, notebook_id)
|
|
|
|
|
|
|
| 115 |
docs = ctx.db.scalars(
|
| 116 |
select(Document)
|
| 117 |
.join(NotebookDocument, NotebookDocument.document_id == Document.id)
|
|
@@ -123,6 +151,7 @@ def get_notebook(notebook_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
|
| 123 |
"title": nb.title,
|
| 124 |
"description": nb.description,
|
| 125 |
"summary": nb.summary,
|
|
|
|
| 126 |
"documents": [serialize_document(d) for d in docs],
|
| 127 |
}
|
| 128 |
|
|
|
|
| 52 |
|
| 53 |
def _add_docs(ctx: Ctx, nb: Notebook, ids: list[uuid.UUID]) -> int:
|
| 54 |
valid = set(ctx.db.scalars(
|
| 55 |
+
# Locked posts (beyond the plan's post limit) have no corpus file and cannot join a notebook.
|
| 56 |
+
select(Document.id).where(Document.id.in_(ids), Document.workspace_id == ctx.workspace.id,
|
| 57 |
+
Document.path.is_not(None))
|
| 58 |
).all())
|
| 59 |
existing = set(ctx.db.scalars(select(NotebookDocument.document_id).where(NotebookDocument.notebook_id == nb.id)))
|
| 60 |
for doc_id in valid - existing:
|
|
|
|
| 72 |
return {"id": str(nb.id), "title": nb.title, "added": added}
|
| 73 |
|
| 74 |
|
| 75 |
+
ARCHIVE_TITLE = "All posts"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def ensure_archive_notebook(ctx: Ctx) -> Notebook:
|
| 79 |
+
"""The workspace's "All posts" notebook, topped up with every indexed post (locked posts are skipped)."""
|
| 80 |
+
nb = ctx.db.scalar(select(Notebook).where(Notebook.workspace_id == ctx.workspace.id, Notebook.is_archive.is_(True)))
|
| 81 |
+
if not nb:
|
| 82 |
+
nb = Notebook(workspace_id=ctx.workspace.id, title=ARCHIVE_TITLE, is_archive=True,
|
| 83 |
+
description="Every indexed post in your archive, kept up to date.")
|
| 84 |
+
ctx.db.add(nb)
|
| 85 |
+
ctx.db.flush()
|
| 86 |
+
ids = list(ctx.db.scalars(select(Document.id).where(Document.workspace_id == ctx.workspace.id)))
|
| 87 |
+
_add_docs(ctx, nb, ids)
|
| 88 |
+
ctx.db.commit()
|
| 89 |
+
return nb
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@router.post("/archive")
|
| 93 |
+
def archive_notebook(ctx: Ctx = Depends(get_ctx)):
|
| 94 |
+
nb = ensure_archive_notebook(ctx)
|
| 95 |
+
return {"id": str(nb.id), "title": nb.title}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
@router.post("/from-topic/{topic_id}")
|
| 99 |
def notebook_from_topic(topic_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 100 |
topic = ctx.db.scalar(select(Topic).where(Topic.id == topic_id, Topic.workspace_id == ctx.workspace.id))
|
|
|
|
| 112 |
@router.get("")
|
| 113 |
def list_notebooks(ctx: Ctx = Depends(get_ctx)):
|
| 114 |
rows = ctx.db.scalars(
|
| 115 |
+
select(Notebook).where(Notebook.workspace_id == ctx.workspace.id)
|
| 116 |
+
.order_by(Notebook.is_archive.desc(), Notebook.updated_at.desc())
|
| 117 |
).all()
|
| 118 |
ids = [n.id for n in rows]
|
| 119 |
doc_counts = dict(ctx.db.execute(
|
|
|
|
| 129 |
return [
|
| 130 |
{"id": str(n.id), "title": n.title, "description": n.description, "summary": n.summary,
|
| 131 |
"document_count": doc_counts.get(n.id, 0), "chat_count": chat_counts.get(n.id, 0),
|
| 132 |
+
"artifact_count": artifact_counts.get(n.id, 0), "is_archive": n.is_archive,
|
| 133 |
"updated_at": n.updated_at.isoformat() if n.updated_at else None}
|
| 134 |
for n in rows
|
| 135 |
]
|
|
|
|
| 138 |
@router.get("/{notebook_id}")
|
| 139 |
def get_notebook(notebook_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 140 |
nb = _get(ctx, notebook_id)
|
| 141 |
+
if nb.is_archive: # posts synced since it was made join on open
|
| 142 |
+
ensure_archive_notebook(ctx)
|
| 143 |
docs = ctx.db.scalars(
|
| 144 |
select(Document)
|
| 145 |
.join(NotebookDocument, NotebookDocument.document_id == Document.id)
|
|
|
|
| 151 |
"title": nb.title,
|
| 152 |
"description": nb.description,
|
| 153 |
"summary": nb.summary,
|
| 154 |
+
"is_archive": nb.is_archive,
|
| 155 |
"documents": [serialize_document(d) for d in docs],
|
| 156 |
}
|
| 157 |
|
backend/app/routers/sources.py
CHANGED
|
@@ -24,7 +24,7 @@ class UploadImportIn(BaseModel):
|
|
| 24 |
upload_id: uuid.UUID
|
| 25 |
|
| 26 |
|
| 27 |
-
def serialize_source(source: Source, doc_count: int = 0) -> dict:
|
| 28 |
return {
|
| 29 |
"id": str(source.id),
|
| 30 |
"feed_url": source.feed_url,
|
|
@@ -35,6 +35,8 @@ def serialize_source(source: Source, doc_count: int = 0) -> dict:
|
|
| 35 |
"sync_error": source.sync_error,
|
| 36 |
"last_synced_at": source.last_synced_at.isoformat() if source.last_synced_at else None,
|
| 37 |
"document_count": doc_count,
|
|
|
|
|
|
|
| 38 |
"is_imports": source.feed_url == IMPORTS_FEED,
|
| 39 |
}
|
| 40 |
|
|
@@ -91,7 +93,12 @@ def list_sources(ctx: Ctx = Depends(get_ctx)):
|
|
| 91 |
.group_by(Source.id)
|
| 92 |
.order_by(Source.created_at)
|
| 93 |
).all()
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
|
| 97 |
@router.post("/{source_id}/sync")
|
|
@@ -151,6 +158,11 @@ def import_upload(body: UploadImportIn, ctx: Ctx = Depends(get_ctx)):
|
|
| 151 |
documents_router = APIRouter(prefix="/api/documents", tags=["documents"])
|
| 152 |
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
def serialize_document(d: Document, source: Source | None = None) -> dict:
|
| 155 |
meta = d.metadata_json or {}
|
| 156 |
return {
|
|
@@ -163,6 +175,7 @@ def serialize_document(d: Document, source: Source | None = None) -> dict:
|
|
| 163 |
"published_at": d.published_at.isoformat() if d.published_at else None,
|
| 164 |
"words": meta.get("words") or len((d.clean_text or "").split()),
|
| 165 |
"evergreen_score": d.evergreen_score,
|
|
|
|
| 166 |
}
|
| 167 |
|
| 168 |
|
|
@@ -198,6 +211,11 @@ def get_document(document_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
|
| 198 |
if not doc:
|
| 199 |
raise HTTPException(404, "Post not found")
|
| 200 |
source = ctx.db.get(Source, doc.source_id) if doc.source_id else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
lines: list[str] = []
|
| 202 |
if doc.path:
|
| 203 |
corpus = Corpus(ctx.workspace.id)
|
|
|
|
| 24 |
upload_id: uuid.UUID
|
| 25 |
|
| 26 |
|
| 27 |
+
def serialize_source(source: Source, doc_count: int = 0, locked_count: int = 0) -> dict:
|
| 28 |
return {
|
| 29 |
"id": str(source.id),
|
| 30 |
"feed_url": source.feed_url,
|
|
|
|
| 35 |
"sync_error": source.sync_error,
|
| 36 |
"last_synced_at": source.last_synced_at.isoformat() if source.last_synced_at else None,
|
| 37 |
"document_count": doc_count,
|
| 38 |
+
# Found but beyond the plan's post limit: listed, not indexed until the workspace upgrades.
|
| 39 |
+
"locked_count": locked_count,
|
| 40 |
"is_imports": source.feed_url == IMPORTS_FEED,
|
| 41 |
}
|
| 42 |
|
|
|
|
| 93 |
.group_by(Source.id)
|
| 94 |
.order_by(Source.created_at)
|
| 95 |
).all()
|
| 96 |
+
locked = dict(ctx.db.execute(
|
| 97 |
+
select(Document.source_id, func.count(Document.id))
|
| 98 |
+
.where(Document.workspace_id == ctx.workspace.id, Document.path.is_(None))
|
| 99 |
+
.group_by(Document.source_id)
|
| 100 |
+
).all())
|
| 101 |
+
return [serialize_source(s, n, locked.get(s.id, 0)) for s, n in rows]
|
| 102 |
|
| 103 |
|
| 104 |
@router.post("/{source_id}/sync")
|
|
|
|
| 158 |
documents_router = APIRouter(prefix="/api/documents", tags=["documents"])
|
| 159 |
|
| 160 |
|
| 161 |
+
def is_locked(d: Document) -> bool:
|
| 162 |
+
"""Past the plan's post limit (see ingest.store_locked): listed with title and link only, not indexed."""
|
| 163 |
+
return not d.path and bool((d.metadata_json or {}).get("locked"))
|
| 164 |
+
|
| 165 |
+
|
| 166 |
def serialize_document(d: Document, source: Source | None = None) -> dict:
|
| 167 |
meta = d.metadata_json or {}
|
| 168 |
return {
|
|
|
|
| 175 |
"published_at": d.published_at.isoformat() if d.published_at else None,
|
| 176 |
"words": meta.get("words") or len((d.clean_text or "").split()),
|
| 177 |
"evergreen_score": d.evergreen_score,
|
| 178 |
+
"locked": is_locked(d),
|
| 179 |
}
|
| 180 |
|
| 181 |
|
|
|
|
| 211 |
if not doc:
|
| 212 |
raise HTTPException(404, "Post not found")
|
| 213 |
source = ctx.db.get(Source, doc.source_id) if doc.source_id else None
|
| 214 |
+
if is_locked(doc):
|
| 215 |
+
# Listed, not indexed: there is no text to show until the workspace upgrades.
|
| 216 |
+
plan = effective_plan(ctx.db, ctx.workspace)
|
| 217 |
+
return {**serialize_document(doc, source), "lines": [f"# {doc.title}"],
|
| 218 |
+
"preview": True, "limit": plan.indexed_posts}
|
| 219 |
lines: list[str] = []
|
| 220 |
if doc.path:
|
| 221 |
corpus = Corpus(ctx.workspace.id)
|
backend/app/routers/voice.py
CHANGED
|
@@ -57,6 +57,23 @@ class PreviewIn(BaseModel):
|
|
| 57 |
delivery: DeliveryIn | None = None
|
| 58 |
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
class LibraryAddIn(BaseModel):
|
| 61 |
public_owner_id: str = Field(min_length=5, max_length=100)
|
| 62 |
voice_id: str = Field(min_length=5, max_length=60)
|
|
@@ -116,6 +133,8 @@ def _serialize(ctx: Ctx) -> dict:
|
|
| 116 |
"error": job.error if job and job.status == "failed" else None,
|
| 117 |
"preview_url": storage.presign_get(preview_key) if preview_key else None,
|
| 118 |
},
|
|
|
|
|
|
|
| 119 |
"tts_configured": bool(settings.elevenlabs_api_key),
|
| 120 |
"model": tts.model_id(),
|
| 121 |
"models": tts.MODELS,
|
|
@@ -146,6 +165,11 @@ def update_voice(body: ProfileIn, ctx: Ctx = Depends(get_ctx)):
|
|
| 146 |
|
| 147 |
@router.post("/build")
|
| 148 |
def build(body: BuildIn, ctx: Ctx = Depends(get_ctx)):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
job = create_job(ctx.db, ctx.workspace.id, "voice_profile",
|
| 150 |
{"document_ids": [str(i) for i in body.document_ids]}, max_attempts=2)
|
| 151 |
return serialize_job(job)
|
|
@@ -211,6 +235,61 @@ async def preview(body: PreviewIn, ctx: Ctx = Depends(get_ctx)):
|
|
| 211 |
return {"url": storage.presign_get(key), "seconds": round(tts.mp3_seconds(clip), 1)}
|
| 212 |
|
| 213 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
@router.get("/reading-script")
|
| 215 |
def reading_script(ctx: Ctx = Depends(get_ctx)):
|
| 216 |
"""About two minutes of the writer's own prose to read aloud for the clone (their words, their rhythm)."""
|
|
|
|
| 57 |
delivery: DeliveryIn | None = None
|
| 58 |
|
| 59 |
|
| 60 |
+
class DesignIn(BaseModel):
|
| 61 |
+
"""Either picked options (Build from options) or a free description (Describe in your own words)."""
|
| 62 |
+
prompt: str | None = Field(None, max_length=1000)
|
| 63 |
+
gender: str = Field("", max_length=20)
|
| 64 |
+
age: str = Field("", max_length=20)
|
| 65 |
+
persona: str = Field("", max_length=30)
|
| 66 |
+
pace: str = Field("", max_length=20)
|
| 67 |
+
accent: str = Field("", max_length=40)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class DesignSaveIn(BaseModel):
|
| 71 |
+
generated_voice_id: str = Field(min_length=5, max_length=100)
|
| 72 |
+
name: str = Field(min_length=1, max_length=60)
|
| 73 |
+
description: str = Field("", max_length=1000)
|
| 74 |
+
use_as: str | None = None # "host_a" | "host_b"
|
| 75 |
+
|
| 76 |
+
|
| 77 |
class LibraryAddIn(BaseModel):
|
| 78 |
public_owner_id: str = Field(min_length=5, max_length=100)
|
| 79 |
voice_id: str = Field(min_length=5, max_length=60)
|
|
|
|
| 133 |
"error": job.error if job and job.status == "failed" else None,
|
| 134 |
"preview_url": storage.presign_get(preview_key) if preview_key else None,
|
| 135 |
},
|
| 136 |
+
# Voices this workspace generated with Voice Design (the ElevenLabs account is shared across workspaces).
|
| 137 |
+
"custom_voices": voices.get("custom") or [],
|
| 138 |
"tts_configured": bool(settings.elevenlabs_api_key),
|
| 139 |
"model": tts.model_id(),
|
| 140 |
"models": tts.MODELS,
|
|
|
|
| 165 |
|
| 166 |
@router.post("/build")
|
| 167 |
def build(body: BuildIn, ctx: Ctx = Depends(get_ctx)):
|
| 168 |
+
if not body.document_ids: # the automatic build after a sync may already be running; follow that one
|
| 169 |
+
pending = ctx.db.scalar(select(Job).where(Job.workspace_id == ctx.workspace.id, Job.kind == "voice_profile",
|
| 170 |
+
Job.status.in_(("queued", "running"))).limit(1))
|
| 171 |
+
if pending:
|
| 172 |
+
return serialize_job(pending)
|
| 173 |
job = create_job(ctx.db, ctx.workspace.id, "voice_profile",
|
| 174 |
{"document_ids": [str(i) for i in body.document_ids]}, max_attempts=2)
|
| 175 |
return serialize_job(job)
|
|
|
|
| 235 |
return {"url": storage.presign_get(key), "seconds": round(tts.mp3_seconds(clip), 1)}
|
| 236 |
|
| 237 |
|
| 238 |
+
def _design_description(body: DesignIn) -> str:
|
| 239 |
+
if body.prompt and body.prompt.strip():
|
| 240 |
+
text = body.prompt.strip()
|
| 241 |
+
if len(text) < 20:
|
| 242 |
+
raise HTTPException(400, "Describe the voice in at least 20 characters.")
|
| 243 |
+
return text
|
| 244 |
+
parts = [f"A {body.age} {body.gender} voice".replace(" ", " ") if body.gender or body.age else "A voice"]
|
| 245 |
+
if body.accent:
|
| 246 |
+
parts.append(f"with a {body.accent} accent")
|
| 247 |
+
sentence = " ".join(parts) + "."
|
| 248 |
+
if body.persona:
|
| 249 |
+
sentence += f" Sounds {body.persona}."
|
| 250 |
+
if body.pace:
|
| 251 |
+
sentence += f" Speaks at a {body.pace} pace."
|
| 252 |
+
return sentence + " Clear studio quality, suited to podcasts and narration."
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
@router.post("/design")
|
| 256 |
+
async def design(body: DesignIn, ctx: Ctx = Depends(get_ctx)):
|
| 257 |
+
"""Three previews of a new voice. Nothing is saved until the writer picks one."""
|
| 258 |
+
if not settings.elevenlabs_api_key:
|
| 259 |
+
raise HTTPException(503, "Set ELEVENLABS_API_KEY in .env to generate voices.")
|
| 260 |
+
description = _design_description(body)
|
| 261 |
+
try:
|
| 262 |
+
previews = await run_in_threadpool(tts.design_voice, description)
|
| 263 |
+
except tts.TTSError as exc:
|
| 264 |
+
raise HTTPException(502, str(exc)) from exc
|
| 265 |
+
out = []
|
| 266 |
+
for p in previews:
|
| 267 |
+
key = f"ws/{ctx.workspace.id}/voice-previews/{uuid.uuid4().hex}.mp3"
|
| 268 |
+
storage.put_bytes(key, p["audio"], "audio/mpeg")
|
| 269 |
+
out.append({"generated_voice_id": p["generated_voice_id"], "url": storage.presign_get(key), "seconds": p["seconds"]})
|
| 270 |
+
record_usage(ctx.db, workspace_id=ctx.workspace.id, kind="voice_design", provider="elevenlabs",
|
| 271 |
+
quantity=len(out), unit="previews")
|
| 272 |
+
return {"description": description, "previews": out}
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
@router.post("/design/save")
|
| 276 |
+
async def save_design(body: DesignSaveIn, ctx: Ctx = Depends(get_ctx)):
|
| 277 |
+
try:
|
| 278 |
+
voice_id = await run_in_threadpool(tts.create_designed_voice, body.name, body.description,
|
| 279 |
+
body.generated_voice_id)
|
| 280 |
+
except tts.TTSError as exc:
|
| 281 |
+
raise HTTPException(502, str(exc)) from exc
|
| 282 |
+
vp = _ensure_profile(ctx)
|
| 283 |
+
voices = dict(vp.host_voices or {})
|
| 284 |
+
voices["custom"] = [*(voices.get("custom") or []),
|
| 285 |
+
{"voice_id": voice_id, "name": body.name, "description": body.description[:300]}]
|
| 286 |
+
if body.use_as in ("host_a", "host_b"):
|
| 287 |
+
voices[body.use_as] = voice_id
|
| 288 |
+
vp.host_voices = voices
|
| 289 |
+
ctx.db.commit()
|
| 290 |
+
return {"voice_id": voice_id, **_serialize(ctx)}
|
| 291 |
+
|
| 292 |
+
|
| 293 |
@router.get("/reading-script")
|
| 294 |
def reading_script(ctx: Ctx = Depends(get_ctx)):
|
| 295 |
"""About two minutes of the writer's own prose to read aloud for the clone (their words, their rhythm)."""
|
backend/app/services/billing.py
CHANGED
|
@@ -112,7 +112,7 @@ def build_nudges(db: Session, workspace: Workspace, plan: Plan, meters: list[dic
|
|
| 112 |
"Open Launchpad", "/app/launchpad", priority=35))
|
| 113 |
out.append(_nudge(f"act-resurface-{month}", "action", "Old posts, new orbit",
|
| 114 |
"Resurfacing finds evergreen posts worth sharing again this month.", "Find evergreen posts",
|
| 115 |
-
"/app/
|
| 116 |
return sorted(out, key=lambda n: -n["priority"])
|
| 117 |
|
| 118 |
|
|
@@ -146,6 +146,8 @@ def apply_subscription(db: Session, workspace_id, *, plan: str, status: str, pro
|
|
| 146 |
if plan not in PLANS:
|
| 147 |
raise ValueError(f"Unknown plan {plan}")
|
| 148 |
sub = db.query(Subscription).filter_by(workspace_id=workspace_id).one_or_none()
|
|
|
|
|
|
|
| 149 |
if not sub:
|
| 150 |
sub = Subscription(workspace_id=workspace_id)
|
| 151 |
db.add(sub)
|
|
@@ -154,9 +156,23 @@ def apply_subscription(db: Session, workspace_id, *, plan: str, status: str, pro
|
|
| 154 |
sub.provider_subscription_id = subscription_id or sub.provider_subscription_id
|
| 155 |
sub.current_period_end = current_period_end
|
| 156 |
db.commit()
|
|
|
|
|
|
|
| 157 |
return sub
|
| 158 |
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
def return_urls() -> tuple[str, str]:
|
| 161 |
"""(success_url, cancel_url) for the hosted checkout page."""
|
| 162 |
base = settings.frontend_url.rstrip("/")
|
|
|
|
| 112 |
"Open Launchpad", "/app/launchpad", priority=35))
|
| 113 |
out.append(_nudge(f"act-resurface-{month}", "action", "Old posts, new orbit",
|
| 114 |
"Resurfacing finds evergreen posts worth sharing again this month.", "Find evergreen posts",
|
| 115 |
+
"/app/launchpad#ideas", priority=20))
|
| 116 |
return sorted(out, key=lambda n: -n["priority"])
|
| 117 |
|
| 118 |
|
|
|
|
| 146 |
if plan not in PLANS:
|
| 147 |
raise ValueError(f"Unknown plan {plan}")
|
| 148 |
sub = db.query(Subscription).filter_by(workspace_id=workspace_id).one_or_none()
|
| 149 |
+
workspace = db.get(Workspace, workspace_id)
|
| 150 |
+
before = effective_plan(db, workspace).indexed_posts if workspace else None
|
| 151 |
if not sub:
|
| 152 |
sub = Subscription(workspace_id=workspace_id)
|
| 153 |
db.add(sub)
|
|
|
|
| 156 |
sub.provider_subscription_id = subscription_id or sub.provider_subscription_id
|
| 157 |
sub.current_period_end = current_period_end
|
| 158 |
db.commit()
|
| 159 |
+
if workspace and effective_plan(db, workspace).indexed_posts != before:
|
| 160 |
+
resync_sources(db, workspace_id)
|
| 161 |
return sub
|
| 162 |
|
| 163 |
|
| 164 |
+
def resync_sources(db: Session, workspace_id) -> None:
|
| 165 |
+
"""The post limit changed: re-sync every feed so locked posts unlock on upgrade (or re-lock on downgrade)."""
|
| 166 |
+
from app.pipeline.ingest import IMPORTS_FEED
|
| 167 |
+
from app.services.jobs import create_job
|
| 168 |
+
|
| 169 |
+
for source in db.scalars(select(Source).where(Source.workspace_id == workspace_id,
|
| 170 |
+
Source.feed_url != IMPORTS_FEED)):
|
| 171 |
+
source.sync_status = "pending"
|
| 172 |
+
create_job(db, workspace_id, "ingest", {"source_id": str(source.id)})
|
| 173 |
+
db.commit()
|
| 174 |
+
|
| 175 |
+
|
| 176 |
def return_urls() -> tuple[str, str]:
|
| 177 |
"""(success_url, cancel_url) for the hosted checkout page."""
|
| 178 |
base = settings.frontend_url.rstrip("/")
|
backend/app/services/plans.py
CHANGED
|
@@ -41,7 +41,7 @@ PLANS: dict[str, Plan] = {
|
|
| 41 |
voice_cloning=False,
|
| 42 |
brand_kit=False,
|
| 43 |
features=(
|
| 44 |
-
"1 source, your
|
| 45 |
"Grounded research chat with citations",
|
| 46 |
"3 min of audio overviews a month",
|
| 47 |
"1 min of video a month",
|
|
|
|
| 41 |
voice_cloning=False,
|
| 42 |
brand_kit=False,
|
| 43 |
features=(
|
| 44 |
+
"1 source, your latest 5 posts indexed",
|
| 45 |
"Grounded research chat with citations",
|
| 46 |
"3 min of audio overviews a month",
|
| 47 |
"1 min of video a month",
|
backend/app/services/tts.py
CHANGED
|
@@ -289,6 +289,29 @@ def clone_voice(name: str, samples: list[tuple[str, bytes, str]], *, remove_back
|
|
| 289 |
return resp.json()["voice_id"]
|
| 290 |
|
| 291 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
def delete_voice(voice_id: str) -> None:
|
| 293 |
_request("DELETE", f"/voices/{voice_id}", attempts=2, timeout=30)
|
| 294 |
|
|
|
|
| 289 |
return resp.json()["voice_id"]
|
| 290 |
|
| 291 |
|
| 292 |
+
def design_voice(description: str) -> list[dict]:
|
| 293 |
+
"""Voice Design: three short previews of a new voice from a text description.
|
| 294 |
+
Each preview's generated_voice_id is temporary until saved with create_designed_voice."""
|
| 295 |
+
data = _request("POST", "/text-to-voice/design", attempts=2, timeout=90,
|
| 296 |
+
json={"voice_description": description[:1000], "auto_generate_text": True}).json()
|
| 297 |
+
return [
|
| 298 |
+
{"generated_voice_id": p["generated_voice_id"], "audio": base64.b64decode(p["audio_base_64"]),
|
| 299 |
+
"seconds": p.get("duration_secs")}
|
| 300 |
+
for p in data.get("previews", []) if p.get("generated_voice_id") and p.get("audio_base_64")
|
| 301 |
+
]
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def create_designed_voice(name: str, description: str, generated_voice_id: str) -> str:
|
| 305 |
+
"""Turns a Voice Design preview into a permanent voice on the account."""
|
| 306 |
+
resp = _request("POST", "/text-to-voice/create-voice-from-preview", attempts=2, timeout=30, json={
|
| 307 |
+
"voice_name": name[:100],
|
| 308 |
+
"voice_description": (description or name)[:1000],
|
| 309 |
+
"generated_voice_id": generated_voice_id,
|
| 310 |
+
"labels": {"source": "notestack"},
|
| 311 |
+
})
|
| 312 |
+
return resp.json()["voice_id"]
|
| 313 |
+
|
| 314 |
+
|
| 315 |
def delete_voice(voice_id: str) -> None:
|
| 316 |
_request("DELETE", f"/voices/{voice_id}", attempts=2, timeout=30)
|
| 317 |
|
backend/app/worker.py
CHANGED
|
@@ -67,10 +67,22 @@ HANDED_OFF = object() # the job continues elsewhere (renderer) and reports back
|
|
| 67 |
# Handlers: (db, job) -> Done | HANDED_OFF. Raise to trigger a retry.
|
| 68 |
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
def _after_import(db: Session, job: Job, workspace_id: uuid.UUID, changed: list[str]) -> None:
|
| 71 |
-
"""New or changed posts
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
|
| 76 |
def handle_ingest(db: Session, job: Job):
|
|
@@ -91,6 +103,9 @@ def handle_ingest(db: Session, job: Job):
|
|
| 91 |
raise PermanentJobError(str(exc)) from exc
|
| 92 |
raise
|
| 93 |
_after_import(db, job, source.workspace_id, result.get("changed", []))
|
|
|
|
|
|
|
|
|
|
| 94 |
return Done(result, f"All posts in orbit: {result['indexed']} new or updated")
|
| 95 |
|
| 96 |
|
|
|
|
| 67 |
# Handlers: (db, job) -> Done | HANDED_OFF. Raise to trigger a retry.
|
| 68 |
|
| 69 |
|
| 70 |
+
def _pending(db: Session, workspace_id: uuid.UUID, kind: str) -> bool:
|
| 71 |
+
return db.scalar(select(Job.id).where(Job.workspace_id == workspace_id, Job.kind == kind,
|
| 72 |
+
Job.status.in_(("queued", "running"))).limit(1)) is not None
|
| 73 |
+
|
| 74 |
+
|
| 75 |
def _after_import(db: Session, job: Job, workspace_id: uuid.UUID, changed: list[str]) -> None:
|
| 76 |
+
"""New or changed posts are mapped into the topic constellation and scored for resurfacing, and the
|
| 77 |
+
first import builds the writing voice, so none of these need a button press."""
|
| 78 |
+
if not changed or not settings.llm_api_key:
|
| 79 |
+
return
|
| 80 |
+
create_job(db, workspace_id, "topics", {"document_ids": changed}, max_attempts=2)
|
| 81 |
+
if not _pending(db, workspace_id, "resurface_scan"):
|
| 82 |
+
create_job(db, workspace_id, "resurface_scan", {}, max_attempts=2)
|
| 83 |
+
vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == workspace_id))
|
| 84 |
+
if not (vp and vp.profile_json) and not _pending(db, workspace_id, "voice_profile"):
|
| 85 |
+
create_job(db, workspace_id, "voice_profile", {"document_ids": []}, max_attempts=2)
|
| 86 |
|
| 87 |
|
| 88 |
def handle_ingest(db: Session, job: Job):
|
|
|
|
| 103 |
raise PermanentJobError(str(exc)) from exc
|
| 104 |
raise
|
| 105 |
_after_import(db, job, source.workspace_id, result.get("changed", []))
|
| 106 |
+
if result.get("locked"):
|
| 107 |
+
return Done(result, f"Found {result['found']} posts. Your latest {result['available']} are indexed; "
|
| 108 |
+
f"upgrade to index the other {result['locked']}")
|
| 109 |
return Done(result, f"All posts in orbit: {result['indexed']} new or updated")
|
| 110 |
|
| 111 |
|
backend/tests/test_locked_posts.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Posts past the plan's indexed_posts are listed, not indexed: no text, no corpus file, indexed on a bigger plan."""
|
| 2 |
+
|
| 3 |
+
import uuid
|
| 4 |
+
from datetime import UTC, datetime, timedelta
|
| 5 |
+
|
| 6 |
+
from sqlalchemy import select
|
| 7 |
+
|
| 8 |
+
from app.models import Document, Job, Source, Workspace
|
| 9 |
+
from app.pipeline import ingest
|
| 10 |
+
from app.pipeline.ingest import FeedEntry, ingest_source
|
| 11 |
+
from app.routers.sources import is_locked
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _entries(n: int) -> list[FeedEntry]:
|
| 15 |
+
start = datetime(2026, 1, 1, tzinfo=UTC)
|
| 16 |
+
# Oldest first on purpose: ingest must pick the newest posts regardless of feed order.
|
| 17 |
+
return [
|
| 18 |
+
FeedEntry(title=f"Post {i}", url=f"https://ada.example/p/{i}", published_at=start + timedelta(days=i),
|
| 19 |
+
html=f"<h2>Point {i}</h2><p>Paragraph for post number {i} with enough words to keep.</p>")
|
| 20 |
+
for i in range(n)
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _setup(db, monkeypatch, n: int):
|
| 25 |
+
monkeypatch.setattr(ingest, "fetch_feed", lambda url, limit: ("Ada", _entries(n)[:limit]))
|
| 26 |
+
ws = Workspace(name="w", owner_id=uuid.uuid4())
|
| 27 |
+
db.add(ws)
|
| 28 |
+
db.flush()
|
| 29 |
+
source = Source(workspace_id=ws.id, feed_url="https://ada.example/feed", platform="rss", title="Ada")
|
| 30 |
+
db.add(source)
|
| 31 |
+
db.flush()
|
| 32 |
+
job = Job(workspace_id=ws.id, kind="ingest", params={"source_id": str(source.id)})
|
| 33 |
+
db.add(job)
|
| 34 |
+
db.commit()
|
| 35 |
+
return ws, source, job
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _docs(db, source):
|
| 39 |
+
return {d.title: d for d in db.scalars(select(Document).where(Document.source_id == source.id))}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_lists_every_post_but_indexes_only_the_latest(db_session, monkeypatch):
|
| 43 |
+
ws, source, job = _setup(db_session, monkeypatch, 12)
|
| 44 |
+
result = ingest_source(db_session, source, job, max_posts=5)
|
| 45 |
+
|
| 46 |
+
assert result["found"] == 12 and result["available"] == 5 and result["locked"] == 7 and result["capped"]
|
| 47 |
+
docs = _docs(db_session, source)
|
| 48 |
+
assert len(docs) == 12
|
| 49 |
+
available = {t for t, d in docs.items() if d.path}
|
| 50 |
+
assert available == {f"Post {i}" for i in range(7, 12)} # the five newest
|
| 51 |
+
locked = [d for d in docs.values() if not d.path]
|
| 52 |
+
assert len(locked) == 7
|
| 53 |
+
assert all(is_locked(d) and d.clean_text == "" and d.title for d in locked)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_upgrade_unlocks_and_newer_posts_relock_the_oldest(db_session, monkeypatch):
|
| 57 |
+
ws, source, job = _setup(db_session, monkeypatch, 12)
|
| 58 |
+
ingest_source(db_session, source, job, max_posts=5)
|
| 59 |
+
|
| 60 |
+
ingest_source(db_session, source, job, max_posts=500)
|
| 61 |
+
docs = _docs(db_session, source)
|
| 62 |
+
assert all(d.path for d in docs.values())
|
| 63 |
+
assert not any(is_locked(d) for d in docs.values())
|
| 64 |
+
|
| 65 |
+
ingest_source(db_session, source, job, max_posts=5)
|
| 66 |
+
docs = _docs(db_session, source)
|
| 67 |
+
assert sum(1 for d in docs.values() if d.path) == 5
|
| 68 |
+
assert docs["Post 0"].path is None and is_locked(docs["Post 0"])
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_scan_is_capped_per_sync(db_session, monkeypatch):
|
| 72 |
+
ws, source, job = _setup(db_session, monkeypatch, ingest.SCAN_CAP + 50)
|
| 73 |
+
result = ingest_source(db_session, source, job, max_posts=5)
|
| 74 |
+
assert result["found"] == ingest.SCAN_CAP
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_crawl_reads_only_the_pages_it_will_index(monkeypatch):
|
| 78 |
+
read = []
|
| 79 |
+
urls = [f"https://ada.example/posts/post-{i}" for i in range(20)]
|
| 80 |
+
monkeypatch.setattr(ingest, "_plain_links", lambda url: urls)
|
| 81 |
+
monkeypatch.setattr(ingest, "_post_urls", lambda links, url, limit: links[:limit])
|
| 82 |
+
|
| 83 |
+
def fake_extract(u):
|
| 84 |
+
read.append(u)
|
| 85 |
+
return FeedEntry(title=u, url=u, published_at=None, html="<p>body text for the page</p>")
|
| 86 |
+
|
| 87 |
+
monkeypatch.setattr(ingest, "extract_article", fake_extract)
|
| 88 |
+
entries = ingest.crawl_site("https://ada.example", 200, read_limit=5)
|
| 89 |
+
assert len(read) == 5 and len(entries) == 20
|
| 90 |
+
assert all(e.html == "" for e in entries[5:])
|
| 91 |
+
assert entries[5].title == "Post 5"
|
backend/tests/test_simplified_flows.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""One-click flows: the "All posts" notebook, whole-archive generation, and jobs that run after a sync."""
|
| 2 |
+
|
| 3 |
+
from sqlalchemy import select
|
| 4 |
+
|
| 5 |
+
from app.config import settings
|
| 6 |
+
from app.models import Job
|
| 7 |
+
from tests.test_buildout import auth, connect, docs, feed, llm # noqa: F401 (fixtures)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_archive_notebook_is_created_once_and_listed_first(client, auth, run_jobs, feed):
|
| 11 |
+
connect(client, auth, run_jobs)
|
| 12 |
+
client.post("/api/notebooks", json={"title": "Newer"}, headers=auth)
|
| 13 |
+
first = client.post("/api/notebooks/archive", headers=auth).json()
|
| 14 |
+
again = client.post("/api/notebooks/archive", headers=auth).json()
|
| 15 |
+
assert first["id"] == again["id"] and first["title"] == "All posts"
|
| 16 |
+
|
| 17 |
+
listed = client.get("/api/notebooks", headers=auth).json()
|
| 18 |
+
assert listed[0]["id"] == first["id"] and listed[0]["is_archive"]
|
| 19 |
+
indexed = [d for d in docs(client, auth) if d["path"]]
|
| 20 |
+
assert listed[0]["document_count"] == len(indexed) > 0
|
| 21 |
+
|
| 22 |
+
nb = client.get(f"/api/notebooks/{first['id']}", headers=auth).json()
|
| 23 |
+
assert nb["is_archive"] and len(nb["documents"]) == len(indexed)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_generate_from_whole_archive_uses_the_archive_notebook(client, auth, run_jobs, feed):
|
| 27 |
+
connect(client, auth, run_jobs)
|
| 28 |
+
art = client.post("/api/artifacts/generate", json={"type": "summary", "archive": True}, headers=auth).json()
|
| 29 |
+
archive = client.post("/api/notebooks/archive", headers=auth).json()
|
| 30 |
+
assert art["notebook_id"] == archive["id"]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_sync_queues_resurfacing_and_writing_voice_once(client, auth, feed, db_session, session_factory,
|
| 34 |
+
monkeypatch):
|
| 35 |
+
from app.services.jobs import claim_next
|
| 36 |
+
from app.worker import run_job
|
| 37 |
+
|
| 38 |
+
monkeypatch.setattr(settings, "llm_api_key", "test-key")
|
| 39 |
+
client.post("/api/sources", json={"url": "ada.example.com"}, headers=auth)
|
| 40 |
+
with session_factory() as db:
|
| 41 |
+
job_id = claim_next(db, "test")
|
| 42 |
+
run_job(job_id, session_factory=session_factory) # the ingest job only; follow-ups stay queued
|
| 43 |
+
db_session.expire_all()
|
| 44 |
+
kinds = [j.kind for j in db_session.scalars(select(Job).where(Job.status == "queued"))]
|
| 45 |
+
assert kinds.count("resurface_scan") == 1
|
| 46 |
+
assert kinds.count("voice_profile") == 1
|
| 47 |
+
assert kinds.count("topics") == 1
|
frontend/index.html
CHANGED
|
@@ -1,6 +1,15 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
<html lang="en">
|
| 3 |
<head>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<meta name="theme-color" content="#000000" />
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
<html lang="en">
|
| 3 |
<head>
|
| 4 |
+
<!-- Google tag (gtag.js) -->
|
| 5 |
+
<script async src="https://www.googletagmanager.com/gtag/js?id=G-GG0C3Y983W"></script>
|
| 6 |
+
<script>
|
| 7 |
+
window.dataLayer = window.dataLayer || [];
|
| 8 |
+
function gtag(){dataLayer.push(arguments);}
|
| 9 |
+
gtag('js', new Date());
|
| 10 |
+
|
| 11 |
+
gtag('config', 'G-GG0C3Y983W');
|
| 12 |
+
</script>
|
| 13 |
<meta charset="UTF-8" />
|
| 14 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 15 |
<meta name="theme-color" content="#000000" />
|
frontend/public/_redirects
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Signed in and auth routes get the empty app shell (noindex), not the prerendered home page.
|
| 2 |
+
/app/* /app 200
|
| 3 |
+
/auth /app 200
|
| 4 |
+
/auth/* /app 200
|
| 5 |
+
/welcome /app 200
|
frontend/seo/prerender.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { readFile, mkdir, writeFile } from "node:fs/promises";
|
| 2 |
+
import path from "node:path";
|
| 3 |
+
import type { Plugin, ResolvedConfig } from "vite";
|
| 4 |
+
import { blogPosts } from "../src/content/blogPosts";
|
| 5 |
+
import { NOTEBOOKLM_ALT_FAQ, NOTEBOOKLM_ALT_META, NOTEBOOKLM_ALT_ROWS } from "../src/content/notebooklmAlternative";
|
| 6 |
+
import type { BlogPost } from "../src/content/seoTypes";
|
| 7 |
+
import { tools, toolsHub, type ToolDef } from "../src/content/tools";
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* Writes a static HTML file per public route after `vite build`, so crawlers that do not run JavaScript still get the
|
| 11 |
+
* page's title, description, canonical, headings, copy and links. The React app replaces #root on load.
|
| 12 |
+
*
|
| 13 |
+
* Files are written as <route>.html, not <route>/index.html: Cloudflare Pages serves /blogs/x from blogs/x.html with no
|
| 14 |
+
* redirect, while a folder index would 308 to /blogs/x/ and fight the canonical (which has no trailing slash).
|
| 15 |
+
*/
|
| 16 |
+
|
| 17 |
+
type Rendered = { path: string; title: string; description: string; body: string; jsonLd?: object[]; ogType?: string };
|
| 18 |
+
|
| 19 |
+
const esc = (s: string) =>
|
| 20 |
+
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
| 21 |
+
|
| 22 |
+
/** Post copy supports [text](url) links and **bold**; render them as real anchors. */
|
| 23 |
+
const inline = (text: string) =>
|
| 24 |
+
esc(text)
|
| 25 |
+
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, label: string, href: string) => `<a href="${href}">${label}</a>`)
|
| 26 |
+
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
| 27 |
+
|
| 28 |
+
const plain = (text: string) => text.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/\*\*([^*]+)\*\*/g, "$1");
|
| 29 |
+
|
| 30 |
+
const faqLd = (items: { q: string; a: string }[]) => ({
|
| 31 |
+
"@context": "https://schema.org",
|
| 32 |
+
"@type": "FAQPage",
|
| 33 |
+
mainEntity: items.map((f) => ({ "@type": "Question", name: f.q, acceptedAnswer: { "@type": "Answer", text: plain(f.a) } })),
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
const faqHtml = (items: { q: string; a: string }[]) =>
|
| 37 |
+
items.length
|
| 38 |
+
? `<section><h2>Questions</h2>${items.map((f) => `<h3>${esc(f.q)}</h3><p>${inline(f.a)}</p>`).join("")}</section>`
|
| 39 |
+
: "";
|
| 40 |
+
|
| 41 |
+
const nav = () =>
|
| 42 |
+
`<header><nav aria-label="Main"><a href="/">Notestack</a> <a href="/notebooklm-alternative">Notestack vs NotebookLM</a> <a href="/tools">Free tools</a> <a href="/pricing">Pricing</a> <a href="/blogs">Blog</a></nav></header>`;
|
| 43 |
+
|
| 44 |
+
const footer = () =>
|
| 45 |
+
`<footer><nav aria-label="Footer"><h2>Free tools</h2><ul>${tools
|
| 46 |
+
.map((t) => `<li><a href="/tools/${t.slug}">${esc(t.heroTitle)}</a></li>`)
|
| 47 |
+
.join("")}</ul><h2>More from Firebird Technologies</h2><ul><li><a href="https://blog2video.app">Blog2Video: blog to video</a></li><li><a href="https://pdf2vid.com">PDF2Video: PDF to video</a></li><li><a href="https://bloghub.app">BlogHub: blog and newsletter directory</a></li></ul></nav><p>© Firebird Technologies</p></footer>`;
|
| 48 |
+
|
| 49 |
+
const page = (main: string) => `${nav()}<main>${main}</main>${footer()}`;
|
| 50 |
+
|
| 51 |
+
function pathTitle(p: string) {
|
| 52 |
+
const post = blogPosts.find((b) => `/blogs/${b.slug}` === p);
|
| 53 |
+
if (post) return post.title;
|
| 54 |
+
const tool = tools.find((t) => `/tools/${t.slug}` === p);
|
| 55 |
+
if (tool) return tool.heroTitle;
|
| 56 |
+
if (p === "/notebooklm-alternative") return "Notestack vs NotebookLM";
|
| 57 |
+
return p;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function renderPost(post: BlogPost): Rendered {
|
| 61 |
+
const sections = post.sections
|
| 62 |
+
.map(
|
| 63 |
+
(s) =>
|
| 64 |
+
`<section><h2>${esc(s.heading)}</h2>${s.paragraphs.map((p) => `<p>${inline(p)}</p>`).join("")}${
|
| 65 |
+
s.bullets?.length ? `<ul>${s.bullets.map((b) => `<li>${inline(b)}</li>`).join("")}</ul>` : ""
|
| 66 |
+
}${s.callout ? `<p>${inline(s.callout)}</p>` : ""}</section>`,
|
| 67 |
+
)
|
| 68 |
+
.join("");
|
| 69 |
+
const mission = post.mission ? `<p><strong>${esc(post.mission.question)}</strong> ${inline(post.mission.answer)}</p>` : "";
|
| 70 |
+
const related = post.relatedPaths.length
|
| 71 |
+
? `<nav aria-label="Related"><h2>Related</h2><ul>${post.relatedPaths
|
| 72 |
+
.map((p) => `<li><a href="${p}">${esc(pathTitle(p))}</a></li>`)
|
| 73 |
+
.join("")}</ul></nav>`
|
| 74 |
+
: "";
|
| 75 |
+
const faq = post.faq.map((f) => ({ q: f.question, a: f.answer }));
|
| 76 |
+
return {
|
| 77 |
+
path: `/blogs/${post.slug}`,
|
| 78 |
+
title: `${post.title} | Notestack`,
|
| 79 |
+
description: post.description,
|
| 80 |
+
ogType: "article",
|
| 81 |
+
body: page(
|
| 82 |
+
`<article><p>${esc(post.heroEyebrow)}</p><h1>${esc(post.title)}</h1><p>${esc(post.heroDescription)}</p><time datetime="${post.publishedAt}">${post.publishedAt}</time>${mission}${sections}${faqHtml(faq)}</article>${related}`,
|
| 83 |
+
),
|
| 84 |
+
jsonLd: [
|
| 85 |
+
{
|
| 86 |
+
"@context": "https://schema.org",
|
| 87 |
+
"@type": "BlogPosting",
|
| 88 |
+
headline: post.title,
|
| 89 |
+
description: post.description,
|
| 90 |
+
datePublished: post.publishedAt,
|
| 91 |
+
author: { "@type": "Organization", name: "Notestack" },
|
| 92 |
+
publisher: { "@type": "Organization", name: "Firebird Technologies" },
|
| 93 |
+
},
|
| 94 |
+
...(faq.length ? [faqLd(faq)] : []),
|
| 95 |
+
],
|
| 96 |
+
};
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
function renderTool(tool: ToolDef): Rendered {
|
| 100 |
+
const faq = tool.faq.map((f) => ({ q: f.question, a: f.answer }));
|
| 101 |
+
const sections = tool.sections
|
| 102 |
+
.map(
|
| 103 |
+
(s) =>
|
| 104 |
+
`<section><h2>${esc(s.heading)}</h2>${s.paragraphs.map((p) => `<p>${inline(p)}</p>`).join("")}${
|
| 105 |
+
s.bullets?.length ? `<ul>${s.bullets.map((b) => `<li>${esc(b)}</li>`).join("")}</ul>` : ""
|
| 106 |
+
}</section>`,
|
| 107 |
+
)
|
| 108 |
+
.join("");
|
| 109 |
+
const next = encodeURIComponent(`/tools/${tool.slug}`);
|
| 110 |
+
return {
|
| 111 |
+
path: `/tools/${tool.slug}`,
|
| 112 |
+
title: tool.metaTitle,
|
| 113 |
+
description: tool.description,
|
| 114 |
+
body: page(
|
| 115 |
+
`<article><p>${esc(tool.eyebrow)}</p><h1>${esc(tool.heroTitle)}</h1><p>${esc(tool.heroDescription)}</p><p><a href="/auth?mode=signup&next=${next}">Sign up free to use the ${esc(
|
| 116 |
+
tool.eyebrow.toLowerCase(),
|
| 117 |
+
)}</a></p>${sections}${faqHtml(faq)}</article><nav aria-label="Related"><h2>Keep exploring</h2><ul>${tool.relatedPaths
|
| 118 |
+
.map((p) => `<li><a href="${p}">${esc(pathTitle(p))}</a></li>`)
|
| 119 |
+
.join("")}</ul></nav>`,
|
| 120 |
+
),
|
| 121 |
+
jsonLd: [
|
| 122 |
+
{
|
| 123 |
+
"@context": "https://schema.org",
|
| 124 |
+
"@type": "SoftwareApplication",
|
| 125 |
+
name: tool.heroTitle,
|
| 126 |
+
applicationCategory: "MultimediaApplication",
|
| 127 |
+
operatingSystem: "Web",
|
| 128 |
+
description: tool.description,
|
| 129 |
+
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
|
| 130 |
+
publisher: { "@type": "Organization", name: "Notestack" },
|
| 131 |
+
},
|
| 132 |
+
faqLd(faq),
|
| 133 |
+
],
|
| 134 |
+
};
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
function routes(): Rendered[] {
|
| 138 |
+
const posts = [...blogPosts].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
|
| 139 |
+
const altFaq = NOTEBOOKLM_ALT_FAQ.map((f) => ({ q: f.q, a: f.a }));
|
| 140 |
+
return [
|
| 141 |
+
{
|
| 142 |
+
path: "/",
|
| 143 |
+
title: "Notestack: your knowledge, in orbit | NotebookLM alternative for writers",
|
| 144 |
+
description:
|
| 145 |
+
"Notestack turns any blog, newsletter, site or markdown files into a grounded research notebook, audio overviews, videos and a launch kit, all in your own voice.",
|
| 146 |
+
body: page(
|
| 147 |
+
`<h1>Your knowledge, in orbit.</h1><p>Notestack turns your blog, newsletter, site or markdown files into a research notebook you can question, podcasts you can publish and launch kits for every platform. Every word traced back to what you actually wrote.</p><p><a href="/auth?mode=signup">Start free</a> or see <a href="/notebooklm-alternative">Notestack vs NotebookLM</a>.</p><h2>Free tools</h2><ul>${tools
|
| 148 |
+
.map((t) => `<li><a href="/tools/${t.slug}">${esc(t.heroTitle)}</a>: ${esc(t.description)}</li>`)
|
| 149 |
+
.join("")}</ul><h2>Field notes</h2><ul>${posts
|
| 150 |
+
.map((p) => `<li><a href="/blogs/${p.slug}">${esc(p.title)}</a></li>`)
|
| 151 |
+
.join("")}</ul>`,
|
| 152 |
+
),
|
| 153 |
+
jsonLd: [
|
| 154 |
+
{
|
| 155 |
+
"@context": "https://schema.org",
|
| 156 |
+
"@type": "SoftwareApplication",
|
| 157 |
+
name: "Notestack",
|
| 158 |
+
applicationCategory: "BusinessApplication",
|
| 159 |
+
operatingSystem: "Web",
|
| 160 |
+
description: "The NotebookLM alternative for writers: a grounded research notebook built from your own archive.",
|
| 161 |
+
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
|
| 162 |
+
publisher: { "@type": "Organization", name: "Firebird Technologies" },
|
| 163 |
+
},
|
| 164 |
+
],
|
| 165 |
+
},
|
| 166 |
+
{
|
| 167 |
+
path: "/notebooklm-alternative",
|
| 168 |
+
title: NOTEBOOKLM_ALT_META.title,
|
| 169 |
+
description: NOTEBOOKLM_ALT_META.description,
|
| 170 |
+
body: page(
|
| 171 |
+
`<article><p>NotebookLM alternative</p><h1>Notestack vs NotebookLM: the NotebookLM alternative for writers</h1><p>NotebookLM is great for a pile of documents. Notestack is built for a body of work: it syncs your blog, newsletter, site or markdown, answers with citations to the exact lines you wrote, and turns your knowledge into audio, video and launch posts.</p><p><a href="/auth?mode=signup">Start free</a></p><section><h2>Notestack vs NotebookLM</h2><table><thead><tr><th></th><th>Notestack</th><th>NotebookLM</th></tr></thead><tbody>${NOTEBOOKLM_ALT_ROWS.map(
|
| 172 |
+
(r) => `<tr><th>${esc(r.feature)}</th><td>${esc(r.notestack)}</td><td>${esc(r.notebooklm)}</td></tr>`,
|
| 173 |
+
).join("")}</tbody></table></section><section><h2>When NotebookLM is still the right call</h2><p>If you are studying someone else's documents for a one off project, want a free tool from Google, or live in Google Drive, NotebookLM is excellent. Notestack is the NotebookLM alternative when the sources are your own writing and the goal is to reuse and publish it.</p></section><section><h2>Pair it with the right publishing tool</h2><ul><li><a href="https://blog2video.app/blog-to-video">Blog2Video</a>: turn blog posts, URLs and scripts into finished videos.</li><li><a href="https://pdf2vid.com">PDF2Video</a>: turn any PDF, report, paper or deck into a narrated video.</li><li><a href="https://bloghub.app/submit-your-newsletter">BlogHub</a>: list your blog or newsletter in a free directory.</li></ul></section>${faqHtml(
|
| 174 |
+
altFaq,
|
| 175 |
+
)}<p>Comparing more tools? Read our guide to <a href="/blogs/notebooklm-alternatives">NotebookLM alternatives</a> and <a href="/blogs/notebooklm-vs-chatgpt">NotebookLM vs ChatGPT</a>.</p></article>`,
|
| 176 |
+
),
|
| 177 |
+
jsonLd: [faqLd(altFaq)],
|
| 178 |
+
},
|
| 179 |
+
{
|
| 180 |
+
path: "/pricing",
|
| 181 |
+
title: "Pricing | Notestack",
|
| 182 |
+
description:
|
| 183 |
+
"Notestack pricing: Free ($0), Writer ($24.99 a month, $18.99 billed annually) and Studio ($48.99 a month, $36.99 billed annually). Annual billing saves 24%.",
|
| 184 |
+
body: page(
|
| 185 |
+
`<h1>Pick your trajectory</h1><ul><li><h2>Free</h2><p>$0. 1 source, your latest 5 posts indexed, 3 audio minutes, 1 video minute, 2 Launch Kits a month.</p></li><li><h2>Writer</h2><p>$24.99 a month ($18.99 a month billed annually). 3 sources, 500 posts, 60 audio minutes, 30 video minutes, 50 Launch Kits.</p></li><li><h2>Studio</h2><p>$48.99 a month ($36.99 a month billed annually). 10 sources, 5,000 posts, 240 audio minutes, 120 video minutes, unlimited Launch Kits.</p></li></ul><p><a href="/auth?mode=signup">Start free</a></p>`,
|
| 186 |
+
),
|
| 187 |
+
},
|
| 188 |
+
{
|
| 189 |
+
path: "/blogs",
|
| 190 |
+
title: "Blog | Notestack",
|
| 191 |
+
description: "Field notes on research, deep writing and turning your archive into audio, video and launches.",
|
| 192 |
+
body: page(
|
| 193 |
+
`<h1>Every post is a search for an answer</h1>${posts
|
| 194 |
+
.map(
|
| 195 |
+
(p) =>
|
| 196 |
+
`<article><h2><a href="/blogs/${p.slug}">${esc(p.title)}</a></h2><p>${esc(p.description)}</p><time datetime="${p.publishedAt}">${p.publishedAt}</time></article>`,
|
| 197 |
+
)
|
| 198 |
+
.join("")}`,
|
| 199 |
+
),
|
| 200 |
+
},
|
| 201 |
+
{
|
| 202 |
+
path: "/tools",
|
| 203 |
+
title: toolsHub.metaTitle,
|
| 204 |
+
description: toolsHub.description,
|
| 205 |
+
body: page(
|
| 206 |
+
`<h1>${esc(toolsHub.heroTitle)}</h1><p>${esc(toolsHub.heroDescription)}</p><ul>${tools
|
| 207 |
+
.map((t) => `<li><h2><a href="/tools/${t.slug}">${esc(t.heroTitle)}</a></h2><p>${esc(t.description)}</p></li>`)
|
| 208 |
+
.join("")}</ul>`,
|
| 209 |
+
),
|
| 210 |
+
},
|
| 211 |
+
...tools.map(renderTool),
|
| 212 |
+
...posts.filter((p) => !p.canonicalPath).map(renderPost),
|
| 213 |
+
];
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
/** Strips the head tags this plugin owns, so every route gets exactly one of each. */
|
| 217 |
+
function sanitize(template: string) {
|
| 218 |
+
return template
|
| 219 |
+
.replace(/<title>[\s\S]*?<\/title>\s*/gi, "")
|
| 220 |
+
.replace(/<meta\s+name="description"[^>]*>\s*/gi, "")
|
| 221 |
+
.replace(/<meta\s+property="og:(title|description|url|type)"[^>]*>\s*/gi, "")
|
| 222 |
+
.replace(/<meta\s+name="twitter:(title|description)"[^>]*>\s*/gi, "")
|
| 223 |
+
.replace(/<link\s+rel="canonical"[^>]*>\s*/gi, "");
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
function head(r: Rendered, origin: string) {
|
| 227 |
+
const url = `${origin}${r.path === "/" ? "/" : r.path}`;
|
| 228 |
+
const tags = [
|
| 229 |
+
`<title>${esc(r.title)}</title>`,
|
| 230 |
+
`<meta name="description" content="${esc(r.description)}" />`,
|
| 231 |
+
`<link rel="canonical" href="${url}" />`,
|
| 232 |
+
`<meta property="og:type" content="${r.ogType ?? "website"}" />`,
|
| 233 |
+
`<meta property="og:title" content="${esc(r.title)}" />`,
|
| 234 |
+
`<meta property="og:description" content="${esc(r.description)}" />`,
|
| 235 |
+
`<meta property="og:url" content="${url}" />`,
|
| 236 |
+
`<meta name="twitter:title" content="${esc(r.title)}" />`,
|
| 237 |
+
`<meta name="twitter:description" content="${esc(r.description)}" />`,
|
| 238 |
+
...(r.jsonLd ?? []).map((ld) => `<script type="application/ld+json">${JSON.stringify(ld).replace(/</g, "\\u003c")}</script>`),
|
| 239 |
+
];
|
| 240 |
+
return tags.join("\n ");
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
export function prerenderPlugin(siteUrl: string): Plugin {
|
| 244 |
+
const origin = siteUrl.replace(/\/+$/, "");
|
| 245 |
+
let config: ResolvedConfig;
|
| 246 |
+
return {
|
| 247 |
+
name: "notestack-prerender",
|
| 248 |
+
apply: "build",
|
| 249 |
+
configResolved(c) {
|
| 250 |
+
config = c;
|
| 251 |
+
},
|
| 252 |
+
async closeBundle() {
|
| 253 |
+
const outDir = path.resolve(config.root, config.build.outDir);
|
| 254 |
+
const template = sanitize(await readFile(path.join(outDir, "index.html"), "utf8"));
|
| 255 |
+
const all = routes();
|
| 256 |
+
for (const r of all) {
|
| 257 |
+
const html = template
|
| 258 |
+
.replace("</head>", ` ${head(r, origin)}\n </head>`)
|
| 259 |
+
.replace('<div id="root"></div>', `<div id="root">${r.body}</div>`);
|
| 260 |
+
const file = r.path === "/" ? path.join(outDir, "index.html") : path.join(outDir, `${r.path.slice(1)}.html`);
|
| 261 |
+
await mkdir(path.dirname(file), { recursive: true });
|
| 262 |
+
await writeFile(file, html, "utf8");
|
| 263 |
+
}
|
| 264 |
+
// Signed in and unknown routes fall back to an app shell with no page content (see public/_redirects).
|
| 265 |
+
await writeFile(path.join(outDir, "app.html"), template.replace("</head>", ` <meta name="robots" content="noindex" />\n <title>Notestack</title>\n </head>`), "utf8");
|
| 266 |
+
config.logger.info(`prerendered ${all.length} public routes`);
|
| 267 |
+
},
|
| 268 |
+
};
|
| 269 |
+
}
|
frontend/seo/sitemap.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import type { Plugin } from "vite";
|
| 2 |
import { blogPosts } from "../src/content/blogPosts";
|
|
|
|
| 3 |
|
| 4 |
/**
|
| 5 |
* sitemap.xml and robots.txt, generated from src/content/blogPosts.ts on every build (and served live
|
|
@@ -13,6 +14,8 @@ const STATIC_PAGES: { path: string; priority: string; changefreq: string }[] = [
|
|
| 13 |
{ path: "/notebooklm-alternative", priority: "0.9", changefreq: "monthly" },
|
| 14 |
{ path: "/pricing", priority: "0.8", changefreq: "monthly" },
|
| 15 |
{ path: "/blogs", priority: "0.9", changefreq: "weekly" },
|
|
|
|
|
|
|
| 16 |
];
|
| 17 |
|
| 18 |
const escapeXml = (s: string) =>
|
|
@@ -93,7 +96,7 @@ differs from NotebookLM.
|
|
| 93 |
SEO pack, carousel).
|
| 94 |
- **Publishing:** a Launchpad calendar that auto posts to X, LinkedIn and Bluesky, plus a topic map of rising and
|
| 95 |
dormant themes and evergreen resurfacing of old posts.
|
| 96 |
-
- **Pricing (USD):** Free: 1 source,
|
| 97 |
Writer: $24.99 a month ($18.99 a month billed annually): 3 sources, 500 posts, 60 audio minutes, 30 video minutes,
|
| 98 |
50 launch kits. Studio: $48.99 a month ($36.99 a month billed annually): 10 sources, 5,000 posts, 240 audio
|
| 99 |
minutes, 120 video minutes, unlimited launch kits. Annual billing saves 24%.
|
|
@@ -106,6 +109,7 @@ differs from NotebookLM.
|
|
| 106 |
- [Notestack vs NotebookLM](${base}/notebooklm-alternative): side by side comparison and FAQ
|
| 107 |
- [Pricing](${base}/pricing): plans and limits
|
| 108 |
- [Blog](${base}/blogs): guides on NotebookLM alternatives, AI for research and AI tools for writers
|
|
|
|
| 109 |
|
| 110 |
## Blog
|
| 111 |
|
|
|
|
| 1 |
import type { Plugin } from "vite";
|
| 2 |
import { blogPosts } from "../src/content/blogPosts";
|
| 3 |
+
import { tools } from "../src/content/tools";
|
| 4 |
|
| 5 |
/**
|
| 6 |
* sitemap.xml and robots.txt, generated from src/content/blogPosts.ts on every build (and served live
|
|
|
|
| 14 |
{ path: "/notebooklm-alternative", priority: "0.9", changefreq: "monthly" },
|
| 15 |
{ path: "/pricing", priority: "0.8", changefreq: "monthly" },
|
| 16 |
{ path: "/blogs", priority: "0.9", changefreq: "weekly" },
|
| 17 |
+
{ path: "/tools", priority: "0.8", changefreq: "monthly" },
|
| 18 |
+
...tools.map((t) => ({ path: `/tools/${t.slug}`, priority: "0.8", changefreq: "monthly" })),
|
| 19 |
];
|
| 20 |
|
| 21 |
const escapeXml = (s: string) =>
|
|
|
|
| 96 |
SEO pack, carousel).
|
| 97 |
- **Publishing:** a Launchpad calendar that auto posts to X, LinkedIn and Bluesky, plus a topic map of rising and
|
| 98 |
dormant themes and evergreen resurfacing of old posts.
|
| 99 |
+
- **Pricing (USD):** Free: 1 source, latest 5 posts indexed, 3 audio minutes, 1 video minute, 2 launch kits a month.
|
| 100 |
Writer: $24.99 a month ($18.99 a month billed annually): 3 sources, 500 posts, 60 audio minutes, 30 video minutes,
|
| 101 |
50 launch kits. Studio: $48.99 a month ($36.99 a month billed annually): 10 sources, 5,000 posts, 240 audio
|
| 102 |
minutes, 120 video minutes, unlimited launch kits. Annual billing saves 24%.
|
|
|
|
| 109 |
- [Notestack vs NotebookLM](${base}/notebooklm-alternative): side by side comparison and FAQ
|
| 110 |
- [Pricing](${base}/pricing): plans and limits
|
| 111 |
- [Blog](${base}/blogs): guides on NotebookLM alternatives, AI for research and AI tools for writers
|
| 112 |
+
- [Free tools](${base}/tools): ${tools.map((t) => `[${t.heroTitle}](${base}/tools/${t.slug})`).join(", ")} (free with sign in)
|
| 113 |
|
| 114 |
## Blog
|
| 115 |
|
frontend/src/App.tsx
CHANGED
|
@@ -19,6 +19,7 @@ import Settings from "./pages/Settings";
|
|
| 19 |
import Sources from "./pages/Sources";
|
| 20 |
import Studio from "./pages/Studio";
|
| 21 |
import TopicMap from "./pages/TopicMap";
|
|
|
|
| 22 |
import VoiceProfile from "./pages/VoiceProfile";
|
| 23 |
import Welcome from "./pages/Welcome";
|
| 24 |
|
|
@@ -43,6 +44,8 @@ export default function App() {
|
|
| 43 |
<Route path="/" element={<Landing />} />
|
| 44 |
<Route path="/pricing" element={<PricingPage />} />
|
| 45 |
<Route path="/notebooklm-alternative" element={<NotebookLMAlternative />} />
|
|
|
|
|
|
|
| 46 |
<Route path="/blogs" element={<Blog />} />
|
| 47 |
<Route path="/blogs/:slug" element={<BlogPostPage />} />
|
| 48 |
<Route path="/auth" element={<AuthPage />} />
|
|
|
|
| 19 |
import Sources from "./pages/Sources";
|
| 20 |
import Studio from "./pages/Studio";
|
| 21 |
import TopicMap from "./pages/TopicMap";
|
| 22 |
+
import { ToolPage, ToolsHub } from "./pages/Tools";
|
| 23 |
import VoiceProfile from "./pages/VoiceProfile";
|
| 24 |
import Welcome from "./pages/Welcome";
|
| 25 |
|
|
|
|
| 44 |
<Route path="/" element={<Landing />} />
|
| 45 |
<Route path="/pricing" element={<PricingPage />} />
|
| 46 |
<Route path="/notebooklm-alternative" element={<NotebookLMAlternative />} />
|
| 47 |
+
<Route path="/tools" element={<ToolsHub />} />
|
| 48 |
+
<Route path="/tools/:slug" element={<ToolPage />} />
|
| 49 |
<Route path="/blogs" element={<Blog />} />
|
| 50 |
<Route path="/blogs/:slug" element={<BlogPostPage />} />
|
| 51 |
<Route path="/auth" element={<AuthPage />} />
|
frontend/src/api/endpoints.ts
CHANGED
|
@@ -24,6 +24,8 @@ import type {
|
|
| 24 |
Usage,
|
| 25 |
Voice,
|
| 26 |
Delivery,
|
|
|
|
|
|
|
| 27 |
VoiceProfileData,
|
| 28 |
VoiceState,
|
| 29 |
} from "./types";
|
|
@@ -54,6 +56,8 @@ export const notebooksApi = {
|
|
| 54 |
get: (id: string) => api<Notebook>(`/api/notebooks/${id}`),
|
| 55 |
create: (title: string, document_ids: string[] = [], description?: string) =>
|
| 56 |
post<{ id: string; title: string; added: number }>("/api/notebooks", { title, document_ids, description }),
|
|
|
|
|
|
|
| 57 |
fromTopic: (topicId: string) => post<{ id: string; title: string }>(`/api/notebooks/from-topic/${topicId}`),
|
| 58 |
update: (id: string, body: { title?: string; description?: string }) => patch(`/api/notebooks/${id}`, body),
|
| 59 |
remove: (id: string) => del(`/api/notebooks/${id}`),
|
|
@@ -70,6 +74,8 @@ export type GenerateBody = {
|
|
| 70 |
type: ArtifactType;
|
| 71 |
notebook_id?: string;
|
| 72 |
document_id?: string;
|
|
|
|
|
|
|
| 73 |
format?: "deep_dive" | "brief" | "debate";
|
| 74 |
minutes?: number;
|
| 75 |
style?: "short" | "explainer" | "audiogram";
|
|
@@ -129,6 +135,9 @@ export const voiceApi = {
|
|
| 129 |
readingScript: () => api<{ title: string; text: string; words: number }>("/api/voice/reading-script"),
|
| 130 |
quota: () => api<Quota>("/api/voice/quota"),
|
| 131 |
revoke: () => del<VoiceState>("/api/voice/consent"),
|
|
|
|
|
|
|
|
|
|
| 132 |
};
|
| 133 |
|
| 134 |
export type ItemBody = {
|
|
|
|
| 24 |
Usage,
|
| 25 |
Voice,
|
| 26 |
Delivery,
|
| 27 |
+
VoiceDesignInput,
|
| 28 |
+
VoiceDesignPreview,
|
| 29 |
VoiceProfileData,
|
| 30 |
VoiceState,
|
| 31 |
} from "./types";
|
|
|
|
| 56 |
get: (id: string) => api<Notebook>(`/api/notebooks/${id}`),
|
| 57 |
create: (title: string, document_ids: string[] = [], description?: string) =>
|
| 58 |
post<{ id: string; title: string; added: number }>("/api/notebooks", { title, document_ids, description }),
|
| 59 |
+
/** The "All posts" notebook, created on first use. */
|
| 60 |
+
archive: () => post<{ id: string; title: string }>("/api/notebooks/archive"),
|
| 61 |
fromTopic: (topicId: string) => post<{ id: string; title: string }>(`/api/notebooks/from-topic/${topicId}`),
|
| 62 |
update: (id: string, body: { title?: string; description?: string }) => patch(`/api/notebooks/${id}`, body),
|
| 63 |
remove: (id: string) => del(`/api/notebooks/${id}`),
|
|
|
|
| 74 |
type: ArtifactType;
|
| 75 |
notebook_id?: string;
|
| 76 |
document_id?: string;
|
| 77 |
+
/** No notebook or post: use the "All posts" notebook. */
|
| 78 |
+
archive?: boolean;
|
| 79 |
format?: "deep_dive" | "brief" | "debate";
|
| 80 |
minutes?: number;
|
| 81 |
style?: "short" | "explainer" | "audiogram";
|
|
|
|
| 135 |
readingScript: () => api<{ title: string; text: string; words: number }>("/api/voice/reading-script"),
|
| 136 |
quota: () => api<Quota>("/api/voice/quota"),
|
| 137 |
revoke: () => del<VoiceState>("/api/voice/consent"),
|
| 138 |
+
design: (input: VoiceDesignInput) => post<{ description: string; previews: VoiceDesignPreview[] }>("/api/voice/design", input),
|
| 139 |
+
saveDesign: (body: { generated_voice_id: string; name: string; description: string; use_as?: "host_a" | "host_b" }) =>
|
| 140 |
+
post<VoiceState & { voice_id: string }>("/api/voice/design/save", body),
|
| 141 |
};
|
| 142 |
|
| 143 |
export type ItemBody = {
|
frontend/src/api/types.ts
CHANGED
|
@@ -25,6 +25,8 @@ export type Source = {
|
|
| 25 |
sync_error: string | null;
|
| 26 |
last_synced_at: string | null;
|
| 27 |
document_count: number;
|
|
|
|
|
|
|
| 28 |
is_imports: boolean;
|
| 29 |
};
|
| 30 |
|
|
@@ -38,9 +40,11 @@ export type Doc = {
|
|
| 38 |
published_at: string | null;
|
| 39 |
words: number;
|
| 40 |
evergreen_score: number | null;
|
|
|
|
|
|
|
| 41 |
};
|
| 42 |
|
| 43 |
-
export type DocDetail = Doc & { lines: string[] };
|
| 44 |
|
| 45 |
export type Page<T> = { total: number; items: T[] };
|
| 46 |
|
|
@@ -52,6 +56,8 @@ export type NotebookSummary = {
|
|
| 52 |
document_count: number;
|
| 53 |
chat_count: number;
|
| 54 |
artifact_count: number;
|
|
|
|
|
|
|
| 55 |
updated_at: string | null;
|
| 56 |
};
|
| 57 |
|
|
@@ -60,6 +66,7 @@ export type Notebook = {
|
|
| 60 |
title: string;
|
| 61 |
description: string | null;
|
| 62 |
summary: string | null;
|
|
|
|
| 63 |
documents: Doc[];
|
| 64 |
};
|
| 65 |
|
|
@@ -195,9 +202,14 @@ export type VoiceState = {
|
|
| 195 |
error: string | null;
|
| 196 |
preview_url: string | null;
|
| 197 |
};
|
|
|
|
|
|
|
| 198 |
tts_configured: boolean;
|
| 199 |
};
|
| 200 |
|
|
|
|
|
|
|
|
|
|
| 201 |
export type Delivery = { stability: number; similarity_boost: number; style: number; speed: number; use_speaker_boost?: boolean };
|
| 202 |
|
| 203 |
export type Voice = {
|
|
|
|
| 25 |
sync_error: string | null;
|
| 26 |
last_synced_at: string | null;
|
| 27 |
document_count: number;
|
| 28 |
+
/** Found but past the plan's post limit: listed, not indexed until the workspace upgrades. */
|
| 29 |
+
locked_count: number;
|
| 30 |
is_imports: boolean;
|
| 31 |
};
|
| 32 |
|
|
|
|
| 40 |
published_at: string | null;
|
| 41 |
words: number;
|
| 42 |
evergreen_score: number | null;
|
| 43 |
+
/** Found but past the plan's post limit: title and link only, not indexed until the workspace upgrades. */
|
| 44 |
+
locked: boolean;
|
| 45 |
};
|
| 46 |
|
| 47 |
+
export type DocDetail = Doc & { lines: string[]; preview?: boolean; limit?: number };
|
| 48 |
|
| 49 |
export type Page<T> = { total: number; items: T[] };
|
| 50 |
|
|
|
|
| 56 |
document_count: number;
|
| 57 |
chat_count: number;
|
| 58 |
artifact_count: number;
|
| 59 |
+
/** The always-present "All posts" notebook. */
|
| 60 |
+
is_archive: boolean;
|
| 61 |
updated_at: string | null;
|
| 62 |
};
|
| 63 |
|
|
|
|
| 66 |
title: string;
|
| 67 |
description: string | null;
|
| 68 |
summary: string | null;
|
| 69 |
+
is_archive: boolean;
|
| 70 |
documents: Doc[];
|
| 71 |
};
|
| 72 |
|
|
|
|
| 202 |
error: string | null;
|
| 203 |
preview_url: string | null;
|
| 204 |
};
|
| 205 |
+
/** Voices this workspace generated with Voice Design. */
|
| 206 |
+
custom_voices: { voice_id: string; name: string; description?: string }[];
|
| 207 |
tts_configured: boolean;
|
| 208 |
};
|
| 209 |
|
| 210 |
+
export type VoiceDesignInput = { prompt?: string; gender?: string; age?: string; persona?: string; pace?: string; accent?: string };
|
| 211 |
+
export type VoiceDesignPreview = { generated_voice_id: string; url: string; seconds: number | null };
|
| 212 |
+
|
| 213 |
export type Delivery = { stability: number; similarity_boost: number; style: number; speed: number; use_speaker_boost?: boolean };
|
| 214 |
|
| 215 |
export type Voice = {
|
frontend/src/components/AppShell.tsx
CHANGED
|
@@ -8,31 +8,25 @@ import SkyCanvas from "./SkyCanvas";
|
|
| 8 |
import UpgradeModal from "./UpgradeModal";
|
| 9 |
import {
|
| 10 |
AsteroidIcon,
|
| 11 |
-
CometIcon,
|
| 12 |
-
ConstellationIcon,
|
| 13 |
HelmetIcon,
|
| 14 |
LaunchpadIcon,
|
| 15 |
LaunchWindowIcon,
|
| 16 |
OrbitIcon,
|
| 17 |
PlanetIcon,
|
| 18 |
RadarIcon,
|
| 19 |
-
RocketIcon,
|
| 20 |
SatelliteDishIcon,
|
| 21 |
} from "./icons/Icons";
|
| 22 |
|
| 23 |
// Theme in the icon, clarity in the label: the plain label always shows.
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
| 26 |
{ to: "/app/notebooks", label: "Notebooks", icon: PlanetIcon },
|
| 27 |
-
{ to: "/app/
|
| 28 |
-
{ to: "/app/
|
| 29 |
-
{ to: "/app/
|
| 30 |
-
{ to: "/app/
|
| 31 |
-
{ to: "/app/launch-kit", label: "Launch Kit", icon: RocketIcon },
|
| 32 |
-
{ to: "/app/launchpad", label: "Launchpad", icon: LaunchpadIcon },
|
| 33 |
-
{ to: "/app/archive", label: "Archive", icon: AsteroidIcon },
|
| 34 |
-
{ to: "/app/resurface", label: "Resurfacing", icon: CometIcon },
|
| 35 |
-
{ to: "/app/settings", label: "Settings", icon: OrbitIcon },
|
| 36 |
];
|
| 37 |
|
| 38 |
const COLLAPSED_KEY = "ns_sidebar_collapsed";
|
|
@@ -95,14 +89,24 @@ export default function AppShell() {
|
|
| 95 |
</button>
|
| 96 |
</div>
|
| 97 |
<nav aria-label="App">
|
| 98 |
-
{NAV.map(({ to, label, icon: Icon, end }) => (
|
| 99 |
-
<NavLink
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
<Icon />
|
| 101 |
<span className="side-label">{label}</span>
|
| 102 |
</NavLink>
|
| 103 |
))}
|
| 104 |
</nav>
|
| 105 |
<FuelGauge collapsed={collapsed} />
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
<div className="sidebar-user">
|
| 107 |
<span className="mono muted side-label">{user.email}</span>
|
| 108 |
<button className="link-btn" onClick={logout} title="Sign out">
|
|
|
|
| 8 |
import UpgradeModal from "./UpgradeModal";
|
| 9 |
import {
|
| 10 |
AsteroidIcon,
|
|
|
|
|
|
|
| 11 |
HelmetIcon,
|
| 12 |
LaunchpadIcon,
|
| 13 |
LaunchWindowIcon,
|
| 14 |
OrbitIcon,
|
| 15 |
PlanetIcon,
|
| 16 |
RadarIcon,
|
|
|
|
| 17 |
SatelliteDishIcon,
|
| 18 |
} from "./icons/Icons";
|
| 19 |
|
| 20 |
// Theme in the icon, clarity in the label: the plain label always shows.
|
| 21 |
+
// Seven stops: related pages share one entry (`also` keeps it lit on them).
|
| 22 |
+
const NAV: { to: string; label: string; icon: typeof RadarIcon; end?: boolean; also?: string[] }[] = [
|
| 23 |
+
{ to: "/app", label: "Home", icon: RadarIcon, end: true },
|
| 24 |
+
{ to: "/app/sources", label: "Sources", icon: SatelliteDishIcon, also: ["/app/map"] },
|
| 25 |
{ to: "/app/notebooks", label: "Notebooks", icon: PlanetIcon },
|
| 26 |
+
{ to: "/app/studio", label: "Create", icon: LaunchWindowIcon, also: ["/app/launch-kit"] },
|
| 27 |
+
{ to: "/app/launchpad", label: "Launchpad", icon: LaunchpadIcon, also: ["/app/resurface"] },
|
| 28 |
+
{ to: "/app/archive", label: "Library", icon: AsteroidIcon },
|
| 29 |
+
{ to: "/app/voice", label: "Voice", icon: HelmetIcon },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
];
|
| 31 |
|
| 32 |
const COLLAPSED_KEY = "ns_sidebar_collapsed";
|
|
|
|
| 89 |
</button>
|
| 90 |
</div>
|
| 91 |
<nav aria-label="App">
|
| 92 |
+
{NAV.map(({ to, label, icon: Icon, end, also }) => (
|
| 93 |
+
<NavLink
|
| 94 |
+
key={to}
|
| 95 |
+
to={to}
|
| 96 |
+
end={end}
|
| 97 |
+
title={collapsed ? label : undefined}
|
| 98 |
+
className={({ isActive }) => `side-link${isActive || also?.some((p) => location.pathname.startsWith(p)) ? " active" : ""}`}
|
| 99 |
+
>
|
| 100 |
<Icon />
|
| 101 |
<span className="side-label">{label}</span>
|
| 102 |
</NavLink>
|
| 103 |
))}
|
| 104 |
</nav>
|
| 105 |
<FuelGauge collapsed={collapsed} />
|
| 106 |
+
<NavLink to="/app/settings" title={collapsed ? "Settings" : undefined} className={({ isActive }) => `side-link side-settings${isActive ? " active" : ""}`}>
|
| 107 |
+
<OrbitIcon />
|
| 108 |
+
<span className="side-label">Settings</span>
|
| 109 |
+
</NavLink>
|
| 110 |
<div className="sidebar-user">
|
| 111 |
<span className="mono muted side-label">{user.email}</span>
|
| 112 |
<button className="link-btn" onClick={logout} title="Sign out">
|
frontend/src/components/PricingTiers.tsx
CHANGED
|
@@ -26,7 +26,7 @@ const FALLBACK: Plan[] = [
|
|
| 26 |
tagline: "For trying Notestack on your archive",
|
| 27 |
price_monthly_usd: 0,
|
| 28 |
features: [
|
| 29 |
-
"1 source, your
|
| 30 |
"Grounded research chat with citations",
|
| 31 |
"3 min of audio overviews a month",
|
| 32 |
"1 min of video a month",
|
|
|
|
| 26 |
tagline: "For trying Notestack on your archive",
|
| 27 |
price_monthly_usd: 0,
|
| 28 |
features: [
|
| 29 |
+
"1 source, your latest 5 posts indexed",
|
| 30 |
"Grounded research chat with citations",
|
| 31 |
"3 min of audio overviews a month",
|
| 32 |
"1 min of video a month",
|
frontend/src/components/PublicChrome.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import { Link, NavLink } from "react-router-dom";
|
| 2 |
import { useAuth } from "../hooks/useAuth";
|
| 3 |
import { blogPosts } from "../content/blogPosts";
|
|
|
|
| 4 |
import Logo from "./Logo";
|
| 5 |
|
| 6 |
export function PublicNav() {
|
|
@@ -14,6 +15,7 @@ export function PublicNav() {
|
|
| 14 |
<nav className="nav-links" aria-label="Main">
|
| 15 |
<NavLink to="/#features">Features</NavLink>
|
| 16 |
<NavLink to="/pricing">Pricing</NavLink>
|
|
|
|
| 17 |
<NavLink to="/blogs">Blog</NavLink>
|
| 18 |
</nav>
|
| 19 |
{user ? (
|
|
@@ -52,6 +54,14 @@ export function PublicFooter() {
|
|
| 52 |
<Link to="/blogs">Blog</Link>
|
| 53 |
<Link to="/auth">Sign in</Link>
|
| 54 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
<div>
|
| 56 |
<p className="eyebrow">From the blog</p>
|
| 57 |
{blogPosts.slice(0, 3).map((p) => (
|
|
|
|
| 1 |
import { Link, NavLink } from "react-router-dom";
|
| 2 |
import { useAuth } from "../hooks/useAuth";
|
| 3 |
import { blogPosts } from "../content/blogPosts";
|
| 4 |
+
import { tools } from "../content/tools";
|
| 5 |
import Logo from "./Logo";
|
| 6 |
|
| 7 |
export function PublicNav() {
|
|
|
|
| 15 |
<nav className="nav-links" aria-label="Main">
|
| 16 |
<NavLink to="/#features">Features</NavLink>
|
| 17 |
<NavLink to="/pricing">Pricing</NavLink>
|
| 18 |
+
<NavLink to="/tools">Free tools</NavLink>
|
| 19 |
<NavLink to="/blogs">Blog</NavLink>
|
| 20 |
</nav>
|
| 21 |
{user ? (
|
|
|
|
| 54 |
<Link to="/blogs">Blog</Link>
|
| 55 |
<Link to="/auth">Sign in</Link>
|
| 56 |
</div>
|
| 57 |
+
<div>
|
| 58 |
+
<p className="eyebrow">Free tools</p>
|
| 59 |
+
{tools.map((t) => (
|
| 60 |
+
<Link key={t.slug} to={`/tools/${t.slug}`}>
|
| 61 |
+
{t.eyebrow}
|
| 62 |
+
</Link>
|
| 63 |
+
))}
|
| 64 |
+
</div>
|
| 65 |
<div>
|
| 66 |
<p className="eyebrow">From the blog</p>
|
| 67 |
{blogPosts.slice(0, 3).map((p) => (
|
frontend/src/components/Reader.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import { useEffect, useRef, useState } from "react";
|
| 2 |
import { docsApi } from "../api/endpoints";
|
| 3 |
import type { DocDetail } from "../api/types";
|
|
|
|
| 4 |
import { Drawer, formatDate, Loading } from "./ui";
|
| 5 |
|
| 6 |
/** Reads a post from the corpus with the exact line numbers citations point at. */
|
|
@@ -16,6 +17,7 @@ export function Reader({
|
|
| 16 |
const [doc, setDoc] = useState<DocDetail | null>(null);
|
| 17 |
const [error, setError] = useState<string | null>(null);
|
| 18 |
const first = useRef<HTMLDivElement>(null);
|
|
|
|
| 19 |
|
| 20 |
useEffect(() => {
|
| 21 |
setDoc(null);
|
|
@@ -69,6 +71,20 @@ export function Reader({
|
|
| 69 |
);
|
| 70 |
})}
|
| 71 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
</article>
|
| 73 |
)}
|
| 74 |
</Drawer>
|
|
|
|
| 1 |
import { useEffect, useRef, useState } from "react";
|
| 2 |
import { docsApi } from "../api/endpoints";
|
| 3 |
import type { DocDetail } from "../api/types";
|
| 4 |
+
import { useUpgrade } from "../hooks/useUpgrade";
|
| 5 |
import { Drawer, formatDate, Loading } from "./ui";
|
| 6 |
|
| 7 |
/** Reads a post from the corpus with the exact line numbers citations point at. */
|
|
|
|
| 17 |
const [doc, setDoc] = useState<DocDetail | null>(null);
|
| 18 |
const [error, setError] = useState<string | null>(null);
|
| 19 |
const first = useRef<HTMLDivElement>(null);
|
| 20 |
+
const { openUpgrade } = useUpgrade();
|
| 21 |
|
| 22 |
useEffect(() => {
|
| 23 |
setDoc(null);
|
|
|
|
| 71 |
);
|
| 72 |
})}
|
| 73 |
</div>
|
| 74 |
+
{doc.preview && (
|
| 75 |
+
<div className="welcome-cap locked-cap">
|
| 76 |
+
<div>
|
| 77 |
+
<strong>This post is not indexed</strong>
|
| 78 |
+
<p className="muted small">
|
| 79 |
+
Your plan indexes your latest {doc.limit ?? 5} posts. Upgrade to index this one and the rest of your
|
| 80 |
+
archive, so you can ask about it and turn it into audio, video and Launch Kits.
|
| 81 |
+
</p>
|
| 82 |
+
</div>
|
| 83 |
+
<button type="button" className="btn btn-small btn-primary" onClick={() => openUpgrade("indexed_posts")}>
|
| 84 |
+
Upgrade to index it
|
| 85 |
+
</button>
|
| 86 |
+
</div>
|
| 87 |
+
)}
|
| 88 |
</article>
|
| 89 |
)}
|
| 90 |
</Drawer>
|
frontend/src/content/blogPosts.ts
CHANGED
|
@@ -46,6 +46,7 @@ export const blogPosts: BlogPost[] = [
|
|
| 46 |
paragraphs: [
|
| 47 |
"Paste your Substack URL. Pick a post or build a notebook of related posts. Choose a format: deep dive, brief or debate. Notestack writes the script, you review it, and ElevenLabs voices it.",
|
| 48 |
"Publish the file to your podcast feed, attach it to the original post, or cut a 60 second audiogram for social.",
|
|
|
|
| 49 |
],
|
| 50 |
},
|
| 51 |
],
|
|
|
|
| 46 |
paragraphs: [
|
| 47 |
"Paste your Substack URL. Pick a post or build a notebook of related posts. Choose a format: deep dive, brief or debate. Notestack writes the script, you review it, and ElevenLabs voices it.",
|
| 48 |
"Publish the file to your podcast feed, attach it to the original post, or cut a 60 second audiogram for social.",
|
| 49 |
+
"Want to see the shape of the episode before you connect anything? Paste a post into the free [AI podcast generator](/tools/ai-podcast-generator) for a two host script, or use the [podcast script generator](/tools/podcast-script-generator) if you plan to record it yourself.",
|
| 50 |
],
|
| 51 |
},
|
| 52 |
],
|
frontend/src/content/missionPosts.ts
CHANGED
|
@@ -77,6 +77,7 @@ export const missionPosts: BlogPost[] = [
|
|
| 77 |
{
|
| 78 |
heading: "For audio and video overviews",
|
| 79 |
paragraphs: [
|
|
|
|
| 80 |
"Audio overviews are the feature people miss most. Notestack makes two host audio overviews from a post or a notebook, with every line tied back to a passage, and you can pick from ElevenLabs voices or clone your own with consent.",
|
| 81 |
"Video is where dedicated tools win. If your source is a report, paper or deck, [PDF2Video](https://pdf2vid.com) turns any PDF into a narrated video. If it is a blog post, URL or script, [Blog2Video](https://blog2video.app) turns it into a finished video without you touching an editor. Both are a better fit than a notebook when the video itself is the deliverable.",
|
| 82 |
],
|
|
@@ -86,7 +87,7 @@ export const missionPosts: BlogPost[] = [
|
|
| 86 |
heading: "How to switch without losing your work",
|
| 87 |
paragraphs: [
|
| 88 |
"Export what you can first: your notes, saved answers and the list of sources. Then rebuild around the job you named at the start rather than recreating every notebook one to one.",
|
| 89 |
-
"If you are a writer, the fastest path is to connect your publication and let the archive rebuild itself. Once it is indexed, list it on [BlogHub](https://bloghub.app), a free blog and newsletter directory, so new readers can find the work you are now researching and repurposing.",
|
| 90 |
],
|
| 91 |
},
|
| 92 |
],
|
|
@@ -173,7 +174,7 @@ export const missionPosts: BlogPost[] = [
|
|
| 173 |
heading: "Stage four: share what you found",
|
| 174 |
paragraphs: [
|
| 175 |
"Research that stays in a document rarely gets read. Turn the dense parts into formats people actually consume. [PDF2Video](https://pdf2vid.com) turns a paper, report or slide deck into a narrated video, which is often the fastest way to brief a team or a class.",
|
| 176 |
-
"For findings you have written up as a post, [Blog2Video](https://blog2video.app) makes a video from the URL, and an audio overview gives listeners the two host version while you keep every line grounded in the source.",
|
| 177 |
],
|
| 178 |
},
|
| 179 |
{
|
|
@@ -274,7 +275,7 @@ export const missionPosts: BlogPost[] = [
|
|
| 274 |
heading: "Distribution without the busywork",
|
| 275 |
paragraphs: [
|
| 276 |
"Deep posts deserve a long life, and that is where AI saves the most time. A Launch Kit turns one post into a thread, a LinkedIn post, Substack Notes and a carousel, written from claims the post actually makes.",
|
| 277 |
-
"Video extends the reach further: [Blog2Video](https://blog2video.app) turns a finished post into a video for YouTube or Shorts. And make sure new readers can find you at all by listing your publication on [BlogHub](https://bloghub.app), a free directory of blogs and newsletters.",
|
| 278 |
],
|
| 279 |
},
|
| 280 |
{
|
|
@@ -302,7 +303,7 @@ export const missionPosts: BlogPost[] = [
|
|
| 302 |
{
|
| 303 |
question: "How do I grow readers for a newsletter?",
|
| 304 |
answer:
|
| 305 |
-
"Repurpose each post into formats people discover on other platforms, and list your publication on [BlogHub](https://bloghub.app) so readers browsing for new blogs and newsletters can find it.",
|
| 306 |
},
|
| 307 |
{
|
| 308 |
question: "Can AI turn my blog posts into videos?",
|
|
@@ -348,7 +349,7 @@ export const missionPosts: BlogPost[] = [
|
|
| 348 |
heading: "Where ChatGPT wins",
|
| 349 |
paragraphs: [
|
| 350 |
"ChatGPT is the better thinking partner when you do not have sources yet. It explains unfamiliar concepts, suggests angles, drafts outlines and argues the other side.",
|
| 351 |
-
"Use it early and loosely. Anything factual that survives into your final draft should be checked against a real source.",
|
| 352 |
],
|
| 353 |
bullets: ["Brainstorming and outlining", "Explaining concepts you are new to", "Stress testing an argument"],
|
| 354 |
},
|
|
|
|
| 77 |
{
|
| 78 |
heading: "For audio and video overviews",
|
| 79 |
paragraphs: [
|
| 80 |
+
"For a quick start, the free [AI podcast generator](/tools/ai-podcast-generator) turns any post into a two host script, and [PDF to podcast](/tools/pdf-to-podcast) does the same for papers and reports.",
|
| 81 |
"Audio overviews are the feature people miss most. Notestack makes two host audio overviews from a post or a notebook, with every line tied back to a passage, and you can pick from ElevenLabs voices or clone your own with consent.",
|
| 82 |
"Video is where dedicated tools win. If your source is a report, paper or deck, [PDF2Video](https://pdf2vid.com) turns any PDF into a narrated video. If it is a blog post, URL or script, [Blog2Video](https://blog2video.app) turns it into a finished video without you touching an editor. Both are a better fit than a notebook when the video itself is the deliverable.",
|
| 83 |
],
|
|
|
|
| 87 |
heading: "How to switch without losing your work",
|
| 88 |
paragraphs: [
|
| 89 |
"Export what you can first: your notes, saved answers and the list of sources. Then rebuild around the job you named at the start rather than recreating every notebook one to one.",
|
| 90 |
+
"If you are a writer, the fastest path is to connect your publication and let the archive rebuild itself. Once it is indexed, list it on [BlogHub](https://bloghub.app/submit-your-newsletter), a free blog and newsletter directory, so new readers can find the work you are now researching and repurposing.",
|
| 91 |
],
|
| 92 |
},
|
| 93 |
],
|
|
|
|
| 174 |
heading: "Stage four: share what you found",
|
| 175 |
paragraphs: [
|
| 176 |
"Research that stays in a document rarely gets read. Turn the dense parts into formats people actually consume. [PDF2Video](https://pdf2vid.com) turns a paper, report or slide deck into a narrated video, which is often the fastest way to brief a team or a class.",
|
| 177 |
+
"For findings you have written up as a post, [Blog2Video](https://blog2video.app/url-to-video) makes a video from the URL, and an audio overview gives listeners the two host version while you keep every line grounded in the source.",
|
| 178 |
],
|
| 179 |
},
|
| 180 |
{
|
|
|
|
| 275 |
heading: "Distribution without the busywork",
|
| 276 |
paragraphs: [
|
| 277 |
"Deep posts deserve a long life, and that is where AI saves the most time. A Launch Kit turns one post into a thread, a LinkedIn post, Substack Notes and a carousel, written from claims the post actually makes.",
|
| 278 |
+
"Video extends the reach further: [Blog2Video](https://blog2video.app/blog-to-shorts) turns a finished post into a video for YouTube or Shorts. And make sure new readers can find you at all by listing your publication on [BlogHub](https://bloghub.app/submit-your-newsletter), a free directory of blogs and newsletters.",
|
| 279 |
],
|
| 280 |
},
|
| 281 |
{
|
|
|
|
| 303 |
{
|
| 304 |
question: "How do I grow readers for a newsletter?",
|
| 305 |
answer:
|
| 306 |
+
"Repurpose each post into formats people discover on other platforms, and list your publication on [BlogHub](https://bloghub.app/submit-your-newsletter) so readers browsing for new blogs and newsletters can find it. For more places to share your work, see this list of [article submission sites](https://bloghub.app/blogs/article-submission-sites).",
|
| 307 |
},
|
| 308 |
{
|
| 309 |
question: "Can AI turn my blog posts into videos?",
|
|
|
|
| 349 |
heading: "Where ChatGPT wins",
|
| 350 |
paragraphs: [
|
| 351 |
"ChatGPT is the better thinking partner when you do not have sources yet. It explains unfamiliar concepts, suggests angles, drafts outlines and argues the other side.",
|
| 352 |
+
"Use it early and loosely. Anything factual that survives into your final draft should be checked against a real source. And if you are wondering whether it can go further and produce media, here is the honest answer to [can ChatGPT make videos](https://blog2video.app/blogs/can-chatgpt-make-videos).",
|
| 353 |
],
|
| 354 |
bullets: ["Brainstorming and outlining", "Explaining concepts you are new to", "Stress testing an argument"],
|
| 355 |
},
|
frontend/src/content/notebooklmAlternative.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Copy for /notebooklm-alternative, shared by the React page and the build time prerender (seo/prerender.ts).
|
| 2 |
+
|
| 3 |
+
export const NOTEBOOKLM_ALT_META = {
|
| 4 |
+
title: "Notestack vs NotebookLM: The NotebookLM Alternative for Writers | Notestack",
|
| 5 |
+
description:
|
| 6 |
+
"Notestack vs NotebookLM, side by side. Notestack is the NotebookLM alternative for writers, bloggers and newsletters: sync your archive, get answers cited to the exact line, and turn your writing into audio, video and launch posts.",
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
export const NOTEBOOKLM_ALT_ROWS: { feature: string; notestack: string; notebooklm: string }[] = [
|
| 10 |
+
{
|
| 11 |
+
feature: "Getting your writing in",
|
| 12 |
+
notestack: "Syncs any blog, newsletter or site feed (Substack, Ghost, WordPress, Medium, RSS), plus markdown, text, HTML and PDF uploads",
|
| 13 |
+
notebooklm: "Add sources one at a time: PDFs, Docs, web pages, videos",
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
feature: "Stays in sync",
|
| 17 |
+
notestack: "New posts arrive automatically on every sync",
|
| 18 |
+
notebooklm: "Re-add sources by hand when they change",
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
feature: "Citations",
|
| 22 |
+
notestack: "Exact line ranges, read back and verified before you see them",
|
| 23 |
+
notebooklm: "Passage level citations to the source",
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
feature: "When the answer is not in your sources",
|
| 27 |
+
notestack: "Says so instead of guessing; unverifiable claims are dropped",
|
| 28 |
+
notebooklm: "Grounded in sources, with passage citations",
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
feature: "Chat memory",
|
| 32 |
+
notestack: "Follow ups resolved from the conversation; off topic questions answered instantly",
|
| 33 |
+
notebooklm: "Conversation within a notebook",
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
feature: "Audio overviews",
|
| 37 |
+
notestack: "Two hosts, ElevenLabs voices, delivery controls, or your own consented voice clone",
|
| 38 |
+
notebooklm: "Two host audio overviews with Google voices",
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
feature: "Video",
|
| 42 |
+
notestack: "9:16 shorts, 16:9 explainers and 1:1 audiograms with captions and your brand",
|
| 43 |
+
notebooklm: "Video overviews",
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
feature: "Publishing",
|
| 47 |
+
notestack: "Launch Kits (threads, LinkedIn, Notes, Bluesky, SEO, carousels, quote cards) and a scheduling calendar",
|
| 48 |
+
notebooklm: "Not a publishing tool",
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
feature: "Your voice",
|
| 52 |
+
notestack: "Voice profile learned from your posts, used for every draft",
|
| 53 |
+
notebooklm: "General purpose writing",
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
feature: "Seeing your archive",
|
| 57 |
+
notestack: "Topic map with rising and dormant topics, plus evergreen resurfacing",
|
| 58 |
+
notebooklm: "Mind maps per notebook",
|
| 59 |
+
},
|
| 60 |
+
];
|
| 61 |
+
|
| 62 |
+
export const NOTEBOOKLM_ALT_FAQ = [
|
| 63 |
+
{
|
| 64 |
+
q: "What is the best NotebookLM alternative?",
|
| 65 |
+
a: "For writers, bloggers and newsletters, Notestack is the ultimate NotebookLM alternative: it syncs your whole archive, cites the exact lines behind every answer, and turns what you wrote into audio, video and launch posts. For academic papers, discovery tools like Elicit or Consensus pair well with it.",
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
q: "Is Notestack free?",
|
| 69 |
+
a: "Yes, there is a free plan to try it on your latest posts. Paid plans add more sources, more posts, more audio and video minutes and voice cloning.",
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
q: "Can Notestack make NotebookLM style audio overviews?",
|
| 73 |
+
a: "Yes. It writes a two host conversation grounded in your posts and voices it with ElevenLabs, with every line tied to the passage it came from. You can choose the voices or clone your own with consent.",
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
q: "What if my writing is not on Substack?",
|
| 77 |
+
a: "Notestack works with any blog, newsletter or site that has a feed, single article URLs, and uploaded markdown, text, HTML or PDF files.",
|
| 78 |
+
},
|
| 79 |
+
];
|
frontend/src/content/tools.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Free tools at /tools/<slug>. Copy is shared by the React page and the build time prerender (seo/prerender.ts).
|
| 2 |
+
// Every tool widget requires sign in; the copy below stays public so the page can rank.
|
| 3 |
+
// Volumes are US Google Ads, Sep 2026. No em dashes (npm run check:copy).
|
| 4 |
+
|
| 5 |
+
export type ToolSection = { heading: string; paragraphs: string[]; bullets?: string[] };
|
| 6 |
+
|
| 7 |
+
export type ToolDef = {
|
| 8 |
+
slug: string;
|
| 9 |
+
/** <title> and meta description. */
|
| 10 |
+
metaTitle: string;
|
| 11 |
+
description: string;
|
| 12 |
+
eyebrow: string;
|
| 13 |
+
heroTitle: string;
|
| 14 |
+
heroDescription: string;
|
| 15 |
+
primaryKeyword: string;
|
| 16 |
+
/** What the widget asks for. */
|
| 17 |
+
inputLabel: string;
|
| 18 |
+
inputPlaceholder: string;
|
| 19 |
+
/** Optional line under the input (inline [text](url) links allowed). */
|
| 20 |
+
inputHint?: string;
|
| 21 |
+
sections: ToolSection[];
|
| 22 |
+
faq: { question: string; answer: string }[];
|
| 23 |
+
relatedPaths: string[];
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
export const toolsHub = {
|
| 27 |
+
path: "/tools",
|
| 28 |
+
metaTitle: "Free AI Podcast Tools for Writers | Notestack",
|
| 29 |
+
description:
|
| 30 |
+
"Free tools that turn your writing into podcasts: an AI podcast generator, a PDF to podcast converter and a two host podcast script generator.",
|
| 31 |
+
heroTitle: "Free tools for turning writing into audio",
|
| 32 |
+
heroDescription:
|
| 33 |
+
"Every tool works on words you already wrote. Sign in free, paste a post or a PDF's text, and get a two host script you can voice in Notestack.",
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
export const tools: ToolDef[] = [
|
| 37 |
+
{
|
| 38 |
+
slug: "ai-podcast-generator",
|
| 39 |
+
metaTitle: "Free AI Podcast Generator: Turn Any Post Into a Two Host Show | Notestack",
|
| 40 |
+
description:
|
| 41 |
+
"Free AI podcast generator for writers. Paste a blog post or newsletter and get a two host podcast script grounded in your words, then voice it as a full audio overview in Notestack.",
|
| 42 |
+
eyebrow: "AI podcast generator",
|
| 43 |
+
heroTitle: "AI podcast generator for writers",
|
| 44 |
+
heroDescription:
|
| 45 |
+
"Paste a blog post, newsletter or article and get a two host podcast script built only from what you wrote. Voice it as a finished audio overview in one click.",
|
| 46 |
+
primaryKeyword: "ai podcast generator",
|
| 47 |
+
inputLabel: "Paste your post",
|
| 48 |
+
inputPlaceholder: "Paste a blog post, newsletter issue or article. The script is built only from this text.",
|
| 49 |
+
sections: [
|
| 50 |
+
{
|
| 51 |
+
heading: "How the AI podcast generator works",
|
| 52 |
+
paragraphs: [
|
| 53 |
+
"Most AI podcast generators write a new show and hope it matches your ideas. This one starts from your text. It picks the sentences that carry your argument, then stages them as a conversation between two hosts: one who guides, one who asks the questions a listener would ask.",
|
| 54 |
+
"Because every host line comes from a passage you wrote, nothing is invented. In the full Notestack app each line keeps a citation back to the exact source lines, so you can check the show before anyone hears it.",
|
| 55 |
+
],
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
heading: "From script to finished episode",
|
| 59 |
+
paragraphs: [
|
| 60 |
+
"The free tool gives you the script. To hear it, connect your blog or newsletter to Notestack and generate an audio overview: two natural voices, delivery controls, and optional consented voice cloning so one host can sound like you. The free plan includes audio minutes to try it.",
|
| 61 |
+
"Want a video instead of audio? [Blog2Video](https://blog2video.app/blog-to-video) turns the same post into a narrated video, and [PDF2Video](https://pdf2vid.com) does the same for reports and papers.",
|
| 62 |
+
],
|
| 63 |
+
bullets: [
|
| 64 |
+
"Two hosts, grounded in your own post",
|
| 65 |
+
"Runtime estimate before you record anything",
|
| 66 |
+
"Copy or download the script as text",
|
| 67 |
+
"Voice it in Notestack with two host audio overviews",
|
| 68 |
+
],
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
heading: "Why writers use a podcast generator",
|
| 72 |
+
paragraphs: [
|
| 73 |
+
"A post that took a week to write gets read once. A two host episode of the same post reaches people on walks, commutes and at the gym, and it keeps your archive working long after publish day. It is the fastest way to add audio to a newsletter without booking a guest or sitting at a microphone.",
|
| 74 |
+
"If you are comparing tools, our guide to the [best NotebookLM alternatives](/blogs/notebooklm-alternatives) covers the other audio overview options, and [Notestack vs NotebookLM](/notebooklm-alternative) explains the difference side by side.",
|
| 75 |
+
],
|
| 76 |
+
},
|
| 77 |
+
],
|
| 78 |
+
faq: [
|
| 79 |
+
{
|
| 80 |
+
question: "Is the AI podcast generator free?",
|
| 81 |
+
answer:
|
| 82 |
+
"Yes. Sign in with a free Notestack account and generate as many scripts as you like. Turning a script into audio uses the audio minutes included in every plan, free plan included.",
|
| 83 |
+
},
|
| 84 |
+
{
|
| 85 |
+
question: "Does it make up content?",
|
| 86 |
+
answer:
|
| 87 |
+
"No. The script is assembled only from sentences in the text you paste. The hosts reframe and question your points, but the substance is always yours.",
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
question: "How is this different from NotebookLM audio overviews?",
|
| 91 |
+
answer:
|
| 92 |
+
"NotebookLM works on sources you upload one at a time. Notestack syncs your whole blog or newsletter, keeps citations to the exact lines, and can voice one host in your own cloned voice with consent.",
|
| 93 |
+
},
|
| 94 |
+
{
|
| 95 |
+
question: "How long will my episode be?",
|
| 96 |
+
answer:
|
| 97 |
+
"The tool estimates runtime at about 150 spoken words per minute. A 1,500 word post usually becomes a six to nine minute episode.",
|
| 98 |
+
},
|
| 99 |
+
],
|
| 100 |
+
relatedPaths: ["/tools/pdf-to-podcast", "/tools/podcast-script-generator", "/blogs/turn-substack-archive-into-podcast", "/notebooklm-alternative"],
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
slug: "pdf-to-podcast",
|
| 104 |
+
metaTitle: "PDF to Podcast: Turn Any PDF Into a Two Host Podcast Free | Notestack",
|
| 105 |
+
description:
|
| 106 |
+
"Turn a PDF, research paper or report into a two host podcast. Paste the PDF's text or upload the file to Notestack, and get an audio overview grounded in the document.",
|
| 107 |
+
eyebrow: "PDF to podcast",
|
| 108 |
+
heroTitle: "Turn any PDF into a podcast",
|
| 109 |
+
heroDescription:
|
| 110 |
+
"Research papers, reports, ebooks and slide decks become a two host conversation you can listen to. Every line stays tied to the page it came from.",
|
| 111 |
+
primaryKeyword: "pdf to podcast",
|
| 112 |
+
inputLabel: "Paste the text from your PDF",
|
| 113 |
+
inputPlaceholder: "Paste the text of your PDF here. The script is built only from this text.",
|
| 114 |
+
inputHint:
|
| 115 |
+
"Need the text first? Pull it out with the free [PDF to text tool](https://pdf2vid.com/tools/pdf-to-text), or upload the PDF straight into a Notestack notebook.",
|
| 116 |
+
sections: [
|
| 117 |
+
{
|
| 118 |
+
heading: "How PDF to podcast works",
|
| 119 |
+
paragraphs: [
|
| 120 |
+
"Paste the text from your PDF and the tool pulls out the sentences that carry the document: the claim, the evidence and the conclusion. It stages them as a two host conversation, with one host explaining and the other asking what a listener would ask.",
|
| 121 |
+
"In the Notestack app you can skip the copy and paste. Upload the PDF into a notebook and generate a full audio overview, with every host line cited to the page and lines it came from.",
|
| 122 |
+
],
|
| 123 |
+
},
|
| 124 |
+
{
|
| 125 |
+
heading: "Which PDFs make the best podcasts",
|
| 126 |
+
paragraphs: [
|
| 127 |
+
"Documents with an argument work best: research papers, white papers, annual reports, long reads and ebook chapters. Pure reference material such as manuals or spreadsheets makes a thinner show.",
|
| 128 |
+
],
|
| 129 |
+
bullets: [
|
| 130 |
+
"Research papers: the hosts walk through the question, method and finding",
|
| 131 |
+
"Reports: turn the executive summary into a ten minute briefing",
|
| 132 |
+
"Ebooks: one episode per chapter becomes a series",
|
| 133 |
+
"Slide decks: the narration fills in what the slides leave out",
|
| 134 |
+
],
|
| 135 |
+
},
|
| 136 |
+
{
|
| 137 |
+
heading: "Podcast or video?",
|
| 138 |
+
paragraphs: [
|
| 139 |
+
"Audio suits people who listen on the move. If your audience watches instead, [PDF2Video](https://pdf2vid.com) turns the same PDF into a narrated video with slides, and its [PDF to audio](https://pdf2vid.com/tools/pdf-to-audio) tool reads a document aloud word for word. A podcast is different: two hosts talk the ideas through.",
|
| 140 |
+
"Researching across many PDFs? Read our guide to the [best AI for research](/blogs/best-ai-for-research).",
|
| 141 |
+
],
|
| 142 |
+
},
|
| 143 |
+
],
|
| 144 |
+
faq: [
|
| 145 |
+
{
|
| 146 |
+
question: "Can I turn a PDF into a podcast for free?",
|
| 147 |
+
answer:
|
| 148 |
+
"Yes. Sign in free, paste the PDF's text and get a two host script. Voicing it in Notestack uses the audio minutes included in the free plan.",
|
| 149 |
+
},
|
| 150 |
+
{
|
| 151 |
+
question: "Does it work with scanned PDFs?",
|
| 152 |
+
answer:
|
| 153 |
+
"The tool needs selectable text. If your PDF is a scan, run it through OCR first, then paste the text.",
|
| 154 |
+
},
|
| 155 |
+
{
|
| 156 |
+
question: "Is PDF to podcast the same as PDF to audio?",
|
| 157 |
+
answer:
|
| 158 |
+
"No. PDF to audio reads the document aloud. PDF to podcast turns it into a conversation between two hosts who explain and question the ideas, which is easier to follow for long or technical documents.",
|
| 159 |
+
},
|
| 160 |
+
],
|
| 161 |
+
relatedPaths: ["/tools/ai-podcast-generator", "/tools/podcast-script-generator", "/blogs/best-ai-for-research", "/notebooklm-alternative"],
|
| 162 |
+
},
|
| 163 |
+
{
|
| 164 |
+
slug: "podcast-script-generator",
|
| 165 |
+
metaTitle: "Free Podcast Script Generator: Two Host Scripts From Your Writing | Notestack",
|
| 166 |
+
description:
|
| 167 |
+
"Free podcast script generator. Paste your notes, post or article and get a formatted two host podcast script with an intro, segments, questions and an outro, built only from your words.",
|
| 168 |
+
eyebrow: "Podcast script generator",
|
| 169 |
+
heroTitle: "Podcast script generator",
|
| 170 |
+
heroDescription:
|
| 171 |
+
"Turn notes, a post or an article into a ready to record two host script: cold open, segments, the questions your listeners would ask, and a clean outro.",
|
| 172 |
+
primaryKeyword: "podcast script generator",
|
| 173 |
+
inputLabel: "Paste your notes or post",
|
| 174 |
+
inputPlaceholder: "Paste notes, an outline or a full post. The script is built only from this text.",
|
| 175 |
+
sections: [
|
| 176 |
+
{
|
| 177 |
+
heading: "What you get",
|
| 178 |
+
paragraphs: [
|
| 179 |
+
"A script in the standard two host format: a cold open that states the big idea, segments built from your key points, host questions that set up each point, and an outro that lands the takeaway. Each line is labelled with its speaker so you can record straight from it.",
|
| 180 |
+
],
|
| 181 |
+
bullets: [
|
| 182 |
+
"Speaker labelled lines for Host A and Host B",
|
| 183 |
+
"Segments ordered the way your text builds its argument",
|
| 184 |
+
"Estimated runtime at a natural speaking pace",
|
| 185 |
+
"Plain text you can copy into any teleprompter or doc",
|
| 186 |
+
],
|
| 187 |
+
},
|
| 188 |
+
{
|
| 189 |
+
heading: "Record it, or let Notestack voice it",
|
| 190 |
+
paragraphs: [
|
| 191 |
+
"Record the script yourself with a co host, or generate a voiced audio overview in Notestack with two natural voices. Writers who publish every week connect their newsletter once, and every new post can become an episode.",
|
| 192 |
+
"Turning a whole back catalog into a show? Read [how to turn your Substack archive into a podcast](/blogs/turn-substack-archive-into-podcast).",
|
| 193 |
+
],
|
| 194 |
+
},
|
| 195 |
+
{
|
| 196 |
+
heading: "Tips for a better script",
|
| 197 |
+
paragraphs: ["The generator follows your text, so the clearer the source, the better the show."],
|
| 198 |
+
bullets: [
|
| 199 |
+
"Paste the full post, not a summary: the hosts need your specifics",
|
| 200 |
+
"Keep one idea per paragraph so segments split cleanly",
|
| 201 |
+
"Edit the questions to sound like your real audience",
|
| 202 |
+
"Aim for 800 to 2,000 words for a five to fifteen minute episode",
|
| 203 |
+
],
|
| 204 |
+
},
|
| 205 |
+
],
|
| 206 |
+
faq: [
|
| 207 |
+
{
|
| 208 |
+
question: "Is the podcast script generator free?",
|
| 209 |
+
answer: "Yes. It is free with a Notestack account. Sign in and generate as many scripts as you need.",
|
| 210 |
+
},
|
| 211 |
+
{
|
| 212 |
+
question: "Can I use the script commercially?",
|
| 213 |
+
answer: "Yes. The script is built from your own text, so it is yours to record, publish and monetize.",
|
| 214 |
+
},
|
| 215 |
+
{
|
| 216 |
+
question: "Does it support one host shows?",
|
| 217 |
+
answer:
|
| 218 |
+
"The generator writes for two hosts, the format that keeps long ideas easy to follow. For a solo show, read Host A's lines and turn Host B's questions into your own transitions.",
|
| 219 |
+
},
|
| 220 |
+
],
|
| 221 |
+
relatedPaths: ["/tools/ai-podcast-generator", "/tools/pdf-to-podcast", "/blogs/substack-launch-kit", "/blogs/ai-tools-for-writers"],
|
| 222 |
+
},
|
| 223 |
+
];
|
| 224 |
+
|
| 225 |
+
export const getTool = (slug: string) => tools.find((t) => t.slug === slug);
|
frontend/src/lib/podcastScript.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Builds a two host podcast script from pasted text, entirely in the browser. Every substantive line is a sentence
|
| 2 |
+
// from the source; the hosts only add framing and questions around it, so nothing is invented.
|
| 3 |
+
|
| 4 |
+
export type ScriptLine = { host: "A" | "B"; text: string };
|
| 5 |
+
export type PodcastScript = { lines: ScriptLine[]; words: number; minutes: number; sourceSentences: number };
|
| 6 |
+
|
| 7 |
+
const WORDS_PER_MINUTE = 150;
|
| 8 |
+
|
| 9 |
+
const STOP = new Set(
|
| 10 |
+
"a an and are as at be but by for from has have he her his i if in into is it its of on or our she so than that the their them then there these they this to was we were what when which who will with you your not can do does did just also more most very about over out up".split(
|
| 11 |
+
" ",
|
| 12 |
+
),
|
| 13 |
+
);
|
| 14 |
+
|
| 15 |
+
const QUESTIONS = [
|
| 16 |
+
"So where does it start?",
|
| 17 |
+
"Okay, and why does that matter?",
|
| 18 |
+
"What is the part people usually miss?",
|
| 19 |
+
"Can you make that concrete?",
|
| 20 |
+
"So what changes because of that?",
|
| 21 |
+
"Is there a catch?",
|
| 22 |
+
"How does that play out in practice?",
|
| 23 |
+
"And where does that leave us?",
|
| 24 |
+
];
|
| 25 |
+
|
| 26 |
+
const words = (s: string) => s.trim().split(/\s+/).filter(Boolean);
|
| 27 |
+
|
| 28 |
+
function sentences(text: string): string[] {
|
| 29 |
+
return text
|
| 30 |
+
.replace(/\r/g, "")
|
| 31 |
+
.split(/\n{2,}|(?<=[.!?])\s+(?=[A-Z0-9"'(])/)
|
| 32 |
+
.map((s) => s.replace(/\s+/g, " ").trim())
|
| 33 |
+
.filter((s) => {
|
| 34 |
+
const n = words(s).length;
|
| 35 |
+
return n >= 6 && n <= 60 && /[a-z]/i.test(s);
|
| 36 |
+
});
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/** Scores sentences by how many of the text's recurring terms they carry, with a small lead bias. */
|
| 40 |
+
function pickPoints(all: string[]): string[] {
|
| 41 |
+
const freq = new Map<string, number>();
|
| 42 |
+
for (const s of all)
|
| 43 |
+
for (const w of words(s.toLowerCase().replace(/[^a-z0-9\s]/g, " ")))
|
| 44 |
+
if (w.length > 3 && !STOP.has(w)) freq.set(w, (freq.get(w) ?? 0) + 1);
|
| 45 |
+
const scored = all.map((s, i) => {
|
| 46 |
+
const terms = words(s.toLowerCase().replace(/[^a-z0-9\s]/g, " ")).filter((w) => freq.has(w));
|
| 47 |
+
const score = terms.reduce((sum, w) => sum + Math.min(freq.get(w) ?? 0, 5), 0) / Math.sqrt(words(s).length);
|
| 48 |
+
return { s, i, score: score + (i === 0 ? 2 : 0) + (i === all.length - 1 ? 1 : 0) };
|
| 49 |
+
});
|
| 50 |
+
return scored
|
| 51 |
+
.sort((a, b) => b.score - a.score)
|
| 52 |
+
// Roughly one point per three source sentences, so longer posts make longer episodes.
|
| 53 |
+
.slice(0, Math.min(16, Math.max(5, Math.round(all.length / 3))))
|
| 54 |
+
.sort((a, b) => a.i - b.i)
|
| 55 |
+
.map((x) => x.s);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
export function buildPodcastScript(text: string): PodcastScript | null {
|
| 59 |
+
const all = sentences(text);
|
| 60 |
+
if (all.length < 3) return null;
|
| 61 |
+
const points = pickPoints(all);
|
| 62 |
+
const [opening, ...body] = points;
|
| 63 |
+
const closing = body.length > 2 ? body.pop()! : undefined;
|
| 64 |
+
|
| 65 |
+
const lines: ScriptLine[] = [
|
| 66 |
+
{ host: "A", text: "Welcome back. Today we are walking through one piece of writing, and staying close to what it actually says." },
|
| 67 |
+
{ host: "B", text: "Good, because I want the argument, not a summary. What is the big idea?" },
|
| 68 |
+
{ host: "A", text: opening },
|
| 69 |
+
];
|
| 70 |
+
body.forEach((point, i) => {
|
| 71 |
+
lines.push({ host: "B", text: QUESTIONS[i % QUESTIONS.length] });
|
| 72 |
+
lines.push({ host: "A", text: point });
|
| 73 |
+
});
|
| 74 |
+
if (closing) {
|
| 75 |
+
lines.push({ host: "B", text: "If someone remembers one thing from this, what should it be?" });
|
| 76 |
+
lines.push({ host: "A", text: closing });
|
| 77 |
+
}
|
| 78 |
+
lines.push({ host: "B", text: "That is the episode. The full piece goes deeper, so read it if this landed for you." });
|
| 79 |
+
|
| 80 |
+
const count = lines.reduce((n, l) => n + words(l.text).length, 0);
|
| 81 |
+
return { lines, words: count, minutes: count / WORDS_PER_MINUTE, sourceSentences: all.length };
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
export const scriptToText = (script: PodcastScript) =>
|
| 85 |
+
script.lines.map((l) => `HOST ${l.host}: ${l.text}`).join("\n\n");
|
frontend/src/pages/Archive.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import { useCallback, useEffect, useState } from "react";
|
|
|
|
| 2 |
import { artifactsApi } from "../api/endpoints";
|
| 3 |
import type { Artifact } from "../api/types";
|
| 4 |
import { ArtifactCard } from "../components/ArtifactCard";
|
|
@@ -10,7 +11,6 @@ const PAGE = 30;
|
|
| 10 |
|
| 11 |
export default function Archive() {
|
| 12 |
const [filter, setFilter] = useState<Filter>("all");
|
| 13 |
-
const [status, setStatus] = useState<"" | "ready" | "failed">("");
|
| 14 |
const [q, setQ] = useState("");
|
| 15 |
const [items, setItems] = useState<Artifact[] | null>(null);
|
| 16 |
const [total, setTotal] = useState(0);
|
|
@@ -19,12 +19,12 @@ export default function Archive() {
|
|
| 19 |
const load = useCallback(
|
| 20 |
(offset = 0) =>
|
| 21 |
artifactsApi
|
| 22 |
-
.list({ type: filter === "all" ? undefined : filter,
|
| 23 |
.then((p) => {
|
| 24 |
setTotal(p.total);
|
| 25 |
setItems((cur) => (offset ? [...(cur ?? []), ...p.items] : p.items));
|
| 26 |
}),
|
| 27 |
-
[filter,
|
| 28 |
);
|
| 29 |
|
| 30 |
useEffect(() => {
|
|
@@ -34,7 +34,7 @@ export default function Archive() {
|
|
| 34 |
|
| 35 |
return (
|
| 36 |
<div className="page-wrap">
|
| 37 |
-
<PageHeader eyebrow="
|
| 38 |
<div className="row between wrap">
|
| 39 |
<Tabs<Filter>
|
| 40 |
tabs={[
|
|
@@ -48,18 +48,16 @@ export default function Archive() {
|
|
| 48 |
value={filter}
|
| 49 |
onChange={setFilter}
|
| 50 |
/>
|
| 51 |
-
<
|
| 52 |
-
<select className="input input-sm" value={status} onChange={(e) => setStatus(e.target.value as typeof status)} aria-label="Status">
|
| 53 |
-
<option value="">Any status</option>
|
| 54 |
-
<option value="ready">Ready</option>
|
| 55 |
-
<option value="failed">Failed</option>
|
| 56 |
-
</select>
|
| 57 |
-
<input className="input input-sm search" placeholder="Search by post or notebook" value={q} onChange={(e) => setQ(e.target.value)} aria-label="Search" />
|
| 58 |
-
</div>
|
| 59 |
</div>
|
| 60 |
-
<p className="mono muted">{total} artifacts</p>
|
| 61 |
{!items && <Loading />}
|
| 62 |
-
{items?.length === 0 && <EmptyState title="Nothing here yet" body="Summaries, audio, videos and launch kits you create show up here."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
<div className="gallery">
|
| 64 |
{items?.map((a) => (
|
| 65 |
<ArtifactCard
|
|
|
|
| 1 |
import { useCallback, useEffect, useState } from "react";
|
| 2 |
+
import { Link } from "react-router-dom";
|
| 3 |
import { artifactsApi } from "../api/endpoints";
|
| 4 |
import type { Artifact } from "../api/types";
|
| 5 |
import { ArtifactCard } from "../components/ArtifactCard";
|
|
|
|
| 11 |
|
| 12 |
export default function Archive() {
|
| 13 |
const [filter, setFilter] = useState<Filter>("all");
|
|
|
|
| 14 |
const [q, setQ] = useState("");
|
| 15 |
const [items, setItems] = useState<Artifact[] | null>(null);
|
| 16 |
const [total, setTotal] = useState(0);
|
|
|
|
| 19 |
const load = useCallback(
|
| 20 |
(offset = 0) =>
|
| 21 |
artifactsApi
|
| 22 |
+
.list({ type: filter === "all" ? undefined : filter, q, limit: PAGE, offset })
|
| 23 |
.then((p) => {
|
| 24 |
setTotal(p.total);
|
| 25 |
setItems((cur) => (offset ? [...(cur ?? []), ...p.items] : p.items));
|
| 26 |
}),
|
| 27 |
+
[filter, q],
|
| 28 |
);
|
| 29 |
|
| 30 |
useEffect(() => {
|
|
|
|
| 34 |
|
| 35 |
return (
|
| 36 |
<div className="page-wrap">
|
| 37 |
+
<PageHeader eyebrow="Library" title="Everything you have made" />
|
| 38 |
<div className="row between wrap">
|
| 39 |
<Tabs<Filter>
|
| 40 |
tabs={[
|
|
|
|
| 48 |
value={filter}
|
| 49 |
onChange={setFilter}
|
| 50 |
/>
|
| 51 |
+
<input className="input input-sm search" placeholder={`Search ${total} items by post or notebook`} value={q} onChange={(e) => setQ(e.target.value)} aria-label="Search" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
</div>
|
|
|
|
| 53 |
{!items && <Loading />}
|
| 54 |
+
{items?.length === 0 && <EmptyState title="Nothing here yet" body="Summaries, audio, videos and launch kits you create show up here."
|
| 55 |
+
action={
|
| 56 |
+
<Link className="btn btn-primary" to="/app/studio">
|
| 57 |
+
Create something
|
| 58 |
+
</Link>
|
| 59 |
+
}
|
| 60 |
+
/>}
|
| 61 |
<div className="gallery">
|
| 62 |
{items?.map((a) => (
|
| 63 |
<ArtifactCard
|
frontend/src/pages/AuthCallback.tsx
CHANGED
|
@@ -24,7 +24,7 @@ export default function AuthCallback() {
|
|
| 24 |
.redeemTicket(ticket)
|
| 25 |
.then((result) => {
|
| 26 |
complete(result);
|
| 27 |
-
navigate(result.created ? "/welcome" : next, { replace: true });
|
| 28 |
})
|
| 29 |
.catch(() => {
|
| 30 |
const error = new URLSearchParams({ error: "Google sign in expired. Try again." });
|
|
|
|
| 24 |
.redeemTicket(ticket)
|
| 25 |
.then((result) => {
|
| 26 |
complete(result);
|
| 27 |
+
navigate(result.created && !next.startsWith("/tools/") ? "/welcome" : next, { replace: true });
|
| 28 |
})
|
| 29 |
.catch(() => {
|
| 30 |
const error = new URLSearchParams({ error: "Google sign in expired. Try again." });
|
frontend/src/pages/AuthPage.tsx
CHANGED
|
@@ -63,7 +63,7 @@ function GoogleButton({ onCredential }: { onCredential: (c: string) => void }) {
|
|
| 63 |
}, [onCredential]);
|
| 64 |
|
| 65 |
if (!GOOGLE_CLIENT_ID) {
|
| 66 |
-
return
|
| 67 |
}
|
| 68 |
return <div ref={ref} className="google-btn" />;
|
| 69 |
}
|
|
@@ -85,7 +85,8 @@ export default function AuthPage() {
|
|
| 85 |
const next = safeNext(params.get("next"));
|
| 86 |
|
| 87 |
useEffect(() => {
|
| 88 |
-
|
|
|
|
| 89 |
}, [user, navigate, next, isNew]);
|
| 90 |
|
| 91 |
useEffect(() => {
|
|
@@ -170,9 +171,6 @@ export default function AuthPage() {
|
|
| 170 |
<>
|
| 171 |
{providers?.google === "redirect" && <GoogleRedirectButton next={next} />}
|
| 172 |
{providers?.google === "popup" && <GoogleButton onCredential={onGoogle} />}
|
| 173 |
-
{providers && !providers.google && (
|
| 174 |
-
<p className="mono muted auth-hint">Set GOOGLE_CLIENT_ID in .env to enable Google sign in.</p>
|
| 175 |
-
)}
|
| 176 |
{!providers && <div className="google-btn" />}
|
| 177 |
<div className="auth-divider mono">
|
| 178 |
<span>or with email</span>
|
|
|
|
| 63 |
}, [onCredential]);
|
| 64 |
|
| 65 |
if (!GOOGLE_CLIENT_ID) {
|
| 66 |
+
return null;
|
| 67 |
}
|
| 68 |
return <div ref={ref} className="google-btn" />;
|
| 69 |
}
|
|
|
|
| 85 |
const next = safeNext(params.get("next"));
|
| 86 |
|
| 87 |
useEffect(() => {
|
| 88 |
+
// New accounts get onboarding, except people who signed up to use a free tool: send them straight back to it.
|
| 89 |
+
if (user) navigate(isNew && !next.startsWith("/tools/") ? "/welcome" : next, { replace: true });
|
| 90 |
}, [user, navigate, next, isNew]);
|
| 91 |
|
| 92 |
useEffect(() => {
|
|
|
|
| 171 |
<>
|
| 172 |
{providers?.google === "redirect" && <GoogleRedirectButton next={next} />}
|
| 173 |
{providers?.google === "popup" && <GoogleButton onCredential={onGoogle} />}
|
|
|
|
|
|
|
|
|
|
| 174 |
{!providers && <div className="google-btn" />}
|
| 175 |
<div className="auth-divider mono">
|
| 176 |
<span>or with email</span>
|
frontend/src/pages/Blog.tsx
CHANGED
|
@@ -45,7 +45,7 @@ const missionOf = (post: BlogPost): Mission =>
|
|
| 45 |
const INLINE = /(\[[^\]]+\]\([^)\s]+\))|(\*\*[^*]+\*\*)/g;
|
| 46 |
|
| 47 |
/** Inline [text](url) links and **bold** inside post copy. External links open in a new tab and stay followed. */
|
| 48 |
-
function Inline({ text }: { text: string }) {
|
| 49 |
const out: ReactNode[] = [];
|
| 50 |
let last = 0;
|
| 51 |
let i = 0;
|
|
|
|
| 45 |
const INLINE = /(\[[^\]]+\]\([^)\s]+\))|(\*\*[^*]+\*\*)/g;
|
| 46 |
|
| 47 |
/** Inline [text](url) links and **bold** inside post copy. External links open in a new tab and stay followed. */
|
| 48 |
+
export function Inline({ text }: { text: string }) {
|
| 49 |
const out: ReactNode[] = [];
|
| 50 |
let last = 0;
|
| 51 |
let i = 0;
|
frontend/src/pages/LaunchKit.tsx
CHANGED
|
@@ -1,15 +1,15 @@
|
|
| 1 |
-
import { useCallback, useEffect, useState } from "react";
|
| 2 |
import { useSearchParams } from "react-router-dom";
|
| 3 |
-
import { artifactsApi } from "../api/endpoints";
|
| 4 |
-
import type { Artifact, LaunchKitContent, Platform } from "../api/types";
|
| 5 |
import { ArtifactCard } from "../components/ArtifactCard";
|
| 6 |
import { DocPicker } from "../components/DocPicker";
|
| 7 |
import { ScheduleModal } from "../components/ScheduleModal";
|
| 8 |
-
import { ConfirmButton, CopyButton,
|
| 9 |
import { useJob } from "../hooks/useJob";
|
| 10 |
|
| 11 |
type PostKey = keyof LaunchKitContent["posts"];
|
| 12 |
-
type Tab = "
|
| 13 |
|
| 14 |
const PLATFORM_OF: Record<PostKey, { platform: Platform; label: string; limit: number; thread: boolean }> = {
|
| 15 |
x_thread: { platform: "x", label: "X thread", limit: 280, thread: true },
|
|
@@ -175,7 +175,8 @@ function QuotesTab({ kit }: { kit: Artifact }) {
|
|
| 175 |
|
| 176 |
function KitView({ kit: initial, onDeleted }: { kit: Artifact; onDeleted: () => void }) {
|
| 177 |
const [kit, setKit] = useState(initial);
|
| 178 |
-
const [tab, setTab] = useState<Tab>("
|
|
|
|
| 179 |
const job = useJob(initial.job, () => artifactsApi.get(initial.id).then(setKit));
|
| 180 |
useEffect(() => setKit(initial), [initial]);
|
| 181 |
const c = kit.content as LaunchKitContent;
|
|
@@ -220,16 +221,23 @@ function KitView({ kit: initial, onDeleted }: { kit: Artifact; onDeleted: () =>
|
|
| 220 |
<>
|
| 221 |
<Tabs<Tab>
|
| 222 |
tabs={[
|
|
|
|
| 223 |
{ id: "hooks", label: "Hooks", count: c.hooks?.length },
|
| 224 |
-
|
| 225 |
{ id: "seo", label: "SEO" },
|
| 226 |
-
{ id: "carousel", label: "Carousel", count: c.carousel?.length },
|
| 227 |
-
{ id: "quotes", label: "Quotes", count: c.quotes?.length },
|
| 228 |
-
{ id: "claims", label: "Claims", count: c.claims?.length },
|
| 229 |
]}
|
| 230 |
value={tab}
|
| 231 |
onChange={setTab}
|
| 232 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
{tab === "hooks" && (
|
| 234 |
<ol className="hooks">
|
| 235 |
{(c.hooks ?? []).map((h, i) => (
|
|
@@ -243,7 +251,7 @@ function KitView({ kit: initial, onDeleted }: { kit: Artifact; onDeleted: () =>
|
|
| 243 |
))}
|
| 244 |
</ol>
|
| 245 |
)}
|
| 246 |
-
{tab
|
| 247 |
{tab === "seo" && (
|
| 248 |
<dl className="seo">
|
| 249 |
<dt>Title options</dt>
|
|
@@ -287,9 +295,17 @@ function KitView({ kit: initial, onDeleted }: { kit: Artifact; onDeleted: () =>
|
|
| 287 |
</dd>
|
| 288 |
</dl>
|
| 289 |
)}
|
| 290 |
-
{tab === "
|
| 291 |
-
|
| 292 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
<ol className="claims">
|
| 294 |
{(c.claims ?? []).map((cl, i) => (
|
| 295 |
<li key={i}>
|
|
@@ -305,6 +321,7 @@ function KitView({ kit: initial, onDeleted }: { kit: Artifact; onDeleted: () =>
|
|
| 305 |
</li>
|
| 306 |
))}
|
| 307 |
</ol>
|
|
|
|
| 308 |
)}
|
| 309 |
</>
|
| 310 |
)}
|
|
@@ -312,91 +329,144 @@ function KitView({ kit: initial, onDeleted }: { kit: Artifact; onDeleted: () =>
|
|
| 312 |
);
|
| 313 |
}
|
| 314 |
|
|
|
|
| 315 |
export default function LaunchKit() {
|
| 316 |
const [params, setParams] = useSearchParams();
|
| 317 |
const [kits, setKits] = useState<Artifact[] | null>(null);
|
| 318 |
-
const [
|
|
|
|
|
|
|
| 319 |
const [active, setActive] = useState<Artifact | null>(null);
|
| 320 |
-
const [busy, setBusy] = useState(
|
| 321 |
const [error, setError] = useState<string | null>(null);
|
|
|
|
| 322 |
|
| 323 |
const load = useCallback(() => artifactsApi.list({ type: "launch_kit", limit: 100 }).then((p) => setKits(p.items)), []);
|
| 324 |
useEffect(() => {
|
| 325 |
load();
|
|
|
|
| 326 |
}, [load]);
|
| 327 |
useEffect(() => {
|
| 328 |
const id = params.get("kit");
|
| 329 |
if (id && active?.id !== id) artifactsApi.get(id).then(setActive, () => setActive(null));
|
| 330 |
-
|
|
|
|
|
|
|
| 331 |
|
| 332 |
-
const generate = async () => {
|
| 333 |
-
|
| 334 |
-
setBusy(true);
|
| 335 |
setError(null);
|
| 336 |
try {
|
| 337 |
-
const kit = await artifactsApi.generate({ type: "launch_kit", document_id:
|
| 338 |
setKits((k) => [kit, ...(k ?? [])]);
|
| 339 |
setActive(kit);
|
| 340 |
setParams({ kit: kit.id });
|
| 341 |
} catch (e) {
|
| 342 |
setError(errorMessage(e));
|
| 343 |
} finally {
|
| 344 |
-
setBusy(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
};
|
| 347 |
|
| 348 |
return (
|
| 349 |
<div className="page-wrap">
|
| 350 |
-
<PageHeader eyebrow="Launch Kit" title="Everything you need to launch a post"
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
<section className="card">
|
| 363 |
-
<h2>Kits</h2>
|
| 364 |
-
{!kits && <Loading />}
|
| 365 |
<ul className="kit-list">
|
| 366 |
{kits?.map((k) => (
|
| 367 |
<li key={k.id}>
|
| 368 |
-
<button
|
| 369 |
-
className={`link-btn${active?.id === k.id ? " active" : ""}`}
|
| 370 |
-
onClick={() => {
|
| 371 |
-
setActive(k);
|
| 372 |
-
setParams({ kit: k.id });
|
| 373 |
-
}}
|
| 374 |
-
>
|
| 375 |
{(k.content.post_title as string | undefined) ?? k.title}
|
| 376 |
</button>
|
| 377 |
<span className="mono muted"> {k.status === "ready" ? formatDate(k.created_at) : k.status}</span>
|
| 378 |
</li>
|
| 379 |
))}
|
| 380 |
-
{kits?.length === 0 && <li className="muted">No kits yet.</li>}
|
| 381 |
</ul>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
</section>
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
<
|
| 397 |
)}
|
| 398 |
-
</
|
| 399 |
-
|
| 400 |
</div>
|
| 401 |
);
|
| 402 |
}
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useRef, useState } from "react";
|
| 2 |
import { useSearchParams } from "react-router-dom";
|
| 3 |
+
import { artifactsApi, docsApi } from "../api/endpoints";
|
| 4 |
+
import type { Artifact, Doc, LaunchKitContent, Platform } from "../api/types";
|
| 5 |
import { ArtifactCard } from "../components/ArtifactCard";
|
| 6 |
import { DocPicker } from "../components/DocPicker";
|
| 7 |
import { ScheduleModal } from "../components/ScheduleModal";
|
| 8 |
+
import { ConfirmButton, CopyButton, errorMessage, formatDate, JobProgress, Loading, PageHeader, StatusPill, Tabs } from "../components/ui";
|
| 9 |
import { useJob } from "../hooks/useJob";
|
| 10 |
|
| 11 |
type PostKey = keyof LaunchKitContent["posts"];
|
| 12 |
+
type Tab = "posts" | "hooks" | "visuals" | "seo";
|
| 13 |
|
| 14 |
const PLATFORM_OF: Record<PostKey, { platform: Platform; label: string; limit: number; thread: boolean }> = {
|
| 15 |
x_thread: { platform: "x", label: "X thread", limit: 280, thread: true },
|
|
|
|
| 175 |
|
| 176 |
function KitView({ kit: initial, onDeleted }: { kit: Artifact; onDeleted: () => void }) {
|
| 177 |
const [kit, setKit] = useState(initial);
|
| 178 |
+
const [tab, setTab] = useState<Tab>("posts");
|
| 179 |
+
const [platform, setPlatform] = useState<PostKey>("x_thread");
|
| 180 |
const job = useJob(initial.job, () => artifactsApi.get(initial.id).then(setKit));
|
| 181 |
useEffect(() => setKit(initial), [initial]);
|
| 182 |
const c = kit.content as LaunchKitContent;
|
|
|
|
| 221 |
<>
|
| 222 |
<Tabs<Tab>
|
| 223 |
tabs={[
|
| 224 |
+
{ id: "posts", label: "Posts" },
|
| 225 |
{ id: "hooks", label: "Hooks", count: c.hooks?.length },
|
| 226 |
+
{ id: "visuals", label: "Visuals", count: (c.carousel?.length ?? 0) + (c.quotes?.length ?? 0) },
|
| 227 |
{ id: "seo", label: "SEO" },
|
|
|
|
|
|
|
|
|
|
| 228 |
]}
|
| 229 |
value={tab}
|
| 230 |
onChange={setTab}
|
| 231 |
/>
|
| 232 |
+
{tab === "posts" && (
|
| 233 |
+
<div className="gen-chips" role="tablist" aria-label="Platform">
|
| 234 |
+
{(Object.keys(PLATFORM_OF) as PostKey[]).map((k) => (
|
| 235 |
+
<button key={k} type="button" role="tab" aria-selected={platform === k} className={`chip${platform === k ? " on" : ""}`} onClick={() => setPlatform(k)}>
|
| 236 |
+
{PLATFORM_OF[k].label}
|
| 237 |
+
</button>
|
| 238 |
+
))}
|
| 239 |
+
</div>
|
| 240 |
+
)}
|
| 241 |
{tab === "hooks" && (
|
| 242 |
<ol className="hooks">
|
| 243 |
{(c.hooks ?? []).map((h, i) => (
|
|
|
|
| 251 |
))}
|
| 252 |
</ol>
|
| 253 |
)}
|
| 254 |
+
{tab === "posts" && <PostsEditor kit={kit} keyName={platform} onSave={(p) => savePosts(platform, p)} />}
|
| 255 |
{tab === "seo" && (
|
| 256 |
<dl className="seo">
|
| 257 |
<dt>Title options</dt>
|
|
|
|
| 295 |
</dd>
|
| 296 |
</dl>
|
| 297 |
)}
|
| 298 |
+
{tab === "visuals" && (
|
| 299 |
+
<>
|
| 300 |
+
<h3>Carousel</h3>
|
| 301 |
+
<CarouselTab kit={kit} />
|
| 302 |
+
<h3>Quotes</h3>
|
| 303 |
+
<QuotesTab kit={kit} />
|
| 304 |
+
</>
|
| 305 |
+
)}
|
| 306 |
+
{tab === "posts" && (c.claims?.length ?? 0) > 0 && (
|
| 307 |
+
<details className="delivery">
|
| 308 |
+
<summary className="mono muted small">Fact check: {c.claims?.length} claims traced to the post</summary>
|
| 309 |
<ol className="claims">
|
| 310 |
{(c.claims ?? []).map((cl, i) => (
|
| 311 |
<li key={i}>
|
|
|
|
| 321 |
</li>
|
| 322 |
))}
|
| 323 |
</ol>
|
| 324 |
+
</details>
|
| 325 |
)}
|
| 326 |
</>
|
| 327 |
)}
|
|
|
|
| 329 |
);
|
| 330 |
}
|
| 331 |
|
| 332 |
+
/** Pick a post card (one click generates), or reopen an earlier kit from the rail. */
|
| 333 |
export default function LaunchKit() {
|
| 334 |
const [params, setParams] = useSearchParams();
|
| 335 |
const [kits, setKits] = useState<Artifact[] | null>(null);
|
| 336 |
+
const [newest, setNewest] = useState<Doc[] | null>(null);
|
| 337 |
+
const [post, setPost] = useState<string[]>([]);
|
| 338 |
+
const [searching, setSearching] = useState(false);
|
| 339 |
const [active, setActive] = useState<Artifact | null>(null);
|
| 340 |
+
const [busy, setBusy] = useState<string | null>(null);
|
| 341 |
const [error, setError] = useState<string | null>(null);
|
| 342 |
+
const autoStarted = useRef(false);
|
| 343 |
|
| 344 |
const load = useCallback(() => artifactsApi.list({ type: "launch_kit", limit: 100 }).then((p) => setKits(p.items)), []);
|
| 345 |
useEffect(() => {
|
| 346 |
load();
|
| 347 |
+
docsApi.list({ limit: 24 }).then((p) => setNewest(p.items.filter((d) => !d.locked).slice(0, 6)));
|
| 348 |
}, [load]);
|
| 349 |
useEffect(() => {
|
| 350 |
const id = params.get("kit");
|
| 351 |
if (id && active?.id !== id) artifactsApi.get(id).then(setActive, () => setActive(null));
|
| 352 |
+
if (!id && active) setActive(null);
|
| 353 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 354 |
+
}, [params]);
|
| 355 |
|
| 356 |
+
const generate = async (documentId: string) => {
|
| 357 |
+
setBusy(documentId);
|
|
|
|
| 358 |
setError(null);
|
| 359 |
try {
|
| 360 |
+
const kit = await artifactsApi.generate({ type: "launch_kit", document_id: documentId });
|
| 361 |
setKits((k) => [kit, ...(k ?? [])]);
|
| 362 |
setActive(kit);
|
| 363 |
setParams({ kit: kit.id });
|
| 364 |
} catch (e) {
|
| 365 |
setError(errorMessage(e));
|
| 366 |
} finally {
|
| 367 |
+
setBusy(null);
|
| 368 |
+
}
|
| 369 |
+
};
|
| 370 |
+
|
| 371 |
+
// ?post=<id> (from Resurfacing or a post page) starts the kit straight away.
|
| 372 |
+
useEffect(() => {
|
| 373 |
+
const id = params.get("post");
|
| 374 |
+
if (id && !autoStarted.current) {
|
| 375 |
+
autoStarted.current = true;
|
| 376 |
+
void generate(id);
|
| 377 |
}
|
| 378 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 379 |
+
}, []);
|
| 380 |
+
|
| 381 |
+
const open = (k: Artifact) => {
|
| 382 |
+
setActive(k);
|
| 383 |
+
setParams({ kit: k.id });
|
| 384 |
};
|
| 385 |
|
| 386 |
return (
|
| 387 |
<div className="page-wrap">
|
| 388 |
+
<PageHeader eyebrow="Launch Kit" title="Everything you need to launch a post">
|
| 389 |
+
{active && (
|
| 390 |
+
<button className="btn btn-primary" onClick={() => setParams({})}>
|
| 391 |
+
New kit
|
| 392 |
+
</button>
|
| 393 |
+
)}
|
| 394 |
+
</PageHeader>
|
| 395 |
+
{error && <p className="error-text">{error}</p>}
|
| 396 |
+
{active ? (
|
| 397 |
+
<div className="kit-layout kit-open">
|
| 398 |
+
<aside className="card kit-rail">
|
| 399 |
+
<p className="eyebrow">Your kits</p>
|
|
|
|
|
|
|
|
|
|
| 400 |
<ul className="kit-list">
|
| 401 |
{kits?.map((k) => (
|
| 402 |
<li key={k.id}>
|
| 403 |
+
<button className={`link-btn${active.id === k.id ? " active" : ""}`} onClick={() => open(k)}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
{(k.content.post_title as string | undefined) ?? k.title}
|
| 405 |
</button>
|
| 406 |
<span className="mono muted"> {k.status === "ready" ? formatDate(k.created_at) : k.status}</span>
|
| 407 |
</li>
|
| 408 |
))}
|
|
|
|
| 409 |
</ul>
|
| 410 |
+
</aside>
|
| 411 |
+
<KitView
|
| 412 |
+
key={active.id}
|
| 413 |
+
kit={active}
|
| 414 |
+
onDeleted={() => {
|
| 415 |
+
setParams({});
|
| 416 |
+
load();
|
| 417 |
+
}}
|
| 418 |
+
/>
|
| 419 |
+
</div>
|
| 420 |
+
) : (
|
| 421 |
+
<>
|
| 422 |
+
<section className="card stack">
|
| 423 |
+
<div className="row between">
|
| 424 |
+
<h2>Pick a post to launch</h2>
|
| 425 |
+
<button className="link-btn small" onClick={() => setSearching(!searching)}>
|
| 426 |
+
{searching ? "Show newest posts" : "Search all posts"}
|
| 427 |
+
</button>
|
| 428 |
+
</div>
|
| 429 |
+
<p className="muted">Hooks, a thread, LinkedIn, Notes, Bluesky, SEO, a carousel and quote cards, in your voice and grounded in the post.</p>
|
| 430 |
+
{searching ? (
|
| 431 |
+
<>
|
| 432 |
+
<DocPicker selected={post} onChange={setPost} single />
|
| 433 |
+
<div className="row end">
|
| 434 |
+
<button className="btn btn-primary" disabled={!post.length || busy !== null} onClick={() => generate(post[0])}>
|
| 435 |
+
{busy ? "Launching..." : "Generate Launch Kit"}
|
| 436 |
+
</button>
|
| 437 |
+
</div>
|
| 438 |
+
</>
|
| 439 |
+
) : (
|
| 440 |
+
<div className="post-cards">
|
| 441 |
+
{!newest && <Loading />}
|
| 442 |
+
{newest?.length === 0 && <p className="muted">No indexed posts yet. Connect a source first.</p>}
|
| 443 |
+
{newest?.map((d) => (
|
| 444 |
+
<button key={d.id} type="button" className="post-card" disabled={busy !== null} onClick={() => generate(d.id)}>
|
| 445 |
+
<span className="mono muted small">{formatDate(d.published_at)}</span>
|
| 446 |
+
<strong className="clamp-3">{d.title}</strong>
|
| 447 |
+
<span className="post-card-go">{busy === d.id ? "Launching..." : "Launch this post"}</span>
|
| 448 |
+
</button>
|
| 449 |
+
))}
|
| 450 |
+
</div>
|
| 451 |
+
)}
|
| 452 |
</section>
|
| 453 |
+
{kits && kits.length > 0 && (
|
| 454 |
+
<section className="card">
|
| 455 |
+
<h2>Earlier kits</h2>
|
| 456 |
+
<ul className="kit-list">
|
| 457 |
+
{kits.map((k) => (
|
| 458 |
+
<li key={k.id}>
|
| 459 |
+
<button className="link-btn" onClick={() => open(k)}>
|
| 460 |
+
{(k.content.post_title as string | undefined) ?? k.title}
|
| 461 |
+
</button>
|
| 462 |
+
<span className="mono muted"> {k.status === "ready" ? formatDate(k.created_at) : k.status}</span>
|
| 463 |
+
</li>
|
| 464 |
+
))}
|
| 465 |
+
</ul>
|
| 466 |
+
</section>
|
| 467 |
)}
|
| 468 |
+
</>
|
| 469 |
+
)}
|
| 470 |
</div>
|
| 471 |
);
|
| 472 |
}
|
frontend/src/pages/Launchpad.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
| 1 |
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
|
| 2 |
-
import { useSearchParams } from "react-router-dom";
|
| 3 |
import { launchpadApi } from "../api/endpoints";
|
| 4 |
import type { CalendarItem, SocialAccounts } from "../api/types";
|
| 5 |
import { PLATFORMS, ScheduleModal, toLocalInput } from "../components/ScheduleModal";
|
| 6 |
import { ConfirmButton, CopyButton, errorMessage, formatDate, Loading, Modal, PageHeader, StatusPill, Tabs } from "../components/ui";
|
|
|
|
| 7 |
|
| 8 |
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
| 9 |
|
|
@@ -289,55 +290,42 @@ export default function Launchpad() {
|
|
| 289 |
{notice && <p className="notice">{notice}</p>}
|
| 290 |
{error && <p className="error-text">{error}</p>}
|
| 291 |
|
| 292 |
-
<section className="
|
| 293 |
-
|
| 294 |
-
<
|
| 295 |
-
|
| 296 |
-
<
|
| 297 |
-
|
| 298 |
-
<
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
setAccounts(await launchpadApi.accounts());
|
| 319 |
-
}}
|
| 320 |
-
>
|
| 321 |
-
Disconnect
|
| 322 |
-
</ConfirmButton>
|
| 323 |
-
</li>
|
| 324 |
-
))}
|
| 325 |
-
{accounts?.accounts.length === 0 && <li className="muted">No accounts yet. Posts for unconnected platforms and Substack Notes arrive as email reminders.</li>}
|
| 326 |
-
</ul>
|
| 327 |
-
{accounts && (!accounts.available.x || !accounts.available.linkedin) && (
|
| 328 |
-
<details className="muted small">
|
| 329 |
-
<summary>Setting up X or LinkedIn</summary>
|
| 330 |
-
<p>
|
| 331 |
-
Create a developer app, then add these redirect URIs and put the client id and secret in .env:
|
| 332 |
-
</p>
|
| 333 |
-
<ul className="mono">
|
| 334 |
-
<li>X: {accounts.redirect_uris.x}</li>
|
| 335 |
-
<li>LinkedIn: {accounts.redirect_uris.linkedin}</li>
|
| 336 |
-
</ul>
|
| 337 |
-
</details>
|
| 338 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
</section>
|
| 340 |
|
|
|
|
| 341 |
<section className="card">
|
| 342 |
<div className="row between cal-head">
|
| 343 |
<div className="row">
|
|
@@ -352,6 +340,14 @@ export default function Launchpad() {
|
|
| 352 |
<Tabs tabs={[{ id: "month", label: "Month" }, { id: "agenda", label: "Agenda" }]} value={view} onChange={setView} />
|
| 353 |
</div>
|
| 354 |
{!items && <Loading />}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
{items && view === "month" && (
|
| 356 |
<div className="cal-grid">
|
| 357 |
{WEEKDAYS.map((d) => (
|
|
@@ -396,6 +392,12 @@ export default function Launchpad() {
|
|
| 396 |
{items && <p className="mono muted small">{upcoming.length} upcoming in view</p>}
|
| 397 |
</section>
|
| 398 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 399 |
{open && <ItemModal item={open} accounts={accounts} onClose={() => setOpen(null)} onChanged={changed} />}
|
| 400 |
{composing && <ScheduleModal platform="x" posts={[""]} onClose={() => setComposing(false)} onScheduled={() => load()} />}
|
| 401 |
{bluesky && <BlueskyModal onClose={() => setBluesky(false)} onConnected={setAccounts} />}
|
|
|
|
| 1 |
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
|
| 2 |
+
import { Link, useSearchParams } from "react-router-dom";
|
| 3 |
import { launchpadApi } from "../api/endpoints";
|
| 4 |
import type { CalendarItem, SocialAccounts } from "../api/types";
|
| 5 |
import { PLATFORMS, ScheduleModal, toLocalInput } from "../components/ScheduleModal";
|
| 6 |
import { ConfirmButton, CopyButton, errorMessage, formatDate, Loading, Modal, PageHeader, StatusPill, Tabs } from "../components/ui";
|
| 7 |
+
import { ResurfaceIdeas } from "./Resurface";
|
| 8 |
|
| 9 |
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
| 10 |
|
|
|
|
| 290 |
{notice && <p className="notice">{notice}</p>}
|
| 291 |
{error && <p className="error-text">{error}</p>}
|
| 292 |
|
| 293 |
+
<section className="lp-accounts" aria-label="Connected accounts">
|
| 294 |
+
{accounts?.accounts.map((a) => (
|
| 295 |
+
<span key={a.id} className={`lp-chip${a.status !== "active" ? " warn" : ""}`}>
|
| 296 |
+
{a.avatar_url && <img src={a.avatar_url} alt="" width={18} height={18} />}
|
| 297 |
+
<strong>{a.platform_label}</strong> <span className="mono muted">{a.handle}</span>
|
| 298 |
+
{a.status !== "active" && <StatusPill status={a.status === "expired" ? "failed" : a.status} />}
|
| 299 |
+
<ConfirmButton
|
| 300 |
+
className="link-btn small"
|
| 301 |
+
onConfirm={async () => {
|
| 302 |
+
await launchpadApi.disconnect(a.id);
|
| 303 |
+
setAccounts(await launchpadApi.accounts());
|
| 304 |
+
}}
|
| 305 |
+
>
|
| 306 |
+
×
|
| 307 |
+
</ConfirmButton>
|
| 308 |
+
</span>
|
| 309 |
+
))}
|
| 310 |
+
{accounts?.available.x && !accounts.accounts.some((a) => a.platform === "x") && (
|
| 311 |
+
<button className="lp-chip add" onClick={() => connect("x")}>
|
| 312 |
+
+ X
|
| 313 |
+
</button>
|
| 314 |
+
)}
|
| 315 |
+
{accounts?.available.linkedin && !accounts.accounts.some((a) => a.platform === "linkedin") && (
|
| 316 |
+
<button className="lp-chip add" onClick={() => connect("linkedin")}>
|
| 317 |
+
+ LinkedIn
|
| 318 |
+
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
)}
|
| 320 |
+
{!accounts?.accounts.some((a) => a.platform === "bluesky") && (
|
| 321 |
+
<button className="lp-chip add" onClick={() => setBluesky(true)}>
|
| 322 |
+
+ Bluesky
|
| 323 |
+
</button>
|
| 324 |
+
)}
|
| 325 |
+
{accounts && accounts.accounts.length === 0 && <span className="muted small">Not connected? Posts arrive as email reminders instead.</span>}
|
| 326 |
</section>
|
| 327 |
|
| 328 |
+
<div className="lp-layout">
|
| 329 |
<section className="card">
|
| 330 |
<div className="row between cal-head">
|
| 331 |
<div className="row">
|
|
|
|
| 340 |
<Tabs tabs={[{ id: "month", label: "Month" }, { id: "agenda", label: "Agenda" }]} value={view} onChange={setView} />
|
| 341 |
</div>
|
| 342 |
{!items && <Loading />}
|
| 343 |
+
{items && items.length === 0 && (
|
| 344 |
+
<div className="lp-empty">
|
| 345 |
+
<p className="muted">Nothing scheduled this month.</p>
|
| 346 |
+
<Link className="btn btn-small" to="/app/launch-kit">
|
| 347 |
+
Schedule your latest Launch Kit
|
| 348 |
+
</Link>
|
| 349 |
+
</div>
|
| 350 |
+
)}
|
| 351 |
{items && view === "month" && (
|
| 352 |
<div className="cal-grid">
|
| 353 |
{WEEKDAYS.map((d) => (
|
|
|
|
| 392 |
{items && <p className="mono muted small">{upcoming.length} upcoming in view</p>}
|
| 393 |
</section>
|
| 394 |
|
| 395 |
+
<aside className="card stack lp-ideas" id="ideas">
|
| 396 |
+
<h2>Ideas worth resharing</h2>
|
| 397 |
+
<ResurfaceIdeas onScheduled={() => load()} />
|
| 398 |
+
</aside>
|
| 399 |
+
</div>
|
| 400 |
+
|
| 401 |
{open && <ItemModal item={open} accounts={accounts} onClose={() => setOpen(null)} onChanged={changed} />}
|
| 402 |
{composing && <ScheduleModal platform="x" posts={[""]} onClose={() => setComposing(false)} onScheduled={() => load()} />}
|
| 403 |
{bluesky && <BlueskyModal onClose={() => setBluesky(false)} onConnected={setAccounts} />}
|
frontend/src/pages/MissionControl.tsx
CHANGED
|
@@ -1,10 +1,10 @@
|
|
| 1 |
-
import { useCallback, useEffect, useRef, useState } from "react";
|
| 2 |
import { Link, useNavigate } from "react-router-dom";
|
| 3 |
import { api } from "../api/client";
|
| 4 |
-
import { artifactsApi, jobsApi, launchpadApi, notebooksApi, sourcesApi,
|
| 5 |
-
import type { Artifact, CalendarItem, Job, NotebookSummary, Source } from "../api/types";
|
| 6 |
-
import {
|
| 7 |
-
import { formatDate, JobProgress, StatusPill } from "../components/ui";
|
| 8 |
import { useAuth } from "../hooks/useAuth";
|
| 9 |
import { useJobMap } from "../hooks/useJob";
|
| 10 |
import { AddSource } from "./Sources";
|
|
@@ -26,9 +26,9 @@ const JOB_LABELS: Record<string, string> = {
|
|
| 26 |
import_url: "Importing an article",
|
| 27 |
import_upload: "Importing a file",
|
| 28 |
topics: "Mapping topics",
|
| 29 |
-
voice_profile: "Building your
|
| 30 |
voice_clone: "Cloning your voice",
|
| 31 |
-
resurface_scan: "
|
| 32 |
summary: "Writing a summary",
|
| 33 |
audio_overview: "Recording an audio overview",
|
| 34 |
video: "Making a video",
|
|
@@ -38,26 +38,31 @@ const JOB_LABELS: Record<string, string> = {
|
|
| 38 |
render: "Rendering",
|
| 39 |
};
|
| 40 |
|
|
|
|
| 41 |
export default function MissionControl() {
|
| 42 |
const { user } = useAuth();
|
| 43 |
-
const [sources, setSources] = useState<Source[]>(
|
| 44 |
const [notebooks, setNotebooks] = useState<NotebookSummary[]>([]);
|
| 45 |
const [recent, setRecent] = useState<Artifact[]>([]);
|
| 46 |
const [upcoming, setUpcoming] = useState<CalendarItem[]>([]);
|
|
|
|
|
|
|
| 47 |
const [plan, setPlan] = useState<Plan | null>(null);
|
| 48 |
-
const [
|
|
|
|
| 49 |
const [error, setError] = useState<string | null>(null);
|
| 50 |
const started = useRef(false);
|
| 51 |
const navigate = useNavigate();
|
| 52 |
|
| 53 |
const refresh = useCallback(async () => {
|
| 54 |
-
const [s, n, p, a, c,
|
| 55 |
sourcesApi.list(),
|
| 56 |
notebooksApi.list(),
|
| 57 |
api<{ plan: Plan }>("/api/billing/me"),
|
| 58 |
-
artifactsApi.list({ limit:
|
| 59 |
launchpadApi.items({ start: new Date().toISOString(), status: "scheduled,draft" }),
|
| 60 |
-
|
|
|
|
| 61 |
]);
|
| 62 |
setSources(s);
|
| 63 |
// First visit with nothing connected: run the guided setup once.
|
|
@@ -69,13 +74,14 @@ export default function MissionControl() {
|
|
| 69 |
setPlan(p.plan);
|
| 70 |
setRecent(a.items);
|
| 71 |
setUpcoming(c.slice(0, 5));
|
| 72 |
-
|
|
|
|
| 73 |
}, [navigate]);
|
| 74 |
|
| 75 |
const { jobs, watch } = useJobMap(() => refresh());
|
| 76 |
|
| 77 |
useEffect(() => {
|
| 78 |
-
refresh().catch(() => setError("Could not
|
| 79 |
jobsApi.active().then((active) => active.forEach((j) => watch(j.id, j)));
|
| 80 |
if (started.current) return;
|
| 81 |
started.current = true;
|
|
@@ -92,45 +98,141 @@ export default function MissionControl() {
|
|
| 92 |
watch(res.job.id, res.job);
|
| 93 |
refresh();
|
| 94 |
},
|
| 95 |
-
(e) => setError(e
|
| 96 |
);
|
| 97 |
}
|
| 98 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 99 |
}, [refresh]);
|
| 100 |
|
| 101 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
const activeJobs = Object.values(jobs).filter((j: Job) => j.status === "queued" || j.status === "running" || j.status === "failed");
|
| 103 |
-
const
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
return (
|
| 112 |
<div className="mc">
|
| 113 |
<header className="mc-head">
|
| 114 |
<div>
|
| 115 |
<p className="eyebrow">Mission Control</p>
|
| 116 |
-
<h1>Good to see you, {
|
| 117 |
</div>
|
| 118 |
{plan && <span className="badge">{plan.name} plan</span>}
|
| 119 |
</header>
|
| 120 |
{error && <p className="error-text">{error}</p>}
|
| 121 |
|
| 122 |
-
{
|
| 123 |
-
<section className="card
|
| 124 |
-
<
|
| 125 |
-
<
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
</ol>
|
| 133 |
</section>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
)}
|
| 135 |
|
| 136 |
{activeJobs.length > 0 && (
|
|
@@ -145,102 +247,97 @@ export default function MissionControl() {
|
|
| 145 |
</section>
|
| 146 |
)}
|
| 147 |
|
| 148 |
-
|
| 149 |
-
<
|
| 150 |
-
<
|
| 151 |
-
<
|
| 152 |
-
<
|
| 153 |
-
Manage
|
| 154 |
-
</Link>
|
| 155 |
-
</div>
|
| 156 |
-
<AddSource
|
| 157 |
-
onAdded={(source, job) => {
|
| 158 |
-
setSources((list) => [...list.filter((s) => s.id !== source.id), source]);
|
| 159 |
-
watch(job.id, job);
|
| 160 |
-
}}
|
| 161 |
-
/>
|
| 162 |
-
<ul className="source-list">
|
| 163 |
-
{sources.map((s) => (
|
| 164 |
-
<li key={s.id} className="source-row">
|
| 165 |
-
<div className="source-title">
|
| 166 |
-
<strong>{s.title ?? s.feed_url}</strong>
|
| 167 |
-
<span className="mono muted">
|
| 168 |
-
{s.is_imports ? "imports" : s.platform} · {s.document_count} posts
|
| 169 |
-
</span>
|
| 170 |
-
</div>
|
| 171 |
-
<StatusPill status={s.sync_status} />
|
| 172 |
-
</li>
|
| 173 |
-
))}
|
| 174 |
-
{sources.length === 0 && <li className="muted">No sources yet. A lone satellite, waiting for signal.</li>}
|
| 175 |
-
</ul>
|
| 176 |
-
</div>
|
| 177 |
-
|
| 178 |
-
<div className="card mc-card">
|
| 179 |
-
<div className="row between">
|
| 180 |
-
<h2>Notebooks</h2>
|
| 181 |
-
<Link to="/app/notebooks" className="mono muted small-link">
|
| 182 |
-
All
|
| 183 |
-
</Link>
|
| 184 |
-
</div>
|
| 185 |
-
<p className="muted">{totalDocs} posts indexed across your sources.</p>
|
| 186 |
-
<ul className="nb-list">
|
| 187 |
-
{notebooks.slice(0, 6).map((n) => (
|
| 188 |
-
<li key={n.id}>
|
| 189 |
-
<Link to={`/app/notebooks/${n.id}`} className="nb-link">
|
| 190 |
-
{n.title}
|
| 191 |
-
</Link>
|
| 192 |
-
<span className="mono muted"> {n.document_count} posts</span>
|
| 193 |
-
</li>
|
| 194 |
-
))}
|
| 195 |
-
</ul>
|
| 196 |
-
<Link className="btn" to="/app/notebooks">
|
| 197 |
-
{notebooks.length ? "Open notebooks" : "Create a notebook"}
|
| 198 |
</Link>
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
<
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
<ul className="recent-list">
|
| 210 |
-
{recent.map((a) => (
|
| 211 |
-
<li key={a.id}>
|
| 212 |
-
<Link to={a.type === "launch_kit" ? `/app/launch-kit?kit=${a.id}` : a.notebook_id ? `/app/notebooks/${a.notebook_id}` : "/app/archive"}>
|
| 213 |
-
{a.title}
|
| 214 |
</Link>
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
<
|
| 228 |
-
|
| 229 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
</div>
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
{upcoming.map((i) => (
|
| 234 |
-
<li key={i.id}>
|
| 235 |
-
<Link to={`/app/launchpad?item=${i.id}`}>
|
| 236 |
-
<strong>{i.platform_label}</strong> <span className="muted">{i.content.slice(0, 70)}</span>
|
| 237 |
-
</Link>
|
| 238 |
-
<span className="mono muted">{formatDate(i.scheduled_at, true)}</span>
|
| 239 |
-
</li>
|
| 240 |
-
))}
|
| 241 |
-
</ul>
|
| 242 |
-
</div>
|
| 243 |
-
</section>
|
| 244 |
</div>
|
| 245 |
);
|
| 246 |
}
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
|
| 2 |
import { Link, useNavigate } from "react-router-dom";
|
| 3 |
import { api } from "../api/client";
|
| 4 |
+
import { artifactsApi, docsApi, jobsApi, launchpadApi, notebooksApi, resurfaceApi, sourcesApi, type GenerateBody } from "../api/endpoints";
|
| 5 |
+
import type { Artifact, CalendarItem, Doc, Job, NotebookSummary, ResurfaceItem, Source } from "../api/types";
|
| 6 |
+
import { LaunchWindowIcon, PlanetIcon, RocketIcon, TelescopeIcon } from "../components/icons/Icons";
|
| 7 |
+
import { errorMessage, formatDate, JobProgress, StatusPill } from "../components/ui";
|
| 8 |
import { useAuth } from "../hooks/useAuth";
|
| 9 |
import { useJobMap } from "../hooks/useJob";
|
| 10 |
import { AddSource } from "./Sources";
|
|
|
|
| 26 |
import_url: "Importing an article",
|
| 27 |
import_upload: "Importing a file",
|
| 28 |
topics: "Mapping topics",
|
| 29 |
+
voice_profile: "Building your writing voice",
|
| 30 |
voice_clone: "Cloning your voice",
|
| 31 |
+
resurface_scan: "Finding posts worth resharing",
|
| 32 |
summary: "Writing a summary",
|
| 33 |
audio_overview: "Recording an audio overview",
|
| 34 |
video: "Making a video",
|
|
|
|
| 38 |
render: "Rendering",
|
| 39 |
};
|
| 40 |
|
| 41 |
+
/** Home: ask the archive, one-click creation, and only the next thing worth doing. */
|
| 42 |
export default function MissionControl() {
|
| 43 |
const { user } = useAuth();
|
| 44 |
+
const [sources, setSources] = useState<Source[] | null>(null);
|
| 45 |
const [notebooks, setNotebooks] = useState<NotebookSummary[]>([]);
|
| 46 |
const [recent, setRecent] = useState<Artifact[]>([]);
|
| 47 |
const [upcoming, setUpcoming] = useState<CalendarItem[]>([]);
|
| 48 |
+
const [newest, setNewest] = useState<Doc | null>(null);
|
| 49 |
+
const [reshare, setReshare] = useState<ResurfaceItem[]>([]);
|
| 50 |
const [plan, setPlan] = useState<Plan | null>(null);
|
| 51 |
+
const [question, setQuestion] = useState("");
|
| 52 |
+
const [busy, setBusy] = useState<string | null>(null);
|
| 53 |
const [error, setError] = useState<string | null>(null);
|
| 54 |
const started = useRef(false);
|
| 55 |
const navigate = useNavigate();
|
| 56 |
|
| 57 |
const refresh = useCallback(async () => {
|
| 58 |
+
const [s, n, p, a, c, d, r] = await Promise.all([
|
| 59 |
sourcesApi.list(),
|
| 60 |
notebooksApi.list(),
|
| 61 |
api<{ plan: Plan }>("/api/billing/me"),
|
| 62 |
+
artifactsApi.list({ limit: 8 }),
|
| 63 |
launchpadApi.items({ start: new Date().toISOString(), status: "scheduled,draft" }),
|
| 64 |
+
docsApi.list({ limit: 20 }),
|
| 65 |
+
resurfaceApi.get().catch(() => null),
|
| 66 |
]);
|
| 67 |
setSources(s);
|
| 68 |
// First visit with nothing connected: run the guided setup once.
|
|
|
|
| 74 |
setPlan(p.plan);
|
| 75 |
setRecent(a.items);
|
| 76 |
setUpcoming(c.slice(0, 5));
|
| 77 |
+
setNewest(d.items.find((x) => !x.locked) ?? null);
|
| 78 |
+
setReshare((r?.evergreen ?? []).slice(0, 3));
|
| 79 |
}, [navigate]);
|
| 80 |
|
| 81 |
const { jobs, watch } = useJobMap(() => refresh());
|
| 82 |
|
| 83 |
useEffect(() => {
|
| 84 |
+
refresh().catch(() => setError("Could not load your dashboard. Refresh to try again."));
|
| 85 |
jobsApi.active().then((active) => active.forEach((j) => watch(j.id, j)));
|
| 86 |
if (started.current) return;
|
| 87 |
started.current = true;
|
|
|
|
| 98 |
watch(res.job.id, res.job);
|
| 99 |
refresh();
|
| 100 |
},
|
| 101 |
+
(e) => setError(errorMessage(e, "Could not add that source.")),
|
| 102 |
);
|
| 103 |
}
|
| 104 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 105 |
}, [refresh]);
|
| 106 |
|
| 107 |
+
const run = async (key: string, fn: () => Promise<void>) => {
|
| 108 |
+
setBusy(key);
|
| 109 |
+
setError(null);
|
| 110 |
+
try {
|
| 111 |
+
await fn();
|
| 112 |
+
} catch (e) {
|
| 113 |
+
setError(errorMessage(e));
|
| 114 |
+
} finally {
|
| 115 |
+
setBusy(null);
|
| 116 |
+
}
|
| 117 |
+
};
|
| 118 |
+
|
| 119 |
+
const ask = (e: FormEvent) => {
|
| 120 |
+
e.preventDefault();
|
| 121 |
+
void run("ask", async () => {
|
| 122 |
+
const nb = await notebooksApi.archive();
|
| 123 |
+
navigate(`/app/notebooks/${nb.id}${question.trim() ? `?q=${encodeURIComponent(question.trim())}` : ""}`);
|
| 124 |
+
});
|
| 125 |
+
};
|
| 126 |
+
const create = (key: string, body: GenerateBody) =>
|
| 127 |
+
run(key, async () => {
|
| 128 |
+
await artifactsApi.generate(body);
|
| 129 |
+
navigate("/app/studio");
|
| 130 |
+
});
|
| 131 |
+
const launch = (doc: Doc) =>
|
| 132 |
+
run(`kit-${doc.id}`, async () => {
|
| 133 |
+
const kit = await artifactsApi.generate({ type: "launch_kit", document_id: doc.id });
|
| 134 |
+
navigate(`/app/launch-kit?kit=${kit.id}`);
|
| 135 |
+
});
|
| 136 |
+
|
| 137 |
+
const totalDocs = (sources ?? []).reduce((n, s) => n + s.document_count, 0);
|
| 138 |
+
const hasPosts = totalDocs > 0;
|
| 139 |
const activeJobs = Object.values(jobs).filter((j: Job) => j.status === "queued" || j.status === "running" || j.status === "failed");
|
| 140 |
+
const lastKit = recent.find((a) => a.type === "launch_kit" && a.status === "ready");
|
| 141 |
+
const next: { text: string; action: ReactNode } | null = !hasPosts
|
| 142 |
+
? null
|
| 143 |
+
: !lastKit && newest
|
| 144 |
+
? {
|
| 145 |
+
text: `Launch your newest post, "${newest.title}", with hooks, threads and SEO in your voice.`,
|
| 146 |
+
action: (
|
| 147 |
+
<button className="btn btn-primary" disabled={busy !== null} onClick={() => launch(newest)}>
|
| 148 |
+
{busy === `kit-${newest.id}` ? "Launching..." : "Generate Launch Kit"}
|
| 149 |
+
</button>
|
| 150 |
+
),
|
| 151 |
+
}
|
| 152 |
+
: lastKit && upcoming.length === 0
|
| 153 |
+
? {
|
| 154 |
+
text: `Your Launch Kit for "${lastKit.title}" is ready. Put it on the calendar.`,
|
| 155 |
+
action: (
|
| 156 |
+
<Link className="btn btn-primary" to={`/app/launch-kit?kit=${lastKit.id}`}>
|
| 157 |
+
Schedule it
|
| 158 |
+
</Link>
|
| 159 |
+
),
|
| 160 |
+
}
|
| 161 |
+
: null;
|
| 162 |
+
|
| 163 |
+
const first = (user?.name ?? user?.email ?? "").split(/[ @]/)[0];
|
| 164 |
|
| 165 |
return (
|
| 166 |
<div className="mc">
|
| 167 |
<header className="mc-head">
|
| 168 |
<div>
|
| 169 |
<p className="eyebrow">Mission Control</p>
|
| 170 |
+
<h1>Good to see you, {first}</h1>
|
| 171 |
</div>
|
| 172 |
{plan && <span className="badge">{plan.name} plan</span>}
|
| 173 |
</header>
|
| 174 |
{error && <p className="error-text">{error}</p>}
|
| 175 |
|
| 176 |
+
{sources && !hasPosts ? (
|
| 177 |
+
<section className="card mc-hero stack">
|
| 178 |
+
<h2>Bring in your writing</h2>
|
| 179 |
+
<p className="muted">Connect a blog or newsletter, import an article or drop in files. Everything else starts from here.</p>
|
| 180 |
+
<AddSource
|
| 181 |
+
onAdded={(source, job) => {
|
| 182 |
+
setSources((list) => [...(list ?? []).filter((s) => s.id !== source.id), source]);
|
| 183 |
+
watch(job.id, job);
|
| 184 |
+
}}
|
| 185 |
+
/>
|
|
|
|
| 186 |
</section>
|
| 187 |
+
) : (
|
| 188 |
+
<>
|
| 189 |
+
<section className="card mc-hero">
|
| 190 |
+
<form onSubmit={ask} className="mc-ask">
|
| 191 |
+
<TelescopeIcon size={22} />
|
| 192 |
+
<input
|
| 193 |
+
className="input"
|
| 194 |
+
value={question}
|
| 195 |
+
onChange={(e) => setQuestion(e.target.value)}
|
| 196 |
+
placeholder="Ask anything about your writing: what have I said about pricing?"
|
| 197 |
+
aria-label="Ask your archive"
|
| 198 |
+
/>
|
| 199 |
+
<button className="btn btn-primary" disabled={busy !== null}>
|
| 200 |
+
{busy === "ask" ? "Opening..." : "Ask"}
|
| 201 |
+
</button>
|
| 202 |
+
</form>
|
| 203 |
+
<div className="mc-actions">
|
| 204 |
+
<button className="mc-action" disabled={busy !== null || !hasPosts} onClick={() => create("audio", { type: "audio_overview", archive: true, format: "deep_dive", minutes: 6 })}>
|
| 205 |
+
<PlanetIcon size={28} />
|
| 206 |
+
<strong>{busy === "audio" ? "Starting..." : "Audio overview"}</strong>
|
| 207 |
+
<span className="muted small">Two hosts, 6 minutes, your whole archive</span>
|
| 208 |
+
</button>
|
| 209 |
+
<button className="mc-action" disabled={busy !== null || !hasPosts} onClick={() => create("video", { type: "video", style: "short", archive: true })}>
|
| 210 |
+
<LaunchWindowIcon size={28} />
|
| 211 |
+
<strong>{busy === "video" ? "Starting..." : "Short video"}</strong>
|
| 212 |
+
<span className="muted small">Vertical 9:16 with captions</span>
|
| 213 |
+
</button>
|
| 214 |
+
<button className="mc-action" disabled={busy !== null || !newest} onClick={() => newest && launch(newest)}>
|
| 215 |
+
<RocketIcon size={28} />
|
| 216 |
+
<strong>{newest && busy === `kit-${newest.id}` ? "Launching..." : "Launch newest post"}</strong>
|
| 217 |
+
<span className="muted small clamp-1">{newest ? newest.title : "No indexed posts yet"}</span>
|
| 218 |
+
</button>
|
| 219 |
+
<Link className="mc-action" to="/app/studio">
|
| 220 |
+
<span className="mc-more" aria-hidden="true">
|
| 221 |
+
+
|
| 222 |
+
</span>
|
| 223 |
+
<strong>More formats</strong>
|
| 224 |
+
<span className="muted small">Explainers, quote cards, audiograms</span>
|
| 225 |
+
</Link>
|
| 226 |
+
</div>
|
| 227 |
+
</section>
|
| 228 |
+
|
| 229 |
+
{next && (
|
| 230 |
+
<section className="mc-next">
|
| 231 |
+
<p>{next.text}</p>
|
| 232 |
+
{next.action}
|
| 233 |
+
</section>
|
| 234 |
+
)}
|
| 235 |
+
</>
|
| 236 |
)}
|
| 237 |
|
| 238 |
{activeJobs.length > 0 && (
|
|
|
|
| 247 |
</section>
|
| 248 |
)}
|
| 249 |
|
| 250 |
+
{hasPosts && (
|
| 251 |
+
<section className="mc-stats">
|
| 252 |
+
<Link to="/app/sources" className="mc-stat">
|
| 253 |
+
<strong>{totalDocs}</strong>
|
| 254 |
+
<span className="muted small">posts in {sources?.length} {sources?.length === 1 ? "source" : "sources"}</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
</Link>
|
| 256 |
+
<Link to="/app/notebooks" className="mc-stat">
|
| 257 |
+
<strong>{notebooks.length}</strong>
|
| 258 |
+
<span className="muted small">notebooks</span>
|
| 259 |
+
</Link>
|
| 260 |
+
<Link to="/app/archive" className="mc-stat">
|
| 261 |
+
<strong>{recent.length}</strong>
|
| 262 |
+
<span className="muted small">recent launches</span>
|
| 263 |
+
</Link>
|
| 264 |
+
<Link to="/app/launchpad" className="mc-stat">
|
| 265 |
+
<strong>{upcoming.length}</strong>
|
| 266 |
+
<span className="muted small">scheduled</span>
|
| 267 |
+
</Link>
|
| 268 |
+
</section>
|
| 269 |
+
)}
|
| 270 |
|
| 271 |
+
{(recent.length > 0 || upcoming.length > 0 || reshare.length > 0) && (
|
| 272 |
+
<section className="mc-grid">
|
| 273 |
+
{recent.length > 0 && (
|
| 274 |
+
<div className="card mc-card">
|
| 275 |
+
<div className="row between">
|
| 276 |
+
<h2>Recent launches</h2>
|
| 277 |
+
<Link to="/app/archive" className="mono muted small-link">
|
| 278 |
+
Library
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
</Link>
|
| 280 |
+
</div>
|
| 281 |
+
<ul className="recent-list">
|
| 282 |
+
{recent.map((a) => (
|
| 283 |
+
<li key={a.id}>
|
| 284 |
+
<Link to={a.type === "launch_kit" ? `/app/launch-kit?kit=${a.id}` : a.notebook_id ? `/app/notebooks/${a.notebook_id}` : "/app/archive"}>{a.title}</Link>
|
| 285 |
+
<span className="row">
|
| 286 |
+
<span className="mono muted">{formatDate(a.created_at)}</span>
|
| 287 |
+
<StatusPill status={a.status} />
|
| 288 |
+
</span>
|
| 289 |
+
</li>
|
| 290 |
+
))}
|
| 291 |
+
</ul>
|
| 292 |
+
</div>
|
| 293 |
+
)}
|
| 294 |
+
<div className="stack">
|
| 295 |
+
{reshare.length > 0 && (
|
| 296 |
+
<div className="card mc-card">
|
| 297 |
+
<div className="row between">
|
| 298 |
+
<h2>Worth resharing</h2>
|
| 299 |
+
<Link to="/app/launchpad" className="mono muted small-link">
|
| 300 |
+
More ideas
|
| 301 |
+
</Link>
|
| 302 |
+
</div>
|
| 303 |
+
<ul className="recent-list">
|
| 304 |
+
{reshare.map((i) => (
|
| 305 |
+
<li key={i.id}>
|
| 306 |
+
<span>
|
| 307 |
+
<strong>{i.title}</strong>
|
| 308 |
+
{i.angle && <span className="muted small"> · {i.angle}</span>}
|
| 309 |
+
</span>
|
| 310 |
+
<button className="btn btn-small" disabled={busy !== null} onClick={() => launch(i)}>
|
| 311 |
+
{busy === `kit-${i.id}` ? "Launching..." : "Launch Kit"}
|
| 312 |
+
</button>
|
| 313 |
+
</li>
|
| 314 |
+
))}
|
| 315 |
+
</ul>
|
| 316 |
+
</div>
|
| 317 |
+
)}
|
| 318 |
+
{upcoming.length > 0 && (
|
| 319 |
+
<div className="card mc-card">
|
| 320 |
+
<div className="row between">
|
| 321 |
+
<h2>Coming up</h2>
|
| 322 |
+
<Link to="/app/launchpad" className="mono muted small-link">
|
| 323 |
+
Calendar
|
| 324 |
+
</Link>
|
| 325 |
+
</div>
|
| 326 |
+
<ul className="recent-list">
|
| 327 |
+
{upcoming.map((i) => (
|
| 328 |
+
<li key={i.id}>
|
| 329 |
+
<Link to={`/app/launchpad?item=${i.id}`}>
|
| 330 |
+
<strong>{i.platform_label}</strong> <span className="muted">{i.content.slice(0, 70)}</span>
|
| 331 |
+
</Link>
|
| 332 |
+
<span className="mono muted">{formatDate(i.scheduled_at, true)}</span>
|
| 333 |
+
</li>
|
| 334 |
+
))}
|
| 335 |
+
</ul>
|
| 336 |
+
</div>
|
| 337 |
+
)}
|
| 338 |
</div>
|
| 339 |
+
</section>
|
| 340 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
</div>
|
| 342 |
);
|
| 343 |
}
|
frontend/src/pages/NotebookLMAlternative.tsx
CHANGED
|
@@ -3,86 +3,13 @@ import SkyCanvas from "../components/SkyCanvas";
|
|
| 3 |
import { PublicFooter, PublicNav } from "../components/PublicChrome";
|
| 4 |
import { useMeta } from "./Blog";
|
| 5 |
import "../styles/blog.css";
|
|
|
|
| 6 |
|
| 7 |
-
/**
|
| 8 |
-
|
| 9 |
-
const ROWS: { feature: string; notestack: string; notebooklm: string }[] = [
|
| 10 |
-
{
|
| 11 |
-
feature: "Getting your writing in",
|
| 12 |
-
notestack: "Syncs any blog, newsletter or site feed (Substack, Ghost, WordPress, Medium, RSS), plus markdown, text, HTML and PDF uploads",
|
| 13 |
-
notebooklm: "Add sources one at a time: PDFs, Docs, web pages, videos",
|
| 14 |
-
},
|
| 15 |
-
{
|
| 16 |
-
feature: "Stays in sync",
|
| 17 |
-
notestack: "New posts arrive automatically on every sync",
|
| 18 |
-
notebooklm: "Re-add sources by hand when they change",
|
| 19 |
-
},
|
| 20 |
-
{
|
| 21 |
-
feature: "Citations",
|
| 22 |
-
notestack: "Exact line ranges, read back and verified before you see them",
|
| 23 |
-
notebooklm: "Passage level citations to the source",
|
| 24 |
-
},
|
| 25 |
-
{
|
| 26 |
-
feature: "When the answer is not in your sources",
|
| 27 |
-
notestack: "Says so instead of guessing; unverifiable claims are dropped",
|
| 28 |
-
notebooklm: "Grounded in sources, with passage citations",
|
| 29 |
-
},
|
| 30 |
-
{
|
| 31 |
-
feature: "Chat memory",
|
| 32 |
-
notestack: "Follow ups resolved from the conversation; off topic questions answered instantly",
|
| 33 |
-
notebooklm: "Conversation within a notebook",
|
| 34 |
-
},
|
| 35 |
-
{
|
| 36 |
-
feature: "Audio overviews",
|
| 37 |
-
notestack: "Two hosts, ElevenLabs voices, delivery controls, or your own consented voice clone",
|
| 38 |
-
notebooklm: "Two host audio overviews with Google voices",
|
| 39 |
-
},
|
| 40 |
-
{
|
| 41 |
-
feature: "Video",
|
| 42 |
-
notestack: "9:16 shorts, 16:9 explainers and 1:1 audiograms with captions and your brand",
|
| 43 |
-
notebooklm: "Video overviews",
|
| 44 |
-
},
|
| 45 |
-
{
|
| 46 |
-
feature: "Publishing",
|
| 47 |
-
notestack: "Launch Kits (threads, LinkedIn, Notes, Bluesky, SEO, carousels, quote cards) and a scheduling calendar",
|
| 48 |
-
notebooklm: "Not a publishing tool",
|
| 49 |
-
},
|
| 50 |
-
{
|
| 51 |
-
feature: "Your voice",
|
| 52 |
-
notestack: "Voice profile learned from your posts, used for every draft",
|
| 53 |
-
notebooklm: "General purpose writing",
|
| 54 |
-
},
|
| 55 |
-
{
|
| 56 |
-
feature: "Seeing your archive",
|
| 57 |
-
notestack: "Topic map with rising and dormant topics, plus evergreen resurfacing",
|
| 58 |
-
notebooklm: "Mind maps per notebook",
|
| 59 |
-
},
|
| 60 |
-
];
|
| 61 |
-
|
| 62 |
-
const FAQ = [
|
| 63 |
-
{
|
| 64 |
-
q: "What is the best NotebookLM alternative?",
|
| 65 |
-
a: "For writers, bloggers and newsletters, Notestack is the ultimate NotebookLM alternative: it syncs your whole archive, cites the exact lines behind every answer, and turns what you wrote into audio, video and launch posts. For academic papers, discovery tools like Elicit or Consensus pair well with it.",
|
| 66 |
-
},
|
| 67 |
-
{
|
| 68 |
-
q: "Is Notestack free?",
|
| 69 |
-
a: "Yes, there is a free plan to try it on your latest posts. Paid plans add more sources, more posts, more audio and video minutes and voice cloning.",
|
| 70 |
-
},
|
| 71 |
-
{
|
| 72 |
-
q: "Can Notestack make NotebookLM style audio overviews?",
|
| 73 |
-
a: "Yes. It writes a two host conversation grounded in your posts and voices it with ElevenLabs, with every line tied to the passage it came from. You can choose the voices or clone your own with consent.",
|
| 74 |
-
},
|
| 75 |
-
{
|
| 76 |
-
q: "What if my writing is not on Substack?",
|
| 77 |
-
a: "Notestack works with any blog, newsletter or site that has a feed, single article URLs, and uploaded markdown, text, HTML or PDF files.",
|
| 78 |
-
},
|
| 79 |
-
];
|
| 80 |
|
| 81 |
export default function NotebookLMAlternative() {
|
| 82 |
-
useMeta(
|
| 83 |
-
"The Ultimate NotebookLM Alternative for Writers | Notestack",
|
| 84 |
-
"Notestack is the ultimate NotebookLM alternative for writers, bloggers and newsletters: sync your archive, get answers cited to the exact line, and turn your writing into audio, video and launch posts.",
|
| 85 |
-
);
|
| 86 |
const faqLd = {
|
| 87 |
"@context": "https://schema.org",
|
| 88 |
"@type": "FAQPage",
|
|
@@ -112,7 +39,7 @@ export default function NotebookLMAlternative() {
|
|
| 112 |
<span className="mono tx-label">Incoming query</span>
|
| 113 |
<p className="tx-query">Is there a NotebookLM built for my own writing?</p>
|
| 114 |
</div>
|
| 115 |
-
<h1 className="post-title">
|
| 116 |
<p className="post-lede muted">
|
| 117 |
NotebookLM is great for a pile of documents. Notestack is built for a body of work: it syncs your blog, newsletter, site or
|
| 118 |
markdown, answers with citations to the exact lines you wrote, and turns your knowledge into audio, video and launch posts.
|
|
|
|
| 3 |
import { PublicFooter, PublicNav } from "../components/PublicChrome";
|
| 4 |
import { useMeta } from "./Blog";
|
| 5 |
import "../styles/blog.css";
|
| 6 |
+
import { NOTEBOOKLM_ALT_FAQ as FAQ, NOTEBOOKLM_ALT_META, NOTEBOOKLM_ALT_ROWS as ROWS } from "../content/notebooklmAlternative";
|
| 7 |
|
| 8 |
+
/** Comparison landing page: "Notestack vs NotebookLM". The /blogs/notebooklm-alternatives listicle owns the plural
|
| 9 |
+
* "notebooklm alternatives" query (590/mo US, KD 0, Sep 2026) and links here, so the two do not compete. */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
export default function NotebookLMAlternative() {
|
| 12 |
+
useMeta(NOTEBOOKLM_ALT_META.title, NOTEBOOKLM_ALT_META.description);
|
|
|
|
|
|
|
|
|
|
| 13 |
const faqLd = {
|
| 14 |
"@context": "https://schema.org",
|
| 15 |
"@type": "FAQPage",
|
|
|
|
| 39 |
<span className="mono tx-label">Incoming query</span>
|
| 40 |
<p className="tx-query">Is there a NotebookLM built for my own writing?</p>
|
| 41 |
</div>
|
| 42 |
+
<h1 className="post-title">Notestack vs NotebookLM: the NotebookLM alternative for writers</h1>
|
| 43 |
<p className="post-lede muted">
|
| 44 |
NotebookLM is great for a pile of documents. Notestack is built for a body of work: it syncs your blog, newsletter, site or
|
| 45 |
markdown, answers with citations to the exact lines you wrote, and turns your knowledge into audio, video and launch posts.
|
frontend/src/pages/NotebookView.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
| 2 |
-
import { Link, useParams } from "react-router-dom";
|
| 3 |
import { artifactsApi, notebooksApi, type GenerateBody } from "../api/endpoints";
|
| 4 |
import { streamSSE } from "../api/stream";
|
| 5 |
import type { Artifact, ChatSummary, Citation, Notebook } from "../api/types";
|
|
@@ -8,15 +8,19 @@ import { Markdown } from "../components/Markdown";
|
|
| 8 |
import { DocPicker } from "../components/DocPicker";
|
| 9 |
import { TelescopeIcon } from "../components/icons/Icons";
|
| 10 |
import { Reader } from "../components/Reader";
|
| 11 |
-
import { ConfirmButton, errorMessage, formatDate, Loading, Modal } from "../components/ui";
|
| 12 |
|
| 13 |
type Turn = { role: "user" | "assistant"; text: string; citations?: Citation[]; status?: string; steps?: string[] };
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
function StudioPanel({ notebookId, disabled }: { notebookId: string; disabled: boolean }) {
|
| 16 |
const [artifacts, setArtifacts] = useState<Artifact[] | null>(null);
|
| 17 |
const [format, setFormat] = useState<"deep_dive" | "brief" | "debate">("deep_dive");
|
| 18 |
const [minutes, setMinutes] = useState(6);
|
| 19 |
-
const [style, setStyle] = useState<"short" | "explainer">("short");
|
| 20 |
const [error, setError] = useState<string | null>(null);
|
| 21 |
const [busy, setBusy] = useState(false);
|
| 22 |
const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null);
|
|
@@ -40,19 +44,32 @@ function StudioPanel({ notebookId, disabled }: { notebookId: string; disabled: b
|
|
| 40 |
};
|
| 41 |
|
| 42 |
const cite = (c: Citation) => c.document_id && setReading({ id: c.document_id, start: c.line_start, end: c.line_end });
|
|
|
|
| 43 |
|
| 44 |
return (
|
| 45 |
-
<
|
| 46 |
-
<
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
Summary
|
| 50 |
</button>
|
| 51 |
-
<button
|
| 52 |
Quote card
|
| 53 |
</button>
|
| 54 |
</div>
|
| 55 |
-
<
|
|
|
|
| 56 |
<div className="row">
|
| 57 |
<select className="input input-sm" value={format} onChange={(e) => setFormat(e.target.value as typeof format)} aria-label="Audio format">
|
| 58 |
<option value="deep_dive">Deep dive</option>
|
|
@@ -67,19 +84,7 @@ function StudioPanel({ notebookId, disabled }: { notebookId: string; disabled: b
|
|
| 67 |
))}
|
| 68 |
</select>
|
| 69 |
</div>
|
| 70 |
-
|
| 71 |
-
Audio overview
|
| 72 |
-
</button>
|
| 73 |
-
</div>
|
| 74 |
-
<div className="studio-group">
|
| 75 |
-
<select className="input input-sm" value={style} onChange={(e) => setStyle(e.target.value as typeof style)} aria-label="Video style">
|
| 76 |
-
<option value="short">Short, vertical 9:16</option>
|
| 77 |
-
<option value="explainer">Explainer, 16:9</option>
|
| 78 |
-
</select>
|
| 79 |
-
<button className="btn btn-small" disabled={disabled || busy} onClick={() => make({ type: "video", style })}>
|
| 80 |
-
Video
|
| 81 |
-
</button>
|
| 82 |
-
</div>
|
| 83 |
{disabled && <p className="muted small">Add posts to this notebook to start creating.</p>}
|
| 84 |
{error && <p className="error-text">{error}</p>}
|
| 85 |
<div className="studio-list">
|
|
@@ -101,7 +106,7 @@ function StudioPanel({ notebookId, disabled }: { notebookId: string; disabled: b
|
|
| 101 |
))}
|
| 102 |
</div>
|
| 103 |
{reading && <Reader documentId={reading.id} highlight={{ start: reading.start, end: reading.end }} onClose={() => setReading(null)} />}
|
| 104 |
-
</
|
| 105 |
);
|
| 106 |
}
|
| 107 |
|
|
@@ -120,6 +125,9 @@ export default function NotebookView() {
|
|
| 120 |
const [reading, setReading] = useState<{ id: string; start?: number; end?: number } | null>(null);
|
| 121 |
const [editingTitle, setEditingTitle] = useState(false);
|
| 122 |
const [title, setTitle] = useState("");
|
|
|
|
|
|
|
|
|
|
| 123 |
const bottom = useRef<HTMLDivElement>(null);
|
| 124 |
|
| 125 |
const load = useCallback(
|
|
@@ -152,9 +160,8 @@ export default function NotebookView() {
|
|
| 152 |
setTurns(msgs.map((m) => ({ role: m.role, text: m.text, citations: m.citations })));
|
| 153 |
};
|
| 154 |
|
| 155 |
-
const ask = async (
|
| 156 |
-
|
| 157 |
-
const question = q.trim();
|
| 158 |
if (!question || busy) return;
|
| 159 |
setQ("");
|
| 160 |
setBusy(true);
|
|
@@ -189,6 +196,16 @@ export default function NotebookView() {
|
|
| 189 |
}
|
| 190 |
};
|
| 191 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
if (loadError) return <p className="error-text">{loadError}</p>;
|
| 193 |
if (!nb) return <Loading label="Opening notebook" />;
|
| 194 |
|
|
@@ -197,90 +214,73 @@ export default function NotebookView() {
|
|
| 197 |
|
| 198 |
return (
|
| 199 |
<div className="nbv">
|
| 200 |
-
<
|
| 201 |
-
<
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
>
|
| 213 |
-
<input className="input input-sm" autoFocus value={title} onChange={(e) => setTitle(e.target.value)} onBlur={() => setEditingTitle(false)} aria-label="Notebook title" />
|
| 214 |
-
</form>
|
| 215 |
-
) : (
|
| 216 |
-
<h2 className="nbv-title" onDoubleClick={() => setEditingTitle(true)} title="Double click to rename">
|
| 217 |
-
{nb.title}
|
| 218 |
-
</h2>
|
| 219 |
-
)}
|
| 220 |
-
<div className="row between">
|
| 221 |
-
<p className="eyebrow">{nb.documents.length} posts</p>
|
| 222 |
-
<button className="btn btn-small" onClick={() => setAdding(true)}>
|
| 223 |
-
Add posts
|
| 224 |
-
</button>
|
| 225 |
-
</div>
|
| 226 |
-
<ul className="nbv-docs">
|
| 227 |
-
{nb.documents.map((d) => (
|
| 228 |
-
<li key={d.id} className="nbv-doc">
|
| 229 |
-
<button className="link-btn" onClick={() => setReading({ id: d.id })}>
|
| 230 |
-
{d.title}
|
| 231 |
-
</button>
|
| 232 |
-
<span className="mono muted">{formatDate(d.published_at)}</span>
|
| 233 |
-
<button
|
| 234 |
-
className="icon-btn nbv-remove"
|
| 235 |
-
aria-label={`Remove ${d.title}`}
|
| 236 |
-
title="Remove from notebook"
|
| 237 |
-
onClick={async () => {
|
| 238 |
-
await notebooksApi.removeDoc(nb.id, d.id);
|
| 239 |
load();
|
| 240 |
}}
|
| 241 |
>
|
| 242 |
-
<
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
<
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
|
|
|
| 278 |
</div>
|
| 279 |
<div className="nbv-turns">
|
| 280 |
{turns.length === 0 && (
|
| 281 |
-
<div className="
|
| 282 |
<TelescopeIcon size={32} />
|
| 283 |
-
<p className="muted">Ask anything about
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
</div>
|
| 285 |
)}
|
| 286 |
{turns.map((t, i) => (
|
|
@@ -307,7 +307,13 @@ export default function NotebookView() {
|
|
| 307 |
))}
|
| 308 |
<div ref={bottom} />
|
| 309 |
</div>
|
| 310 |
-
<form
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
<input
|
| 312 |
className="input"
|
| 313 |
value={q}
|
|
@@ -322,7 +328,53 @@ export default function NotebookView() {
|
|
| 322 |
</form>
|
| 323 |
</section>
|
| 324 |
|
| 325 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
|
| 327 |
{adding && (
|
| 328 |
<Modal title="Add posts" onClose={() => setAdding(false)} wide>
|
|
|
|
| 1 |
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
| 2 |
+
import { Link, useParams, useSearchParams } from "react-router-dom";
|
| 3 |
import { artifactsApi, notebooksApi, type GenerateBody } from "../api/endpoints";
|
| 4 |
import { streamSSE } from "../api/stream";
|
| 5 |
import type { Artifact, ChatSummary, Citation, Notebook } from "../api/types";
|
|
|
|
| 8 |
import { DocPicker } from "../components/DocPicker";
|
| 9 |
import { TelescopeIcon } from "../components/icons/Icons";
|
| 10 |
import { Reader } from "../components/Reader";
|
| 11 |
+
import { ConfirmButton, errorMessage, formatDate, Loading, Modal, Tabs } from "../components/ui";
|
| 12 |
|
| 13 |
type Turn = { role: "user" | "assistant"; text: string; citations?: Citation[]; status?: string; steps?: string[] };
|
| 14 |
|
| 15 |
+
type Pane = "create" | "posts";
|
| 16 |
+
|
| 17 |
+
const STARTERS = ["What are the strongest ideas across these posts?", "Where do I contradict myself?", "Which post is most worth updating, and why?"];
|
| 18 |
+
|
| 19 |
+
/** The Create tab: one click per format with sensible defaults, options folded away. */
|
| 20 |
function StudioPanel({ notebookId, disabled }: { notebookId: string; disabled: boolean }) {
|
| 21 |
const [artifacts, setArtifacts] = useState<Artifact[] | null>(null);
|
| 22 |
const [format, setFormat] = useState<"deep_dive" | "brief" | "debate">("deep_dive");
|
| 23 |
const [minutes, setMinutes] = useState(6);
|
|
|
|
| 24 |
const [error, setError] = useState<string | null>(null);
|
| 25 |
const [busy, setBusy] = useState(false);
|
| 26 |
const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null);
|
|
|
|
| 44 |
};
|
| 45 |
|
| 46 |
const cite = (c: Citation) => c.document_id && setReading({ id: c.document_id, start: c.line_start, end: c.line_end });
|
| 47 |
+
const off = disabled || busy;
|
| 48 |
|
| 49 |
return (
|
| 50 |
+
<div className="stack">
|
| 51 |
+
<div className="nbv-make">
|
| 52 |
+
<button className="nbv-make-main" disabled={off} onClick={() => make({ type: "audio_overview", format, minutes })}>
|
| 53 |
+
<strong>Audio overview</strong>
|
| 54 |
+
<span className="muted small">
|
| 55 |
+
{format === "deep_dive" ? "Deep dive" : format === "brief" ? "Brief" : "Debate"} · {minutes} min
|
| 56 |
+
</span>
|
| 57 |
+
</button>
|
| 58 |
+
<button disabled={off} onClick={() => make({ type: "video", style: "short" })}>
|
| 59 |
+
Short video
|
| 60 |
+
</button>
|
| 61 |
+
<button disabled={off} onClick={() => make({ type: "video", style: "explainer" })}>
|
| 62 |
+
Explainer
|
| 63 |
+
</button>
|
| 64 |
+
<button disabled={off} onClick={() => make({ type: "summary" })}>
|
| 65 |
Summary
|
| 66 |
</button>
|
| 67 |
+
<button disabled={off} onClick={() => make({ type: "quote_card" })}>
|
| 68 |
Quote card
|
| 69 |
</button>
|
| 70 |
</div>
|
| 71 |
+
<details className="delivery">
|
| 72 |
+
<summary className="mono muted small">Audio options</summary>
|
| 73 |
<div className="row">
|
| 74 |
<select className="input input-sm" value={format} onChange={(e) => setFormat(e.target.value as typeof format)} aria-label="Audio format">
|
| 75 |
<option value="deep_dive">Deep dive</option>
|
|
|
|
| 84 |
))}
|
| 85 |
</select>
|
| 86 |
</div>
|
| 87 |
+
</details>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
{disabled && <p className="muted small">Add posts to this notebook to start creating.</p>}
|
| 89 |
{error && <p className="error-text">{error}</p>}
|
| 90 |
<div className="studio-list">
|
|
|
|
| 106 |
))}
|
| 107 |
</div>
|
| 108 |
{reading && <Reader documentId={reading.id} highlight={{ start: reading.start, end: reading.end }} onClose={() => setReading(null)} />}
|
| 109 |
+
</div>
|
| 110 |
);
|
| 111 |
}
|
| 112 |
|
|
|
|
| 125 |
const [reading, setReading] = useState<{ id: string; start?: number; end?: number } | null>(null);
|
| 126 |
const [editingTitle, setEditingTitle] = useState(false);
|
| 127 |
const [title, setTitle] = useState("");
|
| 128 |
+
const [pane, setPane] = useState<Pane>("create");
|
| 129 |
+
const [params, setParams] = useSearchParams();
|
| 130 |
+
const asked = useRef(false);
|
| 131 |
const bottom = useRef<HTMLDivElement>(null);
|
| 132 |
|
| 133 |
const load = useCallback(
|
|
|
|
| 160 |
setTurns(msgs.map((m) => ({ role: m.role, text: m.text, citations: m.citations })));
|
| 161 |
};
|
| 162 |
|
| 163 |
+
const ask = async (text: string) => {
|
| 164 |
+
const question = text.trim();
|
|
|
|
| 165 |
if (!question || busy) return;
|
| 166 |
setQ("");
|
| 167 |
setBusy(true);
|
|
|
|
| 196 |
}
|
| 197 |
};
|
| 198 |
|
| 199 |
+
// A question passed in the link (from the Home ask box) is asked as soon as the notebook opens.
|
| 200 |
+
useEffect(() => {
|
| 201 |
+
const pending = params.get("q");
|
| 202 |
+
if (!nb || !pending || asked.current || !nb.documents.length) return;
|
| 203 |
+
asked.current = true;
|
| 204 |
+
setParams({}, { replace: true });
|
| 205 |
+
void ask(pending);
|
| 206 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 207 |
+
}, [nb]);
|
| 208 |
+
|
| 209 |
if (loadError) return <p className="error-text">{loadError}</p>;
|
| 210 |
if (!nb) return <Loading label="Opening notebook" />;
|
| 211 |
|
|
|
|
| 214 |
|
| 215 |
return (
|
| 216 |
<div className="nbv">
|
| 217 |
+
<section className="card nbv-pane nbv-chat">
|
| 218 |
+
<div className="chat-bar">
|
| 219 |
+
<div className="nbv-titlebar">
|
| 220 |
+
<Link to="/app/notebooks" className="mono muted small-link">
|
| 221 |
+
Notebooks /
|
| 222 |
+
</Link>
|
| 223 |
+
{editingTitle ? (
|
| 224 |
+
<form
|
| 225 |
+
onSubmit={async (e) => {
|
| 226 |
+
e.preventDefault();
|
| 227 |
+
await notebooksApi.update(nb.id, { title });
|
| 228 |
+
setEditingTitle(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
load();
|
| 230 |
}}
|
| 231 |
>
|
| 232 |
+
<input className="input input-sm" autoFocus value={title} onChange={(e) => setTitle(e.target.value)} onBlur={() => setEditingTitle(false)} aria-label="Notebook title" />
|
| 233 |
+
</form>
|
| 234 |
+
) : (
|
| 235 |
+
<h2 className="nbv-title" onDoubleClick={() => setEditingTitle(true)} title="Double click to rename">
|
| 236 |
+
{nb.title}
|
| 237 |
+
</h2>
|
| 238 |
+
)}
|
| 239 |
+
</div>
|
| 240 |
+
<div className="row">
|
| 241 |
+
{(chats.length > 0 || chatId) && (
|
| 242 |
+
<select
|
| 243 |
+
className="input input-sm"
|
| 244 |
+
value={chatId ?? ""}
|
| 245 |
+
onChange={(e) => (e.target.value ? openChat(e.target.value) : (setChatId(null), setTurns([])))}
|
| 246 |
+
aria-label="Chat history"
|
| 247 |
+
>
|
| 248 |
+
<option value="">New chat</option>
|
| 249 |
+
{chats.map((c) => (
|
| 250 |
+
<option key={c.id} value={c.id}>
|
| 251 |
+
{(c.title ?? "Chat").slice(0, 60)} · {formatDate(c.updated_at)}
|
| 252 |
+
</option>
|
| 253 |
+
))}
|
| 254 |
+
</select>
|
| 255 |
+
)}
|
| 256 |
+
{chatId && (
|
| 257 |
+
<ConfirmButton
|
| 258 |
+
onConfirm={async () => {
|
| 259 |
+
await notebooksApi.removeChat(chatId);
|
| 260 |
+
setChatId(null);
|
| 261 |
+
setTurns([]);
|
| 262 |
+
loadChats();
|
| 263 |
+
}}
|
| 264 |
+
>
|
| 265 |
+
Delete chat
|
| 266 |
+
</ConfirmButton>
|
| 267 |
+
)}
|
| 268 |
+
</div>
|
| 269 |
</div>
|
| 270 |
<div className="nbv-turns">
|
| 271 |
{turns.length === 0 && (
|
| 272 |
+
<div className="nbv-empty">
|
| 273 |
<TelescopeIcon size={32} />
|
| 274 |
+
<p className="muted">Ask anything about {nb.documents.length} posts. Answers cite the passage they came from.</p>
|
| 275 |
+
{nb.documents.length > 0 && (
|
| 276 |
+
<div className="nbv-starters">
|
| 277 |
+
{STARTERS.map((s) => (
|
| 278 |
+
<button key={s} type="button" className="nbv-starter" onClick={() => ask(s)}>
|
| 279 |
+
{s}
|
| 280 |
+
</button>
|
| 281 |
+
))}
|
| 282 |
+
</div>
|
| 283 |
+
)}
|
| 284 |
</div>
|
| 285 |
)}
|
| 286 |
{turns.map((t, i) => (
|
|
|
|
| 307 |
))}
|
| 308 |
<div ref={bottom} />
|
| 309 |
</div>
|
| 310 |
+
<form
|
| 311 |
+
onSubmit={(e: FormEvent) => {
|
| 312 |
+
e.preventDefault();
|
| 313 |
+
void ask(q);
|
| 314 |
+
}}
|
| 315 |
+
className="inline-form nbv-ask"
|
| 316 |
+
>
|
| 317 |
<input
|
| 318 |
className="input"
|
| 319 |
value={q}
|
|
|
|
| 328 |
</form>
|
| 329 |
</section>
|
| 330 |
|
| 331 |
+
<aside className="card nbv-pane nbv-side">
|
| 332 |
+
<Tabs<Pane>
|
| 333 |
+
tabs={[
|
| 334 |
+
{ id: "create", label: "Create" },
|
| 335 |
+
{ id: "posts", label: "Posts", count: nb.documents.length },
|
| 336 |
+
]}
|
| 337 |
+
value={pane}
|
| 338 |
+
onChange={setPane}
|
| 339 |
+
/>
|
| 340 |
+
{pane === "create" && <StudioPanel notebookId={nb.id} disabled={nb.documents.length === 0} />}
|
| 341 |
+
{pane === "posts" && (
|
| 342 |
+
<>
|
| 343 |
+
{nb.is_archive ? (
|
| 344 |
+
<p className="muted small">Every indexed post. New posts join automatically.</p>
|
| 345 |
+
) : (
|
| 346 |
+
<button className="btn btn-small" onClick={() => setAdding(true)}>
|
| 347 |
+
Add posts
|
| 348 |
+
</button>
|
| 349 |
+
)}
|
| 350 |
+
<ul className="nbv-docs">
|
| 351 |
+
{nb.documents.map((d) => (
|
| 352 |
+
<li key={d.id} className="nbv-doc">
|
| 353 |
+
<button className="link-btn" onClick={() => setReading({ id: d.id })}>
|
| 354 |
+
{d.title}
|
| 355 |
+
</button>
|
| 356 |
+
<span className="mono muted">{formatDate(d.published_at)}</span>
|
| 357 |
+
{!nb.is_archive && (
|
| 358 |
+
<button
|
| 359 |
+
className="icon-btn nbv-remove"
|
| 360 |
+
aria-label={`Remove ${d.title}`}
|
| 361 |
+
title="Remove from notebook"
|
| 362 |
+
onClick={async () => {
|
| 363 |
+
await notebooksApi.removeDoc(nb.id, d.id);
|
| 364 |
+
load();
|
| 365 |
+
}}
|
| 366 |
+
>
|
| 367 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
|
| 368 |
+
<path d="M6 6l12 12M18 6L6 18" />
|
| 369 |
+
</svg>
|
| 370 |
+
</button>
|
| 371 |
+
)}
|
| 372 |
+
</li>
|
| 373 |
+
))}
|
| 374 |
+
</ul>
|
| 375 |
+
</>
|
| 376 |
+
)}
|
| 377 |
+
</aside>
|
| 378 |
|
| 379 |
{adding && (
|
| 380 |
<Modal title="Add posts" onClose={() => setAdding(false)} wide>
|
frontend/src/pages/Notebooks.tsx
CHANGED
|
@@ -1,23 +1,41 @@
|
|
| 1 |
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
| 2 |
import { Link, useNavigate } from "react-router-dom";
|
| 3 |
-
import { notebooksApi } from "../api/endpoints";
|
| 4 |
-
import type { NotebookSummary } from "../api/types";
|
| 5 |
import { DocPicker } from "../components/DocPicker";
|
| 6 |
-
import {
|
|
|
|
| 7 |
|
|
|
|
| 8 |
export function NewNotebookModal({ onClose, initialDocs = [] }: { onClose: () => void; initialDocs?: string[] }) {
|
| 9 |
-
const [
|
|
|
|
| 10 |
const [selected, setSelected] = useState<string[]>(initialDocs);
|
|
|
|
| 11 |
const [busy, setBusy] = useState(false);
|
| 12 |
const [error, setError] = useState<string | null>(null);
|
| 13 |
const navigate = useNavigate();
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
const submit = async (e: FormEvent) => {
|
| 16 |
e.preventDefault();
|
| 17 |
setBusy(true);
|
| 18 |
setError(null);
|
| 19 |
try {
|
| 20 |
-
const nb = await notebooksApi.create(
|
| 21 |
navigate(`/app/notebooks/${nb.id}`);
|
| 22 |
} catch (err) {
|
| 23 |
setError(errorMessage(err));
|
|
@@ -29,19 +47,43 @@ export function NewNotebookModal({ onClose, initialDocs = [] }: { onClose: () =>
|
|
| 29 |
<Modal title="New notebook" onClose={onClose} wide>
|
| 30 |
<form onSubmit={submit} className="stack">
|
| 31 |
<label className="field">
|
| 32 |
-
<span>
|
| 33 |
-
<input className="input
|
| 34 |
</label>
|
| 35 |
-
|
| 36 |
-
<span>Posts in this notebook</span>
|
| 37 |
<DocPicker selected={selected} onChange={setSelected} />
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
{error && <p className="error-text">{error}</p>}
|
| 40 |
-
<div className="row
|
| 41 |
-
<button type="button" className="btn" onClick={
|
| 42 |
-
|
| 43 |
</button>
|
| 44 |
-
<button className="btn btn-primary" disabled={busy}>
|
| 45 |
{busy ? "Creating..." : `Create with ${selected.length} posts`}
|
| 46 |
</button>
|
| 47 |
</div>
|
|
@@ -50,6 +92,31 @@ export function NewNotebookModal({ onClose, initialDocs = [] }: { onClose: () =>
|
|
| 50 |
);
|
| 51 |
}
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
export default function Notebooks() {
|
| 54 |
const [notebooks, setNotebooks] = useState<NotebookSummary[] | null>(null);
|
| 55 |
const [creating, setCreating] = useState(false);
|
|
@@ -58,7 +125,8 @@ export default function Notebooks() {
|
|
| 58 |
|
| 59 |
const load = useCallback(() => notebooksApi.list().then(setNotebooks), []);
|
| 60 |
useEffect(() => {
|
| 61 |
-
|
|
|
|
| 62 |
}, [load]);
|
| 63 |
|
| 64 |
return (
|
|
@@ -68,24 +136,10 @@ export default function Notebooks() {
|
|
| 68 |
New notebook
|
| 69 |
</button>
|
| 70 |
</PageHeader>
|
| 71 |
-
<p className="muted lede">
|
| 72 |
-
A notebook is a set of posts you can question, summarize and turn into audio, video and launch kits. Every answer cites the lines it came from.
|
| 73 |
-
</p>
|
| 74 |
{!notebooks && <Loading />}
|
| 75 |
-
|
| 76 |
-
<EmptyState
|
| 77 |
-
title="No notebooks yet"
|
| 78 |
-
body="Group posts by theme, series or year. You can also make one from a topic on the Topic map."
|
| 79 |
-
action={
|
| 80 |
-
<button className="btn btn-primary" onClick={() => setCreating(true)}>
|
| 81 |
-
Create your first notebook
|
| 82 |
-
</button>
|
| 83 |
-
}
|
| 84 |
-
/>
|
| 85 |
-
)}
|
| 86 |
-
<section className="grid-cards">
|
| 87 |
{notebooks?.map((n) => (
|
| 88 |
-
<article key={n.id} className=
|
| 89 |
{renaming === n.id ? (
|
| 90 |
<form
|
| 91 |
className="inline-form"
|
|
@@ -101,37 +155,37 @@ export default function Notebooks() {
|
|
| 101 |
</form>
|
| 102 |
) : (
|
| 103 |
<Link to={`/app/notebooks/${n.id}`} className="nb-card-link">
|
|
|
|
| 104 |
<h3>{n.title}</h3>
|
| 105 |
{(n.description || n.summary) && <p className="muted clamp-3">{n.description || n.summary}</p>}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
</Link>
|
| 107 |
)}
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
<footer className="row">
|
| 112 |
-
<Link className="btn btn-small" to={`/app/notebooks/${n.id}`}>
|
| 113 |
-
Open
|
| 114 |
-
</Link>
|
| 115 |
-
<button
|
| 116 |
-
className="btn btn-small"
|
| 117 |
-
onClick={() => {
|
| 118 |
setRenaming(n.id);
|
| 119 |
setName(n.title);
|
| 120 |
}}
|
| 121 |
-
|
| 122 |
-
Rename
|
| 123 |
-
</button>
|
| 124 |
-
<ConfirmButton
|
| 125 |
-
onConfirm={async () => {
|
| 126 |
await notebooksApi.remove(n.id);
|
| 127 |
load();
|
| 128 |
}}
|
| 129 |
-
>
|
| 130 |
-
|
| 131 |
-
</ConfirmButton>
|
| 132 |
-
</footer>
|
| 133 |
</article>
|
| 134 |
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
</section>
|
| 136 |
{creating && <NewNotebookModal onClose={() => setCreating(false)} />}
|
| 137 |
</div>
|
|
|
|
| 1 |
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
| 2 |
import { Link, useNavigate } from "react-router-dom";
|
| 3 |
+
import { docsApi, notebooksApi } from "../api/endpoints";
|
| 4 |
+
import type { Doc, NotebookSummary } from "../api/types";
|
| 5 |
import { DocPicker } from "../components/DocPicker";
|
| 6 |
+
import { PlanetIcon } from "../components/icons/Icons";
|
| 7 |
+
import { ConfirmButton, errorMessage, formatDate, Loading, Modal, PageHeader } from "../components/ui";
|
| 8 |
|
| 9 |
+
/** New notebook from a topic: typing it finds and pre-selects the matching posts; hand picking is the fallback. */
|
| 10 |
export function NewNotebookModal({ onClose, initialDocs = [] }: { onClose: () => void; initialDocs?: string[] }) {
|
| 11 |
+
const [topic, setTopic] = useState("");
|
| 12 |
+
const [matches, setMatches] = useState<Doc[]>([]);
|
| 13 |
const [selected, setSelected] = useState<string[]>(initialDocs);
|
| 14 |
+
const [manual, setManual] = useState(initialDocs.length > 0);
|
| 15 |
const [busy, setBusy] = useState(false);
|
| 16 |
const [error, setError] = useState<string | null>(null);
|
| 17 |
const navigate = useNavigate();
|
| 18 |
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
if (manual || topic.trim().length < 2) {
|
| 21 |
+
if (!manual) setMatches([]);
|
| 22 |
+
return;
|
| 23 |
+
}
|
| 24 |
+
const t = setTimeout(async () => {
|
| 25 |
+
const page = await docsApi.list({ q: topic.trim(), limit: 50 });
|
| 26 |
+
const found = page.items.filter((d) => !d.locked);
|
| 27 |
+
setMatches(found);
|
| 28 |
+
setSelected(found.map((d) => d.id));
|
| 29 |
+
}, 250);
|
| 30 |
+
return () => clearTimeout(t);
|
| 31 |
+
}, [topic, manual]);
|
| 32 |
+
|
| 33 |
const submit = async (e: FormEvent) => {
|
| 34 |
e.preventDefault();
|
| 35 |
setBusy(true);
|
| 36 |
setError(null);
|
| 37 |
try {
|
| 38 |
+
const nb = await notebooksApi.create(topic.trim() || "Untitled notebook", selected);
|
| 39 |
navigate(`/app/notebooks/${nb.id}`);
|
| 40 |
} catch (err) {
|
| 41 |
setError(errorMessage(err));
|
|
|
|
| 47 |
<Modal title="New notebook" onClose={onClose} wide>
|
| 48 |
<form onSubmit={submit} className="stack">
|
| 49 |
<label className="field">
|
| 50 |
+
<span>What is it about?</span>
|
| 51 |
+
<input className="input" autoFocus value={topic} onChange={(e) => setTopic(e.target.value)} placeholder="Pricing, paid tiers, 2024 essays..." />
|
| 52 |
</label>
|
| 53 |
+
{manual ? (
|
|
|
|
| 54 |
<DocPicker selected={selected} onChange={setSelected} />
|
| 55 |
+
) : (
|
| 56 |
+
<>
|
| 57 |
+
{topic.trim().length >= 2 && (
|
| 58 |
+
<p className="muted small">
|
| 59 |
+
{matches.length ? `${matches.length} posts mention it. Untick any that do not belong.` : "No posts mention that yet."}
|
| 60 |
+
</p>
|
| 61 |
+
)}
|
| 62 |
+
{matches.length > 0 && (
|
| 63 |
+
<ul className="picker-list nb-matches">
|
| 64 |
+
{matches.map((d) => (
|
| 65 |
+
<li key={d.id}>
|
| 66 |
+
<label className={`picker-row${selected.includes(d.id) ? " on" : ""}`}>
|
| 67 |
+
<input
|
| 68 |
+
type="checkbox"
|
| 69 |
+
checked={selected.includes(d.id)}
|
| 70 |
+
onChange={(e) => setSelected(e.target.checked ? [...selected, d.id] : selected.filter((x) => x !== d.id))}
|
| 71 |
+
/>
|
| 72 |
+
<span className="picker-title">{d.title}</span>
|
| 73 |
+
<span className="mono muted picker-meta">{formatDate(d.published_at)}</span>
|
| 74 |
+
</label>
|
| 75 |
+
</li>
|
| 76 |
+
))}
|
| 77 |
+
</ul>
|
| 78 |
+
)}
|
| 79 |
+
</>
|
| 80 |
+
)}
|
| 81 |
{error && <p className="error-text">{error}</p>}
|
| 82 |
+
<div className="row between">
|
| 83 |
+
<button type="button" className="link-btn small" onClick={() => setManual(!manual)}>
|
| 84 |
+
{manual ? "Find posts by topic instead" : "Pick posts by hand"}
|
| 85 |
</button>
|
| 86 |
+
<button className="btn btn-primary" disabled={busy || !selected.length}>
|
| 87 |
{busy ? "Creating..." : `Create with ${selected.length} posts`}
|
| 88 |
</button>
|
| 89 |
</div>
|
|
|
|
| 92 |
);
|
| 93 |
}
|
| 94 |
|
| 95 |
+
function CardMenu({ onRename, onDelete }: { onRename: () => void; onDelete: () => Promise<void> }) {
|
| 96 |
+
const [open, setOpen] = useState(false);
|
| 97 |
+
return (
|
| 98 |
+
<div className="card-menu">
|
| 99 |
+
<button type="button" className="icon-btn" aria-label="Notebook options" aria-expanded={open} onClick={() => setOpen(!open)}>
|
| 100 |
+
⋯
|
| 101 |
+
</button>
|
| 102 |
+
{open && (
|
| 103 |
+
<div className="card-menu-list" onMouseLeave={() => setOpen(false)}>
|
| 104 |
+
<button
|
| 105 |
+
type="button"
|
| 106 |
+
onClick={() => {
|
| 107 |
+
setOpen(false);
|
| 108 |
+
onRename();
|
| 109 |
+
}}
|
| 110 |
+
>
|
| 111 |
+
Rename
|
| 112 |
+
</button>
|
| 113 |
+
<ConfirmButton onConfirm={onDelete}>Delete</ConfirmButton>
|
| 114 |
+
</div>
|
| 115 |
+
)}
|
| 116 |
+
</div>
|
| 117 |
+
);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
export default function Notebooks() {
|
| 121 |
const [notebooks, setNotebooks] = useState<NotebookSummary[] | null>(null);
|
| 122 |
const [creating, setCreating] = useState(false);
|
|
|
|
| 125 |
|
| 126 |
const load = useCallback(() => notebooksApi.list().then(setNotebooks), []);
|
| 127 |
useEffect(() => {
|
| 128 |
+
// "All posts" always exists, so asking the whole archive never needs setup.
|
| 129 |
+
notebooksApi.archive().finally(load);
|
| 130 |
}, [load]);
|
| 131 |
|
| 132 |
return (
|
|
|
|
| 136 |
New notebook
|
| 137 |
</button>
|
| 138 |
</PageHeader>
|
|
|
|
|
|
|
|
|
|
| 139 |
{!notebooks && <Loading />}
|
| 140 |
+
<section className="nb-grid">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
{notebooks?.map((n) => (
|
| 142 |
+
<article key={n.id} className={`card nb-card${n.is_archive ? " nb-archive" : ""}`}>
|
| 143 |
{renaming === n.id ? (
|
| 144 |
<form
|
| 145 |
className="inline-form"
|
|
|
|
| 155 |
</form>
|
| 156 |
) : (
|
| 157 |
<Link to={`/app/notebooks/${n.id}`} className="nb-card-link">
|
| 158 |
+
{n.is_archive && <PlanetIcon size={26} />}
|
| 159 |
<h3>{n.title}</h3>
|
| 160 |
{(n.description || n.summary) && <p className="muted clamp-3">{n.description || n.summary}</p>}
|
| 161 |
+
<p className="mono muted">
|
| 162 |
+
{n.document_count} posts
|
| 163 |
+
{n.chat_count ? ` · ${n.chat_count} chats` : ""}
|
| 164 |
+
{n.artifact_count ? ` · ${n.artifact_count} made` : ""} · {formatDate(n.updated_at)}
|
| 165 |
+
</p>
|
| 166 |
</Link>
|
| 167 |
)}
|
| 168 |
+
{!n.is_archive && renaming !== n.id && (
|
| 169 |
+
<CardMenu
|
| 170 |
+
onRename={() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
setRenaming(n.id);
|
| 172 |
setName(n.title);
|
| 173 |
}}
|
| 174 |
+
onDelete={async () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
await notebooksApi.remove(n.id);
|
| 176 |
load();
|
| 177 |
}}
|
| 178 |
+
/>
|
| 179 |
+
)}
|
|
|
|
|
|
|
| 180 |
</article>
|
| 181 |
))}
|
| 182 |
+
<button type="button" className="card nb-card nb-new" onClick={() => setCreating(true)}>
|
| 183 |
+
<span className="mc-more" aria-hidden="true">
|
| 184 |
+
+
|
| 185 |
+
</span>
|
| 186 |
+
<strong>New notebook</strong>
|
| 187 |
+
<span className="muted small">Group posts by theme, series or year</span>
|
| 188 |
+
</button>
|
| 189 |
</section>
|
| 190 |
{creating && <NewNotebookModal onClose={() => setCreating(false)} />}
|
| 191 |
</div>
|
frontend/src/pages/Resurface.tsx
CHANGED
|
@@ -1,10 +1,10 @@
|
|
| 1 |
import { useEffect, useState } from "react";
|
| 2 |
-
import { useNavigate } from "react-router-dom";
|
| 3 |
import { resurfaceApi } from "../api/endpoints";
|
| 4 |
import type { Job, ResurfaceItem } from "../api/types";
|
| 5 |
import { Reader } from "../components/Reader";
|
| 6 |
import { ScheduleModal } from "../components/ScheduleModal";
|
| 7 |
-
import {
|
| 8 |
import { useJob } from "../hooks/useJob";
|
| 9 |
|
| 10 |
type Data = Awaited<ReturnType<typeof resurfaceApi.get>>;
|
|
@@ -12,7 +12,7 @@ type Data = Awaited<ReturnType<typeof resurfaceApi.get>>;
|
|
| 12 |
function Row({ item, onRead, onSchedule }: { item: ResurfaceItem; onRead: () => void; onSchedule: () => void }) {
|
| 13 |
const navigate = useNavigate();
|
| 14 |
return (
|
| 15 |
-
<li className="
|
| 16 |
<div className="row between">
|
| 17 |
<button className="link-btn doc-title" onClick={onRead}>
|
| 18 |
{item.title}
|
|
@@ -23,16 +23,15 @@ function Row({ item, onRead, onSchedule }: { item: ResurfaceItem; onRead: () =>
|
|
| 23 |
</span>
|
| 24 |
)}
|
| 25 |
</div>
|
| 26 |
-
<p className="mono muted">
|
| 27 |
{formatDate(item.published_at)}
|
| 28 |
{item.years_ago ? ` · ${item.years_ago} year${item.years_ago > 1 ? "s" : ""} ago today` : ""}
|
| 29 |
{item.last_resurfaced_at ? ` · reshared ${formatDate(item.last_resurfaced_at)}` : ""}
|
| 30 |
</p>
|
| 31 |
{item.angle && <p className="angle">{item.angle}</p>}
|
| 32 |
-
{item.reason && <p className="muted small">{item.reason}</p>}
|
| 33 |
<div className="row">
|
| 34 |
<button className="btn btn-small btn-primary" onClick={() => navigate(`/app/launch-kit?post=${item.id}`)}>
|
| 35 |
-
|
| 36 |
</button>
|
| 37 |
<button className="btn btn-small" onClick={onSchedule}>
|
| 38 |
Schedule a reshare
|
|
@@ -42,7 +41,8 @@ function Row({ item, onRead, onSchedule }: { item: ResurfaceItem; onRead: () =>
|
|
| 42 |
);
|
| 43 |
}
|
| 44 |
|
| 45 |
-
|
|
|
|
| 46 |
const [data, setData] = useState<Data | null>(null);
|
| 47 |
const [job, setJob] = useState<Job | null>(null);
|
| 48 |
const [reading, setReading] = useState<string | null>(null);
|
|
@@ -56,54 +56,37 @@ export default function Resurface() {
|
|
| 56 |
const live = useJob(job, () => load());
|
| 57 |
const running = live && (live.status === "queued" || live.status === "running");
|
| 58 |
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
try {
|
| 62 |
-
setJob(await resurfaceApi.scan());
|
| 63 |
-
} catch (e) {
|
| 64 |
-
setError(errorMessage(e));
|
| 65 |
-
}
|
| 66 |
-
};
|
| 67 |
|
| 68 |
return (
|
| 69 |
-
<div className="
|
| 70 |
-
|
| 71 |
-
<button className="btn btn-primary" disabled={Boolean(running)} onClick={scan}>
|
| 72 |
-
{data?.unscored ? `Score ${data.unscored} new posts` : "Rescan"}
|
| 73 |
-
</button>
|
| 74 |
-
</PageHeader>
|
| 75 |
-
<p className="muted lede">
|
| 76 |
-
Notestack scores how timeless each post is, then ranks the old, evergreen ones you have not reshared in 90 days.
|
| 77 |
-
</p>
|
| 78 |
-
{live && (running || live.status === "failed") && <JobProgress job={live} />}
|
| 79 |
{error && <p className="error-text">{error}</p>}
|
| 80 |
-
{
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
</ul>
|
| 105 |
-
</section>
|
| 106 |
-
</div>
|
| 107 |
)}
|
| 108 |
{reading && <Reader documentId={reading} onClose={() => setReading(null)} />}
|
| 109 |
{scheduling && (
|
|
@@ -112,9 +95,17 @@ export default function Resurface() {
|
|
| 112 |
posts={[`${scheduling.angle ?? scheduling.title}\n\n${scheduling.url.startsWith("http") ? scheduling.url : ""}`.trim()]}
|
| 113 |
documentId={scheduling.id}
|
| 114 |
onClose={() => setScheduling(null)}
|
| 115 |
-
onScheduled={() =>
|
|
|
|
|
|
|
|
|
|
| 116 |
/>
|
| 117 |
)}
|
| 118 |
</div>
|
| 119 |
);
|
| 120 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { useEffect, useState } from "react";
|
| 2 |
+
import { Navigate, useNavigate } from "react-router-dom";
|
| 3 |
import { resurfaceApi } from "../api/endpoints";
|
| 4 |
import type { Job, ResurfaceItem } from "../api/types";
|
| 5 |
import { Reader } from "../components/Reader";
|
| 6 |
import { ScheduleModal } from "../components/ScheduleModal";
|
| 7 |
+
import { errorMessage, formatDate, JobProgress, Loading } from "../components/ui";
|
| 8 |
import { useJob } from "../hooks/useJob";
|
| 9 |
|
| 10 |
type Data = Awaited<ReturnType<typeof resurfaceApi.get>>;
|
|
|
|
| 12 |
function Row({ item, onRead, onSchedule }: { item: ResurfaceItem; onRead: () => void; onSchedule: () => void }) {
|
| 13 |
const navigate = useNavigate();
|
| 14 |
return (
|
| 15 |
+
<li className="idea">
|
| 16 |
<div className="row between">
|
| 17 |
<button className="link-btn doc-title" onClick={onRead}>
|
| 18 |
{item.title}
|
|
|
|
| 23 |
</span>
|
| 24 |
)}
|
| 25 |
</div>
|
| 26 |
+
<p className="mono muted small">
|
| 27 |
{formatDate(item.published_at)}
|
| 28 |
{item.years_ago ? ` · ${item.years_ago} year${item.years_ago > 1 ? "s" : ""} ago today` : ""}
|
| 29 |
{item.last_resurfaced_at ? ` · reshared ${formatDate(item.last_resurfaced_at)}` : ""}
|
| 30 |
</p>
|
| 31 |
{item.angle && <p className="angle">{item.angle}</p>}
|
|
|
|
| 32 |
<div className="row">
|
| 33 |
<button className="btn btn-small btn-primary" onClick={() => navigate(`/app/launch-kit?post=${item.id}`)}>
|
| 34 |
+
Launch Kit
|
| 35 |
</button>
|
| 36 |
<button className="btn btn-small" onClick={onSchedule}>
|
| 37 |
Schedule a reshare
|
|
|
|
| 41 |
);
|
| 42 |
}
|
| 43 |
|
| 44 |
+
/** Old posts worth a second orbit. Scoring runs after every sync, so there is nothing to press first. */
|
| 45 |
+
export function ResurfaceIdeas({ onScheduled }: { onScheduled?: () => void }) {
|
| 46 |
const [data, setData] = useState<Data | null>(null);
|
| 47 |
const [job, setJob] = useState<Job | null>(null);
|
| 48 |
const [reading, setReading] = useState<string | null>(null);
|
|
|
|
| 56 |
const live = useJob(job, () => load());
|
| 57 |
const running = live && (live.status === "queued" || live.status === "running");
|
| 58 |
|
| 59 |
+
if (!data) return <Loading />;
|
| 60 |
+
const items = [...data.on_this_day, ...data.evergreen.filter((e) => !data.on_this_day.some((o) => o.id === e.id))];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
return (
|
| 63 |
+
<div className="stack">
|
| 64 |
+
{live && (running || live.status === "failed") && <JobProgress job={live} compact />}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
{error && <p className="error-text">{error}</p>}
|
| 66 |
+
{items.length === 0 && (
|
| 67 |
+
<p className="muted small">
|
| 68 |
+
{data.total_posts ? "No reshare ideas yet. They appear after your posts are scored." : "Connect a source to get reshare ideas."}
|
| 69 |
+
</p>
|
| 70 |
+
)}
|
| 71 |
+
<ul className="stack">
|
| 72 |
+
{items.slice(0, 8).map((i) => (
|
| 73 |
+
<Row key={i.id} item={i} onRead={() => setReading(i.id)} onSchedule={() => setScheduling(i)} />
|
| 74 |
+
))}
|
| 75 |
+
</ul>
|
| 76 |
+
{data.unscored > 0 && !running && (
|
| 77 |
+
<button
|
| 78 |
+
className="link-btn small"
|
| 79 |
+
onClick={async () => {
|
| 80 |
+
setError(null);
|
| 81 |
+
try {
|
| 82 |
+
setJob(await resurfaceApi.scan());
|
| 83 |
+
} catch (e) {
|
| 84 |
+
setError(errorMessage(e));
|
| 85 |
+
}
|
| 86 |
+
}}
|
| 87 |
+
>
|
| 88 |
+
Score {data.unscored} new posts now
|
| 89 |
+
</button>
|
|
|
|
|
|
|
|
|
|
| 90 |
)}
|
| 91 |
{reading && <Reader documentId={reading} onClose={() => setReading(null)} />}
|
| 92 |
{scheduling && (
|
|
|
|
| 95 |
posts={[`${scheduling.angle ?? scheduling.title}\n\n${scheduling.url.startsWith("http") ? scheduling.url : ""}`.trim()]}
|
| 96 |
documentId={scheduling.id}
|
| 97 |
onClose={() => setScheduling(null)}
|
| 98 |
+
onScheduled={() => {
|
| 99 |
+
load();
|
| 100 |
+
onScheduled?.();
|
| 101 |
+
}}
|
| 102 |
/>
|
| 103 |
)}
|
| 104 |
</div>
|
| 105 |
);
|
| 106 |
}
|
| 107 |
+
|
| 108 |
+
/** The old Resurfacing page now lives inside Launchpad. */
|
| 109 |
+
export default function Resurface() {
|
| 110 |
+
return <Navigate to="/app/launchpad#ideas" replace />;
|
| 111 |
+
}
|
frontend/src/pages/Settings.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { useEffect,
|
| 2 |
import { useNavigate } from "react-router-dom";
|
| 3 |
import { authApi } from "../api/auth";
|
| 4 |
import { tokens, uploadFile } from "../api/client";
|
|
@@ -27,10 +27,13 @@ function Meter({ label, used, limit, unit }: { label: string; used: number; limi
|
|
| 27 |
);
|
| 28 |
}
|
| 29 |
|
| 30 |
-
function Section({ title, children }: { title: string; children: ReactNode }) {
|
| 31 |
return (
|
| 32 |
<section className="card stack">
|
| 33 |
-
<
|
|
|
|
|
|
|
|
|
|
| 34 |
{children}
|
| 35 |
</section>
|
| 36 |
);
|
|
@@ -44,7 +47,6 @@ export default function Settings() {
|
|
| 44 |
const [accent, setAccent] = useState("#217cff");
|
| 45 |
const [saved, setSaved] = useState<string | null>(null);
|
| 46 |
const [error, setError] = useState<string | null>(null);
|
| 47 |
-
const logoRef = useRef<HTMLInputElement>(null);
|
| 48 |
const [sounds, setSounds] = useState(soundsEnabled);
|
| 49 |
const { logout } = useAuth();
|
| 50 |
const upgrade = useUpgrade();
|
|
@@ -75,6 +77,13 @@ export default function Settings() {
|
|
| 75 |
|
| 76 |
if (!s) return error ? <p className="error-text">{error}</p> : <Loading />;
|
| 77 |
const u = s.usage;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
return (
|
| 80 |
<div className="page-wrap">
|
|
@@ -82,58 +91,54 @@ export default function Settings() {
|
|
| 82 |
{error && <p className="error-text">{error}</p>}
|
| 83 |
<div className="two-col">
|
| 84 |
<div className="stack">
|
| 85 |
-
<Section title="Profile">
|
| 86 |
<label className="field">
|
| 87 |
<span>Name</span>
|
| 88 |
-
<input className="input input-sm" value={name} onChange={(e) => setName(e.target.value)} />
|
| 89 |
</label>
|
| 90 |
<p className="mono muted">
|
| 91 |
{s.user.email} · signed in with {s.user.auth_provider === "google" ? "Google" : "email and password"}
|
| 92 |
</p>
|
| 93 |
<label className="field">
|
| 94 |
<span>Workspace name</span>
|
| 95 |
-
<input className="input input-sm" value={workspace} onChange={(e) => setWorkspace(e.target.value)} />
|
| 96 |
</label>
|
| 97 |
-
<div className="row end">
|
| 98 |
-
<button className="btn btn-primary btn-small" onClick={() => save({ name, workspace_name: workspace }, "profile")}>
|
| 99 |
-
{saved === "profile" ? "Saved" : "Save"}
|
| 100 |
-
</button>
|
| 101 |
-
</div>
|
| 102 |
</Section>
|
| 103 |
|
| 104 |
-
<Section title="Brand kit">
|
| 105 |
<p className="muted">Used on videos, quote cards and carousels.</p>
|
| 106 |
<label className="field">
|
| 107 |
<span>Name on renders</span>
|
| 108 |
-
<input className="input input-sm" placeholder="Your publication" value={brandName} onChange={(e) => setBrandName(e.target.value)} />
|
| 109 |
</label>
|
| 110 |
<label className="field">
|
| 111 |
<span>Accent color</span>
|
| 112 |
<div className="row">
|
| 113 |
-
<input type="color" value={accent} onChange={(e) => setAccent(e.target.value)} aria-label="Accent color" className="color" />
|
| 114 |
-
<input className="input input-sm mono" value={accent} onChange={(e) => setAccent(e.target.value)} />
|
| 115 |
</div>
|
| 116 |
</label>
|
| 117 |
<div className="field">
|
| 118 |
<span>Logo</span>
|
| 119 |
<div className="row">
|
| 120 |
{s.brand.logo_url && <img src={s.brand.logo_url} alt="Logo" className="logo-preview" />}
|
| 121 |
-
<input
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
| 126 |
if (!file) return;
|
| 127 |
try {
|
| 128 |
const up = await uploadFile(file);
|
| 129 |
await save({ logo_upload_id: up.upload_id }, "logo");
|
| 130 |
-
} catch (
|
| 131 |
-
setError(errorMessage(
|
| 132 |
}
|
| 133 |
}}
|
| 134 |
-
>
|
| 135 |
-
Upload
|
| 136 |
-
</button>
|
| 137 |
{s.brand.logo_url && (
|
| 138 |
<button className="btn btn-small" onClick={() => save({ logo_upload_id: "" }, "logo")}>
|
| 139 |
Remove
|
|
@@ -141,11 +146,6 @@ export default function Settings() {
|
|
| 141 |
)}
|
| 142 |
</div>
|
| 143 |
</div>
|
| 144 |
-
<div className="row end">
|
| 145 |
-
<button className="btn btn-primary btn-small" onClick={() => save({ brand_name: brandName, brand_accent: accent }, "brand")}>
|
| 146 |
-
{saved === "brand" ? "Saved" : "Save brand"}
|
| 147 |
-
</button>
|
| 148 |
-
</div>
|
| 149 |
</Section>
|
| 150 |
|
| 151 |
<Section title="Privacy and email">
|
|
@@ -198,36 +198,10 @@ export default function Settings() {
|
|
| 198 |
)}
|
| 199 |
{upgrade.error && !upgrade.modal && <p className="error-text">{upgrade.error}</p>}
|
| 200 |
<p className="mono muted small">
|
| 201 |
-
{s.plan.sources} sources · {s.plan.indexed_posts.toLocaleString()} indexed posts
|
| 202 |
</p>
|
| 203 |
</Section>
|
| 204 |
|
| 205 |
-
<Section title="Connections">
|
| 206 |
-
<ul className="integrations mono">
|
| 207 |
-
<li>
|
| 208 |
-
LLM <span className={s.integrations.llm ? "ok" : "off"}>{s.integrations.llm ? "configured" : "set LLM_API_KEY"}</span>
|
| 209 |
-
</li>
|
| 210 |
-
<li>
|
| 211 |
-
ElevenLabs <span className={s.integrations.elevenlabs ? "ok" : "off"}>{s.integrations.elevenlabs ? "configured" : "set ELEVENLABS_API_KEY"}</span>
|
| 212 |
-
</li>
|
| 213 |
-
<li>
|
| 214 |
-
X app <span className={s.integrations.x ? "ok" : "off"}>{s.integrations.x ? "configured" : "set X_CLIENT_ID"}</span>
|
| 215 |
-
</li>
|
| 216 |
-
<li>
|
| 217 |
-
LinkedIn app <span className={s.integrations.linkedin ? "ok" : "off"}>{s.integrations.linkedin ? "configured" : "set LINKEDIN_CLIENT_ID"}</span>
|
| 218 |
-
</li>
|
| 219 |
-
<li>
|
| 220 |
-
Storage <span className="ok">{s.integrations.storage === "local" ? "local disk" : "Cloudflare R2"}</span>
|
| 221 |
-
</li>
|
| 222 |
-
<li>
|
| 223 |
-
Renderer <span className="muted">{s.integrations.renderer}</span>
|
| 224 |
-
</li>
|
| 225 |
-
</ul>
|
| 226 |
-
<button className="btn btn-small" onClick={() => navigate("/app/launchpad")}>
|
| 227 |
-
Manage social accounts
|
| 228 |
-
</button>
|
| 229 |
-
</Section>
|
| 230 |
-
|
| 231 |
<Section title="Account">
|
| 232 |
<div className="row">
|
| 233 |
<button
|
|
|
|
| 1 |
+
import { useEffect, useState, type ReactNode } from "react";
|
| 2 |
import { useNavigate } from "react-router-dom";
|
| 3 |
import { authApi } from "../api/auth";
|
| 4 |
import { tokens, uploadFile } from "../api/client";
|
|
|
|
| 27 |
);
|
| 28 |
}
|
| 29 |
|
| 30 |
+
function Section({ title, saved, children }: { title: string; saved?: boolean; children: ReactNode }) {
|
| 31 |
return (
|
| 32 |
<section className="card stack">
|
| 33 |
+
<div className="row between">
|
| 34 |
+
<h2>{title}</h2>
|
| 35 |
+
{saved && <span className="mono muted small">Saved</span>}
|
| 36 |
+
</div>
|
| 37 |
{children}
|
| 38 |
</section>
|
| 39 |
);
|
|
|
|
| 47 |
const [accent, setAccent] = useState("#217cff");
|
| 48 |
const [saved, setSaved] = useState<string | null>(null);
|
| 49 |
const [error, setError] = useState<string | null>(null);
|
|
|
|
| 50 |
const [sounds, setSounds] = useState(soundsEnabled);
|
| 51 |
const { logout } = useAuth();
|
| 52 |
const upgrade = useUpgrade();
|
|
|
|
| 77 |
|
| 78 |
if (!s) return error ? <p className="error-text">{error}</p> : <Loading />;
|
| 79 |
const u = s.usage;
|
| 80 |
+
// Fields save on their own when the writer leaves them, so there are no Save buttons.
|
| 81 |
+
const saveProfile = () => {
|
| 82 |
+
if (name !== (s.user.name ?? "") || workspace !== s.workspace.name) void save({ name, workspace_name: workspace }, "profile");
|
| 83 |
+
};
|
| 84 |
+
const saveBrand = (next = { brandName, accent }) => {
|
| 85 |
+
if (next.brandName !== (s.brand.name ?? "") || next.accent !== s.brand.accent) void save({ brand_name: next.brandName, brand_accent: next.accent }, "brand");
|
| 86 |
+
};
|
| 87 |
|
| 88 |
return (
|
| 89 |
<div className="page-wrap">
|
|
|
|
| 91 |
{error && <p className="error-text">{error}</p>}
|
| 92 |
<div className="two-col">
|
| 93 |
<div className="stack">
|
| 94 |
+
<Section title="Profile" saved={saved === "profile"}>
|
| 95 |
<label className="field">
|
| 96 |
<span>Name</span>
|
| 97 |
+
<input className="input input-sm" value={name} onChange={(e) => setName(e.target.value)} onBlur={saveProfile} />
|
| 98 |
</label>
|
| 99 |
<p className="mono muted">
|
| 100 |
{s.user.email} · signed in with {s.user.auth_provider === "google" ? "Google" : "email and password"}
|
| 101 |
</p>
|
| 102 |
<label className="field">
|
| 103 |
<span>Workspace name</span>
|
| 104 |
+
<input className="input input-sm" value={workspace} onChange={(e) => setWorkspace(e.target.value)} onBlur={saveProfile} />
|
| 105 |
</label>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
</Section>
|
| 107 |
|
| 108 |
+
<Section title="Brand kit" saved={saved === "brand" || saved === "logo"}>
|
| 109 |
<p className="muted">Used on videos, quote cards and carousels.</p>
|
| 110 |
<label className="field">
|
| 111 |
<span>Name on renders</span>
|
| 112 |
+
<input className="input input-sm" placeholder="Your publication" value={brandName} onChange={(e) => setBrandName(e.target.value)} onBlur={() => saveBrand()} />
|
| 113 |
</label>
|
| 114 |
<label className="field">
|
| 115 |
<span>Accent color</span>
|
| 116 |
<div className="row">
|
| 117 |
+
<input type="color" value={accent} onChange={(e) => setAccent(e.target.value)} onBlur={() => saveBrand()} aria-label="Accent color" className="color" />
|
| 118 |
+
<input className="input input-sm mono" value={accent} onChange={(e) => setAccent(e.target.value)} onBlur={() => saveBrand()} aria-label="Accent hex" />
|
| 119 |
</div>
|
| 120 |
</label>
|
| 121 |
<div className="field">
|
| 122 |
<span>Logo</span>
|
| 123 |
<div className="row">
|
| 124 |
{s.brand.logo_url && <img src={s.brand.logo_url} alt="Logo" className="logo-preview" />}
|
| 125 |
+
<input
|
| 126 |
+
type="file"
|
| 127 |
+
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
| 128 |
+
className="input file-input"
|
| 129 |
+
aria-label="Logo file"
|
| 130 |
+
onChange={async (e) => {
|
| 131 |
+
const file = e.target.files?.[0];
|
| 132 |
+
e.target.value = "";
|
| 133 |
if (!file) return;
|
| 134 |
try {
|
| 135 |
const up = await uploadFile(file);
|
| 136 |
await save({ logo_upload_id: up.upload_id }, "logo");
|
| 137 |
+
} catch (err) {
|
| 138 |
+
setError(errorMessage(err));
|
| 139 |
}
|
| 140 |
}}
|
| 141 |
+
/>
|
|
|
|
|
|
|
| 142 |
{s.brand.logo_url && (
|
| 143 |
<button className="btn btn-small" onClick={() => save({ logo_upload_id: "" }, "logo")}>
|
| 144 |
Remove
|
|
|
|
| 146 |
)}
|
| 147 |
</div>
|
| 148 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
</Section>
|
| 150 |
|
| 151 |
<Section title="Privacy and email">
|
|
|
|
| 198 |
)}
|
| 199 |
{upgrade.error && !upgrade.modal && <p className="error-text">{upgrade.error}</p>}
|
| 200 |
<p className="mono muted small">
|
| 201 |
+
{s.plan.sources} sources · {s.plan.indexed_posts.toLocaleString()} indexed posts
|
| 202 |
</p>
|
| 203 |
</Section>
|
| 204 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
<Section title="Account">
|
| 206 |
<div className="row">
|
| 207 |
<button
|
frontend/src/pages/Sources.tsx
CHANGED
|
@@ -1,80 +1,137 @@
|
|
| 1 |
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
|
|
|
| 2 |
import { docsApi, jobsApi, sourcesApi } from "../api/endpoints";
|
| 3 |
import type { Doc, Job, Source } from "../api/types";
|
| 4 |
import { Reader } from "../components/Reader";
|
| 5 |
-
import { ConfirmButton, EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader, StatusPill
|
| 6 |
import { useJobMap } from "../hooks/useJob";
|
| 7 |
-
|
| 8 |
-
type AddMode = "feed" | "url" | "file";
|
| 9 |
|
| 10 |
const PAGE = 50;
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
export function AddSource({ onAdded }: { onAdded: (source: Source, job: Job) => void }) {
|
| 13 |
-
const [mode, setMode] = useState<AddMode>("feed");
|
| 14 |
const [value, setValue] = useState("");
|
|
|
|
| 15 |
const [busy, setBusy] = useState(false);
|
| 16 |
const [error, setError] = useState<string | null>(null);
|
| 17 |
const fileRef = useRef<HTMLInputElement>(null);
|
|
|
|
| 18 |
|
| 19 |
const submit = async (e: FormEvent) => {
|
| 20 |
e.preventDefault();
|
|
|
|
| 21 |
setError(null);
|
| 22 |
setBusy(true);
|
| 23 |
try {
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
onAdded(res.source, res.job);
|
| 29 |
-
if (fileRef.current) fileRef.current.value = "";
|
| 30 |
-
} else {
|
| 31 |
-
if (!value.trim()) return;
|
| 32 |
-
const res = mode === "feed" ? await sourcesApi.connect(value.trim()) : await sourcesApi.importUrl(value.trim());
|
| 33 |
-
onAdded(res.source, res.job);
|
| 34 |
-
setValue("");
|
| 35 |
-
}
|
| 36 |
} catch (err) {
|
| 37 |
-
setError(errorMessage(err, "
|
| 38 |
} finally {
|
| 39 |
setBusy(false);
|
| 40 |
}
|
| 41 |
};
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
return (
|
| 44 |
<div className="card add-source">
|
| 45 |
-
<
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
{
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
<form onSubmit={submit} className="inline-form">
|
| 63 |
-
{mode === "file" ? (
|
| 64 |
-
<input ref={fileRef} className="input file-input" type="file" accept=".md,.markdown,.txt,.html,.htm,.pdf,text/markdown,text/plain,text/html,application/pdf" aria-label="File" />
|
| 65 |
-
) : (
|
| 66 |
-
<input
|
| 67 |
-
className="input"
|
| 68 |
-
placeholder={mode === "feed" ? "yourblog.com" : "https://example.com/my-essay"}
|
| 69 |
-
value={value}
|
| 70 |
-
onChange={(e) => setValue(e.target.value)}
|
| 71 |
-
aria-label={mode === "feed" ? "Feed or site URL" : "Article URL"}
|
| 72 |
-
/>
|
| 73 |
-
)}
|
| 74 |
-
<button className="btn btn-primary" type="submit" disabled={busy}>
|
| 75 |
-
{busy ? (mode === "feed" ? "Finding feed..." : "Starting...") : mode === "feed" ? "Connect" : "Import"}
|
| 76 |
</button>
|
|
|
|
| 77 |
</form>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
{error && (
|
| 79 |
<p className="error-text" role="alert">
|
| 80 |
{error}
|
|
@@ -86,6 +143,7 @@ export function AddSource({ onAdded }: { onAdded: (source: Source, job: Job) =>
|
|
| 86 |
|
| 87 |
export default function Sources() {
|
| 88 |
const [sources, setSources] = useState<Source[] | null>(null);
|
|
|
|
| 89 |
const [docs, setDocs] = useState<Doc[]>([]);
|
| 90 |
const [total, setTotal] = useState(0);
|
| 91 |
const [q, setQ] = useState("");
|
|
@@ -113,7 +171,7 @@ export default function Sources() {
|
|
| 113 |
});
|
| 114 |
|
| 115 |
useEffect(() => {
|
| 116 |
-
loadSources().catch(() => setError("Could not
|
| 117 |
}, [loadSources]);
|
| 118 |
|
| 119 |
useEffect(() => {
|
|
@@ -142,8 +200,29 @@ export default function Sources() {
|
|
| 142 |
watch(source.id, job);
|
| 143 |
};
|
| 144 |
|
|
|
|
|
|
|
| 145 |
return (
|
| 146 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
<PageHeader eyebrow="Sources" title="Your knowledge, in orbit" />
|
| 148 |
<AddSource onAdded={added} />
|
| 149 |
{error && <p className="error-text">{error}</p>}
|
|
@@ -160,6 +239,7 @@ export default function Sources() {
|
|
| 160 |
<strong>{s.title ?? s.feed_url}</strong>
|
| 161 |
<span className="mono muted">
|
| 162 |
{s.is_imports ? "imports" : s.platform} · {s.document_count} posts
|
|
|
|
| 163 |
{s.last_synced_at ? ` · synced ${formatDate(s.last_synced_at, true)}` : ""}
|
| 164 |
</span>
|
| 165 |
</div>
|
|
@@ -171,6 +251,20 @@ export default function Sources() {
|
|
| 171 |
</a>
|
| 172 |
)}
|
| 173 |
{job && (active || job.status === "failed") && <JobProgress job={job} compact />}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
{s.sync_status === "error" && s.sync_error && !active && <p className="error-text">{s.sync_error}</p>}
|
| 175 |
<footer className="row">
|
| 176 |
{!s.is_imports && (
|
|
@@ -222,6 +316,11 @@ export default function Sources() {
|
|
| 222 |
<button className="link-btn doc-title" onClick={() => setReading(d.id)}>
|
| 223 |
{d.title}
|
| 224 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
<span className="mono muted">
|
| 226 |
{d.source_title ?? ""} · {formatDate(d.published_at)} · {d.words} words
|
| 227 |
</span>
|
|
|
|
| 1 |
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
| 2 |
+
import { NavLink } from "react-router-dom";
|
| 3 |
import { docsApi, jobsApi, sourcesApi } from "../api/endpoints";
|
| 4 |
import type { Doc, Job, Source } from "../api/types";
|
| 5 |
import { Reader } from "../components/Reader";
|
| 6 |
+
import { ConfirmButton, EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader, StatusPill } from "../components/ui";
|
| 7 |
import { useJobMap } from "../hooks/useJob";
|
| 8 |
+
import { useUpgrade } from "../hooks/useUpgrade";
|
|
|
|
| 9 |
|
| 10 |
const PAGE = 50;
|
| 11 |
|
| 12 |
+
/** Sources and the Topic map share one sidebar entry; this strip switches between them. */
|
| 13 |
+
export function SourcesNav() {
|
| 14 |
+
return (
|
| 15 |
+
<nav className="tabs" aria-label="Sources views">
|
| 16 |
+
<NavLink to="/app/sources" className={({ isActive }) => `tab${isActive ? " active" : ""}`}>
|
| 17 |
+
Posts
|
| 18 |
+
</NavLink>
|
| 19 |
+
<NavLink to="/app/map" className={({ isActive }) => `tab${isActive ? " active" : ""}`}>
|
| 20 |
+
Topic map
|
| 21 |
+
</NavLink>
|
| 22 |
+
</nav>
|
| 23 |
+
);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export const FILE_ACCEPT = ".md,.markdown,.txt,.html,.htm,.pdf,text/markdown,text/plain,text/html,application/pdf";
|
| 27 |
+
|
| 28 |
+
/** A bare domain, a Substack or a feed URL is a whole archive; a deep link is one article. */
|
| 29 |
+
export function looksLikeArticle(raw: string): boolean {
|
| 30 |
+
const v = raw.trim();
|
| 31 |
+
if (!v) return false;
|
| 32 |
+
try {
|
| 33 |
+
const u = new URL(v.includes("://") ? v : `https://${v}`);
|
| 34 |
+
const path = u.pathname.replace(/\/+$/, "");
|
| 35 |
+
if (!path || /(feed|rss|atom)(\.xml)?$/i.test(path) || /^\/@[^/]+$/.test(path)) return false;
|
| 36 |
+
const parts = path.split("/").filter(Boolean);
|
| 37 |
+
return /\/p\/|\/posts?\/|\/\d{4}\/|\.html?$/i.test(path) || parts.length >= 2 || path.length > 24;
|
| 38 |
+
} catch {
|
| 39 |
+
return false;
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/** Imports each file as its own post. Returns how many failed. */
|
| 44 |
+
export async function importFiles(files: File[], onAdded: (source: Source, job: Job) => void): Promise<number> {
|
| 45 |
+
let failed = 0;
|
| 46 |
+
for (const f of files) {
|
| 47 |
+
try {
|
| 48 |
+
const res = await sourcesApi.importFile(f);
|
| 49 |
+
onAdded(res.source, res.job);
|
| 50 |
+
} catch {
|
| 51 |
+
failed += 1;
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
return failed;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/** One box for everything: paste a link (feed or article is worked out for you) or add files. */
|
| 58 |
export function AddSource({ onAdded }: { onAdded: (source: Source, job: Job) => void }) {
|
|
|
|
| 59 |
const [value, setValue] = useState("");
|
| 60 |
+
const [override, setOverride] = useState<"feed" | "url" | null>(null);
|
| 61 |
const [busy, setBusy] = useState(false);
|
| 62 |
const [error, setError] = useState<string | null>(null);
|
| 63 |
const fileRef = useRef<HTMLInputElement>(null);
|
| 64 |
+
const mode = override ?? (looksLikeArticle(value) ? "url" : "feed");
|
| 65 |
|
| 66 |
const submit = async (e: FormEvent) => {
|
| 67 |
e.preventDefault();
|
| 68 |
+
if (!value.trim()) return;
|
| 69 |
setError(null);
|
| 70 |
setBusy(true);
|
| 71 |
try {
|
| 72 |
+
const res = mode === "feed" ? await sourcesApi.connect(value.trim()) : await sourcesApi.importUrl(value.trim());
|
| 73 |
+
onAdded(res.source, res.job);
|
| 74 |
+
setValue("");
|
| 75 |
+
setOverride(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
} catch (err) {
|
| 77 |
+
setError(errorMessage(err, mode === "feed" ? "We could not find a feed there." : "We could not import that page."));
|
| 78 |
} finally {
|
| 79 |
setBusy(false);
|
| 80 |
}
|
| 81 |
};
|
| 82 |
|
| 83 |
+
const upload = async (files: FileList | null) => {
|
| 84 |
+
const list = Array.from(files ?? []);
|
| 85 |
+
if (!list.length) return;
|
| 86 |
+
setError(null);
|
| 87 |
+
setBusy(true);
|
| 88 |
+
const failed = await importFiles(list, onAdded);
|
| 89 |
+
if (failed) setError(`${failed} of ${list.length} files could not be uploaded.`);
|
| 90 |
+
setBusy(false);
|
| 91 |
+
if (fileRef.current) fileRef.current.value = "";
|
| 92 |
+
};
|
| 93 |
+
|
| 94 |
return (
|
| 95 |
<div className="card add-source">
|
| 96 |
+
<form onSubmit={submit} className="add-smart">
|
| 97 |
+
<input
|
| 98 |
+
className="input"
|
| 99 |
+
placeholder="Paste a blog, newsletter or article link"
|
| 100 |
+
value={value}
|
| 101 |
+
onChange={(e) => {
|
| 102 |
+
setValue(e.target.value);
|
| 103 |
+
setOverride(null);
|
| 104 |
+
}}
|
| 105 |
+
aria-label="Blog, newsletter or article link"
|
| 106 |
+
/>
|
| 107 |
+
<button className="btn btn-primary" type="submit" disabled={busy || !value.trim()}>
|
| 108 |
+
{busy ? "Working..." : mode === "feed" ? "Connect" : "Import article"}
|
| 109 |
+
</button>
|
| 110 |
+
<span className="muted small">or</span>
|
| 111 |
+
<button type="button" className="btn" disabled={busy} onClick={() => fileRef.current?.click()}>
|
| 112 |
+
Upload files
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
</button>
|
| 114 |
+
<input ref={fileRef} type="file" multiple accept={FILE_ACCEPT} hidden onChange={(e) => upload(e.target.files)} aria-label="Files to upload" />
|
| 115 |
</form>
|
| 116 |
+
<p className="muted small">
|
| 117 |
+
{!value.trim() && "Substack, Ghost, WordPress, Medium or any site with a feed. Markdown, text, HTML and PDF files work too; drop them anywhere on this page."}
|
| 118 |
+
{value.trim() && mode === "feed" && (
|
| 119 |
+
<>
|
| 120 |
+
We will pull in the whole archive and keep it in sync.{" "}
|
| 121 |
+
<button type="button" className="link-btn small" onClick={() => setOverride("url")}>
|
| 122 |
+
Just this one page instead
|
| 123 |
+
</button>
|
| 124 |
+
</>
|
| 125 |
+
)}
|
| 126 |
+
{value.trim() && mode === "url" && (
|
| 127 |
+
<>
|
| 128 |
+
Looks like a single article, so we will import just this page.{" "}
|
| 129 |
+
<button type="button" className="link-btn small" onClick={() => setOverride("feed")}>
|
| 130 |
+
Connect the whole site instead
|
| 131 |
+
</button>
|
| 132 |
+
</>
|
| 133 |
+
)}
|
| 134 |
+
</p>
|
| 135 |
{error && (
|
| 136 |
<p className="error-text" role="alert">
|
| 137 |
{error}
|
|
|
|
| 143 |
|
| 144 |
export default function Sources() {
|
| 145 |
const [sources, setSources] = useState<Source[] | null>(null);
|
| 146 |
+
const { openUpgrade } = useUpgrade();
|
| 147 |
const [docs, setDocs] = useState<Doc[]>([]);
|
| 148 |
const [total, setTotal] = useState(0);
|
| 149 |
const [q, setQ] = useState("");
|
|
|
|
| 171 |
});
|
| 172 |
|
| 173 |
useEffect(() => {
|
| 174 |
+
loadSources().catch(() => setError("Could not load your sources. Refresh to try again."));
|
| 175 |
}, [loadSources]);
|
| 176 |
|
| 177 |
useEffect(() => {
|
|
|
|
| 200 |
watch(source.id, job);
|
| 201 |
};
|
| 202 |
|
| 203 |
+
const [dropping, setDropping] = useState(false);
|
| 204 |
+
|
| 205 |
return (
|
| 206 |
+
<div
|
| 207 |
+
className={`page-wrap${dropping ? " drop-over" : ""}`}
|
| 208 |
+
onDragOver={(e) => {
|
| 209 |
+
if (!e.dataTransfer.types.includes("Files")) return;
|
| 210 |
+
e.preventDefault();
|
| 211 |
+
setDropping(true);
|
| 212 |
+
}}
|
| 213 |
+
onDragLeave={(e) => {
|
| 214 |
+
if (e.currentTarget === e.target) setDropping(false);
|
| 215 |
+
}}
|
| 216 |
+
onDrop={async (e) => {
|
| 217 |
+
if (!e.dataTransfer.files.length) return;
|
| 218 |
+
e.preventDefault();
|
| 219 |
+
setDropping(false);
|
| 220 |
+
const files = Array.from(e.dataTransfer.files);
|
| 221 |
+
const failed = await importFiles(files, added);
|
| 222 |
+
setError(failed ? `${failed} of ${files.length} files could not be uploaded.` : null);
|
| 223 |
+
}}
|
| 224 |
+
>
|
| 225 |
+
<SourcesNav />
|
| 226 |
<PageHeader eyebrow="Sources" title="Your knowledge, in orbit" />
|
| 227 |
<AddSource onAdded={added} />
|
| 228 |
{error && <p className="error-text">{error}</p>}
|
|
|
|
| 239 |
<strong>{s.title ?? s.feed_url}</strong>
|
| 240 |
<span className="mono muted">
|
| 241 |
{s.is_imports ? "imports" : s.platform} · {s.document_count} posts
|
| 242 |
+
{s.locked_count > 0 ? ` found, ${s.document_count - s.locked_count} indexed` : ""}
|
| 243 |
{s.last_synced_at ? ` · synced ${formatDate(s.last_synced_at, true)}` : ""}
|
| 244 |
</span>
|
| 245 |
</div>
|
|
|
|
| 251 |
</a>
|
| 252 |
)}
|
| 253 |
{job && (active || job.status === "failed") && <JobProgress job={job} compact />}
|
| 254 |
+
{s.locked_count > 0 && !active && (
|
| 255 |
+
<div className="welcome-cap locked-cap">
|
| 256 |
+
<div>
|
| 257 |
+
<strong>{s.locked_count} posts not indexed</strong>
|
| 258 |
+
<p className="muted small">
|
| 259 |
+
Your plan only indexes your latest posts. Upgrade to index the rest so answers, audio and Launch Kits
|
| 260 |
+
can use your whole archive.
|
| 261 |
+
</p>
|
| 262 |
+
</div>
|
| 263 |
+
<button type="button" className="btn btn-small btn-primary" onClick={() => openUpgrade("indexed_posts")}>
|
| 264 |
+
Index all {s.document_count}
|
| 265 |
+
</button>
|
| 266 |
+
</div>
|
| 267 |
+
)}
|
| 268 |
{s.sync_status === "error" && s.sync_error && !active && <p className="error-text">{s.sync_error}</p>}
|
| 269 |
<footer className="row">
|
| 270 |
{!s.is_imports && (
|
|
|
|
| 316 |
<button className="link-btn doc-title" onClick={() => setReading(d.id)}>
|
| 317 |
{d.title}
|
| 318 |
</button>
|
| 319 |
+
{d.locked && (
|
| 320 |
+
<span className="mono muted small" title="Upgrade to index this post">
|
| 321 |
+
Not indexed
|
| 322 |
+
</span>
|
| 323 |
+
)}
|
| 324 |
<span className="mono muted">
|
| 325 |
{d.source_title ?? ""} · {formatDate(d.published_at)} · {d.words} words
|
| 326 |
</span>
|
frontend/src/pages/Studio.tsx
CHANGED
|
@@ -1,61 +1,54 @@
|
|
| 1 |
import { useCallback, useEffect, useState } from "react";
|
|
|
|
| 2 |
import { artifactsApi, notebooksApi, type GenerateBody } from "../api/endpoints";
|
| 3 |
import type { Artifact, NotebookSummary } from "../api/types";
|
| 4 |
import { ArtifactCard } from "../components/ArtifactCard";
|
| 5 |
import { DocPicker } from "../components/DocPicker";
|
|
|
|
| 6 |
import { Reader } from "../components/Reader";
|
| 7 |
-
import {
|
| 8 |
|
| 9 |
-
type Kind = "audio" | "short" | "explainer" | "
|
| 10 |
|
| 11 |
-
const KINDS: { id: Kind; label: string; blurb: string }[] = [
|
| 12 |
-
{ id: "audio", label: "Audio overview", blurb: "Two hosts talk through your posts, every line grounded in what you wrote." },
|
| 13 |
-
{ id: "short", label: "Short
|
| 14 |
-
{ id: "explainer", label: "Explainer
|
| 15 |
-
{ id: "
|
| 16 |
-
{ id: "quote", label: "Quote card", blurb: "Your most quotable line, verified against the post, as a 1080 square." },
|
| 17 |
];
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
export default function Studio() {
|
| 20 |
const [kind, setKind] = useState<Kind>("audio");
|
| 21 |
-
const [
|
| 22 |
const [notebooks, setNotebooks] = useState<NotebookSummary[]>([]);
|
| 23 |
-
const [notebookId, setNotebookId] = useState("");
|
| 24 |
const [postIds, setPostIds] = useState<string[]>([]);
|
| 25 |
const [format, setFormat] = useState<"deep_dive" | "brief" | "debate">("deep_dive");
|
| 26 |
const [minutes, setMinutes] = useState(6);
|
| 27 |
-
const [
|
| 28 |
-
const [gallery, setGallery] = useState<Artifact[] | null>(null);
|
| 29 |
-
const [filter, setFilter] = useState<"all" | "audio_overview" | "video" | "quote_card,carousel">("all");
|
| 30 |
const [busy, setBusy] = useState(false);
|
| 31 |
const [error, setError] = useState<string | null>(null);
|
| 32 |
const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null);
|
| 33 |
|
| 34 |
const load = useCallback(
|
| 35 |
-
() =>
|
| 36 |
-
|
| 37 |
-
.list({ type: filter === "all" ? "audio_overview,video,quote_card,carousel" : filter, limit: 60 })
|
| 38 |
-
.then((p) => setGallery(p.items)),
|
| 39 |
-
[filter],
|
| 40 |
);
|
| 41 |
|
| 42 |
useEffect(() => {
|
| 43 |
-
notebooksApi.list().then((n) =>
|
| 44 |
-
setNotebooks(n);
|
| 45 |
-
setNotebookId((cur) => cur || n[0]?.id || "");
|
| 46 |
-
});
|
| 47 |
-
}, []);
|
| 48 |
-
useEffect(() => {
|
| 49 |
load();
|
| 50 |
}, [load]);
|
| 51 |
|
| 52 |
-
const audios = (gallery ?? []).filter((a) => a.type === "audio_overview" && a.status === "ready");
|
| 53 |
-
|
| 54 |
const create = async () => {
|
| 55 |
setError(null);
|
| 56 |
-
const where: Partial<GenerateBody> =
|
| 57 |
-
|
| 58 |
-
|
|
|
|
| 59 |
return;
|
| 60 |
}
|
| 61 |
const body: GenerateBody =
|
|
@@ -63,17 +56,11 @@ export default function Studio() {
|
|
| 63 |
? { type: "audio_overview", format, minutes, ...where }
|
| 64 |
: kind === "quote"
|
| 65 |
? { type: "quote_card", ...where }
|
| 66 |
-
:
|
| 67 |
-
? { type: "video", style: "audiogram", audio_artifact_id: audioId }
|
| 68 |
-
: { type: "video", style: kind, ...where };
|
| 69 |
-
if (kind === "audiogram" && !audioId) {
|
| 70 |
-
setError("Pick an audio overview first.");
|
| 71 |
-
return;
|
| 72 |
-
}
|
| 73 |
setBusy(true);
|
| 74 |
try {
|
| 75 |
const a = await artifactsApi.generate(body);
|
| 76 |
-
|
| 77 |
} catch (e) {
|
| 78 |
setError(errorMessage(e));
|
| 79 |
} finally {
|
|
@@ -85,108 +72,109 @@ export default function Studio() {
|
|
| 85 |
|
| 86 |
return (
|
| 87 |
<div className="page-wrap">
|
| 88 |
-
<PageHeader eyebrow="
|
| 89 |
<section className="card stack studio-maker">
|
| 90 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
<p className="muted">{meta.blurb}</p>
|
| 92 |
-
|
|
|
|
| 93 |
<label className="field">
|
| 94 |
-
<span>
|
| 95 |
-
<select className="input
|
| 96 |
-
<option value=
|
| 97 |
-
{
|
| 98 |
-
<
|
| 99 |
-
{a.title}
|
| 100 |
-
</option>
|
| 101 |
-
))}
|
| 102 |
-
</select>
|
| 103 |
-
{audios.length === 0 && <span className="muted small">Make an audio overview first.</span>}
|
| 104 |
-
</label>
|
| 105 |
-
) : (
|
| 106 |
-
<>
|
| 107 |
-
<div className="row">
|
| 108 |
-
<label className="check">
|
| 109 |
-
<input type="radio" checked={target === "notebook"} onChange={() => setTarget("notebook")} /> From a notebook
|
| 110 |
-
</label>
|
| 111 |
-
<label className="check">
|
| 112 |
-
<input type="radio" checked={target === "post"} onChange={() => setTarget("post")} /> From one post
|
| 113 |
-
</label>
|
| 114 |
-
</div>
|
| 115 |
-
{target === "notebook" ? (
|
| 116 |
-
notebooks.length ? (
|
| 117 |
-
<select className="input input-sm" value={notebookId} onChange={(e) => setNotebookId(e.target.value)} aria-label="Notebook">
|
| 118 |
{notebooks.map((n) => (
|
| 119 |
<option key={n.id} value={n.id}>
|
| 120 |
{n.title} ({n.document_count} posts)
|
| 121 |
</option>
|
| 122 |
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
</select>
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
<DocPicker selected={postIds} onChange={setPostIds} single />
|
| 129 |
-
)}
|
| 130 |
-
</>
|
| 131 |
-
)}
|
| 132 |
-
{kind === "audio" && (
|
| 133 |
-
<div className="row">
|
| 134 |
-
<label className="field">
|
| 135 |
-
<span>Format</span>
|
| 136 |
-
<select className="input input-sm" value={format} onChange={(e) => setFormat(e.target.value as typeof format)}>
|
| 137 |
-
<option value="deep_dive">Deep dive</option>
|
| 138 |
-
<option value="brief">Brief</option>
|
| 139 |
-
<option value="debate">Debate</option>
|
| 140 |
-
</select>
|
| 141 |
-
</label>
|
| 142 |
-
<label className="field">
|
| 143 |
-
<span>Length</span>
|
| 144 |
-
<select className="input input-sm" value={minutes} onChange={(e) => setMinutes(Number(e.target.value))}>
|
| 145 |
-
{[3, 6, 10, 15, 20].map((m) => (
|
| 146 |
-
<option key={m} value={m}>
|
| 147 |
-
{m} minutes
|
| 148 |
-
</option>
|
| 149 |
-
))}
|
| 150 |
-
</select>
|
| 151 |
-
</label>
|
| 152 |
-
</div>
|
| 153 |
-
)}
|
| 154 |
-
{error && <p className="error-text">{error}</p>}
|
| 155 |
-
<div className="row end">
|
| 156 |
-
<button className="btn btn-primary" onClick={create} disabled={busy}>
|
| 157 |
{busy ? "Launching..." : `Create ${meta.label.toLowerCase()}`}
|
| 158 |
</button>
|
| 159 |
</div>
|
|
|
|
|
|
|
| 160 |
</section>
|
| 161 |
|
| 162 |
-
|
| 163 |
-
<
|
| 164 |
-
<
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
{reading && <Reader documentId={reading.id} highlight={{ start: reading.start, end: reading.end }} onClose={() => setReading(null)} />}
|
| 191 |
</div>
|
| 192 |
);
|
|
|
|
| 1 |
import { useCallback, useEffect, useState } from "react";
|
| 2 |
+
import { Link } from "react-router-dom";
|
| 3 |
import { artifactsApi, notebooksApi, type GenerateBody } from "../api/endpoints";
|
| 4 |
import type { Artifact, NotebookSummary } from "../api/types";
|
| 5 |
import { ArtifactCard } from "../components/ArtifactCard";
|
| 6 |
import { DocPicker } from "../components/DocPicker";
|
| 7 |
+
import { LaunchWindowIcon, PlanetIcon, RocketIcon, SparkleIcon } from "../components/icons/Icons";
|
| 8 |
import { Reader } from "../components/Reader";
|
| 9 |
+
import { errorMessage, PageHeader } from "../components/ui";
|
| 10 |
|
| 11 |
+
type Kind = "audio" | "short" | "explainer" | "quote";
|
| 12 |
|
| 13 |
+
const KINDS: { id: Kind; label: string; blurb: string; icon: typeof PlanetIcon }[] = [
|
| 14 |
+
{ id: "audio", label: "Audio overview", blurb: "Two hosts talk through your posts, every line grounded in what you wrote.", icon: PlanetIcon },
|
| 15 |
+
{ id: "short", label: "Short video", blurb: "Vertical 9:16 with a hook in the first two seconds, narration and captions.", icon: LaunchWindowIcon },
|
| 16 |
+
{ id: "explainer", label: "Explainer", blurb: "16:9 title card and narrated scenes for YouTube or your post.", icon: SparkleIcon },
|
| 17 |
+
{ id: "quote", label: "Quote card", blurb: "Your most quotable line, verified against the post, as a 1080 square.", icon: RocketIcon },
|
|
|
|
| 18 |
];
|
| 19 |
|
| 20 |
+
const ARCHIVE = "archive";
|
| 21 |
+
const ONE_POST = "post";
|
| 22 |
+
|
| 23 |
+
/** Create: pick a format, say what it is about (the whole archive by default), press Create. */
|
| 24 |
export default function Studio() {
|
| 25 |
const [kind, setKind] = useState<Kind>("audio");
|
| 26 |
+
const [about, setAbout] = useState(ARCHIVE);
|
| 27 |
const [notebooks, setNotebooks] = useState<NotebookSummary[]>([]);
|
|
|
|
| 28 |
const [postIds, setPostIds] = useState<string[]>([]);
|
| 29 |
const [format, setFormat] = useState<"deep_dive" | "brief" | "debate">("deep_dive");
|
| 30 |
const [minutes, setMinutes] = useState(6);
|
| 31 |
+
const [recent, setRecent] = useState<Artifact[] | null>(null);
|
|
|
|
|
|
|
| 32 |
const [busy, setBusy] = useState(false);
|
| 33 |
const [error, setError] = useState<string | null>(null);
|
| 34 |
const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null);
|
| 35 |
|
| 36 |
const load = useCallback(
|
| 37 |
+
() => artifactsApi.list({ type: "audio_overview,video,quote_card", limit: 6 }).then((p) => setRecent(p.items)),
|
| 38 |
+
[],
|
|
|
|
|
|
|
|
|
|
| 39 |
);
|
| 40 |
|
| 41 |
useEffect(() => {
|
| 42 |
+
notebooksApi.list().then((n) => setNotebooks(n.filter((x) => !x.is_archive)));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
load();
|
| 44 |
}, [load]);
|
| 45 |
|
|
|
|
|
|
|
| 46 |
const create = async () => {
|
| 47 |
setError(null);
|
| 48 |
+
const where: Partial<GenerateBody> =
|
| 49 |
+
about === ARCHIVE ? { archive: true } : about === ONE_POST ? { document_id: postIds[0] } : { notebook_id: about };
|
| 50 |
+
if (about === ONE_POST && !postIds[0]) {
|
| 51 |
+
setError("Pick a post.");
|
| 52 |
return;
|
| 53 |
}
|
| 54 |
const body: GenerateBody =
|
|
|
|
| 56 |
? { type: "audio_overview", format, minutes, ...where }
|
| 57 |
: kind === "quote"
|
| 58 |
? { type: "quote_card", ...where }
|
| 59 |
+
: { type: "video", style: kind, ...where };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
setBusy(true);
|
| 61 |
try {
|
| 62 |
const a = await artifactsApi.generate(body);
|
| 63 |
+
setRecent((g) => [a, ...(g ?? [])].slice(0, 6));
|
| 64 |
} catch (e) {
|
| 65 |
setError(errorMessage(e));
|
| 66 |
} finally {
|
|
|
|
| 72 |
|
| 73 |
return (
|
| 74 |
<div className="page-wrap">
|
| 75 |
+
<PageHeader eyebrow="Create" title="Make something from your writing" />
|
| 76 |
<section className="card stack studio-maker">
|
| 77 |
+
<div className="format-grid">
|
| 78 |
+
{KINDS.map((k) => (
|
| 79 |
+
<button key={k.id} type="button" className={`format-tile${kind === k.id ? " on" : ""}`} aria-pressed={kind === k.id} onClick={() => setKind(k.id)}>
|
| 80 |
+
<k.icon size={30} />
|
| 81 |
+
<strong>{k.label}</strong>
|
| 82 |
+
</button>
|
| 83 |
+
))}
|
| 84 |
+
<Link to="/app/launch-kit" className="format-tile">
|
| 85 |
+
<span className="mc-more" aria-hidden="true">
|
| 86 |
+
+
|
| 87 |
+
</span>
|
| 88 |
+
<strong>Launch Kit</strong>
|
| 89 |
+
</Link>
|
| 90 |
+
</div>
|
| 91 |
<p className="muted">{meta.blurb}</p>
|
| 92 |
+
|
| 93 |
+
<div className="studio-row">
|
| 94 |
<label className="field">
|
| 95 |
+
<span>What is it about?</span>
|
| 96 |
+
<select className="input" value={about} onChange={(e) => setAbout(e.target.value)}>
|
| 97 |
+
<option value={ARCHIVE}>My whole archive</option>
|
| 98 |
+
{notebooks.length > 0 && (
|
| 99 |
+
<optgroup label="A notebook">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
{notebooks.map((n) => (
|
| 101 |
<option key={n.id} value={n.id}>
|
| 102 |
{n.title} ({n.document_count} posts)
|
| 103 |
</option>
|
| 104 |
))}
|
| 105 |
+
</optgroup>
|
| 106 |
+
)}
|
| 107 |
+
<option value={ONE_POST}>One post...</option>
|
| 108 |
+
</select>
|
| 109 |
+
</label>
|
| 110 |
+
{kind === "audio" && (
|
| 111 |
+
<>
|
| 112 |
+
<label className="field">
|
| 113 |
+
<span>Format</span>
|
| 114 |
+
<select className="input" value={format} onChange={(e) => setFormat(e.target.value as typeof format)}>
|
| 115 |
+
<option value="deep_dive">Deep dive</option>
|
| 116 |
+
<option value="brief">Brief</option>
|
| 117 |
+
<option value="debate">Debate</option>
|
| 118 |
+
</select>
|
| 119 |
+
</label>
|
| 120 |
+
<label className="field">
|
| 121 |
+
<span>Length</span>
|
| 122 |
+
<select className="input" value={minutes} onChange={(e) => setMinutes(Number(e.target.value))}>
|
| 123 |
+
{[3, 6, 10, 15, 20].map((m) => (
|
| 124 |
+
<option key={m} value={m}>
|
| 125 |
+
{m} minutes
|
| 126 |
+
</option>
|
| 127 |
+
))}
|
| 128 |
</select>
|
| 129 |
+
</label>
|
| 130 |
+
</>
|
| 131 |
+
)}
|
| 132 |
+
<button className="btn btn-primary studio-go" onClick={create} disabled={busy}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
{busy ? "Launching..." : `Create ${meta.label.toLowerCase()}`}
|
| 134 |
</button>
|
| 135 |
</div>
|
| 136 |
+
{about === ONE_POST && <DocPicker selected={postIds} onChange={setPostIds} single />}
|
| 137 |
+
{error && <p className="error-text">{error}</p>}
|
| 138 |
</section>
|
| 139 |
|
| 140 |
+
{recent && recent.length > 0 && (
|
| 141 |
+
<section className="stack">
|
| 142 |
+
<div className="row between">
|
| 143 |
+
<h2>Latest</h2>
|
| 144 |
+
<Link to="/app/archive" className="mono muted small-link">
|
| 145 |
+
Everything in Library
|
| 146 |
+
</Link>
|
| 147 |
+
</div>
|
| 148 |
+
<div className="gallery">
|
| 149 |
+
{recent.map((a) => (
|
| 150 |
+
<ArtifactCard
|
| 151 |
+
key={a.id}
|
| 152 |
+
artifact={a}
|
| 153 |
+
onCite={(c) => c.document_id && setReading({ id: c.document_id, start: c.line_start, end: c.line_end })}
|
| 154 |
+
onRemoved={(id) => setRecent((g) => (g ?? []).filter((x) => x.id !== id))}
|
| 155 |
+
onChanged={(next) => setRecent((g) => (g ?? []).map((x) => (x.id === next.id ? next : x)))}
|
| 156 |
+
actions={(art) =>
|
| 157 |
+
art.type === "audio_overview" && art.status === "ready" ? (
|
| 158 |
+
<button
|
| 159 |
+
className="btn btn-small"
|
| 160 |
+
onClick={async () => {
|
| 161 |
+
try {
|
| 162 |
+
const v = await artifactsApi.generate({ type: "video", style: "audiogram", audio_artifact_id: art.id });
|
| 163 |
+
setRecent((g) => [v, ...(g ?? [])].slice(0, 6));
|
| 164 |
+
} catch (e) {
|
| 165 |
+
setError(errorMessage(e));
|
| 166 |
+
}
|
| 167 |
+
}}
|
| 168 |
+
>
|
| 169 |
+
Make audiogram
|
| 170 |
+
</button>
|
| 171 |
+
) : null
|
| 172 |
+
}
|
| 173 |
+
/>
|
| 174 |
+
))}
|
| 175 |
+
</div>
|
| 176 |
+
</section>
|
| 177 |
+
)}
|
| 178 |
{reading && <Reader documentId={reading.id} highlight={{ start: reading.start, end: reading.end }} onClose={() => setReading(null)} />}
|
| 179 |
</div>
|
| 180 |
);
|
frontend/src/pages/Tools.tsx
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useMemo, useState } from "react";
|
| 2 |
+
import { Link, useParams } from "react-router-dom";
|
| 3 |
+
import SkyCanvas from "../components/SkyCanvas";
|
| 4 |
+
import { PublicFooter, PublicNav } from "../components/PublicChrome";
|
| 5 |
+
import { getTool, tools, toolsHub, type ToolDef } from "../content/tools";
|
| 6 |
+
import { useAuth } from "../hooks/useAuth";
|
| 7 |
+
import { buildPodcastScript, scriptToText } from "../lib/podcastScript";
|
| 8 |
+
import { Inline, useMeta } from "./Blog";
|
| 9 |
+
import "../styles/blog.css";
|
| 10 |
+
import "../styles/tools.css";
|
| 11 |
+
|
| 12 |
+
const pathTitle = (path: string) => {
|
| 13 |
+
const tool = tools.find((t) => `/tools/${t.slug}` === path);
|
| 14 |
+
if (tool) return tool.heroTitle;
|
| 15 |
+
if (path === "/notebooklm-alternative") return "Notestack vs NotebookLM";
|
| 16 |
+
return path.replace(/^\/blogs\//, "").replace(/-/g, " ").replace(/^./, (c) => c.toUpperCase());
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
export function ToolsHub() {
|
| 20 |
+
useMeta(toolsHub.metaTitle, toolsHub.description);
|
| 21 |
+
return (
|
| 22 |
+
<div className="page">
|
| 23 |
+
<SkyCanvas intensity={0.7} travel />
|
| 24 |
+
<PublicNav />
|
| 25 |
+
<main className="container post mission alt-page">
|
| 26 |
+
<header className="mission-head">
|
| 27 |
+
<p className="eyebrow">Free tools</p>
|
| 28 |
+
<h1 className="post-title">{toolsHub.heroTitle}</h1>
|
| 29 |
+
<p className="post-lede muted">{toolsHub.heroDescription}</p>
|
| 30 |
+
</header>
|
| 31 |
+
<div className="alt-grid">
|
| 32 |
+
{tools.map((t) => (
|
| 33 |
+
<Link key={t.slug} to={`/tools/${t.slug}`} className="card alt-sister">
|
| 34 |
+
<h2 className="tool-card-title">{t.heroTitle}</h2>
|
| 35 |
+
<p className="muted">{t.description}</p>
|
| 36 |
+
</Link>
|
| 37 |
+
))}
|
| 38 |
+
</div>
|
| 39 |
+
</main>
|
| 40 |
+
<PublicFooter />
|
| 41 |
+
</div>
|
| 42 |
+
);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/** Every free tool requires sign in: signed out visitors see the pitch and a sign in link that returns them here. */
|
| 46 |
+
function SignInGate({ tool }: { tool: ToolDef }) {
|
| 47 |
+
const next = encodeURIComponent(`/tools/${tool.slug}`);
|
| 48 |
+
return (
|
| 49 |
+
<div className="card tool-gate">
|
| 50 |
+
<h2>Sign in to use the {tool.eyebrow.toLowerCase()}</h2>
|
| 51 |
+
<p className="muted">It is free. Create an account in seconds and come straight back to this tool.</p>
|
| 52 |
+
<div className="alt-ctas">
|
| 53 |
+
<Link to={`/auth?mode=signup&next=${next}`} className="btn btn-primary">
|
| 54 |
+
Sign up free
|
| 55 |
+
</Link>
|
| 56 |
+
<Link to={`/auth?next=${next}`} className="btn">
|
| 57 |
+
Sign in
|
| 58 |
+
</Link>
|
| 59 |
+
</div>
|
| 60 |
+
</div>
|
| 61 |
+
);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
function PodcastScriptWidget({ tool }: { tool: ToolDef }) {
|
| 65 |
+
const [text, setText] = useState("");
|
| 66 |
+
const [copied, setCopied] = useState(false);
|
| 67 |
+
const script = useMemo(() => buildPodcastScript(text), [text]);
|
| 68 |
+
const inputWords = text.trim() ? text.trim().split(/\s+/).length : 0;
|
| 69 |
+
|
| 70 |
+
const copy = async () => {
|
| 71 |
+
if (!script) return;
|
| 72 |
+
await navigator.clipboard.writeText(scriptToText(script));
|
| 73 |
+
setCopied(true);
|
| 74 |
+
setTimeout(() => setCopied(false), 1500);
|
| 75 |
+
};
|
| 76 |
+
const download = () => {
|
| 77 |
+
if (!script) return;
|
| 78 |
+
const url = URL.createObjectURL(new Blob([scriptToText(script)], { type: "text/plain" }));
|
| 79 |
+
const a = document.createElement("a");
|
| 80 |
+
a.href = url;
|
| 81 |
+
a.download = `${tool.slug}-script.txt`;
|
| 82 |
+
a.click();
|
| 83 |
+
URL.revokeObjectURL(url);
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
return (
|
| 87 |
+
<div className="tool-widget">
|
| 88 |
+
<label className="field">
|
| 89 |
+
<span>
|
| 90 |
+
{tool.inputLabel} <span className="muted mono small">{inputWords} words</span>
|
| 91 |
+
</span>
|
| 92 |
+
<textarea
|
| 93 |
+
className="input tool-input"
|
| 94 |
+
value={text}
|
| 95 |
+
onChange={(e) => setText(e.target.value)}
|
| 96 |
+
placeholder={tool.inputPlaceholder}
|
| 97 |
+
rows={10}
|
| 98 |
+
/>
|
| 99 |
+
</label>
|
| 100 |
+
{tool.inputHint && (
|
| 101 |
+
<p className="muted small">
|
| 102 |
+
<Inline text={tool.inputHint} />
|
| 103 |
+
</p>
|
| 104 |
+
)}
|
| 105 |
+
{text.trim() && !script && <p className="muted">Paste a little more: the script needs at least three full sentences.</p>}
|
| 106 |
+
{script && (
|
| 107 |
+
<div className="card tool-output">
|
| 108 |
+
<div className="tool-output-head">
|
| 109 |
+
<span className="mono small muted">
|
| 110 |
+
{script.lines.length} lines · about {Math.max(1, Math.round(script.minutes))} min · built from {script.sourceSentences} of your
|
| 111 |
+
sentences
|
| 112 |
+
</span>
|
| 113 |
+
<span className="tool-actions">
|
| 114 |
+
<button type="button" className="btn" onClick={copy}>
|
| 115 |
+
{copied ? "Copied" : "Copy"}
|
| 116 |
+
</button>
|
| 117 |
+
<button type="button" className="btn" onClick={download}>
|
| 118 |
+
Download
|
| 119 |
+
</button>
|
| 120 |
+
</span>
|
| 121 |
+
</div>
|
| 122 |
+
<ol className="tool-script">
|
| 123 |
+
{script.lines.map((l, i) => (
|
| 124 |
+
<li key={i} className={`host-${l.host}`}>
|
| 125 |
+
<span className="mono host-label">Host {l.host}</span>
|
| 126 |
+
<p>{l.text}</p>
|
| 127 |
+
</li>
|
| 128 |
+
))}
|
| 129 |
+
</ol>
|
| 130 |
+
<div className="tool-next">
|
| 131 |
+
<p className="muted">Hear it with two natural voices, grounded in your whole archive.</p>
|
| 132 |
+
<Link to="/app/studio" className="btn btn-primary">
|
| 133 |
+
Voice it in Studio
|
| 134 |
+
</Link>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
)}
|
| 138 |
+
</div>
|
| 139 |
+
);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
export function ToolPage() {
|
| 143 |
+
const { slug = "" } = useParams();
|
| 144 |
+
const tool = getTool(slug);
|
| 145 |
+
const { user, loading } = useAuth();
|
| 146 |
+
useMeta(tool?.metaTitle ?? "Not found | Notestack", tool?.description ?? "");
|
| 147 |
+
|
| 148 |
+
if (!tool) {
|
| 149 |
+
return (
|
| 150 |
+
<div className="page">
|
| 151 |
+
<PublicNav />
|
| 152 |
+
<main className="container section">
|
| 153 |
+
<h1 className="section-title">Tool not found</h1>
|
| 154 |
+
<Link to="/tools">See all free tools</Link>
|
| 155 |
+
</main>
|
| 156 |
+
</div>
|
| 157 |
+
);
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
const faqLd = {
|
| 161 |
+
"@context": "https://schema.org",
|
| 162 |
+
"@type": "FAQPage",
|
| 163 |
+
mainEntity: tool.faq.map((f) => ({ "@type": "Question", name: f.question, acceptedAnswer: { "@type": "Answer", text: f.answer } })),
|
| 164 |
+
};
|
| 165 |
+
|
| 166 |
+
return (
|
| 167 |
+
<div className="page">
|
| 168 |
+
<SkyCanvas intensity={0.7} travel />
|
| 169 |
+
<PublicNav />
|
| 170 |
+
<main className="container post mission alt-page">
|
| 171 |
+
<script type="application/ld+json">{JSON.stringify(faqLd)}</script>
|
| 172 |
+
<header className="mission-head">
|
| 173 |
+
<p className="eyebrow">{tool.eyebrow}</p>
|
| 174 |
+
<h1 className="post-title">{tool.heroTitle}</h1>
|
| 175 |
+
<p className="post-lede muted">{tool.heroDescription}</p>
|
| 176 |
+
</header>
|
| 177 |
+
|
| 178 |
+
<section className="alt-section">{loading ? null : user ? <PodcastScriptWidget tool={tool} /> : <SignInGate tool={tool} />}</section>
|
| 179 |
+
|
| 180 |
+
{tool.sections.map((s) => (
|
| 181 |
+
<section key={s.heading} className="alt-section">
|
| 182 |
+
<h2>{s.heading}</h2>
|
| 183 |
+
{s.paragraphs.map((p, i) => (
|
| 184 |
+
<p key={i} className="muted">
|
| 185 |
+
<Inline text={p} />
|
| 186 |
+
</p>
|
| 187 |
+
))}
|
| 188 |
+
{s.bullets && (
|
| 189 |
+
<ul>
|
| 190 |
+
{s.bullets.map((b) => (
|
| 191 |
+
<li key={b}>{b}</li>
|
| 192 |
+
))}
|
| 193 |
+
</ul>
|
| 194 |
+
)}
|
| 195 |
+
</section>
|
| 196 |
+
))}
|
| 197 |
+
|
| 198 |
+
<section className="alt-section debrief">
|
| 199 |
+
<h2>Questions</h2>
|
| 200 |
+
{tool.faq.map((f) => (
|
| 201 |
+
<details key={f.question} className="faq">
|
| 202 |
+
<summary>{f.question}</summary>
|
| 203 |
+
<p className="muted">{f.answer}</p>
|
| 204 |
+
</details>
|
| 205 |
+
))}
|
| 206 |
+
</section>
|
| 207 |
+
|
| 208 |
+
<nav className="alt-section" aria-label="Related">
|
| 209 |
+
<h2>Keep exploring</h2>
|
| 210 |
+
<ul>
|
| 211 |
+
{tool.relatedPaths.map((p) => (
|
| 212 |
+
<li key={p}>
|
| 213 |
+
<Link to={p}>{pathTitle(p)}</Link>
|
| 214 |
+
</li>
|
| 215 |
+
))}
|
| 216 |
+
</ul>
|
| 217 |
+
</nav>
|
| 218 |
+
</main>
|
| 219 |
+
<PublicFooter />
|
| 220 |
+
</div>
|
| 221 |
+
);
|
| 222 |
+
}
|
frontend/src/pages/TopicMap.tsx
CHANGED
|
@@ -9,7 +9,8 @@ import {
|
|
| 9 |
type RefObject,
|
| 10 |
} from "react";
|
| 11 |
import { Link, useNavigate } from "react-router-dom";
|
| 12 |
-
import { notebooksApi, topicsApi } from "../api/endpoints";
|
|
|
|
| 13 |
import type { Job, TopicDetail, TopicMap as TopicMapData, TopicNode } from "../api/types";
|
| 14 |
import { Reader } from "../components/Reader";
|
| 15 |
import { EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader, Tabs } from "../components/ui";
|
|
@@ -299,6 +300,9 @@ export default function TopicMap() {
|
|
| 299 |
const [data, setData] = useState<TopicMapData | null>(null);
|
| 300 |
const [job, setJob] = useState<Job | null>(null);
|
| 301 |
const [selected, setSelected] = useState<TopicDetail | null>(null);
|
|
|
|
|
|
|
|
|
|
| 302 |
const [hover, setHover] = useState<string | null>(null);
|
| 303 |
const [mode, setMode] = useState<ColorMode>("recency");
|
| 304 |
const [reading, setReading] = useState<string | null>(null);
|
|
@@ -458,6 +462,7 @@ export default function TopicMap() {
|
|
| 458 |
|
| 459 |
return (
|
| 460 |
<div className="page-wrap">
|
|
|
|
| 461 |
<PageHeader eyebrow="Topic map" title="The constellations in your archive">
|
| 462 |
<Tabs<ColorMode>
|
| 463 |
tabs={[
|
|
@@ -467,12 +472,35 @@ export default function TopicMap() {
|
|
| 467 |
value={mode}
|
| 468 |
onChange={setMode}
|
| 469 |
/>
|
| 470 |
-
|
| 471 |
-
{
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 476 |
</PageHeader>
|
| 477 |
{live && (running || live.status === "failed") && <JobProgress job={live} />}
|
| 478 |
{error && <p className="error-text">{error}</p>}
|
|
@@ -489,7 +517,7 @@ export default function TopicMap() {
|
|
| 489 |
/>
|
| 490 |
)}
|
| 491 |
{data && data.nodes.length > 0 && (
|
| 492 |
-
<div className=
|
| 493 |
<div className="card map-canvas">
|
| 494 |
<div className="map-controls">
|
| 495 |
<button className="icon-btn" onClick={() => zoomAt(0.75)} aria-label="Zoom in" title="Zoom in">
|
|
@@ -524,7 +552,8 @@ export default function TopicMap() {
|
|
| 524 |
)}
|
| 525 |
</div>
|
| 526 |
|
| 527 |
-
<
|
|
|
|
| 528 |
<span>
|
| 529 |
<i className="lg-size" /> size = posts
|
| 530 |
</span>
|
|
@@ -545,7 +574,7 @@ export default function TopicMap() {
|
|
| 545 |
<span>
|
| 546 |
<i className="lg-line" /> lines = shared posts
|
| 547 |
</span>
|
| 548 |
-
</
|
| 549 |
|
| 550 |
<svg
|
| 551 |
ref={svgRef}
|
|
@@ -713,6 +742,7 @@ export default function TopicMap() {
|
|
| 713 |
</svg>
|
| 714 |
</div>
|
| 715 |
|
|
|
|
| 716 |
<aside className="card map-side">
|
| 717 |
{!selected && (
|
| 718 |
<>
|
|
@@ -771,7 +801,7 @@ export default function TopicMap() {
|
|
| 771 |
{data.insights.dormant.length === 0 && <li className="muted small">Every topic has been visited lately.</li>}
|
| 772 |
</ul>
|
| 773 |
{data.insights.dormant.length > 0 && (
|
| 774 |
-
<Link to="/app/
|
| 775 |
Resurface old posts
|
| 776 |
</Link>
|
| 777 |
)}
|
|
@@ -802,7 +832,7 @@ export default function TopicMap() {
|
|
| 802 |
{selected && (
|
| 803 |
<>
|
| 804 |
<button className="link-btn mono muted small" onClick={() => setSelected(null)}>
|
| 805 |
-
Back to briefing
|
| 806 |
</button>
|
| 807 |
<div className="row between">
|
| 808 |
<h2>{selected.name}</h2>
|
|
@@ -851,15 +881,39 @@ export default function TopicMap() {
|
|
| 851 |
</div>
|
| 852 |
)}
|
| 853 |
|
| 854 |
-
<
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 863 |
<ul className="doc-list">
|
| 864 |
{selected.posts.map((d) => (
|
| 865 |
<li key={d.id} className="doc-row">
|
|
@@ -873,6 +927,7 @@ export default function TopicMap() {
|
|
| 873 |
</>
|
| 874 |
)}
|
| 875 |
</aside>
|
|
|
|
| 876 |
</div>
|
| 877 |
)}
|
| 878 |
{reading && <Reader documentId={reading} onClose={() => setReading(null)} />}
|
|
|
|
| 9 |
type RefObject,
|
| 10 |
} from "react";
|
| 11 |
import { Link, useNavigate } from "react-router-dom";
|
| 12 |
+
import { artifactsApi, notebooksApi, topicsApi } from "../api/endpoints";
|
| 13 |
+
import { SourcesNav } from "./Sources";
|
| 14 |
import type { Job, TopicDetail, TopicMap as TopicMapData, TopicNode } from "../api/types";
|
| 15 |
import { Reader } from "../components/Reader";
|
| 16 |
import { EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader, Tabs } from "../components/ui";
|
|
|
|
| 300 |
const [data, setData] = useState<TopicMapData | null>(null);
|
| 301 |
const [job, setJob] = useState<Job | null>(null);
|
| 302 |
const [selected, setSelected] = useState<TopicDetail | null>(null);
|
| 303 |
+
const [briefing, setBriefing] = useState(false);
|
| 304 |
+
const [menu, setMenu] = useState(false);
|
| 305 |
+
const [acting, setActing] = useState<string | null>(null);
|
| 306 |
const [hover, setHover] = useState<string | null>(null);
|
| 307 |
const [mode, setMode] = useState<ColorMode>("recency");
|
| 308 |
const [reading, setReading] = useState<string | null>(null);
|
|
|
|
| 462 |
|
| 463 |
return (
|
| 464 |
<div className="page-wrap">
|
| 465 |
+
<SourcesNav />
|
| 466 |
<PageHeader eyebrow="Topic map" title="The constellations in your archive">
|
| 467 |
<Tabs<ColorMode>
|
| 468 |
tabs={[
|
|
|
|
| 472 |
value={mode}
|
| 473 |
onChange={setMode}
|
| 474 |
/>
|
| 475 |
+
{data && data.nodes.length > 0 && (
|
| 476 |
+
<button className={`btn btn-small${briefing ? " btn-primary" : ""}`} onClick={() => setBriefing(!briefing)} aria-pressed={briefing}>
|
| 477 |
+
Briefing
|
| 478 |
+
</button>
|
| 479 |
+
)}
|
| 480 |
+
{data?.has_untagged_posts && (
|
| 481 |
+
<button className="btn btn-small" disabled={Boolean(running)} onClick={() => rebuild(false)}>
|
| 482 |
+
Map new posts
|
| 483 |
+
</button>
|
| 484 |
+
)}
|
| 485 |
+
<div className="card-menu map-menu">
|
| 486 |
+
<button type="button" className="icon-btn" aria-label="Map options" aria-expanded={menu} onClick={() => setMenu(!menu)}>
|
| 487 |
+
⋯
|
| 488 |
+
</button>
|
| 489 |
+
{menu && (
|
| 490 |
+
<div className="card-menu-list" onMouseLeave={() => setMenu(false)}>
|
| 491 |
+
<button
|
| 492 |
+
type="button"
|
| 493 |
+
disabled={Boolean(running)}
|
| 494 |
+
onClick={() => {
|
| 495 |
+
setMenu(false);
|
| 496 |
+
rebuild(true);
|
| 497 |
+
}}
|
| 498 |
+
>
|
| 499 |
+
Rebuild the whole map
|
| 500 |
+
</button>
|
| 501 |
+
</div>
|
| 502 |
+
)}
|
| 503 |
+
</div>
|
| 504 |
</PageHeader>
|
| 505 |
{live && (running || live.status === "failed") && <JobProgress job={live} />}
|
| 506 |
{error && <p className="error-text">{error}</p>}
|
|
|
|
| 517 |
/>
|
| 518 |
)}
|
| 519 |
{data && data.nodes.length > 0 && (
|
| 520 |
+
<div className={`map-layout${selected || briefing ? "" : " map-full"}`}>
|
| 521 |
<div className="card map-canvas">
|
| 522 |
<div className="map-controls">
|
| 523 |
<button className="icon-btn" onClick={() => zoomAt(0.75)} aria-label="Zoom in" title="Zoom in">
|
|
|
|
| 552 |
)}
|
| 553 |
</div>
|
| 554 |
|
| 555 |
+
<details className="map-legend mono" aria-label="Legend">
|
| 556 |
+
<summary>Legend</summary>
|
| 557 |
<span>
|
| 558 |
<i className="lg-size" /> size = posts
|
| 559 |
</span>
|
|
|
|
| 574 |
<span>
|
| 575 |
<i className="lg-line" /> lines = shared posts
|
| 576 |
</span>
|
| 577 |
+
</details>
|
| 578 |
|
| 579 |
<svg
|
| 580 |
ref={svgRef}
|
|
|
|
| 742 |
</svg>
|
| 743 |
</div>
|
| 744 |
|
| 745 |
+
{(selected || briefing) && (
|
| 746 |
<aside className="card map-side">
|
| 747 |
{!selected && (
|
| 748 |
<>
|
|
|
|
| 801 |
{data.insights.dormant.length === 0 && <li className="muted small">Every topic has been visited lately.</li>}
|
| 802 |
</ul>
|
| 803 |
{data.insights.dormant.length > 0 && (
|
| 804 |
+
<Link to="/app/launchpad#ideas" className="small-link mono">
|
| 805 |
Resurface old posts
|
| 806 |
</Link>
|
| 807 |
)}
|
|
|
|
| 832 |
{selected && (
|
| 833 |
<>
|
| 834 |
<button className="link-btn mono muted small" onClick={() => setSelected(null)}>
|
| 835 |
+
{briefing ? "Back to briefing" : "Close"}
|
| 836 |
</button>
|
| 837 |
<div className="row between">
|
| 838 |
<h2>{selected.name}</h2>
|
|
|
|
| 881 |
</div>
|
| 882 |
)}
|
| 883 |
|
| 884 |
+
<div className="topic-actions">
|
| 885 |
+
<button
|
| 886 |
+
className="btn btn-small btn-primary"
|
| 887 |
+
disabled={acting !== null}
|
| 888 |
+
onClick={async () => {
|
| 889 |
+
setActing("audio");
|
| 890 |
+
try {
|
| 891 |
+
const nb = await notebooksApi.fromTopic(selected.id);
|
| 892 |
+
await artifactsApi.generate({ type: "audio_overview", notebook_id: nb.id, format: "deep_dive", minutes: 6 });
|
| 893 |
+
navigate(`/app/notebooks/${nb.id}`);
|
| 894 |
+
} finally {
|
| 895 |
+
setActing(null);
|
| 896 |
+
}
|
| 897 |
+
}}
|
| 898 |
+
>
|
| 899 |
+
{acting === "audio" ? "Starting..." : "Audio about this"}
|
| 900 |
+
</button>
|
| 901 |
+
{selected.posts[0] && (
|
| 902 |
+
<button className="btn btn-small" disabled={acting !== null} onClick={() => navigate(`/app/launch-kit?post=${selected.posts[0].id}`)}>
|
| 903 |
+
Launch Kit for the latest post
|
| 904 |
+
</button>
|
| 905 |
+
)}
|
| 906 |
+
<button
|
| 907 |
+
className="btn btn-small"
|
| 908 |
+
disabled={acting !== null}
|
| 909 |
+
onClick={async () => {
|
| 910 |
+
const nb = await notebooksApi.fromTopic(selected.id);
|
| 911 |
+
navigate(`/app/notebooks/${nb.id}`);
|
| 912 |
+
}}
|
| 913 |
+
>
|
| 914 |
+
Open as notebook
|
| 915 |
+
</button>
|
| 916 |
+
</div>
|
| 917 |
<ul className="doc-list">
|
| 918 |
{selected.posts.map((d) => (
|
| 919 |
<li key={d.id} className="doc-row">
|
|
|
|
| 927 |
</>
|
| 928 |
)}
|
| 929 |
</aside>
|
| 930 |
+
)}
|
| 931 |
</div>
|
| 932 |
)}
|
| 933 |
{reading && <Reader documentId={reading} onClose={() => setReading(null)} />}
|
frontend/src/pages/VoiceProfile.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
| 1 |
import { useCallback, useEffect, useRef, useState } from "react";
|
| 2 |
import { voiceApi } from "../api/endpoints";
|
| 3 |
-
import type { Delivery, Job, LibraryVoice,
|
| 4 |
import { DocPicker } from "../components/DocPicker";
|
| 5 |
-
import { ConfirmButton, errorMessage, formatDate, JobProgress, Loading, Modal, PageHeader } from "../components/ui";
|
| 6 |
import { useJob } from "../hooks/useJob";
|
|
|
|
| 7 |
|
| 8 |
type Host = "host_a" | "host_b";
|
| 9 |
|
|
@@ -22,8 +23,6 @@ const SLIDERS: { key: keyof Delivery; label: string; min: number; max: number; s
|
|
| 22 |
{ key: "speed", label: "Speed", min: 0.7, max: 1.2, step: 0.05, hint: "1.0 is natural pace" },
|
| 23 |
];
|
| 24 |
|
| 25 |
-
const HOST_LABEL: Record<Host, string> = { host_a: "Host A, leads and narrates videos", host_b: "Host B, the co-host" };
|
| 26 |
-
|
| 27 |
/** Plays one URL at a time across the page. */
|
| 28 |
function usePlayer() {
|
| 29 |
const audio = useRef<HTMLAudioElement | null>(null);
|
|
@@ -50,83 +49,159 @@ function fmtSeconds(s: number) {
|
|
| 50 |
|
| 51 |
// Speaking voices
|
| 52 |
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
state,
|
| 56 |
voices,
|
| 57 |
onSaved,
|
| 58 |
onBrowse,
|
|
|
|
| 59 |
}: {
|
| 60 |
-
host: Host;
|
| 61 |
state: VoiceState;
|
| 62 |
voices: Voice[];
|
| 63 |
onSaved: (s: VoiceState) => void;
|
| 64 |
onBrowse: (host: Host) => void;
|
|
|
|
| 65 |
}) {
|
|
|
|
| 66 |
const [delivery, setDelivery] = useState<Delivery>(state.delivery[host]);
|
| 67 |
-
const [busy, setBusy] = useState(false);
|
| 68 |
-
const [error, setError] = useState<string | null>(null);
|
| 69 |
const [dirty, setDirty] = useState(false);
|
|
|
|
|
|
|
| 70 |
const { play, playing } = usePlayer();
|
| 71 |
const current = state.host_voices[host];
|
| 72 |
-
const
|
| 73 |
-
|
| 74 |
-
(acc[k] ??= []).push(v);
|
| 75 |
-
return acc;
|
| 76 |
-
}, {});
|
| 77 |
|
| 78 |
useEffect(() => {
|
| 79 |
setDelivery(state.delivery[host]);
|
| 80 |
setDirty(false);
|
| 81 |
}, [state.delivery, host]);
|
| 82 |
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
setError(null);
|
| 86 |
try {
|
| 87 |
-
const { url } = await voiceApi.preview(
|
| 88 |
-
play(url,
|
| 89 |
} catch (e) {
|
| 90 |
setError(errorMessage(e));
|
| 91 |
} finally {
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
}
|
| 94 |
};
|
| 95 |
|
| 96 |
return (
|
| 97 |
-
<div className="
|
| 98 |
-
<
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
<
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
<details className="delivery">
|
| 129 |
-
<summary className="mono muted small">Delivery settings</summary>
|
| 130 |
{SLIDERS.map((s) => (
|
| 131 |
<label key={s.key} className="slider" title={s.hint}>
|
| 132 |
<span className="row between">
|
|
@@ -163,11 +238,210 @@ function HostCard({
|
|
| 163 |
</div>
|
| 164 |
)}
|
| 165 |
</details>
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
</div>
|
| 168 |
);
|
| 169 |
}
|
| 170 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
// Voice library (commercially licensed voices from ElevenLabs)
|
| 172 |
|
| 173 |
function LibraryModal({ host, onClose, onAdded }: { host: Host; onClose: () => void; onAdded: (s: VoiceState) => void }) {
|
|
@@ -460,19 +734,31 @@ function CloneModal({ state, onClose, onDone }: { state: VoiceState; onClose: ()
|
|
| 460 |
);
|
| 461 |
}
|
| 462 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
export default function VoiceProfile() {
|
| 464 |
const [state, setState] = useState<VoiceState | null>(null);
|
| 465 |
const [draft, setDraft] = useState<VoiceProfileData | null>(null);
|
| 466 |
const [voices, setVoices] = useState<Voice[]>([]);
|
| 467 |
-
const [
|
| 468 |
const [picking, setPicking] = useState(false);
|
| 469 |
const [samples, setSamples] = useState<string[]>([]);
|
| 470 |
const [buildJob, setBuildJob] = useState<Job | null>(null);
|
| 471 |
const [cloneJob, setCloneJob] = useState<Job | null>(null);
|
| 472 |
const [cloning, setCloning] = useState(false);
|
| 473 |
const [browsing, setBrowsing] = useState<Host | null>(null);
|
|
|
|
| 474 |
const [saved, setSaved] = useState(false);
|
| 475 |
const [error, setError] = useState<string | null>(null);
|
|
|
|
| 476 |
const { play, playing } = usePlayer();
|
| 477 |
|
| 478 |
const load = () =>
|
|
@@ -480,13 +766,31 @@ export default function VoiceProfile() {
|
|
| 480 |
setState(s);
|
| 481 |
setDraft(s.profile);
|
| 482 |
setSamples(s.sample_doc_ids);
|
|
|
|
|
|
|
| 483 |
});
|
| 484 |
const loadVoices = () => voiceApi.voices().then(setVoices, () => setVoices([]));
|
| 485 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
useEffect(() => {
|
| 487 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 488 |
loadVoices();
|
| 489 |
-
|
| 490 |
}, []);
|
| 491 |
|
| 492 |
const build = useJob(buildJob, () => load());
|
|
@@ -495,9 +799,11 @@ export default function VoiceProfile() {
|
|
| 495 |
loadVoices();
|
| 496 |
});
|
| 497 |
|
| 498 |
-
if (!state) return <Loading />;
|
| 499 |
const building = build && (build.status === "queued" || build.status === "running");
|
| 500 |
const cloneRunning = clone && (clone.status === "queued" || clone.status === "running");
|
|
|
|
|
|
|
| 501 |
|
| 502 |
const saveProfile = async () => {
|
| 503 |
setError(null);
|
|
@@ -510,111 +816,113 @@ export default function VoiceProfile() {
|
|
| 510 |
}
|
| 511 |
};
|
| 512 |
|
| 513 |
-
const used = quota.character_count ?? 0;
|
| 514 |
-
const limit = quota.character_limit ?? 0;
|
| 515 |
-
|
| 516 |
return (
|
| 517 |
-
<div className="page-wrap">
|
| 518 |
-
<PageHeader eyebrow="Voice profile" title="How you sound, on the page and out loud">
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 524 |
{error && <p className="error-text">{error}</p>}
|
| 525 |
|
| 526 |
-
|
| 527 |
-
<section className="card stack">
|
| 528 |
-
<h2>
|
| 529 |
-
<p className="muted
|
| 530 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 531 |
{draft && (
|
| 532 |
<>
|
| 533 |
<label className="field">
|
| 534 |
-
<span>
|
| 535 |
-
<textarea className="textarea" rows={
|
| 536 |
-
</label>
|
| 537 |
-
<label className="field">
|
| 538 |
-
<span>Sentence length</span>
|
| 539 |
-
<select className="input input-sm" value={draft.sentence_length} onChange={(e) => setDraft({ ...draft, sentence_length: e.target.value as VoiceProfileData["sentence_length"] })}>
|
| 540 |
-
{["short", "medium", "long", "varied"].map((v) => (
|
| 541 |
-
<option key={v}>{v}</option>
|
| 542 |
-
))}
|
| 543 |
-
</select>
|
| 544 |
</label>
|
| 545 |
-
|
| 546 |
-
<
|
| 547 |
-
|
| 548 |
-
<
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
/>
|
| 554 |
</label>
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 561 |
{saved ? "Saved" : "Save changes"}
|
| 562 |
</button>
|
|
|
|
|
|
|
|
|
|
| 563 |
</div>
|
| 564 |
</>
|
| 565 |
)}
|
| 566 |
</section>
|
|
|
|
| 567 |
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
<h2>Your own voice</h2>
|
| 589 |
-
{!state.clone.allowed && <p className="muted">Voice cloning is on the Writer and Studio plans.</p>}
|
| 590 |
-
{quota.can_use_instant_voice_cloning === false && (
|
| 591 |
-
<p className="muted small">Your ElevenLabs plan does not include instant voice cloning. Upgrade it at elevenlabs.io.</p>
|
| 592 |
-
)}
|
| 593 |
-
{state.clone.allowed && (state.clone.status === "none" || state.clone.status === "failed") && !cloneRunning && (
|
| 594 |
-
<>
|
| 595 |
-
<p className="muted">
|
| 596 |
-
Record one to three minutes of your own writing, read aloud. Your voice becomes Host A, so audio overviews and videos sound like you.
|
| 597 |
-
</p>
|
| 598 |
-
{state.clone.status === "failed" && state.clone.error && <p className="error-text">{state.clone.error}</p>}
|
| 599 |
-
<button className="btn btn-primary" onClick={() => setCloning(true)} disabled={!state.tts_configured}>
|
| 600 |
-
Clone my voice
|
| 601 |
-
</button>
|
| 602 |
-
</>
|
| 603 |
-
)}
|
| 604 |
-
{clone && (cloneRunning || clone.status === "failed") && <JobProgress job={clone} />}
|
| 605 |
-
{state.clone.status === "processing" && !clone && <p className="muted">Your voice is being created.</p>}
|
| 606 |
-
{state.clone.status === "ready" && (
|
| 607 |
-
<>
|
| 608 |
-
<p className="muted">Your voice is live and set as Host A. Adjust its delivery above.</p>
|
| 609 |
-
<div className="row">
|
| 610 |
-
{state.clone.preview_url && (
|
| 611 |
-
<button className="btn btn-small" onClick={() => play(state.clone.preview_url!, "clone")}>
|
| 612 |
-
{playing === "clone" ? "Stop" : "Hear my voice"}
|
| 613 |
-
</button>
|
| 614 |
-
)}
|
| 615 |
<button className="btn btn-small" onClick={() => setCloning(true)}>
|
| 616 |
-
Record again
|
| 617 |
</button>
|
|
|
|
|
|
|
| 618 |
<ConfirmButton
|
| 619 |
confirmLabel="Revoke and delete my voice?"
|
| 620 |
onConfirm={async () => {
|
|
@@ -624,35 +932,43 @@ export default function VoiceProfile() {
|
|
| 624 |
>
|
| 625 |
Revoke consent
|
| 626 |
</ConfirmButton>
|
| 627 |
-
|
| 628 |
-
</>
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 633 |
|
| 634 |
{picking && (
|
| 635 |
<Modal title="Pick 5 to 10 posts that sound most like you" onClose={() => setPicking(false)} wide>
|
| 636 |
<DocPicker selected={samples} onChange={setSamples} max={10} />
|
| 637 |
<p className="muted small">Leave empty to let Notestack pick your longest posts.</p>
|
| 638 |
<div className="row end">
|
| 639 |
-
<button
|
| 640 |
-
className="btn btn-primary"
|
| 641 |
-
onClick={async () => {
|
| 642 |
-
setPicking(false);
|
| 643 |
-
try {
|
| 644 |
-
setBuildJob(await voiceApi.build(samples));
|
| 645 |
-
} catch (e) {
|
| 646 |
-
setError(errorMessage(e));
|
| 647 |
-
}
|
| 648 |
-
}}
|
| 649 |
-
>
|
| 650 |
Build profile
|
| 651 |
</button>
|
| 652 |
</div>
|
| 653 |
</Modal>
|
| 654 |
)}
|
| 655 |
{cloning && <CloneModal state={state} onClose={() => setCloning(false)} onDone={setCloneJob} />}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 656 |
{browsing && (
|
| 657 |
<LibraryModal
|
| 658 |
host={browsing}
|
|
|
|
| 1 |
import { useCallback, useEffect, useRef, useState } from "react";
|
| 2 |
import { voiceApi } from "../api/endpoints";
|
| 3 |
+
import type { Delivery, Job, LibraryVoice, Voice, VoiceDesignInput, VoiceDesignPreview, VoiceProfileData, VoiceState } from "../api/types";
|
| 4 |
import { DocPicker } from "../components/DocPicker";
|
| 5 |
+
import { ConfirmButton, errorMessage, formatDate, JobProgress, Loading, Modal, PageHeader, Tabs } from "../components/ui";
|
| 6 |
import { useJob } from "../hooks/useJob";
|
| 7 |
+
import { useUpgrade } from "../hooks/useUpgrade";
|
| 8 |
|
| 9 |
type Host = "host_a" | "host_b";
|
| 10 |
|
|
|
|
| 23 |
{ key: "speed", label: "Speed", min: 0.7, max: 1.2, step: 0.05, hint: "1.0 is natural pace" },
|
| 24 |
];
|
| 25 |
|
|
|
|
|
|
|
| 26 |
/** Plays one URL at a time across the page. */
|
| 27 |
function usePlayer() {
|
| 28 |
const audio = useRef<HTMLAudioElement | null>(null);
|
|
|
|
| 49 |
|
| 50 |
// Speaking voices
|
| 51 |
|
| 52 |
+
type Icon = "sun" | "book" | "mic" | "spark" | "wave" | "person";
|
| 53 |
+
|
| 54 |
+
/** The four ElevenLabs premade voices offered up front. Each has a glyph so they read at a glance. */
|
| 55 |
+
const PREBUILT: { voice_id: string; name: string; tag: string; icon: Icon }[] = [
|
| 56 |
+
{ voice_id: "EXAVITQu4vr4xnSDxMaL", name: "Sarah", tag: "Warm and reassuring", icon: "sun" },
|
| 57 |
+
{ voice_id: "JBFqnCBsd6RMkjVDRZzb", name: "George", tag: "British storyteller", icon: "book" },
|
| 58 |
+
{ voice_id: "nPczCjzI2devNBz1zQrb", name: "Brian", tag: "Deep narrator", icon: "mic" },
|
| 59 |
+
{ voice_id: "cgSgspJ2msm6clMCkdW9", name: "Jessica", tag: "Bright and lively", icon: "spark" },
|
| 60 |
+
];
|
| 61 |
+
|
| 62 |
+
const ICON_PATHS: Record<Icon, string> = {
|
| 63 |
+
sun: "M12 4V2m0 20v-2m8-8h2M2 12h2m13.66-5.66 1.41-1.41M4.93 19.07l1.41-1.41m0-11.32L4.93 4.93m14.14 14.14-1.41-1.41M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z",
|
| 64 |
+
book: "M4 5.5A2.5 2.5 0 0 1 6.5 3H20v15H6.5A2.5 2.5 0 0 0 4 20.5v-15Zm0 15A2.5 2.5 0 0 0 6.5 23H20v-5",
|
| 65 |
+
mic: "M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Zm7 9a7 7 0 0 1-14 0m7 7v4m-4 0h8",
|
| 66 |
+
spark: "M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3Zm7 11 .8 2.2L22 17l-2.2.8L19 20l-.8-2.2L16 17l2.2-.8L19 14Z",
|
| 67 |
+
wave: "M3 12h2m3-5v10m4-13v16m4-11v6m4-3h1",
|
| 68 |
+
person: "M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 9a7 7 0 0 1 14 0",
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
function VoiceIcon({ icon }: { icon: Icon }) {
|
| 72 |
+
return (
|
| 73 |
+
<span className="voice-icon" aria-hidden="true">
|
| 74 |
+
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
| 75 |
+
<path d={ICON_PATHS[icon]} />
|
| 76 |
+
</svg>
|
| 77 |
+
</span>
|
| 78 |
+
);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
type Tile = { voice_id: string; name: string; tag: string; icon: Icon; preview_url?: string | null };
|
| 82 |
+
|
| 83 |
+
function VoicePicker({
|
| 84 |
state,
|
| 85 |
voices,
|
| 86 |
onSaved,
|
| 87 |
onBrowse,
|
| 88 |
+
onGenerate,
|
| 89 |
}: {
|
|
|
|
| 90 |
state: VoiceState;
|
| 91 |
voices: Voice[];
|
| 92 |
onSaved: (s: VoiceState) => void;
|
| 93 |
onBrowse: (host: Host) => void;
|
| 94 |
+
onGenerate: (host: Host) => void;
|
| 95 |
}) {
|
| 96 |
+
const [host, setHost] = useState<Host>("host_a");
|
| 97 |
const [delivery, setDelivery] = useState<Delivery>(state.delivery[host]);
|
|
|
|
|
|
|
| 98 |
const [dirty, setDirty] = useState(false);
|
| 99 |
+
const [loading, setLoading] = useState<string | null>(null);
|
| 100 |
+
const [error, setError] = useState<string | null>(null);
|
| 101 |
const { play, playing } = usePlayer();
|
| 102 |
const current = state.host_voices[host];
|
| 103 |
+
const other: Host = host === "host_a" ? "host_b" : "host_a";
|
| 104 |
+
const byId = new Map(voices.map((v) => [v.voice_id, v]));
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
useEffect(() => {
|
| 107 |
setDelivery(state.delivery[host]);
|
| 108 |
setDirty(false);
|
| 109 |
}, [state.delivery, host]);
|
| 110 |
|
| 111 |
+
// Prebuilt first, then the writer's own voices, then whatever else a host is set to (e.g. a library voice).
|
| 112 |
+
const tiles: Tile[] = PREBUILT.map((p) => ({ ...p, preview_url: byId.get(p.voice_id)?.preview_url }));
|
| 113 |
+
if (state.clone.status === "ready" && state.clone.voice_id) {
|
| 114 |
+
tiles.push({ voice_id: state.clone.voice_id, name: "My voice", tag: "Your clone", icon: "person", preview_url: state.clone.preview_url });
|
| 115 |
+
}
|
| 116 |
+
for (const c of state.custom_voices) tiles.push({ voice_id: c.voice_id, name: c.name, tag: "Generated", icon: "wave" });
|
| 117 |
+
for (const id of [state.host_voices.host_a, state.host_voices.host_b]) {
|
| 118 |
+
if (!tiles.some((t) => t.voice_id === id)) {
|
| 119 |
+
const v = byId.get(id);
|
| 120 |
+
tiles.push({ voice_id: id, name: v?.name ?? "Current voice", tag: v?.labels?.accent ?? "Library voice", icon: "wave", preview_url: v?.preview_url });
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
const listen = async (t: Tile) => {
|
| 125 |
+
if (playing === t.voice_id || t.preview_url) return play(t.preview_url ?? "", t.voice_id);
|
| 126 |
+
setLoading(t.voice_id);
|
| 127 |
setError(null);
|
| 128 |
try {
|
| 129 |
+
const { url } = await voiceApi.preview(t.voice_id, { host });
|
| 130 |
+
play(url, t.voice_id);
|
| 131 |
} catch (e) {
|
| 132 |
setError(errorMessage(e));
|
| 133 |
} finally {
|
| 134 |
+
setLoading(null);
|
| 135 |
+
}
|
| 136 |
+
};
|
| 137 |
+
|
| 138 |
+
const choose = async (voice_id: string) => {
|
| 139 |
+
if (voice_id === current) return;
|
| 140 |
+
setError(null);
|
| 141 |
+
try {
|
| 142 |
+
onSaved(await voiceApi.update({ host_voices: { [host]: voice_id } }));
|
| 143 |
+
} catch (e) {
|
| 144 |
+
setError(errorMessage(e));
|
| 145 |
}
|
| 146 |
};
|
| 147 |
|
| 148 |
return (
|
| 149 |
+
<div className="stack">
|
| 150 |
+
<Tabs<Host>
|
| 151 |
+
tabs={[
|
| 152 |
+
{ id: "host_a", label: "Host A · leads" },
|
| 153 |
+
{ id: "host_b", label: "Host B · co-host" },
|
| 154 |
+
]}
|
| 155 |
+
value={host}
|
| 156 |
+
onChange={setHost}
|
| 157 |
+
/>
|
| 158 |
+
<ul className="voice-grid" aria-label={`Voices for ${host === "host_a" ? "Host A" : "Host B"}`}>
|
| 159 |
+
{tiles.map((t) => {
|
| 160 |
+
const selected = t.voice_id === current;
|
| 161 |
+
const usedByOther = t.voice_id === state.host_voices[other];
|
| 162 |
+
return (
|
| 163 |
+
<li key={t.voice_id} className={`voice-tile${selected ? " on" : ""}`}>
|
| 164 |
+
<button type="button" className="voice-pick" onClick={() => choose(t.voice_id)} aria-pressed={selected}>
|
| 165 |
+
<VoiceIcon icon={t.icon} />
|
| 166 |
+
<strong>{t.name}</strong>
|
| 167 |
+
<span className="muted small">{t.tag}</span>
|
| 168 |
+
</button>
|
| 169 |
+
{selected && <span className="voice-badge mono">{host === "host_a" ? "Host A" : "Host B"}</span>}
|
| 170 |
+
{!selected && usedByOther && <span className="voice-badge dim mono">{other === "host_a" ? "Host A" : "Host B"}</span>}
|
| 171 |
+
<button
|
| 172 |
+
type="button"
|
| 173 |
+
className="icon-btn voice-play"
|
| 174 |
+
onClick={() => listen(t)}
|
| 175 |
+
disabled={loading !== null || (!t.preview_url && !state.tts_configured)}
|
| 176 |
+
aria-label={playing === t.voice_id ? `Stop ${t.name}` : `Play ${t.name}`}
|
| 177 |
+
title={playing === t.voice_id ? "Stop" : "Listen"}
|
| 178 |
+
>
|
| 179 |
+
{loading === t.voice_id ? (
|
| 180 |
+
"…"
|
| 181 |
+
) : (
|
| 182 |
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
| 183 |
+
{playing === t.voice_id ? <path d="M6 5h4v14H6zM14 5h4v14h-4z" /> : <path d="M7 4.5v15l13-7.5z" />}
|
| 184 |
+
</svg>
|
| 185 |
+
)}
|
| 186 |
+
</button>
|
| 187 |
+
</li>
|
| 188 |
+
);
|
| 189 |
+
})}
|
| 190 |
+
<li className="voice-tile voice-new">
|
| 191 |
+
<button type="button" className="voice-pick" onClick={() => onGenerate(host)} aria-label="Add a voice">
|
| 192 |
+
<span className="voice-icon plus" aria-hidden="true">
|
| 193 |
+
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
|
| 194 |
+
<path d="M12 5v14M5 12h14" />
|
| 195 |
+
</svg>
|
| 196 |
+
</span>
|
| 197 |
+
<strong>Add a voice</strong>
|
| 198 |
+
<span className="muted small">Generate or clone your own</span>
|
| 199 |
+
</button>
|
| 200 |
+
</li>
|
| 201 |
+
</ul>
|
| 202 |
+
{error && <p className="error-text">{error}</p>}
|
| 203 |
<details className="delivery">
|
| 204 |
+
<summary className="mono muted small">Delivery settings for {host === "host_a" ? "Host A" : "Host B"}</summary>
|
| 205 |
{SLIDERS.map((s) => (
|
| 206 |
<label key={s.key} className="slider" title={s.hint}>
|
| 207 |
<span className="row between">
|
|
|
|
| 238 |
</div>
|
| 239 |
)}
|
| 240 |
</details>
|
| 241 |
+
<p className="muted small">
|
| 242 |
+
Want something else?{" "}
|
| 243 |
+
<button className="link-btn small" onClick={() => onBrowse(host)}>
|
| 244 |
+
Browse the full voice library
|
| 245 |
+
</button>
|
| 246 |
+
</p>
|
| 247 |
</div>
|
| 248 |
);
|
| 249 |
}
|
| 250 |
|
| 251 |
+
// Generating a new voice: build from options, describe it, or clone your own
|
| 252 |
+
|
| 253 |
+
type GenMode = "options" | "prompt" | "clone";
|
| 254 |
+
|
| 255 |
+
const GEN_OPTIONS: { key: keyof Omit<VoiceDesignInput, "prompt">; label: string; values: string[] }[] = [
|
| 256 |
+
{ key: "gender", label: "Gender", values: ["female", "male", "neutral"] },
|
| 257 |
+
{ key: "age", label: "Age", values: ["young", "middle-aged", "older"] },
|
| 258 |
+
{ key: "persona", label: "Personality", values: ["warm", "professional", "calm", "friendly", "confident", "energetic"] },
|
| 259 |
+
{ key: "pace", label: "Pace", values: ["slow", "measured", "brisk"] },
|
| 260 |
+
{ key: "accent", label: "Accent", values: ["American", "British", "Australian", "Irish", "Scottish", "Indian", "Canadian", "South African"] },
|
| 261 |
+
];
|
| 262 |
+
|
| 263 |
+
const PROMPT_IDEAS = [
|
| 264 |
+
"A calm, low British voice for late night essays",
|
| 265 |
+
"An upbeat young American podcaster who smiles while talking",
|
| 266 |
+
"A thoughtful older narrator with a soft Irish lilt",
|
| 267 |
+
];
|
| 268 |
+
|
| 269 |
+
function GenerateVoiceModal({
|
| 270 |
+
host,
|
| 271 |
+
state,
|
| 272 |
+
onClose,
|
| 273 |
+
onSaved,
|
| 274 |
+
onClone,
|
| 275 |
+
}: {
|
| 276 |
+
host: Host;
|
| 277 |
+
state: VoiceState;
|
| 278 |
+
onClose: () => void;
|
| 279 |
+
onSaved: (s: VoiceState) => void;
|
| 280 |
+
onClone: () => void;
|
| 281 |
+
}) {
|
| 282 |
+
const [mode, setMode] = useState<GenMode>("options");
|
| 283 |
+
const [opts, setOpts] = useState<VoiceDesignInput>({ gender: "female", age: "middle-aged", persona: "warm", pace: "measured", accent: "American" });
|
| 284 |
+
const [prompt, setPrompt] = useState("");
|
| 285 |
+
const [result, setResult] = useState<{ description: string; previews: VoiceDesignPreview[] } | null>(null);
|
| 286 |
+
const [picked, setPicked] = useState<string | null>(null);
|
| 287 |
+
const [name, setName] = useState("");
|
| 288 |
+
const [busy, setBusy] = useState<"generate" | "save" | null>(null);
|
| 289 |
+
const [error, setError] = useState<string | null>(null);
|
| 290 |
+
const { play, playing } = usePlayer();
|
| 291 |
+
const { openUpgrade } = useUpgrade();
|
| 292 |
+
const hostLabel = host === "host_a" ? "Host A" : "Host B";
|
| 293 |
+
|
| 294 |
+
const generate = async () => {
|
| 295 |
+
setBusy("generate");
|
| 296 |
+
setError(null);
|
| 297 |
+
setResult(null);
|
| 298 |
+
setPicked(null);
|
| 299 |
+
try {
|
| 300 |
+
const res = await voiceApi.design(mode === "prompt" ? { prompt } : opts);
|
| 301 |
+
setResult(res);
|
| 302 |
+
setPicked(res.previews[0]?.generated_voice_id ?? null);
|
| 303 |
+
} catch (e) {
|
| 304 |
+
setError(errorMessage(e));
|
| 305 |
+
} finally {
|
| 306 |
+
setBusy(null);
|
| 307 |
+
}
|
| 308 |
+
};
|
| 309 |
+
|
| 310 |
+
const save = async () => {
|
| 311 |
+
if (!result || !picked) return;
|
| 312 |
+
setBusy("save");
|
| 313 |
+
setError(null);
|
| 314 |
+
try {
|
| 315 |
+
onSaved(await voiceApi.saveDesign({ generated_voice_id: picked, name: name.trim(), description: result.description, use_as: host }));
|
| 316 |
+
onClose();
|
| 317 |
+
} catch (e) {
|
| 318 |
+
setError(errorMessage(e));
|
| 319 |
+
} finally {
|
| 320 |
+
setBusy(null);
|
| 321 |
+
}
|
| 322 |
+
};
|
| 323 |
+
|
| 324 |
+
return (
|
| 325 |
+
<Modal title="Generate a new voice" onClose={onClose} wide>
|
| 326 |
+
<div className="stack">
|
| 327 |
+
<Tabs<GenMode>
|
| 328 |
+
tabs={[
|
| 329 |
+
{ id: "options", label: "Build from options" },
|
| 330 |
+
{ id: "prompt", label: "Describe it" },
|
| 331 |
+
{ id: "clone", label: "Clone my voice" },
|
| 332 |
+
]}
|
| 333 |
+
value={mode}
|
| 334 |
+
onChange={(m) => {
|
| 335 |
+
setMode(m);
|
| 336 |
+
setResult(null);
|
| 337 |
+
setError(null);
|
| 338 |
+
}}
|
| 339 |
+
/>
|
| 340 |
+
|
| 341 |
+
{mode === "options" && (
|
| 342 |
+
<div className="gen-options">
|
| 343 |
+
{GEN_OPTIONS.map((g) => (
|
| 344 |
+
<div key={g.key} className="gen-row">
|
| 345 |
+
<span className="mono muted small">{g.label}</span>
|
| 346 |
+
<div className="gen-chips">
|
| 347 |
+
{g.values.map((v) => (
|
| 348 |
+
<button key={v} type="button" className={`chip${opts[g.key] === v ? " on" : ""}`} aria-pressed={opts[g.key] === v} onClick={() => setOpts({ ...opts, [g.key]: v })}>
|
| 349 |
+
{v}
|
| 350 |
+
</button>
|
| 351 |
+
))}
|
| 352 |
+
</div>
|
| 353 |
+
</div>
|
| 354 |
+
))}
|
| 355 |
+
</div>
|
| 356 |
+
)}
|
| 357 |
+
|
| 358 |
+
{mode === "prompt" && (
|
| 359 |
+
<>
|
| 360 |
+
<label className="field">
|
| 361 |
+
<span>Describe the voice you want</span>
|
| 362 |
+
<textarea className="textarea" rows={3} maxLength={1000} placeholder="A calm, low British voice for late night essays" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
|
| 363 |
+
</label>
|
| 364 |
+
<div className="row between">
|
| 365 |
+
<div className="gen-chips">
|
| 366 |
+
{PROMPT_IDEAS.map((p) => (
|
| 367 |
+
<button key={p} type="button" className="chip" onClick={() => setPrompt(p)}>
|
| 368 |
+
{p}
|
| 369 |
+
</button>
|
| 370 |
+
))}
|
| 371 |
+
</div>
|
| 372 |
+
<span className="mono muted small">{prompt.trim().length} / 1000</span>
|
| 373 |
+
</div>
|
| 374 |
+
</>
|
| 375 |
+
)}
|
| 376 |
+
|
| 377 |
+
{mode === "clone" &&
|
| 378 |
+
(state.clone.allowed ? (
|
| 379 |
+
<>
|
| 380 |
+
<p className="muted">Read a passage from your own writing for one to three minutes. Your voice becomes Host A, so every overview and video sounds like you.</p>
|
| 381 |
+
<div className="row">
|
| 382 |
+
<button
|
| 383 |
+
className="btn btn-primary"
|
| 384 |
+
disabled={!state.tts_configured}
|
| 385 |
+
onClick={() => {
|
| 386 |
+
onClose();
|
| 387 |
+
onClone();
|
| 388 |
+
}}
|
| 389 |
+
>
|
| 390 |
+
{state.clone.status === "ready" ? "Record my voice again" : "Start recording"}
|
| 391 |
+
</button>
|
| 392 |
+
</div>
|
| 393 |
+
</>
|
| 394 |
+
) : (
|
| 395 |
+
<div className="vp-upgrade">
|
| 396 |
+
<div>
|
| 397 |
+
<strong>Voice cloning is on the Writer and Studio plans</strong>
|
| 398 |
+
<p className="muted small">Record a couple of minutes and every audio overview and video is narrated by you.</p>
|
| 399 |
+
</div>
|
| 400 |
+
<button className="btn btn-primary" onClick={() => openUpgrade("writer")}>
|
| 401 |
+
Upgrade to clone my voice
|
| 402 |
+
</button>
|
| 403 |
+
</div>
|
| 404 |
+
))}
|
| 405 |
+
|
| 406 |
+
{mode !== "clone" && (
|
| 407 |
+
<div className="row end">
|
| 408 |
+
<button className="btn btn-primary" onClick={generate} disabled={busy !== null || !state.tts_configured || (mode === "prompt" && prompt.trim().length < 20)}>
|
| 409 |
+
{busy === "generate" ? "Generating previews..." : result ? "Generate again" : "Generate previews"}
|
| 410 |
+
</button>
|
| 411 |
+
</div>
|
| 412 |
+
)}
|
| 413 |
+
|
| 414 |
+
{result && mode !== "clone" && (
|
| 415 |
+
<div className="stack">
|
| 416 |
+
<p className="eyebrow">Pick the one you like</p>
|
| 417 |
+
<ul className="gen-previews">
|
| 418 |
+
{result.previews.map((p, i) => (
|
| 419 |
+
<li key={p.generated_voice_id} className={picked === p.generated_voice_id ? "on" : ""}>
|
| 420 |
+
<label className="check">
|
| 421 |
+
<input type="radio" name="gen-preview" checked={picked === p.generated_voice_id} onChange={() => setPicked(p.generated_voice_id)} />
|
| 422 |
+
Option {i + 1}
|
| 423 |
+
</label>
|
| 424 |
+
<button type="button" className="btn btn-small" onClick={() => play(p.url, p.generated_voice_id)}>
|
| 425 |
+
{playing === p.generated_voice_id ? "Stop" : "Play"}
|
| 426 |
+
</button>
|
| 427 |
+
</li>
|
| 428 |
+
))}
|
| 429 |
+
</ul>
|
| 430 |
+
<div className="row">
|
| 431 |
+
<input className="input input-sm" placeholder="Name it, e.g. Late night narrator" maxLength={60} value={name} onChange={(e) => setName(e.target.value)} aria-label="Voice name" />
|
| 432 |
+
<button className="btn btn-primary" onClick={save} disabled={busy !== null || !picked || !name.trim()}>
|
| 433 |
+
{busy === "save" ? "Saving..." : `Save and use as ${hostLabel}`}
|
| 434 |
+
</button>
|
| 435 |
+
</div>
|
| 436 |
+
</div>
|
| 437 |
+
)}
|
| 438 |
+
{!state.tts_configured && <p className="muted small">Voice generation is unavailable right now.</p>}
|
| 439 |
+
{error && <p className="error-text">{error}</p>}
|
| 440 |
+
</div>
|
| 441 |
+
</Modal>
|
| 442 |
+
);
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
// Voice library (commercially licensed voices from ElevenLabs)
|
| 446 |
|
| 447 |
function LibraryModal({ host, onClose, onAdded }: { host: Host; onClose: () => void; onAdded: (s: VoiceState) => void }) {
|
|
|
|
| 734 |
);
|
| 735 |
}
|
| 736 |
|
| 737 |
+
// The page: two steps. The writing voice builds itself, then the writer picks the two hosts.
|
| 738 |
+
// Cloning lives in the "+ Add a voice" menu; a clone's status shows under the voice cards.
|
| 739 |
+
|
| 740 |
+
type Step = "writing" | "hosts";
|
| 741 |
+
|
| 742 |
+
const STEPS: { id: Step; label: string }[] = [
|
| 743 |
+
{ id: "writing", label: "Writing voice" },
|
| 744 |
+
{ id: "hosts", label: "Speaking voices" },
|
| 745 |
+
];
|
| 746 |
+
|
| 747 |
export default function VoiceProfile() {
|
| 748 |
const [state, setState] = useState<VoiceState | null>(null);
|
| 749 |
const [draft, setDraft] = useState<VoiceProfileData | null>(null);
|
| 750 |
const [voices, setVoices] = useState<Voice[]>([]);
|
| 751 |
+
const [step, setStep] = useState<Step | null>(null);
|
| 752 |
const [picking, setPicking] = useState(false);
|
| 753 |
const [samples, setSamples] = useState<string[]>([]);
|
| 754 |
const [buildJob, setBuildJob] = useState<Job | null>(null);
|
| 755 |
const [cloneJob, setCloneJob] = useState<Job | null>(null);
|
| 756 |
const [cloning, setCloning] = useState(false);
|
| 757 |
const [browsing, setBrowsing] = useState<Host | null>(null);
|
| 758 |
+
const [generating, setGenerating] = useState<Host | null>(null);
|
| 759 |
const [saved, setSaved] = useState(false);
|
| 760 |
const [error, setError] = useState<string | null>(null);
|
| 761 |
+
const autoBuilt = useRef(false);
|
| 762 |
const { play, playing } = usePlayer();
|
| 763 |
|
| 764 |
const load = () =>
|
|
|
|
| 766 |
setState(s);
|
| 767 |
setDraft(s.profile);
|
| 768 |
setSamples(s.sample_doc_ids);
|
| 769 |
+
setStep((cur) => cur ?? (s.profile ? "hosts" : "writing"));
|
| 770 |
+
return s;
|
| 771 |
});
|
| 772 |
const loadVoices = () => voiceApi.voices().then(setVoices, () => setVoices([]));
|
| 773 |
|
| 774 |
+
const startBuild = async (ids: string[]) => {
|
| 775 |
+
setPicking(false);
|
| 776 |
+
setError(null);
|
| 777 |
+
try {
|
| 778 |
+
setBuildJob(await voiceApi.build(ids));
|
| 779 |
+
} catch (e) {
|
| 780 |
+
setError(errorMessage(e));
|
| 781 |
+
}
|
| 782 |
+
};
|
| 783 |
+
|
| 784 |
useEffect(() => {
|
| 785 |
+
// No writing voice yet: build one from the longest posts straight away instead of asking.
|
| 786 |
+
load().then((s) => {
|
| 787 |
+
if (!s.profile && !autoBuilt.current) {
|
| 788 |
+
autoBuilt.current = true;
|
| 789 |
+
void startBuild([]);
|
| 790 |
+
}
|
| 791 |
+
});
|
| 792 |
loadVoices();
|
| 793 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 794 |
}, []);
|
| 795 |
|
| 796 |
const build = useJob(buildJob, () => load());
|
|
|
|
| 799 |
loadVoices();
|
| 800 |
});
|
| 801 |
|
| 802 |
+
if (!state || !step) return <Loading />;
|
| 803 |
const building = build && (build.status === "queued" || build.status === "running");
|
| 804 |
const cloneRunning = clone && (clone.status === "queued" || clone.status === "running");
|
| 805 |
+
const done: Record<Step, boolean> = { writing: Boolean(state.profile), hosts: Boolean(state.profile) && step === "hosts" };
|
| 806 |
+
const showClone = state.clone.status !== "none" || Boolean(clone);
|
| 807 |
|
| 808 |
const saveProfile = async () => {
|
| 809 |
setError(null);
|
|
|
|
| 816 |
}
|
| 817 |
};
|
| 818 |
|
|
|
|
|
|
|
|
|
|
| 819 |
return (
|
| 820 |
+
<div className="page-wrap vp">
|
| 821 |
+
<PageHeader eyebrow="Voice profile" title="How you sound, on the page and out loud" />
|
| 822 |
+
|
| 823 |
+
<ol className="vp-steps mono" aria-label="Voice profile steps">
|
| 824 |
+
{STEPS.map((s, i) => (
|
| 825 |
+
<li key={s.id} className={s.id === step ? "current" : done[s.id] ? "done" : ""}>
|
| 826 |
+
<button type="button" onClick={() => setStep(s.id)} aria-current={s.id === step ? "step" : undefined}>
|
| 827 |
+
<span className="vp-dot" />
|
| 828 |
+
<span>
|
| 829 |
+
{i + 1}. {s.label}
|
| 830 |
+
{done[s.id] && s.id !== step ? " ✓" : ""}
|
| 831 |
+
</span>
|
| 832 |
+
</button>
|
| 833 |
+
</li>
|
| 834 |
+
))}
|
| 835 |
+
</ol>
|
| 836 |
{error && <p className="error-text">{error}</p>}
|
| 837 |
|
| 838 |
+
{step === "writing" && (
|
| 839 |
+
<section className="card stack vp-card">
|
| 840 |
+
<h2>How you write</h2>
|
| 841 |
+
<p className="muted">Launch Kits, carousels, hooks and scripts are all written in this voice, so they read like you wrote them.</p>
|
| 842 |
+
{build && (building || build.status === "failed") && <JobProgress job={build} />}
|
| 843 |
+
{!draft && !building && (
|
| 844 |
+
<div className="row">
|
| 845 |
+
<button className="btn btn-primary" onClick={() => startBuild([])}>
|
| 846 |
+
Build my writing voice
|
| 847 |
+
</button>
|
| 848 |
+
<button className="link-btn small" onClick={() => setPicking(true)}>
|
| 849 |
+
or pick the posts yourself
|
| 850 |
+
</button>
|
| 851 |
+
</div>
|
| 852 |
+
)}
|
| 853 |
+
|
| 854 |
{draft && (
|
| 855 |
<>
|
| 856 |
<label className="field">
|
| 857 |
+
<span>In a sentence, you sound like this</span>
|
| 858 |
+
<textarea className="textarea" rows={7} value={draft.summary} onChange={(e) => setDraft({ ...draft, summary: e.target.value })} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 859 |
</label>
|
| 860 |
+
<details className="delivery">
|
| 861 |
+
<summary className="mono muted small">Fine-tune tone, phrases and habits</summary>
|
| 862 |
+
<label className="field">
|
| 863 |
+
<span>Sentence length</span>
|
| 864 |
+
<select className="input input-sm" value={draft.sentence_length} onChange={(e) => setDraft({ ...draft, sentence_length: e.target.value as VoiceProfileData["sentence_length"] })}>
|
| 865 |
+
{["short", "medium", "long", "varied"].map((v) => (
|
| 866 |
+
<option key={v}>{v}</option>
|
| 867 |
+
))}
|
| 868 |
+
</select>
|
| 869 |
</label>
|
| 870 |
+
{LIST_FIELDS.map((f) => (
|
| 871 |
+
<label key={f.key} className="field">
|
| 872 |
+
<span>{f.label}</span>
|
| 873 |
+
<input
|
| 874 |
+
className="input input-sm"
|
| 875 |
+
placeholder={f.hint}
|
| 876 |
+
value={(draft[f.key] as string[]).join(", ")}
|
| 877 |
+
onChange={(e) => setDraft({ ...draft, [f.key]: e.target.value.split(",").map((x) => x.trim()).filter(Boolean) })}
|
| 878 |
+
/>
|
| 879 |
+
</label>
|
| 880 |
+
))}
|
| 881 |
+
</details>
|
| 882 |
+
<p className="mono muted small">
|
| 883 |
+
Built {formatDate(state.updated_at, true)} from {state.sample_doc_ids.length} posts ·{" "}
|
| 884 |
+
<button className="link-btn small" disabled={Boolean(building)} onClick={() => setPicking(true)}>
|
| 885 |
+
Rebuild from other posts
|
| 886 |
+
</button>
|
| 887 |
+
</p>
|
| 888 |
+
<div className="row end">
|
| 889 |
+
<button className="btn btn-small" onClick={saveProfile}>
|
| 890 |
{saved ? "Saved" : "Save changes"}
|
| 891 |
</button>
|
| 892 |
+
<button className="btn btn-primary" onClick={() => setStep("hosts")}>
|
| 893 |
+
Next: pick your hosts
|
| 894 |
+
</button>
|
| 895 |
</div>
|
| 896 |
</>
|
| 897 |
)}
|
| 898 |
</section>
|
| 899 |
+
)}
|
| 900 |
|
| 901 |
+
{step === "hosts" && (
|
| 902 |
+
<section className="card stack vp-card">
|
| 903 |
+
<h2>Who reads your work aloud</h2>
|
| 904 |
+
<p className="muted">Audio overviews are a conversation between two hosts, and videos are narrated by Host A. Tap a voice to use it, or add your own.</p>
|
| 905 |
+
{!state.tts_configured && <p className="muted">Voice previews are unavailable right now.</p>}
|
| 906 |
+
<VoicePicker state={state} voices={voices} onSaved={setState} onBrowse={setBrowsing} onGenerate={setGenerating} />
|
| 907 |
+
|
| 908 |
+
{showClone && (
|
| 909 |
+
<div className="vp-clone">
|
| 910 |
+
<strong>Your cloned voice</strong>
|
| 911 |
+
{clone && (cloneRunning || clone.status === "failed") && <JobProgress job={clone} compact />}
|
| 912 |
+
{state.clone.status === "processing" && !clone && <p className="muted small">Your voice is being created. It becomes Host A when ready.</p>}
|
| 913 |
+
{state.clone.status === "failed" && state.clone.error && !cloneRunning && <p className="error-text">{state.clone.error}</p>}
|
| 914 |
+
<div className="row">
|
| 915 |
+
{state.clone.status === "ready" && state.clone.preview_url && (
|
| 916 |
+
<button className="btn btn-small" onClick={() => play(state.clone.preview_url!, "clone")}>
|
| 917 |
+
{playing === "clone" ? "Stop" : "Hear my voice"}
|
| 918 |
+
</button>
|
| 919 |
+
)}
|
| 920 |
+
{!cloneRunning && state.clone.allowed && (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 921 |
<button className="btn btn-small" onClick={() => setCloning(true)}>
|
| 922 |
+
{state.clone.status === "failed" ? "Try again" : "Record again"}
|
| 923 |
</button>
|
| 924 |
+
)}
|
| 925 |
+
{state.clone.status !== "none" && (
|
| 926 |
<ConfirmButton
|
| 927 |
confirmLabel="Revoke and delete my voice?"
|
| 928 |
onConfirm={async () => {
|
|
|
|
| 932 |
>
|
| 933 |
Revoke consent
|
| 934 |
</ConfirmButton>
|
| 935 |
+
)}
|
| 936 |
+
</div>
|
| 937 |
+
</div>
|
| 938 |
+
)}
|
| 939 |
+
|
| 940 |
+
<div className="row">
|
| 941 |
+
<button className="btn btn-small" onClick={() => setStep("writing")}>
|
| 942 |
+
Back to writing voice
|
| 943 |
+
</button>
|
| 944 |
+
</div>
|
| 945 |
+
</section>
|
| 946 |
+
)}
|
| 947 |
|
| 948 |
{picking && (
|
| 949 |
<Modal title="Pick 5 to 10 posts that sound most like you" onClose={() => setPicking(false)} wide>
|
| 950 |
<DocPicker selected={samples} onChange={setSamples} max={10} />
|
| 951 |
<p className="muted small">Leave empty to let Notestack pick your longest posts.</p>
|
| 952 |
<div className="row end">
|
| 953 |
+
<button className="btn btn-primary" onClick={() => startBuild(samples)}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 954 |
Build profile
|
| 955 |
</button>
|
| 956 |
</div>
|
| 957 |
</Modal>
|
| 958 |
)}
|
| 959 |
{cloning && <CloneModal state={state} onClose={() => setCloning(false)} onDone={setCloneJob} />}
|
| 960 |
+
{generating && (
|
| 961 |
+
<GenerateVoiceModal
|
| 962 |
+
host={generating}
|
| 963 |
+
state={state}
|
| 964 |
+
onClose={() => setGenerating(null)}
|
| 965 |
+
onSaved={(s) => {
|
| 966 |
+
setState(s);
|
| 967 |
+
loadVoices();
|
| 968 |
+
}}
|
| 969 |
+
onClone={() => setCloning(true)}
|
| 970 |
+
/>
|
| 971 |
+
)}
|
| 972 |
{browsing && (
|
| 973 |
<LibraryModal
|
| 974 |
host={browsing}
|
frontend/src/pages/Welcome.tsx
CHANGED
|
@@ -1,16 +1,25 @@
|
|
| 1 |
import { useEffect, useRef, useState, type FormEvent } from "react";
|
| 2 |
import { Link, Navigate, useNavigate } from "react-router-dom";
|
| 3 |
-
import { docsApi, notebooksApi, sourcesApi } from "../api/endpoints";
|
| 4 |
import type { Job, Source } from "../api/types";
|
| 5 |
import Logo from "../components/Logo";
|
| 6 |
import SkyCanvas from "../components/SkyCanvas";
|
| 7 |
-
import { errorMessage, JobProgress } from "../components/ui";
|
| 8 |
import { useAuth } from "../hooks/useAuth";
|
| 9 |
import { useJob } from "../hooks/useJob";
|
| 10 |
import { PENDING_SOURCE_KEY } from "./Landing";
|
| 11 |
|
| 12 |
type Step = "connect" | "indexing" | "goal" | "ready";
|
| 13 |
type Goal = "ask" | "audio" | "launch";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
const EXAMPLES = ["yourblog.com", "yourname.substack.com", "medium.com/@you"];
|
| 16 |
|
|
@@ -40,11 +49,25 @@ function takePending(): string {
|
|
| 40 |
}
|
| 41 |
}
|
| 42 |
|
| 43 |
-
/**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
export default function Welcome() {
|
| 45 |
const { user, loading } = useAuth();
|
| 46 |
const navigate = useNavigate();
|
| 47 |
const [step, setStep] = useState<Step>("connect");
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
const [url, setUrl] = useState(takePending);
|
| 49 |
const [busy, setBusy] = useState(false);
|
| 50 |
const [error, setError] = useState<string | null>(null);
|
|
@@ -56,27 +79,57 @@ export default function Welcome() {
|
|
| 56 |
|
| 57 |
const live = useJob(job, async (j) => {
|
| 58 |
if (j.status !== "done") return;
|
|
|
|
| 59 |
const list = await docsApi.list({ limit: 1 });
|
| 60 |
setPosts(list.total);
|
| 61 |
setStep((s) => (s === "indexing" ? "goal" : s === "goal" ? "goal" : "ready"));
|
| 62 |
});
|
| 63 |
|
| 64 |
-
const connect = async (value: string) => {
|
| 65 |
if (!value.trim()) return;
|
| 66 |
setBusy(true);
|
| 67 |
setError(null);
|
| 68 |
try {
|
| 69 |
-
const res = await sourcesApi.connect(value.trim());
|
|
|
|
| 70 |
setSource(res.source);
|
| 71 |
setJob(res.job);
|
| 72 |
setStep("indexing");
|
| 73 |
} catch (e) {
|
| 74 |
-
setError(errorMessage(e, "We could not reach that address."));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
} finally {
|
| 76 |
setBusy(false);
|
| 77 |
}
|
| 78 |
};
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
// A URL pasted on the landing page starts connecting straight away.
|
| 81 |
useEffect(() => {
|
| 82 |
if (url && !autoStarted.current && user) {
|
|
@@ -91,6 +144,34 @@ export default function Welcome() {
|
|
| 91 |
|
| 92 |
const indexingDone = live?.status === "done";
|
| 93 |
const indexingFailed = live?.status === "failed";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
const first = (user.name ?? user.email).split(/[ @]/)[0];
|
| 95 |
|
| 96 |
const finish = async (chosen: Goal | null) => {
|
|
@@ -109,7 +190,8 @@ export default function Welcome() {
|
|
| 109 |
|
| 110 |
const submit = (e: FormEvent) => {
|
| 111 |
e.preventDefault();
|
| 112 |
-
void
|
|
|
|
| 113 |
};
|
| 114 |
|
| 115 |
const stepIndex = { connect: 0, indexing: 1, goal: 2, ready: 3 }[step];
|
|
@@ -136,40 +218,93 @@ export default function Welcome() {
|
|
| 136 |
{step === "connect" && (
|
| 137 |
<section className="card welcome-card">
|
| 138 |
<p className="eyebrow">Welcome aboard, {first}</p>
|
| 139 |
-
<h1>Where
|
| 140 |
-
<p className="muted">
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
</p>
|
| 144 |
<form onSubmit={submit} className="welcome-form">
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
</button>
|
| 156 |
</form>
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
{ex}
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
|
|
|
|
|
|
| 165 |
{error && (
|
| 166 |
<p className="error-text" role="alert">
|
| 167 |
{error}
|
| 168 |
</p>
|
| 169 |
)}
|
| 170 |
-
<p className="muted small">
|
| 171 |
-
Writing in markdown or no feed? <Link to="/app/sources">Upload markdown, text, HTML or PDF files, or import single pages</Link> instead.
|
| 172 |
-
</p>
|
| 173 |
</section>
|
| 174 |
)}
|
| 175 |
|
|
@@ -178,10 +313,11 @@ export default function Welcome() {
|
|
| 178 |
<p className="eyebrow">{source?.title ?? "Your archive"}</p>
|
| 179 |
<h1>{indexingFailed ? "That did not land" : "Bringing your posts into orbit"}</h1>
|
| 180 |
{live && <JobProgress job={live} />}
|
|
|
|
| 181 |
{indexingFailed ? (
|
| 182 |
<div className="row">
|
| 183 |
<button className="btn btn-primary" onClick={() => setStep("connect")}>
|
| 184 |
-
Try
|
| 185 |
</button>
|
| 186 |
<Link className="btn" to="/app/sources">
|
| 187 |
Import posts another way
|
|
@@ -200,8 +336,9 @@ export default function Welcome() {
|
|
| 200 |
|
| 201 |
{step === "goal" && (
|
| 202 |
<section className="card welcome-card">
|
| 203 |
-
<p className="eyebrow">{indexingDone ? `${posts} posts indexed` : "Still indexing in the background"}</p>
|
| 204 |
<h1>What should we launch first?</h1>
|
|
|
|
| 205 |
<div className="goal-grid">
|
| 206 |
{GOALS.map((g) => (
|
| 207 |
<button key={g.id} type="button" className={`goal${goal === g.id ? " on" : ""}`} onClick={() => setGoal(g.id)} aria-pressed={goal === g.id}>
|
|
@@ -222,8 +359,9 @@ export default function Welcome() {
|
|
| 222 |
{step === "ready" && (
|
| 223 |
<section className="card welcome-card">
|
| 224 |
<p className="eyebrow">Pre-flight check complete</p>
|
| 225 |
-
<h1>{indexingDone ? `${posts} posts are in orbit` : "Almost there"}</h1>
|
| 226 |
{!indexingDone && live && <JobProgress job={live} />}
|
|
|
|
| 227 |
<p className="muted">
|
| 228 |
{goal === "ask" && "We will put your whole archive in a notebook so you can start asking right away."}
|
| 229 |
{goal === "audio" && "We will put your archive in a notebook with the audio studio ready."}
|
|
|
|
| 1 |
import { useEffect, useRef, useState, type FormEvent } from "react";
|
| 2 |
import { Link, Navigate, useNavigate } from "react-router-dom";
|
| 3 |
+
import { docsApi, jobsApi, notebooksApi, sourcesApi } from "../api/endpoints";
|
| 4 |
import type { Job, Source } from "../api/types";
|
| 5 |
import Logo from "../components/Logo";
|
| 6 |
import SkyCanvas from "../components/SkyCanvas";
|
| 7 |
+
import { errorMessage, JobProgress, Tabs } from "../components/ui";
|
| 8 |
import { useAuth } from "../hooks/useAuth";
|
| 9 |
import { useJob } from "../hooks/useJob";
|
| 10 |
import { PENDING_SOURCE_KEY } from "./Landing";
|
| 11 |
|
| 12 |
type Step = "connect" | "indexing" | "goal" | "ready";
|
| 13 |
type Goal = "ask" | "audio" | "launch";
|
| 14 |
+
type Mode = "feed" | "url" | "file";
|
| 15 |
+
|
| 16 |
+
const MODES: { id: Mode; label: string }[] = [
|
| 17 |
+
{ id: "feed", label: "Blog or newsletter" },
|
| 18 |
+
{ id: "url", label: "One article" },
|
| 19 |
+
{ id: "file", label: "Upload files" },
|
| 20 |
+
];
|
| 21 |
+
|
| 22 |
+
const FILE_ACCEPT = ".md,.markdown,.txt,.html,.htm,.pdf,text/markdown,text/plain,text/html,application/pdf";
|
| 23 |
|
| 24 |
const EXAMPLES = ["yourblog.com", "yourname.substack.com", "medium.com/@you"];
|
| 25 |
|
|
|
|
| 49 |
}
|
| 50 |
}
|
| 51 |
|
| 52 |
+
/** Resolves once every job has finished, so a multi-file upload only counts as indexed when all files are in. */
|
| 53 |
+
async function waitForJobs(ids: string[]): Promise<void> {
|
| 54 |
+
let pending = ids;
|
| 55 |
+
while (pending.length) {
|
| 56 |
+
const jobs = await Promise.all(pending.map((id) => jobsApi.get(id).catch(() => null)));
|
| 57 |
+
pending = pending.filter((_, i) => jobs[i] && jobs[i]!.status !== "done" && jobs[i]!.status !== "failed");
|
| 58 |
+
if (pending.length) await new Promise((r) => setTimeout(r, 1500));
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/** First run: connect a blog, import an article or upload files, watch it index, pick what to do first, land somewhere useful. */
|
| 63 |
export default function Welcome() {
|
| 64 |
const { user, loading } = useAuth();
|
| 65 |
const navigate = useNavigate();
|
| 66 |
const [step, setStep] = useState<Step>("connect");
|
| 67 |
+
const [mode, setMode] = useState<Mode>("feed");
|
| 68 |
+
const [files, setFiles] = useState<File[]>([]);
|
| 69 |
+
const [dragging, setDragging] = useState(false);
|
| 70 |
+
const earlierJobs = useRef<string[]>([]);
|
| 71 |
const [url, setUrl] = useState(takePending);
|
| 72 |
const [busy, setBusy] = useState(false);
|
| 73 |
const [error, setError] = useState<string | null>(null);
|
|
|
|
| 79 |
|
| 80 |
const live = useJob(job, async (j) => {
|
| 81 |
if (j.status !== "done") return;
|
| 82 |
+
await waitForJobs(earlierJobs.current);
|
| 83 |
const list = await docsApi.list({ limit: 1 });
|
| 84 |
setPosts(list.total);
|
| 85 |
setStep((s) => (s === "indexing" ? "goal" : s === "goal" ? "goal" : "ready"));
|
| 86 |
});
|
| 87 |
|
| 88 |
+
const connect = async (value: string, as: Mode = "feed") => {
|
| 89 |
if (!value.trim()) return;
|
| 90 |
setBusy(true);
|
| 91 |
setError(null);
|
| 92 |
try {
|
| 93 |
+
const res = as === "url" ? await sourcesApi.importUrl(value.trim()) : await sourcesApi.connect(value.trim());
|
| 94 |
+
earlierJobs.current = [];
|
| 95 |
setSource(res.source);
|
| 96 |
setJob(res.job);
|
| 97 |
setStep("indexing");
|
| 98 |
} catch (e) {
|
| 99 |
+
setError(errorMessage(e, as === "url" ? "We could not import that page." : "We could not reach that address."));
|
| 100 |
+
} finally {
|
| 101 |
+
setBusy(false);
|
| 102 |
+
}
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
const upload = async () => {
|
| 106 |
+
if (!files.length) return;
|
| 107 |
+
setBusy(true);
|
| 108 |
+
setError(null);
|
| 109 |
+
try {
|
| 110 |
+
// One import job per file: the last drives the progress bar, the rest are awaited before moving on.
|
| 111 |
+
const jobs: Job[] = [];
|
| 112 |
+
let res: Awaited<ReturnType<typeof sourcesApi.importFile>> | null = null;
|
| 113 |
+
for (const f of files) {
|
| 114 |
+
res = await sourcesApi.importFile(f);
|
| 115 |
+
jobs.push(res.job);
|
| 116 |
+
}
|
| 117 |
+
earlierJobs.current = jobs.slice(0, -1).map((j) => j.id);
|
| 118 |
+
setSource(res!.source);
|
| 119 |
+
setJob(res!.job);
|
| 120 |
+
setStep("indexing");
|
| 121 |
+
} catch (e) {
|
| 122 |
+
setError(errorMessage(e, "That upload did not go through."));
|
| 123 |
} finally {
|
| 124 |
setBusy(false);
|
| 125 |
}
|
| 126 |
};
|
| 127 |
|
| 128 |
+
const addFiles = (list: FileList | null) => {
|
| 129 |
+
const picked = Array.from(list ?? []);
|
| 130 |
+
if (picked.length) setFiles((cur) => [...cur, ...picked.filter((f) => !cur.some((c) => c.name === f.name && c.size === f.size))]);
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
// A URL pasted on the landing page starts connecting straight away.
|
| 134 |
useEffect(() => {
|
| 135 |
if (url && !autoStarted.current && user) {
|
|
|
|
| 144 |
|
| 145 |
const indexingDone = live?.status === "done";
|
| 146 |
const indexingFailed = live?.status === "failed";
|
| 147 |
+
// The ingest job lists the whole archive but indexes only the plan's limit; the rest waits behind an upgrade.
|
| 148 |
+
const locked = Number(live?.result?.locked ?? 0);
|
| 149 |
+
const capped = indexingDone && locked > 0;
|
| 150 |
+
const available = Number(live?.result?.available ?? posts);
|
| 151 |
+
const found = Number(live?.result?.found ?? posts);
|
| 152 |
+
const lockedNotice = capped ? (
|
| 153 |
+
<div className="welcome-cap">
|
| 154 |
+
<div>
|
| 155 |
+
<strong>
|
| 156 |
+
We found {found} posts. The free plan indexed only your latest {available}.
|
| 157 |
+
</strong>
|
| 158 |
+
<p className="muted small">
|
| 159 |
+
The other {locked} are sitting in your archive, unread. Until you upgrade, answers, audio overviews and Launch
|
| 160 |
+
Kits can only use {available} posts. Upgrade and all {found} are indexed automatically.
|
| 161 |
+
</p>
|
| 162 |
+
</div>
|
| 163 |
+
<button
|
| 164 |
+
type="button"
|
| 165 |
+
className="btn btn-small"
|
| 166 |
+
onClick={() => {
|
| 167 |
+
markOnboarded();
|
| 168 |
+
navigate("/app?upgrade=1", { replace: true });
|
| 169 |
+
}}
|
| 170 |
+
>
|
| 171 |
+
Index all {found} posts
|
| 172 |
+
</button>
|
| 173 |
+
</div>
|
| 174 |
+
) : null;
|
| 175 |
const first = (user.name ?? user.email).split(/[ @]/)[0];
|
| 176 |
|
| 177 |
const finish = async (chosen: Goal | null) => {
|
|
|
|
| 190 |
|
| 191 |
const submit = (e: FormEvent) => {
|
| 192 |
e.preventDefault();
|
| 193 |
+
if (mode === "file") void upload();
|
| 194 |
+
else void connect(url, mode);
|
| 195 |
};
|
| 196 |
|
| 197 |
const stepIndex = { connect: 0, indexing: 1, goal: 2, ready: 3 }[step];
|
|
|
|
| 218 |
{step === "connect" && (
|
| 219 |
<section className="card welcome-card">
|
| 220 |
<p className="eyebrow">Welcome aboard, {first}</p>
|
| 221 |
+
<h1>Where does your writing live?</h1>
|
| 222 |
+
<p className="muted">Bring it in however suits you. We turn it into a research notebook you can question, hear and launch from.</p>
|
| 223 |
+
<Tabs<Mode>
|
| 224 |
+
tabs={MODES}
|
| 225 |
+
value={mode}
|
| 226 |
+
onChange={(m) => {
|
| 227 |
+
setMode(m);
|
| 228 |
+
setError(null);
|
| 229 |
+
}}
|
| 230 |
+
/>
|
| 231 |
+
<p className="muted small">
|
| 232 |
+
{mode === "feed" && "Substack, Ghost, WordPress, Medium or anything with a feed. We find the feed, pull in your archive and keep it in sync."}
|
| 233 |
+
{mode === "url" && "Any single article on the web. Add more later from Sources."}
|
| 234 |
+
{mode === "file" && "Markdown, text, HTML or PDF: drafts, exports and posts that never had a feed. Add as many as you like."}
|
| 235 |
</p>
|
| 236 |
<form onSubmit={submit} className="welcome-form">
|
| 237 |
+
{mode === "file" ? (
|
| 238 |
+
<label
|
| 239 |
+
className={`welcome-drop${dragging ? " over" : ""}`}
|
| 240 |
+
onDragOver={(e) => {
|
| 241 |
+
e.preventDefault();
|
| 242 |
+
setDragging(true);
|
| 243 |
+
}}
|
| 244 |
+
onDragLeave={() => setDragging(false)}
|
| 245 |
+
onDrop={(e) => {
|
| 246 |
+
e.preventDefault();
|
| 247 |
+
setDragging(false);
|
| 248 |
+
addFiles(e.dataTransfer.files);
|
| 249 |
+
}}
|
| 250 |
+
>
|
| 251 |
+
<input
|
| 252 |
+
type="file"
|
| 253 |
+
multiple
|
| 254 |
+
accept={FILE_ACCEPT}
|
| 255 |
+
onChange={(e) => {
|
| 256 |
+
addFiles(e.target.files);
|
| 257 |
+
e.target.value = "";
|
| 258 |
+
}}
|
| 259 |
+
aria-label="Files to upload"
|
| 260 |
+
/>
|
| 261 |
+
<strong>Drop files here or click to choose</strong>
|
| 262 |
+
<span className="mono muted small">.md · .txt · .html · .pdf</span>
|
| 263 |
+
</label>
|
| 264 |
+
) : (
|
| 265 |
+
<input
|
| 266 |
+
key={mode}
|
| 267 |
+
className="input"
|
| 268 |
+
autoFocus
|
| 269 |
+
placeholder={mode === "feed" ? "yourblog.com" : "https://example.com/my-essay"}
|
| 270 |
+
value={url}
|
| 271 |
+
onChange={(e) => setUrl(e.target.value)}
|
| 272 |
+
aria-label={mode === "feed" ? "Blog, newsletter or site address" : "Article URL"}
|
| 273 |
+
/>
|
| 274 |
+
)}
|
| 275 |
+
{mode === "file" && files.length > 0 && (
|
| 276 |
+
<ul className="welcome-files mono small">
|
| 277 |
+
{files.map((f) => (
|
| 278 |
+
<li key={`${f.name}-${f.size}`}>
|
| 279 |
+
<span>{f.name}</span>
|
| 280 |
+
<button type="button" className="link-btn small" onClick={() => setFiles(files.filter((x) => x !== f))} aria-label={`Remove ${f.name}`}>
|
| 281 |
+
Remove
|
| 282 |
+
</button>
|
| 283 |
+
</li>
|
| 284 |
+
))}
|
| 285 |
+
</ul>
|
| 286 |
+
)}
|
| 287 |
+
<button className="btn btn-primary" disabled={busy || (mode === "file" ? !files.length : !url.trim())}>
|
| 288 |
+
{mode === "feed" && (busy ? "Finding your feed..." : "Connect")}
|
| 289 |
+
{mode === "url" && (busy ? "Importing..." : "Import")}
|
| 290 |
+
{mode === "file" && (busy ? "Uploading..." : files.length === 1 ? "Upload 1 file" : `Upload ${files.length || ""} files`.replace(" ", " "))}
|
| 291 |
</button>
|
| 292 |
</form>
|
| 293 |
+
{mode === "feed" && (
|
| 294 |
+
<div className="welcome-examples mono muted">
|
| 295 |
+
Try:{" "}
|
| 296 |
+
{EXAMPLES.map((ex) => (
|
| 297 |
+
<button key={ex} type="button" className="chip" onClick={() => setUrl(ex)}>
|
| 298 |
+
{ex}
|
| 299 |
+
</button>
|
| 300 |
+
))}
|
| 301 |
+
</div>
|
| 302 |
+
)}
|
| 303 |
{error && (
|
| 304 |
<p className="error-text" role="alert">
|
| 305 |
{error}
|
| 306 |
</p>
|
| 307 |
)}
|
|
|
|
|
|
|
|
|
|
| 308 |
</section>
|
| 309 |
)}
|
| 310 |
|
|
|
|
| 313 |
<p className="eyebrow">{source?.title ?? "Your archive"}</p>
|
| 314 |
<h1>{indexingFailed ? "That did not land" : "Bringing your posts into orbit"}</h1>
|
| 315 |
{live && <JobProgress job={live} />}
|
| 316 |
+
{lockedNotice}
|
| 317 |
{indexingFailed ? (
|
| 318 |
<div className="row">
|
| 319 |
<button className="btn btn-primary" onClick={() => setStep("connect")}>
|
| 320 |
+
Try again
|
| 321 |
</button>
|
| 322 |
<Link className="btn" to="/app/sources">
|
| 323 |
Import posts another way
|
|
|
|
| 336 |
|
| 337 |
{step === "goal" && (
|
| 338 |
<section className="card welcome-card">
|
| 339 |
+
<p className="eyebrow">{indexingDone ? (capped ? `${available} of ${found} posts indexed` : `${posts} posts indexed`) : "Still indexing in the background"}</p>
|
| 340 |
<h1>What should we launch first?</h1>
|
| 341 |
+
{lockedNotice}
|
| 342 |
<div className="goal-grid">
|
| 343 |
{GOALS.map((g) => (
|
| 344 |
<button key={g.id} type="button" className={`goal${goal === g.id ? " on" : ""}`} onClick={() => setGoal(g.id)} aria-pressed={goal === g.id}>
|
|
|
|
| 359 |
{step === "ready" && (
|
| 360 |
<section className="card welcome-card">
|
| 361 |
<p className="eyebrow">Pre-flight check complete</p>
|
| 362 |
+
<h1>{indexingDone ? `${capped ? available : posts} posts are in orbit` : "Almost there"}</h1>
|
| 363 |
{!indexingDone && live && <JobProgress job={live} />}
|
| 364 |
+
{lockedNotice}
|
| 365 |
<p className="muted">
|
| 366 |
{goal === "ask" && "We will put your whole archive in a notebook so you can start asking right away."}
|
| 367 |
{goal === "audio" && "We will put your archive in a notebook with the audio studio ready."}
|
frontend/src/styles/app.css
CHANGED
|
@@ -1046,8 +1046,8 @@
|
|
| 1046 |
display: grid;
|
| 1047 |
justify-items: center;
|
| 1048 |
text-align: center;
|
| 1049 |
-
gap:
|
| 1050 |
-
padding:
|
| 1051 |
}
|
| 1052 |
|
| 1053 |
.empty-state.small {
|
|
|
|
| 1046 |
display: grid;
|
| 1047 |
justify-items: center;
|
| 1048 |
text-align: center;
|
| 1049 |
+
gap: 12px;
|
| 1050 |
+
padding: 40px 16px;
|
| 1051 |
}
|
| 1052 |
|
| 1053 |
.empty-state.small {
|
frontend/src/styles/pages.css
CHANGED
|
@@ -1464,6 +1464,68 @@ select.input option {
|
|
| 1464 |
padding: 0 28px;
|
| 1465 |
}
|
| 1466 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1467 |
.welcome-examples {
|
| 1468 |
display: flex;
|
| 1469 |
flex-wrap: wrap;
|
|
@@ -1867,8 +1929,16 @@ select.input option {
|
|
| 1867 |
color: var(--white);
|
| 1868 |
}
|
| 1869 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1870 |
.map-controls .icon-btn.mono {
|
| 1871 |
font-size: 0.65rem;
|
|
|
|
|
|
|
| 1872 |
}
|
| 1873 |
|
| 1874 |
.map-hint {
|
|
@@ -1881,6 +1951,264 @@ select.input option {
|
|
| 1881 |
}
|
| 1882 |
|
| 1883 |
/* Voice page */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1884 |
.host-card {
|
| 1885 |
display: grid;
|
| 1886 |
gap: 10px;
|
|
@@ -2378,3 +2706,631 @@ select.input,
|
|
| 2378 |
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
| 2379 |
}
|
| 2380 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1464 |
padding: 0 28px;
|
| 1465 |
}
|
| 1466 |
|
| 1467 |
+
.welcome-drop {
|
| 1468 |
+
flex: 1 1 100%;
|
| 1469 |
+
display: grid;
|
| 1470 |
+
place-items: center;
|
| 1471 |
+
gap: 4px;
|
| 1472 |
+
padding: 28px 16px;
|
| 1473 |
+
border: 1px dashed var(--line-strong);
|
| 1474 |
+
border-radius: var(--radius-sm);
|
| 1475 |
+
text-align: center;
|
| 1476 |
+
cursor: pointer;
|
| 1477 |
+
transition: border-color 0.2s, background 0.2s;
|
| 1478 |
+
}
|
| 1479 |
+
|
| 1480 |
+
.welcome-drop:hover,
|
| 1481 |
+
.welcome-drop.over,
|
| 1482 |
+
.welcome-drop:focus-within {
|
| 1483 |
+
border-color: var(--blue);
|
| 1484 |
+
background: var(--blue-05);
|
| 1485 |
+
}
|
| 1486 |
+
|
| 1487 |
+
.welcome-drop input {
|
| 1488 |
+
position: absolute;
|
| 1489 |
+
width: 1px;
|
| 1490 |
+
height: 1px;
|
| 1491 |
+
opacity: 0;
|
| 1492 |
+
}
|
| 1493 |
+
|
| 1494 |
+
.welcome-files {
|
| 1495 |
+
flex: 1 1 100%;
|
| 1496 |
+
list-style: none;
|
| 1497 |
+
margin: 0;
|
| 1498 |
+
padding: 0;
|
| 1499 |
+
display: grid;
|
| 1500 |
+
gap: 4px;
|
| 1501 |
+
}
|
| 1502 |
+
|
| 1503 |
+
.welcome-files li {
|
| 1504 |
+
display: flex;
|
| 1505 |
+
justify-content: space-between;
|
| 1506 |
+
gap: 12px;
|
| 1507 |
+
padding: 6px 10px;
|
| 1508 |
+
border: 1px solid var(--line);
|
| 1509 |
+
border-radius: var(--radius-sm);
|
| 1510 |
+
overflow-wrap: anywhere;
|
| 1511 |
+
}
|
| 1512 |
+
|
| 1513 |
+
.welcome-cap {
|
| 1514 |
+
display: flex;
|
| 1515 |
+
flex-wrap: wrap;
|
| 1516 |
+
align-items: center;
|
| 1517 |
+
justify-content: space-between;
|
| 1518 |
+
gap: 12px;
|
| 1519 |
+
padding: 14px 16px;
|
| 1520 |
+
border: 1px solid var(--blue-35);
|
| 1521 |
+
border-radius: var(--radius-sm);
|
| 1522 |
+
background: var(--blue-05);
|
| 1523 |
+
}
|
| 1524 |
+
|
| 1525 |
+
.welcome-cap p {
|
| 1526 |
+
margin: 4px 0 0;
|
| 1527 |
+
}
|
| 1528 |
+
|
| 1529 |
.welcome-examples {
|
| 1530 |
display: flex;
|
| 1531 |
flex-wrap: wrap;
|
|
|
|
| 1929 |
color: var(--white);
|
| 1930 |
}
|
| 1931 |
|
| 1932 |
+
.map-controls .icon-btn {
|
| 1933 |
+
padding: 0;
|
| 1934 |
+
line-height: 1;
|
| 1935 |
+
}
|
| 1936 |
+
|
| 1937 |
+
/* Trailing letter-spacing from .mono pushes "1:1" off centre in the round button. */
|
| 1938 |
.map-controls .icon-btn.mono {
|
| 1939 |
font-size: 0.65rem;
|
| 1940 |
+
letter-spacing: 0;
|
| 1941 |
+
font-variant-numeric: tabular-nums;
|
| 1942 |
}
|
| 1943 |
|
| 1944 |
.map-hint {
|
|
|
|
| 1951 |
}
|
| 1952 |
|
| 1953 |
/* Voice page */
|
| 1954 |
+
.vp .vp-card {
|
| 1955 |
+
animation: welcome-in 0.35s ease both;
|
| 1956 |
+
}
|
| 1957 |
+
|
| 1958 |
+
.vp-steps {
|
| 1959 |
+
list-style: none;
|
| 1960 |
+
margin: 0;
|
| 1961 |
+
padding: 0;
|
| 1962 |
+
display: grid;
|
| 1963 |
+
grid-template-columns: repeat(2, 1fr);
|
| 1964 |
+
gap: 8px;
|
| 1965 |
+
font-size: 0.75rem;
|
| 1966 |
+
}
|
| 1967 |
+
|
| 1968 |
+
.vp-steps button {
|
| 1969 |
+
display: grid;
|
| 1970 |
+
gap: 8px;
|
| 1971 |
+
width: 100%;
|
| 1972 |
+
padding: 0;
|
| 1973 |
+
border: 0;
|
| 1974 |
+
background: none;
|
| 1975 |
+
color: var(--text-3);
|
| 1976 |
+
font: inherit;
|
| 1977 |
+
text-align: left;
|
| 1978 |
+
cursor: pointer;
|
| 1979 |
+
}
|
| 1980 |
+
|
| 1981 |
+
.vp-steps li.done button {
|
| 1982 |
+
color: var(--text-2);
|
| 1983 |
+
}
|
| 1984 |
+
|
| 1985 |
+
.vp-steps li.current button {
|
| 1986 |
+
color: var(--white);
|
| 1987 |
+
}
|
| 1988 |
+
|
| 1989 |
+
.vp-dot {
|
| 1990 |
+
height: 3px;
|
| 1991 |
+
border-radius: 999px;
|
| 1992 |
+
background: var(--line-strong);
|
| 1993 |
+
transition: background 0.3s, box-shadow 0.3s;
|
| 1994 |
+
}
|
| 1995 |
+
|
| 1996 |
+
.vp-steps li.done .vp-dot {
|
| 1997 |
+
background: var(--blue-60);
|
| 1998 |
+
}
|
| 1999 |
+
|
| 2000 |
+
.vp-steps li.current .vp-dot {
|
| 2001 |
+
background: var(--blue);
|
| 2002 |
+
box-shadow: var(--glow-sm);
|
| 2003 |
+
}
|
| 2004 |
+
|
| 2005 |
+
.voice-grid {
|
| 2006 |
+
list-style: none;
|
| 2007 |
+
margin: 0;
|
| 2008 |
+
padding: 0;
|
| 2009 |
+
display: grid;
|
| 2010 |
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
| 2011 |
+
gap: 12px;
|
| 2012 |
+
}
|
| 2013 |
+
|
| 2014 |
+
@media (max-width: 720px) {
|
| 2015 |
+
.voice-grid {
|
| 2016 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 2017 |
+
}
|
| 2018 |
+
}
|
| 2019 |
+
|
| 2020 |
+
.voice-tile {
|
| 2021 |
+
position: relative;
|
| 2022 |
+
border: 1px solid var(--line);
|
| 2023 |
+
border-radius: var(--radius);
|
| 2024 |
+
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s, transform 0.2s;
|
| 2025 |
+
}
|
| 2026 |
+
|
| 2027 |
+
.voice-tile:hover {
|
| 2028 |
+
border-color: var(--line-strong);
|
| 2029 |
+
transform: translateY(-2px);
|
| 2030 |
+
}
|
| 2031 |
+
|
| 2032 |
+
.voice-tile.on {
|
| 2033 |
+
border-color: var(--blue);
|
| 2034 |
+
background: var(--blue-05);
|
| 2035 |
+
box-shadow: var(--glow-sm);
|
| 2036 |
+
}
|
| 2037 |
+
|
| 2038 |
+
.voice-pick {
|
| 2039 |
+
width: 100%;
|
| 2040 |
+
display: grid;
|
| 2041 |
+
justify-items: center;
|
| 2042 |
+
gap: 4px;
|
| 2043 |
+
padding: 26px 12px 18px;
|
| 2044 |
+
border: 0;
|
| 2045 |
+
background: none;
|
| 2046 |
+
color: var(--white);
|
| 2047 |
+
font: inherit;
|
| 2048 |
+
text-align: center;
|
| 2049 |
+
cursor: pointer;
|
| 2050 |
+
}
|
| 2051 |
+
|
| 2052 |
+
.voice-pick strong {
|
| 2053 |
+
margin-top: 10px;
|
| 2054 |
+
font-size: 1rem;
|
| 2055 |
+
}
|
| 2056 |
+
|
| 2057 |
+
.voice-pick strong,
|
| 2058 |
+
.voice-pick span.small {
|
| 2059 |
+
max-width: 100%;
|
| 2060 |
+
overflow: hidden;
|
| 2061 |
+
text-overflow: ellipsis;
|
| 2062 |
+
white-space: nowrap;
|
| 2063 |
+
}
|
| 2064 |
+
|
| 2065 |
+
.voice-icon {
|
| 2066 |
+
display: grid;
|
| 2067 |
+
place-items: center;
|
| 2068 |
+
width: 76px;
|
| 2069 |
+
height: 76px;
|
| 2070 |
+
border-radius: 999px;
|
| 2071 |
+
border: 1px solid var(--blue-35);
|
| 2072 |
+
color: var(--blue);
|
| 2073 |
+
background: var(--blue-05);
|
| 2074 |
+
transition: background 0.2s, color 0.2s;
|
| 2075 |
+
}
|
| 2076 |
+
|
| 2077 |
+
.voice-tile.on .voice-icon {
|
| 2078 |
+
background: var(--blue);
|
| 2079 |
+
border-color: var(--blue);
|
| 2080 |
+
color: var(--white);
|
| 2081 |
+
box-shadow: var(--glow-sm);
|
| 2082 |
+
}
|
| 2083 |
+
|
| 2084 |
+
.voice-badge {
|
| 2085 |
+
position: absolute;
|
| 2086 |
+
top: 10px;
|
| 2087 |
+
left: 10px;
|
| 2088 |
+
padding: 1px 7px;
|
| 2089 |
+
border-radius: 999px;
|
| 2090 |
+
background: var(--blue);
|
| 2091 |
+
color: var(--white);
|
| 2092 |
+
font-size: 0.625rem;
|
| 2093 |
+
letter-spacing: 0.04em;
|
| 2094 |
+
pointer-events: none;
|
| 2095 |
+
}
|
| 2096 |
+
|
| 2097 |
+
.voice-badge.dim {
|
| 2098 |
+
background: var(--line-strong);
|
| 2099 |
+
color: var(--text-2);
|
| 2100 |
+
}
|
| 2101 |
+
|
| 2102 |
+
.voice-play {
|
| 2103 |
+
position: absolute;
|
| 2104 |
+
top: 8px;
|
| 2105 |
+
right: 8px;
|
| 2106 |
+
padding: 0;
|
| 2107 |
+
border-color: var(--line-strong);
|
| 2108 |
+
}
|
| 2109 |
+
|
| 2110 |
+
.voice-new {
|
| 2111 |
+
border-style: dashed;
|
| 2112 |
+
border-color: var(--line-strong);
|
| 2113 |
+
}
|
| 2114 |
+
|
| 2115 |
+
.voice-new:hover {
|
| 2116 |
+
border-color: var(--blue-60);
|
| 2117 |
+
}
|
| 2118 |
+
|
| 2119 |
+
.voice-icon.plus {
|
| 2120 |
+
border-style: dashed;
|
| 2121 |
+
background: transparent;
|
| 2122 |
+
}
|
| 2123 |
+
|
| 2124 |
+
.voice-new:hover .voice-icon.plus {
|
| 2125 |
+
color: var(--white);
|
| 2126 |
+
border-color: var(--blue);
|
| 2127 |
+
}
|
| 2128 |
+
|
| 2129 |
+
.gen-options {
|
| 2130 |
+
display: grid;
|
| 2131 |
+
gap: 12px;
|
| 2132 |
+
}
|
| 2133 |
+
|
| 2134 |
+
.gen-row {
|
| 2135 |
+
display: grid;
|
| 2136 |
+
gap: 6px;
|
| 2137 |
+
}
|
| 2138 |
+
|
| 2139 |
+
.gen-chips {
|
| 2140 |
+
display: flex;
|
| 2141 |
+
flex-wrap: wrap;
|
| 2142 |
+
gap: 6px;
|
| 2143 |
+
}
|
| 2144 |
+
|
| 2145 |
+
.gen-chips .chip {
|
| 2146 |
+
padding: 5px 12px;
|
| 2147 |
+
background: transparent;
|
| 2148 |
+
color: var(--text-2);
|
| 2149 |
+
font: inherit;
|
| 2150 |
+
font-size: 0.8125rem;
|
| 2151 |
+
cursor: pointer;
|
| 2152 |
+
text-transform: capitalize;
|
| 2153 |
+
}
|
| 2154 |
+
|
| 2155 |
+
.gen-chips .chip:hover {
|
| 2156 |
+
color: var(--white);
|
| 2157 |
+
}
|
| 2158 |
+
|
| 2159 |
+
.gen-chips .chip.on {
|
| 2160 |
+
background: var(--blue);
|
| 2161 |
+
border-color: var(--blue);
|
| 2162 |
+
color: var(--white);
|
| 2163 |
+
}
|
| 2164 |
+
|
| 2165 |
+
.gen-previews {
|
| 2166 |
+
list-style: none;
|
| 2167 |
+
margin: 0;
|
| 2168 |
+
padding: 0;
|
| 2169 |
+
display: grid;
|
| 2170 |
+
gap: 8px;
|
| 2171 |
+
}
|
| 2172 |
+
|
| 2173 |
+
.gen-previews li {
|
| 2174 |
+
display: flex;
|
| 2175 |
+
align-items: center;
|
| 2176 |
+
justify-content: space-between;
|
| 2177 |
+
gap: 12px;
|
| 2178 |
+
padding: 10px 12px;
|
| 2179 |
+
border: 1px solid var(--line);
|
| 2180 |
+
border-radius: var(--radius-sm);
|
| 2181 |
+
}
|
| 2182 |
+
|
| 2183 |
+
.gen-previews li.on {
|
| 2184 |
+
border-color: var(--blue);
|
| 2185 |
+
background: var(--blue-05);
|
| 2186 |
+
}
|
| 2187 |
+
|
| 2188 |
+
.vp-clone {
|
| 2189 |
+
display: grid;
|
| 2190 |
+
gap: 8px;
|
| 2191 |
+
padding: 14px 16px;
|
| 2192 |
+
border: 1px solid var(--line);
|
| 2193 |
+
border-radius: var(--radius-sm);
|
| 2194 |
+
}
|
| 2195 |
+
|
| 2196 |
+
.vp-upgrade {
|
| 2197 |
+
display: flex;
|
| 2198 |
+
flex-wrap: wrap;
|
| 2199 |
+
align-items: center;
|
| 2200 |
+
justify-content: space-between;
|
| 2201 |
+
gap: 12px;
|
| 2202 |
+
padding: 16px;
|
| 2203 |
+
border: 1px solid var(--blue-35);
|
| 2204 |
+
border-radius: var(--radius-sm);
|
| 2205 |
+
background: var(--blue-05);
|
| 2206 |
+
}
|
| 2207 |
+
|
| 2208 |
+
.vp-upgrade p {
|
| 2209 |
+
margin: 4px 0 0;
|
| 2210 |
+
}
|
| 2211 |
+
|
| 2212 |
.host-card {
|
| 2213 |
display: grid;
|
| 2214 |
gap: 10px;
|
|
|
|
| 2706 |
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
| 2707 |
}
|
| 2708 |
}
|
| 2709 |
+
|
| 2710 |
+
/* Simplified layouts (2026-09-28): bigger components, fewer steps */
|
| 2711 |
+
|
| 2712 |
+
.clamp-1 {
|
| 2713 |
+
max-width: 100%;
|
| 2714 |
+
overflow: hidden;
|
| 2715 |
+
text-overflow: ellipsis;
|
| 2716 |
+
white-space: nowrap;
|
| 2717 |
+
}
|
| 2718 |
+
|
| 2719 |
+
/* Mission Control */
|
| 2720 |
+
.mc-hero {
|
| 2721 |
+
display: grid;
|
| 2722 |
+
gap: 18px;
|
| 2723 |
+
padding: clamp(18px, 3vw, 28px);
|
| 2724 |
+
}
|
| 2725 |
+
|
| 2726 |
+
.mc-ask {
|
| 2727 |
+
display: flex;
|
| 2728 |
+
align-items: center;
|
| 2729 |
+
gap: 12px;
|
| 2730 |
+
color: var(--blue);
|
| 2731 |
+
}
|
| 2732 |
+
|
| 2733 |
+
.mc-ask .input {
|
| 2734 |
+
flex: 1;
|
| 2735 |
+
min-width: 0;
|
| 2736 |
+
height: 54px;
|
| 2737 |
+
font-size: 1.05rem;
|
| 2738 |
+
}
|
| 2739 |
+
|
| 2740 |
+
.mc-ask .btn {
|
| 2741 |
+
height: 54px;
|
| 2742 |
+
padding: 0 28px;
|
| 2743 |
+
}
|
| 2744 |
+
|
| 2745 |
+
.mc-actions {
|
| 2746 |
+
display: grid;
|
| 2747 |
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
| 2748 |
+
gap: 12px;
|
| 2749 |
+
}
|
| 2750 |
+
|
| 2751 |
+
@media (max-width: 900px) {
|
| 2752 |
+
.mc-actions {
|
| 2753 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 2754 |
+
}
|
| 2755 |
+
}
|
| 2756 |
+
|
| 2757 |
+
.mc-action {
|
| 2758 |
+
display: grid;
|
| 2759 |
+
justify-items: start;
|
| 2760 |
+
align-content: start;
|
| 2761 |
+
gap: 6px;
|
| 2762 |
+
min-height: 132px;
|
| 2763 |
+
padding: 18px;
|
| 2764 |
+
border: 1px solid var(--line);
|
| 2765 |
+
border-radius: var(--radius-sm);
|
| 2766 |
+
background: transparent;
|
| 2767 |
+
color: var(--white);
|
| 2768 |
+
font: inherit;
|
| 2769 |
+
text-align: left;
|
| 2770 |
+
text-decoration: none;
|
| 2771 |
+
cursor: pointer;
|
| 2772 |
+
transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
|
| 2773 |
+
}
|
| 2774 |
+
|
| 2775 |
+
.mc-action svg {
|
| 2776 |
+
color: var(--blue);
|
| 2777 |
+
margin-bottom: 6px;
|
| 2778 |
+
}
|
| 2779 |
+
|
| 2780 |
+
.mc-action:hover:not(:disabled) {
|
| 2781 |
+
border-color: var(--blue-60);
|
| 2782 |
+
box-shadow: var(--glow-sm);
|
| 2783 |
+
transform: translateY(-2px);
|
| 2784 |
+
}
|
| 2785 |
+
|
| 2786 |
+
.mc-action:disabled {
|
| 2787 |
+
opacity: 0.5;
|
| 2788 |
+
cursor: default;
|
| 2789 |
+
}
|
| 2790 |
+
|
| 2791 |
+
.mc-more {
|
| 2792 |
+
display: grid;
|
| 2793 |
+
place-items: center;
|
| 2794 |
+
width: 28px;
|
| 2795 |
+
height: 28px;
|
| 2796 |
+
margin-bottom: 6px;
|
| 2797 |
+
border: 1px dashed var(--blue-60);
|
| 2798 |
+
border-radius: 999px;
|
| 2799 |
+
color: var(--blue);
|
| 2800 |
+
font-size: 1.1rem;
|
| 2801 |
+
line-height: 1;
|
| 2802 |
+
}
|
| 2803 |
+
|
| 2804 |
+
.mc-next {
|
| 2805 |
+
display: flex;
|
| 2806 |
+
flex-wrap: wrap;
|
| 2807 |
+
align-items: center;
|
| 2808 |
+
justify-content: space-between;
|
| 2809 |
+
gap: 12px;
|
| 2810 |
+
padding: 14px 18px;
|
| 2811 |
+
border: 1px solid var(--blue-35);
|
| 2812 |
+
border-radius: var(--radius-sm);
|
| 2813 |
+
background: var(--blue-05);
|
| 2814 |
+
}
|
| 2815 |
+
|
| 2816 |
+
.mc-next p {
|
| 2817 |
+
margin: 0;
|
| 2818 |
+
flex: 1 1 320px;
|
| 2819 |
+
}
|
| 2820 |
+
|
| 2821 |
+
.mc-stats {
|
| 2822 |
+
display: grid;
|
| 2823 |
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
| 2824 |
+
gap: 12px;
|
| 2825 |
+
}
|
| 2826 |
+
|
| 2827 |
+
@media (max-width: 720px) {
|
| 2828 |
+
.mc-stats {
|
| 2829 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 2830 |
+
}
|
| 2831 |
+
}
|
| 2832 |
+
|
| 2833 |
+
.mc-stat {
|
| 2834 |
+
display: grid;
|
| 2835 |
+
gap: 2px;
|
| 2836 |
+
padding: 14px 18px;
|
| 2837 |
+
border: 1px solid var(--line);
|
| 2838 |
+
border-radius: var(--radius-sm);
|
| 2839 |
+
color: var(--white);
|
| 2840 |
+
text-decoration: none;
|
| 2841 |
+
transition: border-color 0.2s;
|
| 2842 |
+
}
|
| 2843 |
+
|
| 2844 |
+
.mc-stat:hover {
|
| 2845 |
+
border-color: var(--line-strong);
|
| 2846 |
+
}
|
| 2847 |
+
|
| 2848 |
+
.mc-stat strong {
|
| 2849 |
+
font: 600 1.6rem var(--font-display, inherit);
|
| 2850 |
+
}
|
| 2851 |
+
|
| 2852 |
+
/* Notebook view: chat takes the room, one side panel with Create / Posts tabs */
|
| 2853 |
+
@media (min-width: 1100px) {
|
| 2854 |
+
.nbv {
|
| 2855 |
+
grid-template-columns: minmax(0, 1fr) minmax(340px, 420px);
|
| 2856 |
+
}
|
| 2857 |
+
}
|
| 2858 |
+
|
| 2859 |
+
.nbv-titlebar {
|
| 2860 |
+
display: flex;
|
| 2861 |
+
align-items: baseline;
|
| 2862 |
+
gap: 8px;
|
| 2863 |
+
min-width: 0;
|
| 2864 |
+
}
|
| 2865 |
+
|
| 2866 |
+
.nbv-titlebar .nbv-title {
|
| 2867 |
+
margin: 0;
|
| 2868 |
+
overflow: hidden;
|
| 2869 |
+
text-overflow: ellipsis;
|
| 2870 |
+
white-space: nowrap;
|
| 2871 |
+
}
|
| 2872 |
+
|
| 2873 |
+
.nbv-empty {
|
| 2874 |
+
display: grid;
|
| 2875 |
+
justify-items: center;
|
| 2876 |
+
gap: 12px;
|
| 2877 |
+
padding: 48px 16px;
|
| 2878 |
+
text-align: center;
|
| 2879 |
+
color: var(--blue);
|
| 2880 |
+
}
|
| 2881 |
+
|
| 2882 |
+
.nbv-starters {
|
| 2883 |
+
display: grid;
|
| 2884 |
+
gap: 8px;
|
| 2885 |
+
width: min(100%, 520px);
|
| 2886 |
+
}
|
| 2887 |
+
|
| 2888 |
+
.nbv-starter {
|
| 2889 |
+
padding: 12px 16px;
|
| 2890 |
+
border: 1px solid var(--line-strong);
|
| 2891 |
+
border-radius: var(--radius-sm);
|
| 2892 |
+
background: transparent;
|
| 2893 |
+
color: var(--text);
|
| 2894 |
+
font: inherit;
|
| 2895 |
+
text-align: left;
|
| 2896 |
+
cursor: pointer;
|
| 2897 |
+
transition: border-color 0.2s, background 0.2s;
|
| 2898 |
+
}
|
| 2899 |
+
|
| 2900 |
+
.nbv-starter:hover {
|
| 2901 |
+
border-color: var(--blue-60);
|
| 2902 |
+
background: var(--blue-05);
|
| 2903 |
+
}
|
| 2904 |
+
|
| 2905 |
+
.nbv-ask .input {
|
| 2906 |
+
height: 52px;
|
| 2907 |
+
font-size: 1rem;
|
| 2908 |
+
}
|
| 2909 |
+
|
| 2910 |
+
.nbv-ask .btn {
|
| 2911 |
+
height: 52px;
|
| 2912 |
+
padding: 0 24px;
|
| 2913 |
+
}
|
| 2914 |
+
|
| 2915 |
+
.nbv-make {
|
| 2916 |
+
display: grid;
|
| 2917 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 2918 |
+
gap: 8px;
|
| 2919 |
+
}
|
| 2920 |
+
|
| 2921 |
+
.nbv-make button {
|
| 2922 |
+
padding: 12px;
|
| 2923 |
+
border: 1px solid var(--line);
|
| 2924 |
+
border-radius: var(--radius-sm);
|
| 2925 |
+
background: transparent;
|
| 2926 |
+
color: var(--white);
|
| 2927 |
+
font: 500 0.875rem var(--font-body);
|
| 2928 |
+
text-align: left;
|
| 2929 |
+
cursor: pointer;
|
| 2930 |
+
transition: border-color 0.2s, box-shadow 0.2s;
|
| 2931 |
+
}
|
| 2932 |
+
|
| 2933 |
+
.nbv-make button:hover:not(:disabled) {
|
| 2934 |
+
border-color: var(--blue-60);
|
| 2935 |
+
}
|
| 2936 |
+
|
| 2937 |
+
.nbv-make button:disabled {
|
| 2938 |
+
opacity: 0.5;
|
| 2939 |
+
cursor: default;
|
| 2940 |
+
}
|
| 2941 |
+
|
| 2942 |
+
.nbv-make .nbv-make-main {
|
| 2943 |
+
grid-column: 1 / -1;
|
| 2944 |
+
display: grid;
|
| 2945 |
+
gap: 2px;
|
| 2946 |
+
padding: 16px;
|
| 2947 |
+
border-color: var(--blue-35);
|
| 2948 |
+
background: var(--blue-05);
|
| 2949 |
+
}
|
| 2950 |
+
|
| 2951 |
+
.nbv-make .nbv-make-main strong {
|
| 2952 |
+
font-size: 1rem;
|
| 2953 |
+
}
|
| 2954 |
+
|
| 2955 |
+
/* Notebooks: big clickable cards, options in a menu */
|
| 2956 |
+
.nb-grid {
|
| 2957 |
+
display: grid;
|
| 2958 |
+
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
| 2959 |
+
gap: 14px;
|
| 2960 |
+
}
|
| 2961 |
+
|
| 2962 |
+
.nb-grid .nb-card {
|
| 2963 |
+
position: relative;
|
| 2964 |
+
min-height: 170px;
|
| 2965 |
+
}
|
| 2966 |
+
|
| 2967 |
+
.nb-grid .nb-card-link {
|
| 2968 |
+
height: 100%;
|
| 2969 |
+
align-content: start;
|
| 2970 |
+
color: var(--white);
|
| 2971 |
+
}
|
| 2972 |
+
|
| 2973 |
+
.nb-card-link svg {
|
| 2974 |
+
color: var(--blue);
|
| 2975 |
+
}
|
| 2976 |
+
|
| 2977 |
+
.nb-archive {
|
| 2978 |
+
border-color: var(--blue-35);
|
| 2979 |
+
background: var(--blue-05);
|
| 2980 |
+
}
|
| 2981 |
+
|
| 2982 |
+
.nb-new {
|
| 2983 |
+
display: grid;
|
| 2984 |
+
justify-items: start;
|
| 2985 |
+
align-content: center;
|
| 2986 |
+
gap: 4px;
|
| 2987 |
+
border-style: dashed;
|
| 2988 |
+
border-color: var(--line-strong);
|
| 2989 |
+
color: var(--white);
|
| 2990 |
+
font: inherit;
|
| 2991 |
+
text-align: left;
|
| 2992 |
+
cursor: pointer;
|
| 2993 |
+
}
|
| 2994 |
+
|
| 2995 |
+
.nb-new:hover {
|
| 2996 |
+
border-color: var(--blue-60);
|
| 2997 |
+
}
|
| 2998 |
+
|
| 2999 |
+
.card-menu {
|
| 3000 |
+
position: absolute;
|
| 3001 |
+
top: 10px;
|
| 3002 |
+
right: 10px;
|
| 3003 |
+
}
|
| 3004 |
+
|
| 3005 |
+
.card-menu-list {
|
| 3006 |
+
position: absolute;
|
| 3007 |
+
top: 34px;
|
| 3008 |
+
right: 0;
|
| 3009 |
+
z-index: 5;
|
| 3010 |
+
display: grid;
|
| 3011 |
+
min-width: 140px;
|
| 3012 |
+
padding: 6px;
|
| 3013 |
+
border: 1px solid var(--line-strong);
|
| 3014 |
+
border-radius: var(--radius-sm);
|
| 3015 |
+
background: var(--black, #000);
|
| 3016 |
+
box-shadow: var(--glow-sm);
|
| 3017 |
+
}
|
| 3018 |
+
|
| 3019 |
+
.card-menu-list button {
|
| 3020 |
+
padding: 8px 10px;
|
| 3021 |
+
border: 0;
|
| 3022 |
+
border-radius: 6px;
|
| 3023 |
+
background: none;
|
| 3024 |
+
color: var(--text);
|
| 3025 |
+
font: inherit;
|
| 3026 |
+
text-align: left;
|
| 3027 |
+
cursor: pointer;
|
| 3028 |
+
}
|
| 3029 |
+
|
| 3030 |
+
.card-menu-list button:hover {
|
| 3031 |
+
background: var(--blue-10);
|
| 3032 |
+
}
|
| 3033 |
+
|
| 3034 |
+
.nb-matches {
|
| 3035 |
+
max-height: 320px;
|
| 3036 |
+
overflow: auto;
|
| 3037 |
+
}
|
| 3038 |
+
|
| 3039 |
+
/* Create (Studio): format tiles and one row of choices */
|
| 3040 |
+
.format-grid {
|
| 3041 |
+
display: grid;
|
| 3042 |
+
grid-template-columns: repeat(5, minmax(0, 1fr));
|
| 3043 |
+
gap: 12px;
|
| 3044 |
+
}
|
| 3045 |
+
|
| 3046 |
+
@media (max-width: 900px) {
|
| 3047 |
+
.format-grid {
|
| 3048 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 3049 |
+
}
|
| 3050 |
+
}
|
| 3051 |
+
|
| 3052 |
+
.format-tile {
|
| 3053 |
+
display: grid;
|
| 3054 |
+
justify-items: center;
|
| 3055 |
+
align-content: center;
|
| 3056 |
+
gap: 10px;
|
| 3057 |
+
min-height: 120px;
|
| 3058 |
+
padding: 16px 10px;
|
| 3059 |
+
border: 1px solid var(--line);
|
| 3060 |
+
border-radius: var(--radius-sm);
|
| 3061 |
+
background: transparent;
|
| 3062 |
+
color: var(--white);
|
| 3063 |
+
font: inherit;
|
| 3064 |
+
text-align: center;
|
| 3065 |
+
text-decoration: none;
|
| 3066 |
+
cursor: pointer;
|
| 3067 |
+
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
| 3068 |
+
}
|
| 3069 |
+
|
| 3070 |
+
.format-tile svg {
|
| 3071 |
+
color: var(--blue);
|
| 3072 |
+
}
|
| 3073 |
+
|
| 3074 |
+
.format-tile:hover {
|
| 3075 |
+
border-color: var(--line-strong);
|
| 3076 |
+
}
|
| 3077 |
+
|
| 3078 |
+
.format-tile.on {
|
| 3079 |
+
border-color: var(--blue);
|
| 3080 |
+
background: var(--blue-05);
|
| 3081 |
+
box-shadow: var(--glow-sm);
|
| 3082 |
+
}
|
| 3083 |
+
|
| 3084 |
+
.studio-row {
|
| 3085 |
+
display: flex;
|
| 3086 |
+
flex-wrap: wrap;
|
| 3087 |
+
align-items: flex-end;
|
| 3088 |
+
gap: 12px;
|
| 3089 |
+
}
|
| 3090 |
+
|
| 3091 |
+
.studio-row .field {
|
| 3092 |
+
flex: 1 1 200px;
|
| 3093 |
+
}
|
| 3094 |
+
|
| 3095 |
+
.studio-row .field:first-child {
|
| 3096 |
+
flex: 2 1 280px;
|
| 3097 |
+
}
|
| 3098 |
+
|
| 3099 |
+
.studio-go {
|
| 3100 |
+
height: 44px;
|
| 3101 |
+
padding: 0 26px;
|
| 3102 |
+
}
|
| 3103 |
+
|
| 3104 |
+
/* Launch Kit: post cards to start, a thin rail of kits once one is open */
|
| 3105 |
+
@media (min-width: 1100px) {
|
| 3106 |
+
.kit-layout.kit-open {
|
| 3107 |
+
grid-template-columns: minmax(200px, 260px) minmax(0, 1fr);
|
| 3108 |
+
}
|
| 3109 |
+
}
|
| 3110 |
+
|
| 3111 |
+
.kit-rail {
|
| 3112 |
+
display: grid;
|
| 3113 |
+
gap: 10px;
|
| 3114 |
+
align-content: start;
|
| 3115 |
+
}
|
| 3116 |
+
|
| 3117 |
+
.post-cards {
|
| 3118 |
+
display: grid;
|
| 3119 |
+
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
| 3120 |
+
gap: 12px;
|
| 3121 |
+
}
|
| 3122 |
+
|
| 3123 |
+
.post-card {
|
| 3124 |
+
display: grid;
|
| 3125 |
+
align-content: space-between;
|
| 3126 |
+
gap: 10px;
|
| 3127 |
+
min-height: 150px;
|
| 3128 |
+
padding: 18px;
|
| 3129 |
+
border: 1px solid var(--line);
|
| 3130 |
+
border-radius: var(--radius-sm);
|
| 3131 |
+
background: transparent;
|
| 3132 |
+
color: var(--white);
|
| 3133 |
+
font: inherit;
|
| 3134 |
+
text-align: left;
|
| 3135 |
+
cursor: pointer;
|
| 3136 |
+
transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
|
| 3137 |
+
}
|
| 3138 |
+
|
| 3139 |
+
.post-card strong {
|
| 3140 |
+
font-size: 1.02rem;
|
| 3141 |
+
line-height: 1.35;
|
| 3142 |
+
}
|
| 3143 |
+
|
| 3144 |
+
.post-card-go {
|
| 3145 |
+
color: var(--blue);
|
| 3146 |
+
font-size: 0.8125rem;
|
| 3147 |
+
font-weight: 600;
|
| 3148 |
+
}
|
| 3149 |
+
|
| 3150 |
+
.post-card:hover:not(:disabled) {
|
| 3151 |
+
border-color: var(--blue-60);
|
| 3152 |
+
box-shadow: var(--glow-sm);
|
| 3153 |
+
transform: translateY(-2px);
|
| 3154 |
+
}
|
| 3155 |
+
|
| 3156 |
+
.post-card:disabled {
|
| 3157 |
+
opacity: 0.6;
|
| 3158 |
+
cursor: default;
|
| 3159 |
+
}
|
| 3160 |
+
|
| 3161 |
+
/* Launchpad: account chips in one line, calendar first, ideas beside it */
|
| 3162 |
+
.lp-accounts {
|
| 3163 |
+
display: flex;
|
| 3164 |
+
flex-wrap: wrap;
|
| 3165 |
+
align-items: center;
|
| 3166 |
+
gap: 8px;
|
| 3167 |
+
}
|
| 3168 |
+
|
| 3169 |
+
.lp-chip {
|
| 3170 |
+
display: inline-flex;
|
| 3171 |
+
align-items: center;
|
| 3172 |
+
gap: 6px;
|
| 3173 |
+
padding: 5px 12px;
|
| 3174 |
+
border: 1px solid var(--line-strong);
|
| 3175 |
+
border-radius: 999px;
|
| 3176 |
+
background: transparent;
|
| 3177 |
+
color: var(--white);
|
| 3178 |
+
font: inherit;
|
| 3179 |
+
font-size: 0.8125rem;
|
| 3180 |
+
}
|
| 3181 |
+
|
| 3182 |
+
.lp-chip img {
|
| 3183 |
+
border-radius: 999px;
|
| 3184 |
+
}
|
| 3185 |
+
|
| 3186 |
+
.lp-chip.warn {
|
| 3187 |
+
border-color: #ff8a3d;
|
| 3188 |
+
}
|
| 3189 |
+
|
| 3190 |
+
.lp-chip.add {
|
| 3191 |
+
border-style: dashed;
|
| 3192 |
+
color: var(--text-2);
|
| 3193 |
+
cursor: pointer;
|
| 3194 |
+
}
|
| 3195 |
+
|
| 3196 |
+
.lp-chip.add:hover {
|
| 3197 |
+
border-color: var(--blue);
|
| 3198 |
+
color: var(--white);
|
| 3199 |
+
}
|
| 3200 |
+
|
| 3201 |
+
.lp-layout {
|
| 3202 |
+
display: grid;
|
| 3203 |
+
gap: 16px;
|
| 3204 |
+
align-items: start;
|
| 3205 |
+
}
|
| 3206 |
+
|
| 3207 |
+
@media (min-width: 1200px) {
|
| 3208 |
+
.lp-layout {
|
| 3209 |
+
grid-template-columns: minmax(0, 1fr) minmax(300px, 380px);
|
| 3210 |
+
}
|
| 3211 |
+
}
|
| 3212 |
+
|
| 3213 |
+
.lp-ideas {
|
| 3214 |
+
align-content: start;
|
| 3215 |
+
}
|
| 3216 |
+
|
| 3217 |
+
.lp-ideas .idea {
|
| 3218 |
+
display: grid;
|
| 3219 |
+
gap: 6px;
|
| 3220 |
+
padding-bottom: 12px;
|
| 3221 |
+
border-bottom: 1px solid var(--line);
|
| 3222 |
+
}
|
| 3223 |
+
|
| 3224 |
+
.lp-ideas .idea:last-child {
|
| 3225 |
+
border-bottom: 0;
|
| 3226 |
+
}
|
| 3227 |
+
|
| 3228 |
+
.lp-empty {
|
| 3229 |
+
display: flex;
|
| 3230 |
+
flex-wrap: wrap;
|
| 3231 |
+
align-items: center;
|
| 3232 |
+
justify-content: space-between;
|
| 3233 |
+
gap: 12px;
|
| 3234 |
+
margin-bottom: 12px;
|
| 3235 |
+
padding: 12px 14px;
|
| 3236 |
+
border: 1px dashed var(--line-strong);
|
| 3237 |
+
border-radius: var(--radius-sm);
|
| 3238 |
+
}
|
| 3239 |
+
|
| 3240 |
+
.lp-empty p {
|
| 3241 |
+
margin: 0;
|
| 3242 |
+
}
|
| 3243 |
+
|
| 3244 |
+
/* Topic map: full width until a topic or the briefing is open */
|
| 3245 |
+
@media (min-width: 1100px) {
|
| 3246 |
+
.map-layout.map-full {
|
| 3247 |
+
grid-template-columns: minmax(0, 1fr);
|
| 3248 |
+
}
|
| 3249 |
+
}
|
| 3250 |
+
|
| 3251 |
+
.map-menu {
|
| 3252 |
+
position: relative;
|
| 3253 |
+
top: auto;
|
| 3254 |
+
right: auto;
|
| 3255 |
+
}
|
| 3256 |
+
|
| 3257 |
+
details.map-legend summary {
|
| 3258 |
+
cursor: pointer;
|
| 3259 |
+
list-style: none;
|
| 3260 |
+
}
|
| 3261 |
+
|
| 3262 |
+
details.map-legend:not([open]) {
|
| 3263 |
+
padding: 4px 10px;
|
| 3264 |
+
}
|
| 3265 |
+
|
| 3266 |
+
details.map-legend[open] summary {
|
| 3267 |
+
margin-bottom: 4px;
|
| 3268 |
+
}
|
| 3269 |
+
|
| 3270 |
+
.topic-actions {
|
| 3271 |
+
display: grid;
|
| 3272 |
+
gap: 8px;
|
| 3273 |
+
}
|
| 3274 |
+
|
| 3275 |
+
/* Sources: one smart input, files drop anywhere */
|
| 3276 |
+
.add-smart {
|
| 3277 |
+
display: flex;
|
| 3278 |
+
flex-wrap: wrap;
|
| 3279 |
+
align-items: center;
|
| 3280 |
+
gap: 10px;
|
| 3281 |
+
}
|
| 3282 |
+
|
| 3283 |
+
.add-smart .input {
|
| 3284 |
+
flex: 1 1 320px;
|
| 3285 |
+
height: 50px;
|
| 3286 |
+
font-size: 1rem;
|
| 3287 |
+
}
|
| 3288 |
+
|
| 3289 |
+
.add-smart .btn {
|
| 3290 |
+
height: 50px;
|
| 3291 |
+
padding: 0 22px;
|
| 3292 |
+
}
|
| 3293 |
+
|
| 3294 |
+
.page-wrap.drop-over {
|
| 3295 |
+
outline: 2px dashed var(--blue);
|
| 3296 |
+
outline-offset: 8px;
|
| 3297 |
+
border-radius: var(--radius);
|
| 3298 |
+
}
|
| 3299 |
+
|
| 3300 |
+
/* Sidebar: seven stops, Settings pinned to the bottom */
|
| 3301 |
+
@media (min-width: 900px) {
|
| 3302 |
+
.sidebar .side-settings {
|
| 3303 |
+
margin-top: auto;
|
| 3304 |
+
}
|
| 3305 |
+
|
| 3306 |
+
.sidebar .side-settings ~ .sidebar-user {
|
| 3307 |
+
margin-top: 0;
|
| 3308 |
+
}
|
| 3309 |
+
}
|
| 3310 |
+
|
| 3311 |
+
@media (max-width: 899px) {
|
| 3312 |
+
.sidebar {
|
| 3313 |
+
position: relative;
|
| 3314 |
+
}
|
| 3315 |
+
|
| 3316 |
+
.sidebar .side-settings {
|
| 3317 |
+
position: absolute;
|
| 3318 |
+
top: 14px;
|
| 3319 |
+
right: 16px;
|
| 3320 |
+
padding: 8px;
|
| 3321 |
+
}
|
| 3322 |
+
|
| 3323 |
+
.sidebar .side-settings .side-label {
|
| 3324 |
+
display: none;
|
| 3325 |
+
}
|
| 3326 |
+
}
|
| 3327 |
+
|
| 3328 |
+
@media (min-width: 900px) {
|
| 3329 |
+
.shell.collapsed .side-settings {
|
| 3330 |
+
display: none;
|
| 3331 |
+
}
|
| 3332 |
+
}
|
| 3333 |
+
|
| 3334 |
+
.tabs a.tab {
|
| 3335 |
+
text-decoration: none;
|
| 3336 |
+
}
|
frontend/src/styles/tools.css
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Free tools (/tools/*). Builds on blog.css (.alt-page, .alt-section, .alt-grid). */
|
| 2 |
+
|
| 3 |
+
.tool-card-title {
|
| 4 |
+
font-size: 1.15rem;
|
| 5 |
+
margin: 0 0 0.5rem;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
.tool-gate {
|
| 9 |
+
text-align: center;
|
| 10 |
+
padding: 2rem 1.5rem;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
.tool-gate h2 {
|
| 14 |
+
margin-top: 0;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
.tool-gate .alt-ctas {
|
| 18 |
+
justify-content: center;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
.tool-widget {
|
| 22 |
+
display: grid;
|
| 23 |
+
gap: 1rem;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/* Flat, opaque writing surface: overrides the pill shaped, see-through .input so the starfield does not show
|
| 27 |
+
through and the box edge is obvious. */
|
| 28 |
+
.tool-input {
|
| 29 |
+
display: block;
|
| 30 |
+
width: 100%;
|
| 31 |
+
height: auto;
|
| 32 |
+
min-height: 14rem;
|
| 33 |
+
padding: 14px 16px;
|
| 34 |
+
resize: vertical;
|
| 35 |
+
border-radius: 8px;
|
| 36 |
+
border: 1px solid rgba(255, 255, 255, 0.28);
|
| 37 |
+
background: #0d0f14;
|
| 38 |
+
box-shadow: none;
|
| 39 |
+
color: var(--text);
|
| 40 |
+
font: 400 1rem/1.6 var(--font-body);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.tool-input:hover {
|
| 44 |
+
border-color: rgba(255, 255, 255, 0.4);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.tool-input:focus {
|
| 48 |
+
border-color: var(--blue);
|
| 49 |
+
box-shadow: none;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
.tool-input::placeholder {
|
| 53 |
+
color: var(--text-2);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.tool-output {
|
| 57 |
+
display: grid;
|
| 58 |
+
gap: 1rem;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
.tool-output-head {
|
| 62 |
+
display: flex;
|
| 63 |
+
flex-wrap: wrap;
|
| 64 |
+
gap: 0.75rem;
|
| 65 |
+
align-items: center;
|
| 66 |
+
justify-content: space-between;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
.tool-actions {
|
| 70 |
+
display: flex;
|
| 71 |
+
gap: 0.5rem;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.tool-script {
|
| 75 |
+
list-style: none;
|
| 76 |
+
margin: 0;
|
| 77 |
+
padding: 0;
|
| 78 |
+
display: grid;
|
| 79 |
+
gap: 0.85rem;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.tool-script li {
|
| 83 |
+
display: grid;
|
| 84 |
+
grid-template-columns: 5.5rem 1fr;
|
| 85 |
+
gap: 0.75rem;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.tool-script p {
|
| 89 |
+
margin: 0;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.tool-script .host-B p {
|
| 93 |
+
opacity: 0.8;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.host-label {
|
| 97 |
+
font-size: 0.75rem;
|
| 98 |
+
opacity: 0.7;
|
| 99 |
+
padding-top: 0.2rem;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
.tool-next {
|
| 103 |
+
display: flex;
|
| 104 |
+
flex-wrap: wrap;
|
| 105 |
+
gap: 1rem;
|
| 106 |
+
align-items: center;
|
| 107 |
+
justify-content: space-between;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
@media (max-width: 560px) {
|
| 111 |
+
.tool-script li {
|
| 112 |
+
grid-template-columns: 1fr;
|
| 113 |
+
gap: 0.2rem;
|
| 114 |
+
}
|
| 115 |
+
}
|