Spaces:
Runtime error
Runtime error
Download frontend/src/components/ScheduleModal.tsx from firebird-technologies/NoteStack-Test: direct link, hf CLI and curl.
- Browser
- Download file 14 kB
-
https://huggingface.co/spaces/firebird-technologies/NoteStack-Test/resolve/main/frontend/src/components/ScheduleModal.tsx
- Command line
-
hf download hf://spaces/firebird-technologies/NoteStack-Test/frontend/src/components/ScheduleModal.tsx
-
curl -L -o ScheduleModal.tsx https://huggingface.co/spaces/firebird-technologies/NoteStack-Test/resolve/main/frontend/src/components/ScheduleModal.tsx
14 kB
| import { useEffect, useState, type FormEvent } from "react"; | |
| import { useNavigate } from "react-router-dom"; | |
| import { launchpadApi } from "../api/endpoints"; | |
| import type { CalendarItem, Platform, PostableArtifact, SocialAccounts } from "../api/types"; | |
| import { AttachmentChip, AttachPicker } from "./launchpad/Attachment"; | |
| import { nextSlot, SlotPicker, toWall, userTimeZone, wallToDate } from "./launchpad/SlotPicker"; | |
| import { errorMessage, Modal } from "./ui"; | |
| export const PLATFORMS: { id: Platform; label: string; limit: number; auto: boolean }[] = [ | |
| { id: "x", label: "X", limit: 280, auto: true }, | |
| { id: "linkedin", label: "LinkedIn", limit: 3000, auto: true }, | |
| { id: "bluesky", label: "Bluesky", limit: 300, auto: true }, | |
| { id: "substack_notes", label: "Substack Notes", limit: 2000, auto: false }, | |
| ]; | |
| /** Only X and LinkedIn take images and videos; the others get the text. */ | |
| const MEDIA_PLATFORMS: Platform[] = ["x", "linkedin"]; | |
| const THREADED: Platform[] = ["x", "bluesky"]; | |
| /** These post only through a live connection: no email fallback. */ | |
| const CONNECTED_ONLY: Platform[] = ["x", "linkedin"]; | |
| /** What the composer offers for now: LinkedIn only (X posting is switched off). */ | |
| const COMPOSER_PLATFORMS: Platform[] = ["linkedin"]; | |
| /** The attachment's starting text for a platform: a thread for X and Bluesky, one post otherwise. */ | |
| const prefillFor = (p: Platform, a: PostableArtifact): string[] => | |
| THREADED.includes(p) ? [...a.prefill.x] : [a.prefill.linkedin]; | |
| /** The composer: one post to any of X, LinkedIn, Bluesky and Substack Notes (one calendar item each), with | |
| * something from the Library attached (its images or video go to X and LinkedIn). Scheduled or posted now. */ | |
| export function ScheduleModal({ | |
| platform: initialPlatform, | |
| posts, | |
| artifactId, | |
| artifact, | |
| kitId, | |
| kitTitle, | |
| documentId, | |
| onClose, | |
| onScheduled, | |
| }: { | |
| platform: Platform; | |
| posts: string[]; | |
| artifactId?: string | null; | |
| /** The attachment, when the caller already has it (else it is looked up from artifactId). */ | |
| artifact?: PostableArtifact | null; | |
| /** The Launch Kit the post is written from: tracked with it, never attached. */ | |
| kitId?: string | null; | |
| kitTitle?: string | null; | |
| documentId?: string | null; | |
| onClose: () => void; | |
| onScheduled?: (item: CalendarItem) => void; | |
| }) { | |
| // One platform per post. A caller asking for one the composer doesn't offer starts on LinkedIn with that text. | |
| const first: Platform = COMPOSER_PLATFORMS.includes(initialPlatform) ? initialPlatform : "linkedin"; | |
| const [selected, setSelected] = useState<Platform[]>([first]); | |
| const [texts, setTexts] = useState<Partial<Record<Platform, string[]>>>(() => ({ | |
| [first]: posts.length ? posts : artifact ? prefillFor(first, artifact) : [""], | |
| })); | |
| const [attached, setAttached] = useState<PostableArtifact | null>(artifact?.type === "launch_kit" ? null : artifact ?? null); | |
| const navigate = useNavigate(); | |
| const [picking, setPicking] = useState(false); | |
| const [when, setWhen] = useState(() => toWall(nextSlot())); | |
| const [accounts, setAccounts] = useState<SocialAccounts | null>(null); | |
| // The connections are checked before anything says one is missing: loading isn't "not connected". | |
| const [accountsState, setAccountsState] = useState<"loading" | "ready" | "error">("loading"); | |
| const loadAccounts = () => { | |
| setAccountsState("loading"); | |
| launchpadApi.accounts().then((a) => { | |
| setAccounts(a); | |
| setAccountsState("ready"); | |
| }, () => setAccountsState("error")); | |
| }; | |
| const [remind, setRemind] = useState(false); | |
| const [busy, setBusy] = useState<"schedule" | "now" | null>(null); | |
| const [error, setError] = useState<string | null>(null); | |
| useEffect(() => { | |
| loadAccounts(); | |
| if (artifactId && !artifact) { | |
| launchpadApi.postable().then((list) => { | |
| const found = list.find((a) => a.id === artifactId); | |
| // a kit is never an attachment, and audio can't be posted (X and LinkedIn take no audio files) | |
| if (found && found.type !== "launch_kit" && found.media !== "audio") attach(found); | |
| }, () => undefined); | |
| } | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, []); | |
| const accountFor = (p: Platform) => | |
| accounts?.accounts.find((a) => a.platform === p && a.status === "active") ?? null; | |
| const carriesMedia = !!attached && attached.media !== "text"; | |
| /** Why a ticked X / LinkedIn can't be scheduled yet (no connection, connection down, can't post media). */ | |
| const blocker = (p: Platform): string | null => { | |
| if (!CONNECTED_ONLY.includes(p) || accountsState !== "ready") return null; | |
| const label = PLATFORMS.find((x) => x.id === p)!.label; | |
| const any = accounts?.accounts.find((a) => a.platform === p); | |
| const live = accountFor(p); | |
| if (!live) return any ? `Your ${label} connection is down. Reconnect ${label}.` : `Connect ${label} to post there.`; | |
| if (carriesMedia && !live.can_post_media) return `Reconnect ${label} to post images and videos.`; | |
| return null; | |
| }; | |
| const blocked = selected.map(blocker).filter(Boolean) as string[]; | |
| const checking = accountsState !== "ready" && selected.some((p) => CONNECTED_ONLY.includes(p)); | |
| const remindable = selected.some((p) => !CONNECTED_ONLY.includes(p)); | |
| /** Switch to another platform (one per post); it starts from the attachment's text, or the text written so far. */ | |
| const pick = (p: Platform) => { | |
| if (selected.includes(p)) return; | |
| setSelected([p]); | |
| if (!texts[p]?.some((t) => t.trim())) { | |
| const from = selected.map((s) => texts[s] ?? []).find((t) => t.some((x) => x.trim())) ?? [""]; | |
| const start = attached ? prefillFor(p, attached) : THREADED.includes(p) ? from : [from.join("\n\n")]; | |
| setTexts((cur) => ({ ...cur, [p]: start })); | |
| } | |
| }; | |
| /** Attach: empty texts take its suggested text. */ | |
| const attach = (a: PostableArtifact) => { | |
| setAttached(a); | |
| setPicking(false); | |
| setTexts((cur) => { | |
| const next = { ...cur }; | |
| for (const p of selected) if (!next[p]?.some((t) => t.trim())) next[p] = prefillFor(p, a); | |
| return next; | |
| }); | |
| }; | |
| const setText = (p: Platform, i: number, value: string) => | |
| setTexts((cur) => ({ ...cur, [p]: (cur[p] ?? [""]).map((t, j) => (j === i ? value : t)) })); | |
| const connect = async (p: "x" | "linkedin") => { | |
| try { | |
| window.location.href = (await launchpadApi.startOAuth(p)).url; | |
| } catch (e) { | |
| setError(errorMessage(e)); | |
| } | |
| }; | |
| const submit = async (now: boolean, e?: FormEvent) => { | |
| e?.preventDefault(); | |
| if (!selected.length) return setError("Pick where to post."); | |
| if (blocked.length) return setError(blocked[0]); | |
| if (!now && wallToDate(when) <= new Date()) return setError("Pick a time that is still ahead."); | |
| setBusy(now ? "now" : "schedule"); | |
| setError(null); | |
| const done: string[] = []; | |
| try { | |
| for (const p of PLATFORMS.map((x) => x.id).filter((id) => selected.includes(id))) { | |
| const meta = PLATFORMS.find((x) => x.id === p)!; | |
| const clean = (texts[p] ?? []).map((t) => t.trim()).filter(Boolean); | |
| const merged = THREADED.includes(p) ? clean : [clean.join("\n\n")]; | |
| const account = meta.auto ? accountFor(p) : null; | |
| const item = await launchpadApi.create({ | |
| platform: p, | |
| // Post now: published by this very request (the server takes the time). Scheduled: the half hour picked, in | |
| // the user's zone (the backend makes it UTC). | |
| ...(now ? { publish_now: true } : { local_time: when, timezone: userTimeZone() }), | |
| content: merged[0] ?? "", | |
| thread: merged.slice(1), | |
| // Images and videos only go to X and LinkedIn; text attachments go anywhere. | |
| artifact_id: attached && (!carriesMedia || MEDIA_PLATFORMS.includes(p)) ? attached.id : null, | |
| document_id: documentId ?? null, | |
| kit_id: kitId ?? null, | |
| social_account_id: account?.id ?? null, | |
| remind_by_email: CONNECTED_ONLY.includes(p) ? false : remind || !account, | |
| }); | |
| onScheduled?.(item); | |
| done.push(meta.label); | |
| } | |
| onClose(); | |
| } catch (err) { | |
| setError(done.length ? `${done.join(" and ")} done; then: ${errorMessage(err)}` : errorMessage(err)); | |
| } finally { | |
| setBusy(null); | |
| } | |
| }; | |
| return ( | |
| <Modal title="New post" onClose={onClose} wide> | |
| <form onSubmit={(e) => submit(false, e)} className="stack lp-compose"> | |
| <div className="field"> | |
| <span className="vw-label">Post to</span> | |
| <div className="gen-chips lp-platforms" role="radiogroup" aria-label="Platform"> | |
| {PLATFORMS.filter((p) => COMPOSER_PLATFORMS.includes(p.id)).map((p) => { | |
| const on = selected.includes(p.id); | |
| return ( | |
| <button key={p.id} type="button" role="radio" className={`chip${on ? " on" : ""}`} aria-checked={on} | |
| onClick={() => pick(p.id)}> | |
| {p.label} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| {kitId && <p className="muted small lp-from-kit">Written from the launch kit: {kitTitle ?? "this kit"}</p>} | |
| <div className="field"> | |
| <span className="vw-label">Attachment</span> | |
| {attached ? ( | |
| <AttachmentChip a={attached} onChange={() => setPicking(true)} onRemove={() => setAttached(null)} /> | |
| ) : ( | |
| <button type="button" className="btn lp-attach-btn" onClick={() => setPicking(true)}> | |
| + Attach from your Library | |
| </button> | |
| )} | |
| </div> | |
| {PLATFORMS.filter((p) => selected.includes(p.id)).map((meta) => { | |
| const p = meta.id; | |
| const list = texts[p] ?? [""]; | |
| const account = meta.auto ? accountFor(p) : null; | |
| const canConnect = (p === "x" || p === "linkedin") && accounts?.available[p]; | |
| const problem = blocker(p); | |
| return ( | |
| <section key={p} className="lp-platform"> | |
| <div className="lp-platform-head"> | |
| <strong>{meta.label}</strong> | |
| <span className={`small ${problem || (accountsState === "error" && CONNECTED_ONLY.includes(p)) ? "lp-warn" : "muted"}`}> | |
| {problem ?? (!meta.auto ? "No posting API: we email you the text at send time." | |
| : accountsState === "loading" ? "Checking your connection..." | |
| : accountsState === "error" ? "Couldn't check your connections." | |
| : account ? `Posting as ${account.handle}` : "Not connected: we email you a reminder.")} | |
| </span> | |
| {accountsState === "error" && ( | |
| <button type="button" className="btn btn-small" onClick={loadAccounts}>Retry</button> | |
| )} | |
| {problem && canConnect && ( | |
| <button type="button" className="btn btn-small btn-primary" onClick={() => connect(p as "x" | "linkedin")}> | |
| {accounts?.accounts.some((a) => a.platform === p) ? "Reconnect" : "Connect"} {meta.label} | |
| </button> | |
| )} | |
| {problem && !canConnect && <span className="muted small">{meta.label} posting isn't set up yet.</span>} | |
| </div> | |
| {list.map((t, i) => ( | |
| <label key={i} className="field"> | |
| <span className="field-row"> | |
| <span className="small muted">{THREADED.includes(p) && list.length > 1 ? `Post ${i + 1}` : "Post"}</span> | |
| <span className={`mono small ${t.length > meta.limit ? "over" : "muted"}`}>{t.length} / {meta.limit}</span> | |
| </span> | |
| <textarea className="textarea" rows={THREADED.includes(p) ? 3 : 6} value={t} | |
| onChange={(e) => setText(p, i, e.target.value)} /> | |
| </label> | |
| ))} | |
| {THREADED.includes(p) && ( | |
| <div className="row"> | |
| <button type="button" className="btn btn-small" onClick={() => setTexts({ ...texts, [p]: [...list, ""] })}> | |
| + Add to thread | |
| </button> | |
| {list.length > 1 && ( | |
| <button type="button" className="btn btn-small" onClick={() => setTexts({ ...texts, [p]: list.slice(0, -1) })}> | |
| Remove last | |
| </button> | |
| )} | |
| </div> | |
| )} | |
| </section> | |
| ); | |
| })} | |
| <div className="row wrap lp-when"> | |
| <div className="field"> | |
| <span className="vw-label">When</span> | |
| <SlotPicker value={when} onChange={setWhen} /> | |
| </div> | |
| {remindable && ( | |
| <label className="check"> | |
| <input type="checkbox" checked={remind} onChange={(e) => setRemind(e.target.checked)} /> | |
| Email me instead of posting automatically (Bluesky, Substack) | |
| </label> | |
| )} | |
| </div> | |
| {error && <p className="error-text">{error}</p>} | |
| <div className="row end"> | |
| <button type="button" className="btn" onClick={onClose}>Cancel</button> | |
| <button type="button" className="btn" disabled={busy !== null || !selected.length || blocked.length > 0 || checking} | |
| onClick={() => submit(true)}> | |
| {busy === "now" ? "Posting..." : "Post now"} | |
| </button> | |
| <button className="btn btn-primary" disabled={busy !== null || !selected.length || blocked.length > 0 || checking}> | |
| {busy === "schedule" ? "Scheduling..." : "Schedule"} | |
| </button> | |
| </div> | |
| </form> | |
| {picking && ( | |
| <AttachPicker onPick={attach} onClose={() => setPicking(false)} onCreateKit={() => { | |
| setPicking(false); | |
| onClose(); | |
| navigate("/app/launchpad/kits"); | |
| }} /> | |
| )} | |
| </Modal> | |
| ); | |
| } | |