web/app/landing.tsxblame
View source
4bb999b1"use client";
4bb999b2
4bb999b3import { useEffect, useRef, useState } from "react";
4bb999b4import { GroveLogo } from "@/app/components/grove-logo";
44863ab5import { useTheme } from "@/lib/theme";
4bb999b6
4bb999b7const TERMINAL_LINES = [
4bb999b8 { prompt: true, text: "grove init --owner letterpress-labs", delay: 40 },
4bb999b9 { prompt: false, text: "┌ grove init grove-cli", delay: 0 },
4bb999b10 { prompt: false, text: "◇ Created letterpress-labs/grove-cli", delay: 800 },
4bb999b11 { prompt: false, text: "◇ Sapling repository initialized", delay: 400 },
4bb999b12 { prompt: false, text: "◇ Remote configured", delay: 300 },
4bb999b13 { prompt: false, text: "└ Initialized letterpress-labs/grove-cli", delay: 200 },
4bb999b14 { prompt: false, text: "", delay: 400 },
4bb999b15 { prompt: true, text: 'sl commit -m "add CLI help system and read-only ISL mode"', delay: 35 },
4bb999b16 { prompt: false, text: "", delay: 600 },
4bb999b17 { prompt: true, text: "sl push --to main", delay: 40 },
4bb999b18 { prompt: false, text: "pushing rev c21911da to bookmark main", delay: 400 },
4bb999b19 { prompt: false, text: "edenapi: uploaded 9 files", delay: 300 },
4bb999b20 { prompt: false, text: "edenapi: uploaded 1 commit", delay: 200 },
4bb999b21 { prompt: false, text: 'updated remote bookmark main to c21911da', delay: 300 },
4bb999b22];
4bb999b23
4bb999b24function TerminalDemo() {
4bb999b25 const [lines, setLines] = useState<{ text: string; isPrompt: boolean }[]>([]);
4bb999b26 const [currentTyping, setCurrentTyping] = useState("");
4bb999b27 const [isTyping, setIsTyping] = useState(false);
4bb999b28 const [showCursor, setShowCursor] = useState(true);
4bb999b29 const termRef = useRef<HTMLDivElement>(null);
4bb999b30
4bb999b31 useEffect(() => {
4bb999b32 const blink = setInterval(() => setShowCursor((c) => !c), 530);
4bb999b33 return () => clearInterval(blink);
4bb999b34 }, []);
4bb999b35
4bb999b36 useEffect(() => {
4bb999b37 let cancelled = false;
4bb999b38
4bb999b39 async function run() {
4bb999b40 await sleep(800);
4bb999b41 for (const line of TERMINAL_LINES) {
4bb999b42 if (cancelled) return;
4bb999b43 if (line.prompt) {
4bb999b44 setIsTyping(true);
4bb999b45 for (let i = 0; i <= line.text.length; i++) {
4bb999b46 if (cancelled) return;
4bb999b47 setCurrentTyping(line.text.slice(0, i));
4bb999b48 await sleep(line.delay + Math.random() * 20);
4bb999b49 }
4bb999b50 setIsTyping(false);
4bb999b51 setLines((prev) => [...prev, { text: line.text, isPrompt: true }]);
4bb999b52 setCurrentTyping("");
4bb999b53 await sleep(200);
4bb999b54 } else {
4bb999b55 await sleep(line.delay);
4bb999b56 if (line.text) {
4bb999b57 setLines((prev) => [...prev, { text: line.text, isPrompt: false }]);
4bb999b58 } else {
4bb999b59 setLines((prev) => [...prev, { text: "", isPrompt: false }]);
4bb999b60 }
4bb999b61 }
4bb999b62 }
4bb999b63 }
4bb999b64 run();
4bb999b65 return () => { cancelled = true; };
4bb999b66 }, []);
4bb999b67
4bb999b68 useEffect(() => {
4bb999b69 if (termRef.current) {
4bb999b70 termRef.current.scrollTop = termRef.current.scrollHeight;
4bb999b71 }
4bb999b72 }, [lines, currentTyping]);
4bb999b73
4bb999b74 const cursor = showCursor ? "█" : " ";
4bb999b75
4bb999b76 return (
4bb999b77 <div
4bb999b78 ref={termRef}
4bb999b79 style={{
4bb999b80 backgroundColor: "#1a1918",
4bb999b81 border: "1px solid #302e2b",
4bb999b82 padding: "20px",
4bb999b83 fontFamily: "'JetBrains Mono', Menlo, monospace",
4bb999b84 fontSize: "13px",
4bb999b85 lineHeight: 1.7,
4bb999b86 color: "#c4bfb8",
4bb999b87 overflow: "hidden",
4bb999b88 maxHeight: "380px",
4bb999b89 }}
4bb999b90 >
4bb999b91 {lines.map((line, i) => (
4bb999b92 <div key={i} style={{ minHeight: "1.7em" }}>
4bb999b93 {line.isPrompt && <span style={{ color: "#7aab9c" }}>❯ </span>}
4bb999b94 {line.isPrompt ? (
4bb999b95 <span style={{ color: "#e8e4df" }}>{line.text}</span>
4bb999b96 ) : (
4bb999b97 <span style={{ color: "#9a948c" }}>{line.text}</span>
4bb999b98 )}
4bb999b99 </div>
4bb999b100 ))}
4bb999b101 {(isTyping || lines.length === 0) && (
4bb999b102 <div>
4bb999b103 <span style={{ color: "#7aab9c" }}>❯ </span>
4bb999b104 <span style={{ color: "#e8e4df" }}>{currentTyping}</span>
4bb999b105 <span style={{ color: "#7aab9c" }}>{cursor}</span>
4bb999b106 </div>
4bb999b107 )}
4bb999b108 </div>
4bb999b109 );
4bb999b110}
4bb999b111
4bb999b112function sleep(ms: number) {
4bb999b113 return new Promise((r) => setTimeout(r, ms));
4bb999b114}
4bb999b115
4bb999b116export function LandingPage() {
4bb999b117 const [islDomain, setIslDomain] = useState("");
44863ab118 const { theme } = useTheme();
44863ab119 const islRef = useRef<HTMLIFrameElement>(null);
4bb999b120
4bb999b121 useEffect(() => {
4bb999b122 const host = window.location.hostname;
4bb999b123 if (host !== "localhost" && !host.match(/^\d/)) {
7e5aa77124 setIslDomain("https://isl.grove.host");
4bb999b125 }
4bb999b126 }, []);
4bb999b127
44863ab128 // Send theme changes to ISL iframe via postMessage
44863ab129 useEffect(() => {
44863ab130 if (islRef.current?.contentWindow) {
44863ab131 islRef.current.contentWindow.postMessage({ type: "theme", value: theme }, "*");
44863ab132 }
44863ab133 }, [theme]);
44863ab134
4bb999b135 return (
44863ab136 <div style={{ display: "flex", flexDirection: "column", minHeight: "calc(100vh - 3.5rem)" }}>
44863ab137 {/* Hero + Terminal + Features */}
44863ab138 <div style={{ maxWidth: "800px", margin: "0 auto", padding: "60px 24px 0", width: "100%" }}>
44863ab139
4bb999b140 {/* Hero */}
4bb999b141 <div style={{ textAlign: "center", marginBottom: "64px" }}>
4bb999b142 <div style={{ display: "inline-block", marginBottom: "24px" }}>
4bb999b143 <GroveLogo size={64} />
4bb999b144 </div>
4bb999b145 <h1
4bb999b146 style={{
4bb999b147 fontSize: "2rem",
4bb999b148 fontWeight: 400,
4bb999b149 color: "var(--text-primary)",
4bb999b150 marginBottom: "12px",
4bb999b151 }}
4bb999b152 >
4bb999b153 Source control, self-hosted
4bb999b154 </h1>
4bb999b155 <p
4bb999b156 style={{
4bb999b157 fontSize: "1rem",
4bb999b158 color: "var(--text-muted)",
4bb999b159 maxWidth: "480px",
4bb999b160 margin: "0 auto 32px",
4bb999b161 lineHeight: 1.6,
4bb999b162 }}
4bb999b163 >
4bb999b164 Grove is a complete code hosting platform built on Sapling SCM and Mononoke.
4bb999b165 Stacked diffs, interactive smartlog, CI pipelines — on your own infrastructure.
4bb999b166 </p>
4bb999b167 <a
4bb999b168 href="/login"
4bb999b169 style={{
4bb999b170 display: "inline-block",
4bb999b171 padding: "10px 24px",
4bb999b172 backgroundColor: "var(--accent)",
4bb999b173 color: "var(--accent-text)",
4bb999b174 fontSize: "0.875rem",
4bb999b175 textDecoration: "none",
4bb999b176 }}
4bb999b177 >
4bb999b178 Get started
4bb999b179 </a>
4bb999b180 </div>
4bb999b181
4bb999b182 {/* Terminal */}
4bb999b183 <section style={{ marginBottom: "64px" }}>
4bb999b184 <div style={{ marginBottom: "16px" }}>
4bb999b185 <h2
4bb999b186 style={{
4bb999b187 fontSize: "0.75rem",
4bb999b188 fontWeight: 600,
4bb999b189 textTransform: "uppercase",
4bb999b190 letterSpacing: "0.1em",
4bb999b191 color: "var(--text-faint)",
4bb999b192 marginBottom: "4px",
4bb999b193 }}
4bb999b194 >
4bb999b195 Init, commit, push
4bb999b196 </h2>
4bb999b197 <p style={{ fontSize: "0.875rem", color: "var(--text-muted)" }}>
4bb999b198 From zero to hosted repository in seconds. Sapling&apos;s clean CLI, backed by Mononoke.
4bb999b199 </p>
4bb999b200 </div>
4bb999b201 <TerminalDemo />
4bb999b202 </section>
4bb999b203
4bb999b204 {/* Features */}
44863ab205 <section style={{ marginBottom: "48px" }}>
4bb999b206 <div
4bb999b207 style={{
4bb999b208 display: "grid",
4bb999b209 gridTemplateColumns: "1fr 1fr",
4bb999b210 gap: "1px",
4bb999b211 backgroundColor: "var(--border-subtle)",
4bb999b212 border: "1px solid var(--border-subtle)",
4bb999b213 }}
4bb999b214 >
4bb999b215 {[
4bb999b216 {
4bb999b217 title: "Sapling SCM",
4bb999b218 desc: "Stacked diffs, amend-based workflow, interactive rebase — built for how engineers actually work.",
4bb999b219 },
4bb999b220 {
4bb999b221 title: "Mononoke",
4bb999b222 desc: "Meta's scalable source control server. Handles monorepos with millions of files.",
4bb999b223 },
4bb999b224 {
4bb999b225 title: "Canopy CI",
4bb999b226 desc: "Pipelines defined in YAML, triggered on push. Build, test, and deploy from your own runners.",
4bb999b227 },
4bb999b228 {
4bb999b229 title: "Self-hosted",
4bb999b230 desc: "Your code, your servers. Deploy on any Linux machine with a single command.",
4bb999b231 },
4bb999b232 ].map((f) => (
4bb999b233 <div
4bb999b234 key={f.title}
4bb999b235 style={{
4bb999b236 backgroundColor: "var(--bg-card)",
4bb999b237 padding: "24px",
4bb999b238 }}
4bb999b239 >
4bb999b240 <h3
4bb999b241 style={{
4bb999b242 fontSize: "0.875rem",
4bb999b243 color: "var(--text-primary)",
4bb999b244 marginBottom: "6px",
4bb999b245 }}
4bb999b246 >
4bb999b247 {f.title}
4bb999b248 </h3>
4bb999b249 <p style={{ fontSize: "0.8rem", color: "var(--text-muted)", lineHeight: 1.6 }}>
4bb999b250 {f.desc}
4bb999b251 </p>
4bb999b252 </div>
4bb999b253 ))}
4bb999b254 </div>
4bb999b255 </section>
44863ab256
44863ab257 </div>{/* end centered wrapper */}
44863ab258
44863ab259 {/* ISL — fills remaining viewport */}
44863ab260 {islDomain && (
44863ab261 <section style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: "500px" }}>
44863ab262 <div style={{ padding: "0 24px 16px", maxWidth: "800px", margin: "0 auto", width: "100%" }}>
44863ab263 <h2
44863ab264 style={{
44863ab265 fontSize: "0.75rem",
44863ab266 fontWeight: 600,
44863ab267 textTransform: "uppercase",
44863ab268 letterSpacing: "0.1em",
44863ab269 color: "var(--text-faint)",
44863ab270 marginBottom: "4px",
44863ab271 }}
44863ab272 >
44863ab273 Interactive Smartlog
44863ab274 </h2>
44863ab275 <p style={{ fontSize: "0.875rem", color: "var(--text-muted)" }}>
44863ab276 Visualize your commit graph, browse diffs, and manage stacked changes — all in the browser.
44863ab277 This is a live, read-only view of Grove&apos;s own repository.
44863ab278 </p>
44863ab279 </div>
44863ab280 <div
44863ab281 style={{
44863ab282 flex: 1,
44863ab283 borderTop: "1px solid var(--border-subtle)",
44863ab284 }}
44863ab285 >
44863ab286 <iframe
44863ab287 ref={islRef}
44863ab288 src={`${islDomain}?theme=${theme}`}
44863ab289 style={{
44863ab290 width: "100%",
44863ab291 height: "100%",
44863ab292 border: "none",
44863ab293 display: "block",
44863ab294 }}
44863ab295 title="Interactive Smartlog"
44863ab296 onLoad={() => {
44863ab297 // Send theme on initial load
44863ab298 islRef.current?.contentWindow?.postMessage({ type: "theme", value: theme }, "*");
44863ab299 }}
44863ab300 />
44863ab301 </div>
44863ab302 </section>
44863ab303 )}
4bb999b304 </div>
4bb999b305 );
4bb999b306}