Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bff150b1a7 | |||
| 8ffb1e5f6a | |||
| 42ff5b6226 | |||
| 539c503d0d | |||
| 4c473a2ffd | |||
| 4f64a94a15 | |||
| 5a82243965 | |||
| ebb14ee302 | |||
| 80f5e46d88 | |||
| b826b5b741 | |||
| 41228aca53 | |||
| cd3b7c1e3b | |||
| ecd525d782 | |||
| 52439cc5aa | |||
| 61bf8517d8 | |||
| 85d9e3a930 | |||
| 2f7d6befe4 | |||
| a6ba484faa | |||
| d7178a9100 | |||
| 1e39dc8567 | |||
| 3c2aa8b71e | |||
| 6e3befdfd9 | |||
| 5377b3c81a | |||
| 941625a4e0 |
@@ -256,6 +256,10 @@ jobs:
|
||||
# Analytics
|
||||
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||
|
||||
# Notifications
|
||||
GOTIFY_URL: ${{ secrets.GOTIFY_URL }}
|
||||
GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -309,6 +313,10 @@ jobs:
|
||||
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
||||
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
||||
echo ""
|
||||
echo "# Notifications"
|
||||
echo "GOTIFY_URL=$GOTIFY_URL"
|
||||
echo "GOTIFY_TOKEN=$GOTIFY_TOKEN"
|
||||
echo ""
|
||||
echo "TARGET=$TARGET"
|
||||
echo "SENTRY_ENVIRONMENT=$TARGET"
|
||||
echo "PROJECT_NAME=$PROJECT_NAME"
|
||||
|
||||
@@ -3,7 +3,7 @@ import Image from 'next/image';
|
||||
import { getAllPosts } from '@/lib/blog';
|
||||
import { Section, Container, Heading, Card, Badge, Button } from '@/components/ui';
|
||||
import Reveal from '@/components/Reveal';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import { BlogPaginationKeyboardObserver } from '@/components/blog/BlogPaginationKeyboardObserver';
|
||||
@@ -12,6 +12,7 @@ interface BlogIndexProps {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
}>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: BlogIndexProps) {
|
||||
@@ -41,8 +42,10 @@ export async function generateMetadata({ params }: BlogIndexProps) {
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
export default async function BlogIndex({ params, searchParams }: BlogIndexProps) {
|
||||
const { locale } = await params;
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const categoryParam = resolvedSearchParams.category as string | undefined;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations('Blog');
|
||||
const posts = await getAllPosts(locale);
|
||||
@@ -52,8 +55,14 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime(),
|
||||
);
|
||||
|
||||
const featuredPost = sortedPosts[0];
|
||||
const remainingPosts = sortedPosts.slice(1);
|
||||
const filteredPosts = categoryParam
|
||||
? sortedPosts.filter(
|
||||
(post) => post.frontmatter.category?.toLowerCase() === categoryParam.toLowerCase(),
|
||||
)
|
||||
: sortedPosts;
|
||||
|
||||
const featuredPost = filteredPosts[0];
|
||||
const remainingPosts = filteredPosts.slice(1);
|
||||
|
||||
return (
|
||||
<div className="bg-neutral-light min-h-screen">
|
||||
@@ -123,31 +132,38 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
{t('allArticles')}
|
||||
</Heading>
|
||||
<div className="flex flex-wrap gap-2 md:gap-4">
|
||||
{/* Category filters could go here */}
|
||||
<Badge
|
||||
variant="primary"
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.all')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="neutral"
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.industry')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="neutral"
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.technical')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="neutral"
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.sustainability')}
|
||||
</Badge>
|
||||
<Link href={`/${locale}/blog`}>
|
||||
<Badge
|
||||
variant={!categoryParam ? 'primary' : 'neutral'}
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.all')}
|
||||
</Badge>
|
||||
</Link>
|
||||
<Link href={`/${locale}/blog?category=industry`}>
|
||||
<Badge
|
||||
variant={categoryParam === 'industry' ? 'primary' : 'neutral'}
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.industry')}
|
||||
</Badge>
|
||||
</Link>
|
||||
<Link href={`/${locale}/blog?category=technical`}>
|
||||
<Badge
|
||||
variant={categoryParam === 'technical' ? 'primary' : 'neutral'}
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.technical')}
|
||||
</Badge>
|
||||
</Link>
|
||||
<Link href={`/${locale}/blog?category=sustainability`}>
|
||||
<Badge
|
||||
variant={categoryParam === 'sustainability' ? 'primary' : 'neutral'}
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.sustainability')}
|
||||
</Badge>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
@@ -260,7 +276,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
{t('prev')}
|
||||
</Button>
|
||||
<Button
|
||||
href={`/${locale}/blog?page=1`}
|
||||
href={`/${locale}/blog?page=1${categoryParam ? `&category=${categoryParam}` : ''}`}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="md:h-11 md:px-6 md:text-base"
|
||||
@@ -269,7 +285,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
1
|
||||
</Button>
|
||||
<Button
|
||||
href={`/${locale}/blog?page=2`}
|
||||
href={`/${locale}/blog?page=2${categoryParam ? `&category=${categoryParam}` : ''}`}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="md:h-11 md:px-6 md:text-base"
|
||||
@@ -277,7 +293,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
2
|
||||
</Button>
|
||||
<Button
|
||||
href={`/${locale}/blog?page=2`}
|
||||
href={`/${locale}/blog?page=2${categoryParam ? `&category=${categoryParam}` : ''}`}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="md:h-11 md:px-6 md:text-base"
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
import React from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export const PDFDownloadBlock: React.FC<{ label: string; style: string }> = ({ label, style }) => {
|
||||
export const PDFDownloadBlock: React.FC<{ label: string; style: string; url?: string }> = ({
|
||||
label,
|
||||
style,
|
||||
url,
|
||||
}) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Extract slug from pathname
|
||||
@@ -12,7 +16,7 @@ export const PDFDownloadBlock: React.FC<{ label: string; style: string }> = ({ l
|
||||
// We want the page slug.
|
||||
const slug = segments[segments.length - 1] || 'home';
|
||||
|
||||
const href = `/api/pages/${slug}/pdf`;
|
||||
const href = url || `/api/pages/${slug}/pdf`;
|
||||
|
||||
return (
|
||||
<div className="my-8">
|
||||
|
||||
@@ -247,7 +247,7 @@ _status: published
|
||||
|
||||
Stand: April 2026
|
||||
|
||||
<Block type="pdfDownload" data={{"id":"69a9b09ea220fecfb9d54bb8","label":"Als PDF herunterladen","style":"primary","blockName":"","blockType":"pdfDownload"}} />
|
||||
<Block type="pdfDownload" data={{"id":"69a9b09ea220fecfb9d54bb8","label":"Als PDF herunterladen","style":"primary","url":"/AVB-KLZ-4-2026.pdf","blockName":"","blockType":"pdfDownload"}} />
|
||||
|
||||
<Block type="agbHistory" data={{"id":"69a9b09ea220fecfb9d54bb9","blockName":"AGB History","blockType":"agbHistory","title":"Vorherige Versionen"}} />
|
||||
|
||||
|
||||
@@ -215,8 +215,8 @@ const styles = StyleSheet.create({
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '20mm', // Generous area for the footer
|
||||
paddingBottom: '7mm', // 3mm bleed + 4mm safe margin
|
||||
height: '22mm', // Increased height to accommodate more bottom padding
|
||||
paddingBottom: '10mm', // Increased bottom padding to move icons further from edge
|
||||
zIndex: 50,
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
@@ -253,7 +253,7 @@ const styles = StyleSheet.create({
|
||||
// -- WHITE PAGE (Vorderseite) --
|
||||
backContainer: {
|
||||
flex: 1,
|
||||
paddingTop: '3mm', // Reduced significantly to pull content up
|
||||
paddingTop: '6mm', // Pushed down as requested
|
||||
paddingLeft: '6mm',
|
||||
paddingRight: '6mm',
|
||||
flexDirection: 'column',
|
||||
@@ -302,12 +302,12 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'column',
|
||||
},
|
||||
nameSection: {
|
||||
marginBottom: '2.5mm', // Clear separation between name block and contact block
|
||||
marginBottom: '2.5mm', // Reduced to fix the 'Lücke' under the role
|
||||
},
|
||||
nameText: {
|
||||
fontSize: 13,
|
||||
fontWeight: 'bold',
|
||||
color: COLORS.neutralDark,
|
||||
color: COLORS.primary,
|
||||
marginBottom: 1.5,
|
||||
letterSpacing: -0.15,
|
||||
},
|
||||
@@ -334,7 +334,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
contactText: {
|
||||
fontSize: 7.5,
|
||||
color: COLORS.neutralDark,
|
||||
color: COLORS.primary,
|
||||
fontWeight: 'normal',
|
||||
paddingTop: 0.5,
|
||||
letterSpacing: 0.1,
|
||||
@@ -351,10 +351,10 @@ const styles = StyleSheet.create({
|
||||
gap: 3,
|
||||
},
|
||||
qrCodeWrapper: {
|
||||
width: '12mm',
|
||||
height: '12mm',
|
||||
width: '10mm',
|
||||
height: '10mm',
|
||||
border: `0.75pt solid ${COLORS.darkGreen}`,
|
||||
padding: 2.5,
|
||||
padding: 2,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
qrCode: {
|
||||
@@ -372,7 +372,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
addressFooterText: {
|
||||
fontSize: 6.5,
|
||||
color: COLORS.grayText,
|
||||
color: COLORS.primary,
|
||||
letterSpacing: 0.4,
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
@@ -383,7 +383,7 @@ const PhoneIcon = () => (
|
||||
<Svg viewBox="0 0 24 24" width={9} height={9}>
|
||||
<Path
|
||||
d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z"
|
||||
fill="#000000"
|
||||
fill={COLORS.primary}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
@@ -392,7 +392,7 @@ const EmailIcon = () => (
|
||||
<Svg viewBox="0 0 24 24" width={9} height={9}>
|
||||
<Path
|
||||
d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"
|
||||
fill="#000000"
|
||||
fill={COLORS.primary}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
@@ -401,7 +401,7 @@ const GlobeIcon = () => (
|
||||
<Svg viewBox="0 0 24 24" width={9} height={9}>
|
||||
<Path
|
||||
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"
|
||||
fill="#000000"
|
||||
fill={COLORS.primary}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
@@ -410,7 +410,7 @@ const LocationIcon = () => (
|
||||
<Svg viewBox="0 0 24 24" width={9} height={9}>
|
||||
<Path
|
||||
d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"
|
||||
fill="#000000"
|
||||
fill={COLORS.primary}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
@@ -419,7 +419,7 @@ const ScanIcon = () => (
|
||||
<Svg viewBox="0 0 24 24" width={9} height={9}>
|
||||
<Path
|
||||
d="M3 3h6v2H5v4H3V3zm18 0h-6v2h4v4h2V3zM3 21h6v-2H5v-4H3v6zm18 0h-6v-2h4v-4h2v6zM7 7h10v10H7V7z"
|
||||
fill="#000000"
|
||||
fill={COLORS.primary}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
@@ -455,6 +455,7 @@ const VectorLogo = ({
|
||||
style,
|
||||
overrideColor,
|
||||
overrideWhiteColor,
|
||||
overrideBlackColor,
|
||||
pathOverrides,
|
||||
solidPaths,
|
||||
solidBackgroundColor = '#000a66',
|
||||
@@ -463,6 +464,7 @@ const VectorLogo = ({
|
||||
style: any;
|
||||
overrideColor?: string;
|
||||
overrideWhiteColor?: string;
|
||||
overrideBlackColor?: string;
|
||||
pathOverrides?: Record<number, string>;
|
||||
solidPaths?: number[];
|
||||
solidBackgroundColor?: string;
|
||||
@@ -492,6 +494,11 @@ const VectorLogo = ({
|
||||
(p.fill === 'white' || p.fill === '#ffffff' || p.fill === '#fff')
|
||||
) {
|
||||
fill = overrideWhiteColor;
|
||||
} else if (
|
||||
overrideBlackColor &&
|
||||
(p.fill === 'black' || p.fill === '#000000' || p.fill === '#0a0a0a' || p.fill === '#000')
|
||||
) {
|
||||
fill = overrideBlackColor;
|
||||
}
|
||||
return <Path key={i} d={d} fill={fill} />;
|
||||
})}
|
||||
@@ -815,6 +822,8 @@ export const PDFBusinessCard: React.FC<PDFBusinessCardProps> = ({
|
||||
};
|
||||
|
||||
const getBackLogoPaths = () => {
|
||||
// We use the original black logo to preserve the exact colors of the Truck and KLZ.
|
||||
// The tagline will be hidden via pathOverrides in the JSX.
|
||||
return logoBlackWithTaglinePaths;
|
||||
};
|
||||
|
||||
@@ -822,6 +831,8 @@ export const PDFBusinessCard: React.FC<PDFBusinessCardProps> = ({
|
||||
? path.join(process.cwd(), 'public', 'media', 'cable_drums_truck.png')
|
||||
: path.join(process.cwd(), 'public', 'cable_drums_bg.png');
|
||||
|
||||
const mmToPt = (mm: number) => (mm * 72) / 25.4;
|
||||
|
||||
const renderPageWrapper = (children: React.ReactNode, bleedStyle: any) => {
|
||||
if (withCropMarks) {
|
||||
return (
|
||||
@@ -836,7 +847,7 @@ export const PDFBusinessCard: React.FC<PDFBusinessCardProps> = ({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Page size={[258, 173]} style={bleedStyle}>
|
||||
<Page size={[mmToPt(91), mmToPt(61)]} style={bleedStyle}>
|
||||
{children}
|
||||
</Page>
|
||||
);
|
||||
@@ -903,16 +914,6 @@ export const PDFBusinessCard: React.FC<PDFBusinessCardProps> = ({
|
||||
solidPaths={KLZ_PATHS}
|
||||
solidBackgroundColor={isWhiteVariant ? '#ffffff' : COLORS.primary}
|
||||
/>
|
||||
|
||||
{/* Letterpress Mask Layer: We use #ff00fe as a marker color. Only the "KLZ" letters! */}
|
||||
{isViaprinto && (
|
||||
<VectorLogo
|
||||
paths={getFrontLogoPaths().filter((_, i) => KLZ_PATHS.includes(i))}
|
||||
style={[styles.frontLogo, { position: 'absolute' }]}
|
||||
overrideColor="#ff00fe"
|
||||
solidPaths={KLZ_PATHS.map((_, i) => i)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>,
|
||||
[styles.pageBleed, isWhiteVariant && { backgroundColor: '#ffffff' }],
|
||||
@@ -924,8 +925,18 @@ export const PDFBusinessCard: React.FC<PDFBusinessCardProps> = ({
|
||||
<VectorLogo
|
||||
paths={getBackLogoPaths()}
|
||||
style={styles.backLogo}
|
||||
overrideWhiteColor={COLORS.black}
|
||||
pathOverrides={KLZ_PATHS.reduce((acc, idx) => ({ ...acc, [idx]: 'none' }), {})}
|
||||
overrideBlackColor={COLORS.primary}
|
||||
pathOverrides={{
|
||||
...(KLZ_PATHS.reduce(
|
||||
(acc: Record<number, string>, idx) => ({ ...acc, [idx]: 'none' }),
|
||||
{} as Record<number, string>,
|
||||
) as any),
|
||||
// Paths 0-24 are the "Cables for a greener tomorrow" tagline in the 40-path logo.
|
||||
...(Array.from({ length: 25 }).reduce(
|
||||
(acc: Record<number, string>, _, i) => ({ ...acc, [i]: 'none' }),
|
||||
{} as Record<number, string>,
|
||||
) as any),
|
||||
}}
|
||||
/>
|
||||
<View style={styles.accentLineHorizontal} />
|
||||
<View style={styles.qrContainer}>
|
||||
|
||||
@@ -329,6 +329,42 @@ const renderLexicalNode = (node: any, idx: number): React.ReactNode => {
|
||||
}
|
||||
};
|
||||
|
||||
const renderMarkdown = (markdown: string) => {
|
||||
const blocks = markdown.split('\n\n').filter((b) => b.trim());
|
||||
return blocks.map((block, idx) => {
|
||||
if (block.startsWith('### ')) {
|
||||
return (
|
||||
<Text key={idx} style={styles.heading3} wrap={false} minPresenceAhead={100}>
|
||||
{block.replace('### ', '').trim()}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (block.startsWith('## ')) {
|
||||
return (
|
||||
<Text key={idx} style={styles.heading2} wrap={false} minPresenceAhead={100}>
|
||||
{block.replace('## ', '').trim()}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (block.startsWith('# ')) {
|
||||
return (
|
||||
<View key={idx} style={styles.heading1Wrapper} wrap={false} minPresenceAhead={100}>
|
||||
<Text style={styles.heading1}>{block.replace('# ', '').trim()}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Ignore block components like <Block ... />
|
||||
if (block.startsWith('<Block')) return null;
|
||||
|
||||
return (
|
||||
<Text key={idx} style={styles.paragraph} minPresenceAhead={15}>
|
||||
{block.trim()}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
interface PDFPageProps {
|
||||
page: any;
|
||||
locale?: string;
|
||||
@@ -360,9 +396,11 @@ export const PDFPage: React.FC<PDFPageProps> = ({ page, locale = 'de' }) => {
|
||||
|
||||
<View style={styles.content}>
|
||||
<View>
|
||||
{page.content?.root?.children?.map((node: any, i: number) =>
|
||||
renderLexicalNode(node, i),
|
||||
)}
|
||||
{typeof page.content === 'string'
|
||||
? renderMarkdown(page.content)
|
||||
: page.content?.root?.children?.map((node: any, i: number) =>
|
||||
renderLexicalNode(node, i),
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
"prepare": "husky",
|
||||
"preinstall": "npx only-allow pnpm"
|
||||
},
|
||||
"version": "2.3.24",
|
||||
"version": "2.3.28",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
10
public/v/johannesgleich.vcf
Normal file
@@ -0,0 +1,10 @@
|
||||
BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
N:Gleich;Johannes;;;
|
||||
FN:Johannes Gleich
|
||||
ORG:KLZ Vertriebs GmbH
|
||||
TITLE:Senior Key Account Manager
|
||||
TEL;TYPE=WORK,VOICE:+49 172 739 1109
|
||||
EMAIL;TYPE=PREF,INTERNET:johannes.gleich@klz-cables.com
|
||||
URL:https://klz-cables.com
|
||||
END:VCARD
|
||||
10
public/v/klausmintel.vcf
Normal file
@@ -0,0 +1,10 @@
|
||||
BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
N:Mintel;Klaus;;;
|
||||
FN:Klaus Mintel
|
||||
ORG:KLZ Vertriebs GmbH
|
||||
TITLE:Managing Director
|
||||
TEL;TYPE=WORK,VOICE:+49 151 1775 2873
|
||||
EMAIL;TYPE=PREF,INTERNET:klaus.mintel@klz-cables.com
|
||||
URL:https://klz-cables.com
|
||||
END:VCARD
|
||||
10
public/v/michaelbodemer.vcf
Normal file
@@ -0,0 +1,10 @@
|
||||
BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
N:Bodemer;Michael;;;
|
||||
FN:Michael Bodemer
|
||||
ORG:KLZ Vertriebs GmbH
|
||||
TITLE:Owner & Managing Director
|
||||
TEL;TYPE=WORK,VOICE:+49 151 462 467 98
|
||||
EMAIL;TYPE=PREF,INTERNET:michael.bodemer@klz-cables.com
|
||||
URL:https://klz-cables.com
|
||||
END:VCARD
|
||||
@@ -52,24 +52,26 @@ async function generateBusinessCards() {
|
||||
for (const person of PEOPLE) {
|
||||
console.log(`\nGenerating Business Card for: ${person.name}`);
|
||||
|
||||
// Generate VCard QR Code
|
||||
const lastName = person.name.split(' ').slice(1).join(' ');
|
||||
const firstName = person.name.split(' ')[0];
|
||||
const vcard = `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
N:${lastName};${firstName};;;
|
||||
FN:${person.name}
|
||||
ORG:KLZ Vertriebs GmbH
|
||||
TITLE:${person.role}
|
||||
TEL;TYPE=WORK,VOICE:${person.phone}
|
||||
EMAIL;TYPE=PREF,INTERNET:${person.email}
|
||||
URL:https://klz-cables.com
|
||||
END:VCARD`;
|
||||
|
||||
const qrCodeDataUrl = await QRCode.toDataURL(vcard, {
|
||||
// To make the QR code extremely coarse (minimalist), we cannot encode all contact details into it directly.
|
||||
// Instead, we create a physical .vcf file and encode ONLY its URL into the QR Code (e.g. 35 chars vs 140 chars).
|
||||
const shortId = person.name.toLowerCase().replace(/[^a-z0-9]/g, ''); // "michaelbodemer"
|
||||
const vcfUrl = `https://klz-cables.com/v/${shortId}.vcf`;
|
||||
|
||||
const vcardFull = `BEGIN:VCARD\r\nVERSION:3.0\r\nN:${lastName};${firstName};;;\r\nFN:${person.name}\r\nORG:KLZ Vertriebs GmbH\r\nTITLE:${person.role}\r\nTEL;TYPE=WORK,VOICE:${person.phone}\r\nEMAIL;TYPE=PREF,INTERNET:${person.email}\r\nURL:https://klz-cables.com\r\nEND:VCARD`;
|
||||
|
||||
// Save the physical .vcf file so it can be served by Next.js from /public/v/
|
||||
const vcardDir = path.join(process.cwd(), 'public/v');
|
||||
if (!fs.existsSync(vcardDir)) fs.mkdirSync(vcardDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(vcardDir, `${shortId}.vcf`), vcardFull);
|
||||
|
||||
const qrCodeDataUrl = await QRCode.toDataURL(vcfUrl, {
|
||||
width: 400,
|
||||
margin: 0,
|
||||
color: { dark: '#000000', light: '#ffffff' },
|
||||
errorCorrectionLevel: 'L', // Low error correction = less dots
|
||||
color: { dark: '#000a66', light: '#ffffff' },
|
||||
});
|
||||
|
||||
const safeName = person.name.replace(/\s+/g, '_');
|
||||
@@ -119,7 +121,36 @@ END:VCARD`;
|
||||
try {
|
||||
execSync(cmykCommand, { stdio: 'inherit' });
|
||||
fs.unlinkSync(tempViaprintoPath);
|
||||
console.log(`✓ CMYK Print File generated: ${finalFileNameCMYK}`);
|
||||
console.log(`[INFO] Injecting PANTONE 289 C Spot Color...`);
|
||||
const injectPantoneCommand = `npx tsx scripts/inject-pantone.ts "${finalPathCMYK}"`;
|
||||
execSync(injectPantoneCommand, { stdio: 'inherit' });
|
||||
|
||||
console.log(`✓ 5-Color Print File generated: ${finalFileNameCMYK}`);
|
||||
} catch (e) {
|
||||
console.error(`❌ CMYK conversion failed! Is Ghostscript installed?`);
|
||||
}
|
||||
|
||||
// 2.5 SAXOPRINT PDF (Pure CMYK, no mask, no Pantone)
|
||||
console.log(`- Rendering Saxoprint PDF (Pure CMYK)...`);
|
||||
const tempSaxoprintPath = path.join(CONFIG.outputDir, `temp_saxoprint_${safeName}.pdf`);
|
||||
buffer = await renderToBuffer(
|
||||
React.createElement(PDFBusinessCard, {
|
||||
person,
|
||||
qrCodeDataUrl,
|
||||
variant: 'blue',
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(tempSaxoprintPath, buffer);
|
||||
|
||||
const finalFileNameSaxoCMYK = `KLZ_Visitenkarte_${safeName}_Saxoprint_CMYK_91x61mm_3mmBleed.pdf`;
|
||||
const finalPathSaxoCMYK = path.join(CONFIG.outputDir, finalFileNameSaxoCMYK);
|
||||
|
||||
console.log(`- Converting to CMYK for Saxoprint...`);
|
||||
const cmykSaxoCommand = `bash "${CONFIG.ghostscriptScript}" "${tempSaxoprintPath}" "${finalPathSaxoCMYK}"`;
|
||||
try {
|
||||
execSync(cmykSaxoCommand, { stdio: 'inherit' });
|
||||
fs.unlinkSync(tempSaxoprintPath);
|
||||
console.log(`✓ 4-Color CMYK Print File generated: ${finalFileNameSaxoCMYK}`);
|
||||
} catch (e) {
|
||||
console.error(`❌ CMYK conversion failed! Is Ghostscript installed?`);
|
||||
}
|
||||
|
||||
105
scripts/inject-pantone.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { PDFDocument, PDFName, decodePDFRawStream, PDFRawStream } from 'pdf-lib';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
async function run() {
|
||||
const inputPath = process.argv[2];
|
||||
const outputPath = process.argv[3] || inputPath;
|
||||
|
||||
if (!inputPath || !fs.existsSync(inputPath)) {
|
||||
console.error(`[ERROR] Input file not found: ${inputPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`[INFO] Injecting PANTONE 289 C into: ${path.basename(inputPath)}`);
|
||||
const pdfBytes = fs.readFileSync(inputPath);
|
||||
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||
|
||||
// CMYK tint transform for PANTONE 289 C.
|
||||
// Approximation: 100C, 64M, 0Y, 60K (1.0 0.64 0.0 0.60)
|
||||
// The function maps an input x (0 to 1) to CMYK: x*1.0, x*0.64, x*0.0, x*0.60
|
||||
const tintTransform = pdfDoc.context.obj({
|
||||
FunctionType: 2,
|
||||
Domain: [0, 1],
|
||||
C0: [0, 0, 0, 0],
|
||||
C1: [1.0, 0.64, 0.0, 0.6],
|
||||
N: 1,
|
||||
});
|
||||
|
||||
// Create the /Separation color space array
|
||||
const pantoneCS = pdfDoc.context.obj([
|
||||
PDFName.of('Separation'),
|
||||
PDFName.of('PANTONE#20289#20C'),
|
||||
PDFName.of('DeviceCMYK'),
|
||||
tintTransform,
|
||||
]);
|
||||
const csRef = pdfDoc.context.register(pantoneCS);
|
||||
|
||||
const pages = pdfDoc.getPages();
|
||||
for (const page of pages) {
|
||||
let resources = page.node.Resources();
|
||||
if (!resources) {
|
||||
resources = pdfDoc.context.obj({});
|
||||
page.node.set(PDFName.of('Resources'), resources);
|
||||
}
|
||||
|
||||
// Ensure ColorSpace dict exists
|
||||
let colorSpaces = resources.get(PDFName.of('ColorSpace')) as any;
|
||||
if (!colorSpaces) {
|
||||
colorSpaces = pdfDoc.context.obj({});
|
||||
resources.set(PDFName.of('ColorSpace'), colorSpaces);
|
||||
}
|
||||
// Register the PANTONE color space as /CSPantone in the page's resources
|
||||
colorSpaces.set(PDFName.of('CSPantone'), csRef);
|
||||
|
||||
// Read all content streams
|
||||
const contents = page.node.Contents();
|
||||
let streams: any[] = [];
|
||||
if (contents) {
|
||||
const resolvedContents = pdfDoc.context.lookup(contents);
|
||||
if (resolvedContents instanceof PDFRawStream) {
|
||||
streams = [resolvedContents];
|
||||
} else if (resolvedContents.constructor.name === 'PDFArray') {
|
||||
const arr = resolvedContents as any;
|
||||
for (let j = 0; j < arr.size(); j++) {
|
||||
streams.push(pdfDoc.context.lookup(arr.get(j)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let newStr = '';
|
||||
for (let i = 0; i < streams.length; i++) {
|
||||
const stream = streams[i];
|
||||
if (stream instanceof PDFRawStream) {
|
||||
const decoded = decodePDFRawStream(stream).decode();
|
||||
const str = Buffer.from(decoded).toString('utf-8');
|
||||
newStr += str + '\n';
|
||||
}
|
||||
}
|
||||
|
||||
// The CMYK values for the KLZ Blue created by Ghostscript from #000a66
|
||||
// is '1 0.98 0.224 0.298'. We use a slightly dynamic regex to catch small precision diffs.
|
||||
const dynamicRegexK = /1 0\.98[0-9]* 0\.224[0-9]* 0\.298[0-9]* k/g;
|
||||
const dynamicRegexStrokeK = /1 0\.98[0-9]* 0\.224[0-9]* 0\.298[0-9]* K/g;
|
||||
|
||||
let modified = false;
|
||||
if (dynamicRegexK.test(newStr) || dynamicRegexStrokeK.test(newStr)) {
|
||||
newStr = newStr.replace(dynamicRegexK, '/CSPantone cs 1 scn');
|
||||
newStr = newStr.replace(dynamicRegexStrokeK, '/CSPantone CS 1 SCN');
|
||||
modified = true;
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
const newStream = pdfDoc.context.flateStream(newStr);
|
||||
const newRef = pdfDoc.context.register(newStream);
|
||||
page.node.set(PDFName.of('Contents'), newRef);
|
||||
console.log(`[INFO] Spot color injected into page ${pages.indexOf(page) + 1}.`);
|
||||
}
|
||||
}
|
||||
|
||||
const finalPdfBytes = await pdfDoc.save();
|
||||
fs.writeFileSync(outputPath, finalPdfBytes);
|
||||
console.log(`[SUCCESS] Saved 5-color PDF to ${outputPath}`);
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||