Skip to content

app/client/Landing.tsx

147 lines · 4.7 KB · TypeScript
Raw
import { useEffect, useMemo, useState } from 'react'
import { planCastle } from '../../src/shared/castle.ts'
import { BotIcon, PersonIcon, SyncIcon } from './icons.tsx'
import { navigate, signInHref, signOutHref } from './nav.ts'
import type { BlockRow, BuildRow, PlayerRow } from './powersync.ts'
import { World } from './World.tsx'

const PREVIEW_WORLD = 'preview'
const noop = () => {}

/** A castle that builds itself in a loop: a local stand-in for an agent build streaming in. */
function usePreviewBlocks() {
	const plan = useMemo(() => planCastle(0, 0), [])
	const [count, setCount] = useState(0)
	useEffect(() => {
		const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches
		if (reduce) {
			setCount(plan.length)
			return
		}
		const t = setInterval(() => setCount((c) => (c >= plan.length + 60 ? 0 : c + 12)), 120)
		return () => clearInterval(t)
	}, [plan])
	return useMemo<Array<BlockRow>>(
		() =>
			plan.slice(0, Math.min(count, plan.length)).map((b) => ({
				id: `${b.x}:${b.y}:${b.z}`,
				world_id: PREVIEW_WORLD,
				x: b.x,
				y: b.y,
				z: b.z,
				material: b.material,
				build_id: null,
				placed_by: null,
				seq: null,
			})),
		[plan, count],
	)
}

export function Landing({ appBase }: { appBase: string }) {
	const blocks = usePreviewBlocks()
	const [me, setMe] = useState<{ signedIn: boolean; displayName?: string } | null>(null)
	const [creating, setCreating] = useState(false)
	const [error, setError] = useState<string | null>(null)
	useEffect(() => {
		void fetch(`${appBase}/api/me`, { credentials: 'same-origin' })
			.then((r) => r.json())
			.then(setMe, () => setMe({ signedIn: false }))
	}, [appBase])

	async function createRoom() {
		setCreating(true)
		setError(null)
		try {
			const res = await fetch(`${appBase}/api/rooms`, { method: 'POST', credentials: 'same-origin' })
			const body = (await res.json().catch(() => ({}))) as { room?: { slug: string }; error?: string }
			if (!res.ok || !body.room) throw new Error(body.error ?? 'Could not create a room. Try again.')
			navigate(`/r/${body.room.slug}`)
		} catch (e) {
			setError(e instanceof Error ? e.message : 'Could not create a room. Try again.')
			setCreating(false)
		}
	}

	const signedOut = me !== null && !me.signedIn
	return (
		<div className="landing">
			<div className="landing-preview" aria-hidden="true">
				<World
					blocks={blocks}
					players={[] as Array<PlayerRow>}
					builds={[] as Array<BuildRow>}
					meId={null}
					mode="place"
					canEdit={false}
					onPlace={noop}
					onRemove={noop}
					onHover={noop}
				/>
			</div>
			<header className="landing-top">
				<div className="brand">
					<img src={`${document.documentElement.dataset.assetBase}/icon.png`} alt="" width={30} height={30} />
					<strong>World Builder</strong>
				</div>
				{me?.signedIn ? (
					<a className="chip" href={signOutHref()} title="Sign out of Kody on this site">
						Sign out
					</a>
				) : signedOut ? (
					<a className="chip" href={signInHref()}>
						Sign in to build
					</a>
				) : null}
			</header>
			<main className="hero">
				<p className="eyebrow">
					<span className="live-dot" /> Live multiplayer · humans + agents
				</p>
				<h1>Build a 3D world together, with your friends and your AI agents.</h1>
				<p className="lede">
					Open a room, share the link, and place blocks together. Then paste one prompt into Claude, ChatGPT or Grok and
					ask for a castle. Everyone in the room watches it go up block by block.
				</p>
				<div className="hero-actions">
					<button type="button" className="cta" onClick={createRoom} disabled={creating || signedOut || me === null}>
						{creating ? 'Creating…' : 'Create a room'}
					</button>
					<button type="button" className="cta ghost" onClick={() => navigate('/r/main')}>
						Watch the main world
					</button>
					{signedOut ? <p className="hint"><a href={signInHref()}>Sign in to build</a> and create your own room, or watch the main world.</p> : null}
					{error ? (
						<p className="hint error" role="alert">
							{error}
						</p>
					) : null}
				</div>
				<ul className="features">
					<li>
						<PersonIcon />
						<span>
							<strong>One-click rooms</strong> with a private share link
						</span>
					</li>
					<li>
						<BotIcon />
						<span>
							<strong>Bring your own agent</strong> over Kody MCP or the CLI
						</span>
					</li>
					<li>
						<SyncIcon state="synced" />
						<span>
							<strong>Live sync</strong> for every block, powered by PowerSync
						</span>
					</li>
				</ul>
			</main>
			<footer className="landing-foot">
				Powered by <a href="https://www.powersync.com" target="_blank" rel="noreferrer">PowerSync</a> and{' '}
				<a href="https://kody.codes" target="_blank" rel="noreferrer">Kody</a>
			</footer>
		</div>
	)
}