Add docs, blog, community pages and polish landing page layout
- Add docs pages (getting-started, changelog, keyboard-shortcuts) - Add blog, community, and legal pages (privacy, terms, EULA) - Add site header, footer, download button, and nav components - Add sitemap and robots.txt generation - Narrow main page container (max-w-2xl), fix footer positioning - Switch README feature list to colon style
This commit is contained in:
parent
5febb66873
commit
f970cdcf33
37 changed files with 3304 additions and 296 deletions
20
web/app/components/callout.tsx
Normal file
20
web/app/components/callout.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export function Callout({
|
||||
type = "info",
|
||||
children,
|
||||
}: {
|
||||
type?: "info" | "warn";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const styles =
|
||||
type === "warn"
|
||||
? "border-l-amber-500 bg-amber-500/5"
|
||||
: "border-l-blue-500 bg-blue-500/5";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles} border-l-2 px-4 py-3 mb-4 rounded-r-lg text-[14px] text-muted leading-relaxed`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
web/app/components/code-block.tsx
Normal file
59
web/app/components/code-block.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { codeToHtml } from "shiki";
|
||||
|
||||
export async function CodeBlock({
|
||||
children,
|
||||
title,
|
||||
lang,
|
||||
variant = "code",
|
||||
}: {
|
||||
children: string;
|
||||
title?: string;
|
||||
lang?: string;
|
||||
variant?: "code" | "ascii";
|
||||
}) {
|
||||
const lineHeightClass =
|
||||
variant === "ascii" ? "leading-[1.15]" : "leading-[1.45]";
|
||||
|
||||
if (lang && variant !== "ascii") {
|
||||
const html = await codeToHtml(children, {
|
||||
lang,
|
||||
themes: { light: "github-light", dark: "github-dark" },
|
||||
defaultColor: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
{title && (
|
||||
<div className="text-[11px] font-mono text-muted px-4 py-1.5 bg-code-bg border border-border border-b-0 rounded-t-lg">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`[&_pre]:bg-code-bg [&_pre]:border [&_pre]:border-border [&_pre]:px-4 [&_pre]:py-3 [&_pre]:overflow-x-auto [&_pre]:text-[13px] [&_pre]:${lineHeightClass} [&_pre]:font-mono ${
|
||||
title
|
||||
? "[&_pre]:rounded-b-lg [&_pre]:border-t-0"
|
||||
: "[&_pre]:rounded-lg"
|
||||
} [&_code]:bg-transparent [&_code]:p-0`}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
{title && (
|
||||
<div className="text-[11px] font-mono text-muted px-4 py-1.5 bg-code-bg border border-border border-b-0 rounded-t-lg">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<pre
|
||||
className={`bg-code-bg border border-border px-4 py-3 overflow-x-auto text-[13px] ${lineHeightClass} font-mono ${
|
||||
title ? "rounded-b-lg" : "rounded-lg"
|
||||
}`}
|
||||
>
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
web/app/components/docs-nav-items.ts
Normal file
9
web/app/components/docs-nav-items.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export const navItems = [
|
||||
{ title: "Getting Started", href: "/docs/getting-started" },
|
||||
{ title: "Concepts", href: "/docs/concepts" },
|
||||
{ title: "Configuration", href: "/docs/configuration" },
|
||||
{ title: "Keyboard Shortcuts", href: "/docs/keyboard-shortcuts" },
|
||||
{ title: "API Reference", href: "/docs/api" },
|
||||
{ title: "Notifications", href: "/docs/notifications" },
|
||||
{ title: "Changelog", href: "/docs/changelog" },
|
||||
];
|
||||
41
web/app/components/docs-pager.tsx
Normal file
41
web/app/components/docs-pager.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { navItems } from "./docs-nav-items";
|
||||
|
||||
export function DocsPager() {
|
||||
const pathname = usePathname();
|
||||
const index = navItems.findIndex((item) => item.href === pathname);
|
||||
const prev = index > 0 ? navItems[index - 1] : null;
|
||||
const next = index < navItems.length - 1 ? navItems[index + 1] : null;
|
||||
|
||||
if (!prev && !next) return null;
|
||||
|
||||
return (
|
||||
<nav className="flex items-center justify-between mt-12 pt-6 border-t border-border text-[14px]">
|
||||
{prev ? (
|
||||
<Link
|
||||
href={prev.href}
|
||||
className="flex items-center gap-1.5 text-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
<span aria-hidden>←</span>
|
||||
{prev.title}
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{next ? (
|
||||
<Link
|
||||
href={next.href}
|
||||
className="flex items-center gap-1.5 text-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
{next.title}
|
||||
<span aria-hidden>→</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
31
web/app/components/docs-sidebar.tsx
Normal file
31
web/app/components/docs-sidebar.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { navItems } from "./docs-nav-items";
|
||||
|
||||
export function DocsSidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="space-y-0.5">
|
||||
{navItems.map((item) => {
|
||||
const active = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
className={`block px-3 py-1.5 text-[14px] rounded-md transition-colors ${
|
||||
active
|
||||
? "text-foreground font-medium bg-code-bg"
|
||||
: "text-muted hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
22
web/app/components/download-button.tsx
Normal file
22
web/app/components/download-button.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
export function DownloadButton({ size = "default" }: { size?: "default" | "sm" }) {
|
||||
const isSmall = size === "sm";
|
||||
return (
|
||||
<a
|
||||
href="https://github.com/manaflow-ai/cmux/releases/latest/download/cmux-macos.dmg"
|
||||
className={`inline-flex items-center rounded-full font-medium bg-foreground hover:opacity-85 transition-opacity ${
|
||||
isSmall ? "gap-2 px-4 py-1.5 text-xs" : "gap-2.5 px-5 py-2.5 text-[15px]"
|
||||
}`}
|
||||
style={{ color: "var(--background)", textDecoration: "none" }}
|
||||
>
|
||||
<svg
|
||||
width={isSmall ? 12 : 16}
|
||||
height={isSmall ? 14 : 19}
|
||||
viewBox="0 0 814 1000"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57.8-155.5-127.4c-58.3-81.6-105.6-208.4-105.6-328.6 0-193 125.6-295.5 249.2-295.5 65.7 0 120.5 43.1 161.7 43.1 39.2 0 100.4-45.8 175.1-45.8 28.3 0 130.3 2.6 197.2 99.2zM554.1 159.4c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.9 32.4-57.2 83.6-57.2 135.4 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 137.6-71.2z" />
|
||||
</svg>
|
||||
Download for Mac
|
||||
</a>
|
||||
);
|
||||
}
|
||||
56
web/app/components/nav-links.tsx
Normal file
56
web/app/components/nav-links.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import Link from "next/link";
|
||||
import { DownloadButton } from "./download-button";
|
||||
|
||||
export function NavLinks() {
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
href="/docs/getting-started"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
<Link
|
||||
href="/blog"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Blog
|
||||
</Link>
|
||||
<Link
|
||||
href="/docs/changelog"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Changelog
|
||||
</Link>
|
||||
<Link
|
||||
href="/community"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Community
|
||||
</Link>
|
||||
<Link
|
||||
href="https://github.com/manaflow-ai/cmux"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
GitHub
|
||||
</Link>
|
||||
<DownloadButton size="sm" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="py-8 flex justify-center">
|
||||
<div className="flex items-center gap-4 text-sm text-muted">
|
||||
<a href="https://github.com/manaflow-ai/cmux" target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors">GitHub</a>
|
||||
<a href="https://twitter.com/manaflowai" target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors">Twitter</a>
|
||||
<a href="https://discord.gg/SDbQmzQhRK" target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors">Discord</a>
|
||||
<Link href="/privacy-policy" className="hover:text-foreground transition-colors">Privacy</Link>
|
||||
<Link href="/terms-of-service" className="hover:text-foreground transition-colors">Terms</Link>
|
||||
<Link href="/eula" className="hover:text-foreground transition-colors">EULA</Link>
|
||||
<a href="mailto:founders@manaflow.com" className="hover:text-foreground transition-colors">Contact</a>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
39
web/app/components/site-header.tsx
Normal file
39
web/app/components/site-header.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { NavLinks } from "./nav-links";
|
||||
|
||||
export function SiteHeader({ section, hideLogo }: { section?: string; hideLogo?: boolean }) {
|
||||
return (
|
||||
<header className="sticky top-0 z-30 w-full bg-background/80 backdrop-blur-sm">
|
||||
<div className="w-full max-w-5xl mx-auto flex items-center justify-between px-6 h-12">
|
||||
<div className="flex items-center gap-3">
|
||||
{!hideLogo && (
|
||||
<>
|
||||
<Link href="/" className="flex items-center gap-2.5">
|
||||
<Image
|
||||
src="/icon.png"
|
||||
alt="cmux"
|
||||
width={24}
|
||||
height={24}
|
||||
className="rounded-md"
|
||||
/>
|
||||
<span className="text-sm font-semibold tracking-tight">
|
||||
cmux
|
||||
</span>
|
||||
</Link>
|
||||
{section && (
|
||||
<>
|
||||
<span className="text-border text-[13px]">/</span>
|
||||
<span className="text-[13px] text-muted">{section}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<nav className="flex items-center gap-4 text-sm text-muted">
|
||||
<NavLinks />
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
221
web/app/components/spacing-control.tsx
Normal file
221
web/app/components/spacing-control.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
type DevValues = {
|
||||
headerTx: number;
|
||||
cursorTop: number;
|
||||
cursorBlink: boolean;
|
||||
subtitleLh: number;
|
||||
downloadAbove: number;
|
||||
downloadBelow: number;
|
||||
featuresLh: number;
|
||||
featuresMb: number;
|
||||
docsPt: number;
|
||||
};
|
||||
|
||||
const defaults: DevValues = {
|
||||
headerTx: -4,
|
||||
cursorTop: 2.5,
|
||||
cursorBlink: true,
|
||||
subtitleLh: 1.5,
|
||||
downloadAbove: 21,
|
||||
downloadBelow: 33,
|
||||
featuresLh: 1.275,
|
||||
featuresMb: 23,
|
||||
docsPt: 8,
|
||||
};
|
||||
|
||||
// Tiny external store (avoids setState-during-render)
|
||||
let snapshot = { ...defaults };
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function getSnapshot() { return snapshot; }
|
||||
function getServerSnapshot() { return defaults; }
|
||||
|
||||
function setStore(patch: Partial<DevValues>) {
|
||||
snapshot = { ...snapshot, ...patch };
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
function subscribe(cb: () => void) {
|
||||
listeners.add(cb);
|
||||
return () => { listeners.delete(cb); };
|
||||
}
|
||||
|
||||
export function useDevValues() {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
|
||||
function el(name: string) {
|
||||
return document.querySelector(`[data-dev="${name}"]`) as HTMLElement | null;
|
||||
}
|
||||
|
||||
function applyToDOM(v: DevValues) {
|
||||
const header = el("header");
|
||||
if (header) header.style.transform = `translateX(${v.headerTx}px)`;
|
||||
|
||||
const subtitle = el("subtitle");
|
||||
if (subtitle) subtitle.style.lineHeight = `${v.subtitleLh}`;
|
||||
|
||||
const download = el("download");
|
||||
if (download) {
|
||||
download.style.marginTop = `${v.downloadAbove}px`;
|
||||
download.style.marginBottom = `${v.downloadBelow}px`;
|
||||
}
|
||||
|
||||
const featuresUl = el("features-ul");
|
||||
if (featuresUl) featuresUl.style.lineHeight = `${v.featuresLh}`;
|
||||
|
||||
const featuresSpacer = el("features-spacer");
|
||||
if (featuresSpacer) featuresSpacer.style.height = `${v.featuresMb}px`;
|
||||
|
||||
const docsContent = el("docs-content");
|
||||
if (docsContent) docsContent.style.paddingTop = `${v.docsPt}px`;
|
||||
}
|
||||
|
||||
export function DevPanel() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [pos, setPos] = useState({ x: 0, y: 0 });
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const vals = useDevValues();
|
||||
const dragOffset = useRef({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
setPos({ x: window.innerWidth - 340, y: window.innerHeight - 320 });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.metaKey && e.key === ".") {
|
||||
e.preventDefault();
|
||||
setVisible((v) => !v);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
|
||||
const update = useCallback((patch: Partial<DevValues>) => {
|
||||
setStore(patch);
|
||||
applyToDOM({ ...snapshot, ...patch });
|
||||
}, []);
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
if ((e.target as HTMLElement).closest("input, button, label")) return;
|
||||
setDragging(true);
|
||||
dragOffset.current = { x: e.clientX - pos.x, y: e.clientY - pos.y };
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}, [pos]);
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragging) return;
|
||||
setPos({ x: e.clientX - dragOffset.current.x, y: e.clientY - dragOffset.current.y });
|
||||
}, [dragging]);
|
||||
|
||||
const onPointerUp = useCallback(() => setDragging(false), []);
|
||||
|
||||
if (process.env.NODE_ENV !== "development" || !visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
style={{ left: pos.x, top: pos.y, cursor: dragging ? "grabbing" : "grab" }}
|
||||
className="fixed z-[9999] bg-[#222] text-white text-xs rounded-xl p-4 space-y-3 font-mono shadow-lg select-none"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-white/50">Dev Controls</span>
|
||||
<span className="text-white/20">⌘.</span>
|
||||
</div>
|
||||
|
||||
<Section label="Header">
|
||||
<Row label="tx" value={vals.headerTx} onChange={(v) => update({ headerTx: v })} min={-50} max={50} step={1} unit="px" />
|
||||
</Section>
|
||||
|
||||
<Section label="Cursor">
|
||||
<div className="flex items-center gap-3">
|
||||
<Row label="top" value={vals.cursorTop} onChange={(v) => update({ cursorTop: v })} min={-5} max={5} step={0.5} unit="px" />
|
||||
<label className="flex items-center gap-2 text-white/70 cursor-pointer">
|
||||
<input type="checkbox" checked={vals.cursorBlink} onChange={(e) => update({ cursorBlink: e.target.checked })} />
|
||||
blink
|
||||
</label>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section label="Subtitle">
|
||||
<Row label="line-h" value={vals.subtitleLh} onChange={(v) => update({ subtitleLh: v })} min={1} max={2.5} step={0.025} unit="" w={16} />
|
||||
</Section>
|
||||
|
||||
<Section label="Download buttons">
|
||||
<Row label="above" value={vals.downloadAbove} onChange={(v) => update({ downloadAbove: v })} />
|
||||
<Row label="below" value={vals.downloadBelow} onChange={(v) => update({ downloadBelow: v })} />
|
||||
</Section>
|
||||
|
||||
<Section label="Features">
|
||||
<Row label="line-h" value={vals.featuresLh} onChange={(v) => update({ featuresLh: v })} min={1} max={2.5} step={0.025} unit="" w={16} />
|
||||
<Row label="mb" value={vals.featuresMb} onChange={(v) => update({ featuresMb: v })} />
|
||||
</Section>
|
||||
|
||||
<Section label="Docs">
|
||||
<Row label="pt" value={vals.docsPt} onChange={(v) => update({ docsPt: v })} />
|
||||
</Section>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const text = [
|
||||
`header-tx: ${vals.headerTx}px`,
|
||||
`cursor-top: ${vals.cursorTop}px`,
|
||||
`cursor-blink: ${vals.cursorBlink}`,
|
||||
`subtitle-lh: ${vals.subtitleLh}`,
|
||||
`download-above: ${vals.downloadAbove}px`,
|
||||
`download-below: ${vals.downloadBelow}px`,
|
||||
`features-lh: ${vals.featuresLh}`,
|
||||
`features-mb: ${vals.featuresMb}px`,
|
||||
`docs-pt: ${vals.docsPt}px`,
|
||||
].join(", ");
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}}
|
||||
className="w-full py-1.5 rounded-lg bg-white/10 hover:bg-white/20 text-white/70 cursor-pointer transition-colors"
|
||||
>
|
||||
{copied ? "Copied!" : "Copy values"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-white/40 text-[10px] uppercase tracking-wider">{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, onChange, min = 0, max = 128, step = 1, unit = "px", w = 10 }: {
|
||||
label: string; value: number; onChange: (v: number) => void;
|
||||
min?: number; max?: number; step?: number; unit?: string; w?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-12 text-white/70">{label}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value))}
|
||||
className="w-28 accent-blue-500 cursor-pointer"
|
||||
/>
|
||||
<span className="text-right tabular-nums" style={{ width: `${w * 4}px` }}>
|
||||
{Number.isInteger(step) ? value : value.toFixed(step < 0.1 ? 2 : 1)}{unit}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue