feat(prompts): add download options and copy functionality
This commit is contained in:
@@ -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}",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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*"],
|
||||
};
|
||||
Reference in New Issue
Block a user