feat(prompts): add download options and copy functionality

This commit is contained in:
Fatih Kadir Akın
2025-12-19 23:44:58 +03:00
parent f3481c13b4
commit 7b7129bcbf
6 changed files with 262 additions and 7 deletions
+8
View File
@@ -215,6 +215,14 @@
"duplicatePromptError": "You already have a prompt with the same title or content",
"contentExistsError": "A prompt with this content already exists: \"{title}\" by {author}",
"run": "Run",
"downloadMarkdown": "Download MD",
"downloadYaml": "Download YAML",
"copyMarkdownUrl": "Copy MD Link",
"copyYamlUrl": "Copy YAML Link",
"downloadStarted": "Download started",
"downloadFailed": "Download failed",
"urlCopied": "Link copied to clipboard",
"failedToCopyUrl": "Failed to copy link",
"promptCopied": "Prompt Copied",
"promptCopiedDescription": "The prompt has been copied to your clipboard. Paste it in {platform} after opening.",
"openPlatform": "Open {platform}",
+102
View File
@@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
type OutputFormat = "md" | "yml";
/**
* Extracts the prompt ID and format from a URL parameter
* Supports formats: "abc123.prompt.md", "abc123_some-slug.prompt.md", "abc123.prompt.yml"
*/
function parseIdParam(idParam: string): { id: string; format: OutputFormat } {
let param = idParam;
let format: OutputFormat = "md";
if (param.endsWith(".prompt.yml")) {
format = "yml";
param = param.slice(0, -".prompt.yml".length);
} else if (param.endsWith(".prompt.md")) {
format = "md";
param = param.slice(0, -".prompt.md".length);
}
// If the param contains an underscore, extract the ID (everything before first underscore)
const underscoreIndex = param.indexOf("_");
if (underscoreIndex !== -1) {
param = param.substring(0, underscoreIndex);
}
return { id: param, format };
}
/**
* Converts ${variable:default} placeholders to {{variable}} format
*/
function convertVariables(str: string): string {
return str.replace(/\$\{([^}:]+)(?::[^}]*)?\}/g, "{{$1}}");
}
/**
* Escapes a string for YAML output
*/
function yamlEscape(str: string): string {
if (str.includes("\n") || str.includes(":") || str.includes("#") || str.startsWith(" ")) {
return `|\n${str.split("\n").map(line => ` ${line}`).join("\n")}`;
}
return str;
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id: idParam } = await params;
const { id, format } = parseIdParam(idParam);
const prompt = await db.prompt.findFirst({
where: { id, deletedAt: null, isPrivate: false, isUnlisted: false },
select: { title: true, description: true, content: true },
});
if (!prompt) {
return new NextResponse("Prompt not found", { status: 404 });
}
let output: string;
let contentType: string;
if (format === "yml") {
// Format as structured YAML with messages array
const lines = [
`name: ${prompt.title}`,
];
if (prompt.description) {
lines.push(`description: ${prompt.description}`);
}
lines.push(
`model: openai/gpt-4o-mini`,
`modelParameters:`,
` temperature: 0.5`,
`messages:`,
` - role: system`,
` content: ${yamlEscape(convertVariables(prompt.content))}`,
);
output = lines.join("\n");
contentType = "text/yaml; charset=utf-8";
} else {
// Format as markdown with YAML frontmatter
const frontmatter = [
"---",
`name: ${prompt.title}`,
prompt.description ? `description: ${prompt.description}` : null,
"---",
].filter(Boolean).join("\n");
output = `${frontmatter}\n${prompt.content}`;
contentType = "text/plain; charset=utf-8";
}
return new NextResponse(output, {
headers: {
"Content-Type": contentType,
},
});
}
+16 -6
View File
@@ -31,17 +31,23 @@ interface PromptPageProps {
/**
* Extracts the prompt ID from a URL parameter that may contain a slug
* Supports formats: "abc123" or "abc123_some-slug"
* Supports formats: "abc123", "abc123_some-slug", or "abc123_some-slug.prompt.md"
*/
function extractPromptId(idParam: string): string {
// If the param contains an underscore, extract the ID (everything before first underscore)
const underscoreIndex = idParam.indexOf("_");
if (underscoreIndex !== -1) {
return idParam.substring(0, underscoreIndex);
let param = idParam;
// Strip .prompt.md suffix if present
if (param.endsWith(".prompt.md")) {
param = param.slice(0, -".prompt.md".length);
}
return idParam;
// If the param contains an underscore, extract the ID (everything before first underscore)
const underscoreIndex = param.indexOf("_");
if (underscoreIndex !== -1) {
return param.substring(0, underscoreIndex);
}
return param;
}
export async function generateMetadata({ params }: PromptPageProps): Promise<Metadata> {
const { id: idParam } = await params;
const id = extractPromptId(idParam);
@@ -419,6 +425,8 @@ export default async function PromptPage({ params }: PromptPageProps) {
isLoggedIn={!!session?.user}
categoryName={prompt.category?.name}
parentCategoryName={prompt.category?.parent?.name}
promptId={prompt.id}
promptSlug={prompt.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}
/>
) : (
<InteractivePromptContent
@@ -427,6 +435,8 @@ export default async function PromptPage({ params }: PromptPageProps) {
isLoggedIn={!!session?.user}
categoryName={prompt.category?.name}
parentCategoryName={prompt.category?.parent?.name}
promptId={prompt.id}
promptSlug={prompt.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}
/>
)}
</div>
@@ -0,0 +1,107 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Download, FileText, FileCode, Copy, Check, Link } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { toast } from "sonner";
interface DownloadPromptDropdownProps {
promptId: string;
promptSlug?: string;
}
export function DownloadPromptDropdown({ promptId, promptSlug }: DownloadPromptDropdownProps) {
const t = useTranslations("prompts");
const [copiedFormat, setCopiedFormat] = useState<"md" | "yml" | null>(null);
const getFileName = (format: "md" | "yml") => {
const base = promptSlug ? `${promptId}_${promptSlug}` : promptId;
return `${base}.prompt.${format}`;
};
const getFileUrl = (format: "md" | "yml") => {
if (typeof window === "undefined") return "";
const base = promptSlug ? `${promptId}_${promptSlug}` : promptId;
return `${window.location.origin}/prompts/${base}.prompt.${format}`;
};
const handleDownload = async (format: "md" | "yml") => {
const url = getFileUrl(format);
try {
const response = await fetch(url);
if (!response.ok) throw new Error("Failed to fetch");
const content = await response.text();
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = downloadUrl;
a.download = getFileName(format);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(downloadUrl);
toast.success(t("downloadStarted"));
} catch {
toast.error(t("downloadFailed"));
}
};
const handleCopyUrl = async (format: "md" | "yml") => {
const url = getFileUrl(format);
try {
await navigator.clipboard.writeText(url);
setCopiedFormat(format);
toast.success(t("urlCopied"));
setTimeout(() => setCopiedFormat(null), 2000);
} catch {
toast.error(t("failedToCopyUrl"));
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<Download className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => handleDownload("md")}>
<FileText className="h-4 w-4 mr-2" />
{t("downloadMarkdown")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDownload("yml")}>
<FileCode className="h-4 w-4 mr-2" />
{t("downloadYaml")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleCopyUrl("md")}>
{copiedFormat === "md" ? (
<Check className="h-4 w-4 mr-2 text-green-500" />
) : (
<Link className="h-4 w-4 mr-2" />
)}
{t("copyMarkdownUrl")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopyUrl("yml")}>
{copiedFormat === "yml" ? (
<Check className="h-4 w-4 mr-2 text-green-500" />
) : (
<Link className="h-4 w-4 mr-2" />
)}
{t("copyYamlUrl")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -9,6 +9,7 @@ import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { RunPromptButton } from "./run-prompt-button";
import { TranslateButton } from "./translate-button";
import { DownloadPromptDropdown } from "./download-prompt-dropdown";
import { CodeView } from "@/components/ui/code-view";
import { prettifyJson } from "@/lib/format";
@@ -27,6 +28,8 @@ interface InteractivePromptContentProps {
isLoggedIn?: boolean;
categoryName?: string;
parentCategoryName?: string;
promptId?: string;
promptSlug?: string;
}
// Parse ${variablename:defaultvalue} or ${variablename} patterns
@@ -143,7 +146,9 @@ export function InteractivePromptContent({
title,
isLoggedIn = false,
categoryName,
parentCategoryName
parentCategoryName,
promptId,
promptSlug
}: InteractivePromptContentProps) {
const t = useTranslations("common");
const [copied, setCopied] = useState(false);
@@ -264,6 +269,7 @@ export function InteractivePromptContent({
/>
</div>
<div className="flex items-center gap-2">
{promptId && <DownloadPromptDropdown promptId={promptId} promptSlug={promptSlug} />}
<RunPromptButton
content={displayContent}
unfilledVariables={unfilledVariables}
@@ -301,6 +307,7 @@ export function InteractivePromptContent({
/>
</div>
<div className="flex items-center gap-2">
{promptId && <DownloadPromptDropdown promptId={promptId} promptSlug={promptSlug} />}
<RunPromptButton
content={displayedContent}
unfilledVariables={unfilledVariables}
@@ -346,6 +353,7 @@ export function InteractivePromptContent({
{t("reset")}
</Button>
)}
{promptId && <DownloadPromptDropdown promptId={promptId} promptSlug={promptSlug} />}
<RunPromptButton
content={getFinalContent()}
unfilledVariables={unfilledVariables}
@@ -449,6 +457,7 @@ export function InteractivePromptContent({
{t("reset")}
</Button>
)}
{promptId && <DownloadPromptDropdown promptId={promptId} promptSlug={promptSlug} />}
<RunPromptButton
content={getFinalContent()}
unfilledVariables={unfilledVariables}
+19
View File
@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// Rewrite .prompt.md and .prompt.yml requests to the raw API route
if (pathname.startsWith("/prompts/") && (pathname.endsWith(".prompt.md") || pathname.endsWith(".prompt.yml"))) {
const id = pathname.slice("/prompts/".length);
const url = request.nextUrl.clone();
url.pathname = `/api/prompts/${id}/raw`;
return NextResponse.rewrite(url);
}
return NextResponse.next();
}
export const config = {
matcher: ["/prompts/:path*"],
};