Spaces:
Runtime error
Runtime error
Commit ·
bdd1640
1
Parent(s): cd4f836
linkedin upload done phase 1
Browse files- README.md +13 -3
- backend/.env.example +34 -0
- backend/alembic/versions/0017_calendar_kit.py +35 -0
- backend/app/config.py +2 -1
- backend/app/models/content.py +38 -1
- backend/app/routers/artifacts.py +11 -1
- backend/app/routers/launchpad.py +234 -26
- backend/app/routers/storage.py +1 -0
- backend/app/services/artifacts.py +1 -0
- backend/app/services/launchpad.py +56 -10
- backend/app/services/social/__init__.py +14 -4
- backend/app/services/social/bluesky.py +3 -1
- backend/app/services/social/health.py +103 -0
- backend/app/services/social/linkedin.py +122 -15
- backend/app/services/social/media.py +174 -0
- backend/app/services/social/x.py +99 -12
- backend/app/services/storage.py +26 -0
- backend/app/services/uploads.py +62 -0
- backend/app/worker.py +28 -2
- backend/tests/test_buildout.py +229 -6
- backend/tests/test_social_posting.py +554 -0
- frontend/src/App.tsx +6 -3
- frontend/src/api/client.ts +2 -0
- frontend/src/api/endpoints.ts +15 -1
- frontend/src/api/types.ts +38 -2
- frontend/src/components/AppShell.tsx +1 -1
- frontend/src/components/ArtifactCard.tsx +1 -1
- frontend/src/components/ScheduleModal.tsx +235 -108
- frontend/src/components/UpgradeModal.tsx +1 -1
- frontend/src/components/launchpad/Attachment.tsx +628 -0
- frontend/src/components/launchpad/PostButton.tsx +27 -0
- frontend/src/components/launchpad/SlotPicker.tsx +133 -0
- frontend/src/components/ui.tsx +27 -3
- frontend/src/components/video/jobState.ts +23 -0
- frontend/src/components/voice/YourVoices.tsx +1 -1
- frontend/src/pages/Archive.tsx +3 -1
- frontend/src/pages/LaunchKit.tsx +121 -325
- frontend/src/pages/Launchpad.tsx +229 -119
- frontend/src/pages/MissionControl.tsx +3 -3
- frontend/src/pages/NotebookView.tsx +2 -1
- frontend/src/pages/Resurface.tsx +2 -2
- frontend/src/pages/Studio.tsx +3 -2
- frontend/src/pages/TopicMap.tsx +1 -1
- frontend/src/pages/VideoEditor.tsx +1 -21
- frontend/src/pages/Videos.tsx +3 -1
- frontend/src/pages/VoiceProfile.tsx +1 -1
- frontend/src/pages/Welcome.tsx +1 -1
- 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:
|
| 97 |
-
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 46 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
| 138 |
item = CalendarItem(
|
| 139 |
-
workspace_id=ctx.workspace.id, platform=body.platform, 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 |
-
|
| 156 |
-
|
|
|
|
| 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 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
item.status = "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 107 |
for stuck in db.scalars(select(CalendarItem).where(
|
| 108 |
-
CalendarItem.status == "publishing", CalendarItem.updated_at < now -
|
| 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 |
-
|
| 119 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
| 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 = "
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
if expired(account):
|
| 60 |
-
account
|
| 61 |
-
|
| 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 |
-
|
| 72 |
-
"
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 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
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
tokens = _token_request({"grant_type": "refresh_token", "refresh_token": decrypt(account.refresh_token)})
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
return token(account)
|
| 67 |
|
| 68 |
|
| 69 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 77 |
if resp.status_code == 401:
|
| 78 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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
|
| 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={<
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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/
|
| 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,
|
|
|
|
|
|
|
| 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 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
return d;
|
| 22 |
-
}
|
| 23 |
|
| 24 |
-
/**
|
|
|
|
| 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 |
-
|
| 41 |
-
const
|
| 42 |
-
const [
|
| 43 |
-
const [
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
const [remind, setRemind] = useState(false);
|
| 46 |
-
const [busy, setBusy] = useState(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
}, []);
|
| 54 |
|
| 55 |
-
const
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
-
const submit = async (e: FormEvent) => {
|
| 62 |
-
e.preventDefault();
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
| 64 |
setError(null);
|
| 65 |
-
const
|
| 66 |
-
const merged = threadable ? clean : [clean.join("\n\n")];
|
| 67 |
try {
|
| 68 |
-
const
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
onClose();
|
| 80 |
} catch (err) {
|
| 81 |
-
setError(errorMessage(err));
|
| 82 |
} finally {
|
| 83 |
-
setBusy(
|
| 84 |
}
|
| 85 |
};
|
| 86 |
|
| 87 |
return (
|
| 88 |
-
<Modal title="
|
| 89 |
-
<form onSubmit={submit} className="stack">
|
| 90 |
-
<div className="
|
| 91 |
-
<
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
| 96 |
{p.label}
|
| 97 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
))}
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
<
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
</label>
|
| 117 |
)}
|
| 118 |
</div>
|
| 119 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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/
|
| 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) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
: "
|
|
|
|
|
|
|
| 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
|
| 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." : "
|
| 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(
|
| 72 |
<div className="row">
|
| 73 |
<CopyButton text={p} />
|
| 74 |
-
{
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 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 |
-
|
| 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 |
-
|
| 179 |
-
const
|
|
|
|
| 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 |
-
<
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
{
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
<
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
</
|
| 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 |
-
{
|
| 307 |
-
<
|
| 308 |
-
|
| 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 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
const [
|
| 337 |
-
const
|
| 338 |
-
const [
|
| 339 |
-
const [
|
| 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 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
<
|
| 389 |
-
|
| 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 |
-
|
| 397 |
-
<div className="
|
| 398 |
-
<
|
| 399 |
-
<
|
| 400 |
-
<
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 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 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
<
|
| 426 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
</button>
|
| 428 |
-
</
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 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
|
| 2 |
-
import {
|
| 3 |
import { launchpadApi } from "../api/endpoints";
|
| 4 |
-
import type { CalendarItem, SocialAccounts } from "../api/types";
|
| 5 |
-
import {
|
| 6 |
-
import {
|
| 7 |
-
import {
|
|
|
|
|
|
|
| 8 |
|
| 9 |
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 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 |
-
<
|
| 41 |
-
|
| 42 |
-
<
|
| 43 |
-
|
| 44 |
-
</
|
| 45 |
-
|
| 46 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 151 |
<span>When</span>
|
| 152 |
-
<
|
| 153 |
-
</
|
| 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 |
-
|
| 195 |
-
|
|
|
|
|
|
|
| 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
|
| 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={() =>
|
| 287 |
-
|
| 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 |
-
|
|
|
|
| 366 |
{new Date(i.scheduled_at).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}
|
| 367 |
-
</span>
|
| 368 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
</div>
|
| 400 |
|
| 401 |
-
{open && <ItemModal item={open} accounts={accounts} onClose={() => setOpen(null)} onChanged={changed}
|
| 402 |
-
|
| 403 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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/
|
| 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/
|
| 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/
|
| 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/
|
| 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="
|
| 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/
|
| 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/
|
| 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/
|
| 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:
|
|
|
|
| 1280 |
width: 100%;
|
| 1281 |
-
padding:
|
| 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 |
-
|
| 3702 |
-
|
| 3703 |
-
|
| 3704 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3705 |
}
|
| 3706 |
}
|
| 3707 |
|
| 3708 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3709 |
display: grid;
|
| 3710 |
-
|
| 3711 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3712 |
}
|
| 3713 |
|
| 3714 |
-
|
|
|
|
| 3715 |
display: grid;
|
| 3716 |
-
|
| 3717 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3718 |
}
|
| 3719 |
|
| 3720 |
-
.
|
| 3721 |
display: grid;
|
| 3722 |
-
|
| 3723 |
-
gap:
|
| 3724 |
-
min-
|
| 3725 |
-
|
| 3726 |
-
|
| 3727 |
-
|
| 3728 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3729 |
color: var(--white);
|
| 3730 |
font: inherit;
|
| 3731 |
-
|
| 3732 |
cursor: pointer;
|
| 3733 |
-
transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
|
| 3734 |
}
|
| 3735 |
|
| 3736 |
-
.
|
| 3737 |
-
|
| 3738 |
-
|
| 3739 |
}
|
| 3740 |
|
| 3741 |
-
.
|
| 3742 |
-
|
| 3743 |
-
font-size: 0.8125rem;
|
| 3744 |
-
font-weight: 600;
|
| 3745 |
}
|
| 3746 |
|
| 3747 |
-
.
|
| 3748 |
-
|
| 3749 |
-
box-shadow: var(--glow-sm);
|
| 3750 |
-
transform: translateY(-2px);
|
| 3751 |
}
|
| 3752 |
|
| 3753 |
-
|
| 3754 |
-
|
| 3755 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3756 |
}
|
| 3757 |
|
| 3758 |
-
|
| 3759 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3760 |
display: flex;
|
| 3761 |
-
flex-wrap: wrap;
|
| 3762 |
align-items: center;
|
| 3763 |
-
|
|
|
|
| 3764 |
}
|
| 3765 |
|
| 3766 |
-
.lp-
|
| 3767 |
-
display:
|
| 3768 |
align-items: center;
|
| 3769 |
-
gap:
|
| 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-
|
| 3780 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3781 |
}
|
| 3782 |
|
| 3783 |
-
.lp-
|
| 3784 |
-
|
| 3785 |
}
|
| 3786 |
|
| 3787 |
-
|
| 3788 |
-
|
| 3789 |
-
|
| 3790 |
-
|
| 3791 |
}
|
| 3792 |
|
| 3793 |
-
.lp-
|
| 3794 |
-
|
| 3795 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3796 |
}
|
| 3797 |
|
| 3798 |
-
.lp-
|
| 3799 |
-
display:
|
| 3800 |
-
|
| 3801 |
-
align-items:
|
|
|
|
| 3802 |
}
|
| 3803 |
|
| 3804 |
-
|
| 3805 |
-
|
| 3806 |
-
grid-template-columns: minmax(0, 1fr) minmax(300px, 380px);
|
| 3807 |
-
}
|
| 3808 |
}
|
| 3809 |
|
| 3810 |
-
.lp-
|
| 3811 |
-
align-
|
|
|
|
| 3812 |
}
|
| 3813 |
|
| 3814 |
-
|
|
|
|
| 3815 |
display: grid;
|
| 3816 |
gap: 6px;
|
| 3817 |
-
|
| 3818 |
-
border-bottom: 1px solid var(--line);
|
| 3819 |
}
|
| 3820 |
|
| 3821 |
-
.
|
| 3822 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|