Muhammad-Mehdi commited on
Commit
bdd1640
·
1 Parent(s): cd4f836

linkedin upload done phase 1

Browse files
Files changed (48) hide show
  1. README.md +13 -3
  2. backend/.env.example +34 -0
  3. backend/alembic/versions/0017_calendar_kit.py +35 -0
  4. backend/app/config.py +2 -1
  5. backend/app/models/content.py +38 -1
  6. backend/app/routers/artifacts.py +11 -1
  7. backend/app/routers/launchpad.py +234 -26
  8. backend/app/routers/storage.py +1 -0
  9. backend/app/services/artifacts.py +1 -0
  10. backend/app/services/launchpad.py +56 -10
  11. backend/app/services/social/__init__.py +14 -4
  12. backend/app/services/social/bluesky.py +3 -1
  13. backend/app/services/social/health.py +103 -0
  14. backend/app/services/social/linkedin.py +122 -15
  15. backend/app/services/social/media.py +174 -0
  16. backend/app/services/social/x.py +99 -12
  17. backend/app/services/storage.py +26 -0
  18. backend/app/services/uploads.py +62 -0
  19. backend/app/worker.py +28 -2
  20. backend/tests/test_buildout.py +229 -6
  21. backend/tests/test_social_posting.py +554 -0
  22. frontend/src/App.tsx +6 -3
  23. frontend/src/api/client.ts +2 -0
  24. frontend/src/api/endpoints.ts +15 -1
  25. frontend/src/api/types.ts +38 -2
  26. frontend/src/components/AppShell.tsx +1 -1
  27. frontend/src/components/ArtifactCard.tsx +1 -1
  28. frontend/src/components/ScheduleModal.tsx +235 -108
  29. frontend/src/components/UpgradeModal.tsx +1 -1
  30. frontend/src/components/launchpad/Attachment.tsx +628 -0
  31. frontend/src/components/launchpad/PostButton.tsx +27 -0
  32. frontend/src/components/launchpad/SlotPicker.tsx +133 -0
  33. frontend/src/components/ui.tsx +27 -3
  34. frontend/src/components/video/jobState.ts +23 -0
  35. frontend/src/components/voice/YourVoices.tsx +1 -1
  36. frontend/src/pages/Archive.tsx +3 -1
  37. frontend/src/pages/LaunchKit.tsx +121 -325
  38. frontend/src/pages/Launchpad.tsx +229 -119
  39. frontend/src/pages/MissionControl.tsx +3 -3
  40. frontend/src/pages/NotebookView.tsx +2 -1
  41. frontend/src/pages/Resurface.tsx +2 -2
  42. frontend/src/pages/Studio.tsx +3 -2
  43. frontend/src/pages/TopicMap.tsx +1 -1
  44. frontend/src/pages/VideoEditor.tsx +1 -21
  45. frontend/src/pages/Videos.tsx +3 -1
  46. frontend/src/pages/VoiceProfile.tsx +1 -1
  47. frontend/src/pages/Welcome.tsx +1 -1
  48. frontend/src/styles/pages.css +720 -121
README.md CHANGED
@@ -93,9 +93,19 @@ cd renderer && npm install && npm start # only needed for vid
93
  - With no R2 settings, files live on local disk under `backend/.storage` and are served through
94
  signed `/api/storage/local/...` URLs (range requests supported for audio and video seeking).
95
  - Audio needs `ELEVENLABS_API_KEY`; everything text based needs `LLM_API_KEY`.
96
- - Launchpad: Bluesky connects with an app password. X and LinkedIn need developer apps; the
97
- redirect URIs are listed in `.env.example` and on the Launchpad page. Substack Notes has no API,
98
- so those posts (and any post without a connected account) arrive as an email reminder at send time.
 
 
 
 
 
 
 
 
 
 
99
 
100
  ## What is in the app
101
 
 
93
  - With no R2 settings, files live on local disk under `backend/.storage` and are served through
94
  signed `/api/storage/local/...` URLs (range requests supported for audio and video seeking).
95
  - Audio needs `ELEVENLABS_API_KEY`; everything text based needs `LLM_API_KEY`.
96
+ - Launchpad: posts (text, images, videos from your Library; not audio) go to X and LinkedIn at their
97
+ scheduled time through a `publish_post` job. Set up the developer apps and keys as described in
98
+ `backend/.env.example`:
99
+ - `X_CLIENT_ID` / `X_CLIENT_SECRET`: the X app's **OAuth 2.0 Client ID and Client Secret** (Read and write,
100
+ Web App, callback `{API_URL}/api/social/x/callback`, scopes incl. `media.write`).
101
+ - `LINKEDIN_CLIENT_ID` / `LINKEDIN_CLIENT_SECRET`: products "Sign In with LinkedIn using OpenID Connect" and
102
+ "Share on LinkedIn", redirect `{API_URL}/api/social/linkedin/callback`.
103
+ - `API_URL` must be reachable from the internet for the callbacks (an https tunnel locally).
104
+ X and LinkedIn posts need a connected account. X tokens are refreshed automatically; LinkedIn connections
105
+ last about 60 days. If a connection goes down (expired, revoked on the platform, or disconnected here) its
106
+ scheduled posts are **paused** and never attempted; reconnecting the same account resumes the ones still
107
+ ahead, and missed ones wait for a new time. Bluesky connects with an app password; Substack Notes has no API,
108
+ so those posts arrive as an email reminder at send time.
109
 
110
  ## What is in the app
111
 
backend/.env.example ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Launchpad: posting to X and LinkedIn (copy what you need into .env)
2
+
3
+ # Where the app and the API are reached. Both platforms send people back to
4
+ # {API_URL}/api/social/{x,linkedin}/callback after they connect an account, so API_URL must be reachable from the
5
+ # internet (when running locally, an https tunnel such as ngrok: API_URL=https://<id>.ngrok-free.app).
6
+ FRONTEND_URL=http://localhost:5173
7
+ API_URL=http://localhost:8000
8
+
9
+ # X: developer.x.com > your app > Keys and tokens > "OAuth 2.0 Client ID and Client Secret"
10
+ # (not the API Key / Bearer Token). Then User authentication settings:
11
+ # App permissions: Read and write. Type of app: Web App (confidential client).
12
+ # Callback URI: {API_URL}/api/social/x/callback
13
+ # Scopes asked for: tweet.read tweet.write users.read offline.access media.write
14
+ # (media.write posts images and videos; accounts connected before it was added are asked to reconnect)
15
+ # X is switched off for now (LinkedIn only). Set to true to bring it back.
16
+ X_ENABLED=false
17
+ X_CLIENT_ID=
18
+ X_CLIENT_SECRET=
19
+
20
+ # LinkedIn: linkedin.com/developers > your app > Auth > "Client ID" and "Primary Client Secret"
21
+ # Products: "Sign In with LinkedIn using OpenID Connect" and "Share on LinkedIn".
22
+ # Auth > Authorized redirect URLs: {API_URL}/api/social/linkedin/callback
23
+ # Scopes asked for: openid profile w_member_social
24
+ # LinkedIn tokens last about 60 days; then the account shows Reconnect.
25
+ LINKEDIN_CLIENT_ID=
26
+ LINKEDIN_CLIENT_SECRET=
27
+
28
+ # Connections: X tokens are refreshed automatically (every 10 minutes the worker checks them, and every 6 hours asks
29
+ # each platform whether the connection still works). When a connection goes down, its scheduled posts are paused and
30
+ # never attempted; reconnecting the same account resumes the ones still ahead.
31
+
32
+ # Encrypts the stored social tokens (a Fernet key). Empty: derived from JWT_SECRET.
33
+ # Make one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
34
+ SOCIAL_TOKEN_KEY=
backend/alembic/versions/0017_calendar_kit.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """calendar_items.kit_id: the Launch Kit a scheduled post was written from, kept apart from its attachment
2
+
3
+ Revision ID: 0017_calendar_kit
4
+ Revises: 0016_support_chat
5
+ Create Date: 2026-10-06
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0017_calendar_kit"
11
+ down_revision = "0016_support_chat"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def upgrade() -> None:
17
+ # 0001 builds from the live models, so a fresh database may already have the column.
18
+ if "kit_id" not in {c["name"] for c in sa.inspect(op.get_bind()).get_columns("calendar_items")}:
19
+ with op.batch_alter_table("calendar_items") as batch:
20
+ batch.add_column(sa.Column("kit_id", sa.Uuid(), nullable=True))
21
+ batch.create_foreign_key("fk_calendar_items_kit_id", "artifacts", ["kit_id"], ["id"], ondelete="SET NULL")
22
+ # Posts that carried a Launch Kit as their "attachment": the kit is where they came from, not an attachment.
23
+ op.execute("UPDATE calendar_items SET kit_id = artifact_id, artifact_id = NULL "
24
+ "WHERE artifact_id IN (SELECT id FROM artifacts WHERE type = 'launch_kit')")
25
+
26
+
27
+ def downgrade() -> None:
28
+ op.execute("UPDATE calendar_items SET artifact_id = kit_id WHERE artifact_id IS NULL AND kit_id IS NOT NULL")
29
+ # The foreign key is named here, or by the database when 0001 made the column from the models.
30
+ fks = [fk["name"] for fk in sa.inspect(op.get_bind()).get_foreign_keys("calendar_items")
31
+ if fk["constrained_columns"] == ["kit_id"] and fk.get("name")]
32
+ with op.batch_alter_table("calendar_items") as batch:
33
+ for name in fks:
34
+ batch.drop_constraint(name, type_="foreignkey")
35
+ batch.drop_column("kit_id")
backend/app/config.py CHANGED
@@ -62,7 +62,7 @@ class Settings(BaseSettings):
62
  # Folder inside the bucket, so Notestack can share a bucket with other apps. Empty = bucket root.
63
  r2_prefix: str = ""
64
  r2_presign_ttl_seconds: int = 3600
65
- max_upload_bytes: int = 200 * 1024 * 1024
66
 
67
  # LLM (LiteLLM model strings; Z.ai GLM by default)
68
  llm_model: str = "openai/glm-5.3"
@@ -116,6 +116,7 @@ class Settings(BaseSettings):
116
 
117
  # Social publishing (Launchpad). Redirect URIs: {API_URL}/api/social/{x,linkedin}/callback
118
  social_token_key: str = "" # Fernet key; derived from JWT_SECRET when empty
 
119
  x_client_id: str = ""
120
  x_client_secret: str = ""
121
  linkedin_client_id: str = ""
 
62
  # Folder inside the bucket, so Notestack can share a bucket with other apps. Empty = bucket root.
63
  r2_prefix: str = ""
64
  r2_presign_ttl_seconds: int = 3600
65
+ max_upload_bytes: int = 300 * 1024 * 1024
66
 
67
  # LLM (LiteLLM model strings; Z.ai GLM by default)
68
  llm_model: str = "openai/glm-5.3"
 
116
 
117
  # Social publishing (Launchpad). Redirect URIs: {API_URL}/api/social/{x,linkedin}/callback
118
  social_token_key: str = "" # Fernet key; derived from JWT_SECRET when empty
119
+ x_enabled: bool = False # X posting is switched off for now (LinkedIn only); set X_ENABLED=true to bring it back
120
  x_client_id: str = ""
121
  x_client_secret: str = ""
122
  linkedin_client_id: str = ""
backend/app/models/content.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import uuid
2
  from datetime import datetime
3
 
@@ -14,9 +15,10 @@ from sqlalchemy import (
14
  Text,
15
  UniqueConstraint,
16
  Uuid,
 
17
  text,
18
  )
19
- from sqlalchemy.orm import Mapped, mapped_column, relationship
20
 
21
  from app.db import Base
22
  from app.models.base import IdMixin, TimestampMixin
@@ -330,11 +332,46 @@ class Upload(IdMixin, TimestampMixin, Base):
330
  status: Mapped[str] = mapped_column(String(20), default="pending") # pending | complete
331
 
332
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
  class CalendarItem(IdMixin, TimestampMixin, Base):
334
  __tablename__ = "calendar_items"
335
 
336
  workspace_id: Mapped[uuid.UUID] = _ws_fk()
 
337
  artifact_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="CASCADE"))
 
 
338
  document_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("documents.id", ondelete="SET NULL"))
339
  platform: Mapped[str] = mapped_column(String(30)) # x | linkedin | bluesky | substack_notes
340
  scheduled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
 
1
+ import logging
2
  import uuid
3
  from datetime import datetime
4
 
 
15
  Text,
16
  UniqueConstraint,
17
  Uuid,
18
+ event,
19
  text,
20
  )
21
+ from sqlalchemy.orm import Mapped, Session, mapped_column, relationship
22
 
23
  from app.db import Base
24
  from app.models.base import IdMixin, TimestampMixin
 
332
  status: Mapped[str] = mapped_column(String(20), default="pending") # pending | complete
333
 
334
 
335
+ # Deleting an upload record removes its file from storage too, once the delete is committed (never on a rollback).
336
+ # This covers deletes made through the ORM; rows removed straight in the database are caught by the daily orphan
337
+ # sweep (app.services.uploads.sweep_orphan_uploads).
338
+ _PENDING_FILE_DELETES = "upload_files_to_delete"
339
+
340
+
341
+ @event.listens_for(Session, "after_flush")
342
+ def _note_deleted_uploads(session: Session, _ctx) -> None:
343
+ keys = [obj.key for obj in session.deleted if isinstance(obj, Upload) and obj.key]
344
+ if keys:
345
+ session.info.setdefault(_PENDING_FILE_DELETES, []).extend(keys)
346
+
347
+
348
+ @event.listens_for(Session, "after_commit")
349
+ def _delete_upload_files(session: Session) -> None:
350
+ keys = session.info.pop(_PENDING_FILE_DELETES, [])
351
+ if not keys:
352
+ return
353
+ from app.services.storage import storage
354
+
355
+ for key in keys:
356
+ try:
357
+ storage.delete(key)
358
+ except Exception: # left for the daily sweep
359
+ logging.getLogger("notestack.storage").warning("couldn't delete upload file %s", key, exc_info=True)
360
+
361
+
362
+ @event.listens_for(Session, "after_rollback")
363
+ def _forget_upload_files(session: Session) -> None:
364
+ session.info.pop(_PENDING_FILE_DELETES, None)
365
+
366
+
367
  class CalendarItem(IdMixin, TimestampMixin, Base):
368
  __tablename__ = "calendar_items"
369
 
370
  workspace_id: Mapped[uuid.UUID] = _ws_fk()
371
+ # What the post carries (a video, quote card, carousel...): never a Launch Kit, which is kit_id.
372
  artifact_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="CASCADE"))
373
+ # The Launch Kit the post was written from, kept apart from the attachment (nothing of it is uploaded).
374
+ kit_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="SET NULL"))
375
  document_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("documents.id", ondelete="SET NULL"))
376
  platform: Mapped[str] = mapped_column(String(30)) # x | linkedin | bluesky | substack_notes
377
  scheduled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
backend/app/routers/artifacts.py CHANGED
@@ -152,6 +152,8 @@ def list_artifacts(
152
  query = select(Artifact).where(Artifact.workspace_id == ctx.workspace.id)
153
  if type:
154
  query = query.where(Artifact.type.in_(type.split(",")))
 
 
155
  if notebook_id:
156
  query = query.where(Artifact.notebook_id == notebook_id)
157
  if document_id:
@@ -183,9 +185,13 @@ def patch_artifact(artifact_id: uuid.UUID, body: PatchIn, ctx: Ctx = Depends(get
183
  content = dict(a.content_json or {})
184
  if body.content is not None:
185
  protected = {"slide_keys", "quote_card_ids", "segments", "voices"}
 
 
186
  content.update({k: v for k, v in body.content.items() if k not in protected})
187
  if body.title is not None:
188
- content["title"] = body.title
 
 
189
  a.content_json = content
190
  ctx.db.commit()
191
  return serialize_artifact(a, latest_jobs(ctx.db, [a.id]).get(a.id))
@@ -200,6 +206,10 @@ def delete_artifact(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
200
  remove_video(ctx, a)
201
  return {"ok": True}
202
  storage.delete_prefix(f"ws/{ctx.workspace.id}/artifacts/{a.id}/")
 
 
 
 
203
  ctx.db.query(CalendarItem).filter(CalendarItem.artifact_id == a.id, CalendarItem.status == "scheduled").delete()
204
  ctx.db.delete(a)
205
  ctx.db.commit()
 
152
  query = select(Artifact).where(Artifact.workspace_id == ctx.workspace.id)
153
  if type:
154
  query = query.where(Artifact.type.in_(type.split(",")))
155
+ else: # files uploaded to post live in the Launchpad's picker, not among the things Notestack made
156
+ query = query.where(Artifact.type != "upload")
157
  if notebook_id:
158
  query = query.where(Artifact.notebook_id == notebook_id)
159
  if document_id:
 
185
  content = dict(a.content_json or {})
186
  if body.content is not None:
187
  protected = {"slide_keys", "quote_card_ids", "segments", "voices"}
188
+ if a.type == "upload": # what the file is, as posting reads it: only its title can change
189
+ protected |= {"filename", "content_type", "media", "size_bytes", "duration_s"}
190
  content.update({k: v for k, v in body.content.items() if k not in protected})
191
  if body.title is not None:
192
+ if not body.title.strip():
193
+ raise HTTPException(400, "Give it a name.")
194
+ content["title"] = body.title.strip()
195
  a.content_json = content
196
  ctx.db.commit()
197
  return serialize_artifact(a, latest_jobs(ctx.db, [a.id]).get(a.id))
 
206
  remove_video(ctx, a)
207
  return {"ok": True}
208
  storage.delete_prefix(f"ws/{ctx.workspace.id}/artifacts/{a.id}/")
209
+ if a.type == "upload": # a file from the user's computer: its upload record (and file) go too
210
+ from app.services.uploads import remove_upload_artifact
211
+
212
+ remove_upload_artifact(ctx.db, a)
213
  ctx.db.query(CalendarItem).filter(CalendarItem.artifact_id == a.id, CalendarItem.status == "scheduled").delete()
214
  ctx.db.delete(a)
215
  ctx.db.commit()
backend/app/routers/launchpad.py CHANGED
@@ -1,11 +1,13 @@
1
  """Launchpad: the calendar, connected social accounts (OAuth / app password) and tracked short links."""
2
 
3
  import json
 
4
  import secrets
5
  import uuid
6
  from datetime import UTC, datetime, timedelta
7
  from typing import Literal
8
  from urllib.parse import urlencode
 
9
 
10
  from fastapi import APIRouter, Depends, HTTPException, Request
11
  from fastapi.concurrency import run_in_threadpool
@@ -17,10 +19,15 @@ from sqlalchemy.orm import Session
17
  from app.auth import Ctx, decode_token_full, encode_signed, get_ctx
18
  from app.config import settings
19
  from app.db import get_db
20
- from app.models import Artifact, CalendarItem, Document, EngagementEvent, SocialAccount, TrackedLink
21
  from app.services import launchpad as lp
 
22
  from app.services.crypto import decrypt, encrypt
23
  from app.services.social import AUTO_POST, LIMITS, PLATFORM_LABELS, SocialError, bluesky, linkedin, x
 
 
 
 
24
 
25
  router = APIRouter(prefix="/api", tags=["launchpad"])
26
 
@@ -30,32 +37,104 @@ Platform = Literal["x", "linkedin", "bluesky", "substack_notes"]
30
  # Calendar
31
 
32
 
33
- class ItemIn(BaseModel):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  platform: Platform
35
- scheduled_at: datetime
36
  content: str = Field(min_length=1, max_length=10000)
37
  thread: list[str] = Field(default_factory=list, max_length=25)
38
- artifact_id: uuid.UUID | None = None
 
39
  document_id: uuid.UUID | None = None
40
  social_account_id: uuid.UUID | None = None
41
  remind_by_email: bool = False
42
  draft: bool = False
43
 
44
 
45
- class ItemPatch(BaseModel):
46
- scheduled_at: datetime | None = None
47
  content: str | None = Field(None, min_length=1, max_length=10000)
48
  thread: list[str] | None = None
49
  social_account_id: uuid.UUID | None = None
50
  remind_by_email: bool | None = None
51
- status: Literal["scheduled", "draft"] | None = None
52
 
53
 
54
  def _aware(dt: datetime) -> datetime:
55
  return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
56
 
57
 
58
- def serialize_item(item: CalendarItem, account: SocialAccount | None = None, clicks: int = 0) -> dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  return {
60
  "id": str(item.id),
61
  "platform": item.platform,
@@ -65,6 +144,9 @@ def serialize_item(item: CalendarItem, account: SocialAccount | None = None, cli
65
  "content": item.content,
66
  "thread": item.thread or [],
67
  "artifact_id": str(item.artifact_id) if item.artifact_id else None,
 
 
 
68
  "document_id": str(item.document_id) if item.document_id else None,
69
  "social_account_id": str(item.social_account_id) if item.social_account_id else None,
70
  "account_handle": account.handle if account else None,
@@ -96,6 +178,26 @@ def _account(ctx: Ctx, account_id: uuid.UUID | None, platform: str) -> SocialAcc
96
  return account
97
 
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  def _validate_lengths(platform: str, posts: list[str]) -> None:
100
  limit = LIMITS.get(platform)
101
  if platform in ("x", "bluesky") and limit:
@@ -110,7 +212,28 @@ def _enrich(ctx: Ctx, items: list[CalendarItem]) -> list[dict]:
110
  accounts = {a.id: a for a in ctx.db.scalars(
111
  select(SocialAccount).where(SocialAccount.workspace_id == ctx.workspace.id))}
112
  clicks = lp.clicks_by_item(ctx.db, [i.id for i in items])
113
- return [serialize_item(i, accounts.get(i.social_account_id), clicks.get(i.id, 0)) for i in items]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
 
115
 
116
  @router.get("/calendar")
@@ -129,15 +252,21 @@ def list_items(ctx: Ctx = Depends(get_ctx), start: datetime | None = None, end:
129
 
130
  @router.post("/calendar")
131
  def create_item(body: ItemIn, ctx: Ctx = Depends(get_ctx)):
 
 
 
132
  account = _account(ctx, body.social_account_id, body.platform)
133
  _validate_lengths(body.platform, [body.content, *body.thread])
134
- for model, value in ((Artifact, body.artifact_id), (Document, body.document_id)):
135
- if value and not ctx.db.scalar(select(model.id).where(model.id == value,
136
- model.workspace_id == ctx.workspace.id)):
137
- raise HTTPException(404, f"{model.__name__} not found")
 
 
138
  item = CalendarItem(
139
- workspace_id=ctx.workspace.id, platform=body.platform, scheduled_at=_aware(body.scheduled_at),
140
  content=body.content, thread=[t for t in body.thread if t.strip()], artifact_id=body.artifact_id,
 
141
  document_id=body.document_id, social_account_id=account.id if account else None,
142
  remind_by_email=body.remind_by_email or body.platform not in AUTO_POST or not account,
143
  status="draft" if body.draft else "scheduled",
@@ -152,12 +281,15 @@ def update_item(item_id: uuid.UUID, body: ItemPatch, ctx: Ctx = Depends(get_ctx)
152
  item = _item(ctx, item_id)
153
  if item.status in ("posted", "publishing"):
154
  raise HTTPException(409, "This post already went out.")
155
- if body.scheduled_at is not None:
156
- item.scheduled_at = _aware(body.scheduled_at)
 
157
  if body.content is not None:
158
  item.content = body.content
159
  if body.thread is not None:
160
  item.thread = [t for t in body.thread if t.strip()]
 
 
161
  if "social_account_id" in body.model_fields_set:
162
  item.social_account_id = (_account(ctx, body.social_account_id, item.platform).id
163
  if body.social_account_id else None)
@@ -165,10 +297,18 @@ def update_item(item_id: uuid.UUID, body: ItemPatch, ctx: Ctx = Depends(get_ctx)
165
  item.remind_by_email = body.remind_by_email
166
  if not item.social_account_id or item.platform not in AUTO_POST:
167
  item.remind_by_email = True
168
- if body.status is not None:
169
- item.status = body.status
170
- elif item.status in ("failed", "reminded"):
171
- item.status = "scheduled"
 
 
 
 
 
 
 
 
172
  item.error = None
173
  item.attempts = 0
174
  _validate_lengths(item.platform, [item.content, *(item.thread or [])])
@@ -192,16 +332,82 @@ async def publish_now(item_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
192
  item.status = "publishing"
193
  item.attempts = 0
194
  ctx.db.commit()
195
- await run_in_threadpool(lp.publish_item, ctx.db, item)
 
 
 
196
  return _enrich(ctx, [item])[0]
197
 
198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  # Social accounts
200
 
201
 
202
  def serialize_account(a: SocialAccount) -> dict:
203
  return {"id": str(a.id), "platform": a.platform, "platform_label": PLATFORM_LABELS[a.platform],
204
  "handle": a.handle, "status": a.status, "avatar_url": a.avatar_url,
 
 
 
 
 
 
 
205
  "connected_at": a.created_at.isoformat() if a.created_at else None}
206
 
207
 
@@ -222,11 +428,12 @@ def disconnect(account_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
222
  SocialAccount.workspace_id == ctx.workspace.id))
223
  if not account:
224
  raise HTTPException(404, "Account not found")
225
- for item in ctx.db.scalars(select(CalendarItem).where(CalendarItem.social_account_id == account.id,
226
- CalendarItem.status == "scheduled")):
227
- item.social_account_id = None
228
- item.remind_by_email = True
229
- ctx.db.delete(account)
 
230
  ctx.db.commit()
231
  return {"ok": True}
232
 
@@ -247,6 +454,7 @@ def _upsert_account(db: Session, workspace_id: uuid.UUID, platform: str, info: d
247
  account.expires_at = datetime.now(UTC) + timedelta(seconds=int(expires_in)) if expires_in else None
248
  account.scopes = scopes
249
  account.status = "active"
 
250
  db.commit()
251
 
252
 
 
1
  """Launchpad: the calendar, connected social accounts (OAuth / app password) and tracked short links."""
2
 
3
  import json
4
+ import re
5
  import secrets
6
  import uuid
7
  from datetime import UTC, datetime, timedelta
8
  from typing import Literal
9
  from urllib.parse import urlencode
10
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
11
 
12
  from fastapi import APIRouter, Depends, HTTPException, Request
13
  from fastapi.concurrency import run_in_threadpool
 
19
  from app.auth import Ctx, decode_token_full, encode_signed, get_ctx
20
  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,
29
+ media_count, upload_kind)
30
+ from app.services.storage import storage
31
 
32
  router = APIRouter(prefix="/api", tags=["launchpad"])
33
 
 
37
  # Calendar
38
 
39
 
40
+ class When(BaseModel):
41
+ """When a post goes out: the wall time the user picked in their own time zone (local_time "2026-10-07T11:00" +
42
+ timezone "Asia/Karachi", on a half hour), converted to UTC here; or an exact scheduled_at (Post now)."""
43
+ scheduled_at: datetime | None = None
44
+ local_time: str | None = Field(None, max_length=32)
45
+ timezone: str | None = Field(None, max_length=64)
46
+
47
+ def utc(self) -> datetime | None:
48
+ if self.local_time:
49
+ try:
50
+ zone = ZoneInfo(self.timezone or "")
51
+ except (ZoneInfoNotFoundError, ValueError) as exc:
52
+ raise HTTPException(400, {"code": "bad_timezone", "message": "Unknown time zone."}) from exc
53
+ try:
54
+ wall = datetime.fromisoformat(self.local_time)
55
+ except ValueError as exc:
56
+ raise HTTPException(400, {"code": "bad_time", "message": "Pick a date and time."}) from exc
57
+ if wall.tzinfo or wall.minute not in (0, 30) or wall.second or wall.microsecond:
58
+ raise HTTPException(400, {"code": "half_hour_only", "message": "Pick a time on the hour or half hour."})
59
+ return wall.replace(tzinfo=zone).astimezone(UTC)
60
+ return _aware(self.scheduled_at) if self.scheduled_at else None
61
+
62
+
63
+ class ItemIn(When):
64
  platform: Platform
 
65
  content: str = Field(min_length=1, max_length=10000)
66
  thread: list[str] = Field(default_factory=list, max_length=25)
67
+ artifact_id: uuid.UUID | None = None # the attachment (a video, quote card, carousel...), never a Launch Kit
68
+ kit_id: uuid.UUID | None = None # the Launch Kit the post was written from (tracked, not attached)
69
  document_id: uuid.UUID | None = None
70
  social_account_id: uuid.UUID | None = None
71
  remind_by_email: bool = False
72
  draft: bool = False
73
 
74
 
75
+ class ItemPatch(When):
76
+ artifact_id: uuid.UUID | None = None # set or (null) clear what the post carries
77
  content: str | None = Field(None, min_length=1, max_length=10000)
78
  thread: list[str] | None = None
79
  social_account_id: uuid.UUID | None = None
80
  remind_by_email: bool | None = None
81
+ status: Literal["scheduled", "draft", "paused"] | None = None
82
 
83
 
84
  def _aware(dt: datetime) -> datetime:
85
  return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
86
 
87
 
88
+ # audio can't be posted; "upload" is a file the user brought from their computer
89
+ POSTABLE = ("video", "quote_card", "carousel", "summary", "launch_kit", "mind_map", "upload")
90
+
91
+
92
+ def _cite_free(text: str) -> str:
93
+ return re.sub(r"\s*\[\d+\]", "", text or "").strip()
94
+
95
+
96
+ def _prefill(a: Artifact) -> dict:
97
+ """A starting text per platform for a post of this artifact: X as a list of posts (a thread), LinkedIn as one."""
98
+ c = a.content_json or {}
99
+ title = c.get("title") or TYPE_LABELS.get(a.type, a.type)
100
+ if a.type == "upload": # their own file: the words are theirs to write
101
+ return {"x": [""], "linkedin": ""}
102
+ if a.type == "launch_kit":
103
+ posts = c.get("posts") or {}
104
+ x_posts = posts.get("x_thread") or posts.get("x") or [title]
105
+ return {"x": x_posts, "linkedin": "\n\n".join(posts.get("linkedin") or x_posts)}
106
+ text = {
107
+ "summary": _cite_free(c.get("summary") or title),
108
+ "quote_card": f'"{c.get("quote")}"' + (f" ({c['source']})" if c.get("source") else "") if c.get("quote")
109
+ else title,
110
+ "carousel": ((c.get("slides") or [{}])[0].get("heading") or title),
111
+ }.get(a.type, title)
112
+ return {"x": [text], "linkedin": text}
113
+
114
+
115
+ def artifact_brief(a: Artifact) -> dict:
116
+ """What the composer and the calendar show of the attached artifact."""
117
+ c = a.content_json or {}
118
+ thumb = (a.storage_key if a.type == "quote_card" or upload_kind(a) == "image"
119
+ else (c.get("slide_keys") or [a.storage_key])[0] if a.type == "carousel" else None)
120
+ return {"id": str(a.id), "type": a.type, "type_label": TYPE_LABELS.get(a.type, a.type),
121
+ "title": c.get("title") or TYPE_LABELS.get(a.type, a.type),
122
+ "media": upload_kind(a) or ("video" if a.type == "video" else "image" if media_count(a) else "text"),
123
+ "media_count": media_count(a), "duration_s": duration(a),
124
+ "thumb_url": storage.presign_get(thumb) if thumb else None,
125
+ # The user's own file, to preview before posting it.
126
+ "view_url": storage.presign_get(a.storage_key) if a.type == "upload" and a.storage_key else None,
127
+ # A blog2video video with scenes but no MP4 yet: listed, but it has to be rendered before it can go out.
128
+ # Made by the video service (blog2video): it opens in the video editor.
129
+ "editable_video": is_blog2video(a),
130
+ "needs_render": is_blog2video(a) and not (a.content_json or {}).get("video_url"),
131
+ # Its MP4 is being made right now: it can't be scheduled until the render finishes.
132
+ "rendering": is_blog2video(a) and a.status == "rendering",
133
+ "created_at": a.created_at.isoformat() if a.created_at else None, "prefill": _prefill(a)}
134
+
135
+
136
+ def serialize_item(item: CalendarItem, account: SocialAccount | None = None, clicks: int = 0,
137
+ artifact: Artifact | None = None, kit: Artifact | None = None) -> dict:
138
  return {
139
  "id": str(item.id),
140
  "platform": item.platform,
 
144
  "content": item.content,
145
  "thread": item.thread or [],
146
  "artifact_id": str(item.artifact_id) if item.artifact_id else None,
147
+ "artifact": artifact_brief(artifact) if artifact else None,
148
+ "kit_id": str(item.kit_id) if item.kit_id else None,
149
+ "kit_title": ((kit.content_json or {}).get("post_title") or (kit.content_json or {}).get("title")) if kit else None,
150
  "document_id": str(item.document_id) if item.document_id else None,
151
  "social_account_id": str(item.social_account_id) if item.social_account_id else None,
152
  "account_handle": account.handle if account else None,
 
178
  return account
179
 
180
 
181
+ CONNECTED_ONLY = ("x", "linkedin") # these post only through a live connection (no email fallback)
182
+
183
+
184
+ def can_post_media(a: SocialAccount) -> bool:
185
+ return {"x": x.can_post_media, "linkedin": linkedin.can_post_media}.get(a.platform, lambda _: False)(a)
186
+
187
+
188
+ def _require_connected(platform: str, account: SocialAccount | None, artifact: Artifact | None) -> None:
189
+ """X and LinkedIn: scheduling needs a live connection (and one allowed to post media, when media is attached)."""
190
+ if platform not in CONNECTED_ONLY:
191
+ return
192
+ label = PLATFORM_LABELS[platform]
193
+ if not account or account.status != "active":
194
+ raise HTTPException(409, {"code": "not_connected",
195
+ "message": f"Connect {label} to schedule posts there."
196
+ if not account else f"Your {label} connection is down. Reconnect {label} first."})
197
+ if artifact and media_count(artifact) and not can_post_media(account):
198
+ raise HTTPException(409, {"code": "reconnect", "message": f"Reconnect {label} to post images and videos."})
199
+
200
+
201
  def _validate_lengths(platform: str, posts: list[str]) -> None:
202
  limit = LIMITS.get(platform)
203
  if platform in ("x", "bluesky") and limit:
 
212
  accounts = {a.id: a for a in ctx.db.scalars(
213
  select(SocialAccount).where(SocialAccount.workspace_id == ctx.workspace.id))}
214
  clicks = lp.clicks_by_item(ctx.db, [i.id for i in items])
215
+ ids = {i.artifact_id for i in items if i.artifact_id} | {i.kit_id for i in items if i.kit_id}
216
+ artifacts = {a.id: a for a in ctx.db.scalars(select(Artifact).where(Artifact.id.in_(ids)))} if ids else {}
217
+ return [serialize_item(i, accounts.get(i.social_account_id), clicks.get(i.id, 0), artifacts.get(i.artifact_id),
218
+ artifacts.get(i.kit_id)) for i in items]
219
+
220
+
221
+ def _kit(ctx: Ctx, kit_id: uuid.UUID) -> Artifact:
222
+ """This workspace's Launch Kit (what a post was written from)."""
223
+ kit = ctx.db.scalar(select(Artifact).where(Artifact.id == kit_id, Artifact.workspace_id == ctx.workspace.id,
224
+ Artifact.type == "launch_kit"))
225
+ if not kit:
226
+ raise HTTPException(404, "Launch Kit not found")
227
+ return kit
228
+
229
+
230
+ def _postable(ctx: Ctx, artifact_id: uuid.UUID, platform: str) -> Artifact:
231
+ """This workspace's artifact, checked for the platform (audio, unfinished, too long for X: 400)."""
232
+ a = ctx.db.scalar(select(Artifact).where(Artifact.id == artifact_id, Artifact.workspace_id == ctx.workspace.id))
233
+ if not a:
234
+ raise HTTPException(404, "Artifact not found")
235
+ check_postable(a, platform)
236
+ return a
237
 
238
 
239
  @router.get("/calendar")
 
252
 
253
  @router.post("/calendar")
254
  def create_item(body: ItemIn, ctx: Ctx = Depends(get_ctx)):
255
+ when = body.utc()
256
+ if when is None:
257
+ raise HTTPException(422, {"code": "no_time", "message": "Pick when it goes out."})
258
  account = _account(ctx, body.social_account_id, body.platform)
259
  _validate_lengths(body.platform, [body.content, *body.thread])
260
+ artifact = _postable(ctx, body.artifact_id, body.platform) if body.artifact_id else None
261
+ kit = _kit(ctx, body.kit_id) if body.kit_id else None
262
+ _require_connected(body.platform, account, artifact)
263
+ if body.document_id and not ctx.db.scalar(select(Document.id).where(Document.id == body.document_id,
264
+ Document.workspace_id == ctx.workspace.id)):
265
+ raise HTTPException(404, "Document not found")
266
  item = CalendarItem(
267
+ workspace_id=ctx.workspace.id, platform=body.platform, scheduled_at=when,
268
  content=body.content, thread=[t for t in body.thread if t.strip()], artifact_id=body.artifact_id,
269
+ kit_id=kit.id if kit else None,
270
  document_id=body.document_id, social_account_id=account.id if account else None,
271
  remind_by_email=body.remind_by_email or body.platform not in AUTO_POST or not account,
272
  status="draft" if body.draft else "scheduled",
 
281
  item = _item(ctx, item_id)
282
  if item.status in ("posted", "publishing"):
283
  raise HTTPException(409, "This post already went out.")
284
+ when = body.utc()
285
+ if when is not None:
286
+ item.scheduled_at = when
287
  if body.content is not None:
288
  item.content = body.content
289
  if body.thread is not None:
290
  item.thread = [t for t in body.thread if t.strip()]
291
+ if "artifact_id" in body.model_fields_set:
292
+ item.artifact_id = _postable(ctx, body.artifact_id, item.platform).id if body.artifact_id else None
293
  if "social_account_id" in body.model_fields_set:
294
  item.social_account_id = (_account(ctx, body.social_account_id, item.platform).id
295
  if body.social_account_id else None)
 
297
  item.remind_by_email = body.remind_by_email
298
  if not item.social_account_id or item.platform not in AUTO_POST:
299
  item.remind_by_email = True
300
+ account = ctx.db.get(SocialAccount, item.social_account_id) if item.social_account_id else None
301
+ artifact = ctx.db.get(Artifact, item.artifact_id) if item.artifact_id else None
302
+ if body.status == "paused":
303
+ item.status = "paused" # held by hand: never attempted until resumed
304
+ elif body.status is not None or item.status in ("failed", "reminded", "paused"):
305
+ # Going out (again): only through a live connection, and only at a time still ahead.
306
+ _require_connected(item.platform, account, artifact)
307
+ if item.status == "paused" and _aware(item.scheduled_at) <= datetime.now(UTC):
308
+ raise HTTPException(409, {"code": "missed", "message": "Pick a time that is still ahead."})
309
+ item.status = body.status or "scheduled"
310
+ elif item.platform in CONNECTED_ONLY and body.model_fields_set & {"social_account_id", "artifact_id"}:
311
+ _require_connected(item.platform, account, artifact)
312
  item.error = None
313
  item.attempts = 0
314
  _validate_lengths(item.platform, [item.content, *(item.thread or [])])
 
332
  item.status = "publishing"
333
  item.attempts = 0
334
  ctx.db.commit()
335
+ if lp.has_media(ctx.db, item):
336
+ lp.queue_publish(ctx.db, item) # uploading takes a while: a job does it, the page follows the status
337
+ else:
338
+ await run_in_threadpool(lp.publish_item, ctx.db, item)
339
  return _enrich(ctx, [item])[0]
340
 
341
 
342
+ class UploadIn(BaseModel):
343
+ upload_id: uuid.UUID
344
+ duration_s: float | None = Field(None, ge=0, le=24 * 3600) # a video's length, read by the browser
345
+
346
+
347
+ @router.post("/launchpad/uploads")
348
+ def register_upload(body: UploadIn, ctx: Ctx = Depends(get_ctx)):
349
+ """A file the user uploaded from their computer (through /api/storage/uploads), made postable: it becomes an
350
+ `upload` artifact, attached and posted like anything Notestack made, and offered again in the picker later."""
351
+ upload = ctx.db.scalar(select(Upload).where(Upload.id == body.upload_id, Upload.workspace_id == ctx.workspace.id))
352
+ if not upload:
353
+ raise HTTPException(404, "Upload not found")
354
+ if upload.status != "complete":
355
+ raise HTTPException(409, "This file hasn't finished uploading.")
356
+ kind = UPLOAD_TYPES.get(upload.content_type)
357
+ if not kind:
358
+ raise HTTPException(415, "LinkedIn takes JPG, PNG or GIF images and MP4 videos.")
359
+ low, high = SIZE_LIMITS[("linkedin", kind)]
360
+ size = upload.size_bytes or 0
361
+ if size > high:
362
+ raise HTTPException(413, f"This {kind} is {size / MB:.0f} MB; LinkedIn takes up to {high // MB} MB.")
363
+ if size < low:
364
+ raise HTTPException(400, f"This {kind} is too small for LinkedIn (at least {low // 1024} KB).")
365
+ title = upload.filename.rsplit(".", 1)[0] if "." in upload.filename else upload.filename
366
+ a = Artifact(workspace_id=ctx.workspace.id, type="upload", status="ready", storage_key=upload.key,
367
+ content_json={"title": title[:200] or "Upload", "filename": upload.filename,
368
+ "content_type": upload.content_type, "media": kind, "size_bytes": size,
369
+ **({"duration_s": body.duration_s} if body.duration_s else {})})
370
+ ctx.db.add(a)
371
+ ctx.db.commit()
372
+ return artifact_brief(a)
373
+
374
+
375
+ @router.get("/launchpad/postable")
376
+ def postable(ctx: Ctx = Depends(get_ctx), q: str = "", limit: int = 60):
377
+ """What can be attached to a post: finished videos, quote cards, carousels, summaries, launch kits, mind maps
378
+ (not audio), newest first. Videos made but not rendered yet are listed too, flagged needs_render (they can only be
379
+ posted once rendered), and so are videos being rendered, flagged rendering."""
380
+ rows = ctx.db.scalars(select(Artifact).where(Artifact.workspace_id == ctx.workspace.id,
381
+ Artifact.type.in_(POSTABLE))
382
+ .order_by(Artifact.created_at.desc()).limit(400))
383
+ needle = q.strip().lower()
384
+ out = []
385
+ for a in rows:
386
+ # A blog2video video counts once blog2video made it (ready), rendered or not; anything else once it is ready.
387
+ # A blog2video video mid-render stays listed (flagged rendering).
388
+ ready = a.status == "ready" or (is_blog2video(a) and (bool((a.content_json or {}).get("video_url"))
389
+ or a.status == "rendering"))
390
+ title = ((a.content_json or {}).get("title") or "").lower()
391
+ if ready and (not needle or needle in title):
392
+ out.append(artifact_brief(a))
393
+ if len(out) >= min(limit, 200):
394
+ break
395
+ return out
396
+
397
+
398
  # Social accounts
399
 
400
 
401
  def serialize_account(a: SocialAccount) -> dict:
402
  return {"id": str(a.id), "platform": a.platform, "platform_label": PLATFORM_LABELS[a.platform],
403
  "handle": a.handle, "status": a.status, "avatar_url": a.avatar_url,
404
+ "can_post_media": can_post_media(a),
405
+ # No refresh token and the connection ends within a week (LinkedIn's 60 day tokens).
406
+ "expires_soon": bool(a.expires_at and not a.refresh_token
407
+ and _aware(a.expires_at) - datetime.now(UTC) < timedelta(days=7)),
408
+ "expires_at": _aware(a.expires_at).isoformat() if a.expires_at else None,
409
+ # Down (expired, revoked, disconnected), or unable to post images and videos.
410
+ "needs_reconnect": a.status != "active" or (a.platform in CONNECTED_ONLY and not can_post_media(a)),
411
  "connected_at": a.created_at.isoformat() if a.created_at else None}
412
 
413
 
 
428
  SocialAccount.workspace_id == ctx.workspace.id))
429
  if not account:
430
  raise HTTPException(404, "Account not found")
431
+ # Kept (its posts stay linked to it), tokens gone, posts paused: reconnecting the same account resumes them.
432
+ account.status = "revoked"
433
+ account.access_token = "" # no usable token kept
434
+ account.refresh_token = None
435
+ label = PLATFORM_LABELS[account.platform]
436
+ pause_account_posts(ctx.db, account, f"{label} disconnected. Reconnect {label} to resume.")
437
  ctx.db.commit()
438
  return {"ok": True}
439
 
 
454
  account.expires_at = datetime.now(UTC) + timedelta(seconds=int(expires_in)) if expires_in else None
455
  account.scopes = scopes
456
  account.status = "active"
457
+ resume_account_posts(db, account) # its paused posts still ahead go back to scheduled
458
  db.commit()
459
 
460
 
backend/app/routers/storage.py CHANGED
@@ -18,6 +18,7 @@ router = APIRouter(prefix="/api/storage", tags=["storage"])
18
  ALLOWED_TYPES = {
19
  "image/png",
20
  "image/jpeg",
 
21
  "image/webp",
22
  "image/svg+xml",
23
  "audio/mpeg",
 
18
  ALLOWED_TYPES = {
19
  "image/png",
20
  "image/jpeg",
21
+ "image/gif",
22
  "image/webp",
23
  "image/svg+xml",
24
  "audio/mpeg",
backend/app/services/artifacts.py CHANGED
@@ -18,6 +18,7 @@ TYPE_LABELS = {
18
  "carousel": "Carousel",
19
  "launch_kit": "Launch Kit",
20
  "mind_map": "Mind Constellation",
 
21
  }
22
 
23
 
 
18
  "carousel": "Carousel",
19
  "launch_kit": "Launch Kit",
20
  "mind_map": "Mind Constellation",
21
+ "upload": "Upload",
22
  }
23
 
24
 
backend/app/services/launchpad.py CHANGED
@@ -1,9 +1,13 @@
1
- """Launchpad: publishes due calendar items, emails reminders for platforms without an API, rewrites links
2
- to tracked short links and pulls engagement back in."""
 
 
 
3
 
4
  import logging
5
  import re
6
  import secrets
 
7
  from collections.abc import Callable
8
  from datetime import UTC, datetime, timedelta
9
 
@@ -12,13 +16,17 @@ from sqlalchemy.orm import Session
12
 
13
  from app.config import settings
14
  from app.db import SessionLocal
15
- from app.models import CalendarItem, Document, EngagementEvent, SocialAccount, TrackedLink, User, Workspace
16
  from app.services.email import email_service
 
17
  from app.services.social import AUTO_POST, PLATFORM_LABELS, SocialError, module
 
 
18
 
19
  log = logging.getLogger("notestack.launchpad")
20
  _URL = re.compile(r"https?://[^\s)\]>]+")
21
  MAX_ATTEMPTS = 3
 
22
 
23
 
24
  def _public_api() -> bool:
@@ -58,6 +66,11 @@ def publish_item(db: Session, item: CalendarItem) -> CalendarItem:
58
  return item
59
  account = db.get(SocialAccount, item.social_account_id) if item.social_account_id else None
60
  label = PLATFORM_LABELS.get(item.platform, item.platform)
 
 
 
 
 
61
  if item.platform not in AUTO_POST or item.remind_by_email or not account:
62
  to = owner_email(db, item.workspace_id)
63
  sent = bool(to) and email_service.send_post_reminder(
@@ -71,8 +84,21 @@ def publish_item(db: Session, item: CalendarItem) -> CalendarItem:
71
  try:
72
  tracked = [track_links(db, item, p) for p in posts]
73
  db.flush()
74
- external_id, url = module(item.platform).publish(db, account, tracked)
 
 
 
 
 
 
 
 
75
  except SocialError as exc:
 
 
 
 
 
76
  _fail_or_retry(db, item, str(exc), exc.permanent)
77
  return item
78
  except Exception as exc: # network trouble: retry
@@ -99,13 +125,34 @@ def _fail_or_retry(db: Session, item: CalendarItem, error: str, permanent: bool)
99
  db.commit()
100
 
101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  def publish_due(session_factory: Callable[[], Session] = SessionLocal) -> int:
 
103
  now = datetime.now(UTC)
104
- count = 0
105
  with session_factory() as db:
106
- # A tick that died mid publish leaves items in "publishing"; put them back after 10 minutes.
107
  for stuck in db.scalars(select(CalendarItem).where(
108
- CalendarItem.status == "publishing", CalendarItem.updated_at < now - timedelta(minutes=10))):
109
  stuck.status = "scheduled"
110
  due = db.scalars(
111
  select(CalendarItem).where(CalendarItem.status == "scheduled", CalendarItem.scheduled_at <= now)
@@ -115,9 +162,8 @@ def publish_due(session_factory: Callable[[], Session] = SessionLocal) -> int:
115
  item.status = "publishing" # claim, so an overlapping tick skips it
116
  db.commit()
117
  for item in due:
118
- publish_item(db, item)
119
- count += 1
120
- return count
121
 
122
 
123
  def clicks_by_item(db: Session, item_ids: list) -> dict:
 
1
+ """Launchpad: publishes due calendar items (with the images or video of the artifact they were made from), emails
2
+ reminders for platforms without an API, rewrites links to tracked short links and pulls engagement back in.
3
+
4
+ Due items are published by a "publish_post" job each, not inside the worker's 30 second tick: a video upload and the
5
+ platform's processing can take minutes."""
6
 
7
  import logging
8
  import re
9
  import secrets
10
+ import uuid
11
  from collections.abc import Callable
12
  from datetime import UTC, datetime, timedelta
13
 
 
16
 
17
  from app.config import settings
18
  from app.db import SessionLocal
19
+ from app.models import Artifact, CalendarItem, Document, EngagementEvent, SocialAccount, TrackedLink, User, Workspace
20
  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)\]>]+")
28
  MAX_ATTEMPTS = 3
29
+ STUCK_AFTER = timedelta(minutes=30) # longer than a video upload and the platform's processing take
30
 
31
 
32
  def _public_api() -> bool:
 
66
  return item
67
  account = db.get(SocialAccount, item.social_account_id) if item.social_account_id else None
68
  label = PLATFORM_LABELS.get(item.platform, item.platform)
69
+ if item.platform in ("x", "linkedin") and not item.remind_by_email and (not account or account.status != "active"):
70
+ # The connection is down: never attempted; it resumes when the account is reconnected.
71
+ item.status, item.error = "paused", f"{label} disconnected. Reconnect {label} to resume."
72
+ db.commit()
73
+ return item
74
  if item.platform not in AUTO_POST or item.remind_by_email or not account:
75
  to = owner_email(db, item.workspace_id)
76
  sent = bool(to) and email_service.send_post_reminder(
 
84
  try:
85
  tracked = [track_links(db, item, p) for p in posts]
86
  db.flush()
87
+ artifact = db.get(Artifact, item.artifact_id) if item.artifact_id else None
88
+ if artifact and media_count(artifact) and item.platform in ("x", "linkedin") \
89
+ and not module(item.platform).can_post_media(account):
90
+ # Scheduled before the account lost the right to post media: held, not attempted, until reconnected.
91
+ item.status, item.error = "paused", f"Reconnect {label} to post images and videos."
92
+ db.commit()
93
+ return item
94
+ with media_for(artifact, item.platform) as media: # temporary video files go when this ends
95
+ external_id, url = module(item.platform).publish(db, account, tracked, media)
96
  except SocialError as exc:
97
+ if exc.reconnect: # the platform refused the connection: it is down, and its posts pause
98
+ mark_down(db, account, "expired", str(exc))
99
+ item.status, item.error = "paused", str(exc)[:1000]
100
+ db.commit()
101
+ return item
102
  _fail_or_retry(db, item, str(exc), exc.permanent)
103
  return item
104
  except Exception as exc: # network trouble: retry
 
125
  db.commit()
126
 
127
 
128
+ def has_media(db: Session, item: CalendarItem) -> bool:
129
+ """The post uploads images or a video (slow: it goes through a job, also for Publish now)."""
130
+ artifact = db.get(Artifact, item.artifact_id) if item.artifact_id else None
131
+ return bool(artifact) and item.platform in ("x", "linkedin") and media_count(artifact) > 0
132
+
133
+
134
+ def queue_publish(db: Session, item: CalendarItem) -> None:
135
+ """Hand the (already claimed, "publishing") item to a worker job."""
136
+ create_job(db, item.workspace_id, "publish_post", {"item_id": str(item.id)}, max_attempts=1)
137
+
138
+
139
+ def publish_job(db: Session, item_id: str) -> CalendarItem | None:
140
+ """The publish_post job: publish the item if it is still waiting for it."""
141
+ item = db.get(CalendarItem, uuid.UUID(str(item_id)))
142
+ if not item or item.status != "publishing":
143
+ return item
144
+ item.updated_at = datetime.now(UTC) # the stuck limit counts from the job's start, not the claim
145
+ db.commit()
146
+ return publish_item(db, item)
147
+
148
+
149
  def publish_due(session_factory: Callable[[], Session] = SessionLocal) -> int:
150
+ """Claim the due items and queue a publish_post job for each. Returns how many were queued."""
151
  now = datetime.now(UTC)
 
152
  with session_factory() as db:
153
+ # A job that died mid publish leaves its item in "publishing"; put it back once that has clearly happened.
154
  for stuck in db.scalars(select(CalendarItem).where(
155
+ CalendarItem.status == "publishing", CalendarItem.updated_at < now - STUCK_AFTER)):
156
  stuck.status = "scheduled"
157
  due = db.scalars(
158
  select(CalendarItem).where(CalendarItem.status == "scheduled", CalendarItem.scheduled_at <= now)
 
162
  item.status = "publishing" # claim, so an overlapping tick skips it
163
  db.commit()
164
  for item in due:
165
+ queue_publish(db, item)
166
+ return len(due)
 
167
 
168
 
169
  def clicks_by_item(db: Session, item_ids: list) -> dict:
backend/app/services/social/__init__.py CHANGED
@@ -1,9 +1,10 @@
1
  """Publishing to X, LinkedIn and Bluesky. Each platform module exposes:
2
 
3
- publish(db, account, posts: list[str]) -> (external_id, external_url)
4
  metrics(db, account, external_id) -> dict
5
 
6
- A list with more than one post is published as a thread (reply chain) where the platform supports it.
 
7
  """
8
 
9
  from datetime import UTC, datetime
@@ -15,9 +16,13 @@ from app.services.crypto import decrypt, encrypt
15
 
16
 
17
  class SocialError(RuntimeError):
18
- def __init__(self, message: str, permanent: bool = False):
 
 
 
19
  super().__init__(message)
20
- self.permanent = permanent
 
21
 
22
 
23
  PLATFORM_LABELS = {"x": "X", "linkedin": "LinkedIn", "bluesky": "Bluesky", "substack_notes": "Substack Notes"}
@@ -49,6 +54,11 @@ def expired(account: SocialAccount) -> bool:
49
  return exp.timestamp() - 120 < datetime.now(UTC).timestamp()
50
 
51
 
 
 
 
 
 
52
  def module(platform: str):
53
  from app.services.social import bluesky, linkedin, x
54
 
 
1
  """Publishing to X, LinkedIn and Bluesky. Each platform module exposes:
2
 
3
+ publish(db, account, posts: list[str], media: list[Media] | None) -> (external_id, external_url)
4
  metrics(db, account, external_id) -> dict
5
 
6
+ A list with more than one post is published as a thread (reply chain) where the platform supports it. Media (images or
7
+ one video, from social.media) goes on the first post; only X and LinkedIn take it.
8
  """
9
 
10
  from datetime import UTC, datetime
 
16
 
17
 
18
  class SocialError(RuntimeError):
19
+ """permanent: don't retry this post. reconnect: the account's connection is gone (expired, revoked, refused):
20
+ the account is marked down and its scheduled posts pause until it is reconnected."""
21
+
22
+ def __init__(self, message: str, permanent: bool = False, reconnect: bool = False):
23
  super().__init__(message)
24
+ self.permanent = permanent or reconnect
25
+ self.reconnect = reconnect
26
 
27
 
28
  PLATFORM_LABELS = {"x": "X", "linkedin": "LinkedIn", "bluesky": "Bluesky", "substack_notes": "Substack Notes"}
 
54
  return exp.timestamp() - 120 < datetime.now(UTC).timestamp()
55
 
56
 
57
+ def scope_set(scopes: str | None) -> set[str]:
58
+ """Granted scopes, whether the platform listed them with spaces or commas."""
59
+ return {s for s in (scopes or "").replace(",", " ").split() if s}
60
+
61
+
62
  def module(platform: str):
63
  from app.services.social import bluesky, linkedin, x
64
 
backend/app/services/social/bluesky.py CHANGED
@@ -34,7 +34,9 @@ def link_facets(text: str) -> list[dict]:
34
  return facets
35
 
36
 
37
- def publish(db: Session, account: SocialAccount, posts: list[str]) -> tuple[str, str]:
 
 
38
  session = create_session(account.handle, token(account))
39
  headers = {"Authorization": f"Bearer {session['accessJwt']}"}
40
  root = parent = None
 
34
  return facets
35
 
36
 
37
+ def publish(db: Session, account: SocialAccount, posts: list[str], media: list | None = None) -> tuple[str, str]:
38
+ if media:
39
+ raise SocialError("Images and videos can only be posted to X and LinkedIn.", permanent=True)
40
  session = create_session(account.handle, token(account))
41
  headers = {"Authorization": f"Bearer {session['accessJwt']}"}
42
  root = parent = None
backend/app/services/social/health.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Keeping X and LinkedIn connected, and what happens to scheduled posts when a connection goes down.
2
+
3
+ A connection that is down (expired, revoked on the platform, or disconnected here) pauses every post scheduled through
4
+ it: paused posts are never attempted (publish_due only takes "scheduled" ones). Reconnecting the same account resumes
5
+ the ones still ahead; posts whose time passed meanwhile stay paused, for a new time to be picked.
6
+
7
+ social_health (every 10 minutes, worker.PERIODIC) refreshes X tokens before they expire, refreshes LinkedIn tokens
8
+ where LinkedIn gave a refresh token, marks expired LinkedIn connections down, and every 6 hours asks each platform
9
+ once whether the connection still works (it catches access revoked on x.com or linkedin.com)."""
10
+
11
+ import logging
12
+ import time
13
+ import uuid
14
+ from collections.abc import Callable
15
+ from datetime import UTC, datetime, timedelta
16
+
17
+ from sqlalchemy import select
18
+ from sqlalchemy.orm import Session
19
+
20
+ from app.db import SessionLocal
21
+ from app.models import CalendarItem, SocialAccount
22
+ from app.services.social import PLATFORM_LABELS, SocialError, module
23
+
24
+ log = logging.getLogger("notestack.social")
25
+
26
+ ACTIVE_STATES = ("scheduled", "draft", "failed") # what a dropped connection pauses
27
+ LIVENESS_EVERY = 6 * 3600 # seconds between "is this connection still accepted" calls per account
28
+ X_REFRESH_AHEAD = timedelta(minutes=15) # X access tokens last 2 hours: refresh before they lapse
29
+ LINKEDIN_REFRESH_AHEAD = timedelta(days=7)
30
+ _last_checked: dict[uuid.UUID, float] = {} # account id -> monotonic time of its last liveness call
31
+
32
+
33
+ def _aware(dt: datetime | None) -> datetime | None:
34
+ return dt if dt is None or dt.tzinfo else dt.replace(tzinfo=UTC)
35
+
36
+
37
+ def pause_account_posts(db: Session, account: SocialAccount, reason: str) -> int:
38
+ """Pause every post still to go out through this account. The caller commits."""
39
+ items = db.scalars(select(CalendarItem).where(CalendarItem.social_account_id == account.id,
40
+ CalendarItem.status.in_(ACTIVE_STATES))).all()
41
+ for item in items:
42
+ item.status, item.error = "paused", reason
43
+ return len(items)
44
+
45
+
46
+ def mark_down(db: Session, account: SocialAccount, status: str, reason: str | None = None) -> None:
47
+ """The connection is gone (expired | revoked): mark it and pause its posts."""
48
+ label = PLATFORM_LABELS.get(account.platform, account.platform)
49
+ account.status = status
50
+ paused = pause_account_posts(db, account, reason or f"{label} disconnected. Reconnect {label} to resume.")
51
+ db.commit()
52
+ log.info("%s account %s is %s; paused %d posts", account.platform, account.id, status, paused)
53
+
54
+
55
+ def resume_account_posts(db: Session, account: SocialAccount) -> None:
56
+ """After a reconnect: paused posts still ahead go back to scheduled; missed ones stay paused. Caller commits."""
57
+ now = datetime.now(UTC)
58
+ for item in db.scalars(select(CalendarItem).where(CalendarItem.social_account_id == account.id,
59
+ CalendarItem.status == "paused")):
60
+ if _aware(item.scheduled_at) > now:
61
+ item.status, item.error, item.attempts = "scheduled", None, 0
62
+ else:
63
+ item.error = "Missed while disconnected: pick a new time."
64
+
65
+
66
+ def _due_for_refresh(account: SocialAccount) -> bool:
67
+ expires = _aware(account.expires_at)
68
+ if not expires:
69
+ return False
70
+ ahead = X_REFRESH_AHEAD if account.platform == "x" else LINKEDIN_REFRESH_AHEAD
71
+ return expires - ahead <= datetime.now(UTC)
72
+
73
+
74
+ def check_account(db: Session, account: SocialAccount) -> None:
75
+ """Refresh what is about to expire, mark down what is gone, and now and then ask the platform."""
76
+ mod = module(account.platform)
77
+ expires = _aware(account.expires_at)
78
+ try:
79
+ if _due_for_refresh(account):
80
+ if account.refresh_token:
81
+ mod.refresh(db, account)
82
+ elif expires and expires <= datetime.now(UTC):
83
+ raise SocialError("expired", reconnect=True)
84
+ if time.monotonic() - _last_checked.get(account.id, -LIVENESS_EVERY) >= LIVENESS_EVERY:
85
+ mod.check_alive(db, account)
86
+ _last_checked[account.id] = time.monotonic()
87
+ except SocialError as exc:
88
+ if exc.reconnect:
89
+ mark_down(db, account, "revoked" if "no longer accepts" in str(exc) else "expired")
90
+ else:
91
+ log.warning("%s check for %s: %s", account.platform, account.id, exc)
92
+ except Exception: # network trouble: try again next round
93
+ log.warning("%s check for %s failed", account.platform, account.id, exc_info=True)
94
+
95
+
96
+ def social_health(session_factory: Callable[[], Session] = SessionLocal) -> int:
97
+ """Every active X and LinkedIn connection, checked. Returns how many were looked at."""
98
+ with session_factory() as db:
99
+ accounts = db.scalars(select(SocialAccount).where(SocialAccount.status == "active",
100
+ SocialAccount.platform.in_(("x", "linkedin")))).all()
101
+ for account in accounts:
102
+ check_account(db, account)
103
+ return len(accounts)
backend/app/services/social/linkedin.py CHANGED
@@ -1,6 +1,8 @@
1
- """LinkedIn member posts (Share on LinkedIn + Sign In with LinkedIn using OpenID Connect products)."""
 
2
 
3
  import re
 
4
  from urllib.parse import quote, urlencode
5
 
6
  import httpx
@@ -8,12 +10,16 @@ from sqlalchemy.orm import Session
8
 
9
  from app.config import settings
10
  from app.models import SocialAccount
11
- from app.services.social import SocialError, expired, token
 
12
 
13
  AUTH_URL = "https://www.linkedin.com/oauth/v2/authorization"
14
  TOKEN_URL = "https://www.linkedin.com/oauth/v2/accessToken"
15
  SCOPES = "openid profile w_member_social"
16
- VERSION = "202509" # LinkedIn-Version header, YYYYMM; LinkedIn supports each version for a year
 
 
 
17
 
18
 
19
  def redirect_uri() -> str:
@@ -55,11 +61,113 @@ def little_text(text: str) -> str:
55
  return _RESERVED.sub(r"\\\1", text)
56
 
57
 
58
- def publish(db: Session, account: SocialAccount, posts: list[str]) -> tuple[str, str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  if expired(account):
60
- account.status = "expired"
61
- db.commit()
62
- raise SocialError("The LinkedIn connection expired. Reconnect the account.", permanent=True)
63
  body = {
64
  "author": f"urn:li:person:{account.external_id}",
65
  "commentary": little_text("\n\n".join(posts)),
@@ -68,14 +176,13 @@ def publish(db: Session, account: SocialAccount, posts: list[str]) -> tuple[str,
68
  "lifecycleState": "PUBLISHED",
69
  "isReshareDisabledByAuthor": False,
70
  }
71
- resp = httpx.post("https://api.linkedin.com/rest/posts", json=body, timeout=20, headers={
72
- "Authorization": f"Bearer {token(account)}", "LinkedIn-Version": VERSION,
73
- "X-Restli-Protocol-Version": "2.0.0",
74
- })
75
- if resp.status_code == 401:
76
- account.status = "expired"
77
- db.commit()
78
- raise SocialError("LinkedIn rejected the token. Reconnect the account.", permanent=True)
79
  if resp.status_code >= 400:
80
  raise SocialError(f"LinkedIn {resp.status_code}: {resp.text[:300]}",
81
  permanent=resp.status_code in (400, 403, 422))
 
1
+ """LinkedIn member posts (Share on LinkedIn + Sign In with LinkedIn using OpenID Connect products), with an image,
2
+ several images (multiImage) or a video uploaded first through the Images / Videos APIs."""
3
 
4
  import re
5
+ import time
6
  from urllib.parse import quote, urlencode
7
 
8
  import httpx
 
10
 
11
  from app.config import settings
12
  from app.models import SocialAccount
13
+ from app.services.crypto import decrypt
14
+ from app.services.social import SocialError, expired, save_tokens, scope_set, token
15
 
16
  AUTH_URL = "https://www.linkedin.com/oauth/v2/authorization"
17
  TOKEN_URL = "https://www.linkedin.com/oauth/v2/accessToken"
18
  SCOPES = "openid profile w_member_social"
19
+ VERSION = "202609" # LinkedIn-Version header, YYYYMM; LinkedIn supports each version for a year
20
+ REST = "https://api.linkedin.com/rest"
21
+ PROCESSING_WAIT = 600 # seconds LinkedIn may take to process a video before we give up
22
+ PART_ATTEMPTS = 4 # a video part upload is tried this often (network errors, 5xx) before the post fails
23
 
24
 
25
  def redirect_uri() -> str:
 
61
  return _RESERVED.sub(r"\\\1", text)
62
 
63
 
64
+ def _call(method: str, url: str, **kw) -> httpx.Response:
65
+ """Every request to LinkedIn goes through here (tests replace it)."""
66
+ return httpx.request(method, url, timeout=kw.pop("timeout", 60), **kw)
67
+
68
+
69
+ def _headers(account: SocialAccount) -> dict:
70
+ return {"Authorization": f"Bearer {token(account)}", "LinkedIn-Version": VERSION,
71
+ "X-Restli-Protocol-Version": "2.0.0"}
72
+
73
+
74
+ def can_post_media(account: SocialAccount) -> bool:
75
+ return "w_member_social" in scope_set(account.scopes)
76
+
77
+
78
+ def refresh(db: Session, account: SocialAccount) -> None:
79
+ """Programmatic refresh, for apps LinkedIn gives refresh tokens to; without one the connection simply expires."""
80
+ if not account.refresh_token:
81
+ raise SocialError("The LinkedIn connection expired. Reconnect LinkedIn.", reconnect=True)
82
+ resp = _call("POST", TOKEN_URL, data={
83
+ "grant_type": "refresh_token", "refresh_token": decrypt(account.refresh_token),
84
+ "client_id": settings.linkedin_client_id, "client_secret": settings.linkedin_client_secret}, timeout=20)
85
+ if resp.status_code in (400, 401):
86
+ raise SocialError("The LinkedIn connection expired. Reconnect LinkedIn.", reconnect=True)
87
+ tokens = _check(resp, "token refresh")
88
+ save_tokens(db, account, tokens["access_token"], tokens.get("refresh_token"), tokens.get("expires_in"))
89
+
90
+
91
+ def check_alive(db: Session, account: SocialAccount) -> None:
92
+ """One cheap call: raises SocialError(reconnect=True) when LinkedIn no longer accepts this connection."""
93
+ resp = _call("GET", "https://api.linkedin.com/v2/userinfo", headers={"Authorization": f"Bearer {token(account)}"},
94
+ timeout=20)
95
+ if resp.status_code == 401:
96
+ raise SocialError("LinkedIn no longer accepts this connection. Reconnect LinkedIn.", reconnect=True)
97
+
98
+
99
+ def _check(resp: httpx.Response, what: str) -> dict:
100
+ if resp.status_code == 426 or "NONEXISTENT_VERSION" in resp.text:
101
+ raise SocialError(f"LinkedIn retired API version {VERSION}; Notestack needs an update to post.", permanent=True)
102
+ if resp.status_code == 401:
103
+ raise SocialError("LinkedIn rejected the connection. Reconnect LinkedIn.", reconnect=True)
104
+ if resp.status_code >= 400:
105
+ raise SocialError(f"LinkedIn {what} {resp.status_code}: {resp.text[:300]}",
106
+ permanent=400 <= resp.status_code < 500 and resp.status_code != 429)
107
+ return resp.json() if resp.content else {}
108
+
109
+
110
+ def _upload_image(account: SocialAccount, m) -> str:
111
+ owner = f"urn:li:person:{account.external_id}"
112
+ got = _check(_call("POST", f"{REST}/images", params={"action": "initializeUpload"}, headers=_headers(account),
113
+ json={"initializeUploadRequest": {"owner": owner}}), "image upload")["value"]
114
+ _check(_call("PUT", got["uploadUrl"], content=m.read(),
115
+ headers={"Authorization": _headers(account)["Authorization"], "Content-Type": m.content_type}),
116
+ "image upload")
117
+ return got["image"]
118
+
119
+
120
+ def _upload_video(account: SocialAccount, m) -> str:
121
+ owner = f"urn:li:person:{account.external_id}"
122
+ got = _check(_call("POST", f"{REST}/videos", params={"action": "initializeUpload"}, headers=_headers(account),
123
+ json={"initializeUploadRequest": {"owner": owner, "fileSizeBytes": m.size,
124
+ "uploadCaptions": False, "uploadThumbnail": False}}),
125
+ "video upload")["value"]
126
+ etags = [_put_part(part, m) for part in got["uploadInstructions"]]
127
+ _check(_call("POST", f"{REST}/videos", params={"action": "finalizeUpload"}, headers=_headers(account),
128
+ json={"finalizeUploadRequest": {"video": got["video"], "uploadToken": got.get("uploadToken", ""),
129
+ "uploadedPartIds": etags}}), "video upload")
130
+ waited = 0
131
+ while True: # usable in a post only once LinkedIn has processed it
132
+ state = _check(_call("GET", f"{REST}/videos/{quote(got['video'])}", headers=_headers(account)),
133
+ "video status").get("status")
134
+ if state == "AVAILABLE":
135
+ return got["video"]
136
+ if state == "PROCESSING_FAILED":
137
+ raise SocialError("LinkedIn could not process the video.", permanent=True)
138
+ if waited >= PROCESSING_WAIT:
139
+ raise SocialError("LinkedIn took too long to process the video.")
140
+ time.sleep(5)
141
+ waited += 5
142
+
143
+
144
+ def _put_part(part: dict, m) -> str:
145
+ """One part of a video, read from the file. Sent to LinkedIn's upload URL with no bearer token; network errors
146
+ and 5xx are retried with backoff. Returns the part's ETag."""
147
+ data = m.read(part["firstByte"], part["lastByte"] + 1)
148
+ for attempt in range(1, PART_ATTEMPTS + 1):
149
+ try:
150
+ resp = _call("PUT", part["uploadUrl"], content=data, headers={"Content-Type": "application/octet-stream"},
151
+ timeout=300)
152
+ except httpx.HTTPError as exc:
153
+ if attempt == PART_ATTEMPTS:
154
+ raise SocialError(f"LinkedIn video upload failed: {exc}") from exc
155
+ else:
156
+ if resp.status_code in (401, 403):
157
+ raise SocialError("LinkedIn's upload session expired; trying again later.")
158
+ if resp.status_code < 400:
159
+ return resp.headers.get("etag", "").strip('"')
160
+ if resp.status_code < 500 or attempt == PART_ATTEMPTS:
161
+ raise SocialError(f"LinkedIn video upload {resp.status_code}: {resp.text[:200]}",
162
+ permanent=resp.status_code < 500)
163
+ time.sleep(2 ** attempt)
164
+ raise SocialError("LinkedIn video upload failed.") # not reached
165
+
166
+
167
+ def publish(db: Session, account: SocialAccount, posts: list[str], media: list | None = None) -> tuple[str, str]:
168
  if expired(account):
169
+ refresh(db, account) # without a refresh token: reconnect
170
+ urns = [(_upload_video if m.kind == "video" else _upload_image)(account, m) for m in media or []]
 
171
  body = {
172
  "author": f"urn:li:person:{account.external_id}",
173
  "commentary": little_text("\n\n".join(posts)),
 
176
  "lifecycleState": "PUBLISHED",
177
  "isReshareDisabledByAuthor": False,
178
  }
179
+ if len(urns) == 1:
180
+ body["content"] = {"media": {"id": urns[0]}}
181
+ elif urns:
182
+ body["content"] = {"multiImage": {"images": [{"id": u} for u in urns]}}
183
+ resp = _call("POST", f"{REST}/posts", json=body, timeout=20, headers=_headers(account))
184
+ if resp.status_code in (401, 426) or "NONEXISTENT_VERSION" in resp.text:
185
+ _check(resp, "post") # reconnect, or the API version was retired
 
186
  if resp.status_code >= 400:
187
  raise SocialError(f"LinkedIn {resp.status_code}: {resp.text[:300]}",
188
  permanent=resp.status_code in (400, 403, 422))
backend/app/services/social/media.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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).
5
+
6
+ Videos are streamed to a temporary file (never held whole in memory) and read in slices by the uploaders; the files are
7
+ removed when the post is done (media_for is a context manager). Sizes are checked before anything is sent."""
8
+
9
+ import contextlib
10
+ import os
11
+ import tempfile
12
+ from collections.abc import Iterator
13
+ from dataclasses import dataclass
14
+
15
+ import httpx
16
+ from fastapi import HTTPException
17
+
18
+ from app.models import Artifact
19
+ from app.services.social import SocialError
20
+
21
+ MAX_IMAGES = {"x": 4, "linkedin": 20}
22
+ X_VIDEO_SECONDS = 140 # X's limit for videos from standard accounts (2:20)
23
+ TEXT_ONLY = {"summary", "launch_kit", "mind_map"}
24
+ MEDIA_PLATFORMS = {"x", "linkedin"}
25
+ MB = 1024 * 1024
26
+ # What the user can upload from their computer to post: LinkedIn takes JPG, PNG and GIF images and MP4 videos.
27
+ UPLOAD_TYPES = {"image/png": "image", "image/jpeg": "image", "image/gif": "image", "video/mp4": "video"}
28
+ # (min, max) bytes per platform and kind, checked before any upload starts
29
+ SIZE_LIMITS = {
30
+ ("x", "image"): (1, 5 * MB),
31
+ ("x", "video"): (1, 512 * MB),
32
+ ("linkedin", "image"): (1, 36 * MB),
33
+ ("linkedin", "video"): (75 * 1024, 500 * MB),
34
+ }
35
+
36
+
37
+ @dataclass
38
+ class Media:
39
+ kind: str # image | video
40
+ filename: str
41
+ content_type: str
42
+ data: bytes | None = None # images: the bytes
43
+ path: str | None = None # videos: a temporary file
44
+ size: int = 0
45
+
46
+ def __post_init__(self):
47
+ if self.data is not None:
48
+ self.size = len(self.data)
49
+ elif self.path:
50
+ self.size = os.path.getsize(self.path)
51
+
52
+ def read(self, start: int = 0, end: int | None = None) -> bytes:
53
+ """Bytes [start, end) (all of them by default)."""
54
+ end = self.size if end is None else min(end, self.size)
55
+ if self.data is not None:
56
+ return self.data[start:end]
57
+ with open(self.path, "rb") as f:
58
+ f.seek(start)
59
+ return f.read(end - start)
60
+
61
+
62
+ def is_blog2video(a: Artifact) -> bool:
63
+ return (a.content_json or {}).get("provider") == "blog2video"
64
+
65
+
66
+ def media_count(a: Artifact) -> int:
67
+ """How many files a post of this artifact carries (before a platform's cap)."""
68
+ if a.type == "quote_card":
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
80
+
81
+
82
+ def duration(a: Artifact) -> float | None:
83
+ """A video's length when we know it (audiograms record it; blog2video's is only known to blog2video)."""
84
+ value = (a.content_json or {}).get("duration_s")
85
+ return float(value) if value else None
86
+
87
+
88
+ def check_postable(a: Artifact, platform: str) -> None:
89
+ """400 when this artifact can't go out on this platform, so it is refused when scheduled, not at post time."""
90
+ if a.type == "audio_overview":
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:
104
+ raise HTTPException(400, "This isn't ready yet. Post it once it has finished.")
105
+ seconds = duration(a) if a.type == "video" else None
106
+ if platform == "x" and seconds and seconds > X_VIDEO_SECONDS:
107
+ raise HTTPException(400, f"This video is {int(seconds // 60)}:{int(seconds % 60):02d} long; X takes videos "
108
+ "up to 2:20. Post it to LinkedIn, or make a shorter one.")
109
+
110
+
111
+ def check_size(m: Media, platform: str) -> None:
112
+ """Too big (or, on LinkedIn, too small a video): a clear permanent error before any network call."""
113
+ low, high = SIZE_LIMITS.get((platform, m.kind), (1, 1 << 40))
114
+ label = "X" if platform == "x" else "LinkedIn"
115
+ if m.size > high:
116
+ raise SocialError(f"This {m.kind} is {m.size / MB:.0f} MB; {label} takes up to {high // MB} MB.", permanent=True)
117
+ if m.size < low:
118
+ raise SocialError(f"This {m.kind} is too small for {label} (at least {low // 1024} KB).", permanent=True)
119
+
120
+
121
+ @contextlib.contextmanager
122
+ def media_for(a: Artifact | None, platform: str) -> Iterator[list[Media]]:
123
+ """The files to upload with a post of this artifact (none for text-only ones), size checked. Temporary video
124
+ files are deleted when the block ends, also on errors."""
125
+ from app.services.storage import storage
126
+
127
+ temps: list[str] = []
128
+ try:
129
+ out: list[Media] = []
130
+ if a is not None and a.type not in TEXT_ONLY and platform in MEDIA_PLATFORMS:
131
+ if a.type == "quote_card":
132
+ out = [Media("image", "quote.png", "image/png", data=storage.get_bytes(a.storage_key))]
133
+ elif a.type == "carousel":
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",
140
+ data=storage.get_bytes(a.storage_key))]
141
+ elif a.type == "upload":
142
+ path = _temp_file(temps)
143
+ with open(path, "wb") as f:
144
+ storage.download_to(a.storage_key, f)
145
+ out = [Media("video", (a.content_json or {}).get("filename") or "video.mp4", "video/mp4", path=path)]
146
+ elif a.type == "video":
147
+ path = _temp_file(temps)
148
+ with open(path, "wb") as f:
149
+ if is_blog2video(a):
150
+ _download(a.content_json["video_url"], f)
151
+ else:
152
+ storage.download_to(a.storage_key, f)
153
+ out = [Media("video", "video.mp4", "video/mp4", path=path)]
154
+ for m in out:
155
+ check_size(m, platform)
156
+ yield out
157
+ finally:
158
+ for path in temps:
159
+ with contextlib.suppress(OSError):
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)
166
+ temps.append(path)
167
+ return path
168
+
169
+
170
+ def _download(url: str, f) -> None:
171
+ with httpx.stream("GET", url, timeout=120, follow_redirects=True) as resp:
172
+ resp.raise_for_status()
173
+ for chunk in resp.iter_bytes(1024 * 1024):
174
+ f.write(chunk)
backend/app/services/social/x.py CHANGED
@@ -1,8 +1,10 @@
1
- """X (Twitter) API v2 with OAuth 2.0 user context (PKCE). Threads are reply chains."""
 
2
 
3
  import base64
4
  import hashlib
5
  import secrets
 
6
  from urllib.parse import urlencode
7
 
8
  import httpx
@@ -11,12 +13,14 @@ from sqlalchemy.orm import Session
11
  from app.config import settings
12
  from app.models import SocialAccount
13
  from app.services.crypto import decrypt
14
- from app.services.social import SocialError, expired, save_tokens, token
15
 
16
  AUTH_URL = "https://x.com/i/oauth2/authorize"
17
  TOKEN_URL = "https://api.x.com/2/oauth2/token"
18
  API = "https://api.x.com/2"
19
- SCOPES = "tweet.read tweet.write users.read offline.access"
 
 
20
 
21
 
22
  def redirect_uri() -> str:
@@ -24,7 +28,7 @@ def redirect_uri() -> str:
24
 
25
 
26
  def configured() -> bool:
27
- return bool(settings.x_client_id and settings.x_client_secret)
28
 
29
 
30
  def pkce_pair() -> tuple[str, str]:
@@ -59,25 +63,108 @@ def exchange_code(code: str, verifier: str) -> dict:
59
  "avatar_url": user.get("profile_image_url")}
60
 
61
 
62
- def _access(db: Session, account: SocialAccount) -> str:
63
- if expired(account) and account.refresh_token:
 
 
 
 
64
  tokens = _token_request({"grant_type": "refresh_token", "refresh_token": decrypt(account.refresh_token)})
65
- save_tokens(db, account, tokens["access_token"], tokens.get("refresh_token"), tokens.get("expires_in"))
 
 
 
 
 
 
 
 
 
66
  return token(account)
67
 
68
 
69
- def publish(db: Session, account: SocialAccount, posts: list[str]) -> tuple[str, str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  headers = {"Authorization": f"Bearer {_access(db, account)}"}
 
 
 
 
 
71
  first_id, reply_to = None, None
72
  for text in posts:
73
  body: dict = {"text": text}
74
  if reply_to:
75
  body["reply"] = {"in_reply_to_tweet_id": reply_to}
76
- resp = httpx.post(f"{API}/tweets", json=body, headers=headers, timeout=20)
 
 
77
  if resp.status_code == 401:
78
- account.status = "expired"
79
- db.commit()
80
- raise SocialError("X rejected the token. Reconnect the account.", permanent=True)
81
  if resp.status_code >= 400:
82
  raise SocialError(f"X {resp.status_code}: {resp.text[:300]}", permanent=resp.status_code in (400, 403))
83
  reply_to = resp.json()["data"]["id"]
 
1
+ """X (Twitter) API v2 with OAuth 2.0 user context (PKCE). Threads are reply chains; images and a video go on the
2
+ first post (v2 media upload: one call for an image, initialize / append / finalize / status for a video)."""
3
 
4
  import base64
5
  import hashlib
6
  import secrets
7
+ import time
8
  from urllib.parse import urlencode
9
 
10
  import httpx
 
13
  from app.config import settings
14
  from app.models import SocialAccount
15
  from app.services.crypto import decrypt
16
+ from app.services.social import SocialError, expired, save_tokens, scope_set, token
17
 
18
  AUTH_URL = "https://x.com/i/oauth2/authorize"
19
  TOKEN_URL = "https://api.x.com/2/oauth2/token"
20
  API = "https://api.x.com/2"
21
+ SCOPES = "tweet.read tweet.write users.read offline.access media.write"
22
+ CHUNK = 4 * 1024 * 1024 # video upload segments
23
+ PROCESSING_WAIT = 600 # seconds X may take to process a video before we give up
24
 
25
 
26
  def redirect_uri() -> str:
 
28
 
29
 
30
  def configured() -> bool:
31
+ return bool(settings.x_enabled and settings.x_client_id and settings.x_client_secret)
32
 
33
 
34
  def pkce_pair() -> tuple[str, str]:
 
63
  "avatar_url": user.get("profile_image_url")}
64
 
65
 
66
+ def refresh(db: Session, account: SocialAccount) -> None:
67
+ """Swap the refresh token for new tokens (X rotates the refresh token too). A refused refresh means the
68
+ connection is gone: reconnect."""
69
+ if not account.refresh_token:
70
+ raise SocialError("The X connection expired. Reconnect X.", reconnect=True)
71
+ try:
72
  tokens = _token_request({"grant_type": "refresh_token", "refresh_token": decrypt(account.refresh_token)})
73
+ except SocialError as exc:
74
+ if exc.permanent:
75
+ raise SocialError("The X connection expired. Reconnect X.", reconnect=True) from exc
76
+ raise
77
+ save_tokens(db, account, tokens["access_token"], tokens.get("refresh_token"), tokens.get("expires_in"))
78
+
79
+
80
+ def _access(db: Session, account: SocialAccount) -> str:
81
+ if expired(account):
82
+ refresh(db, account)
83
  return token(account)
84
 
85
 
86
+ def check_alive(db: Session, account: SocialAccount) -> None:
87
+ """One cheap call: raises SocialError(reconnect=True) when X no longer accepts this connection."""
88
+ resp = _call("GET", f"{API}/users/me", headers={"Authorization": f"Bearer {_access(db, account)}"}, timeout=20)
89
+ if resp.status_code == 401:
90
+ raise SocialError("X no longer accepts this connection. Reconnect X.", reconnect=True)
91
+
92
+
93
+ def _call(method: str, url: str, **kw) -> httpx.Response:
94
+ """Every request to X goes through here (tests replace it)."""
95
+ return httpx.request(method, url, timeout=kw.pop("timeout", 60), **kw)
96
+
97
+
98
+ def can_post_media(account: SocialAccount) -> bool:
99
+ """Accounts connected before media.write was asked for have to reconnect to post images or videos."""
100
+ return "media.write" in scope_set(account.scopes)
101
+
102
+
103
+ def _media_error(resp: httpx.Response) -> SocialError:
104
+ if resp.status_code in (401, 403):
105
+ return SocialError("X refused the upload. Reconnect X to post images and videos.", reconnect=True)
106
+ return SocialError(f"X media upload {resp.status_code}: {resp.text[:300]}",
107
+ permanent=400 <= resp.status_code < 500 and resp.status_code != 429)
108
+
109
+
110
+ def _upload_image(headers: dict, m) -> str:
111
+ resp = _call("POST", f"{API}/media/upload", headers=headers, data={"media_category": "tweet_image"},
112
+ files={"media": (m.filename, m.read(), m.content_type)})
113
+ if resp.status_code >= 400:
114
+ raise _media_error(resp)
115
+ return resp.json()["data"]["id"]
116
+
117
+
118
+ def _upload_video(headers: dict, m) -> str:
119
+ resp = _call("POST", f"{API}/media/upload/initialize", headers=headers,
120
+ json={"media_type": m.content_type, "total_bytes": m.size, "media_category": "tweet_video"})
121
+ if resp.status_code >= 400:
122
+ raise _media_error(resp)
123
+ media_id = resp.json()["data"]["id"]
124
+ for i, start in enumerate(range(0, m.size, CHUNK)):
125
+ resp = _call("POST", f"{API}/media/upload/{media_id}/append", headers=headers, data={"segment_index": i},
126
+ files={"media": (m.filename, m.read(start, start + CHUNK), "application/octet-stream")})
127
+ if resp.status_code >= 400:
128
+ raise _media_error(resp)
129
+ resp = _call("POST", f"{API}/media/upload/{media_id}/finalize", headers=headers)
130
+ if resp.status_code >= 400:
131
+ raise _media_error(resp)
132
+ info = (resp.json().get("data") or {}).get("processing_info")
133
+ waited = 0
134
+ while info and info.get("state") in ("pending", "in_progress"):
135
+ if waited >= PROCESSING_WAIT:
136
+ raise SocialError("X took too long to process the video.")
137
+ pause = int(info.get("check_after_secs") or 5)
138
+ time.sleep(pause)
139
+ waited += pause
140
+ resp = _call("GET", f"{API}/media/upload", headers=headers,
141
+ params={"command": "STATUS", "media_id": media_id})
142
+ if resp.status_code >= 400:
143
+ raise _media_error(resp)
144
+ info = (resp.json().get("data") or {}).get("processing_info")
145
+ if info and info.get("state") == "failed":
146
+ reason = (info.get("error") or {}).get("message") or "X could not process the video"
147
+ raise SocialError(f"{reason}. X takes videos up to 2:20.", permanent=True)
148
+ return media_id
149
+
150
+
151
+ def publish(db: Session, account: SocialAccount, posts: list[str], media: list | None = None) -> tuple[str, str]:
152
  headers = {"Authorization": f"Bearer {_access(db, account)}"}
153
+ media_ids: list[str] = []
154
+ if media:
155
+ if not can_post_media(account):
156
+ raise SocialError("Reconnect X to post images and videos.", permanent=True)
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:
164
+ body["media"] = {"media_ids": media_ids} # the images or video go on the first post
165
+ resp = _call("POST", f"{API}/tweets", json=body, headers=headers, timeout=20)
166
  if resp.status_code == 401:
167
+ raise SocialError("X rejected the connection. Reconnect X.", reconnect=True)
 
 
168
  if resp.status_code >= 400:
169
  raise SocialError(f"X {resp.status_code}: {resp.text[:300]}", permanent=resp.status_code in (400, 403))
170
  reply_to = resp.json()["data"]["id"]
backend/app/services/storage.py CHANGED
@@ -93,6 +93,10 @@ class Storage:
93
  def get_bytes(self, key: str) -> bytes:
94
  return self.client.get_object(Bucket=self.bucket, Key=self._k(key))["Body"].read()
95
 
 
 
 
 
96
  def head(self, key: str) -> dict | None:
97
  try:
98
  return self.client.head_object(Bucket=self.bucket, Key=self._k(key))
@@ -107,6 +111,14 @@ class Storage:
107
  def delete(self, key: str) -> None:
108
  self.client.delete_object(Bucket=self.bucket, Key=self._k(key))
109
 
 
 
 
 
 
 
 
 
110
  def delete_prefix(self, prefix: str) -> int:
111
  deleted = 0
112
  paginator = self.client.get_paginator("list_objects_v2")
@@ -177,6 +189,13 @@ class LocalStorage:
177
  raise FileNotFoundError(key)
178
  return path.read_bytes()
179
 
 
 
 
 
 
 
 
180
  def head(self, key: str) -> dict | None:
181
  path = self.path(key)
182
  if not path.is_file():
@@ -189,6 +208,13 @@ class LocalStorage:
189
  def delete(self, key: str) -> None:
190
  self.path(key).unlink(missing_ok=True)
191
 
 
 
 
 
 
 
 
192
  def delete_prefix(self, prefix: str) -> int:
193
  base = self.path(prefix.rstrip("/"))
194
  if base.is_file():
 
93
  def get_bytes(self, key: str) -> bytes:
94
  return self.client.get_object(Bucket=self.bucket, Key=self._k(key))["Body"].read()
95
 
96
+ def download_to(self, key: str, fileobj) -> None:
97
+ """Stream an object into an open binary file (large files, e.g. a video to post, never fully in memory)."""
98
+ self.client.download_fileobj(self.bucket, self._k(key), fileobj)
99
+
100
  def head(self, key: str) -> dict | None:
101
  try:
102
  return self.client.head_object(Bucket=self.bucket, Key=self._k(key))
 
111
  def delete(self, key: str) -> None:
112
  self.client.delete_object(Bucket=self.bucket, Key=self._k(key))
113
 
114
+ def list_keys(self, prefix: str) -> list[tuple[str, float]]:
115
+ """(key, last modified epoch seconds) of every object under the prefix."""
116
+ out = []
117
+ paginator = self.client.get_paginator("list_objects_v2")
118
+ for page in paginator.paginate(Bucket=self.bucket, Prefix=self._k(prefix)):
119
+ out += [(o["Key"][len(self.prefix):], o["LastModified"].timestamp()) for o in page.get("Contents", [])]
120
+ return out
121
+
122
  def delete_prefix(self, prefix: str) -> int:
123
  deleted = 0
124
  paginator = self.client.get_paginator("list_objects_v2")
 
189
  raise FileNotFoundError(key)
190
  return path.read_bytes()
191
 
192
+ def download_to(self, key: str, fileobj) -> None:
193
+ path = self.path(key)
194
+ if not path.is_file():
195
+ raise FileNotFoundError(key)
196
+ with path.open("rb") as src:
197
+ shutil.copyfileobj(src, fileobj)
198
+
199
  def head(self, key: str) -> dict | None:
200
  path = self.path(key)
201
  if not path.is_file():
 
208
  def delete(self, key: str) -> None:
209
  self.path(key).unlink(missing_ok=True)
210
 
211
+ def list_keys(self, prefix: str) -> list[tuple[str, float]]:
212
+ base = self.path(prefix.rstrip("/"))
213
+ if not base.is_dir():
214
+ return []
215
+ return [(f"{prefix.rstrip('/')}/{f.relative_to(base).as_posix()}", f.stat().st_mtime)
216
+ for f in base.rglob("*") if f.is_file()]
217
+
218
  def delete_prefix(self, prefix: str) -> int:
219
  base = self.path(prefix.rstrip("/"))
220
  if base.is_file():
backend/app/services/uploads.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Files users upload (ws/<workspace>/uploads/<upload id>/<name>): kept only while their upload record exists.
2
+
3
+ Deleting a record through the app removes its file at commit (app.models.content). The daily sweep catches the rest:
4
+ files whose record is gone (deleted straight in the database, or a delete that failed) and uploads that were started
5
+ but never finished."""
6
+
7
+ import logging
8
+ import time
9
+ from collections.abc import Callable
10
+ from datetime import UTC, datetime, timedelta
11
+
12
+ from sqlalchemy import select
13
+ from sqlalchemy.orm import Session
14
+
15
+ from app.db import SessionLocal
16
+ from app.models import Artifact, Upload, Workspace
17
+ from app.services.storage import storage
18
+
19
+ log = logging.getLogger("notestack.storage")
20
+
21
+ GRACE = timedelta(days=1) # a file or a pending upload younger than this may still be on its way
22
+
23
+
24
+ def remove_upload_artifact(db: Session, a: Artifact) -> None:
25
+ """An `upload` artifact is being deleted: its upload record goes too (and with it the file), unless another
26
+ artifact still posts the same file. The caller commits."""
27
+ if a.type != "upload" or not a.storage_key:
28
+ return
29
+ shared = db.scalar(select(Artifact.id).where(Artifact.storage_key == a.storage_key, Artifact.id != a.id).limit(1))
30
+ if shared:
31
+ return
32
+ for up in db.scalars(select(Upload).where(Upload.key == a.storage_key)):
33
+ db.delete(up)
34
+
35
+
36
+ def sweep_orphan_uploads(session_factory: Callable[[], Session] = SessionLocal) -> int:
37
+ """Delete upload files with no upload record, and uploads still pending after a day. Returns files removed."""
38
+ removed = 0
39
+ cutoff = datetime.now(UTC) - GRACE
40
+ with session_factory() as db:
41
+ # Started but never finished: drop the record (its file, if any, goes at commit).
42
+ for up in db.scalars(select(Upload).where(Upload.status == "pending", Upload.created_at < cutoff)):
43
+ db.delete(up)
44
+ db.commit()
45
+ known = set(db.scalars(select(Upload.key)))
46
+ for ws_id in db.scalars(select(Workspace.id)):
47
+ try:
48
+ files = storage.list_keys(f"ws/{ws_id}/uploads/")
49
+ except Exception:
50
+ log.warning("couldn't list uploads for workspace %s", ws_id, exc_info=True)
51
+ continue
52
+ for key, modified in files:
53
+ if key in known or time.time() - modified < GRACE.total_seconds():
54
+ continue
55
+ try:
56
+ storage.delete(key)
57
+ removed += 1
58
+ except Exception:
59
+ log.warning("couldn't delete orphan upload %s", key, exc_info=True)
60
+ if removed:
61
+ log.info("removed %d upload files with no record", removed)
62
+ return removed
backend/app/worker.py CHANGED
@@ -64,6 +64,8 @@ from app.services.jobs import (
64
  from app.services.notestack_voices import save_notestack_voice
65
  from app.services.plans import effective_plan
66
  from app.services.renderer import PermanentJobError, request_render
 
 
67
  from app.services.storage import keys, storage
68
 
69
  log = logging.getLogger("notestack.worker")
@@ -267,11 +269,18 @@ def handle_render(db: Session, job: Job):
267
  return HANDED_OFF
268
 
269
 
 
 
 
 
 
 
270
  HANDLERS: dict[str, Callable[[Session, Job], object]] = {
271
  "ingest": handle_ingest,
272
  "import_url": handle_import_url,
273
  "import_upload": handle_import_upload,
274
  "topics": handle_topics,
 
275
  "ideas": handle_ideas,
276
  "memory_update": handle_memory,
277
  "chat_memory": handle_chat_memory,
@@ -418,6 +427,7 @@ def update_email_batch(session_factory: Callable[[], Session] = SessionLocal) ->
418
  def daily_cleanup(session_factory: Callable[[], Session] = SessionLocal) -> None:
419
  with session_factory() as db:
420
  purge_old_codes(db)
 
421
 
422
 
423
  @dataclass
@@ -426,11 +436,27 @@ class Periodic:
426
  every_seconds: float
427
  fn: Callable[[], object]
428
  next_at: float = 0.0
 
 
 
 
 
 
 
 
 
 
 
 
429
 
 
430
 
431
  PERIODIC = [
432
  Periodic("recover_stale", 60, recover_stale),
433
- Periodic("publish_due", 30, launchpad.publish_due),
 
 
 
434
  Periodic("sync_engagement", 3600, launchpad.sync_engagement),
435
  Periodic("update_email_batch", 300, update_email_batch),
436
  Periodic("topics_backfill", 900, topics_backfill),
@@ -445,7 +471,7 @@ def run_periodic(now: float) -> None:
445
  for task in PERIODIC:
446
  if now < task.next_at:
447
  continue
448
- task.next_at = now + task.every_seconds
449
  try:
450
  with single_flight(task.name) as got:
451
  if got:
 
64
  from app.services.notestack_voices import save_notestack_voice
65
  from app.services.plans import effective_plan
66
  from app.services.renderer import PermanentJobError, request_render
67
+ from app.services.social.health import social_health
68
+ from app.services.uploads import sweep_orphan_uploads
69
  from app.services.storage import keys, storage
70
 
71
  log = logging.getLogger("notestack.worker")
 
269
  return HANDED_OFF
270
 
271
 
272
+ def handle_publish_post(db: Session, job: Job):
273
+ item = launchpad.publish_job(db, job.params["item_id"])
274
+ return Done({"status": item.status if item else "gone"}, "Published" if item and item.status == "posted"
275
+ else f"Post {item.status}" if item else "Post deleted")
276
+
277
+
278
  HANDLERS: dict[str, Callable[[Session, Job], object]] = {
279
  "ingest": handle_ingest,
280
  "import_url": handle_import_url,
281
  "import_upload": handle_import_upload,
282
  "topics": handle_topics,
283
+ "publish_post": handle_publish_post,
284
  "ideas": handle_ideas,
285
  "memory_update": handle_memory,
286
  "chat_memory": handle_chat_memory,
 
427
  def daily_cleanup(session_factory: Callable[[], Session] = SessionLocal) -> None:
428
  with session_factory() as db:
429
  purge_old_codes(db)
430
+ sweep_orphan_uploads(session_factory) # upload files whose record was deleted, and unfinished uploads
431
 
432
 
433
  @dataclass
 
436
  every_seconds: float
437
  fn: Callable[[], object]
438
  next_at: float = 0.0
439
+ # Run on the wall clock's multiples of every_seconds (1800: at :00 and :30 UTC), not every_seconds after the last
440
+ # run. The first run is still at start-up, to catch up on anything already due.
441
+ align: bool = False
442
+
443
+ def schedule_next(self, now: float, wall: float | None = None) -> None:
444
+ if not self.align:
445
+ self.next_at = now + self.every_seconds
446
+ return
447
+ wall = time.time() if wall is None else wall
448
+ # A couple of seconds past the boundary, so a post scheduled exactly on it is already due.
449
+ self.next_at = now + (self.every_seconds - wall % self.every_seconds) + ALIGN_GRACE_SECONDS
450
+
451
 
452
+ ALIGN_GRACE_SECONDS = 2
453
 
454
  PERIODIC = [
455
  Periodic("recover_stale", 60, recover_stale),
456
+ # Posts are scheduled on half hours (in the user's zone), so they go out on the :00 / :30 run. Zones on a :45
457
+ # offset (Nepal) get slots between runs: those go out at the next run, up to 15 minutes late.
458
+ Periodic("publish_due", 1800, launchpad.publish_due, align=True),
459
+ Periodic("social_health", 600, social_health),
460
  Periodic("sync_engagement", 3600, launchpad.sync_engagement),
461
  Periodic("update_email_batch", 300, update_email_batch),
462
  Periodic("topics_backfill", 900, topics_backfill),
 
471
  for task in PERIODIC:
472
  if now < task.next_at:
473
  continue
474
+ task.schedule_next(now)
475
  try:
476
  with single_flight(task.name) as got:
477
  if got:
backend/tests/test_buildout.py CHANGED
@@ -580,14 +580,14 @@ def _bluesky(client, auth, monkeypatch):
580
  return r.json()["accounts"][0]
581
 
582
 
583
- def test_schedule_and_publish_bluesky_thread(client, auth, monkeypatch, session_factory, db_session):
584
  from app.services import launchpad
585
  from app.services.social import bluesky
586
 
587
  account = _bluesky(client, auth, monkeypatch)
588
  published = {}
589
 
590
- def publish(db, acct, posts):
591
  published["posts"] = posts
592
  return "at://did:plc:ada/app.bsky.feed.post/abc", "https://bsky.app/profile/ada.bsky.social/post/abc"
593
 
@@ -602,14 +602,15 @@ def test_schedule_and_publish_bluesky_thread(client, auth, monkeypatch, session_
602
  }, headers=auth).json()
603
  assert item["auto_post"] is True
604
 
605
- assert launchpad.publish_due(session_factory) == 1
 
606
  assert published["posts"] == ["First", "Second"]
607
  got = client.get("/api/calendar", headers=auth).json()[0]
608
  assert got["status"] == "posted" and got["external_url"].startswith("https://bsky.app/")
609
  assert client.patch(f"/api/calendar/{item['id']}", json={"content": "late"}, headers=auth).status_code == 409
610
 
611
 
612
- def test_substack_notes_send_reminder_email(client, auth, session_factory):
613
  from app.services import launchpad
614
 
615
  ConsoleEmailProvider.sent.clear()
@@ -618,17 +619,18 @@ def test_substack_notes_send_reminder_email(client, auth, session_factory):
618
  headers=auth).json()
619
  assert item["remind_by_email"] is True
620
  launchpad.publish_due(session_factory)
 
621
  assert client.get("/api/calendar", headers=auth).json()[0]["status"] == "reminded"
622
  assert ConsoleEmailProvider.sent[-1].subject == "Time to post on Substack Notes"
623
 
624
 
625
- def test_publish_retries_then_fails(client, auth, monkeypatch, session_factory):
626
  from app.services import launchpad
627
  from app.services.social import SocialError, bluesky
628
 
629
  account = _bluesky(client, auth, monkeypatch)
630
 
631
- def boom(db, acct, posts):
632
  raise SocialError("Bluesky 500: busy")
633
 
634
  monkeypatch.setattr(bluesky, "publish", boom)
@@ -642,6 +644,7 @@ def test_publish_retries_then_fails(client, auth, monkeypatch, session_factory):
642
  db.query(CalendarItem).update({"scheduled_at": datetime.now(UTC) - timedelta(seconds=1)})
643
  db.commit()
644
  launchpad.publish_due(session_factory)
 
645
  assert client.get("/api/calendar", headers=auth).json()[0]["status"] == "failed"
646
 
647
 
@@ -697,6 +700,226 @@ def test_oauth_callback_redirects_with_ticket(client, monkeypatch):
697
  assert "error=" in bad.headers["location"]
698
 
699
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
700
  # Settings and activity
701
 
702
 
 
580
  return r.json()["accounts"][0]
581
 
582
 
583
+ def test_schedule_and_publish_bluesky_thread(client, auth, monkeypatch, session_factory, db_session, run_jobs):
584
  from app.services import launchpad
585
  from app.services.social import bluesky
586
 
587
  account = _bluesky(client, auth, monkeypatch)
588
  published = {}
589
 
590
+ def publish(db, acct, posts, media=None):
591
  published["posts"] = posts
592
  return "at://did:plc:ada/app.bsky.feed.post/abc", "https://bsky.app/profile/ada.bsky.social/post/abc"
593
 
 
602
  }, headers=auth).json()
603
  assert item["auto_post"] is True
604
 
605
+ assert launchpad.publish_due(session_factory) == 1 # queued as a publish_post job
606
+ assert [j.kind for j in run_jobs()] == ["publish_post"]
607
  assert published["posts"] == ["First", "Second"]
608
  got = client.get("/api/calendar", headers=auth).json()[0]
609
  assert got["status"] == "posted" and got["external_url"].startswith("https://bsky.app/")
610
  assert client.patch(f"/api/calendar/{item['id']}", json={"content": "late"}, headers=auth).status_code == 409
611
 
612
 
613
+ def test_substack_notes_send_reminder_email(client, auth, session_factory, run_jobs):
614
  from app.services import launchpad
615
 
616
  ConsoleEmailProvider.sent.clear()
 
619
  headers=auth).json()
620
  assert item["remind_by_email"] is True
621
  launchpad.publish_due(session_factory)
622
+ run_jobs()
623
  assert client.get("/api/calendar", headers=auth).json()[0]["status"] == "reminded"
624
  assert ConsoleEmailProvider.sent[-1].subject == "Time to post on Substack Notes"
625
 
626
 
627
+ def test_publish_retries_then_fails(client, auth, monkeypatch, session_factory, run_jobs):
628
  from app.services import launchpad
629
  from app.services.social import SocialError, bluesky
630
 
631
  account = _bluesky(client, auth, monkeypatch)
632
 
633
+ def boom(db, acct, posts, media=None):
634
  raise SocialError("Bluesky 500: busy")
635
 
636
  monkeypatch.setattr(bluesky, "publish", boom)
 
644
  db.query(CalendarItem).update({"scheduled_at": datetime.now(UTC) - timedelta(seconds=1)})
645
  db.commit()
646
  launchpad.publish_due(session_factory)
647
+ run_jobs()
648
  assert client.get("/api/calendar", headers=auth).json()[0]["status"] == "failed"
649
 
650
 
 
700
  assert "error=" in bad.headers["location"]
701
 
702
 
703
+ # Launchpad: posting what was made (images and videos) to X and LinkedIn
704
+
705
+
706
+ def _made(db, ws, type_, *, status="ready", key=None, **content):
707
+ """An artifact as the pipeline leaves it (its file in storage when it has one)."""
708
+ from app.models import Artifact
709
+ from app.services.storage import storage
710
+
711
+ if key:
712
+ storage.put_bytes(key, b"PNG" if key.endswith(".png") else bytes(range(256)) * 400) # a 100 KB "video"
713
+ a = Artifact(workspace_id=ws.id, type=type_, status=status, storage_key=key,
714
+ content_json={"title": f"A {type_}", **content})
715
+ db.add(a)
716
+ db.commit()
717
+ return a
718
+
719
+
720
+ def _account(db, ws, platform, scopes="tweet.read tweet.write users.read offline.access media.write"):
721
+ from app.services.crypto import encrypt
722
+
723
+ acct = SocialAccount(workspace_id=ws.id, platform=platform, handle="ada", external_id="p42",
724
+ access_token=encrypt("tok"), scopes=scopes, status="active")
725
+ db.add(acct)
726
+ db.commit()
727
+ return acct
728
+
729
+
730
+ def _reply(code: int, method: str, url: str, data: dict | None = None, headers: dict | None = None):
731
+ return httpx.Response(code, json=data, headers=headers, request=httpx.Request(method, url))
732
+
733
+
734
+ def _schedule(client, auth, artifact, platform, account=None):
735
+ r = client.post("/api/calendar", json={
736
+ "platform": platform, "content": "Look at this", "artifact_id": str(artifact.id),
737
+ "scheduled_at": (datetime.now(UTC) - timedelta(minutes=1)).isoformat(),
738
+ **({"social_account_id": str(account.id)} if account else {})}, headers=auth)
739
+ return r
740
+
741
+
742
+ def test_postable_lists_what_can_be_posted(client, auth, db_session):
743
+ ws = db_session.query(Workspace).first()
744
+ quote = _made(db_session, ws, "quote_card", key="ws/q/quotecard.png", quote="Ship it", source="Ada")
745
+ _made(db_session, ws, "audio_overview", key="ws/a/audio.mp3")
746
+ _made(db_session, ws, "video", status="processing", provider="blog2video") # not rendered yet
747
+ done = _made(db_session, ws, "video", status="processing", provider="blog2video", video_url="https://v/x.mp4")
748
+ unrendered = _made(db_session, ws, "video", status="ready", provider="blog2video", b2v_status="generated")
749
+ _made(db_session, ws, "summary", summary="Raise prices once a year [1].")
750
+ got = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}
751
+ assert {a["type"] for a in got.values()} == {"quote_card", "video", "summary"} # no audio, no unfinished video
752
+ assert str(done.id) in got and got[str(quote.id)]["thumb_url"] and got[str(quote.id)]["media"] == "image"
753
+ # Made but not rendered: listed, flagged; the rendered one is not.
754
+ assert got[str(unrendered.id)]["needs_render"] is True and got[str(done.id)]["needs_render"] is False
755
+ assert got[str(quote.id)]["prefill"] == {"x": ['"Ship it" (Ada)'], "linkedin": '"Ship it" (Ada)'}
756
+ summary = next(a for a in got.values() if a["type"] == "summary")
757
+ assert summary["prefill"]["linkedin"] == "Raise prices once a year." and summary["media"] == "text"
758
+
759
+
760
+ def test_scheduling_refuses_what_cannot_go_out(client, auth, db_session):
761
+ ws = db_session.query(Workspace).first()
762
+ audio = _made(db_session, ws, "audio_overview", key="ws/a/audio.mp3")
763
+ long_video = _made(db_session, ws, "video", key="ws/v/audiogram.mp4", duration_s=200)
764
+ quote = _made(db_session, ws, "quote_card", key="ws/q/quotecard.png")
765
+ assert _schedule(client, auth, audio, "x").status_code == 400
766
+ r = _schedule(client, auth, long_video, "x")
767
+ assert r.status_code == 400 and "2:20" in r.json()["detail"]
768
+ linkedin_acct = _account(db_session, ws, "linkedin", scopes="openid profile w_member_social")
769
+ assert _schedule(client, auth, long_video, "linkedin", linkedin_acct).status_code == 200 # LinkedIn takes it
770
+ assert _schedule(client, auth, quote, "bluesky").status_code == 400 # images: X and LinkedIn only
771
+
772
+
773
+ def test_x_posts_images_and_a_video(client, auth, db_session, monkeypatch, session_factory, run_jobs):
774
+ from app.services import launchpad
775
+ from app.services.social import x
776
+
777
+ ws = db_session.query(Workspace).first()
778
+ acct = _account(db_session, ws, "x")
779
+ calls = []
780
+
781
+ def call(method, url, **kw):
782
+ path = url.split("/2/", 1)[1]
783
+ calls.append((method, path, kw))
784
+ if path == "media/upload" and method == "POST":
785
+ return _reply(200, method, url, {"data": {"id": f"img{len(calls)}"}})
786
+ if path == "media/upload/initialize":
787
+ return _reply(200, method, url, {"data": {"id": "vid1"}})
788
+ if path.endswith("/append"):
789
+ return _reply(204, method, url)
790
+ if path.endswith("/finalize"):
791
+ return _reply(200, method, url, {"data": {"processing_info": {"state": "pending", "check_after_secs": 1}}})
792
+ if path == "media/upload": # STATUS
793
+ return _reply(200, method, url, {"data": {"processing_info": {"state": "succeeded"}}})
794
+ return _reply(201, method, url, {"data": {"id": "t1"}}) # tweets
795
+
796
+ monkeypatch.setattr(x, "_call", call)
797
+ monkeypatch.setattr(x.time, "sleep", lambda s: None)
798
+ monkeypatch.setattr(x, "CHUNK", 30000)
799
+ carousel = _made(db_session, ws, "carousel", key="ws/c/slide-01.png",
800
+ slide_keys=[f"ws/c/slide-0{i}.png" for i in range(1, 7)])
801
+ for i in range(1, 7):
802
+ from app.services.storage import storage
803
+ storage.put_bytes(f"ws/c/slide-0{i}.png", b"PNG")
804
+ assert _schedule(client, auth, carousel, "x", acct).status_code == 200
805
+ launchpad.publish_due(session_factory)
806
+ run_jobs()
807
+ uploads = [c for c in calls if c[1] == "media/upload"]
808
+ tweet = next(c for c in calls if c[1] == "tweets")
809
+ assert len(uploads) == 4 # X takes 4 images: the first 4 slides
810
+ assert tweet[2]["json"]["media"]["media_ids"] == [f"img{i}" for i in range(1, 5)]
811
+ assert client.get("/api/calendar", headers=auth).json()[0]["status"] == "posted"
812
+
813
+ calls.clear()
814
+ video = _made(db_session, ws, "video", key="ws/v/audiogram.mp4", duration_s=30)
815
+ _schedule(client, auth, video, "x", acct)
816
+ launchpad.publish_due(session_factory)
817
+ run_jobs()
818
+ paths = [c[1] for c in calls]
819
+ assert paths[0] == "media/upload/initialize" and paths.count("media/upload/vid1/append") == 4 # 102400 bytes
820
+ assert paths[-3:] == ["media/upload/vid1/finalize", "media/upload", "tweets"]
821
+ assert calls[-1][2]["json"]["media"] == {"media_ids": ["vid1"]}
822
+
823
+
824
+ def test_x_accounts_from_before_media_must_reconnect(client, auth, db_session, monkeypatch, session_factory,
825
+ run_jobs):
826
+ from app.services import launchpad
827
+ from app.services.social import x
828
+
829
+ ws = db_session.query(Workspace).first()
830
+ acct = _account(db_session, ws, "x", scopes="tweet.read tweet.write users.read offline.access")
831
+ monkeypatch.setattr(x, "_call", lambda *a, **k: pytest.fail("nothing should be sent"))
832
+ quote = _made(db_session, ws, "quote_card", key="ws/q/quotecard.png")
833
+ r = _schedule(client, auth, quote, "x", acct) # refused up front now
834
+ assert r.status_code == 409 and "Reconnect X" in r.json()["detail"]["message"]
835
+ got = client.get("/api/social/accounts", headers=auth).json()["accounts"][0]
836
+ assert got["needs_reconnect"] is True and got["can_post_media"] is False
837
+ # Scheduled earlier, the scope gone since: refused at publish time without a call, as a reconnect.
838
+ with session_factory() as db:
839
+ db.add(CalendarItem(workspace_id=ws.id, platform="x", content="Hi", artifact_id=quote.id,
840
+ social_account_id=acct.id, scheduled_at=datetime.now(UTC) - timedelta(minutes=1)))
841
+ db.commit()
842
+ launchpad.publish_due(session_factory)
843
+ run_jobs()
844
+ item = client.get("/api/calendar", headers=auth).json()[0]
845
+ assert item["status"] == "paused" and "Reconnect X" in item["error"]
846
+
847
+
848
+ def test_linkedin_posts_images_and_a_video(client, auth, db_session, monkeypatch, session_factory, run_jobs):
849
+ from app.services import launchpad
850
+ from app.services.social import linkedin
851
+
852
+ ws = db_session.query(Workspace).first()
853
+ acct = _account(db_session, ws, "linkedin", scopes="openid profile w_member_social")
854
+ calls = []
855
+
856
+ def call(method, url, **kw):
857
+ calls.append((method, url, kw))
858
+ if url.endswith("/rest/images"):
859
+ return _reply(200, method, url, {"value": {"uploadUrl": f"https://up/img{len(calls)}",
860
+ "image": f"urn:li:image:{len(calls)}"}})
861
+ if url.endswith("/rest/videos") and kw["params"]["action"] == "initializeUpload":
862
+ return _reply(200, method, url, {"value": {"video": "urn:li:video:9", "uploadToken": "",
863
+ "uploadInstructions": [
864
+ {"uploadUrl": "https://up/v1", "firstByte": 0, "lastByte": 59999},
865
+ {"uploadUrl": "https://up/v2", "firstByte": 60000,
866
+ "lastByte": 102399}]}})
867
+ if url.startswith("https://up/"):
868
+ return _reply(200, method, url, headers={"etag": f'"e-{url[-2:]}"'})
869
+ if "/rest/videos/" in url:
870
+ return _reply(200, method, url, {"status": "AVAILABLE"})
871
+ if url.endswith("/rest/posts"):
872
+ return _reply(201, method, url, headers={"x-restli-id": "urn:li:share:1"})
873
+ return _reply(200, method, url) # finalize
874
+
875
+ monkeypatch.setattr(linkedin, "_call", call)
876
+ quote = _made(db_session, ws, "quote_card", key="ws/q/quotecard.png")
877
+ carousel = _made(db_session, ws, "carousel", key="ws/c/slide-01.png",
878
+ slide_keys=["ws/q/quotecard.png"] * 3)
879
+ video = _made(db_session, ws, "video", key="ws/v/audiogram.mp4", duration_s=200)
880
+ bodies = []
881
+ for a in (quote, carousel, video):
882
+ calls.clear()
883
+ _schedule(client, auth, a, "linkedin", acct)
884
+ launchpad.publish_due(session_factory)
885
+ run_jobs()
886
+ bodies.append(next(c for c in calls if c[1].endswith("/rest/posts"))[2]["json"]["content"])
887
+ if a is video:
888
+ puts = [c for c in calls if c[1].startswith("https://up/v")]
889
+ assert [len(c[2]["content"]) for c in puts] == [60000, 42400]
890
+ fin = next(c for c in calls if c[1].endswith("/rest/videos") and c[2]["params"]["action"] == "finalizeUpload")
891
+ assert fin[2]["json"]["finalizeUploadRequest"]["uploadedPartIds"] == ["e-v1", "e-v2"]
892
+ assert bodies[0] == {"media": {"id": "urn:li:image:1"}}
893
+ assert len(bodies[1]["multiImage"]["images"]) == 3
894
+ assert bodies[2] == {"media": {"id": "urn:li:video:9"}}
895
+ assert {i["status"] for i in client.get("/api/calendar", headers=auth).json()} == {"posted"}
896
+
897
+
898
+ def test_publish_now_with_media_goes_through_a_job(client, auth, db_session, monkeypatch, run_jobs):
899
+ from app.services.social import linkedin
900
+
901
+ ws = db_session.query(Workspace).first()
902
+ acct = _account(db_session, ws, "linkedin", scopes="openid profile w_member_social")
903
+ monkeypatch.setattr(linkedin, "publish", lambda db, a, posts, media=None: ("urn:li:share:2", "https://li/2"))
904
+ quote = _made(db_session, ws, "quote_card", key="ws/q/quotecard.png")
905
+ item = client.post("/api/calendar", json={"platform": "linkedin", "content": "Hi", "artifact_id": str(quote.id),
906
+ "social_account_id": str(acct.id),
907
+ "scheduled_at": (datetime.now(UTC) + timedelta(days=1)).isoformat()},
908
+ headers=auth).json()
909
+ assert item["artifact"]["type"] == "quote_card"
910
+ r = client.post(f"/api/calendar/{item['id']}/publish", headers=auth).json()
911
+ assert r["status"] == "publishing" # the upload runs in a job
912
+ run_jobs()
913
+ assert client.get("/api/calendar", headers=auth).json()[0]["status"] == "posted"
914
+ # The attachment can be changed or removed before it goes out.
915
+ other = client.post("/api/calendar", json={"platform": "linkedin", "content": "Later", "artifact_id": str(quote.id),
916
+ "social_account_id": str(acct.id),
917
+ "scheduled_at": (datetime.now(UTC) + timedelta(days=1)).isoformat()},
918
+ headers=auth).json()
919
+ r = client.patch(f"/api/calendar/{other['id']}", json={"artifact_id": None}, headers=auth).json()
920
+ assert r["artifact"] is None
921
+
922
+
923
  # Settings and activity
924
 
925
 
backend/tests/test_social_posting.py ADDED
@@ -0,0 +1,554 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Posting to X and LinkedIn: scheduling needs a live connection, a connection that goes down pauses its posts (never
2
+ attempted) and reconnecting resumes them, connections are kept fresh, posts go out at their time, and uploads stream
3
+ from temporary files with size limits and retries. The platforms are faked at their _call seam."""
4
+
5
+ import os
6
+ import uuid
7
+ from datetime import UTC, datetime, timedelta
8
+
9
+ import httpx
10
+ import pytest
11
+
12
+ from app.models import Artifact, CalendarItem, SocialAccount, Workspace
13
+ from app.services.crypto import encrypt
14
+ from tests.conftest import last_code
15
+
16
+ X_SCOPES = "tweet.read tweet.write users.read offline.access media.write"
17
+ LI_SCOPES = "openid,profile,w_member_social" # LinkedIn lists scopes with commas
18
+
19
+
20
+ @pytest.fixture()
21
+ def auth(client):
22
+ client.post("/api/auth/email/register/start", json={"email": "ada@example.com", "password": "stardust-42",
23
+ "name": "Ada Lovelace"})
24
+ r = client.post("/api/auth/email/register/verify", json={"email": "ada@example.com", "code": last_code()})
25
+ return {"Authorization": f"Bearer {r.json()['access_token']}"}
26
+
27
+
28
+ @pytest.fixture()
29
+ def ws(auth, db_session):
30
+ return db_session.query(Workspace).first()
31
+
32
+
33
+ @pytest.fixture(autouse=True)
34
+ def _fresh_health(monkeypatch):
35
+ from app.services.social import health
36
+
37
+ monkeypatch.setattr(health, "_last_checked", {})
38
+
39
+
40
+ def account(db, ws, platform="x", scopes=None, expires_in=None, refresh=None, external_id="p42"):
41
+ acct = SocialAccount(workspace_id=ws.id, platform=platform, handle="ada", external_id=external_id,
42
+ access_token=encrypt("tok"), refresh_token=encrypt(refresh) if refresh else None,
43
+ scopes=scopes or (X_SCOPES if platform == "x" else LI_SCOPES), status="active",
44
+ expires_at=datetime.now(UTC) + expires_in if expires_in is not None else None)
45
+ db.add(acct)
46
+ db.commit()
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()
55
+ return it
56
+
57
+
58
+ def made(db, ws, type_, key=None, data=b"PNG", **content):
59
+ from app.services.storage import storage
60
+
61
+ if key:
62
+ storage.put_bytes(key, data)
63
+ a = Artifact(workspace_id=ws.id, type=type_, status="ready", storage_key=key,
64
+ content_json={"title": type_, **content})
65
+ db.add(a)
66
+ db.commit()
67
+ return a
68
+
69
+
70
+ def reply(code, method, url, data=None, headers=None):
71
+ return httpx.Response(code, json=data, headers=headers, request=httpx.Request(method, url))
72
+
73
+
74
+ def statuses(db, *items):
75
+ db.expire_all()
76
+ return [db.get(CalendarItem, i.id).status for i in items]
77
+
78
+
79
+ # Scheduling needs a live connection
80
+
81
+
82
+ def schedule(client, auth, platform, acct=None, **extra):
83
+ return client.post("/api/calendar", json={
84
+ "platform": platform, "content": "Hello", "scheduled_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(),
85
+ **({"social_account_id": str(acct.id)} if acct else {}), **extra}, headers=auth)
86
+
87
+
88
+ def test_x_and_linkedin_need_a_connected_account(client, auth, ws, db_session):
89
+ r = schedule(client, auth, "x")
90
+ assert r.status_code == 409 and r.json()["detail"]["code"] == "not_connected"
91
+ assert schedule(client, auth, "linkedin").status_code == 409
92
+ down = account(db_session, ws, "linkedin")
93
+ down.status = "expired"
94
+ db_session.commit()
95
+ r = schedule(client, auth, "linkedin", down)
96
+ assert r.status_code == 409 and "Reconnect LinkedIn" in r.json()["detail"]["message"]
97
+ assert schedule(client, auth, "x", account(db_session, ws, "x")).status_code == 200
98
+ assert schedule(client, auth, "bluesky").status_code == 200 # no API connection needed: an email reminder
99
+
100
+
101
+ def test_linkedin_scopes_are_read_with_commas_or_spaces(client, auth, ws, db_session):
102
+ acct = account(db_session, ws, "linkedin", scopes="openid profile w_member_social")
103
+ quote = made(db_session, ws, "quote_card", key="ws/q/q.png")
104
+ assert schedule(client, auth, "linkedin", acct, artifact_id=str(quote.id)).status_code == 200
105
+ got = client.get("/api/social/accounts", headers=auth).json()["accounts"][0]
106
+ assert got["can_post_media"] is True and got["needs_reconnect"] is False
107
+
108
+
109
+ # A connection that goes down pauses its posts; reconnecting resumes them
110
+
111
+
112
+ def test_disconnecting_pauses_its_posts_and_nothing_is_sent(client, auth, ws, db_session, session_factory,
113
+ monkeypatch):
114
+ from app.services import launchpad
115
+ from app.services.social import x
116
+
117
+ monkeypatch.setattr(x, "publish", lambda *a, **k: pytest.fail("a paused post was sent"))
118
+ acct = account(db_session, ws, "x")
119
+ later, due = item(db_session, ws, acct), item(db_session, ws, acct, minutes=-1)
120
+ assert client.delete(f"/api/social/accounts/{acct.id}", headers=auth).status_code == 200
121
+ db_session.expire_all()
122
+ assert db_session.get(SocialAccount, acct.id).status == "revoked"
123
+ assert statuses(db_session, later, due) == ["paused", "paused"]
124
+ assert launchpad.publish_due(session_factory) == 0 # paused posts are never claimed
125
+ got = client.get("/api/social/accounts", headers=auth).json()["accounts"][0]
126
+ assert got["needs_reconnect"] is True
127
+
128
+
129
+ def test_a_refused_connection_at_publish_pauses_all_its_posts(client, auth, ws, db_session, session_factory,
130
+ run_jobs, monkeypatch):
131
+ from app.services import launchpad
132
+ from app.services.social import x
133
+
134
+ acct = account(db_session, ws, "x")
135
+ monkeypatch.setattr(x, "_call", lambda m, url, **k: reply(401, m, url, {"title": "Unauthorized"}))
136
+ due, later = item(db_session, ws, acct, minutes=-1), item(db_session, ws, acct, minutes=90)
137
+ launchpad.publish_due(session_factory)
138
+ run_jobs()
139
+ db_session.expire_all()
140
+ assert db_session.get(SocialAccount, acct.id).status == "expired"
141
+ assert statuses(db_session, due, later) == ["paused", "paused"]
142
+ assert "Reconnect X" in db_session.get(CalendarItem, due.id).error
143
+
144
+
145
+ def test_reconnecting_resumes_future_posts_and_flags_missed_ones(client, auth, ws, db_session):
146
+ import json
147
+
148
+ from app.auth import encode_signed
149
+
150
+ acct = account(db_session, ws, "x")
151
+ ahead, missed = item(db_session, ws, acct, minutes=60), item(db_session, ws, acct, minutes=-30)
152
+ client.delete(f"/api/social/accounts/{acct.id}", headers=auth)
153
+ info = {"access_token": "new", "refresh_token": "r", "expires_in": 7200, "external_id": "p42", "handle": "ada",
154
+ "scope": X_SCOPES}
155
+ ticket = encode_signed({"typ": "social_link", "ws": str(ws.id), "platform": "x",
156
+ "data": encrypt(json.dumps(info))}, timedelta(minutes=5))
157
+ assert client.post("/api/social/complete", json={"ticket": ticket}, headers=auth).status_code == 200
158
+ db_session.expire_all()
159
+ assert db_session.get(SocialAccount, acct.id).status == "active" # the same account, back
160
+ assert statuses(db_session, ahead, missed) == ["scheduled", "paused"]
161
+ assert "pick a new time" in db_session.get(CalendarItem, missed.id).error
162
+ # The missed one goes back out once given a time still ahead.
163
+ r = client.patch(f"/api/calendar/{missed.id}", headers=auth,
164
+ json={"scheduled_at": (datetime.now(UTC) + timedelta(hours=2)).isoformat()})
165
+ assert r.status_code == 200 and r.json()["status"] == "scheduled"
166
+ r = client.patch(f"/api/calendar/{ahead.id}", json={"status": "paused"}, headers=auth) # held by hand
167
+ assert r.json()["status"] == "paused"
168
+
169
+
170
+ # Keeping connections fresh
171
+
172
+
173
+ def test_x_tokens_are_refreshed_before_they_expire(ws, db_session, session_factory, monkeypatch):
174
+ from app.services.crypto import decrypt
175
+ from app.services.social import health, x
176
+
177
+ acct = account(db_session, ws, "x", expires_in=timedelta(minutes=5), refresh="old-refresh")
178
+ sent = []
179
+ monkeypatch.setattr(x, "_token_request", lambda data: sent.append(data) or {
180
+ "access_token": "fresh", "refresh_token": "rotated", "expires_in": 7200})
181
+ monkeypatch.setattr(x, "_call", lambda m, url, **k: reply(200, m, url, {"data": {"id": "p42"}}))
182
+ assert health.social_health(session_factory) == 1
183
+ db_session.expire_all()
184
+ acct = db_session.get(SocialAccount, acct.id)
185
+ assert sent[0]["grant_type"] == "refresh_token" and decrypt(acct.access_token) == "fresh"
186
+ assert decrypt(acct.refresh_token) == "rotated" and acct.status == "active"
187
+
188
+
189
+ def test_a_refused_refresh_or_expiry_takes_the_connection_down(ws, db_session, session_factory, monkeypatch):
190
+ from app.services.social import SocialError, health, x
191
+
192
+ def refused(data):
193
+ raise SocialError("invalid_grant", permanent=True)
194
+
195
+ monkeypatch.setattr(x, "_token_request", refused)
196
+ xa = account(db_session, ws, "x", expires_in=timedelta(minutes=5), refresh="old")
197
+ li = account(db_session, ws, "linkedin", expires_in=timedelta(minutes=-1), external_id="li1") # no refresh token
198
+ posts = [item(db_session, ws, xa), item(db_session, ws, li)]
199
+ health.social_health(session_factory)
200
+ db_session.expire_all()
201
+ assert [db_session.get(SocialAccount, a.id).status for a in (xa, li)] == ["expired", "expired"]
202
+ assert statuses(db_session, *posts) == ["paused", "paused"]
203
+
204
+
205
+ def test_access_revoked_on_the_platform_is_noticed(ws, db_session, session_factory, monkeypatch):
206
+ from app.services.social import health, linkedin
207
+
208
+ acct = account(db_session, ws, "linkedin")
209
+ post = item(db_session, ws, acct)
210
+ monkeypatch.setattr(linkedin, "_call", lambda m, url, **k: reply(401, m, url, {}))
211
+ health.social_health(session_factory)
212
+ db_session.expire_all()
213
+ assert db_session.get(SocialAccount, acct.id).status == "revoked"
214
+ assert statuses(db_session, post) == ["paused"]
215
+
216
+
217
+ def test_liveness_is_asked_at_most_every_six_hours(ws, db_session, session_factory, monkeypatch):
218
+ from app.services.social import health, x
219
+
220
+ account(db_session, ws, "x")
221
+ calls = []
222
+ monkeypatch.setattr(x, "_call", lambda m, url, **k: calls.append(url) or reply(200, m, url, {"data": {}}))
223
+ health.social_health(session_factory)
224
+ health.social_health(session_factory)
225
+ assert len(calls) == 1
226
+
227
+
228
+ # On time
229
+
230
+
231
+ def test_a_due_post_goes_out_through_its_job(client, auth, ws, db_session, session_factory, run_jobs, monkeypatch):
232
+ from app.services import launchpad
233
+ from app.services.social import x
234
+
235
+ acct = account(db_session, ws, "x")
236
+ started = []
237
+ monkeypatch.setattr(x, "publish", lambda db, a, posts, media=None: started.append(
238
+ db.get(CalendarItem, due.id).updated_at) or ("t1", "https://x.com/ada/status/t1"))
239
+ due = item(db_session, ws, acct, minutes=0)
240
+ claimed_at = datetime.now(UTC)
241
+ assert launchpad.publish_due(session_factory) == 1
242
+ run_jobs()
243
+ assert statuses(db_session, due) == ["posted"]
244
+ stamp = started[0] if started[0].tzinfo else started[0].replace(tzinfo=UTC)
245
+ assert stamp >= claimed_at - timedelta(seconds=1) # the job touched it as it started
246
+
247
+
248
+ # Uploads
249
+
250
+
251
+ def test_a_video_is_streamed_to_a_temp_file_that_is_removed(ws, db_session, monkeypatch):
252
+ from app.services.social import media
253
+
254
+ video = made(db_session, ws, "video", key="ws/v/a.mp4", data=os.urandom(100_000), duration_s=30)
255
+ with media.media_for(video, "linkedin") as files:
256
+ path = files[0].path
257
+ assert os.path.exists(path) and files[0].size == 100_000 and files[0].data is None
258
+ assert files[0].read(10, 20) == files[0].read()[10:20]
259
+ assert not os.path.exists(path)
260
+ with pytest.raises(RuntimeError), media.media_for(video, "x") as files:
261
+ path = files[0].path
262
+ raise RuntimeError("upload broke")
263
+ assert not os.path.exists(path) # also on errors
264
+
265
+
266
+ def test_size_limits_fail_before_any_call(ws, db_session, monkeypatch):
267
+ from app.services.social import SocialError, media
268
+
269
+ tiny = made(db_session, ws, "video", key="ws/v/tiny.mp4", data=b"x" * 1000, duration_s=5)
270
+ with pytest.raises(SocialError, match="too small for LinkedIn"), media.media_for(tiny, "linkedin"):
271
+ pass
272
+ monkeypatch.setitem(media.SIZE_LIMITS, ("x", "video"), (1, 500))
273
+ with pytest.raises(SocialError, match="X takes up to"), media.media_for(tiny, "x"):
274
+ pass
275
+
276
+
277
+ def _linkedin(monkeypatch, part_codes: list[int]):
278
+ """A fake LinkedIn: initializeUpload with 2 parts, part PUTs answering from part_codes, then AVAILABLE."""
279
+ from app.services.social import linkedin
280
+
281
+ calls, received = [], {}
282
+ codes = iter(part_codes)
283
+
284
+ def call(method, url, **kw):
285
+ calls.append((method, url, kw))
286
+ if url.endswith("/rest/videos") and kw.get("params", {}).get("action") == "initializeUpload":
287
+ size = kw["json"]["initializeUploadRequest"]["fileSizeBytes"]
288
+ return reply(200, method, url, {"value": {"video": "urn:li:video:1", "uploadToken": "", "uploadInstructions": [
289
+ {"uploadUrl": "https://up/1", "firstByte": 0, "lastByte": 49_999},
290
+ {"uploadUrl": "https://up/2", "firstByte": 50_000, "lastByte": size - 1}]}})
291
+ if url.startswith("https://up/"):
292
+ code = next(codes, 200)
293
+ if code < 400:
294
+ received[url] = kw["content"]
295
+ return reply(code, method, url, {} if code >= 400 else None, headers={"etag": f'"e{url[-1]}"'})
296
+ if "/rest/videos/" in url:
297
+ return reply(200, method, url, {"status": "AVAILABLE"})
298
+ if url.endswith("/rest/posts"):
299
+ return reply(201, method, url, headers={"x-restli-id": "urn:li:share:9"})
300
+ return reply(200, method, url)
301
+
302
+ monkeypatch.setattr(linkedin, "_call", call)
303
+ monkeypatch.setattr(linkedin.time, "sleep", lambda s: None)
304
+ return calls, received
305
+
306
+
307
+ def test_linkedin_video_parts_retry_and_arrive_byte_identical(ws, db_session, monkeypatch):
308
+ from app.services.social import linkedin, media
309
+
310
+ data = os.urandom(90_000)
311
+ acct = account(db_session, ws, "linkedin")
312
+ video = made(db_session, ws, "video", key="ws/v/b.mp4", data=data, duration_s=30)
313
+ calls, received = _linkedin(monkeypatch, [503, 200, 200]) # the first part fails once
314
+ with media.media_for(video, "linkedin") as files:
315
+ linkedin.publish(db_session, acct, ["Watch"], files)
316
+ assert received["https://up/1"] + received["https://up/2"] == data
317
+ part_puts = [c for c in calls if c[1].startswith("https://up/")]
318
+ assert len(part_puts) == 3 and all("Authorization" not in c[2].get("headers", {}) for c in part_puts)
319
+
320
+
321
+ def test_linkedin_retired_api_version_is_permanent(ws, db_session, monkeypatch):
322
+ from app.services.social import SocialError, linkedin
323
+
324
+ acct = account(db_session, ws, "linkedin")
325
+ monkeypatch.setattr(linkedin, "_call", lambda m, url, **k: reply(426, m, url, {"code": "NONEXISTENT_VERSION"}))
326
+ with pytest.raises(SocialError, match="retired API version") as err:
327
+ linkedin.publish(db_session, acct, ["Hi"], [])
328
+ assert err.value.permanent and not err.value.reconnect
329
+
330
+
331
+ def test_x_video_processing_failure_is_permanent(ws, db_session, monkeypatch):
332
+ from app.services.social import SocialError, media, x
333
+
334
+ acct = account(db_session, ws, "x")
335
+ video = made(db_session, ws, "video", key="ws/v/c.mp4", data=os.urandom(100_000), duration_s=30)
336
+
337
+ def call(method, url, **kw):
338
+ path = url.split("/2/", 1)[1]
339
+ if path == "media/upload/initialize":
340
+ return reply(200, method, url, {"data": {"id": "v1"}})
341
+ if path.endswith("/append"):
342
+ return reply(204, method, url)
343
+ if path.endswith("/finalize"):
344
+ return reply(200, method, url, {"data": {"processing_info": {"state": "pending", "check_after_secs": 1}}})
345
+ return reply(200, method, url, {"data": {"processing_info": {"state": "failed",
346
+ "error": {"message": "Unsupported video"}}}})
347
+
348
+ monkeypatch.setattr(x, "_call", call)
349
+ monkeypatch.setattr(x.time, "sleep", lambda s: None)
350
+ with pytest.raises(SocialError, match="Unsupported video") as err, media.media_for(video, "x") as files:
351
+ x.publish(db_session, acct, ["Watch"], files)
352
+ assert err.value.permanent
353
+
354
+
355
+ def test_unfinished_blog2video_video_is_refused_when_scheduled(client, auth, ws, db_session):
356
+ acct = account(db_session, ws, "x")
357
+ video = Artifact(workspace_id=ws.id, type="video", status="processing",
358
+ content_json={"title": "Draft", "provider": "blog2video"})
359
+ db_session.add(video)
360
+ db_session.commit()
361
+ r = schedule(client, auth, "x", acct, artifact_id=str(video.id))
362
+ assert r.status_code == 400 and "isn't finished" in r.json()["detail"]
363
+ assert uuid.UUID(str(video.id))
364
+
365
+
366
+ def test_a_video_being_rendered_stays_listed_as_rendering(client, auth, ws, db_session):
367
+ video = Artifact(workspace_id=ws.id, type="video", status="rendering",
368
+ content_json={"title": "Mid render", "provider": "blog2video"})
369
+ db_session.add(video)
370
+ db_session.commit()
371
+ listed = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}
372
+ item = listed[str(video.id)]
373
+ assert item["needs_render"] and item["rendering"]
374
+
375
+
376
+ def test_a_kit_is_tracked_apart_and_never_attached(client, auth, ws, db_session):
377
+ acct = account(db_session, ws, "x")
378
+ kit = made(db_session, ws, "launch_kit", post_title="The Boring Portfolio", posts={"x_thread": ["One", "Two"]})
379
+ video = made(db_session, ws, "video", key="ws/v/k.mp4", data=os.urandom(100_000), duration_s=30)
380
+ r = schedule(client, auth, "x", acct, artifact_id=str(kit.id))
381
+ assert r.status_code == 400 and "can't be attached" in r.json()["detail"]
382
+ r = schedule(client, auth, "x", acct, kit_id=str(kit.id), artifact_id=str(video.id))
383
+ assert r.status_code == 200
384
+ got = r.json()
385
+ assert got["kit_id"] == str(kit.id) and got["kit_title"] == "The Boring Portfolio"
386
+ assert got["artifact"]["type"] == "video" # the attachment is the video, not the kit
387
+ assert schedule(client, auth, "x", acct, kit_id=str(video.id)).status_code == 404 # only a Launch Kit is a kit
388
+
389
+
390
+ def test_a_time_picked_in_the_users_zone_is_stored_in_utc(client, auth, ws, db_session):
391
+ acct = account(db_session, ws, "linkedin", scopes=LI_SCOPES)
392
+ year = datetime.now(UTC).year + 1
393
+ r = schedule(client, auth, "linkedin", acct, local_time=f"{year}-10-07T11:00", timezone="Asia/Karachi")
394
+ assert r.status_code == 200, r.text
395
+ assert datetime.fromisoformat(r.json()["scheduled_at"]) == datetime(year, 10, 7, 6, 0, tzinfo=UTC)
396
+ # New York is UTC-4 in summer and UTC-5 in winter: the same wall time lands an hour apart.
397
+ summer = schedule(client, auth, "linkedin", acct, local_time=f"{year}-07-01T09:30", timezone="America/New_York")
398
+ winter = schedule(client, auth, "linkedin", acct, local_time=f"{year}-12-01T09:30", timezone="America/New_York")
399
+ assert datetime.fromisoformat(summer.json()["scheduled_at"]) == datetime(year, 7, 1, 13, 30, tzinfo=UTC)
400
+ assert datetime.fromisoformat(winter.json()["scheduled_at"]) == datetime(year, 12, 1, 14, 30, tzinfo=UTC)
401
+ # Moving it keeps to the user's zone too.
402
+ item_id = r.json()["id"]
403
+ moved = client.patch(f"/api/calendar/{item_id}", json={"local_time": f"{year}-10-08T00:30", "timezone": "Asia/Karachi"},
404
+ headers=auth)
405
+ assert datetime.fromisoformat(moved.json()["scheduled_at"]) == datetime(year, 10, 7, 19, 30, tzinfo=UTC)
406
+
407
+
408
+ def test_only_half_hours_in_a_known_zone_are_taken(client, auth, ws, db_session):
409
+ acct = account(db_session, ws, "linkedin", scopes=LI_SCOPES)
410
+ year = datetime.now(UTC).year + 1
411
+ off = schedule(client, auth, "linkedin", acct, local_time=f"{year}-10-07T11:15", timezone="Asia/Karachi")
412
+ assert off.status_code == 400 and off.json()["detail"]["code"] == "half_hour_only"
413
+ bad = schedule(client, auth, "linkedin", acct, local_time=f"{year}-10-07T11:00", timezone="Mars/Olympus")
414
+ assert bad.status_code == 400 and bad.json()["detail"]["code"] == "bad_timezone"
415
+ assert schedule(client, auth, "linkedin", acct).status_code == 200 # an exact scheduled_at (Post now) still works
416
+
417
+
418
+ def test_the_publisher_runs_on_the_half_hour():
419
+ from app.worker import ALIGN_GRACE_SECONDS, Periodic
420
+
421
+ task = Periodic("publish_due", 1800, lambda: None, align=True)
422
+ wall = datetime(2026, 10, 7, 6, 10, tzinfo=UTC).timestamp() # 06:10 UTC: the next run is at 06:30
423
+ task.schedule_next(100.0, wall)
424
+ assert task.next_at == 100.0 + 20 * 60 + ALIGN_GRACE_SECONDS
425
+
426
+
427
+ def uploaded(db, ws, filename, content_type, data, status="complete"):
428
+ """A file the user uploaded through /api/storage/uploads, in storage and recorded."""
429
+ from app.models import Upload, User
430
+ from app.services.storage import keys, storage
431
+
432
+ upload_id = uuid.uuid4()
433
+ key = keys.upload(ws.id, upload_id, filename)
434
+ storage.put_bytes(key, data)
435
+ up = Upload(id=upload_id, workspace_id=ws.id, user_id=db.query(User).first().id, key=key, filename=filename,
436
+ content_type=content_type, size_bytes=len(data), status=status)
437
+ db.add(up)
438
+ db.commit()
439
+ return up
440
+
441
+
442
+ def test_a_file_from_the_computer_is_attached_and_posted(client, auth, ws, db_session):
443
+ from app.services.social import media
444
+
445
+ acct = account(db_session, ws, "linkedin")
446
+ png = uploaded(db_session, ws, "chart.png", "image/png", b"\x89PNG-chart")
447
+ r = client.post("/api/launchpad/uploads", json={"upload_id": str(png.id)}, headers=auth)
448
+ assert r.status_code == 200, r.text
449
+ brief = r.json()
450
+ assert brief["type"] == "upload" and brief["media"] == "image" and brief["title"] == "chart"
451
+ assert brief["thumb_url"] and brief["prefill"]["linkedin"] == ""
452
+ listed = {a["id"] for a in client.get("/api/launchpad/postable", headers=auth).json()}
453
+ assert brief["id"] in listed
454
+ assert schedule(client, auth, "linkedin", acct, artifact_id=brief["id"]).status_code == 200
455
+ art = db_session.get(Artifact, uuid.UUID(brief["id"]))
456
+ with media.media_for(art, "linkedin") as files:
457
+ assert [(f.kind, f.content_type, f.read()) for f in files] == [("image", "image/png", b"\x89PNG-chart")]
458
+ # Not one of the things Notestack made: kept out of the general artifact list.
459
+ assert brief["id"] not in {a["id"] for a in client.get("/api/artifacts", headers=auth).json()["items"]}
460
+
461
+
462
+ def test_an_uploaded_video_goes_out_from_a_temporary_file(client, auth, ws, db_session):
463
+ from app.services.social import media
464
+
465
+ mp4 = uploaded(db_session, ws, "clip.mp4", "video/mp4", b"v" * (80 * 1024))
466
+ r = client.post("/api/launchpad/uploads", json={"upload_id": str(mp4.id), "duration_s": 42}, headers=auth)
467
+ assert r.status_code == 200 and r.json()["media"] == "video" and r.json()["duration_s"] == 42
468
+ art = db_session.get(Artifact, uuid.UUID(r.json()["id"]))
469
+ with media.media_for(art, "linkedin") as files:
470
+ assert files[0].kind == "video" and files[0].path and files[0].size == 80 * 1024
471
+
472
+
473
+ def test_only_files_linkedin_takes_can_be_uploaded(client, auth, ws, db_session):
474
+ def register(up):
475
+ return client.post("/api/launchpad/uploads", json={"upload_id": str(up.id)}, headers=auth)
476
+
477
+ assert register(uploaded(db_session, ws, "doc.pdf", "application/pdf", b"%PDF")).status_code == 415
478
+ assert register(uploaded(db_session, ws, "half.png", "image/png", b"x", status="pending")).status_code == 409
479
+ big = uploaded(db_session, ws, "huge.png", "image/png", b"x")
480
+ big.size_bytes = 40 * 1024 * 1024
481
+ db_session.commit()
482
+ assert register(big).status_code == 413
483
+ assert client.post("/api/launchpad/uploads", json={"upload_id": str(uuid.uuid4())}, headers=auth).status_code == 404
484
+
485
+
486
+ def test_deleting_an_upload_record_removes_its_file(db_session, ws):
487
+ from app.services.storage import storage
488
+
489
+ up = uploaded(db_session, ws, "gone.png", "image/png", b"PNG")
490
+ kept = uploaded(db_session, ws, "kept.png", "image/png", b"PNG")
491
+ db_session.delete(kept)
492
+ db_session.rollback() # a delete that never commits leaves the file alone
493
+ assert storage.exists(kept.key)
494
+ db_session.delete(up)
495
+ db_session.commit()
496
+ assert not storage.exists(up.key)
497
+
498
+
499
+ def test_deleting_an_uploaded_file_from_the_library_removes_record_and_file(client, auth, ws, db_session):
500
+ from app.models import Upload
501
+ from app.services.storage import storage
502
+
503
+ up = uploaded(db_session, ws, "pic.png", "image/png", b"PNG")
504
+ art = client.post("/api/launchpad/uploads", json={"upload_id": str(up.id)}, headers=auth).json()
505
+ assert client.delete(f"/api/artifacts/{art['id']}", headers=auth).status_code == 200
506
+ db_session.expire_all()
507
+ assert db_session.get(Upload, up.id) is None and not storage.exists(up.key)
508
+
509
+
510
+ def test_the_daily_sweep_removes_files_with_no_record(db_session, ws, session_factory):
511
+ from app.models import Upload
512
+ from app.services.storage import keys, storage
513
+ from app.services.uploads import sweep_orphan_uploads
514
+
515
+ old = datetime.now(UTC).timestamp() - 3 * 24 * 3600
516
+ orphan = keys.upload(ws.id, uuid.uuid4(), "orphan.png")
517
+ fresh = keys.upload(ws.id, uuid.uuid4(), "fresh.png") # may still be on its way: kept
518
+ storage.put_bytes(orphan, b"x")
519
+ storage.put_bytes(fresh, b"x")
520
+ os.utime(storage.path(orphan), (old, old))
521
+ known = uploaded(db_session, ws, "known.png", "image/png", b"x")
522
+ os.utime(storage.path(known.key), (old, old))
523
+ stale = uploaded(db_session, ws, "stale.png", "image/png", b"x", status="pending")
524
+ stale.created_at = datetime.now(UTC) - timedelta(days=2)
525
+ db_session.commit()
526
+ stale_id, stale_key = stale.id, stale.key
527
+
528
+ assert sweep_orphan_uploads(session_factory) == 1
529
+ assert not storage.exists(orphan) and storage.exists(fresh) and storage.exists(known.key)
530
+ db_session.expire_all()
531
+ assert db_session.get(Upload, stale_id) is None and not storage.exists(stale_key)
532
+
533
+
534
+ def test_an_uploaded_file_can_be_renamed_but_not_retyped(client, auth, ws, db_session):
535
+ up = uploaded(db_session, ws, "IMG_2041.png", "image/png", b"PNG")
536
+ art = client.post("/api/launchpad/uploads", json={"upload_id": str(up.id)}, headers=auth).json()
537
+ r = client.patch(f"/api/artifacts/{art['id']}", json={"title": " Q3 chart ",
538
+ "content": {"content_type": "video/mp4", "media": "video"}},
539
+ headers=auth)
540
+ assert r.status_code == 200
541
+ listed = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}[art["id"]]
542
+ assert listed["title"] == "Q3 chart" and listed["media"] == "image"
543
+ assert client.patch(f"/api/artifacts/{art['id']}", json={"title": " "}, headers=auth).status_code == 400
544
+
545
+
546
+ def test_uploaded_files_carry_a_preview_link(client, auth, ws, db_session):
547
+ png = uploaded(db_session, ws, "a.png", "image/png", b"PNG")
548
+ mp4 = uploaded(db_session, ws, "b.mp4", "video/mp4", b"v" * (80 * 1024))
549
+ for up in (png, mp4):
550
+ brief = client.post("/api/launchpad/uploads", json={"upload_id": str(up.id)}, headers=auth).json()
551
+ assert brief["view_url"]
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
frontend/src/App.tsx CHANGED
@@ -8,8 +8,8 @@ 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 from "./pages/Launchpad";
12
- import LaunchKit from "./pages/LaunchKit";
13
  import MissionControl from "./pages/MissionControl";
14
  import Notebooks from "./pages/Notebooks";
15
  import NotebookLMAlternative from "./pages/NotebookLMAlternative";
@@ -66,8 +66,11 @@ export default function App() {
66
  <Route path="map" element={<TopicMap />} />
67
  <Route path="voice" element={<VoiceRedirect />} />
68
  <Route path="studio" element={<Studio />} />
69
- <Route path="launch-kit" element={<LaunchKit />} />
70
  <Route path="launchpad" element={<Launchpad />} />
 
 
 
71
  <Route path="archive" element={<Archive />} />
72
  <Route path="resurface" element={<Resurface />} />
73
  <Route path="settings" element={<Settings />} />
 
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";
14
  import Notebooks from "./pages/Notebooks";
15
  import NotebookLMAlternative from "./pages/NotebookLMAlternative";
 
66
  <Route path="map" element={<TopicMap />} />
67
  <Route path="voice" element={<VoiceRedirect />} />
68
  <Route path="studio" element={<Studio />} />
69
+ <Route path="launch-kit" element={<LaunchKitRedirect />} />
70
  <Route path="launchpad" element={<Launchpad />} />
71
+ <Route path="launchpad/new" element={<ScheduleLaunch />} />
72
+ <Route path="launchpad/kits" element={<LaunchKits />} />
73
+ <Route path="launchpad/kits/:id" element={<LaunchKitPage />} />
74
  <Route path="archive" element={<Archive />} />
75
  <Route path="resurface" element={<Resurface />} />
76
  <Route path="settings" element={<Settings />} />
frontend/src/api/client.ts CHANGED
@@ -146,7 +146,9 @@ const TYPES_BY_EXT: Record<string, string> = {
146
  jpg: "image/jpeg",
147
  jpeg: "image/jpeg",
148
  webp: "image/webp",
 
149
  svg: "image/svg+xml",
 
150
  };
151
 
152
  /** Browsers leave File.type empty for some extensions (.md on Windows) and use vendor names for others. */
 
146
  jpg: "image/jpeg",
147
  jpeg: "image/jpeg",
148
  webp: "image/webp",
149
+ gif: "image/gif",
150
  svg: "image/svg+xml",
151
+ mp4: "video/mp4",
152
  };
153
 
154
  /** Browsers leave File.type empty for some extensions (.md on Windows) and use vendor names for others. */
frontend/src/api/endpoints.ts CHANGED
@@ -17,6 +17,7 @@ import type {
17
  WorkspaceChat,
18
  Page,
19
  Platform,
 
20
  Quota,
21
  ResurfaceItem,
22
  Settings,
@@ -181,10 +182,16 @@ export const voiceApi = {
181
 
182
  export type ItemBody = {
183
  platform: Platform;
184
- scheduled_at: string;
 
 
 
 
185
  content: string;
186
  thread?: string[];
187
  artifact_id?: string | null;
 
 
188
  document_id?: string | null;
189
  social_account_id?: string | null;
190
  remind_by_email?: boolean;
@@ -204,6 +211,13 @@ export const launchpadApi = {
204
  startOAuth: (platform: "x" | "linkedin") => post<{ url: string }>(`/api/social/${platform}/start`),
205
  completeOAuth: (ticket: string) => post<SocialAccounts>("/api/social/complete", { ticket }),
206
  disconnect: (id: string) => del(`/api/social/accounts/${id}`),
 
 
 
 
 
 
 
207
  };
208
 
209
  export const resurfaceApi = {
 
17
  WorkspaceChat,
18
  Page,
19
  Platform,
20
+ PostableArtifact,
21
  Quota,
22
  ResurfaceItem,
23
  Settings,
 
182
 
183
  export type ItemBody = {
184
  platform: Platform;
185
+ /** An exact instant (Post now). Scheduled posts send local_time + timezone instead. */
186
+ scheduled_at?: string;
187
+ /** The half hour picked on the user's own clock, "2026-10-07T11:00", read in `timezone` (IANA) by the backend. */
188
+ local_time?: string;
189
+ timezone?: string;
190
  content: string;
191
  thread?: string[];
192
  artifact_id?: string | null;
193
+ /** The Launch Kit the post was written from (tracked, not attached). */
194
+ kit_id?: string | null;
195
  document_id?: string | null;
196
  social_account_id?: string | null;
197
  remind_by_email?: boolean;
 
211
  startOAuth: (platform: "x" | "linkedin") => post<{ url: string }>(`/api/social/${platform}/start`),
212
  completeOAuth: (ticket: string) => post<SocialAccounts>("/api/social/complete", { ticket }),
213
  disconnect: (id: string) => del(`/api/social/accounts/${id}`),
214
+ /** What can be attached to a post (finished videos, quote cards, carousels, summaries, launch kits; not audio). */
215
+ postable: (q = "") => api<PostableArtifact[]>(`/api/launchpad/postable${qs({ q: q || undefined })}`),
216
+ /** A file from the user's computer, sent to storage: make it postable (an `upload` in the picker). */
217
+ uploadToPost: async (file: File, duration_s?: number) => {
218
+ const { upload_id } = await uploadFile(file);
219
+ return post<PostableArtifact>("/api/launchpad/uploads", { upload_id, duration_s });
220
+ },
221
  };
222
 
223
  export const resurfaceApi = {
frontend/src/api/types.ts CHANGED
@@ -89,7 +89,8 @@ export type AnswerFeedback = { rating: "up" | "down"; reasons: string[]; comment
89
  export type WorkspaceChat = ChatSummary & { notebook_id: string; notebook_title: string };
90
  export type ChatMessage = { id: string; role: "user" | "assistant"; text: string; citations: Citation[]; feedback?: AnswerFeedback | null };
91
 
92
- export type ArtifactType = "summary" | "audio_overview" | "video" | "quote_card" | "carousel" | "launch_kit" | "mind_map";
 
93
 
94
  export type SourceRef = { path: string; line_start: number; line_end: number; title?: string | null; quote?: string };
95
 
@@ -257,15 +258,44 @@ export type Quota = {
257
 
258
  export type Platform = "x" | "linkedin" | "bluesky" | "substack_notes";
259
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  export type CalendarItem = {
261
  id: string;
262
  platform: Platform;
263
  platform_label: string;
264
  scheduled_at: string;
265
- status: "scheduled" | "publishing" | "posted" | "reminded" | "failed" | "draft";
 
266
  content: string;
267
  thread: string[];
268
  artifact_id: string | null;
 
 
 
 
 
269
  document_id: string | null;
270
  social_account_id: string | null;
271
  account_handle: string | null;
@@ -286,6 +316,12 @@ export type SocialAccount = {
286
  status: string;
287
  avatar_url: string | null;
288
  connected_at: string | null;
 
 
 
 
 
 
289
  };
290
 
291
  export type SocialAccounts = {
 
89
  export type WorkspaceChat = ChatSummary & { notebook_id: string; notebook_title: string };
90
  export type ChatMessage = { id: string; role: "user" | "assistant"; text: string; citations: Citation[]; feedback?: AnswerFeedback | null };
91
 
92
+ export type ArtifactType = "summary" | "audio_overview" | "video" | "quote_card" | "carousel" | "launch_kit" | "mind_map"
93
+ | "upload"; // a file the user brought from their computer, to post
94
 
95
  export type SourceRef = { path: string; line_start: number; line_end: number; title?: string | null; quote?: string };
96
 
 
258
 
259
  export type Platform = "x" | "linkedin" | "bluesky" | "substack_notes";
260
 
261
+ /** Something made that a post can carry: images (quote card, carousel slides), a video, or text only. */
262
+ export type PostableArtifact = {
263
+ id: string;
264
+ type: ArtifactType;
265
+ type_label: string;
266
+ title: string;
267
+ media: "image" | "video" | "text";
268
+ media_count: number;
269
+ duration_s: number | null;
270
+ thumb_url: string | null;
271
+ /** A video made but not rendered to an MP4 yet: it can be posted once rendered (in the video editor). */
272
+ needs_render: boolean;
273
+ /** Its MP4 is being rendered: it can be scheduled once that's done. */
274
+ rendering: boolean;
275
+ /** A Notestack (blog2video) video: it opens in the video editor. */
276
+ editable_video: boolean;
277
+ /** An uploaded file's link, to preview it (null for anything else). */
278
+ view_url: string | null;
279
+ created_at: string | null;
280
+ /** A starting text: X as a thread (list of posts), LinkedIn as one post. */
281
+ prefill: { x: string[]; linkedin: string };
282
+ };
283
+
284
  export type CalendarItem = {
285
  id: string;
286
  platform: Platform;
287
  platform_label: string;
288
  scheduled_at: string;
289
+ /** paused: its account's connection is down (or held by hand); never attempted until resumed. */
290
+ status: "scheduled" | "publishing" | "posted" | "reminded" | "failed" | "draft" | "paused";
291
  content: string;
292
  thread: string[];
293
  artifact_id: string | null;
294
+ /** What the post carries (its images or video go out with it). */
295
+ artifact: PostableArtifact | null;
296
+ /** The Launch Kit the post was written from (tracked apart; never an attachment). */
297
+ kit_id: string | null;
298
+ kit_title: string | null;
299
  document_id: string | null;
300
  social_account_id: string | null;
301
  account_handle: string | null;
 
316
  status: string;
317
  avatar_url: string | null;
318
  connected_at: string | null;
319
+ /** Down (expired, revoked, disconnected), or not allowed to post images and videos. */
320
+ needs_reconnect: boolean;
321
+ can_post_media: boolean;
322
+ /** No refresh token and the connection ends within a week (LinkedIn's 60 day tokens). */
323
+ expires_soon: boolean;
324
+ expires_at: string | null;
325
  };
326
 
327
  export type SocialAccounts = {
frontend/src/components/AppShell.tsx CHANGED
@@ -19,7 +19,7 @@ const NAV: { to: string; label: string; icon: typeof PlanetIcon; end?: boolean;
19
  { to: "/app/notebooks", label: "Notebooks", icon: PlanetIcon },
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"] },
23
  ];
24
 
25
  const COLLAPSED_KEY = "ns_sidebar_collapsed";
 
19
  { to: "/app/notebooks", label: "Notebooks", icon: PlanetIcon },
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";
frontend/src/components/ArtifactCard.tsx CHANGED
@@ -161,7 +161,7 @@ export function Body({ artifact, onCite }: { artifact: Artifact; onCite?: (c: Ci
161
  <p className="muted">
162
  {c.claims?.length ?? 0} claims, {c.hooks?.length ?? 0} hooks, posts for 4 platforms, SEO pack and carousel.
163
  </p>
164
- <Link className="btn btn-small" to={`/app/launch-kit?kit=${artifact.id}`}>
165
  Open Launch Kit
166
  </Link>
167
  </div>
 
161
  <p className="muted">
162
  {c.claims?.length ?? 0} claims, {c.hooks?.length ?? 0} hooks, posts for 4 platforms, SEO pack and carousel.
163
  </p>
164
+ <Link className="btn btn-small" to={`/app/launchpad/kits/${artifact.id}`}>
165
  Open Launch Kit
166
  </Link>
167
  </div>
frontend/src/components/ScheduleModal.tsx CHANGED
@@ -1,6 +1,9 @@
1
  import { useEffect, useState, type FormEvent } from "react";
 
2
  import { launchpadApi } from "../api/endpoints";
3
- import type { CalendarItem, Platform, SocialAccount } from "../api/types";
 
 
4
  import { errorMessage, Modal } from "./ui";
5
 
6
  export const PLATFORMS: { id: Platform; label: string; limit: number; auto: boolean }[] = [
@@ -10,22 +13,27 @@ export const PLATFORMS: { id: Platform; label: string; limit: number; auto: bool
10
  { id: "substack_notes", label: "Substack Notes", limit: 2000, auto: false },
11
  ];
12
 
13
- export function toLocalInput(date: Date): string {
14
- const pad = (n: number) => String(n).padStart(2, "0");
15
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
16
- }
 
 
 
17
 
18
- export function nextSlot(): Date {
19
- const d = new Date(Date.now() + 60 * 60 * 1000);
20
- d.setMinutes(0, 0, 0);
21
- return d;
22
- }
23
 
24
- /** Schedules one post (or a thread) on the Launchpad. Posts auto publish when an account is connected. */
 
25
  export function ScheduleModal({
26
  platform: initialPlatform,
27
  posts,
28
  artifactId,
 
 
 
29
  documentId,
30
  onClose,
31
  onScheduled,
@@ -33,134 +41,253 @@ export function ScheduleModal({
33
  platform: Platform;
34
  posts: string[];
35
  artifactId?: string | null;
 
 
 
 
 
36
  documentId?: string | null;
37
  onClose: () => void;
38
  onScheduled?: (item: CalendarItem) => void;
39
  }) {
40
- const [platform, setPlatform] = useState<Platform>(initialPlatform);
41
- const [texts, setTexts] = useState<string[]>(posts.length ? posts : [""]);
42
- const [when, setWhen] = useState(toLocalInput(nextSlot()));
43
- const [accounts, setAccounts] = useState<SocialAccount[]>([]);
44
- const [accountId, setAccountId] = useState<string>("");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  const [remind, setRemind] = useState(false);
46
- const [busy, setBusy] = useState(false);
47
  const [error, setError] = useState<string | null>(null);
48
- const meta = PLATFORMS.find((p) => p.id === platform)!;
49
- const threadable = platform === "x" || platform === "bluesky";
50
 
51
  useEffect(() => {
52
- launchpadApi.accounts().then((a) => setAccounts(a.accounts), () => setAccounts([]));
 
 
 
 
 
 
 
53
  }, []);
54
 
55
- const options = accounts.filter((a) => a.platform === platform && a.status === "active");
56
- useEffect(() => {
57
- setAccountId(options[0]?.id ?? "");
58
- // eslint-disable-next-line react-hooks/exhaustive-deps
59
- }, [platform, accounts.length]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
- const submit = async (e: FormEvent) => {
62
- e.preventDefault();
63
- setBusy(true);
 
 
 
64
  setError(null);
65
- const clean = texts.map((t) => t.trim()).filter(Boolean);
66
- const merged = threadable ? clean : [clean.join("\n\n")];
67
  try {
68
- const item = await launchpadApi.create({
69
- platform,
70
- scheduled_at: new Date(when).toISOString(),
71
- content: merged[0] ?? "",
72
- thread: merged.slice(1),
73
- artifact_id: artifactId ?? null,
74
- document_id: documentId ?? null,
75
- social_account_id: meta.auto && accountId ? accountId : null,
76
- remind_by_email: remind || !meta.auto || !accountId,
77
- });
78
- onScheduled?.(item);
 
 
 
 
 
 
 
 
 
 
 
79
  onClose();
80
  } catch (err) {
81
- setError(errorMessage(err));
82
  } finally {
83
- setBusy(false);
84
  }
85
  };
86
 
87
  return (
88
- <Modal title="Schedule on the Launchpad" onClose={onClose} wide>
89
- <form onSubmit={submit} className="stack">
90
- <div className="row">
91
- <label className="field">
92
- <span>Platform</span>
93
- <select className="input input-sm" value={platform} onChange={(e) => setPlatform(e.target.value as Platform)}>
94
- {PLATFORMS.map((p) => (
95
- <option key={p.id} value={p.id}>
 
 
96
  {p.label}
97
- </option>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  ))}
99
- </select>
100
- </label>
101
- <label className="field">
102
- <span>When</span>
103
- <input className="input input-sm" type="datetime-local" required value={when} onChange={(e) => setWhen(e.target.value)} />
104
- </label>
105
- {meta.auto && (
106
- <label className="field">
107
- <span>Account</span>
108
- <select className="input input-sm" value={accountId} onChange={(e) => setAccountId(e.target.value)}>
109
- <option value="">Email me a reminder instead</option>
110
- {options.map((a) => (
111
- <option key={a.id} value={a.id}>
112
- {a.handle}
113
- </option>
114
- ))}
115
- </select>
 
 
 
 
 
 
 
 
116
  </label>
117
  )}
118
  </div>
119
- {!meta.auto && <p className="muted">Substack has no posting API, so we email you the note at send time, ready to paste.</p>}
120
- {meta.auto && !options.length && (
121
- <p className="muted">
122
- No {meta.label} account connected. Connect one on the Launchpad to auto post; until then you get an email reminder.
123
- </p>
124
- )}
125
- {texts.map((t, i) => (
126
- <label key={i} className="field">
127
- <span className="field-row">
128
- <span>{threadable && texts.length > 1 ? `Post ${i + 1}` : "Post"}</span>
129
- <span className={`mono ${t.length > meta.limit ? "over" : "muted"}`}>
130
- {t.length} / {meta.limit}
131
- </span>
132
- </span>
133
- <textarea className="textarea" rows={threadable ? 3 : 8} value={t} onChange={(e) => setTexts(texts.map((x, j) => (j === i ? e.target.value : x)))} />
134
- </label>
135
- ))}
136
- {threadable && (
137
- <div className="row">
138
- <button type="button" className="btn btn-small" onClick={() => setTexts([...texts, ""])}>
139
- Add post to thread
140
- </button>
141
- {texts.length > 1 && (
142
- <button type="button" className="btn btn-small" onClick={() => setTexts(texts.slice(0, -1))}>
143
- Remove last
144
- </button>
145
- )}
146
- </div>
147
- )}
148
- {meta.auto && accountId && (
149
- <label className="check">
150
- <input type="checkbox" checked={remind} onChange={(e) => setRemind(e.target.checked)} />
151
- Email me instead of posting automatically
152
- </label>
153
- )}
154
  {error && <p className="error-text">{error}</p>}
155
  <div className="row end">
156
- <button type="button" className="btn" onClick={onClose}>
157
- Cancel
 
 
158
  </button>
159
- <button className="btn btn-primary" disabled={busy}>
160
- {busy ? "Scheduling..." : "Schedule"}
161
  </button>
162
  </div>
163
  </form>
 
 
 
 
 
 
 
164
  </Modal>
165
  );
166
  }
 
1
  import { useEffect, useState, type FormEvent } from "react";
2
+ import { useNavigate } from "react-router-dom";
3
  import { launchpadApi } from "../api/endpoints";
4
+ import type { CalendarItem, Platform, PostableArtifact, SocialAccounts } from "../api/types";
5
+ import { AttachmentChip, AttachPicker } from "./launchpad/Attachment";
6
+ import { nextSlot, SlotPicker, toWall, userTimeZone, wallToDate } from "./launchpad/SlotPicker";
7
  import { errorMessage, Modal } from "./ui";
8
 
9
  export const PLATFORMS: { id: Platform; label: string; limit: number; auto: boolean }[] = [
 
13
  { id: "substack_notes", label: "Substack Notes", limit: 2000, auto: false },
14
  ];
15
 
16
+ /** Only X and LinkedIn take images and videos; the others get the text. */
17
+ const MEDIA_PLATFORMS: Platform[] = ["x", "linkedin"];
18
+ const THREADED: Platform[] = ["x", "bluesky"];
19
+ /** These post only through a live connection: no email fallback. */
20
+ const CONNECTED_ONLY: Platform[] = ["x", "linkedin"];
21
+ /** What the composer offers for now: LinkedIn only (X posting is switched off). */
22
+ const COMPOSER_PLATFORMS: Platform[] = ["linkedin"];
23
 
24
+ /** The attachment's starting text for a platform: a thread for X and Bluesky, one post otherwise. */
25
+ const prefillFor = (p: Platform, a: PostableArtifact): string[] =>
26
+ THREADED.includes(p) ? [...a.prefill.x] : [a.prefill.linkedin];
 
 
27
 
28
+ /** The composer: one post to any of X, LinkedIn, Bluesky and Substack Notes (one calendar item each), with
29
+ * something from the Library attached (its images or video go to X and LinkedIn). Scheduled or posted now. */
30
  export function ScheduleModal({
31
  platform: initialPlatform,
32
  posts,
33
  artifactId,
34
+ artifact,
35
+ kitId,
36
+ kitTitle,
37
  documentId,
38
  onClose,
39
  onScheduled,
 
41
  platform: Platform;
42
  posts: string[];
43
  artifactId?: string | null;
44
+ /** The attachment, when the caller already has it (else it is looked up from artifactId). */
45
+ artifact?: PostableArtifact | null;
46
+ /** The Launch Kit the post is written from: tracked with it, never attached. */
47
+ kitId?: string | null;
48
+ kitTitle?: string | null;
49
  documentId?: string | null;
50
  onClose: () => void;
51
  onScheduled?: (item: CalendarItem) => void;
52
  }) {
53
+ // One platform per post. A caller asking for one the composer doesn't offer starts on LinkedIn with that text.
54
+ const first: Platform = COMPOSER_PLATFORMS.includes(initialPlatform) ? initialPlatform : "linkedin";
55
+ const [selected, setSelected] = useState<Platform[]>([first]);
56
+ const [texts, setTexts] = useState<Partial<Record<Platform, string[]>>>(() => ({
57
+ [first]: posts.length ? posts : artifact ? prefillFor(first, artifact) : [""],
58
+ }));
59
+ const [attached, setAttached] = useState<PostableArtifact | null>(artifact?.type === "launch_kit" ? null : artifact ?? null);
60
+ const navigate = useNavigate();
61
+ const [picking, setPicking] = useState(false);
62
+ const [when, setWhen] = useState(() => toWall(nextSlot()));
63
+ const [accounts, setAccounts] = useState<SocialAccounts | null>(null);
64
+ // The connections are checked before anything says one is missing: loading isn't "not connected".
65
+ const [accountsState, setAccountsState] = useState<"loading" | "ready" | "error">("loading");
66
+ const loadAccounts = () => {
67
+ setAccountsState("loading");
68
+ launchpadApi.accounts().then((a) => {
69
+ setAccounts(a);
70
+ setAccountsState("ready");
71
+ }, () => setAccountsState("error"));
72
+ };
73
  const [remind, setRemind] = useState(false);
74
+ const [busy, setBusy] = useState<"schedule" | "now" | null>(null);
75
  const [error, setError] = useState<string | null>(null);
 
 
76
 
77
  useEffect(() => {
78
+ loadAccounts();
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
86
  }, []);
87
 
88
+ const accountFor = (p: Platform) =>
89
+ accounts?.accounts.find((a) => a.platform === p && a.status === "active") ?? null;
90
+ const carriesMedia = !!attached && attached.media !== "text";
91
+ /** Why a ticked X / LinkedIn can't be scheduled yet (no connection, connection down, can't post media). */
92
+ const blocker = (p: Platform): string | null => {
93
+ if (!CONNECTED_ONLY.includes(p) || accountsState !== "ready") return null;
94
+ const label = PLATFORMS.find((x) => x.id === p)!.label;
95
+ const any = accounts?.accounts.find((a) => a.platform === p);
96
+ const live = accountFor(p);
97
+ if (!live) return any ? `Your ${label} connection is down. Reconnect ${label}.` : `Connect ${label} to post there.`;
98
+ if (carriesMedia && !live.can_post_media) return `Reconnect ${label} to post images and videos.`;
99
+ return null;
100
+ };
101
+ const blocked = selected.map(blocker).filter(Boolean) as string[];
102
+ const checking = accountsState !== "ready" && selected.some((p) => CONNECTED_ONLY.includes(p));
103
+ const remindable = selected.some((p) => !CONNECTED_ONLY.includes(p));
104
+
105
+ /** Switch to another platform (one per post); it starts from the attachment's text, or the text written so far. */
106
+ const pick = (p: Platform) => {
107
+ if (selected.includes(p)) return;
108
+ setSelected([p]);
109
+ if (!texts[p]?.some((t) => t.trim())) {
110
+ const from = selected.map((s) => texts[s] ?? []).find((t) => t.some((x) => x.trim())) ?? [""];
111
+ const start = attached ? prefillFor(p, attached) : THREADED.includes(p) ? from : [from.join("\n\n")];
112
+ setTexts((cur) => ({ ...cur, [p]: start }));
113
+ }
114
+ };
115
+
116
+ /** Attach: empty texts take its suggested text. */
117
+ const attach = (a: PostableArtifact) => {
118
+ setAttached(a);
119
+ setPicking(false);
120
+ setTexts((cur) => {
121
+ const next = { ...cur };
122
+ for (const p of selected) if (!next[p]?.some((t) => t.trim())) next[p] = prefillFor(p, a);
123
+ return next;
124
+ });
125
+ };
126
+
127
+ const setText = (p: Platform, i: number, value: string) =>
128
+ setTexts((cur) => ({ ...cur, [p]: (cur[p] ?? [""]).map((t, j) => (j === i ? value : t)) }));
129
+
130
+ const connect = async (p: "x" | "linkedin") => {
131
+ try {
132
+ window.location.href = (await launchpadApi.startOAuth(p)).url;
133
+ } catch (e) {
134
+ setError(errorMessage(e));
135
+ }
136
+ };
137
 
138
+ const submit = async (now: boolean, e?: FormEvent) => {
139
+ e?.preventDefault();
140
+ if (!selected.length) return setError("Pick where to post.");
141
+ if (blocked.length) return setError(blocked[0]);
142
+ if (!now && wallToDate(when) <= new Date()) return setError("Pick a time that is still ahead.");
143
+ setBusy(now ? "now" : "schedule");
144
  setError(null);
145
+ const done: string[] = [];
 
146
  try {
147
+ for (const p of PLATFORMS.map((x) => x.id).filter((id) => selected.includes(id))) {
148
+ const meta = PLATFORMS.find((x) => x.id === p)!;
149
+ const clean = (texts[p] ?? []).map((t) => t.trim()).filter(Boolean);
150
+ const merged = THREADED.includes(p) ? clean : [clean.join("\n\n")];
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.
159
+ artifact_id: attached && (!carriesMedia || MEDIA_PLATFORMS.includes(p)) ? attached.id : null,
160
+ document_id: documentId ?? null,
161
+ kit_id: kitId ?? null,
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();
170
  } catch (err) {
171
+ setError(done.length ? `${done.join(" and ")} done; then: ${errorMessage(err)}` : errorMessage(err));
172
  } finally {
173
+ setBusy(null);
174
  }
175
  };
176
 
177
  return (
178
+ <Modal title="New post" onClose={onClose} wide>
179
+ <form onSubmit={(e) => submit(false, e)} className="stack lp-compose">
180
+ <div className="field">
181
+ <span className="vw-label">Post to</span>
182
+ <div className="gen-chips lp-platforms" role="radiogroup" aria-label="Platform">
183
+ {PLATFORMS.filter((p) => COMPOSER_PLATFORMS.includes(p.id)).map((p) => {
184
+ const on = selected.includes(p.id);
185
+ return (
186
+ <button key={p.id} type="button" role="radio" className={`chip${on ? " on" : ""}`} aria-checked={on}
187
+ onClick={() => pick(p.id)}>
188
  {p.label}
189
+ </button>
190
+ );
191
+ })}
192
+ </div>
193
+ </div>
194
+
195
+ {kitId && <p className="muted small lp-from-kit">Written from the launch kit: {kitTitle ?? "this kit"}</p>}
196
+ <div className="field">
197
+ <span className="vw-label">Attachment</span>
198
+ {attached ? (
199
+ <AttachmentChip a={attached} onChange={() => setPicking(true)} onRemove={() => setAttached(null)} />
200
+ ) : (
201
+ <button type="button" className="btn lp-attach-btn" onClick={() => setPicking(true)}>
202
+ + Attach from your Library
203
+ </button>
204
+ )}
205
+ </div>
206
+
207
+ {PLATFORMS.filter((p) => selected.includes(p.id)).map((meta) => {
208
+ const p = meta.id;
209
+ const list = texts[p] ?? [""];
210
+ const account = meta.auto ? accountFor(p) : null;
211
+ const canConnect = (p === "x" || p === "linkedin") && accounts?.available[p];
212
+ const problem = blocker(p);
213
+ return (
214
+ <section key={p} className="lp-platform">
215
+ <div className="lp-platform-head">
216
+ <strong>{meta.label}</strong>
217
+ <span className={`small ${problem || (accountsState === "error" && CONNECTED_ONLY.includes(p)) ? "lp-warn" : "muted"}`}>
218
+ {problem ?? (!meta.auto ? "No posting API: we email you the text at send time."
219
+ : accountsState === "loading" ? "Checking your connection..."
220
+ : accountsState === "error" ? "Couldn't check your connections."
221
+ : account ? `Posting as ${account.handle}` : "Not connected: we email you a reminder.")}
222
+ </span>
223
+ {accountsState === "error" && (
224
+ <button type="button" className="btn btn-small" onClick={loadAccounts}>Retry</button>
225
+ )}
226
+ {problem && canConnect && (
227
+ <button type="button" className="btn btn-small btn-primary" onClick={() => connect(p as "x" | "linkedin")}>
228
+ {accounts?.accounts.some((a) => a.platform === p) ? "Reconnect" : "Connect"} {meta.label}
229
+ </button>
230
+ )}
231
+ {problem && !canConnect && <span className="muted small">{meta.label} posting isn't set up yet.</span>}
232
+ </div>
233
+ {list.map((t, i) => (
234
+ <label key={i} className="field">
235
+ <span className="field-row">
236
+ <span className="small muted">{THREADED.includes(p) && list.length > 1 ? `Post ${i + 1}` : "Post"}</span>
237
+ <span className={`mono small ${t.length > meta.limit ? "over" : "muted"}`}>{t.length} / {meta.limit}</span>
238
+ </span>
239
+ <textarea className="textarea" rows={THREADED.includes(p) ? 3 : 6} value={t}
240
+ onChange={(e) => setText(p, i, e.target.value)} />
241
+ </label>
242
  ))}
243
+ {THREADED.includes(p) && (
244
+ <div className="row">
245
+ <button type="button" className="btn btn-small" onClick={() => setTexts({ ...texts, [p]: [...list, ""] })}>
246
+ + Add to thread
247
+ </button>
248
+ {list.length > 1 && (
249
+ <button type="button" className="btn btn-small" onClick={() => setTexts({ ...texts, [p]: list.slice(0, -1) })}>
250
+ Remove last
251
+ </button>
252
+ )}
253
+ </div>
254
+ )}
255
+ </section>
256
+ );
257
+ })}
258
+
259
+ <div className="row wrap lp-when">
260
+ <div className="field">
261
+ <span className="vw-label">When</span>
262
+ <SlotPicker value={when} onChange={setWhen} />
263
+ </div>
264
+ {remindable && (
265
+ <label className="check">
266
+ <input type="checkbox" checked={remind} onChange={(e) => setRemind(e.target.checked)} />
267
+ Email me instead of posting automatically (Bluesky, Substack)
268
  </label>
269
  )}
270
  </div>
271
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  {error && <p className="error-text">{error}</p>}
273
  <div className="row end">
274
+ <button type="button" className="btn" onClick={onClose}>Cancel</button>
275
+ <button type="button" className="btn" disabled={busy !== null || !selected.length || blocked.length > 0 || checking}
276
+ onClick={() => submit(true)}>
277
+ {busy === "now" ? "Posting..." : "Post now"}
278
  </button>
279
+ <button className="btn btn-primary" disabled={busy !== null || !selected.length || blocked.length > 0 || checking}>
280
+ {busy === "schedule" ? "Scheduling..." : "Schedule"}
281
  </button>
282
  </div>
283
  </form>
284
+ {picking && (
285
+ <AttachPicker onPick={attach} onClose={() => setPicking(false)} onCreateKit={() => {
286
+ setPicking(false);
287
+ onClose();
288
+ navigate("/app/launchpad/kits");
289
+ }} />
290
+ )}
291
  </Modal>
292
  );
293
  }
frontend/src/components/UpgradeModal.tsx CHANGED
@@ -157,7 +157,7 @@ export default function UpgradeModal() {
157
  ))}
158
  </ul>
159
  <div className="row end">
160
- <Link to="/app/launch-kit" className="btn btn-primary" onClick={close}>
161
  Launch something
162
  </Link>
163
  </div>
 
157
  ))}
158
  </ul>
159
  <div className="row end">
160
+ <Link to="/app/launchpad/kits" className="btn btn-primary" onClick={close}>
161
  Launch something
162
  </Link>
163
  </div>
frontend/src/components/launchpad/Attachment.tsx ADDED
@@ -0,0 +1,628 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+ 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
+
11
+ /** X's video limit for standard accounts (2:20): longer videos go to LinkedIn only. */
12
+ export const X_VIDEO_SECONDS = 140;
13
+
14
+ export const fmtDuration = (s: number) => `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, "0")}`;
15
+
16
+ /** "4 images", "Video 1:12", "Text": what the post carries. */
17
+ 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
+ }
27
+
28
+ 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
+ }
36
+
37
+ /** Look at an uploaded image or video before posting it. */
38
+ export function MediaPreviewModal({ a, onClose }: { a: PostableArtifact; onClose: () => void }) {
39
+ const [failed, setFailed] = useState(false);
40
+ return (
41
+ <Modal title={a.title} onClose={onClose} wide>
42
+ <div className="stack">
43
+ <div className="lp-preview">
44
+ {failed || !a.view_url ? (
45
+ <p className="muted">Couldn't load this file.</p>
46
+ ) : a.media === "video" ? (
47
+ <video src={a.view_url} controls autoPlay playsInline onError={() => setFailed(true)} />
48
+ ) : (
49
+ <img src={a.view_url} alt={a.title} onError={() => setFailed(true)} />
50
+ )}
51
+ </div>
52
+ <p className="muted small">{mediaLabel(a)}</p>
53
+ </div>
54
+ </Modal>
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 }: {
61
+ a: PostableArtifact;
62
+ onChange?: () => void;
63
+ onRemove?: () => void;
64
+ }) {
65
+ const [previewing, setPreviewing] = useState(false);
66
+ return (
67
+ <div className="lp-attach">
68
+ {a.view_url ? (
69
+ <button type="button" className="lp-thumb-btn" aria-label={`Preview ${a.title}`} title="Preview"
70
+ onClick={() => setPreviewing(true)}>
71
+ <Thumb a={a} />
72
+ </button>
73
+ ) : <Thumb a={a} />}
74
+ {previewing && <MediaPreviewModal a={a} onClose={() => setPreviewing(false)} />}
75
+ <span className="lp-attach-text">
76
+ <strong>{a.title}</strong>
77
+ <span className="muted small">{a.type_label} · {mediaLabel(a)}</span>
78
+ </span>
79
+ {onChange && <button type="button" className="btn btn-small" onClick={onChange}>Change</button>}
80
+ {onRemove && <button type="button" className="btn btn-small" onClick={onRemove}>Remove</button>}
81
+ </div>
82
+ );
83
+ }
84
+
85
+ type RenderStep = "confirm" | "rendering" | "done" | "failed";
86
+
87
+ /** The furthest each video's render has got, kept across opens of the pop-up: blog2video can answer 0% for a while
88
+ * (preparing, or a poll that misses its progress), and reopening must not drop the bar back to 0. */
89
+ const renderPct = new Map<string, number>();
90
+
91
+ /** Render a video that has no MP4 yet, from the Library list: asks first, then shows the render's progress here.
92
+ * Opened on a video already rendering, it says to wait and shows that render's progress. Closing it mid-render
93
+ * leaves the render running (the row shows Rendering). */
94
+ export function RenderVideoModal({ video, onClose, onRendered }: {
95
+ video: PostableArtifact;
96
+ onClose: () => void;
97
+ /** The MP4 is made: schedule it (the picker reloads the item and opens the composer with it). */
98
+ onRendered: (id: string, schedule: boolean) => void;
99
+ }) {
100
+ const waiting = video.rendering; // opened on a render already under way: scheduling waits for it
101
+ const [step, setStep] = useState<RenderStep>(waiting ? "rendering" : "confirm");
102
+ const [pct, setPct] = useState<number | null>(() => renderPct.get(video.id) ?? null);
103
+ const [busy, setBusy] = useState(false);
104
+ const [error, setError] = useState<string | null>(null);
105
+ // The latest callback, so a parent re-render doesn't restart the polling below.
106
+ const onRenderedRef = useRef(onRendered);
107
+ onRenderedRef.current = onRendered;
108
+
109
+ // While rendering: blog2video's progress for the bar. When it says the job stopped (and every few polls anyway,
110
+ // in case blog2video lost track of the render), /status (which also moves the video to ready with its MP4) says
111
+ // how it ended.
112
+ useEffect(() => {
113
+ if (step !== "rendering") return;
114
+ let stopped = false;
115
+ let timer: ReturnType<typeof setTimeout> | undefined;
116
+ let polls = 0;
117
+ const tick = async () => {
118
+ try {
119
+ const s = await videoEditApi.renderStatus(video.id);
120
+ if (stopped) return;
121
+ const now = s?.progress_unknown ? null : jobPercent(s);
122
+ if (now !== null) renderPct.set(video.id, Math.max(renderPct.get(video.id) ?? 0, now));
123
+ setPct(renderPct.get(video.id) ?? null);
124
+ if (finished(s) || ++polls % 3 === 0) {
125
+ const st = await videosApi.status(video.id);
126
+ if (stopped) return;
127
+ if (st.artifact_status !== "rendering") {
128
+ renderPct.delete(video.id);
129
+ if (st.video_url && !st.error) {
130
+ setStep("done");
131
+ onRenderedRef.current(video.id, false); // the list shows it as ready behind the modal
132
+ } else {
133
+ setError(st.error ?? "The render stopped before the MP4 was made.");
134
+ setStep("failed");
135
+ }
136
+ return;
137
+ }
138
+ }
139
+ } catch {
140
+ // a missed poll: try again on the next one
141
+ }
142
+ if (!stopped) timer = setTimeout(tick, 4000);
143
+ };
144
+ void tick();
145
+ return () => {
146
+ stopped = true;
147
+ clearTimeout(timer);
148
+ };
149
+ }, [step, video.id]);
150
+
151
+ const render = async () => {
152
+ setBusy(true);
153
+ setError(null);
154
+ try {
155
+ await videoEditApi.render(video.id);
156
+ renderPct.delete(video.id); // a new run starts from 0
157
+ setPct(null);
158
+ setStep("rendering");
159
+ } catch (e) {
160
+ setError(e instanceof ApiError && e.status === 409 ? "Another change is still running on this video, try again in a moment."
161
+ : errorMessage(e));
162
+ } finally {
163
+ setBusy(false);
164
+ }
165
+ };
166
+
167
+ const title = step === "done" ? "Video rendered" : step === "failed" ? "Render failed"
168
+ : waiting ? "Still rendering" : step === "rendering" ? "Rendering your video" : "Render this video?";
169
+ return (
170
+ <Modal title={title} onClose={onClose}>
171
+ <div className="stack">
172
+ <p><strong>{video.title}</strong></p>
173
+ {step === "confirm" && (
174
+ <p className="muted">It has no MP4 yet, so it can't be posted. Render it now; it takes a few minutes and you can close this while it runs.</p>
175
+ )}
176
+ {step === "rendering" && (
177
+ <>
178
+ <p className="muted">
179
+ {waiting ? "Wait for the render to complete before scheduling it." : "You can close this: the render keeps going and the video shows as Rendering until it's done."}
180
+ </p>
181
+ <span className="vw-render-status lp-render-status" role="status">
182
+ <span className="vw-render-line">
183
+ <span className="vw-spin small" aria-hidden="true" />
184
+ <span>{pct ? "Rendering MP4" : "Preparing the render"}</span>
185
+ {pct ? <span className="mono vw-render-pct">{pct}%</span> : null}
186
+ </span>
187
+ <span className="vw-render-bar" aria-hidden="true"><span style={{ width: `${Math.max(2, Math.min(100, pct ?? 0))}%` }} /></span>
188
+ </span>
189
+ </>
190
+ )}
191
+ {step === "done" && <p className="muted">The MP4 is ready. Schedule it now, or pick it from the list later.</p>}
192
+ {error && <p className="error-text">{error}</p>}
193
+ <div className="row end">
194
+ {step === "confirm" && (
195
+ <>
196
+ <button type="button" className="btn" onClick={onClose} disabled={busy}>Cancel</button>
197
+ <button type="button" className="btn btn-primary" onClick={render} disabled={busy}>
198
+ {busy ? "Starting..." : "Render"}
199
+ </button>
200
+ </>
201
+ )}
202
+ {step === "rendering" && <button type="button" className="btn" onClick={onClose}>{waiting ? "OK" : "Close"}</button>}
203
+ {step === "failed" && (
204
+ <>
205
+ <button type="button" className="btn" onClick={onClose}>Close</button>
206
+ <button type="button" className="btn btn-primary" onClick={render} disabled={busy}>
207
+ {busy ? "Starting..." : "Try again"}
208
+ </button>
209
+ </>
210
+ )}
211
+ {step === "done" && (
212
+ <>
213
+ <button type="button" className="btn" onClick={onClose}>Close</button>
214
+ <button type="button" className="btn btn-primary" onClick={() => onRendered(video.id, true)}>Schedule it</button>
215
+ </>
216
+ )}
217
+ </div>
218
+ </div>
219
+ </Modal>
220
+ );
221
+ }
222
+
223
+ type MenuItem = { label: string; onSelect: () => void; danger?: boolean };
224
+
225
+ /** A row's ⋮ button and its menu. The menu is drawn over everything (a portal, placed from the button), so the
226
+ * scrolling list and the pop-up around it never cut it off; it opens upwards near the bottom of the screen. */
227
+ function RowMenu({ label, items }: { label: string; items: MenuItem[] }) {
228
+ const [open, setOpen] = useState(false);
229
+ const [at, setAt] = useState<{ top: number; left: number; up: boolean } | null>(null);
230
+ const button = useRef<HTMLButtonElement>(null);
231
+ const menu = useRef<HTMLDivElement>(null);
232
+
233
+ useLayoutEffect(() => {
234
+ if (!open || !button.current) return;
235
+ const r = button.current.getBoundingClientRect();
236
+ const up = r.bottom + 8 + items.length * 40 > window.innerHeight;
237
+ setAt({ top: up ? r.top - 6 : r.bottom + 6, left: r.right, up });
238
+ }, [open, items.length]);
239
+
240
+ useEffect(() => {
241
+ if (!open) return;
242
+ const close = () => setOpen(false);
243
+ const outside = (e: MouseEvent) => {
244
+ const t = e.target as Node;
245
+ if (!menu.current?.contains(t) && !button.current?.contains(t)) close();
246
+ };
247
+ // Escape closes only the menu, not the pop-up the picker sits in.
248
+ const key = (e: KeyboardEvent) => {
249
+ if (e.key !== "Escape") return;
250
+ e.stopPropagation();
251
+ close();
252
+ button.current?.focus();
253
+ };
254
+ document.addEventListener("mousedown", outside);
255
+ document.addEventListener("keydown", key, true);
256
+ window.addEventListener("scroll", close, true);
257
+ window.addEventListener("resize", close);
258
+ menu.current?.querySelector<HTMLElement>("[role=menuitem]")?.focus();
259
+ return () => {
260
+ document.removeEventListener("mousedown", outside);
261
+ document.removeEventListener("keydown", key, true);
262
+ window.removeEventListener("scroll", close, true);
263
+ window.removeEventListener("resize", close);
264
+ };
265
+ }, [open, at]);
266
+
267
+ return (
268
+ <>
269
+ <button ref={button} type="button" className="btn btn-small lp-row-menu-btn" aria-label={`Options for ${label}`}
270
+ aria-haspopup="menu" aria-expanded={open} title="Options" onClick={() => setOpen(!open)}>
271
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
272
+ <circle cx="12" cy="5" r="1.8" /><circle cx="12" cy="12" r="1.8" /><circle cx="12" cy="19" r="1.8" />
273
+ </svg>
274
+ </button>
275
+ {open && at && createPortal(
276
+ <div ref={menu} className="lp-menu" role="menu" aria-label={`Options for ${label}`}
277
+ style={{ top: at.top, left: at.left, transform: `translate(-100%, ${at.up ? "-100%" : "0"})` }}>
278
+ {items.map((it) => (
279
+ <button key={it.label} type="button" role="menuitem" className={`lp-menu-item${it.danger ? " danger" : ""}`}
280
+ onClick={() => {
281
+ setOpen(false);
282
+ it.onSelect();
283
+ }}>
284
+ {it.label}
285
+ </button>
286
+ ))}
287
+ </div>,
288
+ document.body,
289
+ )}
290
+ </>
291
+ );
292
+ }
293
+
294
+ /** The warning before deleting, by kind: what goes, and what happens to the posts that carry it. */
295
+ function deleteWording(a: PostableArtifact): { title: string; body: string } {
296
+ if (a.type === "upload") {
297
+ return { title: "Delete this upload?", body: "The file is deleted for good. Posts on your calendar that carry it are removed too." };
298
+ }
299
+ if (a.type === "launch_kit") {
300
+ return { title: "Delete this launch kit?",
301
+ body: "Its LinkedIn post and hooks are deleted for good. Posts already scheduled from it stay on your calendar." };
302
+ }
303
+ if (a.media === "video") {
304
+ return { title: "Delete this video?",
305
+ body: "It's deleted here and on the video service, for good. Posts on your calendar that carry it are removed too." };
306
+ }
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";
323
+ const MB = 1024 * 1024;
324
+ function uploadProblem(file: File): string | null {
325
+ const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
326
+ const video = file.type === "video/mp4" || ext === "mp4";
327
+ const image = ["image/png", "image/jpeg", "image/gif"].includes(file.type) || ["png", "jpg", "jpeg", "gif"].includes(ext);
328
+ if (!video && !image) return "LinkedIn takes JPG, PNG or GIF images and MP4 videos.";
329
+ if (image && file.size > 36 * MB) return `This image is ${Math.round(file.size / MB)} MB; LinkedIn takes up to 36 MB.`;
330
+ if (video && file.size > 300 * MB) return `This video is ${Math.round(file.size / MB)} MB; uploads are limited to 300 MB.`;
331
+ if (video && file.size < 75 * 1024) return "This video is too small for LinkedIn (at least 75 KB).";
332
+ return null;
333
+ }
334
+
335
+ /** A video file's length in seconds, read by the browser (null if it can't tell). */
336
+ function videoDuration(file: File): Promise<number | null> {
337
+ return new Promise((resolve) => {
338
+ const url = URL.createObjectURL(file);
339
+ const v = document.createElement("video");
340
+ v.preload = "metadata";
341
+ const done = (d: number | null) => {
342
+ URL.revokeObjectURL(url);
343
+ resolve(d);
344
+ };
345
+ v.onloadedmetadata = () => done(Number.isFinite(v.duration) ? v.duration : null);
346
+ v.onerror = () => done(null);
347
+ v.src = url;
348
+ });
349
+ }
350
+
351
+ /** Pick something made to attach to a post (audio is never offered: X and LinkedIn take no audio files), in a
352
+ * pop-up (the composer, editing a scheduled post). */
353
+ export function AttachPicker({ onPick, onCreateKit, onClose }: {
354
+ onPick: (a: PostableArtifact) => void;
355
+ onCreateKit?: () => void;
356
+ onClose: () => void;
357
+ }) {
358
+ return (
359
+ <Modal title="Attach from your Library" onClose={onClose} wide>
360
+ <LibraryPicker onPick={onPick} onCreateKit={onCreateKit} />
361
+ </Modal>
362
+ );
363
+ }
364
+
365
+ /** What can go out with a post: everything postable in the Library, your Launch Kits, or your videos (tabs), and
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");
377
+ const [q, setQ] = useState("");
378
+ const [error, setError] = useState<string | null>(null);
379
+ const [rendering, setRendering] = useState<PostableArtifact | null>(null); // the video in the render pop-up
380
+ const navigate = useNavigate();
381
+ const [uploading, setUploading] = useState<string | null>(null); // the file name being uploaded
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);
388
+
389
+ /** Delete an item after its warning: an upload (record and stored file too), a video (also on blog2video), a kit... */
390
+ const deleteItem = async () => {
391
+ if (!deleting) return;
392
+ setDeleteBusy(true);
393
+ setDeleteError(null);
394
+ try {
395
+ await artifactsApi.remove(deleting.id);
396
+ setItems((list) => list?.filter((x) => x.id !== deleting.id) ?? list);
397
+ setDeleting(null);
398
+ } catch (e) {
399
+ setDeleteError(errorMessage(e));
400
+ } finally {
401
+ setDeleteBusy(false);
402
+ }
403
+ };
404
+
405
+ /** An uploaded file's name, as the picker and the composer show it (the file itself keeps its name). */
406
+ const saveName = async () => {
407
+ if (!renaming) return;
408
+ const title = renaming.title.trim();
409
+ if (!title) return setError("Give it a name.");
410
+ setRenaming({ ...renaming, busy: true });
411
+ try {
412
+ await artifactsApi.update(renaming.id, { title });
413
+ setItems((list) => list?.map((x) => (x.id === renaming.id ? { ...x, title } : x)) ?? list);
414
+ setRenaming(null);
415
+ setError(null);
416
+ } catch (e) {
417
+ setError(errorMessage(e));
418
+ setRenaming((cur) => cur && { ...cur, busy: false });
419
+ }
420
+ };
421
+ const fileInput = useRef<HTMLInputElement>(null);
422
+
423
+ /** A file from the computer: uploaded, made postable, then picked like anything else. */
424
+ const upload = async (file: File) => {
425
+ const problem = uploadProblem(file);
426
+ if (problem) return setError(problem);
427
+ setError(null);
428
+ setUploading(file.name);
429
+ try {
430
+ const seconds = file.type === "video/mp4" || file.name.toLowerCase().endsWith(".mp4") ? await videoDuration(file) : null;
431
+ const item = await launchpadApi.uploadToPost(file, seconds ?? undefined);
432
+ void load().catch(() => undefined);
433
+ onPick(item);
434
+ } catch (e) {
435
+ setError(errorMessage(e));
436
+ } finally {
437
+ setUploading(null);
438
+ }
439
+ };
440
+
441
+ const load = useCallback(() => launchpadApi.postable(q).then((list) => {
442
+ setItems(list);
443
+ return list;
444
+ }), [q]);
445
+
446
+ useEffect(() => {
447
+ const t = setTimeout(() => {
448
+ load().catch((e) => setError(errorMessage(e)));
449
+ }, 200);
450
+ return () => clearTimeout(t);
451
+ }, [load]);
452
+
453
+ // Videos rendering (from here, the editor, or another tab): check on them, and reload once one is done.
454
+ const renderingIds = useMemo(() => (items ?? []).filter((a) => a.rendering).map((a) => a.id).join(","), [items]);
455
+ useEffect(() => {
456
+ if (!renderingIds) return;
457
+ const t = setInterval(async () => {
458
+ const ids = renderingIds.split(",");
459
+ const states = await Promise.all(ids.map((id) => videosApi.status(id).catch(() => null)));
460
+ if (states.some((s) => s && s.artifact_status !== "rendering")) void load().catch(() => undefined);
461
+ }, 8000);
462
+ return () => clearInterval(t);
463
+ }, [renderingIds, load]);
464
+
465
+ const rendered = useCallback((id: string, schedule: boolean) => {
466
+ void load().then((list) => {
467
+ if (!schedule) return;
468
+ const found = list.find((a) => a.id === id);
469
+ setRendering(null);
470
+ if (found && !found.needs_render) onPick(found);
471
+ }).catch((e) => setError(errorMessage(e)));
472
+ }, [load, onPick]);
473
+
474
+ // Kits only on the Schedule a launch page (they open the kit); as an attachment they are never offered.
475
+ const kits = Boolean(onPickKit);
476
+ const shown = useMemo(() => (items ?? []).filter((a) => (kits || a.type !== "launch_kit") && matches(a, kind)),
477
+ [items, kind, kits]);
478
+
479
+ return (
480
+ <div className="stack">
481
+ <div className="row between wrap">
482
+ <div className="gen-chips" role="group" aria-label="Kind">
483
+ {KINDS.filter((k) => kits || k.id !== "kits").map((k) => (
484
+ <button key={k.id} type="button" className={`chip${kind === k.id ? " on" : ""}`} aria-pressed={kind === k.id}
485
+ onClick={() => setKind(k.id)}>
486
+ {k.label}
487
+ </button>
488
+ ))}
489
+ </div>
490
+ {/* A LinkedIn post and hooks from one of your posts: its own pages */}
491
+ {onCreateKit && (
492
+ <button type="button" className="lp-kit-link" onClick={onCreateKit}
493
+ title="A LinkedIn post and hooks from one of your posts">
494
+ Create a new launch kit <ArrowRightIcon size={16} />
495
+ </button>
496
+ )}
497
+ </div>
498
+ {/* Uploads: a drop area for a file from the computer, above the files uploaded before */}
499
+ {kind === "uploads" && (
500
+ <div className={`lp-drop${dragging ? " over" : ""}${uploading ? " busy" : ""}`} role="button" tabIndex={0}
501
+ aria-label="Upload an image or video from your computer" aria-disabled={uploading !== null}
502
+ onClick={() => !uploading && fileInput.current?.click()}
503
+ onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && !uploading && fileInput.current?.click()}
504
+ onDragOver={(e) => {
505
+ if (!e.dataTransfer.types.includes("Files")) return;
506
+ e.preventDefault();
507
+ setDragging(true);
508
+ }}
509
+ onDragLeave={(e) => e.currentTarget === e.target && setDragging(false)}
510
+ onDrop={(e) => {
511
+ e.preventDefault();
512
+ setDragging(false);
513
+ const file = e.dataTransfer.files[0];
514
+ if (file && !uploading) void upload(file);
515
+ }}>
516
+ <span className="lp-drop-icon" aria-hidden="true">
517
+ {uploading ? <span className="vw-spin" /> : <UploadIcon size={22} />}
518
+ </span>
519
+ {uploading ? (
520
+ <strong>Uploading {uploading}...</strong>
521
+ ) : (
522
+ <>
523
+ <strong>Drop a file here, or <span className="lp-drop-link">browse your computer</span></strong>
524
+ <span className="muted small">JPG, PNG or GIF up to 36 MB · MP4 video up to 300 MB</span>
525
+ </>
526
+ )}
527
+ </div>
528
+ )}
529
+ {/* Outside the drop area, so its click doesn't bubble back into the area's own click */}
530
+ <input ref={fileInput} type="file" accept={UPLOAD_ACCEPT} hidden onChange={(e) => {
531
+ const file = e.target.files?.[0];
532
+ e.target.value = ""; // the same file can be picked again
533
+ if (file) void upload(file);
534
+ }} />
535
+ <input className="input input-sm search" placeholder="Search by title" value={q}
536
+ onChange={(e) => setQ(e.target.value)} aria-label="Search" />
537
+ {error && <p className="error-text">{error}</p>}
538
+ {!items && !error && (
539
+ <div className={`lp-loading${fill ? " fill" : ""}`}><Loading label="Loading your Library" /></div>
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}>
552
+ <form className="lk-row lp-rename-row" onSubmit={(e) => {
553
+ e.preventDefault();
554
+ void saveName();
555
+ }}>
556
+ <Thumb a={a} />
557
+ <input className="input input-sm" autoFocus value={renaming.title} maxLength={200}
558
+ aria-label="Name" disabled={renaming.busy}
559
+ onChange={(e) => setRenaming({ ...renaming, title: e.target.value })}
560
+ onKeyDown={(e) => e.key === "Escape" && (e.stopPropagation(), setRenaming(null))} />
561
+ <button type="submit" className="btn btn-small btn-primary" disabled={renaming.busy}>
562
+ {renaming.busy ? "Saving..." : "Save"}
563
+ </button>
564
+ <button type="button" className="btn btn-small" disabled={renaming.busy} onClick={() => setRenaming(null)}>
565
+ Cancel
566
+ </button>
567
+ </form>
568
+ </li>
569
+ );
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} />
577
+ <span className="lk-title">
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}`) }] : []),
600
+ { label: kit ? "Delete kit" : "Delete", danger: true, onSelect: () => {
601
+ setDeleteError(null);
602
+ setDeleting(a);
603
+ } },
604
+ ]} />
605
+ </li>
606
+ );
607
+ })}
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">
614
+ <p><strong>{deleting.title}</strong></p>
615
+ <p className="muted">{deleteWording(deleting).body}</p>
616
+ {deleteError && <p className="error-text">{deleteError}</p>}
617
+ <div className="row end">
618
+ <button type="button" className="btn" disabled={deleteBusy} onClick={() => setDeleting(null)}>Cancel</button>
619
+ <button type="button" className="btn btn-danger" disabled={deleteBusy} onClick={deleteItem}>
620
+ {deleteBusy ? "Deleting..." : "Delete"}
621
+ </button>
622
+ </div>
623
+ </div>
624
+ </Modal>
625
+ )}
626
+ </div>
627
+ );
628
+ }
frontend/src/components/launchpad/PostButton.tsx ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 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;
10
+ if (a.provider === "blog2video") return Boolean((a.content as { video_url?: string } | null)?.video_url);
11
+ return a.status === "ready";
12
+ }
13
+
14
+ /** "Post": opens the composer with this item attached and its text filled in. */
15
+ export function PostButton({ artifactId, className = "btn btn-small", label = "Post" }: {
16
+ artifactId: string;
17
+ className?: string;
18
+ label?: string;
19
+ }) {
20
+ const [open, setOpen] = useState(false);
21
+ return (
22
+ <>
23
+ <button type="button" className={className} onClick={() => setOpen(true)}>{label}</button>
24
+ {open && <ScheduleModal platform="linkedin" posts={[]} artifactId={artifactId} onClose={() => setOpen(false)} />}
25
+ </>
26
+ );
27
+ }
frontend/src/components/launchpad/SlotPicker.tsx ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+
3
+ /** A post's time as the user picked it: their own wall clock, "2026-10-07T11:00", always on :00 or :30. The backend
4
+ * reads it in their time zone (userTimeZone) and stores UTC. */
5
+ export type Wall = string;
6
+
7
+ const pad = (n: number) => String(n).padStart(2, "0");
8
+ const dayKey = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
9
+
10
+ export const userTimeZone = (): string => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
11
+
12
+ export function toWall(d: Date): Wall {
13
+ return `${dayKey(d)}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
14
+ }
15
+
16
+ /** The wall time as a Date in this browser (which is in the user's zone). */
17
+ export function wallToDate(w: Wall): Date {
18
+ const [day, time] = w.split("T");
19
+ const [y, m, d] = day.split("-").map(Number);
20
+ const [h, min] = time.split(":").map(Number);
21
+ return new Date(y, m - 1, d, h, min);
22
+ }
23
+
24
+ /** The next half hour from now (11:07 -> 11:30). */
25
+ export function nextSlot(from = new Date()): Date {
26
+ const d = new Date(from);
27
+ d.setSeconds(0, 0);
28
+ d.setMinutes(d.getMinutes() < 30 ? 30 : 60);
29
+ return d;
30
+ }
31
+
32
+ export const onSlot = (d: Date) => d.getMinutes() % 30 === 0 && d.getSeconds() === 0;
33
+
34
+ /** Weeks start on Monday: the 6 weeks around the month. */
35
+ export function monthGrid(month: Date): Date[] {
36
+ const first = new Date(month.getFullYear(), month.getMonth(), 1);
37
+ const offset = (first.getDay() + 6) % 7;
38
+ return Array.from({ length: 42 }, (_, i) => new Date(first.getFullYear(), first.getMonth(), 1 - offset + i));
39
+ }
40
+
41
+ /** "GMT+5" for the zone right now. */
42
+ function zoneOffset(): string {
43
+ const part = new Intl.DateTimeFormat(undefined, { timeZoneName: "short" }).formatToParts(new Date())
44
+ .find((p) => p.type === "timeZoneName");
45
+ return part?.value ?? "";
46
+ }
47
+
48
+ const WEEKDAYS = ["M", "T", "W", "T", "F", "S", "S"];
49
+ const SLOTS = Array.from({ length: 48 }, (_, i) => `${pad(Math.floor(i / 2))}:${i % 2 ? "30" : "00"}`);
50
+
51
+ /** When a post goes out: a day on a small month calendar and one of the day's 48 half hours, in the user's own time
52
+ * zone (named under it). Past days and past slots are disabled. */
53
+ export function SlotPicker({ value, onChange }: { value: Wall; onChange: (w: Wall) => void }) {
54
+ const picked = wallToDate(value);
55
+ const [open, setOpen] = useState(false);
56
+ const [month, setMonth] = useState(() => new Date(picked.getFullYear(), picked.getMonth(), 1));
57
+ const days = useMemo(() => monthGrid(month), [month]);
58
+ const now = new Date();
59
+ const today = dayKey(now);
60
+ const pickedDay = value.split("T")[0];
61
+ const pickedTime = value.split("T")[1];
62
+ const thisMonth = new Date(now.getFullYear(), now.getMonth(), 1);
63
+ const times = useRef<HTMLDivElement>(null);
64
+ // Opened: the time list scrolls (itself only) to the picked slot.
65
+ useEffect(() => {
66
+ const list = times.current;
67
+ const on = list?.querySelector<HTMLElement>(".slot-time.on");
68
+ if (list && on) list.scrollTop = on.offsetTop - list.clientHeight / 2 + on.clientHeight / 2;
69
+ }, [open, pickedDay]);
70
+
71
+ const slotDate = (day: string, time: string) => wallToDate(`${day}T${time}`);
72
+ /** A day keeps its slot when it's still ahead there, else takes the day's first one still ahead. */
73
+ const pickDay = (d: Date) => {
74
+ const day = dayKey(d);
75
+ const time = slotDate(day, pickedTime) > now ? pickedTime : SLOTS.find((t) => slotDate(day, t) > now);
76
+ if (time) onChange(`${day}T${time}`);
77
+ };
78
+
79
+ const label = picked.toLocaleString(undefined, { weekday: "short", month: "short", day: "numeric", hour: "numeric",
80
+ minute: "2-digit" });
81
+ return (
82
+ <div className="slot-picker">
83
+ <button type="button" className="input input-sm slot-trigger" aria-expanded={open} onClick={() => setOpen(!open)}>
84
+ {label}
85
+ </button>
86
+ <span className="muted small">Your time zone: {userTimeZone()}{zoneOffset() ? ` (${zoneOffset()})` : ""}</span>
87
+ {open && (
88
+ <div className="slot-panel">
89
+ <div className="slot-cal">
90
+ <div className="slot-cal-head">
91
+ <button type="button" className="btn btn-small" aria-label="Previous month"
92
+ disabled={month <= thisMonth}
93
+ onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1))}>‹</button>
94
+ <strong>{month.toLocaleDateString(undefined, { month: "long", year: "numeric" })}</strong>
95
+ <button type="button" className="btn btn-small" aria-label="Next month"
96
+ onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1))}>›</button>
97
+ </div>
98
+ <div className="slot-days" role="grid">
99
+ {WEEKDAYS.map((w, i) => <span key={i} className="slot-weekday muted small">{w}</span>)}
100
+ {days.map((d) => {
101
+ const key = dayKey(d);
102
+ const past = key < today;
103
+ return (
104
+ <button key={key} type="button" disabled={past}
105
+ className={`slot-day${d.getMonth() !== month.getMonth() ? " out" : ""}${key === today ? " today" : ""}${key === pickedDay ? " on" : ""}`}
106
+ aria-pressed={key === pickedDay} onClick={() => pickDay(d)}>
107
+ {d.getDate()}
108
+ </button>
109
+ );
110
+ })}
111
+ </div>
112
+ </div>
113
+ <div className="slot-times" role="listbox" aria-label="Time" ref={times}>
114
+ {SLOTS.map((t) => {
115
+ const at = slotDate(pickedDay, t);
116
+ const on = t === pickedTime;
117
+ return (
118
+ <button key={t} type="button" role="option" aria-selected={on} disabled={at <= now}
119
+ className={`slot-time${on ? " on" : ""}`}
120
+ onClick={() => {
121
+ onChange(`${pickedDay}T${t}`);
122
+ setOpen(false);
123
+ }}>
124
+ {at.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}
125
+ </button>
126
+ );
127
+ })}
128
+ </div>
129
+ </div>
130
+ )}
131
+ </div>
132
+ );
133
+ }
frontend/src/components/ui.tsx CHANGED
@@ -1,5 +1,6 @@
1
  import { useEffect, useRef, useState, type ReactNode } from "react";
2
  import { createPortal } from "react-dom";
 
3
  import { ApiError } from "../api/client";
4
  import type { Job } from "../api/types";
5
 
@@ -51,7 +52,12 @@ export function Modal({
51
  }) {
52
  const ref = useRef<HTMLDivElement>(null);
53
  useEffect(() => {
54
- const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
 
 
 
 
 
55
  document.addEventListener("keydown", onKey);
56
  ref.current?.focus();
57
  return () => document.removeEventListener("keydown", onKey);
@@ -222,14 +228,32 @@ export function Loading({ label = "Loading" }: { label?: string }) {
222
  }
223
 
224
  export function StatusPill({ status }: { status: string }) {
225
- const tone = ["ready", "ok", "done", "posted", "reminded"].includes(status)
226
  ? "ok"
227
  : ["failed", "error"].includes(status)
228
  ? "bad"
229
- : "busy";
 
 
230
  return <span className={`pill pill-${tone} mono`}>{status}</span>;
231
  }
232
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  export function PageHeader({ eyebrow, title, children }: { eyebrow: string; title: string; children?: ReactNode }) {
234
  return (
235
  <header className="page-head">
 
1
  import { useEffect, useRef, useState, type ReactNode } from "react";
2
  import { createPortal } from "react-dom";
3
+ import { useLocation, useNavigate } from "react-router-dom";
4
  import { ApiError } from "../api/client";
5
  import type { Job } from "../api/types";
6
 
 
52
  }) {
53
  const ref = useRef<HTMLDivElement>(null);
54
  useEffect(() => {
55
+ const onKey = (e: KeyboardEvent) => {
56
+ if (e.key !== "Escape") return;
57
+ // A modal opened from a modal (a picker over the composer): Escape closes only the top one.
58
+ const open = document.querySelectorAll(".modal-backdrop");
59
+ if (open[open.length - 1] === ref.current?.parentElement) onClose();
60
+ };
61
  document.addEventListener("keydown", onKey);
62
  ref.current?.focus();
63
  return () => document.removeEventListener("keydown", onKey);
 
228
  }
229
 
230
  export function StatusPill({ status }: { status: string }) {
231
+ const tone = ["ready", "ok", "done", "posted", "reminded", "connected"].includes(status)
232
  ? "ok"
233
  : ["failed", "error"].includes(status)
234
  ? "bad"
235
+ : ["paused", "expired", "revoked", "disconnected"].includes(status)
236
+ ? "warn"
237
+ : "busy";
238
  return <span className={`pill pill-${tone} mono`}>{status}</span>;
239
  }
240
 
241
+ /** An arrow back to exactly the page before (browser history); `fallback` when the page was opened directly. */
242
+ export function BackArrow({ fallback, label = "Back" }: { fallback: string; label?: string }) {
243
+ const navigate = useNavigate();
244
+ const location = useLocation();
245
+ return (
246
+ <button type="button" className="back-link page-back"
247
+ onClick={() => (location.key !== "default" ? navigate(-1) : navigate(fallback))}>
248
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"
249
+ strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
250
+ <path d="M19 12H5m6-6-6 6 6 6" />
251
+ </svg>
252
+ {label}
253
+ </button>
254
+ );
255
+ }
256
+
257
  export function PageHeader({ eyebrow, title, children }: { eyebrow: string; title: string; children?: ReactNode }) {
258
  return (
259
  <header className="page-head">
frontend/src/components/video/jobState.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { VideoJobState } from "../../api/types";
2
+
3
+ /** blog2video job endpoints report progress in slightly different shapes; any of these means it stopped. */
4
+ export function finished(s: VideoJobState | null): boolean {
5
+ if (!s) return true; // e.g. add-status answers null once nothing is running
6
+ // Voice change, voiceover delete and language change say it with active / done. Their `status` is the PROJECT's
7
+ // (e.g. "done" on a video rendered before), so it must not be read as the job's.
8
+ if (typeof s.active === "boolean") return s.done === true || !s.active;
9
+ return s.done === true || s.running === false || !!s.video_url ||
10
+ ["done", "completed", "complete", "ready", "failed", "error", "idle"].includes(String(s.status ?? ""));
11
+ }
12
+
13
+ /** A job's progress as a whole percent, from whichever shape blog2video reported it in (null if it gave none). */
14
+ export function jobPercent(s: VideoJobState | null): number | null {
15
+ if (!s) return null;
16
+ const n = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : null);
17
+ const pct = n(s.progress) ?? ratio(n(s.processed_scenes), n(s.total_scenes)) ?? ratio(n(s.processed), n(s.total));
18
+ return pct === null ? null : Math.max(0, Math.min(100, Math.round(pct)));
19
+ }
20
+
21
+ function ratio(done: number | null, total: number | null): number | null {
22
+ return done !== null && total ? (done / total) * 100 : null;
23
+ }
frontend/src/components/voice/YourVoices.tsx CHANGED
@@ -69,7 +69,7 @@ export function YourVoices({ reloadKey = 0, onAdd }: {
69
  <button className="btn btn-primary" onClick={onAdd}>Add voices</button>
70
  </div>
71
  {error && <p className="error-text">{error}</p>}
72
- {!voices && !error && <div className="loading-center inline"><Loading label="Loading voices" /></div>}
73
 
74
  {voices && (
75
  <>
 
69
  <button className="btn btn-primary" onClick={onAdd}>Add voices</button>
70
  </div>
71
  {error && <p className="error-text">{error}</p>}
72
+ {!voices && !error && <div className="loading-center inline stacked"><Loading label="Loading voices" /></div>}
73
 
74
  {voices && (
75
  <>
frontend/src/pages/Archive.tsx CHANGED
@@ -7,6 +7,7 @@ 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
 
11
  type Filter = "all" | "summary" | "audio_overview" | "launch_kit" | "quote_card,carousel";
12
  /** The Library's tabs: kinds of things made (a filter on one list), plus your videos and your voices (their own pages). */
@@ -62,7 +63,7 @@ function MadeList({ filter }: { filter: Filter }) {
62
  <div className="row end wrap">
63
  <input className="input input-sm search" placeholder={`Search ${total} items by post or notebook`} value={q} onChange={(e) => setQ(e.target.value)} aria-label="Search" />
64
  </div>
65
- {!items && <Loading />}
66
  {items?.length === 0 && <EmptyState title="Nothing here yet" body="Summaries, audio, videos and launch kits you create show up here."
67
  action={
68
  <Link className="btn btn-primary" to="/app/studio">
@@ -75,6 +76,7 @@ function MadeList({ filter }: { filter: Filter }) {
75
  <ArtifactRow
76
  key={a.id}
77
  artifact={a}
 
78
  onCite={(c) => c.document_id && setReading({ id: c.document_id, start: c.line_start, end: c.line_end })}
79
  onRemoved={(id) => {
80
  setItems((list) => (list ?? []).filter((x) => x.id !== id));
 
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" | "summary" | "audio_overview" | "launch_kit" | "quote_card,carousel";
13
  /** The Library's tabs: kinds of things made (a filter on one list), plus your videos and your voices (their own pages). */
 
63
  <div className="row end wrap">
64
  <input className="input input-sm search" placeholder={`Search ${total} items by post or notebook`} value={q} onChange={(e) => setQ(e.target.value)} aria-label="Search" />
65
  </div>
66
+ {!items && <div className="loading-center stacked"><Loading label="Loading your Library" /></div>}
67
  {items?.length === 0 && <EmptyState title="Nothing here yet" body="Summaries, audio, videos and launch kits you create show up here."
68
  action={
69
  <Link className="btn btn-primary" to="/app/studio">
 
76
  <ArtifactRow
77
  key={a.id}
78
  artifact={a}
79
+ actions={(art) => (canPost(art) ? <PostButton artifactId={art.id} /> : null)}
80
  onCite={(c) => c.document_id && setReading({ id: c.document_id, start: c.line_start, end: c.line_end })}
81
  onRemoved={(id) => {
82
  setItems((list) => (list ?? []).filter((x) => x.id !== id));
frontend/src/pages/LaunchKit.tsx CHANGED
@@ -1,15 +1,12 @@
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
- import { useSearchParams } from "react-router-dom";
3
  import { artifactsApi, docsApi } from "../api/endpoints";
4
  import type { Artifact, Doc, LaunchKitContent, Platform } from "../api/types";
5
- import { ArtifactCard } from "../components/ArtifactCard";
6
- import { DocPicker } from "../components/DocPicker";
7
  import { ScheduleModal } from "../components/ScheduleModal";
8
- import { ConfirmButton, CopyButton, errorMessage, formatDate, JobProgress, Loading, PageHeader, StatusPill, Tabs } from "../components/ui";
9
  import { useJob } from "../hooks/useJob";
10
 
11
  type PostKey = keyof LaunchKitContent["posts"];
12
- type Tab = "posts" | "hooks" | "visuals" | "seo";
13
 
14
  const PLATFORM_OF: Record<PostKey, { platform: Platform; label: string; limit: number; thread: boolean }> = {
15
  x_thread: { platform: "x", label: "X thread", limit: 280, thread: true },
@@ -38,7 +35,7 @@ function PostsEditor({ kit, keyName, onSave }: { kit: Artifact; keyName: PostKey
38
  <div className="stack">
39
  <div className="row between">
40
  <p className="muted">
41
- {meta.thread ? `${posts.length} posts in the thread.` : keyName === "substack_notes" ? "Each note stands alone. Schedule them on different days." : "One post."}
42
  </p>
43
  <div className="row">
44
  {dirty && (
@@ -52,12 +49,6 @@ function PostsEditor({ kit, keyName, onSave }: { kit: Artifact; keyName: PostKey
52
  Save edits
53
  </button>
54
  )}
55
- <CopyButton text={posts.join(meta.thread ? "\n\n---\n\n" : "\n\n")} label="Copy all" />
56
- {keyName !== "substack_notes" && (
57
- <button className="btn btn-small" onClick={() => setSchedule(meta.thread ? posts : [posts.join("\n\n")])}>
58
- Schedule
59
- </button>
60
- )}
61
  </div>
62
  </div>
63
  {posts.map((p, i) => (
@@ -68,14 +59,13 @@ function PostsEditor({ kit, keyName, onSave }: { kit: Artifact; keyName: PostKey
68
  {p.length} / {meta.limit}
69
  </span>
70
  </div>
71
- <textarea className="textarea" rows={Math.min(14, Math.max(3, Math.ceil(p.length / 70)))} value={p} onChange={(e) => edit(i, e.target.value)} />
72
  <div className="row">
73
  <CopyButton text={p} />
74
- {keyName === "substack_notes" && (
75
- <button className="btn btn-small" onClick={() => setSchedule([p])}>
76
- Schedule
77
- </button>
78
- )}
79
  </div>
80
  </div>
81
  ))}
@@ -83,7 +73,7 @@ function PostsEditor({ kit, keyName, onSave }: { kit: Artifact; keyName: PostKey
83
  <ScheduleModal
84
  platform={meta.platform}
85
  posts={schedule}
86
- artifactId={kit.id}
87
  documentId={kit.document_id}
88
  onClose={() => setSchedule(null)}
89
  />
@@ -92,91 +82,11 @@ function PostsEditor({ kit, keyName, onSave }: { kit: Artifact; keyName: PostKey
92
  );
93
  }
94
 
95
- function CarouselTab({ kit }: { kit: Artifact }) {
96
- const c = kit.content as LaunchKitContent;
97
- const [slides, setSlides] = useState(c.carousel ?? []);
98
- const [renders, setRenders] = useState<Artifact[]>([]);
99
- const [error, setError] = useState<string | null>(null);
100
- useEffect(() => {
101
- setSlides(c.carousel ?? []);
102
- if (kit.document_id) artifactsApi.list({ type: "carousel", document_id: kit.document_id }).then((p) => setRenders(p.items));
103
- }, [kit.id, kit.document_id, c.carousel]);
104
-
105
- return (
106
- <div className="stack">
107
- {slides.map((s, i) => (
108
- <div key={i} className="slide-edit">
109
- <span className="mono muted">
110
- Slide {i + 1} / {slides.length}
111
- </span>
112
- <input className="input input-sm" value={s.heading} onChange={(e) => setSlides(slides.map((x, j) => (j === i ? { ...x, heading: e.target.value } : x)))} aria-label={`Slide ${i + 1} heading`} />
113
- <textarea className="textarea" rows={2} value={s.body} onChange={(e) => setSlides(slides.map((x, j) => (j === i ? { ...x, body: e.target.value } : x)))} aria-label={`Slide ${i + 1} body`} />
114
- </div>
115
- ))}
116
- {error && <p className="error-text">{error}</p>}
117
- <div className="row">
118
- <button
119
- className="btn btn-small"
120
- onClick={() => artifactsApi.update(kit.id, { content: { carousel: slides } })}
121
- >
122
- Save slides
123
- </button>
124
- <button
125
- className="btn btn-small btn-primary"
126
- disabled={!slides.length || !kit.document_id}
127
- onClick={async () => {
128
- setError(null);
129
- try {
130
- const a = await artifactsApi.generate({ type: "carousel", document_id: kit.document_id!, slides, parent_id: kit.id });
131
- setRenders((r) => [a, ...r]);
132
- } catch (e) {
133
- setError(errorMessage(e));
134
- }
135
- }}
136
- >
137
- Render slides as images
138
- </button>
139
- </div>
140
- <div className="gallery">
141
- {renders.map((a) => (
142
- <ArtifactCard key={a.id} artifact={a} onRemoved={(id) => setRenders((r) => r.filter((x) => x.id !== id))} />
143
- ))}
144
- </div>
145
- </div>
146
- );
147
- }
148
-
149
- function QuotesTab({ kit }: { kit: Artifact }) {
150
- const c = kit.content as LaunchKitContent;
151
- const [cards, setCards] = useState<Artifact[]>([]);
152
- useEffect(() => {
153
- Promise.all((c.quote_card_ids ?? []).map((id) => artifactsApi.get(id).catch(() => null))).then((list) =>
154
- setCards(list.filter(Boolean) as Artifact[]),
155
- );
156
- }, [c.quote_card_ids]);
157
- return (
158
- <div className="stack">
159
- {(c.quotes ?? []).map((q, i) => (
160
- <blockquote key={i} className="pull">
161
- {q.quote}
162
- <footer className="mono muted">
163
- {q.source.title ?? q.source.path}, lines {q.source.line_start} to {q.source.line_end} <CopyButton text={q.quote} />
164
- </footer>
165
- </blockquote>
166
- ))}
167
- <div className="gallery">
168
- {cards.map((a) => (
169
- <ArtifactCard key={a.id} artifact={a} />
170
- ))}
171
- </div>
172
- </div>
173
- );
174
- }
175
-
176
  function KitView({ kit: initial, onDeleted }: { kit: Artifact; onDeleted: () => void }) {
177
  const [kit, setKit] = useState(initial);
178
- const [tab, setTab] = useState<Tab>("posts");
179
- const [platform, setPlatform] = useState<PostKey>("x_thread");
 
180
  const job = useJob(initial.job, () => artifactsApi.get(initial.id).then(setKit));
181
  useEffect(() => setKit(initial), [initial]);
182
  const c = kit.content as LaunchKitContent;
@@ -192,17 +102,7 @@ function KitView({ kit: initial, onDeleted }: { kit: Artifact; onDeleted: () =>
192
  <div>
193
  <p className="eyebrow">Launch Kit</p>
194
  <h2>{c.post_title ?? kit.title}</h2>
195
- <p className="mono muted">
196
- {formatDate(kit.created_at, true)}
197
- {c.post_url?.startsWith("http") && (
198
- <>
199
- {" · "}
200
- <a href={c.post_url} target="_blank" rel="noreferrer">
201
- View post
202
- </a>
203
- </>
204
- )}
205
- </p>
206
  </div>
207
  <div className="row">
208
  <StatusPill status={running ? "working" : kit.status} />
@@ -219,109 +119,41 @@ function KitView({ kit: initial, onDeleted }: { kit: Artifact; onDeleted: () =>
219
  {job && (running || job.status === "failed") && <JobProgress job={job} />}
220
  {kit.status === "ready" && (
221
  <>
222
- <Tabs<Tab>
223
- tabs={[
224
- { id: "posts", label: "Posts" },
225
- { id: "hooks", label: "Hooks", count: c.hooks?.length },
226
- { id: "visuals", label: "Visuals", count: (c.carousel?.length ?? 0) + (c.quotes?.length ?? 0) },
227
- { id: "seo", label: "SEO" },
228
- ]}
229
- value={tab}
230
- onChange={setTab}
231
- />
232
- {tab === "posts" && (
233
- <div className="gen-chips" role="tablist" aria-label="Platform">
234
- {(Object.keys(PLATFORM_OF) as PostKey[]).map((k) => (
235
- <button key={k} type="button" role="tab" aria-selected={platform === k} className={`chip${platform === k ? " on" : ""}`} onClick={() => setPlatform(k)}>
236
- {PLATFORM_OF[k].label}
237
- </button>
238
- ))}
239
- </div>
240
- )}
241
- {tab === "hooks" && (
242
- <ol className="hooks">
243
- {(c.hooks ?? []).map((h, i) => (
244
- <li key={i}>
245
- <p>{h.text}</p>
246
- <p className="mono muted small">
247
- strength {Math.round(h.strength * 100)} · {h.rationale}
248
- </p>
249
- <CopyButton text={h.text} />
250
- </li>
251
- ))}
252
- </ol>
253
- )}
254
- {tab === "posts" && <PostsEditor kit={kit} keyName={platform} onSave={(p) => savePosts(platform, p)} />}
255
- {tab === "seo" && (
256
- <dl className="seo">
257
- <dt>Title options</dt>
258
- <dd>
259
- <ul>
260
- {(c.seo?.title_options ?? []).map((t) => (
261
- <li key={t}>
262
- {t} <CopyButton text={t} />
263
- </li>
264
- ))}
265
- </ul>
266
- </dd>
267
- <dt>Meta description</dt>
268
- <dd>
269
- {c.seo?.meta_description} <span className="mono muted">({c.seo?.meta_description?.length ?? 0} chars)</span>{" "}
270
- <CopyButton text={c.seo?.meta_description ?? ""} />
271
- </dd>
272
- <dt>Slug</dt>
273
- <dd className="mono">{c.seo?.slug}</dd>
274
- <dt>Keywords</dt>
275
- <dd>
276
- <div className="chips">
277
- {(c.seo?.keywords ?? []).map((k) => (
278
- <span key={k} className="chip">
279
- {k}
280
- </span>
281
- ))}
282
- </div>
283
- </dd>
284
- <dt>Internal links</dt>
285
- <dd>
286
- <ul>
287
- {(c.seo?.internal_links ?? []).map((l, i) => (
288
- <li key={i}>
289
- Link <strong>{l.anchor_text}</strong> to <span className="mono">{l.path}</span>
290
- <span className="muted"> {l.reason}</span>
291
- </li>
292
- ))}
293
- {!c.seo?.internal_links?.length && <li className="muted">No internal link suggestions.</li>}
294
- </ul>
295
- </dd>
296
- </dl>
297
- )}
298
- {tab === "visuals" && (
299
- <>
300
- <h3>Carousel</h3>
301
- <CarouselTab kit={kit} />
302
- <h3>Quotes</h3>
303
- <QuotesTab kit={kit} />
304
- </>
305
  )}
306
- {tab === "posts" && (c.claims?.length ?? 0) > 0 && (
307
- <details className="delivery">
308
- <summary className="mono muted small">Fact check: {c.claims?.length} claims traced to the post</summary>
309
- <ol className="claims">
310
- {(c.claims ?? []).map((cl, i) => (
311
- <li key={i}>
312
- <p>{cl.claim}</p>
313
- {cl.sources.map((s, j) => (
314
- <p key={j} className="muted cite-span">
315
- <span className="mono">
316
- lines {s.line_start} to {s.line_end}:
317
- </span>{" "}
318
- {s.quote}
319
- </p>
320
- ))}
321
- </li>
322
- ))}
323
- </ol>
324
- </details>
325
  )}
326
  </>
327
  )}
@@ -330,143 +162,107 @@ function KitView({ kit: initial, onDeleted }: { kit: Artifact; onDeleted: () =>
330
  }
331
 
332
  /** Pick a post card (one click generates), or reopen an earlier kit from the rail. */
333
- export default function LaunchKit() {
334
- const [params, setParams] = useSearchParams();
335
- const [kits, setKits] = useState<Artifact[] | null>(null);
336
- const [newest, setNewest] = useState<Doc[] | null>(null);
337
- const [post, setPost] = useState<string[]>([]);
338
- const [searching, setSearching] = useState(false);
339
- const [active, setActive] = useState<Artifact | null>(null);
340
  const [busy, setBusy] = useState<string | null>(null);
341
  const [error, setError] = useState<string | null>(null);
342
  const autoStarted = useRef(false);
343
 
344
- const load = useCallback(() => artifactsApi.list({ type: "launch_kit", limit: 100 }).then((p) => setKits(p.items)), []);
345
  useEffect(() => {
346
- load();
347
- docsApi.list({ limit: 24 }).then((p) => setNewest(p.items.filter((d) => !d.locked).slice(0, 6)));
348
- }, [load]);
349
- useEffect(() => {
350
- const id = params.get("kit");
351
- if (id && active?.id !== id) artifactsApi.get(id).then(setActive, () => setActive(null));
352
- if (!id && active) setActive(null);
353
- // eslint-disable-next-line react-hooks/exhaustive-deps
354
- }, [params]);
355
 
356
- const generate = async (documentId: string) => {
357
  setBusy(documentId);
358
  setError(null);
359
  try {
360
  const kit = await artifactsApi.generate({ type: "launch_kit", document_id: documentId });
361
- setKits((k) => [kit, ...(k ?? [])]);
362
- setActive(kit);
363
- setParams({ kit: kit.id });
364
  } catch (e) {
365
  setError(errorMessage(e));
366
- } finally {
367
  setBusy(null);
368
  }
369
- };
370
 
371
- // ?post=<id> (from Resurfacing or a post page) starts the kit straight away.
372
  useEffect(() => {
373
  const id = params.get("post");
374
  if (id && !autoStarted.current) {
375
  autoStarted.current = true;
376
  void generate(id);
377
  }
378
- // eslint-disable-next-line react-hooks/exhaustive-deps
379
- }, []);
380
-
381
- const open = (k: Artifact) => {
382
- setActive(k);
383
- setParams({ kit: k.id });
384
- };
385
 
386
  return (
387
  <div className="page-wrap">
388
- <PageHeader eyebrow="Launch Kit" title="Everything you need to launch a post">
389
- {active && (
390
- <button className="btn btn-primary" onClick={() => setParams({})}>
391
- New kit
392
- </button>
393
- )}
394
- </PageHeader>
395
  {error && <p className="error-text">{error}</p>}
396
- {active ? (
397
- <div className="kit-layout kit-open">
398
- <aside className="card kit-rail">
399
- <p className="eyebrow">Your kits</p>
400
- <ul className="kit-list">
401
- {kits?.map((k) => (
402
- <li key={k.id}>
403
- <button className={`link-btn${active.id === k.id ? " active" : ""}`} onClick={() => open(k)}>
404
- {(k.content.post_title as string | undefined) ?? k.title}
405
- </button>
406
- <span className="mono muted"> {k.status === "ready" ? formatDate(k.created_at) : k.status}</span>
407
- </li>
408
- ))}
409
- </ul>
410
- </aside>
411
- <KitView
412
- key={active.id}
413
- kit={active}
414
- onDeleted={() => {
415
- setParams({});
416
- load();
417
- }}
418
- />
419
  </div>
420
- ) : (
421
- <>
422
- <section className="card stack">
423
- <div className="row between">
424
- <h2>Pick a post to launch</h2>
425
- <button className="link-btn small" onClick={() => setSearching(!searching)}>
426
- {searching ? "Show newest posts" : "Search all posts"}
 
 
 
 
 
427
  </button>
428
- </div>
429
- <p className="muted">Hooks, a thread, LinkedIn, Notes, Bluesky, SEO, a carousel and quote cards, in your voice and grounded in the post.</p>
430
- {searching ? (
431
- <>
432
- <DocPicker selected={post} onChange={setPost} single />
433
- <div className="row end">
434
- <button className="btn btn-primary" disabled={!post.length || busy !== null} onClick={() => generate(post[0])}>
435
- {busy ? "Launching..." : "Generate Launch Kit"}
436
- </button>
437
- </div>
438
- </>
439
- ) : (
440
- <div className="post-cards">
441
- {!newest && <Loading />}
442
- {newest?.length === 0 && <p className="muted">No indexed posts yet. Connect a source first.</p>}
443
- {newest?.map((d) => (
444
- <button key={d.id} type="button" className="post-card" disabled={busy !== null} onClick={() => generate(d.id)}>
445
- <span className="mono muted small">{formatDate(d.published_at)}</span>
446
- <strong className="clamp-3">{d.title}</strong>
447
- <span className="post-card-go">{busy === d.id ? "Launching..." : "Launch this post"}</span>
448
- </button>
449
- ))}
450
- </div>
451
- )}
452
- </section>
453
- {kits && kits.length > 0 && (
454
- <section className="card">
455
- <h2>Earlier kits</h2>
456
- <ul className="kit-list">
457
- {kits.map((k) => (
458
- <li key={k.id}>
459
- <button className="link-btn" onClick={() => open(k)}>
460
- {(k.content.post_title as string | undefined) ?? k.title}
461
- </button>
462
- <span className="mono muted"> {k.status === "ready" ? formatDate(k.created_at) : k.status}</span>
463
- </li>
464
- ))}
465
- </ul>
466
- </section>
467
- )}
468
- </>
469
- )}
470
  </div>
471
  );
472
  }
 
 
 
 
 
 
 
 
 
 
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { Navigate, useNavigate, useParams, useSearchParams } from "react-router-dom";
3
  import { artifactsApi, docsApi } from "../api/endpoints";
4
  import type { Artifact, Doc, LaunchKitContent, Platform } from "../api/types";
 
 
5
  import { ScheduleModal } from "../components/ScheduleModal";
6
+ import { BackArrow, ConfirmButton, CopyButton, errorMessage, formatDate, JobProgress, Loading, PageHeader, StatusPill } from "../components/ui";
7
  import { useJob } from "../hooks/useJob";
8
 
9
  type PostKey = keyof LaunchKitContent["posts"];
 
10
 
11
  const PLATFORM_OF: Record<PostKey, { platform: Platform; label: string; limit: number; thread: boolean }> = {
12
  x_thread: { platform: "x", label: "X thread", limit: 280, thread: true },
 
35
  <div className="stack">
36
  <div className="row between">
37
  <p className="muted">
38
+ {meta.thread ? `${posts.length} posts in the thread.` : keyName === "substack_notes" ? "Each note stands alone. Schedule them on different days." : "The full post, written from the article. Edit it here, then schedule it."}
39
  </p>
40
  <div className="row">
41
  {dirty && (
 
49
  Save edits
50
  </button>
51
  )}
 
 
 
 
 
 
52
  </div>
53
  </div>
54
  {posts.map((p, i) => (
 
59
  {p.length} / {meta.limit}
60
  </span>
61
  </div>
62
+ <textarea className="textarea" rows={Math.min(10, Math.max(3, Math.ceil(p.length / 70)))} value={p} onChange={(e) => edit(i, e.target.value)} />
63
  <div className="row">
64
  <CopyButton text={p} />
65
+ {/* Just this post (as it reads on screen, saved or not) */}
66
+ <button className="btn btn-small" onClick={() => setSchedule([p])}>
67
+ Schedule
68
+ </button>
 
69
  </div>
70
  </div>
71
  ))}
 
73
  <ScheduleModal
74
  platform={meta.platform}
75
  posts={schedule}
76
+ kitId={kit.id} kitTitle={c.post_title ?? kit.title}
77
  documentId={kit.document_id}
78
  onClose={() => setSchedule(null)}
79
  />
 
82
  );
83
  }
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  function KitView({ kit: initial, onDeleted }: { kit: Artifact; onDeleted: () => void }) {
86
  const [kit, setKit] = useState(initial);
87
+ // Kits are worked for LinkedIn only for now (X posting is switched off).
88
+ const platform = "linkedin" as const;
89
+ const [scheduleHook, setScheduleHook] = useState<string | null>(null);
90
  const job = useJob(initial.job, () => artifactsApi.get(initial.id).then(setKit));
91
  useEffect(() => setKit(initial), [initial]);
92
  const c = kit.content as LaunchKitContent;
 
102
  <div>
103
  <p className="eyebrow">Launch Kit</p>
104
  <h2>{c.post_title ?? kit.title}</h2>
105
+ <p className="mono muted">{formatDate(kit.created_at, true)}</p>
 
 
 
 
 
 
 
 
 
 
106
  </div>
107
  <div className="row">
108
  <StatusPill status={running ? "working" : kit.status} />
 
119
  {job && (running || job.status === "failed") && <JobProgress job={job} />}
120
  {kit.status === "ready" && (
121
  <>
122
+ <section className="kit-section" aria-labelledby="kit-post-head">
123
+ <header className="kit-section-head">
124
+ <h3 id="kit-post-head">LinkedIn post</h3>
125
+ </header>
126
+ <PostsEditor kit={kit} keyName={platform} onSave={(p) => savePosts(platform, p)} />
127
+ </section>
128
+ {(c.hooks?.length ?? 0) > 0 && (
129
+ <section className="kit-section" aria-labelledby="kit-hooks-head">
130
+ <header className="kit-section-head">
131
+ <h3 id="kit-hooks-head">Hooks</h3>
132
+ <span className="muted small">{c.hooks?.length} short lines to lead with, strongest first. Each can go out on its own.</span>
133
+ </header>
134
+ <ol className="kit-hooks">
135
+ {(c.hooks ?? []).map((h, i) => (
136
+ <li key={i} className="kit-hook">
137
+ <div className="kit-hook-top">
138
+ <span className="mono small kit-hook-num">Hook {i + 1}</span>
139
+ <span className="mono small kit-hook-strength" title={h.rationale}>
140
+ strength {Math.round(h.strength * 100)}
141
+ </span>
142
+ </div>
143
+ <p className="kit-hook-text">{h.text}</p>
144
+ <p className="muted small">{h.rationale}</p>
145
+ <div className="row">
146
+ <CopyButton text={h.text} />
147
+ <button className="btn btn-small" onClick={() => setScheduleHook(h.text)}>Schedule</button>
148
+ </div>
149
+ </li>
150
+ ))}
151
+ </ol>
152
+ </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  )}
154
+ {scheduleHook !== null && (
155
+ <ScheduleModal platform={PLATFORM_OF[platform].platform} posts={[scheduleHook]} kitId={kit.id} kitTitle={c.post_title ?? kit.title}
156
+ documentId={kit.document_id} onClose={() => setScheduleHook(null)} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  )}
158
  </>
159
  )}
 
162
  }
163
 
164
  /** Pick a post card (one click generates), or reopen an earlier kit from the rail. */
165
+ /** /app/launchpad/kits: create a Launch Kit from one of your posts (rows, searchable). Earlier kits are listed on
166
+ * the Schedule a launch page. ?post=<id> starts that post's kit straight away. */
167
+ export function LaunchKits() {
168
+ const [params] = useSearchParams();
169
+ const navigate = useNavigate();
170
+ const [posts, setPosts] = useState<Doc[] | null>(null);
171
+ const [q, setQ] = useState("");
172
  const [busy, setBusy] = useState<string | null>(null);
173
  const [error, setError] = useState<string | null>(null);
174
  const autoStarted = useRef(false);
175
 
 
176
  useEffect(() => {
177
+ const t = setTimeout(() => {
178
+ docsApi.list({ q: q || undefined, limit: 30 }).then((p) => setPosts(p.items.filter((d) => !d.locked)),
179
+ (e) => setError(errorMessage(e)));
180
+ }, 200);
181
+ return () => clearTimeout(t);
182
+ }, [q]);
 
 
 
183
 
184
+ const generate = useCallback(async (documentId: string) => {
185
  setBusy(documentId);
186
  setError(null);
187
  try {
188
  const kit = await artifactsApi.generate({ type: "launch_kit", document_id: documentId });
189
+ navigate(`/app/launchpad/kits/${kit.id}`); // its page shows the progress, then the kit
 
 
190
  } catch (e) {
191
  setError(errorMessage(e));
 
192
  setBusy(null);
193
  }
194
+ }, [navigate]);
195
 
 
196
  useEffect(() => {
197
  const id = params.get("post");
198
  if (id && !autoStarted.current) {
199
  autoStarted.current = true;
200
  void generate(id);
201
  }
202
+ }, [params, generate]);
 
 
 
 
 
 
203
 
204
  return (
205
  <div className="page-wrap">
206
+ <BackArrow fallback="/app/launchpad" />
207
+ <PageHeader eyebrow="Launch Kit" title="Create a launch kit" />
 
 
 
 
 
208
  {error && <p className="error-text">{error}</p>}
209
+ <section className="card stack">
210
+ <div className="row between wrap">
211
+ <div>
212
+ <h2>Pick a post to launch</h2>
213
+ <p className="muted">A LinkedIn post and hooks, in your voice and grounded in the post.</p>
214
+ </div>
215
+ <input className="input input-sm search" placeholder="Search your posts" value={q}
216
+ onChange={(e) => setQ(e.target.value)} aria-label="Search your posts" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  </div>
218
+ {!posts && !error && <div className="lk-loading"><Loading label="Loading your posts" /></div>}
219
+ {posts?.length === 0 && <p className="muted">{q ? "No posts match." : "No indexed posts yet. Connect a source first."}</p>}
220
+ <ul className="lk-rows lk-rows-scroll">
221
+ {posts?.map((d) => (
222
+ <li key={d.id} className="lk-row">
223
+ <span className="mono muted small lk-date">{formatDate(d.published_at)}</span>
224
+ <span className="lk-title">
225
+ <strong>{d.title}</strong>
226
+ {d.source_title && <span className="muted small">{d.source_title}</span>}
227
+ </span>
228
+ <button className="btn btn-small btn-primary" disabled={busy !== null} onClick={() => generate(d.id)}>
229
+ {busy === d.id ? "Launching..." : "Launch"}
230
  </button>
231
+ </li>
232
+ ))}
233
+ </ul>
234
+ </section>
235
+ </div>
236
+ );
237
+ }
238
+
239
+ /** /app/launchpad/kits/:id: one Launch Kit (LinkedIn post and hooks), scheduled from here. */
240
+ export function LaunchKitPage() {
241
+ const { id = "" } = useParams();
242
+ const navigate = useNavigate();
243
+ const [kit, setKit] = useState<Artifact | null>(null);
244
+ const [error, setError] = useState<string | null>(null);
245
+
246
+ useEffect(() => {
247
+ setKit(null);
248
+ artifactsApi.get(id).then(setKit, (e) => setError(errorMessage(e)));
249
+ }, [id]);
250
+
251
+ return (
252
+ <div className="page-wrap">
253
+ <BackArrow fallback="/app/launchpad/kits" />
254
+ {error && <p className="error-text">{error}</p>}
255
+ {!kit && !error && <div className="loading-center full stacked"><Loading label="Opening the kit" /></div>}
256
+ {kit && <KitView key={kit.id} kit={kit} onDeleted={() => navigate("/app/launchpad/kits", { replace: true })} />}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  </div>
258
  );
259
  }
260
+
261
+ /** The old /app/launch-kit address (?kit=, ?post=) lands on the Launchpad's kit pages. */
262
+ export default function LaunchKitRedirect() {
263
+ const [params] = useSearchParams();
264
+ const kit = params.get("kit");
265
+ const post = params.get("post");
266
+ const to = kit ? `/app/launchpad/kits/${kit}` : post ? `/app/launchpad/kits?post=${post}` : "/app/launchpad/kits";
267
+ return <Navigate to={to} replace />;
268
+ }
frontend/src/pages/Launchpad.tsx CHANGED
@@ -1,83 +1,51 @@
1
- import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
2
- import { Link, useSearchParams } from "react-router-dom";
3
  import { launchpadApi } from "../api/endpoints";
4
- import type { CalendarItem, SocialAccounts } from "../api/types";
5
- import { PLATFORMS, ScheduleModal, toLocalInput } from "../components/ScheduleModal";
6
- import { ConfirmButton, CopyButton, errorMessage, formatDate, Loading, Modal, PageHeader, StatusPill, Tabs } from "../components/ui";
7
- import { ResurfaceIdeas } from "./Resurface";
 
 
8
 
9
  const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
10
 
11
- function monthGrid(month: Date): Date[] {
12
- const first = new Date(month.getFullYear(), month.getMonth(), 1);
13
- const offset = (first.getDay() + 6) % 7; // weeks start on Monday
14
- const start = new Date(first);
15
- start.setDate(first.getDate() - offset);
16
- return Array.from({ length: 42 }, (_, i) => new Date(start.getFullYear(), start.getMonth(), start.getDate() + i));
17
- }
18
-
19
- const sameDay = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
20
-
21
- function BlueskyModal({ onClose, onConnected }: { onClose: () => void; onConnected: (a: SocialAccounts) => void }) {
22
- const [handle, setHandle] = useState("");
23
- const [password, setPassword] = useState("");
24
- const [busy, setBusy] = useState(false);
25
- const [error, setError] = useState<string | null>(null);
26
- const submit = async (e: FormEvent) => {
27
- e.preventDefault();
28
- setBusy(true);
29
- setError(null);
30
- try {
31
- onConnected(await launchpadApi.connectBluesky(handle.trim(), password.trim()));
32
- onClose();
33
- } catch (err) {
34
- setError(errorMessage(err));
35
- } finally {
36
- setBusy(false);
37
- }
38
- };
39
  return (
40
- <Modal title="Connect Bluesky" onClose={onClose}>
41
- <form className="stack" onSubmit={submit}>
42
- <p className="muted">
43
- Create an app password in Bluesky under Settings, Privacy and security, App passwords. Never use your main password.
44
- </p>
45
- <label className="field">
46
- <span>Handle</span>
47
- <input className="input input-sm" required placeholder="you.bsky.social" value={handle} onChange={(e) => setHandle(e.target.value)} />
48
- </label>
49
- <label className="field">
50
- <span>App password</span>
51
- <input className="input input-sm" required type="password" placeholder="xxxx-xxxx-xxxx-xxxx" value={password} onChange={(e) => setPassword(e.target.value)} />
52
- </label>
53
- {error && <p className="error-text">{error}</p>}
54
- <div className="row end">
55
- <button type="button" className="btn" onClick={onClose}>
56
- Cancel
57
- </button>
58
- <button className="btn btn-primary" disabled={busy}>
59
- {busy ? "Checking..." : "Connect"}
60
- </button>
61
- </div>
62
- </form>
63
- </Modal>
64
  );
65
  }
66
 
 
 
67
  function ItemModal({
68
  item,
69
  accounts,
70
  onClose,
71
  onChanged,
 
72
  }: {
73
  item: CalendarItem;
74
  accounts: SocialAccounts | null;
75
  onClose: () => void;
76
  onChanged: (item: CalendarItem | null) => void;
 
77
  }) {
78
  const [posts, setPosts] = useState([item.content, ...item.thread]);
79
- const [when, setWhen] = useState(toLocalInput(new Date(item.scheduled_at)));
 
 
80
  const [accountId, setAccountId] = useState(item.social_account_id ?? "");
 
 
81
  const [busy, setBusy] = useState(false);
82
  const [error, setError] = useState<string | null>(null);
83
  const meta = PLATFORMS.find((p) => p.id === item.platform)!;
@@ -85,6 +53,7 @@ function ItemModal({
85
  const options = (accounts?.accounts ?? []).filter((a) => a.platform === item.platform);
86
 
87
  const save = async () => {
 
88
  setBusy(true);
89
  setError(null);
90
  try {
@@ -93,8 +62,9 @@ function ItemModal({
93
  await launchpadApi.update(item.id, {
94
  content: clean[0],
95
  thread: clean.slice(1),
96
- scheduled_at: new Date(when).toISOString(),
97
  social_account_id: accountId || null,
 
98
  }),
99
  );
100
  onClose();
@@ -114,7 +84,21 @@ function ItemModal({
114
  </span>
115
  <StatusPill status={item.status} />
116
  </div>
117
- {item.error && <p className="error-text">{item.error}</p>}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  {item.external_url && (
119
  <p>
120
  <a href={item.external_url} target="_blank" rel="noreferrer">
@@ -134,6 +118,19 @@ function ItemModal({
134
  <span>{item.clicks} link clicks</span>
135
  </div>
136
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  {posts.map((p, i) => (
138
  <label key={i} className="field">
139
  <span className="field-row">
@@ -147,10 +144,10 @@ function ItemModal({
147
  ))}
148
  {!locked && (
149
  <div className="row">
150
- <label className="field">
151
  <span>When</span>
152
- <input className="input input-sm" type="datetime-local" value={when} onChange={(e) => setWhen(e.target.value)} />
153
- </label>
154
  {meta.auto && (
155
  <label className="field">
156
  <span>Account</span>
@@ -191,8 +188,10 @@ function ItemModal({
191
  try {
192
  const next = await launchpadApi.publishNow(item.id);
193
  onChanged(next);
194
- if (next.status === "failed" || next.error) setError(next.error ?? "Publishing failed.");
195
- else onClose();
 
 
196
  } catch (e) {
197
  setError(errorMessage(e));
198
  } finally {
@@ -209,10 +208,119 @@ function ItemModal({
209
  )}
210
  </div>
211
  </div>
 
 
 
 
 
 
212
  </Modal>
213
  );
214
  }
215
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  export default function Launchpad() {
217
  const [params, setParams] = useSearchParams();
218
  const [month, setMonth] = useState(() => {
@@ -223,10 +331,15 @@ export default function Launchpad() {
223
  const [items, setItems] = useState<CalendarItem[] | null>(null);
224
  const [accounts, setAccounts] = useState<SocialAccounts | null>(null);
225
  const [open, setOpen] = useState<CalendarItem | null>(null);
226
- const [composing, setComposing] = useState(false);
227
- const [bluesky, setBluesky] = useState(false);
228
  const [notice, setNotice] = useState<string | null>(null);
229
  const [error, setError] = useState<string | null>(null);
 
 
 
 
 
 
230
 
231
  const days = useMemo(() => monthGrid(month), [month]);
232
  const load = useCallback(() => {
@@ -238,8 +351,15 @@ export default function Launchpad() {
238
  useEffect(() => {
239
  load();
240
  }, [load]);
 
 
 
 
 
 
 
241
  useEffect(() => {
242
- launchpadApi.accounts().then(setAccounts);
243
  }, []);
244
 
245
  // OAuth return: ?link=<ticket> finishes the connection as the signed in user.
@@ -283,47 +403,14 @@ export default function Launchpad() {
283
  return (
284
  <div className="page-wrap">
285
  <PageHeader eyebrow="Launchpad" title="Launch calendar">
286
- <button className="btn btn-primary" onClick={() => setComposing(true)}>
287
- New post
288
  </button>
289
  </PageHeader>
290
  {notice && <p className="notice">{notice}</p>}
 
291
  {error && <p className="error-text">{error}</p>}
292
 
293
- <section className="lp-accounts" aria-label="Connected accounts">
294
- {accounts?.accounts.map((a) => (
295
- <span key={a.id} className={`lp-chip${a.status !== "active" ? " warn" : ""}`}>
296
- {a.avatar_url && <img src={a.avatar_url} alt="" width={18} height={18} />}
297
- <strong>{a.platform_label}</strong> <span className="mono muted">{a.handle}</span>
298
- {a.status !== "active" && <StatusPill status={a.status === "expired" ? "failed" : a.status} />}
299
- <ConfirmButton
300
- className="link-btn small"
301
- onConfirm={async () => {
302
- await launchpadApi.disconnect(a.id);
303
- setAccounts(await launchpadApi.accounts());
304
- }}
305
- >
306
- ×
307
- </ConfirmButton>
308
- </span>
309
- ))}
310
- {accounts?.available.x && !accounts.accounts.some((a) => a.platform === "x") && (
311
- <button className="lp-chip add" onClick={() => connect("x")}>
312
- + X
313
- </button>
314
- )}
315
- {accounts?.available.linkedin && !accounts.accounts.some((a) => a.platform === "linkedin") && (
316
- <button className="lp-chip add" onClick={() => connect("linkedin")}>
317
- + LinkedIn
318
- </button>
319
- )}
320
- {!accounts?.accounts.some((a) => a.platform === "bluesky") && (
321
- <button className="lp-chip add" onClick={() => setBluesky(true)}>
322
- + Bluesky
323
- </button>
324
- )}
325
- {accounts && accounts.accounts.length === 0 && <span className="muted small">Not connected? Posts arrive as email reminders instead.</span>}
326
- </section>
327
 
328
  <div className="lp-layout">
329
  <section className="card">
@@ -339,13 +426,10 @@ export default function Launchpad() {
339
  </div>
340
  <Tabs tabs={[{ id: "month", label: "Month" }, { id: "agenda", label: "Agenda" }]} value={view} onChange={setView} />
341
  </div>
342
- {!items && <Loading />}
343
  {items && items.length === 0 && (
344
  <div className="lp-empty">
345
  <p className="muted">Nothing scheduled this month.</p>
346
- <Link className="btn btn-small" to="/app/launch-kit">
347
- Schedule your latest Launch Kit
348
- </Link>
349
  </div>
350
  )}
351
  {items && view === "month" && (
@@ -362,10 +446,14 @@ export default function Launchpad() {
362
  <span className="cal-date mono">{d.getDate()}</span>
363
  {dayItems.map((i) => (
364
  <button key={i.id} className={`cal-item cal-${i.status}`} onClick={() => setOpen(i)} title={i.content}>
365
- <span className="mono">
 
366
  {new Date(i.scheduled_at).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}
367
- </span>{" "}
368
- {i.platform_label}
 
 
 
369
  </button>
370
  ))}
371
  </div>
@@ -380,6 +468,7 @@ export default function Launchpad() {
380
  <button className="agenda-row" onClick={() => setOpen(i)}>
381
  <span className="mono muted">{formatDate(i.scheduled_at, true)}</span>
382
  <strong>{i.platform_label}</strong>
 
383
  <span className="agenda-text">{i.content}</span>
384
  <StatusPill status={i.status} />
385
  {i.clicks > 0 && <span className="mono muted">{i.clicks} clicks</span>}
@@ -392,15 +481,36 @@ export default function Launchpad() {
392
  {items && <p className="mono muted small">{upcoming.length} upcoming in view</p>}
393
  </section>
394
 
395
- <aside className="card stack lp-ideas" id="ideas">
396
- <h2>Ideas worth resharing</h2>
397
- <ResurfaceIdeas onScheduled={() => load()} />
398
- </aside>
399
  </div>
400
 
401
- {open && <ItemModal item={open} accounts={accounts} onClose={() => setOpen(null)} onChanged={changed} />}
402
- {composing && <ScheduleModal platform="x" posts={[""]} onClose={() => setComposing(false)} onScheduled={() => load()} />}
403
- {bluesky && <BlueskyModal onClose={() => setBluesky(false)} onConnected={setAccounts} />}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
  </div>
405
  );
406
  }
 
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { useNavigate, useSearchParams } from "react-router-dom";
3
  import { launchpadApi } from "../api/endpoints";
4
+ import type { CalendarItem, PostableArtifact, SocialAccounts } from "../api/types";
5
+ import { FilmIcon } from "../components/icons/Icons";
6
+ import { AttachmentChip, AttachPicker, LibraryPicker } from "../components/launchpad/Attachment";
7
+ import { PLATFORMS, ScheduleModal } from "../components/ScheduleModal";
8
+ import { monthGrid, SlotPicker, toWall, userTimeZone, wallToDate } from "../components/launchpad/SlotPicker";
9
+ import { BackArrow, ConfirmButton, CopyButton, errorMessage, formatDate, Loading, Modal, PageHeader, StatusPill, Tabs } from "../components/ui";
10
 
11
  const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
12
 
13
+ /** A small mark on a calendar item that carries a video or images. */
14
+ function MediaMark({ item }: { item: CalendarItem }) {
15
+ if (!item.artifact || item.artifact.media === "text") return null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  return (
17
+ <span className="cal-media" title={item.artifact.media === "video" ? "With a video" : "With images"}>
18
+ {item.artifact.media === "video" ? <FilmIcon size={12} /> : (
19
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
20
+ <rect x="3" y="4" width="18" height="16" rx="2.5" /><path d="M3 16l5-5 4 4 3-3 6 6" />
21
+ </svg>
22
+ )}
23
+ </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  );
25
  }
26
 
27
+ const sameDay = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
28
+
29
  function ItemModal({
30
  item,
31
  accounts,
32
  onClose,
33
  onChanged,
34
+ onReconnect,
35
  }: {
36
  item: CalendarItem;
37
  accounts: SocialAccounts | null;
38
  onClose: () => void;
39
  onChanged: (item: CalendarItem | null) => void;
40
+ onReconnect: (platform: "x" | "linkedin") => void;
41
  }) {
42
  const [posts, setPosts] = useState([item.content, ...item.thread]);
43
+ // Its time on the user's clock; one made before half-hour slots stays as it is until a new slot is picked.
44
+ const [when, setWhen] = useState(() => toWall(new Date(item.scheduled_at)));
45
+ const moved = when !== toWall(new Date(item.scheduled_at));
46
  const [accountId, setAccountId] = useState(item.social_account_id ?? "");
47
+ const [attached, setAttached] = useState<PostableArtifact | null>(item.artifact);
48
+ const [picking, setPicking] = useState(false);
49
  const [busy, setBusy] = useState(false);
50
  const [error, setError] = useState<string | null>(null);
51
  const meta = PLATFORMS.find((p) => p.id === item.platform)!;
 
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
  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
  }),
69
  );
70
  onClose();
 
84
  </span>
85
  <StatusPill status={item.status} />
86
  </div>
87
+ {item.kit_id && (
88
+ <p className="muted small lp-from-kit">Written from the launch kit: {item.kit_title ?? "a launch kit"}</p>
89
+ )}
90
+ {item.status === "paused" ? (
91
+ <div className="lp-paused">
92
+ <span>{item.error ?? "Paused: it won't go out until resumed."}</span>
93
+ {(item.platform === "x" || item.platform === "linkedin")
94
+ && accounts?.accounts.some((a) => a.platform === item.platform && a.needs_reconnect) && (
95
+ <button className="btn btn-small btn-primary" onClick={() => onReconnect(item.platform as "x" | "linkedin")}>
96
+ Reconnect {item.platform_label}
97
+ </button>
98
+ )}
99
+ <span className="muted small">Pick a time still ahead and Save to resume it.</span>
100
+ </div>
101
+ ) : item.error && <p className="error-text">{item.error}</p>}
102
  {item.external_url && (
103
  <p>
104
  <a href={item.external_url} target="_blank" rel="noreferrer">
 
118
  <span>{item.clicks} link clicks</span>
119
  </div>
120
  )}
121
+ {(attached || !locked) && (
122
+ <div className="field">
123
+ <span className="vw-label">Attachment</span>
124
+ {attached ? (
125
+ <AttachmentChip a={attached} onChange={locked ? undefined : () => setPicking(true)}
126
+ onRemove={locked ? undefined : () => setAttached(null)} />
127
+ ) : (
128
+ <button type="button" className="btn lp-attach-btn" onClick={() => setPicking(true)}>
129
+ + Attach from your Library
130
+ </button>
131
+ )}
132
+ </div>
133
+ )}
134
  {posts.map((p, i) => (
135
  <label key={i} className="field">
136
  <span className="field-row">
 
144
  ))}
145
  {!locked && (
146
  <div className="row">
147
+ <div className="field">
148
  <span>When</span>
149
+ <SlotPicker value={when} onChange={setWhen} />
150
+ </div>
151
  {meta.auto && (
152
  <label className="field">
153
  <span>Account</span>
 
188
  try {
189
  const next = await launchpadApi.publishNow(item.id);
190
  onChanged(next);
191
+ // With a video or images it uploads in the background; the calendar follows it.
192
+ if (next.status === "failed" || (next.error && next.status !== "publishing")) {
193
+ setError(next.error ?? "Publishing failed.");
194
+ } else onClose();
195
  } catch (e) {
196
  setError(errorMessage(e));
197
  } finally {
 
208
  )}
209
  </div>
210
  </div>
211
+ {picking && (
212
+ <AttachPicker onClose={() => setPicking(false)} onPick={(a) => {
213
+ setAttached(a);
214
+ setPicking(false);
215
+ }} />
216
+ )}
217
  </Modal>
218
  );
219
  }
220
 
221
+ const CONNECT: { id: "x" | "linkedin"; label: string; blurb: string }[] = [
222
+ { id: "linkedin", label: "LinkedIn", blurb: "Post and schedule to LinkedIn from your Library." },
223
+ ];
224
+
225
+ /** The right column: LinkedIn (X is switched off for now), connected (who, status, expiry, Reconnect / Disconnect) or a Connect button. */
226
+ function ConnectionsPanel({ accounts, onConnect, onChanged }: {
227
+ accounts: SocialAccounts | null;
228
+ onConnect: (platform: "x" | "linkedin") => void;
229
+ onChanged: () => void;
230
+ }) {
231
+ const [disconnecting, setDisconnecting] = useState<SocialAccounts["accounts"][number] | null>(null);
232
+ const [busy, setBusy] = useState(false);
233
+ const [error, setError] = useState<string | null>(null);
234
+ const closeDisconnect = () => {
235
+ setDisconnecting(null);
236
+ setError(null);
237
+ };
238
+ const disconnect = async () => {
239
+ if (!disconnecting) return;
240
+ setBusy(true);
241
+ setError(null);
242
+ try {
243
+ await launchpadApi.disconnect(disconnecting.id);
244
+ closeDisconnect();
245
+ onChanged();
246
+ } catch (e) {
247
+ setError(errorMessage(e));
248
+ } finally {
249
+ setBusy(false);
250
+ }
251
+ };
252
+ return (
253
+ <aside className="card stack lp-connections" aria-label="Connected accounts">
254
+ <h2>Connections</h2>
255
+ <p className="muted small">Posts go out through these accounts at their scheduled time.</p>
256
+ {!accounts && <div className="loading-center inline stacked"><Loading label="Loading your connections" /></div>}
257
+ {accounts && CONNECT.map((c) => {
258
+ const a = accounts.accounts.find((x) => x.platform === c.id);
259
+ const days = a?.expires_at ? Math.max(0, Math.ceil((new Date(a.expires_at).getTime() - Date.now()) / 864e5)) : null;
260
+ return (
261
+ <section key={c.id} className="lp-conn">
262
+ <div className="lp-conn-head">
263
+ <strong>{c.label}</strong>
264
+ {a && <StatusPill status={a.status === "active" ? "connected" : a.status === "revoked" ? "disconnected" : a.status} />}
265
+ </div>
266
+ {a ? (
267
+ <>
268
+ <div className="lp-conn-who">
269
+ {a.avatar_url ? <img src={a.avatar_url} alt="" width={32} height={32} /> : <span className="lp-conn-dot" aria-hidden="true" />}
270
+ <span className="lp-attach-text">
271
+ <strong>{a.handle}</strong>
272
+ <span className="muted small">
273
+ {a.connected_at ? `Connected since ${formatDate(a.connected_at)}` : "Connected"}
274
+ {a.status === "active" && a.expires_soon && days !== null ? ` · expires in ${days} days` : ""}
275
+ </span>
276
+ </span>
277
+ </div>
278
+ {a.status === "active" && !a.can_post_media && (
279
+ <p className="small lp-warn">Can't post images and videos yet: reconnect to allow it.</p>
280
+ )}
281
+ {a.status !== "active" && (
282
+ <p className="small lp-warn">Its scheduled posts are paused until you reconnect.</p>
283
+ )}
284
+ <div className="row end">
285
+ {a.needs_reconnect && (
286
+ <button className="btn btn-small btn-primary" onClick={() => onConnect(c.id)}>Reconnect</button>
287
+ )}
288
+ {a.status === "active" && (
289
+ <button className="btn btn-small" onClick={() => setDisconnecting(a)}>Disconnect</button>
290
+ )}
291
+ </div>
292
+ </>
293
+ ) : (
294
+ <>
295
+ <p className="muted small">{c.blurb}</p>
296
+ <button className="btn btn-primary" disabled={!accounts.available[c.id]} onClick={() => onConnect(c.id)}>
297
+ Connect with {c.label}
298
+ </button>
299
+ </>
300
+ )}
301
+ </section>
302
+ );
303
+ })}
304
+ {disconnecting && (
305
+ <Modal title={`Disconnect ${PLATFORMS.find((p) => p.id === disconnecting.platform)?.label ?? "account"}?`} onClose={closeDisconnect}>
306
+ <div className="stack">
307
+ <p>
308
+ <strong>{disconnecting.handle}</strong> will be disconnected. Its scheduled posts pause until you reconnect.
309
+ </p>
310
+ {error && <p className="error-text">{error}</p>}
311
+ <div className="row end">
312
+ <button type="button" className="btn" onClick={closeDisconnect} disabled={busy}>Cancel</button>
313
+ <button type="button" className="btn btn-danger" onClick={disconnect} disabled={busy}>
314
+ {busy ? "Disconnecting..." : "Disconnect"}
315
+ </button>
316
+ </div>
317
+ </div>
318
+ </Modal>
319
+ )}
320
+ </aside>
321
+ );
322
+ }
323
+
324
  export default function Launchpad() {
325
  const [params, setParams] = useSearchParams();
326
  const [month, setMonth] = useState(() => {
 
331
  const [items, setItems] = useState<CalendarItem[] | null>(null);
332
  const [accounts, setAccounts] = useState<SocialAccounts | null>(null);
333
  const [open, setOpen] = useState<CalendarItem | null>(null);
334
+ const navigate = useNavigate();
 
335
  const [notice, setNotice] = useState<string | null>(null);
336
  const [error, setError] = useState<string | null>(null);
337
+ // Success notices ("LinkedIn connected.") clear themselves after 3 seconds.
338
+ useEffect(() => {
339
+ if (!notice) return;
340
+ const t = setTimeout(() => setNotice(null), 3000);
341
+ return () => clearTimeout(t);
342
+ }, [notice]);
343
 
344
  const days = useMemo(() => monthGrid(month), [month]);
345
  const load = useCallback(() => {
 
351
  useEffect(() => {
352
  load();
353
  }, [load]);
354
+ // Posts with a video or images upload in the background: check back until they are posted (or failed).
355
+ const publishing = (items ?? []).some((i) => i.status === "publishing");
356
+ useEffect(() => {
357
+ if (!publishing) return;
358
+ const t = setTimeout(() => load(), 5000);
359
+ return () => clearTimeout(t);
360
+ }, [publishing, items, load]);
361
  useEffect(() => {
362
+ launchpadApi.accounts().then(setAccounts, (e) => setError(errorMessage(e)));
363
  }, []);
364
 
365
  // OAuth return: ?link=<ticket> finishes the connection as the signed in user.
 
403
  return (
404
  <div className="page-wrap">
405
  <PageHeader eyebrow="Launchpad" title="Launch calendar">
406
+ <button className="btn btn-primary" onClick={() => navigate("/app/launchpad/new")}>
407
+ Schedule a launch
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
 
415
  <div className="lp-layout">
416
  <section className="card">
 
426
  </div>
427
  <Tabs tabs={[{ id: "month", label: "Month" }, { id: "agenda", label: "Agenda" }]} value={view} onChange={setView} />
428
  </div>
429
+ {!items && <div className="loading-center stacked lp-cal-loading"><Loading label="Loading your calendar" /></div>}
430
  {items && items.length === 0 && (
431
  <div className="lp-empty">
432
  <p className="muted">Nothing scheduled this month.</p>
 
 
 
433
  </div>
434
  )}
435
  {items && view === "month" && (
 
446
  <span className="cal-date mono">{d.getDate()}</span>
447
  {dayItems.map((i) => (
448
  <button key={i.id} className={`cal-item cal-${i.status}`} onClick={() => setOpen(i)} title={i.content}>
449
+ {/* Its time, then what it is ("LinkedIn post"), each on its own line */}
450
+ <span className="mono cal-item-time">
451
  {new Date(i.scheduled_at).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}
452
+ </span>
453
+ <span className="cal-item-title">
454
+ {i.platform_label} post
455
+ <MediaMark item={i} />
456
+ </span>
457
  </button>
458
  ))}
459
  </div>
 
468
  <button className="agenda-row" onClick={() => setOpen(i)}>
469
  <span className="mono muted">{formatDate(i.scheduled_at, true)}</span>
470
  <strong>{i.platform_label}</strong>
471
+ <MediaMark item={i} />
472
  <span className="agenda-text">{i.content}</span>
473
  <StatusPill status={i.status} />
474
  {i.clicks > 0 && <span className="mono muted">{i.clicks} clicks</span>}
 
481
  {items && <p className="mono muted small">{upcoming.length} upcoming in view</p>}
482
  </section>
483
 
484
+ <ConnectionsPanel accounts={accounts} onConnect={connect} onChanged={async () => {
485
+ setAccounts(await launchpadApi.accounts());
486
+ load();
487
+ }} />
488
  </div>
489
 
490
+ {open && <ItemModal item={open} accounts={accounts} onClose={() => setOpen(null)} onChanged={changed}
491
+ onReconnect={connect} />}
492
+ </div>
493
+ );
494
+ }
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
  }
frontend/src/pages/MissionControl.tsx CHANGED
@@ -131,7 +131,7 @@ export default function MissionControl() {
131
  const launch = (doc: Doc) =>
132
  run(`kit-${doc.id}`, async () => {
133
  const kit = await artifactsApi.generate({ type: "launch_kit", document_id: doc.id });
134
- navigate(`/app/launch-kit?kit=${kit.id}`);
135
  });
136
 
137
  const totalDocs = (sources ?? []).reduce((n, s) => n + s.document_count, 0);
@@ -153,7 +153,7 @@ export default function MissionControl() {
153
  ? {
154
  text: `Your Launch Kit for "${lastKit.title}" is ready. Put it on the calendar.`,
155
  action: (
156
- <Link className="btn btn-primary" to={`/app/launch-kit?kit=${lastKit.id}`}>
157
  Schedule it
158
  </Link>
159
  ),
@@ -281,7 +281,7 @@ export default function MissionControl() {
281
  <ul className="recent-list">
282
  {recent.map((a) => (
283
  <li key={a.id}>
284
- <Link to={a.type === "launch_kit" ? `/app/launch-kit?kit=${a.id}` : a.notebook_id ? `/app/notebooks/${a.notebook_id}` : "/app/archive"}>{a.title}</Link>
285
  <span className="row">
286
  <span className="mono muted">{formatDate(a.created_at)}</span>
287
  <StatusPill status={a.status} />
 
131
  const launch = (doc: Doc) =>
132
  run(`kit-${doc.id}`, async () => {
133
  const kit = await artifactsApi.generate({ type: "launch_kit", document_id: doc.id });
134
+ navigate(`/app/launchpad/kits/${kit.id}`);
135
  });
136
 
137
  const totalDocs = (sources ?? []).reduce((n, s) => n + s.document_count, 0);
 
153
  ? {
154
  text: `Your Launch Kit for "${lastKit.title}" is ready. Put it on the calendar.`,
155
  action: (
156
+ <Link className="btn btn-primary" to={`/app/launchpad/kits/${lastKit.id}`}>
157
  Schedule it
158
  </Link>
159
  ),
 
281
  <ul className="recent-list">
282
  {recent.map((a) => (
283
  <li key={a.id}>
284
+ <Link to={a.type === "launch_kit" ? `/app/launchpad/kits/${a.id}` : a.notebook_id ? `/app/notebooks/${a.notebook_id}` : "/app/archive"}>{a.title}</Link>
285
  <span className="row">
286
  <span className="mono muted">{formatDate(a.created_at)}</span>
287
  <StatusPill status={a.status} />
frontend/src/pages/NotebookView.tsx CHANGED
@@ -14,6 +14,7 @@ import { Reader } from "../components/Reader";
14
  import { seedDocs } from "../components/video/sourceCache";
15
  import { ConfirmButton, errorMessage, formatDate, Loading, Modal } from "../components/ui";
16
  import { VideoCreateForm } from "./VideoCreate";
 
17
 
18
  type Turn = {
19
  id?: string; // the saved message, once there is one: what a thumbs up or down is attached to
@@ -216,7 +217,7 @@ function StudioPanel({ notebookId, docs, disabled, onCreateVideo }: {
216
  artifact={a}
217
  onCite={cite}
218
  onRemoved={(id) => setArtifacts((list) => (list ?? []).filter((x) => x.id !== id))}
219
- actions={(art) =>
220
  art.type === "audio_overview" && art.status === "ready" ? (
221
  <button className="btn btn-small" onClick={() => make({ type: "video", style: "audiogram", audio_artifact_id: art.id })}>
222
  Audiogram
 
14
  import { seedDocs } from "../components/video/sourceCache";
15
  import { ConfirmButton, errorMessage, formatDate, Loading, Modal } from "../components/ui";
16
  import { VideoCreateForm } from "./VideoCreate";
17
+ import { canPost, PostButton } from "../components/launchpad/PostButton";
18
 
19
  type Turn = {
20
  id?: string; // the saved message, once there is one: what a thumbs up or down is attached to
 
217
  artifact={a}
218
  onCite={cite}
219
  onRemoved={(id) => setArtifacts((list) => (list ?? []).filter((x) => x.id !== id))}
220
+ actions={(art) => canPost(art) ? <PostButton artifactId={art.id} /> :
221
  art.type === "audio_overview" && art.status === "ready" ? (
222
  <button className="btn btn-small" onClick={() => make({ type: "video", style: "audiogram", audio_artifact_id: art.id })}>
223
  Audiogram
frontend/src/pages/Resurface.tsx CHANGED
@@ -30,7 +30,7 @@ function Row({ item, onRead, onSchedule }: { item: ResurfaceItem; onRead: () =>
30
  </p>
31
  {item.angle && <p className="angle">{item.angle}</p>}
32
  <div className="row">
33
- <button className="btn btn-small btn-primary" onClick={() => navigate(`/app/launch-kit?post=${item.id}`)}>
34
  Launch Kit
35
  </button>
36
  <button className="btn btn-small" onClick={onSchedule}>
@@ -91,7 +91,7 @@ export function ResurfaceIdeas({ onScheduled }: { onScheduled?: () => void }) {
91
  {reading && <Reader documentId={reading} onClose={() => setReading(null)} />}
92
  {scheduling && (
93
  <ScheduleModal
94
- platform="x"
95
  posts={[`${scheduling.angle ?? scheduling.title}\n\n${scheduling.url.startsWith("http") ? scheduling.url : ""}`.trim()]}
96
  documentId={scheduling.id}
97
  onClose={() => setScheduling(null)}
 
30
  </p>
31
  {item.angle && <p className="angle">{item.angle}</p>}
32
  <div className="row">
33
+ <button className="btn btn-small btn-primary" onClick={() => navigate(`/app/launchpad/kits?post=${item.id}`)}>
34
  Launch Kit
35
  </button>
36
  <button className="btn btn-small" onClick={onSchedule}>
 
91
  {reading && <Reader documentId={reading} onClose={() => setReading(null)} />}
92
  {scheduling && (
93
  <ScheduleModal
94
+ platform="linkedin"
95
  posts={[`${scheduling.angle ?? scheduling.title}\n\n${scheduling.url.startsWith("http") ? scheduling.url : ""}`.trim()]}
96
  documentId={scheduling.id}
97
  onClose={() => setScheduling(null)}
frontend/src/pages/Studio.tsx CHANGED
@@ -8,6 +8,7 @@ import { Dropdown } from "../components/Dropdown";
8
  import { LaunchWindowIcon, PlanetIcon, RocketIcon } from "../components/icons/Icons";
9
  import { Reader } from "../components/Reader";
10
  import { errorMessage, PageHeader } from "../components/ui";
 
11
 
12
  type Kind = "audio" | "video" | "quote";
13
 
@@ -100,7 +101,7 @@ export default function Studio() {
100
  <strong>{k.label}</strong>
101
  </button>
102
  ))}
103
- <Link to="/app/launch-kit" className="format-tile">
104
  <span className="mc-more" aria-hidden="true">
105
  +
106
  </span>
@@ -173,7 +174,7 @@ export default function Studio() {
173
  onCite={(c) => c.document_id && setReading({ id: c.document_id, start: c.line_start, end: c.line_end })}
174
  onRemoved={(id) => setRecent((g) => (g ?? []).filter((x) => x.id !== id))}
175
  onChanged={(next) => setRecent((g) => (g ?? []).map((x) => (x.id === next.id ? next : x)))}
176
- actions={(art) =>
177
  art.type === "audio_overview" && art.status === "ready" ? (
178
  <button
179
  className="btn btn-small"
 
8
  import { LaunchWindowIcon, PlanetIcon, RocketIcon } from "../components/icons/Icons";
9
  import { Reader } from "../components/Reader";
10
  import { errorMessage, PageHeader } from "../components/ui";
11
+ import { canPost, PostButton } from "../components/launchpad/PostButton";
12
 
13
  type Kind = "audio" | "video" | "quote";
14
 
 
101
  <strong>{k.label}</strong>
102
  </button>
103
  ))}
104
+ <Link to="/app/launchpad/kits" className="format-tile">
105
  <span className="mc-more" aria-hidden="true">
106
  +
107
  </span>
 
174
  onCite={(c) => c.document_id && setReading({ id: c.document_id, start: c.line_start, end: c.line_end })}
175
  onRemoved={(id) => setRecent((g) => (g ?? []).filter((x) => x.id !== id))}
176
  onChanged={(next) => setRecent((g) => (g ?? []).map((x) => (x.id === next.id ? next : x)))}
177
+ actions={(art) => canPost(art) ? <PostButton artifactId={art.id} /> :
178
  art.type === "audio_overview" && art.status === "ready" ? (
179
  <button
180
  className="btn btn-small"
frontend/src/pages/TopicMap.tsx CHANGED
@@ -899,7 +899,7 @@ export default function TopicMap() {
899
  {acting === "audio" ? "Starting..." : "Audio about this"}
900
  </button>
901
  {selected.posts[0] && (
902
- <button className="btn btn-small" disabled={acting !== null} onClick={() => navigate(`/app/launch-kit?post=${selected.posts[0].id}`)}>
903
  Launch Kit for the latest post
904
  </button>
905
  )}
 
899
  {acting === "audio" ? "Starting..." : "Audio about this"}
900
  </button>
901
  {selected.posts[0] && (
902
+ <button className="btn btn-small" disabled={acting !== null} onClick={() => navigate(`/app/launchpad/kits?post=${selected.posts[0].id}`)}>
903
  Launch Kit for the latest post
904
  </button>
905
  )}
frontend/src/pages/VideoEditor.tsx CHANGED
@@ -10,6 +10,7 @@ import { LookPanel } from "../components/video/SettingsPanel";
10
  import { SceneList } from "../components/video/SceneList";
11
  import { PhoneIcon, Premium, ScreenIcon } from "../components/video/parts";
12
  import { usePoll } from "../hooks/usePoll";
 
13
  import { useUpgrade } from "../hooks/useUpgrade";
14
 
15
  const STEPS = ["Reading the source", "Writing the script", "Building scenes", "Finishing"];
@@ -17,16 +18,6 @@ const REVIEW = new Set(["awaiting_stock_footage_review", "awaiting_script_review
17
 
18
  type Panel = "scenes" | "images" | "audio" | "settings";
19
 
20
- /** blog2video job endpoints report progress in slightly different shapes; any of these means it stopped. */
21
- function finished(s: VideoJobState | null): boolean {
22
- if (!s) return true; // e.g. add-status answers null once nothing is running
23
- // Voice change, voiceover delete and language change say it with active / done. Their `status` is the PROJECT's
24
- // (e.g. "done" on a video rendered before), so it must not be read as the job's.
25
- if (typeof s.active === "boolean") return s.done === true || !s.active;
26
- return s.done === true || s.running === false || !!s.video_url ||
27
- ["done", "completed", "complete", "ready", "failed", "error", "idle"].includes(String(s.status ?? ""));
28
- }
29
-
30
  function busyMessage(e: unknown): string {
31
  return e instanceof ApiError && e.status === 409 ? "Another change is still running, try again in a moment." : errorMessage(e);
32
  }
@@ -49,17 +40,6 @@ function jobDetail(s: VideoJobState | null): string | null {
49
  }
50
 
51
  /** How far a job is, 0-100, from whichever shape its status endpoint uses; null when it does not say. */
52
- function jobPercent(s: VideoJobState | null): number | null {
53
- if (!s) return null;
54
- const n = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : null);
55
- const pct = n(s.progress) ?? ratio(n(s.processed_scenes), n(s.total_scenes)) ?? ratio(n(s.processed), n(s.total));
56
- return pct === null ? null : Math.max(0, Math.min(100, Math.round(pct)));
57
- }
58
-
59
- function ratio(done: number | null, total: number | null): number | null {
60
- return done !== null && total ? (done / total) * 100 : null;
61
- }
62
-
63
  // How a job's progress is drawn: "bar" = the full progress screen in place of the editor; "inline" = the editor stays
64
  // (rendering: the preview is final, the Rendering pill under it shows progress). A new kind of job adds its own.
65
  type JobView = "bar" | "inline";
 
10
  import { SceneList } from "../components/video/SceneList";
11
  import { PhoneIcon, Premium, ScreenIcon } from "../components/video/parts";
12
  import { usePoll } from "../hooks/usePoll";
13
+ import { finished, jobPercent } from "../components/video/jobState";
14
  import { useUpgrade } from "../hooks/useUpgrade";
15
 
16
  const STEPS = ["Reading the source", "Writing the script", "Building scenes", "Finishing"];
 
18
 
19
  type Panel = "scenes" | "images" | "audio" | "settings";
20
 
 
 
 
 
 
 
 
 
 
 
21
  function busyMessage(e: unknown): string {
22
  return e instanceof ApiError && e.status === 409 ? "Another change is still running, try again in a moment." : errorMessage(e);
23
  }
 
40
  }
41
 
42
  /** How far a job is, 0-100, from whichever shape its status endpoint uses; null when it does not say. */
 
 
 
 
 
 
 
 
 
 
 
43
  // How a job's progress is drawn: "bar" = the full progress screen in place of the editor; "inline" = the editor stays
44
  // (rendering: the preview is final, the Rendering pill under it shows progress). A new kind of job adds its own.
45
  type JobView = "bar" | "inline";
frontend/src/pages/Videos.tsx CHANGED
@@ -5,6 +5,7 @@ import type { VideoListItem, VideoQuota } from "../api/types";
5
  import { TrashIcon } from "../components/icons/Icons";
6
  import { EmptyState, errorMessage, formatDate, Loading, Modal } from "../components/ui";
7
  import { useUpgrade } from "../hooks/useUpgrade";
 
8
 
9
  const JOB_LABELS: Record<string, string> = {
10
  regenerating: "Changing template",
@@ -93,7 +94,7 @@ export default function Videos() {
93
  </div>
94
  {notice && <p className="notice">{notice}</p>}
95
  {error && <p className="error-text">{error}</p>}
96
- {!videos && !error && <div className="loading-center"><Loading label="Loading videos" /></div>}
97
  {videos?.length === 0 && (
98
  <EmptyState
99
  title="No videos yet"
@@ -120,6 +121,7 @@ export default function Videos() {
120
  {v.created_at && <span>{formatDate(v.created_at)}</span>}
121
  </span>
122
  </Link>
 
123
  <button type="button" className="icon-btn vw-list-delete" aria-label={`Delete ${v.title}`} title="Delete"
124
  onClick={() => askDelete(v)}>
125
  <TrashIcon size={18} />
 
5
  import { TrashIcon } from "../components/icons/Icons";
6
  import { EmptyState, errorMessage, formatDate, Loading, Modal } from "../components/ui";
7
  import { useUpgrade } from "../hooks/useUpgrade";
8
+ import { PostButton } from "../components/launchpad/PostButton";
9
 
10
  const JOB_LABELS: Record<string, string> = {
11
  regenerating: "Changing template",
 
94
  </div>
95
  {notice && <p className="notice">{notice}</p>}
96
  {error && <p className="error-text">{error}</p>}
97
+ {!videos && !error && <div className="loading-center stacked"><Loading label="Loading videos" /></div>}
98
  {videos?.length === 0 && (
99
  <EmptyState
100
  title="No videos yet"
 
121
  {v.created_at && <span>{formatDate(v.created_at)}</span>}
122
  </span>
123
  </Link>
124
+ {st.tone === "done" && <PostButton artifactId={v.id} />}
125
  <button type="button" className="icon-btn vw-list-delete" aria-label={`Delete ${v.title}`} title="Delete"
126
  onClick={() => askDelete(v)}>
127
  <TrashIcon size={18} />
frontend/src/pages/VoiceProfile.tsx CHANGED
@@ -488,7 +488,7 @@ export default function VoiceProfile() {
488
  loadVoices(); // a finished clone is saved to Your voices as "My voice"
489
  });
490
 
491
- if (!state) return <Loading />;
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);
 
488
  loadVoices(); // a finished clone is saved to Your voices as "My voice"
489
  });
490
 
491
+ if (!state) return <div className="loading-center stacked"><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);
frontend/src/pages/Welcome.tsx CHANGED
@@ -177,7 +177,7 @@ export default function Welcome() {
177
  const finish = async (chosen: Goal | null) => {
178
  markOnboarded();
179
  if (!chosen || posts === 0) return navigate("/app", { replace: true });
180
- if (chosen === "launch") return navigate("/app/launch-kit", { replace: true });
181
  setBusy(true);
182
  try {
183
  const docs = await docsApi.list({ limit: 5000 });
 
177
  const finish = async (chosen: Goal | null) => {
178
  markOnboarded();
179
  if (!chosen || posts === 0) return navigate("/app", { replace: true });
180
+ if (chosen === "launch") return navigate("/app/launchpad/kits", { replace: true });
181
  setBusy(true);
182
  try {
183
  const docs = await docsApi.list({ limit: 5000 });
frontend/src/styles/pages.css CHANGED
@@ -66,7 +66,7 @@
66
 
67
  @media (min-width: 720px) {
68
  .grid-cards {
69
- grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
70
  }
71
 
72
  .gallery {
@@ -410,6 +410,13 @@ select.input option {
410
  color: var(--black);
411
  }
412
 
 
 
 
 
 
 
 
413
  @keyframes pill-pulse {
414
  50% {
415
  opacity: 0.5;
@@ -458,6 +465,11 @@ select.input option {
458
  min-height: 80vh;
459
  }
460
 
 
 
 
 
 
461
  /* Inside a card (a tab still loading): centred, without claiming most of the window */
462
  .loading-center.inline {
463
  min-height: 200px;
@@ -1114,30 +1126,6 @@ select.input option {
1114
  }
1115
 
1116
  /* Launch Kit */
1117
- .kit-layout {
1118
- display: grid;
1119
- gap: 16px;
1120
- align-items: start;
1121
- }
1122
-
1123
- @media (min-width: 1100px) {
1124
- .kit-layout {
1125
- grid-template-columns: 340px minmax(0, 1fr);
1126
- }
1127
- }
1128
-
1129
- .kit-list {
1130
- list-style: none;
1131
- margin: 12px 0 0;
1132
- padding: 0;
1133
- display: grid;
1134
- gap: 8px;
1135
- font-size: 0.875rem;
1136
- }
1137
-
1138
- .kit-list .link-btn {
1139
- text-align: left;
1140
- }
1141
 
1142
  .post-edit,
1143
  .slide-edit {
@@ -1163,6 +1151,71 @@ select.input option {
1163
  justify-items: start;
1164
  }
1165
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1166
  .pull {
1167
  margin: 0;
1168
  padding: 14px 18px;
@@ -1276,9 +1329,10 @@ select.input option {
1276
  }
1277
 
1278
  .cal-item {
1279
- display: block;
 
1280
  width: 100%;
1281
- padding: 3px 6px;
1282
  border-radius: 6px;
1283
  border: 1px solid var(--blue-35);
1284
  background: var(--blue-10);
@@ -1291,6 +1345,23 @@ select.input option {
1291
  cursor: pointer;
1292
  }
1293
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1294
  .cal-item:hover {
1295
  border-color: var(--blue);
1296
  }
@@ -1306,6 +1377,14 @@ select.input option {
1306
  border-color: var(--white);
1307
  }
1308
 
 
 
 
 
 
 
 
 
1309
  .cal-draft {
1310
  border-style: dashed;
1311
  background: transparent;
@@ -2756,20 +2835,6 @@ select.input option {
2756
  }
2757
  }
2758
 
2759
- /* Launch Kit: the post picker and kit list share the side column without pushing the page. */
2760
- @media (min-width: 1100px) {
2761
- .kit-layout > aside {
2762
- position: sticky;
2763
- top: 0;
2764
- max-height: calc(100vh - var(--main-pad-top) - var(--main-pad-bottom));
2765
- overflow-y: auto;
2766
- }
2767
-
2768
- .kit-layout .picker-list {
2769
- max-height: 220px;
2770
- }
2771
- }
2772
-
2773
  /* Launchpad: a month fits on one screen. */
2774
  .cal-day {
2775
  min-height: 72px;
@@ -2799,7 +2864,6 @@ select.input option {
2799
  .grid-cards > *,
2800
  .gallery > *,
2801
  .mc-grid > *,
2802
- .kit-layout > *,
2803
  .map-layout > *,
2804
  .nbv > *,
2805
  .stack > *,
@@ -2828,7 +2892,6 @@ select.input option {
2828
  .picker-title,
2829
  .source-title,
2830
  .nbv-doc,
2831
- .kit-list,
2832
  .topic-rank,
2833
  .hooks,
2834
  .claims,
@@ -2954,10 +3017,6 @@ select.input,
2954
  grid-template-columns: minmax(240px, 300px) minmax(0, 1fr) minmax(320px, 400px);
2955
  }
2956
 
2957
- .kit-layout {
2958
- grid-template-columns: minmax(300px, 380px) minmax(0, 1fr);
2959
- }
2960
-
2961
  .map-layout {
2962
  grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
2963
  }
@@ -3698,128 +3757,668 @@ select.input,
3698
  padding: 0 26px;
3699
  }
3700
 
3701
- /* Launch Kit: post cards to start, a thin rail of kits once one is open */
3702
- @media (min-width: 1100px) {
3703
- .kit-layout.kit-open {
3704
- grid-template-columns: minmax(200px, 260px) minmax(0, 1fr);
 
 
 
 
 
3705
  }
3706
  }
3707
 
3708
- .kit-rail {
 
 
 
 
 
 
3709
  display: grid;
3710
- gap: 10px;
3711
- align-content: start;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3712
  }
3713
 
3714
- .post-cards {
 
3715
  display: grid;
3716
- grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
3717
- gap: 12px;
 
 
 
 
 
3718
  }
3719
 
3720
- .post-card {
3721
  display: grid;
3722
- align-content: space-between;
3723
- gap: 10px;
3724
- min-height: 150px;
3725
- padding: 18px;
3726
- border: 1px solid var(--line);
3727
- border-radius: var(--radius-sm);
3728
- background: transparent;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3729
  color: var(--white);
3730
  font: inherit;
3731
- text-align: left;
3732
  cursor: pointer;
3733
- transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
3734
  }
3735
 
3736
- .post-card strong {
3737
- font-size: 1.02rem;
3738
- line-height: 1.35;
3739
  }
3740
 
3741
- .post-card-go {
3742
- color: var(--blue);
3743
- font-size: 0.8125rem;
3744
- font-weight: 600;
3745
  }
3746
 
3747
- .post-card:hover:not(:disabled) {
3748
- border-color: var(--blue-60);
3749
- box-shadow: var(--glow-sm);
3750
- transform: translateY(-2px);
3751
  }
3752
 
3753
- .post-card:disabled {
3754
- opacity: 0.6;
3755
- cursor: default;
 
 
 
 
 
 
 
 
 
 
 
3756
  }
3757
 
3758
- /* Launchpad: account chips in one line, calendar first, ideas beside it */
3759
- .lp-accounts {
 
 
 
 
 
 
 
 
 
 
 
 
3760
  display: flex;
3761
- flex-wrap: wrap;
3762
  align-items: center;
3763
- gap: 8px;
 
3764
  }
3765
 
3766
- .lp-chip {
3767
- display: inline-flex;
3768
  align-items: center;
3769
- gap: 6px;
3770
- padding: 5px 12px;
3771
- border: 1px solid var(--line-strong);
3772
- border-radius: 999px;
3773
- background: transparent;
3774
- color: var(--white);
3775
- font: inherit;
3776
- font-size: 0.8125rem;
3777
  }
3778
 
3779
- .lp-chip img {
3780
- border-radius: 999px;
 
 
 
 
 
 
3781
  }
3782
 
3783
- .lp-chip.warn {
3784
- border-color: #ff8a3d;
3785
  }
3786
 
3787
- .lp-chip.add {
3788
- border-style: dashed;
3789
- color: var(--text-2);
3790
- cursor: pointer;
3791
  }
3792
 
3793
- .lp-chip.add:hover {
3794
- border-color: var(--blue);
3795
- color: var(--white);
 
 
 
 
3796
  }
3797
 
3798
- .lp-layout {
3799
- display: grid;
3800
- gap: 16px;
3801
- align-items: start;
 
3802
  }
3803
 
3804
- @media (min-width: 1200px) {
3805
- .lp-layout {
3806
- grid-template-columns: minmax(0, 1fr) minmax(300px, 380px);
3807
- }
3808
  }
3809
 
3810
- .lp-ideas {
3811
- align-content: start;
 
3812
  }
3813
 
3814
- .lp-ideas .idea {
 
3815
  display: grid;
3816
  gap: 6px;
3817
- padding-bottom: 12px;
3818
- border-bottom: 1px solid var(--line);
3819
  }
3820
 
3821
- .lp-ideas .idea:last-child {
3822
- border-bottom: 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3823
  }
3824
 
3825
  .lp-empty {
 
66
 
67
  @media (min-width: 720px) {
68
  .grid-cards {
69
+ grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
70
  }
71
 
72
  .gallery {
 
410
  color: var(--black);
411
  }
412
 
413
+ /* Paused / disconnected: needs the user, but nothing broke */
414
+ .pill-warn {
415
+ border: 1px solid rgba(255, 196, 0, 0.55);
416
+ background: rgba(255, 196, 0, 0.12);
417
+ color: #ffd257;
418
+ }
419
+
420
  @keyframes pill-pulse {
421
  50% {
422
  opacity: 0.5;
 
465
  min-height: 80vh;
466
  }
467
 
468
+ /* The Launchpad's calendar while it loads: about the month grid's height, so the card doesn't jump */
469
+ .loading-center.lp-cal-loading {
470
+ min-height: 560px;
471
+ }
472
+
473
  /* Inside a card (a tab still loading): centred, without claiming most of the window */
474
  .loading-center.inline {
475
  min-height: 200px;
 
1126
  }
1127
 
1128
  /* Launch Kit */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1129
 
1130
  .post-edit,
1131
  .slide-edit {
 
1151
  justify-items: start;
1152
  }
1153
 
1154
+ /* A Launch Kit's two parts, told apart: the full post, then the hooks as numbered cards */
1155
+ .kit-section {
1156
+ display: grid;
1157
+ gap: 14px;
1158
+ padding-top: 20px;
1159
+ border-top: 1px solid var(--line);
1160
+ }
1161
+
1162
+ .kit-section-head {
1163
+ display: grid;
1164
+ gap: 4px;
1165
+ justify-items: start;
1166
+ }
1167
+
1168
+ /* The section names as plain, large titles */
1169
+ .kit-section-head h3 {
1170
+ margin: 0;
1171
+ font-size: 1.6rem;
1172
+ line-height: 1.2;
1173
+ }
1174
+
1175
+ .kit-hooks {
1176
+ display: grid;
1177
+ grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
1178
+ gap: 12px;
1179
+ margin: 0;
1180
+ padding: 0;
1181
+ list-style: none;
1182
+ }
1183
+
1184
+ .kit-hook {
1185
+ display: grid;
1186
+ align-content: start;
1187
+ gap: 8px;
1188
+ padding: 14px 16px;
1189
+ border: 1px solid var(--line);
1190
+ border-left: 3px solid var(--blue-35);
1191
+ border-radius: var(--radius-sm);
1192
+ background: rgba(255, 255, 255, 0.02);
1193
+ overflow-wrap: anywhere;
1194
+ }
1195
+
1196
+ .kit-hook-top {
1197
+ display: flex;
1198
+ justify-content: space-between;
1199
+ gap: 8px;
1200
+ }
1201
+
1202
+ .kit-hook-num {
1203
+ color: var(--blue);
1204
+ }
1205
+
1206
+ .kit-hook-strength {
1207
+ color: var(--text-2);
1208
+ }
1209
+
1210
+ .kit-hook-text {
1211
+ margin: 0;
1212
+ font-weight: 500;
1213
+ }
1214
+
1215
+ .kit-hook p {
1216
+ margin: 0;
1217
+ }
1218
+
1219
  .pull {
1220
  margin: 0;
1221
  padding: 14px 18px;
 
1329
  }
1330
 
1331
  .cal-item {
1332
+ display: grid;
1333
+ gap: 1px;
1334
  width: 100%;
1335
+ padding: 4px 7px;
1336
  border-radius: 6px;
1337
  border: 1px solid var(--blue-35);
1338
  background: var(--blue-10);
 
1345
  cursor: pointer;
1346
  }
1347
 
1348
+ .cal-item-time,
1349
+ .cal-item-title {
1350
+ overflow: hidden;
1351
+ text-overflow: ellipsis;
1352
+ white-space: nowrap;
1353
+ }
1354
+
1355
+ .cal-item-time {
1356
+ font-size: 0.68rem;
1357
+ opacity: 0.85;
1358
+ }
1359
+
1360
+ .cal-item-title {
1361
+ font-size: 0.75rem;
1362
+ font-weight: 600;
1363
+ }
1364
+
1365
  .cal-item:hover {
1366
  border-color: var(--blue);
1367
  }
 
1377
  border-color: var(--white);
1378
  }
1379
 
1380
+ /* Its account's connection is down (or held by hand): never attempted until resumed */
1381
+ .cal-paused {
1382
+ border-style: dashed;
1383
+ opacity: 0.55;
1384
+ text-decoration: line-through;
1385
+ text-decoration-color: var(--text-3);
1386
+ }
1387
+
1388
  .cal-draft {
1389
  border-style: dashed;
1390
  background: transparent;
 
2835
  }
2836
  }
2837
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2838
  /* Launchpad: a month fits on one screen. */
2839
  .cal-day {
2840
  min-height: 72px;
 
2864
  .grid-cards > *,
2865
  .gallery > *,
2866
  .mc-grid > *,
 
2867
  .map-layout > *,
2868
  .nbv > *,
2869
  .stack > *,
 
2892
  .picker-title,
2893
  .source-title,
2894
  .nbv-doc,
 
2895
  .topic-rank,
2896
  .hooks,
2897
  .claims,
 
3017
  grid-template-columns: minmax(240px, 300px) minmax(0, 1fr) minmax(320px, 400px);
3018
  }
3019
 
 
 
 
 
3020
  .map-layout {
3021
  grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
3022
  }
 
3757
  padding: 0 26px;
3758
  }
3759
 
3760
+ .lp-layout {
3761
+ display: grid;
3762
+ gap: 16px;
3763
+ align-items: start;
3764
+ }
3765
+
3766
+ @media (min-width: 1200px) {
3767
+ .lp-layout {
3768
+ grid-template-columns: minmax(0, 1fr) minmax(300px, 380px);
3769
  }
3770
  }
3771
 
3772
+ /* A page's way back (to the page before it), above its header */
3773
+ .page-back {
3774
+ margin-bottom: 4px;
3775
+ }
3776
+
3777
+ /* Create a launch kit: posts and earlier kits as rows (date, title, action on the right) */
3778
+ .lk-rows {
3779
  display: grid;
3780
+ align-content: start; /* rows keep their height and stay together, however tall the list is */
3781
+ gap: 6px;
3782
+ margin: 0;
3783
+ padding: 0;
3784
+ list-style: none;
3785
+ }
3786
+
3787
+ .lk-row {
3788
+ display: flex;
3789
+ flex: none;
3790
+ align-items: center;
3791
+ gap: 16px;
3792
+ height: 64px; /* a title and its source line; fixed, so the list shows an exact number of rows */
3793
+ padding: 8px 12px 8px 16px;
3794
+ border: 1px solid var(--line);
3795
+ border-radius: var(--radius);
3796
+ background: rgba(255, 255, 255, 0.03);
3797
+ transition: border-color 0.15s;
3798
+ }
3799
+
3800
+ .lk-row:hover {
3801
+ border-color: var(--line-strong);
3802
+ }
3803
+
3804
+ /* Pick a post to launch: the rows take the rest of the screen (at least four and a half), the rest scrolls inside
3805
+ the card */
3806
+ .lk-rows-scroll {
3807
+ max-height: max(calc(4.5 * 64px + 4 * 6px), calc(100dvh - 380px));
3808
+ overflow-y: auto;
3809
+ padding-right: 4px;
3810
+ scrollbar-width: thin;
3811
  }
3812
 
3813
+ /* While the posts load: the loader in the middle of the list's space */
3814
+ .lk-loading {
3815
  display: grid;
3816
+ place-items: center;
3817
+ min-height: max(calc(4.5 * 64px + 4 * 6px), calc(100dvh - 380px));
3818
+ }
3819
+
3820
+ .lk-date {
3821
+ flex: none;
3822
+ width: 100px;
3823
  }
3824
 
3825
+ .lk-title {
3826
  display: grid;
3827
+ flex: 1;
3828
+ gap: 2px;
3829
+ min-width: 0;
3830
+ }
3831
+
3832
+ .lk-title strong {
3833
+ overflow: hidden;
3834
+ text-overflow: ellipsis;
3835
+ white-space: nowrap;
3836
+ }
3837
+
3838
+ @media (max-width: 640px) {
3839
+ .lk-date {
3840
+ display: none;
3841
+ }
3842
+ }
3843
+
3844
+ /* The picker's "Create a launch kit": a text link at the end of the tabs (goes to its own page) */
3845
+ .lp-kit-link {
3846
+ display: inline-flex;
3847
+ align-items: center;
3848
+ gap: 6px;
3849
+ padding: 4px 0;
3850
+ border: 0;
3851
+ background: none;
3852
  color: var(--white);
3853
  font: inherit;
3854
+ font-weight: 500;
3855
  cursor: pointer;
 
3856
  }
3857
 
3858
+ .lp-kit-link:hover {
3859
+ text-decoration: underline;
3860
+ text-underline-offset: 4px;
3861
  }
3862
 
3863
+ .lp-kit-link .icon {
3864
+ transition: transform 0.15s;
 
 
3865
  }
3866
 
3867
+ .lp-kit-link:hover .icon {
3868
+ transform: translateX(3px);
 
 
3869
  }
3870
 
3871
+ /* Where a post came from (its Launch Kit): a quiet line, apart from the attachment */
3872
+ .lp-from-kit {
3873
+ margin: 0;
3874
+ }
3875
+
3876
+ /* The Launchpad's right column: X and LinkedIn, connected or a Connect button */
3877
+ .lp-connections {
3878
+ align-content: start;
3879
+ gap: 14px;
3880
+ }
3881
+
3882
+ .lp-connections > h2,
3883
+ .lp-connections > p {
3884
+ margin: 0;
3885
  }
3886
 
3887
+ .lp-conn {
3888
+ display: grid;
3889
+ gap: 10px;
3890
+ padding: 14px 16px;
3891
+ border: 1px solid var(--line);
3892
+ border-radius: var(--radius);
3893
+ background: rgba(255, 255, 255, 0.03);
3894
+ }
3895
+
3896
+ .lp-conn p {
3897
+ margin: 0;
3898
+ }
3899
+
3900
+ .lp-conn-head {
3901
  display: flex;
 
3902
  align-items: center;
3903
+ justify-content: space-between;
3904
+ gap: 10px;
3905
  }
3906
 
3907
+ .lp-conn-who {
3908
+ display: flex;
3909
  align-items: center;
3910
+ gap: 12px;
 
 
 
 
 
 
 
3911
  }
3912
 
3913
+ .lp-conn-who img,
3914
+ .lp-conn-dot {
3915
+ flex: none;
3916
+ width: 32px;
3917
+ height: 32px;
3918
+ border-radius: 50%;
3919
+ object-fit: cover;
3920
+ background: var(--surface-2);
3921
  }
3922
 
3923
+ .lp-conn > .btn {
3924
+ justify-self: start;
3925
  }
3926
 
3927
+ /* The composer (New post / Post): platforms, the attachment, one text box per platform */
3928
+ .lp-compose .lp-platforms .chip {
3929
+ padding: 6px 14px;
3930
+ font-size: 0.875rem;
3931
  }
3932
 
3933
+ .lp-platform {
3934
+ display: grid;
3935
+ gap: 8px;
3936
+ padding: 14px 16px;
3937
+ border: 1px solid var(--line);
3938
+ border-radius: var(--radius);
3939
+ background: rgba(255, 255, 255, 0.03);
3940
  }
3941
 
3942
+ .lp-platform-head {
3943
+ display: flex;
3944
+ flex-wrap: wrap;
3945
+ align-items: center;
3946
+ gap: 6px 12px;
3947
  }
3948
 
3949
+ .lp-platform-head .btn {
3950
+ margin-left: auto;
 
 
3951
  }
3952
 
3953
+ .lp-when {
3954
+ align-items: end;
3955
+ gap: 12px 24px;
3956
  }
3957
 
3958
+ /* When a post goes out: a button with the picked time, opening a month calendar and the day's half hours */
3959
+ .slot-picker {
3960
  display: grid;
3961
  gap: 6px;
3962
+ width: 100%;
 
3963
  }
3964
 
3965
+ .slot-trigger {
3966
+ text-align: left;
3967
+ cursor: pointer;
3968
+ }
3969
+
3970
+ .slot-panel {
3971
+ display: grid;
3972
+ grid-template-columns: minmax(0, 1fr) 140px;
3973
+ gap: 16px;
3974
+ padding: 14px;
3975
+ border: 1px solid var(--line-strong);
3976
+ border-radius: var(--radius);
3977
+ background: var(--surface);
3978
+ }
3979
+
3980
+ .slot-cal-head {
3981
+ display: flex;
3982
+ align-items: center;
3983
+ justify-content: space-between;
3984
+ margin-bottom: 8px;
3985
+ }
3986
+
3987
+ .slot-days {
3988
+ display: grid;
3989
+ grid-template-columns: repeat(7, 1fr);
3990
+ gap: 4px;
3991
+ text-align: center;
3992
+ }
3993
+
3994
+ .slot-weekday {
3995
+ padding: 4px 0;
3996
+ }
3997
+
3998
+ .slot-day,
3999
+ .slot-time {
4000
+ border: 1px solid transparent;
4001
+ border-radius: var(--radius-sm);
4002
+ background: transparent;
4003
+ color: var(--text);
4004
+ font: inherit;
4005
+ cursor: pointer;
4006
+ }
4007
+
4008
+ .slot-day {
4009
+ height: 34px;
4010
+ }
4011
+
4012
+ .slot-day.out {
4013
+ color: var(--text-2);
4014
+ }
4015
+
4016
+ .slot-day.today {
4017
+ border-color: var(--line-strong);
4018
+ }
4019
+
4020
+ .slot-day:hover:not(:disabled),
4021
+ .slot-time:hover:not(:disabled) {
4022
+ border-color: var(--blue-35);
4023
+ }
4024
+
4025
+ .slot-day.on,
4026
+ .slot-time.on {
4027
+ border-color: var(--blue);
4028
+ background: var(--blue-10);
4029
+ color: var(--white);
4030
+ }
4031
+
4032
+ .slot-day:disabled,
4033
+ .slot-time:disabled {
4034
+ opacity: 0.3;
4035
+ cursor: default;
4036
+ }
4037
+
4038
+ .slot-times {
4039
+ display: grid;
4040
+ align-content: start;
4041
+ gap: 4px;
4042
+ max-height: 280px;
4043
+ overflow-y: auto;
4044
+ padding-right: 4px;
4045
+ scrollbar-width: thin;
4046
+ }
4047
+
4048
+ .slot-time {
4049
+ padding: 7px 10px;
4050
+ text-align: left;
4051
+ }
4052
+
4053
+ @media (max-width: 640px) {
4054
+ .slot-panel {
4055
+ grid-template-columns: 1fr;
4056
+ }
4057
+
4058
+ .slot-times {
4059
+ grid-template-columns: repeat(3, 1fr);
4060
+ max-height: 180px;
4061
+ }
4062
+ }
4063
+
4064
+ /* What a post carries: a thumbnail (or an icon), its title and what it is */
4065
+ .lp-attach {
4066
+ display: flex;
4067
+ align-items: center;
4068
+ gap: 12px;
4069
+ padding: 10px 12px;
4070
+ border: 1px solid var(--line);
4071
+ border-radius: var(--radius);
4072
+ background: rgba(255, 255, 255, 0.03);
4073
+ }
4074
+
4075
+ .lp-attach-text {
4076
+ display: grid;
4077
+ flex: 1;
4078
+ gap: 2px;
4079
+ min-width: 0;
4080
+ text-align: left;
4081
+ }
4082
+
4083
+ .lp-attach-text strong {
4084
+ overflow: hidden;
4085
+ text-overflow: ellipsis;
4086
+ white-space: nowrap;
4087
+ }
4088
+
4089
+ .lp-attach-btn {
4090
+ justify-self: start;
4091
+ border-style: dashed;
4092
+ }
4093
+
4094
+ .lp-thumb {
4095
+ flex: none;
4096
+ width: 48px;
4097
+ height: 48px;
4098
+ border-radius: 10px;
4099
+ object-fit: cover;
4100
+ background: var(--surface-2);
4101
+ }
4102
+
4103
+ .lp-thumb-icon {
4104
+ display: grid;
4105
+ place-items: center;
4106
+ color: var(--text-2);
4107
+ }
4108
+
4109
+ /* Paused (connection down): why, Reconnect, and how to resume */
4110
+ .lp-paused {
4111
+ display: flex;
4112
+ flex-wrap: wrap;
4113
+ align-items: center;
4114
+ gap: 8px 12px;
4115
+ padding: 12px 14px;
4116
+ border: 1px solid rgba(255, 196, 0, 0.45);
4117
+ border-radius: var(--radius-sm);
4118
+ background: rgba(255, 196, 0, 0.08);
4119
+ color: #ffd257;
4120
+ }
4121
+
4122
+ .lp-paused .muted {
4123
+ flex-basis: 100%;
4124
+ }
4125
+
4126
+ .lp-warn {
4127
+ color: #ffd257;
4128
+ }
4129
+
4130
+ /* The Library as rows to schedule from: thumbnail, title, a Video / Launch Kit tag, the action on the right */
4131
+ .lp-pick-rows {
4132
+ max-height: calc(4.5 * 64px + 4 * 6px);
4133
+ overflow-y: auto;
4134
+ padding-right: 4px;
4135
+ scrollbar-width: thin;
4136
+ }
4137
+
4138
+ /* The picker's empty message ("Nothing uploaded yet."), centered under the search */
4139
+ .lp-pick-empty {
4140
+ margin: 8px 0;
4141
+ text-align: center;
4142
+ }
4143
+
4144
+ /* An uploaded file's row: the row itself, and a Rename (pencil) button beside it */
4145
+ /* One card per row: the row (attach / open) and its ⋮ inside the same border */
4146
+ .lp-pick-item {
4147
+ display: flex;
4148
+ align-items: center;
4149
+ padding-right: 8px;
4150
+ border: 1px solid var(--line);
4151
+ border-radius: var(--radius);
4152
+ background: rgba(255, 255, 255, 0.03);
4153
+ transition: border-color 0.15s;
4154
+ }
4155
+
4156
+ .lp-pick-item:hover {
4157
+ border-color: var(--blue-35);
4158
+ }
4159
+
4160
+ .lp-pick-item > .lp-pick-row,
4161
+ .lp-pick-item > .lp-pick-row:hover {
4162
+ flex: 1;
4163
+ min-width: 0;
4164
+ border-color: transparent;
4165
+ background: transparent;
4166
+ }
4167
+
4168
+ /* A row's ⋮ button, and the menu it opens (drawn over the picker's pop-up) */
4169
+ .lp-row-menu-btn,
4170
+ .lp-row-menu-btn:hover {
4171
+ flex: none;
4172
+ width: 32px;
4173
+ height: 40px;
4174
+ padding: 0;
4175
+ display: grid;
4176
+ place-items: center;
4177
+ border: 0;
4178
+ border-radius: 8px;
4179
+ background: transparent;
4180
+ box-shadow: none;
4181
+ color: var(--text-2);
4182
+ }
4183
+
4184
+ .lp-row-menu-btn:hover,
4185
+ .lp-row-menu-btn[aria-expanded="true"] {
4186
+ background: rgba(255, 255, 255, 0.08);
4187
+ color: var(--white);
4188
+ }
4189
+
4190
+ .lp-menu {
4191
+ position: fixed;
4192
+ z-index: 60;
4193
+ display: grid;
4194
+ min-width: 160px;
4195
+ padding: 6px;
4196
+ border: 1px solid var(--line-strong);
4197
+ border-radius: var(--radius-sm);
4198
+ background: #0b0d12;
4199
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.6);
4200
+ }
4201
+
4202
+ .lp-menu-item {
4203
+ padding: 9px 12px;
4204
+ border: 0;
4205
+ border-radius: 6px;
4206
+ background: transparent;
4207
+ color: var(--text);
4208
+ font: inherit;
4209
+ text-align: left;
4210
+ cursor: pointer;
4211
+ }
4212
+
4213
+ .lp-menu-item:hover,
4214
+ .lp-menu-item:focus-visible {
4215
+ background: var(--blue-10);
4216
+ outline: none;
4217
+ }
4218
+
4219
+ /* Delete: lit up like the app's other danger buttons */
4220
+ .lp-menu-item.danger:hover,
4221
+ .lp-menu-item.danger:focus-visible {
4222
+ color: var(--black);
4223
+ background: var(--white);
4224
+ }
4225
+
4226
+ /* Renaming it: the row turns into a name field with Save / Cancel */
4227
+ .lp-rename-row {
4228
+ display: flex;
4229
+ align-items: center;
4230
+ gap: 10px;
4231
+ }
4232
+
4233
+ .lp-rename-row .input {
4234
+ flex: 1;
4235
+ min-width: 0;
4236
+ }
4237
+
4238
+ /* Preview of an uploaded image or video, fitted to the screen */
4239
+ .lp-preview {
4240
+ display: grid;
4241
+ place-items: center;
4242
+ min-height: 160px;
4243
+ border-radius: var(--radius-sm);
4244
+ background: #000;
4245
+ overflow: hidden;
4246
+ }
4247
+
4248
+ .lp-preview video,
4249
+ .lp-preview img {
4250
+ display: block;
4251
+ max-width: 100%;
4252
+ max-height: 70vh;
4253
+ }
4254
+
4255
+ /* The attached upload's thumbnail, clickable to preview it */
4256
+ .lp-thumb-btn {
4257
+ flex: none;
4258
+ padding: 0;
4259
+ border: 0;
4260
+ border-radius: var(--radius-sm);
4261
+ background: none;
4262
+ cursor: zoom-in;
4263
+ }
4264
+
4265
+ .lp-thumb-btn:focus-visible {
4266
+ outline: 2px solid var(--blue);
4267
+ outline-offset: 2px;
4268
+ }
4269
+
4270
+ /* Uploads: the drop area for a file from the computer (click or drag one in) */
4271
+ .lp-drop {
4272
+ display: grid;
4273
+ justify-items: center;
4274
+ gap: 6px;
4275
+ padding: 28px 16px;
4276
+ border: 1.5px dashed var(--line-strong);
4277
+ border-radius: var(--radius);
4278
+ background: rgba(255, 255, 255, 0.02);
4279
+ text-align: center;
4280
+ cursor: pointer;
4281
+ transition: border-color 0.15s, background 0.15s;
4282
+ }
4283
+
4284
+ .lp-drop:hover,
4285
+ .lp-drop:focus-visible,
4286
+ .lp-drop.over {
4287
+ border-color: var(--blue);
4288
+ background: var(--blue-10);
4289
+ outline: none;
4290
+ }
4291
+
4292
+ .lp-drop.busy {
4293
+ cursor: progress;
4294
+ }
4295
+
4296
+ .lp-drop-icon {
4297
+ display: grid;
4298
+ place-items: center;
4299
+ width: 44px;
4300
+ height: 44px;
4301
+ border-radius: 50%;
4302
+ background: var(--blue-10);
4303
+ color: var(--blue);
4304
+ }
4305
+
4306
+ .lp-drop-link {
4307
+ color: var(--blue);
4308
+ text-decoration: underline;
4309
+ text-underline-offset: 3px;
4310
+ }
4311
+
4312
+ /* The render pop-up: the progress bar spans the modal (the editor's is capped at 280px) */
4313
+ .vw-render-status.lp-render-status {
4314
+ width: 100%;
4315
+ margin: 12px 0 8px; /* room from the text above and the buttons below */
4316
+ }
4317
+
4318
+ /* "Rendering MP4 42%" centered over the bar */
4319
+ .lp-render-status .vw-render-line {
4320
+ justify-content: center;
4321
+ }
4322
+
4323
+ .lp-render-status .vw-render-pct {
4324
+ margin-left: 0;
4325
+ }
4326
+
4327
+ /* While the Library loads: the loader in the middle of the list's space (the screen's height on Schedule a launch) */
4328
+ .lp-loading {
4329
+ display: grid;
4330
+ place-items: center;
4331
+ min-height: calc(4.5 * 64px + 4 * 6px);
4332
+ }
4333
+
4334
+ /* These loaders (Library, Create a launch kit, opening a kit): the text under the spinner, centered */
4335
+ .lp-loading .loading,
4336
+ .lk-loading .loading,
4337
+ .loading-center.stacked .loading {
4338
+ flex-direction: column;
4339
+ text-align: center;
4340
+ }
4341
+
4342
+ .lp-loading.fill {
4343
+ min-height: max(calc(4.5 * 64px + 4 * 6px), calc(100dvh - 340px));
4344
+ }
4345
+
4346
+ /* Schedule a launch: the rows take the rest of the screen (below the header, kind chips and search) */
4347
+ .lp-pick-rows-fill {
4348
+ max-height: calc(100dvh - 340px);
4349
+ }
4350
+
4351
+ .lp-pick-row {
4352
+ width: 100%;
4353
+ padding-left: 10px;
4354
+ color: var(--text);
4355
+ font: inherit;
4356
+ text-align: left;
4357
+ cursor: pointer;
4358
+ }
4359
+
4360
+ .lp-pick-row .lp-thumb {
4361
+ width: 44px;
4362
+ height: 44px;
4363
+ }
4364
+
4365
+ .lp-pick-row:hover {
4366
+ border-color: var(--blue-35);
4367
+ }
4368
+
4369
+ .lp-tag {
4370
+ flex: none;
4371
+ min-width: 96px; /* one width for every kind, so the tags line up in a column, label centered */
4372
+ padding: 3px 10px;
4373
+ text-align: center;
4374
+ border: 1px solid var(--line-strong);
4375
+ border-radius: 999px;
4376
+ color: var(--text-2);
4377
+ font-size: 0.75rem;
4378
+ white-space: nowrap;
4379
+ }
4380
+
4381
+ /* Both in the app's blue: a kit filled, a video outlined */
4382
+ .lp-tag.kit {
4383
+ border-color: var(--blue-60);
4384
+ background: var(--blue-10);
4385
+ color: var(--white);
4386
+ }
4387
+
4388
+ .lp-tag.video {
4389
+ border-color: var(--blue-35);
4390
+ background: transparent;
4391
+ color: var(--white);
4392
+ }
4393
+
4394
+ .lp-pick-go {
4395
+ display: inline-flex;
4396
+ flex: none;
4397
+ align-items: center;
4398
+ gap: 6px;
4399
+ min-width: 110px;
4400
+ justify-content: flex-end;
4401
+ color: var(--text-2);
4402
+ font-size: 0.85rem;
4403
+ }
4404
+
4405
+ .lp-pick-row:hover .lp-pick-go {
4406
+ color: var(--white);
4407
+ }
4408
+
4409
+ @media (max-width: 640px) {
4410
+ .lp-pick-go {
4411
+ display: none;
4412
+ }
4413
+ }
4414
+
4415
+
4416
+ /* A calendar item that carries a video or images */
4417
+ .cal-media {
4418
+ display: inline-flex;
4419
+ margin-left: 4px;
4420
+ vertical-align: -1px;
4421
+ color: var(--blue);
4422
  }
4423
 
4424
  .lp-empty {