初始化项目启动器代码
This commit is contained in:
28
src/components/project-boot/project-boot-workspace.tsx
Normal file
28
src/components/project-boot/project-boot-workspace.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
286
src/components/project-boot/shadcn/constants.ts
Normal file
286
src/components/project-boot/shadcn/constants.ts
Normal 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}`
|
||||
}
|
||||
201
src/components/project-boot/shadcn/create-project-dialog.tsx
Normal file
201
src/components/project-boot/shadcn/create-project-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
128
src/components/project-boot/shadcn/shadcn-config-panel.tsx
Normal file
128
src/components/project-boot/shadcn/shadcn-config-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
src/components/project-boot/shadcn/shadcn-launcher.tsx
Normal file
50
src/components/project-boot/shadcn/shadcn-launcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
src/components/project-boot/shadcn/shadcn-preview.tsx
Normal file
51
src/components/project-boot/shadcn/shadcn-preview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user