Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d824a8c2e0 | ||
|
|
1b23003f04 | ||
|
|
fd90485548 | ||
|
|
86196831e3 | ||
|
|
065d52627b | ||
|
|
5dc83186fd | ||
|
|
ffd85a1352 | ||
|
|
69f244b219 | ||
|
|
c41f4ae9f9 | ||
|
|
eae1ae6844 | ||
|
|
7324a87385 | ||
|
|
409a3d8d54 | ||
|
|
c23bcafacb | ||
|
|
54caa12821 | ||
|
|
889fbb410d | ||
|
|
6a910b211a | ||
|
|
7d17d8e262 | ||
|
|
a4f7db006e | ||
|
|
0a69a9bfbf | ||
|
|
6558e9ae79 | ||
|
|
09b31fd7c1 | ||
|
|
a64647cce0 | ||
|
|
5181357f9d | ||
|
|
cf6b80201c | ||
|
|
b4f9ac947b | ||
|
|
53ba05450f | ||
|
|
4d9e0d3a29 | ||
|
|
5f212996e0 | ||
|
|
a033c0d9e6 | ||
|
|
9860aa0dd2 | ||
|
|
cad7d46c7d | ||
|
|
09c6f31ff0 | ||
|
|
f73b46178f | ||
|
|
483c8aefe7 | ||
|
|
f2b4c0a227 | ||
|
|
bfdc52a117 | ||
|
|
baa3ad0571 | ||
|
|
41abcac9f7 | ||
|
|
5b4c24e737 | ||
|
|
d1b8665652 | ||
|
|
db13754e24 | ||
|
|
a46b4f61fe | ||
|
|
52ef3b96da | ||
|
|
ac36667dad |
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,6 +15,8 @@ interface FormData {
|
|||||||
export function ContactForm() {
|
export function ContactForm() {
|
||||||
const t = useTranslations('pages.contact');
|
const t = useTranslations('pages.contact');
|
||||||
|
|
||||||
|
const MAX_MESSAGE_LENGTH = 1000;
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||||
@@ -26,6 +28,8 @@ export function ContactForm() {
|
|||||||
const [errors, setErrors] = useState<Partial<FormData>>({});
|
const [errors, setErrors] = useState<Partial<FormData>>({});
|
||||||
const [remainingAttempts, setRemainingAttempts] = useState<number | null>(null);
|
const [remainingAttempts, setRemainingAttempts] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const messageLength = formData.message.length;
|
||||||
|
|
||||||
const validateForm = () => {
|
const validateForm = () => {
|
||||||
const newErrors: Partial<FormData> = {};
|
const newErrors: Partial<FormData> = {};
|
||||||
|
|
||||||
@@ -319,6 +323,7 @@ export function ContactForm() {
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder={t('contactForm.message.placeholder')}
|
placeholder={t('contactForm.message.placeholder')}
|
||||||
rows={6}
|
rows={6}
|
||||||
|
maxLength={MAX_MESSAGE_LENGTH}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
aria-required="true"
|
aria-required="true"
|
||||||
aria-invalid={!!errors.message}
|
aria-invalid={!!errors.message}
|
||||||
@@ -328,6 +333,19 @@ export function ContactForm() {
|
|||||||
{errors.message && (
|
{errors.message && (
|
||||||
<p id="message-error" role="alert" className="text-sm text-red-400">{errors.message}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const LanguageSwitcher = () => {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
const [focusedIndex, setFocusedIndex] = useState<number>(-1);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const languages = [
|
const languages = [
|
||||||
@@ -30,8 +31,28 @@ const LanguageSwitcher = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (!isOpen) return;
|
||||||
setIsOpen(false);
|
|
||||||
|
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
|
px-4 py-2 rounded-full transition-all duration-200
|
||||||
hover:bg-zinc-800/50 border border-transparent hover:border-zinc-800
|
hover:bg-zinc-800/50 border border-transparent hover:border-zinc-800
|
||||||
hover:scale-105 active:scale-95"
|
hover:scale-105 active:scale-95"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => {
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
setFocusedIndex(-1);
|
||||||
|
}}
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
aria-label="Select language"
|
aria-label="Select language"
|
||||||
@@ -79,18 +103,21 @@ const LanguageSwitcher = () => {
|
|||||||
${isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'}`}
|
${isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'}`}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
aria-label="Languages"
|
aria-label="Languages"
|
||||||
|
aria-activedescendant={focusedIndex >= 0 ? `lang-option-${languages[focusedIndex].code}` : undefined}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
{languages.map((lang) => (
|
{languages.map((lang, index) => (
|
||||||
<button
|
<button
|
||||||
key={lang.code}
|
key={lang.code}
|
||||||
|
id={`lang-option-${lang.code}`}
|
||||||
className={`w-full text-left px-4 py-2 text-sm transition-colors duration-200
|
className={`w-full text-left px-4 py-2 text-sm transition-colors duration-200
|
||||||
hover:bg-zinc-800/50
|
hover:bg-zinc-800/50
|
||||||
${locale === lang.code
|
${locale === lang.code
|
||||||
? 'bg-zinc-800 text-white'
|
? 'bg-zinc-800 text-white'
|
||||||
: 'text-zinc-400 hover:text-white'
|
: 'text-zinc-400 hover:text-white'
|
||||||
}`}
|
}
|
||||||
|
${focusedIndex === index ? 'ring-2 ring-orange-500 ring-inset' : ''}`}
|
||||||
onClick={() => handleLanguageChange(lang.code)}
|
onClick={() => handleLanguageChange(lang.code)}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={locale === lang.code}
|
aria-selected={locale === lang.code}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function NavLink({ href, icon, label, onClick, className }: NavLinkProps)
|
|||||||
className={`
|
className={`
|
||||||
relative flex items-center gap-2 rounded-full py-2 px-4
|
relative flex items-center gap-2 rounded-full py-2 px-4
|
||||||
transition-all duration-200 ease-in-out
|
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'}
|
${isActive ? 'text-white bg-zinc-700/60' : 'text-zinc-400 hover:text-white hover:bg-zinc-800/30'}
|
||||||
${className || ''}
|
${className || ''}
|
||||||
`}
|
`}
|
||||||
|
|||||||
@@ -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 { useState, useCallback } from 'react';
|
||||||
import { handleError } from '../utils/errorHandling';
|
import { handleError } from '../utils/errorHandling';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State interface for async operations
|
||||||
|
* @template T - The type of data returned by the async operation
|
||||||
|
*/
|
||||||
interface AsyncState<T> {
|
interface AsyncState<T> {
|
||||||
|
/** The data returned from the async operation, null if not yet loaded or error occurred */
|
||||||
data: T | null;
|
data: T | null;
|
||||||
|
/** Error object if the operation failed, null otherwise */
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
|
/** Loading state indicator, true while operation is in progress */
|
||||||
loading: boolean;
|
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>() {
|
export function useAsync<T>() {
|
||||||
const [state, setState] = useState<AsyncState<T>>({
|
const [state, setState] = useState<AsyncState<T>>({
|
||||||
data: null,
|
data: null,
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Click Outside Detection Hook
|
||||||
|
* Detects clicks outside of a referenced element and triggers a handler
|
||||||
|
*/
|
||||||
|
|
||||||
import { RefObject, useEffect } from 'react';
|
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) {
|
export function useOnClickOutside(ref: RefObject<HTMLElement>, handler: (event: MouseEvent | TouchEvent) => void) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = (event: MouseEvent | TouchEvent) => {
|
const listener = (event: MouseEvent | TouchEvent) => {
|
||||||
|
|||||||
@@ -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 { useTranslation } from 'react-i18next';
|
||||||
import { useState, useEffect } from 'react';
|
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 => {
|
export const useProjectData = <T = unknown>(projectId: string): T | null => {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const [projectData, setProjectData] = useState<T | null>(null);
|
const [projectData, setProjectData] = useState<T | null>(null);
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Scroll Lock Hook
|
||||||
|
* Manages body scroll locking based on state and navigation transitions
|
||||||
|
*/
|
||||||
|
|
||||||
// hooks/useScrollLock.ts
|
// hooks/useScrollLock.ts
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useScrollContext } from '../components/ScrollContext';
|
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) => {
|
export const useScrollLock = (lock: boolean) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { isTransitioning, lockScroll, unlockScroll } = useScrollContext();
|
const { isTransitioning, lockScroll, unlockScroll } = useScrollContext();
|
||||||
|
|||||||
@@ -1,8 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Scroll Tracking Hook
|
||||||
|
* Tracks user scroll depth and sends events to Google Tag Manager
|
||||||
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { trackScrollDepth } from '../services/gtm';
|
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 = () => {
|
export const useScrollTracking = () => {
|
||||||
const trackedDepths = useRef(new Set<number>());
|
const trackedDepths = useRef(new Set<number>());
|
||||||
|
|||||||
+51
-1
@@ -1,21 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Blog Post Management Service
|
||||||
|
* Zentrale Verwaltung aller Blog-Beiträge mit automatischer Kategorie-Erkennung
|
||||||
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { cache } from '@/utils/cache';
|
import { cache } from '@/utils/cache';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blog Post Interface
|
||||||
|
* Definiert die Struktur eines Blog-Beitrags
|
||||||
|
*/
|
||||||
export interface BlogPost {
|
export interface BlogPost {
|
||||||
|
/** URL-freundlicher Slug */
|
||||||
slug: string;
|
slug: string;
|
||||||
|
/** Titel des Beitrags */
|
||||||
title: string;
|
title: string;
|
||||||
|
/** Veröffentlichungsdatum (ISO 8601) */
|
||||||
date: string;
|
date: string;
|
||||||
|
/** Kurze Zusammenfassung */
|
||||||
excerpt: string;
|
excerpt: string;
|
||||||
|
/** Pfad zum Cover-Bild */
|
||||||
coverImage: string;
|
coverImage: string;
|
||||||
|
/** Kategorie des Beitrags */
|
||||||
category?: string;
|
category?: string;
|
||||||
|
/** Schlagwörter/Tags */
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
/** Vollständiger Markdown-Inhalt */
|
||||||
content?: string;
|
content?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verzeichnis der Blog-Beiträge
|
||||||
const BLOG_POSTS_DIR = path.join(process.cwd(), 'blog-posts');
|
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> = {
|
const categoryMap: Record<string, string> = {
|
||||||
'agentic': 'KI-Agenten',
|
'agentic': 'KI-Agenten',
|
||||||
'ai-agent': 'KI-Agenten',
|
'ai-agent': 'KI-Agenten',
|
||||||
@@ -59,6 +80,13 @@ const categoryMap: Record<string, string> = {
|
|||||||
'home-assistant': 'Smart Home',
|
'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 {
|
function detectCategory(filename: string, content: string): string {
|
||||||
const lowerFilename = filename.toLowerCase();
|
const lowerFilename = filename.toLowerCase();
|
||||||
const lowerContent = content.toLowerCase().slice(0, 500);
|
const lowerContent = content.toLowerCase().slice(0, 500);
|
||||||
@@ -71,6 +99,13 @@ function detectCategory(filename: string, content: string): string {
|
|||||||
return 'Technologie';
|
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 {
|
function parseMarkdownPost(filename: string, content: string): BlogPost | null {
|
||||||
try {
|
try {
|
||||||
// Extract title from first H1
|
// 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[] {
|
export function getAllBlogPosts(): BlogPost[] {
|
||||||
const cacheKey = 'blog:all-posts';
|
const cacheKey = 'blog:all-posts';
|
||||||
|
|
||||||
@@ -174,11 +214,21 @@ export function getAllBlogPosts(): BlogPost[] {
|
|||||||
return sortedPosts;
|
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 {
|
export function getBlogPostBySlug(slug: string): BlogPost | null {
|
||||||
const posts = getAllBlogPosts();
|
const posts = getAllBlogPosts();
|
||||||
return posts.find(post => post.slug === slug) || null;
|
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[] {
|
export function getBlogPostSlugs(): string[] {
|
||||||
return getAllBlogPosts().map(post => post.slug);
|
return getAllBlogPosts().map(post => post.slug);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Markdown Rendering Utility
|
||||||
|
* Converts markdown text to styled React components
|
||||||
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
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 {
|
function parseInlineMarkdown(text: string): React.ReactNode {
|
||||||
const parts: React.ReactNode[] = [];
|
const parts: React.ReactNode[] = [];
|
||||||
let remaining = text;
|
let remaining = text;
|
||||||
@@ -63,6 +72,10 @@ function parseInlineMarkdown(text: string): React.ReactNode {
|
|||||||
return parts.length === 1 ? parts[0] : <>{parts}</>;
|
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 {
|
export function renderMarkdown(content: string): React.ReactNode {
|
||||||
const lines = content.split('\n');
|
const lines = content.split('\n');
|
||||||
const elements: React.ReactNode[] = [];
|
const elements: React.ReactNode[] = [];
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Supabase Browser Client
|
||||||
|
* Client-side Supabase client for authentication and database access
|
||||||
|
*/
|
||||||
|
|
||||||
import { createBrowserClient } from '@supabase/ssr';
|
import { createBrowserClient } from '@supabase/ssr';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Supabase browser client instance
|
||||||
|
* @returns Supabase client configured with environment variables
|
||||||
|
*/
|
||||||
export function createClient() {
|
export function createClient() {
|
||||||
return createBrowserClient(
|
return createBrowserClient(
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Supabase Server Client Utility
|
||||||
|
* Creates server-side Supabase clients with cookie handling for SSR
|
||||||
|
*/
|
||||||
|
|
||||||
import { createServerClient } from '@supabase/ssr';
|
import { createServerClient } from '@supabase/ssr';
|
||||||
import { cookies } from 'next/headers';
|
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() {
|
export async function createClient() {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
|
|||||||
@@ -394,6 +394,7 @@
|
|||||||
"label": "Nachricht",
|
"label": "Nachricht",
|
||||||
"placeholder": "Ihre Nachricht an mich..."
|
"placeholder": "Ihre Nachricht an mich..."
|
||||||
},
|
},
|
||||||
|
"characterCounter": "{current}/{max} Zeichen",
|
||||||
"submit": "Nachricht senden",
|
"submit": "Nachricht senden",
|
||||||
"submitting": "Nachricht wird gesendet...",
|
"submitting": "Nachricht wird gesendet...",
|
||||||
"successMessage": "Vielen Dank für Ihre Nachricht! Ich werde mich so schnell wie möglich bei Ihnen melden.",
|
"successMessage": "Vielen Dank für Ihre Nachricht! Ich werde mich so schnell wie möglich bei Ihnen melden.",
|
||||||
|
|||||||
@@ -409,6 +409,7 @@
|
|||||||
"label": "Message",
|
"label": "Message",
|
||||||
"placeholder": "Your message to me..."
|
"placeholder": "Your message to me..."
|
||||||
},
|
},
|
||||||
|
"characterCounter": "{current}/{max} characters",
|
||||||
"submit": "Send Message",
|
"submit": "Send Message",
|
||||||
"submitting": "Sending message...",
|
"submitting": "Sending message...",
|
||||||
"successMessage": "Thank you for your message! I will get back to you as soon as possible.",
|
"successMessage": "Thank you for your message! I will get back to you as soon as possible.",
|
||||||
|
|||||||
@@ -416,6 +416,7 @@
|
|||||||
"label": "Poruka",
|
"label": "Poruka",
|
||||||
"placeholder": "Vasa poruka za mene..."
|
"placeholder": "Vasa poruka za mene..."
|
||||||
},
|
},
|
||||||
|
"characterCounter": "{current}/{max} karaktera",
|
||||||
"submit": "Posalji poruku",
|
"submit": "Posalji poruku",
|
||||||
"submitting": "Slanje poruke...",
|
"submitting": "Slanje poruke...",
|
||||||
"successMessage": "Hvala vam na poruci! Javicu vam se sto je pre moguce.",
|
"successMessage": "Hvala vam na poruci! Javicu vam se sto je pre moguce.",
|
||||||
|
|||||||
+38
-7
@@ -1,9 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Analytics Service
|
||||||
|
* Zentrale Verwaltung von Google Analytics, Facebook Pixel und Cookie-basiertem Tracking
|
||||||
|
*/
|
||||||
|
|
||||||
import ReactGA from 'react-ga4';
|
import ReactGA from 'react-ga4';
|
||||||
|
|
||||||
const TRACKING_ID = 'G-N0PZBL7X18';
|
const TRACKING_ID = 'G-N0PZBL7X18';
|
||||||
const FB_PIXEL_ID = 'XXXXXXXXXXXXXXXXX'; // Ersetze mit deiner Facebook Pixel ID
|
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 => {
|
export const isCookieCategoryEnabled = (category: string): boolean => {
|
||||||
try {
|
try {
|
||||||
const cookieSettings = localStorage.getItem('cookieSettings');
|
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 removeGACookies = (): void => {
|
||||||
const cookies = document.cookie.split(';');
|
const cookies = document.cookie.split(';');
|
||||||
for (let i = 0; i < cookies.length; i++) {
|
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 => {
|
export const initGA = (): void => {
|
||||||
if (!isCookieCategoryEnabled('analytics')) {
|
if (!isCookieCategoryEnabled('analytics')) {
|
||||||
// Entferne bestehende GA-Cookies, falls keine Zustimmung
|
// 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 => {
|
export const logPageView = (): void => {
|
||||||
if (!isCookieCategoryEnabled('analytics')) {
|
if (!isCookieCategoryEnabled('analytics')) {
|
||||||
return;
|
return;
|
||||||
@@ -61,6 +75,9 @@ export const logPageView = (): void => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trackt ein benutzerdefiniertes Event
|
||||||
|
*/
|
||||||
export const trackEvent = (category: string, action: string, label?: string): void => {
|
export const trackEvent = (category: string, action: string, label?: string): void => {
|
||||||
if (!isCookieCategoryEnabled('analytics')) {
|
if (!isCookieCategoryEnabled('analytics')) {
|
||||||
return;
|
return;
|
||||||
@@ -73,6 +90,9 @@ export const trackEvent = (category: string, action: string, label?: string): vo
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trackt eine Conversion
|
||||||
|
*/
|
||||||
export const trackConversion = (value?: number): void => {
|
export const trackConversion = (value?: number): void => {
|
||||||
if (!isCookieCategoryEnabled('analytics')) {
|
if (!isCookieCategoryEnabled('analytics')) {
|
||||||
return;
|
return;
|
||||||
@@ -85,6 +105,9 @@ export const trackConversion = (value?: number): void => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trackt Performance-Timing
|
||||||
|
*/
|
||||||
export const trackTiming = (category: string, variable: string, value: number): void => {
|
export const trackTiming = (category: string, variable: string, value: number): void => {
|
||||||
if (!isCookieCategoryEnabled('analytics')) {
|
if (!isCookieCategoryEnabled('analytics')) {
|
||||||
return;
|
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 => {
|
export const initFBPixel = (): void => {
|
||||||
if (!isCookieCategoryEnabled('marketing')) {
|
if (!isCookieCategoryEnabled('marketing')) {
|
||||||
return;
|
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 => {
|
export const trackFBEvent = (event: string, params?: Record<string, unknown>): void => {
|
||||||
if (!isCookieCategoryEnabled('marketing') || typeof window === 'undefined' || !window.fbq) {
|
if (!isCookieCategoryEnabled('marketing') || typeof window === 'undefined' || !window.fbq) {
|
||||||
return;
|
return;
|
||||||
@@ -131,7 +158,9 @@ export const trackFBEvent = (event: string, params?: Record<string, unknown>): v
|
|||||||
window.fbq('track', event, params);
|
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 => {
|
export const initGoogleFonts = (): void => {
|
||||||
if (!isCookieCategoryEnabled('functional')) {
|
if (!isCookieCategoryEnabled('functional')) {
|
||||||
// Entferne bestehende Google Fonts
|
// Entferne bestehende Google Fonts
|
||||||
@@ -151,7 +180,9 @@ export const initGoogleFonts = (): void => {
|
|||||||
// Code hier zur lokalen Einbindung der Schriftarten
|
// Code hier zur lokalen Einbindung der Schriftarten
|
||||||
};
|
};
|
||||||
|
|
||||||
// Funktionale Cookies initialisieren
|
/**
|
||||||
|
* Initialisiert funktionale Cookies und Services
|
||||||
|
*/
|
||||||
export const initFunctionalCookies = (): void => {
|
export const initFunctionalCookies = (): void => {
|
||||||
if (!isCookieCategoryEnabled('functional')) {
|
if (!isCookieCategoryEnabled('functional')) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,9 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* API Utility Functions
|
||||||
|
* Provides enhanced fetch functionality with timeout support
|
||||||
|
*/
|
||||||
|
|
||||||
import { handleError, createAPIError } from './errorHandling';
|
import { handleError, createAPIError } from './errorHandling';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended request options with timeout support
|
||||||
|
*/
|
||||||
interface RequestOptions extends RequestInit {
|
interface RequestOptions extends RequestInit {
|
||||||
|
/** Request timeout in milliseconds (default: 8000ms) */
|
||||||
timeout?: number;
|
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(
|
export async function fetchWithTimeout(
|
||||||
resource: string,
|
resource: string,
|
||||||
options: RequestOptions = {}
|
options: RequestOptions = {}
|
||||||
|
|||||||
+18
-2
@@ -1,17 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Authentication Utility Functions
|
||||||
|
* Handles user authentication checks and sign out operations
|
||||||
|
*/
|
||||||
|
|
||||||
import { supabase } from './supabaseClient';
|
import { supabase } from './supabaseClient';
|
||||||
import { NavigateFunction } from 'react-router-dom';
|
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) => {
|
export const checkAuth = async (navigate: NavigateFunction) => {
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
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) => {
|
export const signOut = async (navigate: NavigateFunction) => {
|
||||||
await supabase.auth.signOut();
|
await supabase.auth.signOut();
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
|
|||||||
+88
-6
@@ -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> = {
|
type CacheItem<T> = {
|
||||||
value: T;
|
value: T;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
ttl: number;
|
ttl: number;
|
||||||
|
lastAccessed: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory cache implementation with automatic expiration
|
||||||
|
*/
|
||||||
class Cache {
|
class Cache {
|
||||||
private storage: Map<string, CacheItem<any>>;
|
private storage: Map<string, CacheItem<any>>;
|
||||||
private readonly defaultTTL: number;
|
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) { // 5 minutes default TTL
|
||||||
|
=======
|
||||||
|
constructor(defaultTTL = 5 * 60 * 1000, maxSize?: number) { // 5 minutes default TTL
|
||||||
|
>>>>>>> origin/master
|
||||||
this.storage = new Map();
|
this.storage = new Map();
|
||||||
this.defaultTTL = defaultTTL;
|
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 {
|
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, {
|
this.storage.set(key, {
|
||||||
value,
|
value,
|
||||||
timestamp: Date.now(),
|
timestamp: now,
|
||||||
ttl
|
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 {
|
get<T>(key: string): T | null {
|
||||||
const item = this.storage.get(key);
|
const item = this.storage.get(key);
|
||||||
|
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
|
|
||||||
if (Date.now() > item.timestamp + item.ttl) {
|
if (Date.now() > item.timestamp + item.ttl) {
|
||||||
this.storage.delete(key);
|
this.storage.delete(key);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
=======
|
||||||
|
// Update last accessed time for LRU tracking
|
||||||
|
item.lastAccessed = Date.now();
|
||||||
|
|
||||||
|
>>>>>>> origin/master
|
||||||
return item.value;
|
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 {
|
has(key: string): boolean {
|
||||||
return this.get(key) !== null;
|
return this.get(key) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a specific key from cache
|
||||||
|
* @param key - Cache key to delete
|
||||||
|
*/
|
||||||
delete(key: string): void {
|
delete(key: string): void {
|
||||||
this.storage.delete(key);
|
this.storage.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all cached items
|
||||||
|
*/
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.storage.clear();
|
this.storage.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const cache = new Cache();
|
/**
|
||||||
|
* Shared cache instance with 5-minute default TTL
|
||||||
|
*/
|
||||||
|
export const cache = new Cache();
|
||||||
|
|||||||
+16
-2
@@ -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 = {
|
export const CONTACT = {
|
||||||
email: "info@damjan-savic.com",
|
email: "info@damjan-savic.com",
|
||||||
phone: "+49 175 695 0979",
|
phone: "+49 175 695 0979",
|
||||||
@@ -14,12 +20,20 @@ export const CONTACT = {
|
|||||||
}
|
}
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current professional role
|
||||||
|
* Aktuelle berufliche Position und Unternehmen
|
||||||
|
*/
|
||||||
export const CURRENT_ROLE = {
|
export const CURRENT_ROLE = {
|
||||||
company: "Everlast Consulting GmbH",
|
company: "Everlast Consulting GmbH",
|
||||||
position: "Process Automation Specialist",
|
position: "Process Automation Specialist",
|
||||||
since: "2024-12"
|
since: "2024-12"
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profile information
|
||||||
|
* Profil- und persönliche Informationen für die gesamte Website
|
||||||
|
*/
|
||||||
export const PROFILE = {
|
export const PROFILE = {
|
||||||
name: "Damjan Savić",
|
name: "Damjan Savić",
|
||||||
title: "Fullstack Developer",
|
title: "Fullstack Developer",
|
||||||
|
|||||||
+14
-3
@@ -1,20 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* CSRF Token Utility
|
||||||
|
* Verwaltung von Cross-Site Request Forgery Schutz-Tokens
|
||||||
|
*/
|
||||||
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
// CSRF token storage
|
// CSRF token storage
|
||||||
let csrfToken: string | null = null;
|
let csrfToken: string | null = null;
|
||||||
|
|
||||||
// Generate a new CSRF token
|
/**
|
||||||
|
* Generate a new CSRF token
|
||||||
|
*/
|
||||||
export const generateCsrfToken = (): string => {
|
export const generateCsrfToken = (): string => {
|
||||||
csrfToken = uuidv4();
|
csrfToken = uuidv4();
|
||||||
return csrfToken;
|
return csrfToken;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate a CSRF token
|
/**
|
||||||
|
* Validate a CSRF token
|
||||||
|
*/
|
||||||
export const validateCsrfToken = (token: string): boolean => {
|
export const validateCsrfToken = (token: string): boolean => {
|
||||||
return token === csrfToken;
|
return token === csrfToken;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get the current CSRF token
|
/**
|
||||||
|
* Get the current CSRF token
|
||||||
|
*/
|
||||||
export const getCsrfToken = (): string => {
|
export const getCsrfToken = (): string => {
|
||||||
if (!csrfToken) {
|
if (!csrfToken) {
|
||||||
return generateCsrfToken();
|
return generateCsrfToken();
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Error Handling Utilities
|
||||||
|
* Centralized error handling and custom error types
|
||||||
|
*/
|
||||||
|
|
||||||
import { trackEvent } from './analytics';
|
import { trackEvent } from './analytics';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom application error class with error codes, severity levels, and metadata
|
||||||
|
*/
|
||||||
export class AppError extends Error {
|
export class AppError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
@@ -12,6 +20,9 @@ export class AppError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard error codes used throughout the application
|
||||||
|
*/
|
||||||
export const errorCodes = {
|
export const errorCodes = {
|
||||||
NETWORK_ERROR: 'ERR_NETWORK',
|
NETWORK_ERROR: 'ERR_NETWORK',
|
||||||
API_ERROR: 'ERR_API',
|
API_ERROR: 'ERR_API',
|
||||||
@@ -20,6 +31,9 @@ export const errorCodes = {
|
|||||||
UNKNOWN_ERROR: 'ERR_UNKNOWN'
|
UNKNOWN_ERROR: 'ERR_UNKNOWN'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle and normalize errors with tracking and logging
|
||||||
|
*/
|
||||||
export const handleError = (error: unknown, context: string) => {
|
export const handleError = (error: unknown, context: string) => {
|
||||||
let appError: AppError;
|
let appError: AppError;
|
||||||
|
|
||||||
@@ -52,6 +66,9 @@ export const handleError = (error: unknown, context: string) => {
|
|||||||
return appError;
|
return appError;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an API error with HTTP status code
|
||||||
|
*/
|
||||||
export const createAPIError = (status: number, message: string) => {
|
export const createAPIError = (status: number, message: string) => {
|
||||||
return new AppError(
|
return new AppError(
|
||||||
message,
|
message,
|
||||||
|
|||||||
+18
-5
@@ -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 () => {
|
export const preloadFonts = async () => {
|
||||||
if ('fonts' in document) {
|
if ('fonts' in document) {
|
||||||
try {
|
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 = () => {
|
export const initializeFonts = () => {
|
||||||
// Add loading class immediately
|
// Add loading class immediately
|
||||||
document.documentElement.classList.add('fonts-loading');
|
document.documentElement.classList.add('fonts-loading');
|
||||||
|
|
||||||
// Start font loading
|
// Start font loading
|
||||||
preloadFonts();
|
preloadFonts();
|
||||||
|
|
||||||
// Remove loading class after a timeout to prevent indefinite loading state
|
// Remove loading class after a timeout to prevent indefinite loading state
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.documentElement.classList.remove('fonts-loading');
|
document.documentElement.classList.remove('fonts-loading');
|
||||||
@@ -27,4 +40,4 @@ export const initializeFonts = () => {
|
|||||||
document.documentElement.classList.add('fonts-fallback');
|
document.documentElement.classList.add('fonts-fallback');
|
||||||
}
|
}
|
||||||
}, 3000);
|
}, 3000);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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() {
|
export function register() {
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
@@ -14,6 +23,10 @@ export function register() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregisters the service worker
|
||||||
|
* Removes the currently registered service worker
|
||||||
|
*/
|
||||||
export function unregister() {
|
export function unregister() {
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.ready
|
navigator.serviceWorker.ready
|
||||||
|
|||||||
@@ -1,6 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Supabase Client Configuration
|
||||||
|
* Zentrale Konfiguration für die Supabase-Verbindung
|
||||||
|
*/
|
||||||
|
|
||||||
import { createClient } from '@supabase/supabase-js';
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
const supabaseUrl = 'https://mxadgucxhmstlzsbgmoz.supabase.co';
|
const supabaseUrl = 'https://mxadgucxhmstlzsbgmoz.supabase.co';
|
||||||
const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im14YWRndWN4aG1zdGx6c2JnbW96Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzkwMDgwNjIsImV4cCI6MjA1NDU4NDA2Mn0.MmFqEm7OfwzOlSegLYl9YWLCmIp8YajzK3Aozubn66Q';
|
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);
|
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
||||||
+17
-1
@@ -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';
|
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web Vitals metric data structure
|
||||||
|
*/
|
||||||
interface Metric {
|
interface Metric {
|
||||||
name: string;
|
name: string;
|
||||||
value: number;
|
value: number;
|
||||||
@@ -8,6 +16,9 @@ interface Metric {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send Web Vitals metric to Google Analytics and dispatch custom event
|
||||||
|
*/
|
||||||
function sendToAnalytics(metric: Metric) {
|
function sendToAnalytics(metric: Metric) {
|
||||||
const body = JSON.stringify({
|
const body = JSON.stringify({
|
||||||
name: metric.name,
|
name: metric.name,
|
||||||
@@ -45,6 +56,9 @@ function sendToAnalytics(metric: Metric) {
|
|||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize Web Vitals reporting for all Core Web Vitals metrics
|
||||||
|
*/
|
||||||
export function reportWebVitals() {
|
export function reportWebVitals() {
|
||||||
onCLS(sendToAnalytics);
|
onCLS(sendToAnalytics);
|
||||||
onINP(sendToAnalytics);
|
onINP(sendToAnalytics);
|
||||||
@@ -53,7 +67,9 @@ export function reportWebVitals() {
|
|||||||
onTTFB(sendToAnalytics);
|
onTTFB(sendToAnalytics);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get Web Vitals score
|
/**
|
||||||
|
* Calculate Web Vitals rating based on metric thresholds
|
||||||
|
*/
|
||||||
export function getWebVitalsScore(metric: Metric): string {
|
export function getWebVitalsScore(metric: Metric): string {
|
||||||
const thresholds = {
|
const thresholds = {
|
||||||
LCP: { good: 2500, poor: 4000 },
|
LCP: { good: 2500, poor: 4000 },
|
||||||
|
|||||||
Reference in New Issue
Block a user