open-prompt / src /components /editor /prompt-editor.tsx
GitHub Action
Automated sync to Hugging Face
bcce530
"use client"
import { useState, useCallback, useMemo } from "react"
import { useUser } from "@stackframe/stack"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input, Textarea } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SelectGroup, SelectLabel } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { extractVariables, generateSlug } from "@/lib/utils"
import { VariableSchema, PromptSchema, AI_MODELS, ModelId, DEFAULT_MODEL } from "@/types/prompt"
import { useOllama } from "@/contexts/ollama-context"
import { FrameworkSelector } from "@/components/create/framework-selector"
import { FrameworkScaffold } from "@/components/create/framework-scaffold"
import { PromptCoach } from "@/components/editor/prompt-coach"
import { getFramework, generateFromFramework, Framework } from "@/lib/frameworks"
import {
Sparkles,
Play,
Save,
Eye,
Code,
Plus,
Trash2,
GripVertical,
ChevronDown,
AlertCircle,
Target
} from "lucide-react"
import { cn } from "@/lib/utils"
type VariableType = "text" | "textarea" | "dropdown" | "number" | "boolean"
const VARIABLE_TYPES: { value: VariableType; label: string }[] = [
{ value: "text", label: "Text Input" },
{ value: "textarea", label: "Text Area" },
{ value: "dropdown", label: "Dropdown" },
{ value: "number", label: "Number" },
{ value: "boolean", label: "Checkbox" },
]
const CATEGORIES = [
"Content",
"Development",
"Marketing",
"Business",
"Education",
"Creative",
"Research",
"Other",
]
interface EditorState {
title: string
description: string
template: string
category: string
tags: string
modelDefault: string
visibility: "public" | "unlisted" | "private"
}
interface PromptEditorProps {
initialData?: {
title: string
description: string | null
template: string
category: string | null
tags: string[]
modelDefault: string
visibility: string
schema: PromptSchema | null
}
promptSlug?: string // If provided, we're editing an existing prompt
}
export function PromptEditor({ initialData, promptSlug }: PromptEditorProps = {}) {
const user = useUser()
const { settings: ollamaSettings } = useOllama()
const [state, setState] = useState<EditorState>({
title: initialData?.title || "",
description: initialData?.description || "",
template: initialData?.template || "",
category: initialData?.category || "",
tags: initialData?.tags?.join(", ") || "",
modelDefault: initialData?.modelDefault || DEFAULT_MODEL,
visibility: (initialData?.visibility as EditorState["visibility"]) || "public",
})
// Initialize variables from initialData if editing
const initialVariables = initialData?.schema?.variables || []
const [variables, setVariables] = useState<VariableSchema[]>(initialVariables)
const [isPreview, setIsPreview] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
// Framework Engine state
const [selectedFramework, setSelectedFramework] = useState<string | null>(null)
const [frameworkSections, setFrameworkSections] = useState<Record<string, string>>({})
const [showFrameworkSelector, setShowFrameworkSelector] = useState(true)
// Get the selected framework object
const currentFramework = useMemo(() => {
return selectedFramework ? getFramework(selectedFramework) : null
}, [selectedFramework])
// Handle framework selection
const handleFrameworkSelect = (frameworkId: string | null) => {
setSelectedFramework(frameworkId)
setFrameworkSections({})
if (!frameworkId) {
// Clear template if deselecting framework
setShowFrameworkSelector(false)
} else {
setShowFrameworkSelector(false)
}
}
// Generate template from framework sections
const handleGenerateFromFramework = useCallback(() => {
if (currentFramework) {
const generatedTemplate = generateFromFramework(currentFramework, frameworkSections)
setState(s => ({ ...s, template: generatedTemplate }))
}
}, [currentFramework, frameworkSections])
// Auto-detect variables from template
const detectedVariables = useMemo(() => {
return extractVariables(state.template)
}, [state.template])
// Sync detected variables with current variable configs
const handleDetectVariables = useCallback(() => {
const newVariables = detectedVariables.map((name) => {
// Keep existing config if variable already exists
const existing = variables.find((v) => v.name === name)
if (existing) return existing
return {
name,
type: "text" as const,
label: name.charAt(0).toUpperCase() + name.slice(1).replace(/_/g, " "),
placeholder: `Enter ${name.replace(/_/g, " ")}...`,
required: true,
}
})
setVariables(newVariables)
}, [detectedVariables, variables])
// Update a variable config
const updateVariable = (index: number, updates: Partial<VariableSchema>) => {
setVariables((prev) =>
prev.map((v, i) => (i === index ? { ...v, ...updates } : v))
)
}
// Handle form submission
const handleSave = async (publish: boolean = false) => {
setError(null)
if (!state.title.trim()) {
setError("Title is required")
return
}
if (!state.template.trim()) {
setError("Prompt template is required")
return
}
setIsSaving(true)
try {
const schema: PromptSchema = {
variables,
output: {
format: "markdown",
streaming: true,
},
}
const isEditing = !!promptSlug
const url = isEditing ? `/api/prompts/${promptSlug}` : "/api/prompts"
const method = isEditing ? "PUT" : "POST"
const response = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: state.title,
description: state.description,
template: state.template,
schema,
category: state.category || null,
tags: state.tags.split(",").map((t) => t.trim()).filter(Boolean),
modelDefault: state.modelDefault,
visibility: publish ? state.visibility : "private",
...(isEditing ? {} : {}), // creatorId is now handled server-side via auth
}),
})
if (!response.ok) {
throw new Error(isEditing ? "Failed to update prompt" : "Failed to save prompt")
}
const data = await response.json()
// Redirect to the prompt page
window.location.href = `/p/${data.slug || promptSlug}`
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save prompt")
} finally {
setIsSaving(false)
}
}
return (
<div className="grid lg:grid-cols-2 gap-6">
{/* Left Pane - Template Editor */}
<div className="space-y-4">
{/* Framework Selector */}
{showFrameworkSelector && (
<Card>
<CardContent className="pt-6">
<FrameworkSelector
selectedFramework={selectedFramework ?? undefined}
onSelect={handleFrameworkSelect}
/>
</CardContent>
</Card>
)}
{/* Framework Scaffold - when a framework is selected */}
{currentFramework && !showFrameworkSelector && (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Target className="h-5 w-5 text-primary" />
<CardTitle className="text-base">Using {currentFramework.name} Framework</CardTitle>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowFrameworkSelector(true)}
>
Change
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedFramework(null)
setFrameworkSections({})
}}
>
Clear
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<FrameworkScaffold
framework={currentFramework}
sections={frameworkSections}
onSectionsChange={setFrameworkSections}
/>
<Button
className="w-full gap-2"
onClick={handleGenerateFromFramework}
disabled={Object.keys(frameworkSections).length === 0}
>
<Sparkles className="h-4 w-4" />
Generate Template from Framework
</Button>
</CardContent>
</Card>
)}
{/* Button to show framework selector if hidden and no framework selected */}
{!showFrameworkSelector && !currentFramework && (
<Button
variant="outline"
size="sm"
onClick={() => setShowFrameworkSelector(true)}
className="gap-2"
>
<Target className="h-4 w-4" />
Use a Framework
</Button>
)}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Code className="h-5 w-5" />
Prompt Template
</CardTitle>
{selectedFramework && (
<Badge variant="secondary" className="gap-1">
<Target className="h-3 w-3" />
{selectedFramework}
</Badge>
)}
</div>
<CardDescription>
Use {"{{variable_name}}"} syntax to create input fields
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Input
placeholder="Prompt Title"
value={state.title}
onChange={(e) => setState((s) => ({ ...s, title: e.target.value }))}
className="text-lg font-medium"
/>
<Textarea
placeholder="Brief description of what this prompt does..."
value={state.description}
onChange={(e) => setState((s) => ({ ...s, description: e.target.value }))}
className="min-h-20"
/>
<Textarea
placeholder={`Write your prompt here...
Example:
Write a blog post about {{topic}} in a {{tone}} tone.
The post should be {{length}} words and include:
- An engaging introduction
- 3 main points
- A compelling conclusion`}
value={state.template}
onChange={(e) => setState((s) => ({ ...s, template: e.target.value }))}
className="min-h-75 font-mono text-sm"
/>
{/* AI Prompt Coach */}
<PromptCoach
prompt={state.template}
onApply={(improved) => setState((s) => ({ ...s, template: improved }))}
/>
{detectedVariables.length > 0 && (
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
<span className="text-sm">
Detected {detectedVariables.length} variable{detectedVariables.length !== 1 ? "s" : ""}:{" "}
{detectedVariables.map((v, i) => (
<span key={v}>
<code className="prompt-variable">{`{{${v}}}`}</code>
{i < detectedVariables.length - 1 ? ", " : ""}
</span>
))}
</span>
<Button size="sm" variant="secondary" onClick={handleDetectVariables}>
<Sparkles className="h-3.5 w-3.5 mr-1.5" />
Configure
</Button>
</div>
)}
</CardContent>
</Card>
{/* Settings Card */}
<Card>
<CardHeader>
<CardTitle className="text-base">Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium mb-1.5 block">Category</label>
<Select
value={state.category}
onValueChange={(val) => setState((s) => ({ ...s, category: val }))}
>
<SelectTrigger>
<SelectValue placeholder="Select category..." />
</SelectTrigger>
<SelectContent>
{CATEGORIES.map((cat) => (
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-sm font-medium mb-1.5 block">Default Model</label>
<Select
value={state.modelDefault}
onValueChange={(val) => setState((s) => ({ ...s, modelDefault: val }))}
>
<SelectTrigger>
<SelectValue placeholder="Select model..." />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel className="flex items-center gap-1">☁️ Cloud Models</SelectLabel>
{Object.entries(AI_MODELS).map(([id, model]) => (
<SelectItem key={id} value={id}>{model.name}</SelectItem>
))}
</SelectGroup>
{ollamaSettings.enabled && ollamaSettings.availableModels.length > 0 && (
<SelectGroup>
<SelectLabel className="flex items-center gap-1 mt-2 border-t pt-2">🖥️ Ollama (Local)</SelectLabel>
{ollamaSettings.availableModels.map((model) => (
<SelectItem key={model} value={model}>{model}</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
</div>
</div>
<div>
<label className="text-sm font-medium mb-1.5 block">Tags (comma-separated)</label>
<Input
placeholder="e.g., blog, writing, seo"
value={state.tags}
onChange={(e) => setState((s) => ({ ...s, tags: e.target.value }))}
/>
</div>
<div>
<label className="text-sm font-medium mb-1.5 block">Visibility</label>
<div className="flex gap-2">
{(["public", "unlisted", "private"] as const).map((v) => (
<Button
key={v}
variant={state.visibility === v ? "default" : "outline"}
size="sm"
onClick={() => setState((s) => ({ ...s, visibility: v }))}
className="capitalize"
>
{v}
</Button>
))}
</div>
</div>
</CardContent>
</Card>
</div>
{/* Right Pane - Variable Schema */}
<div className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5" />
Variable Configuration
</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => setIsPreview(!isPreview)}
className="gap-1.5"
>
<Eye className="h-4 w-4" />
{isPreview ? "Edit" : "Preview"}
</Button>
</div>
<CardDescription>
Configure how each variable appears to users
</CardDescription>
</CardHeader>
<CardContent>
{variables.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Sparkles className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p className="mb-2">No variables configured</p>
<p className="text-sm">
Add {"{{variables}}"} to your template and click "Configure"
</p>
</div>
) : (
<div className="space-y-4">
{variables.map((variable, index) => (
<Card key={variable.name} className="border-dashed">
<CardContent className="p-4 space-y-3">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" />
<code className="prompt-variable text-xs">
{`{{${variable.name}}}`}
</code>
<Badge variant="outline" className="capitalize text-xs ml-auto">
{variable.type}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium mb-1 block">Label</label>
<Input
value={variable.label}
onChange={(e) => updateVariable(index, { label: e.target.value })}
placeholder="Field label"
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs font-medium mb-1 block">Type</label>
<select
value={variable.type}
onChange={(e) => updateVariable(index, {
type: e.target.value as VariableType
})}
className="flex h-8 w-full rounded-lg border border-border bg-background px-2 text-sm"
>
{VARIABLE_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
</div>
<div>
<label className="text-xs font-medium mb-1 block">Placeholder</label>
<Input
value={variable.placeholder || ""}
onChange={(e) => updateVariable(index, { placeholder: e.target.value })}
placeholder="Placeholder text..."
className="h-8 text-sm"
/>
</div>
{variable.type === "dropdown" && (
<div>
<label className="text-xs font-medium mb-1 block">
Options (comma-separated)
</label>
<Input
value={variable.options?.join(", ") || ""}
onChange={(e) => updateVariable(index, {
options: e.target.value.split(",").map((s) => s.trim())
})}
placeholder="Option 1, Option 2, Option 3"
className="h-8 text-sm"
/>
</div>
)}
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={variable.required}
onChange={(e) => updateVariable(index, { required: e.target.checked })}
className="h-3.5 w-3.5 rounded"
/>
<span className="text-xs">Required</span>
</label>
</div>
</CardContent>
</Card>
))}
</div>
)}
</CardContent>
</Card>
{/* Error Display */}
{error && (
<Card className="border-destructive">
<CardContent className="p-4 flex items-center gap-3">
<AlertCircle className="h-5 w-5 text-destructive" />
<p className="text-sm text-destructive">{error}</p>
</CardContent>
</Card>
)}
{/* Action Buttons */}
<div className="flex gap-3">
<Button
variant="outline"
className="flex-1 gap-2"
onClick={() => handleSave(false)}
disabled={isSaving}
>
<Save className="h-4 w-4" />
Save Draft
</Button>
<Button
className="flex-1 gap-2"
onClick={() => handleSave(true)}
disabled={isSaving}
>
<Play className="h-4 w-4" />
{isSaving ? "Publishing..." : "Publish"}
</Button>
</div>
</div>
</div>
)
}