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