Merge origin/master

This commit is contained in:
2026-01-25 19:47:08 +01:00
48 changed files with 2687 additions and 122 deletions
+485
View File
@@ -0,0 +1,485 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { cache } from '../../utils/cache';
// We need to import the Cache class itself to create test instances
// Since it's not exported, we'll use dynamic import and manipulation
type CacheItem<T> = {
value: T;
timestamp: number;
ttl: number;
lastAccessed: number;
};
class TestCache {
private storage: Map<string, CacheItem<any>>;
private readonly defaultTTL: number;
private readonly maxSize?: number;
constructor(defaultTTL = 5 * 60 * 1000, maxSize?: number) {
this.storage = new Map();
this.defaultTTL = defaultTTL;
this.maxSize = maxSize;
}
set<T>(key: string, value: T, ttl = this.defaultTTL): void {
const now = Date.now();
if (this.maxSize && this.storage.size >= this.maxSize && !this.storage.has(key)) {
this.evictLRU();
}
this.storage.set(key, {
value,
timestamp: now,
ttl,
lastAccessed: now
});
}
private evictLRU(): void {
let lruKey: string | null = null;
let lruTime = Infinity;
for (const [key, item] of this.storage.entries()) {
if (item.lastAccessed < lruTime) {
lruTime = item.lastAccessed;
lruKey = key;
}
}
if (lruKey) {
this.storage.delete(lruKey);
}
}
get<T>(key: string): T | null {
const item = this.storage.get(key);
if (!item) return null;
if (Date.now() > item.timestamp + item.ttl) {
this.storage.delete(key);
return null;
}
item.lastAccessed = Date.now();
return item.value;
}
has(key: string): boolean {
return this.get(key) !== null;
}
delete(key: string): void {
this.storage.delete(key);
}
clear(): void {
this.storage.clear();
}
// Helper method for testing
size(): number {
return this.storage.size;
}
}
describe('Cache', () => {
let testCache: TestCache;
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('Basic Operations', () => {
beforeEach(() => {
testCache = new TestCache();
});
it('should store and retrieve values', () => {
testCache.set('key1', 'value1');
expect(testCache.get('key1')).toBe('value1');
});
it('should return null for non-existent keys', () => {
expect(testCache.get('nonexistent')).toBeNull();
});
it('should delete values', () => {
testCache.set('key1', 'value1');
testCache.delete('key1');
expect(testCache.get('key1')).toBeNull();
});
it('should clear all values', () => {
testCache.set('key1', 'value1');
testCache.set('key2', 'value2');
testCache.clear();
expect(testCache.get('key1')).toBeNull();
expect(testCache.get('key2')).toBeNull();
});
it('should check if key exists with has()', () => {
testCache.set('key1', 'value1');
expect(testCache.has('key1')).toBe(true);
expect(testCache.has('nonexistent')).toBe(false);
});
it('should handle different data types', () => {
testCache.set('string', 'text');
testCache.set('number', 42);
testCache.set('object', { foo: 'bar' });
testCache.set('array', [1, 2, 3]);
testCache.set('boolean', true);
expect(testCache.get('string')).toBe('text');
expect(testCache.get('number')).toBe(42);
expect(testCache.get('object')).toEqual({ foo: 'bar' });
expect(testCache.get('array')).toEqual([1, 2, 3]);
expect(testCache.get('boolean')).toBe(true);
});
});
describe('TTL Expiration', () => {
beforeEach(() => {
testCache = new TestCache(1000); // 1 second default TTL
});
it('should expire items after TTL', () => {
testCache.set('key1', 'value1');
expect(testCache.get('key1')).toBe('value1');
vi.advanceTimersByTime(1001);
expect(testCache.get('key1')).toBeNull();
});
it('should use custom TTL per item', () => {
testCache.set('key1', 'value1', 500);
testCache.set('key2', 'value2', 2000);
vi.advanceTimersByTime(501);
expect(testCache.get('key1')).toBeNull();
expect(testCache.get('key2')).toBe('value2');
vi.advanceTimersByTime(1500);
expect(testCache.get('key2')).toBeNull();
});
it('should use default TTL when not specified', () => {
testCache.set('key1', 'value1');
vi.advanceTimersByTime(999);
expect(testCache.get('key1')).toBe('value1');
vi.advanceTimersByTime(2);
expect(testCache.get('key1')).toBeNull();
});
it('should remove expired items from storage', () => {
testCache.set('key1', 'value1', 1000);
expect(testCache.size()).toBe(1);
vi.advanceTimersByTime(1001);
testCache.get('key1'); // Triggers cleanup
expect(testCache.size()).toBe(0);
});
it('should return false for has() on expired items', () => {
testCache.set('key1', 'value1', 1000);
expect(testCache.has('key1')).toBe(true);
vi.advanceTimersByTime(1001);
expect(testCache.has('key1')).toBe(false);
});
});
describe('Size Limit and LRU Eviction', () => {
beforeEach(() => {
testCache = new TestCache(60000, 3); // 60s TTL, max 3 items
});
it('should enforce size limit', () => {
testCache.set('key1', 'value1');
testCache.set('key2', 'value2');
testCache.set('key3', 'value3');
expect(testCache.size()).toBe(3);
testCache.set('key4', 'value4');
expect(testCache.size()).toBe(3);
});
it('should evict least recently used item when adding new item', () => {
testCache.set('key1', 'value1');
vi.advanceTimersByTime(10);
testCache.set('key2', 'value2');
vi.advanceTimersByTime(10);
testCache.set('key3', 'value3');
vi.advanceTimersByTime(10);
// key1 is oldest, should be evicted when adding key4
testCache.set('key4', 'value4');
expect(testCache.get('key1')).toBeNull();
expect(testCache.get('key2')).toBe('value2');
expect(testCache.get('key3')).toBe('value3');
expect(testCache.get('key4')).toBe('value4');
});
it('should update access time on get and affect LRU eviction', () => {
testCache.set('key1', 'value1');
vi.advanceTimersByTime(10);
testCache.set('key2', 'value2');
vi.advanceTimersByTime(10);
testCache.set('key3', 'value3');
vi.advanceTimersByTime(10);
// Access key1, making it recently used
testCache.get('key1');
vi.advanceTimersByTime(10);
// Now key2 is least recently used, should be evicted
testCache.set('key4', 'value4');
expect(testCache.get('key1')).toBe('value1');
expect(testCache.get('key2')).toBeNull();
expect(testCache.get('key3')).toBe('value3');
expect(testCache.get('key4')).toBe('value4');
});
it('should not evict when updating existing key', () => {
testCache.set('key1', 'value1');
testCache.set('key2', 'value2');
testCache.set('key3', 'value3');
// Update key1, should not trigger eviction
testCache.set('key1', 'updated1');
expect(testCache.size()).toBe(3);
expect(testCache.get('key1')).toBe('updated1');
expect(testCache.get('key2')).toBe('value2');
expect(testCache.get('key3')).toBe('value3');
});
it('should handle multiple evictions correctly', () => {
testCache.set('key1', 'value1');
vi.advanceTimersByTime(10);
testCache.set('key2', 'value2');
vi.advanceTimersByTime(10);
testCache.set('key3', 'value3');
vi.advanceTimersByTime(10);
// Add key4, evicts key1
testCache.set('key4', 'value4');
vi.advanceTimersByTime(10);
// Add key5, evicts key2
testCache.set('key5', 'value5');
expect(testCache.get('key1')).toBeNull();
expect(testCache.get('key2')).toBeNull();
expect(testCache.get('key3')).toBe('value3');
expect(testCache.get('key4')).toBe('value4');
expect(testCache.get('key5')).toBe('value5');
});
});
describe('Edge Cases', () => {
it('should handle maxSize of 1', () => {
testCache = new TestCache(60000, 1);
testCache.set('key1', 'value1');
expect(testCache.get('key1')).toBe('value1');
testCache.set('key2', 'value2');
expect(testCache.get('key1')).toBeNull();
expect(testCache.get('key2')).toBe('value2');
});
it('should handle maxSize of 0 (treated as no limit)', () => {
testCache = new TestCache(60000, 0);
testCache.set('key1', 'value1');
// maxSize of 0 is falsy, so it's treated as no limit
expect(testCache.size()).toBe(1);
expect(testCache.get('key1')).toBe('value1');
});
it('should handle empty cache operations', () => {
testCache = new TestCache();
expect(testCache.get('key1')).toBeNull();
expect(testCache.has('key1')).toBe(false);
testCache.delete('key1'); // Should not throw
testCache.clear(); // Should not throw
});
it('should handle deleting non-existent keys', () => {
testCache = new TestCache();
testCache.set('key1', 'value1');
testCache.delete('nonexistent');
expect(testCache.get('key1')).toBe('value1');
});
it('should handle clearing empty cache', () => {
testCache = new TestCache();
testCache.clear();
expect(testCache.size()).toBe(0);
});
});
describe('Backward Compatibility', () => {
it('should work as unlimited cache without maxSize', () => {
testCache = new TestCache();
// Add many items without size limit
for (let i = 0; i < 100; i++) {
testCache.set(`key${i}`, `value${i}`);
}
expect(testCache.size()).toBe(100);
// All items should still be accessible
expect(testCache.get('key0')).toBe('value0');
expect(testCache.get('key50')).toBe('value50');
expect(testCache.get('key99')).toBe('value99');
});
it('should use default TTL of 5 minutes when not specified', () => {
testCache = new TestCache();
testCache.set('key1', 'value1');
// Should still be available after 4 minutes
vi.advanceTimersByTime(4 * 60 * 1000);
expect(testCache.get('key1')).toBe('value1');
// Should expire after 5 minutes
vi.advanceTimersByTime(61 * 1000);
expect(testCache.get('key1')).toBeNull();
});
it('should maintain original behavior for set/get/delete/clear', () => {
testCache = new TestCache();
testCache.set('key1', 'value1');
expect(testCache.get('key1')).toBe('value1');
testCache.set('key2', 'value2');
testCache.delete('key1');
expect(testCache.get('key1')).toBeNull();
expect(testCache.get('key2')).toBe('value2');
testCache.clear();
expect(testCache.get('key2')).toBeNull();
});
});
describe('Complex LRU Scenarios', () => {
beforeEach(() => {
testCache = new TestCache(60000, 3);
});
it('should evict based on access time, not insertion time', () => {
testCache.set('key1', 'value1');
vi.advanceTimersByTime(10);
testCache.set('key2', 'value2');
vi.advanceTimersByTime(10);
testCache.set('key3', 'value3');
vi.advanceTimersByTime(10);
// Access key1 and key2, making key3 the least recently used
testCache.get('key1');
vi.advanceTimersByTime(5);
testCache.get('key2');
vi.advanceTimersByTime(5);
// key3 should be evicted
testCache.set('key4', 'value4');
expect(testCache.get('key1')).toBe('value1');
expect(testCache.get('key2')).toBe('value2');
expect(testCache.get('key3')).toBeNull();
expect(testCache.get('key4')).toBe('value4');
});
it('should handle repeated access correctly', () => {
testCache.set('key1', 'value1');
vi.advanceTimersByTime(10);
testCache.set('key2', 'value2');
vi.advanceTimersByTime(10);
testCache.set('key3', 'value3');
vi.advanceTimersByTime(10);
// Keep accessing key1
for (let i = 0; i < 5; i++) {
testCache.get('key1');
vi.advanceTimersByTime(5);
}
// key2 should be evicted (oldest access)
testCache.set('key4', 'value4');
expect(testCache.get('key1')).toBe('value1');
expect(testCache.get('key2')).toBeNull();
expect(testCache.get('key3')).toBe('value3');
expect(testCache.get('key4')).toBe('value4');
});
it('should handle mixed operations correctly', () => {
testCache.set('key1', 'value1');
vi.advanceTimersByTime(10);
testCache.set('key2', 'value2');
vi.advanceTimersByTime(10);
testCache.set('key3', 'value3');
vi.advanceTimersByTime(10);
// Update key1 (should update access time)
testCache.set('key1', 'updated1');
vi.advanceTimersByTime(10);
// Delete key2
testCache.delete('key2');
// Add key4 - should not trigger eviction (only 2 items now)
testCache.set('key4', 'value4');
expect(testCache.size()).toBe(3);
// Add key5 - should evict key3 (least recently used)
vi.advanceTimersByTime(10);
testCache.set('key5', 'value5');
expect(testCache.get('key1')).toBe('updated1');
expect(testCache.get('key2')).toBeNull();
expect(testCache.get('key3')).toBeNull();
expect(testCache.get('key4')).toBe('value4');
expect(testCache.get('key5')).toBe('value5');
});
});
describe('Global cache instance', () => {
it('should export a global cache instance', () => {
expect(cache).toBeDefined();
expect(typeof cache.get).toBe('function');
expect(typeof cache.set).toBe('function');
expect(typeof cache.delete).toBe('function');
expect(typeof cache.clear).toBe('function');
expect(typeof cache.has).toBe('function');
});
});
});
+26 -40
View File
@@ -1,17 +1,19 @@
import { setRequestLocale } from 'next-intl/server';
import { getTranslations } from 'next-intl/server';
import { ArrowRight, Calendar, Tag, ExternalLink, ChevronLeft, ChevronRight } from 'lucide-react';
import Link from 'next/link';
import Image from 'next/image';
import { ArrowRight } from 'lucide-react';
import type { Metadata } from 'next';
import { getAllBlogPosts, BlogPost } from '@/lib/blog';
<<<<<<< HEAD
import { BlogList } from '@/components/blog/BlogList';
=======
import { legacyBlogPosts } from '@/data/legacyBlogPosts';
const POSTS_PER_PAGE = 12;
>>>>>>> origin/master
type Props = {
params: Promise<{ locale: string }>;
searchParams: Promise<{ page?: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
};
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://damjan-savic.com';
@@ -82,6 +84,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
};
}
<<<<<<< HEAD
=======
function getFormattedDate(date: string, locale: string) {
const languageMap: Record<string, string> = {
de: 'de-DE',
@@ -334,10 +338,11 @@ function Pagination({
</nav>
);
}
>>>>>>> origin/master
export default async function BlogPage({ params, searchParams }: Props) {
const { locale } = await params;
const { page } = await searchParams;
const resolvedSearchParams = await searchParams;
setRequestLocale(locale);
const t = await getTranslations('blog');
@@ -355,14 +360,9 @@ export default async function BlogPage({ params, searchParams }: Props) {
...legacyPosts,
].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
// Pagination
const currentPage = Math.max(1, parseInt(page || '1', 10) || 1);
const totalPages = Math.ceil(allPosts.length / POSTS_PER_PAGE);
const validPage = Math.min(currentPage, totalPages || 1);
const startIndex = (validPage - 1) * POSTS_PER_PAGE;
const endIndex = startIndex + POSTS_PER_PAGE;
const posts = allPosts.slice(startIndex, endIndex);
// Extract search and category from URL params
const initialSearch = typeof resolvedSearchParams.search === 'string' ? resolvedSearchParams.search : '';
const initialCategory = typeof resolvedSearchParams.category === 'string' ? resolvedSearchParams.category : null;
return (
<main className="min-h-screen">
@@ -379,37 +379,23 @@ export default async function BlogPage({ params, searchParams }: Props) {
<p className="text-lg sm:text-xl text-zinc-400 max-w-2xl">
{t('meta.header.subtitle')}
</p>
{/* Post count */}
<p className="text-sm text-zinc-500 mt-4">
{t('ui.pagination.showing', {
start: startIndex + 1,
end: Math.min(endIndex, allPosts.length),
total: allPosts.length,
})}
</p>
</div>
{/* Blog Posts Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8">
{posts.map((post) => (
<BlogPostCard key={post.slug} post={post} locale={locale} />
))}
</div>
{/* Pagination */}
<Pagination
currentPage={validPage}
totalPages={totalPages}
{/* Blog List with Search and Filters */}
<BlogList
posts={allPosts}
locale={locale}
t={(key) => t(key)}
initialSearch={initialSearch}
initialCategory={initialCategory}
translations={{
searchPlaceholder: t('ui.search.placeholder'),
showingText: (start: number, end: number, total: number) =>
t('ui.pagination.showing', { start, end, total }),
noPostsText: t('ui.errors.posts'),
paginationPrevious: t('ui.pagination.previous'),
paginationNext: t('ui.pagination.next'),
}}
/>
{/* Empty State */}
{posts.length === 0 && (
<div className="text-center py-12">
<p className="text-zinc-400">{t('ui.errors.posts')}</p>
</div>
)}
</div>
</main>
);
+14 -3
View File
@@ -1,10 +1,11 @@
import { setRequestLocale } from 'next-intl/server';
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { PortfolioGrid } from '@/components/portfolio';
import { PortfolioGrid, CategoryFilter } from '@/components/portfolio';
type Props = {
params: Promise<{ locale: string }>;
searchParams: Promise<{ category?: string }>;
};
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://damjan-savic.com';
@@ -159,12 +160,20 @@ const projects = [
},
];
export default async function PortfolioPage({ params }: Props) {
export default async function PortfolioPage({ params, searchParams }: Props) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('portfolio');
// Get category filter from URL params
const { category } = await searchParams;
// Filter projects by category if specified
const filteredProjects = category
? projects.filter((project) => project.category === category)
: projects;
return (
<div className="relative min-h-screen">
<section className="py-24">
@@ -178,7 +187,9 @@ export default async function PortfolioPage({ params }: Props) {
</p>
</div>
<PortfolioGrid projects={projects} />
<CategoryFilter />
<PortfolioGrid projects={filteredProjects} />
</div>
</section>
</div>
+427
View File
@@ -0,0 +1,427 @@
'use client';
import { useState, useMemo, useEffect } from 'react';
import { Calendar, Tag, ExternalLink, ChevronLeft, ChevronRight } from 'lucide-react';
import Link from 'next/link';
import Image from 'next/image';
import { useRouter, usePathname } from 'next/navigation';
import { BlogPost } from '@/lib/blog';
import { SearchBar } from './SearchBar';
import { CategoryFilter } from './CategoryFilter';
const POSTS_PER_PAGE = 12;
// Placeholder image for posts without cover images
const PLACEHOLDER_IMAGE = 'data:image/svg+xml,' + encodeURIComponent(`
<svg width="1792" height="1024" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="#181C14"/>
<rect x="40%" y="35%" width="20%" height="30%" rx="8" fill="#697565" opacity="0.3"/>
<circle cx="50%" cy="45%" r="8%" fill="#697565" opacity="0.2"/>
<rect x="30%" y="65%" width="40%" height="4%" rx="2" fill="#465B50" opacity="0.3"/>
<rect x="35%" y="72%" width="30%" height="3%" rx="2" fill="#465B50" opacity="0.2"/>
</svg>
`);
// Known existing images (from public/images/posts/)
const EXISTING_IMAGES = new Set([
'/images/posts/erp-integration-breuninger/cover.jpg',
'/images/posts/fullstack-development-timetracking/cover.jpg',
'/images/posts/rfid-automation/cover.jpg',
'/images/posts/automated-ad-creatives/cover.jpg',
]);
function getFormattedDate(date: string, locale: string) {
const languageMap: Record<string, string> = {
de: 'de-DE',
en: 'en-US',
sr: 'sr-RS',
};
return new Date(date).toLocaleDateString(languageMap[locale] || 'de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
function getImagePath(coverImage: string) {
if (coverImage.startsWith('http')) {
return coverImage;
}
return coverImage.replace('/blog/', '/images/posts/');
}
// Blog image component with fallback
function BlogImage({ src, alt, fallback }: { src: string; alt: string; fallback: string }) {
const imageSrc = EXISTING_IMAGES.has(src) ? src : fallback;
return (
<Image
src={imageSrc}
alt={alt}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
className="object-cover transition-transform duration-700 group-hover:scale-110"
unoptimized={imageSrc.startsWith('data:')}
/>
);
}
function BlogPostCard({ post, locale }: { post: BlogPost; locale: string }) {
return (
<Link
href={`/${locale}/blog/${post.slug}`}
className="group relative block bg-zinc-900/50 backdrop-blur-sm
rounded-xl shadow-lg hover:shadow-2xl
ring-1 ring-zinc-800 hover:ring-zinc-700
transition-all duration-500 focus:outline-none focus:ring-2
focus:ring-zinc-600 focus:ring-offset-2 focus:ring-offset-zinc-950
transform hover:-translate-y-1"
>
<div className="relative overflow-hidden rounded-xl">
{/* Image Container */}
<div className="aspect-[16/9] w-full relative overflow-hidden bg-zinc-900">
<BlogImage
src={getImagePath(post.coverImage)}
alt={post.title}
fallback={PLACEHOLDER_IMAGE}
/>
<div className="absolute inset-x-0 -bottom-20 top-0 bg-gradient-to-t from-zinc-900 via-zinc-900/70 to-transparent opacity-95" />
</div>
{/* Content */}
<div className="relative -mt-2 p-6 bg-zinc-900 z-10">
<div className="flex items-center gap-4 mb-4 text-zinc-500">
<div className="flex items-center gap-2 text-sm">
<Calendar className="h-4 w-4" />
<span>{getFormattedDate(post.date, locale)}</span>
</div>
{post.tags && post.tags.length > 0 && (
<div className="flex items-center gap-2 text-sm">
<Tag className="h-4 w-4" />
<span>{post.tags.length} Tags</span>
</div>
)}
</div>
<h3 className="text-xl font-semibold text-white mb-3
group-hover:text-zinc-100 transition-colors duration-300">
{post.title}
</h3>
<p className="text-zinc-400 text-sm line-clamp-2 mb-4
group-hover:text-zinc-300 transition-colors duration-300">
{post.excerpt}
</p>
{/* Tags */}
<div className="flex flex-wrap gap-2">
{post.tags?.slice(0, 3).map((tag, index) => (
<span
key={index}
className="px-3 py-1 bg-zinc-800/50
text-sm text-zinc-400 rounded-full
ring-1 ring-zinc-700/50 group-hover:ring-zinc-600/50
group-hover:bg-zinc-800/70 group-hover:text-zinc-300
transition-all duration-300"
>
{tag}
</span>
))}
{post.tags && post.tags.length > 3 && (
<span className="text-sm text-zinc-600">
+{post.tags.length - 3} more
</span>
)}
</div>
{/* Link Icon */}
<div className="absolute top-6 right-6 p-2.5 rounded-full
bg-zinc-800/60 backdrop-blur-sm ring-1 ring-zinc-700/50
opacity-0 group-hover:opacity-100
transform translate-y-2 group-hover:translate-y-0
transition-all duration-300">
<ExternalLink className="h-4 w-4 text-zinc-400" />
</div>
</div>
</div>
</Link>
);
}
// Pagination component
function Pagination({
currentPage,
totalPages,
onPageChange,
t,
}: {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
t: (key: string) => string;
}) {
const getPageNumbers = () => {
const pages: (number | 'ellipsis')[] = [];
if (totalPages <= 7) {
// Show all pages if 7 or fewer
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
// Always show first page
pages.push(1);
if (currentPage > 3) {
pages.push('ellipsis');
}
// Show pages around current
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (currentPage < totalPages - 2) {
pages.push('ellipsis');
}
// Always show last page
pages.push(totalPages);
}
return pages;
};
if (totalPages <= 1) return null;
return (
<nav className="flex items-center justify-center gap-2 mt-12 sm:mt-16" aria-label="Pagination">
{/* Previous button */}
{currentPage > 1 ? (
<button
onClick={() => onPageChange(currentPage - 1)}
className="flex items-center gap-1 px-3 py-2 text-sm text-zinc-400 hover:text-white
bg-zinc-800/50 hover:bg-zinc-800 rounded-lg ring-1 ring-zinc-700/50
hover:ring-zinc-600 transition-all duration-200"
aria-label={t('ui.pagination.previous')}
>
<ChevronLeft className="h-4 w-4" />
<span className="hidden sm:inline">{t('ui.pagination.previous')}</span>
</button>
) : (
<span
className="flex items-center gap-1 px-3 py-2 text-sm text-zinc-600
bg-zinc-800/30 rounded-lg ring-1 ring-zinc-800/50 cursor-not-allowed"
aria-disabled="true"
>
<ChevronLeft className="h-4 w-4" />
<span className="hidden sm:inline">{t('ui.pagination.previous')}</span>
</span>
)}
{/* Page numbers */}
<div className="flex items-center gap-1">
{getPageNumbers().map((page, index) => {
if (page === 'ellipsis') {
return (
<span key={`ellipsis-${index}`} className="px-2 text-zinc-600">
...
</span>
);
}
const isCurrentPage = page === currentPage;
return (
<button
key={page}
onClick={() => onPageChange(page)}
className={`min-w-[40px] h-10 flex items-center justify-center text-sm rounded-lg
ring-1 transition-all duration-200
${isCurrentPage
? 'bg-[#697565] text-white ring-[#697565] font-medium'
: 'text-zinc-400 hover:text-white bg-zinc-800/50 hover:bg-zinc-800 ring-zinc-700/50 hover:ring-zinc-600'
}`}
aria-current={isCurrentPage ? 'page' : undefined}
>
{page}
</button>
);
})}
</div>
{/* Next button */}
{currentPage < totalPages ? (
<button
onClick={() => onPageChange(currentPage + 1)}
className="flex items-center gap-1 px-3 py-2 text-sm text-zinc-400 hover:text-white
bg-zinc-800/50 hover:bg-zinc-800 rounded-lg ring-1 ring-zinc-700/50
hover:ring-zinc-600 transition-all duration-200"
aria-label={t('ui.pagination.next')}
>
<span className="hidden sm:inline">{t('ui.pagination.next')}</span>
<ChevronRight className="h-4 w-4" />
</button>
) : (
<span
className="flex items-center gap-1 px-3 py-2 text-sm text-zinc-600
bg-zinc-800/30 rounded-lg ring-1 ring-zinc-800/50 cursor-not-allowed"
aria-disabled="true"
>
<span className="hidden sm:inline">{t('ui.pagination.next')}</span>
<ChevronRight className="h-4 w-4" />
</span>
)}
</nav>
);
}
interface BlogListProps {
posts: BlogPost[];
locale: string;
initialSearch?: string;
initialCategory?: string | null;
translations: {
searchPlaceholder: string;
showingText: (start: number, end: number, total: number) => string;
noPostsText: string;
paginationPrevious: string;
paginationNext: string;
};
}
export function BlogList({ posts, locale, initialSearch = '', initialCategory = null, translations }: BlogListProps) {
const router = useRouter();
const pathname = usePathname();
const [searchQuery, setSearchQuery] = useState(initialSearch);
const [selectedCategory, setSelectedCategory] = useState<string | null>(initialCategory);
const [currentPage, setCurrentPage] = useState(1);
// Update URL when filters change
useEffect(() => {
const params = new URLSearchParams();
if (searchQuery.trim()) {
params.set('search', searchQuery.trim());
}
if (selectedCategory) {
params.set('category', selectedCategory);
}
const queryString = params.toString();
const newUrl = queryString ? `${pathname}?${queryString}` : pathname;
router.push(newUrl, { scroll: false });
}, [searchQuery, selectedCategory, pathname, router]);
// Extract unique categories from all posts
const allCategories = useMemo(() => {
const categories = new Set(posts.map(post => post.category).filter(Boolean));
return Array.from(categories).sort();
}, [posts]);
// Filter posts based on search query and category
const filteredPosts = useMemo(() => {
let filtered = posts;
// Apply search filter (case-insensitive search in title, excerpt, and tags)
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(post => {
const titleMatch = post.title.toLowerCase().includes(query);
const excerptMatch = post.excerpt.toLowerCase().includes(query);
const tagsMatch = post.tags?.some(tag => tag.toLowerCase().includes(query)) || false;
return titleMatch || excerptMatch || tagsMatch;
});
}
// Apply category filter
if (selectedCategory) {
filtered = filtered.filter(post => post.category === selectedCategory);
}
return filtered;
}, [posts, searchQuery, selectedCategory]);
// Reset to first page when filters change
useMemo(() => {
setCurrentPage(1);
}, [searchQuery, selectedCategory]);
// Paginate filtered posts
const totalPages = Math.ceil(filteredPosts.length / POSTS_PER_PAGE);
const validPage = Math.min(Math.max(1, currentPage), totalPages || 1);
const startIndex = (validPage - 1) * POSTS_PER_PAGE;
const endIndex = startIndex + POSTS_PER_PAGE;
const paginatedPosts = filteredPosts.slice(startIndex, endIndex);
const handlePageChange = (page: number) => {
setCurrentPage(page);
// Scroll to top of the page
window.scrollTo({ top: 0, behavior: 'smooth' });
};
return (
<div>
{/* Search and Filter Section */}
<div className="mb-8 sm:mb-12 space-y-6">
{/* Search Bar */}
<SearchBar
value={searchQuery}
onChange={setSearchQuery}
placeholder={translations.searchPlaceholder}
/>
{/* Category Filter */}
<CategoryFilter
categories={allCategories}
selectedCategory={selectedCategory}
onCategoryChange={setSelectedCategory}
/>
</div>
{/* Results count */}
<p className="text-sm text-zinc-500 mb-6">
{translations.showingText(
startIndex + 1,
Math.min(endIndex, filteredPosts.length),
filteredPosts.length
)}
</p>
{/* Blog Posts Grid */}
{paginatedPosts.length > 0 ? (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8">
{paginatedPosts.map((post) => (
<BlogPostCard key={post.slug} post={post} locale={locale} />
))}
</div>
{/* Pagination */}
<Pagination
currentPage={validPage}
totalPages={totalPages}
onPageChange={handlePageChange}
t={(key) => {
if (key === 'ui.pagination.previous') return translations.paginationPrevious;
if (key === 'ui.pagination.next') return translations.paginationNext;
return '';
}}
/>
</>
) : (
/* Empty State */
<div className="text-center py-12">
<p className="text-zinc-400">{translations.noPostsText}</p>
</div>
)}
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
'use client';
import { Tag } from 'lucide-react';
import { motion } from 'framer-motion';
interface CategoryFilterProps {
categories: string[];
selectedCategory: string | null;
onCategoryChange: (category: string | null) => void;
className?: string;
}
export function CategoryFilter({
categories,
selectedCategory,
onCategoryChange,
className = ''
}: CategoryFilterProps) {
const allCategories = ['All', ...categories];
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className={`${className}`}
>
<div className="flex items-center gap-3 mb-4">
<Tag className="h-5 w-5 text-zinc-400 flex-shrink-0" />
<h3 className="text-sm font-medium text-zinc-300">Filter by Category</h3>
</div>
{/* Mobile: Horizontal scrollable */}
<div className="md:hidden overflow-x-auto pb-2 -mx-4 px-4">
<div className="flex gap-2 min-w-max">
{allCategories.map((category) => {
const isActive = category === 'All'
? selectedCategory === null
: selectedCategory === category;
return (
<button
key={category}
onClick={() => onCategoryChange(category === 'All' ? null : category)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all whitespace-nowrap ${
isActive
? 'bg-[#697565] text-white'
: 'bg-zinc-900/50 text-zinc-400 border border-zinc-800 hover:bg-zinc-800/50 hover:text-white'
}`}
>
{category}
</button>
);
})}
</div>
</div>
{/* Desktop: Grid */}
<div className="hidden md:grid md:grid-cols-4 lg:grid-cols-6 gap-2">
{allCategories.map((category) => {
const isActive = category === 'All'
? selectedCategory === null
: selectedCategory === category;
return (
<button
key={category}
onClick={() => onCategoryChange(category === 'All' ? null : category)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
isActive
? 'bg-[#697565] text-white'
: 'bg-zinc-900/50 text-zinc-400 border border-zinc-800 hover:bg-zinc-800/50 hover:text-white'
}`}
>
{category}
</button>
);
})}
</div>
</motion.div>
);
}
+50
View File
@@ -0,0 +1,50 @@
'use client';
import { Search, X } from 'lucide-react';
import { motion } from 'framer-motion';
interface SearchBarProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
}
export function SearchBar({
value,
onChange,
placeholder = 'Search posts...',
className = ''
}: SearchBarProps) {
const handleClear = () => {
onChange('');
};
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={`relative ${className}`}
>
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-zinc-400" />
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full pl-12 pr-12 py-3 bg-zinc-900/50 border border-zinc-800 rounded-lg text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-[#697565] focus:border-transparent transition-all"
/>
{value && (
<button
onClick={handleClear}
className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-white transition-colors"
aria-label="Clear search"
>
<X className="h-5 w-5" />
</button>
)}
</div>
</motion.div>
);
}
+18
View File
@@ -15,6 +15,8 @@ interface FormData {
export function ContactForm() {
const t = useTranslations('pages.contact');
const MAX_MESSAGE_LENGTH = 1000;
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState<string>('');
@@ -26,6 +28,8 @@ export function ContactForm() {
const [errors, setErrors] = useState<Partial<FormData>>({});
const [remainingAttempts, setRemainingAttempts] = useState<number | null>(null);
const messageLength = formData.message.length;
const validateForm = () => {
const newErrors: Partial<FormData> = {};
@@ -319,6 +323,7 @@ export function ContactForm() {
onChange={handleChange}
placeholder={t('contactForm.message.placeholder')}
rows={6}
maxLength={MAX_MESSAGE_LENGTH}
disabled={isSubmitting}
aria-required="true"
aria-invalid={!!errors.message}
@@ -328,6 +333,19 @@ export function ContactForm() {
{errors.message && (
<p id="message-error" role="alert" className="text-sm text-red-400">{errors.message}</p>
)}
<div className="flex justify-end">
<p
className={`text-xs transition-colors ${
messageLength > MAX_MESSAGE_LENGTH
? 'text-red-500'
: messageLength >= MAX_MESSAGE_LENGTH * 0.8
? 'text-yellow-500'
: 'text-zinc-500'
}`}
>
{t('contactForm.characterCounter', { current: messageLength, max: MAX_MESSAGE_LENGTH })}
</p>
</div>
</div>
</div>
+32 -5
View File
@@ -11,6 +11,7 @@ const LanguageSwitcher = () => {
const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [focusedIndex, setFocusedIndex] = useState<number>(-1);
const dropdownRef = useRef<HTMLDivElement>(null);
const languages = [
@@ -30,8 +31,28 @@ const LanguageSwitcher = () => {
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setIsOpen(false);
if (!isOpen) return;
switch (e.key) {
case 'Escape':
setIsOpen(false);
setFocusedIndex(-1);
break;
case 'ArrowDown':
e.preventDefault();
setFocusedIndex(prev => (prev + 1) % languages.length);
break;
case 'ArrowUp':
e.preventDefault();
setFocusedIndex(prev => (prev - 1 + languages.length) % languages.length);
break;
case 'Enter':
e.preventDefault();
if (focusedIndex >= 0) {
handleLanguageChange(languages[focusedIndex].code);
setFocusedIndex(-1);
}
break;
}
};
@@ -63,7 +84,10 @@ const LanguageSwitcher = () => {
px-4 py-2 rounded-full transition-all duration-200
hover:bg-zinc-800/50 border border-transparent hover:border-zinc-800
hover:scale-105 active:scale-95"
onClick={() => setIsOpen(!isOpen)}
onClick={() => {
setIsOpen(!isOpen);
setFocusedIndex(-1);
}}
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-label="Select language"
@@ -79,18 +103,21 @@ const LanguageSwitcher = () => {
${isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'}`}
role="listbox"
aria-label="Languages"
aria-activedescendant={focusedIndex >= 0 ? `lang-option-${languages[focusedIndex].code}` : undefined}
onKeyDown={handleKeyDown}
>
<div className="py-1">
{languages.map((lang) => (
{languages.map((lang, index) => (
<button
key={lang.code}
id={`lang-option-${lang.code}`}
className={`w-full text-left px-4 py-2 text-sm transition-colors duration-200
hover:bg-zinc-800/50
${locale === lang.code
? 'bg-zinc-800 text-white'
: 'text-zinc-400 hover:text-white'
}`}
}
${focusedIndex === index ? 'ring-2 ring-orange-500 ring-inset' : ''}`}
onClick={() => handleLanguageChange(lang.code)}
role="option"
aria-selected={locale === lang.code}
+1
View File
@@ -27,6 +27,7 @@ export function NavLink({ href, icon, label, onClick, className }: NavLinkProps)
className={`
relative flex items-center gap-2 rounded-full py-2 px-4
transition-all duration-200 ease-in-out
focus:outline-none focus:ring-2 focus:ring-zinc-600 focus:ring-offset-2 focus:ring-offset-zinc-950
${isActive ? 'text-white bg-zinc-700/60' : 'text-zinc-400 hover:text-white hover:bg-zinc-800/30'}
${className || ''}
`}
@@ -0,0 +1,74 @@
'use client';
import { useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
const categories = [
'AI Development',
'IoT',
'Full-Stack',
'Enterprise Software',
'E-Commerce',
'Developer Tools',
] as const;
export function CategoryFilter() {
const t = useTranslations('portfolio.filters');
const router = useRouter();
const searchParams = useSearchParams();
const selectedCategory = searchParams.get('category');
const handleCategoryClick = (category: string | null) => {
if (category === null) {
// Remove category param to show all
router.push(window.location.pathname);
} else {
// Set category param
const params = new URLSearchParams(searchParams.toString());
params.set('category', category);
router.push(`${window.location.pathname}?${params.toString()}`);
}
};
return (
<div className="mb-12">
<div className="flex items-center gap-3 overflow-x-auto pb-4 scrollbar-hide">
{/* All button */}
<button
onClick={() => handleCategoryClick(null)}
className={`
relative whitespace-nowrap rounded-full py-2 px-4
transition-all duration-200 ease-in-out text-sm tracking-wide
${!selectedCategory
? 'text-white bg-zinc-700/60'
: 'text-zinc-400 hover:text-white hover:bg-zinc-800/30'
}
`}
>
{t('all')}
</button>
{/* Category buttons */}
{categories.map((category) => {
const isActive = selectedCategory === category;
return (
<button
key={category}
onClick={() => handleCategoryClick(category)}
className={`
relative whitespace-nowrap rounded-full py-2 px-4
transition-all duration-200 ease-in-out text-sm tracking-wide
${isActive
? 'text-white bg-zinc-700/60'
: 'text-zinc-400 hover:text-white hover:bg-zinc-800/30'
}
`}
>
{t(`categories.${category}`)}
</button>
);
})}
</div>
</div>
);
}
+1
View File
@@ -1,2 +1,3 @@
export { CategoryFilter } from './CategoryFilter';
export { default as PortfolioCard } from './PortfolioCard';
export { default as PortfolioGrid } from './PortfolioGrid';
+32
View File
@@ -1,12 +1,44 @@
/**
* Custom Hook for Async Operations
* Provides a reusable pattern for handling asynchronous operations with loading, error, and data states
*/
import { useState, useCallback } from 'react';
import { handleError } from '../utils/errorHandling';
/**
* State interface for async operations
* @template T - The type of data returned by the async operation
*/
interface AsyncState<T> {
/** The data returned from the async operation, null if not yet loaded or error occurred */
data: T | null;
/** Error object if the operation failed, null otherwise */
error: Error | null;
/** Loading state indicator, true while operation is in progress */
loading: boolean;
}
/**
* Custom hook for managing asynchronous operations
* Handles loading states, error handling, and data management for async functions
*
* @template T - The type of data returned by the async operation
* @returns Object containing:
* - data: The result of the async operation or null
* - error: Any error that occurred or null
* - loading: Boolean indicating if operation is in progress
* - execute: Function to trigger the async operation
*
* @example
* ```tsx
* const { data, error, loading, execute } = useAsync<User>();
*
* useEffect(() => {
* execute(() => fetchUser(userId));
* }, [userId]);
* ```
*/
export function useAsync<T>() {
const [state, setState] = useState<AsyncState<T>>({
data: null,
+16
View File
@@ -1,5 +1,21 @@
/**
* Click Outside Detection Hook
* Detects clicks outside of a referenced element and triggers a handler
*/
import { RefObject, useEffect } from 'react';
/**
* Hook that triggers a handler when user clicks outside the referenced element
* Useful for closing dropdowns, modals, or menus when clicking outside
*
* @param ref - React ref object pointing to the element to monitor
* @param handler - Callback function to execute when click occurs outside the element
*
* @example
* const menuRef = useRef<HTMLDivElement>(null);
* useOnClickOutside(menuRef, () => setMenuOpen(false));
*/
export function useOnClickOutside(ref: RefObject<HTMLElement>, handler: (event: MouseEvent | TouchEvent) => void) {
useEffect(() => {
const listener = (event: MouseEvent | TouchEvent) => {
+12 -1
View File
@@ -1,7 +1,18 @@
// src/hooks/useProjectData.ts
/**
* Project Data Hook
* Custom hook for dynamically loading project data based on current language
*/
import { useTranslation } from 'react-i18next';
import { useState, useEffect } from 'react';
/**
* Loads project data dynamically based on project ID and current language
*
* @template T - The type of the project data object
* @param {string} projectId - The unique identifier of the project
* @returns {T | null} The project data object or null if loading fails
*/
export const useProjectData = <T = unknown>(projectId: string): T | null => {
const { i18n } = useTranslation();
const [projectData, setProjectData] = useState<T | null>(null);
+10
View File
@@ -1,8 +1,18 @@
/**
* Scroll Lock Hook
* Manages body scroll locking based on state and navigation transitions
*/
// hooks/useScrollLock.ts
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useScrollContext } from '../components/ScrollContext';
/**
* Custom hook to lock/unlock page scrolling
* Automatically handles scroll restoration on route changes and cleanup on unmount
* @param lock - Whether to lock the page scroll
*/
export const useScrollLock = (lock: boolean) => {
const location = useLocation();
const { isTransitioning, lockScroll, unlockScroll } = useScrollContext();
+30 -1
View File
@@ -1,8 +1,37 @@
/**
* Scroll Tracking Hook
* Tracks user scroll depth and sends events to Google Tag Manager
*/
import { useEffect, useRef } from 'react';
import { trackScrollDepth } from '../services/gtm';
/**
* Hook to track scroll depth for GTM
* Custom hook to track scroll depth for GTM analytics
*
* Monitors user scroll behavior and triggers GTM events when the user reaches
* specific depth milestones (25%, 50%, 75%, 90%, 100%). Each milestone is
* tracked only once per page visit to avoid duplicate events.
*
* Features:
* - Debounced scroll event handling (100ms) for performance
* - Tracks depth milestones: 25%, 50%, 75%, 90%, 100%
* - Prevents duplicate tracking of the same milestone
* - Automatically resets tracking state on route changes
* - Checks initial scroll position on mount
*
* @example
* ```tsx
* function MyPage() {
* useScrollTracking();
* return <div>...</div>;
* }
* ```
*
* @remarks
* This hook should be used at the page/route level component to ensure
* accurate tracking across navigation. The tracking state is automatically
* reset when the URL pathname changes.
*/
export const useScrollTracking = () => {
const trackedDepths = useRef(new Set<number>());
+51 -1
View File
@@ -1,21 +1,42 @@
/**
* Blog Post Management Service
* Zentrale Verwaltung aller Blog-Beiträge mit automatischer Kategorie-Erkennung
*/
import fs from 'fs';
import path from 'path';
import { cache } from '@/utils/cache';
/**
* Blog Post Interface
* Definiert die Struktur eines Blog-Beitrags
*/
export interface BlogPost {
/** URL-freundlicher Slug */
slug: string;
/** Titel des Beitrags */
title: string;
/** Veröffentlichungsdatum (ISO 8601) */
date: string;
/** Kurze Zusammenfassung */
excerpt: string;
/** Pfad zum Cover-Bild */
coverImage: string;
/** Kategorie des Beitrags */
category?: string;
/** Schlagwörter/Tags */
tags?: string[];
/** Vollständiger Markdown-Inhalt */
content?: string;
}
// Verzeichnis der Blog-Beiträge
const BLOG_POSTS_DIR = path.join(process.cwd(), 'blog-posts');
// Category mapping based on filename patterns
/**
* Kategorie-Mapping basierend auf Dateinamen- und Inhaltsmustern
* Ordnet Keywords automatisch passenden Kategorien zu
*/
const categoryMap: Record<string, string> = {
'agentic': 'KI-Agenten',
'ai-agent': 'KI-Agenten',
@@ -59,6 +80,13 @@ const categoryMap: Record<string, string> = {
'home-assistant': 'Smart Home',
};
/**
* Erkennt automatisch die Kategorie eines Blog-Posts
* Analysiert Dateiname und Inhalt auf Keywords
* @param filename - Dateiname des Blog-Posts
* @param content - Inhalt des Blog-Posts
* @returns Die erkannte Kategorie oder 'Technologie' als Fallback
*/
function detectCategory(filename: string, content: string): string {
const lowerFilename = filename.toLowerCase();
const lowerContent = content.toLowerCase().slice(0, 500);
@@ -71,6 +99,13 @@ function detectCategory(filename: string, content: string): string {
return 'Technologie';
}
/**
* Parst einen Markdown-Blog-Post und extrahiert alle Metadaten
* Extrahiert Titel, Meta-Description, Keywords, Kategorie und generiert Slug
* @param filename - Dateiname des Markdown-Posts
* @param content - Vollständiger Markdown-Inhalt
* @returns BlogPost-Objekt oder null bei Fehler
*/
function parseMarkdownPost(filename: string, content: string): BlogPost | null {
try {
// Extract title from first H1
@@ -130,6 +165,11 @@ function parseMarkdownPost(filename: string, content: string): BlogPost | null {
}
}
/**
* Lädt alle Blog-Posts aus dem Dateisystem
* Posts werden nach Datum absteigend sortiert (neueste zuerst)
* @returns Array aller Blog-Posts
*/
export function getAllBlogPosts(): BlogPost[] {
const cacheKey = 'blog:all-posts';
@@ -174,11 +214,21 @@ export function getAllBlogPosts(): BlogPost[] {
return sortedPosts;
}
/**
* Sucht einen Blog-Post anhand seines Slugs
* @param slug - Der URL-Slug des gesuchten Posts
* @returns Der gefundene Blog-Post oder null
*/
export function getBlogPostBySlug(slug: string): BlogPost | null {
const posts = getAllBlogPosts();
return posts.find(post => post.slug === slug) || null;
}
/**
* Gibt alle verfügbaren Blog-Post Slugs zurück
* Nützlich für statische Seitengenerierung
* @returns Array aller Post-Slugs
*/
export function getBlogPostSlugs(): string[] {
return getAllBlogPosts().map(post => post.slug);
}
+13
View File
@@ -1,5 +1,14 @@
/**
* Markdown Rendering Utility
* Converts markdown text to styled React components
*/
import React from 'react';
/**
* Parse inline markdown syntax (code, bold, italic, links)
* Converts inline markdown elements within a text string to React components
*/
function parseInlineMarkdown(text: string): React.ReactNode {
const parts: React.ReactNode[] = [];
let remaining = text;
@@ -63,6 +72,10 @@ function parseInlineMarkdown(text: string): React.ReactNode {
return parts.length === 1 ? parts[0] : <>{parts}</>;
}
/**
* Render markdown content to React components
* Supports headers, lists, code blocks, tables, blockquotes, and inline formatting
*/
export function renderMarkdown(content: string): React.ReactNode {
const lines = content.split('\n');
const elements: React.ReactNode[] = [];
+9
View File
@@ -1,5 +1,14 @@
/**
* Supabase Browser Client
* Client-side Supabase client for authentication and database access
*/
import { createBrowserClient } from '@supabase/ssr';
/**
* Creates a Supabase browser client instance
* @returns Supabase client configured with environment variables
*/
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
+11
View File
@@ -1,6 +1,17 @@
/**
* Supabase Server Client Utility
* Creates server-side Supabase clients with cookie handling for SSR
*/
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
/**
* Creates a Supabase client for server-side usage
* Handles cookie management for authentication in Next.js server components
*
* @returns {Promise} Supabase server client instance
*/
export async function createClient() {
const cookieStore = await cookies();
+10 -5
View File
@@ -394,6 +394,7 @@
"label": "Nachricht",
"placeholder": "Ihre Nachricht an mich..."
},
"characterCounter": "{current}/{max} Zeichen",
"submit": "Nachricht senden",
"submitting": "Nachricht wird gesendet...",
"successMessage": "Vielen Dank für Ihre Nachricht! Ich werde mich so schnell wie möglich bei Ihnen melden.",
@@ -449,11 +450,12 @@
"filters": {
"all": "Alle",
"categories": {
"Data Science": "Data Science",
"AI Development": "KI-Entwicklung",
"Integration": "Integration",
"Full-Stack Development": "Full-Stack Entwicklung",
"Data Processing": "Datenverarbeitung"
"IoT": "IoT",
"Full-Stack": "Full-Stack",
"Enterprise Software": "Unternehmenssoftware",
"E-Commerce": "E-Commerce",
"Developer Tools": "Entwickler-Tools"
}
},
"ui": {
@@ -475,12 +477,15 @@
},
"ui": {
"readMore": "Weiterlesen",
"search": {
"placeholder": "Beiträge durchsuchen..."
},
"loading": {
"posts": "Blogbeiträge werden geladen...",
"post": "Blogbeitrag wird geladen..."
},
"errors": {
"posts": "Fehler beim Laden der Blogbeiträge",
"posts": "Keine Beiträge gefunden",
"post": "Der gesuchte Blogbeitrag existiert nicht oder wurde verschoben."
},
"backToBlog": "Zurück zum Blog",
+10 -5
View File
@@ -409,6 +409,7 @@
"label": "Message",
"placeholder": "Your message to me..."
},
"characterCounter": "{current}/{max} characters",
"submit": "Send Message",
"submitting": "Sending message...",
"successMessage": "Thank you for your message! I will get back to you as soon as possible.",
@@ -464,11 +465,12 @@
"filters": {
"all": "All",
"categories": {
"Data Science": "Data Science",
"AI Development": "AI Development",
"Integration": "Integration",
"Full-Stack Development": "Full-Stack Development",
"Data Processing": "Data Processing"
"IoT": "IoT",
"Full-Stack": "Full-Stack",
"Enterprise Software": "Enterprise Software",
"E-Commerce": "E-Commerce",
"Developer Tools": "Developer Tools"
}
},
"ui": {
@@ -490,12 +492,15 @@
},
"ui": {
"readMore": "Read More",
"search": {
"placeholder": "Search posts..."
},
"loading": {
"posts": "Loading blog posts...",
"post": "Loading blog post..."
},
"errors": {
"posts": "Error loading blog posts",
"posts": "No posts found",
"post": "The requested blog post does not exist or has been moved."
},
"backToBlog": "Back to Blog",
+10 -5
View File
@@ -416,6 +416,7 @@
"label": "Poruka",
"placeholder": "Vasa poruka za mene..."
},
"characterCounter": "{current}/{max} karaktera",
"submit": "Posalji poruku",
"submitting": "Slanje poruke...",
"successMessage": "Hvala vam na poruci! Javicu vam se sto je pre moguce.",
@@ -471,11 +472,12 @@
"filters": {
"all": "Sve",
"categories": {
"Data Science": "Data Science",
"AI Development": "AI Razvoj",
"Integration": "Integracija",
"Full-Stack Development": "Full-Stack Razvoj",
"Data Processing": "Obrada podataka"
"IoT": "IoT",
"Full-Stack": "Full-Stack",
"Enterprise Software": "Korporativni Softver",
"E-Commerce": "E-trgovina",
"Developer Tools": "Alati za Razvoj"
}
},
"ui": {
@@ -497,12 +499,15 @@
},
"ui": {
"readMore": "Procitaj vise",
"search": {
"placeholder": "Pretrazi postove..."
},
"loading": {
"posts": "Ucitavanje blog postova...",
"post": "Ucitavanje blog posta..."
},
"errors": {
"posts": "Greska pri ucitavanju blog postova",
"posts": "Nema pronađenih postova",
"post": "Trazeni blog post ne postoji ili je premesten."
},
"backToBlog": "Nazad na Blog",
+29 -30
View File
@@ -1,48 +1,47 @@
<<<<<<< HEAD
import { chain, chainMatch, isPageRequest, csp } from '@next-safe/middleware';
=======
import { NextRequest, NextResponse } from 'next/server';
>>>>>>> origin/master
import createMiddleware from 'next-intl/middleware';
import { locales, defaultLocale } from './i18n/config';
<<<<<<< HEAD
import { createClient } from '@/lib/supabase/middleware';
import { NextRequest, NextResponse } from 'next/server';
=======
import { randomBytes } from 'crypto';
<<<<<<< HEAD
const handleI18nRouting = createMiddleware({
=======
const CSRF_TOKEN_COOKIE_NAME = 'csrf_token';
const CSRF_TOKEN_LENGTH = 32;
>>>>>>> origin/master
const intlMiddleware = createMiddleware({
>>>>>>> origin/master
locales,
defaultLocale,
localePrefix: 'always',
});
<<<<<<< HEAD
export async function middleware(request: NextRequest) {
// Check if the request is for a protected dashboard route
const pathname = request.nextUrl.pathname;
const isDashboardRoute = pathname.match(/^\/(de|en|sr)\/dashboard(\/|$)/);
// Define CSP using @next-safe/middleware
const securityMiddleware = csp({
directives: {
'default-src': ["'self'"],
'script-src': ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
'style-src': ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
'font-src': ["'self'", 'https://fonts.gstatic.com', 'data:'],
'img-src': ["'self'", 'data:', 'blob:', 'https://mxadgucxhmstlzsbgmoz.supabase.co'],
'connect-src': ["'self'", 'https://mxadgucxhmstlzsbgmoz.supabase.co'],
'frame-ancestors': ["'self'"],
'base-uri': ["'self'"],
'form-action': ["'self'"],
},
});
if (isDashboardRoute) {
// Create Supabase client and check authentication
const { supabase, response } = await createClient(request);
const { data: { user } } = await supabase.auth.getUser();
// If not authenticated, redirect to login page with locale preserved
if (!user) {
const locale = pathname.split('/')[1];
const loginUrl = new URL(`/${locale}/login`, request.url);
loginUrl.searchParams.set('returnUrl', pathname);
return NextResponse.redirect(loginUrl);
}
// User is authenticated, continue with the response from Supabase client
return response;
}
// For non-dashboard routes, use the intl middleware
return intlMiddleware(request);
// Use chain to combine i18n middleware with security middleware
// First run i18n, then apply CSP only on page requests
export default chain(
handleI18nRouting,
chainMatch(isPageRequest)(securityMiddleware)
);
=======
export default function middleware(request: NextRequest) {
// Run the i18n middleware first
@@ -70,8 +69,8 @@ export default function middleware(request: NextRequest) {
}
return response;
>>>>>>> origin/master
}
>>>>>>> origin/master
export const config = {
matcher: [
+38 -7
View File
@@ -1,9 +1,16 @@
/**
* Analytics Service
* Zentrale Verwaltung von Google Analytics, Facebook Pixel und Cookie-basiertem Tracking
*/
import ReactGA from 'react-ga4';
const TRACKING_ID = 'G-N0PZBL7X18';
const FB_PIXEL_ID = 'XXXXXXXXXXXXXXXXX'; // Ersetze mit deiner Facebook Pixel ID
// Prüft, ob eine bestimmte Cookie-Kategorie aktiviert ist
/**
* Prüft, ob eine bestimmte Cookie-Kategorie aktiviert ist
*/
export const isCookieCategoryEnabled = (category: string): boolean => {
try {
const cookieSettings = localStorage.getItem('cookieSettings');
@@ -17,7 +24,9 @@ export const isCookieCategoryEnabled = (category: string): boolean => {
}
};
// Entfernt alle Google Analytics Cookies
/**
* Entfernt alle Google Analytics Cookies
*/
const removeGACookies = (): void => {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
@@ -33,7 +42,9 @@ const removeGACookies = (): void => {
}
};
// Google Analytics Initialisierung - mit strikter Zustimmungsprüfung
/**
* Initialisiert Google Analytics mit strikter Zustimmungsprüfung
*/
export const initGA = (): void => {
if (!isCookieCategoryEnabled('analytics')) {
// Entferne bestehende GA-Cookies, falls keine Zustimmung
@@ -49,6 +60,9 @@ export const initGA = (): void => {
});
};
/**
* Loggt einen Seitenaufruf in Google Analytics
*/
export const logPageView = (): void => {
if (!isCookieCategoryEnabled('analytics')) {
return;
@@ -61,6 +75,9 @@ export const logPageView = (): void => {
});
};
/**
* Trackt ein benutzerdefiniertes Event
*/
export const trackEvent = (category: string, action: string, label?: string): void => {
if (!isCookieCategoryEnabled('analytics')) {
return;
@@ -73,6 +90,9 @@ export const trackEvent = (category: string, action: string, label?: string): vo
});
};
/**
* Trackt eine Conversion
*/
export const trackConversion = (value?: number): void => {
if (!isCookieCategoryEnabled('analytics')) {
return;
@@ -85,6 +105,9 @@ export const trackConversion = (value?: number): void => {
});
};
/**
* Trackt Performance-Timing
*/
export const trackTiming = (category: string, variable: string, value: number): void => {
if (!isCookieCategoryEnabled('analytics')) {
return;
@@ -98,7 +121,9 @@ export const trackTiming = (category: string, variable: string, value: number):
});
};
// Facebook Pixel Initialisierung - nur mit Zustimmung
/**
* Initialisiert Facebook Pixel nur mit Zustimmung
*/
export const initFBPixel = (): void => {
if (!isCookieCategoryEnabled('marketing')) {
return;
@@ -122,7 +147,9 @@ export const initFBPixel = (): void => {
}
};
// Facebook Event tracking
/**
* Trackt Facebook Pixel Events
*/
export const trackFBEvent = (event: string, params?: Record<string, unknown>): void => {
if (!isCookieCategoryEnabled('marketing') || typeof window === 'undefined' || !window.fbq) {
return;
@@ -131,7 +158,9 @@ export const trackFBEvent = (event: string, params?: Record<string, unknown>): v
window.fbq('track', event, params);
};
// Google Fonts Handling - nur mit funktionaler Cookie-Zustimmung
/**
* Initialisiert Google Fonts nur mit funktionaler Cookie-Zustimmung
*/
export const initGoogleFonts = (): void => {
if (!isCookieCategoryEnabled('functional')) {
// Entferne bestehende Google Fonts
@@ -151,7 +180,9 @@ export const initGoogleFonts = (): void => {
// Code hier zur lokalen Einbindung der Schriftarten
};
// Funktionale Cookies initialisieren
/**
* Initialisiert funktionale Cookies und Services
*/
export const initFunctionalCookies = (): void => {
if (!isCookieCategoryEnabled('functional')) {
return;
+16
View File
@@ -1,9 +1,25 @@
/**
* API Utility Functions
* Provides enhanced fetch functionality with timeout support
*/
import { handleError, createAPIError } from './errorHandling';
/**
* Extended request options with timeout support
*/
interface RequestOptions extends RequestInit {
/** Request timeout in milliseconds (default: 8000ms) */
timeout?: number;
}
/**
* Fetch with automatic timeout and error handling
* @param resource - URL or resource to fetch
* @param options - Request options including optional timeout
* @returns Promise resolving to the fetch Response
* @throws {Error} On timeout, network error, or HTTP error status
*/
export async function fetchWithTimeout(
resource: string,
options: RequestOptions = {}
+18 -2
View File
@@ -1,17 +1,33 @@
/**
* Authentication Utility Functions
* Handles user authentication checks and sign out operations
*/
import { supabase } from './supabaseClient';
import { NavigateFunction } from 'react-router-dom';
/**
* Check if user is authenticated
* Verifies the current session and redirects to login if not authenticated
* @param {NavigateFunction} navigate - React Router navigate function for redirects
* @returns {Promise<boolean>} True if authenticated, false otherwise
*/
export const checkAuth = async (navigate: NavigateFunction) => {
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
navigate('/login');
return false;
}
return true;
};
/**
* Sign out current user
* Terminates the user session and redirects to login page
* @param {NavigateFunction} navigate - React Router navigate function for redirects
*/
export const signOut = async (navigate: NavigateFunction) => {
await supabase.auth.signOut();
navigate('/login');
+88 -6
View File
@@ -1,50 +1,132 @@
/**
* In-Memory Cache Utility
* Provides simple key-value caching with TTL (Time To Live) support
*/
/**
* Cache item structure with value, timestamp, and TTL
*/
type CacheItem<T> = {
value: T;
timestamp: number;
ttl: number;
lastAccessed: number;
};
/**
* In-memory cache implementation with automatic expiration
*/
class Cache {
private storage: Map<string, CacheItem<any>>;
private readonly defaultTTL: number;
private readonly maxSize?: number;
<<<<<<< HEAD
/**
* Create a new cache instance
* @param defaultTTL - Default time-to-live in milliseconds (default: 5 minutes)
*/
constructor(defaultTTL = 5 * 60 * 1000) { // 5 minutes default TTL
=======
constructor(defaultTTL = 5 * 60 * 1000, maxSize?: number) { // 5 minutes default TTL
>>>>>>> origin/master
this.storage = new Map();
this.defaultTTL = defaultTTL;
this.maxSize = maxSize;
}
/**
* Store a value in cache with optional TTL
* @param key - Cache key
* @param value - Value to cache
* @param ttl - Time-to-live in milliseconds (uses default if not specified)
*/
set<T>(key: string, value: T, ttl = this.defaultTTL): void {
const now = Date.now();
// If maxSize is set and cache is full, evict least recently used item
if (this.maxSize && this.storage.size >= this.maxSize && !this.storage.has(key)) {
this.evictLRU();
}
this.storage.set(key, {
value,
timestamp: Date.now(),
ttl
timestamp: now,
ttl,
lastAccessed: now
});
}
<<<<<<< HEAD
/**
* Retrieve a value from cache
* Returns null if key doesn't exist or has expired
* @param key - Cache key
* @returns Cached value or null
*/
=======
private evictLRU(): void {
let lruKey: string | null = null;
let lruTime = Infinity;
for (const [key, item] of this.storage.entries()) {
if (item.lastAccessed < lruTime) {
lruTime = item.lastAccessed;
lruKey = key;
}
}
if (lruKey) {
this.storage.delete(lruKey);
}
}
>>>>>>> origin/master
get<T>(key: string): T | null {
const item = this.storage.get(key);
if (!item) return null;
if (Date.now() > item.timestamp + item.ttl) {
this.storage.delete(key);
return null;
}
<<<<<<< HEAD
=======
// Update last accessed time for LRU tracking
item.lastAccessed = Date.now();
>>>>>>> origin/master
return item.value;
}
/**
* Check if a key exists and is not expired
* @param key - Cache key
* @returns True if key exists and is valid
*/
has(key: string): boolean {
return this.get(key) !== null;
}
/**
* Remove a specific key from cache
* @param key - Cache key to delete
*/
delete(key: string): void {
this.storage.delete(key);
}
/**
* Clear all cached items
*/
clear(): void {
this.storage.clear();
}
}
export const cache = new Cache();
/**
* Shared cache instance with 5-minute default TTL
*/
export const cache = new Cache();
+16 -2
View File
@@ -1,6 +1,12 @@
// src/utils/constants.ts
// Zentrale Konstanten für die gesamte Website
/**
* Application Constants
* Zentrale Konstanten für die gesamte Website
*/
/**
* Contact information
* Kontaktinformationen für alle Kontaktpunkte auf der Website
*/
export const CONTACT = {
email: "info@damjan-savic.com",
phone: "+49 175 695 0979",
@@ -14,12 +20,20 @@ export const CONTACT = {
}
} as const;
/**
* Current professional role
* Aktuelle berufliche Position und Unternehmen
*/
export const CURRENT_ROLE = {
company: "Everlast Consulting GmbH",
position: "Process Automation Specialist",
since: "2024-12"
} as const;
/**
* Profile information
* Profil- und persönliche Informationen für die gesamte Website
*/
export const PROFILE = {
name: "Damjan Savić",
title: "Fullstack Developer",
+14 -3
View File
@@ -1,20 +1,31 @@
/**
* CSRF Token Utility
* Verwaltung von Cross-Site Request Forgery Schutz-Tokens
*/
import { v4 as uuidv4 } from 'uuid';
// CSRF token storage
let csrfToken: string | null = null;
// Generate a new CSRF token
/**
* Generate a new CSRF token
*/
export const generateCsrfToken = (): string => {
csrfToken = uuidv4();
return csrfToken;
};
// Validate a CSRF token
/**
* Validate a CSRF token
*/
export const validateCsrfToken = (token: string): boolean => {
return token === csrfToken;
};
// Get the current CSRF token
/**
* Get the current CSRF token
*/
export const getCsrfToken = (): string => {
if (!csrfToken) {
return generateCsrfToken();
+17
View File
@@ -1,5 +1,13 @@
/**
* Error Handling Utilities
* Centralized error handling and custom error types
*/
import { trackEvent } from './analytics';
/**
* Custom application error class with error codes, severity levels, and metadata
*/
export class AppError extends Error {
constructor(
message: string,
@@ -12,6 +20,9 @@ export class AppError extends Error {
}
}
/**
* Standard error codes used throughout the application
*/
export const errorCodes = {
NETWORK_ERROR: 'ERR_NETWORK',
API_ERROR: 'ERR_API',
@@ -20,6 +31,9 @@ export const errorCodes = {
UNKNOWN_ERROR: 'ERR_UNKNOWN'
} as const;
/**
* Handle and normalize errors with tracking and logging
*/
export const handleError = (error: unknown, context: string) => {
let appError: AppError;
@@ -52,6 +66,9 @@ export const handleError = (error: unknown, context: string) => {
return appError;
};
/**
* Create an API error with HTTP status code
*/
export const createAPIError = (status: number, message: string) => {
return new AppError(
message,
+18 -5
View File
@@ -1,4 +1,13 @@
// Font loading utility to ensure fonts are loaded before showing content
/**
* Font Loading Utility
* Ensures fonts are loaded before showing content to prevent FOUT/FOIT
*/
/**
* Preload fonts asynchronously
* Uses the Font Loading API to ensure Inter font is loaded
* Falls back to system fonts on error
*/
export const preloadFonts = async () => {
if ('fonts' in document) {
try {
@@ -12,14 +21,18 @@ export const preloadFonts = async () => {
}
};
// Call this function as early as possible
/**
* Initialize font loading process
* Should be called as early as possible in the application lifecycle
* Adds loading state and fallback timeout to prevent indefinite loading
*/
export const initializeFonts = () => {
// Add loading class immediately
document.documentElement.classList.add('fonts-loading');
// Start font loading
preloadFonts();
// Remove loading class after a timeout to prevent indefinite loading state
setTimeout(() => {
document.documentElement.classList.remove('fonts-loading');
@@ -27,4 +40,4 @@ export const initializeFonts = () => {
document.documentElement.classList.add('fonts-fallback');
}
}, 3000);
};
};
+71
View File
@@ -0,0 +1,71 @@
/**
* Rate limit entry tracking request count and timestamp
*/
interface RateLimitEntry {
count: number;
timestamp: number;
}
const WINDOW_SIZE_MS = 3600000; // 1 hour
const MAX_REQUESTS = 5; // Maximum requests per hour
/**
* Rate Limiter for API request throttling
* Manages rate limiting based on IP addresses with sliding window
*/
class RateLimiter {
private cache: Map<string, RateLimitEntry>;
constructor() {
this.cache = new Map();
}
/**
* Check if an IP address is currently rate limited
*/
isRateLimited(ip: string): boolean {
const now = Date.now();
const entry = this.cache.get(ip);
if (!entry) {
this.cache.set(ip, { count: 1, timestamp: now });
return false;
}
if (now - entry.timestamp > WINDOW_SIZE_MS) {
this.cache.set(ip, { count: 1, timestamp: now });
return false;
}
if (entry.count >= MAX_REQUESTS) {
return true;
}
entry.count++;
return false;
}
/**
* Get remaining attempts for an IP address
*/
getRemainingAttempts(ip: string): number {
const entry = this.cache.get(ip);
if (!entry) {
return MAX_REQUESTS;
}
return Math.max(0, MAX_REQUESTS - entry.count);
}
/**
* Get time in milliseconds until rate limit resets for an IP
*/
getTimeToReset(ip: string): number {
const entry = this.cache.get(ip);
if (!entry) {
return 0;
}
return Math.max(0, WINDOW_SIZE_MS - (Date.now() - entry.timestamp));
}
}
export const rateLimiter = new RateLimiter();
+13
View File
@@ -1,3 +1,12 @@
/**
* Service Worker Registration Utility
* Handles registration and unregistration of service workers for PWA functionality
*/
/**
* Registers the service worker
* Automatically registers the service worker after the window load event
*/
export function register() {
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
@@ -14,6 +23,10 @@ export function register() {
}
}
/**
* Unregisters the service worker
* Removes the currently registered service worker
*/
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
+24
View File
@@ -1,6 +1,30 @@
/**
* Supabase Client Configuration
* Zentrale Konfiguration für die Supabase-Verbindung
*/
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = 'https://mxadgucxhmstlzsbgmoz.supabase.co';
const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im14YWRndWN4aG1zdGx6c2JnbW96Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzkwMDgwNjIsImV4cCI6MjA1NDU4NDA2Mn0.MmFqEm7OfwzOlSegLYl9YWLCmIp8YajzK3Aozubn66Q';
/**
* Supabase Client Instanz
*
* Vorkonfigurierter Supabase-Client für den Zugriff auf die Datenbank.
* Verwendet die anonyme (anon) Rolle für öffentliche Zugriffe.
*
* @example
* // Daten aus einer Tabelle abrufen
* const { data, error } = await supabase
* .from('table_name')
* .select('*');
*
* @example
* // Authentifizierung
* const { user, error } = await supabase.auth.signIn({
* email: 'user@example.com',
* password: 'password'
* });
*/
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
+17 -1
View File
@@ -1,5 +1,13 @@
/**
* Web Vitals Reporting Service
* Tracks and reports Core Web Vitals metrics to Google Analytics
*/
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';
/**
* Web Vitals metric data structure
*/
interface Metric {
name: string;
value: number;
@@ -8,6 +16,9 @@ interface Metric {
id: string;
}
/**
* Send Web Vitals metric to Google Analytics and dispatch custom event
*/
function sendToAnalytics(metric: Metric) {
const body = JSON.stringify({
name: metric.name,
@@ -45,6 +56,9 @@ function sendToAnalytics(metric: Metric) {
// }
}
/**
* Initialize Web Vitals reporting for all Core Web Vitals metrics
*/
export function reportWebVitals() {
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
@@ -53,7 +67,9 @@ export function reportWebVitals() {
onTTFB(sendToAnalytics);
}
// Helper function to get Web Vitals score
/**
* Calculate Web Vitals rating based on metric thresholds
*/
export function getWebVitalsScore(metric: Metric): string {
const thresholds = {
LCP: { good: 2500, poor: 4000 },