web/app/landing.tsxblame
View source
4bb999b1"use client";
4bb999b2
4bb999b3import { useEffect, useRef, useState } from "react";
4bb999b4import { GroveLogo } from "@/app/components/grove-logo";
4bb999b5
4bb999b6const TERMINAL_LINES = [
4bb999b7 { prompt: true, text: "grove init --owner letterpress-labs", delay: 40 },
4bb999b8 { prompt: false, text: "┌ grove init grove-cli", delay: 0 },
4bb999b9 { prompt: false, text: "◇ Created letterpress-labs/grove-cli", delay: 800 },
4bb999b10 { prompt: false, text: "◇ Sapling repository initialized", delay: 400 },
4bb999b11 { prompt: false, text: "◇ Remote configured", delay: 300 },
4bb999b12 { prompt: false, text: "└ Initialized letterpress-labs/grove-cli", delay: 200 },
4bb999b13 { prompt: false, text: "", delay: 400 },
4bb999b14 { prompt: true, text: 'sl commit -m "add CLI help system and read-only ISL mode"', delay: 35 },
4bb999b15 { prompt: false, text: "", delay: 600 },
4bb999b16 { prompt: true, text: "sl push --to main", delay: 40 },
4bb999b17 { prompt: false, text: "pushing rev c21911da to bookmark main", delay: 400 },
4bb999b18 { prompt: false, text: "edenapi: uploaded 9 files", delay: 300 },
4bb999b19 { prompt: false, text: "edenapi: uploaded 1 commit", delay: 200 },
4bb999b20 { prompt: false, text: 'updated remote bookmark main to c21911da', delay: 300 },
4bb999b21];
4bb999b22
4bb999b23function TerminalDemo() {
4bb999b24 const [lines, setLines] = useState<{ text: string; isPrompt: boolean }[]>([]);
4bb999b25 const [currentTyping, setCurrentTyping] = useState("");
4bb999b26 const [isTyping, setIsTyping] = useState(false);
4bb999b27 const [showCursor, setShowCursor] = useState(true);
4bb999b28 const termRef = useRef<HTMLDivElement>(null);
4bb999b29
4bb999b30 useEffect(() => {
4bb999b31 const blink = setInterval(() => setShowCursor((c) => !c), 530);
4bb999b32 return () => clearInterval(blink);
4bb999b33 }, []);
4bb999b34
4bb999b35 useEffect(() => {
4bb999b36 let cancelled = false;
4bb999b37
4bb999b38 async function run() {
4bb999b39 await sleep(800);
4bb999b40 for (const line of TERMINAL_LINES) {
4bb999b41 if (cancelled) return;
4bb999b42 if (line.prompt) {
4bb999b43 setIsTyping(true);
4bb999b44 for (let i = 0; i <= line.text.length; i++) {
4bb999b45 if (cancelled) return;
4bb999b46 setCurrentTyping(line.text.slice(0, i));
4bb999b47 await sleep(line.delay + Math.random() * 20);
4bb999b48 }
4bb999b49 setIsTyping(false);
4bb999b50 setLines((prev) => [...prev, { text: line.text, isPrompt: true }]);
4bb999b51 setCurrentTyping("");
4bb999b52 await sleep(200);
4bb999b53 } else {
4bb999b54 await sleep(line.delay);
4bb999b55 if (line.text) {
4bb999b56 setLines((prev) => [...prev, { text: line.text, isPrompt: false }]);
4bb999b57 } else {
4bb999b58 setLines((prev) => [...prev, { text: "", isPrompt: false }]);
4bb999b59 }
4bb999b60 }
4bb999b61 }
4bb999b62 }
4bb999b63 run();
4bb999b64 return () => { cancelled = true; };
4bb999b65 }, []);
4bb999b66
4bb999b67 useEffect(() => {
4bb999b68 if (termRef.current) {
4bb999b69 termRef.current.scrollTop = termRef.current.scrollHeight;
4bb999b70 }
4bb999b71 }, [lines, currentTyping]);
4bb999b72
4bb999b73 const cursor = showCursor ? "█" : " ";
4bb999b74
4bb999b75 return (
4bb999b76 <div
4bb999b77 ref={termRef}
4bb999b78 style={{
4bb999b79 backgroundColor: "#1a1918",
4bb999b80 border: "1px solid #302e2b",
4bb999b81 padding: "20px",
4bb999b82 fontFamily: "'JetBrains Mono', Menlo, monospace",
4bb999b83 fontSize: "13px",
4bb999b84 lineHeight: 1.7,
4bb999b85 color: "#c4bfb8",
4bb999b86 overflow: "hidden",
4bb999b87 maxHeight: "380px",
4bb999b88 }}
4bb999b89 >
4bb999b90 {lines.map((line, i) => (
4bb999b91 <div key={i} style={{ minHeight: "1.7em" }}>
4bb999b92 {line.isPrompt && <span style={{ color: "#7aab9c" }}>❯ </span>}
4bb999b93 {line.isPrompt ? (
4bb999b94 <span style={{ color: "#e8e4df" }}>{line.text}</span>
4bb999b95 ) : (
4bb999b96 <span style={{ color: "#9a948c" }}>{line.text}</span>
4bb999b97 )}
4bb999b98 </div>
4bb999b99 ))}
4bb999b100 {(isTyping || lines.length === 0) && (
4bb999b101 <div>
4bb999b102 <span style={{ color: "#7aab9c" }}>❯ </span>
4bb999b103 <span style={{ color: "#e8e4df" }}>{currentTyping}</span>
4bb999b104 <span style={{ color: "#7aab9c" }}>{cursor}</span>
4bb999b105 </div>
4bb999b106 )}
4bb999b107 </div>
4bb999b108 );
4bb999b109}
4bb999b110
4bb999b111function sleep(ms: number) {
4bb999b112 return new Promise((r) => setTimeout(r, ms));
4bb999b113}
4bb999b114
4bb999b115export function LandingPage() {
4bb999b116 const [islDomain, setIslDomain] = useState("");
4bb999b117
4bb999b118 useEffect(() => {
4bb999b119 const host = window.location.hostname;
4bb999b120 // Derive isl subdomain: grove.host → isl.grove.host, localhost → ""
4bb999b121 if (host !== "localhost" && !host.match(/^\d/)) {
4bb999b122 setIslDomain(`https://isl.${host}`);
4bb999b123 }
4bb999b124 }, []);
4bb999b125
4bb999b126 return (
4bb999b127 <div style={{ maxWidth: "800px", margin: "0 auto", padding: "60px 24px 80px" }}>
4bb999b128 {/* Hero */}
4bb999b129 <div style={{ textAlign: "center", marginBottom: "64px" }}>
4bb999b130 <div style={{ display: "inline-block", marginBottom: "24px" }}>
4bb999b131 <GroveLogo size={64} />
4bb999b132 </div>
4bb999b133 <h1
4bb999b134 style={{
4bb999b135 fontSize: "2rem",
4bb999b136 fontWeight: 400,
4bb999b137 color: "var(--text-primary)",
4bb999b138 marginBottom: "12px",
4bb999b139 }}
4bb999b140 >
4bb999b141 Source control, self-hosted
4bb999b142 </h1>
4bb999b143 <p
4bb999b144 style={{
4bb999b145 fontSize: "1rem",
4bb999b146 color: "var(--text-muted)",
4bb999b147 maxWidth: "480px",
4bb999b148 margin: "0 auto 32px",
4bb999b149 lineHeight: 1.6,
4bb999b150 }}
4bb999b151 >
4bb999b152 Grove is a complete code hosting platform built on Sapling SCM and Mononoke.
4bb999b153 Stacked diffs, interactive smartlog, CI pipelines — on your own infrastructure.
4bb999b154 </p>
4bb999b155 <a
4bb999b156 href="/login"
4bb999b157 style={{
4bb999b158 display: "inline-block",
4bb999b159 padding: "10px 24px",
4bb999b160 backgroundColor: "var(--accent)",
4bb999b161 color: "var(--accent-text)",
4bb999b162 fontSize: "0.875rem",
4bb999b163 textDecoration: "none",
4bb999b164 }}
4bb999b165 >
4bb999b166 Get started
4bb999b167 </a>
4bb999b168 </div>
4bb999b169
4bb999b170 {/* Terminal */}
4bb999b171 <section style={{ marginBottom: "64px" }}>
4bb999b172 <div style={{ marginBottom: "16px" }}>
4bb999b173 <h2
4bb999b174 style={{
4bb999b175 fontSize: "0.75rem",
4bb999b176 fontWeight: 600,
4bb999b177 textTransform: "uppercase",
4bb999b178 letterSpacing: "0.1em",
4bb999b179 color: "var(--text-faint)",
4bb999b180 marginBottom: "4px",
4bb999b181 }}
4bb999b182 >
4bb999b183 Init, commit, push
4bb999b184 </h2>
4bb999b185 <p style={{ fontSize: "0.875rem", color: "var(--text-muted)" }}>
4bb999b186 From zero to hosted repository in seconds. Sapling&apos;s clean CLI, backed by Mononoke.
4bb999b187 </p>
4bb999b188 </div>
4bb999b189 <TerminalDemo />
4bb999b190 </section>
4bb999b191
4bb999b192 {/* ISL */}
4bb999b193 {islDomain && (
4bb999b194 <section style={{ marginBottom: "64px" }}>
4bb999b195 <div style={{ marginBottom: "16px" }}>
4bb999b196 <h2
4bb999b197 style={{
4bb999b198 fontSize: "0.75rem",
4bb999b199 fontWeight: 600,
4bb999b200 textTransform: "uppercase",
4bb999b201 letterSpacing: "0.1em",
4bb999b202 color: "var(--text-faint)",
4bb999b203 marginBottom: "4px",
4bb999b204 }}
4bb999b205 >
4bb999b206 Interactive Smartlog
4bb999b207 </h2>
4bb999b208 <p style={{ fontSize: "0.875rem", color: "var(--text-muted)" }}>
4bb999b209 Visualize your commit graph, browse diffs, and manage stacked changes — all in the browser.
4bb999b210 This is a live, read-only view of Grove&apos;s own repository.
4bb999b211 </p>
4bb999b212 </div>
4bb999b213 <div
4bb999b214 style={{
4bb999b215 border: "1px solid var(--border-subtle)",
4bb999b216 overflow: "hidden",
4bb999b217 height: "500px",
4bb999b218 }}
4bb999b219 >
4bb999b220 <iframe
4bb999b221 src={islDomain}
4bb999b222 style={{
4bb999b223 width: "100%",
4bb999b224 height: "100%",
4bb999b225 border: "none",
4bb999b226 }}
4bb999b227 title="Interactive Smartlog"
4bb999b228 />
4bb999b229 </div>
4bb999b230 </section>
4bb999b231 )}
4bb999b232
4bb999b233 {/* Features */}
4bb999b234 <section>
4bb999b235 <div
4bb999b236 style={{
4bb999b237 display: "grid",
4bb999b238 gridTemplateColumns: "1fr 1fr",
4bb999b239 gap: "1px",
4bb999b240 backgroundColor: "var(--border-subtle)",
4bb999b241 border: "1px solid var(--border-subtle)",
4bb999b242 }}
4bb999b243 >
4bb999b244 {[
4bb999b245 {
4bb999b246 title: "Sapling SCM",
4bb999b247 desc: "Stacked diffs, amend-based workflow, interactive rebase — built for how engineers actually work.",
4bb999b248 },
4bb999b249 {
4bb999b250 title: "Mononoke",
4bb999b251 desc: "Meta's scalable source control server. Handles monorepos with millions of files.",
4bb999b252 },
4bb999b253 {
4bb999b254 title: "Canopy CI",
4bb999b255 desc: "Pipelines defined in YAML, triggered on push. Build, test, and deploy from your own runners.",
4bb999b256 },
4bb999b257 {
4bb999b258 title: "Self-hosted",
4bb999b259 desc: "Your code, your servers. Deploy on any Linux machine with a single command.",
4bb999b260 },
4bb999b261 ].map((f) => (
4bb999b262 <div
4bb999b263 key={f.title}
4bb999b264 style={{
4bb999b265 backgroundColor: "var(--bg-card)",
4bb999b266 padding: "24px",
4bb999b267 }}
4bb999b268 >
4bb999b269 <h3
4bb999b270 style={{
4bb999b271 fontSize: "0.875rem",
4bb999b272 color: "var(--text-primary)",
4bb999b273 marginBottom: "6px",
4bb999b274 }}
4bb999b275 >
4bb999b276 {f.title}
4bb999b277 </h3>
4bb999b278 <p style={{ fontSize: "0.8rem", color: "var(--text-muted)", lineHeight: 1.6 }}>
4bb999b279 {f.desc}
4bb999b280 </p>
4bb999b281 </div>
4bb999b282 ))}
4bb999b283 </div>
4bb999b284 </section>
4bb999b285 </div>
4bb999b286 );
4bb999b287}