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`;
+}