From 7b7129bcbf59466f4d62fdcf28f45f4edc676830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fatih=20Kadir=20Ak=C4=B1n?= Date: Fri, 19 Dec 2025 23:44:58 +0300 Subject: [PATCH] feat(prompts): add download options and copy functionality --- messages/en.json | 8 ++ src/app/api/prompts/[id]/raw/route.ts | 102 +++++++++++++++++ src/app/prompts/[id]/page.tsx | 22 +++- .../prompts/download-prompt-dropdown.tsx | 107 ++++++++++++++++++ .../prompts/interactive-prompt-content.tsx | 11 +- src/proxy.ts | 19 ++++ 6 files changed, 262 insertions(+), 7 deletions(-) create mode 100644 src/app/api/prompts/[id]/raw/route.ts create mode 100644 src/components/prompts/download-prompt-dropdown.tsx create mode 100644 src/proxy.ts diff --git a/messages/en.json b/messages/en.json index 7151d69b..c0c207ee 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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}", diff --git a/src/app/api/prompts/[id]/raw/route.ts b/src/app/api/prompts/[id]/raw/route.ts new file mode 100644 index 00000000..a285e463 --- /dev/null +++ b/src/app/api/prompts/[id]/raw/route.ts @@ -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, + }, + }); +} diff --git a/src/app/prompts/[id]/page.tsx b/src/app/prompts/[id]/page.tsx index be161a30..1468bb25 100644 --- a/src/app/prompts/[id]/page.tsx +++ b/src/app/prompts/[id]/page.tsx @@ -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 { 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, "")} /> ) : ( )} diff --git a/src/components/prompts/download-prompt-dropdown.tsx b/src/components/prompts/download-prompt-dropdown.tsx new file mode 100644 index 00000000..6e12e580 --- /dev/null +++ b/src/components/prompts/download-prompt-dropdown.tsx @@ -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 ( + + + + + + handleDownload("md")}> + + {t("downloadMarkdown")} + + handleDownload("yml")}> + + {t("downloadYaml")} + + + handleCopyUrl("md")}> + {copiedFormat === "md" ? ( + + ) : ( + + )} + {t("copyMarkdownUrl")} + + handleCopyUrl("yml")}> + {copiedFormat === "yml" ? ( + + ) : ( + + )} + {t("copyYamlUrl")} + + + + ); +} diff --git a/src/components/prompts/interactive-prompt-content.tsx b/src/components/prompts/interactive-prompt-content.tsx index 05801d30..b87b6917 100644 --- a/src/components/prompts/interactive-prompt-content.tsx +++ b/src/components/prompts/interactive-prompt-content.tsx @@ -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({ />
+ {promptId && }
+ {promptId && } )} + {promptId && } )} + {promptId && }