Merged PR 105: Full upgrade: Vite and Typescript migration

feat: add mock CV data and setup testing environment

- Created a mock CV data file for testing purposes.
- Added a setup file for testing with @testing-library/jest-dom.
- Implemented a ThemeProvider component to manage themes and favicons.
- Added unit tests for theme utility functions.
- Replaced JavaScript theme constants with TypeScript constants.
- Refactored theme hooks to TypeScript and improved favicon handling.
- Removed deprecated JavaScript files and replaced them with TypeScript equivalents.
- Introduced a new TypeScript types file for better type safety.
- Set up TypeScript configuration files for the project.
- Configured Vite for building and testing the project.
This commit is contained in:
Tudor Stanciu 2025-08-10 15:30:08 +00:00
parent 29aac33728
commit 6a183d479a
71 changed files with 6642 additions and 32192 deletions

22
.eslintrc.cjs Normal file
View File

@ -0,0 +1,22 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended",
],
ignorePatterns: ["dist", ".eslintrc.cjs"],
parser: "@typescript-eslint/parser",
plugins: ["react-refresh"],
rules: {
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-explicit-any": "warn",
},
};

3
.gitignore vendored
View File

@ -20,4 +20,5 @@ dist
npm-debug.log*
yarn-debug.log*
yarn-error.log*
example/package-lock.json
example/package-lock.json
coverage

View File

@ -1,4 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run precommit
npx lint-staged

134
CHANGELOG.md Normal file
View File

@ -0,0 +1,134 @@
# Changelog
All notable changes to the Standard CV project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.0.0] - 2025-08-10
### 🚀 Major Release - Complete Rewrite
This release represents a complete rewrite and modernization of the Standard CV library with breaking changes.
### ✨ Added
- **TypeScript Support**: Full TypeScript rewrite with comprehensive type definitions
- **Modern Build System**: Migrated from Babel to Vite for faster builds and better DX
- **Enhanced Theme System**:
- 10 professionally designed themes
- CSS custom properties for dynamic theming
- URL-based theme switching support
- Rainbow theme with gradient effects
- **Improved Component Architecture**:
- Modular component design with better separation of concerns
- Theme provider pattern for better theme management
- Flexible data structure with comprehensive TypeScript interfaces
- **Testing Infrastructure**:
- Vitest + React Testing Library setup
- Comprehensive test coverage
- Visual regression testing capabilities
- **Modern Styling**:
- Complete CSS rewrite with modern patterns
- CSS custom properties for theming
- Improved responsive design
- Better print styles
- Accessibility improvements
- **Developer Experience**:
- ESLint + Prettier configuration
- Husky pre-commit hooks
- Comprehensive documentation
- Type-safe development
### 🔄 Changed
- **BREAKING**: Completely new API and data structure
- **BREAKING**: New import paths and component structure
- **BREAKING**: Minimum React version now 18+
- **BREAKING**: Now requires TypeScript-aware bundler
- Improved component performance with modern React patterns
- Better tree-shaking support for reduced bundle size
- Enhanced accessibility with ARIA labels and semantic HTML
### 🗑️ Removed
- **BREAKING**: Removed jQuery dependency
- **BREAKING**: Removed legacy theme variants
- **BREAKING**: Removed old Babel-based build system
- Removed old PropTypes validation (replaced with TypeScript)
### 🛠️ Dependencies
- Updated to React 18+
- Removed heavy dependencies (jQuery, etc.)
- Added modern development dependencies (Vite, TypeScript, etc.)
- Minimal runtime dependencies for better performance
### 📚 Documentation
- Complete README rewrite with examples
- Added comprehensive TypeScript documentation
- Added component architecture diagrams
- Added migration guide from v1.x
- Added licensing information and terms
### 🔒 License
- Changed from MIT to proprietary license
- Added commercial licensing requirements
- Personal use remains free for non-commercial purposes
---
## [1.0.4] - 2023-XX-XX
### 🔄 Changed
- @flare/react-hooks upgrade
## [1.0.3] - 2023-XX-XX
### 🔄 Changed
- @flare/react-hooks upgrade
## [1.0.2] - 2023-XX-XX
### 🔄 Changed
- Packages update
## [1.0.1] - 2023-XX-XX
### 🔄 Changed
- Made user interface more responsive based on window size
## [1.0.0] - 2023-XX-XX
### ✨ Added
- Initial version of standard-cv React component
- Basic CV layout with header, sections, and footer
- Theme support with CSS custom properties
- Responsive design
- FontAwesome icons integration
- Bootstrap 4 styling
---
## Migration from 1.x to 2.x
Due to the complete rewrite, migration from v1.x requires significant changes:
1. **Update your data structure** to match the new TypeScript interfaces
2. **Update import statements** to use the new component exports
3. **Update CSS imports** to use the new styling system
4. **Add TypeScript support** to your project for the best experience
5. **Review and update** any custom styling to work with the new CSS architecture
For detailed migration guidance, please refer to the [Migration Guide](./MIGRATION.md) (coming soon).
---
**Note**: Version 2.0.0 represents a significant architectural improvement that provides better performance, type safety, and developer experience. While it requires migration effort, the benefits include better maintainability, modern tooling, and professional licensing options.

48
LICENSE Normal file
View File

@ -0,0 +1,48 @@
PROPRIETARY SOFTWARE LICENSE AGREEMENT
Copyright (c) 2024 Tudor Stanciu. All rights reserved.
GRANT OF LICENSE
Subject to the terms and conditions of this License Agreement, Tudor Stanciu ("Licensor") hereby grants you ("Licensee") a limited, non-exclusive, non-transferable, revocable license to use the Standard CV software ("Software") solely for the purposes and under the conditions specified herein.
PERMITTED USES
1. PERSONAL USE: You may use the Software for personal, non-commercial purposes to create and display your own curriculum vitae.
2. EVALUATION: You may evaluate the Software for potential commercial licensing for a period not exceeding 30 days.
PROHIBITED USES
1. COMMERCIAL USE: You may NOT use the Software for any commercial purposes, including but not limited to:
- Offering CV creation services to third parties
- Incorporating the Software into commercial products or services
- Using the Software in any business or profit-generating activity
2. REDISTRIBUTION: You may NOT distribute, sublicense, sell, rent, lease, or otherwise transfer the Software or any portion thereof to any third party.
3. MODIFICATION: You may NOT modify, adapt, alter, translate, or create derivative works based upon the Software.
4. REVERSE ENGINEERING: You may NOT reverse engineer, decompile, disassemble, or otherwise attempt to derive the source code of the Software.
COMMERCIAL LICENSING
For commercial use of the Software, you must obtain a separate commercial license from the Licensor. Please contact tudor.stanciu94@gmail.com to inquire about commercial licensing terms and pricing.
APPROVAL REQUIREMENT
Any use of this Software, beyond personal evaluation, requires explicit written approval from Tudor Stanciu. Unauthorized use will be considered a violation of this license and may result in legal action.
INTELLECTUAL PROPERTY
The Software is protected by copyright laws and international copyright treaties, as well as other intellectual property laws and treaties. The Licensor retains all rights, title, and interest in and to the Software.
TERMINATION
This license is effective until terminated. The Licensor may terminate this license at any time with or without notice. Upon termination, you must immediately cease all use of the Software and destroy all copies in your possession.
DISCLAIMER OF WARRANTY
THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.
LIMITATION OF LIABILITY
IN NO EVENT SHALL THE LICENSOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THE SOFTWARE.
GOVERNING LAW
This License Agreement shall be governed by and construed in accordance with the laws of Romania, without regard to its conflict of laws principles.
By using the Software, you acknowledge that you have read this License Agreement, understand it, and agree to be bound by its terms and conditions.
For licensing inquiries, contact: tudor.stanciu94@gmail.com

314
README.md
View File

@ -1,29 +1,313 @@
# react-hooks
# Standard CV
## Introduction
A professional, customizable, and responsive CV/Resume component library for React with full TypeScript support. Create beautiful, modern curriculum vitae with ease using this comprehensive component system.
**standard-cv** is an npm package that provides a standard, beautiful and responsive curriculum vitae.
![Version](https://img.shields.io/badge/version-2.0.0-blue.svg)
![License](https://img.shields.io/badge/license-Proprietary-red.svg)
![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue.svg)
![React](https://img.shields.io/badge/React-18+-green.svg)
## Package installation
## ✨ Features
- 🎨 **10 Beautiful Themes** - Choose from carefully crafted color schemes
- 🎯 **TypeScript First** - Full type safety and excellent developer experience
- 📱 **Fully Responsive** - Looks great on all devices and screen sizes
- 🎨 **Modern CSS** - CSS custom properties for easy theming
- 🚀 **Performance Optimized** - Built with modern tooling (Vite + ESBuild)
- 🧪 **Well Tested** - Comprehensive test coverage with Vitest
- 📖 **Accessible** - Built with accessibility best practices
- 🖨️ **Print Ready** - Optimized styles for printing
- 🔧 **Highly Customizable** - Flexible data structure and styling options
- ⚡ **Tree Shakeable** - Import only what you need
## 🚀 Installation
```bash
// with npm
npm i --save @react-bricks/standard-cv --registry https://lab.code-rove.com/public-node-registry
# Using npm
npm install @react-bricks/standard-cv --registry https://lab.code-rove.com/public-node-registry
// with yarn
# Using yarn
yarn add @react-bricks/standard-cv --registry https://lab.code-rove.com/public-node-registry
# Using pnpm
pnpm add @react-bricks/standard-cv --registry https://lab.code-rove.com/public-node-registry
```
## How to use the package
## 📦 Peer Dependencies
```jsx
Make sure you have the following peer dependencies installed:
```bash
npm install react react-dom
```
## 🎯 Quick Start
```tsx
import React from "react";
import { StandardCV } from "@react-bricks/standard-cv";
import "@react-bricks/standard-cv/dist/style.css";
const cvData = {
configuration: {
theme: "turquoise",
favicon: {
use: true,
id: "favicon-svg",
placeholder: "{#theme}",
href: "/icons/{#theme}-favicon.svg",
},
options: {
urlTheme: true,
},
},
header: {
profile: {
name: "Tudor Stanciu",
position: "Senior Software Engineer",
picture: {
src: "/images/profile.jpg",
alt: "John Doe's profile picture",
},
download: {
label: "Download CV",
src: "/files/cv.pdf",
},
},
about: {
content:
"Passionate software engineer with expertise in modern web technologies...",
},
networks: [
{
id: 1,
icon: "fas fa-envelope",
url: "mailto:john@example.com",
label: "john@example.com",
sameTab: true,
},
],
},
article: {
education: {
visible: true,
icon: "fa-graduation-cap",
label: "Education",
elements: [
{
id: 1,
name: "University of Technology",
time: "2018-2022",
title: "Bachelor of Computer Science",
},
],
},
work: {
visible: true,
icon: "fa-briefcase",
label: "Work Experience",
elements: [
{
name: "Tech Company",
time: "2022-Present",
position: "Software Engineer",
content: [
{
id: 1,
text: "Developed modern web applications using React and TypeScript.",
},
],
},
],
},
projects: {
visible: true,
icon: "fa-pen",
label: "Projects",
elements: [],
},
skills: { visible: true, icon: "fa-wrench", label: "Skills", elements: [] },
honors: { visible: false, icon: "fa-medal", label: "Honors", elements: [] },
conferences: {
visible: true,
icon: "fa-users",
label: "Conferences",
elements: [],
},
},
footer: {
visible: true,
owner: {
message: "Created by",
name: "Tudor Stanciu",
url: "https://johndoe.dev",
},
},
};
function App() {
return (
<div className="App">
<StandardCV data={cvData} />
</div>
);
}
```
## Changelog
## 🎨 Available Themes
**1.0.0** - This version includes the initial version of standard-cv react component.
**1.0.1** - The user interface has been made more responsive based on the window size.
**1.0.2** - Packages update.
**1.0.3** - @flare/react-hooks upgrade.
**1.0.4** - @flare/react-hooks upgrade.
Choose from 10 beautiful, professionally designed themes:
- `turquoise` (default) - Fresh and professional
- `blue` - Classic and trustworthy
- `green` - Natural and calming
- `brown` - Warm and earthy
- `orange` - Energetic and creative
- `purple` - Modern and sophisticated
- `pink` - Friendly and approachable
- `coral` - Vibrant and distinctive
- `nude` - Minimal and elegant
- `rainbow` - Creative and colorful (gradient effects)
## 🔧 Advanced Features
### URL Theme Switching
Enable dynamic theme switching via URL parameters:
```tsx
const cvData = {
configuration: {
theme: "turquoise",
options: {
urlTheme: true, // Enable URL theme switching
},
},
// ... rest of config
};
// Now users can change themes via: ?theme=blue
```
### Custom CSS Classes
Add custom styling with className prop:
```tsx
<StandardCV data={cvData} className="my-custom-cv" />
```
### Dynamic Favicons
Automatically update favicons based on the selected theme:
```tsx
const cvData = {
configuration: {
favicon: {
use: true,
id: "favicon-svg",
placeholder: "{#theme}",
href: "/icons/{#theme}-favicon.svg", // Will replace {#theme} with actual theme name
},
},
};
```
## 📊 Data Structure
The component expects a comprehensive data object with the following structure:
```typescript
interface StandardCVData {
configuration: Configuration;
header: Header;
article: Article;
footer: Footer;
}
```
For detailed TypeScript interfaces, see the [type definitions](./src/types/index.ts).
## 🎯 Component Architecture
The library follows a modular architecture:
```
StandardCV (Main Component)
├── ThemeProvider (Theme Management)
├── Layout (Main Layout)
│ ├── HeaderLayout (Left Sidebar)
│ │ ├── Profile
│ │ ├── About
│ │ └── SocialNetworks
│ └── Sections (Right Content)
│ ├── Education
│ ├── Work
│ ├── Projects
│ ├── Skills
│ ├── Honors
│ └── Conferences
└── Footer
```
## 🧪 Testing
The library includes comprehensive test coverage using Vitest and React Testing Library:
```bash
# Run tests
npm test
# Run tests with UI
npm run test:ui
# Run tests with coverage
npm run test:coverage
```
## 🛠️ Development
```bash
# Install dependencies
npm install
# Start development mode
npm run dev
# Build the library
npm run build
# Type checking
npm run typecheck
# Linting
npm run lint
npm run lint:fix
```
## 📄 License
This software is proprietary and requires a license for use.
- ✅ **Personal use**: Free for personal, non-commercial CV creation
- ❌ **Commercial use**: Requires separate commercial license
- ❌ **Redistribution**: Not permitted without explicit approval
For commercial licensing inquiries, please contact: tudor.stanciu94@gmail.com
## 🤝 Support
For support, questions, or licensing inquiries:
- 📧 Email: tudor.stanciu94@gmail.com
- 🌐 Portfolio: https://lab.code-rove.com/tsp
- 📝 Blog: https://lab.code-rove.com/lab-stories
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for detailed release history.
---
**⚠️ Important**: This is proprietary software. Please ensure you comply with the license terms. Commercial use requires a separate license agreement.

35797
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,13 @@
{
"name": "@react-bricks/standard-cv",
"version": "1.0.4",
"description": "standard-cv is an npm package that provides a standard, beautiful and responsive curriculum vitae.",
"version": "2.0.0",
"description": "A professional, customizable, and responsive CV/Resume component for React with TypeScript support",
"author": {
"name": "Tudor Stanciu",
"email": "tudor.stanciu94@gmail.com",
"url": "https://lab.code-rove.com/tsp"
},
"license": "MIT",
"license": "SEE LICENSE IN LICENSE",
"repository": {
"type": "git",
"url": "https://dev.azure.com/tstanciu94/Packages/_git/standard-cv"
@ -17,65 +17,96 @@
"react-bricks",
"standard-cv"
],
"main": "./src/index.js",
"babel": {
"presets": [
"@babel/preset-react"
]
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist",
"LICENSE",
"README.md",
"CHANGELOG.md"
],
"scripts": {
"prebuild": "rimraf build",
"build": "npm run build:cjs && npm run build:copy-files",
"build:cjs": "babel src -d build --copy-files",
"build:copy-files": "node ./scripts/copy-files.js",
"precommit": "lint-staged",
"dev": "vite",
"build": "tsc && vite build",
"build:watch": "vite build --watch",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint:fix": "eslint . --ext ts,tsx --fix",
"typecheck": "tsc --noEmit",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"clean": "rimraf dist",
"prepublishOnly": "npm run clean && npm run build",
"prepare": "husky install",
"test": "echo \"Error: no test specified\" && exit 1",
"prepush": "npm run build",
"push": "cd build && npm publish --registry https://lab.code-rove.com/public-node-registry",
"push:major": "npm run version:major && npm run push",
"push:minor": "npm run version:minor && npm run push",
"push:patch": "npm run version:patch && npm run push",
"push": "cd dist && npm publish --registry https://lab.code-rove.com/public-node-registry",
"push:major": "npm run version:major && npm run prepublishOnly && npm run push",
"push:minor": "npm run version:minor && npm run prepublishOnly && npm run push",
"push:patch": "npm run version:patch && npm run prepublishOnly && npm run push",
"version:major": "npm version major --no-git-tag-version",
"version:minor": "npm version minor --no-git-tag-version",
"version:patch": "npm version patch --no-git-tag-version"
},
"devDependencies": {
"@babel/cli": "^7.16.7",
"@babel/core": "^7.16.7",
"@babel/node": "^7.16.7",
"@babel/preset-env": "^7.16.7",
"@babel/preset-react": "^7.8.0",
"rimraf": "^3.0.2",
"fs-extra": "^10.0.0",
"husky": "^7.0.4",
"lint-staged": "^12.2.2",
"prettier": "^2.5.1"
},
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1"
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@testing-library/user-event": "^14.5.1",
"@types/node": "^24.2.1",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^1.0.4",
"@vitest/ui": "^1.0.4",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"happy-dom": "^12.10.3",
"husky": "^8.0.3",
"lint-staged": "^15.2.0",
"prettier": "^3.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"rimraf": "^5.0.5",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vite-plugin-css-injected-by-js": "^3.5.2",
"vite-plugin-dts": "^3.6.4",
"vitest": "^1.0.4"
},
"dependencies": {
"@flare/lumrop": "^1.3.0",
"@fortawesome/fontawesome-free": "^5.10.2",
"@flare/react-hooks": "^1.1.1",
"bootstrap": "^4.3.1",
"jquery": "^3.3.1",
"popper.js": "^1.14.7",
"prop-types": "^15.8.1"
"bootstrap": "^4.6.2",
"clsx": "^2.0.0"
},
"prettier": {
"trailingComma": "none",
"tabWidth": 2,
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 80,
"arrowParens": "avoid"
},
"lint-staged": {
"**/*.+(js|md|ts|css|sass|less|graphql|scss|json|vue)": [
"prettier --write",
"git add"
"**/*.{js,jsx,ts,tsx,md,json,css}": [
"prettier --write"
],
"**/*.{js,jsx,ts,tsx}": [
"eslint --fix"
]
},
"publishConfig": {

View File

@ -1,87 +0,0 @@
/* eslint-disable no-console */
const path = require("path");
const fse = require("fs-extra");
const glob = require("glob");
const packagePath = process.cwd();
const buildPath = path.join(packagePath, "./build");
const srcPath = path.join(packagePath, "./src");
/**
* Puts a package.json into every immediate child directory of rootDir.
* That package.json contains information about esm for bundlers so that imports
* like import Typography from '@material-ui/core/Typography' are tree-shakeable.
*
* It also tests that an this import can be used in typescript by checking
* if an index.d.ts is present at that path.
*
* @param {string} rootDir
*/
// async function createModulePackages({ from, to }) {
// const directoryPackages = glob.sync('*/index.js', { cwd: from }).map(path.dirname);
// await Promise.all(
// directoryPackages.map(async directoryPackage => {
// const packageJson = {
// sideEffects: false,
// module: path.join('../esm', directoryPackage, 'index.js'),
// typings: './index.d.ts',
// };
// const packageJsonPath = path.join(to, directoryPackage, 'package.json');
// /*const [typingsExist] =*/ await Promise.all([
// //fse.exists(path.join(to, directoryPackage, 'index.d.ts')),
// fse.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2)),
// ]);
// // if (!typingsExist) {
// // throw new Error(`index.d.ts for ${directoryPackage} is missing`);
// // }
// return packageJsonPath;
// }),
// );
// }
async function createPackageFile() {
const packageData = await fse.readFile(
path.resolve(packagePath, "./package.json"),
"utf8"
);
// eslint-disable-next-line no-unused-vars
const { nyc, scripts, devDependencies, workspaces, ...packageDataOther } =
JSON.parse(packageData);
const newPackageData = {
...packageDataOther,
private: false,
main: "./index.js"
};
const targetPath = path.resolve(buildPath, "./package.json");
await fse.writeFile(
targetPath,
JSON.stringify(newPackageData, null, 2),
"utf8"
);
console.log(`Created package.json in ${targetPath}`);
fse.copy("./README.md", `${buildPath}/README.md`, err => {
if (err) throw err;
console.log("README file was copied to destination");
});
return newPackageData;
}
async function run() {
try {
await createPackageFile();
//await createModulePackages({ from: srcPath, to: buildPath });
} catch (err) {
console.error(err);
process.exit(1);
}
}
run();

View File

@ -1,41 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
const Footer = ({ data }) => {
return (
<footer>
<small className="copyright">
{data.custom ? (
data.custom
) : (
<>
<a href={data.project.repository} target="_blank" rel="noreferrer">
{data.project.name}
</a>
{` | ${data.owner.message} `}
<a href={data.owner.url} target="_blank" rel="noreferrer">
{data.owner.name}
</a>
</>
)}
</small>
</footer>
);
};
Footer.propTypes = {
data: PropTypes.shape({
project: PropTypes.shape({
name: PropTypes.string.isRequired,
repository: PropTypes.string
}),
owner: PropTypes.shape({
message: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
url: PropTypes.string
}),
custom: PropTypes.node
}).isRequired
};
export default Footer;

38
src/components/Footer.tsx Normal file
View File

@ -0,0 +1,38 @@
import React from "react";
import type { SectionProps, Footer as FooterType } from "../types";
type FooterProps = SectionProps<FooterType>;
const Footer: React.FC<FooterProps> = ({ data }) => {
if (data.custom) {
return (
<footer>
<small className="copyright">{data.custom}</small>
</footer>
);
}
return (
<footer>
<small className="copyright">
{data.project && (
<>
<a href={data.project.repository} target="_blank" rel="noreferrer">
{data.project.name}
</a>
{data.owner && (
<>
{` | ${data.owner.message} `}
<a href={data.owner.url} target="_blank" rel="noreferrer">
{data.owner.name}
</a>
</>
)}
</>
)}
</small>
</footer>
);
};
export default Footer;

View File

@ -1,5 +1,6 @@
import React from "react";
import PropTypes from "prop-types";
import clsx from "clsx";
import type { SectionProps, StandardCVData } from "../types";
import HeaderLayout from "./header/HeaderLayout";
import Education from "./section/Education";
import Work from "./section/Work";
@ -9,9 +10,11 @@ import Honors from "./section/Honors";
import Conferences from "./section/Conferences";
import Footer from "./Footer";
const Layout = ({ data }) => {
type LayoutProps = SectionProps<StandardCVData>;
const Layout: React.FC<LayoutProps> = ({ data, className }) => {
return (
<main className="container">
<main className={clsx("container", className)}>
<div className="row">
<div className="col-lg-4 left-side">
<HeaderLayout data={data.header} />
@ -48,19 +51,4 @@ const Layout = ({ data }) => {
);
};
Layout.propTypes = {
data: PropTypes.shape({
header: PropTypes.object.isRequired,
article: PropTypes.shape({
education: PropTypes.object.isRequired,
work: PropTypes.object.isRequired,
projects: PropTypes.object,
skills: PropTypes.object.isRequired,
honors: PropTypes.object,
conferences: PropTypes.object
}).isRequired,
footer: PropTypes.object.isRequired
}).isRequired
};
export default Layout;

View File

@ -0,0 +1,23 @@
import React from "react";
import clsx from "clsx";
import type { StandardCVProps } from "../types";
import { ThemeProvider } from "../theme/ThemeProvider";
import Layout from "./Layout";
const StandardCV: React.FC<StandardCVProps> = ({ data, className }) => {
const { theme, favicon, options } = data.configuration;
const { urlTheme: enableUrlTheme } = options;
return (
<ThemeProvider
theme={theme}
favicon={favicon}
enableUrlTheme={enableUrlTheme}
className={clsx("standard-cv", className)}
>
<Layout data={data} />
</ThemeProvider>
);
};
export default StandardCV;

View File

@ -0,0 +1,68 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import StandardCV from "../StandardCV";
import { mockCVData } from "../../test/mocks/data";
describe("StandardCV", () => {
it("renders without crashing", () => {
render(<StandardCV data={mockCVData} />);
expect(
screen.getByRole("heading", { name: "Tudor Stanciu" })
).toBeInTheDocument();
});
it("displays profile information correctly", () => {
render(<StandardCV data={mockCVData} />);
expect(
screen.getByRole("heading", { name: "Tudor Stanciu" })
).toBeInTheDocument();
expect(screen.getAllByText("Software Engineer")).toHaveLength(1); // Only in profile header
expect(screen.getByText("Download CV")).toBeInTheDocument();
});
it("displays sections based on visibility", () => {
render(<StandardCV data={mockCVData} />);
// Visible sections
expect(screen.getByText("Education")).toBeInTheDocument();
expect(screen.getByText("Work Experience")).toBeInTheDocument();
expect(screen.getByText("Projects")).toBeInTheDocument();
expect(screen.getByText("Technical Skills")).toBeInTheDocument();
expect(screen.getByText("Conferences")).toBeInTheDocument();
// Hidden section should not be present
expect(screen.queryByText("Honors")).not.toBeInTheDocument();
});
it("applies custom className", () => {
const { container } = render(
<StandardCV data={mockCVData} className="custom-class" />
);
expect(container.firstChild).toHaveClass(
"theme-provider",
"standard-cv",
"custom-class"
);
});
it("displays social networks", () => {
render(<StandardCV data={mockCVData} />);
expect(screen.getByText("john.doe@example.com")).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Tudor Stanciu" })
).toBeInTheDocument();
});
it("displays footer when visible", () => {
render(<StandardCV data={mockCVData} />);
expect(screen.getByText("Standard CV")).toBeInTheDocument();
// Check for footer text parts separately since they are in different elements
const footerElement = screen.getByRole("contentinfo");
expect(footerElement).toBeInTheDocument();
expect(footerElement.textContent).toContain("Created by");
expect(footerElement.textContent).toContain("Lab Team");
});
});

View File

@ -1,14 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
const About = ({ data }) => {
return <p className="justify-text">{data.content}</p>;
};
About.propTypes = {
data: PropTypes.shape({
content: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired
}).isRequired
};
export default About;

View File

@ -0,0 +1,10 @@
import React from "react";
import type { SectionProps, About as AboutType } from "../../types";
type AboutProps = SectionProps<AboutType>;
const About: React.FC<AboutProps> = ({ data }) => {
return <p className="justify-text">{data.content}</p>;
};
export default About;

View File

@ -1,12 +1,12 @@
import React from "react";
import PropTypes from "prop-types";
import type { SectionProps, Header } from "../../types";
import Profile from "./Profile";
import ProfileName from "./ProfileName";
import SocialNetworks from "./SocialNetworks";
import About from "./About";
import { useWindowSize } from "@flare/react-hooks";
import { useWindowSize } from "@flare/lumrop";
const TabletHeader = ({ data }) => {
const TabletHeader: React.FC<HeaderLayoutProps> = ({ data }) => {
return (
<header>
<div className="row">
@ -32,7 +32,7 @@ const TabletHeader = ({ data }) => {
);
};
const Header = ({ data }) => {
const Header: React.FC<HeaderLayoutProps> = ({ data }) => {
return (
<header>
<Profile data={data.profile} />
@ -42,17 +42,13 @@ const Header = ({ data }) => {
);
};
const HeaderLayout = ({ data }) => {
type HeaderLayoutProps = SectionProps<Header>;
const HeaderLayout: React.FC<HeaderLayoutProps> = ({ data }) => {
const { isTablet } = useWindowSize();
if (isTablet) return <TabletHeader data={data} />;
return <Header data={data} />;
};
HeaderLayout.propTypes = {
data: PropTypes.shape({
profile: PropTypes.object.isRequired
}).isRequired
};
export default HeaderLayout;

View File

@ -1,51 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import ProfileName from "./ProfileName";
const Profile = ({ data, displayProfileName, className }) => {
const handleDownloadClick = () => {
window.umami && window.umami("cv-download");
};
return (
<div className={className}>
<div className="picture">
<img src={data.picture.src} alt={data.picture.alt} />
</div>
<a
className="btn btn-cv"
href={data.download.src}
role="button"
download
onClick={handleDownloadClick}
>
<i className="fas fa-file-alt"></i> {data.download.label}
</a>
{displayProfileName && <ProfileName data={data} />}
</div>
);
};
Profile.defaultProps = {
displayProfileName: true,
className: "profile"
};
Profile.propTypes = {
data: PropTypes.shape({
name: PropTypes.string.isRequired,
position: PropTypes.string.isRequired,
picture: PropTypes.shape({
src: PropTypes.string.isRequired,
alt: PropTypes.string
}),
download: PropTypes.shape({
label: PropTypes.string.isRequired,
src: PropTypes.string.isRequired
})
}).isRequired,
displayProfileName: PropTypes.bool,
className: PropTypes.string
};
export default Profile;

View File

@ -0,0 +1,41 @@
import React from "react";
import type { SectionProps, Profile as ProfileType } from "../../types";
import ProfileName from "./ProfileName";
const handleDownloadClick = () => {
window.umami && window.umami("cv-download");
};
type ProfileProps = SectionProps<ProfileType> & {
displayProfileName?: boolean;
};
const Profile: React.FC<ProfileProps> = ({
data,
className = "profile",
displayProfileName = true,
}) => {
return (
<div className={className}>
{data.picture && (
<div className="picture">
<img src={data.picture.src} alt={data.picture.alt} />
</div>
)}
{data.download && (
<a
className="btn btn-cv"
href={data.download.src}
role="button"
download
onClick={handleDownloadClick}
>
<i className="fas fa-file-alt"></i> {data.download.label}
</a>
)}
{displayProfileName && <ProfileName data={data} />}
</div>
);
};
export default Profile;

View File

@ -1,20 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
const ProfileName = ({ data }) => {
return (
<>
<h1>{data.name}</h1>
<span>{data.position}</span>
</>
);
};
ProfileName.propTypes = {
data: PropTypes.shape({
name: PropTypes.string.isRequired,
position: PropTypes.string.isRequired
}).isRequired
};
export default ProfileName;

View File

@ -0,0 +1,19 @@
import React from "react";
type Props = {
data: {
name: string;
position: string;
};
};
const ProfileName: React.FC<Props> = ({ data }) => {
return (
<>
<h1>{data.name}</h1>
<span>{data.position}</span>
</>
);
};
export default ProfileName;

View File

@ -1,11 +1,16 @@
import React from "react";
import PropTypes from "prop-types";
import type { SocialNetwork } from "../../types";
const SocialNetworks = ({ data }) => {
const handleSocialNetworkClick = (event?: string) => () => {
event && window.umami && window.umami(event);
};
interface SocialNetworksProps {
data: SocialNetwork[];
}
const SocialNetworks: React.FC<SocialNetworksProps> = ({ data }) => {
const networks = data.sort((a, b) => a.id - b.id);
const handleSocialNetworkClick = event => () => {
event && window.umami && window.umami(event);
};
return (
<ul className="social">
@ -35,17 +40,4 @@ const SocialNetworks = ({ data }) => {
);
};
SocialNetworks.propTypes = {
data: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.number.isRequired,
icon: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
sameTab: PropTypes.bool,
event: PropTypes.string
})
).isRequired
};
export default SocialNetworks;

View File

@ -1,9 +1,11 @@
import React from "react";
import PropTypes from "prop-types";
import type { SectionProps, Conferences as ConferencesType } from "../../types";
import SectionTitle from "./SectionTitle";
import { composeKey } from "../../utils/textUtils";
import { composeKey } from "../../utils";
const Conferences = ({ data }) => {
type ConferencesProps = SectionProps<ConferencesType>;
const Conferences: React.FC<ConferencesProps> = ({ data }) => {
return (
<section>
<SectionTitle icon={data.icon} label={data.label} />
@ -18,12 +20,4 @@ const Conferences = ({ data }) => {
);
};
Conferences.propTypes = {
data: PropTypes.shape({
icon: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
elements: PropTypes.array.isRequired
}).isRequired
};
export default Conferences;

View File

@ -1,8 +1,26 @@
import React from "react";
import PropTypes from "prop-types";
import type {
SectionProps,
Education as EducationType,
Courses,
} from "../../types";
import SectionTitle from "./SectionTitle";
const School = ({ name, time, title, courses, last }) => {
interface EducationItemProps {
name: string;
time: string;
title: string;
courses?: Courses;
last?: boolean;
}
const EducationItem: React.FC<EducationItemProps> = ({
name,
time,
title,
courses,
last,
}) => {
return (
<>
<div className="school justify-text">
@ -22,7 +40,9 @@ const School = ({ name, time, title, courses, last }) => {
);
};
const Education = ({ data }) => {
type EducationProps = SectionProps<EducationType>;
const Education: React.FC<EducationProps> = ({ data }) => {
const _schools = [...data.elements.sort((a, b) => a.id - b.id)];
const last = _schools.pop();
@ -31,30 +51,12 @@ const Education = ({ data }) => {
<SectionTitle icon={data.icon} label={data.label} />
{_schools.map(school => (
<School key={`school-${school.id}`} {...school} />
<EducationItem key={`school-${school.id}`} {...school} />
))}
<School {...last} last />
{last && <EducationItem {...last} last />}
</section>
);
};
Education.propTypes = {
data: PropTypes.shape({
icon: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
elements: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
time: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
courses: PropTypes.shape({
label: PropTypes.string.isRequired,
content: PropTypes.string.isRequired
})
})
).isRequired
}).isRequired
};
export default Education;

View File

@ -1,59 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import SectionTitle from "./SectionTitle";
import { composeKey } from "../../utils/textUtils";
const HonorComponent = ({ name, abbreviation, bullets, last }) => {
const ulProps = last ? { style: { marginBottom: "-5px" } } : {};
return (
<>
<h3>
{abbreviation ? <abbr title={abbreviation}>{name}</abbr> : <>{name}</>}
</h3>
<ul {...ulProps}>
{bullets.map(bullet => (
<li key={composeKey(bullet)}>{bullet}</li>
))}
</ul>
</>
);
};
HonorComponent.propTypes = {
name: PropTypes.string.isRequired,
abbreviation: PropTypes.string,
bullets: PropTypes.array.isRequired,
last: PropTypes.bool
};
const Honors = ({ data }) => {
const _honors = [...data.elements.sort((a, b) => a.id - b.id)];
const last = _honors.pop();
return (
<section className="honors">
<SectionTitle icon={data.icon} label={data.label} />
{_honors.map(honor => (
<HonorComponent key={`honor-${honor.id}`} {...honor} />
))}
<HonorComponent {...last} last={true} />
</section>
);
};
Honors.propTypes = {
data: PropTypes.shape({
icon: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
elements: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
abbreviation: PropTypes.string,
bullets: PropTypes.array.isRequired
})
).isRequired
}).isRequired
};
export default Honors;

View File

@ -0,0 +1,52 @@
import React from "react";
import type { SectionProps, Honors as HonorsType } from "../../types";
import SectionTitle from "./SectionTitle";
import { composeKey } from "../../utils";
interface HonorItemProps {
name: string;
abbreviation?: string;
bullets: string[];
last?: boolean;
}
const HonorItem: React.FC<HonorItemProps> = ({
name,
abbreviation,
bullets,
last,
}) => {
const ulProps = last ? { style: { marginBottom: "-5px" } } : {};
return (
<>
<h3>
{abbreviation ? <abbr title={abbreviation}>{name}</abbr> : <>{name}</>}
</h3>
<ul {...ulProps}>
{bullets.map(bullet => (
<li key={composeKey(bullet)}>{bullet}</li>
))}
</ul>
</>
);
};
type HonorsProps = SectionProps<HonorsType>;
const Honors: React.FC<HonorsProps> = ({ data }) => {
const _honors = [...data.elements.sort((a, b) => a.id - b.id)];
const lastHonor = _honors.pop();
return (
<section className="honors">
<SectionTitle icon={data.icon} label={data.label} />
{_honors.map(honor => (
<HonorItem key={`honor-${honor.id}`} {...honor} />
))}
{lastHonor && <HonorItem {...lastHonor} last={true} />}
</section>
);
};
export default Honors;

View File

@ -1,37 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import SectionTitle from "./SectionTitle";
import Job from "./job/Job";
import { composeKey } from "../../utils/textUtils";
const Projects = ({ data }) => {
const _projects = [...data.elements.sort((a, b) => a.id - b.id)];
const last = _projects.pop();
return (
<section>
<SectionTitle icon={data.icon} label={data.label} />
{_projects.map(project => (
<Job key={composeKey(project.name)} {...project} />
))}
<Job {...last} last />
</section>
);
};
Projects.propTypes = {
data: PropTypes.shape({
icon: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
elements: PropTypes.arrayOf(
PropTypes.shape({
text: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
bullets: PropTypes.arrayOf(
PropTypes.oneOfType([PropTypes.string, PropTypes.node])
)
})
).isRequired
}).isRequired
};
export default Projects;

View File

@ -0,0 +1,23 @@
import React from "react";
import type { SectionProps, Projects as ProjectsType } from "../../types";
import SectionTitle from "./SectionTitle";
import Job from "./job/Job";
import { composeKey } from "../../utils";
type ProjectsProps = SectionProps<ProjectsType>;
const Projects: React.FC<ProjectsProps> = ({ data }) => {
const lastProject = data.elements.pop();
return (
<section>
<SectionTitle icon={data.icon} label={data.label} />
{data.elements.map(project => (
<Job key={composeKey(project.name)} {...project} />
))}
{lastProject && <Job {...lastProject} last />}
</section>
);
};
export default Projects;

View File

@ -1,7 +1,11 @@
import React from "react";
import PropTypes from "prop-types";
const SectionTitle = ({ icon, label }) => {
interface SectionTitleProps {
icon: string;
label: string;
}
const SectionTitle: React.FC<SectionTitleProps> = ({ icon, label }) => {
return (
<h2 className="section-title">
<span className="fa-stack fa-xs">
@ -13,9 +17,4 @@ const SectionTitle = ({ icon, label }) => {
);
};
SectionTitle.propTypes = {
icon: PropTypes.string.isRequired,
label: PropTypes.string.isRequired
};
export default SectionTitle;

View File

@ -1,52 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import SectionTitle from "./SectionTitle";
const Skill = ({ type, description, last }) => {
return (
<>
<b>{type}:</b> {description}
{!last && <br />}
</>
);
};
Skill.propTypes = {
type: PropTypes.string.isRequired,
description: PropTypes.oneOfType([PropTypes.string, PropTypes.node])
.isRequired,
last: PropTypes.bool
};
const Skills = ({ data }) => {
const _skills = [...data.elements.sort((a, b) => a.id - b.id)];
const last = _skills.pop();
return (
<section>
<SectionTitle icon={data.icon} label={data.label} />
<div className="justify-text">
{_skills.map(skill => (
<Skill key={`skill-${skill.id}`} {...skill} />
))}
<Skill {...last} last={true} />
</div>
</section>
);
};
Skills.propTypes = {
data: PropTypes.shape({
icon: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
elements: PropTypes.arrayOf(
PropTypes.shape({
type: PropTypes.string.isRequired,
description: PropTypes.oneOfType([PropTypes.string, PropTypes.node])
.isRequired
})
).isRequired
}).isRequired
};
export default Skills;

View File

@ -0,0 +1,49 @@
import React from "react";
import type { SectionProps, Skills as SkillsType } from "../../types";
import SectionTitle from "./SectionTitle";
interface SkillProps {
type: string;
description: string | React.ReactNode;
last?: boolean;
}
const Skill: React.FC<SkillProps> = ({ type, description, last = false }) => {
return (
<>
<b>{type}:</b> {description}
{!last && <br />}
</>
);
};
type SkillsProps = SectionProps<SkillsType>;
const Skills: React.FC<SkillsProps> = ({ data }) => {
const sortedSkills = [...data.elements].sort((a, b) => a.id - b.id);
const lastSkill = sortedSkills.pop();
return (
<section>
<SectionTitle icon={data.icon} label={data.label} />
<div className="justify-text">
{sortedSkills.map(skill => (
<Skill
key={`skill-${skill.id}`}
type={skill.type}
description={skill.description}
/>
))}
{lastSkill && (
<Skill
type={lastSkill.type}
description={lastSkill.description}
last
/>
)}
</div>
</section>
);
};
export default Skills;

View File

@ -1,83 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import SectionTitle from "./SectionTitle";
import Job from "./job/Job";
import JobChapter from "./job/JobChapter";
import { composeKey } from "../../utils/textUtils";
const MultiplePositionsWork = ({ data, last }) => {
const divProps = last
? { className: "job justify-text", style: { marginBottom: "-15px" } }
: { className: "job justify-text" };
return (
<div {...divProps}>
<h3 className="company">{data.name}</h3>
{data.positions.map(position => (
<JobChapter key={composeKey(position.name)} {...position} trivial />
))}
</div>
);
};
const WorkPoint = ({ data, last }) =>
data.positions ? (
<MultiplePositionsWork data={data} last={last} />
) : (
<Job {...data} last={last} />
);
const Work = ({ data }) => {
const _work = [...data.elements];
const last = _work.pop();
return (
<section>
<SectionTitle icon={data.icon} label={data.label} />
{_work.map(point => (
<WorkPoint key={composeKey(point.name)} data={point} />
))}
<WorkPoint data={last} last={true} />
</section>
);
};
Work.propTypes = {
data: PropTypes.shape({
icon: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
elements: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
time: PropTypes.string,
position: PropTypes.string,
positions: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
time: PropTypes.string,
content: PropTypes.arrayOf(
PropTypes.shape({
text: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
bullets: PropTypes.arrayOf(
PropTypes.oneOfType([PropTypes.string, PropTypes.node])
)
})
).isRequired
})
),
content: PropTypes.arrayOf(
PropTypes.shape({
text: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
bullets: PropTypes.arrayOf(
PropTypes.oneOfType([PropTypes.string, PropTypes.node])
)
})
)
})
).isRequired
}).isRequired
};
export default Work;

View File

@ -0,0 +1,63 @@
import React from "react";
import type { SectionProps, Work as WorkType, WorkElement } from "../../types";
import SectionTitle from "./SectionTitle";
import Job from "./job/Job";
import JobChapter from "./job/JobChapter";
import { composeKey } from "../../utils";
type MultiplePositionsWorkProps = {
data: WorkElement;
last?: boolean;
};
const MultiplePositionsWork: React.FC<MultiplePositionsWorkProps> = ({
data,
last,
}) => {
const divProps = last
? { className: "job justify-text", style: { marginBottom: "-15px" } }
: { className: "job justify-text" };
return (
<div {...divProps}>
<h3 className="company">{data.name}</h3>
{data.positions &&
data.positions.map(position => (
<JobChapter key={composeKey(position.name)} {...position} trivial />
))}
</div>
);
};
type WorkPointProps = {
data: WorkElement;
last?: boolean;
};
const WorkPoint: React.FC<WorkPointProps> = ({ data, last }) =>
data.positions ? (
<MultiplePositionsWork data={data} last={last} />
) : (
<Job {...data} last={last} />
);
type WorkProps = SectionProps<WorkType>;
const Work: React.FC<WorkProps> = ({ data }) => {
const _work = [...data.elements];
const lastWorkPoint = _work.pop();
return (
<section>
<SectionTitle icon={data.icon} label={data.label} />
{_work.map(point => (
<WorkPoint key={composeKey(point.name)} data={point} />
))}
{lastWorkPoint && <WorkPoint data={lastWorkPoint} last={true} />}
</section>
);
};
export default Work;

View File

@ -1,34 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import JobChapter from "./JobChapter";
const Job = ({ last, ...props }) => {
// style="margin-bottom: -15px" must pe used on last element of section
const divProps = last
? { className: "job justify-text", style: { marginBottom: "-15px" } }
: { className: "job justify-text" };
return (
<div {...divProps}>
<JobChapter {...props} />
</div>
);
};
Job.propTypes = {
name: PropTypes.string.isRequired,
time: PropTypes.string,
position: PropTypes.string,
content: PropTypes.arrayOf(
PropTypes.shape({
text: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
bullets: PropTypes.arrayOf(
PropTypes.oneOfType([PropTypes.string, PropTypes.node])
)
})
).isRequired,
chapter: PropTypes.bool,
last: PropTypes.bool
};
export default Job;

View File

@ -0,0 +1,26 @@
import React from "react";
import JobChapter from "./JobChapter";
import type { JobFragment } from "./JobChapter";
export interface JobProps {
name: string;
time?: string;
position?: string;
content?: JobFragment[];
trivial?: boolean;
last?: boolean;
}
const Job: React.FC<JobProps> = ({ last, ...props }) => {
const divProps = last
? { className: "job justify-text", style: { marginBottom: "-15px" } }
: { className: "job justify-text" };
return (
<div {...divProps}>
<JobChapter {...props} />
</div>
);
};
export default Job;

View File

@ -1,63 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import { composeKey } from "../../../utils/textUtils";
const JobFragment = ({ text, bullets }) => {
return (
<>
{text && text}
{bullets ? (
<ul>
{bullets.map(bullet => (
<li key={composeKey(bullet)}>{bullet}</li>
))}
</ul>
) : (
<ul></ul>
)}
</>
);
};
JobFragment.propTypes = {
text: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
bullets: PropTypes.arrayOf(
PropTypes.oneOfType([PropTypes.string, PropTypes.node])
)
};
const JobChapter = ({ name, time, position, content, trivial }) => {
return (
<>
<div className="upper-row">
{trivial ? (
<div className="job-title">{name}</div>
) : (
<h3 className="company">{name}</h3>
)}
<div className="time">{time}</div>
</div>
{position && <div className="job-title">{position}</div>}
{content.map(fragment => (
<JobFragment key={`job-fragment-${fragment.id}`} {...fragment} />
))}
</>
);
};
JobChapter.propTypes = {
name: PropTypes.string.isRequired,
time: PropTypes.string,
position: PropTypes.string,
content: PropTypes.arrayOf(
PropTypes.shape({
text: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
bullets: PropTypes.arrayOf(
PropTypes.oneOfType([PropTypes.string, PropTypes.node])
)
})
).isRequired,
trivial: PropTypes.bool
};
export default JobChapter;

View File

@ -0,0 +1,62 @@
import React from "react";
import { composeKey } from "../../../utils";
import { BulletContent } from "@/types";
export type JobFragment = {
id: number;
text?: string;
bullets?: BulletContent[];
};
type JobFragmentProps = JobFragment;
const JobFragment: React.FC<JobFragmentProps> = ({ text, bullets }) => (
<>
{text && text}
{bullets ? (
<ul>
{bullets.map(bullet => (
<li key={composeKey(bullet)}>{bullet}</li>
))}
</ul>
) : (
<ul></ul>
)}
</>
);
interface JobChapterProps {
name: string;
time?: string;
position?: string;
content?: JobFragment[];
trivial?: boolean;
}
const JobChapter: React.FC<JobChapterProps> = ({
name,
time,
position,
content,
trivial,
}) => (
<>
<div className="upper-row">
{trivial ? (
<div className="job-title">{name}</div>
) : (
<h3 className="company">{name}</h3>
)}
<div className="time">{time}</div>
</div>
{position && <div className="job-title">{position}</div>}
{content &&
content.map(fragment => (
<JobFragment
key={`job-fragment-${fragment.id ?? composeKey(fragment.text ?? "")}`}
{...fragment}
/>
))}
</>
);
export default JobChapter;

View File

@ -1,55 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
// styles
import "bootstrap/dist/css/bootstrap.min.css";
import "@fortawesome/fontawesome-free/css/all.min.css";
import "./styles/main.css";
// jQuery first, then Popper.js, then Bootstrap JS
import "jquery/dist/jquery.slim.min.js";
import "popper.js/dist/popper.min.js";
import "bootstrap/dist/js/bootstrap.min.js";
import Layout from "./components/Layout";
import { useLink } from "@flare/react-hooks";
import { constants, useTheme, useFavicon, utils } from "./theme";
const StandardCV = ({ data }) => {
const { theme, favicon, options } = data.configuration;
const { urlTheme: enableUrlTheme } = options;
useLink(
"https://fonts.googleapis.com/css?family=Noto+Sans&display=swap",
"stylesheet"
);
const themeFromUrl = enableUrlTheme ? utils.getThemeFromUrl() : null;
const _theme = themeFromUrl ?? theme ?? constants.defaultTheme;
useTheme(_theme);
useFavicon(_theme, favicon);
return <Layout data={data} />;
};
StandardCV.propTypes = {
data: PropTypes.shape({
configuration: PropTypes.shape({
theme: PropTypes.oneOf(constants.themes),
favicon: PropTypes.shape({
use: PropTypes.bool.isRequired,
id: PropTypes.string,
placeholder: PropTypes.string,
href: PropTypes.string
}).isRequired,
options: PropTypes.shape({
urlTheme: PropTypes.bool
}).isRequired
}).isRequired,
header: PropTypes.object.isRequired,
article: PropTypes.object.isRequired,
footer: PropTypes.object.isRequired
}).isRequired
};
export default StandardCV;

12
src/index.ts Normal file
View File

@ -0,0 +1,12 @@
// Bootstrap styles and FontAwesome icons
import "bootstrap/dist/css/bootstrap.min.css";
import "@fortawesome/fontawesome-free/css/all.min.css";
// Main styles
import "./styles/main.css";
import StandardCV from "./components/StandardCV";
export type * from "./types";
export { StandardCV };
export default StandardCV;

View File

@ -7,71 +7,71 @@
--shadow-color: rgba(0, 0, 0, 0.1);
}
body {
.standard-cv {
font-family: "Noto Sans", sans-serif;
padding: 15px 0px;
color: var(--text-color);
}
main {
.standard-cv main {
background-color: var(--background-color);
border: 1px solid var(--border-color);
box-shadow: 0 0 25px 0 var(--shadow-color);
}
a,
a:hover {
.standard-cv a,
.standard-cv a:hover {
color: var(--secondary-color);
}
h2 {
.standard-cv h2 {
font-size: 20px;
font-weight: 500;
color: var(--secondary-color);
}
.left-side {
.standard-cv .left-side {
padding: 25px;
}
.profile {
.standard-cv .profile {
text-align: center;
padding-bottom: 15px;
}
.profile h1 {
.standard-cv .profile h1 {
padding-top: 20px;
text-transform: uppercase;
}
.profile-inline {
.standard-cv .profile-inline {
text-align: center;
padding: 15px;
}
.profile-inline h1 {
.standard-cv .profile-inline h1 {
text-transform: uppercase;
font-size: 2.3rem;
}
.picture {
.standard-cv .picture {
background-repeat: no-repeat;
background-size: cover;
}
.picture img {
.standard-cv .picture img {
max-width: 100%;
}
.about-flat {
.standard-cv .about-flat {
font-size: 15px;
}
.justify-text {
.standard-cv .justify-text {
text-align: justify;
}
.btn-cv {
.standard-cv .btn-cv {
padding: 10px;
color: white;
background-color: var(--primary-color);
@ -79,111 +79,111 @@ h2 {
border-radius: 0px;
}
.btn-cv:hover {
.standard-cv .btn-cv:hover {
color: var(--background-color);
background-color: var(--secondary-color);
}
.btn:focus,
.btn:active {
.standard-cv .btn:focus,
.standard-cv .btn:active {
outline: none !important;
box-shadow: none;
color: white;
}
.social {
.standard-cv .social {
padding: 0;
list-style-type: none;
}
.social i {
.standard-cv .social i {
width: 20px;
}
.right-side {
.standard-cv .right-side {
padding: 0px;
}
section {
.standard-cv section {
padding: 40px;
border-bottom: 1px solid var(--border-color);
}
.section-title {
.standard-cv .section-title {
text-transform: uppercase;
margin-left: -5px;
}
.upper-row {
.standard-cv .upper-row {
position: relative;
}
.company,
.school-name {
.standard-cv .company,
.standard-cv .school-name {
color: var(--primary-color);
font-size: 15px;
font-weight: 500;
margin-bottom: 0px;
}
.time {
.standard-cv .time {
font-size: 15px;
font-weight: 500;
color: var(--primary-color);
}
.job-title,
.school-title {
.standard-cv .job-title,
.standard-cv .school-title {
font-size: 14px;
font-style: italic;
margin-bottom: 5px;
}
.job li,
section {
.standard-cv .job li,
.standard-cv section {
font-size: 15px;
}
.honors h3 {
.standard-cv .honors h3 {
color: var(--primary-color);
font-size: 15px;
font-weight: 500;
margin-bottom: 0px;
}
.contact-form {
.standard-cv .contact-form {
padding-top: 5px;
}
.form-group input,
.form-group textarea {
.standard-cv .form-group input,
.standard-cv .form-group textarea {
border-radius: 0px;
border-color: var(--border-color);
}
.form-control:focus {
.standard-cv .form-control:focus {
box-shadow: none;
border-radius: 0px;
border-color: var(--border-color);
}
footer {
.standard-cv footer {
text-align: center;
padding: 30px;
}
@media (max-width: 991.98px) {
.right-side {
.standard-cv .right-side {
border-top: 1px solid var(--border-color);
}
}
@media (min-width: 992px) {
.left-side {
.standard-cv .left-side {
border-right: 1px solid var(--border-color);
}
header {
.standard-cv header {
/* fixed sidebar */
position: -webkit-sticky;
position: sticky;
@ -192,7 +192,7 @@ footer {
}
@media (min-width: 768px) {
.time {
.standard-cv .time {
position: absolute;
right: 0;
top: 0;

150
src/test/mocks/data.ts Normal file
View File

@ -0,0 +1,150 @@
import type { StandardCVData } from "../../types";
export const mockCVData: StandardCVData = {
configuration: {
theme: "turquoise",
favicon: {
use: true,
id: "favicon-svg",
placeholder: "{#theme}",
href: "/icons/{#theme}-favicon.svg",
},
options: {
urlTheme: true,
},
},
header: {
profile: {
name: "Tudor Stanciu",
position: "Software Engineer",
picture: {
src: "/images/avatar.jpg",
alt: "John Doe's profile picture",
},
download: {
label: "Download CV",
src: "/files/cv.pdf",
},
},
about: {
content:
"Passionate software engineer with expertise in modern web technologies.",
},
networks: [
{
id: 1,
icon: "fas fa-envelope",
url: "mailto:john.doe@example.com",
label: "john.doe@example.com",
sameTab: true,
event: "mail-click",
},
{
id: 2,
icon: "fab fa-linkedin",
url: "https://linkedin.com/in/johndoe",
label: "Tudor Stanciu",
event: "linkedin-click",
},
],
},
article: {
education: {
visible: true,
icon: "fa-graduation-cap",
label: "Education",
elements: [
{
id: 1,
name: "University of Technology",
time: "2018-2022",
title: "Bachelor of Computer Science",
courses: {
label: "Relevant Coursework",
content: "Data Structures, Algorithms, Software Engineering",
},
},
],
},
work: {
visible: true,
icon: "fa-briefcase",
label: "Work Experience",
elements: [
{
name: "Tech Company",
time: "2022-Present",
position: "Senior Software Engineer",
content: [
{
id: 1,
text: "Developed web applications using React and TypeScript.",
bullets: ["Built responsive UIs", "Implemented REST APIs"],
},
],
},
],
},
projects: {
visible: true,
icon: "fa-pen",
label: "Projects",
elements: [
{
name: "Personal Website",
time: "2023",
content: [
{
id: 1,
bullets: [
"Built with React and TypeScript",
"Deployed on Vercel",
],
},
],
},
],
},
skills: {
visible: true,
icon: "fa-wrench",
label: "Technical Skills",
elements: [
{
id: 1,
type: "Languages",
description: "JavaScript, TypeScript, Python",
},
{
id: 2,
type: "Frameworks",
description: "React, Node.js, Express",
},
],
},
honors: {
visible: false,
icon: "fa-medal",
label: "Honors",
elements: [],
},
conferences: {
visible: true,
icon: "fa-users",
label: "Conferences",
elements: ["React Conference 2023", "TypeScript Summit 2023"],
},
},
footer: {
visible: true,
project: {
name: "Standard CV",
repository: "https://github.com/user/standard-cv",
},
owner: {
message: "Created by",
name: "Lab Team",
url: "https://lab.code-rove.com/ts-cv",
},
},
};

2
src/test/setup.ts Normal file
View File

@ -0,0 +1,2 @@
import "@testing-library/jest-dom";
import "@testing-library/jest-dom/vitest";

View File

@ -0,0 +1,44 @@
import React, { useMemo } from "react";
import clsx from "clsx";
import type { ThemeName, FaviconConfig } from "../types";
import { useTheme } from "./hooks/useTheme";
import { useFavicon } from "./hooks/useFavicon";
import { getThemeFromUrl } from "./utils";
import { useLink } from "@flare/lumrop";
interface ThemeProviderProps {
theme?: ThemeName;
favicon?: FaviconConfig;
enableUrlTheme?: boolean;
className?: string;
children: React.ReactNode;
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({
theme,
favicon,
enableUrlTheme = false,
className,
children,
}) => {
const themeFromUrl = useMemo(
() => (enableUrlTheme ? getThemeFromUrl() : null),
[enableUrlTheme]
);
const activeTheme = themeFromUrl || theme;
useTheme(activeTheme);
useFavicon(activeTheme, favicon);
useLink(
"https://fonts.googleapis.com/css?family=Noto+Sans&display=swap",
"stylesheet"
);
return (
<div className={clsx("theme-provider", className)} data-theme={activeTheme}>
{children}
</div>
);
};

View File

@ -0,0 +1,75 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getThemeConfig, getThemeFromUrl, isValidTheme } from "../utils";
describe("Theme Utils", () => {
describe("getThemeConfig", () => {
it("returns correct theme config for valid theme", () => {
const theme = getThemeConfig("blue");
expect(theme).toBeDefined();
expect(theme.colors).toBeDefined();
expect(theme.colors.primary).toBe("#65a3cc");
});
it("returns default theme config for invalid theme", () => {
const theme = getThemeConfig("invalid" as never);
expect(theme).toBeDefined();
expect(theme.colors.primary).toBe("#71c9ce"); // turquoise default
});
it("returns default theme config when no theme provided", () => {
const theme = getThemeConfig();
expect(theme).toBeDefined();
expect(theme.colors.primary).toBe("#71c9ce"); // turquoise default
});
});
describe("getThemeFromUrl", () => {
const mockWindow = {
location: {
search: "",
},
};
beforeEach(() => {
vi.stubGlobal("window", mockWindow);
});
it("returns theme from URL params when valid", () => {
mockWindow.location.search = "?theme=blue";
const theme = getThemeFromUrl();
expect(theme).toBe("blue");
});
it("returns null for invalid theme in URL", () => {
mockWindow.location.search = "?theme=invalid";
const theme = getThemeFromUrl();
expect(theme).toBe(null);
});
it("returns null when no theme param in URL", () => {
mockWindow.location.search = "?other=value";
const theme = getThemeFromUrl();
expect(theme).toBe(null);
});
it("returns null in server-side environment", () => {
vi.stubGlobal("window", undefined);
const theme = getThemeFromUrl();
expect(theme).toBe(null);
});
});
describe("isValidTheme", () => {
it("returns true for valid theme names", () => {
expect(isValidTheme("turquoise")).toBe(true);
expect(isValidTheme("blue")).toBe(true);
expect(isValidTheme("green")).toBe(true);
});
it("returns false for invalid theme names", () => {
expect(isValidTheme("invalid")).toBe(false);
expect(isValidTheme("")).toBe(false);
expect(isValidTheme("BLUE")).toBe(false); // case sensitive
});
});
});

View File

@ -1,29 +0,0 @@
const theme = {
TURQUOISE: "turquoise",
BLUE: "blue",
GREEN: "green",
BROWN: "brown",
ORANGE: "orange",
PURPLE: "purple",
PINK: "pink",
CORAL: "coral",
NUDE: "nude",
RAINBOW: "rainbow"
};
const themes = [
theme.TURQUOISE,
theme.BLUE,
theme.GREEN,
theme.BROWN,
theme.ORANGE,
theme.PURPLE,
theme.PINK,
theme.CORAL,
theme.NUDE,
theme.RAINBOW
];
const defaultTheme = theme.TURQUOISE;
export { defaultTheme, theme, themes };

29
src/theme/constants.ts Normal file
View File

@ -0,0 +1,29 @@
import type { ThemeName } from "../types";
export const THEME_NAMES = {
TURQUOISE: "turquoise" as const,
BLUE: "blue" as const,
GREEN: "green" as const,
BROWN: "brown" as const,
ORANGE: "orange" as const,
PURPLE: "purple" as const,
PINK: "pink" as const,
CORAL: "coral" as const,
NUDE: "nude" as const,
RAINBOW: "rainbow" as const,
} as const;
export const THEME_LIST: ThemeName[] = [
THEME_NAMES.TURQUOISE,
THEME_NAMES.BLUE,
THEME_NAMES.GREEN,
THEME_NAMES.BROWN,
THEME_NAMES.ORANGE,
THEME_NAMES.PURPLE,
THEME_NAMES.PINK,
THEME_NAMES.CORAL,
THEME_NAMES.NUDE,
THEME_NAMES.RAINBOW,
];
export const DEFAULT_THEME: ThemeName = THEME_NAMES.TURQUOISE;

View File

@ -1,21 +0,0 @@
import { useEffect } from "react";
const useFavicon = (themeName, configuration) => {
const { use, id: faviconElementId, placeholder, href } = configuration;
if (
use &&
(!faviconElementId || !placeholder || !href || !href.includes(placeholder))
) {
throw Error("Invalid favicon configuration.");
}
useEffect(() => {
if (!use) return;
const favicon = document.getElementById(faviconElementId);
if (favicon) {
favicon.href = href.replace(placeholder, themeName);
}
}, [use, themeName, faviconElementId, placeholder, href]);
};
export default useFavicon;

View File

@ -0,0 +1,35 @@
import { useEffect } from "react";
import type { ThemeName, FaviconConfig } from "../../types";
export const useFavicon = (
themeName?: ThemeName,
faviconConfig?: FaviconConfig
): void => {
useEffect(() => {
if (!faviconConfig?.use || !faviconConfig.href || !themeName) {
return;
}
const { id, placeholder = "{#theme}", href } = faviconConfig;
const faviconUrl = href.replace(placeholder, themeName);
// Remove existing favicon if it exists
const existingFavicon = document.querySelector<HTMLLinkElement>(
id ? `#${id}` : 'link[rel*="icon"]'
);
if (existingFavicon) {
existingFavicon.remove();
}
// Create and append new favicon
const favicon = document.createElement("link");
favicon.rel = "icon";
favicon.type = "image/svg+xml";
favicon.href = faviconUrl;
if (id) {
favicon.id = id;
}
document.head.appendChild(favicon);
}, [themeName, faviconConfig]);
};

View File

@ -1,71 +0,0 @@
import { useEffect } from "react";
import {
turquoise,
blue,
brown,
pink,
purple,
orange,
coral,
nude,
green
} from "../variants";
import { theme } from "../constants";
const getTheme = name => {
switch (name) {
case theme.TURQUOISE:
return turquoise;
case theme.BLUE:
return blue;
case theme.BROWN:
return brown;
case theme.PINK:
return pink;
case theme.PURPLE:
return purple;
case theme.ORANGE:
return orange;
case theme.CORAL:
return coral;
case theme.NUDE:
return nude;
case theme.GREEN:
return green;
default:
return turquoise;
}
};
const useTheme = name => {
useEffect(() => {
const theme = getTheme(name);
document.documentElement.style.setProperty(
"--primary-color",
theme.colors.primary
);
document.documentElement.style.setProperty(
"--secondary-color",
theme.colors.secondary
);
document.documentElement.style.setProperty(
"--text-color",
theme.colors.text
);
document.documentElement.style.setProperty(
"--border-color",
theme.colors.border
);
document.documentElement.style.setProperty(
"--background-color",
theme.colors.background
);
document.documentElement.style.setProperty(
"--shadow-color",
theme.colors.shadow
);
}, [name]);
};
export default useTheme;

View File

@ -0,0 +1,18 @@
import { useEffect } from "react";
import type { ThemeName } from "../../types";
import { getThemeConfig } from "../utils";
export const useTheme = (themeName?: ThemeName): void => {
useEffect(() => {
const theme = getThemeConfig(themeName);
const root = document.documentElement;
// Apply CSS custom properties
root.style.setProperty("--primary-color", theme.colors.primary);
root.style.setProperty("--secondary-color", theme.colors.secondary);
root.style.setProperty("--text-color", theme.colors.text);
root.style.setProperty("--border-color", theme.colors.border);
root.style.setProperty("--background-color", theme.colors.background);
root.style.setProperty("--shadow-color", theme.colors.shadow);
}, [themeName]);
};

View File

@ -1,6 +0,0 @@
import useTheme from "./hooks/useTheme";
import useFavicon from "./hooks/useFavicon";
import * as constants from "./constants";
import * as utils from "./utils";
export { constants, useTheme, useFavicon, utils };

View File

@ -1,6 +0,0 @@
export const getThemeFromUrl = () => {
const urlString = window.location.href;
const url = new URL(urlString);
const theme = url.searchParams.get("theme");
return theme;
};

23
src/theme/utils.ts Normal file
View File

@ -0,0 +1,23 @@
import type { ThemeName, Theme } from "../types";
import { DEFAULT_THEME, THEME_LIST } from "./constants";
import * as variants from "./variants";
export const getThemeConfig = (themeName?: ThemeName): Theme => {
const validTheme =
themeName && THEME_LIST.includes(themeName) ? themeName : DEFAULT_THEME;
return variants[validTheme] || variants[DEFAULT_THEME];
};
export const getThemeFromUrl = (): ThemeName | null => {
if (typeof window === "undefined") return null;
const urlParams = new URLSearchParams(window.location.search);
const themeParam = urlParams.get("theme") as ThemeName;
return themeParam && THEME_LIST.includes(themeParam) ? themeParam : null;
};
export const isValidTheme = (theme: string): theme is ThemeName => {
return THEME_LIST.includes(theme as ThemeName);
};

View File

@ -1,12 +0,0 @@
const theme = {
colors: {
primary: "#65a3cc",
secondary: "#5499c7",
text: "#3d3d3f",
border: "#ddd",
background: "#fff",
shadow: "rgba(0, 0, 0, 0.1)"
}
};
export default theme;

View File

@ -1,12 +0,0 @@
const theme = {
colors: {
primary: "#d27065",
secondary: "#cd6155",
text: "#3d3d3f",
border: "#ddd",
background: "#fff",
shadow: "rgba(0, 0, 0, 0.1)"
}
};
export default theme;

View File

@ -1,12 +0,0 @@
const theme = {
colors: {
primary: "#ff9483",
secondary: "#F1948A",
text: "#3d3d3f",
border: "#ddd",
background: "#fff",
shadow: "rgba(0, 0, 0, 0.1)"
}
};
export default theme;

View File

@ -1,12 +0,0 @@
const theme = {
colors: {
primary: "#3d884d",
secondary: "#367a45",
text: "#3d3d3f",
border: "#ddd",
background: "#fff",
shadow: "rgba(0, 0, 0, 0.1)"
}
};
export default theme;

View File

@ -1,11 +0,0 @@
import turquoise from "./turquoise";
import blue from "./blue";
import brown from "./brown";
import pink from "./pink";
import purple from "./purple";
import orange from "./orange";
import coral from "./coral";
import nude from "./nude";
import green from "./green";
export { turquoise, blue, brown, pink, purple, orange, coral, nude, green };

113
src/theme/variants/index.ts Normal file
View File

@ -0,0 +1,113 @@
import type { Theme } from "../../types";
export const turquoise: Theme = {
colors: {
primary: "#71c9ce",
secondary: "#64b2b6",
text: "#3d3d3f",
border: "#ddd",
background: "#fff",
shadow: "rgba(0, 0, 0, 0.1)",
},
};
export const blue: Theme = {
colors: {
primary: "#65a3cc",
secondary: "#5499c7",
text: "#3d3d3f",
border: "#ddd",
background: "#fff",
shadow: "rgba(0, 0, 0, 0.1)",
},
};
export const green: Theme = {
colors: {
primary: "#3d884d",
secondary: "#367a45",
text: "#3d3d3f",
border: "#ddd",
background: "#fff",
shadow: "rgba(0, 0, 0, 0.1)",
},
};
export const brown: Theme = {
colors: {
primary: "#d27065",
secondary: "#cd6155",
text: "#3d3d3f",
border: "#ddd",
background: "#fff",
shadow: "rgba(0, 0, 0, 0.1)",
},
};
export const orange: Theme = {
colors: {
primary: "#f49f2b",
secondary: "#f39514",
text: "#3d3d3f",
border: "#ddd",
background: "#fff",
shadow: "rgba(0, 0, 0, 0.1)",
},
};
export const purple: Theme = {
colors: {
primary: "#c19ad2",
secondary: "#BB8FCE",
text: "#3d3d3f",
border: "#ddd",
background: "#fff",
shadow: "rgba(0, 0, 0, 0.1)",
},
};
export const pink: Theme = {
colors: {
primary: "#d5819b",
secondary: "#D17390",
text: "#3d3d3f",
border: "#ddd",
background: "#fff",
shadow: "rgba(0, 0, 0, 0.1)",
},
};
export const coral: Theme = {
colors: {
primary: "#ff9483",
secondary: "#F1948A",
text: "#3d3d3f",
border: "#ddd",
background: "#fff",
shadow: "rgba(0, 0, 0, 0.1)",
},
};
export const nude: Theme = {
colors: {
primary: "#f29e95",
secondary: "#f1948a",
text: "#3d3d3f",
border: "#ddd",
background: "#fff",
shadow: "rgba(0, 0, 0, 0.1)",
},
};
export const rainbow: Theme = {
colors: {
primary:
"linear-gradient(45deg, #FF6B6B, #4ECDC4, #45B7D1, #96CEB4, #FECA57, #FF9FF3)",
secondary:
"linear-gradient(45deg, #FF5252, #26C6DA, #42A5F5, #66BB6A, #FFCA28, #AB47BC)",
text: "#495057",
border: "#DEE2E6",
background: "#FFFFFF",
shadow: "rgba(0, 0, 0, 0.1)",
},
};

View File

@ -1,12 +0,0 @@
const theme = {
colors: {
primary: "#f29e95",
secondary: "#f1948a",
text: "#3d3d3f",
border: "#ddd",
background: "#fff",
shadow: "rgba(0, 0, 0, 0.1)"
}
};
export default theme;

View File

@ -1,12 +0,0 @@
const theme = {
colors: {
primary: "#f49f2b",
secondary: "#f39514",
text: "#3d3d3f",
border: "#ddd",
background: "#fff",
shadow: "rgba(0, 0, 0, 0.1)"
}
};
export default theme;

View File

@ -1,12 +0,0 @@
const theme = {
colors: {
primary: "#d5819b",
secondary: "#D17390",
text: "#3d3d3f",
border: "#ddd",
background: "#fff",
shadow: "rgba(0, 0, 0, 0.1)"
}
};
export default theme;

View File

@ -1,12 +0,0 @@
const theme = {
colors: {
primary: "#c19ad2",
secondary: "#BB8FCE",
text: "#3d3d3f",
border: "#ddd",
background: "#fff",
shadow: "rgba(0, 0, 0, 0.1)"
}
};
export default theme;

View File

@ -1,12 +0,0 @@
const theme = {
colors: {
primary: "#71c9ce",
secondary: "#64b2b6",
text: "#3d3d3f",
border: "#ddd",
background: "#fff",
shadow: "rgba(0, 0, 0, 0.1)"
}
};
export default theme;

9
src/types/globals.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
/// <reference types="@testing-library/jest-dom" />
declare global {
interface Window {
umami?: (event: string) => void;
}
}
export {};

225
src/types/index.ts Normal file
View File

@ -0,0 +1,225 @@
import { ReactNode } from "react";
export type ThemeName =
| "turquoise"
| "blue"
| "green"
| "brown"
| "orange"
| "purple"
| "pink"
| "coral"
| "nude"
| "rainbow";
export interface ThemeColors {
primary: string;
secondary: string;
text: string;
border: string;
background: string;
shadow: string;
}
export interface Theme {
colors: ThemeColors;
}
export interface FaviconConfig {
use: boolean;
id?: string;
placeholder?: string;
href?: string;
}
export interface ConfigurationOptions {
urlTheme?: boolean;
}
export interface Configuration {
theme?: ThemeName;
favicon: FaviconConfig;
options: ConfigurationOptions;
}
export interface Picture {
src: string;
alt: string;
}
export interface Download {
label: string;
src: string;
}
export interface Profile {
name: string;
position: string;
picture?: Picture;
download?: Download;
}
export interface About {
content: ReactNode;
}
export interface SocialNetwork {
id: number;
icon: string;
url: string;
label: string;
sameTab?: boolean;
event?: string;
}
export interface Header {
profile: Profile;
about: About;
networks: SocialNetwork[];
}
export interface Courses {
label: string;
content: string;
}
export interface EducationElement {
id: number;
name: string;
time: string;
title: string;
courses?: Courses;
}
export interface Education {
visible: boolean;
icon: string;
label: string;
elements: EducationElement[];
}
export interface JobContent {
id: number;
text: string;
bullets?: BulletContent[];
}
export interface Position {
name: string;
time: string;
content: JobContent[];
}
export interface WorkElement {
name: string;
time?: string;
position?: string;
positions?: Position[];
content?: JobContent[];
}
export interface Work {
visible: boolean;
icon: string;
label: string;
elements: WorkElement[];
}
export interface ProjectContent {
id: number;
text?: string;
bullets?: BulletContent[];
}
export type BulletContent = string | ReactNode;
export interface ProjectElement {
name: string;
time: string;
content: ProjectContent[];
}
export interface Projects {
visible: boolean;
icon: string;
label: string;
elements: ProjectElement[];
}
export interface SkillElement {
id: number;
type: string;
description: string | ReactNode;
}
export interface Skills {
visible: boolean;
icon: string;
label: string;
elements: SkillElement[];
}
export interface HonorElement {
id: number;
name: string;
abbreviation?: string;
bullets: string[];
}
export interface Honors {
visible: boolean;
icon: string;
label: string;
elements: HonorElement[];
}
export interface Conferences {
visible: boolean;
icon: string;
label: string;
elements: string[];
}
export interface Article {
education: Education;
work: Work;
projects: Projects;
skills: Skills;
honors: Honors;
conferences: Conferences;
}
export interface FooterProject {
name: string;
repository: string;
}
export interface FooterOwner {
message: string;
name: string;
url: string;
}
export interface Footer {
visible: boolean;
project?: FooterProject;
owner?: FooterOwner;
custom?: ReactNode;
}
export interface StandardCVData {
configuration: Configuration;
header: Header;
article: Article;
footer: Footer;
}
export interface StandardCVProps {
data: StandardCVData;
className?: string;
}
export type SectionProps<T = unknown> = {
data: T;
className?: string;
};

View File

@ -1,9 +1,9 @@
const composeKey = input => {
function composeKey(input: string | unknown): string {
if (typeof input === "string" || input instanceof String) {
return input.replace(/\s+/g, "-").toLowerCase(); //replace spaces with dashes
return (input as string).replace(/\s+/g, "-").toLowerCase();
}
const key = Date.now();
return `generated-key-${key}`;
};
}
export { composeKey };

37
tsconfig.json Normal file
View File

@ -0,0 +1,37 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Paths */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Types */
"types": ["vitest/globals", "@testing-library/jest-dom"]
/* Declaration - only for build */
},
"include": ["src/**/*", "**/*.test.*", "**/*.spec.*"],
"exclude": ["node_modules", "dist"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
tsconfig.node.json Normal file
View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}

42
vite.config.ts Normal file
View File

@ -0,0 +1,42 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import dts from "vite-plugin-dts";
import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js";
import path from "path";
export default defineConfig({
plugins: [
react(),
cssInjectedByJsPlugin(),
dts({
insertTypesEntry: true,
include: ["src/**/*"],
exclude: ["src/**/*.test.*", "src/**/*.spec.*"],
}),
],
build: {
lib: {
entry: path.resolve(__dirname, "src/index.ts"),
name: "StandardCV",
formats: ["es"],
fileName: "index",
},
rollupOptions: {
external: ["react", "react-dom"],
output: {
globals: {
react: "React",
"react-dom": "ReactDOM",
},
},
},
sourcemap: true,
minify: "esbuild",
cssCodeSplit: false,
},
css: {
modules: {
localsConvention: "camelCase",
},
},
});

13
vitest.config.ts Normal file
View File

@ -0,0 +1,13 @@
/// <reference types="vitest" />
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "happy-dom",
setupFiles: "./src/test/setup.ts",
css: true,
},
});