diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6852914
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,33 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+# Database
+backend/data/
+*.db
+*.db-journal
+*.db-wal
+
+# Test files
+backend/__tests__/*.db
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..433e4c3
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,26 @@
+services:
+ frontend:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ ports:
+ - "5173:80"
+ depends_on:
+ - backend
+ environment:
+ - VITE_API_URL=http://localhost:3001
+
+ backend:
+ build:
+ context: ./backend
+ dockerfile: Dockerfile
+ ports:
+ - "3001:3001"
+ volumes:
+ - cv-data:/app/data
+ environment:
+ - PORT=3001
+ - DB_PATH=/app/data/cv.db
+
+volumes:
+ cv-data:
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..427d1af
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,89 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import { defineConfig, globalIgnores } from 'eslint/config'
+
+export default defineConfig([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{js,jsx}'],
+ extends: [
+ js.configs.recommended,
+ reactHooks.configs.flat.recommended,
+ reactRefresh.configs.vite,
+ ],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ parserOptions: {
+ ecmaVersion: 'latest',
+ ecmaFeatures: { jsx: true },
+ sourceType: 'module',
+ },
+ },
+ rules: {
+ 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]|^motion$', argsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_' }],
+ },
+ },
+ {
+ files: ['**/*.test.js', '**/__tests__/**/*.js'],
+ languageOptions: {
+ globals: {
+ ...globals.node,
+ describe: 'readonly',
+ it: 'readonly',
+ expect: 'readonly',
+ beforeEach: 'readonly',
+ afterEach: 'readonly',
+ beforeAll: 'readonly',
+ afterAll: 'readonly',
+ vi: 'readonly',
+ process: 'readonly',
+ },
+ },
+ },
+ {
+ files: ['backend/**/*.js'],
+ languageOptions: {
+ globals: {
+ ...globals.node,
+ process: 'readonly',
+ console: 'readonly',
+ __dirname: 'readonly',
+ __filename: 'readonly',
+ },
+ },
+ },
+ {
+ files: ['playwright.config.js', 'tests/e2e/**/*.js', 'tests/regression/visual.test.js'],
+ languageOptions: {
+ globals: {
+ ...globals.node,
+ process: 'readonly',
+ },
+ },
+ },
+ {
+ files: ['tests/performance/k6/**/*.js'],
+ languageOptions: {
+ globals: {
+ __ENV: 'readonly',
+ },
+ },
+ rules: {
+ 'no-undef': 'off',
+ },
+ },
+ {
+ files: ['tests/performance/lighthouserc.js'],
+ languageOptions: {
+ globals: {
+ module: 'readonly',
+ },
+ },
+ rules: {
+ 'no-undef': 'off',
+ },
+ },
+])
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..dd3edc3
--- /dev/null
+++ b/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ Tuan-Dat Tran | Junior DevOps Engineer
+
+
+
+
+
+
+
diff --git a/nginx.conf b/nginx.conf
new file mode 100644
index 0000000..cc4a0bc
--- /dev/null
+++ b/nginx.conf
@@ -0,0 +1,28 @@
+server {
+ resolver 127.0.0.11 valid=30s ipv6=off;
+
+ listen 80;
+ server_name localhost;
+
+ set $backend_upstream http://backend:3001;
+
+ root /usr/share/nginx/html;
+
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ location /cv {
+ alias /usr/share/nginx/html;
+ try_files $uri $uri/ /cv/index.html;
+ }
+
+ location /api {
+ proxy_pass $backend_upstream;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection 'upgrade';
+ proxy_set_header Host $host;
+ proxy_cache_bypass $http_upgrade;
+ }
+}
diff --git a/playwright.config.js b/playwright.config.js
new file mode 100644
index 0000000..247e274
--- /dev/null
+++ b/playwright.config.js
@@ -0,0 +1,26 @@
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './tests/e2e',
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ workers: process.env.CI ? 1 : undefined,
+ reporter: 'html',
+ use: {
+ baseURL: process.env.BASE_URL || 'http://localhost:5173',
+ trace: 'on-first-retry',
+ screenshot: 'only-on-failure',
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+ webServer: {
+ command: 'npm run dev',
+ url: 'http://localhost:5173',
+ reuseExistingServer: !process.env.CI,
+ },
+});
diff --git a/public/vite.svg b/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/App.jsx b/src/App.jsx
new file mode 100644
index 0000000..795b09f
--- /dev/null
+++ b/src/App.jsx
@@ -0,0 +1,51 @@
+import { BrowserRouter, Routes, Route } from 'react-router-dom';
+import Hero from './components/Hero';
+import Experience from './components/Experience';
+import Skills from './components/Skills';
+import Education from './components/Education';
+import Projects from './components/Projects';
+import Contact from './components/Contact';
+import AdminLayout from './admin/AdminLayout';
+import AdminPersonal from './admin/AdminPersonal';
+import AdminExperience from './admin/AdminExperience';
+import AdminSkills from './admin/AdminSkills';
+import AdminEducation from './admin/AdminEducation';
+import AdminProjects from './admin/AdminProjects';
+import { CVProvider } from './admin/hooks/CVContext';
+import { AuthProvider } from './admin/hooks/AuthContext';
+
+function CV() {
+ return (
+
+ );
+}
+
+function App() {
+ return (
+
+
+
+
+ } />
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+
+ );
+}
+
+export default App;
diff --git a/src/admin/AdminEducation.jsx b/src/admin/AdminEducation.jsx
new file mode 100644
index 0000000..0b26d5e
--- /dev/null
+++ b/src/admin/AdminEducation.jsx
@@ -0,0 +1,14 @@
+import EducationForm from './sections/EducationForm';
+import ExportButton from './components/ExportButton';
+import { useCVData } from './hooks/CVContext';
+
+export default function AdminEducation() {
+ const { data, updateEducation, exportJSON, importJSON, resetToDefault } = useCVData();
+
+ return (
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/admin/AdminExperience.jsx b/src/admin/AdminExperience.jsx
new file mode 100644
index 0000000..9feb8fd
--- /dev/null
+++ b/src/admin/AdminExperience.jsx
@@ -0,0 +1,14 @@
+import ExperienceForm from './sections/ExperienceForm';
+import ExportButton from './components/ExportButton';
+import { useCVData } from './hooks/CVContext';
+
+export default function AdminExperience() {
+ const { data, updateExperience, exportJSON, importJSON, resetToDefault } = useCVData();
+
+ return (
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/admin/AdminLayout.jsx b/src/admin/AdminLayout.jsx
new file mode 100644
index 0000000..c1a60ab
--- /dev/null
+++ b/src/admin/AdminLayout.jsx
@@ -0,0 +1,89 @@
+import { NavLink, Outlet } from 'react-router-dom';
+import { User, Briefcase, Code, GraduationCap, FolderOpen, Settings, LogOut } from 'lucide-react';
+import { useAuth } from './hooks/AuthContext';
+import LoginPage from './pages/LoginPage';
+
+const navItems = [
+ { to: '/admin', label: 'Persönlich', icon: User, end: true },
+ { to: '/admin/experience', label: 'Erfahrung', icon: Briefcase },
+ { to: '/admin/skills', label: 'Skills', icon: Code },
+ { to: '/admin/education', label: 'Ausbildung', icon: GraduationCap },
+ { to: '/admin/projects', label: 'Projekte', icon: FolderOpen },
+];
+
+export default function AdminLayout() {
+ const { isAuthenticated, loading, logout } = useAuth();
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!isAuthenticated) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/admin/AdminPersonal.jsx b/src/admin/AdminPersonal.jsx
new file mode 100644
index 0000000..b3dcbad
--- /dev/null
+++ b/src/admin/AdminPersonal.jsx
@@ -0,0 +1,14 @@
+import PersonalForm from './sections/PersonalForm';
+import ExportButton from './components/ExportButton';
+import { useCVData } from './hooks/CVContext';
+
+export default function AdminPersonal() {
+ const { data, updatePersonal, exportJSON, importJSON, resetToDefault } = useCVData();
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/admin/AdminProjects.jsx b/src/admin/AdminProjects.jsx
new file mode 100644
index 0000000..634bd8f
--- /dev/null
+++ b/src/admin/AdminProjects.jsx
@@ -0,0 +1,14 @@
+import ProjectsForm from './sections/ProjectsForm';
+import ExportButton from './components/ExportButton';
+import { useCVData } from './hooks/CVContext';
+
+export default function AdminProjects() {
+ const { data, updateProjects, exportJSON, importJSON, resetToDefault } = useCVData();
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/admin/AdminSkills.jsx b/src/admin/AdminSkills.jsx
new file mode 100644
index 0000000..4245124
--- /dev/null
+++ b/src/admin/AdminSkills.jsx
@@ -0,0 +1,14 @@
+import SkillsForm from './sections/SkillsForm';
+import ExportButton from './components/ExportButton';
+import { useCVData } from './hooks/CVContext';
+
+export default function AdminSkills() {
+ const { data, updateSkills, exportJSON, importJSON, resetToDefault } = useCVData();
+
+ return (
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/admin/components/ExportButton.jsx b/src/admin/components/ExportButton.jsx
new file mode 100644
index 0000000..06040d1
--- /dev/null
+++ b/src/admin/components/ExportButton.jsx
@@ -0,0 +1,104 @@
+import { Download, Upload, Eye, RotateCcw, FileJson } from 'lucide-react';
+import { useRef, useState } from 'react';
+
+export default function ExportButton({ onExport, onImport, onReset }) {
+ const fileInputRef = useRef(null);
+ const [showInstructions, setShowInstructions] = useState(false);
+
+ const handleImport = (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ const result = onImport(event.target.result);
+ if (result.success) {
+ alert('Import erfolgreich!');
+ } else {
+ alert(`Import fehlgeschlagen: ${result.error}`);
+ }
+ };
+ reader.readAsText(file);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ Vorschau
+
+
+ {onReset && (
+
+ )}
+
+
+
+
+ {showInstructions && (
+
+
+
+
+
Deployment Instructions
+
+ - JSON Datei wurde heruntergeladen
+ - Inhalt der
cv.json kopieren
+ - In
src/data/cv.js einfügen
+ - Committen und pushen:
git add . && git commit -m "update cv" && git push
+ - GitHub Actions deployt automatisch
+
+
+
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/src/admin/hooks/AuthContext.jsx b/src/admin/hooks/AuthContext.jsx
new file mode 100644
index 0000000..1a8627a
--- /dev/null
+++ b/src/admin/hooks/AuthContext.jsx
@@ -0,0 +1,61 @@
+import { createContext, useContext, useState, useEffect, useCallback } from 'react';
+import { getAuthConfig, login } from '../../lib/api';
+import { getToken, setToken, clearToken } from '../../lib/auth';
+
+const AuthContext = createContext(null);
+
+export function AuthProvider({ children }) {
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
+ const [authMode, setAuthMode] = useState(null);
+ const [keycloakConfig, setKeycloakConfig] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ getAuthConfig()
+ .then((config) => {
+ setAuthMode(config.mode);
+ setKeycloakConfig(config.keycloak);
+
+ if (config.mode === 'simple') {
+ const token = getToken();
+ setIsAuthenticated(!!token);
+ }
+ })
+ .catch(console.error)
+ .finally(() => setLoading(false));
+ }, []);
+
+ const simpleLogin = useCallback(async (password) => {
+ const result = await login(password);
+ setToken(result.token);
+ setIsAuthenticated(true);
+ return result;
+ }, []);
+
+ const logout = useCallback(() => {
+ clearToken();
+ setIsAuthenticated(false);
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+// eslint-disable-next-line react-refresh/only-export-components
+export function useAuth() {
+ const context = useContext(AuthContext);
+ if (!context) {
+ throw new Error('useAuth must be used within AuthProvider');
+ }
+ return context;
+}
diff --git a/src/admin/hooks/CVContext.jsx b/src/admin/hooks/CVContext.jsx
new file mode 100644
index 0000000..956b760
--- /dev/null
+++ b/src/admin/hooks/CVContext.jsx
@@ -0,0 +1,132 @@
+import { createContext, useContext, useState, useEffect, useCallback } from 'react';
+import { fetchCV as apiFetchCV, updateCV as apiUpdateCV, exportCV as apiExportCV, importCV as apiImportCV } from '../../lib/api';
+import { DEFAULT_DATA, validateCV } from '../../lib/cv-data';
+
+const CVContext = createContext(null);
+
+export function CVProvider({ children }) {
+ const [data, setData] = useState(DEFAULT_DATA);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ apiFetchCV()
+ .then(data => {
+ setData(data);
+ setLoading(false);
+ })
+ .catch(err => {
+ console.error('Failed to fetch CV:', err);
+ setError(err.message);
+ setLoading(false);
+ });
+ }, []);
+
+ const updatePersonal = useCallback(async (personal) => {
+ const newData = { ...data, personal };
+ setData(newData);
+ await apiUpdateCV(newData);
+ }, [data]);
+
+ const updateExperience = useCallback(async (experience) => {
+ const newData = { ...data, experience };
+ setData(newData);
+ await apiUpdateCV(newData);
+ }, [data]);
+
+ const updateSkills = useCallback(async (skills) => {
+ const newData = { ...data, skills };
+ setData(newData);
+ await apiUpdateCV(newData);
+ }, [data]);
+
+ const updateEducation = useCallback(async (education) => {
+ const newData = { ...data, education };
+ setData(newData);
+ await apiUpdateCV(newData);
+ }, [data]);
+
+ const updateProjects = useCallback(async (projects) => {
+ const newData = { ...data, projects };
+ setData(newData);
+ await apiUpdateCV(newData);
+ }, [data]);
+
+ const exportJSON = useCallback(async () => {
+ const validation = validateCV(data);
+ if (!validation.valid) {
+ console.warn('Exporting CV with validation warnings:', validation.errors);
+ }
+
+ const blob = await apiExportCV();
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'cv.json';
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ }, [data]);
+
+ const importJSON = useCallback(async (jsonString) => {
+ try {
+ const parsed = JSON.parse(jsonString);
+ const validation = validateCV(parsed);
+ if (!validation.valid) {
+ return { success: false, errors: validation.errors };
+ }
+ await apiImportCV(parsed);
+ setData(parsed);
+ return { success: true };
+ } catch (e) {
+ return { success: false, errors: [`JSON parse error: ${e.message}`] };
+ }
+ }, []);
+
+ const resetToDefault = useCallback(async () => {
+ await apiUpdateCV(DEFAULT_DATA);
+ setData(DEFAULT_DATA);
+ }, []);
+
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ try {
+ const data = await apiFetchCV();
+ setData(data);
+ setError(null);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+// eslint-disable-next-line react-refresh/only-export-components
+export function useCVData() {
+ const context = useContext(CVContext);
+ if (!context) {
+ throw new Error('useCVData must be used within a CVProvider');
+ }
+ return context;
+}
diff --git a/src/admin/hooks/__tests__/CVContext.test.jsx b/src/admin/hooks/__tests__/CVContext.test.jsx
new file mode 100644
index 0000000..16ab005
--- /dev/null
+++ b/src/admin/hooks/__tests__/CVContext.test.jsx
@@ -0,0 +1,89 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook, act, waitFor } from '@testing-library/react';
+import { CVProvider, useCVData } from '../CVContext';
+
+const mockFetch = vi.fn();
+const mockUpdateCV = vi.fn();
+const mockExportCV = vi.fn();
+const mockImportCV = vi.fn();
+
+vi.mock('../../../lib/api', () => ({
+ fetchCV: () => mockFetch(),
+ updateCV: (data) => mockUpdateCV(data),
+ exportCV: () => mockExportCV(),
+ importCV: (data) => mockImportCV(data),
+}));
+
+const defaultMockData = {
+ personal: { name: 'Test User', title: 'Developer', email: 'test@test.com' },
+ experience: [],
+ skills: {},
+ education: [],
+ projects: []
+};
+
+describe('CVContext', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockFetch.mockResolvedValue(defaultMockData);
+ mockUpdateCV.mockResolvedValue({ success: true });
+ mockExportCV.mockResolvedValue(new Blob([JSON.stringify(defaultMockData)], { type: 'application/json' }));
+ mockImportCV.mockResolvedValue({ success: true });
+ });
+
+ afterEach(() => {
+ vi.resetAllMocks();
+ });
+
+ const wrapper = ({ children }) => {children};
+
+ it('provides default data after loading', async () => {
+ const { result } = renderHook(() => useCVData(), { wrapper });
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ expect(result.current.data).toBeDefined();
+ expect(result.current.data.personal.name).toBe('Test User');
+ });
+
+ it('updates personal data', async () => {
+ const { result } = renderHook(() => useCVData(), { wrapper });
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ act(() => {
+ result.current.updatePersonal({
+ ...result.current.data.personal,
+ name: 'New Name'
+ });
+ });
+
+ await waitFor(() => {
+ expect(result.current.data.personal.name).toBe('New Name');
+ });
+ expect(mockUpdateCV).toHaveBeenCalled();
+ });
+
+ it('updates experience data', async () => {
+ const { result } = renderHook(() => useCVData(), { wrapper });
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ const newExperience = [{ id: 1, role: 'New Role' }];
+ act(() => {
+ result.current.updateExperience(newExperience);
+ });
+
+ await waitFor(() => {
+ expect(result.current.data.experience).toEqual(newExperience);
+ });
+ });
+
+ it('throws error when used outside CVProvider', () => {
+ expect(() => {
+ renderHook(() => useCVData());
+ }).toThrow('useCVData must be used within a CVProvider');
+ });
+});
diff --git a/src/admin/hooks/useCVData.js b/src/admin/hooks/useCVData.js
new file mode 100644
index 0000000..c8dfb4b
--- /dev/null
+++ b/src/admin/hooks/useCVData.js
@@ -0,0 +1,60 @@
+import { useState, useCallback } from 'react';
+import { cvData as initialData } from '../../data/cv';
+
+export function useCVData() {
+ const [data, setData] = useState(initialData);
+
+ const updatePersonal = useCallback((personal) => {
+ setData(prev => ({ ...prev, personal }));
+ }, []);
+
+ const updateExperience = useCallback((experience) => {
+ setData(prev => ({ ...prev, experience }));
+ }, []);
+
+ const updateSkills = useCallback((skills) => {
+ setData(prev => ({ ...prev, skills }));
+ }, []);
+
+ const updateEducation = useCallback((education) => {
+ setData(prev => ({ ...prev, education }));
+ }, []);
+
+ const updateProjects = useCallback((projects) => {
+ setData(prev => ({ ...prev, projects }));
+ }, []);
+
+ const exportJSON = useCallback(() => {
+ const json = JSON.stringify(data, null, 2);
+ const blob = new Blob([json], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'cv.json';
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ }, [data]);
+
+ const importJSON = useCallback((jsonString) => {
+ try {
+ const parsed = JSON.parse(jsonString);
+ setData(parsed);
+ return { success: true };
+ } catch (e) {
+ return { success: false, error: e.message };
+ }
+ }, []);
+
+ return {
+ data,
+ updatePersonal,
+ updateExperience,
+ updateSkills,
+ updateEducation,
+ updateProjects,
+ exportJSON,
+ importJSON
+ };
+}
diff --git a/src/admin/hooks/useFormValidation.js b/src/admin/hooks/useFormValidation.js
new file mode 100644
index 0000000..fb5db97
--- /dev/null
+++ b/src/admin/hooks/useFormValidation.js
@@ -0,0 +1,69 @@
+import { useState } from 'react';
+
+export function useFormValidation(rules) {
+ const [errors, setErrors] = useState({});
+
+ const validate = (data) => {
+ const newErrors = {};
+
+ Object.entries(rules).forEach(([field, fieldRules]) => {
+ for (const rule of fieldRules) {
+ const error = rule(data[field], data);
+ if (error) {
+ newErrors[field] = error;
+ break;
+ }
+ }
+ });
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const clearError = (field) => {
+ setErrors(prev => {
+ const next = { ...prev };
+ delete next[field];
+ return next;
+ });
+ };
+
+ const clearAllErrors = () => setErrors({});
+
+ return { errors, validate, clearError, clearAllErrors };
+}
+
+export const validators = {
+ required: (message = 'Pflichtfeld') => (value) => {
+ if (!value || (typeof value === 'string' && !value.trim())) {
+ return message;
+ }
+ return null;
+ },
+
+ email: (message = 'Ungültige E-Mail-Adresse') => (value) => {
+ if (!value) return null;
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(value)) {
+ return message;
+ }
+ return null;
+ },
+
+ url: (message = 'Ungültige URL') => (value) => {
+ if (!value) return null;
+ try {
+ new URL(value);
+ return null;
+ } catch {
+ return message;
+ }
+ },
+
+ minLength: (min, message) => (value) => {
+ if (!value || value.length < min) {
+ return message || `Mindestens ${min} Zeichen`;
+ }
+ return null;
+ }
+};
diff --git a/src/admin/pages/LoginPage.jsx b/src/admin/pages/LoginPage.jsx
new file mode 100644
index 0000000..22aed9c
--- /dev/null
+++ b/src/admin/pages/LoginPage.jsx
@@ -0,0 +1,95 @@
+import { useState } from 'react';
+import { Lock, AlertCircle } from 'lucide-react';
+import { useAuth } from '../hooks/AuthContext';
+
+export default function LoginPage() {
+ const { authMode, keycloakConfig, login } = useAuth();
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setError('');
+ setLoading(true);
+
+ try {
+ await login(password);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleKeycloakLogin = () => {
+ if (!keycloakConfig?.url || !keycloakConfig?.realm || !keycloakConfig?.clientId) {
+ setError('Keycloak not configured');
+ return;
+ }
+ const redirectUri = encodeURIComponent(window.location.origin + '/admin/callback');
+ const loginUrl = `${keycloakConfig.url}/realms/${keycloakConfig.realm}/protocol/openid-connect/auth?client_id=${keycloakConfig.clientId}&redirect_uri=${redirectUri}&response_type=code`;
+ window.location.href = loginUrl;
+ };
+
+ if (authMode === 'keycloak') {
+ return (
+
+
+
+
+
Admin Login
+
Authenticating with Keycloak
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
Admin Login
+
Enter the admin password to continue
+
+
+ {error && (
+
+ )}
+
+
+
+
+ Password shown in server console on startup
+
+
+
+ );
+}
diff --git a/src/admin/sections/EducationForm.jsx b/src/admin/sections/EducationForm.jsx
new file mode 100644
index 0000000..b080f44
--- /dev/null
+++ b/src/admin/sections/EducationForm.jsx
@@ -0,0 +1,167 @@
+import { useState } from 'react';
+import { Plus, Edit2, Trash2, X, Check } from 'lucide-react';
+
+export default function EducationForm({ data, onUpdate }) {
+ const [editingId, setEditingId] = useState(null);
+ const [editForm, setEditForm] = useState(null);
+
+ const defaultForm = {
+ degree: '',
+ institution: '',
+ period: '',
+ status: ''
+ };
+
+ const startAdd = () => {
+ setEditingId('new');
+ setEditForm({ ...defaultForm, id: Date.now() });
+ };
+
+ const startEdit = (edu) => {
+ setEditingId(edu.id);
+ setEditForm({ ...edu });
+ };
+
+ const cancelEdit = () => {
+ setEditingId(null);
+ setEditForm(null);
+ };
+
+ const saveEdit = () => {
+ if (!editForm.degree || !editForm.institution) return;
+
+ if (editingId === 'new') {
+ onUpdate([...data, editForm]);
+ } else {
+ onUpdate(data.map(e => e.id === editingId ? editForm : e));
+ }
+ cancelEdit();
+ };
+
+ const deleteItem = (id) => {
+ onUpdate(data.filter(e => e.id !== id));
+ };
+
+ const handleChange = (e) => {
+ const { name, value } = e.target;
+ setEditForm(prev => ({ ...prev, [name]: value }));
+ };
+
+ return (
+
+
+
Ausbildung
+ {editingId !== 'new' && (
+
+ )}
+
+
+ {editingId === 'new' && editForm && (
+
+
+
+ )}
+
+
+ {data.map((edu) => (
+
+ {editingId === edu.id ? (
+
+
+
+ ) : (
+
+
+
{edu.degree}
+
{edu.institution} • {edu.period}
+ {edu.status && (
+
+ {edu.status}
+
+ )}
+
+
+
+
+
+
+ )}
+
+ ))}
+
+
+ );
+}
+
+function FormFields({ form, onChange, onSave, onCancel }) {
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/admin/sections/ExperienceForm.jsx b/src/admin/sections/ExperienceForm.jsx
new file mode 100644
index 0000000..c010939
--- /dev/null
+++ b/src/admin/sections/ExperienceForm.jsx
@@ -0,0 +1,248 @@
+import { useState } from 'react';
+import { Plus, Edit2, Trash2, X, Check } from 'lucide-react';
+
+export default function ExperienceForm({ data, onUpdate }) {
+ const [editingId, setEditingId] = useState(null);
+ const [editForm, setEditForm] = useState(null);
+
+ const defaultForm = {
+ role: '',
+ company: '',
+ period: '',
+ type: 'Vollzeit',
+ description: '',
+ highlights: []
+ };
+
+ const startAdd = () => {
+ setEditingId('new');
+ setEditForm({ ...defaultForm, id: Date.now() });
+ };
+
+ const startEdit = (exp) => {
+ setEditingId(exp.id);
+ setEditForm({ ...exp });
+ };
+
+ const cancelEdit = () => {
+ setEditingId(null);
+ setEditForm(null);
+ };
+
+ const saveEdit = () => {
+ if (!editForm.role || !editForm.company) return;
+
+ if (editingId === 'new') {
+ onUpdate([...data, editForm]);
+ } else {
+ onUpdate(data.map(e => e.id === editingId ? editForm : e));
+ }
+ cancelEdit();
+ };
+
+ const deleteItem = (id) => {
+ onUpdate(data.filter(e => e.id !== id));
+ };
+
+ const addHighlight = () => {
+ setEditForm(prev => ({
+ ...prev,
+ highlights: [...(prev.highlights || []), '']
+ }));
+ };
+
+ const updateHighlight = (index, value) => {
+ setEditForm(prev => ({
+ ...prev,
+ highlights: prev.highlights.map((h, i) => i === index ? value : h)
+ }));
+ };
+
+ const removeHighlight = (index) => {
+ setEditForm(prev => ({
+ ...prev,
+ highlights: prev.highlights.filter((_, i) => i !== index)
+ }));
+ };
+
+ return (
+
+
+
Berufserfahrung
+ {editingId !== 'new' && (
+
+ )}
+
+
+ {editingId === 'new' && editForm && (
+
+
+
+ )}
+
+
+ {data.map((exp) => (
+
+ {editingId === exp.id ? (
+
+
+
+ ) : (
+
+
+
{exp.role}
+
{exp.company} • {exp.period}
+
+ {exp.type}
+
+
+
+
+
+
+
+ )}
+
+ ))}
+
+
+ );
+}
+
+function EditForm({ form, setForm, onSave, onCancel, onAddHighlight, onUpdateHighlight, onRemoveHighlight }) {
+ const handleChange = (e) => {
+ const { name, value } = e.target;
+ setForm(prev => ({ ...prev, [name]: value }));
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {(form.highlights || []).map((h, i) => (
+
+ onUpdateHighlight(i, e.target.value)}
+ placeholder="Highlight..."
+ className="flex-1 px-3 py-2 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500 text-sm"
+ />
+
+
+ ))}
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/admin/sections/PersonalForm.jsx b/src/admin/sections/PersonalForm.jsx
new file mode 100644
index 0000000..136372f
--- /dev/null
+++ b/src/admin/sections/PersonalForm.jsx
@@ -0,0 +1,169 @@
+import { useState, useEffect } from 'react';
+import { useFormValidation, validators } from '../hooks/useFormValidation';
+import { Save, RotateCcw, Check } from 'lucide-react';
+
+export default function PersonalForm({ data, onUpdate }) {
+ const [formData, setFormData] = useState(data);
+ const [saved, setSaved] = useState(false);
+ const { errors, validate, clearError } = useFormValidation({
+ name: [validators.required('Name ist erforderlich')],
+ title: [validators.required('Titel ist erforderlich')],
+ email: [validators.required('E-Mail ist erforderlich'), validators.email()],
+ github: [validators.url()],
+ linkedin: [validators.url()]
+ });
+
+ useEffect(() => {
+ setFormData(data);
+ }, [data]);
+
+ const handleChange = (e) => {
+ const { name, value } = e.target;
+ setFormData(prev => ({ ...prev, [name]: value }));
+ clearError(name);
+ };
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ if (validate(formData)) {
+ onUpdate(formData);
+ setSaved(true);
+ setTimeout(() => setSaved(false), 2000);
+ }
+ };
+
+ const handleReset = () => {
+ setFormData(data);
+ };
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/admin/sections/ProjectsForm.jsx b/src/admin/sections/ProjectsForm.jsx
new file mode 100644
index 0000000..369b2ad
--- /dev/null
+++ b/src/admin/sections/ProjectsForm.jsx
@@ -0,0 +1,227 @@
+import { useState } from 'react';
+import { Plus, Edit2, Trash2, X, Check } from 'lucide-react';
+
+export default function ProjectsForm({ data, onUpdate }) {
+ const [editingId, setEditingId] = useState(null);
+ const [editForm, setEditForm] = useState(null);
+
+ const defaultForm = {
+ name: '',
+ description: '',
+ url: '',
+ tech: []
+ };
+
+ const startAdd = () => {
+ setEditingId('new');
+ setEditForm({ ...defaultForm, id: Date.now() });
+ };
+
+ const startEdit = (project) => {
+ setEditingId(project.id);
+ setEditForm({ ...project });
+ };
+
+ const cancelEdit = () => {
+ setEditingId(null);
+ setEditForm(null);
+ };
+
+ const saveEdit = () => {
+ if (!editForm.name) return;
+
+ if (editingId === 'new') {
+ onUpdate([...data, editForm]);
+ } else {
+ onUpdate(data.map(p => p.id === editingId ? editForm : p));
+ }
+ cancelEdit();
+ };
+
+ const deleteItem = (id) => {
+ onUpdate(data.filter(p => p.id !== id));
+ };
+
+ const handleChange = (e) => {
+ const { name, value } = e.target;
+ setEditForm(prev => ({ ...prev, [name]: value }));
+ };
+
+ const addTech = () => {
+ setEditForm(prev => ({
+ ...prev,
+ tech: [...(prev.tech || []), '']
+ }));
+ };
+
+ const updateTech = (index, value) => {
+ setEditForm(prev => ({
+ ...prev,
+ tech: prev.tech.map((t, i) => i === index ? value : t)
+ }));
+ };
+
+ const removeTech = (index) => {
+ setEditForm(prev => ({
+ ...prev,
+ tech: prev.tech.filter((_, i) => i !== index)
+ }));
+ };
+
+ return (
+
+
+
Projekte
+ {editingId !== 'new' && (
+
+ )}
+
+
+ {editingId === 'new' && editForm && (
+
+
+
+ )}
+
+
+ {data.map((project) => (
+
+ {editingId === project.id ? (
+
+
+
+ ) : (
+
+
+
{project.name}
+
{project.description}
+
+ {(project.tech || []).map((t, i) => (
+
+ {t}
+
+ ))}
+
+
+
+
+
+
+
+ )}
+
+ ))}
+
+
+ );
+}
+
+function FormFields({ form, onChange, onSave, onCancel, onAddTech, onUpdateTech, onRemoveTech }) {
+ return (
+
+
+
+
+
+
+
+
+ {(form.tech || []).map((t, i) => (
+
+ onUpdateTech(i, e.target.value)}
+ className="w-24 px-2 py-1 text-sm rounded border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500"
+ />
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/admin/sections/SkillsForm.jsx b/src/admin/sections/SkillsForm.jsx
new file mode 100644
index 0000000..61724be
--- /dev/null
+++ b/src/admin/sections/SkillsForm.jsx
@@ -0,0 +1,160 @@
+import { useState, useEffect } from 'react';
+import { Plus, X } from 'lucide-react';
+
+function CategoryHeader({ category, onRename, onDelete }) {
+ const [localValue, setLocalValue] = useState(category);
+
+ useEffect(() => {
+ setLocalValue(category);
+ }, [category]);
+
+ const handleBlur = () => {
+ onRename(category, localValue);
+ };
+
+ const handleKeyDown = (e) => {
+ if (e.key === 'Enter') {
+ e.target.blur();
+ }
+ };
+
+ return (
+
+ setLocalValue(e.target.value)}
+ onBlur={handleBlur}
+ onKeyDown={handleKeyDown}
+ className="flex-1 font-medium text-slate-900 bg-transparent border-none focus:outline-none focus:ring-0 mr-2"
+ />
+
+
+ );
+}
+
+export default function SkillsForm({ data, onUpdate }) {
+ const [newCategory, setNewCategory] = useState('');
+ const [newSkill, setNewSkill] = useState('');
+ const [selectedCategory, setSelectedCategory] = useState(null);
+
+ const addCategory = () => {
+ if (!newCategory.trim()) return;
+ onUpdate({ ...data, [newCategory.trim()]: [] });
+ setNewCategory('');
+ };
+
+ const deleteCategory = (category) => {
+ const updated = { ...data };
+ delete updated[category];
+ onUpdate(updated);
+ };
+
+ const addSkill = (category) => {
+ if (!newSkill.trim()) return;
+ onUpdate({
+ ...data,
+ [category]: [...(data[category] || []), newSkill.trim()]
+ });
+ setNewSkill('');
+ };
+
+ const deleteSkill = (category, skillIndex) => {
+ onUpdate({
+ ...data,
+ [category]: data[category].filter((_, i) => i !== skillIndex)
+ });
+ };
+
+ const renameCategory = (oldName, newName) => {
+ if (!newName.trim() || oldName === newName) return;
+ const updated = { ...data };
+ updated[newName] = updated[oldName];
+ delete updated[oldName];
+ onUpdate(updated);
+ };
+
+ return (
+
+
Skills
+
+
+
+
+
setNewCategory(e.target.value)}
+ placeholder="z.B. DevOps, Frontend, Backend..."
+ className="flex-1 px-3 py-2 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500"
+ onKeyPress={(e) => e.key === 'Enter' && addCategory()}
+ />
+
+
+
+
+
+ {Object.entries(data).map(([category, skills], index) => (
+
+
deleteCategory(category)}
+ />
+
+
+ {(skills || []).map((skill, i) => (
+
+ {skill}
+
+
+ ))}
+
+
+
+
{
+ setSelectedCategory(category);
+ setNewSkill(e.target.value);
+ }}
+ placeholder="Skill hinzufügen..."
+ className="flex-1 px-3 py-1.5 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500 text-sm"
+ onKeyPress={(e) => {
+ if (e.key === 'Enter') {
+ addSkill(category);
+ }
+ }}
+ />
+
+
+
+ ))}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/assets/react.svg b/src/assets/react.svg
new file mode 100644
index 0000000..6c87de9
--- /dev/null
+++ b/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/components/Contact.jsx b/src/components/Contact.jsx
new file mode 100644
index 0000000..429718a
--- /dev/null
+++ b/src/components/Contact.jsx
@@ -0,0 +1,61 @@
+import { motion } from 'framer-motion';
+import { Mail, Github, Linkedin, Heart } from 'lucide-react';
+import { useCVData } from '../admin/hooks/CVContext';
+
+export default function Contact() {
+ const { data } = useCVData();
+ const { personal } = data;
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/Education.jsx b/src/components/Education.jsx
new file mode 100644
index 0000000..fe9267c
--- /dev/null
+++ b/src/components/Education.jsx
@@ -0,0 +1,53 @@
+import { motion } from 'framer-motion';
+import { GraduationCap, Calendar } from 'lucide-react';
+import { useCVData } from '../admin/hooks/CVContext';
+
+export default function Education() {
+ const { data } = useCVData();
+ const { education } = data;
+
+ return (
+
+
+
+ Ausbildung
+
+
+
+ {education.map((edu, index) => (
+
+
+
+
+
+
+
{edu.degree}
+
{edu.institution}
+
+
+ {edu.period}
+
+
+ {edu.status}
+
+
+
+
+ ))}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/Experience.jsx b/src/components/Experience.jsx
new file mode 100644
index 0000000..9b10d98
--- /dev/null
+++ b/src/components/Experience.jsx
@@ -0,0 +1,72 @@
+import { motion } from 'framer-motion';
+import { Briefcase, Calendar } from 'lucide-react';
+import { useCVData } from '../admin/hooks/CVContext';
+
+export default function Experience() {
+ const { data } = useCVData();
+ const { experience } = data;
+
+ return (
+
+
+
+ Berufserfahrung
+
+
+
+ {experience.map((exp, index) => (
+
+
+ {index !== experience.length - 1 && (
+
+ )}
+
+
+
+
{exp.role}
+
+ {exp.type}
+
+
+
+
+
+
+ {exp.company}
+
+
+
+ {exp.period}
+
+
+
+
{exp.description}
+
+
+ {exp.highlights.map((highlight, i) => (
+ -
+ •
+ {highlight}
+
+ ))}
+
+
+
+ ))}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/Hero.jsx b/src/components/Hero.jsx
new file mode 100644
index 0000000..6f7b7b1
--- /dev/null
+++ b/src/components/Hero.jsx
@@ -0,0 +1,73 @@
+import { motion } from 'framer-motion';
+import { Github, Linkedin, Mail, MapPin } from 'lucide-react';
+import { useCVData } from '../admin/hooks/CVContext';
+
+export default function Hero() {
+ const { data } = useCVData();
+ const { personal } = data;
+
+ return (
+
+
+
+ {personal.name.split(' ').map(n => n[0]).join('')}
+
+
+
+ {personal.name}
+
+
+
+ {personal.title}
+
+
+
+
+ {personal.location}
+
+
+
+ {personal.intro}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/Projects.jsx b/src/components/Projects.jsx
new file mode 100644
index 0000000..8ceccb8
--- /dev/null
+++ b/src/components/Projects.jsx
@@ -0,0 +1,57 @@
+import { motion } from 'framer-motion';
+import { ExternalLink } from 'lucide-react';
+import { useCVData } from '../admin/hooks/CVContext';
+
+export default function Projects() {
+ const { data } = useCVData();
+ const { projects } = data;
+
+ return (
+
+
+
+ Projekte
+
+
+
+ {projects.map((project, index) => (
+
+
+
+ {project.name}
+
+
+
+
+ {project.description}
+
+
+ {project.tech.map((t, i) => (
+
+ {t}
+
+ ))}
+
+
+ ))}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/Skills.jsx b/src/components/Skills.jsx
new file mode 100644
index 0000000..a98626d
--- /dev/null
+++ b/src/components/Skills.jsx
@@ -0,0 +1,72 @@
+import { motion } from 'framer-motion';
+import { useCVData } from '../admin/hooks/CVContext';
+
+const skillIcons = {
+ "Docker": "🐳",
+ "Kubernetes": "☸️",
+ "Helm": "⛵",
+ "Jenkins": "🔧",
+ "Bitbucket": "📘",
+ "ArgoCD": "🔄",
+ "Git": "📝",
+ "Azure": "☁️",
+ "Azure Resource Manager": "🏗️",
+ "Release Engineering": "🚀",
+ "Linux Administration": "🐧",
+ "Python": "🐍",
+ "Bash": "💻",
+ "Rust": "🦀"
+};
+
+export default function Skills() {
+ const { data } = useCVData();
+ const { skills } = data;
+
+ return (
+
+
+
+ Skills
+
+
+
+ {Object.entries(skills).map(([category, items], catIndex) => (
+
+
+ {category}
+
+
+ {items.map((skill, i) => (
+
+ {skillIcons[skill] && {skillIcons[skill]}}
+ {skill}
+
+ ))}
+
+
+ ))}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/data/cv.js b/src/data/cv.js
new file mode 100644
index 0000000..3f838ae
--- /dev/null
+++ b/src/data/cv.js
@@ -0,0 +1,3 @@
+import { DEFAULT_DATA } from './lib/cv-data.js';
+
+export const cvData = DEFAULT_DATA;
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..2131fd7
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1,26 @@
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
+@import "tailwindcss";
+
+@theme {
+ --color-primary-50: #eef2ff;
+ --color-primary-100: #e0e7ff;
+ --color-primary-200: #c7d2fe;
+ --color-primary-300: #a5b4fc;
+ --color-primary-400: #818cf8;
+ --color-primary-500: #6366f1;
+ --color-primary-600: #4f46e5;
+ --color-primary-700: #4338ca;
+ --color-primary-800: #3730a3;
+ --color-primary-900: #312e81;
+
+ --font-sans: 'Inter', system-ui, sans-serif;
+}
+
+html {
+ scroll-behavior: smooth;
+}
+
+body {
+ font-family: var(--font-sans);
+ margin: 0;
+}
diff --git a/src/lib/__tests__/cv-data.test.js b/src/lib/__tests__/cv-data.test.js
new file mode 100644
index 0000000..6057b80
--- /dev/null
+++ b/src/lib/__tests__/cv-data.test.js
@@ -0,0 +1,168 @@
+import { describe, it, expect } from 'vitest';
+import {
+ validateCV,
+ mergeCVData,
+ exportToJSON,
+ importFromJSON,
+ DEFAULT_DATA
+} from '../cv-data';
+
+describe('validateCV', () => {
+ it('validates correct CV data', () => {
+ const result = validateCV(DEFAULT_DATA);
+ expect(result.valid).toBe(true);
+ expect(result.errors).toHaveLength(0);
+ });
+
+ it('requires personal section', () => {
+ const result = validateCV({});
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContain('personal is required');
+ });
+
+ it('requires personal.name', () => {
+ const result = validateCV({
+ personal: { title: 'Developer', email: 'test@example.com' }
+ });
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContain('personal.name is required');
+ });
+
+ it('requires personal.title', () => {
+ const result = validateCV({
+ personal: { name: 'Test', email: 'test@example.com' }
+ });
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContain('personal.title is required');
+ });
+
+ it('requires personal.email', () => {
+ const result = validateCV({
+ personal: { name: 'Test', title: 'Developer' }
+ });
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContain('personal.email is required');
+ });
+
+ it('requires experience to be array', () => {
+ const result = validateCV({
+ personal: { name: 'Test', title: 'Dev', email: 'test@test.com' },
+ experience: 'not an array'
+ });
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContain('experience must be an array');
+ });
+
+ it('requires skills to be object', () => {
+ const result = validateCV({
+ personal: { name: 'Test', title: 'Dev', email: 'test@test.com' },
+ experience: [],
+ skills: 'not an object'
+ });
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContain('skills must be an object');
+ });
+
+ it('requires education to be array', () => {
+ const result = validateCV({
+ personal: { name: 'Test', title: 'Dev', email: 'test@test.com' },
+ experience: [],
+ skills: {},
+ education: 'not an array'
+ });
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContain('education must be an array');
+ });
+
+ it('requires projects to be array', () => {
+ const result = validateCV({
+ personal: { name: 'Test', title: 'Dev', email: 'test@test.com' },
+ experience: [],
+ skills: {},
+ education: [],
+ projects: 'not an array'
+ });
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContain('projects must be an array');
+ });
+});
+
+describe('mergeCVData', () => {
+ it('merges personal data', () => {
+ const base = { personal: { name: 'Old', title: 'Dev' } };
+ const updates = { personal: { name: 'New' } };
+ const result = mergeCVData(base, updates);
+ expect(result.personal.name).toBe('New');
+ expect(result.personal.title).toBe('Dev');
+ });
+
+ it('merges top-level fields', () => {
+ const base = { experience: [], skills: {} };
+ const updates = { experience: [{ id: 1 }] };
+ const result = mergeCVData(base, updates);
+ expect(result.experience).toHaveLength(1);
+ expect(result.skills).toEqual({});
+ });
+});
+
+describe('exportToJSON', () => {
+ it('exports valid JSON string', () => {
+ const data = { personal: { name: 'Test' } };
+ const json = exportToJSON(data);
+ expect(typeof json).toBe('string');
+ expect(JSON.parse(json)).toEqual(data);
+ });
+
+ it('formats JSON with indentation', () => {
+ const data = { personal: { name: 'Test' } };
+ const json = exportToJSON(data);
+ expect(json).toContain('\n');
+ expect(json).toContain(' ');
+ });
+});
+
+describe('importFromJSON', () => {
+ it('imports valid JSON', () => {
+ const json = JSON.stringify({
+ personal: { name: 'Test', title: 'Dev', email: 'test@test.com' },
+ experience: [],
+ skills: {},
+ education: [],
+ projects: []
+ });
+ const result = importFromJSON(json);
+ expect(result.success).toBe(true);
+ expect(result.data.personal.name).toBe('Test');
+ });
+
+ it('fails on invalid JSON', () => {
+ const result = importFromJSON('not valid json');
+ expect(result.success).toBe(false);
+ expect(result.errors).toBeDefined();
+ expect(result.errors[0]).toContain('JSON parse error');
+ });
+
+ it('fails on invalid CV structure', () => {
+ const json = JSON.stringify({ personal: {} });
+ const result = importFromJSON(json);
+ expect(result.success).toBe(false);
+ expect(result.errors).toContain('personal.name is required');
+ });
+});
+
+describe('DEFAULT_DATA', () => {
+ it('is valid', () => {
+ const result = validateCV(DEFAULT_DATA);
+ expect(result.valid).toBe(true);
+ });
+
+ it('has all required fields', () => {
+ expect(DEFAULT_DATA.personal.name).toBeDefined();
+ expect(DEFAULT_DATA.personal.title).toBeDefined();
+ expect(DEFAULT_DATA.personal.email).toBeDefined();
+ expect(Array.isArray(DEFAULT_DATA.experience)).toBe(true);
+ expect(typeof DEFAULT_DATA.skills).toBe('object');
+ expect(Array.isArray(DEFAULT_DATA.education)).toBe(true);
+ expect(Array.isArray(DEFAULT_DATA.projects)).toBe(true);
+ });
+});
diff --git a/src/lib/api.js b/src/lib/api.js
new file mode 100644
index 0000000..48ab522
--- /dev/null
+++ b/src/lib/api.js
@@ -0,0 +1,71 @@
+const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
+import { authHeaders } from './auth';
+
+export async function fetchCV() {
+ const response = await fetch(`${API_URL}/api/cv`);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch CV: ${response.statusText}`);
+ }
+ return response.json();
+}
+
+export async function updateCV(data) {
+ const response = await fetch(`${API_URL}/api/cv`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...authHeaders(),
+ },
+ body: JSON.stringify(data),
+ });
+ if (!response.ok) {
+ throw new Error(`Failed to update CV: ${response.statusText}`);
+ }
+ return response.json();
+}
+
+export async function exportCV() {
+ const response = await fetch(`${API_URL}/api/cv/export`);
+ if (!response.ok) {
+ throw new Error(`Failed to export CV: ${response.statusText}`);
+ }
+ return response.blob();
+}
+
+export async function importCV(data) {
+ const response = await fetch(`${API_URL}/api/cv/import`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...authHeaders(),
+ },
+ body: JSON.stringify(data),
+ });
+ if (!response.ok) {
+ throw new Error(`Failed to import CV: ${response.statusText}`);
+ }
+ return response.json();
+}
+
+export async function getAuthConfig() {
+ const response = await fetch(`${API_URL}/api/auth/config`);
+ if (!response.ok) {
+ throw new Error(`Failed to get auth config: ${response.statusText}`);
+ }
+ return response.json();
+}
+
+export async function login(password) {
+ const response = await fetch(`${API_URL}/api/auth/login`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ password }),
+ });
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error || 'Login failed');
+ }
+ return response.json();
+}
diff --git a/src/lib/auth.js b/src/lib/auth.js
new file mode 100644
index 0000000..ce19c8b
--- /dev/null
+++ b/src/lib/auth.js
@@ -0,0 +1,18 @@
+const AUTH_TOKEN_KEY = 'cv_admin_token';
+
+export function getToken() {
+ return localStorage.getItem(AUTH_TOKEN_KEY);
+}
+
+export function setToken(token) {
+ localStorage.setItem(AUTH_TOKEN_KEY, token);
+}
+
+export function clearToken() {
+ localStorage.removeItem(AUTH_TOKEN_KEY);
+}
+
+export function authHeaders() {
+ const token = getToken();
+ return token ? { Authorization: `Bearer ${token}` } : {};
+}
diff --git a/src/lib/cv-data.js b/src/lib/cv-data.js
new file mode 100644
index 0000000..ed8d45a
--- /dev/null
+++ b/src/lib/cv-data.js
@@ -0,0 +1,74 @@
+import defaultData from './cv.json';
+
+export const DEFAULT_DATA = defaultData;
+
+export function validateCV(data) {
+ const errors = [];
+
+ if (!data.personal) {
+ errors.push('personal is required');
+ } else {
+ if (!data.personal.name?.trim()) errors.push('personal.name is required');
+ if (!data.personal.title?.trim()) errors.push('personal.title is required');
+ if (!data.personal.email?.trim()) errors.push('personal.email is required');
+ }
+
+ if (!Array.isArray(data.experience)) {
+ errors.push('experience must be an array');
+ }
+
+ if (!data.skills || typeof data.skills !== 'object') {
+ errors.push('skills must be an object');
+ }
+
+ if (!Array.isArray(data.education)) {
+ errors.push('education must be an array');
+ }
+
+ if (!Array.isArray(data.projects)) {
+ errors.push('projects must be an array');
+ }
+
+ return {
+ valid: errors.length === 0,
+ errors
+ };
+}
+
+export function mergeCVData(base, updates) {
+ return {
+ ...base,
+ ...updates,
+ personal: { ...base.personal, ...updates.personal }
+ };
+}
+
+export function exportToJSON(data) {
+ return JSON.stringify(data, null, 2);
+}
+
+export function importFromJSON(jsonString) {
+ try {
+ const parsed = JSON.parse(jsonString);
+ const validation = validateCV(parsed);
+ if (!validation.valid) {
+ return { success: false, errors: validation.errors };
+ }
+ return { success: true, data: parsed };
+ } catch (e) {
+ return { success: false, errors: [`JSON parse error: ${e.message}`] };
+ }
+}
+
+export function downloadJSON(data, filename = 'cv.json') {
+ const json = exportToJSON(data);
+ const blob = new Blob([json], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+}
diff --git a/src/lib/cv.json b/src/lib/cv.json
new file mode 100644
index 0000000..701089b
--- /dev/null
+++ b/src/lib/cv.json
@@ -0,0 +1,73 @@
+{
+ "personal": {
+ "name": "Tuan-Dat Tran",
+ "title": "Junior DevOps Engineer",
+ "intro": "Passionierter DevOps Engineer mit Fokus auf Cloud-Infrastruktur, Container-Orchestrierung und automatisierte Deployment-Pipelines.",
+ "email": "tuan-dat.tran@example.com",
+ "github": "https://github.com/tuan-dat-tran",
+ "linkedin": "https://linkedin.com/in/tuan-dat-tran",
+ "location": "Deutschland"
+ },
+ "experience": [
+ {
+ "id": 1,
+ "role": "Junior DevOps Engineer",
+ "company": "Unternehmen",
+ "period": "Seit 2025",
+ "type": "Vollzeit",
+ "description": "Verantwortlich für CI/CD Pipelines, Container-Orchestrierung und Cloud-Infrastruktur auf Azure.",
+ "highlights": [
+ "Automatisierte Deployments mit Jenkins und Bitbucket Pipelines",
+ "Kubernetes Cluster Management mit Helm und ArgoCD",
+ "Infrastructure as Code mit Azure Resource Manager"
+ ]
+ },
+ {
+ "id": 2,
+ "role": "Software Entwickler",
+ "company": "Verschiedene Unternehmen",
+ "period": "2021 - 2025",
+ "type": "Teilzeit / Werkstudent / SHK",
+ "description": "Entwicklung und Maintenance von Softwarelösungen in verschiedenen Rollen.",
+ "highlights": [
+ "Backend-Entwicklung mit Python",
+ "Linux System Administration",
+ "Git Version Control und Release Engineering"
+ ]
+ }
+ ],
+ "skills": {
+ "Container & Orchestration": ["Docker", "Kubernetes", "Helm"],
+ "CI/CD & GitOps": ["Jenkins", "Bitbucket", "ArgoCD", "Git"],
+ "Cloud": ["Azure"],
+ "Infrastructure as Code": ["Azure Resource Manager"],
+ "Release Engineering": ["Release Engineering"],
+ "Operating Systems": ["Linux Administration"],
+ "Programming": ["Python", "Bash", "Rust"]
+ },
+ "education": [
+ {
+ "id": 1,
+ "degree": "Bachelor Informatik",
+ "institution": "Universität",
+ "period": "Laufend",
+ "status": "Bachelorarbeit offen"
+ }
+ ],
+ "projects": [
+ {
+ "id": 1,
+ "name": "Projekt 1",
+ "description": "Beschreibung folgt",
+ "url": "#",
+ "tech": ["Docker", "Kubernetes", "ArgoCD"]
+ },
+ {
+ "id": 2,
+ "name": "Projekt 2",
+ "description": "Beschreibung folgt",
+ "url": "#",
+ "tech": ["Azure", "ARM", "Jenkins"]
+ }
+ ]
+}
diff --git a/src/main.jsx b/src/main.jsx
new file mode 100644
index 0000000..b9a1a6d
--- /dev/null
+++ b/src/main.jsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App.jsx'
+
+createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
diff --git a/src/test/setup.js b/src/test/setup.js
new file mode 100644
index 0000000..bb02c60
--- /dev/null
+++ b/src/test/setup.js
@@ -0,0 +1 @@
+import '@testing-library/jest-dom/vitest';
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 0000000..5ebdfa3
--- /dev/null
+++ b/vite.config.js
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import tailwindcss from '@tailwindcss/vite'
+
+export default defineConfig(({ mode }) => ({
+ plugins: [react(), tailwindcss()],
+ base: mode === 'production' ? '/cv/' : '/',
+}))
\ No newline at end of file