Refactor for i18n, CMS integration, and project slugs; enhance admin & analytics
Co-authored-by: dennis <dennis@konkol.net>
This commit is contained in:
71
lib/content.ts
Normal file
71
lib/content.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
71
lib/richtext.ts
Normal 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
30
lib/slug.ts
Normal 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
67
lib/tiptap/fontFamily.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user