diff --git a/messages/en.json b/messages/en.json index 4df2a8ed..5fd7401f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -380,7 +380,12 @@ "regenerateEmbeddings": "Regenerate all embeddings", "pending": "pending", "embeddingsSuccess": "{count} embeddings generated", - "embeddingsResult": "Generated: {success}, Failed: {failed}" + "embeddingsResult": "Generated: {success}, Failed: {failed}", + "slugsTitle": "URL Slugs", + "generateSlugs": "Generate Slugs", + "regenerateSlugs": "Regenerate all slugs (translates titles to English)", + "slugsSuccess": "{count} slugs generated", + "slugsResult": "Generated: {success}, Failed: {failed}" }, "users": { "title": "User Management", diff --git a/prisma/migrations/20251216124600_add_prompt_slug/migration.sql b/prisma/migrations/20251216124600_add_prompt_slug/migration.sql new file mode 100644 index 00000000..a5386224 --- /dev/null +++ b/prisma/migrations/20251216124600_add_prompt_slug/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "prompts" ADD COLUMN "slug" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 331bdb73..3469b34f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -76,6 +76,7 @@ model VerificationToken { model Prompt { id String @id @default(cuid()) title String + slug String? description String? content String type PromptType @default(TEXT) diff --git a/src/app/[username]/page.tsx b/src/app/[username]/page.tsx index b1b2fbde..c1b88d5c 100644 --- a/src/app/[username]/page.tsx +++ b/src/app/[username]/page.tsx @@ -3,6 +3,7 @@ import { notFound } from "next/navigation"; import Link from "next/link"; import { getTranslations, getLocale } from "next-intl/server"; import { formatDistanceToNow } from "@/lib/date"; +import { getPromptUrl } from "@/lib/urls"; import { Calendar, ArrowBigUp, FileText, Settings, GitPullRequest, Clock, Check, X, Pin, BadgeCheck, Users } from "lucide-react"; import { auth } from "@/lib/auth"; import { db } from "@/lib/db"; @@ -220,6 +221,7 @@ export default async function UserProfilePage({ params, searchParams }: UserProf prompt: { select: { id: true, + slug: true, title: true, author: { select: { @@ -254,6 +256,7 @@ export default async function UserProfilePage({ params, searchParams }: UserProf prompt: { select: { id: true, + slug: true, title: true, author: { select: { @@ -468,7 +471,7 @@ export default async function UserProfilePage({ params, searchParams }: UserProf return (
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 1837fa84..0df35798 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -59,6 +59,21 @@ export default async function AdminPage() { ]); } + // Count prompts without slugs + const [promptsWithoutSlugs, totalPrompts] = await Promise.all([ + db.prompt.count({ + where: { + slug: null, + deletedAt: null, + }, + }), + db.prompt.count({ + where: { + deletedAt: null, + }, + }), + ]); + // Fetch data for tables const [users, categories, tags, webhooks, reports] = await Promise.all([ db.user.findMany({ @@ -115,6 +130,7 @@ export default async function AdminPage() { prompt: { select: { id: true, + slug: true, title: true, }, }, @@ -232,6 +248,8 @@ export default async function AdminPage() { aiSearchEnabled={aiSearchEnabled} promptsWithoutEmbeddings={promptsWithoutEmbeddings} totalPublicPrompts={totalPublicPrompts} + promptsWithoutSlugs={promptsWithoutSlugs} + totalPrompts={totalPrompts} /> diff --git a/src/app/api/admin/slugs/route.ts b/src/app/api/admin/slugs/route.ts new file mode 100644 index 00000000..6719ef8d --- /dev/null +++ b/src/app/api/admin/slugs/route.ts @@ -0,0 +1,139 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { generatePromptSlug } from "@/lib/slug"; + +export async function POST(request: Request) { + try { + const session = await auth(); + + if (!session?.user || session.user.role !== "ADMIN") { + return NextResponse.json( + { error: "unauthorized", message: "Admin access required" }, + { status: 401 } + ); + } + + const { searchParams } = new URL(request.url); + const regenerateAll = searchParams.get("regenerate") === "true"; + + // Get prompts that need slug generation + const whereClause = regenerateAll + ? { deletedAt: null } + : { slug: null, deletedAt: null }; + + const prompts = await db.prompt.findMany({ + where: whereClause, + select: { id: true, title: true }, + }); + + if (prompts.length === 0) { + return NextResponse.json({ + success: true, + updated: 0, + message: "No prompts to update", + }); + } + + // Stream response for progress updates + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + let success = 0; + let failed = 0; + + for (let i = 0; i < prompts.length; i++) { + const prompt = prompts[i]; + + try { + const slug = await generatePromptSlug(prompt.title); + + await db.prompt.update({ + where: { id: prompt.id }, + data: { slug }, + }); + + success++; + } catch (error) { + console.error(`Failed to generate slug for prompt ${prompt.id}:`, error); + failed++; + } + + // Send progress update + const progress = { + current: i + 1, + total: prompts.length, + success, + failed, + done: false, + }; + controller.enqueue(encoder.encode(`data: ${JSON.stringify(progress)}\n\n`)); + } + + // Send final result + const finalResult = { + current: prompts.length, + total: prompts.length, + success, + failed, + done: true, + }; + controller.enqueue(encoder.encode(`data: ${JSON.stringify(finalResult)}\n\n`)); + controller.close(); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } catch (error) { + console.error("Generate slugs error:", error); + return NextResponse.json( + { error: "server_error", message: "Something went wrong" }, + { status: 500 } + ); + } +} + +// GET endpoint to check slug status +export async function GET() { + try { + const session = await auth(); + + if (!session?.user || session.user.role !== "ADMIN") { + return NextResponse.json( + { error: "unauthorized", message: "Admin access required" }, + { status: 401 } + ); + } + + const [promptsWithoutSlugs, totalPrompts] = await Promise.all([ + db.prompt.count({ + where: { + slug: null, + deletedAt: null, + }, + }), + db.prompt.count({ + where: { + deletedAt: null, + }, + }), + ]); + + return NextResponse.json({ + promptsWithoutSlugs, + totalPrompts, + }); + } catch (error) { + console.error("Get slug status error:", error); + return NextResponse.json( + { error: "server_error", message: "Something went wrong" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/prompts/[id]/route.ts b/src/app/api/prompts/[id]/route.ts index 3089515a..44d67827 100644 --- a/src/app/api/prompts/[id]/route.ts +++ b/src/app/api/prompts/[id]/route.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { auth } from "@/lib/auth"; import { db } from "@/lib/db"; import { generatePromptEmbedding } from "@/lib/ai/embeddings"; +import { generatePromptSlug } from "@/lib/slug"; const updatePromptSchema = z.object({ title: z.string().min(1).max(200).optional(), @@ -124,11 +125,19 @@ export async function PATCH( ); } - const { tagIds, contributorIds, categoryId, mediaUrl, ...data } = parsed.data; + const { tagIds, contributorIds, categoryId, mediaUrl, title, ...data } = parsed.data; + + // Regenerate slug if title changed + let newSlug: string | undefined; + if (title) { + newSlug = await generatePromptSlug(title); + } // Convert empty strings to null for optional foreign keys const cleanedData = { ...data, + ...(title && { title }), + ...(newSlug && { slug: newSlug }), ...(categoryId !== undefined && { categoryId: categoryId || null }), ...(mediaUrl !== undefined && { mediaUrl: mediaUrl || null }), }; @@ -187,7 +196,7 @@ export async function PATCH( // Regenerate embedding if content, title, or description changed (non-blocking) // Only for public prompts - the function checks if aiSearch is enabled - const contentChanged = data.content || data.title || data.description !== undefined; + const contentChanged = data.content || title || data.description !== undefined; if (contentChanged && !prompt.isPrivate) { generatePromptEmbedding(id).catch((err) => console.error("Failed to regenerate embedding for prompt:", id, err) diff --git a/src/app/api/prompts/route.ts b/src/app/api/prompts/route.ts index 667ee79f..5d60b876 100644 --- a/src/app/api/prompts/route.ts +++ b/src/app/api/prompts/route.ts @@ -4,6 +4,7 @@ import { auth } from "@/lib/auth"; import { db } from "@/lib/db"; import { triggerWebhooks } from "@/lib/webhook"; import { generatePromptEmbedding } from "@/lib/ai/embeddings"; +import { generatePromptSlug } from "@/lib/slug"; const promptSchema = z.object({ title: z.string().min(1).max(200), @@ -44,10 +45,14 @@ export async function POST(request: Request) { const { title, description, content, type, structuredFormat, categoryId, tagIds, contributorIds, isPrivate, mediaUrl, requiresMediaUpload, requiredMediaType, requiredMediaCount } = parsed.data; + // Generate slug from title (translated to English) + const slug = await generatePromptSlug(title); + // Create prompt with tags const prompt = await db.prompt.create({ data: { title, + slug, description: description || null, content, type, diff --git a/src/app/prompts/[id]/changes/[changeId]/page.tsx b/src/app/prompts/[id]/changes/[changeId]/page.tsx index 5d07891b..5b00017f 100644 --- a/src/app/prompts/[id]/changes/[changeId]/page.tsx +++ b/src/app/prompts/[id]/changes/[changeId]/page.tsx @@ -17,11 +17,23 @@ interface ChangeRequestPageProps { params: Promise<{ id: string; changeId: string }>; } +/** + * Extracts the prompt ID from a URL parameter that may contain a slug + */ +function extractPromptId(idParam: string): string { + const underscoreIndex = idParam.indexOf("_"); + if (underscoreIndex !== -1) { + return idParam.substring(0, underscoreIndex); + } + return idParam; +} + export default async function ChangeRequestPage({ params }: ChangeRequestPageProps) { const session = await auth(); const t = await getTranslations("changeRequests"); const locale = await getLocale(); - const { id: promptId, changeId } = await params; + const { id: idParam, changeId } = await params; + const promptId = extractPromptId(idParam); const changeRequest = await db.changeRequest.findUnique({ where: { id: changeId }, diff --git a/src/app/prompts/[id]/changes/new/page.tsx b/src/app/prompts/[id]/changes/new/page.tsx index a8a55c9b..877a0972 100644 --- a/src/app/prompts/[id]/changes/new/page.tsx +++ b/src/app/prompts/[id]/changes/new/page.tsx @@ -11,6 +11,17 @@ interface NewChangeRequestPageProps { params: Promise<{ id: string }>; } +/** + * Extracts the prompt ID from a URL parameter that may contain a slug + */ +function extractPromptId(idParam: string): string { + const underscoreIndex = idParam.indexOf("_"); + if (underscoreIndex !== -1) { + return idParam.substring(0, underscoreIndex); + } + return idParam; +} + export default async function NewChangeRequestPage({ params }: NewChangeRequestPageProps) { const session = await auth(); const t = await getTranslations("changeRequests"); @@ -19,7 +30,8 @@ export default async function NewChangeRequestPage({ params }: NewChangeRequestP redirect("/login"); } - const { id } = await params; + const { id: idParam } = await params; + const id = extractPromptId(idParam); const prompt = await db.prompt.findUnique({ where: { id }, diff --git a/src/app/prompts/[id]/edit/page.tsx b/src/app/prompts/[id]/edit/page.tsx index 93bb2866..c8518889 100644 --- a/src/app/prompts/[id]/edit/page.tsx +++ b/src/app/prompts/[id]/edit/page.tsx @@ -10,13 +10,25 @@ interface EditPromptPageProps { params: Promise<{ id: string }>; } +/** + * Extracts the prompt ID from a URL parameter that may contain a slug + */ +function extractPromptId(idParam: string): string { + const underscoreIndex = idParam.indexOf("_"); + if (underscoreIndex !== -1) { + return idParam.substring(0, underscoreIndex); + } + return idParam; +} + export const metadata: Metadata = { title: "Edit Prompt", description: "Edit your prompt", }; export default async function EditPromptPage({ params }: EditPromptPageProps) { - const { id } = await params; + const { id: idParam } = await params; + const id = extractPromptId(idParam); const session = await auth(); const t = await getTranslations("prompts"); diff --git a/src/app/prompts/[id]/opengraph-image.tsx b/src/app/prompts/[id]/opengraph-image.tsx index ffb4eb1e..7d6a849c 100644 --- a/src/app/prompts/[id]/opengraph-image.tsx +++ b/src/app/prompts/[id]/opengraph-image.tsx @@ -32,8 +32,20 @@ const radiusMap: Record = { lg: 16, }; +/** + * Extracts the prompt ID from a URL parameter that may contain a slug + */ +function extractPromptId(idParam: string): string { + const underscoreIndex = idParam.indexOf("_"); + if (underscoreIndex !== -1) { + return idParam.substring(0, underscoreIndex); + } + return idParam; +} + export default async function OGImage({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params; + const { id: idParam } = await params; + const id = extractPromptId(idParam); const config = await getConfig(); const radius = radiusMap[config.theme?.radius || "sm"] || 8; const radiusLg = radius * 2; // For larger elements like content box diff --git a/src/app/prompts/[id]/page.tsx b/src/app/prompts/[id]/page.tsx index 3d7bef90..f3e7bebd 100644 --- a/src/app/prompts/[id]/page.tsx +++ b/src/app/prompts/[id]/page.tsx @@ -27,8 +27,22 @@ interface PromptPageProps { params: Promise<{ id: string }>; } +/** + * Extracts the prompt ID from a URL parameter that may contain a slug + * Supports formats: "abc123" or "abc123_some-slug" + */ +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); + } + return idParam; +} + export async function generateMetadata({ params }: PromptPageProps): Promise { - const { id } = await params; + const { id: idParam } = await params; + const id = extractPromptId(idParam); const prompt = await db.prompt.findUnique({ where: { id }, select: { title: true, description: true }, @@ -45,7 +59,8 @@ export async function generateMetadata({ params }: PromptPageProps): Promise(null); const [embeddingResult, setEmbeddingResult] = useState<{ success: number; failed: number } | null>(null); const [embeddingProgress, setEmbeddingProgress] = useState(null); + const [slugResult, setSlugResult] = useState<{ success: number; failed: number } | null>(null); + const [slugProgress, setSlugProgress] = useState(null); const handleImport = async () => { setLoading(true); @@ -165,6 +170,63 @@ export function PromptsManagement({ aiSearchEnabled, promptsWithoutEmbeddings, t toast.success(t("prompts.exportSuccess")); }; + const handleGenerateSlugs = async (regenerate: boolean = false) => { + setGeneratingSlugs(true); + setSlugResult(null); + setSlugProgress(null); + + try { + const url = regenerate ? "/api/admin/slugs?regenerate=true" : "/api/admin/slugs"; + const res = await fetch(url, { method: "POST" }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Failed to generate slugs"); + } + + // Read the stream + const reader = res.body?.getReader(); + if (!reader) throw new Error("No response body"); + + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const text = decoder.decode(value); + const lines = text.split("\n\n").filter(line => line.startsWith("data: ")); + + for (const line of lines) { + const jsonStr = line.replace("data: ", ""); + try { + const data = JSON.parse(jsonStr); + + if (data.done) { + setSlugResult({ success: data.success, failed: data.failed }); + toast.success(t("prompts.slugsSuccess", { count: data.success })); + router.refresh(); + } else { + setSlugProgress({ + current: data.current, + total: data.total, + success: data.success, + failed: data.failed, + }); + } + } catch { + // Ignore parse errors + } + } + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to generate slugs"); + } finally { + setGeneratingSlugs(false); + setSlugProgress(null); + } + }; + return ( <>
@@ -272,6 +334,58 @@ export function PromptsManagement({ aiSearchEnabled, promptsWithoutEmbeddings, t )} )} + + {/* URL Slugs Row */} +
+ + {t("prompts.slugsTitle")} ({promptsWithoutSlugs} {t("prompts.pending")}) + + + +
+ + {/* Slug Progress bar */} + {slugProgress && ( +
+ +
+ {slugProgress.current} / {slugProgress.total} + {Math.round((slugProgress.current / slugProgress.total) * 100)}% +
+
+ ✓ {slugProgress.success} + {slugProgress.failed > 0 && ✗ {slugProgress.failed}} +
+
+ )} + + {slugResult && !slugProgress && ( +
+ {slugResult.failed === 0 ? : } + {t("prompts.slugsResult", { success: slugResult.success, failed: slugResult.failed })} +
+ )}
diff --git a/src/components/admin/reports-table.tsx b/src/components/admin/reports-table.tsx index 554fb36a..0f2b92d5 100644 --- a/src/components/admin/reports-table.tsx +++ b/src/components/admin/reports-table.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { useTranslations, useLocale } from "next-intl"; import Link from "next/link"; import { formatDistanceToNow } from "@/lib/date"; +import { getPromptUrl } from "@/lib/urls"; import { MoreHorizontal, Check, X, Eye, ExternalLink } from "lucide-react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; @@ -34,6 +35,7 @@ interface Report { createdAt: Date; prompt: { id: string; + slug?: string | null; title: string; }; reporter: { @@ -120,7 +122,7 @@ export function ReportsTable({ reports }: ReportsTableProps) { {report.prompt.title} @@ -170,7 +172,7 @@ export function ReportsTable({ reports }: ReportsTableProps) { - + {t("viewPrompt")} diff --git a/src/components/prompts/mini-prompt-card.tsx b/src/components/prompts/mini-prompt-card.tsx index 407b9385..3f772c61 100644 --- a/src/components/prompts/mini-prompt-card.tsx +++ b/src/components/prompts/mini-prompt-card.tsx @@ -2,10 +2,12 @@ import Link from "next/link"; import { Badge } from "@/components/ui/badge"; +import { getPromptUrl } from "@/lib/urls"; interface MiniPromptCardProps { prompt: { id: string; + slug?: string | null; title: string; description?: string | null; contentPreview: string; @@ -17,7 +19,7 @@ interface MiniPromptCardProps { export function MiniPromptCard({ prompt }: MiniPromptCardProps) { return ( diff --git a/src/components/prompts/prompt-card.tsx b/src/components/prompts/prompt-card.tsx index 166db535..e620bac9 100644 --- a/src/components/prompts/prompt-card.tsx +++ b/src/components/prompts/prompt-card.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import Image from "next/image"; import { useTranslations, useLocale } from "next-intl"; import { formatDistanceToNow } from "@/lib/date"; +import { getPromptUrl } from "@/lib/urls"; import { ArrowBigUp, Lock, Copy, ImageIcon, Play } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { CodeView } from "@/components/ui/code-view"; @@ -23,6 +24,7 @@ import { export interface PromptCardProps { prompt: { id: string; + slug?: string | null; title: string; description: string | null; content: string; @@ -130,7 +132,7 @@ export function PromptCard({ prompt, showPinButton = false, isPinned = false }:
{prompt.isPrivate && } - + {prompt.title}
diff --git a/src/components/prompts/prompt-form.tsx b/src/components/prompts/prompt-form.tsx index f5237ce8..13c07d08 100644 --- a/src/components/prompts/prompt-form.tsx +++ b/src/components/prompts/prompt-form.tsx @@ -37,6 +37,7 @@ import { CodeEditor, type CodeEditorHandle } from "@/components/ui/code-editor"; import { toast } from "sonner"; import { prettifyJson } from "@/lib/format"; import { analyticsPrompt } from "@/lib/analytics"; +import { getPromptUrl } from "@/lib/urls"; interface MediaFieldProps { form: ReturnType>; @@ -403,7 +404,7 @@ export function PromptForm({ categories, tags, initialData, initialContributors analyticsPrompt.create(data.type); } toast.success(isEdit ? t("promptUpdated") : t("promptCreated")); - router.push(`/prompts/${result.id || promptId}`); + router.push(getPromptUrl(result.id || promptId, result.slug)); router.refresh(); } catch { toast.error(tCommon("somethingWentWrong")); diff --git a/src/lib/slug.ts b/src/lib/slug.ts new file mode 100644 index 00000000..3179935d --- /dev/null +++ b/src/lib/slug.ts @@ -0,0 +1,99 @@ +import OpenAI from "openai"; + +let openai: OpenAI | null = null; + +function getOpenAIClient(): OpenAI | null { + if (!openai) { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + return null; + } + openai = new OpenAI({ + apiKey, + baseURL: process.env.OPENAI_BASE_URL || undefined, + }); + } + return openai; +} + +const GENERATIVE_MODEL = process.env.OPENAI_GENERATIVE_MODEL || "gpt-4o-mini"; + +/** + * Converts a string to a URL-friendly slug + */ +export function slugify(text: string): string { + return text + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, "") // Remove non-word characters except spaces and hyphens + .replace(/[\s_]+/g, "-") // Replace spaces and underscores with hyphens + .replace(/-+/g, "-") // Replace multiple hyphens with single hyphen + .replace(/^-+|-+$/g, "") // Remove leading/trailing hyphens + .substring(0, 100); // Limit length +} + +/** + * Detects if text is likely not in English + */ +function isLikelyNonEnglish(text: string): boolean { + // Check for common non-ASCII character ranges (CJK, Arabic, Cyrillic, etc.) + const nonEnglishPattern = /[\u0080-\uFFFF]/; + return nonEnglishPattern.test(text); +} + +/** + * Translates text to English using OpenAI + */ +export async function translateToEnglish(text: string): Promise { + const client = getOpenAIClient(); + + if (!client) { + // No OpenAI key, return original text + return text; + } + + try { + const response = await client.chat.completions.create({ + model: GENERATIVE_MODEL, + messages: [ + { + role: "system", + content: "Translate the following text to English. Return ONLY the translated text, nothing else. If the text is already in English, return it as-is." + }, + { role: "user", content: text } + ], + temperature: 0.1, + max_tokens: 200, + }); + + return response.choices[0]?.message?.content?.trim() || text; + } catch (error) { + console.error("Translation error:", error); + return text; + } +} + +/** + * Generates a URL-friendly slug from a title + * Translates to English first if the title contains non-English characters + */ +export async function generateSlug(title: string): Promise { + let textToSlugify = title; + + // If text contains non-English characters, translate it first + if (isLikelyNonEnglish(title)) { + textToSlugify = await translateToEnglish(title); + } + + return slugify(textToSlugify); +} + +/** + * Generates a slug for a prompt, always translating to English first + * This ensures consistent English slugs regardless of the original language + */ +export async function generatePromptSlug(title: string): Promise { + // Always translate to English for consistency + const englishTitle = await translateToEnglish(title); + return slugify(englishTitle); +} diff --git a/src/lib/urls.ts b/src/lib/urls.ts new file mode 100644 index 00000000..fd8be73b --- /dev/null +++ b/src/lib/urls.ts @@ -0,0 +1,24 @@ +/** + * Generates a URL path for a prompt, including the slug if available + * Format: /prompts/{id} or /prompts/{id}_{slug} + */ +export function getPromptUrl(id: string, slug?: string | null): string { + if (slug) { + return `/prompts/${id}_${slug}`; + } + return `/prompts/${id}`; +} + +/** + * Generates edit URL for a prompt + */ +export function getPromptEditUrl(id: string, slug?: string | null): string { + return `${getPromptUrl(id, slug)}/edit`; +} + +/** + * Generates changes URL for a prompt + */ +export function getPromptChangesUrl(id: string, slug?: string | null): string { + return `${getPromptUrl(id, slug)}/changes/new`; +}