feat(prompts): add variable fill modal for prompts with variables

This commit is contained in:
Fatih Kadir Akın
2025-12-11 00:27:13 +03:00
parent af768e048f
commit 259e6955ac
3 changed files with 243 additions and 11 deletions
+10 -1
View File
@@ -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],
});
+48 -10
View File
@@ -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;
}