feat(prisma, src): add slug field to Prompt model and implement slug generation for prompts without slugs

This commit is contained in:
Fatih Kadir Akın
2025-12-16 14:00:08 +03:00
parent 0091df48db
commit 1bb8f90888
20 changed files with 506 additions and 17 deletions

View File

@@ -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",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "prompts" ADD COLUMN "slug" TEXT;

View File

@@ -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)

View File

@@ -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 (
<Link
key={cr.id}
href={`/prompts/${cr.prompt.id}/changes/${cr.id}`}
href={`${getPromptUrl(cr.prompt.id, cr.prompt.slug)}/changes/${cr.id}`}
className="flex items-center justify-between px-3 py-2 hover:bg-accent/50 transition-colors"
>
<div className="min-w-0 flex-1">

View File

@@ -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}
/>
</TabsContent>

View File

@@ -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 }
);
}
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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 },

View File

@@ -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 },

View File

@@ -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");

View File

@@ -32,8 +32,20 @@ const radiusMap: Record<string, number> = {
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

View File

@@ -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<Metadata> {
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<Met
}
export default async function PromptPage({ params }: PromptPageProps) {
const { id } = await params;
const { id: idParam } = await params;
const id = extractPromptId(idParam);
const session = await auth();
const t = await getTranslations("prompts");
const locale = await getLocale();

View File

@@ -3,7 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Upload, Trash2, Loader2, CheckCircle, AlertCircle, Sparkles, Download, RefreshCw } from "lucide-react";
import { Upload, Trash2, Loader2, CheckCircle, AlertCircle, Sparkles, Download, RefreshCw, Link2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
@@ -38,19 +38,24 @@ interface PromptsManagementProps {
aiSearchEnabled: boolean;
promptsWithoutEmbeddings: number;
totalPublicPrompts: number;
promptsWithoutSlugs: number;
totalPrompts: number;
}
export function PromptsManagement({ aiSearchEnabled, promptsWithoutEmbeddings, totalPublicPrompts }: PromptsManagementProps) {
export function PromptsManagement({ aiSearchEnabled, promptsWithoutEmbeddings, totalPublicPrompts, promptsWithoutSlugs, totalPrompts }: PromptsManagementProps) {
const router = useRouter();
const t = useTranslations("admin");
const [loading, setLoading] = useState(false);
const [deleting, setDeleting] = useState(false);
const [generating, setGenerating] = useState(false);
const [generatingSlugs, setGeneratingSlugs] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const [embeddingResult, setEmbeddingResult] = useState<{ success: number; failed: number } | null>(null);
const [embeddingProgress, setEmbeddingProgress] = useState<ProgressState | null>(null);
const [slugResult, setSlugResult] = useState<{ success: number; failed: number } | null>(null);
const [slugProgress, setSlugProgress] = useState<ProgressState | null>(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 (
<>
<div className="flex items-center justify-between mb-4">
@@ -272,6 +334,58 @@ export function PromptsManagement({ aiSearchEnabled, promptsWithoutEmbeddings, t
)}
</>
)}
{/* URL Slugs Row */}
<div className="flex items-center gap-2 pt-3 border-t">
<span className="text-sm text-muted-foreground flex-1">
{t("prompts.slugsTitle")} <span className="tabular-nums">({promptsWithoutSlugs} {t("prompts.pending")})</span>
</span>
<Button
size="sm"
variant="outline"
onClick={() => handleGenerateSlugs(false)}
disabled={loading || deleting || generating || generatingSlugs || promptsWithoutSlugs === 0}
>
{generatingSlugs && !slugProgress ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : generatingSlugs && slugProgress ? (
<><Loader2 className="h-4 w-4 animate-spin mr-2" />{slugProgress.current}/{slugProgress.total}</>
) : (
<><Link2 className="h-4 w-4 mr-2" />{t("prompts.generateSlugs")}</>
)}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleGenerateSlugs(true)}
disabled={loading || deleting || generating || generatingSlugs || totalPrompts === 0}
title={t("prompts.regenerateSlugs")}
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
{/* Slug Progress bar */}
{slugProgress && (
<div className="space-y-2">
<Progress value={Math.round((slugProgress.current / slugProgress.total) * 100)} className="h-2" />
<div className="flex justify-between text-xs text-muted-foreground">
<span>{slugProgress.current} / {slugProgress.total}</span>
<span>{Math.round((slugProgress.current / slugProgress.total) * 100)}%</span>
</div>
<div className="flex gap-4 text-xs">
<span className="text-green-600"> {slugProgress.success}</span>
{slugProgress.failed > 0 && <span className="text-red-600"> {slugProgress.failed}</span>}
</div>
</div>
)}
{slugResult && !slugProgress && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{slugResult.failed === 0 ? <CheckCircle className="h-4 w-4 text-green-500" /> : <AlertCircle className="h-4 w-4 text-amber-500" />}
<span>{t("prompts.slugsResult", { success: slugResult.success, failed: slugResult.failed })}</span>
</div>
)}
</div>
<AlertDialog open={showConfirm} onOpenChange={setShowConfirm}>

View File

@@ -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) {
<TableRow key={report.id}>
<TableCell>
<Link
href={`/prompts/${report.prompt.id}`}
href={getPromptUrl(report.prompt.id, report.prompt.slug)}
className="font-medium hover:underline flex items-center gap-1"
>
{report.prompt.title}
@@ -170,7 +172,7 @@ export function ReportsTable({ reports }: ReportsTableProps) {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/prompts/${report.prompt.id}`}>
<Link href={getPromptUrl(report.prompt.id, report.prompt.slug)}>
<Eye className="h-4 w-4 mr-2" />
{t("viewPrompt")}
</Link>

View File

@@ -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 (
<Link
href={`/prompts/${prompt.id}`}
href={getPromptUrl(prompt.id, prompt.slug)}
target="_blank"
className="block p-2 border rounded-md hover:bg-accent/50 transition-colors text-xs"
>

View File

@@ -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 }:
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex items-center gap-1 flex-1 min-w-0">
{prompt.isPrivate && <Lock className="h-3 w-3 text-muted-foreground shrink-0" />}
<Link href={`/prompts/${prompt.id}`} className="font-medium text-sm hover:underline line-clamp-1">
<Link href={getPromptUrl(prompt.id, prompt.slug)} className="font-medium text-sm hover:underline line-clamp-1">
{prompt.title}
</Link>
</div>

View File

@@ -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<typeof useForm<PromptFormValues>>;
@@ -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"));

99
src/lib/slug.ts Normal file
View File

@@ -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<string> {
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<string> {
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<string> {
// Always translate to English for consistency
const englishTitle = await translateToEnglish(title);
return slugify(englishTitle);
}

24
src/lib/urls.ts Normal file
View File

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