Files
vibn-agent-runner/vibn-frontend/components/project-creation/setup-shared.tsx

798 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import React, { ReactNode, CSSProperties, useRef, useState } from "react";
import { JM } from "./modal-theme";
import { STARTER_KITS } from "@/lib/design-kits/registry";
import type { StarterKitDefinition } from "@/lib/design-kits/types";
import { UI_FOUNDATION_LABELS } from "@/lib/design-kits/types";
import {
PRODUCT_CATEGORIES,
kitsOrderedForCategory,
type ProductCategoryId,
} from "@/lib/design-kits/product-categories";
import { DesignKitPreviewPane } from "./design-kit-preview-pane";
export interface SetupProps {
workspace: string;
onClose: () => void;
onBack: () => void;
}
export function FieldLabel({ children }: { children: ReactNode }) {
return (
<label style={{
display: "block", fontSize: 12, fontWeight: 600, color: JM.mid,
marginBottom: 6, fontFamily: JM.fontSans,
}}>
{children}
</label>
);
}
/** Audience picker — drives default infra (auth, payments, email, domain).
* - "team" → internal users (your team / employees). SSO-style auth,
* no payments by default, simple roles.
* - "customers" → external users (the public). Public sign-up, payments
* on by default, transactional email, custom domain.
* Either choice can be changed later from the Infrastructure tab. */
export type Audience = "team" | "customers";
export function ProductCategorySelector({
value,
onChange,
}: {
value: ProductCategoryId;
onChange: (v: ProductCategoryId) => void;
}) {
return (
<div style={{ marginBottom: 22 }}>
<FieldLabel>What are you building?</FieldLabel>
<p
style={{
fontSize: 11,
color: JM.muted,
marginTop: -2,
marginBottom: 10,
lineHeight: 1.45,
fontFamily: JM.fontSans,
}}
>
Choose a product shape we&apos;ll suggest matching design starters on the next step.
</p>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 8,
}}
>
{PRODUCT_CATEGORIES.map((c) => {
const sel = c.id === value;
return (
<button
key={c.id}
type="button"
onClick={() => onChange(c.id)}
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: 6,
padding: "10px 11px",
minHeight: 76,
borderRadius: 9,
border: `1px solid ${sel ? JM.indigo : JM.border}`,
background: sel ? JM.cream : JM.inputBg,
cursor: "pointer",
textAlign: "left",
fontFamily: JM.fontSans,
transition: "all 0.15s",
boxShadow: sel ? "0 0 0 1px rgba(99,102,241,0.2)" : undefined,
boxSizing: "border-box",
}}
>
<span
style={{
width: 7,
height: 7,
borderRadius: 999,
background: sel ? JM.indigo : "#d4d4d8",
flexShrink: 0,
}}
/>
<span style={{ display: "flex", flexDirection: "column", gap: 3, minWidth: 0 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: JM.ink, lineHeight: 1.25 }}>
{c.label}
</span>
<span
style={{
fontSize: 10,
color: JM.muted,
lineHeight: 1.35,
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical" as const,
overflow: "hidden",
}}
>
{c.hint}
</span>
</span>
</button>
);
})}
</div>
</div>
);
}
/** Split layout: starter kits (filtered by product category) + live preview pane. */
export function DesignKitSplitSelector({
productCategory,
value,
onChange,
}: {
productCategory: ProductCategoryId;
value: string;
onChange: (kitId: string) => void;
}) {
const order = kitsOrderedForCategory(productCategory);
const kits = order
.map((id) => STARTER_KITS.find((k) => k.id === id))
.filter(Boolean) as StarterKitDefinition[];
const selected = kits.find((k) => k.id === value) ?? kits[0];
return (
<div style={{ marginBottom: 12 }}>
<FieldLabel>Design system</FieldLabel>
<p
style={{
fontSize: 11,
color: JM.muted,
marginTop: -2,
marginBottom: 12,
lineHeight: 1.45,
fontFamily: JM.fontSans,
}}
>
Pick a starter look preview updates as you click. You can customize tokens anytime in the Design tab.
</p>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "stretch",
gap: 18,
flexWrap: "wrap",
}}
>
<div
style={{
width: 288,
flexShrink: 0,
maxHeight: 420,
overflowY: "auto",
paddingRight: 6,
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
{kits.map((k) => {
const sel = value === k.id;
const dot = k.defaults.accentHex ?? "#6366f1";
return (
<button
key={k.id}
type="button"
onClick={() => onChange(k.id)}
style={{
textAlign: "left",
padding: "11px 12px",
borderRadius: 9,
border: `1px solid ${sel ? JM.indigo : JM.border}`,
background: sel ? JM.cream : JM.inputBg,
cursor: "pointer",
fontFamily: JM.fontSans,
transition: "all 0.15s",
boxShadow: sel ? "0 0 0 1px rgba(99,102,241,0.2)" : undefined,
flexShrink: 0,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 5 }}>
<span
style={{
width: 11,
height: 11,
borderRadius: 999,
background: dot,
flexShrink: 0,
border: "1px solid rgba(0,0,0,0.08)",
}}
/>
<span style={{ fontSize: 12.5, fontWeight: 600, color: JM.ink }}>{k.name}</span>
</div>
<div style={{ fontSize: 10.5, color: JM.muted, lineHeight: 1.35 }}>{k.tagline}</div>
<div style={{ fontSize: 10, color: "#94a3b8", marginTop: 4, lineHeight: 1.3 }}>
{UI_FOUNDATION_LABELS[k.uiFoundation]}
</div>
</button>
);
})}
</div>
<div
style={{
flex: "1 1 280px",
minWidth: 280,
minHeight: 308,
borderRadius: 12,
padding: 12,
background: JM.cream,
border: `1px solid ${JM.border}`,
boxSizing: "border-box",
}}
>
{selected ? <DesignKitPreviewPane kit={selected} /> : null}
</div>
</div>
</div>
);
}
/** Starter design system picker — persisted as `fs_projects.data.designKit` on create. */
export function DesignKitSelector({
value,
onChange,
}: {
value: string;
onChange: (kitId: string) => void;
}) {
return (
<div style={{ marginBottom: 22 }}>
<FieldLabel>Design system</FieldLabel>
<p style={{
fontSize: 11,
color: JM.muted,
marginTop: -2,
marginBottom: 10,
lineHeight: 1.45,
fontFamily: JM.fontSans,
}}>
Pick a starter look for your UI. Vibn uses this when building and when you open the Design tab you can customize tokens anytime.
</p>
<div style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 8,
maxHeight: 280,
overflowY: "auto",
paddingRight: 4,
}}>
{STARTER_KITS.map((k) => {
const sel = value === k.id;
const dot = k.defaults.accentHex ?? "#6366f1";
return (
<button
key={k.id}
type="button"
onClick={() => onChange(k.id)}
style={{
textAlign: "left",
padding: "12px 12px",
borderRadius: 9,
border: `1px solid ${sel ? JM.indigo : JM.border}`,
background: sel ? JM.cream : JM.inputBg,
cursor: "pointer",
fontFamily: JM.fontSans,
transition: "all 0.15s",
boxShadow: sel ? "0 0 0 1px rgba(99,102,241,0.2)" : undefined,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
<span style={{
width: 12,
height: 12,
borderRadius: 999,
background: dot,
flexShrink: 0,
border: "1px solid rgba(0,0,0,0.08)",
}} />
<span style={{ fontSize: 12.5, fontWeight: 600, color: JM.ink }}>{k.name}</span>
</div>
<div style={{ fontSize: 10.5, color: JM.muted, lineHeight: 1.35 }}>{k.tagline}</div>
<div style={{ fontSize: 10, color: "#94a3b8", marginTop: 4, lineHeight: 1.3 }}>
{UI_FOUNDATION_LABELS[k.uiFoundation]}
</div>
</button>
);
})}
</div>
</div>
);
}
export function AudienceSelector({
value,
onChange,
}: {
value: Audience;
onChange: (v: Audience) => void;
}) {
const cardBase: CSSProperties = {
flex: "0 0 auto",
width: 148,
border: `1px solid ${JM.border}`,
borderRadius: 8,
padding: "8px 10px",
cursor: "pointer",
textAlign: "center" as const,
background: JM.inputBg,
transition: "all 0.15s",
fontFamily: JM.fontSans,
minHeight: 0,
};
const row = (key: Audience, emoji: string, title: string, sub: string) => {
const sel = value === key;
return (
<button
type="button"
key={key}
onClick={() => onChange(key)}
style={{
...cardBase,
borderColor: sel ? JM.indigo : JM.border,
background: sel ? JM.cream : JM.inputBg,
boxShadow: sel ? "0 0 0 1px rgba(99,102,241,0.2)" : undefined,
}}
>
<div style={{ fontSize: 15, lineHeight: 1, marginBottom: 4 }}>{emoji}</div>
<div style={{ fontSize: 11, fontWeight: 600, color: JM.ink, lineHeight: 1.25 }}>{title}</div>
<div style={{ fontSize: 9.5, color: JM.muted, marginTop: 2, lineHeight: 1.35 }}>{sub}</div>
</button>
);
};
return (
<div style={{ marginBottom: 22 }}>
<FieldLabel>Who will use this?</FieldLabel>
<div style={{ display: "flex", gap: 6, alignItems: "stretch", flexWrap: "wrap" }}>
{row("team", "🏢", "My team", "Internal tool")}
{row("customers", "🌍", "Customers", "Public product")}
</div>
<p style={{ fontSize: 11, color: JM.muted, marginTop: 7, lineHeight: 1.45 }}>
We&apos;ll set up the right defaults (sign-up, payments, email). You can change this later.
</p>
</div>
);
}
export type SeedDocumentPayload = {
fileName: string;
kind: "markdown" | "pdf";
text?: string;
base64?: string;
};
const MAX_MARKDOWN_CHARS = 200_000;
const MAX_PDF_BYTES = 4 * 1024 * 1024;
function readFileAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(typeof r.result === "string" ? r.result : "");
r.onerror = () => reject(r.error ?? new Error("read failed"));
r.readAsText(file);
});
}
function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(typeof r.result === "string" ? r.result : "");
r.onerror = () => reject(r.error ?? new Error("read failed"));
r.readAsDataURL(file);
});
}
/** Optional Markdown / PDF seed for the build wizard last step. */
export function SeedDocumentUpload({
value,
onChange,
}: {
value: SeedDocumentPayload | null;
onChange: (v: SeedDocumentPayload | null) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [pickErr, setPickErr] = useState<string | null>(null);
const handlePick = () => {
setPickErr(null);
inputRef.current?.click();
};
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const list = e.target.files;
e.target.value = "";
const file = list?.[0];
if (!file) return;
const lower = file.name.toLowerCase();
setPickErr(null);
try {
if (lower.endsWith(".md") || lower.endsWith(".markdown")) {
let text = await readFileAsText(file);
if (text.length > MAX_MARKDOWN_CHARS) text = text.slice(0, MAX_MARKDOWN_CHARS);
onChange({ fileName: file.name, kind: "markdown", text });
return;
}
if (lower.endsWith(".pdf")) {
if (file.size > MAX_PDF_BYTES) {
setPickErr("PDF must be 4MB or smaller.");
return;
}
const dataUrl = await readFileAsDataUrl(file);
const comma = dataUrl.indexOf(",");
const base64 = comma >= 0 ? dataUrl.slice(comma + 1) : "";
if (!base64) {
setPickErr("Could not read PDF.");
return;
}
onChange({ fileName: file.name, kind: "pdf", base64 });
return;
}
setPickErr("Please choose a .md, .markdown, or .pdf file.");
} catch {
setPickErr("Could not read that file.");
}
};
return (
<div style={{ marginBottom: 14 }}>
<FieldLabel>Supporting document</FieldLabel>
<input
ref={inputRef}
type="file"
accept=".md,.markdown,.pdf,application/pdf,text/markdown,text/plain"
style={{ display: "none" }}
onChange={handleChange}
/>
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
<button
type="button"
onClick={handlePick}
style={{
padding: "8px 12px",
borderRadius: 8,
border: `1px solid ${JM.border}`,
background: JM.inputBg,
fontSize: 12,
fontWeight: 600,
color: JM.mid,
cursor: "pointer",
fontFamily: JM.fontSans,
}}
>
{value ? "Replace document" : "Upload Markdown or PDF"}
</button>
{value ? (
<span style={{ fontSize: 11, color: JM.ink, fontFamily: JM.fontSans }}>
<span style={{ fontWeight: 600 }}>{value.fileName}</span>
<span style={{ color: JM.muted }}> ({value.kind === "pdf" ? "PDF" : "Markdown"})</span>
<button
type="button"
onClick={() => {
onChange(null);
setPickErr(null);
}}
style={{
marginLeft: 10,
border: "none",
background: "none",
color: JM.indigo,
cursor: "pointer",
fontSize: 11,
fontWeight: 600,
fontFamily: JM.fontSans,
}}
>
Remove
</button>
</span>
) : (
<span style={{ fontSize: 11, color: JM.muted, fontFamily: JM.fontSans }}>
Optional specs, notes, or briefs (.md / .pdf, PDF max 4MB)
</span>
)}
</div>
{pickErr ? (
<p
style={{
fontSize: 11,
color: "#b91c1c",
marginTop: 8,
marginBottom: 0,
fontFamily: JM.fontSans,
}}
>
{pickErr}
</p>
) : null}
</div>
);
}
export function SetupHeader({
icon,
label,
tagline,
accent,
onBack,
onClose,
}: {
icon: string;
label: string;
tagline: string;
accent: string;
onBack: () => void;
onClose: () => void;
}) {
return (
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 22 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<button
type="button"
onClick={onBack}
style={{
background: "none", border: "none", cursor: "pointer",
color: JM.muted, fontSize: "1rem", padding: "3px 5px",
borderRadius: 4, lineHeight: 1, flexShrink: 0,
fontFamily: JM.fontSans,
}}
onMouseEnter={e => (e.currentTarget.style.color = JM.ink)}
onMouseLeave={e => (e.currentTarget.style.color = JM.muted)}
>
</button>
<div>
<h2 style={{
fontFamily: JM.fontDisplay, fontSize: 18, fontWeight: 700,
color: JM.ink, margin: 0, marginBottom: 3, letterSpacing: "-0.02em",
display: "flex", alignItems: "center", gap: 8,
}}>
<span aria-hidden style={{ fontSize: 17, lineHeight: 1, opacity: 0.92 }}>{icon}</span>
<span>{label}</span>
</h2>
<p style={{
fontSize: 10.5, fontWeight: 600, color: accent, textTransform: "uppercase",
letterSpacing: "0.07em", margin: 0, fontFamily: JM.fontSans,
}}>
{tagline}
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
style={{
background: "none", border: "none", cursor: "pointer",
color: JM.muted, fontSize: 20, lineHeight: 1,
padding: 4, flexShrink: 0, fontFamily: JM.fontSans,
}}
onMouseEnter={e => (e.currentTarget.style.color = JM.mid)}
onMouseLeave={e => (e.currentTarget.style.color = JM.muted)}
>
×
</button>
</div>
);
}
export function TextInput({
value,
onChange,
placeholder,
onKeyDown,
autoFocus,
inputRef,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
autoFocus?: boolean;
inputRef?: React.RefObject<HTMLInputElement | null> | React.RefObject<HTMLInputElement>;
}) {
const base: CSSProperties = {
width: "100%", padding: "10px 13px", marginBottom: 16,
borderRadius: 8, border: `1px solid ${JM.border}`,
background: JM.inputBg, fontSize: 14,
fontFamily: JM.fontSans, color: JM.ink,
outline: "none", boxSizing: "border-box",
};
return (
<input
ref={inputRef}
type="text"
value={value}
onChange={e => onChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
autoFocus={autoFocus}
style={base}
onFocus={e => (e.currentTarget.style.borderColor = JM.indigo)}
onBlur={e => (e.currentTarget.style.borderColor = JM.border)}
/>
);
}
export function TextArea({
value,
onChange,
placeholder,
rows = 5,
autoFocus,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
rows?: number;
autoFocus?: boolean;
}) {
return (
<textarea
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
autoFocus={autoFocus}
style={{
width: "100%", padding: "11px 13px", marginBottom: 16,
borderRadius: 8, border: `1px solid ${JM.border}`,
background: JM.inputBg, fontSize: 14,
fontFamily: JM.fontSans, color: JM.ink,
outline: "none", boxSizing: "border-box",
resize: "vertical", lineHeight: 1.5,
minHeight: 96,
}}
onFocus={e => (e.currentTarget.style.borderColor = JM.indigo)}
onBlur={e => (e.currentTarget.style.borderColor = JM.border)}
/>
);
}
/** Page indicator dots for a multi-step setup screen. */
export function StepDots({ step, total }: { step: number; total: number }) {
return (
<div style={{ display: "flex", gap: 5, alignItems: "center" }}>
{Array.from({ length: total }).map((_, i) => (
<span
key={i}
style={{
width: i === step ? 18 : 6, height: 6, borderRadius: 3,
background: i === step ? JM.indigo : JM.border,
transition: "all 0.2s",
}}
/>
))}
</div>
);
}
/** Inline tab selector — used inside OSS setup to pick paste-link vs describe-it. */
export function SegmentedTabs<T extends string>({
value, onChange, options,
}: {
value: T;
onChange: (v: T) => void;
options: { id: T; label: string }[];
}) {
return (
<div style={{
display: "flex", padding: 3, marginBottom: 14,
background: JM.inputBg, border: `1px solid ${JM.border}`,
borderRadius: 9, gap: 3,
}}>
{options.map(opt => {
const sel = opt.id === value;
return (
<button
key={opt.id}
type="button"
onClick={() => onChange(opt.id)}
style={{
flex: 1, padding: "7px 10px", borderRadius: 6,
border: "none", cursor: "pointer",
background: sel ? "#fff" : "transparent",
color: sel ? JM.ink : JM.mid,
fontSize: 12.5, fontWeight: sel ? 600 : 500,
fontFamily: JM.fontSans,
boxShadow: sel ? "0 1px 3px rgba(0,0,0,0.06)" : "none",
transition: "all 0.15s",
}}
>
{opt.label}
</button>
);
})}
</div>
);
}
export function SecondaryButton({
onClick, children,
}: { onClick: () => void; children: ReactNode }) {
return (
<button
type="button"
onClick={onClick}
style={{
padding: "11px 16px", borderRadius: 8,
border: `1px solid ${JM.border}`, background: "#fff",
color: JM.mid, fontSize: 13.5, fontWeight: 600,
fontFamily: JM.fontSans, cursor: "pointer",
transition: "border-color 0.15s, color 0.15s",
}}
onMouseEnter={e => { e.currentTarget.style.borderColor = JM.mid; e.currentTarget.style.color = JM.ink; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = JM.border; e.currentTarget.style.color = JM.mid; }}
>
{children}
</button>
);
}
export function PrimaryButton({
onClick,
disabled,
loading,
children,
}: {
onClick: () => void;
disabled?: boolean;
loading?: boolean;
children: ReactNode;
}) {
const active = !disabled && !loading;
return (
<button
type="button"
onClick={onClick}
disabled={!active}
style={{
width: "100%", padding: "12px",
borderRadius: 8, border: "none",
background: active ? JM.primaryGradient : "#E5E7EB",
color: active ? "#fff" : JM.muted,
fontSize: 14, fontWeight: 600,
fontFamily: JM.fontSans,
cursor: active ? "pointer" : "not-allowed",
display: "flex", alignItems: "center", justifyContent: "center", gap: 6,
boxShadow: active ? JM.primaryShadow : "none",
transition: "box-shadow 0.2s, transform 0.15s, opacity 0.15s",
}}
onMouseEnter={e => {
if (active) {
e.currentTarget.style.boxShadow = JM.primaryShadowHover;
e.currentTarget.style.transform = "translateY(-1px)";
}
}}
onMouseLeave={e => {
e.currentTarget.style.boxShadow = active ? JM.primaryShadow : "none";
e.currentTarget.style.transform = "none";
}}
>
{loading ? (
<>
<span style={{
width: 14, height: 14, borderRadius: "50%",
border: "2px solid rgba(255,255,255,0.35)", borderTopColor: "#fff",
animation: "vibn-spin 0.7s linear infinite", display: "inline-block",
}} />
Creating
</>
) : (
children
)}
</button>
);
}