feat(prompts): add variable fill modal for prompts with variables
This commit is contained in:
@@ -11,6 +11,15 @@ interface CsvRow {
|
||||
type: string;
|
||||
}
|
||||
|
||||
// Unescape literal escape sequences like \n, \t, etc.
|
||||
function unescapeString(str: string): string {
|
||||
return str
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\r/g, '\r')
|
||||
.replace(/\\t/g, '\t')
|
||||
.replace(/\\\\/g, '\\');
|
||||
}
|
||||
|
||||
function parseCSV(content: string): CsvRow[] {
|
||||
const lines = content.split("\n");
|
||||
const rows: CsvRow[] = [];
|
||||
@@ -51,7 +60,7 @@ function parseCSV(content: string): CsvRow[] {
|
||||
if (values.length >= 4) {
|
||||
rows.push({
|
||||
act: values[0],
|
||||
prompt: values[1],
|
||||
prompt: unescapeString(values[1]),
|
||||
for_devs: values[2],
|
||||
type: values[3],
|
||||
});
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { formatDistanceToNow } from "@/lib/date";
|
||||
import { ArrowBigUp, Lock, Copy, ImageIcon, Pin } from "lucide-react";
|
||||
import { ArrowBigUp, Lock, Copy, ImageIcon } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CodeView } from "@/components/ui/code-view";
|
||||
import { toast } from "sonner";
|
||||
import { prettifyJson } from "@/lib/format";
|
||||
import { RunPromptButton } from "@/components/prompts/run-prompt-button";
|
||||
import { PinButton } from "@/components/prompts/pin-button";
|
||||
import { RunPromptButton } from "@/components/prompts/run-prompt-button";
|
||||
import { VariableFillModal, hasVariables, renderContentWithVariables } from "@/components/prompts/variable-fill-modal";
|
||||
|
||||
export interface PromptCardProps {
|
||||
prompt: {
|
||||
@@ -53,14 +55,31 @@ export function PromptCard({ prompt, showPinButton = false, isPinned = false }:
|
||||
const t = useTranslations("prompts");
|
||||
const tCommon = useTranslations("common");
|
||||
const locale = useLocale();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<"copy" | "run">("copy");
|
||||
|
||||
const hasMediaBackground = prompt.type === "IMAGE" || (prompt.type === "STRUCTURED" && !!prompt.mediaUrl);
|
||||
const contentHasVariables = hasVariables(prompt.content);
|
||||
|
||||
const copyToClipboard = async (content: string) => {
|
||||
await navigator.clipboard.writeText(content);
|
||||
toast.success(tCommon("copiedToClipboard"));
|
||||
};
|
||||
|
||||
const handleCopyClick = () => {
|
||||
if (contentHasVariables) {
|
||||
setModalMode("copy");
|
||||
setModalOpen(true);
|
||||
} else {
|
||||
copyToClipboard(prompt.content);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunClick = () => {
|
||||
setModalMode("run");
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group border rounded-[var(--radius)] overflow-hidden hover:border-foreground/20 transition-colors flex flex-col ${hasMediaBackground ? "" : "p-4"}`}
|
||||
@@ -124,7 +143,7 @@ export function PromptCard({ prompt, showPinButton = false, isPinned = false }:
|
||||
/>
|
||||
) : (
|
||||
<pre className={`text-xs text-muted-foreground bg-muted p-2 rounded overflow-hidden font-mono h-full whitespace-pre-wrap break-words ${hasMediaBackground ? "line-clamp-2" : "line-clamp-4"}`}>
|
||||
{prompt.content}
|
||||
{contentHasVariables ? renderContentWithVariables(prompt.content) : prompt.content}
|
||||
</pre>
|
||||
)}
|
||||
<div className="absolute top-1.5 right-1.5 flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
|
||||
@@ -132,20 +151,39 @@ export function PromptCard({ prompt, showPinButton = false, isPinned = false }:
|
||||
<PinButton promptId={prompt.id} initialPinned={isPinned} iconOnly />
|
||||
)}
|
||||
<button
|
||||
onClick={() => copyToClipboard(prompt.content)}
|
||||
onClick={handleCopyClick}
|
||||
className="p-1 rounded bg-background/80 border hover:bg-accent"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
<RunPromptButton
|
||||
content={prompt.content}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-6 w-6 bg-background/80 [&_svg]:h-3 [&_svg]:w-3"
|
||||
/>
|
||||
{contentHasVariables ? (
|
||||
<button
|
||||
onClick={handleRunClick}
|
||||
className="p-1 rounded bg-background/80 border hover:bg-accent"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="6 3 20 12 6 21 6 3"/></svg>
|
||||
</button>
|
||||
) : (
|
||||
<RunPromptButton
|
||||
content={prompt.content}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-6 w-6 bg-background/80 [&_svg]:h-3 [&_svg]:w-3"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Variable fill modal */}
|
||||
{contentHasVariables && (
|
||||
<VariableFillModal
|
||||
content={prompt.content}
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
mode={modalMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{prompt.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Copy } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import { RunPromptButton } from "@/components/prompts/run-prompt-button";
|
||||
|
||||
interface Variable {
|
||||
name: string;
|
||||
defaultValue: string;
|
||||
fullMatch: string;
|
||||
}
|
||||
|
||||
interface VariableFillModalProps {
|
||||
content: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mode: "copy" | "run";
|
||||
}
|
||||
|
||||
// Parse ${variablename:defaultvalue} patterns
|
||||
function parseVariables(content: string): Variable[] {
|
||||
const regex = /\$\{([^:}]+):([^}]*)\}/g;
|
||||
const variables: Variable[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
variables.push({
|
||||
name: match[1].trim(),
|
||||
defaultValue: match[2].trim(),
|
||||
fullMatch: match[0],
|
||||
});
|
||||
}
|
||||
|
||||
return variables;
|
||||
}
|
||||
|
||||
// Get unique variables with their default values
|
||||
function getUniqueVariables(variables: Variable[]): { name: string; defaultValue: string }[] {
|
||||
const seen = new Map<string, string>();
|
||||
for (const variable of variables) {
|
||||
if (!seen.has(variable.name)) {
|
||||
seen.set(variable.name, variable.defaultValue);
|
||||
}
|
||||
}
|
||||
return Array.from(seen.entries()).map(([name, defaultValue]) => ({ name, defaultValue }));
|
||||
}
|
||||
|
||||
export function VariableFillModal({
|
||||
content,
|
||||
open,
|
||||
onOpenChange,
|
||||
mode
|
||||
}: VariableFillModalProps) {
|
||||
const t = useTranslations("common");
|
||||
|
||||
// Parse variables from content
|
||||
const variables = useMemo(() => parseVariables(content), [content]);
|
||||
const uniqueVariables = useMemo(() => getUniqueVariables(variables), [variables]);
|
||||
|
||||
// Initialize values with defaults
|
||||
const [values, setValues] = useState<Record<string, string>>(() => {
|
||||
const initial: Record<string, string> = {};
|
||||
for (const { name, defaultValue } of uniqueVariables) {
|
||||
initial[name] = defaultValue;
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
|
||||
// Get the final content with variables replaced
|
||||
const getFinalContent = useCallback(() => {
|
||||
let result = content;
|
||||
for (const variable of variables) {
|
||||
const value = values[variable.name] || variable.defaultValue;
|
||||
result = result.replace(variable.fullMatch, value);
|
||||
}
|
||||
return result;
|
||||
}, [content, variables, values]);
|
||||
|
||||
// Update a variable value
|
||||
const updateValue = useCallback((name: string, value: string) => {
|
||||
setValues((prev) => ({ ...prev, [name]: value }));
|
||||
}, []);
|
||||
|
||||
// Handle copy
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(getFinalContent());
|
||||
toast.success(t("copied"));
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
toast.error(t("failedToCopy"));
|
||||
}
|
||||
};
|
||||
|
||||
const finalContent = getFinalContent();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base">{t("variables")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
{uniqueVariables.map(({ name, defaultValue }) => (
|
||||
<div key={name} className="space-y-1">
|
||||
<Label htmlFor={`modal-var-${name}`} className="text-xs text-muted-foreground">
|
||||
{name}
|
||||
</Label>
|
||||
<Input
|
||||
id={`modal-var-${name}`}
|
||||
value={values[name] || ""}
|
||||
onChange={(e) => updateValue(name, e.target.value)}
|
||||
placeholder={defaultValue}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
{mode === "copy" ? (
|
||||
<Button onClick={handleCopy} size="sm">
|
||||
<Copy className="h-4 w-4 mr-1.5" />
|
||||
{t("copy")}
|
||||
</Button>
|
||||
) : (
|
||||
<RunPromptButton content={finalContent} size="sm" />
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if content has variables
|
||||
export function hasVariables(content: string): boolean {
|
||||
return /\$\{[^:}]+:[^}]*\}/.test(content);
|
||||
}
|
||||
|
||||
// Render content with styled variable placeholders (non-editable)
|
||||
export function renderContentWithVariables(content: string): React.ReactNode {
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
const regex = /\$\{([^:}]+):([^}]*)\}/g;
|
||||
let match;
|
||||
let keyIndex = 0;
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
// Add text before the variable
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(content.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
const defaultValue = match[2].trim();
|
||||
|
||||
// Add styled variable badge showing default value
|
||||
parts.push(
|
||||
<span
|
||||
key={keyIndex++}
|
||||
className="inline-block px-1 py-0.5 mx-0.5 rounded bg-primary/15 text-primary text-[10px] font-medium"
|
||||
>
|
||||
{defaultValue}
|
||||
</span>
|
||||
);
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < content.length) {
|
||||
parts.push(content.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
Reference in New Issue
Block a user