mirror of
https://github.com/f/awesome-chatgpt-prompts.git
synced 2026-03-03 02:57:01 +00:00
feat(prisma, src): add slug field to Prompt model and implement slug generation for prompts without slugs
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "prompts" ADD COLUMN "slug" TEXT;
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
139
src/app/api/admin/slugs/route.ts
Normal file
139
src/app/api/admin/slugs/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
99
src/lib/slug.ts
Normal 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
24
src/lib/urls.ts
Normal 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`;
|
||||
}
|
||||
Reference in New Issue
Block a user