Spaces:
Runtime error
Runtime error
File size: 15,858 Bytes
34b0226 0bf4415 34b0226 0bf4415 34b0226 0bf4415 1e1198f 0bf4415 0649f1b 34b0226 2823b28 34b0226 0bf4415 34b0226 0bf4415 34b0226 0bf4415 34b0226 0bf4415 34b0226 0bf4415 34b0226 0bf4415 34b0226 0bf4415 34b0226 ba40d17 b612dd5 ba40d17 b612dd5 ba40d17 b612dd5 ba40d17 b612dd5 34b0226 bdd1640 34b0226 0bf4415 34b0226 1e1198f 34b0226 0bf4415 0649f1b 34b0226 0bf4415 34b0226 0bf4415 34b0226 b612dd5 34b0226 0bf4415 34b0226 ba40d17 34b0226 b612dd5 34b0226 b612dd5 34b0226 b612dd5 34b0226 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 | import { useEffect, useRef, useState, type FormEvent } from "react";
import { Link, Navigate, useNavigate } from "react-router-dom";
import { docsApi, jobsApi, notebooksApi, sourcesApi } from "../api/endpoints";
import type { Job, Source } from "../api/types";
import Logo from "../components/Logo";
import SkyCanvas from "../components/SkyCanvas";
import { errorMessage, JobProgress, Tabs } from "../components/ui";
import { useAuth } from "../hooks/useAuth";
import { useJob } from "../hooks/useJob";
import { PENDING_SOURCE_KEY } from "./Landing";
type Step = "connect" | "indexing" | "goal" | "ready";
type Goal = "ask" | "audio" | "launch";
type Mode = "feed" | "url" | "file";
const MODES: { id: Mode; label: string }[] = [
{ id: "feed", label: "Blog or website" },
{ id: "url", label: "One article" },
{ id: "file", label: "Upload files" },
];
const FILE_ACCEPT = ".md,.markdown,.txt,.vtt,.html,.htm,.pdf,text/vtt,text/markdown,text/plain,text/html,application/pdf";
const EXAMPLES = ["yourblog.com", "yourname.substack.com", "medium.com/@you"];
const GOALS: { id: Goal; title: string; body: string }[] = [
{ id: "ask", title: "Ask my archive", body: "Chat with everything you have written. Every answer cites the lines it came from." },
{ id: "audio", title: "Hear it as a podcast", body: "Two hosts talk through your best posts, grounded in your words." },
{ id: "launch", title: "Launch my next post", body: "Threads, LinkedIn, Notes, SEO and quote cards, in your voice." },
];
export const ONBOARDED_KEY = "ns_onboarded";
export function markOnboarded() {
try {
localStorage.setItem(ONBOARDED_KEY, "1");
} catch {
/* ignore */
}
}
function takePending(): string {
try {
const v = sessionStorage.getItem(PENDING_SOURCE_KEY) ?? "";
sessionStorage.removeItem(PENDING_SOURCE_KEY);
return v;
} catch {
return "";
}
}
/** Resolves once every job has finished, so a multi-file upload only counts as indexed when all files are in. */
async function waitForJobs(ids: string[]): Promise<void> {
let pending = ids;
while (pending.length) {
const jobs = await Promise.all(pending.map((id) => jobsApi.get(id).catch(() => null)));
pending = pending.filter((_, i) => jobs[i] && jobs[i]!.status !== "done" && jobs[i]!.status !== "failed");
if (pending.length) await new Promise((r) => setTimeout(r, 1500));
}
}
/** First run: connect a blog, import an article or upload files, watch it index, pick what to do first, land somewhere useful. */
export default function Welcome() {
const { user, loading } = useAuth();
const navigate = useNavigate();
const [step, setStep] = useState<Step>("connect");
const [mode, setMode] = useState<Mode>("feed");
const [files, setFiles] = useState<File[]>([]);
const [dragging, setDragging] = useState(false);
const earlierJobs = useRef<string[]>([]);
const [url, setUrl] = useState(takePending);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [source, setSource] = useState<Source | null>(null);
const [job, setJob] = useState<Job | null>(null);
const [goal, setGoal] = useState<Goal | null>(null);
const [posts, setPosts] = useState(0);
const autoStarted = useRef(false);
const live = useJob(job, async (j) => {
if (j.status !== "done") return;
await waitForJobs(earlierJobs.current);
const list = await docsApi.list({ limit: 1 });
setPosts(list.total);
setStep((s) => (s === "indexing" ? "goal" : s === "goal" ? "goal" : "ready"));
});
const connect = async (value: string, as: Mode = "feed") => {
if (!value.trim()) return;
setBusy(true);
setError(null);
try {
const res = as === "url" ? await sourcesApi.importUrl(value.trim()) : await sourcesApi.connect(value.trim());
earlierJobs.current = [];
setSource(res.source);
setJob(res.job);
setStep("indexing");
} catch (e) {
setError(errorMessage(e, as === "url" ? "We could not import that page." : "We could not reach that address."));
} finally {
setBusy(false);
}
};
const upload = async () => {
if (!files.length) return;
setBusy(true);
setError(null);
try {
// One import job per file: the last drives the progress bar, the rest are awaited before moving on.
const jobs: Job[] = [];
let res: Awaited<ReturnType<typeof sourcesApi.importFile>> | null = null;
for (const f of files) {
res = await sourcesApi.importFile(f);
jobs.push(res.job);
}
earlierJobs.current = jobs.slice(0, -1).map((j) => j.id);
setSource(res!.source);
setJob(res!.job);
setStep("indexing");
} catch (e) {
setError(errorMessage(e, "That upload did not go through."));
} finally {
setBusy(false);
}
};
const addFiles = (list: FileList | null) => {
const picked = Array.from(list ?? []);
if (picked.length) setFiles((cur) => [...cur, ...picked.filter((f) => !cur.some((c) => c.name === f.name && c.size === f.size))]);
};
// A URL pasted on the landing page starts connecting straight away.
useEffect(() => {
if (url && !autoStarted.current && user) {
autoStarted.current = true;
void connect(url);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user]);
if (loading) return <div className="boot"><div className="orbit-loader"><span /></div></div>;
if (!user) return <Navigate to="/auth?mode=signup" replace />;
const indexingDone = live?.status === "done";
const indexingFailed = live?.status === "failed";
// The ingest job lists the whole archive but indexes only the plan's limit; the rest waits behind an upgrade.
const locked = Number(live?.result?.locked ?? 0);
const capped = indexingDone && locked > 0;
const available = Number(live?.result?.available ?? posts);
const found = Number(live?.result?.found ?? posts);
const lockedNotice = capped ? (
<div className="welcome-cap">
<div>
<strong>
We found {found} posts. The free plan indexed only your latest {available}.
</strong>
<p className="muted small">
The other {locked} are sitting in your archive, unread. Until you upgrade, answers, audio overviews and Launch
Kits can only use {available} posts. Upgrade and all {found} are indexed automatically.
</p>
</div>
<button
type="button"
className="btn btn-small"
onClick={() => {
markOnboarded();
navigate("/app?upgrade=1", { replace: true });
}}
>
Index all {found} posts
</button>
</div>
) : null;
const first = (user.name ?? user.email).split(/[ @]/)[0];
const finish = async (chosen: Goal | null) => {
markOnboarded();
if (!chosen || posts === 0) return navigate("/app", { replace: true });
if (chosen === "launch") return navigate("/app/launchpad/kits", { replace: true });
setBusy(true);
try {
const docs = await docsApi.list({ limit: 5000 });
const nb = await notebooksApi.create(source?.title ? `${source.title} archive` : "My archive", docs.items.map((d) => d.id));
navigate(chosen === "audio" ? `/app/notebooks/${nb.id}?studio=audio` : `/app/notebooks/${nb.id}`, { replace: true });
} catch {
navigate("/app", { replace: true });
}
};
const submit = (e: FormEvent) => {
e.preventDefault();
if (mode === "file") void upload();
else void connect(url, mode);
};
const stepIndex = { connect: 0, indexing: 1, goal: 2, ready: 3 }[step];
return (
<div className="page welcome">
<SkyCanvas intensity={0.9} />
<main className="welcome-wrap">
<div className="welcome-top">
<Logo size={36} />
<button className="link-btn mono" onClick={() => finish(null)}>
Skip for now
</button>
</div>
<ol className="welcome-steps mono" aria-label="Onboarding progress">
{["Connect", "Index", "Choose", "Launch"].map((label, i) => (
<li key={label} className={i < stepIndex ? "done" : i === stepIndex ? "current" : ""}>
<span className="welcome-dot" />
<span>
<span className="welcome-step-num">{i + 1}</span>
{label}
</span>
</li>
))}
</ol>
{step === "connect" && (
<section className="card welcome-card">
<p className="eyebrow">Welcome aboard, {first}</p>
<h1>Where does your writing live?</h1>
<p className="muted">Bring it in however suits you. We turn it into a research notebook you can question, hear and launch from.</p>
<Tabs<Mode>
tabs={MODES}
value={mode}
onChange={(m) => {
setMode(m);
setError(null);
}}
/>
<p className="muted small">
{mode === "feed" && "Substack, Ghost, WordPress, Medium or anything with a feed. We find the feed, pull in your archive and keep it in sync."}
{mode === "url" && "Any single article on the web. Add more later from Sources."}
{mode === "file" && "Markdown, text, VTT transcripts, HTML or PDF: drafts, exports and posts that never had a feed. Add as many as you like."}
</p>
<form onSubmit={submit} className="welcome-form">
{mode === "file" ? (
<label
className={`welcome-drop${dragging ? " over" : ""}`}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
addFiles(e.dataTransfer.files);
}}
>
<input
type="file"
multiple
accept={FILE_ACCEPT}
onChange={(e) => {
addFiles(e.target.files);
e.target.value = "";
}}
aria-label="Files to upload"
/>
<strong>Drop files here or click to choose</strong>
<span className="mono muted small">.md 路 .txt 路 .html 路 .pdf</span>
</label>
) : (
<input
key={mode}
className="input"
autoFocus
placeholder={mode === "feed" ? "yourblog.com" : "https://example.com/my-essay"}
value={url}
onChange={(e) => setUrl(e.target.value)}
aria-label={mode === "feed" ? "Blog, newsletter or site address" : "Article URL"}
/>
)}
{mode === "file" && files.length > 0 && (
<ul className="welcome-files mono small">
{files.map((f) => (
<li key={`${f.name}-${f.size}`}>
<span>{f.name}</span>
<button type="button" className="link-btn small" onClick={() => setFiles(files.filter((x) => x !== f))} aria-label={`Remove ${f.name}`}>
Remove
</button>
</li>
))}
</ul>
)}
<button className="btn btn-primary" disabled={busy || (mode === "file" ? !files.length : !url.trim())}>
{mode === "feed" && (busy ? "Finding your feed..." : "Connect")}
{mode === "url" && (busy ? "Importing..." : "Import")}
{mode === "file" && (busy ? "Uploading..." : files.length === 1 ? "Upload 1 file" : `Upload ${files.length || ""} files`.replace(" ", " "))}
</button>
</form>
{mode === "feed" && (
<div className="welcome-examples mono muted">
Try:{" "}
{EXAMPLES.map((ex) => (
<button key={ex} type="button" className="chip" onClick={() => setUrl(ex)}>
{ex}
</button>
))}
</div>
)}
{error && (
<p className="error-text" role="alert">
{error}
</p>
)}
</section>
)}
{step === "indexing" && (
<section className="card welcome-card">
<p className="eyebrow">{source?.title ?? "Your archive"}</p>
<h1>{indexingFailed ? "That did not land" : "Bringing your posts into orbit"}</h1>
{live && <JobProgress job={live} />}
{lockedNotice}
{indexingFailed ? (
<div className="row">
<button className="btn btn-primary" onClick={() => setStep("connect")}>
Try again
</button>
<Link className="btn" to="/app/sources">
Import posts another way
</Link>
</div>
) : (
<>
<p className="muted">This takes a minute for big archives. While it runs, tell us what you want to do first.</p>
<button className="btn btn-primary" onClick={() => setStep("goal")}>
Continue
</button>
</>
)}
</section>
)}
{step === "goal" && (
<section className="card welcome-card">
<p className="eyebrow">{indexingDone ? (capped ? `${available} of ${found} posts indexed` : `${posts} posts indexed`) : "Still indexing in the background"}</p>
<h1>What should we launch first?</h1>
{lockedNotice}
<div className="goal-grid">
{GOALS.map((g) => (
<button key={g.id} type="button" className={`goal${goal === g.id ? " on" : ""}`} onClick={() => setGoal(g.id)} aria-pressed={goal === g.id}>
<strong>{g.title}</strong>
<span className="muted">{g.body}</span>
</button>
))}
</div>
{live && !indexingDone && !indexingFailed && <JobProgress job={live} compact />}
<div className="row end">
<button className="btn btn-primary" disabled={!goal || busy} onClick={() => setStep("ready")}>
Next
</button>
</div>
</section>
)}
{step === "ready" && (
<section className="card welcome-card">
<p className="eyebrow">Pre-flight check complete</p>
<h1>{indexingDone ? `${capped ? available : posts} posts are in orbit` : "Almost there"}</h1>
{!indexingDone && live && <JobProgress job={live} />}
{lockedNotice}
<p className="muted">
{goal === "ask" && "We will put your whole archive in a notebook so you can start asking right away."}
{goal === "audio" && "We will put your archive in a notebook with the audio studio ready."}
{goal === "launch" && "Pick a post and we will build its Launch Kit."}
</p>
<div className="row end">
<button className="btn" onClick={() => finish(null)}>
Go to Mission Control
</button>
<button className="btn btn-primary" disabled={!indexingDone || busy} onClick={() => finish(goal)}>
{busy ? "Preparing..." : indexingDone ? "Launch" : "Waiting for posts..."}
</button>
</div>
</section>
)}
</main>
</div>
);
}
|