feat(ui): add cv application frontend and configuration
This commit is contained in:
61
src/admin/hooks/AuthContext.jsx
Normal file
61
src/admin/hooks/AuthContext.jsx
Normal 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;
|
||||
}
|
||||
132
src/admin/hooks/CVContext.jsx
Normal file
132
src/admin/hooks/CVContext.jsx
Normal 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;
|
||||
}
|
||||
89
src/admin/hooks/__tests__/CVContext.test.jsx
Normal file
89
src/admin/hooks/__tests__/CVContext.test.jsx
Normal 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');
|
||||
});
|
||||
});
|
||||
60
src/admin/hooks/useCVData.js
Normal file
60
src/admin/hooks/useCVData.js
Normal 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
|
||||
};
|
||||
}
|
||||
69
src/admin/hooks/useFormValidation.js
Normal file
69
src/admin/hooks/useFormValidation.js
Normal 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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user