1019 lines
41 KiB
TypeScript
1019 lines
41 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useRef, useEffect } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { useTranslations } from "next-intl";
|
||
import { useForm } from "react-hook-form";
|
||
import { zodResolver } from "@hookform/resolvers/zod";
|
||
import { z } from "zod";
|
||
import { Loader2, Upload, X, ArrowDown, Play, Image as ImageIcon, Video, Volume2, Paperclip, Search, Sparkles, BookOpen, ExternalLink } from "lucide-react";
|
||
import Link from "next/link";
|
||
import { VariableToolbar } from "./variable-toolbar";
|
||
import { VariableWarning } from "./variable-warning";
|
||
import { VariableHint } from "./variable-hint";
|
||
import { ContributorSearch } from "./contributor-search";
|
||
import { PromptBuilder, type PromptBuilderHandle } from "./prompt-builder";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import {
|
||
Form,
|
||
FormControl,
|
||
FormDescription,
|
||
FormField,
|
||
FormItem,
|
||
FormLabel,
|
||
FormMessage,
|
||
} from "@/components/ui/form";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { CodeEditor, type CodeEditorHandle } from "@/components/ui/code-editor";
|
||
import { toast } from "sonner";
|
||
import { prettifyJson } from "@/lib/format";
|
||
import { analyticsPrompt } from "@/lib/analytics";
|
||
import { getPromptUrl } from "@/lib/urls";
|
||
|
||
interface MediaFieldProps {
|
||
form: ReturnType<typeof useForm<PromptFormValues>>;
|
||
t: (key: string) => string;
|
||
}
|
||
|
||
function MediaField({ form, t }: MediaFieldProps) {
|
||
const [storageMode, setStorageMode] = useState<string>("url");
|
||
const [isUploading, setIsUploading] = useState(false);
|
||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const mediaUrl = form.watch("mediaUrl");
|
||
|
||
useEffect(() => {
|
||
fetch("/api/config/storage")
|
||
.then((res) => res.json())
|
||
.then((data) => setStorageMode(data.mode))
|
||
.catch(() => setStorageMode("url"));
|
||
}, []);
|
||
|
||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
|
||
// Validate file size (5MB)
|
||
if (file.size > 5 * 1024 * 1024) {
|
||
setUploadError(t("fileTooLarge"));
|
||
return;
|
||
}
|
||
|
||
// Validate file type
|
||
const allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
||
if (!allowedTypes.includes(file.type)) {
|
||
setUploadError(t("invalidFileType"));
|
||
return;
|
||
}
|
||
|
||
setIsUploading(true);
|
||
setUploadError(null);
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append("file", file);
|
||
|
||
const response = await fetch("/api/upload", {
|
||
method: "POST",
|
||
body: formData,
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || "Upload failed");
|
||
}
|
||
|
||
const result = await response.json();
|
||
form.setValue("mediaUrl", result.url);
|
||
} catch (error) {
|
||
setUploadError(error instanceof Error ? error.message : "Upload failed");
|
||
} finally {
|
||
setIsUploading(false);
|
||
if (fileInputRef.current) {
|
||
fileInputRef.current.value = "";
|
||
}
|
||
}
|
||
};
|
||
|
||
const clearMedia = () => {
|
||
form.setValue("mediaUrl", "");
|
||
setUploadError(null);
|
||
};
|
||
|
||
// URL mode: show text input
|
||
if (storageMode === "url") {
|
||
return (
|
||
<FormField
|
||
control={form.control}
|
||
name="mediaUrl"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>{t("mediaUrl")}</FormLabel>
|
||
<FormControl>
|
||
<Input placeholder={t("mediaUrlPlaceholder")} {...field} />
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// Upload mode: show file upload
|
||
return (
|
||
<FormField
|
||
control={form.control}
|
||
name="mediaUrl"
|
||
render={() => (
|
||
<FormItem>
|
||
<FormLabel>{t("mediaImage")}</FormLabel>
|
||
<FormControl>
|
||
<div className="space-y-2">
|
||
{mediaUrl ? (
|
||
<div className="relative inline-block">
|
||
<img
|
||
src={mediaUrl}
|
||
alt="Preview"
|
||
className="max-h-40 rounded-md border"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
variant="destructive"
|
||
size="icon"
|
||
className="absolute -top-2 -right-2 h-6 w-6"
|
||
onClick={clearMedia}
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<div
|
||
className="flex flex-col items-center justify-center gap-2 p-6 border-2 border-dashed rounded-md cursor-pointer hover:border-primary/50 transition-colors"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
>
|
||
{isUploading ? (
|
||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||
) : (
|
||
<Upload className="h-8 w-8 text-muted-foreground" />
|
||
)}
|
||
<p className="text-sm text-muted-foreground">
|
||
{isUploading ? t("uploading") : t("clickToUpload")}
|
||
</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t("maxFileSize")}
|
||
</p>
|
||
</div>
|
||
)}
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||
className="hidden"
|
||
onChange={handleFileSelect}
|
||
disabled={isUploading}
|
||
/>
|
||
{uploadError && (
|
||
<p className="text-sm text-destructive">{uploadError}</p>
|
||
)}
|
||
</div>
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const createPromptSchema = (t: (key: string) => string) => z.object({
|
||
title: z.string().min(1, t("titleRequired")).max(200),
|
||
description: z.string().max(500).optional(),
|
||
content: z.string().min(1, t("contentRequired")),
|
||
type: z.enum(["TEXT", "IMAGE", "VIDEO", "AUDIO", "SKILL"]), // Output type or SKILL
|
||
structuredFormat: z.enum(["JSON", "YAML"]).optional(),
|
||
categoryId: z.string().optional(),
|
||
tagIds: z.array(z.string()),
|
||
isPrivate: z.boolean(),
|
||
mediaUrl: z.string().url().optional().or(z.literal("")),
|
||
requiresMediaUpload: z.boolean(),
|
||
requiredMediaType: z.enum(["IMAGE", "VIDEO", "DOCUMENT"]).optional(),
|
||
requiredMediaCount: z.coerce.number().int().min(1).max(10).optional(),
|
||
});
|
||
|
||
type PromptFormValues = z.infer<ReturnType<typeof createPromptSchema>>;
|
||
|
||
interface Contributor {
|
||
id: string;
|
||
username: string;
|
||
name: string | null;
|
||
avatar: string | null;
|
||
}
|
||
|
||
interface PromptFormProps {
|
||
categories: Array<{
|
||
id: string;
|
||
name: string;
|
||
slug: string;
|
||
parentId: string | null;
|
||
}>;
|
||
tags: Array<{
|
||
id: string;
|
||
name: string;
|
||
slug: string;
|
||
color: string;
|
||
}>;
|
||
initialData?: Partial<PromptFormValues>;
|
||
initialContributors?: Contributor[];
|
||
promptId?: string;
|
||
mode?: "create" | "edit";
|
||
aiGenerationEnabled?: boolean;
|
||
aiModelName?: string;
|
||
initialPromptRequest?: string;
|
||
}
|
||
|
||
export function PromptForm({ categories, tags, initialData, initialContributors = [], promptId, mode = "create", aiGenerationEnabled = false, aiModelName, initialPromptRequest }: PromptFormProps) {
|
||
const router = useRouter();
|
||
const t = useTranslations("prompts");
|
||
const tCommon = useTranslations("common");
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [contributors, setContributors] = useState<Contributor[]>(initialContributors);
|
||
const [usedAiButtons, setUsedAiButtons] = useState<Set<string>>(new Set());
|
||
const builderRef = useRef<PromptBuilderHandle>(null);
|
||
|
||
const promptSchema = createPromptSchema(t);
|
||
const form = useForm<PromptFormValues>({
|
||
resolver: zodResolver(promptSchema) as never,
|
||
defaultValues: {
|
||
title: initialData?.title || "",
|
||
description: initialData?.description || "",
|
||
content: initialData?.content && initialData?.structuredFormat === "JSON"
|
||
? prettifyJson(initialData.content)
|
||
: (initialData?.content || ""),
|
||
type: initialData?.type || "TEXT",
|
||
structuredFormat: initialData?.structuredFormat || undefined,
|
||
categoryId: initialData?.categoryId || "",
|
||
tagIds: initialData?.tagIds || [],
|
||
isPrivate: initialData?.isPrivate || false,
|
||
mediaUrl: initialData?.mediaUrl || "",
|
||
requiresMediaUpload: initialData?.requiresMediaUpload || false,
|
||
requiredMediaType: initialData?.requiredMediaType || "IMAGE",
|
||
requiredMediaCount: initialData?.requiredMediaCount || 1,
|
||
},
|
||
});
|
||
|
||
const selectedTags = form.watch("tagIds");
|
||
const promptType = form.watch("type");
|
||
const structuredFormat = form.watch("structuredFormat");
|
||
const isStructuredInput = !!structuredFormat;
|
||
const [tagSearch, setTagSearch] = useState("");
|
||
const [tagDropdownOpen, setTagDropdownOpen] = useState(false);
|
||
const tagInputRef = useRef<HTMLInputElement>(null);
|
||
const requiresMediaUpload = form.watch("requiresMediaUpload");
|
||
const promptContent = form.watch("content");
|
||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||
const codeEditorRef = useRef<CodeEditorHandle>(null);
|
||
|
||
// Warn user before leaving page with unsaved changes
|
||
const isDirty = form.formState.isDirty;
|
||
useEffect(() => {
|
||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||
if (isDirty) {
|
||
e.preventDefault();
|
||
e.returnValue = "";
|
||
}
|
||
};
|
||
|
||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||
}, [isDirty]);
|
||
|
||
// Handler for AI builder state changes
|
||
const handleBuilderStateChange = (newState: {
|
||
title: string;
|
||
description: string;
|
||
content: string;
|
||
type: "TEXT" | "IMAGE" | "VIDEO" | "AUDIO" | "SKILL";
|
||
structuredFormat?: "JSON" | "YAML";
|
||
categoryId?: string;
|
||
tagIds: string[];
|
||
isPrivate: boolean;
|
||
requiresMediaUpload: boolean;
|
||
requiredMediaType?: "IMAGE" | "VIDEO" | "DOCUMENT";
|
||
requiredMediaCount?: number;
|
||
}) => {
|
||
const opts = { shouldDirty: true, shouldTouch: true };
|
||
if (newState.title) form.setValue("title", newState.title, opts);
|
||
if (newState.description) form.setValue("description", newState.description, opts);
|
||
if (newState.content) form.setValue("content", newState.content, opts);
|
||
if (newState.type) form.setValue("type", newState.type, opts);
|
||
if (newState.structuredFormat) form.setValue("structuredFormat", newState.structuredFormat, opts);
|
||
if (newState.categoryId) form.setValue("categoryId", newState.categoryId, opts);
|
||
if (newState.tagIds?.length) form.setValue("tagIds", newState.tagIds, opts);
|
||
form.setValue("isPrivate", newState.isPrivate, opts);
|
||
form.setValue("requiresMediaUpload", newState.requiresMediaUpload, opts);
|
||
if (newState.requiredMediaType) form.setValue("requiredMediaType", newState.requiredMediaType, opts);
|
||
if (newState.requiredMediaCount) form.setValue("requiredMediaCount", newState.requiredMediaCount, opts);
|
||
};
|
||
|
||
// Current state for AI builder
|
||
const currentBuilderState = {
|
||
title: form.watch("title"),
|
||
description: form.watch("description") || "",
|
||
content: form.watch("content"),
|
||
type: form.watch("type"),
|
||
structuredFormat: form.watch("structuredFormat"),
|
||
categoryId: form.watch("categoryId"),
|
||
tagIds: form.watch("tagIds"),
|
||
isPrivate: form.watch("isPrivate"),
|
||
requiresMediaUpload: form.watch("requiresMediaUpload"),
|
||
requiredMediaType: form.watch("requiredMediaType"),
|
||
requiredMediaCount: form.watch("requiredMediaCount"),
|
||
};
|
||
|
||
const getSelectedText = () => {
|
||
// For text prompts using textarea
|
||
const textarea = textareaRef.current;
|
||
if (textarea) {
|
||
return textarea.value.substring(textarea.selectionStart, textarea.selectionEnd);
|
||
}
|
||
return "";
|
||
};
|
||
|
||
const insertVariable = (variable: string) => {
|
||
// For structured prompts using Monaco editor
|
||
if (isStructuredInput && codeEditorRef.current) {
|
||
codeEditorRef.current.insertAtCursor(variable);
|
||
return;
|
||
}
|
||
|
||
// For text prompts using textarea
|
||
const textarea = textareaRef.current;
|
||
const currentContent = form.getValues("content");
|
||
|
||
if (textarea) {
|
||
const start = textarea.selectionStart;
|
||
const end = textarea.selectionEnd;
|
||
const newContent = currentContent.slice(0, start) + variable + currentContent.slice(end);
|
||
form.setValue("content", newContent);
|
||
|
||
// Set cursor position after inserted variable
|
||
setTimeout(() => {
|
||
textarea.focus();
|
||
textarea.setSelectionRange(start + variable.length, start + variable.length);
|
||
}, 0);
|
||
} else {
|
||
// Fallback: append to end
|
||
form.setValue("content", currentContent + variable);
|
||
}
|
||
};
|
||
|
||
async function onSubmit(data: PromptFormValues) {
|
||
setIsLoading(true);
|
||
|
||
try {
|
||
const isEdit = mode === "edit" && promptId;
|
||
const url = isEdit ? `/api/prompts/${promptId}` : "/api/prompts";
|
||
const method = isEdit ? "PATCH" : "POST";
|
||
|
||
const response = await fetch(url, {
|
||
method,
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
...data,
|
||
structuredFormat: data.structuredFormat || null, // Explicitly send null to clear
|
||
contributorIds: contributors.map((c) => c.id),
|
||
}),
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (!response.ok) {
|
||
// Handle specific error types
|
||
if (result.error === "rate_limit") {
|
||
toast.error(t("rateLimitError"));
|
||
return;
|
||
}
|
||
if (result.error === "duplicate_prompt") {
|
||
toast.error(t("duplicatePromptError"));
|
||
return;
|
||
}
|
||
if (result.error === "content_exists") {
|
||
toast.error(t("contentExistsError", {
|
||
title: result.existingPromptTitle,
|
||
author: result.existingPromptAuthor
|
||
}));
|
||
return;
|
||
}
|
||
throw new Error("Failed to save prompt");
|
||
}
|
||
if (isEdit) {
|
||
analyticsPrompt.edit(promptId!);
|
||
} else {
|
||
analyticsPrompt.create(data.type);
|
||
}
|
||
toast.success(isEdit ? t("promptUpdated") : t("promptCreated"));
|
||
router.push(getPromptUrl(result.id || promptId, result.slug));
|
||
router.refresh();
|
||
} catch {
|
||
toast.error(tCommon("somethingWentWrong"));
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}
|
||
|
||
const toggleTag = (tagId: string) => {
|
||
const current = form.getValues("tagIds");
|
||
if (current.includes(tagId)) {
|
||
form.setValue("tagIds", current.filter((id) => id !== tagId));
|
||
} else {
|
||
form.setValue("tagIds", [...current, tagId]);
|
||
}
|
||
};
|
||
|
||
const handleAiGenerate = (field: string, label: string) => {
|
||
if (usedAiButtons.has(field) || !builderRef.current) return;
|
||
setUsedAiButtons(prev => new Set(prev).add(field));
|
||
builderRef.current.sendMessage(`Generate ${label}`);
|
||
};
|
||
|
||
const AiGenerateButton = ({ field, label }: { field: string; label: string }) => {
|
||
if (!aiGenerationEnabled) return null;
|
||
const isUsed = usedAiButtons.has(field);
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => handleAiGenerate(field, label)}
|
||
disabled={isUsed}
|
||
className={`inline-flex items-center justify-center h-4 w-4 rounded transition-colors ${
|
||
isUsed
|
||
? "text-muted-foreground/30 cursor-not-allowed"
|
||
: "text-primary/60 hover:text-primary hover:bg-primary/10"
|
||
}`}
|
||
title={`Generate ${label}`}
|
||
>
|
||
<Sparkles className="h-3 w-3" />
|
||
</button>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<Form {...form}>
|
||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||
{/* Header: Page title + Private Switch */}
|
||
<div className="flex items-center justify-between mb-2">
|
||
<h1 className="text-lg font-semibold">{mode === "edit" ? t("edit") : t("create")}</h1>
|
||
<div className="flex items-center gap-3">
|
||
{aiGenerationEnabled && (
|
||
<PromptBuilder
|
||
ref={builderRef}
|
||
availableTags={tags}
|
||
availableCategories={categories}
|
||
currentState={currentBuilderState}
|
||
onStateChange={handleBuilderStateChange}
|
||
modelName={aiModelName}
|
||
initialPromptRequest={initialPromptRequest}
|
||
/>
|
||
)}
|
||
<FormField
|
||
control={form.control}
|
||
name="isPrivate"
|
||
render={({ field }) => (
|
||
<FormItem className="flex items-center gap-2">
|
||
<FormControl>
|
||
<Switch
|
||
checked={field.value}
|
||
onCheckedChange={field.onChange}
|
||
/>
|
||
</FormControl>
|
||
<FormLabel className="!mt-0 text-sm font-normal">{t("promptPrivate")}</FormLabel>
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ===== PROMPT WRITING GUIDE LINK ===== */}
|
||
<Link
|
||
href="/how_to_write_effective_prompts"
|
||
target="_blank"
|
||
className="flex items-center gap-2 p-3 rounded-lg border bg-muted/30 hover:bg-muted/50 transition-colors text-sm text-muted-foreground hover:text-foreground"
|
||
>
|
||
<BookOpen className="h-4 w-4 text-primary" />
|
||
<span>{t("learnHowToWritePrompts")}</span>
|
||
<ExternalLink className="h-3 w-3 ml-auto" />
|
||
</Link>
|
||
|
||
{/* ===== METADATA SECTION ===== */}
|
||
<div className="space-y-4 pb-6 border-b">
|
||
{/* Row 1: Title + Category */}
|
||
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
|
||
<FormField
|
||
control={form.control}
|
||
name="title"
|
||
render={({ field }) => (
|
||
<FormItem className="flex-1">
|
||
<FormLabel className="flex items-center gap-1.5">
|
||
{t("promptTitle")}
|
||
<AiGenerateButton field="title" label="Title" />
|
||
</FormLabel>
|
||
<FormControl>
|
||
<Input placeholder={t("titlePlaceholder")} {...field} />
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
<FormField
|
||
control={form.control}
|
||
name="categoryId"
|
||
render={({ field }) => (
|
||
<FormItem className="w-full sm:w-64">
|
||
<FormLabel className="flex items-center gap-1.5">
|
||
{t("promptCategory")}
|
||
<AiGenerateButton field="category" label="Category" />
|
||
</FormLabel>
|
||
<Select
|
||
onValueChange={(value) => field.onChange(value === "__none__" ? undefined : value)}
|
||
value={field.value || "__none__"}
|
||
>
|
||
<FormControl>
|
||
<SelectTrigger className="w-full">
|
||
<SelectValue placeholder={t("selectCategory")} />
|
||
</SelectTrigger>
|
||
</FormControl>
|
||
<SelectContent>
|
||
<SelectItem value="__none__">{t("noCategory")}</SelectItem>
|
||
{categories
|
||
.filter((c) => c.id && !c.parentId)
|
||
.map((parent) => (
|
||
<div key={parent.id}>
|
||
<SelectItem value={parent.id} className="font-medium">
|
||
{parent.name}
|
||
</SelectItem>
|
||
{categories
|
||
.filter((c) => c.parentId === parent.id)
|
||
.map((child) => (
|
||
<SelectItem key={child.id} value={child.id} className="pl-6 text-muted-foreground">
|
||
↳ {child.name}
|
||
</SelectItem>
|
||
))}
|
||
</div>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</div>
|
||
|
||
{/* Row 2: Description */}
|
||
<FormField
|
||
control={form.control}
|
||
name="description"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel className="flex items-center gap-1.5">
|
||
{t("promptDescription")}
|
||
<AiGenerateButton field="description" label="Description" />
|
||
</FormLabel>
|
||
<FormControl>
|
||
<Textarea
|
||
placeholder={t("descriptionPlaceholder")}
|
||
className="resize-none"
|
||
rows={2}
|
||
{...field}
|
||
/>
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
{/* Tags with Search Autocomplete */}
|
||
<FormField
|
||
control={form.control}
|
||
name="tagIds"
|
||
render={() => {
|
||
const filteredTags = tags.filter(
|
||
(tag) =>
|
||
!selectedTags.includes(tag.id) &&
|
||
tag.name.toLowerCase().includes(tagSearch.toLowerCase())
|
||
);
|
||
const selectedTagObjects = tags.filter((tag) => selectedTags.includes(tag.id));
|
||
|
||
return (
|
||
<FormItem>
|
||
<FormLabel className="flex items-center gap-1.5">
|
||
{t("promptTags")}
|
||
<AiGenerateButton field="tags" label="Tags" />
|
||
</FormLabel>
|
||
{/* Selected tags */}
|
||
{selectedTagObjects.length > 0 && (
|
||
<div className="flex flex-wrap gap-2 mb-2">
|
||
{selectedTagObjects.map((tag) => (
|
||
<Badge
|
||
key={tag.id}
|
||
style={{ backgroundColor: tag.color, color: "white" }}
|
||
className="pr-1 flex items-center gap-1"
|
||
>
|
||
{tag.name}
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleTag(tag.id)}
|
||
className="ml-1 rounded-full hover:bg-white/20 p-0.5"
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</button>
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
)}
|
||
{/* Search input */}
|
||
<div className="relative">
|
||
<div className="relative">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
ref={tagInputRef}
|
||
type="text"
|
||
placeholder={t("searchTags")}
|
||
value={tagSearch}
|
||
onChange={(e) => {
|
||
setTagSearch(e.target.value);
|
||
setTagDropdownOpen(true);
|
||
}}
|
||
onFocus={() => setTagDropdownOpen(true)}
|
||
onBlur={() => setTimeout(() => setTagDropdownOpen(false), 150)}
|
||
className="pl-9"
|
||
autoComplete="off"
|
||
autoCorrect="off"
|
||
autoCapitalize="none"
|
||
spellCheck={false}
|
||
data-1p-ignore
|
||
data-lpignore="true"
|
||
data-form-type="other"
|
||
/>
|
||
</div>
|
||
{/* Dropdown suggestions */}
|
||
{tagDropdownOpen && filteredTags.length > 0 && (
|
||
<div className="absolute z-10 w-full mt-1 bg-popover border rounded-md shadow-md max-h-48 overflow-auto">
|
||
{filteredTags.map((tag) => (
|
||
<button
|
||
key={tag.id}
|
||
type="button"
|
||
onClick={() => {
|
||
toggleTag(tag.id);
|
||
setTagSearch("");
|
||
tagInputRef.current?.focus();
|
||
}}
|
||
className="w-full px-3 py-2 text-left text-sm hover:bg-muted flex items-center gap-2"
|
||
>
|
||
<span
|
||
className="w-3 h-3 rounded-full"
|
||
style={{ backgroundColor: tag.color }}
|
||
/>
|
||
{tag.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
{tagDropdownOpen && tagSearch && filteredTags.length === 0 && (
|
||
<div className="absolute z-10 w-full mt-1 bg-popover border rounded-md shadow-md p-3 text-sm text-muted-foreground">
|
||
{t("noTagsFound")}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<FormMessage />
|
||
</FormItem>
|
||
);
|
||
}}
|
||
/>
|
||
|
||
{/* Contributors */}
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-medium block">{t("promptContributors")}</label>
|
||
<p className="text-xs text-muted-foreground">{t("contributorsDescription")}</p>
|
||
<ContributorSearch
|
||
selectedUsers={contributors}
|
||
onSelect={(user) => setContributors((prev) => [...prev, user])}
|
||
onRemove={(userId) => setContributors((prev) => prev.filter((u) => u.id !== userId))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ===== INPUT SECTION ===== */}
|
||
<div className="space-y-4 py-6">
|
||
<div className="flex items-center gap-2">
|
||
<h2 className="text-base font-semibold">{t("inputType")}</h2>
|
||
</div>
|
||
|
||
{/* Input Type & Format selectors */}
|
||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||
<div className="flex items-center gap-3">
|
||
<Select
|
||
value={promptType === "SKILL" ? "SKILL" : (isStructuredInput ? "STRUCTURED" : "TEXT")}
|
||
onValueChange={(v) => {
|
||
if (v === "STRUCTURED") {
|
||
form.setValue("structuredFormat", "JSON");
|
||
form.setValue("type", "TEXT");
|
||
} else if (v === "SKILL") {
|
||
form.setValue("structuredFormat", undefined);
|
||
form.setValue("type", "SKILL");
|
||
} else {
|
||
form.setValue("structuredFormat", undefined);
|
||
form.setValue("type", "TEXT");
|
||
}
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-9 w-48">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="TEXT">{t("inputTypes.text")}</SelectItem>
|
||
<SelectItem value="STRUCTURED">{t("inputTypes.structured")}</SelectItem>
|
||
<SelectItem value="SKILL">{t("inputTypes.skill")}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
{isStructuredInput && (
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
<Select value={structuredFormat || "JSON"} onValueChange={(v) => form.setValue("structuredFormat", v as any)}>
|
||
<SelectTrigger className="h-9 w-24">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="JSON">JSON</SelectItem>
|
||
<SelectItem value="YAML">YAML</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
)}
|
||
</div>
|
||
{/* Media upload toggle */}
|
||
<div className="flex items-center gap-2 sm:ml-auto">
|
||
<Switch
|
||
id="media-upload"
|
||
checked={requiresMediaUpload}
|
||
onCheckedChange={(v) => form.setValue("requiresMediaUpload", v)}
|
||
/>
|
||
<label htmlFor="media-upload" className="text-sm cursor-pointer">
|
||
{t("requiresMediaUpload")}
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Media type & count - grouped buttons */}
|
||
{requiresMediaUpload && (
|
||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 p-3 rounded-md border bg-muted/30">
|
||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||
<Paperclip className="h-4 w-4" />
|
||
<span>{t("attachedMediaType")}:</span>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<div className="inline-flex rounded-md border divide-x">
|
||
{(["IMAGE", "VIDEO", "DOCUMENT"] as const).map((type) => (
|
||
<button
|
||
key={type}
|
||
type="button"
|
||
onClick={() => form.setValue("requiredMediaType", type)}
|
||
className={`px-3 py-1.5 text-xs font-medium transition-colors first:rounded-l-md last:rounded-r-md ${
|
||
form.watch("requiredMediaType") === type
|
||
? "bg-primary text-primary-foreground"
|
||
: "bg-background hover:bg-muted"
|
||
}`}
|
||
>
|
||
{t(`types.${type.toLowerCase()}`)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<span className="text-xs text-muted-foreground">×</span>
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
max={10}
|
||
value={form.watch("requiredMediaCount")}
|
||
onChange={(e) => form.setValue("requiredMediaCount", parseInt(e.target.value) || 1)}
|
||
className="h-7 w-16 text-xs"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Prompt Content */}
|
||
<FormField
|
||
control={form.control}
|
||
name="content"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormControl>
|
||
{promptType === "SKILL" ? (
|
||
<div className="rounded-md border overflow-hidden">
|
||
<VariableToolbar onInsert={insertVariable} getSelectedText={getSelectedText} />
|
||
<CodeEditor
|
||
ref={codeEditorRef}
|
||
value={field.value}
|
||
onChange={field.onChange}
|
||
language="markdown"
|
||
placeholder={`# My Skill\n\nDescribe what this skill does and how the agent should use it.\n\n## Instructions\n\n- Step 1: ...\n- Step 2: ...`}
|
||
minHeight="400px"
|
||
className="border-0 rounded-none"
|
||
/>
|
||
</div>
|
||
) : isStructuredInput ? (
|
||
<div className="rounded-md border overflow-hidden">
|
||
<VariableToolbar onInsert={insertVariable} getSelectedText={getSelectedText} />
|
||
<CodeEditor
|
||
ref={codeEditorRef}
|
||
value={field.value}
|
||
onChange={field.onChange}
|
||
language={structuredFormat?.toLowerCase() as "json" | "yaml" || "json"}
|
||
placeholder={
|
||
structuredFormat === "JSON"
|
||
? '{\n "name": "My Workflow",\n "steps": []\n}'
|
||
: 'name: My Workflow\nsteps:\n - step: first\n prompt: "..."'
|
||
}
|
||
minHeight="250px"
|
||
className="border-0 rounded-none"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="rounded-md border overflow-hidden">
|
||
<VariableToolbar onInsert={insertVariable} getSelectedText={getSelectedText} />
|
||
<Textarea
|
||
ref={(el) => {
|
||
textareaRef.current = el;
|
||
if (typeof field.ref === 'function') field.ref(el);
|
||
}}
|
||
name={field.name}
|
||
value={field.value}
|
||
onChange={field.onChange}
|
||
onBlur={field.onBlur}
|
||
placeholder={t("contentPlaceholder")}
|
||
className="min-h-[150px] font-mono border-0 rounded-none focus-visible:ring-0"
|
||
/>
|
||
</div>
|
||
)}
|
||
</FormControl>
|
||
<VariableHint content={field.value} onContentChange={(newContent) => form.setValue("content", newContent)} />
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
{/* Variable detection warning */}
|
||
<VariableWarning
|
||
content={promptContent}
|
||
onConvert={(converted) => form.setValue("content", converted)}
|
||
/>
|
||
</div>
|
||
|
||
{/* ===== LLM PROCESSING ARROW ===== */}
|
||
<div className="flex flex-col items-center py-4">
|
||
<div className="flex items-center gap-2 text-muted-foreground">
|
||
<div className="h-px w-16 bg-border" />
|
||
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-muted text-xs font-medium">
|
||
<ArrowDown className="h-3.5 w-3.5" />
|
||
<span>{t("afterAiProcessing")}</span>
|
||
</div>
|
||
<div className="h-px w-16 bg-border" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* ===== OUTPUT SECTION ===== */}
|
||
{promptType === "SKILL" ? (
|
||
/* SKILL type shows a code output preview - code generated BY the skill */
|
||
<div className="space-y-4 py-6 border-t">
|
||
<div className="space-y-1">
|
||
<h2 className="text-base font-semibold">{t("outputType")}</h2>
|
||
<p className="text-sm text-muted-foreground">{t("outputTypeSkillNote")}</p>
|
||
</div>
|
||
|
||
{/* Code output preview - what the agent generates */}
|
||
<div className="rounded-lg border bg-[#1e1e1e] overflow-hidden">
|
||
{/* Editor title bar */}
|
||
<div className="flex items-center gap-2 px-3 py-2 bg-[#2d2d2d] border-b border-[#3d3d3d]">
|
||
<div className="flex gap-1.5">
|
||
<div className="w-3 h-3 rounded-full bg-[#ff5f57]" />
|
||
<div className="w-3 h-3 rounded-full bg-[#febc2e]" />
|
||
<div className="w-3 h-3 rounded-full bg-[#28c840]" />
|
||
</div>
|
||
<span className="text-xs text-[#808080] ml-2 font-mono">generated-code.ts</span>
|
||
</div>
|
||
{/* Code output content */}
|
||
<div className="p-4 text-xs space-y-1" style={{ fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace' }}>
|
||
<div><span className="text-[#6a9955]">// Code generated by skill...</span></div>
|
||
<div><span className="text-[#c586c0]">export</span> <span className="text-[#569cd6]">function</span> <span className="text-[#dcdcaa]">handler</span><span className="text-[#d4d4d4]">()</span> <span className="text-[#d4d4d4]">{'{'}</span></div>
|
||
<div><span className="text-[#d4d4d4]"> </span><span className="text-[#c586c0]">return</span> <span className="text-[#ce9178]">"..."</span><span className="text-[#d4d4d4]">;</span></div>
|
||
<div><span className="text-[#d4d4d4]">{'}'}</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4 py-6 border-t">
|
||
<div className="space-y-1">
|
||
<h2 className="text-base font-semibold">{t("outputType")}</h2>
|
||
<p className="text-sm text-muted-foreground">{t("outputTypeDescription")}</p>
|
||
</div>
|
||
|
||
{/* Output Type selector as grouped buttons */}
|
||
<div className="grid grid-cols-2 sm:inline-flex rounded-md border sm:divide-x">
|
||
{(["TEXT", "IMAGE", "VIDEO", "AUDIO"] as const).map((type, index) => (
|
||
<button
|
||
key={type}
|
||
type="button"
|
||
onClick={() => form.setValue("type", type)}
|
||
className={`px-4 py-2 text-sm font-medium transition-colors flex items-center justify-center gap-2 ${
|
||
index === 0 ? "rounded-tl-md sm:rounded-l-md sm:rounded-tr-none" : ""
|
||
} ${
|
||
index === 1 ? "rounded-tr-md sm:rounded-none border-l sm:border-l-0" : ""
|
||
} ${
|
||
index === 2 ? "rounded-bl-md sm:rounded-none border-t sm:border-t-0" : ""
|
||
} ${
|
||
index === 3 ? "rounded-br-md sm:rounded-r-md sm:rounded-bl-none border-t border-l sm:border-t-0 sm:border-l-0" : ""
|
||
} ${
|
||
promptType === type
|
||
? "bg-primary text-primary-foreground"
|
||
: "bg-background hover:bg-muted"
|
||
}`}
|
||
>
|
||
{type === "TEXT" && <span className="text-xs">Aa</span>}
|
||
{type === "IMAGE" && <ImageIcon className="h-4 w-4" />}
|
||
{type === "VIDEO" && <Video className="h-4 w-4" />}
|
||
{type === "AUDIO" && <Volume2 className="h-4 w-4" />}
|
||
{t(`outputTypes.${type.toLowerCase()}`)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Output Preview based on type */}
|
||
<div className="rounded-lg border bg-muted/20 p-4">
|
||
{promptType === "TEXT" && (
|
||
<div className="text-muted-foreground/50 italic text-sm">
|
||
{t("outputPreview.text")}
|
||
</div>
|
||
)}
|
||
{promptType === "IMAGE" && (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||
<ImageIcon className="h-4 w-4" />
|
||
<span>{t("outputPreview.imageUpload")}</span>
|
||
</div>
|
||
<MediaField form={form} t={t} />
|
||
</div>
|
||
)}
|
||
{promptType === "VIDEO" && (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||
<Video className="h-4 w-4" />
|
||
<span>{t("outputPreview.videoUpload")}</span>
|
||
</div>
|
||
<MediaField form={form} t={t} />
|
||
</div>
|
||
)}
|
||
{promptType === "AUDIO" && (
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||
<Volume2 className="h-4 w-4" />
|
||
<span>{t("outputPreview.audio")}</span>
|
||
</div>
|
||
{/* Fake audio player bar */}
|
||
<div className="flex items-center gap-3 p-3 rounded-md bg-muted/50">
|
||
<button type="button" className="h-8 w-8 rounded-full bg-muted flex items-center justify-center cursor-not-allowed" disabled>
|
||
<Play className="h-4 w-4 text-muted-foreground/50" />
|
||
</button>
|
||
<div className="flex-1 h-1.5 bg-muted-foreground/20 rounded-full">
|
||
<div className="h-full w-0 bg-muted-foreground/30 rounded-full" />
|
||
</div>
|
||
<span className="text-xs text-muted-foreground/50">0:00</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex justify-end gap-4 pt-2">
|
||
<Button type="button" variant="outline" onClick={() => router.back()}>
|
||
{tCommon("cancel")}
|
||
</Button>
|
||
<Button type="submit" disabled={isLoading}>
|
||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
{mode === "edit" ? t("update") : t("createButton")} Prompt
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</Form>
|
||
</>
|
||
);
|
||
}
|