Refactor for i18n, CMS integration, and project slugs; enhance admin & analytics
Co-authored-by: dennis <dennis@konkol.net>
This commit is contained in:
@@ -3,6 +3,7 @@ import { prisma } from '@/lib/prisma';
|
||||
import { apiCache } from '@/lib/cache';
|
||||
import { checkRateLimit, getRateLimitHeaders, requireSessionAuth } from '@/lib/auth';
|
||||
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
|
||||
import { generateUniqueSlug } from '@/lib/slug';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
@@ -88,12 +89,37 @@ export async function PUT(
|
||||
const data = await request.json();
|
||||
|
||||
// Remove difficulty field if it exists (since we're removing it)
|
||||
const { difficulty, ...projectData } = data;
|
||||
const { difficulty, slug, defaultLocale, ...projectData } = data;
|
||||
|
||||
// Keep slug stable by default; only update if explicitly provided,
|
||||
// or if the project currently has no slug (e.g. after migration).
|
||||
const existing = await prisma.project.findUnique({
|
||||
where: { id },
|
||||
select: { slug: true, title: true },
|
||||
});
|
||||
|
||||
const nextSlug =
|
||||
typeof slug === 'string' && slug.trim()
|
||||
? slug.trim()
|
||||
: existing?.slug?.trim()
|
||||
? existing.slug
|
||||
: await generateUniqueSlug({
|
||||
base: String(projectData.title || existing?.title || 'project'),
|
||||
isTaken: async (candidate) => {
|
||||
const found = await prisma.project.findUnique({
|
||||
where: { slug: candidate },
|
||||
select: { id: true },
|
||||
});
|
||||
return !!found && found.id !== id;
|
||||
},
|
||||
});
|
||||
|
||||
const project = await prisma.project.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...projectData,
|
||||
slug: nextSlug,
|
||||
defaultLocale: typeof defaultLocale === 'string' && defaultLocale ? defaultLocale : undefined,
|
||||
updatedAt: new Date(),
|
||||
// Keep existing difficulty if not provided
|
||||
...(difficulty ? { difficulty } : {})
|
||||
|
||||
71
app/api/projects/[id]/translation/route.ts
Normal file
71
app/api/projects/[id]/translation/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSessionAuth } from "@/lib/auth";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const isAdminRequest = request.headers.get("x-admin-request") === "true";
|
||||
if (!isAdminRequest) return NextResponse.json({ error: "Admin access required" }, { status: 403 });
|
||||
const authError = requireSessionAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const { id: idParam } = await params;
|
||||
const id = parseInt(idParam, 10);
|
||||
if (!Number.isFinite(id)) return NextResponse.json({ error: "Invalid project id" }, { status: 400 });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const locale = searchParams.get("locale") || "en";
|
||||
|
||||
const translation = await prisma.projectTranslation.findFirst({
|
||||
where: { projectId: id, locale },
|
||||
});
|
||||
|
||||
return NextResponse.json({ translation });
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const isAdminRequest = request.headers.get("x-admin-request") === "true";
|
||||
if (!isAdminRequest) return NextResponse.json({ error: "Admin access required" }, { status: 403 });
|
||||
const authError = requireSessionAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const { id: idParam } = await params;
|
||||
const id = parseInt(idParam, 10);
|
||||
if (!Number.isFinite(id)) return NextResponse.json({ error: "Invalid project id" }, { status: 400 });
|
||||
|
||||
const body = (await request.json()) as {
|
||||
locale?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const locale = body.locale || "en";
|
||||
const title = body.title?.trim();
|
||||
const description = body.description?.trim();
|
||||
|
||||
if (!title || !description) {
|
||||
return NextResponse.json({ error: "title and description are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const saved = await prisma.projectTranslation.upsert({
|
||||
where: { projectId_locale: { projectId: id, locale } },
|
||||
create: {
|
||||
projectId: id,
|
||||
locale,
|
||||
title,
|
||||
description,
|
||||
},
|
||||
update: {
|
||||
title,
|
||||
description,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ translation: saved });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { prisma } from '@/lib/prisma';
|
||||
import { apiCache } from '@/lib/cache';
|
||||
import { requireSessionAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
|
||||
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
|
||||
import { generateUniqueSlug } from '@/lib/slug';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -154,11 +155,27 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Remove difficulty field if it exists (since we're removing it)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { difficulty, ...projectData } = data;
|
||||
const { difficulty, slug, defaultLocale, ...projectData } = data;
|
||||
|
||||
const derivedSlug =
|
||||
typeof slug === 'string' && slug.trim()
|
||||
? slug.trim()
|
||||
: await generateUniqueSlug({
|
||||
base: String(projectData.title || 'project'),
|
||||
isTaken: async (candidate) => {
|
||||
const existing = await prisma.project.findUnique({
|
||||
where: { slug: candidate },
|
||||
select: { id: true },
|
||||
});
|
||||
return !!existing;
|
||||
},
|
||||
});
|
||||
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
...projectData,
|
||||
slug: derivedSlug,
|
||||
defaultLocale: typeof defaultLocale === 'string' && defaultLocale ? defaultLocale : undefined,
|
||||
// Set default difficulty since it's required in schema
|
||||
difficulty: 'INTERMEDIATE',
|
||||
performance: data.performance || { lighthouse: 0, bundleSize: '0KB', loadTime: '0s' },
|
||||
|
||||
@@ -9,28 +9,15 @@ export async function GET(request: NextRequest) {
|
||||
const category = searchParams.get('category');
|
||||
|
||||
if (slug) {
|
||||
// Search by slug (convert title to slug format)
|
||||
const projects = await prisma.project.findMany({
|
||||
const project = await prisma.project.findFirst({
|
||||
where: {
|
||||
published: true
|
||||
published: true,
|
||||
slug,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// Find exact match by converting titles to slugs
|
||||
const foundProject = projects.find(project => {
|
||||
const projectSlug = project.title.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return projectSlug === slug;
|
||||
});
|
||||
|
||||
if (foundProject) {
|
||||
return NextResponse.json({ projects: [foundProject] });
|
||||
}
|
||||
|
||||
// If no exact match, return empty array
|
||||
return NextResponse.json({ projects: [] });
|
||||
return NextResponse.json({ projects: project ? [project] : [] });
|
||||
}
|
||||
|
||||
if (search) {
|
||||
|
||||
Reference in New Issue
Block a user