Spaces:
Runtime error
Runtime error
Commit ·
3c705c5
1
Parent(s): ba40d17
LETS GOOOOOOOOOOOOOOOOOOOOOOOOO
Browse files- backend/alembic/versions/0004_archive_notebook.py +24 -0
- backend/app/models/content.py +2 -0
- backend/app/routers/artifacts.py +4 -0
- backend/app/routers/notebooks.py +29 -2
- backend/app/routers/voice.py +5 -0
- backend/app/services/billing.py +1 -1
- backend/app/worker.py +15 -3
- backend/tests/test_simplified_flows.py +47 -0
- frontend/src/api/endpoints.ts +4 -0
- frontend/src/api/types.ts +3 -0
- frontend/src/components/AppShell.tsx +20 -16
- frontend/src/pages/Archive.tsx +12 -14
- frontend/src/pages/AuthPage.tsx +1 -4
- frontend/src/pages/LaunchKit.tsx +131 -61
- frontend/src/pages/Launchpad.tsx +49 -47
- frontend/src/pages/MissionControl.tsx +224 -127
- 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 +127 -50
- frontend/src/pages/Studio.tsx +113 -125
- frontend/src/pages/TopicMap.tsx +76 -21
- frontend/src/pages/VoiceProfile.tsx +63 -122
- frontend/src/styles/app.css +2 -2
- frontend/src/styles/pages.css +633 -50
backend/alembic/versions/0004_archive_notebook.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""notebooks.is_archive: the workspace's always-present "All posts" notebook
|
| 2 |
+
|
| 3 |
+
Revision ID: 0004_archive_notebook
|
| 4 |
+
Revises: 0003_billing_events
|
| 5 |
+
Create Date: 2026-09-28
|
| 6 |
+
"""
|
| 7 |
+
import sqlalchemy as sa
|
| 8 |
+
from alembic import op
|
| 9 |
+
|
| 10 |
+
revision = "0004_archive_notebook"
|
| 11 |
+
down_revision = "0003_billing_events"
|
| 12 |
+
branch_labels = None
|
| 13 |
+
depends_on = None
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def upgrade() -> None:
|
| 17 |
+
# 0001 builds from the live models, so a fresh database may already have the column.
|
| 18 |
+
columns = {c["name"] for c in sa.inspect(op.get_bind()).get_columns("notebooks")}
|
| 19 |
+
if "is_archive" not in columns:
|
| 20 |
+
op.add_column("notebooks", sa.Column("is_archive", sa.Boolean(), nullable=False, server_default=sa.false()))
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def downgrade() -> None:
|
| 24 |
+
op.drop_column("notebooks", "is_archive")
|
backend/app/models/content.py
CHANGED
|
@@ -65,6 +65,8 @@ class Notebook(IdMixin, TimestampMixin, Base):
|
|
| 65 |
title: Mapped[str] = mapped_column(String(300))
|
| 66 |
description: Mapped[str | None] = mapped_column(Text)
|
| 67 |
summary: Mapped[str | None] = mapped_column(Text)
|
|
|
|
|
|
|
| 68 |
|
| 69 |
|
| 70 |
class NotebookDocument(Base):
|
|
|
|
| 65 |
title: Mapped[str] = mapped_column(String(300))
|
| 66 |
description: Mapped[str | None] = mapped_column(Text)
|
| 67 |
summary: Mapped[str | None] = mapped_column(Text)
|
| 68 |
+
# The workspace's "All posts" notebook: created on demand, kept in step with every indexed post.
|
| 69 |
+
is_archive: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 70 |
|
| 71 |
|
| 72 |
class NotebookDocument(Base):
|
backend/app/routers/artifacts.py
CHANGED
|
@@ -7,6 +7,7 @@ from sqlalchemy import func, or_, select
|
|
| 7 |
|
| 8 |
from app.auth import Ctx, get_ctx
|
| 9 |
from app.models import Artifact, CalendarItem, Document, Job, Notebook
|
|
|
|
| 10 |
from app.services.artifacts import latest_jobs, serialize_artifact, start_artifact
|
| 11 |
from app.services.jobs import create_job, serialize_job
|
| 12 |
from app.services.renderer import COMPOSITIONS
|
|
@@ -28,6 +29,7 @@ class GenerateIn(BaseModel):
|
|
| 28 |
type: Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit"]
|
| 29 |
notebook_id: uuid.UUID | None = None
|
| 30 |
document_id: uuid.UUID | None = None
|
|
|
|
| 31 |
# audio_overview
|
| 32 |
format: Literal["deep_dive", "brief", "debate"] = "deep_dive"
|
| 33 |
minutes: int = Field(6, ge=1, le=30)
|
|
@@ -75,6 +77,8 @@ def _target_title(ctx: Ctx, notebook_id: uuid.UUID | None, document_id: uuid.UUI
|
|
| 75 |
@router.post("/generate")
|
| 76 |
def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
|
| 77 |
"""One entry point for every generated artifact."""
|
|
|
|
|
|
|
| 78 |
params: dict = {}
|
| 79 |
content: dict = {}
|
| 80 |
if body.type == "video" and body.style == "audiogram":
|
|
|
|
| 7 |
|
| 8 |
from app.auth import Ctx, get_ctx
|
| 9 |
from app.models import Artifact, CalendarItem, Document, Job, Notebook
|
| 10 |
+
from app.routers.notebooks import ensure_archive_notebook
|
| 11 |
from app.services.artifacts import latest_jobs, serialize_artifact, start_artifact
|
| 12 |
from app.services.jobs import create_job, serialize_job
|
| 13 |
from app.services.renderer import COMPOSITIONS
|
|
|
|
| 29 |
type: Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit"]
|
| 30 |
notebook_id: uuid.UUID | None = None
|
| 31 |
document_id: uuid.UUID | None = None
|
| 32 |
+
archive: bool = False # no notebook or post picked: use the "All posts" notebook
|
| 33 |
# audio_overview
|
| 34 |
format: Literal["deep_dive", "brief", "debate"] = "deep_dive"
|
| 35 |
minutes: int = Field(6, ge=1, le=30)
|
|
|
|
| 77 |
@router.post("/generate")
|
| 78 |
def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
|
| 79 |
"""One entry point for every generated artifact."""
|
| 80 |
+
if body.archive and not body.notebook_id and not body.document_id:
|
| 81 |
+
body.notebook_id = ensure_archive_notebook(ctx).id
|
| 82 |
params: dict = {}
|
| 83 |
content: dict = {}
|
| 84 |
if body.type == "video" and body.style == "audiogram":
|
backend/app/routers/notebooks.py
CHANGED
|
@@ -72,6 +72,29 @@ def create_notebook(body: NotebookIn, ctx: Ctx = Depends(get_ctx)):
|
|
| 72 |
return {"id": str(nb.id), "title": nb.title, "added": added}
|
| 73 |
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
@router.post("/from-topic/{topic_id}")
|
| 76 |
def notebook_from_topic(topic_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 77 |
topic = ctx.db.scalar(select(Topic).where(Topic.id == topic_id, Topic.workspace_id == ctx.workspace.id))
|
|
@@ -89,7 +112,8 @@ def notebook_from_topic(topic_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
|
| 89 |
@router.get("")
|
| 90 |
def list_notebooks(ctx: Ctx = Depends(get_ctx)):
|
| 91 |
rows = ctx.db.scalars(
|
| 92 |
-
select(Notebook).where(Notebook.workspace_id == ctx.workspace.id)
|
|
|
|
| 93 |
).all()
|
| 94 |
ids = [n.id for n in rows]
|
| 95 |
doc_counts = dict(ctx.db.execute(
|
|
@@ -105,7 +129,7 @@ def list_notebooks(ctx: Ctx = Depends(get_ctx)):
|
|
| 105 |
return [
|
| 106 |
{"id": str(n.id), "title": n.title, "description": n.description, "summary": n.summary,
|
| 107 |
"document_count": doc_counts.get(n.id, 0), "chat_count": chat_counts.get(n.id, 0),
|
| 108 |
-
"artifact_count": artifact_counts.get(n.id, 0),
|
| 109 |
"updated_at": n.updated_at.isoformat() if n.updated_at else None}
|
| 110 |
for n in rows
|
| 111 |
]
|
|
@@ -114,6 +138,8 @@ def list_notebooks(ctx: Ctx = Depends(get_ctx)):
|
|
| 114 |
@router.get("/{notebook_id}")
|
| 115 |
def get_notebook(notebook_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 116 |
nb = _get(ctx, notebook_id)
|
|
|
|
|
|
|
| 117 |
docs = ctx.db.scalars(
|
| 118 |
select(Document)
|
| 119 |
.join(NotebookDocument, NotebookDocument.document_id == Document.id)
|
|
@@ -125,6 +151,7 @@ def get_notebook(notebook_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
|
| 125 |
"title": nb.title,
|
| 126 |
"description": nb.description,
|
| 127 |
"summary": nb.summary,
|
|
|
|
| 128 |
"documents": [serialize_document(d) for d in docs],
|
| 129 |
}
|
| 130 |
|
|
|
|
| 72 |
return {"id": str(nb.id), "title": nb.title, "added": added}
|
| 73 |
|
| 74 |
|
| 75 |
+
ARCHIVE_TITLE = "All posts"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def ensure_archive_notebook(ctx: Ctx) -> Notebook:
|
| 79 |
+
"""The workspace's "All posts" notebook, topped up with every indexed post (locked posts are skipped)."""
|
| 80 |
+
nb = ctx.db.scalar(select(Notebook).where(Notebook.workspace_id == ctx.workspace.id, Notebook.is_archive.is_(True)))
|
| 81 |
+
if not nb:
|
| 82 |
+
nb = Notebook(workspace_id=ctx.workspace.id, title=ARCHIVE_TITLE, is_archive=True,
|
| 83 |
+
description="Every indexed post in your archive, kept up to date.")
|
| 84 |
+
ctx.db.add(nb)
|
| 85 |
+
ctx.db.flush()
|
| 86 |
+
ids = list(ctx.db.scalars(select(Document.id).where(Document.workspace_id == ctx.workspace.id)))
|
| 87 |
+
_add_docs(ctx, nb, ids)
|
| 88 |
+
ctx.db.commit()
|
| 89 |
+
return nb
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@router.post("/archive")
|
| 93 |
+
def archive_notebook(ctx: Ctx = Depends(get_ctx)):
|
| 94 |
+
nb = ensure_archive_notebook(ctx)
|
| 95 |
+
return {"id": str(nb.id), "title": nb.title}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
@router.post("/from-topic/{topic_id}")
|
| 99 |
def notebook_from_topic(topic_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 100 |
topic = ctx.db.scalar(select(Topic).where(Topic.id == topic_id, Topic.workspace_id == ctx.workspace.id))
|
|
|
|
| 112 |
@router.get("")
|
| 113 |
def list_notebooks(ctx: Ctx = Depends(get_ctx)):
|
| 114 |
rows = ctx.db.scalars(
|
| 115 |
+
select(Notebook).where(Notebook.workspace_id == ctx.workspace.id)
|
| 116 |
+
.order_by(Notebook.is_archive.desc(), Notebook.updated_at.desc())
|
| 117 |
).all()
|
| 118 |
ids = [n.id for n in rows]
|
| 119 |
doc_counts = dict(ctx.db.execute(
|
|
|
|
| 129 |
return [
|
| 130 |
{"id": str(n.id), "title": n.title, "description": n.description, "summary": n.summary,
|
| 131 |
"document_count": doc_counts.get(n.id, 0), "chat_count": chat_counts.get(n.id, 0),
|
| 132 |
+
"artifact_count": artifact_counts.get(n.id, 0), "is_archive": n.is_archive,
|
| 133 |
"updated_at": n.updated_at.isoformat() if n.updated_at else None}
|
| 134 |
for n in rows
|
| 135 |
]
|
|
|
|
| 138 |
@router.get("/{notebook_id}")
|
| 139 |
def get_notebook(notebook_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 140 |
nb = _get(ctx, notebook_id)
|
| 141 |
+
if nb.is_archive: # posts synced since it was made join on open
|
| 142 |
+
ensure_archive_notebook(ctx)
|
| 143 |
docs = ctx.db.scalars(
|
| 144 |
select(Document)
|
| 145 |
.join(NotebookDocument, NotebookDocument.document_id == Document.id)
|
|
|
|
| 151 |
"title": nb.title,
|
| 152 |
"description": nb.description,
|
| 153 |
"summary": nb.summary,
|
| 154 |
+
"is_archive": nb.is_archive,
|
| 155 |
"documents": [serialize_document(d) for d in docs],
|
| 156 |
}
|
| 157 |
|
backend/app/routers/voice.py
CHANGED
|
@@ -165,6 +165,11 @@ def update_voice(body: ProfileIn, ctx: Ctx = Depends(get_ctx)):
|
|
| 165 |
|
| 166 |
@router.post("/build")
|
| 167 |
def build(body: BuildIn, ctx: Ctx = Depends(get_ctx)):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
job = create_job(ctx.db, ctx.workspace.id, "voice_profile",
|
| 169 |
{"document_ids": [str(i) for i in body.document_ids]}, max_attempts=2)
|
| 170 |
return serialize_job(job)
|
|
|
|
| 165 |
|
| 166 |
@router.post("/build")
|
| 167 |
def build(body: BuildIn, ctx: Ctx = Depends(get_ctx)):
|
| 168 |
+
if not body.document_ids: # the automatic build after a sync may already be running; follow that one
|
| 169 |
+
pending = ctx.db.scalar(select(Job).where(Job.workspace_id == ctx.workspace.id, Job.kind == "voice_profile",
|
| 170 |
+
Job.status.in_(("queued", "running"))).limit(1))
|
| 171 |
+
if pending:
|
| 172 |
+
return serialize_job(pending)
|
| 173 |
job = create_job(ctx.db, ctx.workspace.id, "voice_profile",
|
| 174 |
{"document_ids": [str(i) for i in body.document_ids]}, max_attempts=2)
|
| 175 |
return serialize_job(job)
|
backend/app/services/billing.py
CHANGED
|
@@ -112,7 +112,7 @@ def build_nudges(db: Session, workspace: Workspace, plan: Plan, meters: list[dic
|
|
| 112 |
"Open Launchpad", "/app/launchpad", priority=35))
|
| 113 |
out.append(_nudge(f"act-resurface-{month}", "action", "Old posts, new orbit",
|
| 114 |
"Resurfacing finds evergreen posts worth sharing again this month.", "Find evergreen posts",
|
| 115 |
-
"/app/
|
| 116 |
return sorted(out, key=lambda n: -n["priority"])
|
| 117 |
|
| 118 |
|
|
|
|
| 112 |
"Open Launchpad", "/app/launchpad", priority=35))
|
| 113 |
out.append(_nudge(f"act-resurface-{month}", "action", "Old posts, new orbit",
|
| 114 |
"Resurfacing finds evergreen posts worth sharing again this month.", "Find evergreen posts",
|
| 115 |
+
"/app/launchpad#ideas", priority=20))
|
| 116 |
return sorted(out, key=lambda n: -n["priority"])
|
| 117 |
|
| 118 |
|
backend/app/worker.py
CHANGED
|
@@ -67,10 +67,22 @@ HANDED_OFF = object() # the job continues elsewhere (renderer) and reports back
|
|
| 67 |
# Handlers: (db, job) -> Done | HANDED_OFF. Raise to trigger a retry.
|
| 68 |
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
def _after_import(db: Session, job: Job, workspace_id: uuid.UUID, changed: list[str]) -> None:
|
| 71 |
-
"""New or changed posts
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
|
| 76 |
def handle_ingest(db: Session, job: Job):
|
|
|
|
| 67 |
# Handlers: (db, job) -> Done | HANDED_OFF. Raise to trigger a retry.
|
| 68 |
|
| 69 |
|
| 70 |
+
def _pending(db: Session, workspace_id: uuid.UUID, kind: str) -> bool:
|
| 71 |
+
return db.scalar(select(Job.id).where(Job.workspace_id == workspace_id, Job.kind == kind,
|
| 72 |
+
Job.status.in_(("queued", "running"))).limit(1)) is not None
|
| 73 |
+
|
| 74 |
+
|
| 75 |
def _after_import(db: Session, job: Job, workspace_id: uuid.UUID, changed: list[str]) -> None:
|
| 76 |
+
"""New or changed posts are mapped into the topic constellation and scored for resurfacing, and the
|
| 77 |
+
first import builds the writing voice, so none of these need a button press."""
|
| 78 |
+
if not changed or not settings.llm_api_key:
|
| 79 |
+
return
|
| 80 |
+
create_job(db, workspace_id, "topics", {"document_ids": changed}, max_attempts=2)
|
| 81 |
+
if not _pending(db, workspace_id, "resurface_scan"):
|
| 82 |
+
create_job(db, workspace_id, "resurface_scan", {}, max_attempts=2)
|
| 83 |
+
vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == workspace_id))
|
| 84 |
+
if not (vp and vp.profile_json) and not _pending(db, workspace_id, "voice_profile"):
|
| 85 |
+
create_job(db, workspace_id, "voice_profile", {"document_ids": []}, max_attempts=2)
|
| 86 |
|
| 87 |
|
| 88 |
def handle_ingest(db: Session, job: Job):
|
backend/tests/test_simplified_flows.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""One-click flows: the "All posts" notebook, whole-archive generation, and jobs that run after a sync."""
|
| 2 |
+
|
| 3 |
+
from sqlalchemy import select
|
| 4 |
+
|
| 5 |
+
from app.config import settings
|
| 6 |
+
from app.models import Job
|
| 7 |
+
from tests.test_buildout import auth, connect, docs, feed, llm # noqa: F401 (fixtures)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_archive_notebook_is_created_once_and_listed_first(client, auth, run_jobs, feed):
|
| 11 |
+
connect(client, auth, run_jobs)
|
| 12 |
+
client.post("/api/notebooks", json={"title": "Newer"}, headers=auth)
|
| 13 |
+
first = client.post("/api/notebooks/archive", headers=auth).json()
|
| 14 |
+
again = client.post("/api/notebooks/archive", headers=auth).json()
|
| 15 |
+
assert first["id"] == again["id"] and first["title"] == "All posts"
|
| 16 |
+
|
| 17 |
+
listed = client.get("/api/notebooks", headers=auth).json()
|
| 18 |
+
assert listed[0]["id"] == first["id"] and listed[0]["is_archive"]
|
| 19 |
+
indexed = [d for d in docs(client, auth) if d["path"]]
|
| 20 |
+
assert listed[0]["document_count"] == len(indexed) > 0
|
| 21 |
+
|
| 22 |
+
nb = client.get(f"/api/notebooks/{first['id']}", headers=auth).json()
|
| 23 |
+
assert nb["is_archive"] and len(nb["documents"]) == len(indexed)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_generate_from_whole_archive_uses_the_archive_notebook(client, auth, run_jobs, feed):
|
| 27 |
+
connect(client, auth, run_jobs)
|
| 28 |
+
art = client.post("/api/artifacts/generate", json={"type": "summary", "archive": True}, headers=auth).json()
|
| 29 |
+
archive = client.post("/api/notebooks/archive", headers=auth).json()
|
| 30 |
+
assert art["notebook_id"] == archive["id"]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_sync_queues_resurfacing_and_writing_voice_once(client, auth, feed, db_session, session_factory,
|
| 34 |
+
monkeypatch):
|
| 35 |
+
from app.services.jobs import claim_next
|
| 36 |
+
from app.worker import run_job
|
| 37 |
+
|
| 38 |
+
monkeypatch.setattr(settings, "llm_api_key", "test-key")
|
| 39 |
+
client.post("/api/sources", json={"url": "ada.example.com"}, headers=auth)
|
| 40 |
+
with session_factory() as db:
|
| 41 |
+
job_id = claim_next(db, "test")
|
| 42 |
+
run_job(job_id, session_factory=session_factory) # the ingest job only; follow-ups stay queued
|
| 43 |
+
db_session.expire_all()
|
| 44 |
+
kinds = [j.kind for j in db_session.scalars(select(Job).where(Job.status == "queued"))]
|
| 45 |
+
assert kinds.count("resurface_scan") == 1
|
| 46 |
+
assert kinds.count("voice_profile") == 1
|
| 47 |
+
assert kinds.count("topics") == 1
|
frontend/src/api/endpoints.ts
CHANGED
|
@@ -56,6 +56,8 @@ export const notebooksApi = {
|
|
| 56 |
get: (id: string) => api<Notebook>(`/api/notebooks/${id}`),
|
| 57 |
create: (title: string, document_ids: string[] = [], description?: string) =>
|
| 58 |
post<{ id: string; title: string; added: number }>("/api/notebooks", { title, document_ids, description }),
|
|
|
|
|
|
|
| 59 |
fromTopic: (topicId: string) => post<{ id: string; title: string }>(`/api/notebooks/from-topic/${topicId}`),
|
| 60 |
update: (id: string, body: { title?: string; description?: string }) => patch(`/api/notebooks/${id}`, body),
|
| 61 |
remove: (id: string) => del(`/api/notebooks/${id}`),
|
|
@@ -72,6 +74,8 @@ export type GenerateBody = {
|
|
| 72 |
type: ArtifactType;
|
| 73 |
notebook_id?: string;
|
| 74 |
document_id?: string;
|
|
|
|
|
|
|
| 75 |
format?: "deep_dive" | "brief" | "debate";
|
| 76 |
minutes?: number;
|
| 77 |
style?: "short" | "explainer" | "audiogram";
|
|
|
|
| 56 |
get: (id: string) => api<Notebook>(`/api/notebooks/${id}`),
|
| 57 |
create: (title: string, document_ids: string[] = [], description?: string) =>
|
| 58 |
post<{ id: string; title: string; added: number }>("/api/notebooks", { title, document_ids, description }),
|
| 59 |
+
/** The "All posts" notebook, created on first use. */
|
| 60 |
+
archive: () => post<{ id: string; title: string }>("/api/notebooks/archive"),
|
| 61 |
fromTopic: (topicId: string) => post<{ id: string; title: string }>(`/api/notebooks/from-topic/${topicId}`),
|
| 62 |
update: (id: string, body: { title?: string; description?: string }) => patch(`/api/notebooks/${id}`, body),
|
| 63 |
remove: (id: string) => del(`/api/notebooks/${id}`),
|
|
|
|
| 74 |
type: ArtifactType;
|
| 75 |
notebook_id?: string;
|
| 76 |
document_id?: string;
|
| 77 |
+
/** No notebook or post: use the "All posts" notebook. */
|
| 78 |
+
archive?: boolean;
|
| 79 |
format?: "deep_dive" | "brief" | "debate";
|
| 80 |
minutes?: number;
|
| 81 |
style?: "short" | "explainer" | "audiogram";
|
frontend/src/api/types.ts
CHANGED
|
@@ -56,6 +56,8 @@ export type NotebookSummary = {
|
|
| 56 |
document_count: number;
|
| 57 |
chat_count: number;
|
| 58 |
artifact_count: number;
|
|
|
|
|
|
|
| 59 |
updated_at: string | null;
|
| 60 |
};
|
| 61 |
|
|
@@ -64,6 +66,7 @@ export type Notebook = {
|
|
| 64 |
title: string;
|
| 65 |
description: string | null;
|
| 66 |
summary: string | null;
|
|
|
|
| 67 |
documents: Doc[];
|
| 68 |
};
|
| 69 |
|
|
|
|
| 56 |
document_count: number;
|
| 57 |
chat_count: number;
|
| 58 |
artifact_count: number;
|
| 59 |
+
/** The always-present "All posts" notebook. */
|
| 60 |
+
is_archive: boolean;
|
| 61 |
updated_at: string | null;
|
| 62 |
};
|
| 63 |
|
|
|
|
| 66 |
title: string;
|
| 67 |
description: string | null;
|
| 68 |
summary: string | null;
|
| 69 |
+
is_archive: boolean;
|
| 70 |
documents: Doc[];
|
| 71 |
};
|
| 72 |
|
frontend/src/components/AppShell.tsx
CHANGED
|
@@ -8,31 +8,25 @@ import SkyCanvas from "./SkyCanvas";
|
|
| 8 |
import UpgradeModal from "./UpgradeModal";
|
| 9 |
import {
|
| 10 |
AsteroidIcon,
|
| 11 |
-
CometIcon,
|
| 12 |
-
ConstellationIcon,
|
| 13 |
HelmetIcon,
|
| 14 |
LaunchpadIcon,
|
| 15 |
LaunchWindowIcon,
|
| 16 |
OrbitIcon,
|
| 17 |
PlanetIcon,
|
| 18 |
RadarIcon,
|
| 19 |
-
RocketIcon,
|
| 20 |
SatelliteDishIcon,
|
| 21 |
} from "./icons/Icons";
|
| 22 |
|
| 23 |
// Theme in the icon, clarity in the label: the plain label always shows.
|
| 24 |
-
|
| 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/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/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 |
}
|
|
@@ -171,9 +171,6 @@ export default function AuthPage() {
|
|
| 171 |
<>
|
| 172 |
{providers?.google === "redirect" && <GoogleRedirectButton next={next} />}
|
| 173 |
{providers?.google === "popup" && <GoogleButton onCredential={onGoogle} />}
|
| 174 |
-
{providers && !providers.google && (
|
| 175 |
-
<p className="mono muted auth-hint">Set GOOGLE_CLIENT_ID in .env to enable Google sign in.</p>
|
| 176 |
-
)}
|
| 177 |
{!providers && <div className="google-btn" />}
|
| 178 |
<div className="auth-divider mono">
|
| 179 |
<span>or with email</span>
|
|
|
|
| 63 |
}, [onCredential]);
|
| 64 |
|
| 65 |
if (!GOOGLE_CLIENT_ID) {
|
| 66 |
+
return null;
|
| 67 |
}
|
| 68 |
return <div ref={ref} className="google-btn" />;
|
| 69 |
}
|
|
|
|
| 171 |
<>
|
| 172 |
{providers?.google === "redirect" && <GoogleRedirectButton next={next} />}
|
| 173 |
{providers?.google === "popup" && <GoogleButton onCredential={onGoogle} />}
|
|
|
|
|
|
|
|
|
|
| 174 |
{!providers && <div className="google-btn" />}
|
| 175 |
<div className="auth-divider mono">
|
| 176 |
<span>or with email</span>
|
frontend/src/pages/LaunchKit.tsx
CHANGED
|
@@ -1,15 +1,15 @@
|
|
| 1 |
-
import { useCallback, useEffect, useState } from "react";
|
| 2 |
import { useSearchParams } from "react-router-dom";
|
| 3 |
-
import { artifactsApi } from "../api/endpoints";
|
| 4 |
-
import type { Artifact, LaunchKitContent, Platform } from "../api/types";
|
| 5 |
import { ArtifactCard } from "../components/ArtifactCard";
|
| 6 |
import { DocPicker } from "../components/DocPicker";
|
| 7 |
import { ScheduleModal } from "../components/ScheduleModal";
|
| 8 |
-
import { ConfirmButton, CopyButton,
|
| 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/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,81 +1,137 @@
|
|
| 1 |
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
|
|
|
| 2 |
import { docsApi, jobsApi, sourcesApi } from "../api/endpoints";
|
| 3 |
import type { Doc, Job, Source } from "../api/types";
|
| 4 |
import { Reader } from "../components/Reader";
|
| 5 |
-
import { ConfirmButton, EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader, StatusPill
|
| 6 |
import { useJobMap } from "../hooks/useJob";
|
| 7 |
import { useUpgrade } from "../hooks/useUpgrade";
|
| 8 |
|
| 9 |
-
type AddMode = "feed" | "url" | "file";
|
| 10 |
-
|
| 11 |
const PAGE = 50;
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
export function AddSource({ onAdded }: { onAdded: (source: Source, job: Job) => void }) {
|
| 14 |
-
const [mode, setMode] = useState<AddMode>("feed");
|
| 15 |
const [value, setValue] = useState("");
|
|
|
|
| 16 |
const [busy, setBusy] = useState(false);
|
| 17 |
const [error, setError] = useState<string | null>(null);
|
| 18 |
const fileRef = useRef<HTMLInputElement>(null);
|
|
|
|
| 19 |
|
| 20 |
const submit = async (e: FormEvent) => {
|
| 21 |
e.preventDefault();
|
|
|
|
| 22 |
setError(null);
|
| 23 |
setBusy(true);
|
| 24 |
try {
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
onAdded(res.source, res.job);
|
| 30 |
-
if (fileRef.current) fileRef.current.value = "";
|
| 31 |
-
} else {
|
| 32 |
-
if (!value.trim()) return;
|
| 33 |
-
const res = mode === "feed" ? await sourcesApi.connect(value.trim()) : await sourcesApi.importUrl(value.trim());
|
| 34 |
-
onAdded(res.source, res.job);
|
| 35 |
-
setValue("");
|
| 36 |
-
}
|
| 37 |
} catch (err) {
|
| 38 |
-
setError(errorMessage(err, "
|
| 39 |
} finally {
|
| 40 |
setBusy(false);
|
| 41 |
}
|
| 42 |
};
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
return (
|
| 45 |
<div className="card add-source">
|
| 46 |
-
<
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
{
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
<form onSubmit={submit} className="inline-form">
|
| 64 |
-
{mode === "file" ? (
|
| 65 |
-
<input ref={fileRef} className="input file-input" type="file" accept=".md,.markdown,.txt,.html,.htm,.pdf,text/markdown,text/plain,text/html,application/pdf" aria-label="File" />
|
| 66 |
-
) : (
|
| 67 |
-
<input
|
| 68 |
-
className="input"
|
| 69 |
-
placeholder={mode === "feed" ? "yourblog.com" : "https://example.com/my-essay"}
|
| 70 |
-
value={value}
|
| 71 |
-
onChange={(e) => setValue(e.target.value)}
|
| 72 |
-
aria-label={mode === "feed" ? "Feed or site URL" : "Article URL"}
|
| 73 |
-
/>
|
| 74 |
-
)}
|
| 75 |
-
<button className="btn btn-primary" type="submit" disabled={busy}>
|
| 76 |
-
{busy ? (mode === "feed" ? "Finding feed..." : "Starting...") : mode === "feed" ? "Connect" : "Import"}
|
| 77 |
</button>
|
|
|
|
| 78 |
</form>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
{error && (
|
| 80 |
<p className="error-text" role="alert">
|
| 81 |
{error}
|
|
@@ -115,7 +171,7 @@ export default function Sources() {
|
|
| 115 |
});
|
| 116 |
|
| 117 |
useEffect(() => {
|
| 118 |
-
loadSources().catch(() => setError("Could not
|
| 119 |
}, [loadSources]);
|
| 120 |
|
| 121 |
useEffect(() => {
|
|
@@ -144,8 +200,29 @@ export default function Sources() {
|
|
| 144 |
watch(source.id, job);
|
| 145 |
};
|
| 146 |
|
|
|
|
|
|
|
| 147 |
return (
|
| 148 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
<PageHeader eyebrow="Sources" title="Your knowledge, in orbit" />
|
| 150 |
<AddSource onAdded={added} />
|
| 151 |
{error && <p className="error-text">{error}</p>}
|
|
|
|
| 1 |
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
| 2 |
+
import { NavLink } from "react-router-dom";
|
| 3 |
import { docsApi, jobsApi, sourcesApi } from "../api/endpoints";
|
| 4 |
import type { Doc, Job, Source } from "../api/types";
|
| 5 |
import { Reader } from "../components/Reader";
|
| 6 |
+
import { ConfirmButton, EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader, StatusPill } from "../components/ui";
|
| 7 |
import { useJobMap } from "../hooks/useJob";
|
| 8 |
import { useUpgrade } from "../hooks/useUpgrade";
|
| 9 |
|
|
|
|
|
|
|
| 10 |
const PAGE = 50;
|
| 11 |
|
| 12 |
+
/** Sources and the Topic map share one sidebar entry; this strip switches between them. */
|
| 13 |
+
export function SourcesNav() {
|
| 14 |
+
return (
|
| 15 |
+
<nav className="tabs" aria-label="Sources views">
|
| 16 |
+
<NavLink to="/app/sources" className={({ isActive }) => `tab${isActive ? " active" : ""}`}>
|
| 17 |
+
Posts
|
| 18 |
+
</NavLink>
|
| 19 |
+
<NavLink to="/app/map" className={({ isActive }) => `tab${isActive ? " active" : ""}`}>
|
| 20 |
+
Topic map
|
| 21 |
+
</NavLink>
|
| 22 |
+
</nav>
|
| 23 |
+
);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export const FILE_ACCEPT = ".md,.markdown,.txt,.html,.htm,.pdf,text/markdown,text/plain,text/html,application/pdf";
|
| 27 |
+
|
| 28 |
+
/** A bare domain, a Substack or a feed URL is a whole archive; a deep link is one article. */
|
| 29 |
+
export function looksLikeArticle(raw: string): boolean {
|
| 30 |
+
const v = raw.trim();
|
| 31 |
+
if (!v) return false;
|
| 32 |
+
try {
|
| 33 |
+
const u = new URL(v.includes("://") ? v : `https://${v}`);
|
| 34 |
+
const path = u.pathname.replace(/\/+$/, "");
|
| 35 |
+
if (!path || /(feed|rss|atom)(\.xml)?$/i.test(path) || /^\/@[^/]+$/.test(path)) return false;
|
| 36 |
+
const parts = path.split("/").filter(Boolean);
|
| 37 |
+
return /\/p\/|\/posts?\/|\/\d{4}\/|\.html?$/i.test(path) || parts.length >= 2 || path.length > 24;
|
| 38 |
+
} catch {
|
| 39 |
+
return false;
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/** Imports each file as its own post. Returns how many failed. */
|
| 44 |
+
export async function importFiles(files: File[], onAdded: (source: Source, job: Job) => void): Promise<number> {
|
| 45 |
+
let failed = 0;
|
| 46 |
+
for (const f of files) {
|
| 47 |
+
try {
|
| 48 |
+
const res = await sourcesApi.importFile(f);
|
| 49 |
+
onAdded(res.source, res.job);
|
| 50 |
+
} catch {
|
| 51 |
+
failed += 1;
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
return failed;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/** One box for everything: paste a link (feed or article is worked out for you) or add files. */
|
| 58 |
export function AddSource({ onAdded }: { onAdded: (source: Source, job: Job) => void }) {
|
|
|
|
| 59 |
const [value, setValue] = useState("");
|
| 60 |
+
const [override, setOverride] = useState<"feed" | "url" | null>(null);
|
| 61 |
const [busy, setBusy] = useState(false);
|
| 62 |
const [error, setError] = useState<string | null>(null);
|
| 63 |
const fileRef = useRef<HTMLInputElement>(null);
|
| 64 |
+
const mode = override ?? (looksLikeArticle(value) ? "url" : "feed");
|
| 65 |
|
| 66 |
const submit = async (e: FormEvent) => {
|
| 67 |
e.preventDefault();
|
| 68 |
+
if (!value.trim()) return;
|
| 69 |
setError(null);
|
| 70 |
setBusy(true);
|
| 71 |
try {
|
| 72 |
+
const res = mode === "feed" ? await sourcesApi.connect(value.trim()) : await sourcesApi.importUrl(value.trim());
|
| 73 |
+
onAdded(res.source, res.job);
|
| 74 |
+
setValue("");
|
| 75 |
+
setOverride(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
} catch (err) {
|
| 77 |
+
setError(errorMessage(err, mode === "feed" ? "We could not find a feed there." : "We could not import that page."));
|
| 78 |
} finally {
|
| 79 |
setBusy(false);
|
| 80 |
}
|
| 81 |
};
|
| 82 |
|
| 83 |
+
const upload = async (files: FileList | null) => {
|
| 84 |
+
const list = Array.from(files ?? []);
|
| 85 |
+
if (!list.length) return;
|
| 86 |
+
setError(null);
|
| 87 |
+
setBusy(true);
|
| 88 |
+
const failed = await importFiles(list, onAdded);
|
| 89 |
+
if (failed) setError(`${failed} of ${list.length} files could not be uploaded.`);
|
| 90 |
+
setBusy(false);
|
| 91 |
+
if (fileRef.current) fileRef.current.value = "";
|
| 92 |
+
};
|
| 93 |
+
|
| 94 |
return (
|
| 95 |
<div className="card add-source">
|
| 96 |
+
<form onSubmit={submit} className="add-smart">
|
| 97 |
+
<input
|
| 98 |
+
className="input"
|
| 99 |
+
placeholder="Paste a blog, newsletter or article link"
|
| 100 |
+
value={value}
|
| 101 |
+
onChange={(e) => {
|
| 102 |
+
setValue(e.target.value);
|
| 103 |
+
setOverride(null);
|
| 104 |
+
}}
|
| 105 |
+
aria-label="Blog, newsletter or article link"
|
| 106 |
+
/>
|
| 107 |
+
<button className="btn btn-primary" type="submit" disabled={busy || !value.trim()}>
|
| 108 |
+
{busy ? "Working..." : mode === "feed" ? "Connect" : "Import article"}
|
| 109 |
+
</button>
|
| 110 |
+
<span className="muted small">or</span>
|
| 111 |
+
<button type="button" className="btn" disabled={busy} onClick={() => fileRef.current?.click()}>
|
| 112 |
+
Upload files
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
</button>
|
| 114 |
+
<input ref={fileRef} type="file" multiple accept={FILE_ACCEPT} hidden onChange={(e) => upload(e.target.files)} aria-label="Files to upload" />
|
| 115 |
</form>
|
| 116 |
+
<p className="muted small">
|
| 117 |
+
{!value.trim() && "Substack, Ghost, WordPress, Medium or any site with a feed. Markdown, text, HTML and PDF files work too; drop them anywhere on this page."}
|
| 118 |
+
{value.trim() && mode === "feed" && (
|
| 119 |
+
<>
|
| 120 |
+
We will pull in the whole archive and keep it in sync.{" "}
|
| 121 |
+
<button type="button" className="link-btn small" onClick={() => setOverride("url")}>
|
| 122 |
+
Just this one page instead
|
| 123 |
+
</button>
|
| 124 |
+
</>
|
| 125 |
+
)}
|
| 126 |
+
{value.trim() && mode === "url" && (
|
| 127 |
+
<>
|
| 128 |
+
Looks like a single article, so we will import just this page.{" "}
|
| 129 |
+
<button type="button" className="link-btn small" onClick={() => setOverride("feed")}>
|
| 130 |
+
Connect the whole site instead
|
| 131 |
+
</button>
|
| 132 |
+
</>
|
| 133 |
+
)}
|
| 134 |
+
</p>
|
| 135 |
{error && (
|
| 136 |
<p className="error-text" role="alert">
|
| 137 |
{error}
|
|
|
|
| 171 |
});
|
| 172 |
|
| 173 |
useEffect(() => {
|
| 174 |
+
loadSources().catch(() => setError("Could not load your sources. Refresh to try again."));
|
| 175 |
}, [loadSources]);
|
| 176 |
|
| 177 |
useEffect(() => {
|
|
|
|
| 200 |
watch(source.id, job);
|
| 201 |
};
|
| 202 |
|
| 203 |
+
const [dropping, setDropping] = useState(false);
|
| 204 |
+
|
| 205 |
return (
|
| 206 |
+
<div
|
| 207 |
+
className={`page-wrap${dropping ? " drop-over" : ""}`}
|
| 208 |
+
onDragOver={(e) => {
|
| 209 |
+
if (!e.dataTransfer.types.includes("Files")) return;
|
| 210 |
+
e.preventDefault();
|
| 211 |
+
setDropping(true);
|
| 212 |
+
}}
|
| 213 |
+
onDragLeave={(e) => {
|
| 214 |
+
if (e.currentTarget === e.target) setDropping(false);
|
| 215 |
+
}}
|
| 216 |
+
onDrop={async (e) => {
|
| 217 |
+
if (!e.dataTransfer.files.length) return;
|
| 218 |
+
e.preventDefault();
|
| 219 |
+
setDropping(false);
|
| 220 |
+
const files = Array.from(e.dataTransfer.files);
|
| 221 |
+
const failed = await importFiles(files, added);
|
| 222 |
+
setError(failed ? `${failed} of ${files.length} files could not be uploaded.` : null);
|
| 223 |
+
}}
|
| 224 |
+
>
|
| 225 |
+
<SourcesNav />
|
| 226 |
<PageHeader eyebrow="Sources" title="Your knowledge, in orbit" />
|
| 227 |
<AddSource onAdded={added} />
|
| 228 |
{error && <p className="error-text">{error}</p>}
|
frontend/src/pages/Studio.tsx
CHANGED
|
@@ -1,61 +1,54 @@
|
|
| 1 |
import { useCallback, useEffect, useState } from "react";
|
|
|
|
| 2 |
import { artifactsApi, notebooksApi, type GenerateBody } from "../api/endpoints";
|
| 3 |
import type { Artifact, NotebookSummary } from "../api/types";
|
| 4 |
import { ArtifactCard } from "../components/ArtifactCard";
|
| 5 |
import { DocPicker } from "../components/DocPicker";
|
|
|
|
| 6 |
import { Reader } from "../components/Reader";
|
| 7 |
-
import {
|
| 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/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,6 +1,6 @@
|
|
| 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, Tabs } from "../components/ui";
|
| 6 |
import { useJob } from "../hooks/useJob";
|
|
@@ -435,7 +435,7 @@ function GenerateVoiceModal({
|
|
| 435 |
</div>
|
| 436 |
</div>
|
| 437 |
)}
|
| 438 |
-
{!state.tts_configured && <p className="muted small">
|
| 439 |
{error && <p className="error-text">{error}</p>}
|
| 440 |
</div>
|
| 441 |
</Modal>
|
|
@@ -734,21 +734,20 @@ function CloneModal({ state, onClose, onDone }: { state: VoiceState; onClose: ()
|
|
| 734 |
);
|
| 735 |
}
|
| 736 |
|
| 737 |
-
// The page:
|
|
|
|
| 738 |
|
| 739 |
-
type Step = "writing" | "hosts"
|
| 740 |
|
| 741 |
const STEPS: { id: Step; label: string }[] = [
|
| 742 |
{ id: "writing", label: "Writing voice" },
|
| 743 |
{ id: "hosts", label: "Speaking voices" },
|
| 744 |
-
{ id: "clone", label: "Your own voice" },
|
| 745 |
];
|
| 746 |
|
| 747 |
export default function VoiceProfile() {
|
| 748 |
const [state, setState] = useState<VoiceState | null>(null);
|
| 749 |
const [draft, setDraft] = useState<VoiceProfileData | null>(null);
|
| 750 |
const [voices, setVoices] = useState<Voice[]>([]);
|
| 751 |
-
const [quota, setQuota] = useState<Quota>({});
|
| 752 |
const [step, setStep] = useState<Step | null>(null);
|
| 753 |
const [picking, setPicking] = useState(false);
|
| 754 |
const [samples, setSamples] = useState<string[]>([]);
|
|
@@ -759,8 +758,8 @@ export default function VoiceProfile() {
|
|
| 759 |
const [generating, setGenerating] = useState<Host | null>(null);
|
| 760 |
const [saved, setSaved] = useState(false);
|
| 761 |
const [error, setError] = useState<string | null>(null);
|
|
|
|
| 762 |
const { play, playing } = usePlayer();
|
| 763 |
-
const { openUpgrade } = useUpgrade();
|
| 764 |
|
| 765 |
const load = () =>
|
| 766 |
voiceApi.get().then((s) => {
|
|
@@ -768,13 +767,30 @@ export default function VoiceProfile() {
|
|
| 768 |
setDraft(s.profile);
|
| 769 |
setSamples(s.sample_doc_ids);
|
| 770 |
setStep((cur) => cur ?? (s.profile ? "hosts" : "writing"));
|
|
|
|
| 771 |
});
|
| 772 |
const loadVoices = () => voiceApi.voices().then(setVoices, () => setVoices([]));
|
| 773 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 774 |
useEffect(() => {
|
| 775 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 776 |
loadVoices();
|
| 777 |
-
|
| 778 |
}, []);
|
| 779 |
|
| 780 |
const build = useJob(buildJob, () => load());
|
|
@@ -786,22 +802,8 @@ export default function VoiceProfile() {
|
|
| 786 |
if (!state || !step) return <Loading />;
|
| 787 |
const building = build && (build.status === "queued" || build.status === "running");
|
| 788 |
const cloneRunning = clone && (clone.status === "queued" || clone.status === "running");
|
| 789 |
-
const
|
| 790 |
-
const
|
| 791 |
-
writing: Boolean(state.profile),
|
| 792 |
-
hosts: Boolean(state.profile) && stepIndex > 1,
|
| 793 |
-
clone: state.clone.status === "ready",
|
| 794 |
-
};
|
| 795 |
-
|
| 796 |
-
const startBuild = async (ids: string[]) => {
|
| 797 |
-
setPicking(false);
|
| 798 |
-
setError(null);
|
| 799 |
-
try {
|
| 800 |
-
setBuildJob(await voiceApi.build(ids));
|
| 801 |
-
} catch (e) {
|
| 802 |
-
setError(errorMessage(e));
|
| 803 |
-
}
|
| 804 |
-
};
|
| 805 |
|
| 806 |
const saveProfile = async () => {
|
| 807 |
setError(null);
|
|
@@ -835,20 +837,16 @@ export default function VoiceProfile() {
|
|
| 835 |
|
| 836 |
{step === "writing" && (
|
| 837 |
<section className="card stack vp-card">
|
| 838 |
-
<
|
| 839 |
-
<h2>Teach Notestack how you write</h2>
|
| 840 |
<p className="muted">Launch Kits, carousels, hooks and scripts are all written in this voice, so they read like you wrote them.</p>
|
| 841 |
{build && (building || build.status === "failed") && <JobProgress job={build} />}
|
| 842 |
-
|
| 843 |
{!draft && !building && (
|
| 844 |
-
<div className="
|
| 845 |
-
<button className="
|
| 846 |
-
|
| 847 |
-
<span className="muted small">We read your longest posts. One click, about a minute.</span>
|
| 848 |
</button>
|
| 849 |
-
<button className="
|
| 850 |
-
|
| 851 |
-
<span className="muted small">Choose 5 to 10 posts that sound most like you.</span>
|
| 852 |
</button>
|
| 853 |
</div>
|
| 854 |
)}
|
|
@@ -902,103 +900,46 @@ export default function VoiceProfile() {
|
|
| 902 |
|
| 903 |
{step === "hosts" && (
|
| 904 |
<section className="card stack vp-card">
|
| 905 |
-
<
|
| 906 |
-
<
|
| 907 |
-
<p className="muted">
|
| 908 |
-
Audio overviews are a conversation between two hosts, and videos are narrated by Host A. Tap a voice to use it, press play to hear it, or generate your own.
|
| 909 |
-
</p>
|
| 910 |
-
{!state.tts_configured && <p className="muted">Set ELEVENLABS_API_KEY in .env to generate audio.</p>}
|
| 911 |
<VoicePicker state={state} voices={voices} onSaved={setState} onBrowse={setBrowsing} onGenerate={setGenerating} />
|
| 912 |
-
<div className="row between">
|
| 913 |
-
<button className="btn btn-small" onClick={() => setStep("writing")}>
|
| 914 |
-
Back
|
| 915 |
-
</button>
|
| 916 |
-
<button className="btn btn-primary" onClick={() => setStep("clone")}>
|
| 917 |
-
Last step: use your own voice
|
| 918 |
-
</button>
|
| 919 |
-
</div>
|
| 920 |
-
</section>
|
| 921 |
-
)}
|
| 922 |
-
|
| 923 |
-
{step === "clone" && (
|
| 924 |
-
<section className="card stack vp-card">
|
| 925 |
-
<p className="eyebrow">Step 3 of 3</p>
|
| 926 |
-
<h2>Make Host A sound exactly like you</h2>
|
| 927 |
-
|
| 928 |
-
{!state.clone.allowed && (
|
| 929 |
-
<>
|
| 930 |
-
<p className="muted">Clone your voice and every audio overview and video you make is narrated by you, not a stock voice.</p>
|
| 931 |
-
<ol className="vp-how">
|
| 932 |
-
<li>
|
| 933 |
-
<strong>Read for 1 to 3 minutes</strong>
|
| 934 |
-
<span className="muted small">We give you a passage from your own writing.</span>
|
| 935 |
-
</li>
|
| 936 |
-
<li>
|
| 937 |
-
<strong>We build your voice</strong>
|
| 938 |
-
<span className="muted small">Takes a few minutes. Only you can use it.</span>
|
| 939 |
-
</li>
|
| 940 |
-
<li>
|
| 941 |
-
<strong>Everything sounds like you</strong>
|
| 942 |
-
<span className="muted small">Your voice becomes Host A across overviews and videos.</span>
|
| 943 |
-
</li>
|
| 944 |
-
</ol>
|
| 945 |
-
<div className="vp-upgrade">
|
| 946 |
-
<div>
|
| 947 |
-
<strong>Voice cloning is on the Writer and Studio plans</strong>
|
| 948 |
-
<p className="muted small">Your writing voice and hosts are all set. Upgrade whenever you want to add your own.</p>
|
| 949 |
-
</div>
|
| 950 |
-
<button className="btn btn-primary" onClick={() => openUpgrade("writer")}>
|
| 951 |
-
Upgrade to clone my voice
|
| 952 |
-
</button>
|
| 953 |
-
</div>
|
| 954 |
-
</>
|
| 955 |
-
)}
|
| 956 |
|
| 957 |
-
{
|
| 958 |
-
<
|
| 959 |
-
|
| 960 |
-
|
| 961 |
-
|
| 962 |
-
<p className="
|
| 963 |
-
Record one to three minutes of your own writing, read aloud. Your voice becomes Host A, so audio overviews and videos sound like you.
|
| 964 |
-
</p>
|
| 965 |
-
{state.clone.status === "failed" && state.clone.error && <p className="error-text">{state.clone.error}</p>}
|
| 966 |
<div className="row">
|
| 967 |
-
|
| 968 |
-
Clone my voice
|
| 969 |
-
</button>
|
| 970 |
-
</div>
|
| 971 |
-
</>
|
| 972 |
-
)}
|
| 973 |
-
{clone && (cloneRunning || clone.status === "failed") && <JobProgress job={clone} />}
|
| 974 |
-
{state.clone.status === "processing" && !clone && <p className="muted">Your voice is being created.</p>}
|
| 975 |
-
{state.clone.status === "ready" && (
|
| 976 |
-
<>
|
| 977 |
-
<p className="muted">Your voice is live and set as Host A. Adjust its delivery in step 2.</p>
|
| 978 |
-
<div className="row">
|
| 979 |
-
{state.clone.preview_url && (
|
| 980 |
<button className="btn btn-small" onClick={() => play(state.clone.preview_url!, "clone")}>
|
| 981 |
{playing === "clone" ? "Stop" : "Hear my voice"}
|
| 982 |
</button>
|
| 983 |
)}
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
|
| 987 |
-
|
| 988 |
-
|
| 989 |
-
|
| 990 |
-
|
| 991 |
-
|
| 992 |
-
|
| 993 |
-
|
| 994 |
-
|
| 995 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 996 |
</div>
|
| 997 |
-
</>
|
| 998 |
)}
|
|
|
|
| 999 |
<div className="row">
|
| 1000 |
-
<button className="btn btn-small" onClick={() => setStep("
|
| 1001 |
-
Back
|
| 1002 |
</button>
|
| 1003 |
</div>
|
| 1004 |
</section>
|
|
|
|
| 1 |
import { useCallback, useEffect, useRef, useState } from "react";
|
| 2 |
import { voiceApi } from "../api/endpoints";
|
| 3 |
+
import type { Delivery, Job, LibraryVoice, Voice, VoiceDesignInput, VoiceDesignPreview, VoiceProfileData, VoiceState } from "../api/types";
|
| 4 |
import { DocPicker } from "../components/DocPicker";
|
| 5 |
import { ConfirmButton, errorMessage, formatDate, JobProgress, Loading, Modal, PageHeader, Tabs } from "../components/ui";
|
| 6 |
import { useJob } from "../hooks/useJob";
|
|
|
|
| 435 |
</div>
|
| 436 |
</div>
|
| 437 |
)}
|
| 438 |
+
{!state.tts_configured && <p className="muted small">Voice generation is unavailable right now.</p>}
|
| 439 |
{error && <p className="error-text">{error}</p>}
|
| 440 |
</div>
|
| 441 |
</Modal>
|
|
|
|
| 734 |
);
|
| 735 |
}
|
| 736 |
|
| 737 |
+
// The page: two steps. The writing voice builds itself, then the writer picks the two hosts.
|
| 738 |
+
// Cloning lives in the "+ Add a voice" menu; a clone's status shows under the voice cards.
|
| 739 |
|
| 740 |
+
type Step = "writing" | "hosts";
|
| 741 |
|
| 742 |
const STEPS: { id: Step; label: string }[] = [
|
| 743 |
{ id: "writing", label: "Writing voice" },
|
| 744 |
{ id: "hosts", label: "Speaking voices" },
|
|
|
|
| 745 |
];
|
| 746 |
|
| 747 |
export default function VoiceProfile() {
|
| 748 |
const [state, setState] = useState<VoiceState | null>(null);
|
| 749 |
const [draft, setDraft] = useState<VoiceProfileData | null>(null);
|
| 750 |
const [voices, setVoices] = useState<Voice[]>([]);
|
|
|
|
| 751 |
const [step, setStep] = useState<Step | null>(null);
|
| 752 |
const [picking, setPicking] = useState(false);
|
| 753 |
const [samples, setSamples] = useState<string[]>([]);
|
|
|
|
| 758 |
const [generating, setGenerating] = useState<Host | null>(null);
|
| 759 |
const [saved, setSaved] = useState(false);
|
| 760 |
const [error, setError] = useState<string | null>(null);
|
| 761 |
+
const autoBuilt = useRef(false);
|
| 762 |
const { play, playing } = usePlayer();
|
|
|
|
| 763 |
|
| 764 |
const load = () =>
|
| 765 |
voiceApi.get().then((s) => {
|
|
|
|
| 767 |
setDraft(s.profile);
|
| 768 |
setSamples(s.sample_doc_ids);
|
| 769 |
setStep((cur) => cur ?? (s.profile ? "hosts" : "writing"));
|
| 770 |
+
return s;
|
| 771 |
});
|
| 772 |
const loadVoices = () => voiceApi.voices().then(setVoices, () => setVoices([]));
|
| 773 |
|
| 774 |
+
const startBuild = async (ids: string[]) => {
|
| 775 |
+
setPicking(false);
|
| 776 |
+
setError(null);
|
| 777 |
+
try {
|
| 778 |
+
setBuildJob(await voiceApi.build(ids));
|
| 779 |
+
} catch (e) {
|
| 780 |
+
setError(errorMessage(e));
|
| 781 |
+
}
|
| 782 |
+
};
|
| 783 |
+
|
| 784 |
useEffect(() => {
|
| 785 |
+
// No writing voice yet: build one from the longest posts straight away instead of asking.
|
| 786 |
+
load().then((s) => {
|
| 787 |
+
if (!s.profile && !autoBuilt.current) {
|
| 788 |
+
autoBuilt.current = true;
|
| 789 |
+
void startBuild([]);
|
| 790 |
+
}
|
| 791 |
+
});
|
| 792 |
loadVoices();
|
| 793 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 794 |
}, []);
|
| 795 |
|
| 796 |
const build = useJob(buildJob, () => load());
|
|
|
|
| 802 |
if (!state || !step) return <Loading />;
|
| 803 |
const building = build && (build.status === "queued" || build.status === "running");
|
| 804 |
const cloneRunning = clone && (clone.status === "queued" || clone.status === "running");
|
| 805 |
+
const done: Record<Step, boolean> = { writing: Boolean(state.profile), hosts: Boolean(state.profile) && step === "hosts" };
|
| 806 |
+
const showClone = state.clone.status !== "none" || Boolean(clone);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 807 |
|
| 808 |
const saveProfile = async () => {
|
| 809 |
setError(null);
|
|
|
|
| 837 |
|
| 838 |
{step === "writing" && (
|
| 839 |
<section className="card stack vp-card">
|
| 840 |
+
<h2>How you write</h2>
|
|
|
|
| 841 |
<p className="muted">Launch Kits, carousels, hooks and scripts are all written in this voice, so they read like you wrote them.</p>
|
| 842 |
{build && (building || build.status === "failed") && <JobProgress job={build} />}
|
|
|
|
| 843 |
{!draft && !building && (
|
| 844 |
+
<div className="row">
|
| 845 |
+
<button className="btn btn-primary" onClick={() => startBuild([])}>
|
| 846 |
+
Build my writing voice
|
|
|
|
| 847 |
</button>
|
| 848 |
+
<button className="link-btn small" onClick={() => setPicking(true)}>
|
| 849 |
+
or pick the posts yourself
|
|
|
|
| 850 |
</button>
|
| 851 |
</div>
|
| 852 |
)}
|
|
|
|
| 900 |
|
| 901 |
{step === "hosts" && (
|
| 902 |
<section className="card stack vp-card">
|
| 903 |
+
<h2>Who reads your work aloud</h2>
|
| 904 |
+
<p className="muted">Audio overviews are a conversation between two hosts, and videos are narrated by Host A. Tap a voice to use it, or add your own.</p>
|
| 905 |
+
{!state.tts_configured && <p className="muted">Voice previews are unavailable right now.</p>}
|
|
|
|
|
|
|
|
|
|
| 906 |
<VoicePicker state={state} voices={voices} onSaved={setState} onBrowse={setBrowsing} onGenerate={setGenerating} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 907 |
|
| 908 |
+
{showClone && (
|
| 909 |
+
<div className="vp-clone">
|
| 910 |
+
<strong>Your cloned voice</strong>
|
| 911 |
+
{clone && (cloneRunning || clone.status === "failed") && <JobProgress job={clone} compact />}
|
| 912 |
+
{state.clone.status === "processing" && !clone && <p className="muted small">Your voice is being created. It becomes Host A when ready.</p>}
|
| 913 |
+
{state.clone.status === "failed" && state.clone.error && !cloneRunning && <p className="error-text">{state.clone.error}</p>}
|
|
|
|
|
|
|
|
|
|
| 914 |
<div className="row">
|
| 915 |
+
{state.clone.status === "ready" && state.clone.preview_url && (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 916 |
<button className="btn btn-small" onClick={() => play(state.clone.preview_url!, "clone")}>
|
| 917 |
{playing === "clone" ? "Stop" : "Hear my voice"}
|
| 918 |
</button>
|
| 919 |
)}
|
| 920 |
+
{!cloneRunning && state.clone.allowed && (
|
| 921 |
+
<button className="btn btn-small" onClick={() => setCloning(true)}>
|
| 922 |
+
{state.clone.status === "failed" ? "Try again" : "Record again"}
|
| 923 |
+
</button>
|
| 924 |
+
)}
|
| 925 |
+
{state.clone.status !== "none" && (
|
| 926 |
+
<ConfirmButton
|
| 927 |
+
confirmLabel="Revoke and delete my voice?"
|
| 928 |
+
onConfirm={async () => {
|
| 929 |
+
setState(await voiceApi.revoke());
|
| 930 |
+
loadVoices();
|
| 931 |
+
}}
|
| 932 |
+
>
|
| 933 |
+
Revoke consent
|
| 934 |
+
</ConfirmButton>
|
| 935 |
+
)}
|
| 936 |
</div>
|
| 937 |
+
</div>
|
| 938 |
)}
|
| 939 |
+
|
| 940 |
<div className="row">
|
| 941 |
+
<button className="btn btn-small" onClick={() => setStep("writing")}>
|
| 942 |
+
Back to writing voice
|
| 943 |
</button>
|
| 944 |
</div>
|
| 945 |
</section>
|
frontend/src/styles/app.css
CHANGED
|
@@ -1046,8 +1046,8 @@
|
|
| 1046 |
display: grid;
|
| 1047 |
justify-items: center;
|
| 1048 |
text-align: center;
|
| 1049 |
-
gap:
|
| 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
|
@@ -1952,17 +1952,15 @@ select.input option {
|
|
| 1952 |
|
| 1953 |
/* Voice page */
|
| 1954 |
.vp .vp-card {
|
| 1955 |
-
max-width: 760px;
|
| 1956 |
animation: welcome-in 0.35s ease both;
|
| 1957 |
}
|
| 1958 |
|
| 1959 |
.vp-steps {
|
| 1960 |
list-style: none;
|
| 1961 |
-
margin: 0
|
| 1962 |
padding: 0;
|
| 1963 |
-
max-width: 760px;
|
| 1964 |
display: grid;
|
| 1965 |
-
grid-template-columns: repeat(
|
| 1966 |
gap: 8px;
|
| 1967 |
font-size: 0.75rem;
|
| 1968 |
}
|
|
@@ -2004,32 +2002,6 @@ select.input option {
|
|
| 2004 |
box-shadow: var(--glow-sm);
|
| 2005 |
}
|
| 2006 |
|
| 2007 |
-
.vp-choices {
|
| 2008 |
-
display: grid;
|
| 2009 |
-
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
| 2010 |
-
gap: 12px;
|
| 2011 |
-
}
|
| 2012 |
-
|
| 2013 |
-
.vp-choice {
|
| 2014 |
-
display: grid;
|
| 2015 |
-
gap: 6px;
|
| 2016 |
-
padding: 18px;
|
| 2017 |
-
border: 1px solid var(--line-strong);
|
| 2018 |
-
border-radius: var(--radius-sm);
|
| 2019 |
-
background: transparent;
|
| 2020 |
-
color: var(--white);
|
| 2021 |
-
font: inherit;
|
| 2022 |
-
text-align: left;
|
| 2023 |
-
cursor: pointer;
|
| 2024 |
-
transition: border-color 0.2s, box-shadow 0.2s;
|
| 2025 |
-
}
|
| 2026 |
-
|
| 2027 |
-
.vp-choice:hover,
|
| 2028 |
-
.vp-choice:focus-visible {
|
| 2029 |
-
border-color: var(--blue-60);
|
| 2030 |
-
box-shadow: var(--glow-sm);
|
| 2031 |
-
}
|
| 2032 |
-
|
| 2033 |
.voice-grid {
|
| 2034 |
list-style: none;
|
| 2035 |
margin: 0;
|
|
@@ -2213,29 +2185,12 @@ select.input option {
|
|
| 2213 |
background: var(--blue-05);
|
| 2214 |
}
|
| 2215 |
|
| 2216 |
-
.vp-
|
| 2217 |
-
list-style: none;
|
| 2218 |
-
margin: 0;
|
| 2219 |
-
padding: 0;
|
| 2220 |
display: grid;
|
| 2221 |
-
|
| 2222 |
-
|
| 2223 |
-
counter-reset: vp-how;
|
| 2224 |
-
}
|
| 2225 |
-
|
| 2226 |
-
.vp-how li {
|
| 2227 |
-
display: grid;
|
| 2228 |
-
gap: 4px;
|
| 2229 |
-
padding: 14px;
|
| 2230 |
border: 1px solid var(--line);
|
| 2231 |
border-radius: var(--radius-sm);
|
| 2232 |
-
counter-increment: vp-how;
|
| 2233 |
-
}
|
| 2234 |
-
|
| 2235 |
-
.vp-how li::before {
|
| 2236 |
-
content: counter(vp-how);
|
| 2237 |
-
font: 600 0.75rem var(--font-mono);
|
| 2238 |
-
color: var(--blue);
|
| 2239 |
}
|
| 2240 |
|
| 2241 |
.vp-upgrade {
|
|
@@ -2751,3 +2706,631 @@ select.input,
|
|
| 2751 |
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
| 2752 |
}
|
| 2753 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1952 |
|
| 1953 |
/* Voice page */
|
| 1954 |
.vp .vp-card {
|
|
|
|
| 1955 |
animation: welcome-in 0.35s ease both;
|
| 1956 |
}
|
| 1957 |
|
| 1958 |
.vp-steps {
|
| 1959 |
list-style: none;
|
| 1960 |
+
margin: 0;
|
| 1961 |
padding: 0;
|
|
|
|
| 1962 |
display: grid;
|
| 1963 |
+
grid-template-columns: repeat(2, 1fr);
|
| 1964 |
gap: 8px;
|
| 1965 |
font-size: 0.75rem;
|
| 1966 |
}
|
|
|
|
| 2002 |
box-shadow: var(--glow-sm);
|
| 2003 |
}
|
| 2004 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2005 |
.voice-grid {
|
| 2006 |
list-style: none;
|
| 2007 |
margin: 0;
|
|
|
|
| 2185 |
background: var(--blue-05);
|
| 2186 |
}
|
| 2187 |
|
| 2188 |
+
.vp-clone {
|
|
|
|
|
|
|
|
|
|
| 2189 |
display: grid;
|
| 2190 |
+
gap: 8px;
|
| 2191 |
+
padding: 14px 16px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2192 |
border: 1px solid var(--line);
|
| 2193 |
border-radius: var(--radius-sm);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2194 |
}
|
| 2195 |
|
| 2196 |
.vp-upgrade {
|
|
|
|
| 2706 |
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
| 2707 |
}
|
| 2708 |
}
|
| 2709 |
+
|
| 2710 |
+
/* Simplified layouts (2026-09-28): bigger components, fewer steps */
|
| 2711 |
+
|
| 2712 |
+
.clamp-1 {
|
| 2713 |
+
max-width: 100%;
|
| 2714 |
+
overflow: hidden;
|
| 2715 |
+
text-overflow: ellipsis;
|
| 2716 |
+
white-space: nowrap;
|
| 2717 |
+
}
|
| 2718 |
+
|
| 2719 |
+
/* Mission Control */
|
| 2720 |
+
.mc-hero {
|
| 2721 |
+
display: grid;
|
| 2722 |
+
gap: 18px;
|
| 2723 |
+
padding: clamp(18px, 3vw, 28px);
|
| 2724 |
+
}
|
| 2725 |
+
|
| 2726 |
+
.mc-ask {
|
| 2727 |
+
display: flex;
|
| 2728 |
+
align-items: center;
|
| 2729 |
+
gap: 12px;
|
| 2730 |
+
color: var(--blue);
|
| 2731 |
+
}
|
| 2732 |
+
|
| 2733 |
+
.mc-ask .input {
|
| 2734 |
+
flex: 1;
|
| 2735 |
+
min-width: 0;
|
| 2736 |
+
height: 54px;
|
| 2737 |
+
font-size: 1.05rem;
|
| 2738 |
+
}
|
| 2739 |
+
|
| 2740 |
+
.mc-ask .btn {
|
| 2741 |
+
height: 54px;
|
| 2742 |
+
padding: 0 28px;
|
| 2743 |
+
}
|
| 2744 |
+
|
| 2745 |
+
.mc-actions {
|
| 2746 |
+
display: grid;
|
| 2747 |
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
| 2748 |
+
gap: 12px;
|
| 2749 |
+
}
|
| 2750 |
+
|
| 2751 |
+
@media (max-width: 900px) {
|
| 2752 |
+
.mc-actions {
|
| 2753 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 2754 |
+
}
|
| 2755 |
+
}
|
| 2756 |
+
|
| 2757 |
+
.mc-action {
|
| 2758 |
+
display: grid;
|
| 2759 |
+
justify-items: start;
|
| 2760 |
+
align-content: start;
|
| 2761 |
+
gap: 6px;
|
| 2762 |
+
min-height: 132px;
|
| 2763 |
+
padding: 18px;
|
| 2764 |
+
border: 1px solid var(--line);
|
| 2765 |
+
border-radius: var(--radius-sm);
|
| 2766 |
+
background: transparent;
|
| 2767 |
+
color: var(--white);
|
| 2768 |
+
font: inherit;
|
| 2769 |
+
text-align: left;
|
| 2770 |
+
text-decoration: none;
|
| 2771 |
+
cursor: pointer;
|
| 2772 |
+
transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
|
| 2773 |
+
}
|
| 2774 |
+
|
| 2775 |
+
.mc-action svg {
|
| 2776 |
+
color: var(--blue);
|
| 2777 |
+
margin-bottom: 6px;
|
| 2778 |
+
}
|
| 2779 |
+
|
| 2780 |
+
.mc-action:hover:not(:disabled) {
|
| 2781 |
+
border-color: var(--blue-60);
|
| 2782 |
+
box-shadow: var(--glow-sm);
|
| 2783 |
+
transform: translateY(-2px);
|
| 2784 |
+
}
|
| 2785 |
+
|
| 2786 |
+
.mc-action:disabled {
|
| 2787 |
+
opacity: 0.5;
|
| 2788 |
+
cursor: default;
|
| 2789 |
+
}
|
| 2790 |
+
|
| 2791 |
+
.mc-more {
|
| 2792 |
+
display: grid;
|
| 2793 |
+
place-items: center;
|
| 2794 |
+
width: 28px;
|
| 2795 |
+
height: 28px;
|
| 2796 |
+
margin-bottom: 6px;
|
| 2797 |
+
border: 1px dashed var(--blue-60);
|
| 2798 |
+
border-radius: 999px;
|
| 2799 |
+
color: var(--blue);
|
| 2800 |
+
font-size: 1.1rem;
|
| 2801 |
+
line-height: 1;
|
| 2802 |
+
}
|
| 2803 |
+
|
| 2804 |
+
.mc-next {
|
| 2805 |
+
display: flex;
|
| 2806 |
+
flex-wrap: wrap;
|
| 2807 |
+
align-items: center;
|
| 2808 |
+
justify-content: space-between;
|
| 2809 |
+
gap: 12px;
|
| 2810 |
+
padding: 14px 18px;
|
| 2811 |
+
border: 1px solid var(--blue-35);
|
| 2812 |
+
border-radius: var(--radius-sm);
|
| 2813 |
+
background: var(--blue-05);
|
| 2814 |
+
}
|
| 2815 |
+
|
| 2816 |
+
.mc-next p {
|
| 2817 |
+
margin: 0;
|
| 2818 |
+
flex: 1 1 320px;
|
| 2819 |
+
}
|
| 2820 |
+
|
| 2821 |
+
.mc-stats {
|
| 2822 |
+
display: grid;
|
| 2823 |
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
| 2824 |
+
gap: 12px;
|
| 2825 |
+
}
|
| 2826 |
+
|
| 2827 |
+
@media (max-width: 720px) {
|
| 2828 |
+
.mc-stats {
|
| 2829 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 2830 |
+
}
|
| 2831 |
+
}
|
| 2832 |
+
|
| 2833 |
+
.mc-stat {
|
| 2834 |
+
display: grid;
|
| 2835 |
+
gap: 2px;
|
| 2836 |
+
padding: 14px 18px;
|
| 2837 |
+
border: 1px solid var(--line);
|
| 2838 |
+
border-radius: var(--radius-sm);
|
| 2839 |
+
color: var(--white);
|
| 2840 |
+
text-decoration: none;
|
| 2841 |
+
transition: border-color 0.2s;
|
| 2842 |
+
}
|
| 2843 |
+
|
| 2844 |
+
.mc-stat:hover {
|
| 2845 |
+
border-color: var(--line-strong);
|
| 2846 |
+
}
|
| 2847 |
+
|
| 2848 |
+
.mc-stat strong {
|
| 2849 |
+
font: 600 1.6rem var(--font-display, inherit);
|
| 2850 |
+
}
|
| 2851 |
+
|
| 2852 |
+
/* Notebook view: chat takes the room, one side panel with Create / Posts tabs */
|
| 2853 |
+
@media (min-width: 1100px) {
|
| 2854 |
+
.nbv {
|
| 2855 |
+
grid-template-columns: minmax(0, 1fr) minmax(340px, 420px);
|
| 2856 |
+
}
|
| 2857 |
+
}
|
| 2858 |
+
|
| 2859 |
+
.nbv-titlebar {
|
| 2860 |
+
display: flex;
|
| 2861 |
+
align-items: baseline;
|
| 2862 |
+
gap: 8px;
|
| 2863 |
+
min-width: 0;
|
| 2864 |
+
}
|
| 2865 |
+
|
| 2866 |
+
.nbv-titlebar .nbv-title {
|
| 2867 |
+
margin: 0;
|
| 2868 |
+
overflow: hidden;
|
| 2869 |
+
text-overflow: ellipsis;
|
| 2870 |
+
white-space: nowrap;
|
| 2871 |
+
}
|
| 2872 |
+
|
| 2873 |
+
.nbv-empty {
|
| 2874 |
+
display: grid;
|
| 2875 |
+
justify-items: center;
|
| 2876 |
+
gap: 12px;
|
| 2877 |
+
padding: 48px 16px;
|
| 2878 |
+
text-align: center;
|
| 2879 |
+
color: var(--blue);
|
| 2880 |
+
}
|
| 2881 |
+
|
| 2882 |
+
.nbv-starters {
|
| 2883 |
+
display: grid;
|
| 2884 |
+
gap: 8px;
|
| 2885 |
+
width: min(100%, 520px);
|
| 2886 |
+
}
|
| 2887 |
+
|
| 2888 |
+
.nbv-starter {
|
| 2889 |
+
padding: 12px 16px;
|
| 2890 |
+
border: 1px solid var(--line-strong);
|
| 2891 |
+
border-radius: var(--radius-sm);
|
| 2892 |
+
background: transparent;
|
| 2893 |
+
color: var(--text);
|
| 2894 |
+
font: inherit;
|
| 2895 |
+
text-align: left;
|
| 2896 |
+
cursor: pointer;
|
| 2897 |
+
transition: border-color 0.2s, background 0.2s;
|
| 2898 |
+
}
|
| 2899 |
+
|
| 2900 |
+
.nbv-starter:hover {
|
| 2901 |
+
border-color: var(--blue-60);
|
| 2902 |
+
background: var(--blue-05);
|
| 2903 |
+
}
|
| 2904 |
+
|
| 2905 |
+
.nbv-ask .input {
|
| 2906 |
+
height: 52px;
|
| 2907 |
+
font-size: 1rem;
|
| 2908 |
+
}
|
| 2909 |
+
|
| 2910 |
+
.nbv-ask .btn {
|
| 2911 |
+
height: 52px;
|
| 2912 |
+
padding: 0 24px;
|
| 2913 |
+
}
|
| 2914 |
+
|
| 2915 |
+
.nbv-make {
|
| 2916 |
+
display: grid;
|
| 2917 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 2918 |
+
gap: 8px;
|
| 2919 |
+
}
|
| 2920 |
+
|
| 2921 |
+
.nbv-make button {
|
| 2922 |
+
padding: 12px;
|
| 2923 |
+
border: 1px solid var(--line);
|
| 2924 |
+
border-radius: var(--radius-sm);
|
| 2925 |
+
background: transparent;
|
| 2926 |
+
color: var(--white);
|
| 2927 |
+
font: 500 0.875rem var(--font-body);
|
| 2928 |
+
text-align: left;
|
| 2929 |
+
cursor: pointer;
|
| 2930 |
+
transition: border-color 0.2s, box-shadow 0.2s;
|
| 2931 |
+
}
|
| 2932 |
+
|
| 2933 |
+
.nbv-make button:hover:not(:disabled) {
|
| 2934 |
+
border-color: var(--blue-60);
|
| 2935 |
+
}
|
| 2936 |
+
|
| 2937 |
+
.nbv-make button:disabled {
|
| 2938 |
+
opacity: 0.5;
|
| 2939 |
+
cursor: default;
|
| 2940 |
+
}
|
| 2941 |
+
|
| 2942 |
+
.nbv-make .nbv-make-main {
|
| 2943 |
+
grid-column: 1 / -1;
|
| 2944 |
+
display: grid;
|
| 2945 |
+
gap: 2px;
|
| 2946 |
+
padding: 16px;
|
| 2947 |
+
border-color: var(--blue-35);
|
| 2948 |
+
background: var(--blue-05);
|
| 2949 |
+
}
|
| 2950 |
+
|
| 2951 |
+
.nbv-make .nbv-make-main strong {
|
| 2952 |
+
font-size: 1rem;
|
| 2953 |
+
}
|
| 2954 |
+
|
| 2955 |
+
/* Notebooks: big clickable cards, options in a menu */
|
| 2956 |
+
.nb-grid {
|
| 2957 |
+
display: grid;
|
| 2958 |
+
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
| 2959 |
+
gap: 14px;
|
| 2960 |
+
}
|
| 2961 |
+
|
| 2962 |
+
.nb-grid .nb-card {
|
| 2963 |
+
position: relative;
|
| 2964 |
+
min-height: 170px;
|
| 2965 |
+
}
|
| 2966 |
+
|
| 2967 |
+
.nb-grid .nb-card-link {
|
| 2968 |
+
height: 100%;
|
| 2969 |
+
align-content: start;
|
| 2970 |
+
color: var(--white);
|
| 2971 |
+
}
|
| 2972 |
+
|
| 2973 |
+
.nb-card-link svg {
|
| 2974 |
+
color: var(--blue);
|
| 2975 |
+
}
|
| 2976 |
+
|
| 2977 |
+
.nb-archive {
|
| 2978 |
+
border-color: var(--blue-35);
|
| 2979 |
+
background: var(--blue-05);
|
| 2980 |
+
}
|
| 2981 |
+
|
| 2982 |
+
.nb-new {
|
| 2983 |
+
display: grid;
|
| 2984 |
+
justify-items: start;
|
| 2985 |
+
align-content: center;
|
| 2986 |
+
gap: 4px;
|
| 2987 |
+
border-style: dashed;
|
| 2988 |
+
border-color: var(--line-strong);
|
| 2989 |
+
color: var(--white);
|
| 2990 |
+
font: inherit;
|
| 2991 |
+
text-align: left;
|
| 2992 |
+
cursor: pointer;
|
| 2993 |
+
}
|
| 2994 |
+
|
| 2995 |
+
.nb-new:hover {
|
| 2996 |
+
border-color: var(--blue-60);
|
| 2997 |
+
}
|
| 2998 |
+
|
| 2999 |
+
.card-menu {
|
| 3000 |
+
position: absolute;
|
| 3001 |
+
top: 10px;
|
| 3002 |
+
right: 10px;
|
| 3003 |
+
}
|
| 3004 |
+
|
| 3005 |
+
.card-menu-list {
|
| 3006 |
+
position: absolute;
|
| 3007 |
+
top: 34px;
|
| 3008 |
+
right: 0;
|
| 3009 |
+
z-index: 5;
|
| 3010 |
+
display: grid;
|
| 3011 |
+
min-width: 140px;
|
| 3012 |
+
padding: 6px;
|
| 3013 |
+
border: 1px solid var(--line-strong);
|
| 3014 |
+
border-radius: var(--radius-sm);
|
| 3015 |
+
background: var(--black, #000);
|
| 3016 |
+
box-shadow: var(--glow-sm);
|
| 3017 |
+
}
|
| 3018 |
+
|
| 3019 |
+
.card-menu-list button {
|
| 3020 |
+
padding: 8px 10px;
|
| 3021 |
+
border: 0;
|
| 3022 |
+
border-radius: 6px;
|
| 3023 |
+
background: none;
|
| 3024 |
+
color: var(--text);
|
| 3025 |
+
font: inherit;
|
| 3026 |
+
text-align: left;
|
| 3027 |
+
cursor: pointer;
|
| 3028 |
+
}
|
| 3029 |
+
|
| 3030 |
+
.card-menu-list button:hover {
|
| 3031 |
+
background: var(--blue-10);
|
| 3032 |
+
}
|
| 3033 |
+
|
| 3034 |
+
.nb-matches {
|
| 3035 |
+
max-height: 320px;
|
| 3036 |
+
overflow: auto;
|
| 3037 |
+
}
|
| 3038 |
+
|
| 3039 |
+
/* Create (Studio): format tiles and one row of choices */
|
| 3040 |
+
.format-grid {
|
| 3041 |
+
display: grid;
|
| 3042 |
+
grid-template-columns: repeat(5, minmax(0, 1fr));
|
| 3043 |
+
gap: 12px;
|
| 3044 |
+
}
|
| 3045 |
+
|
| 3046 |
+
@media (max-width: 900px) {
|
| 3047 |
+
.format-grid {
|
| 3048 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 3049 |
+
}
|
| 3050 |
+
}
|
| 3051 |
+
|
| 3052 |
+
.format-tile {
|
| 3053 |
+
display: grid;
|
| 3054 |
+
justify-items: center;
|
| 3055 |
+
align-content: center;
|
| 3056 |
+
gap: 10px;
|
| 3057 |
+
min-height: 120px;
|
| 3058 |
+
padding: 16px 10px;
|
| 3059 |
+
border: 1px solid var(--line);
|
| 3060 |
+
border-radius: var(--radius-sm);
|
| 3061 |
+
background: transparent;
|
| 3062 |
+
color: var(--white);
|
| 3063 |
+
font: inherit;
|
| 3064 |
+
text-align: center;
|
| 3065 |
+
text-decoration: none;
|
| 3066 |
+
cursor: pointer;
|
| 3067 |
+
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
| 3068 |
+
}
|
| 3069 |
+
|
| 3070 |
+
.format-tile svg {
|
| 3071 |
+
color: var(--blue);
|
| 3072 |
+
}
|
| 3073 |
+
|
| 3074 |
+
.format-tile:hover {
|
| 3075 |
+
border-color: var(--line-strong);
|
| 3076 |
+
}
|
| 3077 |
+
|
| 3078 |
+
.format-tile.on {
|
| 3079 |
+
border-color: var(--blue);
|
| 3080 |
+
background: var(--blue-05);
|
| 3081 |
+
box-shadow: var(--glow-sm);
|
| 3082 |
+
}
|
| 3083 |
+
|
| 3084 |
+
.studio-row {
|
| 3085 |
+
display: flex;
|
| 3086 |
+
flex-wrap: wrap;
|
| 3087 |
+
align-items: flex-end;
|
| 3088 |
+
gap: 12px;
|
| 3089 |
+
}
|
| 3090 |
+
|
| 3091 |
+
.studio-row .field {
|
| 3092 |
+
flex: 1 1 200px;
|
| 3093 |
+
}
|
| 3094 |
+
|
| 3095 |
+
.studio-row .field:first-child {
|
| 3096 |
+
flex: 2 1 280px;
|
| 3097 |
+
}
|
| 3098 |
+
|
| 3099 |
+
.studio-go {
|
| 3100 |
+
height: 44px;
|
| 3101 |
+
padding: 0 26px;
|
| 3102 |
+
}
|
| 3103 |
+
|
| 3104 |
+
/* Launch Kit: post cards to start, a thin rail of kits once one is open */
|
| 3105 |
+
@media (min-width: 1100px) {
|
| 3106 |
+
.kit-layout.kit-open {
|
| 3107 |
+
grid-template-columns: minmax(200px, 260px) minmax(0, 1fr);
|
| 3108 |
+
}
|
| 3109 |
+
}
|
| 3110 |
+
|
| 3111 |
+
.kit-rail {
|
| 3112 |
+
display: grid;
|
| 3113 |
+
gap: 10px;
|
| 3114 |
+
align-content: start;
|
| 3115 |
+
}
|
| 3116 |
+
|
| 3117 |
+
.post-cards {
|
| 3118 |
+
display: grid;
|
| 3119 |
+
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
| 3120 |
+
gap: 12px;
|
| 3121 |
+
}
|
| 3122 |
+
|
| 3123 |
+
.post-card {
|
| 3124 |
+
display: grid;
|
| 3125 |
+
align-content: space-between;
|
| 3126 |
+
gap: 10px;
|
| 3127 |
+
min-height: 150px;
|
| 3128 |
+
padding: 18px;
|
| 3129 |
+
border: 1px solid var(--line);
|
| 3130 |
+
border-radius: var(--radius-sm);
|
| 3131 |
+
background: transparent;
|
| 3132 |
+
color: var(--white);
|
| 3133 |
+
font: inherit;
|
| 3134 |
+
text-align: left;
|
| 3135 |
+
cursor: pointer;
|
| 3136 |
+
transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
|
| 3137 |
+
}
|
| 3138 |
+
|
| 3139 |
+
.post-card strong {
|
| 3140 |
+
font-size: 1.02rem;
|
| 3141 |
+
line-height: 1.35;
|
| 3142 |
+
}
|
| 3143 |
+
|
| 3144 |
+
.post-card-go {
|
| 3145 |
+
color: var(--blue);
|
| 3146 |
+
font-size: 0.8125rem;
|
| 3147 |
+
font-weight: 600;
|
| 3148 |
+
}
|
| 3149 |
+
|
| 3150 |
+
.post-card:hover:not(:disabled) {
|
| 3151 |
+
border-color: var(--blue-60);
|
| 3152 |
+
box-shadow: var(--glow-sm);
|
| 3153 |
+
transform: translateY(-2px);
|
| 3154 |
+
}
|
| 3155 |
+
|
| 3156 |
+
.post-card:disabled {
|
| 3157 |
+
opacity: 0.6;
|
| 3158 |
+
cursor: default;
|
| 3159 |
+
}
|
| 3160 |
+
|
| 3161 |
+
/* Launchpad: account chips in one line, calendar first, ideas beside it */
|
| 3162 |
+
.lp-accounts {
|
| 3163 |
+
display: flex;
|
| 3164 |
+
flex-wrap: wrap;
|
| 3165 |
+
align-items: center;
|
| 3166 |
+
gap: 8px;
|
| 3167 |
+
}
|
| 3168 |
+
|
| 3169 |
+
.lp-chip {
|
| 3170 |
+
display: inline-flex;
|
| 3171 |
+
align-items: center;
|
| 3172 |
+
gap: 6px;
|
| 3173 |
+
padding: 5px 12px;
|
| 3174 |
+
border: 1px solid var(--line-strong);
|
| 3175 |
+
border-radius: 999px;
|
| 3176 |
+
background: transparent;
|
| 3177 |
+
color: var(--white);
|
| 3178 |
+
font: inherit;
|
| 3179 |
+
font-size: 0.8125rem;
|
| 3180 |
+
}
|
| 3181 |
+
|
| 3182 |
+
.lp-chip img {
|
| 3183 |
+
border-radius: 999px;
|
| 3184 |
+
}
|
| 3185 |
+
|
| 3186 |
+
.lp-chip.warn {
|
| 3187 |
+
border-color: #ff8a3d;
|
| 3188 |
+
}
|
| 3189 |
+
|
| 3190 |
+
.lp-chip.add {
|
| 3191 |
+
border-style: dashed;
|
| 3192 |
+
color: var(--text-2);
|
| 3193 |
+
cursor: pointer;
|
| 3194 |
+
}
|
| 3195 |
+
|
| 3196 |
+
.lp-chip.add:hover {
|
| 3197 |
+
border-color: var(--blue);
|
| 3198 |
+
color: var(--white);
|
| 3199 |
+
}
|
| 3200 |
+
|
| 3201 |
+
.lp-layout {
|
| 3202 |
+
display: grid;
|
| 3203 |
+
gap: 16px;
|
| 3204 |
+
align-items: start;
|
| 3205 |
+
}
|
| 3206 |
+
|
| 3207 |
+
@media (min-width: 1200px) {
|
| 3208 |
+
.lp-layout {
|
| 3209 |
+
grid-template-columns: minmax(0, 1fr) minmax(300px, 380px);
|
| 3210 |
+
}
|
| 3211 |
+
}
|
| 3212 |
+
|
| 3213 |
+
.lp-ideas {
|
| 3214 |
+
align-content: start;
|
| 3215 |
+
}
|
| 3216 |
+
|
| 3217 |
+
.lp-ideas .idea {
|
| 3218 |
+
display: grid;
|
| 3219 |
+
gap: 6px;
|
| 3220 |
+
padding-bottom: 12px;
|
| 3221 |
+
border-bottom: 1px solid var(--line);
|
| 3222 |
+
}
|
| 3223 |
+
|
| 3224 |
+
.lp-ideas .idea:last-child {
|
| 3225 |
+
border-bottom: 0;
|
| 3226 |
+
}
|
| 3227 |
+
|
| 3228 |
+
.lp-empty {
|
| 3229 |
+
display: flex;
|
| 3230 |
+
flex-wrap: wrap;
|
| 3231 |
+
align-items: center;
|
| 3232 |
+
justify-content: space-between;
|
| 3233 |
+
gap: 12px;
|
| 3234 |
+
margin-bottom: 12px;
|
| 3235 |
+
padding: 12px 14px;
|
| 3236 |
+
border: 1px dashed var(--line-strong);
|
| 3237 |
+
border-radius: var(--radius-sm);
|
| 3238 |
+
}
|
| 3239 |
+
|
| 3240 |
+
.lp-empty p {
|
| 3241 |
+
margin: 0;
|
| 3242 |
+
}
|
| 3243 |
+
|
| 3244 |
+
/* Topic map: full width until a topic or the briefing is open */
|
| 3245 |
+
@media (min-width: 1100px) {
|
| 3246 |
+
.map-layout.map-full {
|
| 3247 |
+
grid-template-columns: minmax(0, 1fr);
|
| 3248 |
+
}
|
| 3249 |
+
}
|
| 3250 |
+
|
| 3251 |
+
.map-menu {
|
| 3252 |
+
position: relative;
|
| 3253 |
+
top: auto;
|
| 3254 |
+
right: auto;
|
| 3255 |
+
}
|
| 3256 |
+
|
| 3257 |
+
details.map-legend summary {
|
| 3258 |
+
cursor: pointer;
|
| 3259 |
+
list-style: none;
|
| 3260 |
+
}
|
| 3261 |
+
|
| 3262 |
+
details.map-legend:not([open]) {
|
| 3263 |
+
padding: 4px 10px;
|
| 3264 |
+
}
|
| 3265 |
+
|
| 3266 |
+
details.map-legend[open] summary {
|
| 3267 |
+
margin-bottom: 4px;
|
| 3268 |
+
}
|
| 3269 |
+
|
| 3270 |
+
.topic-actions {
|
| 3271 |
+
display: grid;
|
| 3272 |
+
gap: 8px;
|
| 3273 |
+
}
|
| 3274 |
+
|
| 3275 |
+
/* Sources: one smart input, files drop anywhere */
|
| 3276 |
+
.add-smart {
|
| 3277 |
+
display: flex;
|
| 3278 |
+
flex-wrap: wrap;
|
| 3279 |
+
align-items: center;
|
| 3280 |
+
gap: 10px;
|
| 3281 |
+
}
|
| 3282 |
+
|
| 3283 |
+
.add-smart .input {
|
| 3284 |
+
flex: 1 1 320px;
|
| 3285 |
+
height: 50px;
|
| 3286 |
+
font-size: 1rem;
|
| 3287 |
+
}
|
| 3288 |
+
|
| 3289 |
+
.add-smart .btn {
|
| 3290 |
+
height: 50px;
|
| 3291 |
+
padding: 0 22px;
|
| 3292 |
+
}
|
| 3293 |
+
|
| 3294 |
+
.page-wrap.drop-over {
|
| 3295 |
+
outline: 2px dashed var(--blue);
|
| 3296 |
+
outline-offset: 8px;
|
| 3297 |
+
border-radius: var(--radius);
|
| 3298 |
+
}
|
| 3299 |
+
|
| 3300 |
+
/* Sidebar: seven stops, Settings pinned to the bottom */
|
| 3301 |
+
@media (min-width: 900px) {
|
| 3302 |
+
.sidebar .side-settings {
|
| 3303 |
+
margin-top: auto;
|
| 3304 |
+
}
|
| 3305 |
+
|
| 3306 |
+
.sidebar .side-settings ~ .sidebar-user {
|
| 3307 |
+
margin-top: 0;
|
| 3308 |
+
}
|
| 3309 |
+
}
|
| 3310 |
+
|
| 3311 |
+
@media (max-width: 899px) {
|
| 3312 |
+
.sidebar {
|
| 3313 |
+
position: relative;
|
| 3314 |
+
}
|
| 3315 |
+
|
| 3316 |
+
.sidebar .side-settings {
|
| 3317 |
+
position: absolute;
|
| 3318 |
+
top: 14px;
|
| 3319 |
+
right: 16px;
|
| 3320 |
+
padding: 8px;
|
| 3321 |
+
}
|
| 3322 |
+
|
| 3323 |
+
.sidebar .side-settings .side-label {
|
| 3324 |
+
display: none;
|
| 3325 |
+
}
|
| 3326 |
+
}
|
| 3327 |
+
|
| 3328 |
+
@media (min-width: 900px) {
|
| 3329 |
+
.shell.collapsed .side-settings {
|
| 3330 |
+
display: none;
|
| 3331 |
+
}
|
| 3332 |
+
}
|
| 3333 |
+
|
| 3334 |
+
.tabs a.tab {
|
| 3335 |
+
text-decoration: none;
|
| 3336 |
+
}
|