Skip to content
File

Blob: src/client/components/landing-page.tsx

typescript457 lines
1import { Link } from "@tanstack/react-router";
2import { FileText, ChevronRight, Circle, Search, Pilcrow, Heart } from "lucide-react";
3import { useDocumentTitle } from "@/client/hooks/use-document-title";
4 
5const STACK = [
6 { name: "Workers", role: "Hono router + auth" },
7 { name: "D1", role: "users, pages, memberships" },
8 { name: "Durable Objects", role: "one per document" },
9 { name: "R2", role: "uploads" },
10 { name: "Queues", role: "search indexing" },
11] as const;
12 
13const MOCK_SIDEBAR_PAGES = [
14 { title: "Getting Started", depth: 0, active: false },
15 { title: "Architecture", depth: 0, active: true },
16 { title: "Workers API", depth: 1, active: false },
17 { title: "Data Model", depth: 1, active: false },
18 { title: "Deployment", depth: 0, active: false },
19] as const;
20 
21export function LandingPage() {
22 useDocumentTitle(undefined);
23 
24 return (
25 <div className="relative min-h-screen">
26 {/* Header */}
27 <header className="sticky top-0 z-50 border-b border-zinc-800/60 bg-zinc-900/95 backdrop-blur-sm">
28 <div className="mx-auto flex max-w-7xl items-center px-4 py-3 sm:px-6">
29 <Link
30 to="/"
31 className="group flex items-center gap-3 transition-opacity hover:opacity-80"
32 aria-label="bland home"
33 >
34 <span className="inline-grid h-9 w-9 place-items-center">
35 <Pilcrow
36 className="h-6 w-6 text-accent-400 transition-transform duration-200 group-hover:-rotate-6"
37 strokeWidth={2}
38 aria-hidden="true"
39 />
40 </span>
41 <span className="hidden sm:block">
42 <strong className="block text-sm font-semibold text-zinc-100">bland</strong>
43 <small className="block text-xs text-zinc-400">Docs on Cloudflare</small>
44 </span>
45 </Link>
46 </div>
47 </header>
48
49 <main id="main-content">
50 {/* Hero */}
51 <section className="mx-auto max-w-7xl px-4 pb-16 pt-24 sm:px-6 sm:pt-32 lg:pt-40">
52 <div className="ml-auto max-w-4xl text-right">
53 <h1 className="font-display text-6xl font-bold leading-[1.08] tracking-[-0.03em] text-zinc-100 sm:text-7xl lg:text-8xl">
54 <span className="animate-slide-up block opacity-0">Write together.</span>
55 <span
56 className="animate-slide-up block opacity-0 pr-1 text-accent-400 sm:pr-2"
57 style={{ animationDelay: "80ms" }}
58 >
59 Ship on Cloudflare.
60 </span>
61 </h1>
62
63 <p
64 className="animate-slide-up ml-auto mt-8 max-w-xl text-lg leading-relaxed text-zinc-400 opacity-0"
65 style={{ animationDelay: "160ms" }}
66 >
67 A block editor with real-time collaboration, nested pages, full-text search, and AI writing help that
68 stays out of the way. Runs entirely on Cloudflare Workers.
69 </p>
70
71 <div
72 className="animate-slide-up mt-8 flex items-center justify-end gap-4 opacity-0"
73 style={{ animationDelay: "240ms" }}
74 >
75 <span className="text-sm text-zinc-500">Invite only — ask your teammate for a link</span>
76 <Link
77 to="/login"
78 search={{ redirect: undefined }}
79 className="inline-flex items-center gap-2 rounded-xl bg-accent-600 px-5 py-2.5 text-sm font-medium text-white transition-[background-color,transform] hover:bg-accent-500 active:scale-[0.98]"
80 >
81 Sign in
82 <ChevronRight className="h-4 w-4" aria-hidden="true" />
83 </Link>
84 </div>
85 </div>
86 </section>
87
88 {/* Product mockup */}
89 <section className="relative mx-auto max-w-7xl px-4 pb-32 sm:px-6">
90 <div
91 className="animate-fade-in relative opacity-0"
92 style={{
93 animationDelay: "400ms",
94 transform: "perspective(2400px) rotateY(-1.5deg) rotateX(1deg)",
95 }}
96 >
97 <div className="overflow-hidden rounded-2xl border border-zinc-800/50 bg-zinc-800/40 shadow-2xl shadow-black/30">
98 {/* Window chrome */}
99 <div className="flex items-center gap-2 border-b border-zinc-800/40 px-4 py-2.5">
100 <span className="h-2.5 w-2.5 rounded-full bg-zinc-700/80" />
101 <span className="h-2.5 w-2.5 rounded-full bg-zinc-700/80" />
102 <span className="h-2.5 w-2.5 rounded-full bg-zinc-700/80" />
103 <span className="ml-3 text-[11px] text-zinc-500">bland.tools</span>
104 </div>
105
106 {/* App body */}
107 <div className="flex min-h-[340px] sm:min-h-[420px]">
108 {/* Sidebar mock */}
109 <div className="hidden w-52 shrink-0 border-r border-zinc-800/40 px-3 py-4 sm:block">
110 <div className="mb-3 flex items-center gap-2 px-2">
111 <div className="h-5 w-5 rounded bg-gradient-to-br from-accent-500/80 to-accent-600/80" />
112 <span className="text-xs font-medium text-zinc-300">My workspace</span>
113 </div>
114 <div className="space-y-0.5">
115 {MOCK_SIDEBAR_PAGES.map((p) => (
116 <div
117 key={p.title}
118 className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-xs ${
119 p.active ? "bg-accent-500/10 text-accent-400" : "text-zinc-500 hover:text-zinc-300"
120 }`}
121 style={{ paddingLeft: `${8 + p.depth * 16}px` }}
122 >
123 <FileText className="h-3 w-3 shrink-0" aria-hidden="true" />
124 {p.title}
125 </div>
126 ))}
127 </div>
128 </div>
129
130 {/* Editor mock */}
131 <div className="flex-1 px-6 py-6 sm:px-10 sm:py-8">
132 <h2 className="font-display text-2xl font-semibold tracking-tight text-zinc-100 sm:text-3xl">
133 Architecture
134 </h2>
135 <p className="mt-4 max-w-lg text-sm leading-relaxed text-zinc-400">
136 The platform runs on Cloudflare Workers with Durable Objects for real-time document collaboration.{" "}
137 <span className="relative inline-flex items-baseline">
138 <span className="inline-block h-3.5 w-px bg-accent-400 animate-caret-blink" />
139 <span className="ml-0.5 rounded-sm bg-accent-500/20 px-1 py-px text-[10px] font-medium leading-none text-accent-300">
140 Rachel
141 </span>
142 </span>{" "}
143 Each document is a lightweight server that handles WebSocket connections, Yjs sync, and persistence.
144 </p>
145
146 <div className="mt-5 space-y-2">
147 <div className="flex items-start gap-2 text-sm text-zinc-400">
148 <Circle className="mt-1.5 h-1.5 w-1.5 shrink-0 fill-zinc-500 text-zinc-500" aria-hidden="true" />
149 <span>Hono-based HTTP router for auth, CRUD, and search</span>
150 </div>
151 <div className="flex items-start gap-2 text-sm text-zinc-400">
152 <Circle className="mt-1.5 h-1.5 w-1.5 shrink-0 fill-zinc-500 text-zinc-500" aria-hidden="true" />
153 <span>One Durable Object per document with Yjs state</span>
154 </div>
155 <div className="flex items-start gap-2 text-sm text-zinc-400">
156 <Circle className="mt-1.5 h-1.5 w-1.5 shrink-0 fill-zinc-500 text-zinc-500" aria-hidden="true" />
157 <span>D1 as single source of truth for structured data</span>
158 </div>
159 </div>
160
161 {/* Code block mock */}
162 <div className="mt-5 rounded-lg border border-zinc-800/50 bg-zinc-950/60 px-4 py-3">
163 <pre className="font-mono text-xs leading-relaxed text-zinc-400">
164 <span className="text-accent-400">export class</span>{" "}
165 <span className="text-zinc-200">DocSync</span> <span className="text-accent-400">extends</span>{" "}
166 <span className="text-zinc-300">YServer</span> {"{"}
167 {"\n "}
168 <span className="text-accent-400">async</span> <span className="text-zinc-300">onSave</span>
169 {"() { "}
170 <span className="text-zinc-500">/* ... */</span>
171 {" }"}
172 {"\n}"}
173 </pre>
174 </div>
175 </div>
176 </div>
177 </div>
178
179 {/* Bottom gradient mask */}
180 <div
181 className="pointer-events-none absolute inset-x-0 bottom-0 h-24"
182 style={{
183 background: "linear-gradient(to top, var(--color-canvas), transparent)",
184 }}
185 aria-hidden="true"
186 />
187 </div>
188 </section>
189
190 {/* Features — bento grid */}
191 <section className="mx-auto max-w-7xl px-4 pb-28 sm:px-6">
192 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
193 {/* Collaboration — dominant, full-width lead */}
194 <div className="group relative overflow-hidden rounded-2xl border border-zinc-800/50 bg-zinc-800/40 p-6 sm:col-span-2 sm:p-8 lg:col-span-3 lg:p-10">
195 <div className="grid gap-6 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] lg:gap-12">
196 <div>
197 <h3 className="font-display text-2xl font-semibold tracking-tight text-zinc-100 sm:text-3xl">
198 Real-time collaboration
199 </h3>
200 <p className="mt-4 max-w-md text-[15px] leading-relaxed text-zinc-400">
201 One Durable Object per document. Yjs-powered presence, live cursors, awareness &mdash; the document{" "}
202 <em className="not-italic font-medium text-zinc-200">is</em> the server. No broker, no third-party
203 sync service, no drift.
204 </p>
205 <div className="mt-5 flex flex-wrap items-center gap-x-5 gap-y-2 text-[11px]">
206 <span className="flex items-center gap-1.5">
207 <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
208 <span className="font-medium text-zinc-300">Alex</span>
209 <span className="text-zinc-600">· line 4</span>
210 </span>
211 <span className="flex items-center gap-1.5">
212 <span className="h-1.5 w-1.5 rounded-full bg-accent-500" />
213 <span className="font-medium text-zinc-300">Rachel</span>
214 <span className="text-zinc-600">· commented</span>
215 </span>
216 <span className="flex items-center gap-1.5">
217 <span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
218 <span className="font-medium text-zinc-300">Sam</span>
219 <span className="text-zinc-600">· idle</span>
220 </span>
221 </div>
222 </div>
223
224 <div className="rounded-lg border border-zinc-800/40 bg-zinc-950/60 p-5 sm:p-6">
225 <p className="text-[15px] leading-relaxed text-zinc-400">
226 The Worker routes each connection to a matching{" "}
227 <span className="relative inline-flex items-baseline whitespace-nowrap">
228 <span className="inline-block h-[1.1em] w-px translate-y-[0.12em] bg-emerald-400 animate-caret-blink" />
229 <span className="ml-0.5 rounded-sm bg-emerald-500/20 px-1 py-px text-[10px] font-medium leading-none text-emerald-300">
230 Alex
231 </span>
232 </span>{" "}
233 <span className="font-semibold text-zinc-200">Durable Object</span> that holds the Yjs document in
234 memory, fans updates to every connected client, and persists to SQLite on save.{" "}
235 <span className="relative inline-flex items-baseline whitespace-nowrap">
236 <span className="inline-block h-[1.1em] w-px translate-y-[0.12em] bg-accent-400 animate-caret-blink" />
237 <span className="ml-0.5 rounded-sm bg-accent-500/20 px-1 py-px text-[10px] font-medium leading-none text-accent-300">
238 Rachel
239 </span>
240 </span>
241 </p>
242 </div>
243 </div>
244 </div>
245
246 {/* AI writing help — secondary */}
247 <div className="group relative overflow-hidden rounded-2xl border border-zinc-800/50 bg-zinc-800/40 p-6 sm:col-span-2 lg:col-span-2">
248 <div className="flex items-baseline justify-between">
249 <h3 className="font-display text-lg font-semibold tracking-tight text-zinc-100">AI writing help</h3>
250 <span className="hidden text-[10px] font-medium uppercase tracking-widest text-zinc-600 sm:inline">
251 Workers AI
252 </span>
253 </div>
254 <p className="mt-2 max-w-md text-sm leading-relaxed text-zinc-500">
255 Select a paragraph, pick an intent, accept the edit. Streams into the editor &mdash; and keeps quiet
256 about it.
257 </p>
258
259 <div className="mt-5 flex flex-col items-start gap-3 rounded-lg border border-zinc-800/40 bg-zinc-950/50 px-4 py-3 sm:flex-row sm:items-start sm:gap-3">
260 <p className="text-sm leading-relaxed text-zinc-400 sm:max-w-[19rem]">
261 Each document is{" "}
262 <span className="rounded-sm bg-accent-500/25 px-0.5 text-accent-200">
263 its own{" "}
264 <span className="underline decoration-red-400 decoration-wavy decoration-1 underline-offset-[3px]">
265 lightwieght
266 </span>{" "}
267 server handling WebSocket connections
268 </span>
269 .
270 </p>
271 <div className="shrink-0 rounded-md border border-zinc-800/60 bg-zinc-900/95 p-1 shadow-xl shadow-black/30">
272 {[
273 { label: "Proofread", desc: "Fix grammar & spelling", active: true },
274 { label: "Formal", desc: "More professional tone" },
275 { label: "Casual", desc: "More conversational" },
276 { label: "Simplify", desc: "Easier to read" },
277 ].map(({ label, desc, active }) => (
278 <div
279 key={label}
280 className={`flex items-baseline gap-2 rounded-sm px-2 py-0.5 text-[11px] ${
281 active ? "bg-accent-500/10 text-accent-300" : "text-zinc-400"
282 }`}
283 >
284 <span className="font-medium">{label}</span>
285 <span
286 className={`truncate text-[9px] ${active ? "text-accent-400/70" : "text-zinc-600"}`}
287 aria-hidden="true"
288 >
289 {desc}
290 </span>
291 </div>
292 ))}
293 </div>
294 </div>
295
296 <ul className="mt-4 grid gap-1.5 text-[11px] text-zinc-500 sm:grid-cols-2">
297 <li className="flex items-center gap-2">
298 <span className="font-mono text-[10px] text-zinc-400">/ai</span>
299 <span>continues at the cursor</span>
300 </li>
301 <li className="flex items-center gap-2">
302 <span className="font-mono text-[10px] text-zinc-400">Summarize</span>
303 <span>asks the page a follow-up</span>
304 </li>
305 </ul>
306 </div>
307
308 {/* Search — tall card */}
309 <div className="group relative overflow-hidden rounded-2xl border border-zinc-800/50 bg-zinc-800/40 p-6 sm:col-span-2 lg:col-span-1 lg:row-span-2">
310 <h3 className="font-display text-lg font-semibold tracking-tight text-zinc-100">Full-text search</h3>
311 <p className="mt-2 text-sm leading-relaxed text-zinc-500">
312 FTS5-powered instant search across every page in the workspace.
313 </p>
314 {/* Mini visual — search UI */}
315 <div className="mt-5 space-y-2">
316 <div className="flex items-center gap-2 rounded-lg border border-zinc-700/50 bg-zinc-950/60 px-3 py-2">
317 <Search className="h-3.5 w-3.5 text-zinc-500" aria-hidden="true" />
318 <span className="text-xs text-zinc-400">durable objects</span>
319 <span className="ml-auto text-[10px] text-zinc-700">⌘K</span>
320 </div>
321 {[
322 { title: "Architecture", match: "…Durable Objects for real-time…" },
323 { title: "Workers API", match: "…each Durable Object handles…" },
324 { title: "Data Model", match: "…Object storage and D1…" },
325 ].map((r) => (
326 <div
327 key={r.title}
328 className="rounded-lg border border-transparent bg-zinc-800/30 px-3 py-2 transition-colors first:border-accent-500/20 first:bg-accent-500/[0.06]"
329 >
330 <div className="flex items-center gap-2">
331 <FileText className="h-3 w-3 text-zinc-500" aria-hidden="true" />
332 <span className="text-xs font-medium text-zinc-300">{r.title}</span>
333 </div>
334 <p className="mt-0.5 text-[11px] leading-relaxed text-zinc-500">{r.match}</p>
335 </div>
336 ))}
337 </div>
338 </div>
339
340 {/* Block types — compact card */}
341 <div className="group relative overflow-hidden rounded-2xl border border-zinc-800/50 bg-zinc-800/40 p-6 sm:col-span-2 lg:col-span-2">
342 <div className="relative z-10">
343 <h3 className="font-display text-lg font-semibold tracking-tight text-zinc-100">Rich block editor</h3>
344 <p className="mt-2 max-w-md text-sm leading-relaxed text-zinc-500">
345 Headings, lists, code blocks with syntax highlighting, tables, images, toggles, and more — all from a
346 slash menu.
347 </p>
348 </div>
349 {/* Mini visual — block sampler */}
350 <div className="mt-5 grid gap-2 sm:grid-cols-3">
351 {/* Code block */}
352 <div className="rounded-lg border border-zinc-800/40 bg-zinc-950/50 px-3 py-2.5">
353 <span className="mb-1.5 block text-[10px] font-medium uppercase tracking-wider text-zinc-500">
354 Code
355 </span>
356 <pre className="font-mono text-[11px] leading-relaxed text-zinc-500">
357 <span className="text-accent-400">const</span> <span className="text-zinc-300">doc</span> ={" "}
358 <span className="text-accent-400">new</span> <span className="text-zinc-300">Y.Doc</span>()
359 </pre>
360 </div>
361 {/* Task list */}
362 <div className="rounded-lg border border-zinc-800/40 bg-zinc-950/50 px-3 py-2.5">
363 <span className="mb-1.5 block text-[10px] font-medium uppercase tracking-wider text-zinc-500">
364 Tasks
365 </span>
366 <div className="space-y-1">
367 <div className="flex items-center gap-2 text-xs">
368 <span className="inline-grid h-3.5 w-3.5 place-items-center rounded border border-accent-500/40 bg-accent-500/10 text-[8px] text-accent-400">
369 ✓
370 </span>
371 <span className="text-zinc-500 line-through">Set up D1 schema</span>
372 </div>
373 <div className="flex items-center gap-2 text-xs">
374 <span className="inline-block h-3.5 w-3.5 rounded border border-zinc-700" />
375 <span className="text-zinc-400">Wire search indexer</span>
376 </div>
377 <div className="flex items-center gap-2 text-xs">
378 <span className="inline-block h-3.5 w-3.5 rounded border border-zinc-700" />
379 <span className="text-zinc-400">Deploy to prod</span>
380 </div>
381 </div>
382 </div>
383 {/* Table */}
384 <div className="rounded-lg border border-zinc-800/40 bg-zinc-950/50 px-3 py-2.5">
385 <span className="mb-1.5 block text-[10px] font-medium uppercase tracking-wider text-zinc-500">
386 Table
387 </span>
388 <table className="w-full text-[11px]">
389 <thead>
390 <tr className="border-b border-zinc-800/50 text-left text-zinc-500">
391 <th className="pb-1 pr-3 font-medium">Binding</th>
392 <th className="pb-1 font-medium">Type</th>
393 </tr>
394 </thead>
395 <tbody className="text-zinc-500">
396 <tr className="border-b border-zinc-800/30">
397 <td className="py-0.5 pr-3 text-zinc-400">DB</td>
398 <td className="py-0.5">D1</td>
399 </tr>
400 <tr className="border-b border-zinc-800/30">
401 <td className="py-0.5 pr-3 text-zinc-400">R2</td>
402 <td className="py-0.5">Bucket</td>
403 </tr>
404 <tr>
405 <td className="py-0.5 pr-3 text-zinc-400">DocSync</td>
406 <td className="py-0.5">DO</td>
407 </tr>
408 </tbody>
409 </table>
410 </div>
411 </div>
412 </div>
413 </div>
414 </section>
415
416 {/* Stack ribbon — what each binding actually does */}
417 <section className="border-t border-zinc-800/40 py-12">
418 <div className="mx-auto max-w-7xl px-4 sm:px-6">
419 <dl className="grid gap-x-8 gap-y-5 sm:grid-cols-2 lg:grid-cols-5">
420 {STACK.map(({ name, role }) => (
421 <div key={name} className="flex flex-col gap-1">
422 <dt className="font-mono text-[10px] uppercase tracking-[0.18em] text-zinc-500">{name}</dt>
423 <dd className="text-sm leading-snug text-zinc-400">{role}</dd>
424 </div>
425 ))}
426 </dl>
427 </div>
428 </section>
429 </main>
430
431 {/* Footer */}
432 <footer className="border-t border-zinc-800/40 py-6">
433 <div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 px-4 sm:flex-row sm:px-6">
434 <a
435 href="https://limic.dev"
436 target="_blank"
437 rel="noopener noreferrer"
438 className="flex items-center gap-1 text-xs text-zinc-500 underline decoration-zinc-700 underline-offset-2 transition-colors hover:text-accent-400"
439 >
440 Made with
441 <Heart className="inline h-3 w-3 text-accent-500" aria-hidden="true" />
442 on Cloudflare
443 </a>
444 <a
445 href="https://git.limic.dev/rachel/bland"
446 target="_blank"
447 rel="noopener noreferrer"
448 className="text-xs text-zinc-500 underline decoration-zinc-700 underline-offset-2 transition-colors hover:text-accent-400"
449 >
450 Source code
451 </a>
452 </div>
453 </footer>
454 </div>
455 );
456}