Refactor for i18n, CMS integration, and project slugs; enhance admin & analytics

Co-authored-by: dennis <dennis@konkol.net>
This commit is contained in:
Cursor Agent
2026-01-12 14:36:10 +00:00
parent 0349c686fa
commit 12245eec8e
55 changed files with 4573 additions and 753 deletions

71
lib/content.ts Normal file
View File

@@ -0,0 +1,71 @@
import { prisma } from "@/lib/prisma";
export async function getSiteSettings() {
return prisma.siteSettings.findUnique({ where: { id: 1 } });
}
export async function getContentByKey(opts: { key: string; locale: string }) {
const { key, locale } = opts;
const page = await prisma.contentPage.findUnique({
where: { key },
include: {
translations: {
where: { locale },
take: 1,
},
},
});
if (page?.translations?.[0]) return page.translations[0];
const settings = await getSiteSettings();
const fallbackLocale = settings?.defaultLocale || "en";
const fallback = await prisma.contentPageTranslation.findFirst({
where: {
page: { key },
locale: fallbackLocale,
},
});
return fallback;
}
export async function upsertContentByKey(opts: {
key: string;
locale: string;
title?: string | null;
slug?: string | null;
content: unknown;
metaDescription?: string | null;
keywords?: string | null;
}) {
const { key, locale, title, slug, content, metaDescription, keywords } = opts;
const page = await prisma.contentPage.upsert({
where: { key },
create: { key, status: "PUBLISHED" },
update: {},
});
return prisma.contentPageTranslation.upsert({
where: { pageId_locale: { pageId: page.id, locale } },
create: {
pageId: page.id,
locale,
title: title ?? undefined,
slug: slug ?? undefined,
content: content as any, // JSON
metaDescription: metaDescription ?? undefined,
keywords: keywords ?? undefined,
},
update: {
title: title ?? undefined,
slug: slug ?? undefined,
content: content as any, // JSON
metaDescription: metaDescription ?? undefined,
keywords: keywords ?? undefined,
},
});
}

View File

@@ -1,4 +1,5 @@
import { PrismaClient } from '@prisma/client';
import { generateUniqueSlug } from './slug';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
@@ -68,9 +69,26 @@ export const projectService = {
// Create new project
async createProject(data: Record<string, unknown>) {
const providedSlug = typeof data.slug === 'string' ? data.slug : undefined;
const providedTitle = typeof data.title === 'string' ? data.title : undefined;
const slug =
providedSlug?.trim() ||
(await generateUniqueSlug({
base: providedTitle || 'project',
isTaken: async (candidate) => {
const existing = await prisma.project.findUnique({
where: { slug: candidate },
select: { id: true },
});
return !!existing;
},
}));
return prisma.project.create({
data: {
...data,
slug,
performance: data.performance || { lighthouse: 0, bundleSize: '0KB', loadTime: '0s' },
analytics: data.analytics || { views: 0, likes: 0, shares: 0 }
} as any // eslint-disable-line @typescript-eslint/no-explicit-any

71
lib/richtext.ts Normal file
View File

@@ -0,0 +1,71 @@
import sanitizeHtml from "sanitize-html";
import type { JSONContent } from "@tiptap/react";
import { generateHTML } from "@tiptap/html";
import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline";
import Link from "@tiptap/extension-link";
import { TextStyle } from "@tiptap/extension-text-style";
import Color from "@tiptap/extension-color";
import Highlight from "@tiptap/extension-highlight";
import { FontFamily } from "@/lib/tiptap/fontFamily";
export function richTextToSafeHtml(doc: JSONContent): string {
const raw = generateHTML(doc, [
StarterKit,
Underline,
Link.configure({
openOnClick: false,
autolink: false,
HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" },
}),
TextStyle,
FontFamily,
Color,
Highlight,
]);
return sanitizeHtml(raw, {
allowedTags: [
"p",
"br",
"h1",
"h2",
"h3",
"blockquote",
"strong",
"em",
"u",
"a",
"ul",
"ol",
"li",
"code",
"pre",
"span"
],
allowedAttributes: {
a: ["href", "rel", "target"],
span: ["style"],
code: ["class"],
pre: ["class"],
p: ["class"],
h1: ["class"],
h2: ["class"],
h3: ["class"],
blockquote: ["class"],
ul: ["class"],
ol: ["class"],
li: ["class"]
},
allowedSchemes: ["http", "https", "mailto"],
allowProtocolRelative: false,
allowedStyles: {
span: {
color: [/^#[0-9a-fA-F]{3,8}$/],
"background-color": [/^#[0-9a-fA-F]{3,8}$/],
"font-family": [/^(Inter|ui-sans-serif|ui-serif|ui-monospace)$/],
},
},
});
}

30
lib/slug.ts Normal file
View File

@@ -0,0 +1,30 @@
export function slugify(input: string): string {
return input
.trim()
.toLowerCase()
.replace(/['"]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export async function generateUniqueSlug(opts: {
base: string;
isTaken: (slug: string) => Promise<boolean>;
maxAttempts?: number;
}): Promise<string> {
const maxAttempts = opts.maxAttempts ?? 50;
const normalizedBase = slugify(opts.base) || "item";
let candidate = normalizedBase;
for (let i = 0; i < maxAttempts; i++) {
// First try the base, then base-2, base-3, ...
candidate = i === 0 ? normalizedBase : `${normalizedBase}-${i + 1}`;
// eslint-disable-next-line no-await-in-loop
const taken = await opts.isTaken(candidate);
if (!taken) return candidate;
}
// Last resort: append timestamp to avoid collisions
return `${normalizedBase}-${Date.now()}`;
}

67
lib/tiptap/fontFamily.ts Normal file
View File

@@ -0,0 +1,67 @@
import { Extension } from "@tiptap/core";
const allowedFonts = [
"Inter",
"ui-sans-serif",
"ui-serif",
"ui-monospace",
] as const;
export type AllowedFontFamily = (typeof allowedFonts)[number];
declare module "@tiptap/core" {
interface Commands<ReturnType> {
fontFamily: {
setFontFamily: (fontFamily: string) => ReturnType;
unsetFontFamily: () => ReturnType;
};
}
}
export const FontFamily = Extension.create({
name: "fontFamily",
addGlobalAttributes() {
return [
{
types: ["textStyle"],
attributes: {
fontFamily: {
default: null,
parseHTML: (element) => {
const raw = (element as HTMLElement).style.fontFamily;
if (!raw) return null;
// Normalize: remove quotes and take first family only
const first = raw.split(",")[0]?.trim().replace(/^["']|["']$/g, "");
if (!first) return null;
return first;
},
renderHTML: (attributes) => {
const fontFamily = attributes.fontFamily as string | null;
if (!fontFamily) return {};
if (!allowedFonts.includes(fontFamily as AllowedFontFamily)) return {};
return { style: `font-family: ${fontFamily}` };
},
},
},
},
];
},
addCommands() {
return {
setFontFamily:
(fontFamily: string) =>
({ chain }) => {
if (!allowedFonts.includes(fontFamily as AllowedFontFamily)) return false;
return chain().setMark("textStyle", { fontFamily }).run();
},
unsetFontFamily:
() =>
({ chain }) => {
return chain().setMark("textStyle", { fontFamily: null }).removeEmptyTextStyle().run();
},
};
},
});