All files / src/pages/blogs new.tsx

92.68% Statements 38/41
94.73% Branches 18/19
84.61% Functions 11/13
94.87% Lines 37/39

Press n or j to go to the next uncovered block, b, p or k for the previous block.

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                                39x 39x 39x       39x 39x 39x       39x 27x 8x       39x   8x     39x 1x 1x     39x 4x 4x 4x 4x 4x         3x 2x   2x   4x       39x 1x     39x 3x         1x             36x 20x                                                                         4x       2x         1x                                                 16x           1x               3x              
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import { notify } from "../../lib/notify";
import { ArrowLeft, Loader2 } from "lucide-react";
import { usePro } from "../../lib/pro-context";
import { proApi } from "../../lib/api/pro";
import { getErrorMessage } from "../../lib/api";
import { Button } from "../../components/ui/button";
import { BlogStartChoiceScreen } from "../../components/blogs/BlogStartChoiceScreen";
import { AIPreStep } from "../../components/blogs/AIPreStep";
import { StickyPageHeader } from "../../components/ui/sticky-page-header";
import { MobileFullPage } from "../../components/ui/mobile-full-page";
 
type View = "choice" | "ai" | "scratchTitle";
 
export function NewBlogPage() {
	const { proId } = usePro();
	const navigate = useNavigate();
	const [view, setView] = useState<View>("choice");
 
	// Scratch flow: hold title locally until the user explicitly continues so we
	// never create a persistent "Untitled Blog" draft (regression #589 / #586).
	const [scratchTitle, setScratchTitle] = useState("");
	const [creating, setCreating] = useState(false);
	const titleInputRef = useRef<HTMLInputElement | null>(null);
 
	// Focus the title field when the scratch view becomes visible — using a ref
	// avoids the lint rule against the autoFocus attribute on inputs.
	useEffect(() => {
		if (view === "scratchTitle") {
			titleInputRef.current?.focus();
		}
	}, [view]);
 
	const handleStartFromScratch = () => {
		// Just reveal the inline title form — DO NOT call the API yet.
		setView("scratchTitle");
	};
 
	const exitScratchView = () => {
		setScratchTitle("");
		setView("choice");
	};
 
	const handleContinueFromScratch = async () => {
		const title = scratchTitle.trim();
		Iif (!title) return;
		try {
			setCreating(true);
			const blog = await proApi.createMyBlog(proId as string, {
				title,
				blogType: "general",
				content: "",
			});
			if (!blog.data) throw new Error("Failed to create blog");
			navigate({ to: "/blogs/$id/edit", params: { id: blog.data.id } });
		} catch (err) {
			notify.error(getErrorMessage(err));
		} finally {
			setCreating(false);
		}
	};
 
	const handleDraftCreated = (blogId: string) => {
		navigate({ to: "/blogs/$id/edit", params: { id: blogId } });
	};
 
	if (view === "ai") {
		return (
			<MobileFullPage title="Start with AI" onBack={() => setView("choice")}>
				<div className="sm:max-w-5xl mx-auto">
					<AIPreStep
						onDraftCreated={handleDraftCreated}
						onBack={() => setView("choice")}
					/>
				</div>
			</MobileFullPage>
		);
	}
 
	if (view === "scratchTitle") {
		return (
			<MobileFullPage title="New Blog Post" onBack={exitScratchView}>
				<div className="sm:max-w-5xl mx-auto">
					<div className="hidden sm:block">
						<StickyPageHeader>
							<div className="flex items-center gap-3">
								<button
									type="button"
									onClick={exitScratchView}
									className="p-2 -ml-2 hover:bg-background-muted rounded-md"
								>
									<ArrowLeft className="h-5 w-5" />
								</button>
								<h1 className="text-xl font-bold text-foreground-default">
									New Blog Post
								</h1>
							</div>
						</StickyPageHeader>
					</div>
					<div className="max-w-xl mx-auto py-6 sm:py-12 px-4 sm:px-0 space-y-4">
						<div>
							<h2 className="text-xl font-semibold mb-1">
								Give your blog a title
							</h2>
							<p className="text-sm text-foreground-muted">
								The draft is created when you continue. You can change the title
								and everything else in the editor.
							</p>
						</div>
						<label className="block">
							<span className="block text-sm font-medium mb-1">
								Blog Title <span className="text-error">*</span>
							</span>
							<input
								type="text"
								ref={titleInputRef}
								value={scratchTitle}
								onChange={(e) => setScratchTitle(e.target.value)}
								placeholder="Title of your blog post"
								className="w-full rounded-md border border-border-default bg-background-elevated px-3 py-2"
								onKeyDown={(e) => {
									if (
										e.key === "Enter" &&
										scratchTitle.trim() &&
										!creating
									) {
										handleContinueFromScratch();
									}
								}}
							/>
						</label>
						<div className="flex items-center gap-3">
							<Button
								onClick={handleContinueFromScratch}
								disabled={!scratchTitle.trim() || creating}
							>
								{creating ? (
									<Loader2 className="h-4 w-4 animate-spin mr-1" />
								) : null}
								Continue
							</Button>
							<Button variant="ghost" onClick={exitScratchView}>
								Cancel
							</Button>
						</div>
					</div>
				</div>
			</MobileFullPage>
		);
	}
 
	return (
		<MobileFullPage title="New Blog Post" onBack={() => navigate({ to: "/blogs" })}>
			<div className="sm:max-w-5xl mx-auto">
				<div className="hidden sm:block">
					<StickyPageHeader>
						<div className="flex items-center gap-3">
							<button type="button" onClick={() => navigate({ to: "/blogs" })} className="p-2 -ml-2 hover:bg-background-muted rounded-md">
								<ArrowLeft className="h-5 w-5" />
							</button>
							<h1 className="text-xl font-bold text-foreground-default">New Blog Post</h1>
						</div>
					</StickyPageHeader>
				</div>
				<BlogStartChoiceScreen
					onStartWithAI={() => setView("ai")}
					onStartFromScratch={handleStartFromScratch}
				/>
			</div>
		</MobileFullPage>
	);
}