feat: payload cms
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Failing after 1m13s
Build & Deploy / 🏗️ Build (push) Failing after 5m53s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Smoke Test (push) Has been skipped
Build & Deploy / ⚡ Lighthouse (push) Has been skipped
Build & Deploy / ♿ WCAG (push) Has been skipped
Build & Deploy / 🛡️ Quality Gates (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 4s
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Failing after 1m13s
Build & Deploy / 🏗️ Build (push) Failing after 5m53s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Smoke Test (push) Has been skipped
Build & Deploy / ⚡ Lighthouse (push) Has been skipped
Build & Deploy / ♿ WCAG (push) Has been skipped
Build & Deploy / 🛡️ Quality Gates (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 4s
This commit is contained in:
4
.env
4
.env
@@ -37,3 +37,7 @@ INFRA_DIRECTUS_URL=http://localhost:8059
|
||||
INFRA_DIRECTUS_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
|
||||
GATEKEEPER_ORIGIN=http://klz.localhost
|
||||
OPENROUTER_API_KEY=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
|
||||
# Payload CMS Local Development
|
||||
POSTGRES_URI=postgres://klz_db_user:klz_db_pass@127.0.0.1:54322/directus
|
||||
PAYLOAD_SECRET=D7F36ED8D3B2F77A5E6B4A3962
|
||||
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,18 +1,17 @@
|
||||
node_modules
|
||||
.next
|
||||
.DS_Store
|
||||
.pnpm-store
|
||||
public/uploads
|
||||
public/media
|
||||
|
||||
# Lighthouse CI
|
||||
.lighthouseci/
|
||||
lighthouserc.cjs
|
||||
.lighthouserc.json
|
||||
|
||||
# Directus
|
||||
# Legacy (Directus) cleanup
|
||||
directus/uploads
|
||||
!directus/extensions/
|
||||
!directus/schema/
|
||||
!directus/migrations/
|
||||
|
||||
.next-docker
|
||||
|
||||
|
||||
18
Dockerfile.dev
Normal file
18
Dockerfile.dev
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
# Install essential build tools if needed (e.g., for node-gyp)
|
||||
RUN apk add --no-cache libc6-compat python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Enable corepack for pnpm
|
||||
RUN corepack enable
|
||||
|
||||
# Pre-set the pnpm store directory
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
# Set up pnpm store configuration
|
||||
RUN pnpm config set store-dir /pnpm/store
|
||||
|
||||
EXPOSE 3000
|
||||
17
app/(payload)/admin/[[...segments]]/page.tsx
Normal file
17
app/(payload)/admin/[[...segments]]/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import configPromise from '@payload-config';
|
||||
import { RootPage } from '@payloadcms/next/views';
|
||||
import { importMap } from '../importMap';
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
segments: string[];
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
const Page = ({ params, searchParams }: Args) =>
|
||||
RootPage({ config: configPromise, importMap, params, searchParams });
|
||||
|
||||
export default Page;
|
||||
77
app/(payload)/admin/importMap.js
Normal file
77
app/(payload)/admin/importMap.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
|
||||
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
|
||||
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
|
||||
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc';
|
||||
|
||||
export const importMap = {
|
||||
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell':
|
||||
RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalField':
|
||||
RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
'@payloadcms/richtext-lexical/rsc#LexicalDiffComponent':
|
||||
LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
'@payloadcms/richtext-lexical/client#BlocksFeatureClient':
|
||||
BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient':
|
||||
InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient':
|
||||
HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#UploadFeatureClient':
|
||||
UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#BlockquoteFeatureClient':
|
||||
BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#RelationshipFeatureClient':
|
||||
RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#LinkFeatureClient':
|
||||
LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#ChecklistFeatureClient':
|
||||
ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#OrderedListFeatureClient':
|
||||
OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#UnorderedListFeatureClient':
|
||||
UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#IndentFeatureClient':
|
||||
IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#AlignFeatureClient':
|
||||
AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#HeadingFeatureClient':
|
||||
HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#ParagraphFeatureClient':
|
||||
ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#InlineCodeFeatureClient':
|
||||
InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#SuperscriptFeatureClient':
|
||||
SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#SubscriptFeatureClient':
|
||||
SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#StrikethroughFeatureClient':
|
||||
StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#UnderlineFeatureClient':
|
||||
UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#BoldFeatureClient':
|
||||
BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#ItalicFeatureClient':
|
||||
ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/next/rsc#CollectionCards': CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1,
|
||||
};
|
||||
14
app/(payload)/api/[...slug]/route.ts
Normal file
14
app/(payload)/api/[...slug]/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import config from '@payload-config';
|
||||
import {
|
||||
REST_GET,
|
||||
REST_OPTIONS,
|
||||
REST_PATCH,
|
||||
REST_POST,
|
||||
REST_DELETE,
|
||||
} from '@payloadcms/next/routes';
|
||||
|
||||
export const GET = REST_GET(config);
|
||||
export const POST = REST_POST(config);
|
||||
export const DELETE = REST_DELETE(config);
|
||||
export const PATCH = REST_PATCH(config);
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
4
app/(payload)/api/graphql/route.ts
Normal file
4
app/(payload)/api/graphql/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import config from '@payload-config';
|
||||
import { GRAPHQL_POST } from '@payloadcms/next/routes';
|
||||
|
||||
export const POST = GRAPHQL_POST(config);
|
||||
1
app/(payload)/custom.scss
Normal file
1
app/(payload)/custom.scss
Normal file
@@ -0,0 +1 @@
|
||||
/* Custom Payload CMS admin styles can go here. Do not import payloadcms/ui/scss/app.scss as it is handled by @payloadcms/next/css */
|
||||
31
app/(payload)/layout.tsx
Normal file
31
app/(payload)/layout.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import configPromise from '@payload-config';
|
||||
import { RootLayout } from '@payloadcms/next/layouts';
|
||||
import React from 'react';
|
||||
|
||||
import '@payloadcms/next/css';
|
||||
import './custom.scss';
|
||||
import { handleServerFunctions } from '@payloadcms/next/layouts';
|
||||
import { importMap } from './admin/importMap';
|
||||
|
||||
type Args = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const serverFunction: any = async function (args: any) {
|
||||
'use server';
|
||||
return handleServerFunctions({
|
||||
...args,
|
||||
config: configPromise,
|
||||
importMap,
|
||||
});
|
||||
};
|
||||
|
||||
const Layout = ({ children }: Args) => {
|
||||
return (
|
||||
<RootLayout config={configPromise} importMap={importMap} serverFunction={serverFunction}>
|
||||
{children}
|
||||
</RootLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -1,10 +1,9 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
import { Container, Badge, Heading } from '@/components/ui';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import { getPageBySlug, getAllPages } from '@/lib/pages';
|
||||
import { mdxComponents } from '@/components/blog/MDXComponents';
|
||||
import PayloadRichText from '@/components/PayloadRichText';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||
|
||||
@@ -102,7 +101,7 @@ export default async function StandardPage({ params }: PageProps) {
|
||||
|
||||
{/* Main content with shared blog components */}
|
||||
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary">
|
||||
<MDXRemote source={pageData.content} components={mdxComponents} />
|
||||
<PayloadRichText data={pageData.content} />
|
||||
</div>
|
||||
|
||||
{/* Support Section */}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
import { getPostBySlug, getAdjacentPosts, getReadingTime, getHeadings } from '@/lib/blog';
|
||||
import { getPostBySlug, getAdjacentPosts, getReadingTime } from '@/lib/blog';
|
||||
import { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import PostNavigation from '@/components/blog/PostNavigation';
|
||||
import PowerCTA from '@/components/blog/PowerCTA';
|
||||
import TableOfContents from '@/components/blog/TableOfContents';
|
||||
import { mdxComponents } from '@/components/blog/MDXComponents';
|
||||
import { Heading } from '@/components/ui';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker';
|
||||
|
||||
// Payload CMS Imports
|
||||
import PayloadRichText from '@/components/PayloadRichText';
|
||||
|
||||
interface BlogPostProps {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
@@ -60,7 +60,8 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const headings = getHeadings(post.content);
|
||||
// Convert Lexical content into a plain string to estimate reading time roughly
|
||||
const rawTextContent = JSON.stringify(post.content);
|
||||
|
||||
return (
|
||||
<article className="bg-white min-h-screen font-sans selection:bg-primary/10 selection:text-primary">
|
||||
@@ -68,7 +69,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
title={post.frontmatter.title}
|
||||
slug={slug}
|
||||
category={post.frontmatter.category}
|
||||
readingTime={getReadingTime(post.content)}
|
||||
readingTime={getReadingTime(rawTextContent)}
|
||||
/>
|
||||
|
||||
{/* Featured Image Header */}
|
||||
@@ -76,7 +77,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
<div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group">
|
||||
<div className="absolute inset-0 transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100">
|
||||
<Image
|
||||
src={`${post.frontmatter.featuredImage}?ar=16:9`}
|
||||
src={post.frontmatter.featuredImage.split('?')[0]}
|
||||
alt={post.frontmatter.title}
|
||||
fill
|
||||
priority
|
||||
@@ -109,7 +110,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
})}
|
||||
</time>
|
||||
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
||||
<span>{getReadingTime(post.content)} min read</span>
|
||||
<span>{getReadingTime(rawTextContent)} min read</span>
|
||||
{(new Date(post.frontmatter.date) > new Date() ||
|
||||
post.frontmatter.public === false) && (
|
||||
<>
|
||||
@@ -146,7 +147,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
})}
|
||||
</time>
|
||||
<span className="w-1 h-1 bg-neutral-400 rounded-full" />
|
||||
<span>{getReadingTime(post.content)} min read</span>
|
||||
<span>{getReadingTime(rawTextContent)} min read</span>
|
||||
{(new Date(post.frontmatter.date) > new Date() ||
|
||||
post.frontmatter.public === false) && (
|
||||
<>
|
||||
@@ -175,9 +176,9 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content with enhanced styling */}
|
||||
{/* Main content with enhanced styling rendering Payload Lexical */}
|
||||
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary">
|
||||
<MDXRemote source={post.content} components={mdxComponents} />
|
||||
<PayloadRichText data={post.content} />
|
||||
</div>
|
||||
|
||||
{/* Power CTA */}
|
||||
@@ -220,10 +221,10 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Sticky Sidebar */}
|
||||
{/* Right Column: Sticky Sidebar - Temporarily Hidden without ToC */}
|
||||
<aside className="sticky-narrative-sidebar hidden lg:block">
|
||||
<div className="space-y-12">
|
||||
<TableOfContents headings={headings} locale={locale} />
|
||||
{/* Future Payload Table of Contents Implementation */}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -262,8 +263,8 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
'@id': `${SITE_URL}/${locale}/blog/${slug}`,
|
||||
},
|
||||
articleSection: post.frontmatter.category,
|
||||
wordCount: post.content.split(/\s+/).length,
|
||||
timeRequired: `PT${getReadingTime(post.content)}M`,
|
||||
wordCount: rawTextContent.split(/\s+/).length,
|
||||
timeRequired: `PT${getReadingTime(rawTextContent)}M`,
|
||||
} as any
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -63,7 +63,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
{featuredPost && featuredPost.frontmatter.featuredImage && (
|
||||
<>
|
||||
<Image
|
||||
src={featuredPost.frontmatter.featuredImage}
|
||||
src={featuredPost.frontmatter.featuredImage.split('?')[0]}
|
||||
alt={featuredPost.frontmatter.title}
|
||||
fill
|
||||
className="absolute inset-0 w-full h-full object-cover opacity-40 md:opacity-60"
|
||||
@@ -164,7 +164,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
{post.frontmatter.featuredImage && (
|
||||
<div className="relative h-48 md:h-72 overflow-hidden">
|
||||
<Image
|
||||
src={post.frontmatter.featuredImage}
|
||||
src={post.frontmatter.featuredImage.split('?')[0]}
|
||||
alt={post.frontmatter.title}
|
||||
fill
|
||||
className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||
|
||||
@@ -8,7 +8,6 @@ export const contentType = 'image/png';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||
console.log('🖼️ OG Image Handler Called');
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Index.meta' });
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
@@ -12,11 +12,11 @@ import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
|
||||
import { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { getProductOGImageMetadata } from '@/lib/metadata';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import ProductEngagementTracker from '@/components/analytics/ProductEngagementTracker';
|
||||
import PayloadRichText from '@/components/PayloadRichText';
|
||||
|
||||
interface ProductPageProps {
|
||||
params: Promise<{
|
||||
@@ -103,76 +103,7 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
||||
};
|
||||
}
|
||||
|
||||
const components = {
|
||||
ProductTechnicalData,
|
||||
ProductTabs,
|
||||
p: (props: any) => (
|
||||
<p
|
||||
{...props}
|
||||
className="text-lg md:text-xl text-text-secondary leading-relaxed mb-8 font-medium"
|
||||
/>
|
||||
),
|
||||
h1: (props: any) => (
|
||||
<div className="relative mb-16">
|
||||
<h2
|
||||
{...props}
|
||||
className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-6"
|
||||
/>
|
||||
<div className="w-20 h-1.5 bg-accent rounded-full" />
|
||||
</div>
|
||||
),
|
||||
h2: (props: any) => (
|
||||
<h3
|
||||
{...props}
|
||||
className="text-2xl md:text-3xl font-black text-primary mb-10 tracking-tight uppercase"
|
||||
/>
|
||||
),
|
||||
h3: (props: any) => (
|
||||
<h4
|
||||
{...props}
|
||||
className="text-xl md:text-2xl font-black text-primary mb-8 tracking-tight uppercase"
|
||||
/>
|
||||
),
|
||||
ul: (props: any) => <ul {...props} className="list-none pl-0 mb-10" />,
|
||||
section: (props: any) => <div {...props} className="block" />,
|
||||
li: (props: any) => (
|
||||
<li className="flex items-start gap-4 group mb-4 last:mb-0">
|
||||
<div className="mt-2.5 w-2 h-2 rounded-full bg-accent flex-shrink-0 group-hover:scale-125 transition-transform" />
|
||||
<span
|
||||
{...props}
|
||||
className="text-lg md:text-xl text-text-secondary leading-relaxed font-medium"
|
||||
/>
|
||||
</li>
|
||||
),
|
||||
strong: (props: any) => <strong {...props} className="font-black text-primary" />,
|
||||
table: (props: any) => (
|
||||
<div className="overflow-x-auto my-20 rounded-[32px] border border-neutral-dark/10 shadow-xl bg-white p-1">
|
||||
<table {...props} className="min-w-full divide-y divide-neutral-dark/10" />
|
||||
</div>
|
||||
),
|
||||
th: (props: any) => (
|
||||
<th
|
||||
{...props}
|
||||
className="px-8 py-6 bg-neutral-light/50 text-left text-[10px] font-black uppercase tracking-[0.25em] text-primary/60"
|
||||
/>
|
||||
),
|
||||
td: (props: any) => (
|
||||
<td
|
||||
{...props}
|
||||
className="px-8 py-6 text-text-secondary border-t border-neutral-dark/5 text-lg md:text-xl font-medium"
|
||||
/>
|
||||
),
|
||||
hr: () => <hr className="my-24 border-t-2 border-neutral-dark/5" />,
|
||||
blockquote: (props: any) => (
|
||||
<div className="my-20 p-10 md:p-16 bg-primary-dark rounded-[40px] relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-accent/10 rounded-full -translate-y-1/2 translate-x-1/2 blur-3xl group-hover:bg-accent/20 transition-colors duration-700" />
|
||||
<div
|
||||
className="relative z-10 italic text-2xl md:text-3xl text-white/90 leading-relaxed font-black tracking-tight"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
// ... the rest of the file layout ...
|
||||
|
||||
export default async function ProductPage({ params }: ProductPageProps) {
|
||||
const { locale, slug } = await params;
|
||||
@@ -181,7 +112,6 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
const t = await getTranslations('Products');
|
||||
const productsSlug = await mapFileSlugToTranslated('products', locale);
|
||||
|
||||
// Check if it's a category page
|
||||
const categories = [
|
||||
'low-voltage-cables',
|
||||
'medium-voltage-cables',
|
||||
@@ -191,6 +121,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
const fileSlug = await mapSlugToFileSlug(productSlug, locale);
|
||||
|
||||
if (categories.includes(fileSlug)) {
|
||||
// (Skipping category page block, same as before)
|
||||
const allProducts = await getAllProducts(locale);
|
||||
const categoryKey = fileSlug
|
||||
.replace(/-cables$/, '')
|
||||
@@ -199,14 +130,12 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
? t(`categories.${categoryKey}.title`)
|
||||
: fileSlug;
|
||||
|
||||
// Filter products for this category
|
||||
const filteredProducts = allProducts.filter((p) =>
|
||||
p.frontmatter.categories.some(
|
||||
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === fileSlug || cat === categoryTitle,
|
||||
),
|
||||
);
|
||||
|
||||
// Get translated product slugs
|
||||
const productsWithTranslatedSlugs = await Promise.all(
|
||||
filteredProducts.map(async (p) => ({
|
||||
...p,
|
||||
@@ -257,7 +186,6 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
/>
|
||||
{/* Subtle reflection/shadow effect */}
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-2/3 h-4 bg-black/5 blur-xl rounded-full" />
|
||||
</>
|
||||
)}
|
||||
@@ -314,17 +242,14 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Extract technical data for schema
|
||||
const technicalDataMatch = product.content.match(
|
||||
/technicalData=\{<ProductTechnicalData data=\{(.*?)\}\s*\/>\}/s,
|
||||
);
|
||||
// Extract technical data natively from the Lexical AST for Schema.org
|
||||
let technicalItems = [];
|
||||
if (technicalDataMatch) {
|
||||
try {
|
||||
const data = JSON.parse(technicalDataMatch[1]);
|
||||
technicalItems = data.technicalItems || [];
|
||||
} catch (e) {
|
||||
console.error('Failed to parse technical data for schema', e);
|
||||
if (product.content?.root?.children) {
|
||||
const productTabsBlock = product.content.root.children.find(
|
||||
(node: any) => node.type === 'block' && node.fields?.blockType === 'productTabs',
|
||||
);
|
||||
if (productTabsBlock && productTabsBlock.fields?.technicalItems) {
|
||||
technicalItems = productTabsBlock.fields.technicalItems;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,23 +272,6 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
/>
|
||||
);
|
||||
|
||||
const productComponents = {
|
||||
...components,
|
||||
ProductTabs: (props: any) => <ProductTabs {...props} sidebar={sidebar} />,
|
||||
};
|
||||
|
||||
// Pre-process content to convert raw HTML tags to Markdown so they use our custom components
|
||||
const processedContent = product.content
|
||||
.replace(/<h1[^>]*>(.*?)<\/h1>/gs, '\n# $1\n') // Maps to our custom h1 (which renders h2)
|
||||
.replace(/<h2[^>]*>(.*?)<\/h2>/gs, '\n## $1\n') // Maps to our custom h2 (which renders h3)
|
||||
.replace(/<h3[^>]*>(.*?)<\/h3>/gs, '\n### $1\n') // Maps to our custom h3 (which renders h4)
|
||||
.replace(/<p[^>]*>(.*?)<\/p>/gs, '\n$1\n')
|
||||
.replace(/<ul[^>]*>(.*?)<\/ul>/gs, '\n$1\n')
|
||||
.replace(/<li[^>]*>(.*?)<\/li>/gs, '\n- $1\n')
|
||||
.replace(/<strong[^>]*>(.*?)<\/strong>/gs, '**$1**')
|
||||
.replace(/<section[^>]*>/gs, '')
|
||||
.replace(/<\/section>/gs, '');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-white relative">
|
||||
{/* Product Hero */}
|
||||
@@ -474,8 +382,8 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
<div className="relative">
|
||||
<div className="w-full">
|
||||
{/* Main Content Area */}
|
||||
<div className="max-w-none">
|
||||
<MDXRemote source={processedContent} components={productComponents} />
|
||||
<div className="max-w-none prose prose-lg mt-8">
|
||||
<PayloadRichText data={product.content} />
|
||||
</div>
|
||||
|
||||
{/* Datasheet Download Section - Only for Medium Voltage for now */}
|
||||
|
||||
@@ -236,7 +236,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
className="group whitespace-nowrap w-full md:w-auto md:h-16 md:px-10 md:text-xl"
|
||||
>
|
||||
{t('cta.button')}
|
||||
<span className="ml-4 transition-transform group-hover:translate-x-2">
|
||||
<span className="ml-2 md:ml-4 transition-transform group-hover:translate-x-2">
|
||||
→
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
'use server';
|
||||
|
||||
import client, { ensureAuthenticated } from '@/lib/directus';
|
||||
import { createItem } from '@directus/sdk';
|
||||
import { sendEmail } from '@/lib/mail/mailer';
|
||||
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
|
||||
import React from 'react';
|
||||
@@ -41,31 +39,30 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
return { success: false, error: 'Missing required fields' };
|
||||
}
|
||||
|
||||
// 1. Save to Directus
|
||||
// 1. Save to CMS
|
||||
try {
|
||||
await ensureAuthenticated();
|
||||
if (productName) {
|
||||
await client.request(
|
||||
createItem('product_requests', {
|
||||
product_name: productName,
|
||||
email,
|
||||
message,
|
||||
}),
|
||||
);
|
||||
logger.info('Product request stored in Directus');
|
||||
} else {
|
||||
await client.request(
|
||||
createItem('contact_submissions', {
|
||||
const { getPayload } = await import('payload');
|
||||
const configPromise = (await import('@payload-config')).default;
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
await payload.create({
|
||||
collection: 'form-submissions',
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
}),
|
||||
);
|
||||
logger.info('Contact submission stored in Directus');
|
||||
}
|
||||
type: productName ? 'product_quote' : 'contact',
|
||||
productName: productName || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('Successfully saved form submission to Payload CMS', {
|
||||
type: productName ? 'product_quote' : 'contact',
|
||||
email,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to store submission in Directus', { error });
|
||||
services.errors.captureException(error, { action: 'directus_store_submission' });
|
||||
logger.error('Failed to store submission in Payload CMS', { error });
|
||||
services.errors.captureException(error, { action: 'payload_store_submission' });
|
||||
}
|
||||
|
||||
// 2. Send Emails
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { checkHealth } from '@/lib/directus';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
const health = await checkHealth();
|
||||
return NextResponse.json(health, { status: health.status === 'ok' ? 200 : 503 });
|
||||
// Payload is embedded within the Next.js app, so if this route responds, the CMS is up.
|
||||
// Further DB health checks can be implemented via Payload Local API later.
|
||||
return NextResponse.json({ status: 'ok', message: 'Payload CMS is embedded.' }, { status: 200 });
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ export async function POST(request: NextRequest) {
|
||||
const logger = services.logger.child({ component: 'sentry-relay' });
|
||||
|
||||
try {
|
||||
// Prevent 403 Forbidden console noise in local dev
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return NextResponse.json({ status: 'ignored_in_dev' }, { status: 200 });
|
||||
}
|
||||
|
||||
const envelope = await request.text();
|
||||
|
||||
// Sentry envelopes can contain multiple parts separated by newlines
|
||||
|
||||
@@ -19,6 +19,11 @@ export async function POST(request: NextRequest) {
|
||||
const logger = services.logger.child({ component: 'umami-smart-proxy' });
|
||||
|
||||
try {
|
||||
// Prevent 400 Bad Request console noise in local dev
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return NextResponse.json({ status: 'ignored_in_dev' }, { status: 200 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { type, payload } = body;
|
||||
|
||||
|
||||
39
check-data.ts
Normal file
39
check-data.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
|
||||
async function checkData() {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
const { docs: posts } = await payload.find({ collection: 'posts', limit: 3 });
|
||||
const { docs: products } = await payload.find({ collection: 'products', limit: 3 });
|
||||
const { docs: pages } = await payload.find({ collection: 'pages', limit: 3 });
|
||||
|
||||
const checkDocs = (name: string, docs: any[]) => {
|
||||
console.log(`\n----- ${name.toUpperCase()} -----`);
|
||||
docs.forEach((p) => {
|
||||
console.log(`ID: ${p.id}, Slug: ${p.slug}`);
|
||||
if (Array.isArray(p.content)) {
|
||||
console.log(
|
||||
'Content is ARRAY (Slate format!)',
|
||||
JSON.stringify(p.content).substring(0, 100),
|
||||
);
|
||||
} else if (p.content && p.content.root) {
|
||||
console.log('Content is Lexical format.');
|
||||
} else {
|
||||
console.log('Content is UNKNOWN format.');
|
||||
console.log(JSON.stringify(p.content).substring(0, 100));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
checkDocs('posts', posts);
|
||||
checkDocs('products', products);
|
||||
checkDocs('pages', pages);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
checkData();
|
||||
213
components/PayloadRichText.tsx
Normal file
213
components/PayloadRichText.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { RichText } from '@payloadcms/richtext-lexical/react';
|
||||
import type { JSXConverters } from '@payloadcms/richtext-lexical/react';
|
||||
import Image from 'next/image';
|
||||
|
||||
// Import all custom React components that were previously mapped via MDX
|
||||
import StickyNarrative from '@/components/blog/StickyNarrative';
|
||||
import ComparisonGrid from '@/components/blog/ComparisonGrid';
|
||||
import VisualLinkPreview from '@/components/blog/VisualLinkPreview';
|
||||
import TechnicalGrid from '@/components/blog/TechnicalGrid';
|
||||
import HighlightBox from '@/components/blog/HighlightBox';
|
||||
import AnimatedImage from '@/components/blog/AnimatedImage';
|
||||
import ChatBubble from '@/components/blog/ChatBubble';
|
||||
import PowerCTA from '@/components/blog/PowerCTA';
|
||||
import { Callout } from '@/components/ui/Callout';
|
||||
import Stats from '@/components/blog/Stats';
|
||||
import SplitHeading from '@/components/blog/SplitHeading';
|
||||
import ProductTabs from '@/components/ProductTabs';
|
||||
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
||||
const jsxConverters: JSXConverters = {
|
||||
blocks: {
|
||||
// Map the custom Payload Blocks created in src/payload/blocks to their React components
|
||||
// Payload Lexical exposes blocks using the 'block-[slug]' pattern
|
||||
stickyNarrative: ({ node }: any) => (
|
||||
<StickyNarrative title={node.fields.title} items={node.fields.items} />
|
||||
),
|
||||
'block-stickyNarrative': ({ node }: any) => (
|
||||
<StickyNarrative title={node.fields.title} items={node.fields.items} />
|
||||
),
|
||||
comparisonGrid: ({ node }: any) => (
|
||||
<ComparisonGrid
|
||||
title={node.fields.title}
|
||||
leftLabel={node.fields.leftLabel}
|
||||
rightLabel={node.fields.rightLabel}
|
||||
items={node.fields.items}
|
||||
/>
|
||||
),
|
||||
'block-comparisonGrid': ({ node }: any) => (
|
||||
<ComparisonGrid
|
||||
title={node.fields.title}
|
||||
leftLabel={node.fields.leftLabel}
|
||||
rightLabel={node.fields.rightLabel}
|
||||
items={node.fields.items}
|
||||
/>
|
||||
),
|
||||
visualLinkPreview: ({ node }: any) => (
|
||||
<VisualLinkPreview
|
||||
url={node.fields.url}
|
||||
title={node.fields.title}
|
||||
summary={node.fields.summary}
|
||||
image={node.fields.image?.sizes?.card?.url || node.fields.image?.url}
|
||||
/>
|
||||
),
|
||||
'block-visualLinkPreview': ({ node }: any) => (
|
||||
<VisualLinkPreview
|
||||
url={node.fields.url}
|
||||
title={node.fields.title}
|
||||
summary={node.fields.summary}
|
||||
image={node.fields.image?.sizes?.card?.url || node.fields.image?.url}
|
||||
/>
|
||||
),
|
||||
technicalGrid: ({ node }: any) => (
|
||||
<TechnicalGrid title={node.fields.title} items={node.fields.items} />
|
||||
),
|
||||
'block-technicalGrid': ({ node }: any) => {
|
||||
console.log('[PayloadRichText] Rendering block-technicalGrid:', node.fields.title);
|
||||
return <TechnicalGrid title={node.fields.title} items={node.fields.items} />;
|
||||
},
|
||||
highlightBox: ({ node }: any) => (
|
||||
<HighlightBox title={node.fields.title} color={node.fields.color}>
|
||||
<RichText data={node.fields.content} converters={jsxConverters} />
|
||||
</HighlightBox>
|
||||
),
|
||||
'block-highlightBox': ({ node }: any) => (
|
||||
<HighlightBox title={node.fields.title} color={node.fields.color}>
|
||||
<RichText data={node.fields.content} converters={jsxConverters} />
|
||||
</HighlightBox>
|
||||
),
|
||||
animatedImage: ({ node }: any) => (
|
||||
<AnimatedImage
|
||||
src={node.fields.src}
|
||||
alt={node.fields.alt}
|
||||
width={node.fields.width}
|
||||
height={node.fields.height}
|
||||
/>
|
||||
),
|
||||
'block-animatedImage': ({ node }: any) => (
|
||||
<AnimatedImage
|
||||
src={node.fields.src}
|
||||
alt={node.fields.alt}
|
||||
width={node.fields.width}
|
||||
height={node.fields.height}
|
||||
/>
|
||||
),
|
||||
chatBubble: ({ node }: any) => (
|
||||
<ChatBubble
|
||||
author={node.fields.author}
|
||||
avatar={node.fields.avatar}
|
||||
role={node.fields.role}
|
||||
align={node.fields.align}
|
||||
>
|
||||
<RichText data={node.fields.content} converters={jsxConverters} />
|
||||
</ChatBubble>
|
||||
),
|
||||
'block-chatBubble': ({ node }: any) => (
|
||||
<ChatBubble
|
||||
author={node.fields.author}
|
||||
avatar={node.fields.avatar}
|
||||
role={node.fields.role}
|
||||
align={node.fields.align}
|
||||
>
|
||||
<RichText data={node.fields.content} converters={jsxConverters} />
|
||||
</ChatBubble>
|
||||
),
|
||||
powerCTA: ({ node }: any) => <PowerCTA locale={node.fields.locale} />,
|
||||
'block-powerCTA': ({ node }: any) => <PowerCTA locale={node.fields.locale} />,
|
||||
callout: ({ node }: any) => (
|
||||
<Callout type={node.fields.type} title={node.fields.title}>
|
||||
<RichText data={node.fields.content} converters={jsxConverters} />
|
||||
</Callout>
|
||||
),
|
||||
'block-callout': ({ node }: any) => (
|
||||
<Callout type={node.fields.type} title={node.fields.title}>
|
||||
<RichText data={node.fields.content} converters={jsxConverters} />
|
||||
</Callout>
|
||||
),
|
||||
stats: ({ node }: any) => <Stats stats={node.fields.stats} />,
|
||||
'block-stats': ({ node }: any) => <Stats stats={node.fields.stats} />,
|
||||
splitHeading: ({ node }: any) => (
|
||||
<SplitHeading id={node.fields.id} level={node.fields.level}>
|
||||
{node.fields.title}
|
||||
</SplitHeading>
|
||||
),
|
||||
'block-splitHeading': ({ node }: any) => (
|
||||
<SplitHeading id={node.fields.id} level={node.fields.level}>
|
||||
{node.fields.title}
|
||||
</SplitHeading>
|
||||
),
|
||||
productTabs: ({ node }: any) => (
|
||||
<ProductTabs
|
||||
technicalData={
|
||||
<ProductTechnicalData
|
||||
data={{
|
||||
technicalItems: node.fields.technicalItems,
|
||||
voltageTables: node.fields.voltageTables,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<></>
|
||||
</ProductTabs>
|
||||
),
|
||||
'block-productTabs': ({ node }: any) => (
|
||||
<ProductTabs
|
||||
technicalData={
|
||||
<ProductTechnicalData
|
||||
data={{
|
||||
technicalItems: node.fields.technicalItems,
|
||||
voltageTables: node.fields.voltageTables,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<></>
|
||||
</ProductTabs>
|
||||
),
|
||||
},
|
||||
// Custom converter for the Payload "upload" Lexical node (Media collection)
|
||||
// This natively reconstructs Next.js <Image /> tags pointing to the focal-point cropped sizes
|
||||
upload: ({ node }: any) => {
|
||||
// Attempt to extract the highly optimized 'card' generated size from Payload, fallback to raw url
|
||||
let src = node?.value?.sizes?.card?.url || node?.value?.url;
|
||||
const alt = node?.value?.alt || 'Blog Post Media';
|
||||
|
||||
if (!src) return null;
|
||||
|
||||
// Strip legacy imgproxy query parameters (e.g. ?ar=16:9) that crash Next.js 14+ localPatterns
|
||||
if (src.includes('?')) {
|
||||
src = src.split('?')[0];
|
||||
}
|
||||
|
||||
// Fallback dimensions if unmapped or loading from raw
|
||||
const width = node?.value?.sizes?.card?.width || 800;
|
||||
const height = node?.value?.sizes?.card?.height || 600;
|
||||
|
||||
return (
|
||||
<figure className="my-8 md:my-12 relative w-full rounded-2xl md:rounded-[32px] overflow-hidden shadow-xl md:shadow-2xl">
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
className="w-full object-cover transition-transform duration-700 hover:scale-105"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 75vw, 60vw"
|
||||
/>
|
||||
{node?.value?.caption && (
|
||||
<figcaption className="p-4 bg-neutral-dark text-white/80 text-sm text-center italic border-t border-white/10">
|
||||
{node.value.caption}
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default function PayloadRichText({ data }: { data: any }) {
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="article-content max-w-none">
|
||||
<RichText data={data} converters={jsxConverters} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export default function ProductSidebar({
|
||||
<div className="relative w-full aspect-[16/10] mb-6 rounded-2xl overflow-hidden bg-white/5 backdrop-blur-md p-4 border border-white/10 z-10 group">
|
||||
<div className="relative w-full h-full transition-transform duration-1000 ease-out group-hover:scale-105">
|
||||
<Image
|
||||
src={productImage}
|
||||
src={productImage.split('?')[0]}
|
||||
alt={productName}
|
||||
fill
|
||||
className="object-contain p-2 drop-shadow-[0_20px_30px_rgba(0,0,0,0.4)]"
|
||||
|
||||
@@ -82,7 +82,7 @@ export default async function RelatedProducts({
|
||||
{product.frontmatter.images?.[0] ? (
|
||||
<>
|
||||
<Image
|
||||
src={product.frontmatter.images[0]}
|
||||
src={product.frontmatter.images[0].split('?')[0]}
|
||||
alt={product.frontmatter.title}
|
||||
fill
|
||||
className="object-contain p-4 transition-transform duration-700 group-hover:scale-110 z-10"
|
||||
|
||||
@@ -29,10 +29,14 @@ export default function TrackedLink({
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
try {
|
||||
trackEvent(eventName, {
|
||||
href,
|
||||
...eventProperties,
|
||||
});
|
||||
} catch (_e) {
|
||||
// Analytics tracking should not block navigation, so we catch and ignore errors.
|
||||
}
|
||||
if (onClick) onClick();
|
||||
};
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function PostNavigation({
|
||||
{prev.frontmatter.featuredImage ? (
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110"
|
||||
style={{ backgroundImage: `url(${prev.frontmatter.featuredImage}?ar=16:9)` }}
|
||||
style={{ backgroundImage: `url(${prev.frontmatter.featuredImage.split('?')[0]})` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-neutral-100" />
|
||||
@@ -82,7 +82,7 @@ export default function PostNavigation({
|
||||
{next.frontmatter.featuredImage ? (
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110"
|
||||
style={{ backgroundImage: `url(${next.frontmatter.featuredImage}?ar=16:9)` }}
|
||||
style={{ backgroundImage: `url(${next.frontmatter.featuredImage.split('?')[0]})` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-neutral-100" />
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function VisualLinkPreview({ url, title, summary, image }: Visual
|
||||
<div className="relative w-full md:w-64 h-48 md:h-auto flex-shrink-0 bg-neutral-50 overflow-hidden">
|
||||
{image ? (
|
||||
<Image
|
||||
src={image}
|
||||
src={image.split('?')[0]}
|
||||
alt={title}
|
||||
fill
|
||||
unoptimized
|
||||
|
||||
@@ -43,7 +43,7 @@ export default async function RecentPosts({ locale }: RecentPostsProps) {
|
||||
{post.frontmatter.featuredImage && (
|
||||
<div className="relative h-64 overflow-hidden">
|
||||
<Image
|
||||
src={`${post.frontmatter.featuredImage}?ar=16:9`}
|
||||
src={post.frontmatter.featuredImage.split('?')[0]}
|
||||
alt={post.frontmatter.title}
|
||||
fill
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
|
||||
@@ -26,7 +26,7 @@ export function Button({
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const baseStyles =
|
||||
'inline-flex items-center justify-center rounded-full font-semibold transition-all duration-500 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-95 hover:-translate-y-1 hover:scale-[1.02] relative overflow-hidden group/btn isolate';
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-full font-semibold transition-all duration-500 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-95 hover:-translate-y-1 hover:scale-[1.02] relative overflow-hidden group/btn isolate';
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-primary text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl',
|
||||
@@ -45,8 +45,8 @@ export function Button({
|
||||
const sizes = {
|
||||
sm: 'h-9 px-4 text-sm md:text-base',
|
||||
md: 'h-11 px-6 text-base md:text-lg',
|
||||
lg: 'h-14 px-8 text-base md:text-lg',
|
||||
xl: 'h-16 px-10 text-lg md:text-xl',
|
||||
lg: 'h-14 px-5 md:px-8 text-base md:text-lg',
|
||||
xl: 'h-16 px-6 md:px-10 text-lg md:text-xl',
|
||||
};
|
||||
|
||||
const styles = cn(baseStyles, variants[variant], sizes[size], className);
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
version: 1
|
||||
directus: 11.14.1
|
||||
vendor: postgres
|
||||
collections:
|
||||
- collection: contact_submissions
|
||||
meta:
|
||||
accountability: all
|
||||
archive_app_filter: true
|
||||
collapse: open
|
||||
collection: contact_submissions
|
||||
color: '#002b49'
|
||||
display_template: '{{name}} | {{email}}'
|
||||
hidden: false
|
||||
icon: contact_mail
|
||||
singleton: false
|
||||
schema:
|
||||
name: contact_submissions
|
||||
fields:
|
||||
- collection: contact_submissions
|
||||
field: id
|
||||
type: uuid
|
||||
meta:
|
||||
collection: contact_submissions
|
||||
field: id
|
||||
hidden: true
|
||||
sort: 1
|
||||
schema:
|
||||
name: id
|
||||
table: contact_submissions
|
||||
data_type: uuid
|
||||
is_nullable: false
|
||||
is_primary_key: true
|
||||
- collection: contact_submissions
|
||||
field: name
|
||||
type: string
|
||||
meta:
|
||||
collection: contact_submissions
|
||||
field: name
|
||||
interface: input
|
||||
sort: 2
|
||||
schema:
|
||||
name: name
|
||||
table: contact_submissions
|
||||
data_type: character varying
|
||||
- collection: contact_submissions
|
||||
field: email
|
||||
type: string
|
||||
meta:
|
||||
collection: contact_submissions
|
||||
field: email
|
||||
interface: input
|
||||
sort: 3
|
||||
schema:
|
||||
name: email
|
||||
table: contact_submissions
|
||||
data_type: character varying
|
||||
- collection: contact_submissions
|
||||
field: message
|
||||
type: text
|
||||
meta:
|
||||
collection: contact_submissions
|
||||
field: message
|
||||
interface: textarea
|
||||
sort: 4
|
||||
schema:
|
||||
name: message
|
||||
table: contact_submissions
|
||||
data_type: text
|
||||
- collection: contact_submissions
|
||||
field: date_created
|
||||
type: timestamp
|
||||
meta:
|
||||
collection: contact_submissions
|
||||
field: date_created
|
||||
interface: datetime
|
||||
readonly: true
|
||||
sort: 5
|
||||
schema:
|
||||
name: date_created
|
||||
table: contact_submissions
|
||||
data_type: timestamp with time zone
|
||||
default_value: CURRENT_TIMESTAMP
|
||||
relations: []
|
||||
@@ -1,229 +0,0 @@
|
||||
version: 1
|
||||
directus: 11.14.1
|
||||
vendor: postgres
|
||||
collections:
|
||||
- collection: contact_submissions
|
||||
meta:
|
||||
accountability: all
|
||||
archive_app_filter: true
|
||||
collapse: open
|
||||
collection: contact_submissions
|
||||
color: '#002b49'
|
||||
display_template: '{{name}} | {{email}}'
|
||||
hidden: false
|
||||
icon: contact_mail
|
||||
singleton: false
|
||||
schema:
|
||||
name: contact_submissions
|
||||
- collection: product_requests
|
||||
meta:
|
||||
accountability: all
|
||||
archive_app_filter: true
|
||||
collapse: open
|
||||
collection: product_requests
|
||||
color: '#002b49'
|
||||
display_template: '{{product_name}} | {{email}}'
|
||||
hidden: false
|
||||
icon: inventory
|
||||
singleton: false
|
||||
schema:
|
||||
name: product_requests
|
||||
- collection: products
|
||||
meta:
|
||||
accountability: all
|
||||
collection: products
|
||||
icon: inventory_2
|
||||
singleton: false
|
||||
schema:
|
||||
name: products
|
||||
- collection: products_translations
|
||||
meta:
|
||||
accountability: all
|
||||
collection: products_translations
|
||||
hidden: true
|
||||
schema:
|
||||
name: products_translations
|
||||
|
||||
fields:
|
||||
# contact_submissions
|
||||
- collection: contact_submissions
|
||||
field: id
|
||||
type: uuid
|
||||
meta:
|
||||
collection: contact_submissions
|
||||
field: id
|
||||
hidden: true
|
||||
sort: 1
|
||||
schema:
|
||||
name: id
|
||||
table: contact_submissions
|
||||
data_type: uuid
|
||||
is_nullable: false
|
||||
is_primary_key: true
|
||||
- collection: contact_submissions
|
||||
field: name
|
||||
type: string
|
||||
meta:
|
||||
collection: contact_submissions
|
||||
field: name
|
||||
interface: input
|
||||
sort: 2
|
||||
schema:
|
||||
name: name
|
||||
table: contact_submissions
|
||||
data_type: character varying
|
||||
- collection: contact_submissions
|
||||
field: email
|
||||
type: string
|
||||
meta:
|
||||
collection: contact_submissions
|
||||
field: email
|
||||
interface: input
|
||||
sort: 3
|
||||
schema:
|
||||
name: email
|
||||
table: contact_submissions
|
||||
data_type: character varying
|
||||
- collection: contact_submissions
|
||||
field: message
|
||||
type: text
|
||||
meta:
|
||||
collection: contact_submissions
|
||||
field: message
|
||||
interface: textarea
|
||||
sort: 4
|
||||
schema:
|
||||
name: message
|
||||
table: contact_submissions
|
||||
data_type: text
|
||||
- collection: contact_submissions
|
||||
field: date_created
|
||||
type: timestamp
|
||||
meta:
|
||||
collection: contact_submissions
|
||||
field: date_created
|
||||
interface: datetime
|
||||
readonly: true
|
||||
sort: 5
|
||||
schema:
|
||||
name: date_created
|
||||
table: contact_submissions
|
||||
data_type: timestamp with time zone
|
||||
default_value: CURRENT_TIMESTAMP
|
||||
|
||||
# product_requests
|
||||
- collection: product_requests
|
||||
field: id
|
||||
type: uuid
|
||||
meta:
|
||||
collection: product_requests
|
||||
field: id
|
||||
hidden: true
|
||||
sort: 1
|
||||
schema:
|
||||
name: id
|
||||
table: product_requests
|
||||
data_type: uuid
|
||||
is_nullable: false
|
||||
is_primary_key: true
|
||||
- collection: product_requests
|
||||
field: product_name
|
||||
type: string
|
||||
meta:
|
||||
collection: product_requests
|
||||
field: product_name
|
||||
interface: input
|
||||
sort: 2
|
||||
schema:
|
||||
name: product_name
|
||||
table: product_requests
|
||||
data_type: character varying
|
||||
- collection: product_requests
|
||||
field: email
|
||||
type: string
|
||||
meta:
|
||||
collection: product_requests
|
||||
field: email
|
||||
interface: input
|
||||
sort: 3
|
||||
schema:
|
||||
name: email
|
||||
table: product_requests
|
||||
data_type: character varying
|
||||
- collection: product_requests
|
||||
field: message
|
||||
type: text
|
||||
meta:
|
||||
collection: product_requests
|
||||
field: message
|
||||
interface: textarea
|
||||
sort: 4
|
||||
schema:
|
||||
name: message
|
||||
table: product_requests
|
||||
data_type: text
|
||||
- collection: product_requests
|
||||
field: date_created
|
||||
type: timestamp
|
||||
meta:
|
||||
collection: product_requests
|
||||
field: date_created
|
||||
interface: datetime
|
||||
readonly: true
|
||||
sort: 5
|
||||
schema:
|
||||
name: date_created
|
||||
table: product_requests
|
||||
data_type: timestamp with time zone
|
||||
default_value: CURRENT_TIMESTAMP
|
||||
|
||||
# products
|
||||
- collection: products
|
||||
field: id
|
||||
type: uuid
|
||||
meta:
|
||||
collection: products
|
||||
field: id
|
||||
hidden: true
|
||||
sort: 1
|
||||
schema:
|
||||
name: id
|
||||
table: products
|
||||
data_type: uuid
|
||||
is_nullable: false
|
||||
is_primary_key: true
|
||||
|
||||
# products_translations
|
||||
- collection: products_translations
|
||||
field: id
|
||||
type: integer
|
||||
meta:
|
||||
collection: products_translations
|
||||
field: id
|
||||
hidden: true
|
||||
schema:
|
||||
name: id
|
||||
table: products_translations
|
||||
data_type: integer
|
||||
is_primary_key: true
|
||||
has_auto_increment: true
|
||||
|
||||
|
||||
|
||||
systemFields:
|
||||
- collection: directus_activity
|
||||
field: timestamp
|
||||
schema:
|
||||
is_indexed: true
|
||||
- collection: directus_revisions
|
||||
field: activity
|
||||
schema:
|
||||
is_indexed: true
|
||||
- collection: directus_revisions
|
||||
field: parent
|
||||
schema:
|
||||
is_indexed: true
|
||||
|
||||
relations: []
|
||||
|
||||
|
||||
65
docker-compose.dev.yml
Normal file
65
docker-compose.dev.yml
Normal file
@@ -0,0 +1,65 @@
|
||||
services:
|
||||
klz-app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
working_dir: /app
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
default:
|
||||
infra:
|
||||
aliases:
|
||||
- klz.localhost
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
POSTGRES_URI: postgres://${DIRECTUS_DB_USER:-payload}:${DIRECTUS_DB_PASSWORD:-120in09oenaoinsd9iaidon}@klz-db:5432/${DIRECTUS_DB_NAME:-payload}
|
||||
PAYLOAD_SECRET: ${DIRECTUS_SECRET:-fallback-secret-for-dev}
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
CI: "true" # Prevents some interactive prompts during install
|
||||
volumes:
|
||||
- .:/app
|
||||
# Named volumes for persistence & performance (mintel.me standard)
|
||||
- klz_node_modules:/app/node_modules
|
||||
- klz_next_cache:/app/.next
|
||||
- klz_pnpm_store:/pnpm
|
||||
- ~/.npmrc:/root/.npmrc:ro
|
||||
command: >
|
||||
sh -c "pnpm install && pnpm next dev --turbo --hostname 0.0.0.0"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=infra"
|
||||
- "caddy=http://${TRAEFIK_HOST:-klz.localhost}"
|
||||
- "caddy.reverse_proxy={{upstreams 3000}}"
|
||||
|
||||
klz-db:
|
||||
image: postgres:15-alpine
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
environment:
|
||||
POSTGRES_DB: ${DIRECTUS_DB_NAME:-payload}
|
||||
POSTGRES_USER: ${DIRECTUS_DB_USER:-payload}
|
||||
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-120in09oenaoinsd9iaidon}
|
||||
volumes:
|
||||
- klz_db_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- default
|
||||
ports:
|
||||
- "54322:5432"
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: ${PROJECT_NAME:-klz-cables}-internal
|
||||
infra:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
klz_db_data:
|
||||
external: false
|
||||
klz_node_modules:
|
||||
klz_next_cache:
|
||||
klz_pnpm_store:
|
||||
@@ -1,45 +0,0 @@
|
||||
services:
|
||||
klz-app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: development
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
- /app/.next
|
||||
environment:
|
||||
- WATCHPACK_POLLING=true # Useful for Docker volume mounting issues on some systems
|
||||
restart: "no"
|
||||
container_name: klz-app-dev
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# Clear any production middlewares/headers redirect
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.middlewares="
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.rule=Host(`${TRAEFIK_HOST:-klz.localhost}`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.entrypoints=web"
|
||||
# Configure main router for local HTTP without auth
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=Host(`${TRAEFIK_HOST:-klz.localhost}`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.entrypoints=web"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.middlewares="
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls=false"
|
||||
- "traefik.docker.network=infra"
|
||||
|
||||
klz-cms:
|
||||
container_name: klz-cms-dev
|
||||
restart: "no"
|
||||
ports:
|
||||
- "8055:8055"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-cms.rule=Host(`${DIRECTUS_HOST:-cms.klz.localhost}`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-cms.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-cms.service=${PROJECT_NAME:-klz-cables}-cms"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-cms.loadbalancer.server.port=8055"
|
||||
- "traefik.docker.network=infra"
|
||||
|
||||
klz-db:
|
||||
restart: "no"
|
||||
|
||||
klz-gatekeeper:
|
||||
restart: "no"
|
||||
@@ -1,12 +1,6 @@
|
||||
services:
|
||||
klz-app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL}
|
||||
DIRECTUS_URL: "${DIRECTUS_URL}"
|
||||
image: registry.infra.mintel.me/mintel/klz-cables.com:${IMAGE_TAG:-latest}
|
||||
image: registry.infra.mintel.me/mintel/klz-2026:${IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
default:
|
||||
@@ -15,8 +9,6 @@ services:
|
||||
- klz.localhost
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
environment:
|
||||
IMGPROXY_URL: ${IMGPROXY_URL:-http://klz-imgproxy:8080}
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# HTTP ⇒ HTTPS redirect
|
||||
@@ -31,43 +23,23 @@ services:
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}.service=${PROJECT_NAME:-klz}-app-svc"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-klz-ratelimit,klz-forward,klz-compress}"
|
||||
|
||||
# Public Router (Whitelist for OG Images, Sitemaps, Health)
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && (PathPrefix(`/health`) || PathPrefix(`/sitemap.xml`) || PathPrefix(`/robots.txt`) || PathPrefix(`/manifest.webmanifest`) || PathPrefix(`/api/og`) || PathPrefix(`/de/api/og`) || PathPrefix(`/en/api/og`) || PathPrefix(`/logo-white.svg`) || PathPrefix(`/icon-white.svg`) || PathPrefix(`/opengraph-image`) || PathPrefix(`/de/opengraph-image`) || PathPrefix(`/en/opengraph-image`) || PathPrefix(`/blog/opengraph-image`) || PathPrefix(`/de/blog/opengraph-image`) || PathPrefix(`/en/blog/opengraph-image`) || PathRegexp(`^/sitemap(-[0-9]+)?\\.xml$`) || PathRegexp(`.*\\.(svg|png|jpg|jpeg|gif|webp|ico|webm|mp4|map)$`))"
|
||||
# Public Router (Whitelist)
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && (PathPrefix(`/health`) || PathPrefix(`/sitemap.xml`) || PathPrefix(`/robots.txt`) || PathPrefix(`/manifest.webmanifest`) || PathPrefix(`/api/og`) || PathRegexp(`.*opengraph-image.*`) || PathRegexp(`^/sitemap(-[0-9]+)?\\.xml$`))"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.service=${PROJECT_NAME:-klz}-app-svc"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.middlewares=${AUTH_MIDDLEWARE_UNPROTECTED:-klz-ratelimit,klz-forward,klz-compress}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.priority=2000"
|
||||
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.scheme=http"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=infra"
|
||||
- "caddy=http://${TRAEFIK_HOST:-klz.localhost}"
|
||||
- "caddy.reverse_proxy={{upstreams 3000}}"
|
||||
|
||||
# Middleware Definitions
|
||||
# Middlewares
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-compress.compress=true"
|
||||
|
||||
# Forwarded Headers
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
||||
|
||||
# Authentication Middleware (ForwardAuth)
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.address=http://${PROJECT_NAME:-klz}-gatekeeper:3000/gatekeeper/api/verify"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.trustForwardHeader=true"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.authRequestHeaders=X-Forwarded-Host,X-Forwarded-Proto,X-Forwarded-For,Cookie"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||
|
||||
# Rate Limit Middleware
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-ratelimit.ratelimit.average=100"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-ratelimit.ratelimit.burst=50"
|
||||
healthcheck:
|
||||
test: [ "CMD", "curl", "-f", "http://127.0.0.1:3000/health" ]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 45s
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
||||
|
||||
klz-gatekeeper:
|
||||
profiles: [ "gatekeeper" ]
|
||||
@@ -81,125 +53,26 @@ services:
|
||||
- ${ENV_FILE:-.env}
|
||||
environment:
|
||||
PORT: 3000
|
||||
PROJECT_NAME: ${PROJECT_NAME:-KLZ Cables}
|
||||
PROJECT_COLOR: "#82ed20"
|
||||
COOKIE_DOMAIN: ${COOKIE_DOMAIN}
|
||||
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-klz_gatekeeper_session}
|
||||
GATEKEEPER_PASSWORD: ${GATEKEEPER_PASSWORD}
|
||||
NEXT_PUBLIC_BASE_URL: ${GATEKEEPER_ORIGIN}
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=infra"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.rule=(Host(`${TRAEFIK_HOST:-testing.klz-cables.com}`) && PathPrefix(`/gatekeeper`))"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls=${TRAEFIK_TLS:-false}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.service=${PROJECT_NAME:-klz}-gatekeeper-svc"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz}-gatekeeper-svc.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=infra"
|
||||
|
||||
klz-cms:
|
||||
image: registry.infra.mintel.me/mintel/directus:latest
|
||||
restart: unless-stopped
|
||||
command: [ "node", "cli.js", "start" ]
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
environment:
|
||||
KEY: ${DIRECTUS_KEY}
|
||||
SECRET: ${DIRECTUS_SECRET}
|
||||
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
|
||||
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
|
||||
DB_CLIENT: 'pg'
|
||||
DB_HOST: 'klz-db'
|
||||
DB_PORT: '5432'
|
||||
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
|
||||
DB_USER: ${DIRECTUS_DB_USER:-directus}
|
||||
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-120in09oenaoinsd9iaidon}
|
||||
WEBSOCKETS_ENABLED: 'true'
|
||||
PUBLIC_URL: ${DIRECTUS_URL:-https://cms.klz-cables.com}
|
||||
HOST: '0.0.0.0'
|
||||
networks:
|
||||
- default
|
||||
- infra
|
||||
volumes:
|
||||
- ./directus/uploads:/directus/uploads
|
||||
- ./directus/extensions:/directus/extensions
|
||||
- ./directus/schema:/directus/schema
|
||||
- ./directus/migrations:/directus/migrations
|
||||
healthcheck:
|
||||
disable: true
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.rule=Host(`${DIRECTUS_HOST:-cms.klz-cables.com}`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.entrypoints=websecure"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.priority=5000"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.tls=true"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.tls.certresolver=le"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.service=${PROJECT_NAME:-klz}-cms-svc"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz}-cms-svc.loadbalancer.server.port=8055"
|
||||
- "traefik.docker.network=infra"
|
||||
- "caddy=http://${DIRECTUS_HOST:-cms.klz-cables.com}"
|
||||
- "caddy.reverse_proxy={{upstreams 8055}}"
|
||||
klz-db:
|
||||
image: postgres:15-alpine
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
environment:
|
||||
POSTGRES_DB: ${DIRECTUS_DB_NAME:-directus}
|
||||
POSTGRES_USER: ${DIRECTUS_DB_USER:-directus}
|
||||
POSTGRES_DB: ${DIRECTUS_DB_NAME:-payload}
|
||||
POSTGRES_USER: ${DIRECTUS_DB_USER:-payload}
|
||||
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-120in09oenaoinsd9iaidon}
|
||||
volumes:
|
||||
- directus-db-data:/var/lib/postgresql/data
|
||||
- klz_db_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- default
|
||||
|
||||
klz-imgproxy:
|
||||
build:
|
||||
context: ../at-mintel
|
||||
dockerfile: apps/image-service/Dockerfile
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- default
|
||||
- infra
|
||||
extra_hosts:
|
||||
- "klz.localhost:host-gateway"
|
||||
- "cms.klz.localhost:host-gateway"
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
|
||||
# explicitly map localhost, production and staging to bypass gatekeeper
|
||||
IMGPROXY_URL_MAPPING: "https://staging.klz-cables.com:http://klz-app:3000,https://klz-cables.com:http://klz-app:3000,https://${TRAEFIK_HOST:-klz.localhost}:http://klz-app:3000,${DIRECTUS_URL}:http://klz-cms:8055,https://cms.klz-cables.com:http://klz-cms:8055"
|
||||
IMGPROXY_LOG_LEVEL: debug
|
||||
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# Existing Local HTTP Router
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy.rule=Host(`img.${TRAEFIK_HOST:-klz.localhost}`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy.entrypoints=web"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy.service=${PROJECT_NAME:-klz}-imgproxy-svc"
|
||||
|
||||
# NEW: Direct Public Staging Router for /_img (Bypasses Next.js rewrites)
|
||||
# This fixes the Next.js URL-decoding bug on dynamic image proxy paths
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.rule=(Host(`${TRAEFIK_HOST:-klz.localhost}`) || Host(`staging.klz-cables.com`) || Host(`testing.klz-cables.com`)) && PathPrefix(`/_img`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.priority=99999"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.tls=${TRAEFIK_TLS:-false}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.service=${PROJECT_NAME:-klz}-imgproxy-svc"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz}-imgproxy-svc.loadbalancer.server.port=8080"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.middlewares=${PROJECT_NAME:-klz}-img-strip"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-img-strip.stripprefix.prefixes=/_img"
|
||||
# HTTPS router (staging/prod)
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.rule=Host(`img.${TRAEFIK_HOST:-klz.localhost}`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.tls=${TRAEFIK_TLS:-false}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.service=${PROJECT_NAME:-klz}-imgproxy-svc"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz}-imgproxy-svc.loadbalancer.server.port=8080"
|
||||
- "traefik.docker.network=infra"
|
||||
- "caddy=http://img.${TRAEFIK_HOST:-klz.localhost}"
|
||||
- "caddy.reverse_proxy={{upstreams 8080}}"
|
||||
ports:
|
||||
- "54322:5432"
|
||||
|
||||
networks:
|
||||
default:
|
||||
@@ -208,6 +81,5 @@ networks:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
directus-db-data:
|
||||
external: true
|
||||
name: klz-cablescom_directus-db-data
|
||||
klz_db_data:
|
||||
external: false
|
||||
|
||||
130
lib/blog.ts
130
lib/blog.ts
@@ -1,7 +1,5 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import matter from 'gray-matter';
|
||||
import { mapSlugToFileSlug } from './slugs';
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
export function extractExcerpt(content: string): string {
|
||||
@@ -42,7 +40,7 @@ export interface PostFrontmatter {
|
||||
export interface PostMdx {
|
||||
slug: string;
|
||||
frontmatter: PostFrontmatter;
|
||||
content: string;
|
||||
content: any; // Mapped to Lexical SerializedEditorState
|
||||
}
|
||||
|
||||
export function isPostVisible(post: { frontmatter: { date: string; public?: boolean } }) {
|
||||
@@ -57,87 +55,81 @@ export function isPostVisible(post: { frontmatter: { date: string; public?: bool
|
||||
}
|
||||
|
||||
export async function getPostBySlug(slug: string, locale: string): Promise<PostMdx | null> {
|
||||
// Map translated slug to file slug
|
||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||
const postsDir = path.join(process.cwd(), 'data', 'blog', locale);
|
||||
const filePath = path.join(postsDir, `${fileSlug}.mdx`);
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
const { docs } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: {
|
||||
slug: { equals: slug },
|
||||
locale: { equals: locale },
|
||||
},
|
||||
draft: process.env.NODE_ENV === 'development',
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
if (!docs || docs.length === 0) return null;
|
||||
|
||||
const postInfo = {
|
||||
slug: fileSlug,
|
||||
const doc = docs[0];
|
||||
|
||||
return {
|
||||
slug: doc.slug,
|
||||
frontmatter: {
|
||||
...data,
|
||||
excerpt: data.excerpt || extractExcerpt(content),
|
||||
title: doc.title,
|
||||
date: doc.date,
|
||||
excerpt: doc.excerpt || '',
|
||||
category: doc.category || '',
|
||||
locale: doc.locale,
|
||||
featuredImage:
|
||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
|
||||
: null,
|
||||
public: doc._status === 'published',
|
||||
} as PostFrontmatter,
|
||||
content,
|
||||
content: doc.content as any, // Native Lexical Editor State
|
||||
};
|
||||
|
||||
if (!isPostVisible(postInfo)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return postInfo;
|
||||
}
|
||||
|
||||
export async function getAllPosts(locale: string): Promise<PostMdx[]> {
|
||||
const postsDir = path.join(process.cwd(), 'data', 'blog', locale);
|
||||
if (!fs.existsSync(postsDir)) return [];
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
// Query only published posts (access checks applied automatically by Payload!)
|
||||
const { docs } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: {
|
||||
locale: {
|
||||
equals: locale,
|
||||
},
|
||||
},
|
||||
sort: '-date',
|
||||
draft: process.env.NODE_ENV === 'development', // Includes Drafts if running locally
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const files = fs.readdirSync(postsDir);
|
||||
const posts = files
|
||||
.filter((file) => file.endsWith('.mdx'))
|
||||
.map((file) => {
|
||||
const filePath = path.join(postsDir, file);
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
return docs.map((doc) => {
|
||||
return {
|
||||
slug: file.replace(/\.mdx$/, ''),
|
||||
slug: doc.slug,
|
||||
frontmatter: {
|
||||
...data,
|
||||
excerpt: data.excerpt || extractExcerpt(content),
|
||||
title: doc.title,
|
||||
date: doc.date,
|
||||
excerpt: doc.excerpt || '',
|
||||
category: doc.category || '',
|
||||
locale: doc.locale,
|
||||
featuredImage:
|
||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
|
||||
: null,
|
||||
} as PostFrontmatter,
|
||||
content,
|
||||
// Pass the Lexical content object rather than raw markdown string
|
||||
content: doc.content as any,
|
||||
};
|
||||
})
|
||||
.filter(isPostVisible)
|
||||
.sort(
|
||||
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime(),
|
||||
);
|
||||
|
||||
return posts;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAllPostsMetadata(locale: string): Promise<Partial<PostMdx>[]> {
|
||||
const postsDir = path.join(process.cwd(), 'data', 'blog', locale);
|
||||
if (!fs.existsSync(postsDir)) return [];
|
||||
|
||||
const files = fs.readdirSync(postsDir);
|
||||
return files
|
||||
.filter((file) => file.endsWith('.mdx'))
|
||||
.map((file) => {
|
||||
const filePath = path.join(postsDir, file);
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data } = matter(fileContent);
|
||||
return {
|
||||
slug: file.replace(/\.mdx$/, ''),
|
||||
frontmatter: {
|
||||
...data,
|
||||
excerpt: data.excerpt || extractExcerpt(fileContent.replace(/^---[\s\S]*?---/, '')),
|
||||
} as PostFrontmatter,
|
||||
};
|
||||
})
|
||||
.filter(isPostVisible)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.frontmatter.date as string).getTime() -
|
||||
new Date(a.frontmatter.date as string).getTime(),
|
||||
);
|
||||
const posts = await getAllPosts(locale);
|
||||
return posts.map((p) => ({
|
||||
slug: p.slug,
|
||||
frontmatter: p.frontmatter,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getAdjacentPosts(
|
||||
|
||||
@@ -13,7 +13,10 @@ let memoizedConfig: ReturnType<typeof createConfig> | undefined;
|
||||
function createConfig() {
|
||||
const env = getRawEnv();
|
||||
|
||||
const target = env.NEXT_PUBLIC_TARGET || env.TARGET;
|
||||
const target =
|
||||
env.NEXT_PUBLIC_TARGET ||
|
||||
env.TARGET ||
|
||||
(env.NODE_ENV === 'development' ? 'development' : 'production');
|
||||
|
||||
console.log('[Config] Initializing Toggles:', {
|
||||
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
|
||||
@@ -65,17 +68,9 @@ function createConfig() {
|
||||
from: env.MAIL_FROM,
|
||||
recipients: env.MAIL_RECIPIENTS,
|
||||
},
|
||||
directus: {
|
||||
url: env.DIRECTUS_URL,
|
||||
adminEmail: env.DIRECTUS_ADMIN_EMAIL,
|
||||
password: env.DIRECTUS_ADMIN_PASSWORD,
|
||||
token: env.DIRECTUS_API_TOKEN,
|
||||
internalUrl: env.INTERNAL_DIRECTUS_URL,
|
||||
proxyPath: '/cms',
|
||||
},
|
||||
infraCMS: {
|
||||
url: env.INFRA_DIRECTUS_URL || env.DIRECTUS_URL,
|
||||
token: env.INFRA_DIRECTUS_TOKEN || env.DIRECTUS_API_TOKEN,
|
||||
url: env.INFRA_DIRECTUS_URL,
|
||||
token: env.INFRA_DIRECTUS_TOKEN,
|
||||
},
|
||||
notifications: {
|
||||
gotify: {
|
||||
@@ -139,9 +134,6 @@ export const config = {
|
||||
get mail() {
|
||||
return getConfig().mail;
|
||||
},
|
||||
get directus() {
|
||||
return getConfig().directus;
|
||||
},
|
||||
get notifications() {
|
||||
return getConfig().notifications;
|
||||
},
|
||||
@@ -192,12 +184,6 @@ export function getMaskedConfig() {
|
||||
from: c.mail.from,
|
||||
recipients: c.mail.recipients,
|
||||
},
|
||||
directus: {
|
||||
url: c.directus.url,
|
||||
adminEmail: mask(c.directus.adminEmail),
|
||||
password: mask(c.directus.password),
|
||||
token: mask(c.directus.token),
|
||||
},
|
||||
notifications: {
|
||||
gotify: {
|
||||
url: c.notifications.gotify.url,
|
||||
|
||||
192
lib/directus.ts
192
lib/directus.ts
@@ -1,192 +0,0 @@
|
||||
import { readItems, readCollections } from '@directus/sdk';
|
||||
import { createMintelDirectusClient, ensureDirectusAuthenticated } from '@mintel/next-utils';
|
||||
import { config } from './config';
|
||||
import { getServerAppServices } from './services/create-services.server';
|
||||
|
||||
/**
|
||||
* Directus Schema Definitions
|
||||
*/
|
||||
export interface Schema {
|
||||
products: any[];
|
||||
categories: any[];
|
||||
contact_submissions: any[];
|
||||
product_requests: any[];
|
||||
translations: any[];
|
||||
categories_link: any[];
|
||||
}
|
||||
|
||||
// Initialize client using Mintel standards (environment-aware)
|
||||
const client = createMintelDirectusClient<Schema>();
|
||||
|
||||
/**
|
||||
* Helper to determine if we should show detailed errors
|
||||
*/
|
||||
const shouldShowDevErrors = config.isTesting || config.isDevelopment;
|
||||
|
||||
/**
|
||||
* Genericizes error messages for production/staging
|
||||
*/
|
||||
function formatError(error: any) {
|
||||
if (shouldShowDevErrors) {
|
||||
return error.errors?.[0]?.message || error.message || 'An unexpected error occurred.';
|
||||
}
|
||||
return 'A system error occurred. Our team has been notified.';
|
||||
}
|
||||
|
||||
export async function ensureAuthenticated() {
|
||||
try {
|
||||
await ensureDirectusAuthenticated(client);
|
||||
} catch (e: any) {
|
||||
if (typeof window === 'undefined') {
|
||||
getServerAppServices().errors.captureException(e, { part: 'directus_auth' });
|
||||
}
|
||||
console.error(`Failed to authenticate with Directus:`, e.message);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the new translation-based schema back to the application's Product interface
|
||||
*/
|
||||
function mapDirectusProduct(item: any, locale: string): any {
|
||||
const langCode = locale === 'en' ? 'en-US' : 'de-DE';
|
||||
const translation =
|
||||
item.translations?.find((t: any) => t.languages_code === langCode) ||
|
||||
item.translations?.[0] ||
|
||||
{};
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
sku: item.sku,
|
||||
title: translation.name || '',
|
||||
description: translation.description || '',
|
||||
content: translation.content || '',
|
||||
technicalData: {
|
||||
technicalItems: translation.technical_items || [],
|
||||
voltageTables: translation.voltage_tables || [],
|
||||
},
|
||||
locale: locale,
|
||||
// Use standardized proxy path for assets to avoid CORS
|
||||
data_sheet_url: item.data_sheet ? `/api/directus/assets/${item.data_sheet}` : null,
|
||||
categories: (item.categories_link || [])
|
||||
.map((c: any) => c.categories_id?.translations?.[0]?.name)
|
||||
.filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getProducts(locale: string = 'de') {
|
||||
await ensureAuthenticated();
|
||||
try {
|
||||
const items = await client.request(
|
||||
readItems('products', {
|
||||
fields: ['*', 'translations.*', 'categories_link.categories_id.translations.name'],
|
||||
}),
|
||||
);
|
||||
return items.map((item) => mapDirectusProduct(item, locale));
|
||||
} catch (error) {
|
||||
if (typeof window === 'undefined') {
|
||||
getServerAppServices().errors.captureException(error, { part: 'directus_get_products' });
|
||||
}
|
||||
console.error('Error fetching products:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProductBySlug(slug: string, locale: string = 'de') {
|
||||
await ensureAuthenticated();
|
||||
const langCode = locale === 'en' ? 'en-US' : 'de-DE';
|
||||
try {
|
||||
const items = await client.request(
|
||||
readItems('products', {
|
||||
filter: {
|
||||
translations: {
|
||||
slug: { _eq: slug },
|
||||
languages_code: { _eq: langCode },
|
||||
},
|
||||
},
|
||||
fields: ['*', 'translations.*', 'categories_link.categories_id.translations.name'],
|
||||
limit: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!items || items.length === 0) return null;
|
||||
return mapDirectusProduct(items[0], locale);
|
||||
} catch (error) {
|
||||
if (typeof window === 'undefined') {
|
||||
getServerAppServices().errors.captureException(error, {
|
||||
part: 'directus_get_product_by_slug',
|
||||
slug,
|
||||
});
|
||||
}
|
||||
console.error(`Error fetching product ${slug}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkHealth() {
|
||||
try {
|
||||
// 1. Connectivity & Auth Check
|
||||
try {
|
||||
await ensureAuthenticated();
|
||||
await client.request(readCollections());
|
||||
} catch (e: any) {
|
||||
if (typeof window === 'undefined') {
|
||||
getServerAppServices().errors.captureException(e, { part: 'directus_health_auth' });
|
||||
}
|
||||
console.error('Directus authentication or collection-read failed during health check:', e);
|
||||
return {
|
||||
status: 'error',
|
||||
message: shouldShowDevErrors
|
||||
? `Directus Health Error: ${e.message || 'Unknown'}`
|
||||
: 'CMS is currently unavailable due to an internal authentication or connection error.',
|
||||
code: e.code || 'HEALTH_AUTH_FAILED',
|
||||
details: shouldShowDevErrors
|
||||
? { message: e.message, code: e.code, errors: e.errors }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Schema check (does the contact_submissions table exist?)
|
||||
try {
|
||||
await client.request(readItems('contact_submissions', { limit: 1 }));
|
||||
} catch (e: any) {
|
||||
if (typeof window === 'undefined') {
|
||||
getServerAppServices().errors.captureException(e, { part: 'directus_health_schema' });
|
||||
}
|
||||
if (
|
||||
e.message?.includes('does not exist') ||
|
||||
e.code === 'INVALID_PAYLOAD' ||
|
||||
e.status === 404
|
||||
) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: shouldShowDevErrors
|
||||
? `The "contact_submissions" collection is missing or inaccessible. Error: ${e.message || 'Unknown'}`
|
||||
: 'Required data structures are currently unavailable.',
|
||||
code: 'SCHEMA_MISSING',
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'error',
|
||||
message: shouldShowDevErrors
|
||||
? `Schema error: ${e.errors?.[0]?.message || e.message || 'Unknown error'}`
|
||||
: 'The data schema is currently misconfigured.',
|
||||
code: 'SCHEMA_ERROR',
|
||||
};
|
||||
}
|
||||
|
||||
return { status: 'ok', message: 'Directus is reachable and responding.' };
|
||||
} catch (error: any) {
|
||||
if (typeof window === 'undefined') {
|
||||
getServerAppServices().errors.captureException(error, { part: 'directus_health_critical' });
|
||||
}
|
||||
console.error('Directus health check failed with unexpected error:', error);
|
||||
return {
|
||||
status: 'error',
|
||||
message: formatError(error),
|
||||
code: error.code || 'UNKNOWN',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default client;
|
||||
@@ -43,12 +43,6 @@ const envExtension = {
|
||||
MAIL_PASSWORD: z.string().optional(),
|
||||
MAIL_FROM: z.string().optional(),
|
||||
MAIL_RECIPIENTS: z.string().optional(),
|
||||
|
||||
// Directus Authentication
|
||||
DIRECTUS_URL: z.string().url().optional(),
|
||||
DIRECTUS_ADMIN_EMAIL: z.string().email().optional(),
|
||||
DIRECTUS_ADMIN_PASSWORD: z.string().optional(),
|
||||
DIRECTUS_API_TOKEN: z.string().optional(),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { getImgproxyUrl } from './imgproxy';
|
||||
|
||||
/**
|
||||
* Next.js Image Loader for imgproxy
|
||||
*
|
||||
* @param {Object} props - properties from Next.js Image component
|
||||
* @param {string} props.src - The source image URL
|
||||
* @param {number} props.width - The desired image width
|
||||
* @param {number} props.quality - The desired image quality (ignored for now as imgproxy handles it)
|
||||
*/
|
||||
export default function imgproxyLoader({
|
||||
src,
|
||||
width,
|
||||
_quality,
|
||||
}: {
|
||||
src: string;
|
||||
width: number;
|
||||
_quality?: number;
|
||||
}) {
|
||||
// Skip imgproxy for SVGs as they are vectors and don't benefit from resizing,
|
||||
// and often cause 404s if the source is not correctly resolvable by imgproxy.
|
||||
if (src.toLowerCase().endsWith('.svg')) {
|
||||
return src;
|
||||
}
|
||||
|
||||
// Check if src contains custom gravity or aspect ratio query parameters
|
||||
let gravity = 'sm'; // Use smart gravity (content-aware) by default
|
||||
let cleanSrc = src;
|
||||
let calculatedHeight = 0;
|
||||
let resizingType: 'fit' | 'fill' = 'fit';
|
||||
|
||||
try {
|
||||
// Dummy base needed for relative URLs
|
||||
const url = new URL(src, 'http://localhost');
|
||||
const customGravity = url.searchParams.get('gravity');
|
||||
const aspectRatio = url.searchParams.get('ar'); // e.g. "16:9"
|
||||
|
||||
if (customGravity) {
|
||||
gravity = customGravity;
|
||||
url.searchParams.delete('gravity');
|
||||
}
|
||||
|
||||
if (aspectRatio) {
|
||||
const parts = aspectRatio.split(':');
|
||||
if (parts.length === 2) {
|
||||
const arW = parseFloat(parts[0]);
|
||||
const arH = parseFloat(parts[1]);
|
||||
if (!isNaN(arW) && !isNaN(arH) && arW > 0) {
|
||||
calculatedHeight = Math.round(width * (arH / arW));
|
||||
resizingType = 'fill'; // Must use fill to allow imgproxy to crop
|
||||
}
|
||||
}
|
||||
url.searchParams.delete('ar');
|
||||
}
|
||||
|
||||
if (customGravity || aspectRatio) {
|
||||
cleanSrc = src.startsWith('http') ? url.href : url.pathname + url.search;
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback if parsing fails
|
||||
}
|
||||
|
||||
// We use the width provided by Next.js for responsive images
|
||||
// Height is calculated from aspect ratio if provided, otherwise 0 to maintain aspect ratio
|
||||
return getImgproxyUrl(cleanSrc, {
|
||||
width,
|
||||
height: calculatedHeight,
|
||||
resizing_type: resizingType,
|
||||
gravity,
|
||||
});
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Generates an imgproxy URL for a given source image and options.
|
||||
*
|
||||
* Documentation: https://docs.imgproxy.net/usage/processing
|
||||
*/
|
||||
|
||||
interface ImgproxyOptions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
resizing_type?: 'fit' | 'fill' | 'fill-down' | 'force' | 'auto';
|
||||
gravity?: string;
|
||||
enlarge?: boolean;
|
||||
extension?: string;
|
||||
}
|
||||
|
||||
export function getImgproxyUrl(src: string, options: ImgproxyOptions = {}): string {
|
||||
// Use local proxy path which is rewritten in next.config.mjs
|
||||
const baseUrl = '/_img';
|
||||
|
||||
// Handle local paths or relative URLs
|
||||
let absoluteSrc = src;
|
||||
if (src.startsWith('/')) {
|
||||
const baseUrlForSrc =
|
||||
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||
(typeof window !== 'undefined' ? window.location.origin : 'https://klz-cables.com');
|
||||
if (baseUrlForSrc) {
|
||||
absoluteSrc = `${baseUrlForSrc.replace(/\/$/, '')}${src}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Development mapping: Map local domains to internal Docker hostnames
|
||||
// so imgproxy can fetch images without SSL issues or external routing
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
if (absoluteSrc.includes('klz.localhost')) {
|
||||
absoluteSrc = absoluteSrc.replace(/^https?:\/\/klz\.localhost/, 'http://klz-app:3000');
|
||||
} else if (absoluteSrc.includes('cms.klz.localhost')) {
|
||||
absoluteSrc = absoluteSrc.replace(/^https?:\/\/cms\.klz\.localhost/, 'http://klz-cms:8055');
|
||||
}
|
||||
// Also handle direct container names if needed
|
||||
}
|
||||
|
||||
const { width = 0, height = 0, enlarge = false, extension = '' } = options;
|
||||
|
||||
let quality = 80;
|
||||
if (extension) quality = 90;
|
||||
|
||||
// Re-map imgproxy URL to our new parameter structure
|
||||
// e.g. /process?url=...&w=...&h=...&q=...&format=...
|
||||
const queryParams = new URLSearchParams({
|
||||
url: absoluteSrc,
|
||||
});
|
||||
|
||||
if (width > 0) queryParams.set('w', width.toString());
|
||||
if (height > 0) queryParams.set('h', height.toString());
|
||||
if (extension) queryParams.set('format', extension.replace('.', ''));
|
||||
if (quality) queryParams.set('q', quality.toString());
|
||||
|
||||
return `${baseUrl}/process?${queryParams.toString()}`;
|
||||
}
|
||||
285
lib/mdx.ts
285
lib/mdx.ts
@@ -1,6 +1,5 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import matter from 'gray-matter';
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
import { mapSlugToFileSlug } from './slugs';
|
||||
|
||||
export interface ProductFrontmatter {
|
||||
@@ -10,65 +9,69 @@ export interface ProductFrontmatter {
|
||||
categories: string[];
|
||||
images: string[];
|
||||
locale: string;
|
||||
isFallback?: boolean;
|
||||
}
|
||||
|
||||
export interface ProductMdx {
|
||||
slug: string;
|
||||
frontmatter: ProductFrontmatter;
|
||||
content: string;
|
||||
content: any; // Lexical AST from Payload
|
||||
}
|
||||
|
||||
export async function getProductMetadata(
|
||||
slug: string,
|
||||
locale: string,
|
||||
): Promise<Partial<ProductMdx> | null> {
|
||||
// Map translated slug to file slug
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
|
||||
|
||||
if (!fs.existsSync(productsDir)) return null;
|
||||
let result = await payload.find({
|
||||
collection: 'products',
|
||||
where: {
|
||||
and: [{ slug: { equals: fileSlug } }, { locale: { equals: locale } }],
|
||||
},
|
||||
depth: 1, // To auto-resolve Media relation (images array)
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
// Recursive search for the file
|
||||
const findFile = (dir: string): string | null => {
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(dir, file);
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
const found = findFile(fullPath);
|
||||
if (found) return found;
|
||||
} else if (file === `${fileSlug}.mdx` || file === `${fileSlug}-2.mdx`) {
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
let isFallback = false;
|
||||
|
||||
let filePath = findFile(productsDir);
|
||||
|
||||
if (!filePath && locale !== 'en') {
|
||||
if (result.docs.length === 0 && locale !== 'en') {
|
||||
// Fallback to English
|
||||
const enProductsDir = path.join(process.cwd(), 'data', 'products', 'en');
|
||||
if (fs.existsSync(enProductsDir)) {
|
||||
filePath = findFile(enProductsDir);
|
||||
result = await payload.find({
|
||||
collection: 'products',
|
||||
where: {
|
||||
and: [{ slug: { equals: fileSlug } }, { locale: { equals: 'en' } }],
|
||||
},
|
||||
depth: 1,
|
||||
limit: 1,
|
||||
});
|
||||
if (result.docs.length > 0) {
|
||||
isFallback = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (filePath && fs.existsSync(filePath)) {
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data } = matter(fileContent);
|
||||
if (result.docs.length > 0) {
|
||||
const doc = result.docs[0];
|
||||
|
||||
// Filter out products without images to match getProductBySlug behavior
|
||||
if (!data.images || data.images.length === 0 || !data.images[0]) {
|
||||
return null;
|
||||
}
|
||||
// Process Images
|
||||
const resolvedImages = ((doc.images as any[]) || [])
|
||||
.map((img) => (typeof img === 'string' ? img : img.url))
|
||||
.filter(Boolean);
|
||||
|
||||
if (resolvedImages.length === 0) return null; // Original logic skipped docs without images
|
||||
|
||||
return {
|
||||
slug: fileSlug,
|
||||
slug: doc.slug,
|
||||
frontmatter: {
|
||||
...data,
|
||||
isFallback: filePath.includes('/en/'),
|
||||
} as any,
|
||||
title: doc.title,
|
||||
sku: doc.sku,
|
||||
description: doc.description,
|
||||
categories: Array.isArray(doc.categories) ? doc.categories.map((c: any) => c.category) : [],
|
||||
images: resolvedImages,
|
||||
locale: doc.locale,
|
||||
isFallback,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,111 +79,159 @@ export async function getProductMetadata(
|
||||
}
|
||||
|
||||
export async function getProductBySlug(slug: string, locale: string): Promise<ProductMdx | null> {
|
||||
// Map translated slug to file slug
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
|
||||
|
||||
if (!fs.existsSync(productsDir)) return null;
|
||||
let result = await payload.find({
|
||||
collection: 'products',
|
||||
where: {
|
||||
and: [{ slug: { equals: fileSlug } }, { locale: { equals: locale } }],
|
||||
},
|
||||
depth: 1, // Auto-resolve Media logic
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
// Recursive search for the file
|
||||
const findFile = (dir: string): string | null => {
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(dir, file);
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
const found = findFile(fullPath);
|
||||
if (found) return found;
|
||||
} else if (file === `${fileSlug}.mdx` || file === `${fileSlug}-2.mdx`) {
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
let filePath = findFile(productsDir);
|
||||
let isFallback = false;
|
||||
|
||||
if (!filePath && locale !== 'en') {
|
||||
if (result.docs.length === 0 && locale !== 'en') {
|
||||
// Fallback to English
|
||||
const enProductsDir = path.join(process.cwd(), 'data', 'products', 'en');
|
||||
if (fs.existsSync(enProductsDir)) {
|
||||
filePath = findFile(enProductsDir);
|
||||
if (filePath) isFallback = true;
|
||||
result = await payload.find({
|
||||
collection: 'products',
|
||||
where: {
|
||||
and: [{ slug: { equals: fileSlug } }, { locale: { equals: 'en' } }],
|
||||
},
|
||||
depth: 1,
|
||||
limit: 1,
|
||||
});
|
||||
if (result.docs.length > 0) {
|
||||
isFallback = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (filePath && fs.existsSync(filePath)) {
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
const product = {
|
||||
slug: fileSlug,
|
||||
if (result.docs.length > 0) {
|
||||
const doc = result.docs[0];
|
||||
|
||||
// Map Images correctly from resolved Media docs
|
||||
const resolvedImages = ((doc.images as any[]) || [])
|
||||
.map((img) => (typeof img === 'string' ? img : img.url))
|
||||
.filter(Boolean);
|
||||
|
||||
if (resolvedImages.length === 0) return null;
|
||||
|
||||
return {
|
||||
slug: doc.slug,
|
||||
frontmatter: {
|
||||
...data,
|
||||
title: doc.title,
|
||||
sku: doc.sku,
|
||||
description: doc.description,
|
||||
categories: Array.isArray(doc.categories) ? doc.categories.map((c: any) => c.category) : [],
|
||||
images: resolvedImages,
|
||||
locale: doc.locale,
|
||||
isFallback,
|
||||
} as any,
|
||||
content,
|
||||
},
|
||||
content: doc.content, // Lexical payload instead of raw MDX String
|
||||
};
|
||||
|
||||
// Filter out products without images
|
||||
if (
|
||||
!product.frontmatter.images ||
|
||||
product.frontmatter.images.length === 0 ||
|
||||
!product.frontmatter.images[0]
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return product;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getAllProductSlugs(locale: string): Promise<string[]> {
|
||||
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
|
||||
if (!fs.existsSync(productsDir)) return [];
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
const result = await payload.find({
|
||||
collection: 'products',
|
||||
where: {
|
||||
locale: {
|
||||
equals: locale,
|
||||
},
|
||||
},
|
||||
pagination: false, // get all docs
|
||||
});
|
||||
|
||||
const slugs: string[] = [];
|
||||
const walk = (dir: string) => {
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(dir, file);
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
walk(fullPath);
|
||||
} else if (file.endsWith('.mdx')) {
|
||||
slugs.push(file.replace(/\.mdx$/, ''));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(productsDir);
|
||||
return slugs;
|
||||
return result.docs.map((doc) => doc.slug);
|
||||
}
|
||||
|
||||
export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
|
||||
const slugs = await getAllProductSlugs(locale);
|
||||
let allSlugs = slugs;
|
||||
// Fetch ALL products in a single query to avoid N+1 getPayload() calls
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
// Get products for this locale
|
||||
const result = await payload.find({
|
||||
collection: 'products',
|
||||
where: { locale: { equals: locale } },
|
||||
depth: 1,
|
||||
pagination: false,
|
||||
});
|
||||
|
||||
let products: ProductMdx[] = result.docs
|
||||
.filter((doc) => {
|
||||
const resolvedImages = ((doc.images as any[]) || [])
|
||||
.map((img) => (typeof img === 'string' ? img : img.url))
|
||||
.filter(Boolean);
|
||||
return resolvedImages.length > 0;
|
||||
})
|
||||
.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
frontmatter: {
|
||||
title: doc.title,
|
||||
sku: doc.sku,
|
||||
description: doc.description,
|
||||
categories: Array.isArray(doc.categories) ? doc.categories.map((c: any) => c.category) : [],
|
||||
images: ((doc.images as any[]) || [])
|
||||
.map((img) => (typeof img === 'string' ? img : img.url))
|
||||
.filter(Boolean),
|
||||
locale: doc.locale,
|
||||
},
|
||||
content: doc.content,
|
||||
}));
|
||||
|
||||
// Also include English fallbacks for slugs not in this locale
|
||||
if (locale !== 'en') {
|
||||
const enSlugs = await getAllProductSlugs('en');
|
||||
allSlugs = Array.from(new Set([...slugs, ...enSlugs]));
|
||||
const localeSlugs = new Set(products.map((p) => p.slug));
|
||||
const enResult = await payload.find({
|
||||
collection: 'products',
|
||||
where: { locale: { equals: 'en' } },
|
||||
depth: 1,
|
||||
pagination: false,
|
||||
});
|
||||
|
||||
const fallbacks = enResult.docs
|
||||
.filter((doc) => !localeSlugs.has(doc.slug))
|
||||
.filter((doc) => {
|
||||
const resolvedImages = ((doc.images as any[]) || [])
|
||||
.map((img) => (typeof img === 'string' ? img : img.url))
|
||||
.filter(Boolean);
|
||||
return resolvedImages.length > 0;
|
||||
})
|
||||
.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
frontmatter: {
|
||||
title: doc.title,
|
||||
sku: doc.sku,
|
||||
description: doc.description,
|
||||
categories: Array.isArray(doc.categories)
|
||||
? doc.categories.map((c: any) => c.category)
|
||||
: [],
|
||||
images: ((doc.images as any[]) || [])
|
||||
.map((img) => (typeof img === 'string' ? img : img.url))
|
||||
.filter(Boolean),
|
||||
locale: doc.locale,
|
||||
isFallback: true,
|
||||
},
|
||||
content: doc.content,
|
||||
}));
|
||||
|
||||
products = [...products, ...fallbacks];
|
||||
}
|
||||
|
||||
const products = await Promise.all(allSlugs.map((slug) => getProductBySlug(slug, locale)));
|
||||
return products.filter((p): p is ProductMdx => p !== null);
|
||||
return products;
|
||||
}
|
||||
|
||||
export async function getAllProductsMetadata(locale: string): Promise<Partial<ProductMdx>[]> {
|
||||
const slugs = await getAllProductSlugs(locale);
|
||||
let allSlugs = slugs;
|
||||
|
||||
if (locale !== 'en') {
|
||||
const enSlugs = await getAllProductSlugs('en');
|
||||
allSlugs = Array.from(new Set([...slugs, ...enSlugs]));
|
||||
}
|
||||
|
||||
const metadata = await Promise.all(allSlugs.map((slug) => getProductMetadata(slug, locale)));
|
||||
return metadata.filter((m): m is Partial<ProductMdx> => m !== null);
|
||||
// Reuse getAllProducts to avoid separate N+1 queries
|
||||
const products = await getAllProducts(locale);
|
||||
return products.map((p) => ({
|
||||
slug: p.slug,
|
||||
frontmatter: p.frontmatter,
|
||||
}));
|
||||
}
|
||||
|
||||
138
lib/pages.ts
138
lib/pages.ts
@@ -1,80 +1,112 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import matter from 'gray-matter';
|
||||
import { mapSlugToFileSlug } from './slugs';
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
|
||||
export interface PageFrontmatter {
|
||||
title: string;
|
||||
excerpt: string;
|
||||
featuredImage: string | null;
|
||||
locale: string;
|
||||
public?: boolean;
|
||||
}
|
||||
|
||||
export interface PageMdx {
|
||||
slug: string;
|
||||
frontmatter: PageFrontmatter;
|
||||
content: string;
|
||||
content: any; // Lexical AST Document
|
||||
}
|
||||
|
||||
export async function getPageBySlug(slug: string, locale: string): Promise<PageMdx | null> {
|
||||
// Map translated slug to file slug
|
||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||
const pagesDir = path.join(process.cwd(), 'data', 'pages', locale);
|
||||
const filePath = path.join(pagesDir, `${fileSlug}.mdx`);
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
const result = await payload.find({
|
||||
collection: 'pages' as any,
|
||||
where: {
|
||||
slug: { equals: slug },
|
||||
locale: { equals: locale },
|
||||
},
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
const docs = result.docs as any[];
|
||||
|
||||
if (!docs || docs.length === 0) return null;
|
||||
|
||||
const doc = docs[0];
|
||||
|
||||
return {
|
||||
slug: fileSlug,
|
||||
frontmatter: data as PageFrontmatter,
|
||||
content,
|
||||
slug: doc.slug,
|
||||
frontmatter: {
|
||||
title: doc.title,
|
||||
excerpt: doc.excerpt || '',
|
||||
locale: doc.locale,
|
||||
featuredImage:
|
||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
|
||||
: null,
|
||||
} as PageFrontmatter,
|
||||
content: doc.content as any, // Native Lexical Editor State
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAllPages(locale: string): Promise<PageMdx[]> {
|
||||
const pagesDir = path.join(process.cwd(), 'data', 'pages', locale);
|
||||
if (!fs.existsSync(pagesDir)) return [];
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
const files = fs.readdirSync(pagesDir);
|
||||
const pages = await Promise.all(
|
||||
files
|
||||
.filter((file) => file.endsWith('.mdx'))
|
||||
.map((file) => {
|
||||
const fileSlug = file.replace(/\.mdx$/, '');
|
||||
const filePath = path.join(pagesDir, file);
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
const result = await payload.find({
|
||||
collection: 'pages' as any,
|
||||
where: {
|
||||
locale: {
|
||||
equals: locale,
|
||||
},
|
||||
},
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const docs = result.docs as any[];
|
||||
|
||||
return docs.map((doc: any) => {
|
||||
return {
|
||||
slug: fileSlug,
|
||||
frontmatter: data as PageFrontmatter,
|
||||
content,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return pages.filter((p): p is PageMdx => p !== null);
|
||||
}
|
||||
|
||||
export async function getAllPagesMetadata(locale: string): Promise<Partial<PageMdx>[]> {
|
||||
const pagesDir = path.join(process.cwd(), 'data', 'pages', locale);
|
||||
if (!fs.existsSync(pagesDir)) return [];
|
||||
|
||||
const files = fs.readdirSync(pagesDir);
|
||||
return files
|
||||
.filter((file) => file.endsWith('.mdx'))
|
||||
.map((file) => {
|
||||
const fileSlug = file.replace(/\.mdx$/, '');
|
||||
const filePath = path.join(pagesDir, file);
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data } = matter(fileContent);
|
||||
return {
|
||||
slug: fileSlug,
|
||||
frontmatter: data as PageFrontmatter,
|
||||
slug: doc.slug,
|
||||
frontmatter: {
|
||||
title: doc.title,
|
||||
excerpt: doc.excerpt || '',
|
||||
locale: doc.locale,
|
||||
featuredImage:
|
||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
|
||||
: null,
|
||||
} as PageFrontmatter,
|
||||
content: doc.content as any,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAllPagesMetadata(locale: string): Promise<Partial<PageMdx>[]> {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
const result = await payload.find({
|
||||
collection: 'pages' as any,
|
||||
where: {
|
||||
locale: {
|
||||
equals: locale,
|
||||
},
|
||||
},
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const docs = result.docs as any[];
|
||||
|
||||
return docs.map((doc: any) => {
|
||||
return {
|
||||
slug: doc.slug,
|
||||
frontmatter: {
|
||||
title: doc.title,
|
||||
excerpt: doc.excerpt || '',
|
||||
locale: doc.locale,
|
||||
featuredImage:
|
||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
|
||||
: null,
|
||||
} as PageFrontmatter,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,9 +11,19 @@ import {
|
||||
import { PinoLoggerService } from './logging/pino-logger-service';
|
||||
import { config, getMaskedConfig } from '../config';
|
||||
|
||||
let singleton: AppServices | undefined;
|
||||
declare global {
|
||||
var __appServices: AppServices | undefined;
|
||||
}
|
||||
|
||||
// Add a local cache to prevent re-looking up globalThis frequently
|
||||
let serverSingleton: AppServices | undefined;
|
||||
|
||||
export function getServerAppServices(): AppServices {
|
||||
if (singleton) return singleton;
|
||||
if (serverSingleton) return serverSingleton;
|
||||
if (globalThis.__appServices) {
|
||||
serverSingleton = globalThis.__appServices;
|
||||
return serverSingleton;
|
||||
}
|
||||
|
||||
// Create logger first to log initialization
|
||||
const logger = new PinoLoggerService('server');
|
||||
@@ -74,9 +84,9 @@ export function getServerAppServices(): AppServices {
|
||||
level: config.logging.level,
|
||||
});
|
||||
|
||||
singleton = new AppServices(analytics, errors, cache, logger, notifications);
|
||||
globalThis.__appServices = new AppServices(analytics, errors, cache, logger, notifications);
|
||||
|
||||
logger.info('All application services initialized successfully');
|
||||
|
||||
return singleton;
|
||||
return globalThis.__appServices;
|
||||
}
|
||||
|
||||
@@ -9,65 +9,18 @@ import { PinoLoggerService } from './logging/pino-logger-service';
|
||||
import { NoopNotificationService } from './notifications/gotify-notification-service';
|
||||
import { config, getMaskedConfig } from '../config';
|
||||
|
||||
/**
|
||||
* Singleton instance of AppServices.
|
||||
*
|
||||
* In Next.js, module singletons are per-process (server) and per-tab (client).
|
||||
* This is sufficient for a small service layer and provides better performance
|
||||
* than creating new instances on every request.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
declare global {
|
||||
var __appServices: AppServices | undefined;
|
||||
}
|
||||
|
||||
let singleton: AppServices | undefined;
|
||||
|
||||
/**
|
||||
* Get the application services singleton.
|
||||
*
|
||||
* This function creates and caches the application services, including:
|
||||
* - Analytics service (Umami or no-op)
|
||||
* - Error reporting service (GlitchTip/Sentry or no-op)
|
||||
* - Cache service (in-memory)
|
||||
*
|
||||
* The services are configured based on environment variables:
|
||||
* - `UMAMI_WEBSITE_ID` - Enables Umami analytics
|
||||
* - `SENTRY_DSN` - Enables error reporting (server-side direct, client-side via relay)
|
||||
*
|
||||
* @returns {AppServices} The application services singleton
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Get services in a client component
|
||||
* import { getAppServices } from '@/lib/services/create-services';
|
||||
*
|
||||
* const services = getAppServices();
|
||||
* services.analytics.track('button_click', { button_id: 'cta' });
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Get services in a server component or API route
|
||||
* import { getAppServices } from '@/lib/services/create-services';
|
||||
*
|
||||
* const services = getAppServices();
|
||||
* await services.cache.set('key', 'value');
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Automatic service selection based on environment
|
||||
* // If UMAMI_WEBSITE_ID is set:
|
||||
* // services.analytics = UmamiAnalyticsService
|
||||
* // If not set:
|
||||
* // services.analytics = NoopAnalyticsService (safe no-op)
|
||||
* ```
|
||||
*
|
||||
* @see {@link UmamiAnalyticsService} for analytics implementation
|
||||
* @see {@link NoopAnalyticsService} for no-op fallback
|
||||
* @see {@link GlitchtipErrorReportingService} for error reporting
|
||||
* @see {@link MemoryCacheService} for caching
|
||||
*/
|
||||
export function getAppServices(): AppServices {
|
||||
// Return cached instance if available
|
||||
if (typeof window === 'undefined' && globalThis.__appServices) return globalThis.__appServices;
|
||||
if (singleton) return singleton;
|
||||
|
||||
// Create logger first to log initialization
|
||||
@@ -127,9 +80,6 @@ export function getAppServices(): AppServices {
|
||||
logger.info('Noop error reporting service initialized (error reporting disabled)');
|
||||
}
|
||||
|
||||
// IMPORTANT: This module is imported by client components.
|
||||
// Do not import Node-only modules (like the `redis` client) here.
|
||||
// Use [`getServerAppServices()`](lib/services/create-services.server.ts:1) on the server.
|
||||
const cache = new MemoryCacheService();
|
||||
logger.info('Memory cache service initialized');
|
||||
|
||||
@@ -139,6 +89,11 @@ export function getAppServices(): AppServices {
|
||||
});
|
||||
|
||||
// Create and cache the singleton
|
||||
if (typeof window === 'undefined') {
|
||||
globalThis.__appServices = new AppServices(analytics, errors, cache, logger, notifications);
|
||||
return globalThis.__appServices;
|
||||
}
|
||||
|
||||
singleton = new AppServices(analytics, errors, cache, logger, notifications);
|
||||
|
||||
logger.info('All application services initialized successfully');
|
||||
|
||||
@@ -97,7 +97,7 @@ export class GlitchtipErrorReportingService implements ErrorReportingService {
|
||||
this.getSentry().then((Sentry) => Sentry.setTag(key, value));
|
||||
}
|
||||
|
||||
withScope<T>(fn: () => T, context?: Record<string, unknown>): T {
|
||||
withScope<T>(fn: () => T, _context?: Record<string, unknown>): T {
|
||||
if (!this.options.enabled) return fn();
|
||||
|
||||
// Since withScope mandates executing fn() synchronously to return T,
|
||||
|
||||
@@ -10,40 +10,14 @@ const intlMiddleware = createMiddleware({
|
||||
defaultLocale: 'en',
|
||||
});
|
||||
|
||||
const imgproxyStatus = { isDown: false, lastCheck: 0 };
|
||||
|
||||
async function isImgproxyDown() {
|
||||
const now = Date.now();
|
||||
if (now - imgproxyStatus.lastCheck > 60000) {
|
||||
try {
|
||||
const imgproxyUrl = process.env.IMGPROXY_URL || 'http://klz-imgproxy:8080';
|
||||
const checkUrl = imgproxyUrl.startsWith('http') ? imgproxyUrl : `https://${imgproxyUrl}`;
|
||||
const res = await fetch(checkUrl, { signal: AbortSignal.timeout(2000) });
|
||||
imgproxyStatus.isDown = res.status >= 500;
|
||||
} catch (e) {
|
||||
imgproxyStatus.isDown = true;
|
||||
}
|
||||
imgproxyStatus.lastCheck = now;
|
||||
}
|
||||
return imgproxyStatus.isDown;
|
||||
}
|
||||
|
||||
export default async function middleware(request: NextRequest) {
|
||||
const { method, url, headers } = request;
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
if (pathname.startsWith('/_img/')) {
|
||||
if (await isImgproxyDown()) {
|
||||
const originalUrl = request.nextUrl.searchParams.get('url');
|
||||
if (originalUrl) {
|
||||
return NextResponse.redirect(originalUrl);
|
||||
}
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Explicit bypass for infrastructure routes to avoid locale redirects/interception
|
||||
// Explicit bypass for infrastructure and Payload CMS routes to avoid locale redirects/interception
|
||||
if (
|
||||
pathname.startsWith('/admin') ||
|
||||
pathname.startsWith('/api') ||
|
||||
pathname.startsWith('/stats') ||
|
||||
pathname.startsWith('/errors') ||
|
||||
pathname.startsWith('/health') ||
|
||||
@@ -125,8 +99,7 @@ export default async function middleware(request: NextRequest) {
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!api|_next/static|_next/image|favicon.ico|manifest.webmanifest|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|txt|vcf|xml|webm|mp4|map)$).*)',
|
||||
'/(de|en)/:path*',
|
||||
'/((?!api|_next/static|_next/image|favicon.ico|admin|manifest.webmanifest|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|txt|vcf|xml|webm|mp4|map)$).*)',
|
||||
'/(de|en)/:path*',
|
||||
],
|
||||
};
|
||||
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
101
next.config.mjs
101
next.config.mjs
@@ -2,8 +2,9 @@ import withMintelConfig from '@mintel/next-config';
|
||||
import withBundleAnalyzer from '@next/bundle-analyzer';
|
||||
import { withSentryConfig } from '@sentry/nextjs';
|
||||
|
||||
console.log('🚀 NEXT CONFIG LOADED FROM:', process.cwd());
|
||||
import { withPayload } from '@payloadcms/next/withPayload';
|
||||
|
||||
console.log('🚀 NEXT CONFIG LOADED FROM:', process.cwd());
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
onDemandEntries: {
|
||||
@@ -11,9 +12,10 @@ const nextConfig = {
|
||||
maxInactiveAge: 60 * 1000,
|
||||
},
|
||||
experimental: {
|
||||
optimizeCss: true,
|
||||
optimizePackageImports: ['lucide-react', 'framer-motion', '@/components/ui'],
|
||||
},
|
||||
swcMinify: false,
|
||||
reactStrictMode: false,
|
||||
productionBrowserSourceMaps: false,
|
||||
logging: {
|
||||
fetches: {
|
||||
@@ -22,38 +24,39 @@ const nextConfig = {
|
||||
},
|
||||
output: 'standalone',
|
||||
async headers() {
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
const umamiDomain = new URL(process.env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me').origin;
|
||||
const directusDomain = new URL(process.env.DIRECTUS_URL || 'https://cms.klz-cables.com').origin;
|
||||
const imgproxyDomain = new URL(process.env.IMGPROXY_URL || 'http://klz-imgproxy:8080').origin;
|
||||
const glitchtipDomain = new URL(process.env.SENTRY_DSN ? new URL(process.env.SENTRY_DSN).origin : 'https://errors.infra.mintel.me').origin;
|
||||
// Additional domains that need to be whitelisted for images
|
||||
const extraImgDomains = [
|
||||
'https://klz-cables.com',
|
||||
'https://staging.klz-cables.com',
|
||||
'https://testing.klz-cables.com',
|
||||
'http://klz.localhost',
|
||||
'https://www.gravatar.com',
|
||||
'https://gravatar.com',
|
||||
].join(' ');
|
||||
|
||||
const cspHeader = `
|
||||
default-src 'self';
|
||||
script-src 'self' 'unsafe-inline' 'unsafe-eval' ${umamiDomain};
|
||||
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
|
||||
font-src 'self' https://fonts.gstatic.com;
|
||||
img-src 'self' data: blob: ${imgproxyDomain} ${directusDomain};
|
||||
connect-src 'self' ${umamiDomain} ${glitchtipDomain} ${directusDomain};
|
||||
img-src 'self' data: blob: ${extraImgDomains};
|
||||
connect-src 'self' ${umamiDomain} ${glitchtipDomain};
|
||||
frame-src 'self';
|
||||
object-src 'none';
|
||||
base-uri 'self';
|
||||
form-action 'self';
|
||||
frame-ancestors 'none';
|
||||
upgrade-insecure-requests;
|
||||
${isProd ? 'upgrade-insecure-requests;' : ''}
|
||||
`.replace(/\s{2,}/g, ' ').trim();
|
||||
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: [
|
||||
const secureHeaders = [
|
||||
{
|
||||
key: 'Content-Security-Policy',
|
||||
value: cspHeader,
|
||||
},
|
||||
{
|
||||
key: 'Strict-Transport-Security',
|
||||
value: 'max-age=63072000; includeSubDomains; preload',
|
||||
},
|
||||
{
|
||||
key: 'X-Frame-Options',
|
||||
value: 'DENY',
|
||||
@@ -70,7 +73,19 @@ const nextConfig = {
|
||||
key: 'Permissions-Policy',
|
||||
value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()',
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
if (isProd) {
|
||||
secureHeaders.push({
|
||||
key: 'Strict-Transport-Security',
|
||||
value: 'max-age=63072000; includeSubDomains; preload',
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: secureHeaders,
|
||||
},
|
||||
];
|
||||
},
|
||||
@@ -373,33 +388,31 @@ const nextConfig = {
|
||||
source: '/vcf/michael-bodemer',
|
||||
destination: '/michael-bodemer.vcf',
|
||||
permanent: true,
|
||||
},
|
||||
}
|
||||
];
|
||||
},
|
||||
images: {
|
||||
loader: 'custom',
|
||||
loaderFile: './lib/imgproxy-loader.ts',
|
||||
dangerouslyAllowSVG: true,
|
||||
contentDispositionType: "attachment",
|
||||
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
|
||||
formats: ['image/webp'],
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'klz-cables.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '*.klz-cables.com',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'klz.localhost',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'localhost',
|
||||
},
|
||||
],
|
||||
},
|
||||
async rewrites() {
|
||||
const umamiUrl =
|
||||
process.env.UMAMI_API_ENDPOINT ||
|
||||
process.env.UMAMI_SCRIPT_URL ||
|
||||
process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL ||
|
||||
'https://analytics.infra.mintel.me';
|
||||
const glitchtipUrl = process.env.SENTRY_DSN
|
||||
? new URL(process.env.SENTRY_DSN).origin
|
||||
: 'https://errors.infra.mintel.me';
|
||||
|
||||
const directusUrl = process.env.INTERNAL_DIRECTUS_URL || process.env.DIRECTUS_URL || 'https://cms.klz-cables.com';
|
||||
|
||||
let imgproxyUrl = process.env.IMGPROXY_URL || 'http://klz-imgproxy:8080';
|
||||
if (!imgproxyUrl.startsWith('http')) {
|
||||
imgproxyUrl = `https://${imgproxyUrl}`;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
source: '/de/produkte',
|
||||
@@ -409,14 +422,6 @@ const nextConfig = {
|
||||
source: '/de/produkte/:path*',
|
||||
destination: '/de/products/:path*',
|
||||
},
|
||||
{
|
||||
source: '/cms/:path*',
|
||||
destination: `${directusUrl}/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/_img/:path*',
|
||||
destination: `${imgproxyUrl}/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -425,6 +430,6 @@ const withAnalyzer = withBundleAnalyzer({
|
||||
enabled: process.env.ANALYZE === 'true',
|
||||
});
|
||||
|
||||
export default withAnalyzer(withMintelConfig(nextConfig, {
|
||||
export default withPayload(withAnalyzer(withMintelConfig(nextConfig, {
|
||||
hideSourceMaps: true,
|
||||
}));
|
||||
})));
|
||||
|
||||
13328
openrouter_models.json
Normal file
13328
openrouter_models.json
Normal file
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -4,11 +4,15 @@
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.18.3",
|
||||
"dependencies": {
|
||||
"@directus/sdk": "^21.0.0",
|
||||
"@mintel/mail": "1.8.3",
|
||||
"@mintel/next-config": "1.8.3",
|
||||
"@mintel/next-feedback": "1.8.10",
|
||||
"@mintel/next-utils": "^1.7.15",
|
||||
"@payloadcms/db-postgres": "^3.77.0",
|
||||
"@payloadcms/email-nodemailer": "^3.77.0",
|
||||
"@payloadcms/next": "^3.77.0",
|
||||
"@payloadcms/richtext-lexical": "^3.77.0",
|
||||
"@payloadcms/ui": "^3.77.0",
|
||||
"@react-email/components": "^1.0.7",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"@sentry/nextjs": "^10.39.0",
|
||||
@@ -16,6 +20,7 @@
|
||||
"axios": "^1.13.5",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.34.0",
|
||||
"graphql": "^16.12.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"i18next": "^25.7.3",
|
||||
"import-in-the-middle": "^1.11.0",
|
||||
@@ -26,6 +31,7 @@
|
||||
"next-intl": "^4.8.2",
|
||||
"next-mdx-remote": "^5.0.0",
|
||||
"nodemailer": "^7.0.12",
|
||||
"payload": "^3.77.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pino": "^10.3.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
@@ -85,9 +91,8 @@
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "docker network create infra 2>/dev/null || true && echo '\\n🚀 Development Environment Starting...\\n\\n📱 App (Next.js): http://localhost:3000\\n📱 App (Traefik): http://klz.localhost\\n🗄️ CMS: http://cms.klz.localhost/admin\\n🚦 Traefik: http://localhost:8080\\n\\n(Press Ctrl+C to stop)\\n' && docker-compose down --remove-orphans && docker-compose up --build klz-app klz-cms klz-db klz-gatekeeper",
|
||||
"dev:infra": "docker network create infra 2>/dev/null || true && docker-compose up -d klz-cms klz-db klz-gatekeeper",
|
||||
"dev:local": "next dev",
|
||||
"dev": "docker network create infra 2>/dev/null || true && echo \"\\n🚀 Dockerized Environment Starting...\\n\" && COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up klz-app klz-db",
|
||||
"dev:infra": "docker-compose up klz-db klz-gatekeeper",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
|
||||
702
payload-types.ts
Normal file
702
payload-types.ts
Normal file
@@ -0,0 +1,702 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* This file was automatically generated by Payload.
|
||||
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
|
||||
* and re-run `payload generate:types` to regenerate this file.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supported timezones in IANA format.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "supportedTimezones".
|
||||
*/
|
||||
export type SupportedTimezones =
|
||||
| 'Pacific/Midway'
|
||||
| 'Pacific/Niue'
|
||||
| 'Pacific/Honolulu'
|
||||
| 'Pacific/Rarotonga'
|
||||
| 'America/Anchorage'
|
||||
| 'Pacific/Gambier'
|
||||
| 'America/Los_Angeles'
|
||||
| 'America/Tijuana'
|
||||
| 'America/Denver'
|
||||
| 'America/Phoenix'
|
||||
| 'America/Chicago'
|
||||
| 'America/Guatemala'
|
||||
| 'America/New_York'
|
||||
| 'America/Bogota'
|
||||
| 'America/Caracas'
|
||||
| 'America/Santiago'
|
||||
| 'America/Buenos_Aires'
|
||||
| 'America/Sao_Paulo'
|
||||
| 'Atlantic/South_Georgia'
|
||||
| 'Atlantic/Azores'
|
||||
| 'Atlantic/Cape_Verde'
|
||||
| 'Europe/London'
|
||||
| 'Europe/Berlin'
|
||||
| 'Africa/Lagos'
|
||||
| 'Europe/Athens'
|
||||
| 'Africa/Cairo'
|
||||
| 'Europe/Moscow'
|
||||
| 'Asia/Riyadh'
|
||||
| 'Asia/Dubai'
|
||||
| 'Asia/Baku'
|
||||
| 'Asia/Karachi'
|
||||
| 'Asia/Tashkent'
|
||||
| 'Asia/Calcutta'
|
||||
| 'Asia/Dhaka'
|
||||
| 'Asia/Almaty'
|
||||
| 'Asia/Jakarta'
|
||||
| 'Asia/Bangkok'
|
||||
| 'Asia/Shanghai'
|
||||
| 'Asia/Singapore'
|
||||
| 'Asia/Tokyo'
|
||||
| 'Asia/Seoul'
|
||||
| 'Australia/Brisbane'
|
||||
| 'Australia/Sydney'
|
||||
| 'Pacific/Guam'
|
||||
| 'Pacific/Noumea'
|
||||
| 'Pacific/Auckland'
|
||||
| 'Pacific/Fiji';
|
||||
|
||||
export interface Config {
|
||||
auth: {
|
||||
users: UserAuthOperations;
|
||||
};
|
||||
blocks: {};
|
||||
collections: {
|
||||
users: User;
|
||||
media: Media;
|
||||
posts: Post;
|
||||
'form-submissions': FormSubmission;
|
||||
products: Product;
|
||||
pages: Page;
|
||||
'payload-kv': PayloadKv;
|
||||
'payload-locked-documents': PayloadLockedDocument;
|
||||
'payload-preferences': PayloadPreference;
|
||||
'payload-migrations': PayloadMigration;
|
||||
};
|
||||
collectionsJoins: {};
|
||||
collectionsSelect: {
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
media: MediaSelect<false> | MediaSelect<true>;
|
||||
posts: PostsSelect<false> | PostsSelect<true>;
|
||||
'form-submissions': FormSubmissionsSelect<false> | FormSubmissionsSelect<true>;
|
||||
products: ProductsSelect<false> | ProductsSelect<true>;
|
||||
pages: PagesSelect<false> | PagesSelect<true>;
|
||||
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||
'payload-locked-documents':
|
||||
| PayloadLockedDocumentsSelect<false>
|
||||
| PayloadLockedDocumentsSelect<true>;
|
||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||
};
|
||||
db: {
|
||||
defaultIDType: number;
|
||||
};
|
||||
fallbackLocale: null;
|
||||
globals: {};
|
||||
globalsSelect: {};
|
||||
locale: null;
|
||||
user: User;
|
||||
jobs: {
|
||||
tasks: unknown;
|
||||
workflows: unknown;
|
||||
};
|
||||
}
|
||||
export interface UserAuthOperations {
|
||||
forgotPassword: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
login: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
registerFirstUser: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
unlock: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users".
|
||||
*/
|
||||
export interface User {
|
||||
id: number;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
email: string;
|
||||
resetPasswordToken?: string | null;
|
||||
resetPasswordExpiration?: string | null;
|
||||
salt?: string | null;
|
||||
hash?: string | null;
|
||||
loginAttempts?: number | null;
|
||||
lockUntil?: string | null;
|
||||
sessions?:
|
||||
| {
|
||||
id: string;
|
||||
createdAt?: string | null;
|
||||
expiresAt: string;
|
||||
}[]
|
||||
| null;
|
||||
password?: string | null;
|
||||
collection: 'users';
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "media".
|
||||
*/
|
||||
export interface Media {
|
||||
id: number;
|
||||
alt: string;
|
||||
caption?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
url?: string | null;
|
||||
thumbnailURL?: string | null;
|
||||
filename?: string | null;
|
||||
mimeType?: string | null;
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
sizes?: {
|
||||
thumbnail?: {
|
||||
url?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
mimeType?: string | null;
|
||||
filesize?: number | null;
|
||||
filename?: string | null;
|
||||
};
|
||||
card?: {
|
||||
url?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
mimeType?: string | null;
|
||||
filesize?: number | null;
|
||||
filename?: string | null;
|
||||
};
|
||||
tablet?: {
|
||||
url?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
mimeType?: string | null;
|
||||
filesize?: number | null;
|
||||
filename?: string | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "posts".
|
||||
*/
|
||||
export interface Post {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
/**
|
||||
* A short summary for blog feed cards and SEO.
|
||||
*/
|
||||
excerpt?: string | null;
|
||||
/**
|
||||
* Future dates will schedule the post to publish automatically.
|
||||
*/
|
||||
date: string;
|
||||
/**
|
||||
* The primary Hero image used for headers and OpenGraph previews.
|
||||
*/
|
||||
featuredImage?: (number | null) | Media;
|
||||
locale: 'en' | 'de';
|
||||
/**
|
||||
* Used for tag bucketing (e.g. "Kabel Technologie").
|
||||
*/
|
||||
category?: string | null;
|
||||
content?: {
|
||||
root: {
|
||||
type: string;
|
||||
children: {
|
||||
type: any;
|
||||
version: number;
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
direction: ('ltr' | 'rtl') | null;
|
||||
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
|
||||
indent: number;
|
||||
version: number;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
} | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
_status?: ('draft' | 'published') | null;
|
||||
}
|
||||
/**
|
||||
* Captured leads from Contact and Product Quote forms.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "form-submissions".
|
||||
*/
|
||||
export interface FormSubmission {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
type: 'contact' | 'product_quote';
|
||||
/**
|
||||
* The specific KLZ product the user requested a quote for.
|
||||
*/
|
||||
productName?: string | null;
|
||||
message: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "products".
|
||||
*/
|
||||
export interface Product {
|
||||
id: number;
|
||||
title: string;
|
||||
sku: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
locale: 'en' | 'de';
|
||||
categories: {
|
||||
category?: string | null;
|
||||
id?: string | null;
|
||||
}[];
|
||||
/**
|
||||
* The primary thumbnail used in list views.
|
||||
*/
|
||||
featuredImage?: (number | null) | Media;
|
||||
images?: (number | Media)[] | null;
|
||||
application?: {
|
||||
root: {
|
||||
type: string;
|
||||
children: {
|
||||
type: any;
|
||||
version: number;
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
direction: ('ltr' | 'rtl') | null;
|
||||
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
|
||||
indent: number;
|
||||
version: number;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
} | null;
|
||||
content?: {
|
||||
root: {
|
||||
type: string;
|
||||
children: {
|
||||
type: any;
|
||||
version: number;
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
direction: ('ltr' | 'rtl') | null;
|
||||
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
|
||||
indent: number;
|
||||
version: number;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
} | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
_status?: ('draft' | 'published') | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "pages".
|
||||
*/
|
||||
export interface Page {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
locale: 'en' | 'de';
|
||||
excerpt?: string | null;
|
||||
featuredImage?: (number | null) | Media;
|
||||
content: {
|
||||
root: {
|
||||
type: string;
|
||||
children: {
|
||||
type: any;
|
||||
version: number;
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
direction: ('ltr' | 'rtl') | null;
|
||||
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
|
||||
indent: number;
|
||||
version: number;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-kv".
|
||||
*/
|
||||
export interface PayloadKv {
|
||||
id: number;
|
||||
key: string;
|
||||
data:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-locked-documents".
|
||||
*/
|
||||
export interface PayloadLockedDocument {
|
||||
id: number;
|
||||
document?:
|
||||
| ({
|
||||
relationTo: 'users';
|
||||
value: number | User;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'media';
|
||||
value: number | Media;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'posts';
|
||||
value: number | Post;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'form-submissions';
|
||||
value: number | FormSubmission;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'products';
|
||||
value: number | Product;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'pages';
|
||||
value: number | Page;
|
||||
} | null);
|
||||
globalSlug?: string | null;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
value: number | User;
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-preferences".
|
||||
*/
|
||||
export interface PayloadPreference {
|
||||
id: number;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
value: number | User;
|
||||
};
|
||||
key?: string | null;
|
||||
value?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-migrations".
|
||||
*/
|
||||
export interface PayloadMigration {
|
||||
id: number;
|
||||
name?: string | null;
|
||||
batch?: number | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users_select".
|
||||
*/
|
||||
export interface UsersSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
email?: T;
|
||||
resetPasswordToken?: T;
|
||||
resetPasswordExpiration?: T;
|
||||
salt?: T;
|
||||
hash?: T;
|
||||
loginAttempts?: T;
|
||||
lockUntil?: T;
|
||||
sessions?:
|
||||
| T
|
||||
| {
|
||||
id?: T;
|
||||
createdAt?: T;
|
||||
expiresAt?: T;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "media_select".
|
||||
*/
|
||||
export interface MediaSelect<T extends boolean = true> {
|
||||
alt?: T;
|
||||
caption?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
url?: T;
|
||||
thumbnailURL?: T;
|
||||
filename?: T;
|
||||
mimeType?: T;
|
||||
filesize?: T;
|
||||
width?: T;
|
||||
height?: T;
|
||||
focalX?: T;
|
||||
focalY?: T;
|
||||
sizes?:
|
||||
| T
|
||||
| {
|
||||
thumbnail?:
|
||||
| T
|
||||
| {
|
||||
url?: T;
|
||||
width?: T;
|
||||
height?: T;
|
||||
mimeType?: T;
|
||||
filesize?: T;
|
||||
filename?: T;
|
||||
};
|
||||
card?:
|
||||
| T
|
||||
| {
|
||||
url?: T;
|
||||
width?: T;
|
||||
height?: T;
|
||||
mimeType?: T;
|
||||
filesize?: T;
|
||||
filename?: T;
|
||||
};
|
||||
tablet?:
|
||||
| T
|
||||
| {
|
||||
url?: T;
|
||||
width?: T;
|
||||
height?: T;
|
||||
mimeType?: T;
|
||||
filesize?: T;
|
||||
filename?: T;
|
||||
};
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "posts_select".
|
||||
*/
|
||||
export interface PostsSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
slug?: T;
|
||||
excerpt?: T;
|
||||
date?: T;
|
||||
featuredImage?: T;
|
||||
locale?: T;
|
||||
category?: T;
|
||||
content?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
_status?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "form-submissions_select".
|
||||
*/
|
||||
export interface FormSubmissionsSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
email?: T;
|
||||
type?: T;
|
||||
productName?: T;
|
||||
message?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "products_select".
|
||||
*/
|
||||
export interface ProductsSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
sku?: T;
|
||||
slug?: T;
|
||||
description?: T;
|
||||
locale?: T;
|
||||
categories?:
|
||||
| T
|
||||
| {
|
||||
category?: T;
|
||||
id?: T;
|
||||
};
|
||||
featuredImage?: T;
|
||||
images?: T;
|
||||
application?: T;
|
||||
content?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
_status?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "pages_select".
|
||||
*/
|
||||
export interface PagesSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
slug?: T;
|
||||
locale?: T;
|
||||
excerpt?: T;
|
||||
featuredImage?: T;
|
||||
content?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-kv_select".
|
||||
*/
|
||||
export interface PayloadKvSelect<T extends boolean = true> {
|
||||
key?: T;
|
||||
data?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-locked-documents_select".
|
||||
*/
|
||||
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
|
||||
document?: T;
|
||||
globalSlug?: T;
|
||||
user?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-preferences_select".
|
||||
*/
|
||||
export interface PayloadPreferencesSelect<T extends boolean = true> {
|
||||
user?: T;
|
||||
key?: T;
|
||||
value?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-migrations_select".
|
||||
*/
|
||||
export interface PayloadMigrationsSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
batch?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "StatsBlock".
|
||||
*/
|
||||
export interface StatsBlock {
|
||||
stats: {
|
||||
value: string;
|
||||
label: string;
|
||||
id?: string | null;
|
||||
}[];
|
||||
id?: string | null;
|
||||
blockName?: string | null;
|
||||
blockType: 'stats';
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "SplitHeadingBlock".
|
||||
*/
|
||||
export interface SplitHeadingBlock {
|
||||
title: string;
|
||||
id?: string | null;
|
||||
level?: ('h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6') | null;
|
||||
blockName?: string | null;
|
||||
blockType: 'splitHeading';
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "ProductTabsBlock".
|
||||
*/
|
||||
export interface ProductTabsBlock {
|
||||
technicalItems?:
|
||||
| {
|
||||
label: string;
|
||||
value: string;
|
||||
unit?: string | null;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
voltageTables?:
|
||||
| {
|
||||
voltageLabel: string;
|
||||
metaItems?:
|
||||
| {
|
||||
label: string;
|
||||
value: string;
|
||||
unit?: string | null;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
columns?:
|
||||
| {
|
||||
key: string;
|
||||
label: string;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
rows?:
|
||||
| {
|
||||
configuration: string;
|
||||
cells?:
|
||||
| {
|
||||
value?: string | null;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
id?: string | null;
|
||||
blockName?: string | null;
|
||||
blockType: 'productTabs';
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "auth".
|
||||
*/
|
||||
export interface Auth {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
declare module 'payload' {
|
||||
export interface GeneratedTypes extends Config {}
|
||||
}
|
||||
66
payload.config.ts
Normal file
66
payload.config.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { buildConfig } from 'payload';
|
||||
import { postgresAdapter } from '@payloadcms/db-postgres';
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical';
|
||||
import sharp from 'sharp';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { nodemailerAdapter } from '@payloadcms/email-nodemailer';
|
||||
import { BlocksFeature } from '@payloadcms/richtext-lexical';
|
||||
import { payloadBlocks } from './src/payload/blocks/allBlocks';
|
||||
|
||||
// Disable sharp cache to prevent memory leaks in Docker
|
||||
sharp.cache(false);
|
||||
|
||||
import { Users } from './src/payload/collections/Users';
|
||||
import { Media } from './src/payload/collections/Media';
|
||||
import { Posts } from './src/payload/collections/Posts';
|
||||
import { FormSubmissions } from './src/payload/collections/FormSubmissions';
|
||||
import { Products } from './src/payload/collections/Products';
|
||||
import { Pages } from './src/payload/collections/Pages';
|
||||
|
||||
const filename = fileURLToPath(import.meta.url);
|
||||
const dirname = path.dirname(filename);
|
||||
|
||||
export default buildConfig({
|
||||
admin: {
|
||||
user: Users.slug,
|
||||
importMap: {
|
||||
baseDir: path.resolve(dirname),
|
||||
},
|
||||
},
|
||||
collections: [Users, Media, Posts, FormSubmissions, Products, Pages],
|
||||
editor: lexicalEditor({
|
||||
features: ({ defaultFeatures }) => [
|
||||
...defaultFeatures,
|
||||
BlocksFeature({
|
||||
blocks: payloadBlocks,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
secret: process.env.PAYLOAD_SECRET || 'fallback-secret-for-dev',
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||
},
|
||||
db: postgresAdapter({
|
||||
pool: {
|
||||
connectionString:
|
||||
process.env.DATABASE_URI ||
|
||||
process.env.POSTGRES_URI ||
|
||||
'postgresql://payload:120in09oenaoinsd9iaidon@localhost:5432/payload',
|
||||
},
|
||||
}),
|
||||
email: nodemailerAdapter({
|
||||
defaultFromAddress: process.env.MAIL_FROM?.replace(/.*<|>.*/g, '') || 'postmaster@mg.mintel.me',
|
||||
defaultFromName: process.env.MAIL_FROM?.split('<')[0]?.trim() || 'KLZ Cables',
|
||||
transportOptions: {
|
||||
host: process.env.MAIL_HOST || 'smtp.eu.mailgun.org',
|
||||
port: Number(process.env.MAIL_PORT) || 587,
|
||||
auth: {
|
||||
user: process.env.MAIL_USERNAME,
|
||||
pass: process.env.MAIL_PASSWORD,
|
||||
},
|
||||
},
|
||||
}),
|
||||
sharp,
|
||||
plugins: [],
|
||||
});
|
||||
2137
pnpm-lock.yaml
generated
2137
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,65 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
ENV=$1
|
||||
REMOTE_HOST="root@alpha.mintel.me"
|
||||
REMOTE_DIR="/home/deploy/sites/klz-cables.com"
|
||||
|
||||
if [ -z "$ENV" ]; then
|
||||
echo "Usage: ./scripts/cms-apply.sh [local|testing|staging|production]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PRJ_ID=$(jq -r .name package.json | sed 's/@mintel\///' | sed 's/\.com$//' | sed 's/-nextjs$//' | sed 's/-cables$//')
|
||||
|
||||
case $ENV in
|
||||
local)
|
||||
CONTAINER=$(docker compose ps -q klz-cms)
|
||||
if [ -z "$CONTAINER" ]; then
|
||||
echo "❌ Local directus container not found."
|
||||
exit 1
|
||||
fi
|
||||
echo "🚀 Applying schema locally..."
|
||||
docker exec "$CONTAINER" npx directus schema apply /directus/schema/snapshot.yaml --yes
|
||||
;;
|
||||
testing|staging|production)
|
||||
case $ENV in
|
||||
testing) PROJECT_NAME="${PRJ_ID}-testing" ;;
|
||||
staging) PROJECT_NAME="${PRJ_ID}-staging" ;;
|
||||
production)
|
||||
PROJECT_NAME="${PRJ_ID}-production"
|
||||
OLD_PROJECT_NAME="${PRJ_ID}-prod" # Fallback for previous convention
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "📤 Uploading snapshot to $ENV..."
|
||||
scp ./directus/schema/snapshot.yaml "$REMOTE_HOST:$REMOTE_DIR/directus/schema/snapshot.yaml"
|
||||
|
||||
echo "🔍 Detecting remote container..."
|
||||
REMOTE_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus")
|
||||
|
||||
if [ -z "$REMOTE_CONTAINER" ] && [ -n "$OLD_PROJECT_NAME" ]; then
|
||||
echo "⚠️ $PROJECT_NAME not found, trying fallback $OLD_PROJECT_NAME..."
|
||||
REMOTE_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $OLD_PROJECT_NAME ps -q directus")
|
||||
if [ -n "$REMOTE_CONTAINER" ]; then
|
||||
PROJECT_NAME=$OLD_PROJECT_NAME
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$REMOTE_CONTAINER" ]; then
|
||||
echo "❌ Remote container for $ENV not found (checked $PROJECT_NAME)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🚀 Applying schema to $ENV..."
|
||||
ssh "$REMOTE_HOST" "docker exec $REMOTE_CONTAINER npx directus schema apply /directus/schema/snapshot.yaml --yes"
|
||||
|
||||
echo "🔄 Restarting Directus to clear cache..."
|
||||
ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME restart directus"
|
||||
;;
|
||||
*)
|
||||
echo "❌ Invalid environment."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "✨ Schema apply complete!"
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Detect local container
|
||||
LOCAL_CONTAINER=$(docker compose ps -q directus)
|
||||
|
||||
if [ -z "$LOCAL_CONTAINER" ]; then
|
||||
echo "❌ Local directus container not found. Is it running?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📸 Creating schema snapshot..."
|
||||
# Note: we save it to the mounted volume path inside the container
|
||||
docker exec "$LOCAL_CONTAINER" npx directus schema snapshot /directus/schema/snapshot.yaml
|
||||
|
||||
echo "✅ Snapshot saved to ./directus/schema/snapshot.yaml"
|
||||
140
scripts/migrate-mdx.ts
Normal file
140
scripts/migrate-mdx.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import matter from 'gray-matter';
|
||||
import { parseMarkdownToLexical } from '../src/payload/utils/lexicalParser';
|
||||
|
||||
async function mapImageToMediaId(payload: any, imagePath: string): Promise<string | null> {
|
||||
if (!imagePath) return null;
|
||||
const filename = path.basename(imagePath);
|
||||
|
||||
const media = await payload.find({
|
||||
collection: 'media',
|
||||
where: {
|
||||
filename: {
|
||||
equals: filename,
|
||||
},
|
||||
},
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (media.docs.length > 0) {
|
||||
return media.docs[0].id;
|
||||
}
|
||||
|
||||
// Auto-ingest missing images from legacy public/ directory
|
||||
const cleanPath = imagePath.startsWith('/') ? imagePath.slice(1) : imagePath;
|
||||
const fullPath = path.join(process.cwd(), 'public', cleanPath);
|
||||
|
||||
if (fs.existsSync(fullPath)) {
|
||||
try {
|
||||
console.log(`[Blog Migration] 📤 Ingesting missing Media into Payload: ${filename}`);
|
||||
const newMedia = await payload.create({
|
||||
collection: 'media',
|
||||
data: {
|
||||
alt: filename.replace(/[-_]/g, ' ').replace(/\.[^/.]+$/, ''), // create a human readable alt text
|
||||
},
|
||||
filePath: fullPath,
|
||||
});
|
||||
return newMedia.id;
|
||||
} catch (err: any) {
|
||||
console.error(`[Blog Migration] ❌ Failed to ingest ${filename}:`, err);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[Blog Migration] ⚠️ Missing image entirely from disk: ${fullPath}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function migrateBlogPosts() {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
const locales = ['en', 'de'];
|
||||
for (const locale of locales) {
|
||||
const postsDir = path.join(process.cwd(), 'data', 'blog', locale);
|
||||
if (!fs.existsSync(postsDir)) continue;
|
||||
|
||||
const files = fs.readdirSync(postsDir);
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.mdx')) continue;
|
||||
|
||||
const slug = file.replace(/\.mdx$/, '');
|
||||
const filePath = path.join(postsDir, file);
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
|
||||
console.log(`Migrating ${locale}/${slug}...`);
|
||||
|
||||
const lexicalBlocks = parseMarkdownToLexical(content);
|
||||
const lexicalAST = {
|
||||
root: {
|
||||
type: 'root',
|
||||
format: '',
|
||||
indent: 0,
|
||||
version: 1,
|
||||
children: lexicalBlocks,
|
||||
direction: 'ltr',
|
||||
},
|
||||
};
|
||||
|
||||
const publishDate = data.date ? new Date(data.date).toISOString() : new Date().toISOString();
|
||||
const status = data.public === false ? 'draft' : 'published';
|
||||
|
||||
let featuredImageId = null;
|
||||
if (data.featuredImage || data.image) {
|
||||
featuredImageId = await mapImageToMediaId(payload, data.featuredImage || data.image);
|
||||
}
|
||||
|
||||
try {
|
||||
// Find existing post
|
||||
const existing = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { slug: { equals: slug }, locale: { equals: locale } as any },
|
||||
});
|
||||
|
||||
if (slug.includes('welcome-to-the-future')) {
|
||||
console.log(`\n--- AST for ${slug} ---`);
|
||||
console.log(JSON.stringify(lexicalAST, null, 2));
|
||||
console.log(`-----------------------\n`);
|
||||
}
|
||||
|
||||
if (existing.docs.length > 0) {
|
||||
await payload.update({
|
||||
collection: 'posts',
|
||||
id: existing.docs[0].id,
|
||||
data: {
|
||||
content: lexicalAST as any,
|
||||
_status: status as any,
|
||||
...(featuredImageId ? { featuredImage: featuredImageId } : {}),
|
||||
},
|
||||
});
|
||||
console.log(`✅ AST Components & Image RE-INJECTED for ${slug}`);
|
||||
} else {
|
||||
await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
title: data.title,
|
||||
slug: slug,
|
||||
locale: locale,
|
||||
date: publishDate,
|
||||
category: data.category || '',
|
||||
excerpt: data.excerpt || '',
|
||||
content: lexicalAST as any,
|
||||
_status: status as any,
|
||||
...(featuredImageId ? { featuredImage: featuredImageId } : {}),
|
||||
},
|
||||
});
|
||||
console.log(`✅ Created ${slug}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`❌ Failed ${slug}`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Migration completed.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
migrateBlogPosts().catch(console.error);
|
||||
135
scripts/migrate-pages.ts
Normal file
135
scripts/migrate-pages.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import matter from 'gray-matter';
|
||||
import { parseMarkdownToLexical } from '../src/payload/utils/lexicalParser';
|
||||
|
||||
async function mapImageToMediaId(payload: any, imagePath: string): Promise<string | null> {
|
||||
if (!imagePath) return null;
|
||||
const filename = path.basename(imagePath);
|
||||
|
||||
const media = await payload.find({
|
||||
collection: 'media',
|
||||
where: {
|
||||
filename: {
|
||||
equals: filename,
|
||||
},
|
||||
},
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (media.docs.length > 0) {
|
||||
return media.docs[0].id;
|
||||
}
|
||||
|
||||
// Auto-ingest missing images from legacy public/ directory
|
||||
const cleanPath = imagePath.startsWith('/') ? imagePath.slice(1) : imagePath;
|
||||
const fullPath = path.join(process.cwd(), 'public', cleanPath);
|
||||
|
||||
if (fs.existsSync(fullPath)) {
|
||||
try {
|
||||
console.log(`[Blog Migration] 📤 Ingesting missing Media into Payload: ${filename}`);
|
||||
const newMedia = await payload.create({
|
||||
collection: 'media',
|
||||
data: {
|
||||
alt: filename.replace(/[-_]/g, ' ').replace(/\.[^/.]+$/, ''), // create a human readable alt text
|
||||
},
|
||||
filePath: fullPath,
|
||||
});
|
||||
return newMedia.id;
|
||||
} catch (err: any) {
|
||||
console.error(`[Blog Migration] ❌ Failed to ingest ${filename}:`, err);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[Blog Migration] ⚠️ Missing image entirely from disk: ${fullPath}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function migrateBlogPages() {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
const locales = ['en', 'de'];
|
||||
for (const locale of locales) {
|
||||
const pagesDir = path.join(process.cwd(), 'data', 'pages', locale);
|
||||
if (!fs.existsSync(pagesDir)) continue;
|
||||
|
||||
const files = fs.readdirSync(pagesDir);
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.mdx')) continue;
|
||||
|
||||
const slug = file.replace(/\.mdx$/, '');
|
||||
const filePath = path.join(pagesDir, file);
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
|
||||
console.log(`Migrating ${locale}/${slug}...`);
|
||||
|
||||
const lexicalBlocks = parseMarkdownToLexical(content);
|
||||
const lexicalAST = {
|
||||
root: {
|
||||
type: 'root',
|
||||
format: '',
|
||||
indent: 0,
|
||||
version: 1,
|
||||
children: lexicalBlocks,
|
||||
direction: 'ltr',
|
||||
},
|
||||
};
|
||||
|
||||
const status = data.public === false ? 'draft' : 'published';
|
||||
|
||||
let featuredImageId = null;
|
||||
if (data.featuredImage || data.image) {
|
||||
featuredImageId = await mapImageToMediaId(payload, data.featuredImage || data.image);
|
||||
}
|
||||
|
||||
try {
|
||||
// Find existing post
|
||||
const existing = await payload.find({
|
||||
collection: 'pages',
|
||||
where: { slug: { equals: slug }, locale: { equals: locale } as any },
|
||||
});
|
||||
|
||||
if (slug.includes('welcome-to-the-future')) {
|
||||
console.log(`\n--- AST for ${slug} ---`);
|
||||
console.log(JSON.stringify(lexicalAST, null, 2));
|
||||
console.log(`-----------------------\n`);
|
||||
}
|
||||
|
||||
if (existing.docs.length > 0) {
|
||||
await payload.update({
|
||||
collection: 'pages',
|
||||
id: existing.docs[0].id,
|
||||
data: {
|
||||
content: lexicalAST as any,
|
||||
...(featuredImageId ? { featuredImage: featuredImageId } : {}),
|
||||
},
|
||||
});
|
||||
console.log(`✅ AST Components & Image RE-INJECTED for ${slug}`);
|
||||
} else {
|
||||
await payload.create({
|
||||
collection: 'pages',
|
||||
data: {
|
||||
title: data.title,
|
||||
slug: slug,
|
||||
locale: locale,
|
||||
excerpt: data.excerpt || '',
|
||||
content: lexicalAST as any,
|
||||
...(featuredImageId ? { featuredImage: featuredImageId } : {}),
|
||||
},
|
||||
});
|
||||
console.log(`✅ Created ${slug}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`❌ Failed ${slug}`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Migration completed.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
migrateBlogPages().catch(console.error);
|
||||
153
scripts/migrate-products.ts
Normal file
153
scripts/migrate-products.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '../payload.config';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import matter from 'gray-matter';
|
||||
import { parseMarkdownToLexical } from '../src/payload/utils/lexicalParser';
|
||||
|
||||
async function mapImageToMediaId(payload: any, imagePath: string): Promise<string | null> {
|
||||
if (!imagePath) return null;
|
||||
const filename = path.basename(imagePath);
|
||||
|
||||
// Exact match instead of substring to avoid matching "cable-black.jpg" with "cable.jpg"
|
||||
const media = await payload.find({
|
||||
collection: 'media',
|
||||
where: {
|
||||
filename: {
|
||||
equals: filename,
|
||||
},
|
||||
},
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (media.docs.length > 0) {
|
||||
return media.docs[0].id;
|
||||
}
|
||||
|
||||
const cleanPath = imagePath.startsWith('/') ? imagePath.slice(1) : imagePath;
|
||||
const fullPath = path.join(process.cwd(), 'public', cleanPath);
|
||||
|
||||
if (fs.existsSync(fullPath)) {
|
||||
try {
|
||||
console.log(`[Products Migration] 📤 Ingesting missing Media into Payload: ${filename}`);
|
||||
const newMedia = await payload.create({
|
||||
collection: 'media',
|
||||
data: {
|
||||
alt: filename.replace(/[-_]/g, ' ').replace(/\.[^/.]+$/, ''),
|
||||
},
|
||||
filePath: fullPath,
|
||||
});
|
||||
return newMedia.id;
|
||||
} catch (err: any) {
|
||||
console.error(`[Products Migration] ❌ Failed to ingest ${filename}:`, err);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[Products Migration] ⚠️ Missing image entirely from disk: ${fullPath}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function migrateProducts() {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
const productLocales = ['en', 'de'];
|
||||
|
||||
for (const locale of productLocales) {
|
||||
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
|
||||
if (!fs.existsSync(productsDir)) continue;
|
||||
|
||||
// Recursive file finder
|
||||
const mdFiles: string[] = [];
|
||||
const walk = (dir: string) => {
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(dir, file);
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
walk(fullPath);
|
||||
} else if (file.endsWith('.mdx')) {
|
||||
mdFiles.push(fullPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(productsDir);
|
||||
|
||||
for (const filePath of mdFiles) {
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
|
||||
console.log(`Processing Product: [${locale.toUpperCase()}] ${data.title}`);
|
||||
|
||||
// 1. Process Images
|
||||
const mediaIds = [];
|
||||
if (data.images && Array.isArray(data.images)) {
|
||||
for (const imgPath of data.images) {
|
||||
const id = await mapImageToMediaId(payload, imgPath);
|
||||
if (id) mediaIds.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Map Lexical AST for deeply nested components (like ProductTabs + Technical data)
|
||||
const lexicalContent = parseMarkdownToLexical(content);
|
||||
|
||||
const wrapLexical = (blocks: any[]) => ({
|
||||
root: {
|
||||
type: 'root',
|
||||
format: '',
|
||||
indent: 0,
|
||||
version: 1,
|
||||
children: blocks,
|
||||
direction: 'ltr',
|
||||
},
|
||||
});
|
||||
|
||||
// Payload expects category objects via the 'category' key
|
||||
const formattedCategories = Array.isArray(data.categories)
|
||||
? data.categories.map((c: string) => ({ category: c }))
|
||||
: [];
|
||||
|
||||
const productData = {
|
||||
title: data.title,
|
||||
sku: data.sku || path.basename(filePath, '.mdx'),
|
||||
slug: path.basename(filePath, '.mdx'),
|
||||
locale: locale as 'en' | 'de',
|
||||
categories: formattedCategories,
|
||||
description: data.description || '',
|
||||
featuredImage: mediaIds.length > 0 ? mediaIds[0] : undefined,
|
||||
images: mediaIds.length > 0 ? mediaIds : undefined,
|
||||
content: wrapLexical(lexicalContent) as any,
|
||||
application: data.application
|
||||
? (wrapLexical(parseMarkdownToLexical(data.application)) as any)
|
||||
: undefined,
|
||||
_status: 'published' as any,
|
||||
};
|
||||
|
||||
// Check if product exists (by sku combined with locale, since slug may differ by language)
|
||||
const existing = await payload.find({
|
||||
collection: 'products',
|
||||
where: {
|
||||
and: [{ slug: { equals: productData.slug } }, { locale: { equals: locale } }],
|
||||
},
|
||||
});
|
||||
|
||||
if (existing.docs.length > 0) {
|
||||
console.log(`Updating existing product ${productData.slug} (${locale})`);
|
||||
await payload.update({
|
||||
collection: 'products',
|
||||
id: existing.docs[0].id,
|
||||
data: productData,
|
||||
});
|
||||
} else {
|
||||
console.log(`Creating new product ${productData.slug} (${locale})`);
|
||||
await payload.create({
|
||||
collection: 'products',
|
||||
data: productData,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✅ Products Migration Complete!`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
migrateProducts().catch(console.error);
|
||||
@@ -1,217 +0,0 @@
|
||||
import client, { ensureAuthenticated } from '../lib/directus';
|
||||
import { updateSettings, uploadFiles } from '@directus/sdk';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// Helper for ESM __dirname
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
async function setupBranding() {
|
||||
console.log('🎨 Refining Directus Branding for Premium Website Look...');
|
||||
|
||||
// 1. Authenticate
|
||||
await ensureAuthenticated();
|
||||
|
||||
try {
|
||||
// 2. Upload Assets (MIME FIXED)
|
||||
console.log('📤 Re-uploading assets for clean IDs...');
|
||||
|
||||
const getMimeType = (filePath: string) => {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
switch (ext) {
|
||||
case '.svg':
|
||||
return 'image/svg+xml';
|
||||
case '.png':
|
||||
return 'image/png';
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
return 'image/jpeg';
|
||||
case '.ico':
|
||||
return 'image/x-icon';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
};
|
||||
|
||||
const uploadAsset = async (filePath: string, title: string) => {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(`⚠️ File not found: ${filePath}`);
|
||||
return null;
|
||||
}
|
||||
const mimeType = getMimeType(filePath);
|
||||
const form = new FormData();
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
const blob = new Blob([fileBuffer], { type: mimeType });
|
||||
form.append('file', blob, path.basename(filePath));
|
||||
form.append('title', title);
|
||||
const res = await client.request(uploadFiles(form));
|
||||
return res.id;
|
||||
};
|
||||
|
||||
const logoWhiteId = await uploadAsset(
|
||||
path.resolve(__dirname, '../public/logo-white.svg'),
|
||||
'Logo White',
|
||||
);
|
||||
const logoBlueId = await uploadAsset(
|
||||
path.resolve(__dirname, '../public/logo-blue.svg'),
|
||||
'Logo Blue',
|
||||
);
|
||||
const faviconId = await uploadAsset(
|
||||
path.resolve(__dirname, '../public/favicon.ico'),
|
||||
'Favicon',
|
||||
);
|
||||
|
||||
// Smoother Background SVG
|
||||
const bgSvgPath = path.resolve(__dirname, '../public/login-bg.svg');
|
||||
fs.writeFileSync(
|
||||
bgSvgPath,
|
||||
`<svg width="1920" height="1080" viewBox="0 0 1920 1080" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1920" height="1080" fill="#001a4d"/>
|
||||
<ellipse cx="960" cy="540" rx="800" ry="600" fill="url(#paint0_radial_premium)"/>
|
||||
<defs>
|
||||
<radialGradient id="paint0_radial_premium" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(960 540) rotate(90) scale(600 800)">
|
||||
<stop stop-color="#003d82" stop-opacity="0.8"/>
|
||||
<stop offset="1" stop-color="#001a4d" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>`,
|
||||
);
|
||||
const backgroundId = await uploadAsset(bgSvgPath, 'Login Bg');
|
||||
if (fs.existsSync(bgSvgPath)) fs.unlinkSync(bgSvgPath);
|
||||
|
||||
// 3. Update Settings with "Premium Web" Theme
|
||||
console.log('⚙️ Updating Directus settings...');
|
||||
|
||||
const COLOR_PRIMARY = '#001a4d'; // Deep Blue
|
||||
const COLOR_ACCENT = '#82ed20'; // Sustainability Green
|
||||
const COLOR_SECONDARY = '#003d82';
|
||||
|
||||
const customCss = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
/* Global Login Styles */
|
||||
body, .v-app {
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Glassmorphism Effect for Login Card */
|
||||
.public-view .v-card {
|
||||
background: rgba(255, 255, 255, 0.95) !important;
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3) !important;
|
||||
border-radius: 32px !important;
|
||||
box-shadow: 0 50px 100px -20px rgba(0, 0, 0, 0.4) !important;
|
||||
padding: 40px !important;
|
||||
}
|
||||
|
||||
.public-view .v-button {
|
||||
border-radius: 9999px !important;
|
||||
height: 56px !important;
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: -0.01em !important;
|
||||
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1) !important;
|
||||
}
|
||||
|
||||
.public-view .v-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 15px 30px rgba(130, 237, 32, 0.2) !important;
|
||||
}
|
||||
|
||||
.public-view .v-input {
|
||||
--v-input-border-radius: 12px !important;
|
||||
--v-input-background-color: #f8f9fa !important;
|
||||
}
|
||||
|
||||
/* Inject Headline via CSS to avoid raw HTML display in public_note */
|
||||
.public-view .form::before {
|
||||
content: 'Sustainable Energy. Industrial Reliability.';
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.public-view .form::after {
|
||||
content: 'KLZ INFRASTRUCTURE ENGINE';
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-top: 24px;
|
||||
}
|
||||
`;
|
||||
|
||||
const publicNote = '';
|
||||
|
||||
await client.request(
|
||||
updateSettings({
|
||||
project_name: 'KLZ Cables',
|
||||
project_url: 'https://klz-cables.com',
|
||||
project_color: COLOR_ACCENT,
|
||||
project_descriptor: 'Sustainable Energy Infrastructure',
|
||||
|
||||
// FIXED: Use WHITE logo for the Blue Sidebar
|
||||
project_logo: logoWhiteId as any,
|
||||
|
||||
public_foreground: logoWhiteId as any,
|
||||
public_background: backgroundId as any,
|
||||
public_note: publicNote,
|
||||
public_favicon: faviconId as any,
|
||||
custom_css: customCss,
|
||||
|
||||
// DEEP PREMIUM THEME
|
||||
theme_light_overrides: {
|
||||
// Brands
|
||||
primary: COLOR_ACCENT, // Buttons/Actions are GREEN like the website
|
||||
secondary: COLOR_SECONDARY,
|
||||
|
||||
// Content Area
|
||||
background: '#f1f3f7',
|
||||
backgroundNormal: '#ffffff',
|
||||
backgroundAccent: '#eef2ff',
|
||||
|
||||
// Sidebar Branding
|
||||
navigationBackground: COLOR_PRIMARY,
|
||||
navigationForeground: '#ffffff',
|
||||
navigationBackgroundHover: 'rgba(255,255,255,0.05)',
|
||||
navigationForegroundHover: '#ffffff',
|
||||
navigationBackgroundActive: 'rgba(130, 237, 32, 0.15)', // Subtle Green highlight
|
||||
navigationForegroundActive: COLOR_ACCENT, // Active item is GREEN
|
||||
|
||||
// Module Bar (Thin far left)
|
||||
moduleBarBackground: '#000d26',
|
||||
moduleBarForeground: '#ffffff',
|
||||
moduleBarForegroundActive: COLOR_ACCENT,
|
||||
|
||||
// UI Standards
|
||||
borderRadius: '16px', // Larger radius for modern feel
|
||||
borderWidth: '1px',
|
||||
borderColor: '#e2e8f0',
|
||||
formFieldHeight: '48px', // Touch-target height
|
||||
} as any,
|
||||
|
||||
theme_dark_overrides: {
|
||||
primary: COLOR_ACCENT,
|
||||
background: '#0a0a0a',
|
||||
navigationBackground: '#000000',
|
||||
moduleBarBackground: COLOR_PRIMARY,
|
||||
borderRadius: '16px',
|
||||
formFieldHeight: '48px',
|
||||
} as any,
|
||||
}),
|
||||
);
|
||||
|
||||
console.log('✨ Premium Theme applied successfully!');
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error:', JSON.stringify(error, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
setupBranding();
|
||||
@@ -1,132 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Configuration
|
||||
REMOTE_HOST="root@alpha.mintel.me"
|
||||
REMOTE_DIR="/home/deploy/sites/klz-cables.com"
|
||||
|
||||
# DB Details (matching docker-compose defaults)
|
||||
LOCAL_DB_USER="klz_db_user"
|
||||
REMOTE_DB_USER="directus"
|
||||
DB_NAME="directus"
|
||||
|
||||
ACTION=$1
|
||||
ENV=$2
|
||||
|
||||
# Help
|
||||
if [ -z "$ACTION" ] || [ -z "$ENV" ]; then
|
||||
echo "Usage: ./scripts/sync-directus.sh [push|pull] [testing|staging|production]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " push Sync LOCAL data -> REMOTE"
|
||||
echo " pull Sync REMOTE data -> LOCAL"
|
||||
echo ""
|
||||
echo "Environments:"
|
||||
echo " testing, staging, production"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Map Environment to Project Name
|
||||
case $ENV in
|
||||
production|staging|testing)
|
||||
PROJECT_NAME="klz-cablescom"
|
||||
ENV_FILE=".env"
|
||||
;;
|
||||
*)
|
||||
echo "❌ Invalid environment: $ENV. Use testing, staging, or production."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Detect local container
|
||||
echo "🔍 Detecting local database..."
|
||||
# Use a more robust way to find the container if multiple projects exist locally
|
||||
LOCAL_DB_CONTAINER=$(docker compose ps -q klz-db)
|
||||
if [ -z "$LOCAL_DB_CONTAINER" ]; then
|
||||
echo "❌ Local klz-directus-db container not found. Is it running? (npm run dev)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$ACTION" == "push" ]; then
|
||||
echo "🚀 Pushing Local Data to $ENV ($PROJECT_NAME)..."
|
||||
|
||||
# 1. DB Dump
|
||||
echo "📦 Dumping local database..."
|
||||
# Note: we use --no-owner --no-privileges to ensure restore works on remote with different user setup
|
||||
docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$DB_USER" --clean --if-exists --no-owner --no-privileges "$DB_NAME" > dump.sql
|
||||
|
||||
# 2. Upload Dump
|
||||
echo "📤 Uploading dump to remote server..."
|
||||
scp dump.sql "$REMOTE_HOST:$REMOTE_DIR/dump.sql"
|
||||
|
||||
# 3. Restore on Remote
|
||||
echo "🔄 Restoring dump on $ENV..."
|
||||
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db")
|
||||
if [ -z "$REMOTE_DB_CONTAINER" ] && [ -n "$OLD_PROJECT_NAME" ]; then
|
||||
echo "⚠️ $PROJECT_NAME not found, trying fallback $OLD_PROJECT_NAME..."
|
||||
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $OLD_PROJECT_NAME ps -q directus-db")
|
||||
fi
|
||||
|
||||
if [ -z "$REMOTE_DB_CONTAINER" ]; then
|
||||
echo "❌ Remote $ENV-db container not found!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Wipe remote DB clean before restore to avoid constraint errors
|
||||
echo "🧹 Wiping remote database schema..."
|
||||
ssh "$REMOTE_HOST" "docker exec $REMOTE_DB_CONTAINER psql -U $REMOTE_DB_USER $DB_NAME -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'"
|
||||
|
||||
echo "⚡ Restoring database..."
|
||||
ssh "$REMOTE_HOST" "docker exec -i $REMOTE_DB_CONTAINER psql -U $REMOTE_DB_USER $DB_NAME < $REMOTE_DIR/dump.sql"
|
||||
|
||||
# 4. Sync Uploads
|
||||
echo "📁 Syncing uploads (Local -> $ENV)..."
|
||||
# Note: If environments share the same directory, this might overwrite others' files if not careful.
|
||||
# But since they share the same host directory currently, rsync will update the shared folder.
|
||||
rsync -avz --progress ./directus/uploads/ "$REMOTE_HOST:$REMOTE_DIR/directus/uploads/"
|
||||
|
||||
# Clean up
|
||||
rm dump.sql
|
||||
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
|
||||
|
||||
# 5. Restart Directus to trigger migrations and refresh schema cache
|
||||
echo "🔄 Restarting remote Directus to apply migrations..."
|
||||
ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME restart directus"
|
||||
|
||||
echo "✨ Push to $ENV complete!"
|
||||
|
||||
elif [ "$ACTION" == "pull" ]; then
|
||||
echo "📥 Pulling $ENV Data to Local..."
|
||||
|
||||
# The remote service name is 'klz-db' according to docker compose config
|
||||
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q klz-db")
|
||||
|
||||
if [ -z "$REMOTE_DB_CONTAINER" ]; then
|
||||
echo "❌ Remote $ENV-db container not found!"
|
||||
exit 1
|
||||
fi
|
||||
ssh "$REMOTE_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $REMOTE_DB_USER --clean --if-exists --no-owner --no-privileges $DB_NAME > $REMOTE_DIR/dump.sql"
|
||||
|
||||
# 2. Download Dump
|
||||
echo "📥 Downloading dump..."
|
||||
scp "$REMOTE_HOST:$REMOTE_DIR/dump.sql" dump.sql
|
||||
|
||||
# Wipe local DB clean before restore to avoid constraint errors
|
||||
echo "🧹 Wiping local database schema..."
|
||||
docker exec "$LOCAL_DB_CONTAINER" psql -U "$LOCAL_DB_USER" "$DB_NAME" -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'
|
||||
|
||||
echo "⚡ Restoring database locally..."
|
||||
docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$LOCAL_DB_USER" "$DB_NAME" < dump.sql
|
||||
|
||||
# 4. Sync Uploads
|
||||
echo "📁 Syncing uploads ($ENV -> Local)..."
|
||||
rsync -avz --progress "$REMOTE_HOST:$REMOTE_DIR/directus/uploads/" ./directus/uploads/
|
||||
|
||||
# Clean up
|
||||
rm dump.sql
|
||||
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
|
||||
|
||||
echo "✨ Pull to Local complete!"
|
||||
else
|
||||
echo "Invalid action: $ACTION. Use push or pull."
|
||||
exit 1
|
||||
fi
|
||||
2621
src/migrations/20260223_195005_products_collection.json
Normal file
2621
src/migrations/20260223_195005_products_collection.json
Normal file
File diff suppressed because it is too large
Load Diff
356
src/migrations/20260223_195005_products_collection.ts
Normal file
356
src/migrations/20260223_195005_products_collection.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres';
|
||||
|
||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TYPE "public"."enum_posts_locale" AS ENUM('en', 'de');
|
||||
CREATE TYPE "public"."enum_posts_status" AS ENUM('draft', 'published');
|
||||
CREATE TYPE "public"."enum__posts_v_version_locale" AS ENUM('en', 'de');
|
||||
CREATE TYPE "public"."enum__posts_v_version_status" AS ENUM('draft', 'published');
|
||||
CREATE TYPE "public"."enum_form_submissions_type" AS ENUM('contact', 'product_quote');
|
||||
CREATE TYPE "public"."enum_products_locale" AS ENUM('en', 'de');
|
||||
CREATE TYPE "public"."enum_products_status" AS ENUM('draft', 'published');
|
||||
CREATE TYPE "public"."enum__products_v_version_locale" AS ENUM('en', 'de');
|
||||
CREATE TYPE "public"."enum__products_v_version_status" AS ENUM('draft', 'published');
|
||||
CREATE TABLE "users_sessions" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"created_at" timestamp(3) with time zone,
|
||||
"expires_at" timestamp(3) with time zone NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "users" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"email" varchar NOT NULL,
|
||||
"reset_password_token" varchar,
|
||||
"reset_password_expiration" timestamp(3) with time zone,
|
||||
"salt" varchar,
|
||||
"hash" varchar,
|
||||
"login_attempts" numeric DEFAULT 0,
|
||||
"lock_until" timestamp(3) with time zone
|
||||
);
|
||||
|
||||
CREATE TABLE "media" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"alt" varchar NOT NULL,
|
||||
"caption" varchar,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"url" varchar,
|
||||
"thumbnail_u_r_l" varchar,
|
||||
"filename" varchar,
|
||||
"mime_type" varchar,
|
||||
"filesize" numeric,
|
||||
"width" numeric,
|
||||
"height" numeric,
|
||||
"focal_x" numeric,
|
||||
"focal_y" numeric,
|
||||
"sizes_thumbnail_url" varchar,
|
||||
"sizes_thumbnail_width" numeric,
|
||||
"sizes_thumbnail_height" numeric,
|
||||
"sizes_thumbnail_mime_type" varchar,
|
||||
"sizes_thumbnail_filesize" numeric,
|
||||
"sizes_thumbnail_filename" varchar,
|
||||
"sizes_card_url" varchar,
|
||||
"sizes_card_width" numeric,
|
||||
"sizes_card_height" numeric,
|
||||
"sizes_card_mime_type" varchar,
|
||||
"sizes_card_filesize" numeric,
|
||||
"sizes_card_filename" varchar,
|
||||
"sizes_hero_url" varchar,
|
||||
"sizes_hero_width" numeric,
|
||||
"sizes_hero_height" numeric,
|
||||
"sizes_hero_mime_type" varchar,
|
||||
"sizes_hero_filesize" numeric,
|
||||
"sizes_hero_filename" varchar,
|
||||
"sizes_hero_mobile_url" varchar,
|
||||
"sizes_hero_mobile_width" numeric,
|
||||
"sizes_hero_mobile_height" numeric,
|
||||
"sizes_hero_mobile_mime_type" varchar,
|
||||
"sizes_hero_mobile_filesize" numeric,
|
||||
"sizes_hero_mobile_filename" varchar
|
||||
);
|
||||
|
||||
CREATE TABLE "posts" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"title" varchar,
|
||||
"slug" varchar,
|
||||
"excerpt" varchar,
|
||||
"date" timestamp(3) with time zone,
|
||||
"featured_image_id" integer,
|
||||
"locale" "enum_posts_locale" DEFAULT 'en',
|
||||
"category" varchar,
|
||||
"content" jsonb,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"_status" "enum_posts_status" DEFAULT 'draft'
|
||||
);
|
||||
|
||||
CREATE TABLE "_posts_v" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"parent_id" integer,
|
||||
"version_title" varchar,
|
||||
"version_slug" varchar,
|
||||
"version_excerpt" varchar,
|
||||
"version_date" timestamp(3) with time zone,
|
||||
"version_featured_image_id" integer,
|
||||
"version_locale" "enum__posts_v_version_locale" DEFAULT 'en',
|
||||
"version_category" varchar,
|
||||
"version_content" jsonb,
|
||||
"version_updated_at" timestamp(3) with time zone,
|
||||
"version_created_at" timestamp(3) with time zone,
|
||||
"version__status" "enum__posts_v_version_status" DEFAULT 'draft',
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"latest" boolean
|
||||
);
|
||||
|
||||
CREATE TABLE "form_submissions" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"email" varchar NOT NULL,
|
||||
"type" "enum_form_submissions_type" NOT NULL,
|
||||
"product_name" varchar,
|
||||
"message" varchar NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "products_categories" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"category" varchar
|
||||
);
|
||||
|
||||
CREATE TABLE "products" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"title" varchar,
|
||||
"sku" varchar,
|
||||
"slug" varchar,
|
||||
"description" varchar,
|
||||
"locale" "enum_products_locale" DEFAULT 'de',
|
||||
"application" jsonb,
|
||||
"content" jsonb,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"_status" "enum_products_status" DEFAULT 'draft'
|
||||
);
|
||||
|
||||
CREATE TABLE "products_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"media_id" integer
|
||||
);
|
||||
|
||||
CREATE TABLE "_products_v_version_categories" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"category" varchar,
|
||||
"_uuid" varchar
|
||||
);
|
||||
|
||||
CREATE TABLE "_products_v" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"parent_id" integer,
|
||||
"version_title" varchar,
|
||||
"version_sku" varchar,
|
||||
"version_slug" varchar,
|
||||
"version_description" varchar,
|
||||
"version_locale" "enum__products_v_version_locale" DEFAULT 'de',
|
||||
"version_application" jsonb,
|
||||
"version_content" jsonb,
|
||||
"version_updated_at" timestamp(3) with time zone,
|
||||
"version_created_at" timestamp(3) with time zone,
|
||||
"version__status" "enum__products_v_version_status" DEFAULT 'draft',
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"latest" boolean
|
||||
);
|
||||
|
||||
CREATE TABLE "_products_v_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"media_id" integer
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_kv" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"key" varchar NOT NULL,
|
||||
"data" jsonb NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_locked_documents" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"global_slug" varchar,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_locked_documents_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"users_id" integer,
|
||||
"media_id" integer,
|
||||
"posts_id" integer,
|
||||
"form_submissions_id" integer,
|
||||
"products_id" integer
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_preferences" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"key" varchar,
|
||||
"value" jsonb,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_preferences_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"users_id" integer
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_migrations" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar,
|
||||
"batch" numeric,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE "users_sessions" ADD CONSTRAINT "users_sessions_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "posts" ADD CONSTRAINT "posts_featured_image_id_media_id_fk" FOREIGN KEY ("featured_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "_posts_v" ADD CONSTRAINT "_posts_v_parent_id_posts_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."posts"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "_posts_v" ADD CONSTRAINT "_posts_v_version_featured_image_id_media_id_fk" FOREIGN KEY ("version_featured_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "products_categories" ADD CONSTRAINT "products_categories_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "products_rels" ADD CONSTRAINT "products_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "products_rels" ADD CONSTRAINT "products_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "_products_v_version_categories" ADD CONSTRAINT "_products_v_version_categories_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_products_v"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "_products_v" ADD CONSTRAINT "_products_v_parent_id_products_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."products"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "_products_v_rels" ADD CONSTRAINT "_products_v_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."_products_v"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "_products_v_rels" ADD CONSTRAINT "_products_v_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_locked_documents"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_posts_fk" FOREIGN KEY ("posts_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_form_submissions_fk" FOREIGN KEY ("form_submissions_id") REFERENCES "public"."form_submissions"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_products_fk" FOREIGN KEY ("products_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_preferences"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "users_sessions_order_idx" ON "users_sessions" USING btree ("_order");
|
||||
CREATE INDEX "users_sessions_parent_id_idx" ON "users_sessions" USING btree ("_parent_id");
|
||||
CREATE INDEX "users_updated_at_idx" ON "users" USING btree ("updated_at");
|
||||
CREATE INDEX "users_created_at_idx" ON "users" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree ("email");
|
||||
CREATE INDEX "media_updated_at_idx" ON "media" USING btree ("updated_at");
|
||||
CREATE INDEX "media_created_at_idx" ON "media" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "media_filename_idx" ON "media" USING btree ("filename");
|
||||
CREATE INDEX "media_sizes_thumbnail_sizes_thumbnail_filename_idx" ON "media" USING btree ("sizes_thumbnail_filename");
|
||||
CREATE INDEX "media_sizes_card_sizes_card_filename_idx" ON "media" USING btree ("sizes_card_filename");
|
||||
CREATE INDEX "media_sizes_hero_sizes_hero_filename_idx" ON "media" USING btree ("sizes_hero_filename");
|
||||
CREATE INDEX "media_sizes_hero_mobile_sizes_hero_mobile_filename_idx" ON "media" USING btree ("sizes_hero_mobile_filename");
|
||||
CREATE UNIQUE INDEX "posts_slug_idx" ON "posts" USING btree ("slug");
|
||||
CREATE INDEX "posts_featured_image_idx" ON "posts" USING btree ("featured_image_id");
|
||||
CREATE INDEX "posts_updated_at_idx" ON "posts" USING btree ("updated_at");
|
||||
CREATE INDEX "posts_created_at_idx" ON "posts" USING btree ("created_at");
|
||||
CREATE INDEX "posts__status_idx" ON "posts" USING btree ("_status");
|
||||
CREATE INDEX "_posts_v_parent_idx" ON "_posts_v" USING btree ("parent_id");
|
||||
CREATE INDEX "_posts_v_version_version_slug_idx" ON "_posts_v" USING btree ("version_slug");
|
||||
CREATE INDEX "_posts_v_version_version_featured_image_idx" ON "_posts_v" USING btree ("version_featured_image_id");
|
||||
CREATE INDEX "_posts_v_version_version_updated_at_idx" ON "_posts_v" USING btree ("version_updated_at");
|
||||
CREATE INDEX "_posts_v_version_version_created_at_idx" ON "_posts_v" USING btree ("version_created_at");
|
||||
CREATE INDEX "_posts_v_version_version__status_idx" ON "_posts_v" USING btree ("version__status");
|
||||
CREATE INDEX "_posts_v_created_at_idx" ON "_posts_v" USING btree ("created_at");
|
||||
CREATE INDEX "_posts_v_updated_at_idx" ON "_posts_v" USING btree ("updated_at");
|
||||
CREATE INDEX "_posts_v_latest_idx" ON "_posts_v" USING btree ("latest");
|
||||
CREATE INDEX "form_submissions_updated_at_idx" ON "form_submissions" USING btree ("updated_at");
|
||||
CREATE INDEX "form_submissions_created_at_idx" ON "form_submissions" USING btree ("created_at");
|
||||
CREATE INDEX "products_categories_order_idx" ON "products_categories" USING btree ("_order");
|
||||
CREATE INDEX "products_categories_parent_id_idx" ON "products_categories" USING btree ("_parent_id");
|
||||
CREATE UNIQUE INDEX "products_sku_idx" ON "products" USING btree ("sku");
|
||||
CREATE INDEX "products_updated_at_idx" ON "products" USING btree ("updated_at");
|
||||
CREATE INDEX "products_created_at_idx" ON "products" USING btree ("created_at");
|
||||
CREATE INDEX "products__status_idx" ON "products" USING btree ("_status");
|
||||
CREATE INDEX "products_rels_order_idx" ON "products_rels" USING btree ("order");
|
||||
CREATE INDEX "products_rels_parent_idx" ON "products_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "products_rels_path_idx" ON "products_rels" USING btree ("path");
|
||||
CREATE INDEX "products_rels_media_id_idx" ON "products_rels" USING btree ("media_id");
|
||||
CREATE INDEX "_products_v_version_categories_order_idx" ON "_products_v_version_categories" USING btree ("_order");
|
||||
CREATE INDEX "_products_v_version_categories_parent_id_idx" ON "_products_v_version_categories" USING btree ("_parent_id");
|
||||
CREATE INDEX "_products_v_parent_idx" ON "_products_v" USING btree ("parent_id");
|
||||
CREATE INDEX "_products_v_version_version_sku_idx" ON "_products_v" USING btree ("version_sku");
|
||||
CREATE INDEX "_products_v_version_version_updated_at_idx" ON "_products_v" USING btree ("version_updated_at");
|
||||
CREATE INDEX "_products_v_version_version_created_at_idx" ON "_products_v" USING btree ("version_created_at");
|
||||
CREATE INDEX "_products_v_version_version__status_idx" ON "_products_v" USING btree ("version__status");
|
||||
CREATE INDEX "_products_v_created_at_idx" ON "_products_v" USING btree ("created_at");
|
||||
CREATE INDEX "_products_v_updated_at_idx" ON "_products_v" USING btree ("updated_at");
|
||||
CREATE INDEX "_products_v_latest_idx" ON "_products_v" USING btree ("latest");
|
||||
CREATE INDEX "_products_v_rels_order_idx" ON "_products_v_rels" USING btree ("order");
|
||||
CREATE INDEX "_products_v_rels_parent_idx" ON "_products_v_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "_products_v_rels_path_idx" ON "_products_v_rels" USING btree ("path");
|
||||
CREATE INDEX "_products_v_rels_media_id_idx" ON "_products_v_rels" USING btree ("media_id");
|
||||
CREATE UNIQUE INDEX "payload_kv_key_idx" ON "payload_kv" USING btree ("key");
|
||||
CREATE INDEX "payload_locked_documents_global_slug_idx" ON "payload_locked_documents" USING btree ("global_slug");
|
||||
CREATE INDEX "payload_locked_documents_updated_at_idx" ON "payload_locked_documents" USING btree ("updated_at");
|
||||
CREATE INDEX "payload_locked_documents_created_at_idx" ON "payload_locked_documents" USING btree ("created_at");
|
||||
CREATE INDEX "payload_locked_documents_rels_order_idx" ON "payload_locked_documents_rels" USING btree ("order");
|
||||
CREATE INDEX "payload_locked_documents_rels_parent_idx" ON "payload_locked_documents_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_path_idx" ON "payload_locked_documents_rels" USING btree ("path");
|
||||
CREATE INDEX "payload_locked_documents_rels_users_id_idx" ON "payload_locked_documents_rels" USING btree ("users_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_media_id_idx" ON "payload_locked_documents_rels" USING btree ("media_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_posts_id_idx" ON "payload_locked_documents_rels" USING btree ("posts_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_form_submissions_id_idx" ON "payload_locked_documents_rels" USING btree ("form_submissions_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_products_id_idx" ON "payload_locked_documents_rels" USING btree ("products_id");
|
||||
CREATE INDEX "payload_preferences_key_idx" ON "payload_preferences" USING btree ("key");
|
||||
CREATE INDEX "payload_preferences_updated_at_idx" ON "payload_preferences" USING btree ("updated_at");
|
||||
CREATE INDEX "payload_preferences_created_at_idx" ON "payload_preferences" USING btree ("created_at");
|
||||
CREATE INDEX "payload_preferences_rels_order_idx" ON "payload_preferences_rels" USING btree ("order");
|
||||
CREATE INDEX "payload_preferences_rels_parent_idx" ON "payload_preferences_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "payload_preferences_rels_path_idx" ON "payload_preferences_rels" USING btree ("path");
|
||||
CREATE INDEX "payload_preferences_rels_users_id_idx" ON "payload_preferences_rels" USING btree ("users_id");
|
||||
CREATE INDEX "payload_migrations_updated_at_idx" ON "payload_migrations" USING btree ("updated_at");
|
||||
CREATE INDEX "payload_migrations_created_at_idx" ON "payload_migrations" USING btree ("created_at");`);
|
||||
}
|
||||
|
||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
DROP TABLE "users_sessions" CASCADE;
|
||||
DROP TABLE "users" CASCADE;
|
||||
DROP TABLE "media" CASCADE;
|
||||
DROP TABLE "posts" CASCADE;
|
||||
DROP TABLE "_posts_v" CASCADE;
|
||||
DROP TABLE "form_submissions" CASCADE;
|
||||
DROP TABLE "products_categories" CASCADE;
|
||||
DROP TABLE "products" CASCADE;
|
||||
DROP TABLE "products_rels" CASCADE;
|
||||
DROP TABLE "_products_v_version_categories" CASCADE;
|
||||
DROP TABLE "_products_v" CASCADE;
|
||||
DROP TABLE "_products_v_rels" CASCADE;
|
||||
DROP TABLE "payload_kv" CASCADE;
|
||||
DROP TABLE "payload_locked_documents" CASCADE;
|
||||
DROP TABLE "payload_locked_documents_rels" CASCADE;
|
||||
DROP TABLE "payload_preferences" CASCADE;
|
||||
DROP TABLE "payload_preferences_rels" CASCADE;
|
||||
DROP TABLE "payload_migrations" CASCADE;
|
||||
DROP TYPE "public"."enum_posts_locale";
|
||||
DROP TYPE "public"."enum_posts_status";
|
||||
DROP TYPE "public"."enum__posts_v_version_locale";
|
||||
DROP TYPE "public"."enum__posts_v_version_status";
|
||||
DROP TYPE "public"."enum_form_submissions_type";
|
||||
DROP TYPE "public"."enum_products_locale";
|
||||
DROP TYPE "public"."enum_products_status";
|
||||
DROP TYPE "public"."enum__products_v_version_locale";
|
||||
DROP TYPE "public"."enum__products_v_version_status";`);
|
||||
}
|
||||
2591
src/migrations/20260223_195151_remove_sku_unique.json
Normal file
2591
src/migrations/20260223_195151_remove_sku_unique.json
Normal file
File diff suppressed because it is too large
Load Diff
13
src/migrations/20260223_195151_remove_sku_unique.ts
Normal file
13
src/migrations/20260223_195151_remove_sku_unique.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres';
|
||||
|
||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
DROP INDEX "products_sku_idx";
|
||||
DROP INDEX "_products_v_version_version_sku_idx";`);
|
||||
}
|
||||
|
||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE UNIQUE INDEX "products_sku_idx" ON "products" USING btree ("sku");
|
||||
CREATE INDEX "_products_v_version_version_sku_idx" ON "_products_v" USING btree ("version_sku");`);
|
||||
}
|
||||
15
src/migrations/index.ts
Normal file
15
src/migrations/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import * as migration_20260223_195005_products_collection from './20260223_195005_products_collection';
|
||||
import * as migration_20260223_195151_remove_sku_unique from './20260223_195151_remove_sku_unique';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
up: migration_20260223_195005_products_collection.up,
|
||||
down: migration_20260223_195005_products_collection.down,
|
||||
name: '20260223_195005_products_collection',
|
||||
},
|
||||
{
|
||||
up: migration_20260223_195151_remove_sku_unique.up,
|
||||
down: migration_20260223_195151_remove_sku_unique.down,
|
||||
name: '20260223_195151_remove_sku_unique',
|
||||
},
|
||||
];
|
||||
793
src/payload-generated-schema.ts
Normal file
793
src/payload-generated-schema.ts
Normal file
@@ -0,0 +1,793 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* This file was automatically generated by Payload.
|
||||
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
|
||||
* and re-run `payload generate:db-schema` to regenerate this file.
|
||||
*/
|
||||
|
||||
import type {} from '@payloadcms/db-postgres';
|
||||
import {
|
||||
pgTable,
|
||||
index,
|
||||
uniqueIndex,
|
||||
foreignKey,
|
||||
integer,
|
||||
varchar,
|
||||
timestamp,
|
||||
serial,
|
||||
numeric,
|
||||
jsonb,
|
||||
boolean,
|
||||
pgEnum,
|
||||
} from '@payloadcms/db-postgres/drizzle/pg-core';
|
||||
import { sql, relations } from '@payloadcms/db-postgres/drizzle';
|
||||
export const enum_posts_locale = pgEnum('enum_posts_locale', ['en', 'de']);
|
||||
export const enum_posts_status = pgEnum('enum_posts_status', ['draft', 'published']);
|
||||
export const enum__posts_v_version_locale = pgEnum('enum__posts_v_version_locale', ['en', 'de']);
|
||||
export const enum__posts_v_version_status = pgEnum('enum__posts_v_version_status', [
|
||||
'draft',
|
||||
'published',
|
||||
]);
|
||||
export const enum_form_submissions_type = pgEnum('enum_form_submissions_type', [
|
||||
'contact',
|
||||
'product_quote',
|
||||
]);
|
||||
export const enum_products_locale = pgEnum('enum_products_locale', ['en', 'de']);
|
||||
export const enum_products_status = pgEnum('enum_products_status', ['draft', 'published']);
|
||||
export const enum__products_v_version_locale = pgEnum('enum__products_v_version_locale', [
|
||||
'en',
|
||||
'de',
|
||||
]);
|
||||
export const enum__products_v_version_status = pgEnum('enum__products_v_version_status', [
|
||||
'draft',
|
||||
'published',
|
||||
]);
|
||||
|
||||
export const users_sessions = pgTable(
|
||||
'users_sessions',
|
||||
{
|
||||
_order: integer('_order').notNull(),
|
||||
_parentID: integer('_parent_id').notNull(),
|
||||
id: varchar('id').primaryKey(),
|
||||
createdAt: timestamp('created_at', { mode: 'string', withTimezone: true, precision: 3 }),
|
||||
expiresAt: timestamp('expires_at', {
|
||||
mode: 'string',
|
||||
withTimezone: true,
|
||||
precision: 3,
|
||||
}).notNull(),
|
||||
},
|
||||
(columns) => [
|
||||
index('users_sessions_order_idx').on(columns._order),
|
||||
index('users_sessions_parent_id_idx').on(columns._parentID),
|
||||
foreignKey({
|
||||
columns: [columns['_parentID']],
|
||||
foreignColumns: [users.id],
|
||||
name: 'users_sessions_parent_id_fk',
|
||||
}).onDelete('cascade'),
|
||||
],
|
||||
);
|
||||
|
||||
export const users = pgTable(
|
||||
'users',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
createdAt: timestamp('created_at', { mode: 'string', withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
email: varchar('email').notNull(),
|
||||
resetPasswordToken: varchar('reset_password_token'),
|
||||
resetPasswordExpiration: timestamp('reset_password_expiration', {
|
||||
mode: 'string',
|
||||
withTimezone: true,
|
||||
precision: 3,
|
||||
}),
|
||||
salt: varchar('salt'),
|
||||
hash: varchar('hash'),
|
||||
loginAttempts: numeric('login_attempts', { mode: 'number' }).default(0),
|
||||
lockUntil: timestamp('lock_until', { mode: 'string', withTimezone: true, precision: 3 }),
|
||||
},
|
||||
(columns) => [
|
||||
index('users_updated_at_idx').on(columns.updatedAt),
|
||||
index('users_created_at_idx').on(columns.createdAt),
|
||||
uniqueIndex('users_email_idx').on(columns.email),
|
||||
],
|
||||
);
|
||||
|
||||
export const media = pgTable(
|
||||
'media',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
alt: varchar('alt').notNull(),
|
||||
caption: varchar('caption'),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
createdAt: timestamp('created_at', { mode: 'string', withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
url: varchar('url'),
|
||||
thumbnailURL: varchar('thumbnail_u_r_l'),
|
||||
filename: varchar('filename'),
|
||||
mimeType: varchar('mime_type'),
|
||||
filesize: numeric('filesize', { mode: 'number' }),
|
||||
width: numeric('width', { mode: 'number' }),
|
||||
height: numeric('height', { mode: 'number' }),
|
||||
focalX: numeric('focal_x', { mode: 'number' }),
|
||||
focalY: numeric('focal_y', { mode: 'number' }),
|
||||
sizes_thumbnail_url: varchar('sizes_thumbnail_url'),
|
||||
sizes_thumbnail_width: numeric('sizes_thumbnail_width', { mode: 'number' }),
|
||||
sizes_thumbnail_height: numeric('sizes_thumbnail_height', { mode: 'number' }),
|
||||
sizes_thumbnail_mimeType: varchar('sizes_thumbnail_mime_type'),
|
||||
sizes_thumbnail_filesize: numeric('sizes_thumbnail_filesize', { mode: 'number' }),
|
||||
sizes_thumbnail_filename: varchar('sizes_thumbnail_filename'),
|
||||
sizes_card_url: varchar('sizes_card_url'),
|
||||
sizes_card_width: numeric('sizes_card_width', { mode: 'number' }),
|
||||
sizes_card_height: numeric('sizes_card_height', { mode: 'number' }),
|
||||
sizes_card_mimeType: varchar('sizes_card_mime_type'),
|
||||
sizes_card_filesize: numeric('sizes_card_filesize', { mode: 'number' }),
|
||||
sizes_card_filename: varchar('sizes_card_filename'),
|
||||
sizes_hero_url: varchar('sizes_hero_url'),
|
||||
sizes_hero_width: numeric('sizes_hero_width', { mode: 'number' }),
|
||||
sizes_hero_height: numeric('sizes_hero_height', { mode: 'number' }),
|
||||
sizes_hero_mimeType: varchar('sizes_hero_mime_type'),
|
||||
sizes_hero_filesize: numeric('sizes_hero_filesize', { mode: 'number' }),
|
||||
sizes_hero_filename: varchar('sizes_hero_filename'),
|
||||
sizes_hero_mobile_url: varchar('sizes_hero_mobile_url'),
|
||||
sizes_hero_mobile_width: numeric('sizes_hero_mobile_width', { mode: 'number' }),
|
||||
sizes_hero_mobile_height: numeric('sizes_hero_mobile_height', { mode: 'number' }),
|
||||
sizes_hero_mobile_mimeType: varchar('sizes_hero_mobile_mime_type'),
|
||||
sizes_hero_mobile_filesize: numeric('sizes_hero_mobile_filesize', { mode: 'number' }),
|
||||
sizes_hero_mobile_filename: varchar('sizes_hero_mobile_filename'),
|
||||
},
|
||||
(columns) => [
|
||||
index('media_updated_at_idx').on(columns.updatedAt),
|
||||
index('media_created_at_idx').on(columns.createdAt),
|
||||
uniqueIndex('media_filename_idx').on(columns.filename),
|
||||
index('media_sizes_thumbnail_sizes_thumbnail_filename_idx').on(
|
||||
columns.sizes_thumbnail_filename,
|
||||
),
|
||||
index('media_sizes_card_sizes_card_filename_idx').on(columns.sizes_card_filename),
|
||||
index('media_sizes_hero_sizes_hero_filename_idx').on(columns.sizes_hero_filename),
|
||||
index('media_sizes_hero_mobile_sizes_hero_mobile_filename_idx').on(
|
||||
columns.sizes_hero_mobile_filename,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const posts = pgTable(
|
||||
'posts',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
title: varchar('title'),
|
||||
slug: varchar('slug'),
|
||||
excerpt: varchar('excerpt'),
|
||||
date: timestamp('date', { mode: 'string', withTimezone: true, precision: 3 }),
|
||||
featuredImage: integer('featured_image_id').references(() => media.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
locale: enum_posts_locale('locale').default('en'),
|
||||
category: varchar('category'),
|
||||
content: jsonb('content'),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
createdAt: timestamp('created_at', { mode: 'string', withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
_status: enum_posts_status('_status').default('draft'),
|
||||
},
|
||||
(columns) => [
|
||||
uniqueIndex('posts_slug_idx').on(columns.slug),
|
||||
index('posts_featured_image_idx').on(columns.featuredImage),
|
||||
index('posts_updated_at_idx').on(columns.updatedAt),
|
||||
index('posts_created_at_idx').on(columns.createdAt),
|
||||
index('posts__status_idx').on(columns._status),
|
||||
],
|
||||
);
|
||||
|
||||
export const _posts_v = pgTable(
|
||||
'_posts_v',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
parent: integer('parent_id').references(() => posts.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
version_title: varchar('version_title'),
|
||||
version_slug: varchar('version_slug'),
|
||||
version_excerpt: varchar('version_excerpt'),
|
||||
version_date: timestamp('version_date', { mode: 'string', withTimezone: true, precision: 3 }),
|
||||
version_featuredImage: integer('version_featured_image_id').references(() => media.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
version_locale: enum__posts_v_version_locale('version_locale').default('en'),
|
||||
version_category: varchar('version_category'),
|
||||
version_content: jsonb('version_content'),
|
||||
version_updatedAt: timestamp('version_updated_at', {
|
||||
mode: 'string',
|
||||
withTimezone: true,
|
||||
precision: 3,
|
||||
}),
|
||||
version_createdAt: timestamp('version_created_at', {
|
||||
mode: 'string',
|
||||
withTimezone: true,
|
||||
precision: 3,
|
||||
}),
|
||||
version__status: enum__posts_v_version_status('version__status').default('draft'),
|
||||
createdAt: timestamp('created_at', { mode: 'string', withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
latest: boolean('latest'),
|
||||
},
|
||||
(columns) => [
|
||||
index('_posts_v_parent_idx').on(columns.parent),
|
||||
index('_posts_v_version_version_slug_idx').on(columns.version_slug),
|
||||
index('_posts_v_version_version_featured_image_idx').on(columns.version_featuredImage),
|
||||
index('_posts_v_version_version_updated_at_idx').on(columns.version_updatedAt),
|
||||
index('_posts_v_version_version_created_at_idx').on(columns.version_createdAt),
|
||||
index('_posts_v_version_version__status_idx').on(columns.version__status),
|
||||
index('_posts_v_created_at_idx').on(columns.createdAt),
|
||||
index('_posts_v_updated_at_idx').on(columns.updatedAt),
|
||||
index('_posts_v_latest_idx').on(columns.latest),
|
||||
],
|
||||
);
|
||||
|
||||
export const form_submissions = pgTable(
|
||||
'form_submissions',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name').notNull(),
|
||||
email: varchar('email').notNull(),
|
||||
type: enum_form_submissions_type('type').notNull(),
|
||||
productName: varchar('product_name'),
|
||||
message: varchar('message').notNull(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
createdAt: timestamp('created_at', { mode: 'string', withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(columns) => [
|
||||
index('form_submissions_updated_at_idx').on(columns.updatedAt),
|
||||
index('form_submissions_created_at_idx').on(columns.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const products_categories = pgTable(
|
||||
'products_categories',
|
||||
{
|
||||
_order: integer('_order').notNull(),
|
||||
_parentID: integer('_parent_id').notNull(),
|
||||
id: varchar('id').primaryKey(),
|
||||
category: varchar('category'),
|
||||
},
|
||||
(columns) => [
|
||||
index('products_categories_order_idx').on(columns._order),
|
||||
index('products_categories_parent_id_idx').on(columns._parentID),
|
||||
foreignKey({
|
||||
columns: [columns['_parentID']],
|
||||
foreignColumns: [products.id],
|
||||
name: 'products_categories_parent_id_fk',
|
||||
}).onDelete('cascade'),
|
||||
],
|
||||
);
|
||||
|
||||
export const products = pgTable(
|
||||
'products',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
title: varchar('title'),
|
||||
sku: varchar('sku'),
|
||||
slug: varchar('slug'),
|
||||
description: varchar('description'),
|
||||
locale: enum_products_locale('locale').default('de'),
|
||||
application: jsonb('application'),
|
||||
content: jsonb('content'),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
createdAt: timestamp('created_at', { mode: 'string', withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
_status: enum_products_status('_status').default('draft'),
|
||||
},
|
||||
(columns) => [
|
||||
uniqueIndex('products_sku_idx').on(columns.sku),
|
||||
index('products_updated_at_idx').on(columns.updatedAt),
|
||||
index('products_created_at_idx').on(columns.createdAt),
|
||||
index('products__status_idx').on(columns._status),
|
||||
],
|
||||
);
|
||||
|
||||
export const products_rels = pgTable(
|
||||
'products_rels',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
order: integer('order'),
|
||||
parent: integer('parent_id').notNull(),
|
||||
path: varchar('path').notNull(),
|
||||
mediaID: integer('media_id'),
|
||||
},
|
||||
(columns) => [
|
||||
index('products_rels_order_idx').on(columns.order),
|
||||
index('products_rels_parent_idx').on(columns.parent),
|
||||
index('products_rels_path_idx').on(columns.path),
|
||||
index('products_rels_media_id_idx').on(columns.mediaID),
|
||||
foreignKey({
|
||||
columns: [columns['parent']],
|
||||
foreignColumns: [products.id],
|
||||
name: 'products_rels_parent_fk',
|
||||
}).onDelete('cascade'),
|
||||
foreignKey({
|
||||
columns: [columns['mediaID']],
|
||||
foreignColumns: [media.id],
|
||||
name: 'products_rels_media_fk',
|
||||
}).onDelete('cascade'),
|
||||
],
|
||||
);
|
||||
|
||||
export const _products_v_version_categories = pgTable(
|
||||
'_products_v_version_categories',
|
||||
{
|
||||
_order: integer('_order').notNull(),
|
||||
_parentID: integer('_parent_id').notNull(),
|
||||
id: serial('id').primaryKey(),
|
||||
category: varchar('category'),
|
||||
_uuid: varchar('_uuid'),
|
||||
},
|
||||
(columns) => [
|
||||
index('_products_v_version_categories_order_idx').on(columns._order),
|
||||
index('_products_v_version_categories_parent_id_idx').on(columns._parentID),
|
||||
foreignKey({
|
||||
columns: [columns['_parentID']],
|
||||
foreignColumns: [_products_v.id],
|
||||
name: '_products_v_version_categories_parent_id_fk',
|
||||
}).onDelete('cascade'),
|
||||
],
|
||||
);
|
||||
|
||||
export const _products_v = pgTable(
|
||||
'_products_v',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
parent: integer('parent_id').references(() => products.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
version_title: varchar('version_title'),
|
||||
version_sku: varchar('version_sku'),
|
||||
version_slug: varchar('version_slug'),
|
||||
version_description: varchar('version_description'),
|
||||
version_locale: enum__products_v_version_locale('version_locale').default('de'),
|
||||
version_application: jsonb('version_application'),
|
||||
version_content: jsonb('version_content'),
|
||||
version_updatedAt: timestamp('version_updated_at', {
|
||||
mode: 'string',
|
||||
withTimezone: true,
|
||||
precision: 3,
|
||||
}),
|
||||
version_createdAt: timestamp('version_created_at', {
|
||||
mode: 'string',
|
||||
withTimezone: true,
|
||||
precision: 3,
|
||||
}),
|
||||
version__status: enum__products_v_version_status('version__status').default('draft'),
|
||||
createdAt: timestamp('created_at', { mode: 'string', withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
latest: boolean('latest'),
|
||||
},
|
||||
(columns) => [
|
||||
index('_products_v_parent_idx').on(columns.parent),
|
||||
index('_products_v_version_version_sku_idx').on(columns.version_sku),
|
||||
index('_products_v_version_version_updated_at_idx').on(columns.version_updatedAt),
|
||||
index('_products_v_version_version_created_at_idx').on(columns.version_createdAt),
|
||||
index('_products_v_version_version__status_idx').on(columns.version__status),
|
||||
index('_products_v_created_at_idx').on(columns.createdAt),
|
||||
index('_products_v_updated_at_idx').on(columns.updatedAt),
|
||||
index('_products_v_latest_idx').on(columns.latest),
|
||||
],
|
||||
);
|
||||
|
||||
export const _products_v_rels = pgTable(
|
||||
'_products_v_rels',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
order: integer('order'),
|
||||
parent: integer('parent_id').notNull(),
|
||||
path: varchar('path').notNull(),
|
||||
mediaID: integer('media_id'),
|
||||
},
|
||||
(columns) => [
|
||||
index('_products_v_rels_order_idx').on(columns.order),
|
||||
index('_products_v_rels_parent_idx').on(columns.parent),
|
||||
index('_products_v_rels_path_idx').on(columns.path),
|
||||
index('_products_v_rels_media_id_idx').on(columns.mediaID),
|
||||
foreignKey({
|
||||
columns: [columns['parent']],
|
||||
foreignColumns: [_products_v.id],
|
||||
name: '_products_v_rels_parent_fk',
|
||||
}).onDelete('cascade'),
|
||||
foreignKey({
|
||||
columns: [columns['mediaID']],
|
||||
foreignColumns: [media.id],
|
||||
name: '_products_v_rels_media_fk',
|
||||
}).onDelete('cascade'),
|
||||
],
|
||||
);
|
||||
|
||||
export const payload_kv = pgTable(
|
||||
'payload_kv',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
key: varchar('key').notNull(),
|
||||
data: jsonb('data').notNull(),
|
||||
},
|
||||
(columns) => [uniqueIndex('payload_kv_key_idx').on(columns.key)],
|
||||
);
|
||||
|
||||
export const payload_locked_documents = pgTable(
|
||||
'payload_locked_documents',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
globalSlug: varchar('global_slug'),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
createdAt: timestamp('created_at', { mode: 'string', withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(columns) => [
|
||||
index('payload_locked_documents_global_slug_idx').on(columns.globalSlug),
|
||||
index('payload_locked_documents_updated_at_idx').on(columns.updatedAt),
|
||||
index('payload_locked_documents_created_at_idx').on(columns.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const payload_locked_documents_rels = pgTable(
|
||||
'payload_locked_documents_rels',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
order: integer('order'),
|
||||
parent: integer('parent_id').notNull(),
|
||||
path: varchar('path').notNull(),
|
||||
usersID: integer('users_id'),
|
||||
mediaID: integer('media_id'),
|
||||
postsID: integer('posts_id'),
|
||||
'form-submissionsID': integer('form_submissions_id'),
|
||||
productsID: integer('products_id'),
|
||||
},
|
||||
(columns) => [
|
||||
index('payload_locked_documents_rels_order_idx').on(columns.order),
|
||||
index('payload_locked_documents_rels_parent_idx').on(columns.parent),
|
||||
index('payload_locked_documents_rels_path_idx').on(columns.path),
|
||||
index('payload_locked_documents_rels_users_id_idx').on(columns.usersID),
|
||||
index('payload_locked_documents_rels_media_id_idx').on(columns.mediaID),
|
||||
index('payload_locked_documents_rels_posts_id_idx').on(columns.postsID),
|
||||
index('payload_locked_documents_rels_form_submissions_id_idx').on(
|
||||
columns['form-submissionsID'],
|
||||
),
|
||||
index('payload_locked_documents_rels_products_id_idx').on(columns.productsID),
|
||||
foreignKey({
|
||||
columns: [columns['parent']],
|
||||
foreignColumns: [payload_locked_documents.id],
|
||||
name: 'payload_locked_documents_rels_parent_fk',
|
||||
}).onDelete('cascade'),
|
||||
foreignKey({
|
||||
columns: [columns['usersID']],
|
||||
foreignColumns: [users.id],
|
||||
name: 'payload_locked_documents_rels_users_fk',
|
||||
}).onDelete('cascade'),
|
||||
foreignKey({
|
||||
columns: [columns['mediaID']],
|
||||
foreignColumns: [media.id],
|
||||
name: 'payload_locked_documents_rels_media_fk',
|
||||
}).onDelete('cascade'),
|
||||
foreignKey({
|
||||
columns: [columns['postsID']],
|
||||
foreignColumns: [posts.id],
|
||||
name: 'payload_locked_documents_rels_posts_fk',
|
||||
}).onDelete('cascade'),
|
||||
foreignKey({
|
||||
columns: [columns['form-submissionsID']],
|
||||
foreignColumns: [form_submissions.id],
|
||||
name: 'payload_locked_documents_rels_form_submissions_fk',
|
||||
}).onDelete('cascade'),
|
||||
foreignKey({
|
||||
columns: [columns['productsID']],
|
||||
foreignColumns: [products.id],
|
||||
name: 'payload_locked_documents_rels_products_fk',
|
||||
}).onDelete('cascade'),
|
||||
],
|
||||
);
|
||||
|
||||
export const payload_preferences = pgTable(
|
||||
'payload_preferences',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
key: varchar('key'),
|
||||
value: jsonb('value'),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
createdAt: timestamp('created_at', { mode: 'string', withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(columns) => [
|
||||
index('payload_preferences_key_idx').on(columns.key),
|
||||
index('payload_preferences_updated_at_idx').on(columns.updatedAt),
|
||||
index('payload_preferences_created_at_idx').on(columns.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const payload_preferences_rels = pgTable(
|
||||
'payload_preferences_rels',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
order: integer('order'),
|
||||
parent: integer('parent_id').notNull(),
|
||||
path: varchar('path').notNull(),
|
||||
usersID: integer('users_id'),
|
||||
},
|
||||
(columns) => [
|
||||
index('payload_preferences_rels_order_idx').on(columns.order),
|
||||
index('payload_preferences_rels_parent_idx').on(columns.parent),
|
||||
index('payload_preferences_rels_path_idx').on(columns.path),
|
||||
index('payload_preferences_rels_users_id_idx').on(columns.usersID),
|
||||
foreignKey({
|
||||
columns: [columns['parent']],
|
||||
foreignColumns: [payload_preferences.id],
|
||||
name: 'payload_preferences_rels_parent_fk',
|
||||
}).onDelete('cascade'),
|
||||
foreignKey({
|
||||
columns: [columns['usersID']],
|
||||
foreignColumns: [users.id],
|
||||
name: 'payload_preferences_rels_users_fk',
|
||||
}).onDelete('cascade'),
|
||||
],
|
||||
);
|
||||
|
||||
export const payload_migrations = pgTable(
|
||||
'payload_migrations',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name'),
|
||||
batch: numeric('batch', { mode: 'number' }),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
createdAt: timestamp('created_at', { mode: 'string', withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(columns) => [
|
||||
index('payload_migrations_updated_at_idx').on(columns.updatedAt),
|
||||
index('payload_migrations_created_at_idx').on(columns.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const relations_users_sessions = relations(users_sessions, ({ one }) => ({
|
||||
_parentID: one(users, {
|
||||
fields: [users_sessions._parentID],
|
||||
references: [users.id],
|
||||
relationName: 'sessions',
|
||||
}),
|
||||
}));
|
||||
export const relations_users = relations(users, ({ many }) => ({
|
||||
sessions: many(users_sessions, {
|
||||
relationName: 'sessions',
|
||||
}),
|
||||
}));
|
||||
export const relations_media = relations(media, () => ({}));
|
||||
export const relations_posts = relations(posts, ({ one }) => ({
|
||||
featuredImage: one(media, {
|
||||
fields: [posts.featuredImage],
|
||||
references: [media.id],
|
||||
relationName: 'featuredImage',
|
||||
}),
|
||||
}));
|
||||
export const relations__posts_v = relations(_posts_v, ({ one }) => ({
|
||||
parent: one(posts, {
|
||||
fields: [_posts_v.parent],
|
||||
references: [posts.id],
|
||||
relationName: 'parent',
|
||||
}),
|
||||
version_featuredImage: one(media, {
|
||||
fields: [_posts_v.version_featuredImage],
|
||||
references: [media.id],
|
||||
relationName: 'version_featuredImage',
|
||||
}),
|
||||
}));
|
||||
export const relations_form_submissions = relations(form_submissions, () => ({}));
|
||||
export const relations_products_categories = relations(products_categories, ({ one }) => ({
|
||||
_parentID: one(products, {
|
||||
fields: [products_categories._parentID],
|
||||
references: [products.id],
|
||||
relationName: 'categories',
|
||||
}),
|
||||
}));
|
||||
export const relations_products_rels = relations(products_rels, ({ one }) => ({
|
||||
parent: one(products, {
|
||||
fields: [products_rels.parent],
|
||||
references: [products.id],
|
||||
relationName: '_rels',
|
||||
}),
|
||||
mediaID: one(media, {
|
||||
fields: [products_rels.mediaID],
|
||||
references: [media.id],
|
||||
relationName: 'media',
|
||||
}),
|
||||
}));
|
||||
export const relations_products = relations(products, ({ many }) => ({
|
||||
categories: many(products_categories, {
|
||||
relationName: 'categories',
|
||||
}),
|
||||
_rels: many(products_rels, {
|
||||
relationName: '_rels',
|
||||
}),
|
||||
}));
|
||||
export const relations__products_v_version_categories = relations(
|
||||
_products_v_version_categories,
|
||||
({ one }) => ({
|
||||
_parentID: one(_products_v, {
|
||||
fields: [_products_v_version_categories._parentID],
|
||||
references: [_products_v.id],
|
||||
relationName: 'version_categories',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
export const relations__products_v_rels = relations(_products_v_rels, ({ one }) => ({
|
||||
parent: one(_products_v, {
|
||||
fields: [_products_v_rels.parent],
|
||||
references: [_products_v.id],
|
||||
relationName: '_rels',
|
||||
}),
|
||||
mediaID: one(media, {
|
||||
fields: [_products_v_rels.mediaID],
|
||||
references: [media.id],
|
||||
relationName: 'media',
|
||||
}),
|
||||
}));
|
||||
export const relations__products_v = relations(_products_v, ({ one, many }) => ({
|
||||
parent: one(products, {
|
||||
fields: [_products_v.parent],
|
||||
references: [products.id],
|
||||
relationName: 'parent',
|
||||
}),
|
||||
version_categories: many(_products_v_version_categories, {
|
||||
relationName: 'version_categories',
|
||||
}),
|
||||
_rels: many(_products_v_rels, {
|
||||
relationName: '_rels',
|
||||
}),
|
||||
}));
|
||||
export const relations_payload_kv = relations(payload_kv, () => ({}));
|
||||
export const relations_payload_locked_documents_rels = relations(
|
||||
payload_locked_documents_rels,
|
||||
({ one }) => ({
|
||||
parent: one(payload_locked_documents, {
|
||||
fields: [payload_locked_documents_rels.parent],
|
||||
references: [payload_locked_documents.id],
|
||||
relationName: '_rels',
|
||||
}),
|
||||
usersID: one(users, {
|
||||
fields: [payload_locked_documents_rels.usersID],
|
||||
references: [users.id],
|
||||
relationName: 'users',
|
||||
}),
|
||||
mediaID: one(media, {
|
||||
fields: [payload_locked_documents_rels.mediaID],
|
||||
references: [media.id],
|
||||
relationName: 'media',
|
||||
}),
|
||||
postsID: one(posts, {
|
||||
fields: [payload_locked_documents_rels.postsID],
|
||||
references: [posts.id],
|
||||
relationName: 'posts',
|
||||
}),
|
||||
'form-submissionsID': one(form_submissions, {
|
||||
fields: [payload_locked_documents_rels['form-submissionsID']],
|
||||
references: [form_submissions.id],
|
||||
relationName: 'form-submissions',
|
||||
}),
|
||||
productsID: one(products, {
|
||||
fields: [payload_locked_documents_rels.productsID],
|
||||
references: [products.id],
|
||||
relationName: 'products',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
export const relations_payload_locked_documents = relations(
|
||||
payload_locked_documents,
|
||||
({ many }) => ({
|
||||
_rels: many(payload_locked_documents_rels, {
|
||||
relationName: '_rels',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
export const relations_payload_preferences_rels = relations(
|
||||
payload_preferences_rels,
|
||||
({ one }) => ({
|
||||
parent: one(payload_preferences, {
|
||||
fields: [payload_preferences_rels.parent],
|
||||
references: [payload_preferences.id],
|
||||
relationName: '_rels',
|
||||
}),
|
||||
usersID: one(users, {
|
||||
fields: [payload_preferences_rels.usersID],
|
||||
references: [users.id],
|
||||
relationName: 'users',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
export const relations_payload_preferences = relations(payload_preferences, ({ many }) => ({
|
||||
_rels: many(payload_preferences_rels, {
|
||||
relationName: '_rels',
|
||||
}),
|
||||
}));
|
||||
export const relations_payload_migrations = relations(payload_migrations, () => ({}));
|
||||
|
||||
type DatabaseSchema = {
|
||||
enum_posts_locale: typeof enum_posts_locale;
|
||||
enum_posts_status: typeof enum_posts_status;
|
||||
enum__posts_v_version_locale: typeof enum__posts_v_version_locale;
|
||||
enum__posts_v_version_status: typeof enum__posts_v_version_status;
|
||||
enum_form_submissions_type: typeof enum_form_submissions_type;
|
||||
enum_products_locale: typeof enum_products_locale;
|
||||
enum_products_status: typeof enum_products_status;
|
||||
enum__products_v_version_locale: typeof enum__products_v_version_locale;
|
||||
enum__products_v_version_status: typeof enum__products_v_version_status;
|
||||
users_sessions: typeof users_sessions;
|
||||
users: typeof users;
|
||||
media: typeof media;
|
||||
posts: typeof posts;
|
||||
_posts_v: typeof _posts_v;
|
||||
form_submissions: typeof form_submissions;
|
||||
products_categories: typeof products_categories;
|
||||
products: typeof products;
|
||||
products_rels: typeof products_rels;
|
||||
_products_v_version_categories: typeof _products_v_version_categories;
|
||||
_products_v: typeof _products_v;
|
||||
_products_v_rels: typeof _products_v_rels;
|
||||
payload_kv: typeof payload_kv;
|
||||
payload_locked_documents: typeof payload_locked_documents;
|
||||
payload_locked_documents_rels: typeof payload_locked_documents_rels;
|
||||
payload_preferences: typeof payload_preferences;
|
||||
payload_preferences_rels: typeof payload_preferences_rels;
|
||||
payload_migrations: typeof payload_migrations;
|
||||
relations_users_sessions: typeof relations_users_sessions;
|
||||
relations_users: typeof relations_users;
|
||||
relations_media: typeof relations_media;
|
||||
relations_posts: typeof relations_posts;
|
||||
relations__posts_v: typeof relations__posts_v;
|
||||
relations_form_submissions: typeof relations_form_submissions;
|
||||
relations_products_categories: typeof relations_products_categories;
|
||||
relations_products_rels: typeof relations_products_rels;
|
||||
relations_products: typeof relations_products;
|
||||
relations__products_v_version_categories: typeof relations__products_v_version_categories;
|
||||
relations__products_v_rels: typeof relations__products_v_rels;
|
||||
relations__products_v: typeof relations__products_v;
|
||||
relations_payload_kv: typeof relations_payload_kv;
|
||||
relations_payload_locked_documents_rels: typeof relations_payload_locked_documents_rels;
|
||||
relations_payload_locked_documents: typeof relations_payload_locked_documents;
|
||||
relations_payload_preferences_rels: typeof relations_payload_preferences_rels;
|
||||
relations_payload_preferences: typeof relations_payload_preferences;
|
||||
relations_payload_migrations: typeof relations_payload_migrations;
|
||||
};
|
||||
|
||||
declare module '@payloadcms/db-postgres' {
|
||||
export interface GeneratedDatabaseSchema {
|
||||
schema: DatabaseSchema;
|
||||
}
|
||||
}
|
||||
25
src/payload/blocks/AnimatedImage.ts
Normal file
25
src/payload/blocks/AnimatedImage.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Block } from 'payload';
|
||||
|
||||
export const AnimatedImage: Block = {
|
||||
slug: 'animatedImage',
|
||||
fields: [
|
||||
{
|
||||
name: 'src',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'alt',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'width',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
name: 'height',
|
||||
type: 'number',
|
||||
},
|
||||
],
|
||||
};
|
||||
20
src/payload/blocks/Callout.ts
Normal file
20
src/payload/blocks/Callout.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Block } from 'payload';
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical';
|
||||
|
||||
export const Callout: Block = {
|
||||
slug: 'callout',
|
||||
fields: [
|
||||
{
|
||||
name: 'type',
|
||||
type: 'select',
|
||||
options: ['info', 'warning', 'important', 'tip', 'caution'],
|
||||
defaultValue: 'info',
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({}),
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
34
src/payload/blocks/ChatBubble.ts
Normal file
34
src/payload/blocks/ChatBubble.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Block } from 'payload';
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical';
|
||||
|
||||
export const ChatBubble: Block = {
|
||||
slug: 'chatBubble',
|
||||
fields: [
|
||||
{
|
||||
name: 'author',
|
||||
type: 'text',
|
||||
defaultValue: 'KLZ Team',
|
||||
},
|
||||
{
|
||||
name: 'avatar',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'role',
|
||||
type: 'text',
|
||||
defaultValue: 'Assistant',
|
||||
},
|
||||
{
|
||||
name: 'align',
|
||||
type: 'select',
|
||||
options: ['left', 'right'],
|
||||
defaultValue: 'left',
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({}),
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
47
src/payload/blocks/ComparisonGrid.ts
Normal file
47
src/payload/blocks/ComparisonGrid.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Block } from 'payload';
|
||||
|
||||
export const ComparisonGrid: Block = {
|
||||
slug: 'comparisonGrid',
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
label: 'Main Heading',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'leftLabel',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'rightLabel',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'items',
|
||||
type: 'array',
|
||||
required: true,
|
||||
minRows: 1,
|
||||
fields: [
|
||||
{
|
||||
name: 'label',
|
||||
label: 'Row Label',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'leftValue',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'rightValue',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
20
src/payload/blocks/HighlightBox.ts
Normal file
20
src/payload/blocks/HighlightBox.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Block } from 'payload';
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical';
|
||||
|
||||
export const HighlightBox: Block = {
|
||||
slug: 'highlightBox',
|
||||
fields: [
|
||||
{
|
||||
name: 'type',
|
||||
type: 'select',
|
||||
options: ['info', 'warning', 'success', 'error', 'neutral'],
|
||||
defaultValue: 'neutral',
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({}),
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
12
src/payload/blocks/PowerCTA.ts
Normal file
12
src/payload/blocks/PowerCTA.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Block } from 'payload';
|
||||
|
||||
export const PowerCTA: Block = {
|
||||
slug: 'powerCTA',
|
||||
fields: [
|
||||
{
|
||||
name: 'locale',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
96
src/payload/blocks/ProductTabs.ts
Normal file
96
src/payload/blocks/ProductTabs.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Block } from 'payload';
|
||||
|
||||
export const ProductTabs: Block = {
|
||||
slug: 'productTabs',
|
||||
interfaceName: 'ProductTabsBlock',
|
||||
fields: [
|
||||
{
|
||||
name: 'technicalItems',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'label',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'value',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'unit',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'voltageTables',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'voltageLabel',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'metaItems',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'label',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'value',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'unit',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'columns',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'key',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'label',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'rows',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'configuration',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'cells',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'value',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
23
src/payload/blocks/SplitHeading.ts
Normal file
23
src/payload/blocks/SplitHeading.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Block } from 'payload';
|
||||
|
||||
export const SplitHeading: Block = {
|
||||
slug: 'splitHeading',
|
||||
interfaceName: 'SplitHeadingBlock',
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'id',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'level',
|
||||
type: 'select',
|
||||
options: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
|
||||
defaultValue: 'h2',
|
||||
},
|
||||
],
|
||||
};
|
||||
25
src/payload/blocks/Stats.ts
Normal file
25
src/payload/blocks/Stats.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Block } from 'payload';
|
||||
|
||||
export const Stats: Block = {
|
||||
slug: 'stats',
|
||||
interfaceName: 'StatsBlock',
|
||||
fields: [
|
||||
{
|
||||
name: 'stats',
|
||||
type: 'array',
|
||||
required: true,
|
||||
fields: [
|
||||
{
|
||||
name: 'value',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'label',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
31
src/payload/blocks/StickyNarrative.ts
Normal file
31
src/payload/blocks/StickyNarrative.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Block } from 'payload';
|
||||
|
||||
export const StickyNarrative: Block = {
|
||||
slug: 'stickyNarrative',
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
label: 'Main Heading',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'items',
|
||||
type: 'array',
|
||||
required: true,
|
||||
minRows: 1,
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
30
src/payload/blocks/TechnicalGrid.ts
Normal file
30
src/payload/blocks/TechnicalGrid.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Block } from 'payload';
|
||||
|
||||
export const TechnicalGrid: Block = {
|
||||
slug: 'technicalGrid',
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
label: 'Main Heading',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'items',
|
||||
type: 'array',
|
||||
required: true,
|
||||
minRows: 1,
|
||||
fields: [
|
||||
{
|
||||
name: 'label',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'value',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
30
src/payload/blocks/VisualLinkPreview.ts
Normal file
30
src/payload/blocks/VisualLinkPreview.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Block } from 'payload';
|
||||
|
||||
export const VisualLinkPreview: Block = {
|
||||
slug: 'visualLinkPreview',
|
||||
fields: [
|
||||
{
|
||||
name: 'url',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'summary',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Legacy HTTP string from the old hardcoded images.',
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
27
src/payload/blocks/allBlocks.ts
Normal file
27
src/payload/blocks/allBlocks.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { AnimatedImage } from './AnimatedImage';
|
||||
import { Callout } from './Callout';
|
||||
import { ChatBubble } from './ChatBubble';
|
||||
import { ComparisonGrid } from './ComparisonGrid';
|
||||
import { HighlightBox } from './HighlightBox';
|
||||
import { PowerCTA } from './PowerCTA';
|
||||
import { ProductTabs } from './ProductTabs';
|
||||
import { SplitHeading } from './SplitHeading';
|
||||
import { Stats } from './Stats';
|
||||
import { StickyNarrative } from './StickyNarrative';
|
||||
import { TechnicalGrid } from './TechnicalGrid';
|
||||
import { VisualLinkPreview } from './VisualLinkPreview';
|
||||
|
||||
export const payloadBlocks = [
|
||||
AnimatedImage,
|
||||
Callout,
|
||||
ChatBubble,
|
||||
ComparisonGrid,
|
||||
HighlightBox,
|
||||
PowerCTA,
|
||||
ProductTabs,
|
||||
SplitHeading,
|
||||
Stats,
|
||||
StickyNarrative,
|
||||
TechnicalGrid,
|
||||
VisualLinkPreview,
|
||||
];
|
||||
67
src/payload/collections/FormSubmissions.ts
Normal file
67
src/payload/collections/FormSubmissions.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
export const FormSubmissions: CollectionConfig = {
|
||||
slug: 'form-submissions',
|
||||
admin: {
|
||||
useAsTitle: 'name',
|
||||
defaultColumns: ['name', 'email', 'type', 'createdAt'],
|
||||
description: 'Captured leads from Contact and Product Quote forms.',
|
||||
},
|
||||
access: {
|
||||
// Only Admins can view and delete leads via dashboard.
|
||||
read: ({ req: { user } }) => Boolean(user) || process.env.NODE_ENV === 'development',
|
||||
update: ({ req: { user } }) => Boolean(user) || process.env.NODE_ENV === 'development',
|
||||
delete: ({ req: { user } }) => Boolean(user) || process.env.NODE_ENV === 'development',
|
||||
// Next.js server actions handle secure inserts natively. No public client create access.
|
||||
create: () => false,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
required: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'General Contact', value: 'contact' },
|
||||
{ label: 'Product Quote', value: 'product_quote' },
|
||||
],
|
||||
required: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'productName',
|
||||
type: 'text',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
readOnly: true,
|
||||
condition: (data) => data.type === 'product_quote',
|
||||
description: 'The specific KLZ product the user requested a quote for.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
48
src/payload/collections/Media.ts
Normal file
48
src/payload/collections/Media.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
export const Media: CollectionConfig = {
|
||||
slug: 'media',
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'alt',
|
||||
defaultColumns: ['filename', 'alt', 'updatedAt'],
|
||||
},
|
||||
upload: {
|
||||
staticDir: 'public/media',
|
||||
adminThumbnail: 'thumbnail',
|
||||
imageSizes: [
|
||||
{
|
||||
name: 'thumbnail',
|
||||
width: 600,
|
||||
// height: undefined allows wide 5:1 aspect ratios to be preserved without cropping
|
||||
height: undefined,
|
||||
position: 'centre',
|
||||
},
|
||||
{
|
||||
name: 'card',
|
||||
width: 768,
|
||||
height: undefined,
|
||||
position: 'centre',
|
||||
},
|
||||
{
|
||||
name: 'tablet',
|
||||
width: 1024,
|
||||
height: undefined,
|
||||
position: 'centre',
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'alt',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'caption',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
};
|
||||
61
src/payload/collections/Pages.ts
Normal file
61
src/payload/collections/Pages.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { CollectionConfig } from 'payload';
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical';
|
||||
|
||||
export const Pages: CollectionConfig = {
|
||||
slug: 'pages',
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
defaultColumns: ['title', 'slug', 'locale', 'updatedAt'],
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'locale',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'English', value: 'en' },
|
||||
{ label: 'German', value: 'de' },
|
||||
],
|
||||
required: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'excerpt',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'featuredImage',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({}),
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
154
src/payload/collections/Posts.ts
Normal file
154
src/payload/collections/Posts.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
import { lexicalEditor, BlocksFeature } from '@payloadcms/richtext-lexical';
|
||||
|
||||
import { StickyNarrative } from '../blocks/StickyNarrative';
|
||||
import { ComparisonGrid } from '../blocks/ComparisonGrid';
|
||||
import { VisualLinkPreview } from '../blocks/VisualLinkPreview';
|
||||
import { TechnicalGrid } from '../blocks/TechnicalGrid';
|
||||
import { HighlightBox } from '../blocks/HighlightBox';
|
||||
import { AnimatedImage } from '../blocks/AnimatedImage';
|
||||
import { ChatBubble } from '../blocks/ChatBubble';
|
||||
import { PowerCTA } from '../blocks/PowerCTA';
|
||||
import { Callout } from '../blocks/Callout';
|
||||
import { Stats } from '../blocks/Stats';
|
||||
import { SplitHeading } from '../blocks/SplitHeading';
|
||||
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
defaultColumns: ['featuredImage', 'title', 'date', 'updatedAt', '_status'],
|
||||
},
|
||||
versions: {
|
||||
drafts: true, // Enables Draft/Published workflows
|
||||
},
|
||||
access: {
|
||||
read: ({ req: { user } }) => {
|
||||
// In local development, always show everything (including Drafts and scheduled future posts)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If an Admin user is logged in, they can view everything
|
||||
if (user) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For public unauthenticated visitors in PROD/STAGING contexts:
|
||||
// Only serve Posts where Status = "published" AND the publish Date is in the past!
|
||||
return {
|
||||
and: [
|
||||
{
|
||||
_status: {
|
||||
equals: 'published',
|
||||
},
|
||||
},
|
||||
{
|
||||
date: {
|
||||
less_than_equal: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
hooks: {
|
||||
beforeValidate: [
|
||||
({ value, data }) => {
|
||||
// Auto-generate slug from title if left blank
|
||||
if (value || !data?.title) return value;
|
||||
return data.title
|
||||
.toLowerCase()
|
||||
.replace(/ /g, '-')
|
||||
.replace(/[^\w-]+/g, '');
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'excerpt',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'A short summary for blog feed cards and SEO.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
type: 'date',
|
||||
required: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
description: 'Future dates will schedule the post to publish automatically.',
|
||||
},
|
||||
defaultValue: () => new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
name: 'featuredImage',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
description: 'The primary Hero image used for headers and OpenGraph previews.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'locale',
|
||||
type: 'select',
|
||||
required: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
options: [
|
||||
{ label: 'English', value: 'en' },
|
||||
{ label: 'German', value: 'de' },
|
||||
],
|
||||
defaultValue: 'en',
|
||||
},
|
||||
{
|
||||
name: 'category',
|
||||
type: 'text',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
description: 'Used for tag bucketing (e.g. "Kabel Technologie").',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features: ({ defaultFeatures }) => [
|
||||
...defaultFeatures,
|
||||
BlocksFeature({
|
||||
blocks: [
|
||||
StickyNarrative,
|
||||
ComparisonGrid,
|
||||
VisualLinkPreview,
|
||||
TechnicalGrid,
|
||||
HighlightBox,
|
||||
AnimatedImage,
|
||||
ChatBubble,
|
||||
PowerCTA,
|
||||
Callout,
|
||||
Stats,
|
||||
SplitHeading,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
144
src/payload/collections/Products.ts
Normal file
144
src/payload/collections/Products.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
import { lexicalEditor, BlocksFeature } from '@payloadcms/richtext-lexical';
|
||||
|
||||
import { StickyNarrative } from '../blocks/StickyNarrative';
|
||||
import { ComparisonGrid } from '../blocks/ComparisonGrid';
|
||||
import { VisualLinkPreview } from '../blocks/VisualLinkPreview';
|
||||
import { TechnicalGrid } from '../blocks/TechnicalGrid';
|
||||
import { HighlightBox } from '../blocks/HighlightBox';
|
||||
import { AnimatedImage } from '../blocks/AnimatedImage';
|
||||
import { ChatBubble } from '../blocks/ChatBubble';
|
||||
import { PowerCTA } from '../blocks/PowerCTA';
|
||||
import { Callout } from '../blocks/Callout';
|
||||
import { Stats } from '../blocks/Stats';
|
||||
import { SplitHeading } from '../blocks/SplitHeading';
|
||||
import { ProductTabs } from '../blocks/ProductTabs';
|
||||
|
||||
export const Products: CollectionConfig = {
|
||||
slug: 'products',
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
defaultColumns: ['featuredImage', 'title', 'sku', 'locale', 'updatedAt', '_status'],
|
||||
},
|
||||
versions: {
|
||||
drafts: true,
|
||||
},
|
||||
access: {
|
||||
read: ({ req: { user } }) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return true;
|
||||
}
|
||||
if (user) {
|
||||
return true;
|
||||
}
|
||||
return {
|
||||
_status: {
|
||||
equals: 'published',
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'sku',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'locale',
|
||||
type: 'select',
|
||||
required: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
options: [
|
||||
{ label: 'English', value: 'en' },
|
||||
{ label: 'German', value: 'de' },
|
||||
],
|
||||
defaultValue: 'de',
|
||||
},
|
||||
{
|
||||
name: 'categories',
|
||||
type: 'array',
|
||||
required: true,
|
||||
fields: [
|
||||
{
|
||||
name: 'category',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'featuredImage',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
description: 'The primary thumbnail used in list views.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'images',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
hasMany: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'application',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({}),
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features: ({ defaultFeatures }) => [
|
||||
...defaultFeatures,
|
||||
BlocksFeature({
|
||||
blocks: [
|
||||
StickyNarrative,
|
||||
ComparisonGrid,
|
||||
VisualLinkPreview,
|
||||
TechnicalGrid,
|
||||
HighlightBox,
|
||||
AnimatedImage,
|
||||
ChatBubble,
|
||||
PowerCTA,
|
||||
Callout,
|
||||
Stats,
|
||||
SplitHeading,
|
||||
ProductTabs,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
12
src/payload/collections/Users.ts
Normal file
12
src/payload/collections/Users.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
},
|
||||
auth: true,
|
||||
fields: [
|
||||
// Email added by default
|
||||
],
|
||||
};
|
||||
296
src/payload/utils/lexicalParser.ts
Normal file
296
src/payload/utils/lexicalParser.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Converts a Markdown+JSX string into a Lexical AST node array.
|
||||
* Specifically adapted for klz-cables.com custom Component Blocks.
|
||||
*/
|
||||
|
||||
function propValue(chunk: string, prop: string): string {
|
||||
// Match prop="value" or prop='value' or prop={value}
|
||||
// and also multiline props like prop={\n [\n {...}\n ]\n}
|
||||
// For arrays or complex objects passed as props, basic regex might fail,
|
||||
// but the MDX in klz-cables usually uses simpler props or children.
|
||||
const match =
|
||||
chunk.match(new RegExp(`${prop}=["']([^"']+)["']`)) ||
|
||||
chunk.match(new RegExp(`${prop}=\\{([^}]+)\\}`));
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
|
||||
function extractItemsProp(chunk: string, startTag: string): any[] {
|
||||
// Match items={ [ ... ] } robustly without stopping at inner object braces
|
||||
const itemsMatch = chunk.match(/items=\{\s*(\[[\s\S]*?\])\s*\}/);
|
||||
if (itemsMatch) {
|
||||
try {
|
||||
const arrayString = itemsMatch[1].trim();
|
||||
// Since klz-cables MDX passes pure JS object arrays like `items={[{title: 'A', content: 'B'}]}`,
|
||||
// parsing it via Regex to JSON is extremely brittle due to unquoted keys and trailing commas.
|
||||
// Using `new Function` safely evaluates the array AST directly in this Node script environment.
|
||||
const fn = new Function(`return ${arrayString};`);
|
||||
return fn();
|
||||
} catch (_e: any) {
|
||||
console.warn(`Could not parse items array for block ${startTag}:`, _e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function blockNode(blockType: string, fields: Record<string, any>) {
|
||||
return { type: 'block', format: '', version: 2, fields: { blockType, ...fields } };
|
||||
}
|
||||
|
||||
function ensureChildren(parsedNodes: any[]): any[] {
|
||||
// Lexical root nodes require at least one child node, or validation fails
|
||||
if (parsedNodes.length === 0) {
|
||||
return [
|
||||
{
|
||||
type: 'paragraph',
|
||||
format: '',
|
||||
indent: 0,
|
||||
version: 1,
|
||||
children: [{ mode: 'normal', type: 'text', text: ' ', version: 1 }],
|
||||
},
|
||||
];
|
||||
}
|
||||
return parsedNodes;
|
||||
}
|
||||
|
||||
export function parseMarkdownToLexical(markdown: string): any[] {
|
||||
const textNode = (text: string) => ({
|
||||
type: 'paragraph',
|
||||
format: '',
|
||||
indent: 0,
|
||||
version: 1,
|
||||
children: [{ mode: 'normal', type: 'text', text, version: 1 }],
|
||||
});
|
||||
|
||||
const nodes: any[] = [];
|
||||
let content = markdown;
|
||||
|
||||
// Strip frontmatter
|
||||
const fm = content.match(/^---\s*\n[\s\S]*?\n---/);
|
||||
if (fm) content = content.replace(fm[0], '').trim();
|
||||
|
||||
// 1. EXTRACT MULTILINE WRAPPERS BEFORE CHUNKING
|
||||
// This allows nested newlines inside components without breaking them.
|
||||
const extractBlocks = [
|
||||
{
|
||||
tag: 'HighlightBox',
|
||||
regex: /<HighlightBox([^>]*)>([\s\S]*?)<\/HighlightBox>/g,
|
||||
build: (props: string, inner: string) =>
|
||||
blockNode('highlightBox', {
|
||||
title: propValue(`<Tag ${props}>`, 'title'),
|
||||
color: propValue(`<Tag ${props}>`, 'color') || 'primary',
|
||||
content: {
|
||||
root: {
|
||||
type: 'root',
|
||||
format: '',
|
||||
indent: 0,
|
||||
version: 1,
|
||||
direction: 'ltr',
|
||||
children: ensureChildren(parseMarkdownToLexical(inner.trim())),
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
tag: 'ChatBubble',
|
||||
regex: /<ChatBubble([^>]*)>([\s\S]*?)<\/ChatBubble>/g,
|
||||
build: (props: string, inner: string) =>
|
||||
blockNode('chatBubble', {
|
||||
author: propValue(`<Tag ${props}>`, 'author') || 'KLZ Team',
|
||||
avatar: propValue(`<Tag ${props}>`, 'avatar'),
|
||||
role: propValue(`<Tag ${props}>`, 'role') || 'Assistant',
|
||||
align: propValue(`<Tag ${props}>`, 'align') || 'left',
|
||||
content: {
|
||||
root: {
|
||||
type: 'root',
|
||||
format: '',
|
||||
indent: 0,
|
||||
version: 1,
|
||||
direction: 'ltr',
|
||||
children: ensureChildren(parseMarkdownToLexical(inner.trim())),
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
tag: 'Callout',
|
||||
regex: /<Callout([^>]*)>([\s\S]*?)<\/Callout>/g,
|
||||
build: (props: string, inner: string) =>
|
||||
blockNode('callout', {
|
||||
type: propValue(`<Tag ${props}>`, 'type') || 'info',
|
||||
title: propValue(`<Tag ${props}>`, 'title'),
|
||||
content: {
|
||||
root: {
|
||||
type: 'root',
|
||||
format: '',
|
||||
indent: 0,
|
||||
version: 1,
|
||||
direction: 'ltr',
|
||||
children: ensureChildren(parseMarkdownToLexical(inner.trim())),
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
// Placeholder map to temporarily store extracted multi-line blocks
|
||||
const placeholders = new Map<string, any>();
|
||||
let placeholderIdx = 0;
|
||||
|
||||
for (const block of extractBlocks) {
|
||||
content = content.replace(block.regex, (match, propsMatch, innerMatch) => {
|
||||
const id = `__BLOCK_PLACEHOLDER_${placeholderIdx++}__`;
|
||||
placeholders.set(id, block.build(propsMatch, innerMatch));
|
||||
return `\n\n${id}\n\n`; // Pad with newlines so it becomes its own chunk
|
||||
});
|
||||
}
|
||||
|
||||
// 2. CHUNK THE REST (Paragraphs, Single-line Components)
|
||||
const rawChunks = content.split(/\n\s*\n/);
|
||||
|
||||
for (let chunk of rawChunks) {
|
||||
chunk = chunk.trim();
|
||||
if (!chunk) continue;
|
||||
|
||||
// Has Placeholder?
|
||||
if (chunk.startsWith('__BLOCK_PLACEHOLDER_')) {
|
||||
nodes.push(placeholders.get(chunk));
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Custom Component: ProductTabs ---
|
||||
if (chunk.includes('<ProductTabs')) {
|
||||
const dataMatch = chunk.match(/data=\{({[\s\S]*?})\}\s*\/>/);
|
||||
if (dataMatch) {
|
||||
try {
|
||||
const parsedData = JSON.parse(dataMatch[1]);
|
||||
|
||||
// Normalize String Arrays to Payload Object Arrays { value: "string" }
|
||||
if (parsedData.voltageTables) {
|
||||
parsedData.voltageTables.forEach((vt: any) => {
|
||||
if (vt.rows) {
|
||||
vt.rows.forEach((row: any) => {
|
||||
if (row.cells && Array.isArray(row.cells)) {
|
||||
row.cells = row.cells.map((cell: any) =>
|
||||
typeof cell !== 'object' || cell === null ? { value: String(cell) } : cell,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
nodes.push(
|
||||
blockNode('productTabs', {
|
||||
technicalItems: parsedData.technicalItems || [],
|
||||
voltageTables: parsedData.voltageTables || [],
|
||||
}),
|
||||
);
|
||||
} catch (e: any) {
|
||||
console.warn(`Could not parse JSON payload for ProductTabs:`, e.message);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Custom Component: StickyNarrative ---
|
||||
if (chunk.includes('<StickyNarrative')) {
|
||||
nodes.push(
|
||||
blockNode('stickyNarrative', {
|
||||
title: propValue(chunk, 'title'),
|
||||
items: extractItemsProp(chunk, 'StickyNarrative'),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Custom Component: ComparisonGrid ---
|
||||
if (chunk.includes('<ComparisonGrid')) {
|
||||
nodes.push(
|
||||
blockNode('comparisonGrid', {
|
||||
title: propValue(chunk, 'title'),
|
||||
leftLabel: propValue(chunk, 'leftLabel'),
|
||||
rightLabel: propValue(chunk, 'rightLabel'),
|
||||
items: extractItemsProp(chunk, 'ComparisonGrid'),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Custom Component: VisualLinkPreview ---
|
||||
if (chunk.includes('<VisualLinkPreview')) {
|
||||
nodes.push(
|
||||
blockNode('visualLinkPreview', {
|
||||
url: propValue(chunk, 'url'),
|
||||
title: propValue(chunk, 'title'),
|
||||
summary: propValue(chunk, 'summary'),
|
||||
image: propValue(chunk, 'image'),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Custom Component: TechnicalGrid ---
|
||||
if (chunk.includes('<TechnicalGrid')) {
|
||||
nodes.push(
|
||||
blockNode('technicalGrid', {
|
||||
title: propValue(chunk, 'title'),
|
||||
items: extractItemsProp(chunk, 'TechnicalGrid'),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Custom Component: AnimatedImage ---
|
||||
if (chunk.includes('<AnimatedImage')) {
|
||||
const widthMatch = chunk.match(/width=\{?(\d+)\}?/);
|
||||
const heightMatch = chunk.match(/height=\{?(\d+)\}?/);
|
||||
nodes.push(
|
||||
blockNode('animatedImage', {
|
||||
src: propValue(chunk, 'src'),
|
||||
alt: propValue(chunk, 'alt'),
|
||||
width: widthMatch ? parseInt(widthMatch[1], 10) : undefined,
|
||||
height: heightMatch ? parseInt(heightMatch[1], 10) : undefined,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Custom Component: PowerCTA ---
|
||||
if (chunk.includes('<PowerCTA')) {
|
||||
nodes.push(
|
||||
blockNode('powerCTA', {
|
||||
locale: propValue(chunk, 'locale') || 'de',
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Standard Markdown: Headings ---
|
||||
const headingMatch = chunk.match(/^(#{1,6})\s+(.*)/);
|
||||
if (headingMatch) {
|
||||
nodes.push({
|
||||
type: 'heading',
|
||||
tag: `h${headingMatch[1].length}`,
|
||||
format: '',
|
||||
indent: 0,
|
||||
version: 1,
|
||||
direction: 'ltr',
|
||||
children: [{ mode: 'normal', type: 'text', text: headingMatch[2], version: 1 }],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Standard Markdown: Images ---
|
||||
const imageMatch = chunk.match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
|
||||
if (imageMatch) {
|
||||
nodes.push(textNode(chunk));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Default: plain text paragraph
|
||||
nodes.push(textNode(chunk));
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
@@ -54,6 +54,7 @@
|
||||
stroke-dasharray: 1;
|
||||
stroke-dashoffset: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
stroke-dasharray: 1;
|
||||
stroke-dashoffset: 0;
|
||||
@@ -174,6 +175,18 @@
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
/* Scale text down slightly on mobile (87.5% of 16px = 14px) */
|
||||
font-size: 87.5%;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
html {
|
||||
/* Normal scaling on tablet and desktop (100% = 16px) */
|
||||
font-size: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.bg-primary a,
|
||||
.bg-primary-dark a {
|
||||
@apply text-white/90 hover:text-white transition-colors;
|
||||
|
||||
@@ -56,21 +56,21 @@ module.exports = {
|
||||
heading: ['Inter', 'system-ui', 'sans-serif'],
|
||||
body: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
// Enhanced Fluid Typography with CSS Clamp
|
||||
// Improved readability with better line heights and spacing
|
||||
// Standard Fluid Typography
|
||||
// Responsive scaling is handled globally via the html selector percentage in globals.css
|
||||
fontSize: {
|
||||
'xs': ['clamp(0.75rem, 0.7rem + 0.2vw, 0.875rem)', { lineHeight: '1.6' }],
|
||||
'sm': ['clamp(0.875rem, 0.8rem + 0.25vw, 1rem)', { lineHeight: '1.6' }],
|
||||
'base': ['clamp(1rem, 0.9rem + 0.35vw, 1.125rem)', { lineHeight: '1.7' }],
|
||||
'lg': ['clamp(1.125rem, 1rem + 0.4vw, 1.25rem)', { lineHeight: '1.7' }],
|
||||
'xl': ['clamp(1.25rem, 1.1rem + 0.5vw, 1.5rem)', { lineHeight: '1.6' }],
|
||||
'2xl': ['clamp(1.5rem, 1.3rem + 0.75vw, 1.875rem)', { lineHeight: '1.5' }],
|
||||
'3xl': ['clamp(1.875rem, 1.6rem + 1vw, 2.25rem)', { lineHeight: '1.4' }],
|
||||
'4xl': ['clamp(2.25rem, 1.9rem + 1.25vw, 3rem)', { lineHeight: '1.3' }],
|
||||
'5xl': ['clamp(3rem, 2.5rem + 2vw, 3.75rem)', { lineHeight: '1.25' }],
|
||||
'6xl': ['clamp(3.75rem, 3rem + 2.5vw, 4.5rem)', { lineHeight: '1.2' }],
|
||||
'7xl': ['clamp(4.5rem, 3.5rem + 3vw, 5.5rem)', { lineHeight: '1.15' }],
|
||||
'8xl': ['clamp(5.5rem, 4rem + 4vw, 7rem)', { lineHeight: '1.1' }],
|
||||
'xs': ['0.75rem', { lineHeight: '1.6' }],
|
||||
'sm': ['0.875rem', { lineHeight: '1.6' }],
|
||||
'base': ['1rem', { lineHeight: '1.7' }],
|
||||
'lg': ['1.125rem', { lineHeight: '1.7' }],
|
||||
'xl': ['1.25rem', { lineHeight: '1.6' }],
|
||||
'2xl': ['1.5rem', { lineHeight: '1.5' }],
|
||||
'3xl': ['1.875rem', { lineHeight: '1.4' }],
|
||||
'4xl': ['2.25rem', { lineHeight: '1.3' }],
|
||||
'5xl': ['3rem', { lineHeight: '1.25' }],
|
||||
'6xl': ['3.75rem', { lineHeight: '1.2' }],
|
||||
'7xl': ['4.5rem', { lineHeight: '1.15' }],
|
||||
'8xl': ['6rem', { lineHeight: '1.1' }],
|
||||
},
|
||||
fontWeight: {
|
||||
regular: '400',
|
||||
|
||||
@@ -3,18 +3,11 @@
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
],
|
||||
"lib/*": [
|
||||
"./lib/*"
|
||||
],
|
||||
"components/*": [
|
||||
"./components/*"
|
||||
],
|
||||
"data/*": [
|
||||
"./data/*"
|
||||
]
|
||||
"@/*": ["./*"],
|
||||
"lib/*": ["./lib/*"],
|
||||
"components/*": ["./components/*"],
|
||||
"data/*": ["./data/*"],
|
||||
"@payload-config": ["./payload.config.ts"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
@@ -25,11 +18,5 @@
|
||||
"tests/**/*.test.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"scripts",
|
||||
"reference",
|
||||
"data",
|
||||
"remotion"
|
||||
]
|
||||
"exclude": ["node_modules", "scripts", "reference", "data", "remotion"]
|
||||
}
|
||||
Reference in New Issue
Block a user