feat(ui): add cv application frontend and configuration

This commit is contained in:
Tuan-Dat Tran
2026-02-23 13:47:08 +01:00
parent cbf40908a6
commit 7f06ee7f53
43 changed files with 2849 additions and 0 deletions

View File

@@ -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 (
<AuthContext.Provider value={{
isAuthenticated,
authMode,
keycloakConfig,
loading,
login: simpleLogin,
logout,
}}>
{children}
</AuthContext.Provider>
);
}
// 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;
}

View File

@@ -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 (
<CVContext.Provider value={{
data,
loading,
error,
updatePersonal,
updateExperience,
updateSkills,
updateEducation,
updateProjects,
exportJSON,
importJSON,
resetToDefault,
refresh
}}>
{children}
</CVContext.Provider>
);
}
// 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;
}

View File

@@ -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 }) => <CVProvider>{children}</CVProvider>;
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');
});
});

View File

@@ -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
};
}

View File

@@ -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;
}
};