Muhammad-Mehdi commited on
Commit
fa6e4a3
·
1 Parent(s): 4a3d0a5

changes added

Browse files
backend/app/routers/launchpad.py CHANGED
@@ -21,11 +21,21 @@ from app.config import settings
21
  from app.db import get_db
22
  from app.models import Artifact, CalendarItem, Document, EngagementEvent, SocialAccount, TrackedLink, Upload
23
  from app.services import launchpad as lp
24
- from app.services.artifacts import TYPE_LABELS
25
  from app.services.crypto import decrypt, encrypt
26
  from app.services.social import AUTO_POST, LIMITS, PLATFORM_LABELS, SocialError, bluesky, linkedin, x
27
  from app.services.social.health import pause_account_posts, resume_account_posts
28
- from app.services.social.media import MB, SIZE_LIMITS, UPLOAD_TYPES, check_postable, duration, is_blog2video, media_count, upload_kind
 
 
 
 
 
 
 
 
 
 
29
  from app.services.storage import storage
30
 
31
  router = APIRouter(prefix="/api", tags=["launchpad"])
@@ -61,7 +71,7 @@ class When(BaseModel):
61
 
62
  class ItemIn(When):
63
  platform: Platform
64
- content: str = Field(min_length=1, max_length=10000)
65
  thread: list[str] = Field(default_factory=list, max_length=25)
66
  artifact_id: uuid.UUID | None = None # the attachment (a video, quote card, carousel...), never a Launch Kit
67
  kit_id: uuid.UUID | None = None # the Launch Kit the post was written from (tracked, not attached)
@@ -69,11 +79,13 @@ class ItemIn(When):
69
  social_account_id: uuid.UUID | None = None
70
  remind_by_email: bool = False
71
  draft: bool = False
 
 
72
 
73
 
74
  class ItemPatch(When):
75
  artifact_id: uuid.UUID | None = None # set or (null) clear what the post carries
76
- content: str | None = Field(None, min_length=1, max_length=10000)
77
  thread: list[str] | None = None
78
  social_account_id: uuid.UUID | None = None
79
  remind_by_email: bool | None = None
@@ -85,7 +97,9 @@ def _aware(dt: datetime) -> datetime:
85
 
86
 
87
  # audio can't be posted; "upload" is a file the user brought from their computer
88
- POSTABLE = ("video", "quote_card", "carousel", "summary", "launch_kit", "mind_map", "upload")
 
 
89
 
90
 
91
  def _cite_free(text: str) -> str:
@@ -107,22 +121,38 @@ def _prefill(a: Artifact) -> dict:
107
  "quote_card": f'"{c.get("quote")}"' + (f" ({c['source']})" if c.get("source") else "") if c.get("quote")
108
  else title,
109
  "carousel": ((c.get("slides") or [{}])[0].get("heading") or title),
 
110
  }.get(a.type, title)
111
  return {"x": [text], "linkedin": text}
112
 
113
 
 
 
 
 
 
 
 
 
 
 
114
  def artifact_brief(a: Artifact) -> dict:
115
  """What the composer and the calendar show of the attached artifact."""
116
  c = a.content_json or {}
117
  thumb = (a.storage_key if a.type == "quote_card" or upload_kind(a) == "image"
118
  else (c.get("slide_keys") or [a.storage_key])[0] if a.type == "carousel" else None)
119
  return {"id": str(a.id), "type": a.type, "type_label": TYPE_LABELS.get(a.type, a.type),
120
- "title": c.get("title") or TYPE_LABELS.get(a.type, a.type),
121
- "media": upload_kind(a) or ("video" if a.type == "video" else "image" if media_count(a) else "text"),
 
 
122
  "media_count": media_count(a), "duration_s": duration(a),
123
  "thumb_url": storage.presign_get(thumb) if thumb else None,
124
  # The user's own file, to preview before posting it.
125
  "view_url": storage.presign_get(a.storage_key) if a.type == "upload" and a.storage_key else None,
 
 
 
126
  # A blog2video video with scenes but no MP4 yet: listed, but it has to be rendered before it can go out.
127
  # Made by the video service (blog2video): it opens in the video editor.
128
  "editable_video": is_blog2video(a),
@@ -197,6 +227,12 @@ def _require_connected(platform: str, account: SocialAccount | None, artifact: A
197
  raise HTTPException(409, {"code": "reconnect", "message": f"Reconnect {label} to post images and videos."})
198
 
199
 
 
 
 
 
 
 
200
  def _validate_lengths(platform: str, posts: list[str]) -> None:
201
  limit = LIMITS.get(platform)
202
  if platform in ("x", "bluesky") and limit:
@@ -251,12 +287,13 @@ def list_items(ctx: Ctx = Depends(get_ctx), start: datetime | None = None, end:
251
 
252
  @router.post("/calendar")
253
  def create_item(body: ItemIn, ctx: Ctx = Depends(get_ctx)):
254
- when = body.utc()
255
  if when is None:
256
  raise HTTPException(422, {"code": "no_time", "message": "Pick when it goes out."})
257
  account = _account(ctx, body.social_account_id, body.platform)
258
  _validate_lengths(body.platform, [body.content, *body.thread])
259
  artifact = _postable(ctx, body.artifact_id, body.platform) if body.artifact_id else None
 
260
  kit = _kit(ctx, body.kit_id) if body.kit_id else None
261
  _require_connected(body.platform, account, artifact)
262
  if body.document_id and not ctx.db.scalar(select(Document.id).where(Document.id == body.document_id,
@@ -268,13 +305,24 @@ def create_item(body: ItemIn, ctx: Ctx = Depends(get_ctx)):
268
  kit_id=kit.id if kit else None,
269
  document_id=body.document_id, social_account_id=account.id if account else None,
270
  remind_by_email=body.remind_by_email or body.platform not in AUTO_POST or not account,
271
- status="draft" if body.draft else "scheduled",
272
  )
273
  ctx.db.add(item)
274
  ctx.db.commit()
 
 
275
  return _enrich(ctx, [item])[0]
276
 
277
 
 
 
 
 
 
 
 
 
 
278
  @router.patch("/calendar/{item_id}")
279
  def update_item(item_id: uuid.UUID, body: ItemPatch, ctx: Ctx = Depends(get_ctx)):
280
  item = _item(ctx, item_id)
@@ -301,15 +349,17 @@ def update_item(item_id: uuid.UUID, body: ItemPatch, ctx: Ctx = Depends(get_ctx)
301
  if body.status == "paused":
302
  item.status = "paused" # held by hand: never attempted until resumed
303
  elif body.status is not None or item.status in ("failed", "reminded", "paused"):
304
- # Going out (again): only through a live connection, and only at a time still ahead.
 
305
  _require_connected(item.platform, account, artifact)
306
- if item.status == "paused" and _aware(item.scheduled_at) <= datetime.now(UTC):
307
  raise HTTPException(409, {"code": "missed", "message": "Pick a time that is still ahead."})
308
  item.status = body.status or "scheduled"
309
  elif item.platform in CONNECTED_ONLY and body.model_fields_set & {"social_account_id", "artifact_id"}:
310
  _require_connected(item.platform, account, artifact)
311
  item.error = None
312
  item.attempts = 0
 
313
  _validate_lengths(item.platform, [item.content, *(item.thread or [])])
314
  ctx.db.commit()
315
  return _enrich(ctx, [item])[0]
@@ -324,17 +374,16 @@ def delete_item(item_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
324
 
325
 
326
  @router.post("/calendar/{item_id}/publish")
327
- async def publish_now(item_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
 
 
328
  item = _item(ctx, item_id)
329
  if item.status in ("posted", "publishing"):
330
  raise HTTPException(409, "This post already went out.")
331
  item.status = "publishing"
332
  item.attempts = 0
333
  ctx.db.commit()
334
- if lp.has_media(ctx.db, item):
335
- lp.queue_publish(ctx.db, item) # uploading takes a while: a job does it, the page follows the status
336
- else:
337
- await run_in_threadpool(lp.publish_item, ctx.db, item)
338
  return _enrich(ctx, [item])[0]
339
 
340
 
@@ -373,11 +422,12 @@ def register_upload(body: UploadIn, ctx: Ctx = Depends(get_ctx)):
373
 
374
  @router.get("/launchpad/postable")
375
  def postable(ctx: Ctx = Depends(get_ctx), q: str = "", limit: int = 60):
376
- """What can be attached to a post: finished videos, quote cards, carousels, summaries, launch kits, mind maps
377
- (not audio), newest first. Videos made but not rendered yet are listed too, flagged needs_render (they can only be
378
- posted once rendered), and so are videos being rendered, flagged rendering."""
 
379
  rows = ctx.db.scalars(select(Artifact).where(Artifact.workspace_id == ctx.workspace.id,
380
- Artifact.type.in_(POSTABLE))
381
  .order_by(Artifact.created_at.desc()).limit(400))
382
  needle = q.strip().lower()
383
  out = []
@@ -391,6 +441,18 @@ def postable(ctx: Ctx = Depends(get_ctx), q: str = "", limit: int = 60):
391
  out.append(artifact_brief(a))
392
  if len(out) >= min(limit, 200):
393
  break
 
 
 
 
 
 
 
 
 
 
 
 
394
  return out
395
 
396
 
 
21
  from app.db import get_db
22
  from app.models import Artifact, CalendarItem, Document, EngagementEvent, SocialAccount, TrackedLink, Upload
23
  from app.services import launchpad as lp
24
+ from app.services.artifacts import TYPE_LABELS, download_name
25
  from app.services.crypto import decrypt, encrypt
26
  from app.services.social import AUTO_POST, LIMITS, PLATFORM_LABELS, SocialError, bluesky, linkedin, x
27
  from app.services.social.health import pause_account_posts, resume_account_posts
28
+ from app.services.social.media import (
29
+ MB,
30
+ SIZE_LIMITS,
31
+ UPLOAD_TYPES,
32
+ carries_media,
33
+ check_postable,
34
+ duration,
35
+ is_blog2video,
36
+ media_count,
37
+ upload_kind,
38
+ )
39
  from app.services.storage import storage
40
 
41
  router = APIRouter(prefix="/api", tags=["launchpad"])
 
71
 
72
  class ItemIn(When):
73
  platform: Platform
74
+ content: str = Field("", max_length=10000) # may be empty when the post carries images or a video
75
  thread: list[str] = Field(default_factory=list, max_length=25)
76
  artifact_id: uuid.UUID | None = None # the attachment (a video, quote card, carousel...), never a Launch Kit
77
  kit_id: uuid.UUID | None = None # the Launch Kit the post was written from (tracked, not attached)
 
79
  social_account_id: uuid.UUID | None = None
80
  remind_by_email: bool = False
81
  draft: bool = False
82
+ # Post now: published (or, by email, reminded) in this request, never left scheduled; no time needed
83
+ publish_now: bool = False
84
 
85
 
86
  class ItemPatch(When):
87
  artifact_id: uuid.UUID | None = None # set or (null) clear what the post carries
88
+ content: str | None = Field(None, max_length=10000)
89
  thread: list[str] | None = None
90
  social_account_id: uuid.UUID | None = None
91
  remind_by_email: bool | None = None
 
97
 
98
 
99
  # audio can't be posted; "upload" is a file the user brought from their computer
100
+ POSTABLE = ("video", "quote_card", "carousel", "summary", "launch_kit", "mind_map", "upload", "slide_deck")
101
+ # Listed on the Launchpad too, but only to download: X and LinkedIn take no audio files (check_postable refuses it)
102
+ LISTED = (*POSTABLE, "audio_overview")
103
 
104
 
105
  def _cite_free(text: str) -> str:
 
121
  "quote_card": f'"{c.get("quote")}"' + (f" ({c['source']})" if c.get("source") else "") if c.get("quote")
122
  else title,
123
  "carousel": ((c.get("slides") or [{}])[0].get("heading") or title),
124
+ "slide_deck": _deck_prefill(c) or title,
125
  }.get(a.type, title)
126
  return {"x": [text], "linkedin": text}
127
 
128
 
129
+ def _deck_prefill(c: dict) -> str:
130
+ """A slide deck's post: its title, its subtitle, and the takeaways of its closing slide."""
131
+ deck = c.get("deck") or {}
132
+ slides = deck.get("slides") or []
133
+ closing = next((s for s in reversed(slides) if s.get("layout") == "closing"), {})
134
+ head = "\n\n".join(x for x in (deck.get("title"), deck.get("subtitle")) if x)
135
+ takeaways = "\n".join(f"- {t}" for t in closing.get("takeaways") or [])
136
+ return "\n\n".join(x for x in (head, takeaways) if x)
137
+
138
+
139
  def artifact_brief(a: Artifact) -> dict:
140
  """What the composer and the calendar show of the attached artifact."""
141
  c = a.content_json or {}
142
  thumb = (a.storage_key if a.type == "quote_card" or upload_kind(a) == "image"
143
  else (c.get("slide_keys") or [a.storage_key])[0] if a.type == "carousel" else None)
144
  return {"id": str(a.id), "type": a.type, "type_label": TYPE_LABELS.get(a.type, a.type),
145
+ # A deck's title starts "Slide deck: ", which its tag already says
146
+ "title": (c.get("title") or TYPE_LABELS.get(a.type, a.type)).removeprefix("Slide deck: " if a.type == "slide_deck" else ""),
147
+ "media": upload_kind(a) or ("video" if a.type == "video" else "audio" if a.type == "audio_overview"
148
+ else "image" if media_count(a) else "text"),
149
  "media_count": media_count(a), "duration_s": duration(a),
150
  "thumb_url": storage.presign_get(thumb) if thumb else None,
151
  # The user's own file, to preview before posting it.
152
  "view_url": storage.presign_get(a.storage_key) if a.type == "upload" and a.storage_key else None,
153
+ # An audio overview can't be posted: it is offered to download instead.
154
+ "download_url": (storage.presign_get(a.storage_key, download_name=download_name(a))
155
+ if a.type == "audio_overview" and a.storage_key else None),
156
  # A blog2video video with scenes but no MP4 yet: listed, but it has to be rendered before it can go out.
157
  # Made by the video service (blog2video): it opens in the video editor.
158
  "editable_video": is_blog2video(a),
 
227
  raise HTTPException(409, {"code": "reconnect", "message": f"Reconnect {label} to post images and videos."})
228
 
229
 
230
+ def _require_words(posts: list[str], artifact: Artifact | None) -> None:
231
+ """A post needs words, unless it carries images or a video (those can go out without a caption)."""
232
+ if not any(p.strip() for p in posts) and not carries_media(artifact):
233
+ raise HTTPException(422, {"code": "no_text", "message": "Write something to post, or attach an image or video."})
234
+
235
+
236
  def _validate_lengths(platform: str, posts: list[str]) -> None:
237
  limit = LIMITS.get(platform)
238
  if platform in ("x", "bluesky") and limit:
 
287
 
288
  @router.post("/calendar")
289
  def create_item(body: ItemIn, ctx: Ctx = Depends(get_ctx)):
290
+ when = datetime.now(UTC) if body.publish_now else body.utc()
291
  if when is None:
292
  raise HTTPException(422, {"code": "no_time", "message": "Pick when it goes out."})
293
  account = _account(ctx, body.social_account_id, body.platform)
294
  _validate_lengths(body.platform, [body.content, *body.thread])
295
  artifact = _postable(ctx, body.artifact_id, body.platform) if body.artifact_id else None
296
+ _require_words([body.content, *body.thread], artifact)
297
  kit = _kit(ctx, body.kit_id) if body.kit_id else None
298
  _require_connected(body.platform, account, artifact)
299
  if body.document_id and not ctx.db.scalar(select(Document.id).where(Document.id == body.document_id,
 
305
  kit_id=kit.id if kit else None,
306
  document_id=body.document_id, social_account_id=account.id if account else None,
307
  remind_by_email=body.remind_by_email or body.platform not in AUTO_POST or not account,
308
+ status="draft" if body.draft else "publishing" if body.publish_now else "scheduled",
309
  )
310
  ctx.db.add(item)
311
  ctx.db.commit()
312
+ if body.publish_now:
313
+ _go_now(ctx, item)
314
  return _enrich(ctx, [item])[0]
315
 
316
 
317
+ def _go_now(ctx: Ctx, item: CalendarItem) -> None:
318
+ """Publish an item claimed for it ("publishing") now: with images or a video through a job (uploading takes a
319
+ while; the page follows its status), anything else right here."""
320
+ if lp.has_media(ctx.db, item):
321
+ lp.queue_publish(ctx.db, item)
322
+ else:
323
+ lp.publish_item(ctx.db, item)
324
+
325
+
326
  @router.patch("/calendar/{item_id}")
327
  def update_item(item_id: uuid.UUID, body: ItemPatch, ctx: Ctx = Depends(get_ctx)):
328
  item = _item(ctx, item_id)
 
349
  if body.status == "paused":
350
  item.status = "paused" # held by hand: never attempted until resumed
351
  elif body.status is not None or item.status in ("failed", "reminded", "paused"):
352
+ # Going out (again), rescheduled: only through a live connection, and only at a time still ahead (a failed or
353
+ # reminded post's old time has passed, and it would otherwise go out at the very next run).
354
  _require_connected(item.platform, account, artifact)
355
+ if (body.status or "scheduled") == "scheduled" and _aware(item.scheduled_at) <= datetime.now(UTC):
356
  raise HTTPException(409, {"code": "missed", "message": "Pick a time that is still ahead."})
357
  item.status = body.status or "scheduled"
358
  elif item.platform in CONNECTED_ONLY and body.model_fields_set & {"social_account_id", "artifact_id"}:
359
  _require_connected(item.platform, account, artifact)
360
  item.error = None
361
  item.attempts = 0
362
+ _require_words([item.content or "", *(item.thread or [])], artifact) # e.g. the caption emptied, or the video removed
363
  _validate_lengths(item.platform, [item.content, *(item.thread or [])])
364
  ctx.db.commit()
365
  return _enrich(ctx, [item])[0]
 
374
 
375
 
376
  @router.post("/calendar/{item_id}/publish")
377
+ def publish_now(item_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
378
+ """Publish a scheduled post now. A plain (threadpool) endpoint: its database calls and an inline publish must
379
+ never hold up the event loop, and with it every other request."""
380
  item = _item(ctx, item_id)
381
  if item.status in ("posted", "publishing"):
382
  raise HTTPException(409, "This post already went out.")
383
  item.status = "publishing"
384
  item.attempts = 0
385
  ctx.db.commit()
386
+ _go_now(ctx, item)
 
 
 
387
  return _enrich(ctx, [item])[0]
388
 
389
 
 
422
 
423
  @router.get("/launchpad/postable")
424
  def postable(ctx: Ctx = Depends(get_ctx), q: str = "", limit: int = 60):
425
+ """What can be attached to a post: finished videos, quote cards, carousels, summaries, launch kits, mind maps,
426
+ slide decks, newest first, and audio overviews (listed to download: they can't be posted). Videos made but not
427
+ rendered yet are listed too, flagged needs_render (they can only be posted once rendered), and so are videos being
428
+ rendered, flagged rendering."""
429
  rows = ctx.db.scalars(select(Artifact).where(Artifact.workspace_id == ctx.workspace.id,
430
+ Artifact.type.in_(LISTED))
431
  .order_by(Artifact.created_at.desc()).limit(400))
432
  needle = q.strip().lower()
433
  out = []
 
441
  out.append(artifact_brief(a))
442
  if len(out) >= min(limit, 200):
443
  break
444
+ # What each one already has coming up (scheduled, still ahead), soonest first: the list offers to reschedule it
445
+ upcoming = ctx.db.scalars(select(CalendarItem).where(
446
+ CalendarItem.workspace_id == ctx.workspace.id, CalendarItem.status == "scheduled",
447
+ CalendarItem.scheduled_at > datetime.now(UTC),
448
+ CalendarItem.artifact_id.in_([uuid.UUID(b["id"]) for b in out])).order_by(CalendarItem.scheduled_at))
449
+ by_artifact: dict[str, list[dict]] = {}
450
+ for it in upcoming:
451
+ by_artifact.setdefault(str(it.artifact_id), []).append(
452
+ {"id": str(it.id), "scheduled_at": _aware(it.scheduled_at).isoformat(),
453
+ "platform_label": PLATFORM_LABELS.get(it.platform, it.platform)})
454
+ for b in out:
455
+ b["scheduled"] = by_artifact.get(b["id"], [])
456
  return out
457
 
458
 
backend/app/services/artifacts.py CHANGED
@@ -104,7 +104,7 @@ def serialize_artifact(a: Artifact, job: Job | None = None) -> dict:
104
  "storage_key": a.storage_key,
105
  # blog2video videos live on blog2video's storage (video_url once rendered)
106
  "url": storage.presign_get(a.storage_key) if a.storage_key else content.get("video_url"),
107
- "download_url": (storage.presign_get(a.storage_key, download_name=_download_name(a)) if a.storage_key
108
  else content.get("video_url")),
109
  "provider": content.get("provider"),
110
  "slide_urls": [storage.presign_get(k) for k in slides],
@@ -113,7 +113,8 @@ def serialize_artifact(a: Artifact, job: Job | None = None) -> dict:
113
  }
114
 
115
 
116
- def _download_name(a: Artifact) -> str:
 
117
  title = (a.content_json or {}).get("title") or a.type
118
  ext = (a.storage_key or "").rsplit(".", 1)[-1]
119
  return f"{title[:60]}.{ext}"
 
104
  "storage_key": a.storage_key,
105
  # blog2video videos live on blog2video's storage (video_url once rendered)
106
  "url": storage.presign_get(a.storage_key) if a.storage_key else content.get("video_url"),
107
+ "download_url": (storage.presign_get(a.storage_key, download_name=download_name(a)) if a.storage_key
108
  else content.get("video_url")),
109
  "provider": content.get("provider"),
110
  "slide_urls": [storage.presign_get(k) for k in slides],
 
113
  }
114
 
115
 
116
+ def download_name(a: Artifact) -> str:
117
+ """The file name a download is saved under: the artifact's title and its file's extension."""
118
  title = (a.content_json or {}).get("title") or a.type
119
  ext = (a.storage_key or "").rsplit(".", 1)[-1]
120
  return f"{title[:60]}.{ext}"
backend/app/services/launchpad.py CHANGED
@@ -21,7 +21,7 @@ from app.services.email import email_service
21
  from app.services.jobs import create_job
22
  from app.services.social import AUTO_POST, PLATFORM_LABELS, SocialError, module
23
  from app.services.social.health import mark_down
24
- from app.services.social.media import media_count, media_for
25
 
26
  log = logging.getLogger("notestack.launchpad")
27
  _URL = re.compile(r"https?://[^\s)\]>]+")
@@ -60,6 +60,8 @@ def owner_email(db: Session, workspace_id) -> str | None:
60
  def publish_item(db: Session, item: CalendarItem) -> CalendarItem:
61
  """Publish or remind now. Leaves the item posted, reminded, failed, or scheduled again for a retry."""
62
  posts = [p for p in [item.content, *(item.thread or [])] if p and p.strip()]
 
 
63
  if not posts:
64
  item.status, item.error = "failed", "Nothing to post."
65
  db.commit()
 
21
  from app.services.jobs import create_job
22
  from app.services.social import AUTO_POST, PLATFORM_LABELS, SocialError, module
23
  from app.services.social.health import mark_down
24
+ from app.services.social.media import carries_media, media_count, media_for
25
 
26
  log = logging.getLogger("notestack.launchpad")
27
  _URL = re.compile(r"https?://[^\s)\]>]+")
 
60
  def publish_item(db: Session, item: CalendarItem) -> CalendarItem:
61
  """Publish or remind now. Leaves the item posted, reminded, failed, or scheduled again for a retry."""
62
  posts = [p for p in [item.content, *(item.thread or [])] if p and p.strip()]
63
+ if not posts and carries_media(db.get(Artifact, item.artifact_id) if item.artifact_id else None):
64
+ posts = [""] # a video or images on their own: the post goes out without a caption
65
  if not posts:
66
  item.status, item.error = "failed", "Nothing to post."
67
  db.commit()
backend/app/services/social/media.py CHANGED
@@ -1,4 +1,5 @@
1
- """What a scheduled post carries from the artifact it was made from: images (quote cards, carousel slides) or one
 
2
  video (an audiogram, or a blog2video video), or a file the user uploaded from their computer (an `upload`: one image or
3
  one MP4). Summaries, launch kits and mind maps post as text; audio overviews can't
4
  be posted (X and LinkedIn take no audio files).
@@ -69,11 +70,18 @@ def media_count(a: Artifact) -> int:
69
  return 1
70
  if a.type == "carousel":
71
  return len((a.content_json or {}).get("slide_keys") or ([a.storage_key] if a.storage_key else []))
 
 
72
  if a.type in ("video", "upload"):
73
  return 1
74
  return 0
75
 
76
 
 
 
 
 
 
77
  def upload_kind(a: Artifact) -> str | None:
78
  """An uploaded file's kind: image | video."""
79
  return (a.content_json or {}).get("media") if a.type == "upload" else None
@@ -91,13 +99,16 @@ def check_postable(a: Artifact, platform: str) -> None:
91
  raise HTTPException(400, "Audio can't be posted: X and LinkedIn don't take audio files.")
92
  if a.type == "launch_kit": # a post is written from a kit (kit_id); the kit itself is never an attachment
93
  raise HTTPException(400, "A launch kit can't be attached. Attach a video, quote card or carousel instead.")
94
- if a.type not in TEXT_ONLY and a.type not in ("quote_card", "carousel", "video", "upload"):
95
  raise HTTPException(400, "This can't be posted.")
96
  if a.type in TEXT_ONLY:
97
  return
98
  if platform not in MEDIA_PLATFORMS:
99
  raise HTTPException(400, "Images and videos can only be posted to X and LinkedIn.")
100
- if a.type == "video" and is_blog2video(a):
 
 
 
101
  if not (a.content_json or {}).get("video_url"):
102
  raise HTTPException(400, "This video isn't finished yet. Post it once it has rendered.")
103
  elif a.status != "ready" or not a.storage_key:
@@ -134,6 +145,9 @@ def media_for(a: Artifact | None, platform: str) -> Iterator[list[Media]]:
134
  keys = (a.content_json or {}).get("slide_keys") or [a.storage_key]
135
  out = [Media("image", f"slide-{i + 1}.png", "image/png", data=storage.get_bytes(k))
136
  for i, k in enumerate(keys[:MAX_IMAGES[platform]])]
 
 
 
137
  elif a.type == "upload" and upload_kind(a) == "image":
138
  c = a.content_json or {}
139
  out = [Media("image", c.get("filename") or "image", c.get("content_type") or "image/png",
@@ -160,6 +174,21 @@ def media_for(a: Artifact | None, platform: str) -> Iterator[list[Media]]:
160
  os.unlink(path)
161
 
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  def _temp_file(temps: list[str]) -> str:
164
  fd, path = tempfile.mkstemp(prefix="notestack-post-", suffix=".mp4")
165
  os.close(fd)
 
1
+ """What a scheduled post carries from the artifact it was made from: images (quote cards, carousel slides, a slide
2
+ deck's slides, drawn when the post goes out) or one
3
  video (an audiogram, or a blog2video video), or a file the user uploaded from their computer (an `upload`: one image or
4
  one MP4). Summaries, launch kits and mind maps post as text; audio overviews can't
5
  be posted (X and LinkedIn take no audio files).
 
70
  return 1
71
  if a.type == "carousel":
72
  return len((a.content_json or {}).get("slide_keys") or ([a.storage_key] if a.storage_key else []))
73
+ if a.type == "slide_deck":
74
+ return len(((a.content_json or {}).get("deck") or {}).get("slides") or [])
75
  if a.type in ("video", "upload"):
76
  return 1
77
  return 0
78
 
79
 
80
+ def carries_media(a: Artifact | None) -> bool:
81
+ """Whether a post of this artifact carries images or a video: such a post may go out without a caption."""
82
+ return a is not None and a.type not in TEXT_ONLY and media_count(a) > 0
83
+
84
+
85
  def upload_kind(a: Artifact) -> str | None:
86
  """An uploaded file's kind: image | video."""
87
  return (a.content_json or {}).get("media") if a.type == "upload" else None
 
99
  raise HTTPException(400, "Audio can't be posted: X and LinkedIn don't take audio files.")
100
  if a.type == "launch_kit": # a post is written from a kit (kit_id); the kit itself is never an attachment
101
  raise HTTPException(400, "A launch kit can't be attached. Attach a video, quote card or carousel instead.")
102
+ if a.type not in TEXT_ONLY and a.type not in ("quote_card", "carousel", "video", "upload", "slide_deck"):
103
  raise HTTPException(400, "This can't be posted.")
104
  if a.type in TEXT_ONLY:
105
  return
106
  if platform not in MEDIA_PLATFORMS:
107
  raise HTTPException(400, "Images and videos can only be posted to X and LinkedIn.")
108
+ if a.type == "slide_deck": # drawn from the deck when the post goes out: nothing stored to wait for
109
+ if a.status != "ready" or not media_count(a):
110
+ raise HTTPException(400, "This slide deck isn't ready yet. Post it once it has finished.")
111
+ elif a.type == "video" and is_blog2video(a):
112
  if not (a.content_json or {}).get("video_url"):
113
  raise HTTPException(400, "This video isn't finished yet. Post it once it has rendered.")
114
  elif a.status != "ready" or not a.storage_key:
 
145
  keys = (a.content_json or {}).get("slide_keys") or [a.storage_key]
146
  out = [Media("image", f"slide-{i + 1}.png", "image/png", data=storage.get_bytes(k))
147
  for i, k in enumerate(keys[:MAX_IMAGES[platform]])]
148
+ elif a.type == "slide_deck":
149
+ out = [Media("image", f"slide-{i + 1}.png", "image/png", data=png)
150
+ for i, png in enumerate(_slide_images(a, MAX_IMAGES[platform]))]
151
  elif a.type == "upload" and upload_kind(a) == "image":
152
  c = a.content_json or {}
153
  out = [Media("image", c.get("filename") or "image", c.get("content_type") or "image/png",
 
174
  os.unlink(path)
175
 
176
 
177
+ def _slide_images(a: Artifact, limit: int) -> list[bytes]:
178
+ """A slide deck's first `limit` slides as PNGs, exactly as the deck shows them."""
179
+ from app.infographics.image import ImageUnavailable
180
+ from app.slides import export
181
+ from app.slides.build import stored
182
+
183
+ got = stored(a.content_json or {})
184
+ if not got:
185
+ raise SocialError("This slide deck has no slides to post.", permanent=True)
186
+ try:
187
+ return export.render_slide_pngs(*got, limit=limit)
188
+ except ImageUnavailable as exc: # no Chrome on this worker right now: worth another try
189
+ raise SocialError(f"The slides could not be drawn: {exc}") from exc
190
+
191
+
192
  def _temp_file(temps: list[str]) -> str:
193
  fd, path = tempfile.mkstemp(prefix="notestack-post-", suffix=".mp4")
194
  os.close(fd)
backend/app/services/social/x.py CHANGED
@@ -157,7 +157,7 @@ def publish(db: Session, account: SocialAccount, posts: list[str], media: list |
157
  media_ids = [(_upload_video if m.kind == "video" else _upload_image)(headers, m) for m in media]
158
  first_id, reply_to = None, None
159
  for text in posts:
160
- body: dict = {"text": text}
161
  if reply_to:
162
  body["reply"] = {"in_reply_to_tweet_id": reply_to}
163
  elif media_ids:
 
157
  media_ids = [(_upload_video if m.kind == "video" else _upload_image)(headers, m) for m in media]
158
  first_id, reply_to = None, None
159
  for text in posts:
160
+ body: dict = {"text": text} if text else {} # X takes a post with media and no text, but not an empty text
161
  if reply_to:
162
  body["reply"] = {"in_reply_to_tweet_id": reply_to}
163
  elif media_ids:
backend/app/slides/export.py CHANGED
@@ -24,7 +24,7 @@ from pptx.util import Emu, Pt
24
  from app.infographics.image import ImageUnavailable, chrome
25
  from app.slides.decor import Decor, Shape, build, ring_path
26
  from app.slides.layout import El, compose
27
- from app.slides.render import SHADE, color, deck_print_html, planet_colors, ring_shade
28
  from app.slides.themes import FONTS, THEMES, H, Palette, W, theme_id
29
 
30
  TIMEOUT = 120
@@ -88,6 +88,27 @@ def render_pdf(deck: dict, theme: str, fmt: str, seed: int) -> bytes:
88
  return _cached(_key("pdf", deck, theme, fmt, seed), make)
89
 
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  # --- overflow check -------------------------------------------------------------------------------------------------
92
 
93
  _MEASURE = """<script>
 
24
  from app.infographics.image import ImageUnavailable, chrome
25
  from app.slides.decor import Decor, Shape, build, ring_path
26
  from app.slides.layout import El, compose
27
+ from app.slides.render import SHADE, color, deck_print_html, planet_colors, ring_shade, slides_html
28
  from app.slides.themes import FONTS, THEMES, H, Palette, W, theme_id
29
 
30
  TIMEOUT = 120
 
88
  return _cached(_key("pdf", deck, theme, fmt, seed), make)
89
 
90
 
91
+ PNG_BATCH = 8 # slides per Chrome run: one tall screenshot cut into slides (one launch per slide was the slow part)
92
+
93
+
94
+ def render_slide_pngs(deck: dict, theme: str, fmt: str, seed: int, limit: int) -> list[bytes]:
95
+ """The first `limit` slides as PNG images at the slide's own size (1920 x 1080), as a post carries them. A batch
96
+ of slides is drawn stacked on one page and the screenshot is cut at each slide."""
97
+ theme = theme_id(theme)
98
+ n = min(limit, len(deck["slides"]))
99
+ out: list[bytes] = []
100
+ for start in range(0, n, PNG_BATCH):
101
+ count = min(PNG_BATCH, n - start)
102
+ html = slides_html(deck, theme, fmt, seed, start, start + count)
103
+ shot, _ = _run_chrome(html, [f"--window-size={W},{H * count}", "--screenshot={out}"], "slides.png")
104
+ sheet = Image.open(io.BytesIO(shot)).convert("RGB")
105
+ for i in range(count):
106
+ buf = io.BytesIO()
107
+ sheet.crop((0, i * H, W, (i + 1) * H)).save(buf, "PNG", optimize=True)
108
+ out.append(buf.getvalue())
109
+ return out
110
+
111
+
112
  # --- overflow check -------------------------------------------------------------------------------------------------
113
 
114
  _MEASURE = """<script>
backend/app/slides/render.py CHANGED
@@ -167,6 +167,13 @@ def slide_html(deck: dict, index: int, theme: str, fmt: str, seed: int) -> str:
167
  f"body{{background:{THEMES[theme].pal.bg1}}}")
168
 
169
 
 
 
 
 
 
 
 
170
  def deck_print_html(deck: dict, theme: str, fmt: str, seed: int, script: str = "") -> str:
171
  """Every slide on one page, one per printed page."""
172
  theme = theme_id(theme)
 
167
  f"body{{background:{THEMES[theme].pal.bg1}}}")
168
 
169
 
170
+ def slides_html(deck: dict, theme: str, fmt: str, seed: int, start: int, end: int) -> str:
171
+ """Slides start..end-1 of the deck stacked on one page, each as it is in the deck (its number, its sky)."""
172
+ theme = theme_id(theme)
173
+ body = "".join(slide_body(compose(deck, i, theme, fmt, seed), deck, i, theme, fmt) for i in range(start, end))
174
+ return _page(body, f"body{{background:{THEMES[theme].pal.bg1}}}")
175
+
176
+
177
  def deck_print_html(deck: dict, theme: str, fmt: str, seed: int, script: str = "") -> str:
178
  """Every slide on one page, one per printed page."""
179
  theme = theme_id(theme)
backend/tests/test_buildout.py CHANGED
@@ -754,13 +754,14 @@ def _schedule(client, auth, artifact, platform, account=None):
754
  def test_postable_lists_what_can_be_posted(client, auth, db_session):
755
  ws = db_session.query(Workspace).first()
756
  quote = _made(db_session, ws, "quote_card", key="ws/q/quotecard.png", quote="Ship it", source="Ada")
757
- _made(db_session, ws, "audio_overview", key="ws/a/audio.mp3")
758
  _made(db_session, ws, "video", status="processing", provider="blog2video") # not rendered yet
759
  done = _made(db_session, ws, "video", status="processing", provider="blog2video", video_url="https://v/x.mp4")
760
  unrendered = _made(db_session, ws, "video", status="ready", provider="blog2video", b2v_status="generated")
761
  _made(db_session, ws, "summary", summary="Raise prices once a year [1].")
762
  got = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}
763
- assert {a["type"] for a in got.values()} == {"quote_card", "video", "summary"} # no audio, no unfinished video
 
764
  assert str(done.id) in got and got[str(quote.id)]["thumb_url"] and got[str(quote.id)]["media"] == "image"
765
  # Made but not rendered: listed, flagged; the rendered one is not.
766
  assert got[str(unrendered.id)]["needs_render"] is True and got[str(done.id)]["needs_render"] is False
 
754
  def test_postable_lists_what_can_be_posted(client, auth, db_session):
755
  ws = db_session.query(Workspace).first()
756
  quote = _made(db_session, ws, "quote_card", key="ws/q/quotecard.png", quote="Ship it", source="Ada")
757
+ audio = _made(db_session, ws, "audio_overview", key="ws/a/audio.mp3")
758
  _made(db_session, ws, "video", status="processing", provider="blog2video") # not rendered yet
759
  done = _made(db_session, ws, "video", status="processing", provider="blog2video", video_url="https://v/x.mp4")
760
  unrendered = _made(db_session, ws, "video", status="ready", provider="blog2video", b2v_status="generated")
761
  _made(db_session, ws, "summary", summary="Raise prices once a year [1].")
762
  got = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}
763
+ assert {a["type"] for a in got.values()} == {"quote_card", "video", "summary", "audio_overview"} # no unfinished video
764
+ assert got[str(audio.id)]["media"] == "audio" and got[str(audio.id)]["download_url"] # audio: to download, not post
765
  assert str(done.id) in got and got[str(quote.id)]["thumb_url"] and got[str(quote.id)]["media"] == "image"
766
  # Made but not rendered: listed, flagged; the rendered one is not.
767
  assert got[str(unrendered.id)]["needs_render"] is True and got[str(done.id)]["needs_render"] is False
backend/tests/test_social_posting.py CHANGED
@@ -47,8 +47,8 @@ def account(db, ws, platform="x", scopes=None, expires_in=None, refresh=None, ex
47
  return acct
48
 
49
 
50
- def item(db, ws, acct, minutes=60, status="scheduled", **kw):
51
- it = CalendarItem(workspace_id=ws.id, platform=acct.platform, content="Hello", social_account_id=acct.id,
52
  scheduled_at=datetime.now(UTC) + timedelta(minutes=minutes), status=status, **kw)
53
  db.add(it)
54
  db.commit()
@@ -552,3 +552,171 @@ def test_uploaded_files_carry_a_preview_link(client, auth, ws, db_session):
552
  kit = made(db_session, ws, "launch_kit", post_title="Kit", posts={"linkedin": ["Hi"]})
553
  listed = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}
554
  assert listed[str(kit.id)]["view_url"] is None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  return acct
48
 
49
 
50
+ def item(db, ws, acct, minutes=60, status="scheduled", content="Hello", **kw):
51
+ it = CalendarItem(workspace_id=ws.id, platform=acct.platform, content=content, social_account_id=acct.id,
52
  scheduled_at=datetime.now(UTC) + timedelta(minutes=minutes), status=status, **kw)
53
  db.add(it)
54
  db.commit()
 
552
  kit = made(db_session, ws, "launch_kit", post_title="Kit", posts={"linkedin": ["Hi"]})
553
  listed = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}
554
  assert listed[str(kit.id)]["view_url"] is None
555
+
556
+
557
+ def test_a_slide_deck_is_listed_and_posts_its_slides_as_images(client, auth, ws, db_session, monkeypatch):
558
+ from app.services.social import media
559
+ from app.slides import export
560
+ from app.slides.build import fit
561
+ from app.slides.content import clamp_deck
562
+
563
+ raw = {"title": "Pricing for writers", "subtitle": "What two years taught us.", "slides": [
564
+ {"layout": "title", "heading": "Pricing for writers", "lead": "What two years taught us."},
565
+ *[{"layout": "section", "heading": f"Part {i}"} for i in range(5)],
566
+ {"layout": "closing", "heading": "Key takeaways", "takeaways": ["Charge more.", "Readers take it seriously."]}]}
567
+ deck = fit(clamp_deck(raw, "detailed"), "dark-space", "detailed", 3)
568
+ art = Artifact(workspace_id=ws.id, type="slide_deck", status="ready",
569
+ content_json={"title": "Slide deck: Pricing for writers", "theme": "dark-space", "format": "detailed",
570
+ "seed": "abc", "deck": deck})
571
+ db_session.add(art)
572
+ db_session.commit()
573
+ listed = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}[str(art.id)]
574
+ assert listed["type"] == "slide_deck" and listed["media"] == "image" and listed["media_count"] == 7
575
+ assert listed["title"] == "Pricing for writers"
576
+ assert listed["prefill"]["linkedin"].startswith("Pricing for writers\n\nWhat two years taught us.")
577
+ assert "- Charge more." in listed["prefill"]["linkedin"]
578
+
579
+ drawn: list[int] = []
580
+
581
+ def chrome(html, args, out): # a screenshot of the size asked for: a batch of slides stacked
582
+ import io
583
+
584
+ from PIL import Image
585
+
586
+ w, h = (int(v) for v in next(a for a in args if a.startswith("--window-size=")).split("=")[1].split(","))
587
+ drawn.append(h // 1080)
588
+ buf = io.BytesIO()
589
+ Image.new("RGB", (w, h), "navy").save(buf, "PNG")
590
+ return buf.getvalue(), ""
591
+
592
+ monkeypatch.setattr(export, "_run_chrome", chrome)
593
+ with media.media_for(art, "x") as files: # X takes 4 images: one Chrome run draws them
594
+ assert [f.filename for f in files] == [f"slide-{i}.png" for i in range(1, 5)] and drawn == [4]
595
+ assert all(f.data.startswith(b"\x89PNG") for f in files)
596
+ with media.media_for(art, "linkedin") as files:
597
+ assert len(files) == 7 and all(f.content_type == "image/png" for f in files)
598
+ acct = account(db_session, ws, "linkedin", scopes=LI_SCOPES)
599
+ assert schedule(client, auth, "linkedin", acct, artifact_id=str(art.id)).status_code == 200
600
+ r = schedule(client, auth, "bluesky", artifact_id=str(art.id))
601
+ assert r.status_code == 400 and "X and LinkedIn" in r.json()["detail"] # images go to X and LinkedIn only
602
+
603
+
604
+ def test_a_slide_deck_that_cannot_be_drawn_is_retried(ws, db_session, monkeypatch):
605
+ from app.infographics.image import ImageUnavailable
606
+ from app.services.social import SocialError, media
607
+ from app.slides import export
608
+
609
+ art = Artifact(workspace_id=ws.id, type="slide_deck", status="ready", content_json={
610
+ "format": "detailed", "deck": {"title": "T", "slides": [{"layout": "section", "heading": "Hello"}]}})
611
+ db_session.add(art)
612
+ db_session.commit()
613
+
614
+ def no_chrome(*a, **k):
615
+ raise ImageUnavailable("No Chrome")
616
+
617
+ monkeypatch.setattr(export, "_run_chrome", no_chrome)
618
+ with pytest.raises(SocialError, match="could not be drawn") as err, media.media_for(art, "x"):
619
+ pass
620
+ assert not err.value.permanent
621
+
622
+
623
+ def test_an_audio_overview_is_listed_to_download_but_never_posted(client, auth, ws, db_session):
624
+ audio = made(db_session, ws, "audio_overview", key="ws/a/overview.mp3", title="Audio overview: Pricing", duration_s=225)
625
+ listed = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}[str(audio.id)]
626
+ assert listed["media"] == "audio" and listed["media_count"] == 0 and listed["duration_s"] == 225
627
+ assert listed["download_url"]
628
+ acct = account(db_session, ws, "linkedin", scopes=LI_SCOPES)
629
+ r = schedule(client, auth, "linkedin", acct, artifact_id=str(audio.id))
630
+ assert r.status_code == 400 and "Audio can't be posted" in r.json()["detail"]
631
+
632
+
633
+ def test_a_video_or_image_can_go_out_without_a_caption(client, auth, ws, db_session, monkeypatch):
634
+ """An upload starts with no words (they are the writer's to add): a post that carries media may stay that way.
635
+ Without media, a post still needs words."""
636
+ from app.services import launchpad
637
+ from app.services.social import linkedin, x
638
+
639
+ li = account(db_session, ws, "linkedin", scopes=LI_SCOPES)
640
+ upload = made(db_session, ws, "upload", key="ws/u/clip.mp4", data=os.urandom(100_000), media="video",
641
+ content_type="video/mp4", filename="clip.mp4")
642
+ r = schedule(client, auth, "linkedin", li, content="", artifact_id=str(upload.id))
643
+ assert r.status_code == 200, r.text
644
+ r = schedule(client, auth, "linkedin", li, content=" ")
645
+ assert r.status_code == 422 and r.json()["detail"]["message"] == "Write something to post, or attach an image or video."
646
+ created = schedule(client, auth, "linkedin", li, content="Words", artifact_id=str(upload.id)).json()
647
+ r = client.patch(f"/api/calendar/{created['id']}", json={"content": "", "artifact_id": None}, headers=auth)
648
+ assert r.status_code == 422 and r.json()["detail"]["code"] == "no_text" # emptied and no media left
649
+
650
+ # Publishing: LinkedIn gets an empty caption, X a post with the media and no "text" at all.
651
+ sent: dict = {}
652
+ monkeypatch.setattr(linkedin, "_upload_video", lambda acct, m: "urn:li:video:1")
653
+ monkeypatch.setattr(linkedin, "_call", lambda method, url, **kw: sent.setdefault("li", kw["json"]) and httpx.Response(
654
+ 201, headers={"x-restli-id": "urn:li:share:1"}, request=httpx.Request(method, url)))
655
+ li_item = item(db_session, ws, li, minutes=0, content="", artifact_id=upload.id)
656
+ launchpad.publish_item(db_session, li_item)
657
+ assert li_item.status == "posted" and sent["li"]["commentary"] == "" and sent["li"]["content"]["media"]["id"]
658
+
659
+ xa = account(db_session, ws, "x", scopes="tweet.write media.write")
660
+ monkeypatch.setattr(x, "can_post_media", lambda a: True)
661
+ monkeypatch.setattr(x, "_upload_video", lambda headers, m: "m1")
662
+ monkeypatch.setattr(x, "_access", lambda db, a: "token")
663
+ monkeypatch.setattr(x, "_call", lambda method, url, **kw: sent.setdefault("x", kw["json"]) and httpx.Response(
664
+ 201, json={"data": {"id": "t1"}}, request=httpx.Request(method, url)))
665
+ x_item = item(db_session, ws, xa, minutes=0, content="", artifact_id=upload.id)
666
+ launchpad.publish_item(db_session, x_item)
667
+ assert x_item.status == "posted" and sent["x"] == {"media": {"media_ids": ["m1"]}}
668
+
669
+ text_only = item(db_session, ws, xa, minutes=0, content="")
670
+ launchpad.publish_item(db_session, text_only)
671
+ assert text_only.status == "failed" and text_only.error == "Nothing to post."
672
+
673
+
674
+ def test_post_now_publishes_in_the_same_request_and_is_never_left_scheduled(client, auth, ws, db_session, monkeypatch):
675
+ from app.models import Job
676
+ from app.services.social import linkedin
677
+
678
+ li = account(db_session, ws, "linkedin", scopes=LI_SCOPES)
679
+ monkeypatch.setattr(linkedin, "publish", lambda db, a, posts, media=None: ("urn:li:share:9", "https://linkedin.com/x"))
680
+ now = lambda **kw: client.post("/api/calendar", json={ # noqa: E731 (no time sent: the server takes it)
681
+ "platform": "linkedin", "content": "Hello", "social_account_id": str(li.id), "publish_now": True, **kw}, headers=auth)
682
+
683
+ text = now()
684
+ assert text.status_code == 200, text.text
685
+ assert text.json()["status"] == "posted" and text.json()["external_url"] == "https://linkedin.com/x"
686
+
687
+ video = made(db_session, ws, "video", key="ws/v/now.mp4", data=os.urandom(100_000), duration_s=30)
688
+ media = now(artifact_id=str(video.id)).json()
689
+ assert media["status"] == "publishing" # uploading goes through a job: never "scheduled" in between
690
+ job = db_session.query(Job).filter(Job.kind == "publish_post").one()
691
+ assert job.params["item_id"] == media["id"]
692
+
693
+
694
+ def test_saving_a_failed_or_draft_post_reschedules_it_at_a_time_still_ahead(client, auth, ws, db_session):
695
+ li = account(db_session, ws, "linkedin", scopes=LI_SCOPES)
696
+ year = datetime.now(UTC).year + 1
697
+ for status in ("failed", "draft", "reminded"):
698
+ it = item(db_session, ws, li, minutes=-90, status=status)
699
+ # Its old time has passed: rescheduled there, it would go out at the very next run.
700
+ r = client.patch(f"/api/calendar/{it.id}", json={"status": "scheduled"}, headers=auth)
701
+ assert r.status_code == 409 and r.json()["detail"]["code"] == "missed", status
702
+ r = client.patch(f"/api/calendar/{it.id}", json={"status": "scheduled", "local_time": f"{year}-01-05T10:30",
703
+ "timezone": "UTC"}, headers=auth)
704
+ assert r.status_code == 200, r.text
705
+ got = r.json()
706
+ assert got["status"] == "scheduled" and got["error"] is None
707
+ assert datetime.fromisoformat(got["scheduled_at"]) == datetime(year, 1, 5, 10, 30, tzinfo=UTC)
708
+
709
+
710
+ def test_the_list_says_what_each_item_already_has_scheduled(client, auth, ws, db_session):
711
+ li = account(db_session, ws, "linkedin", scopes=LI_SCOPES)
712
+ deck = made(db_session, ws, "summary", summary="Raise prices once a year.")
713
+ video = made(db_session, ws, "video", key="ws/v/s.mp4", data=os.urandom(100_000), duration_s=30)
714
+ later = item(db_session, ws, li, minutes=600, artifact_id=deck.id)
715
+ sooner = item(db_session, ws, li, minutes=120, artifact_id=deck.id)
716
+ item(db_session, ws, li, minutes=-60, artifact_id=deck.id) # its time has passed
717
+ item(db_session, ws, li, minutes=300, status="posted", artifact_id=deck.id)
718
+ item(db_session, ws, li, minutes=300, status="failed", artifact_id=deck.id)
719
+ listed = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}
720
+ assert [s["id"] for s in listed[str(deck.id)]["scheduled"]] == [str(sooner.id), str(later.id)]
721
+ assert listed[str(deck.id)]["scheduled"][0]["platform_label"] == "LinkedIn"
722
+ assert listed[str(video.id)]["scheduled"] == []
frontend/src/App.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { Navigate, Route, Routes, useSearchParams } from "react-router-dom";
2
  import AppShell from "./components/AppShell";
3
  import { PublicFooter, PublicNav } from "./components/PublicChrome";
4
  import PricingTiers from "./components/PricingTiers";
@@ -8,6 +8,7 @@ import AuthPage from "./pages/AuthPage";
8
  import { Blog, BlogPostPage } from "./pages/Blog";
9
  import Landing from "./pages/Landing";
10
  import Archive from "./pages/Archive";
 
11
  import Launchpad, { ScheduleLaunch } from "./pages/Launchpad";
12
  import LaunchKitRedirect, { LaunchKitPage, LaunchKits } from "./pages/LaunchKit";
13
  import MissionControl from "./pages/MissionControl";
@@ -64,7 +65,7 @@ export default function App() {
64
  <Route path="reports/:id" element={<ReportPage />} />
65
  <Route path="videos" element={<Navigate to="/app/archive?tab=videos" replace />} />
66
  <Route path="videos/new" element={<VideoCreate />} />
67
- <Route path="videos/voices" element={<Navigate to="/app/archive?tab=voices" replace />} />
68
  <Route path="videos/:id" element={<VideoEditor />} />
69
  <Route path="sources" element={<Sources />} />
70
  <Route path="map" element={<TopicMap />} />
@@ -76,6 +77,7 @@ export default function App() {
76
  <Route path="launchpad/kits" element={<LaunchKits />} />
77
  <Route path="launchpad/kits/:id" element={<LaunchKitPage />} />
78
  <Route path="archive" element={<Archive />} />
 
79
  <Route path="resurface" element={<Resurface />} />
80
  <Route path="settings" element={<Settings />} />
81
  <Route path="*" element={<MissionControl />} />
@@ -87,9 +89,7 @@ export default function App() {
87
  );
88
  }
89
 
90
- /** The old Voice page (/app/voice, ?step=writing for the writing voice) is the Library's Voices tab now. */
91
  function VoiceRedirect() {
92
- const [params] = useSearchParams();
93
- const writing = params.get("step") === "writing";
94
- return <Navigate to={`/app/archive?tab=voices${writing ? "&step=writing" : ""}`} replace />;
95
  }
 
1
+ import { Navigate, Route, Routes } from "react-router-dom";
2
  import AppShell from "./components/AppShell";
3
  import { PublicFooter, PublicNav } from "./components/PublicChrome";
4
  import PricingTiers from "./components/PricingTiers";
 
8
  import { Blog, BlogPostPage } from "./pages/Blog";
9
  import Landing from "./pages/Landing";
10
  import Archive from "./pages/Archive";
11
+ import Voices from "./pages/Voices";
12
  import Launchpad, { ScheduleLaunch } from "./pages/Launchpad";
13
  import LaunchKitRedirect, { LaunchKitPage, LaunchKits } from "./pages/LaunchKit";
14
  import MissionControl from "./pages/MissionControl";
 
65
  <Route path="reports/:id" element={<ReportPage />} />
66
  <Route path="videos" element={<Navigate to="/app/archive?tab=videos" replace />} />
67
  <Route path="videos/new" element={<VideoCreate />} />
68
+ <Route path="videos/voices" element={<Navigate to="/app/voices" replace />} />
69
  <Route path="videos/:id" element={<VideoEditor />} />
70
  <Route path="sources" element={<Sources />} />
71
  <Route path="map" element={<TopicMap />} />
 
77
  <Route path="launchpad/kits" element={<LaunchKits />} />
78
  <Route path="launchpad/kits/:id" element={<LaunchKitPage />} />
79
  <Route path="archive" element={<Archive />} />
80
+ <Route path="voices" element={<Voices />} />
81
  <Route path="resurface" element={<Resurface />} />
82
  <Route path="settings" element={<Settings />} />
83
  <Route path="*" element={<MissionControl />} />
 
89
  );
90
  }
91
 
92
+ /** The old Voice page (/app/voice) is Manage voices (/app/voices) now. */
93
  function VoiceRedirect() {
94
+ return <Navigate to="/app/voices" replace />;
 
 
95
  }
frontend/src/api/client.ts CHANGED
@@ -63,7 +63,12 @@ async function parseError(res: Response): Promise<ApiError> {
63
  const body = await res.json();
64
  const detail = body.detail;
65
  if (typeof detail === "string") message = detail;
66
- else if (detail?.message) {
 
 
 
 
 
67
  message = detail.message;
68
  code = detail.code;
69
  extra = detail;
 
63
  const body = await res.json();
64
  const detail = body.detail;
65
  if (typeof detail === "string") message = detail;
66
+ else if (Array.isArray(detail) && detail.length) {
67
+ // A request the server's field checks refused (FastAPI's 422 list): say which field and why, not "Unprocessable Entity".
68
+ const first = detail[0] as { loc?: (string | number)[]; msg?: string };
69
+ const field = (first.loc ?? []).filter((p) => p !== "body").join(" ");
70
+ if (first.msg) message = field ? `${field}: ${first.msg}` : first.msg;
71
+ } else if (detail?.message) {
72
  message = detail.message;
73
  code = detail.code;
74
  extra = detail;
frontend/src/api/endpoints.ts CHANGED
@@ -249,6 +249,8 @@ export type ItemBody = {
249
  social_account_id?: string | null;
250
  remind_by_email?: boolean;
251
  draft?: boolean;
 
 
252
  };
253
 
254
  export const launchpadApi = {
 
249
  social_account_id?: string | null;
250
  remind_by_email?: boolean;
251
  draft?: boolean;
252
+ /** Post now: published (or reminded) in the same request, never left scheduled. */
253
+ publish_now?: boolean;
254
  };
255
 
256
  export const launchpadApi = {
frontend/src/api/types.ts CHANGED
@@ -320,7 +320,8 @@ export type PostableArtifact = {
320
  type: ArtifactType;
321
  type_label: string;
322
  title: string;
323
- media: "image" | "video" | "text";
 
324
  media_count: number;
325
  duration_s: number | null;
326
  thumb_url: string | null;
@@ -332,6 +333,10 @@ export type PostableArtifact = {
332
  editable_video: boolean;
333
  /** An uploaded file's link, to preview it (null for anything else). */
334
  view_url: string | null;
 
 
 
 
335
  created_at: string | null;
336
  /** A starting text: X as a thread (list of posts), LinkedIn as one post. */
337
  prefill: { x: string[]; linkedin: string };
 
320
  type: ArtifactType;
321
  type_label: string;
322
  title: string;
323
+ /** "audio": an audio overview, listed to download only (X and LinkedIn take no audio files). */
324
+ media: "image" | "video" | "audio" | "text";
325
  media_count: number;
326
  duration_s: number | null;
327
  thumb_url: string | null;
 
333
  editable_video: boolean;
334
  /** An uploaded file's link, to preview it (null for anything else). */
335
  view_url: string | null;
336
+ /** An audio overview's file, saved as a download (null for anything else). */
337
+ download_url?: string | null;
338
+ /** Its posts still to go out (scheduled, ahead), soonest first: the Schedule a launch list offers to reschedule them. */
339
+ scheduled?: { id: string; scheduled_at: string; platform_label: string }[];
340
  created_at: string | null;
341
  /** A starting text: X as a thread (list of posts), LinkedIn as one post. */
342
  prefill: { x: string[]; linkedin: string };
frontend/src/components/AppShell.tsx CHANGED
@@ -9,6 +9,7 @@ import UpgradeModal from "./UpgradeModal";
9
  import {
10
  LaunchpadIcon,
11
  LibraryIcon,
 
12
  OrbitIcon,
13
  PlanetIcon,
14
  SatelliteDishIcon,
@@ -20,6 +21,7 @@ const NAV: { to: string; label: string; icon: typeof PlanetIcon; end?: boolean;
20
  { to: "/app/sources", label: "Sources", icon: SatelliteDishIcon, also: ["/app/map"] },
21
  { to: "/app/archive", label: "Library", icon: LibraryIcon, also: ["/app/videos"] },
22
  { to: "/app/launchpad", label: "Launchpad", icon: LaunchpadIcon, also: ["/app/resurface", "/app/launch-kit"] },
 
23
  ];
24
 
25
  const COLLAPSED_KEY = "ns_sidebar_collapsed";
 
9
  import {
10
  LaunchpadIcon,
11
  LibraryIcon,
12
+ MicIcon,
13
  OrbitIcon,
14
  PlanetIcon,
15
  SatelliteDishIcon,
 
21
  { to: "/app/sources", label: "Sources", icon: SatelliteDishIcon, also: ["/app/map"] },
22
  { to: "/app/archive", label: "Library", icon: LibraryIcon, also: ["/app/videos"] },
23
  { to: "/app/launchpad", label: "Launchpad", icon: LaunchpadIcon, also: ["/app/resurface", "/app/launch-kit"] },
24
+ { to: "/app/voices", label: "Manage voices", icon: MicIcon },
25
  ];
26
 
27
  const COLLAPSED_KEY = "ns_sidebar_collapsed";
frontend/src/components/ArtifactRow.tsx CHANGED
@@ -4,6 +4,7 @@ import { artifactsApi, videosApi } from "../api/endpoints";
4
  import type { Artifact, Citation } from "../api/types";
5
  import { useJob } from "../hooks/useJob";
6
  import { Body } from "./ArtifactCard";
 
7
  import { TrashIcon } from "./icons/Icons";
8
  import { errorMessage, formatDate, formatDuration, JobProgress } from "./ui";
9
 
@@ -14,7 +15,8 @@ function statusOf(a: Artifact, running: boolean): { label: string; tone: "done"
14
  }
15
 
16
  /** One generated item as a full-width row (like the Videos list): its type, title, status and date. A video opens its
17
- * editor; anything else expands in place to its player or image. */
 
18
  export function ArtifactRow({
19
  artifact: initial,
20
  onCite,
@@ -41,6 +43,8 @@ export function ArtifactRow({
41
  const running = !!job && (job.status === "queued" || job.status === "running");
42
  const st = statusOf(artifact, running);
43
  const isEditorVideo = artifact.provider === "blog2video";
 
 
44
 
45
  useEffect(() => {
46
  if (!armed) return;
@@ -73,7 +77,8 @@ export function ArtifactRow({
73
  <>
74
  <span className="vw-list-title">
75
  <span className={`type-chip ${artifact.type}`}>{artifact.type_label}</span>
76
- <strong>{artifact.title}</strong>
 
77
  <span className={`vw-dot ${st.tone}`} aria-hidden="true" />
78
  <span className="muted">{st.label}</span>
79
  </span>
@@ -90,12 +95,17 @@ export function ArtifactRow({
90
  <div className="artifact-row-head">
91
  {isEditorVideo ? (
92
  <Link to={`/app/videos/${artifact.id}`} className="vw-list-main">{main}</Link>
 
 
 
 
93
  ) : (
94
  <button type="button" className="vw-list-main artifact-row-toggle" aria-expanded={openRow}
95
  onClick={() => setOpenRow((o) => !o)}>
96
  {main}
97
  </button>
98
  )}
 
99
  {artifact.status === "failed" && (
100
  <button type="button" className="btn btn-small" onClick={retry}>Retry</button>
101
  )}
@@ -109,7 +119,17 @@ export function ArtifactRow({
109
  </div>
110
  {running && job && <JobProgress job={job} compact />}
111
  {error && <p className="error-text">{error}</p>}
112
- {openRow && !isEditorVideo && (
 
 
 
 
 
 
 
 
 
 
113
  <div className="artifact-row-body">
114
  {artifact.status === "failed" && artifact.content.error && <p className="error-text">{artifact.content.error}</p>}
115
  <Body artifact={artifact} onCite={onCite} />
 
4
  import type { Artifact, Citation } from "../api/types";
5
  import { useJob } from "../hooks/useJob";
6
  import { Body } from "./ArtifactCard";
7
+ import { SlideDeckViewer } from "./SlideDeck";
8
  import { TrashIcon } from "./icons/Icons";
9
  import { errorMessage, formatDate, formatDuration, JobProgress } from "./ui";
10
 
 
15
  }
16
 
17
  /** One generated item as a full-width row (like the Videos list): its type, title, status and date. A video opens its
18
+ * editor, a finished slide deck its viewer (to read, edit, present or download it); anything else expands in place
19
+ * to its player or image. */
20
  export function ArtifactRow({
21
  artifact: initial,
22
  onCite,
 
43
  const running = !!job && (job.status === "queued" || job.status === "running");
44
  const st = statusOf(artifact, running);
45
  const isEditorVideo = artifact.provider === "blog2video";
46
+ const isDeck = artifact.type === "slide_deck" && artifact.status === "ready";
47
+ const [viewing, setViewing] = useState(false);
48
 
49
  useEffect(() => {
50
  if (!armed) return;
 
77
  <>
78
  <span className="vw-list-title">
79
  <span className={`type-chip ${artifact.type}`}>{artifact.type_label}</span>
80
+ {/* A deck's title starts "Slide deck: ", which its chip already says */}
81
+ <strong>{artifact.type === "slide_deck" ? artifact.title.replace(/^Slide deck: /, "") : artifact.title}</strong>
82
  <span className={`vw-dot ${st.tone}`} aria-hidden="true" />
83
  <span className="muted">{st.label}</span>
84
  </span>
 
95
  <div className="artifact-row-head">
96
  {isEditorVideo ? (
97
  <Link to={`/app/videos/${artifact.id}`} className="vw-list-main">{main}</Link>
98
+ ) : isDeck ? (
99
+ <button type="button" className="vw-list-main artifact-row-toggle" onClick={() => setViewing(true)}>
100
+ {main}
101
+ </button>
102
  ) : (
103
  <button type="button" className="vw-list-main artifact-row-toggle" aria-expanded={openRow}
104
  onClick={() => setOpenRow((o) => !o)}>
105
  {main}
106
  </button>
107
  )}
108
+ {isDeck && actions?.(artifact)}
109
  {artifact.status === "failed" && (
110
  <button type="button" className="btn btn-small" onClick={retry}>Retry</button>
111
  )}
 
119
  </div>
120
  {running && job && <JobProgress job={job} compact />}
121
  {error && <p className="error-text">{error}</p>}
122
+ {viewing && (
123
+ <SlideDeckViewer artifact={artifact} onClose={() => setViewing(false)} onSaved={(a) => {
124
+ setArtifact(a);
125
+ onChanged?.(a);
126
+ }} onDelete={async () => {
127
+ await artifactsApi.remove(artifact.id);
128
+ setViewing(false);
129
+ onRemoved?.(artifact.id);
130
+ }} />
131
+ )}
132
+ {openRow && !isEditorVideo && !isDeck && (
133
  <div className="artifact-row-body">
134
  {artifact.status === "failed" && artifact.content.error && <p className="error-text">{artifact.content.error}</p>}
135
  <Body artifact={artifact} onCite={onCite} />
frontend/src/components/ScheduleModal.tsx CHANGED
@@ -79,7 +79,8 @@ export function ScheduleModal({
79
  if (artifactId && !artifact) {
80
  launchpadApi.postable().then((list) => {
81
  const found = list.find((a) => a.id === artifactId);
82
- if (found && found.type !== "launch_kit") attach(found); // a kit is never an attachment
 
83
  }, () => undefined);
84
  }
85
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -151,8 +152,9 @@ export function ScheduleModal({
151
  const account = meta.auto ? accountFor(p) : null;
152
  const item = await launchpadApi.create({
153
  platform: p,
154
- // Post now: this instant. Scheduled: the half hour picked, in the user's zone (the backend makes it UTC).
155
- ...(now ? { scheduled_at: new Date().toISOString() } : { local_time: when, timezone: userTimeZone() }),
 
156
  content: merged[0] ?? "",
157
  thread: merged.slice(1),
158
  // Images and videos only go to X and LinkedIn; text attachments go anywhere.
@@ -162,8 +164,7 @@ export function ScheduleModal({
162
  social_account_id: account?.id ?? null,
163
  remind_by_email: CONNECTED_ONLY.includes(p) ? false : remind || !account,
164
  });
165
- const out = now && item.auto_post ? await launchpadApi.publishNow(item.id) : item;
166
- onScheduled?.(out);
167
  done.push(meta.label);
168
  }
169
  onClose();
 
79
  if (artifactId && !artifact) {
80
  launchpadApi.postable().then((list) => {
81
  const found = list.find((a) => a.id === artifactId);
82
+ // a kit is never an attachment, and audio can't be posted (X and LinkedIn take no audio files)
83
+ if (found && found.type !== "launch_kit" && found.media !== "audio") attach(found);
84
  }, () => undefined);
85
  }
86
  // eslint-disable-next-line react-hooks/exhaustive-deps
 
152
  const account = meta.auto ? accountFor(p) : null;
153
  const item = await launchpadApi.create({
154
  platform: p,
155
+ // Post now: published by this very request (the server takes the time). Scheduled: the half hour picked, in
156
+ // the user's zone (the backend makes it UTC).
157
+ ...(now ? { publish_now: true } : { local_time: when, timezone: userTimeZone() }),
158
  content: merged[0] ?? "",
159
  thread: merged.slice(1),
160
  // Images and videos only go to X and LinkedIn; text attachments go anywhere.
 
164
  social_account_id: account?.id ?? null,
165
  remind_by_email: CONNECTED_ONLY.includes(p) ? false : remind || !account,
166
  });
167
+ onScheduled?.(item);
 
168
  done.push(meta.label);
169
  }
170
  onClose();
frontend/src/components/SlideDeck.tsx CHANGED
@@ -9,6 +9,10 @@ import { CheckIcon, CollapseIcon, DownloadIcon, ExpandIcon, MoreIcon, PdfIcon, P
9
  import { LANGUAGES, Pills, SourceFocusFields, sourceSummary, type SourceSelection } from "./SourceFocusFields";
10
  import { ConfirmDeleteModal, errorMessage, formatDate, JobProgress, Modal } from "./ui";
11
 
 
 
 
 
12
  /** The two deck themes. Their slides are laid out on the server (backend/app/slides); the thumbnails are rendered from
13
  * them (backend/scripts/render_slide_thumbnails.py). */
14
  export const SLIDE_THEMES = [
@@ -60,7 +64,7 @@ export function SlideDeckDialog({ notebookId, notebookTitle, chats, currentChatI
60
  <button key={t.id} type="button" role="radio" aria-checked={theme === t.id} title={t.blurb}
61
  className={`sd-theme${theme === t.id ? " on" : ""}`} onClick={() => setTheme(t.id)}>
62
  <span className="sd-theme-img">
63
- <img src={`/slides/${t.id}.png`} alt="" loading="lazy" />
64
  {theme === t.id && <span className="sd-check" aria-hidden="true"><CheckIcon size={12} /></span>}
65
  </span>
66
  <span className="sd-theme-name">{t.name}</span>
 
9
  import { LANGUAGES, Pills, SourceFocusFields, sourceSummary, type SourceSelection } from "./SourceFocusFields";
10
  import { ConfirmDeleteModal, errorMessage, formatDate, JobProgress, Modal } from "./ui";
11
 
12
+ /** Bumped when the thumbnails are drawn again (backend/scripts/render_slide_thumbnails.py), so browsers fetch the new
13
+ * images instead of the ones they cached. */
14
+ const THUMBS_VERSION = 2;
15
+
16
  /** The two deck themes. Their slides are laid out on the server (backend/app/slides); the thumbnails are rendered from
17
  * them (backend/scripts/render_slide_thumbnails.py). */
18
  export const SLIDE_THEMES = [
 
64
  <button key={t.id} type="button" role="radio" aria-checked={theme === t.id} title={t.blurb}
65
  className={`sd-theme${theme === t.id ? " on" : ""}`} onClick={() => setTheme(t.id)}>
66
  <span className="sd-theme-img">
67
+ <img src={`/slides/${t.id}.png?v=${THUMBS_VERSION}`} alt="" loading="lazy" />
68
  {theme === t.id && <span className="sd-check" aria-hidden="true"><CheckIcon size={12} /></span>}
69
  </span>
70
  <span className="sd-theme-name">{t.name}</span>
frontend/src/components/launchpad/Attachment.tsx CHANGED
@@ -4,7 +4,7 @@ import { useNavigate } from "react-router-dom";
4
  import { ApiError } from "../../api/client";
5
  import { artifactsApi, launchpadApi, videoEditApi, videosApi } from "../../api/endpoints";
6
  import type { PostableArtifact } from "../../api/types";
7
- import { ArrowRightIcon, FilmIcon, SparkleIcon, UploadIcon } from "../icons/Icons";
8
  import { errorMessage, formatDate, Loading, Modal } from "../ui";
9
  import { finished, jobPercent } from "../video/jobState";
10
 
@@ -18,9 +18,16 @@ export function mediaLabel(a: PostableArtifact): string {
18
  if (a.type === "upload") {
19
  return a.media === "video" ? `Your video${a.duration_s ? ` ${fmtDuration(a.duration_s)}` : ""}` : "Your image";
20
  }
 
21
  if (a.media === "video" && a.rendering) return "Rendering: wait for it to finish";
22
  if (a.media === "video" && a.needs_render) return "Not rendered yet: render it first";
23
  if (a.media === "video") return a.duration_s ? `Video ${fmtDuration(a.duration_s)}` : "Video";
 
 
 
 
 
 
24
  if (a.media === "image") return a.media_count === 1 ? "Image" : `${a.media_count} images`;
25
  return "Text";
26
  }
@@ -29,7 +36,8 @@ function Thumb({ a }: { a: PostableArtifact }) {
29
  if (a.thumb_url) return <img className="lp-thumb" src={a.thumb_url} alt="" />;
30
  return (
31
  <span className="lp-thumb lp-thumb-icon" aria-hidden="true">
32
- {a.media === "video" ? <FilmIcon size={20} /> : <SparkleIcon size={20} />}
 
33
  </span>
34
  );
35
  }
@@ -55,6 +63,32 @@ export function MediaPreviewModal({ a, onClose }: { a: PostableArtifact; onClose
55
  );
56
  }
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  /** The attached item: thumbnail, title, what it carries, and Change / Remove. An uploaded file's thumbnail opens its
59
  * preview. */
60
  export function AttachmentChip({ a, onChange, onRemove }: {
@@ -307,16 +341,29 @@ function deleteWording(a: PostableArtifact): { title: string; body: string } {
307
  return { title: "Delete this item?", body: "It's deleted for good. Posts on your calendar that carry it are removed too." };
308
  }
309
 
310
- type Kind = "all" | "kits" | "video" | "uploads";
311
  const KINDS: { id: Kind; label: string }[] = [
312
  { id: "all", label: "All" },
313
  { id: "kits", label: "Launch kits" },
314
  { id: "video", label: "Videos" },
 
 
315
  { id: "uploads", label: "Uploads" },
316
  ];
317
  /** Which rows a tab shows: Videos is only the ones the video service made (uploaded videos are under Uploads). */
318
  const matches = (a: PostableArtifact, kind: Kind) =>
319
- kind === "all" || (kind === "kits" ? a.type === "launch_kit" : kind === "uploads" ? a.type === "upload" : a.editable_video);
 
 
 
 
 
 
 
 
 
 
 
320
 
321
  /** What can be uploaded to post, and LinkedIn's size limits for it (checked again by the backend). */
322
  const UPLOAD_ACCEPT = "image/png,image/jpeg,image/gif,video/mp4";
@@ -366,11 +413,20 @@ export function AttachPicker({ onPick, onCreateKit, onClose }: {
366
  * searchable; with a link to create a new launch kit when onCreateKit is given. onPickKit, when given, takes the
367
  * Launch Kits instead of onPick (the Schedule a launch page opens the kit, to schedule its posts from there). A video
368
  * with no MP4 yet (or one rendering) opens the render pop-up instead. fill: the rows take the screen's height. */
369
- export function LibraryPicker({ onPick, onPickKit, onCreateKit, fill = false }: {
370
  onPick: (a: PostableArtifact) => void;
371
  onPickKit?: (a: PostableArtifact) => void;
372
  onCreateKit?: () => void;
373
  fill?: boolean;
 
 
 
 
 
 
 
 
 
374
  }) {
375
  const [items, setItems] = useState<PostableArtifact[] | null>(null);
376
  const [kind, setKind] = useState<Kind>("all");
@@ -382,6 +438,7 @@ export function LibraryPicker({ onPick, onPickKit, onCreateKit, fill = false }:
382
  const [dragging, setDragging] = useState(false); // a file is held over the drop area
383
  const [renaming, setRenaming] = useState<{ id: string; title: string; busy?: boolean } | null>(null);
384
  const [previewing, setPreviewing] = useState<PostableArtifact | null>(null); // the upload being looked at
 
385
  const [deleting, setDeleting] = useState<PostableArtifact | null>(null); // the item in the delete pop-up
386
  const [deleteBusy, setDeleteBusy] = useState(false);
387
  const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -441,7 +498,7 @@ export function LibraryPicker({ onPick, onPickKit, onCreateKit, fill = false }:
441
  const load = useCallback(() => launchpadApi.postable(q).then((list) => {
442
  setItems(list);
443
  return list;
444
- }), [q]);
445
 
446
  useEffect(() => {
447
  const t = setTimeout(() => {
@@ -540,12 +597,18 @@ export function LibraryPicker({ onPick, onPickKit, onCreateKit, fill = false }:
540
  )}
541
  {items && shown.length === 0 && (
542
  <p className="muted lp-pick-empty">{kind === "kits" ? "No launch kits yet: create a new one."
543
- : kind === "uploads" ? "Nothing uploaded yet." : "Nothing to attach here yet."}</p>
 
 
544
  )}
545
  <ul className={`lk-rows lp-pick-rows${fill ? " lp-pick-rows-fill" : ""}`}>
546
  {shown.map((a) => {
547
  const kit = a.type === "launch_kit";
548
- const go = () => (a.needs_render || a.rendering ? setRendering(a) : kit && onPickKit ? onPickKit(a) : onPick(a));
 
 
 
 
549
  if (renaming?.id === a.id) {
550
  return (
551
  <li key={a.id}>
@@ -570,7 +633,7 @@ export function LibraryPicker({ onPick, onPickKit, onCreateKit, fill = false }:
570
  }
571
  return (
572
  <li key={a.id} className="lp-pick-item">
573
- <button type="button" className="lk-row lp-pick-row" onClick={go}
574
  title={a.rendering ? "Rendering: it can be scheduled once the MP4 is made"
575
  : a.needs_render ? "Render it, then post it" : undefined}>
576
  <Thumb a={a} />
@@ -578,22 +641,33 @@ export function LibraryPicker({ onPick, onPickKit, onCreateKit, fill = false }:
578
  <strong>{a.title}</strong>
579
  <span className="muted small">
580
  {kit ? "LinkedIn post and hooks ready to schedule" : mediaLabel(a)}
581
- {a.created_at ? ` · ${formatDate(a.created_at)}` : ""}
 
582
  </span>
583
  </span>
584
- <span className={`lp-tag${a.media === "video" ? " video" : kit ? " kit" : ""}`}>
585
- {a.type === "upload" ? "Upload" : a.media === "video" ? "Video" : kit ? "Launch Kit" : a.type_label}
 
586
  </span>
587
  <span className="lp-pick-go">
588
- {a.rendering ? (
 
 
589
  <><span className="vw-spin small" aria-hidden="true" /> Rendering</>
590
  ) : (
591
- <>{a.needs_render ? "Render it" : kit && onPickKit ? "Open kit" : "Schedule"} <ArrowRightIcon size={14} /></>
 
592
  )}
593
  </span>
594
  </button>
595
  <RowMenu label={a.title} items={[
596
  ...(a.view_url ? [{ label: "Preview", onSelect: () => setPreviewing(a) }] : []),
 
 
 
 
 
 
597
  ...(a.type === "upload" ? [{ label: "Rename", onSelect: () => setRenaming({ id: a.id, title: a.title }) }] : []),
598
  ...(kit ? [{ label: "Open kit", onSelect: () => (onPickKit ? onPickKit(a) : navigate(`/app/launchpad/kits/${a.id}`)) }]
599
  : a.editable_video ? [{ label: "Open", onSelect: () => navigate(`/app/videos/${a.id}`) }] : []),
@@ -608,6 +682,7 @@ export function LibraryPicker({ onPick, onPickKit, onCreateKit, fill = false }:
608
  </ul>
609
  {rendering && <RenderVideoModal video={rendering} onClose={() => setRendering(null)} onRendered={rendered} />}
610
  {previewing && <MediaPreviewModal a={previewing} onClose={() => setPreviewing(null)} />}
 
611
  {deleting && (
612
  <Modal title={deleteWording(deleting).title} onClose={() => !deleteBusy && setDeleting(null)}>
613
  <div className="stack">
 
4
  import { ApiError } from "../../api/client";
5
  import { artifactsApi, launchpadApi, videoEditApi, videosApi } from "../../api/endpoints";
6
  import type { PostableArtifact } from "../../api/types";
7
+ import { ArrowRightIcon, DownloadIcon, FilmIcon, HeadphonesIcon, SlidesIcon, SparkleIcon, UploadIcon } from "../icons/Icons";
8
  import { errorMessage, formatDate, Loading, Modal } from "../ui";
9
  import { finished, jobPercent } from "../video/jobState";
10
 
 
18
  if (a.type === "upload") {
19
  return a.media === "video" ? `Your video${a.duration_s ? ` ${fmtDuration(a.duration_s)}` : ""}` : "Your image";
20
  }
21
+ if (a.media === "audio") return `Audio${a.duration_s ? ` ${fmtDuration(a.duration_s)}` : ""} · download only`;
22
  if (a.media === "video" && a.rendering) return "Rendering: wait for it to finish";
23
  if (a.media === "video" && a.needs_render) return "Not rendered yet: render it first";
24
  if (a.media === "video") return a.duration_s ? `Video ${fmtDuration(a.duration_s)}` : "Video";
25
+ if (a.type === "slide_deck") {
26
+ // X takes 4 images and LinkedIn 20 (backend app/services/social/media.py MAX_IMAGES): longer decks are cut there.
27
+ const n = a.media_count;
28
+ const cut = n > 20 ? " · X posts the first 4, LinkedIn the first 20" : n > 4 ? " · X posts the first 4" : "";
29
+ return `${n} slide${n === 1 ? "" : "s"} as images${cut}`;
30
+ }
31
  if (a.media === "image") return a.media_count === 1 ? "Image" : `${a.media_count} images`;
32
  return "Text";
33
  }
 
36
  if (a.thumb_url) return <img className="lp-thumb" src={a.thumb_url} alt="" />;
37
  return (
38
  <span className="lp-thumb lp-thumb-icon" aria-hidden="true">
39
+ {a.media === "video" ? <FilmIcon size={20} /> : a.media === "audio" ? <HeadphonesIcon size={20} />
40
+ : a.type === "slide_deck" ? <SlidesIcon size={20} /> : <SparkleIcon size={20} />}
41
  </span>
42
  );
43
  }
 
63
  );
64
  }
65
 
66
+ /** An audio overview someone tried to schedule: X and LinkedIn take no audio files, so it is offered to download
67
+ * instead. Used by the Launchpad's list and the Library. */
68
+ export function AudioOnlyModal({ title, downloadUrl, onClose }: { title: string; downloadUrl: string | null | undefined; onClose: () => void }) {
69
+ return (
70
+ <Modal title="Audio can't be posted" onClose={onClose}>
71
+ <div className="stack lp-audio-only">
72
+ <span className="lp-audio-only-icon" aria-hidden="true"><HeadphonesIcon size={28} /></span>
73
+ <p>
74
+ LinkedIn does not support posting audio files, so <strong>{title}</strong> can't be scheduled. Download it now
75
+ to use it elsewhere, like a podcast app, your newsletter or a website.
76
+ </p>
77
+ <div className="row lp-audio-only-actions">
78
+ <button type="button" className="btn" onClick={onClose}>Close</button>
79
+ {downloadUrl ? (
80
+ <a className="btn btn-primary" href={downloadUrl} download onClick={onClose} autoFocus>
81
+ <DownloadIcon size={18} /> Download
82
+ </a>
83
+ ) : (
84
+ <span className="muted small">The file isn't available right now. Try again later.</span>
85
+ )}
86
+ </div>
87
+ </div>
88
+ </Modal>
89
+ );
90
+ }
91
+
92
  /** The attached item: thumbnail, title, what it carries, and Change / Remove. An uploaded file's thumbnail opens its
93
  * preview. */
94
  export function AttachmentChip({ a, onChange, onRemove }: {
 
341
  return { title: "Delete this item?", body: "It's deleted for good. Posts on your calendar that carry it are removed too." };
342
  }
343
 
344
+ type Kind = "all" | "kits" | "video" | "decks" | "audio" | "uploads";
345
  const KINDS: { id: Kind; label: string }[] = [
346
  { id: "all", label: "All" },
347
  { id: "kits", label: "Launch kits" },
348
  { id: "video", label: "Videos" },
349
+ { id: "decks", label: "Slide decks" },
350
+ { id: "audio", label: "Audio" },
351
  { id: "uploads", label: "Uploads" },
352
  ];
353
  /** Which rows a tab shows: Videos is only the ones the video service made (uploaded videos are under Uploads). */
354
  const matches = (a: PostableArtifact, kind: Kind) =>
355
+ kind === "all" || (kind === "kits" ? a.type === "launch_kit" : kind === "uploads" ? a.type === "upload"
356
+ : kind === "decks" ? a.type === "slide_deck" : kind === "audio" ? a.type === "audio_overview" : a.editable_video);
357
+
358
+ /** Saves a file from its (presigned, attachment) link. */
359
+ function download(url: string) {
360
+ const link = document.createElement("a");
361
+ link.href = url;
362
+ link.download = "";
363
+ document.body.appendChild(link);
364
+ link.click();
365
+ link.remove();
366
+ }
367
 
368
  /** What can be uploaded to post, and LinkedIn's size limits for it (checked again by the backend). */
369
  const UPLOAD_ACCEPT = "image/png,image/jpeg,image/gif,video/mp4";
 
413
  * searchable; with a link to create a new launch kit when onCreateKit is given. onPickKit, when given, takes the
414
  * Launch Kits instead of onPick (the Schedule a launch page opens the kit, to schedule its posts from there). A video
415
  * with no MP4 yet (or one rendering) opens the render pop-up instead. fill: the rows take the screen's height. */
416
+ export function LibraryPicker({ onPick, onPickKit, onCreateKit, fill = false, onReschedule, onDuplicate, refreshKey = 0, openingId }: {
417
  onPick: (a: PostableArtifact) => void;
418
  onPickKit?: (a: PostableArtifact) => void;
419
  onCreateKit?: () => void;
420
  fill?: boolean;
421
+ /** With these (the Schedule a launch page), an item already scheduled says Reschedule and opens that post
422
+ * (`itemId`), and its menu can schedule it again (a copy of that post). Without them (the composer's picker), every
423
+ * item is simply picked. */
424
+ onReschedule?: (a: PostableArtifact, itemId: string) => void;
425
+ onDuplicate?: (a: PostableArtifact, itemId: string) => void;
426
+ /** Changed by the page to load the list again (a post was rescheduled, moved or deleted). */
427
+ refreshKey?: number;
428
+ /** The item whose scheduled post is being opened: its row shows a spinner and takes no clicks meanwhile. */
429
+ openingId?: string | null;
430
  }) {
431
  const [items, setItems] = useState<PostableArtifact[] | null>(null);
432
  const [kind, setKind] = useState<Kind>("all");
 
438
  const [dragging, setDragging] = useState(false); // a file is held over the drop area
439
  const [renaming, setRenaming] = useState<{ id: string; title: string; busy?: boolean } | null>(null);
440
  const [previewing, setPreviewing] = useState<PostableArtifact | null>(null); // the upload being looked at
441
+ const [audio, setAudio] = useState<PostableArtifact | null>(null); // an audio overview: offered to download, not posted
442
  const [deleting, setDeleting] = useState<PostableArtifact | null>(null); // the item in the delete pop-up
443
  const [deleteBusy, setDeleteBusy] = useState(false);
444
  const [deleteError, setDeleteError] = useState<string | null>(null);
 
498
  const load = useCallback(() => launchpadApi.postable(q).then((list) => {
499
  setItems(list);
500
  return list;
501
+ }), [q, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
502
 
503
  useEffect(() => {
504
  const t = setTimeout(() => {
 
597
  )}
598
  {items && shown.length === 0 && (
599
  <p className="muted lp-pick-empty">{kind === "kits" ? "No launch kits yet: create a new one."
600
+ : kind === "uploads" ? "Nothing uploaded yet." : kind === "decks" ? "No slide decks yet: make one from a notebook."
601
+ : kind === "audio" ? "No audio overviews yet: make one from a notebook."
602
+ : "Nothing to attach here yet."}</p>
603
  )}
604
  <ul className={`lk-rows lp-pick-rows${fill ? " lp-pick-rows-fill" : ""}`}>
605
  {shown.map((a) => {
606
  const kit = a.type === "launch_kit";
607
+ // Already on the calendar (and this list offers it): open that post rather than start another one.
608
+ const next = onReschedule && !kit && a.media !== "audio" ? a.scheduled?.[0] : undefined;
609
+ const opening = openingId === a.id;
610
+ const go = () => opening ? undefined : (a.media === "audio" ? setAudio(a) : a.needs_render || a.rendering ? setRendering(a)
611
+ : next ? onReschedule!(a, next.id) : kit && onPickKit ? onPickKit(a) : onPick(a));
612
  if (renaming?.id === a.id) {
613
  return (
614
  <li key={a.id}>
 
633
  }
634
  return (
635
  <li key={a.id} className="lp-pick-item">
636
+ <button type="button" className="lk-row lp-pick-row" onClick={go} aria-busy={opening}
637
  title={a.rendering ? "Rendering: it can be scheduled once the MP4 is made"
638
  : a.needs_render ? "Render it, then post it" : undefined}>
639
  <Thumb a={a} />
 
641
  <strong>{a.title}</strong>
642
  <span className="muted small">
643
  {kit ? "LinkedIn post and hooks ready to schedule" : mediaLabel(a)}
644
+ {next ? ` · Scheduled ${formatDate(next.scheduled_at, true)}${a.scheduled!.length > 1
645
+ ? ` +${a.scheduled!.length - 1} more` : ""}` : a.created_at ? ` · ${formatDate(a.created_at)}` : ""}
646
  </span>
647
  </span>
648
+ <span className={`lp-tag${a.media === "video" ? " video" : kit ? " kit" : a.type === "slide_deck" ? " deck" : ""}`}>
649
+ {a.type === "upload" ? "Upload" : a.media === "video" ? "Video" : a.media === "audio" ? "Audio" : kit ? "Launch Kit"
650
+ : a.type_label}
651
  </span>
652
  <span className="lp-pick-go">
653
+ {opening ? (
654
+ <><span className="vw-spin small" aria-hidden="true" /> Opening...</>
655
+ ) : a.rendering ? (
656
  <><span className="vw-spin small" aria-hidden="true" /> Rendering</>
657
  ) : (
658
+ <>{a.needs_render ? "Render it" : kit && onPickKit ? "Open kit" : next ? "Reschedule" : "Schedule"}
659
+ {" "}<ArrowRightIcon size={14} /></>
660
  )}
661
  </span>
662
  </button>
663
  <RowMenu label={a.title} items={[
664
  ...(a.view_url ? [{ label: "Preview", onSelect: () => setPreviewing(a) }] : []),
665
+ ...(a.download_url ? [{ label: "Download", onSelect: () => download(a.download_url!) }] : []),
666
+ ...(next && onDuplicate ? [{ label: "Duplicate schedule", onSelect: () => onDuplicate(a, next.id) }] : []),
667
+ // Several posts of it coming up: each can be opened.
668
+ ...(next && a.scheduled!.length > 1 ? a.scheduled!.map((sc) => ({
669
+ label: `Edit schedule: ${formatDate(sc.scheduled_at, true)}`, onSelect: () => onReschedule!(a, sc.id),
670
+ })) : []),
671
  ...(a.type === "upload" ? [{ label: "Rename", onSelect: () => setRenaming({ id: a.id, title: a.title }) }] : []),
672
  ...(kit ? [{ label: "Open kit", onSelect: () => (onPickKit ? onPickKit(a) : navigate(`/app/launchpad/kits/${a.id}`)) }]
673
  : a.editable_video ? [{ label: "Open", onSelect: () => navigate(`/app/videos/${a.id}`) }] : []),
 
682
  </ul>
683
  {rendering && <RenderVideoModal video={rendering} onClose={() => setRendering(null)} onRendered={rendered} />}
684
  {previewing && <MediaPreviewModal a={previewing} onClose={() => setPreviewing(null)} />}
685
+ {audio && <AudioOnlyModal title={audio.title} downloadUrl={audio.download_url} onClose={() => setAudio(null)} />}
686
  {deleting && (
687
  <Modal title={deleteWording(deleting).title} onClose={() => !deleteBusy && setDeleting(null)}>
688
  <div className="stack">
frontend/src/components/launchpad/PostButton.tsx CHANGED
@@ -2,8 +2,9 @@ import { useState } from "react";
2
  import type { Artifact } from "../../api/types";
3
  import { ScheduleModal } from "../ScheduleModal";
4
 
5
- /** What can go out on X / LinkedIn: images, videos and text. Not audio (neither platform takes audio files). */
6
- const POSTABLE = new Set(["video", "quote_card", "carousel", "summary", "launch_kit", "mind_map"]);
 
7
 
8
  export function canPost(a: Artifact): boolean {
9
  if (!POSTABLE.has(a.type)) return false;
 
2
  import type { Artifact } from "../../api/types";
3
  import { ScheduleModal } from "../ScheduleModal";
4
 
5
+ /** What can go out on X / LinkedIn: images (a slide deck's slides too), videos and text. Not audio (neither platform
6
+ * takes audio files). */
7
+ const POSTABLE = new Set(["video", "quote_card", "carousel", "summary", "launch_kit", "mind_map", "slide_deck"]);
8
 
9
  export function canPost(a: Artifact): boolean {
10
  if (!POSTABLE.has(a.type)) return false;
frontend/src/pages/Archive.tsx CHANGED
@@ -1,17 +1,18 @@
1
- import { useCallback, useEffect, useState } from "react";
2
- import { Link, useSearchParams } from "react-router-dom";
3
  import { artifactsApi } from "../api/endpoints";
4
  import type { Artifact } from "../api/types";
5
  import { ArtifactRow } from "../components/ArtifactRow";
6
  import { Reader } from "../components/Reader";
7
  import { EmptyState, Loading, PageHeader, Tabs } from "../components/ui";
8
  import Videos from "./Videos";
9
- import VoiceProfile from "./VoiceProfile";
10
  import { canPost, PostButton } from "../components/launchpad/PostButton";
 
11
 
12
- type Filter = "all" | "report" | "summary" | "quiz" | "flashcards" | "mind_map" | "infographic";
13
- /** The Library's tabs: kinds of things made (a filter on one list), plus your videos and your voices (their own pages). */
14
- type Tab = Filter | "videos" | "voices";
 
15
  const TABS: { id: Tab; label: string }[] = [
16
  { id: "all", label: "All" },
17
  { id: "report", label: "Reports" },
@@ -20,13 +21,15 @@ const TABS: { id: Tab; label: string }[] = [
20
  { id: "flashcards", label: "Flashcards" },
21
  { id: "mind_map", label: "Mind Constellations" },
22
  { id: "infographic", label: "Infographics" },
 
 
23
  { id: "videos", label: "Videos" },
24
- { id: "voices", label: "Voices" },
25
  ];
26
  const PAGE = 30;
27
 
28
  export default function Archive() {
29
  const [params, setParams] = useSearchParams();
 
30
  const tab: Tab = TABS.find((t) => t.id === params.get("tab"))?.id ?? "all";
31
  // The tab lives in the URL (?tab=videos), so the sidebar, redirects and Back land on it.
32
  const pickTab = (t: Tab) => setParams(t === "all" ? {} : { tab: t });
@@ -35,11 +38,22 @@ export default function Archive() {
35
  <div className="page-wrap">
36
  <PageHeader eyebrow="Library" title="Everything you have made" />
37
  <Tabs<Tab> tabs={TABS} value={tab} onChange={pickTab} />
38
- {tab === "videos" ? <Videos /> : tab === "voices" ? <VoiceProfile /> : <MadeList filter={tab} />}
39
  </div>
40
  );
41
  }
42
 
 
 
 
 
 
 
 
 
 
 
 
43
  /** What was made, of one kind (or all), searchable. */
44
  function MadeList({ filter }: { filter: Filter }) {
45
  const [q, setQ] = useState("");
@@ -48,19 +62,31 @@ function MadeList({ filter }: { filter: Filter }) {
48
  const [failed, setFailed] = useState(false);
49
  const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null);
50
 
 
 
 
51
  const load = useCallback(
52
- (offset = 0) =>
53
- artifactsApi
 
54
  .list({ type: filter === "all" ? undefined : filter, q, limit: PAGE, offset })
55
  .then((p) => {
 
56
  setFailed(false);
57
  setTotal(p.total);
58
  setItems((cur) => (offset ? [...(cur ?? []), ...p.items] : p.items));
59
  })
60
- .catch(() => setFailed(true)),
 
61
  [filter, q],
62
  );
63
 
 
 
 
 
 
 
64
  useEffect(() => {
65
  const t = setTimeout(() => load(0), 200);
66
  return () => clearTimeout(t);
@@ -76,7 +102,7 @@ function MadeList({ filter }: { filter: Filter }) {
76
  <EmptyState title="Could not load your library" body="Something went wrong reaching the server."
77
  action={<button className="btn btn-primary" onClick={() => load(0)}>Try again</button>} />
78
  )}
79
- {items?.length === 0 && <EmptyState title="Nothing here yet" body="Reports, summaries, quizzes, flashcards, Mind Constellations, audio and more that you create show up here."
80
  action={
81
  <Link className="btn btn-primary" to="/app/studio">
82
  Create something
@@ -88,7 +114,8 @@ function MadeList({ filter }: { filter: Filter }) {
88
  <ArtifactRow
89
  key={a.id}
90
  artifact={a}
91
- actions={(art) => (canPost(art) ? <PostButton artifactId={art.id} /> : null)}
 
92
  onCite={(c) => c.document_id && setReading({ id: c.document_id, start: c.line_start, end: c.line_end })}
93
  onRemoved={(id) => {
94
  setItems((list) => (list ?? []).filter((x) => x.id !== id));
 
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { Link, Navigate, useSearchParams } from "react-router-dom";
3
  import { artifactsApi } from "../api/endpoints";
4
  import type { Artifact } from "../api/types";
5
  import { ArtifactRow } from "../components/ArtifactRow";
6
  import { Reader } from "../components/Reader";
7
  import { EmptyState, Loading, PageHeader, Tabs } from "../components/ui";
8
  import Videos from "./Videos";
 
9
  import { canPost, PostButton } from "../components/launchpad/PostButton";
10
+ import { AudioOnlyModal } from "../components/launchpad/Attachment";
11
 
12
+ type Filter = "all" | "report" | "summary" | "quiz" | "flashcards" | "mind_map" | "infographic" | "slide_deck" | "audio_overview";
13
+ /** The Library's tabs: kinds of things made (a filter on one list), plus your videos (their own page). Voices moved to
14
+ * the sidebar (Manage voices, /app/voices); an old ?tab=voices link goes there. */
15
+ type Tab = Filter | "videos";
16
  const TABS: { id: Tab; label: string }[] = [
17
  { id: "all", label: "All" },
18
  { id: "report", label: "Reports" },
 
21
  { id: "flashcards", label: "Flashcards" },
22
  { id: "mind_map", label: "Mind Constellations" },
23
  { id: "infographic", label: "Infographics" },
24
+ { id: "slide_deck", label: "Slide decks" },
25
+ { id: "audio_overview", label: "Audio overviews" },
26
  { id: "videos", label: "Videos" },
 
27
  ];
28
  const PAGE = 30;
29
 
30
  export default function Archive() {
31
  const [params, setParams] = useSearchParams();
32
+ if (params.get("tab") === "voices") return <Navigate to="/app/voices" replace />;
33
  const tab: Tab = TABS.find((t) => t.id === params.get("tab"))?.id ?? "all";
34
  // The tab lives in the URL (?tab=videos), so the sidebar, redirects and Back land on it.
35
  const pickTab = (t: Tab) => setParams(t === "all" ? {} : { tab: t });
 
38
  <div className="page-wrap">
39
  <PageHeader eyebrow="Library" title="Everything you have made" />
40
  <Tabs<Tab> tabs={TABS} value={tab} onChange={pickTab} />
41
+ {tab === "videos" ? <Videos /> : <MadeList filter={tab} />}
42
  </div>
43
  );
44
  }
45
 
46
+ /** "Post" on an audio overview: X and LinkedIn take no audio files, so it explains that and offers the download. */
47
+ function AudioPostButton({ artifact }: { artifact: Artifact }) {
48
+ const [open, setOpen] = useState(false);
49
+ return (
50
+ <>
51
+ <button type="button" className="btn btn-small" onClick={() => setOpen(true)}>Post</button>
52
+ {open && <AudioOnlyModal title={artifact.title} downloadUrl={artifact.download_url} onClose={() => setOpen(false)} />}
53
+ </>
54
+ );
55
+ }
56
+
57
  /** What was made, of one kind (or all), searchable. */
58
  function MadeList({ filter }: { filter: Filter }) {
59
  const [q, setQ] = useState("");
 
62
  const [failed, setFailed] = useState(false);
63
  const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null);
64
 
65
+ // The tab and search the list is showing: an answer for any other (a tab just left) is dropped when it arrives late.
66
+ const showing = useRef("");
67
+ showing.current = `${filter}|${q}`;
68
  const load = useCallback(
69
+ (offset = 0) => {
70
+ const asked = `${filter}|${q}`;
71
+ return artifactsApi
72
  .list({ type: filter === "all" ? undefined : filter, q, limit: PAGE, offset })
73
  .then((p) => {
74
+ if (showing.current !== asked) return;
75
  setFailed(false);
76
  setTotal(p.total);
77
  setItems((cur) => (offset ? [...(cur ?? []), ...p.items] : p.items));
78
  })
79
+ .catch(() => showing.current === asked && setFailed(true));
80
+ },
81
  [filter, q],
82
  );
83
 
84
+ // Another tab: the loader until its items are fetched, never the last tab's items meanwhile.
85
+ useEffect(() => {
86
+ setItems(null);
87
+ setFailed(false);
88
+ }, [filter]);
89
+
90
  useEffect(() => {
91
  const t = setTimeout(() => load(0), 200);
92
  return () => clearTimeout(t);
 
102
  <EmptyState title="Could not load your library" body="Something went wrong reaching the server."
103
  action={<button className="btn btn-primary" onClick={() => load(0)}>Try again</button>} />
104
  )}
105
+ {items?.length === 0 && <EmptyState title="Nothing here yet" body="Reports, summaries, quizzes, flashcards, Mind Constellations, slide decks, audio and more that you create show up here."
106
  action={
107
  <Link className="btn btn-primary" to="/app/studio">
108
  Create something
 
114
  <ArtifactRow
115
  key={a.id}
116
  artifact={a}
117
+ actions={(art) => (canPost(art) ? <PostButton artifactId={art.id} />
118
+ : art.type === "audio_overview" && art.status === "ready" ? <AudioPostButton artifact={art} /> : null)}
119
  onCite={(c) => c.document_id && setReading({ id: c.document_id, start: c.line_start, end: c.line_end })}
120
  onRemoved={(id) => {
121
  setItems((list) => (list ?? []).filter((x) => x.id !== id));
frontend/src/pages/Launchpad.tsx CHANGED
@@ -52,8 +52,10 @@ function ItemModal({
52
  const locked = item.status === "posted" || item.status === "publishing";
53
  const options = (accounts?.accounts ?? []).filter((a) => a.platform === item.platform);
54
 
 
 
55
  const save = async () => {
56
- if (moved && wallToDate(when) <= new Date()) return setError("Pick a time that is still ahead.");
57
  setBusy(true);
58
  setError(null);
59
  try {
@@ -62,7 +64,8 @@ function ItemModal({
62
  await launchpadApi.update(item.id, {
63
  content: clean[0],
64
  thread: clean.slice(1),
65
- ...(moved ? { local_time: when, timezone: userTimeZone() } : {}),
 
66
  social_account_id: accountId || null,
67
  ...((attached?.id ?? null) !== item.artifact_id ? { artifact_id: attached?.id ?? null } : {}),
68
  }),
@@ -143,7 +146,7 @@ function ItemModal({
143
  </label>
144
  ))}
145
  {!locked && (
146
- <div className="row">
147
  <div className="field">
148
  <span>When</span>
149
  <SlotPicker value={when} onChange={setWhen} />
@@ -164,7 +167,7 @@ function ItemModal({
164
  </div>
165
  )}
166
  {error && <p className="error-text">{error}</p>}
167
- <div className="row between">
168
  <div className="row">
169
  <CopyButton text={posts.join("\n\n")} />
170
  <ConfirmButton
@@ -201,8 +204,9 @@ function ItemModal({
201
  >
202
  {item.auto_post ? "Publish now" : "Send reminder now"}
203
  </button>
204
- <button className="btn btn-primary" disabled={busy} onClick={save}>
205
- Save
 
206
  </button>
207
  </div>
208
  )}
@@ -408,7 +412,6 @@ export default function Launchpad() {
408
  </button>
409
  </PageHeader>
410
  {notice && <p className="notice">{notice}</p>}
411
- {publishing && <p className="notice">Publishing... uploading a video can take a few minutes.</p>}
412
  {error && <p className="error-text">{error}</p>}
413
 
414
 
@@ -478,7 +481,14 @@ export default function Launchpad() {
478
  {items.length === 0 && <li className="muted">Nothing scheduled. Quiet moon tonight.</li>}
479
  </ul>
480
  )}
481
- {items && <p className="mono muted small">{upcoming.length} upcoming in view</p>}
 
 
 
 
 
 
 
482
  </section>
483
 
484
  <ConnectionsPanel accounts={accounts} onConnect={connect} onChanged={async () => {
@@ -495,22 +505,75 @@ export default function Launchpad() {
495
 
496
  /** /app/launchpad/new: what to launch. Something from the Library opens the composer with it attached; Create a
497
  * launch kit goes to the kit pages (and is scheduled from the kit). */
 
 
498
  export function ScheduleLaunch() {
499
  const navigate = useNavigate();
500
  const [picked, setPicked] = useState<PostableArtifact | null>(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
501
  return (
502
  <div className="page-wrap">
503
  <BackArrow fallback="/app/launchpad" />
504
  <PageHeader eyebrow="Launchpad" title="Schedule a launch" />
 
505
  <section className="card">
506
  <LibraryPicker fill onPick={setPicked} onPickKit={(k) => navigate(`/app/launchpad/kits/${k.id}`)}
507
- onCreateKit={() => navigate("/app/launchpad/kits")} />
 
 
 
 
 
508
  </section>
509
  {picked && (
510
  <ScheduleModal platform="linkedin" posts={[]} artifact={picked} artifactId={picked.id}
511
  onClose={() => setPicked(null)}
512
  onScheduled={() => navigate("/app/launchpad")} />
513
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
514
  </div>
515
  );
516
  }
 
52
  const locked = item.status === "posted" || item.status === "publishing";
53
  const options = (accounts?.accounts ?? []).filter((a) => a.platform === item.platform);
54
 
55
+ // Not on the calendar to go out (a draft, failed, reminded or paused post): Save puts it back, at the time shown.
56
+ const reschedules = item.status !== "scheduled";
57
  const save = async () => {
58
+ if ((moved || reschedules) && wallToDate(when) <= new Date()) return setError("Pick a time that is still ahead.");
59
  setBusy(true);
60
  setError(null);
61
  try {
 
64
  await launchpadApi.update(item.id, {
65
  content: clean[0],
66
  thread: clean.slice(1),
67
+ ...(moved || reschedules ? { local_time: when, timezone: userTimeZone() } : {}),
68
+ ...(reschedules ? { status: "scheduled" as const } : {}),
69
  social_account_id: accountId || null,
70
  ...((attached?.id ?? null) !== item.artifact_id ? { artifact_id: attached?.id ?? null } : {}),
71
  }),
 
146
  </label>
147
  ))}
148
  {!locked && (
149
+ <div className="lp-edit-when">
150
  <div className="field">
151
  <span>When</span>
152
  <SlotPicker value={when} onChange={setWhen} />
 
167
  </div>
168
  )}
169
  {error && <p className="error-text">{error}</p>}
170
+ <div className="row between lp-edit-actions">
171
  <div className="row">
172
  <CopyButton text={posts.join("\n\n")} />
173
  <ConfirmButton
 
204
  >
205
  {item.auto_post ? "Publish now" : "Send reminder now"}
206
  </button>
207
+ <button className="btn btn-primary" disabled={busy} onClick={save}
208
+ title={reschedules ? "Save it and put it back on the calendar at this time" : undefined}>
209
+ {reschedules ? "Reschedule" : "Save"}
210
  </button>
211
  </div>
212
  )}
 
412
  </button>
413
  </PageHeader>
414
  {notice && <p className="notice">{notice}</p>}
 
415
  {error && <p className="error-text">{error}</p>}
416
 
417
 
 
481
  {items.length === 0 && <li className="muted">Nothing scheduled. Quiet moon tonight.</li>}
482
  </ul>
483
  )}
484
+ {items && (
485
+ <div className="lp-cal-foot">
486
+ <p className="mono muted small">{upcoming.length} upcoming in view</p>
487
+ <p className="muted small lp-cal-hint">
488
+ Click a post on the calendar to edit it: change its words or time, reschedule it, or publish it now.
489
+ </p>
490
+ </div>
491
+ )}
492
  </section>
493
 
494
  <ConnectionsPanel accounts={accounts} onConnect={connect} onChanged={async () => {
 
505
 
506
  /** /app/launchpad/new: what to launch. Something from the Library opens the composer with it attached; Create a
507
  * launch kit goes to the kit pages (and is scheduled from the kit). */
508
+ /** Schedule a launch: pick something from the Library to post. Something already scheduled says Reschedule and opens
509
+ * that post (the calendar's editor), and its menu can schedule it once more, starting from a copy of that post. */
510
  export function ScheduleLaunch() {
511
  const navigate = useNavigate();
512
  const [picked, setPicked] = useState<PostableArtifact | null>(null);
513
+ // A post already on the calendar: opened to edit (rescheduled), or copied into a new one (a duplicate schedule).
514
+ const [editing, setEditing] = useState<CalendarItem | null>(null);
515
+ const [copying, setCopying] = useState<{ a: PostableArtifact; item: CalendarItem } | null>(null);
516
+ const [accounts, setAccounts] = useState<SocialAccounts | null>(null);
517
+ const [refreshKey, setRefreshKey] = useState(0);
518
+ const [error, setError] = useState<string | null>(null);
519
+ const [openingId, setOpeningId] = useState<string | null>(null); // the row whose post is loading
520
+ // The accounts the editor needs, loaded once with the page rather than on every click.
521
+ useEffect(() => {
522
+ launchpadApi.accounts().then(setAccounts, () => undefined);
523
+ }, []);
524
+ const scheduledItem = async (artifactId: string, id: string) => {
525
+ setError(null);
526
+ setOpeningId(artifactId);
527
+ try {
528
+ const [all, acc] = await Promise.all([launchpadApi.items({ status: "scheduled" }),
529
+ accounts ? Promise.resolve(accounts) : launchpadApi.accounts()]);
530
+ setAccounts(acc);
531
+ const found = all.find((i) => i.id === id);
532
+ if (!found) {
533
+ setError("That post isn't scheduled anymore.");
534
+ setRefreshKey((k) => k + 1);
535
+ }
536
+ return found ?? null;
537
+ } catch (e) {
538
+ setError(errorMessage(e));
539
+ return null;
540
+ } finally {
541
+ setOpeningId(null);
542
+ }
543
+ };
544
  return (
545
  <div className="page-wrap">
546
  <BackArrow fallback="/app/launchpad" />
547
  <PageHeader eyebrow="Launchpad" title="Schedule a launch" />
548
+ {error && <p className="error-text">{error}</p>}
549
  <section className="card">
550
  <LibraryPicker fill onPick={setPicked} onPickKit={(k) => navigate(`/app/launchpad/kits/${k.id}`)}
551
+ onCreateKit={() => navigate("/app/launchpad/kits")} refreshKey={refreshKey} openingId={openingId}
552
+ onReschedule={async (a, id) => setEditing(await scheduledItem(a.id, id))}
553
+ onDuplicate={async (a, id) => {
554
+ const item = await scheduledItem(a.id, id);
555
+ if (item) setCopying({ a, item });
556
+ }} />
557
  </section>
558
  {picked && (
559
  <ScheduleModal platform="linkedin" posts={[]} artifact={picked} artifactId={picked.id}
560
  onClose={() => setPicked(null)}
561
  onScheduled={() => navigate("/app/launchpad")} />
562
  )}
563
+ {copying && (
564
+ <ScheduleModal platform={copying.item.platform} posts={[copying.item.content, ...copying.item.thread]}
565
+ artifact={copying.a} artifactId={copying.a.id}
566
+ onClose={() => setCopying(null)}
567
+ onScheduled={() => {
568
+ setCopying(null);
569
+ setRefreshKey((k) => k + 1);
570
+ }} />
571
+ )}
572
+ {editing && (
573
+ <ItemModal item={editing} accounts={accounts} onClose={() => setEditing(null)}
574
+ onChanged={() => setRefreshKey((k) => k + 1)}
575
+ onReconnect={() => navigate("/app/launchpad")} />
576
+ )}
577
  </div>
578
  );
579
  }
frontend/src/pages/VideoCreate.tsx CHANGED
@@ -465,7 +465,7 @@ export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal
465
  {voices === null ? (
466
  <Loading label="Loading voices" />
467
  ) : saved.length === 0 ? (
468
- <p className="muted">No voices yet. Add some on the <Link to="/app/archive?tab=voices">Voices</Link> tab of your Library.</p>
469
  ) : (
470
  <div className="vw-voice-list vw-voice-list-compact" role="radiogroup" aria-label="Narration voice">
471
  {saved.map((v) => {
 
465
  {voices === null ? (
466
  <Loading label="Loading voices" />
467
  ) : saved.length === 0 ? (
468
+ <p className="muted">No voices yet. Add some in <Link to="/app/voices">Manage voices</Link>.</p>
469
  ) : (
470
  <div className="vw-voice-list vw-voice-list-compact" role="radiogroup" aria-label="Narration voice">
471
  {saved.map((v) => {
frontend/src/pages/VoiceProfile.tsx CHANGED
@@ -1,21 +1,11 @@
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
- import { useSearchParams } from "react-router-dom";
3
  import { voiceApi } from "../api/endpoints";
4
- import type { Job, VoiceDesignInput, VoiceDesignPreview, VoiceProfileData, VoiceState } from "../api/types";
5
- import { DocPicker } from "../components/DocPicker";
6
  import { YourVoices } from "../components/voice/YourVoices";
7
- import { ConfirmButton, errorMessage, formatDate, JobProgress, Loading, Modal, Tabs } from "../components/ui";
8
  import { useJob } from "../hooks/useJob";
9
  import { useUpgrade } from "../hooks/useUpgrade";
10
 
11
- const LIST_FIELDS: { key: keyof VoiceProfileData; label: string; hint: string }[] = [
12
- { key: "tone", label: "Tone", hint: "warm, wry, direct" },
13
- { key: "vocabulary", label: "Signature words and phrases", hint: "readers, the long game" },
14
- { key: "structure_habits", label: "Structure habits", hint: "short paragraphs, one idea each" },
15
- { key: "openings", label: "How pieces open", hint: "a number, a small scene" },
16
- { key: "avoid", label: "Never does", hint: "jargon, hashtags" },
17
- ];
18
-
19
  /** Plays one URL at a time across the page. */
20
  function usePlayer() {
21
  const audio = useRef<HTMLAudioElement | null>(null);
@@ -416,216 +406,79 @@ function CloneModal({ state, onClose, onDone }: { state: VoiceState; onClose: ()
416
  );
417
  }
418
 
419
- // The page: two steps. The writing voice builds itself, then the writer picks the two hosts.
420
- // Cloning lives in the "+ Add a voice" menu; a clone's status shows under the voice cards.
421
-
422
- type Step = "writing" | "hosts";
423
-
424
- /** The underlined link between the two views (Speaking voices <-> Writing voice), its arrow pointing the way. */
425
- function ViewLink({ to, label, onGo }: { to: Step; label: string; onGo: (s: Step) => void }) {
426
- const back = to === "hosts";
427
- return (
428
- <button type="button" className={`vp-switch${back ? " back" : ""}`} onClick={() => onGo(to)}>
429
- {back && <span aria-hidden="true">←</span>}
430
- <span className="vp-switch-text">{label}</span>
431
- {!back && <span aria-hidden="true">→</span>}
432
- </button>
433
- );
434
- }
435
 
436
  export default function VoiceProfile() {
437
  const [state, setState] = useState<VoiceState | null>(null);
438
- const [draft, setDraft] = useState<VoiceProfileData | null>(null);
439
- const [params] = useSearchParams();
440
- // Opens on Speaking voices; ?step=writing opens the writing voice (links between the two switch views).
441
- const [step, setStep] = useState<Step>(params.get("step") === "writing" ? "writing" : "hosts");
442
- const [picking, setPicking] = useState(false);
443
- const [samples, setSamples] = useState<string[]>([]);
444
- const [buildJob, setBuildJob] = useState<Job | null>(null);
445
  const [cloneJob, setCloneJob] = useState<Job | null>(null);
446
  const [cloning, setCloning] = useState(false);
447
  const [adding, setAdding] = useState(false); // the Add voices modal
448
  const [voicesKey, setVoicesKey] = useState(0); // bumped when a voice was made, so Your voices reloads
449
  const { openUpgrade } = useUpgrade();
450
- const [saved, setSaved] = useState(false);
451
  const [error, setError] = useState<string | null>(null);
452
- const autoBuilt = useRef(false);
453
  const { play, playing } = usePlayer();
454
 
455
- const load = () =>
456
- voiceApi.get().then((s) => {
457
- setState(s);
458
- setDraft(s.profile);
459
- setSamples(s.sample_doc_ids);
460
- return s;
461
- });
462
  const loadVoices = () => setVoicesKey((k) => k + 1);
463
 
464
- const startBuild = async (ids: string[]) => {
465
- setPicking(false);
466
- setError(null);
467
- try {
468
- setBuildJob(await voiceApi.build(ids));
469
- } catch (e) {
470
- setError(errorMessage(e));
471
- }
472
- };
473
-
474
  useEffect(() => {
475
- // No writing voice yet: build one from the longest posts straight away instead of asking.
476
- load().then((s) => {
477
- if (!s.profile && !autoBuilt.current) {
478
- autoBuilt.current = true;
479
- void startBuild([]);
480
- }
481
- });
482
  // eslint-disable-next-line react-hooks/exhaustive-deps
483
  }, []);
484
 
485
- const build = useJob(buildJob, () => load());
486
  const clone = useJob(cloneJob, () => {
487
  load();
488
  loadVoices(); // a finished clone is saved to Your voices as "My voice"
489
  });
490
 
491
  if (!state) return <div className="loading-center"><Loading label="Loading your voice" /></div>;
492
- const building = build && (build.status === "queued" || build.status === "running");
493
  const cloneRunning = clone && (clone.status === "queued" || clone.status === "running");
494
  const showClone = state.clone.status !== "none" || Boolean(clone);
495
 
496
- const saveProfile = async () => {
497
- setError(null);
498
- try {
499
- setState(await voiceApi.update({ profile: draft! }));
500
- setSaved(true);
501
- setTimeout(() => setSaved(false), 1500);
502
- } catch (e) {
503
- setError(errorMessage(e));
504
- }
505
- };
506
-
507
  return (
508
- // The Library's Voices tab: speaking voices, and the writing voice behind a link.
509
  <div className="stack vp">
510
-
511
- <div className="vp-switch-row">
512
- {step === "hosts"
513
- ? <ViewLink to="writing" label="Writing voice" onGo={setStep} />
514
- : <ViewLink to="hosts" label="Speaking voices" onGo={setStep} />}
515
- </div>
516
  {error && <p className="error-text">{error}</p>}
517
 
518
- {step === "writing" && (
519
- <section className="card stack vp-card">
520
- <h2>How you write</h2>
521
- <p className="muted">Launch Kits, carousels, hooks and scripts are all written in this voice, so they read like you wrote them.</p>
522
- {build && (building || build.status === "failed") && <JobProgress job={build} />}
523
- {!draft && !building && (
 
 
 
 
 
524
  <div className="row">
525
- <button className="btn btn-primary" onClick={() => startBuild([])}>
526
- Build my writing voice
527
- </button>
528
- <button className="link-btn small" onClick={() => setPicking(true)}>
529
- or pick the posts yourself
530
- </button>
531
- </div>
532
- )}
533
-
534
- {draft && (
535
- <>
536
- <label className="field">
537
- <span>In a sentence, you sound like this</span>
538
- <textarea className="textarea" rows={7} value={draft.summary} onChange={(e) => setDraft({ ...draft, summary: e.target.value })} />
539
- </label>
540
- <details className="delivery">
541
- <summary className="mono muted small">Fine-tune tone, phrases and habits</summary>
542
- <label className="field">
543
- <span>Sentence length</span>
544
- <select className="input input-sm" value={draft.sentence_length} onChange={(e) => setDraft({ ...draft, sentence_length: e.target.value as VoiceProfileData["sentence_length"] })}>
545
- {["short", "medium", "long", "varied"].map((v) => (
546
- <option key={v}>{v}</option>
547
- ))}
548
- </select>
549
- </label>
550
- {LIST_FIELDS.map((f) => (
551
- <label key={f.key} className="field">
552
- <span>{f.label}</span>
553
- <input
554
- className="input input-sm"
555
- placeholder={f.hint}
556
- value={(draft[f.key] as string[]).join(", ")}
557
- onChange={(e) => setDraft({ ...draft, [f.key]: e.target.value.split(",").map((x) => x.trim()).filter(Boolean) })}
558
- />
559
- </label>
560
- ))}
561
- </details>
562
- <p className="mono muted small">
563
- Built {formatDate(state.updated_at, true)} from {state.sample_doc_ids.length} posts ·{" "}
564
- <button className="link-btn small" disabled={Boolean(building)} onClick={() => setPicking(true)}>
565
- Rebuild from other posts
566
  </button>
567
- </p>
568
- <div className="row end">
569
- <button className="btn btn-small" onClick={saveProfile}>
570
- {saved ? "Saved" : "Save changes"}
571
  </button>
572
- </div>
573
- </>
574
- )}
575
- </section>
576
- )}
577
-
578
- {step === "hosts" && (
579
- <section className="card stack vp-card">
580
- <h2>Who reads your work aloud</h2>
581
- {!state.tts_configured && <p className="muted">Voice previews are unavailable right now.</p>}
582
- <YourVoices reloadKey={voicesKey} onAdd={() => setAdding(true)} />
583
-
584
- {showClone && (
585
- <div className="vp-clone">
586
- <strong>Your cloned voice</strong>
587
- {clone && (cloneRunning || clone.status === "failed") && <JobProgress job={clone} compact />}
588
- {state.clone.status === "processing" && !clone && <p className="muted small">Your voice is being created. It joins your voices when ready.</p>}
589
- {state.clone.status === "failed" && state.clone.error && !cloneRunning && <p className="error-text">{state.clone.error}</p>}
590
- <div className="row">
591
- {state.clone.status === "ready" && state.clone.preview_url && (
592
- <button className="btn btn-small" onClick={() => play(state.clone.preview_url!, "clone")}>
593
- {playing === "clone" ? "Stop" : "Hear my voice"}
594
- </button>
595
- )}
596
- {!cloneRunning && state.clone.allowed && (
597
- <button className="btn btn-small" onClick={() => setCloning(true)}>
598
- {state.clone.status === "failed" ? "Try again" : "Record again"}
599
- </button>
600
- )}
601
- {state.clone.status !== "none" && (
602
- <ConfirmButton
603
- confirmLabel="Revoke and delete my voice?"
604
- onConfirm={async () => {
605
- setState(await voiceApi.revoke());
606
- loadVoices();
607
- }}
608
- >
609
- Revoke consent
610
- </ConfirmButton>
611
- )}
612
- </div>
613
  </div>
614
- )}
615
- </section>
616
- )}
617
-
618
- {picking && (
619
- <Modal title="Pick 5 to 10 posts that sound most like you" onClose={() => setPicking(false)} wide>
620
- <DocPicker selected={samples} onChange={setSamples} max={10} />
621
- <p className="muted small">Leave empty to let Notestack pick your longest posts.</p>
622
- <div className="row end">
623
- <button className="btn btn-primary" onClick={() => startBuild(samples)}>
624
- Build profile
625
- </button>
626
  </div>
627
- </Modal>
628
- )}
 
629
  {cloning && <CloneModal state={state} onClose={() => setCloning(false)} onDone={setCloneJob} />}
630
  {adding && (
631
  <Modal title="Add voices" onClose={() => setAdding(false)} wide>
 
1
  import { useCallback, useEffect, useRef, useState } from "react";
 
2
  import { voiceApi } from "../api/endpoints";
3
+ import type { Job, VoiceDesignInput, VoiceDesignPreview, VoiceState } from "../api/types";
 
4
  import { YourVoices } from "../components/voice/YourVoices";
5
+ import { ConfirmButton, errorMessage, JobProgress, Loading, Modal, Tabs } from "../components/ui";
6
  import { useJob } from "../hooks/useJob";
7
  import { useUpgrade } from "../hooks/useUpgrade";
8
 
 
 
 
 
 
 
 
 
9
  /** Plays one URL at a time across the page. */
10
  function usePlayer() {
11
  const audio = useRef<HTMLAudioElement | null>(null);
 
406
  );
407
  }
408
 
409
+ // The page: the speaking voices (who reads audio and video aloud). Cloning lives in the "+ Add a voice" menu; a
410
+ // clone's status shows under the voice cards. The writing voice is built by the server on import and not shown here.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
 
412
  export default function VoiceProfile() {
413
  const [state, setState] = useState<VoiceState | null>(null);
 
 
 
 
 
 
 
414
  const [cloneJob, setCloneJob] = useState<Job | null>(null);
415
  const [cloning, setCloning] = useState(false);
416
  const [adding, setAdding] = useState(false); // the Add voices modal
417
  const [voicesKey, setVoicesKey] = useState(0); // bumped when a voice was made, so Your voices reloads
418
  const { openUpgrade } = useUpgrade();
 
419
  const [error, setError] = useState<string | null>(null);
 
420
  const { play, playing } = usePlayer();
421
 
422
+ const load = () => voiceApi.get().then(setState, (e) => setError(errorMessage(e)));
 
 
 
 
 
 
423
  const loadVoices = () => setVoicesKey((k) => k + 1);
424
 
 
 
 
 
 
 
 
 
 
 
425
  useEffect(() => {
426
+ void load();
 
 
 
 
 
 
427
  // eslint-disable-next-line react-hooks/exhaustive-deps
428
  }, []);
429
 
 
430
  const clone = useJob(cloneJob, () => {
431
  load();
432
  loadVoices(); // a finished clone is saved to Your voices as "My voice"
433
  });
434
 
435
  if (!state) return <div className="loading-center"><Loading label="Loading your voice" /></div>;
 
436
  const cloneRunning = clone && (clone.status === "queued" || clone.status === "running");
437
  const showClone = state.clone.status !== "none" || Boolean(clone);
438
 
 
 
 
 
 
 
 
 
 
 
 
439
  return (
440
+ // Manage voices (/app/voices): the speaking voices.
441
  <div className="stack vp">
 
 
 
 
 
 
442
  {error && <p className="error-text">{error}</p>}
443
 
444
+ <section className="card stack vp-card">
445
+ <h2>Who reads your work aloud</h2>
446
+ {!state.tts_configured && <p className="muted">Voice previews are unavailable right now.</p>}
447
+ <YourVoices reloadKey={voicesKey} onAdd={() => setAdding(true)} />
448
+
449
+ {showClone && (
450
+ <div className="vp-clone">
451
+ <strong>Your cloned voice</strong>
452
+ {clone && (cloneRunning || clone.status === "failed") && <JobProgress job={clone} compact />}
453
+ {state.clone.status === "processing" && !clone && <p className="muted small">Your voice is being created. It joins your voices when ready.</p>}
454
+ {state.clone.status === "failed" && state.clone.error && !cloneRunning && <p className="error-text">{state.clone.error}</p>}
455
  <div className="row">
456
+ {state.clone.status === "ready" && state.clone.preview_url && (
457
+ <button className="btn btn-small" onClick={() => play(state.clone.preview_url!, "clone")}>
458
+ {playing === "clone" ? "Stop" : "Hear my voice"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
459
  </button>
460
+ )}
461
+ {!cloneRunning && state.clone.allowed && (
462
+ <button className="btn btn-small" onClick={() => setCloning(true)}>
463
+ {state.clone.status === "failed" ? "Try again" : "Record again"}
464
  </button>
465
+ )}
466
+ {state.clone.status !== "none" && (
467
+ <ConfirmButton
468
+ confirmLabel="Revoke and delete my voice?"
469
+ onConfirm={async () => {
470
+ setState(await voiceApi.revoke());
471
+ loadVoices();
472
+ }}
473
+ >
474
+ Revoke consent
475
+ </ConfirmButton>
476
+ )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
478
  </div>
479
+ )}
480
+ </section>
481
+
482
  {cloning && <CloneModal state={state} onClose={() => setCloning(false)} onDone={setCloneJob} />}
483
  {adding && (
484
  <Modal title="Add voices" onClose={() => setAdding(false)} wide>
frontend/src/pages/Voices.tsx ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { PageHeader } from "../components/ui";
2
+ import VoiceProfile from "./VoiceProfile";
3
+
4
+ /** Manage voices (in the sidebar): the speaking voices for audio and video, and the writing voice. */
5
+ export default function Voices() {
6
+ return (
7
+ <div className="page-wrap">
8
+ <PageHeader eyebrow="Voices" title="Manage voices" />
9
+ <VoiceProfile />
10
+ </div>
11
+ );
12
+ }
frontend/src/styles/pages.css CHANGED
@@ -2277,54 +2277,6 @@ select.input option {
2277
  animation: welcome-in 0.35s ease both;
2278
  }
2279
 
2280
- /* The link between the two views (Speaking voices / Writing voice): underlined text and an arrow */
2281
- .vp-switch-row {
2282
- display: flex;
2283
- justify-content: flex-end; /* at the right end */
2284
- margin: -6px 0 4px;
2285
- }
2286
-
2287
- .vp-switch {
2288
- display: inline-flex;
2289
- align-items: center;
2290
- gap: 8px;
2291
- padding: 4px 0;
2292
- border: 0;
2293
- background: none;
2294
- color: var(--text-2);
2295
- font: inherit;
2296
- font-size: 0.95rem;
2297
- cursor: pointer;
2298
- transition: color 0.15s;
2299
- }
2300
-
2301
- .vp-switch-text {
2302
- text-decoration: underline;
2303
- text-decoration-color: var(--line-strong);
2304
- text-underline-offset: 4px;
2305
- transition: text-decoration-color 0.15s;
2306
- }
2307
-
2308
- .vp-switch:hover {
2309
- color: var(--text);
2310
- }
2311
-
2312
- .vp-switch:hover .vp-switch-text {
2313
- text-decoration-color: var(--blue);
2314
- }
2315
-
2316
- .vp-switch > span[aria-hidden] {
2317
- transition: transform 0.15s;
2318
- }
2319
-
2320
- .vp-switch:hover > span[aria-hidden] {
2321
- transform: translateX(3px);
2322
- }
2323
-
2324
- .vp-switch.back:hover > span[aria-hidden] {
2325
- transform: translateX(-3px);
2326
- }
2327
-
2328
  .voice-grid {
2329
  list-style: none;
2330
  margin: 0;
@@ -4403,6 +4355,87 @@ select.input,
4403
  color: var(--white);
4404
  }
4405
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4406
  .lp-pick-go {
4407
  display: inline-flex;
4408
  flex: none;
 
2277
  animation: welcome-in 0.35s ease both;
2278
  }
2279
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2280
  .voice-grid {
2281
  list-style: none;
2282
  margin: 0;
 
4355
  color: var(--white);
4356
  }
4357
 
4358
+ /* Under the calendar: how many posts are coming up, and how to change one */
4359
+ .lp-cal-foot {
4360
+ display: flex;
4361
+ flex-wrap: wrap;
4362
+ align-items: baseline;
4363
+ justify-content: space-between;
4364
+ gap: 6px 16px;
4365
+ }
4366
+
4367
+ .lp-cal-foot p {
4368
+ margin: 0;
4369
+ }
4370
+
4371
+ .lp-cal-hint {
4372
+ color: var(--text-2);
4373
+ }
4374
+
4375
+ /* Editing a scheduled post: When and Account side by side, their labels and boxes on one line (the time zone note
4376
+ sits under When without pushing Account down), and the actions on their own line below a rule */
4377
+ .lp-edit-when {
4378
+ display: grid;
4379
+ grid-template-columns: repeat(2, minmax(0, 1fr));
4380
+ align-items: start;
4381
+ gap: 16px;
4382
+ }
4383
+
4384
+ .lp-edit-when .field {
4385
+ margin: 0;
4386
+ }
4387
+
4388
+ .lp-edit-when select.input {
4389
+ width: 100%;
4390
+ }
4391
+
4392
+ @media (max-width: 640px) {
4393
+ .lp-edit-when {
4394
+ grid-template-columns: minmax(0, 1fr);
4395
+ }
4396
+ }
4397
+
4398
+ .lp-edit-actions {
4399
+ flex-wrap: wrap;
4400
+ gap: 12px;
4401
+ margin-top: 4px;
4402
+ padding-top: 16px;
4403
+ border-top: 1px solid var(--line);
4404
+ }
4405
+
4406
+ /* "Audio can't be posted": the headphones, why, and the download */
4407
+ .lp-audio-only {
4408
+ max-width: 460px;
4409
+ }
4410
+
4411
+ .lp-audio-only p {
4412
+ margin: 0;
4413
+ line-height: 1.6;
4414
+ }
4415
+
4416
+ .lp-audio-only-icon {
4417
+ display: grid;
4418
+ place-items: center;
4419
+ width: 52px;
4420
+ height: 52px;
4421
+ border-radius: 50%;
4422
+ background: var(--blue-10);
4423
+ color: var(--blue);
4424
+ }
4425
+
4426
+ .lp-audio-only-actions {
4427
+ justify-content: flex-end;
4428
+ align-items: center;
4429
+ gap: 10px;
4430
+ }
4431
+
4432
+ /* A slide deck: outlined like a video, dashed (its slides go out as images) */
4433
+ .lp-tag.deck {
4434
+ border-color: var(--blue-35);
4435
+ border-style: dashed;
4436
+ color: var(--white);
4437
+ }
4438
+
4439
  .lp-pick-go {
4440
  display: inline-flex;
4441
  flex: none;