feat(prisma, src): add slug field to Prompt model and implement slug generation for prompts without slugs
This commit is contained in:
+6
-1
@@ -380,7 +380,12 @@
|
|||||||
"regenerateEmbeddings": "Regenerate all embeddings",
|
"regenerateEmbeddings": "Regenerate all embeddings",
|
||||||
"pending": "pending",
|
"pending": "pending",
|
||||||
"embeddingsSuccess": "{count} embeddings generated",
|
"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": {
|
"users": {
|
||||||
"title": "User Management",
|
"title": "User Management",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "prompts" ADD COLUMN "slug" TEXT;
|
||||||
@@ -76,6 +76,7 @@ model VerificationToken {
|
|||||||
model Prompt {
|
model Prompt {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
title String
|
title String
|
||||||
|
slug String?
|
||||||
description String?
|
description String?
|
||||||
content String
|
content String
|
||||||
type PromptType @default(TEXT)
|
type PromptType @default(TEXT)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations, getLocale } from "next-intl/server";
|
import { getTranslations, getLocale } from "next-intl/server";
|
||||||
import { formatDistanceToNow } from "@/lib/date";
|
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 { Calendar, ArrowBigUp, FileText, Settings, GitPullRequest, Clock, Check, X, Pin, BadgeCheck, Users } from "lucide-react";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
@@ -220,6 +221,7 @@ export default async function UserProfilePage({ params, searchParams }: UserProf
|
|||||||
prompt: {
|
prompt: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
slug: true,
|
||||||
title: true,
|
title: true,
|
||||||
author: {
|
author: {
|
||||||
select: {
|
select: {
|
||||||
@@ -254,6 +256,7 @@ export default async function UserProfilePage({ params, searchParams }: UserProf
|
|||||||
prompt: {
|
prompt: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
slug: true,
|
||||||
title: true,
|
title: true,
|
||||||
author: {
|
author: {
|
||||||
select: {
|
select: {
|
||||||
@@ -468,7 +471,7 @@ export default async function UserProfilePage({ params, searchParams }: UserProf
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={cr.id}
|
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"
|
className="flex items-center justify-between px-3 py-2 hover:bg-accent/50 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<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
|
// Fetch data for tables
|
||||||
const [users, categories, tags, webhooks, reports] = await Promise.all([
|
const [users, categories, tags, webhooks, reports] = await Promise.all([
|
||||||
db.user.findMany({
|
db.user.findMany({
|
||||||
@@ -115,6 +130,7 @@ export default async function AdminPage() {
|
|||||||
prompt: {
|
prompt: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
slug: true,
|
||||||
title: true,
|
title: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -232,6 +248,8 @@ export default async function AdminPage() {
|
|||||||
aiSearchEnabled={aiSearchEnabled}
|
aiSearchEnabled={aiSearchEnabled}
|
||||||
promptsWithoutEmbeddings={promptsWithoutEmbeddings}
|
promptsWithoutEmbeddings={promptsWithoutEmbeddings}
|
||||||
totalPublicPrompts={totalPublicPrompts}
|
totalPublicPrompts={totalPublicPrompts}
|
||||||
|
promptsWithoutSlugs={promptsWithoutSlugs}
|
||||||
|
totalPrompts={totalPrompts}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|||||||
@@ -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 { auth } from "@/lib/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { generatePromptEmbedding } from "@/lib/ai/embeddings";
|
import { generatePromptEmbedding } from "@/lib/ai/embeddings";
|
||||||
|
import { generatePromptSlug } from "@/lib/slug";
|
||||||
|
|
||||||
const updatePromptSchema = z.object({
|
const updatePromptSchema = z.object({
|
||||||
title: z.string().min(1).max(200).optional(),
|
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
|
// Convert empty strings to null for optional foreign keys
|
||||||
const cleanedData = {
|
const cleanedData = {
|
||||||
...data,
|
...data,
|
||||||
|
...(title && { title }),
|
||||||
|
...(newSlug && { slug: newSlug }),
|
||||||
...(categoryId !== undefined && { categoryId: categoryId || null }),
|
...(categoryId !== undefined && { categoryId: categoryId || null }),
|
||||||
...(mediaUrl !== undefined && { mediaUrl: mediaUrl || null }),
|
...(mediaUrl !== undefined && { mediaUrl: mediaUrl || null }),
|
||||||
};
|
};
|
||||||
@@ -187,7 +196,7 @@ export async function PATCH(
|
|||||||
|
|
||||||
// Regenerate embedding if content, title, or description changed (non-blocking)
|
// Regenerate embedding if content, title, or description changed (non-blocking)
|
||||||
// Only for public prompts - the function checks if aiSearch is enabled
|
// 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) {
|
if (contentChanged && !prompt.isPrivate) {
|
||||||
generatePromptEmbedding(id).catch((err) =>
|
generatePromptEmbedding(id).catch((err) =>
|
||||||
console.error("Failed to regenerate embedding for prompt:", id, 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 { db } from "@/lib/db";
|
||||||
import { triggerWebhooks } from "@/lib/webhook";
|
import { triggerWebhooks } from "@/lib/webhook";
|
||||||
import { generatePromptEmbedding } from "@/lib/ai/embeddings";
|
import { generatePromptEmbedding } from "@/lib/ai/embeddings";
|
||||||
|
import { generatePromptSlug } from "@/lib/slug";
|
||||||
|
|
||||||
const promptSchema = z.object({
|
const promptSchema = z.object({
|
||||||
title: z.string().min(1).max(200),
|
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;
|
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
|
// Create prompt with tags
|
||||||
const prompt = await db.prompt.create({
|
const prompt = await db.prompt.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
|
slug,
|
||||||
description: description || null,
|
description: description || null,
|
||||||
content,
|
content,
|
||||||
type,
|
type,
|
||||||
|
|||||||
@@ -17,11 +17,23 @@ interface ChangeRequestPageProps {
|
|||||||
params: Promise<{ id: string; changeId: string }>;
|
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) {
|
export default async function ChangeRequestPage({ params }: ChangeRequestPageProps) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const t = await getTranslations("changeRequests");
|
const t = await getTranslations("changeRequests");
|
||||||
const locale = await getLocale();
|
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({
|
const changeRequest = await db.changeRequest.findUnique({
|
||||||
where: { id: changeId },
|
where: { id: changeId },
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ interface NewChangeRequestPageProps {
|
|||||||
params: Promise<{ id: string }>;
|
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) {
|
export default async function NewChangeRequestPage({ params }: NewChangeRequestPageProps) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const t = await getTranslations("changeRequests");
|
const t = await getTranslations("changeRequests");
|
||||||
@@ -19,7 +30,8 @@ export default async function NewChangeRequestPage({ params }: NewChangeRequestP
|
|||||||
redirect("/login");
|
redirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await params;
|
const { id: idParam } = await params;
|
||||||
|
const id = extractPromptId(idParam);
|
||||||
|
|
||||||
const prompt = await db.prompt.findUnique({
|
const prompt = await db.prompt.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|||||||
@@ -10,13 +10,25 @@ interface EditPromptPageProps {
|
|||||||
params: Promise<{ id: string }>;
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: "Edit Prompt",
|
title: "Edit Prompt",
|
||||||
description: "Edit your prompt",
|
description: "Edit your prompt",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function EditPromptPage({ params }: EditPromptPageProps) {
|
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 session = await auth();
|
||||||
const t = await getTranslations("prompts");
|
const t = await getTranslations("prompts");
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,20 @@ const radiusMap: Record<string, number> = {
|
|||||||
lg: 16,
|
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 }> }) {
|
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 config = await getConfig();
|
||||||
const radius = radiusMap[config.theme?.radius || "sm"] || 8;
|
const radius = radiusMap[config.theme?.radius || "sm"] || 8;
|
||||||
const radiusLg = radius * 2; // For larger elements like content box
|
const radiusLg = radius * 2; // For larger elements like content box
|
||||||
|
|||||||
@@ -27,8 +27,22 @@ interface PromptPageProps {
|
|||||||
params: Promise<{ id: string }>;
|
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> {
|
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({
|
const prompt = await db.prompt.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
select: { title: true, description: true },
|
select: { title: true, description: true },
|
||||||
@@ -45,7 +59,8 @@ export async function generateMetadata({ params }: PromptPageProps): Promise<Met
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function PromptPage({ params }: PromptPageProps) {
|
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 session = await auth();
|
||||||
const t = await getTranslations("prompts");
|
const t = await getTranslations("prompts");
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
@@ -38,19 +38,24 @@ interface PromptsManagementProps {
|
|||||||
aiSearchEnabled: boolean;
|
aiSearchEnabled: boolean;
|
||||||
promptsWithoutEmbeddings: number;
|
promptsWithoutEmbeddings: number;
|
||||||
totalPublicPrompts: 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 router = useRouter();
|
||||||
const t = useTranslations("admin");
|
const t = useTranslations("admin");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [generatingSlugs, setGeneratingSlugs] = useState(false);
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||||
const [embeddingResult, setEmbeddingResult] = useState<{ success: number; failed: number } | null>(null);
|
const [embeddingResult, setEmbeddingResult] = useState<{ success: number; failed: number } | null>(null);
|
||||||
const [embeddingProgress, setEmbeddingProgress] = useState<ProgressState | 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 () => {
|
const handleImport = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -165,6 +170,63 @@ export function PromptsManagement({ aiSearchEnabled, promptsWithoutEmbeddings, t
|
|||||||
toast.success(t("prompts.exportSuccess"));
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<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>
|
</div>
|
||||||
|
|
||||||
<AlertDialog open={showConfirm} onOpenChange={setShowConfirm}>
|
<AlertDialog open={showConfirm} onOpenChange={setShowConfirm}>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useTranslations, useLocale } from "next-intl";
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { formatDistanceToNow } from "@/lib/date";
|
import { formatDistanceToNow } from "@/lib/date";
|
||||||
|
import { getPromptUrl } from "@/lib/urls";
|
||||||
import { MoreHorizontal, Check, X, Eye, ExternalLink } from "lucide-react";
|
import { MoreHorizontal, Check, X, Eye, ExternalLink } from "lucide-react";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -34,6 +35,7 @@ interface Report {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
prompt: {
|
prompt: {
|
||||||
id: string;
|
id: string;
|
||||||
|
slug?: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
reporter: {
|
reporter: {
|
||||||
@@ -120,7 +122,7 @@ export function ReportsTable({ reports }: ReportsTableProps) {
|
|||||||
<TableRow key={report.id}>
|
<TableRow key={report.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link
|
<Link
|
||||||
href={`/prompts/${report.prompt.id}`}
|
href={getPromptUrl(report.prompt.id, report.prompt.slug)}
|
||||||
className="font-medium hover:underline flex items-center gap-1"
|
className="font-medium hover:underline flex items-center gap-1"
|
||||||
>
|
>
|
||||||
{report.prompt.title}
|
{report.prompt.title}
|
||||||
@@ -170,7 +172,7 @@ export function ReportsTable({ reports }: ReportsTableProps) {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem asChild>
|
<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" />
|
<Eye className="h-4 w-4 mr-2" />
|
||||||
{t("viewPrompt")}
|
{t("viewPrompt")}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { getPromptUrl } from "@/lib/urls";
|
||||||
|
|
||||||
interface MiniPromptCardProps {
|
interface MiniPromptCardProps {
|
||||||
prompt: {
|
prompt: {
|
||||||
id: string;
|
id: string;
|
||||||
|
slug?: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
contentPreview: string;
|
contentPreview: string;
|
||||||
@@ -17,7 +19,7 @@ interface MiniPromptCardProps {
|
|||||||
export function MiniPromptCard({ prompt }: MiniPromptCardProps) {
|
export function MiniPromptCard({ prompt }: MiniPromptCardProps) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/prompts/${prompt.id}`}
|
href={getPromptUrl(prompt.id, prompt.slug)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="block p-2 border rounded-md hover:bg-accent/50 transition-colors text-xs"
|
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 Image from "next/image";
|
||||||
import { useTranslations, useLocale } from "next-intl";
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
import { formatDistanceToNow } from "@/lib/date";
|
import { formatDistanceToNow } from "@/lib/date";
|
||||||
|
import { getPromptUrl } from "@/lib/urls";
|
||||||
import { ArrowBigUp, Lock, Copy, ImageIcon, Play } from "lucide-react";
|
import { ArrowBigUp, Lock, Copy, ImageIcon, Play } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { CodeView } from "@/components/ui/code-view";
|
import { CodeView } from "@/components/ui/code-view";
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
export interface PromptCardProps {
|
export interface PromptCardProps {
|
||||||
prompt: {
|
prompt: {
|
||||||
id: string;
|
id: string;
|
||||||
|
slug?: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
content: string;
|
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-start justify-between gap-2 mb-2">
|
||||||
<div className="flex items-center gap-1 flex-1 min-w-0">
|
<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" />}
|
{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}
|
{prompt.title}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { CodeEditor, type CodeEditorHandle } from "@/components/ui/code-editor";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { prettifyJson } from "@/lib/format";
|
import { prettifyJson } from "@/lib/format";
|
||||||
import { analyticsPrompt } from "@/lib/analytics";
|
import { analyticsPrompt } from "@/lib/analytics";
|
||||||
|
import { getPromptUrl } from "@/lib/urls";
|
||||||
|
|
||||||
interface MediaFieldProps {
|
interface MediaFieldProps {
|
||||||
form: ReturnType<typeof useForm<PromptFormValues>>;
|
form: ReturnType<typeof useForm<PromptFormValues>>;
|
||||||
@@ -403,7 +404,7 @@ export function PromptForm({ categories, tags, initialData, initialContributors
|
|||||||
analyticsPrompt.create(data.type);
|
analyticsPrompt.create(data.type);
|
||||||
}
|
}
|
||||||
toast.success(isEdit ? t("promptUpdated") : t("promptCreated"));
|
toast.success(isEdit ? t("promptUpdated") : t("promptCreated"));
|
||||||
router.push(`/prompts/${result.id || promptId}`);
|
router.push(getPromptUrl(result.id || promptId, result.slug));
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(tCommon("somethingWentWrong"));
|
toast.error(tCommon("somethingWentWrong"));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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