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 { 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("connect"); const [mode, setMode] = useState("feed"); const [files, setFiles] = useState([]); const [dragging, setDragging] = useState(false); const earlierJobs = useRef([]); const [url, setUrl] = useState(takePending); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [source, setSource] = useState(null); const [job, setJob] = useState(null); const [goal, setGoal] = useState(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> | 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
; if (!user) return ; 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 ? (
We found {found} posts. The free plan indexed only your latest {available}.

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.

) : 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 (
    {["Connect", "Index", "Choose", "Launch"].map((label, i) => (
  1. {i + 1} {label}
  2. ))}
{step === "connect" && (

Welcome aboard, {first}

Where does your writing live?

Bring it in however suits you. We turn it into a research notebook you can question, hear and launch from.

tabs={MODES} value={mode} onChange={(m) => { setMode(m); setError(null); }} />

{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."}

{mode === "file" ? ( ) : ( setUrl(e.target.value)} aria-label={mode === "feed" ? "Blog, newsletter or site address" : "Article URL"} /> )} {mode === "file" && files.length > 0 && (
    {files.map((f) => (
  • {f.name}
  • ))}
)}
{mode === "feed" && (
Try:{" "} {EXAMPLES.map((ex) => ( ))}
)} {error && (

{error}

)}
)} {step === "indexing" && (

{source?.title ?? "Your archive"}

{indexingFailed ? "That did not land" : "Bringing your posts into orbit"}

{live && } {lockedNotice} {indexingFailed ? (
Import posts another way
) : ( <>

This takes a minute for big archives. While it runs, tell us what you want to do first.

)}
)} {step === "goal" && (

{indexingDone ? (capped ? `${available} of ${found} posts indexed` : `${posts} posts indexed`) : "Still indexing in the background"}

What should we launch first?

{lockedNotice}
{GOALS.map((g) => ( ))}
{live && !indexingDone && !indexingFailed && }
)} {step === "ready" && (

Pre-flight check complete

{indexingDone ? `${capped ? available : posts} posts are in orbit` : "Almost there"}

{!indexingDone && live && } {lockedNotice}

{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."}

)}
); }