From 7f06ee7f53bcf693a52903ca3b89dba022798c0e Mon Sep 17 00:00:00 2001 From: Tuan-Dat Tran Date: Mon, 23 Feb 2026 13:47:08 +0100 Subject: [PATCH] feat(ui): add cv application frontend and configuration --- .gitignore | 33 +++ docker-compose.yml | 26 ++ eslint.config.js | 89 +++++++ index.html | 14 ++ nginx.conf | 28 +++ playwright.config.js | 26 ++ public/vite.svg | 1 + src/App.jsx | 51 ++++ src/admin/AdminEducation.jsx | 14 ++ src/admin/AdminExperience.jsx | 14 ++ src/admin/AdminLayout.jsx | 89 +++++++ src/admin/AdminPersonal.jsx | 14 ++ src/admin/AdminProjects.jsx | 14 ++ src/admin/AdminSkills.jsx | 14 ++ src/admin/components/ExportButton.jsx | 104 ++++++++ src/admin/hooks/AuthContext.jsx | 61 +++++ src/admin/hooks/CVContext.jsx | 132 ++++++++++ src/admin/hooks/__tests__/CVContext.test.jsx | 89 +++++++ src/admin/hooks/useCVData.js | 60 +++++ src/admin/hooks/useFormValidation.js | 69 ++++++ src/admin/pages/LoginPage.jsx | 95 +++++++ src/admin/sections/EducationForm.jsx | 167 +++++++++++++ src/admin/sections/ExperienceForm.jsx | 248 +++++++++++++++++++ src/admin/sections/PersonalForm.jsx | 169 +++++++++++++ src/admin/sections/ProjectsForm.jsx | 227 +++++++++++++++++ src/admin/sections/SkillsForm.jsx | 160 ++++++++++++ src/assets/react.svg | 1 + src/components/Contact.jsx | 61 +++++ src/components/Education.jsx | 53 ++++ src/components/Experience.jsx | 72 ++++++ src/components/Hero.jsx | 73 ++++++ src/components/Projects.jsx | 57 +++++ src/components/Skills.jsx | 72 ++++++ src/data/cv.js | 3 + src/index.css | 26 ++ src/lib/__tests__/cv-data.test.js | 168 +++++++++++++ src/lib/api.js | 71 ++++++ src/lib/auth.js | 18 ++ src/lib/cv-data.js | 74 ++++++ src/lib/cv.json | 73 ++++++ src/main.jsx | 10 + src/test/setup.js | 1 + vite.config.js | 8 + 43 files changed, 2849 insertions(+) create mode 100644 .gitignore create mode 100644 docker-compose.yml create mode 100644 eslint.config.js create mode 100644 index.html create mode 100644 nginx.conf create mode 100644 playwright.config.js create mode 100644 public/vite.svg create mode 100644 src/App.jsx create mode 100644 src/admin/AdminEducation.jsx create mode 100644 src/admin/AdminExperience.jsx create mode 100644 src/admin/AdminLayout.jsx create mode 100644 src/admin/AdminPersonal.jsx create mode 100644 src/admin/AdminProjects.jsx create mode 100644 src/admin/AdminSkills.jsx create mode 100644 src/admin/components/ExportButton.jsx create mode 100644 src/admin/hooks/AuthContext.jsx create mode 100644 src/admin/hooks/CVContext.jsx create mode 100644 src/admin/hooks/__tests__/CVContext.test.jsx create mode 100644 src/admin/hooks/useCVData.js create mode 100644 src/admin/hooks/useFormValidation.js create mode 100644 src/admin/pages/LoginPage.jsx create mode 100644 src/admin/sections/EducationForm.jsx create mode 100644 src/admin/sections/ExperienceForm.jsx create mode 100644 src/admin/sections/PersonalForm.jsx create mode 100644 src/admin/sections/ProjectsForm.jsx create mode 100644 src/admin/sections/SkillsForm.jsx create mode 100644 src/assets/react.svg create mode 100644 src/components/Contact.jsx create mode 100644 src/components/Education.jsx create mode 100644 src/components/Experience.jsx create mode 100644 src/components/Hero.jsx create mode 100644 src/components/Projects.jsx create mode 100644 src/components/Skills.jsx create mode 100644 src/data/cv.js create mode 100644 src/index.css create mode 100644 src/lib/__tests__/cv-data.test.js create mode 100644 src/lib/api.js create mode 100644 src/lib/auth.js create mode 100644 src/lib/cv-data.js create mode 100644 src/lib/cv.json create mode 100644 src/main.jsx create mode 100644 src/test/setup.js create mode 100644 vite.config.js 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 ( +
+
Loading...
+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return ( +
+
+
+
+
+ +

CV Admin

+
+
+ + Zur CV Ansicht → + + +
+
+
+
+ +
+
+ + +
+ +
+
+
+
+ ); +} \ 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

+
    +
  1. JSON Datei wurde heruntergeladen
  2. +
  3. Inhalt der cv.json kopieren
  4. +
  5. In src/data/cv.js einfügen
  6. +
  7. Committen und pushen: git add . && git commit -m "update cv" && git push
  8. +
  9. GitHub Actions deployt automatisch
  10. +
+ +
+
+
+ )} +
+ ); +} \ 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 && ( +
+ + {error} +
+ )} + +
+ setPassword(e.target.value)} + placeholder="Password" + className="w-full px-4 py-3 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500 mb-4" + autoFocus + /> + +
+ +

+ 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 ( +
+
+ + +
+
+ + +
+