初始化项目启动器代码

This commit is contained in:
xintaofei
2026-03-27 13:05:27 +08:00
parent 77204e2295
commit 7c89e150f9
25 changed files with 1434 additions and 15 deletions

View File

@@ -0,0 +1,28 @@
"use client"
import { useTranslations } from "next-intl"
import {
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from "@/components/ui/tabs"
import { ShadcnLauncher } from "./shadcn/shadcn-launcher"
export function ProjectBootWorkspace() {
const t = useTranslations("ProjectBoot")
return (
<Tabs defaultValue="shadcn" className="flex h-full flex-col">
<div className="shrink-0 border-b px-4 py-2">
<TabsList>
<TabsTrigger value="shadcn">{t("tabs.shadcn")}</TabsTrigger>
</TabsList>
</div>
<TabsContent value="shadcn" className="min-h-0 flex-1">
<ShadcnLauncher />
</TabsContent>
</Tabs>
)
}

View File

@@ -0,0 +1,286 @@
// ── Preset encoding/decoding (matches shadcn v2 format) ─────────────
const PRESET_STYLES = [
"nova",
"vega",
"maia",
"lyra",
"mira",
] as const
const PRESET_BASE_COLORS = [
"neutral",
"stone",
"zinc",
"gray",
"mauve",
"olive",
"mist",
"taupe",
] as const
const PRESET_THEMES = [
"neutral",
"stone",
"zinc",
"gray",
"amber",
"blue",
"cyan",
"emerald",
"fuchsia",
"green",
"indigo",
"lime",
"orange",
"pink",
"purple",
"red",
"rose",
"sky",
"teal",
"violet",
"yellow",
"mauve",
"olive",
"mist",
"taupe",
] as const
const PRESET_ICON_LIBRARIES = [
"lucide",
"hugeicons",
"tabler",
"phosphor",
"remixicon",
] as const
const PRESET_FONTS = [
"inter",
"noto-sans",
"nunito-sans",
"figtree",
"roboto",
"raleway",
"dm-sans",
"public-sans",
"outfit",
"jetbrains-mono",
"geist",
"geist-mono",
"lora",
"merriweather",
"playfair-display",
"noto-serif",
"roboto-slab",
"oxanium",
"manrope",
"space-grotesk",
"montserrat",
"ibm-plex-sans",
"source-sans-3",
"instrument-sans",
] as const
const PRESET_FONT_HEADINGS = ["inherit", ...PRESET_FONTS] as const
const PRESET_RADII = [
"default",
"none",
"small",
"medium",
"large",
] as const
const PRESET_MENU_ACCENTS = ["subtle", "bold"] as const
const PRESET_MENU_COLORS = [
"default",
"inverted",
"default-translucent",
"inverted-translucent",
] as const
/** V2 field layout for bit-packing (order must match shadcn exactly) */
const PRESET_FIELDS_V2 = [
{ key: "menuColor", values: PRESET_MENU_COLORS, bits: 3 },
{ key: "menuAccent", values: PRESET_MENU_ACCENTS, bits: 3 },
{ key: "radius", values: PRESET_RADII, bits: 4 },
{ key: "font", values: PRESET_FONTS, bits: 6 },
{ key: "iconLibrary", values: PRESET_ICON_LIBRARIES, bits: 6 },
{ key: "theme", values: PRESET_THEMES, bits: 6 },
{ key: "baseColor", values: PRESET_BASE_COLORS, bits: 6 },
{ key: "style", values: PRESET_STYLES, bits: 6 },
{ key: "chartColor", values: PRESET_THEMES, bits: 6 },
{ key: "fontHeading", values: PRESET_FONT_HEADINGS, bits: 5 },
] as const
const BASE62 =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
function toBase62(num: number): string {
if (num === 0) return "0"
let result = ""
let n = num
while (n > 0) {
result = BASE62[n % 62] + result
n = Math.floor(n / 62)
}
return result
}
/** Encode a preset config into a compact base62 code (v2 format). */
export function encodePreset(config: PresetCodeConfig): string {
const defaults: Record<string, string> = {
menuColor: "default",
menuAccent: "subtle",
radius: "default",
font: "inter",
iconLibrary: "lucide",
theme: "neutral",
baseColor: "neutral",
style: "nova",
chartColor: config.theme ?? "neutral",
fontHeading: "inherit",
}
const merged: Record<string, string> = { ...defaults }
for (const [k, v] of Object.entries(config)) {
if (v) merged[k] = v
}
let bits = 0
let offset = 0
for (const field of PRESET_FIELDS_V2) {
const idx = (field.values as readonly string[]).indexOf(
merged[field.key] ?? ""
)
bits += (idx === -1 ? 0 : idx) * 2 ** offset
offset += field.bits
}
return "b" + toBase62(bits)
}
// ── Config types ────────────────────────────────────────────────────
/** Fields that are encoded into the preset code (sent to CLI & preview). */
export interface PresetCodeConfig {
style: string
baseColor: string
theme: string
chartColor: string
iconLibrary: string
font: string
fontHeading: string
radius: string
menuAccent: string
menuColor: string
}
/** Full UI config (preset fields + non-preset fields like base/template). */
export interface ShadcnPresetConfig extends PresetCodeConfig {
base: string
template: string
}
export const DEFAULT_PRESET_CONFIG: ShadcnPresetConfig = {
base: "radix",
style: "nova",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "lucide",
font: "inter",
fontHeading: "inherit",
radius: "default",
menuAccent: "subtle",
menuColor: "default",
template: "start",
}
// ── UI option arrays ────────────────────────────────────────────────
export const BASE_OPTIONS = [
{ value: "radix", label: "Radix" },
{ value: "base", label: "Base" },
]
export const STYLE_OPTIONS = PRESET_STYLES.map((v) => ({
value: v,
label: v.charAt(0).toUpperCase() + v.slice(1),
}))
export const BASE_COLOR_OPTIONS = PRESET_BASE_COLORS.map((v) => ({
value: v,
label: v.charAt(0).toUpperCase() + v.slice(1),
}))
export const THEME_OPTIONS = PRESET_THEMES.map((v) => ({
value: v,
label: v.charAt(0).toUpperCase() + v.slice(1),
}))
export const ICON_LIBRARY_OPTIONS = PRESET_ICON_LIBRARIES.map((v) => ({
value: v,
label: v.charAt(0).toUpperCase() + v.slice(1),
}))
export const FONT_OPTIONS = PRESET_FONTS.map((v) => ({
value: v,
label: v
.split("-")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" "),
}))
export const FONT_HEADING_OPTIONS = PRESET_FONT_HEADINGS.map((v) => ({
value: v,
label:
v === "inherit"
? "Inherit"
: v
.split("-")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" "),
}))
export const MENU_ACCENT_OPTIONS = PRESET_MENU_ACCENTS.map((v) => ({
value: v,
label: v.charAt(0).toUpperCase() + v.slice(1),
}))
export const MENU_COLOR_OPTIONS = PRESET_MENU_COLORS.map((v) => ({
value: v,
label: v
.split("-")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" "),
}))
export const RADIUS_OPTIONS = PRESET_RADII.map((v) => ({
value: v,
label: v.charAt(0).toUpperCase() + v.slice(1),
}))
export const TEMPLATE_OPTIONS = [{ value: "start", label: "Start" }]
export const FRAMEWORK_OPTIONS = [
{ value: "next", label: "Next.js" },
{ value: "vite", label: "Vite" },
{ value: "start", label: "TanStack Start" },
{ value: "react-router", label: "React Router" },
{ value: "laravel", label: "Laravel" },
{ value: "astro", label: "Astro" },
]
export const PACKAGE_MANAGER_OPTIONS = [
{ value: "pnpm", label: "pnpm" },
{ value: "npm", label: "npm" },
{ value: "yarn", label: "yarn" },
{ value: "bun", label: "bun" },
]
// ── URL builders ────────────────────────────────────────────────────
/** Build the preview iframe URL using a preset code. */
export function buildPreviewUrl(base: string, presetCode: string): string {
return `https://ui.shadcn.com/preview/${base}/preview?preset=${presetCode}`
}

View File

@@ -0,0 +1,201 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
import { Loader2, FolderOpen } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { openFileDialog } from "@/lib/platform"
import { createShadcnProject, openFolderWindow } from "@/lib/api"
import { FRAMEWORK_OPTIONS, PACKAGE_MANAGER_OPTIONS } from "./constants"
interface CreateProjectDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
presetCode: string
}
export function CreateProjectDialog({
open,
onOpenChange,
presetCode,
}: CreateProjectDialogProps) {
const t = useTranslations("ProjectBoot")
const [projectName, setProjectName] = useState("my-app")
const [framework, setFramework] = useState("next")
const [packageManager, setPackageManager] = useState("pnpm")
const [saveDirectory, setSaveDirectory] = useState("")
const [creating, setCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleBrowse = async () => {
const result = await openFileDialog({ directory: true, multiple: false })
if (!result) return
const selected = Array.isArray(result) ? result[0] : result
setSaveDirectory(selected)
}
const handleCreate = async () => {
setError(null)
setCreating(true)
try {
const projectPath = await createShadcnProject({
projectName,
template: framework,
presetCode,
packageManager,
targetDir: saveDirectory,
})
toast.success(t("toasts.createSuccess"))
onOpenChange(false)
resetForm()
await openFolderWindow(projectPath)
} catch (err) {
const message =
err && typeof err === "object" && "message" in err
? (err as { message: string }).message
: String(err)
setError(message)
toast.error(t("toasts.createFailed"), { description: message })
} finally {
setCreating(false)
}
}
const resetForm = () => {
setProjectName("my-app")
setFramework("next")
setPackageManager("pnpm")
setSaveDirectory("")
setError(null)
}
const canCreate =
projectName.trim().length > 0 && saveDirectory.trim().length > 0
return (
<Dialog
open={open}
onOpenChange={(v) => {
onOpenChange(v)
if (!v) resetForm()
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("createDialog.title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<Label>{t("createDialog.projectName")}</Label>
<Input
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
placeholder={t("createDialog.projectNamePlaceholder")}
disabled={creating}
/>
</div>
<div className="space-y-1.5">
<Label>{t("createDialog.frameworkTemplate")}</Label>
<Select
value={framework}
onValueChange={setFramework}
disabled={creating}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FRAMEWORK_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>{t("createDialog.packageManager")}</Label>
<Select
value={packageManager}
onValueChange={setPackageManager}
disabled={creating}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PACKAGE_MANAGER_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>{t("createDialog.saveDirectory")}</Label>
<div className="flex gap-2">
<Input
value={saveDirectory}
onChange={(e) => setSaveDirectory(e.target.value)}
placeholder={t("createDialog.saveDirectoryPlaceholder")}
disabled={creating}
className="flex-1"
/>
<Button
variant="outline"
size="sm"
onClick={handleBrowse}
disabled={creating}
type="button"
>
<FolderOpen className="h-4 w-4" />
</Button>
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={creating}
>
{t("createDialog.cancel")}
</Button>
<Button onClick={handleCreate} disabled={!canCreate || creating}>
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{creating ? t("createDialog.creating") : t("createDialog.create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,128 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
BASE_OPTIONS,
STYLE_OPTIONS,
BASE_COLOR_OPTIONS,
THEME_OPTIONS,
ICON_LIBRARY_OPTIONS,
FONT_OPTIONS,
FONT_HEADING_OPTIONS,
MENU_ACCENT_OPTIONS,
MENU_COLOR_OPTIONS,
RADIUS_OPTIONS,
TEMPLATE_OPTIONS,
type ShadcnPresetConfig,
} from "./constants"
import { CreateProjectDialog } from "./create-project-dialog"
interface ShadcnConfigPanelProps {
config: ShadcnPresetConfig
onConfigChange: (key: keyof ShadcnPresetConfig, value: string) => void
presetCode: string
}
type ConfigI18nKey =
| "config.base"
| "config.style"
| "config.baseColor"
| "config.theme"
| "config.chartColor"
| "config.iconLibrary"
| "config.font"
| "config.fontHeading"
| "config.menuAccent"
| "config.menuColor"
| "config.radius"
| "config.template"
const CONFIG_FIELDS: {
key: keyof ShadcnPresetConfig
i18nKey: ConfigI18nKey
options: { value: string; label: string }[]
}[] = [
{ key: "base", i18nKey: "config.base", options: BASE_OPTIONS },
{ key: "style", i18nKey: "config.style", options: STYLE_OPTIONS },
{ key: "baseColor", i18nKey: "config.baseColor", options: BASE_COLOR_OPTIONS },
{ key: "theme", i18nKey: "config.theme", options: THEME_OPTIONS },
{ key: "chartColor", i18nKey: "config.chartColor", options: THEME_OPTIONS },
{
key: "iconLibrary",
i18nKey: "config.iconLibrary",
options: ICON_LIBRARY_OPTIONS,
},
{ key: "font", i18nKey: "config.font", options: FONT_OPTIONS },
{
key: "fontHeading",
i18nKey: "config.fontHeading",
options: FONT_HEADING_OPTIONS,
},
{ key: "menuAccent", i18nKey: "config.menuAccent", options: MENU_ACCENT_OPTIONS },
{ key: "menuColor", i18nKey: "config.menuColor", options: MENU_COLOR_OPTIONS },
{ key: "radius", i18nKey: "config.radius", options: RADIUS_OPTIONS },
{ key: "template", i18nKey: "config.template", options: TEMPLATE_OPTIONS },
]
export function ShadcnConfigPanel({
config,
onConfigChange,
presetCode,
}: ShadcnConfigPanelProps) {
const t = useTranslations("ProjectBoot")
const [createOpen, setCreateOpen] = useState(false)
return (
<div className="flex h-full flex-col">
<ScrollArea className="flex-1 px-4 py-3">
<div className="space-y-3">
{CONFIG_FIELDS.map((field) => (
<div key={field.key} className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(field.i18nKey)}
</Label>
<Select
value={config[field.key]}
onValueChange={(v) => onConfigChange(field.key, v)}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{field.options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
</ScrollArea>
<div className="shrink-0 border-t px-4 py-3">
<Button className="w-full" onClick={() => setCreateOpen(true)}>
{t("config.createProject")}
</Button>
</div>
<CreateProjectDialog
open={createOpen}
onOpenChange={setCreateOpen}
presetCode={presetCode}
/>
</div>
)
}

View File

@@ -0,0 +1,50 @@
"use client"
import { useMemo, useState } from "react"
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable"
import { ShadcnConfigPanel } from "./shadcn-config-panel"
import { ShadcnPreview } from "./shadcn-preview"
import {
DEFAULT_PRESET_CONFIG,
encodePreset,
buildPreviewUrl,
type ShadcnPresetConfig,
} from "./constants"
export function ShadcnLauncher() {
const [config, setConfig] = useState<ShadcnPresetConfig>(
DEFAULT_PRESET_CONFIG
)
const presetCode = useMemo(() => encodePreset(config), [config])
const previewUrl = useMemo(
() => buildPreviewUrl(config.base, presetCode),
[config.base, presetCode]
)
const updateConfig = (key: keyof ShadcnPresetConfig, value: string) => {
setConfig((prev) => ({ ...prev, [key]: value }))
}
return (
<ResizablePanelGroup direction="horizontal" className="h-full">
<ResizablePanel defaultSize={40} minSize={30} maxSize={50}>
<ShadcnConfigPanel
config={config}
onConfigChange={updateConfig}
presetCode={presetCode}
/>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={60} minSize={40}>
<ShadcnPreview previewUrl={previewUrl} />
</ResizablePanel>
</ResizablePanelGroup>
)
}

View File

@@ -0,0 +1,51 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { useTranslations } from "next-intl"
import { Loader2 } from "lucide-react"
interface ShadcnPreviewProps {
previewUrl: string
}
export function ShadcnPreview({ previewUrl }: ShadcnPreviewProps) {
const t = useTranslations("ProjectBoot")
const [debouncedUrl, setDebouncedUrl] = useState(previewUrl)
const [loading, setLoading] = useState(true)
const timerRef = useRef<ReturnType<typeof setTimeout>>(null)
useEffect(() => {
if (timerRef.current) {
clearTimeout(timerRef.current)
}
timerRef.current = setTimeout(() => {
setDebouncedUrl(previewUrl)
setLoading(true)
}, 500)
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
}
}
}, [previewUrl])
return (
<div className="relative h-full w-full">
{loading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">
{t("preview.loading")}
</span>
</div>
)}
<iframe
key={debouncedUrl}
src={debouncedUrl}
className="h-full w-full border-0"
onLoad={() => setLoading(false)}
sandbox="allow-scripts allow-same-origin allow-popups"
/>
</div>
)
}

View File

@@ -1,10 +1,10 @@
"use client"
import { useState } from "react"
import { FolderOpen, GitBranch } from "lucide-react"
import { FolderOpen, GitBranch, Rocket } from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { openFolderWindow } from "@/lib/api"
import { openFolderWindow, openProjectBootWindow } from "@/lib/api"
import { openFileDialog } from "@/lib/platform"
import { Button } from "@/components/ui/button"
import { CloneDialog } from "./clone-dialog"
@@ -51,6 +51,23 @@ export function FolderActions() {
{t("cloneRepository")}
</Button>
<Button
variant="ghost"
className="justify-start gap-2 h-9"
onClick={async () => {
try {
await openProjectBootWindow()
} catch (err) {
console.error("[FolderActions] failed to open project boot:", err)
toast.error(t("toasts.openProjectBootFailed"))
}
}}
type="button"
>
<Rocket className="h-4 w-4" />
{t("projectBoot")}
</Button>
<CloneDialog open={cloneOpen} onOpenChange={setCloneOpen} />
</div>
)