8.9 KB288 lines
Blame
1"use client";
2
3import { useEffect, useRef, useState } from "react";
4import { GroveLogo } from "@/app/components/grove-logo";
5
6const TERMINAL_LINES = [
7 { prompt: true, text: "grove init --owner letterpress-labs", delay: 40 },
8 { prompt: false, text: "┌ grove init grove-cli", delay: 0 },
9 { prompt: false, text: "◇ Created letterpress-labs/grove-cli", delay: 800 },
10 { prompt: false, text: "◇ Sapling repository initialized", delay: 400 },
11 { prompt: false, text: "◇ Remote configured", delay: 300 },
12 { prompt: false, text: "└ Initialized letterpress-labs/grove-cli", delay: 200 },
13 { prompt: false, text: "", delay: 400 },
14 { prompt: true, text: 'sl commit -m "add CLI help system and read-only ISL mode"', delay: 35 },
15 { prompt: false, text: "", delay: 600 },
16 { prompt: true, text: "sl push --to main", delay: 40 },
17 { prompt: false, text: "pushing rev c21911da to bookmark main", delay: 400 },
18 { prompt: false, text: "edenapi: uploaded 9 files", delay: 300 },
19 { prompt: false, text: "edenapi: uploaded 1 commit", delay: 200 },
20 { prompt: false, text: 'updated remote bookmark main to c21911da', delay: 300 },
21];
22
23function TerminalDemo() {
24 const [lines, setLines] = useState<{ text: string; isPrompt: boolean }[]>([]);
25 const [currentTyping, setCurrentTyping] = useState("");
26 const [isTyping, setIsTyping] = useState(false);
27 const [showCursor, setShowCursor] = useState(true);
28 const termRef = useRef<HTMLDivElement>(null);
29
30 useEffect(() => {
31 const blink = setInterval(() => setShowCursor((c) => !c), 530);
32 return () => clearInterval(blink);
33 }, []);
34
35 useEffect(() => {
36 let cancelled = false;
37
38 async function run() {
39 await sleep(800);
40 for (const line of TERMINAL_LINES) {
41 if (cancelled) return;
42 if (line.prompt) {
43 setIsTyping(true);
44 for (let i = 0; i <= line.text.length; i++) {
45 if (cancelled) return;
46 setCurrentTyping(line.text.slice(0, i));
47 await sleep(line.delay + Math.random() * 20);
48 }
49 setIsTyping(false);
50 setLines((prev) => [...prev, { text: line.text, isPrompt: true }]);
51 setCurrentTyping("");
52 await sleep(200);
53 } else {
54 await sleep(line.delay);
55 if (line.text) {
56 setLines((prev) => [...prev, { text: line.text, isPrompt: false }]);
57 } else {
58 setLines((prev) => [...prev, { text: "", isPrompt: false }]);
59 }
60 }
61 }
62 }
63 run();
64 return () => { cancelled = true; };
65 }, []);
66
67 useEffect(() => {
68 if (termRef.current) {
69 termRef.current.scrollTop = termRef.current.scrollHeight;
70 }
71 }, [lines, currentTyping]);
72
73 const cursor = showCursor ? "█" : " ";
74
75 return (
76 <div
77 ref={termRef}
78 style={{
79 backgroundColor: "#1a1918",
80 border: "1px solid #302e2b",
81 padding: "20px",
82 fontFamily: "'JetBrains Mono', Menlo, monospace",
83 fontSize: "13px",
84 lineHeight: 1.7,
85 color: "#c4bfb8",
86 overflow: "hidden",
87 maxHeight: "380px",
88 }}
89 >
90 {lines.map((line, i) => (
91 <div key={i} style={{ minHeight: "1.7em" }}>
92 {line.isPrompt && <span style={{ color: "#7aab9c" }}>❯ </span>}
93 {line.isPrompt ? (
94 <span style={{ color: "#e8e4df" }}>{line.text}</span>
95 ) : (
96 <span style={{ color: "#9a948c" }}>{line.text}</span>
97 )}
98 </div>
99 ))}
100 {(isTyping || lines.length === 0) && (
101 <div>
102 <span style={{ color: "#7aab9c" }}>❯ </span>
103 <span style={{ color: "#e8e4df" }}>{currentTyping}</span>
104 <span style={{ color: "#7aab9c" }}>{cursor}</span>
105 </div>
106 )}
107 </div>
108 );
109}
110
111function sleep(ms: number) {
112 return new Promise((r) => setTimeout(r, ms));
113}
114
115export function LandingPage() {
116 const [islDomain, setIslDomain] = useState("");
117
118 useEffect(() => {
119 const host = window.location.hostname;
120 // Derive isl subdomain: grove.host → isl.grove.host, localhost → ""
121 if (host !== "localhost" && !host.match(/^\d/)) {
122 setIslDomain(`https://isl.${host}`);
123 }
124 }, []);
125
126 return (
127 <div style={{ maxWidth: "800px", margin: "0 auto", padding: "60px 24px 80px" }}>
128 {/* Hero */}
129 <div style={{ textAlign: "center", marginBottom: "64px" }}>
130 <div style={{ display: "inline-block", marginBottom: "24px" }}>
131 <GroveLogo size={64} />
132 </div>
133 <h1
134 style={{
135 fontSize: "2rem",
136 fontWeight: 400,
137 color: "var(--text-primary)",
138 marginBottom: "12px",
139 }}
140 >
141 Source control, self-hosted
142 </h1>
143 <p
144 style={{
145 fontSize: "1rem",
146 color: "var(--text-muted)",
147 maxWidth: "480px",
148 margin: "0 auto 32px",
149 lineHeight: 1.6,
150 }}
151 >
152 Grove is a complete code hosting platform built on Sapling SCM and Mononoke.
153 Stacked diffs, interactive smartlog, CI pipelines — on your own infrastructure.
154 </p>
155 <a
156 href="/login"
157 style={{
158 display: "inline-block",
159 padding: "10px 24px",
160 backgroundColor: "var(--accent)",
161 color: "var(--accent-text)",
162 fontSize: "0.875rem",
163 textDecoration: "none",
164 }}
165 >
166 Get started
167 </a>
168 </div>
169
170 {/* Terminal */}
171 <section style={{ marginBottom: "64px" }}>
172 <div style={{ marginBottom: "16px" }}>
173 <h2
174 style={{
175 fontSize: "0.75rem",
176 fontWeight: 600,
177 textTransform: "uppercase",
178 letterSpacing: "0.1em",
179 color: "var(--text-faint)",
180 marginBottom: "4px",
181 }}
182 >
183 Init, commit, push
184 </h2>
185 <p style={{ fontSize: "0.875rem", color: "var(--text-muted)" }}>
186 From zero to hosted repository in seconds. Sapling&apos;s clean CLI, backed by Mononoke.
187 </p>
188 </div>
189 <TerminalDemo />
190 </section>
191
192 {/* ISL */}
193 {islDomain && (
194 <section style={{ marginBottom: "64px" }}>
195 <div style={{ marginBottom: "16px" }}>
196 <h2
197 style={{
198 fontSize: "0.75rem",
199 fontWeight: 600,
200 textTransform: "uppercase",
201 letterSpacing: "0.1em",
202 color: "var(--text-faint)",
203 marginBottom: "4px",
204 }}
205 >
206 Interactive Smartlog
207 </h2>
208 <p style={{ fontSize: "0.875rem", color: "var(--text-muted)" }}>
209 Visualize your commit graph, browse diffs, and manage stacked changes — all in the browser.
210 This is a live, read-only view of Grove&apos;s own repository.
211 </p>
212 </div>
213 <div
214 style={{
215 border: "1px solid var(--border-subtle)",
216 overflow: "hidden",
217 height: "500px",
218 }}
219 >
220 <iframe
221 src={islDomain}
222 style={{
223 width: "100%",
224 height: "100%",
225 border: "none",
226 }}
227 title="Interactive Smartlog"
228 />
229 </div>
230 </section>
231 )}
232
233 {/* Features */}
234 <section>
235 <div
236 style={{
237 display: "grid",
238 gridTemplateColumns: "1fr 1fr",
239 gap: "1px",
240 backgroundColor: "var(--border-subtle)",
241 border: "1px solid var(--border-subtle)",
242 }}
243 >
244 {[
245 {
246 title: "Sapling SCM",
247 desc: "Stacked diffs, amend-based workflow, interactive rebase — built for how engineers actually work.",
248 },
249 {
250 title: "Mononoke",
251 desc: "Meta's scalable source control server. Handles monorepos with millions of files.",
252 },
253 {
254 title: "Canopy CI",
255 desc: "Pipelines defined in YAML, triggered on push. Build, test, and deploy from your own runners.",
256 },
257 {
258 title: "Self-hosted",
259 desc: "Your code, your servers. Deploy on any Linux machine with a single command.",
260 },
261 ].map((f) => (
262 <div
263 key={f.title}
264 style={{
265 backgroundColor: "var(--bg-card)",
266 padding: "24px",
267 }}
268 >
269 <h3
270 style={{
271 fontSize: "0.875rem",
272 color: "var(--text-primary)",
273 marginBottom: "6px",
274 }}
275 >
276 {f.title}
277 </h3>
278 <p style={{ fontSize: "0.8rem", color: "var(--text-muted)", lineHeight: 1.6 }}>
279 {f.desc}
280 </p>
281 </div>
282 ))}
283 </div>
284 </section>
285 </div>
286 );
287}
288