15 Commits

Author SHA1 Message Date
semantic-release-bot
7aaca06ce7 chore(release): 1.0.0 [skip ci]
## 1.0.0 (2026-02-23)

### Features

* **api:** add backend api with express and sqlite ([00af886](00af8862d3))
* **api:** add initial schema migration ([3b0d98f](3b0d98f15c))
* **api:** add knex configuration ([7e766c5](7e766c5229))
* **api:** add swagger docs route ([736fefb](736fefbdc9))
* **api:** register swagger docs route ([4c0f53a](4c0f53a3e3))
* **ci:** add bundlesize configuration ([dee36da](dee36dafd3))
* **ci:** add commitlint validation to ci workflow ([1541212](154121297b))
* **ci:** add coverage configuration ([278082d](278082d322))
* **ci:** add gitleaks configuration ([32f3221](32f3221215))
* **ci:** add gitleaks to pre-commit hook ([c18410b](c18410bf5e))
* **ci:** add lighthouse ci configuration ([f6f2dba](f6f2dba9e0))
* **ci:** add nightly build workflow ([5b56edd](5b56edddb2))
* **ci:** add quality gates to ci workflow ([963b8ec](963b8eccf8))
* **ci:** add staging deployment workflow ([f5a00d3](f5a00d356a))
* **docker:** add multi-platform build configuration ([6b7eba9](6b7eba99d4))
* **ui:** add cv application frontend and configuration ([7f06ee7](7f06ee7f53))

### Bug Fixes

* **ci:** Set node versions in ci ([60d0aa5](60d0aa5ae2))
* **ci:** use lts version for ci ([cd22a97](cd22a970cb))
* **ci:** use POSIX-compatible syntax in pre-commit hook ([c240aa3](c240aa3641))

### Documentation

* add comprehensive release engineering documentation ([c8816c4](c8816c4f36))
* add gitleaks setup and api docs info ([cbf4090](cbf40908a6))
* add initial changelog ([e209d6e](e209d6e241))
* add release engineering design document ([1741fe1](1741fe1ca1))
* add release engineering implementation plan ([b3f2919](b3f2919791))
* add release process documentation ([f544577](f5445770ee))
* **api:** add openapi docs to auth routes ([8a22e4b](8a22e4b120))
* **release:** add devops features design document ([252afb7](252afb7e9f))
* **release:** add devops features implementation plan ([162ddb6](162ddb67dd))
* **release:** add link to release engineering documentation ([950d812](950d812159))
* **release:** remove inline markdownlint disable comment ([31d5810](31d58103fd))
* **ui:** add open source documentation files ([15a5c0a](15a5c0a59c))
2026-02-23 18:56:36 +00:00
Tuan-Dat Tran
c240aa3641 fix(ci): use POSIX-compatible syntax in pre-commit hook
Some checks failed
Release / Release (push) Failing after 6m48s
Release / Build & Push Docker Image (push) Has been skipped
2026-02-23 19:48:11 +01:00
Tuan-Dat Tran
cd22a970cb fix(ci): use lts version for ci
Some checks failed
Release / Release (push) Failing after 10m4s
Release / Build & Push Docker Image (push) Has been skipped
Signed-off-by: Tuan-Dat Tran <tuan-dat.tran@tudattr.dev>
2026-02-23 19:33:36 +01:00
Tuan-Dat Tran
60d0aa5ae2 fix(ci): Set node versions in ci
Some checks failed
Release / Release (push) Failing after 9s
Release / Build & Push Docker Image (push) Has been skipped
Signed-off-by: Tuan-Dat Tran <tuan-dat.tran@tudattr.dev>
2026-02-23 19:27:48 +01:00
Tuan-Dat Tran
ff26ff6570 chore(ci): bump node version
Some checks failed
Release / Release (push) Failing after 13s
Release / Build & Push Docker Image (push) Has been skipped
Signed-off-by: Tuan-Dat Tran <tuan-dat.tran@tudattr.dev>
2026-02-23 19:20:53 +01:00
Tuan-Dat Tran
baa57473ac chore(ci): add coverage to gitignore and clean up artifacts
Some checks failed
Release / Release (push) Failing after 14m49s
Release / Build & Push Docker Image (push) Has been skipped
2026-02-23 13:50:12 +01:00
Tuan-Dat Tran
31d58103fd docs(release): remove inline markdownlint disable comment 2026-02-23 13:49:27 +01:00
Tuan-Dat Tran
15a5c0a59c docs(ui): add open source documentation files 2026-02-23 13:48:52 +01:00
Tuan-Dat Tran
36b5a62953 ci(ui): add github workflows and issue templates 2026-02-23 13:48:28 +01:00
Tuan-Dat Tran
4198b91498 test(ui): add e2e and integration tests 2026-02-23 13:48:13 +01:00
Tuan-Dat Tran
00af8862d3 feat(api): add backend api with express and sqlite 2026-02-23 13:47:44 +01:00
Tuan-Dat Tran
7f06ee7f53 feat(ui): add cv application frontend and configuration 2026-02-23 13:47:08 +01:00
Tuan-Dat Tran
cbf40908a6 docs: add gitleaks setup and api docs info 2026-02-20 17:42:46 +01:00
Tuan-Dat Tran
3d2ee7df7f test(api): update tests for knex 2026-02-20 17:36:03 +01:00
Tuan-Dat Tran
c18410bf5e feat(ci): add gitleaks to pre-commit hook 2026-02-20 17:27:40 +01:00
76 changed files with 4352 additions and 172 deletions

63
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,63 @@
name: Bug Report
description: Report a bug to help us improve
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report this bug!
- type: textarea
id: description
attributes:
label: Description
description: A clear description of the bug
placeholder: "When I do X, Y happens instead of Z..."
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
description: Steps to reproduce the behavior
placeholder: |
1. Go to '...'
2. Click on '...'
3. Scroll to '...'
4. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What did you expect to happen?
validations:
required: true
- type: input
id: browser
attributes:
label: Browser
description: Which browser(s) are you experiencing the issue?
placeholder: "Chrome 120, Firefox 121, Safari 17..."
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: Add screenshots if applicable
- type: dropdown
id: severity
attributes:
label: Severity
options:
- Low - Minor inconvenience
- Medium - Affects functionality
- High - Major feature broken
- Critical - Site unusable
validations:
required: true

View File

@@ -0,0 +1,46 @@
name: Feature Request
description: Suggest a new feature or enhancement
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for suggesting a feature!
- type: textarea
id: problem
attributes:
label: Problem/Use Case
description: Is your feature request related to a problem? Please describe.
placeholder: "I'm always frustrated when..."
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: Describe the solution you'd like
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Any alternative solutions you've considered?
- type: dropdown
id: priority
attributes:
label: Priority
options:
- Low - Nice to have
- Medium - Would improve experience
- High - Important for usability
validations:
required: true
- type: textarea
id: additional
attributes:
label: Additional Context
description: Any other context, mockups, or screenshots

25
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,25 @@
## Description
<!-- Brief description of changes -->
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Documentation update
## Checklist
- [ ] I have followed the [Contributing Guidelines](CONTRIBUTING.md)
- [ ] Lint passes (`npm run lint`)
- [ ] Build succeeds (`npm run build`)
- [ ] I have tested my changes locally
## Screenshots (if applicable)
<!-- Add screenshots here -->
## Related Issues
<!-- Link any related issues: Closes #123 -->

32
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "06:00"
open-pull-requests-limit: 5
reviewers:
- "tuan-dat-tran"
labels:
- "dependencies"
commit-message:
prefix: "deps"
include: "scope"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "06:00"
open-pull-requests-limit: 3
reviewers:
- "tuan-dat-tran"
labels:
- "dependencies"
- "github-actions"
commit-message:
prefix: "ci"
include: "scope"

View File

@@ -14,53 +14,53 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Validate current commit
if: github.event_name == 'push'
run: npx commitlint --from HEAD~1 --to HEAD --verbose
- name: Validate PR commits
if: github.event_name == 'pull_request'
run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
frontend:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
node-version: [latest, 24.*, 25.*, 26.*]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Unit Tests
run: npm run test:run
- name: Build
run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
@@ -70,20 +70,20 @@ jobs:
backend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
node-version: "20.x"
- name: Install dependencies
working-directory: ./backend
run: npm ci
- name: Test
working-directory: ./backend
run: npm test
@@ -91,46 +91,46 @@ jobs:
integration:
runs-on: ubuntu-latest
needs: [frontend, backend, commitlint]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
node-version: "20.x"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run Integration Tests
run: npm run test:integration
e2e:
runs-on: ubuntu-latest
needs: [frontend, backend, commitlint]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
node-version: "20.x"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E Tests
run: npm run test:e2e
- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: always()
@@ -142,23 +142,23 @@ jobs:
regression:
runs-on: ubuntu-latest
needs: [frontend, backend, commitlint]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
node-version: "20.x"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run Regression Tests
run: npm run test:regression
- name: Upload snapshot diffs
uses: actions/upload-artifact@v4
if: failure()
@@ -170,23 +170,23 @@ jobs:
lighthouse:
runs-on: ubuntu-latest
needs: [frontend]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
node-version: "20.x"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Run Lighthouse CI
run: npm run test:lighthouse
continue-on-error: true
@@ -194,35 +194,35 @@ jobs:
quality:
name: Quality Gates
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run tests with coverage
run: npm run test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: Build
run: npm run build
- name: Check bundle size
run: npx bundlesize
- name: Security audit
run: npm audit --audit-level=moderate
continue-on-error: true
continue-on-error: true

55
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,55 @@
name: Deploy to GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
env:
BASE_URL: /${{ github.event.repository.name }}/
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: "dist"
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

@@ -2,7 +2,7 @@ name: Nightly Build
on:
schedule:
- cron: '0 2 * * *'
- cron: "0 2 * * *"
workflow_dispatch:
permissions:
@@ -14,52 +14,52 @@ jobs:
name: Build Nightly Image
runs-on: ubuntu-latest
environment: nightly
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: master
- name: Get date
id: date
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Install backend dependencies
working-directory: ./backend
run: npm ci
- name: Build
run: npm run build
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:

View File

@@ -15,40 +15,40 @@ jobs:
name: Release
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, '[skip ci]')"
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Install backend dependencies
working-directory: ./backend
run: npm ci
- name: Lint
run: npm run lint
- name: Run tests
run: npm run test:run
- name: Run backend tests
working-directory: ./backend
run: npm test
- name: Build
run: npm run build
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -59,39 +59,39 @@ jobs:
runs-on: ubuntu-latest
needs: release
if: needs.release.result == 'success'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get version
id: version
run: |
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "latest")
echo "version=${VERSION#v}" >> $GITHUB_OUTPUT
echo "tag=${VERSION}" >> $GITHUB_OUTPUT
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:

View File

@@ -14,52 +14,52 @@ jobs:
name: Build & Deploy to Staging
runs-on: ubuntu-latest
environment: staging
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Install backend dependencies
working-directory: ./backend
run: npm ci
- name: Lint
run: npm run lint
- name: Run tests
run: npm run test:run
- name: Build
run: npm run build
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:

37
.github/workflows/stale.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Stale Issues
on:
schedule:
- cron: '30 1 * * *'
workflow_dispatch:
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-issue-stale: 60
days-before-issue-close: 14
days-before-pr-stale: 30
days-before-pr-close: 14
stale-issue-message: |
This issue has been automatically marked as stale because it has not had recent activity.
It will be closed if no further activity occurs within 14 days.
Thank you for your contributions.
stale-pr-message: |
This PR has been automatically marked as stale because it has not had recent activity.
It will be closed if no further activity occurs within 14 days.
Thank you for your contributions.
close-issue-message: |
This issue was closed because it has been stalled for 14 days with no activity.
close-pr-message: |
This PR was closed because it has been stalled for 14 days with no activity.
stale-issue-label: 'stale'
stale-pr-label: 'stale'
exempt-issue-labels: 'pinned,security,bug'
exempt-pr-labels: 'pinned,security'

34
.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Database
backend/data/
*.db
*.db-journal
*.db-wal
# Test files
backend/__tests__/*.db
coverage/

View File

@@ -1 +1,13 @@
npm run lint
# Gitleaks secret scanning
if command -v gitleaks > /dev/null 2>&1; then
gitleaks protect --verbose --staged
if [ $? -eq 1 ]; then
echo ""
echo "❌ Secrets detected in staged files!"
echo "Please remove sensitive data before committing."
echo ""
exit 1
fi
fi

View File

@@ -2,6 +2,48 @@
All notable changes to this project will be documented in this file.
## 1.0.0 (2026-02-23)
### Features
* **api:** add backend api with express and sqlite ([00af886](https://git.seyshiro.de/tudattr/kilo-cv/commit/00af8862d3f642edc1f38beecfbd703fe562f722))
* **api:** add initial schema migration ([3b0d98f](https://git.seyshiro.de/tudattr/kilo-cv/commit/3b0d98f15cadd6c03dc298559abe6ac58c26c9b0))
* **api:** add knex configuration ([7e766c5](https://git.seyshiro.de/tudattr/kilo-cv/commit/7e766c52294f9b06161d168ef4e114a6fb0560ba))
* **api:** add swagger docs route ([736fefb](https://git.seyshiro.de/tudattr/kilo-cv/commit/736fefbdc9323412d37309d95de842a6d86f2e17))
* **api:** register swagger docs route ([4c0f53a](https://git.seyshiro.de/tudattr/kilo-cv/commit/4c0f53a3e3fc8d0c25980396429643ab43a231a6))
* **ci:** add bundlesize configuration ([dee36da](https://git.seyshiro.de/tudattr/kilo-cv/commit/dee36dafd3bfa25873f3232b8e83d71ad5a5aa36))
* **ci:** add commitlint validation to ci workflow ([1541212](https://git.seyshiro.de/tudattr/kilo-cv/commit/154121297b5845c41a53fc2761e263671e3771c8))
* **ci:** add coverage configuration ([278082d](https://git.seyshiro.de/tudattr/kilo-cv/commit/278082d32219107d157dbb8126611d4f2dca6a9a))
* **ci:** add gitleaks configuration ([32f3221](https://git.seyshiro.de/tudattr/kilo-cv/commit/32f32212150d0114a6fdd048cfaffec07fbe74a6))
* **ci:** add gitleaks to pre-commit hook ([c18410b](https://git.seyshiro.de/tudattr/kilo-cv/commit/c18410bf5ebe3048db3ddadcbbc0afa55147f241))
* **ci:** add lighthouse ci configuration ([f6f2dba](https://git.seyshiro.de/tudattr/kilo-cv/commit/f6f2dba9e0e396a99fed200923acc8640ff0fd76))
* **ci:** add nightly build workflow ([5b56edd](https://git.seyshiro.de/tudattr/kilo-cv/commit/5b56edddb2ffddecf124d29f92825723230fd9d5))
* **ci:** add quality gates to ci workflow ([963b8ec](https://git.seyshiro.de/tudattr/kilo-cv/commit/963b8eccf8af59d398f4973504fd43a72ed23d98))
* **ci:** add staging deployment workflow ([f5a00d3](https://git.seyshiro.de/tudattr/kilo-cv/commit/f5a00d356ae04f9f1180a051918c221a1f9e521b))
* **docker:** add multi-platform build configuration ([6b7eba9](https://git.seyshiro.de/tudattr/kilo-cv/commit/6b7eba99d4c989dce7ba1a8ea2c7ecbc857eefcd))
* **ui:** add cv application frontend and configuration ([7f06ee7](https://git.seyshiro.de/tudattr/kilo-cv/commit/7f06ee7f53bcf693a52903ca3b89dba022798c0e))
### Bug Fixes
* **ci:** Set node versions in ci ([60d0aa5](https://git.seyshiro.de/tudattr/kilo-cv/commit/60d0aa5ae29a5ed9c327061fa5b20e458109a534))
* **ci:** use lts version for ci ([cd22a97](https://git.seyshiro.de/tudattr/kilo-cv/commit/cd22a970cbf21a8cd9806a6eccf61e622c8efe80))
* **ci:** use POSIX-compatible syntax in pre-commit hook ([c240aa3](https://git.seyshiro.de/tudattr/kilo-cv/commit/c240aa3641215b76bcf5af645a05fcebe611a43c))
### Documentation
* add comprehensive release engineering documentation ([c8816c4](https://git.seyshiro.de/tudattr/kilo-cv/commit/c8816c4f36db9731b0b42f0584b466e1666042ff))
* add gitleaks setup and api docs info ([cbf4090](https://git.seyshiro.de/tudattr/kilo-cv/commit/cbf40908a692252bacf35e9d849f786716caacc8))
* add initial changelog ([e209d6e](https://git.seyshiro.de/tudattr/kilo-cv/commit/e209d6e24135715b3f09ade4c9abb1e8a07f5be8))
* add release engineering design document ([1741fe1](https://git.seyshiro.de/tudattr/kilo-cv/commit/1741fe1ca1c38195520a394df90a983993666d39))
* add release engineering implementation plan ([b3f2919](https://git.seyshiro.de/tudattr/kilo-cv/commit/b3f29197910d8b17d6327b03831ac2b03eba7247))
* add release process documentation ([f544577](https://git.seyshiro.de/tudattr/kilo-cv/commit/f5445770ee36f9364f036ba0d1e89363fe726734))
* **api:** add openapi docs to auth routes ([8a22e4b](https://git.seyshiro.de/tudattr/kilo-cv/commit/8a22e4b120213e070783715b8fd8ee6919ad0e83))
* **release:** add devops features design document ([252afb7](https://git.seyshiro.de/tudattr/kilo-cv/commit/252afb7e9f3f76348667c572d0dcfa5a67a4be76))
* **release:** add devops features implementation plan ([162ddb6](https://git.seyshiro.de/tudattr/kilo-cv/commit/162ddb67dd570b8ac257e1d2d6f0eac40dddf24a))
* **release:** add link to release engineering documentation ([950d812](https://git.seyshiro.de/tudattr/kilo-cv/commit/950d812159528093c67c91a64f24dab107348bed))
* **release:** remove inline markdownlint disable comment ([31d5810](https://git.seyshiro.de/tudattr/kilo-cv/commit/31d58103fd195db42d0bbfa9c262a23beb7b1734))
* **ui:** add open source documentation files ([15a5c0a](https://git.seyshiro.de/tudattr/kilo-cv/commit/15a5c0a59cfb51c124577107368ac620bf7a5635))
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).

1
CODEOWNERS Normal file
View File

@@ -0,0 +1 @@
* @tuan-dat-tran

60
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,60 @@
# Contributing to CV
Thank you for your interest in contributing!
## Quick Start
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
3. Make your changes
4. Run linting (`npm run lint`)
5. Commit your changes (`git commit -m 'Add amazing feature'`)
6. Push to the branch (`git push origin feature/amazing-feature`)
7. Open a Pull Request
## Development Guidelines
### Code Style
- Follow the existing code style
- Run `npm run lint` before committing
- Use meaningful commit messages
### Component Structure
```jsx
// Imports at top
import { motion } from 'framer-motion';
import { Icon } from 'lucide-react';
// Component definition
export default function Component() {
return (
// JSX
);
}
```
### Testing Your Changes
```bash
# Start dev server
npm run dev
# Build for production
npm run build
# Preview production build
npm run preview
```
## Pull Request Checklist
- [ ] Code follows the existing style
- [ ] Linting passes (`npm run lint`)
- [ ] Build succeeds (`npm run build`)
- [ ] Changes are documented in PR description
## Questions?
Feel free to open an issue for any questions or discussions.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Tuan-Dat Tran
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
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 NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -102,6 +102,30 @@ KEYCLOAK_REALM=your-realm
KEYCLOAK_CLIENT_ID=cv-admin
```
## Development Setup
### Pre-commit Setup
This project uses Gitleaks for secret scanning. Install it before committing:
```bash
# macOS
brew install gitleaks
# Linux
curl -sSfL https://github.com/gitleaks/gitleaks/releases/latest/download/gitleaks_linux_x64.tar.gz | tar -xz
sudo mv gitleaks /usr/local/bin/
# Windows
scoop install gitleaks
```
### API Documentation
API documentation is available at `/api/docs` when running the server.
Access the interactive Swagger UI at: `http://localhost:3001/api/docs`
## Admin Authentication
### Simple Mode (Default)
@@ -128,6 +152,7 @@ KEYCLOAK_CLIENT_ID=cv-admin
| POST | `/api/cv/import` | Yes | Import JSON data |
| GET | `/api/auth/config` | No | Get auth configuration |
| POST | `/api/auth/login` | No | Login with password |
| GET | `/api/docs` | No | Swagger API documentation |
| GET | `/health` | No | Health check |
## Project Structure

36
SECURITY.md Normal file
View File

@@ -0,0 +1,36 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| main | :white_check_mark: |
## Reporting a Vulnerability
We take security seriously. If you discover a security vulnerability, please follow these steps:
1. **Do not** open a public issue
2. Email the maintainer directly at `tuan-dat.tran@example.com`
3. Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
### What to Expect
- Acknowledgment within 48 hours
- Assessment within 7 days
- Fix timeline based on severity:
- Critical: 24-72 hours
- High: 1 week
- Medium/Low: Next release
### Disclosure Policy
- Please allow time for the fix before public disclosure
- Coordinated disclosure is appreciated
- Credit will be given in the fix commit
Thank you for helping keep this project secure!

3
backend/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
*.log
.git

18
backend/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:20-slim
WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
COPY package*.json ./
RUN npm install --omit=dev --ignore-scripts && \
cd node_modules/better-sqlite3 && \
npm run build-release
COPY . .
RUN mkdir -p /app/data
EXPOSE 3001
CMD ["node", "server.js"]

View File

@@ -0,0 +1,166 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import express from 'express';
import cors from 'cors';
import knex from 'knex';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { Router } from 'express';
import { registerToken, authMiddleware } from '../middleware/auth.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
let app;
let db;
const TEST_TOKEN = 'test-token-12345';
function createCVRoutes(db) {
const router = Router();
router.get('/', async (req, res) => {
try {
const row = await db('cv_data').where({ id: 1 }).first();
if (!row) {
return res.status(404).json({ error: 'CV data not found' });
}
res.json(JSON.parse(row.data));
} catch (error) {
console.error('Error fetching CV:', error);
res.status(500).json({ error: 'Failed to fetch CV data' });
}
});
router.put('/', authMiddleware, async (req, res) => {
try {
const data = req.body;
if (!data || typeof data !== 'object') {
return res.status(400).json({ error: 'Invalid CV data' });
}
if (!data.personal?.name) {
return res.status(400).json({ error: 'personal.name is required' });
}
await db('cv_data').where({ id: 1 }).update({
data: JSON.stringify(data),
updated_at: db.fn.now()
});
res.json({ success: true, message: 'CV data updated' });
} catch (error) {
console.error('Error updating CV:', error);
res.status(500).json({ error: 'Failed to update CV data' });
}
});
router.get('/export', async (req, res) => {
try {
const row = await db('cv_data').where({ id: 1 }).first();
if (!row) {
return res.status(404).json({ error: 'CV data not found' });
}
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', 'attachment; filename="cv.json"');
res.send(row.data);
} catch (error) {
console.error('Error exporting CV:', error);
res.status(500).json({ error: 'Failed to export CV data' });
}
});
return router;
}
beforeAll(async () => {
const testDbPath = join(__dirname, 'test.db');
db = knex({
client: 'better-sqlite3',
connection: { filename: testDbPath },
useNullAsDefault: true
});
await db.schema.dropTableIfExists('cv_data');
await db.schema.createTable('cv_data', (table) => {
table.increments('id').primary();
table.text('data').notNullable();
table.datetime('updated_at').defaultTo(db.fn.now());
});
const defaultData = {
personal: { name: 'Test User', title: 'Developer', email: 'test@test.com' },
experience: [],
skills: {},
education: [],
projects: []
};
await db('cv_data').insert({ id: 1, data: JSON.stringify(defaultData) });
registerToken(TEST_TOKEN);
app = express();
app.use(cors());
app.use(express.json());
app.use('/api/cv', createCVRoutes(db));
});
afterAll(async () => {
if (db) {
await db.destroy();
}
});
describe('CV API', () => {
it('GET /api/cv returns CV data', async () => {
const response = await request(app).get('/api/cv');
expect(response.status).toBe(200);
expect(response.body.personal.name).toBe('Test User');
});
it('PUT /api/cv updates CV data', async () => {
const response = await request(app)
.put('/api/cv')
.set('Authorization', `Bearer ${TEST_TOKEN}`)
.send({
personal: { name: 'Updated Name', title: 'Dev', email: 'test@test.com' },
experience: [],
skills: {},
education: [],
projects: []
});
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
const getResponse = await request(app).get('/api/cv');
expect(getResponse.body.personal.name).toBe('Updated Name');
});
it('PUT /api/cv validates required fields', async () => {
const response = await request(app)
.put('/api/cv')
.set('Authorization', `Bearer ${TEST_TOKEN}`)
.send({ personal: {} });
expect(response.status).toBe(400);
expect(response.body.error).toContain('name is required');
});
it('PUT /api/cv requires authentication', async () => {
const response = await request(app)
.put('/api/cv')
.send({
personal: { name: 'Test', title: 'Dev', email: 'test@test.com' },
experience: [],
skills: {},
education: [],
projects: []
});
expect(response.status).toBe(401);
});
it('GET /api/cv/export returns JSON file', async () => {
const response = await request(app).get('/api/cv/export');
expect(response.status).toBe(200);
expect(response.headers['content-type']).toContain('application/json');
const data = JSON.parse(response.text);
expect(data.personal).toBeDefined();
});
});

View File

@@ -0,0 +1,41 @@
const tokens = new Set();
export function registerToken(token) {
tokens.add(token);
}
export function revokeToken(token) {
tokens.delete(token);
}
export function isValidToken(token) {
return tokens.has(token);
}
export function authMiddleware(req, res, next) {
if (process.env.USE_KEYCLOAK === 'true') {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing bearer token' });
}
// In Keycloak mode, token validation would happen here
// For now, we accept any bearer token (real Keycloak integration would verify JWT)
return next();
}
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing bearer token' });
}
const token = authHeader.slice(7);
if (!isValidToken(token)) {
return res.status(401).json({ error: 'Invalid token' });
}
next();
}
export function optionalAuth(req, res, next) {
next();
}

8
backend/vitest.config.js Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
globals: true,
},
})

26
docker-compose.yml Normal file
View File

@@ -0,0 +1,26 @@
services:
frontend:
build:
context: .
dockerfile: Dockerfile
ports:
- "5173:80"
depends_on:
- backend
environment:
- VITE_API_URL=http://localhost:3001
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "3001:3001"
volumes:
- cv-data:/app/data
environment:
- PORT=3001
- DB_PATH=/app/data/cv.db
volumes:
cv-data:

View File

@@ -1,3 +1,5 @@
<!-- markdownlint-disable MD013 -->
# Release Engineering Documentation
## Overview
@@ -70,38 +72,38 @@ All commits must follow the [Conventional Commits](https://www.conventionalcommi
#### Commit Types
| Type | Description | Version Bump | Example |
|------|-------------|--------------|---------|
| `feat` | New feature | Minor (1.0.0 → 1.1.0) | `feat(admin): add password protection` |
| `fix` | Bug fix | Patch (1.0.0 → 1.0.1) | `fix(skills): prevent focus loss` |
| `perf` | Performance improvement | Patch | `perf(ui): lazy load images` |
| `feat!` | Breaking change | Major (1.0.0 → 2.0.0) | `feat(api)!: remove deprecated endpoints` |
| `docs` | Documentation only | None | `docs(readme): update installation` |
| `style` | Code style (formatting) | None | `style(ui): fix indentation` |
| `refactor` | Code refactoring | None | `refactor(auth): simplify token validation` |
| `test` | Adding/updating tests | None | `test(api): add auth middleware tests` |
| `build` | Build system changes | None | `build(docker): update node version` |
| `ci` | CI/CD changes | None | `ci(github): add staging workflow` |
| `chore` | Maintenance tasks | None | `chore(deps): update dependencies` |
| `revert` | Revert previous commit | Patch | `revert: remove broken feature` |
| Type | Description | Version Bump | Example |
| ---------- | ----------------------- | --------------------- | ------------------------------------------- |
| `feat` | New feature | Minor (1.0.0 → 1.1.0) | `feat(admin): add password protection` |
| `fix` | Bug fix | Patch (1.0.0 → 1.0.1) | `fix(skills): prevent focus loss` |
| `perf` | Performance improvement | Patch | `perf(ui): lazy load images` |
| `feat!` | Breaking change | Major (1.0.0 → 2.0.0) | `feat(api)!: remove deprecated endpoints` |
| `docs` | Documentation only | None | `docs(readme): update installation` |
| `style` | Code style (formatting) | None | `style(ui): fix indentation` |
| `refactor` | Code refactoring | None | `refactor(auth): simplify token validation` |
| `test` | Adding/updating tests | None | `test(api): add auth middleware tests` |
| `build` | Build system changes | None | `build(docker): update node version` |
| `ci` | CI/CD changes | None | `ci(github): add staging workflow` |
| `chore` | Maintenance tasks | None | `chore(deps): update dependencies` |
| `revert` | Revert previous commit | Patch | `revert: remove broken feature` |
#### Allowed Scopes
| Scope | Description |
|-------|-------------|
| `admin` | Admin panel components |
| `api` | Backend API |
| `ui` | Frontend UI components |
| `docker` | Docker configuration |
| `ci` | CI/CD workflows |
| `deps` | Dependencies |
| `release` | Release configuration |
| `auth` | Authentication |
| `skills` | Skills section |
| `experience` | Experience section |
| `education` | Education section |
| `projects` | Projects section |
| `personal` | Personal info section |
| Scope | Description |
| ------------ | ---------------------- |
| `admin` | Admin panel components |
| `api` | Backend API |
| `ui` | Frontend UI components |
| `docker` | Docker configuration |
| `ci` | CI/CD workflows |
| `deps` | Dependencies |
| `release` | Release configuration |
| `auth` | Authentication |
| `skills` | Skills section |
| `experience` | Experience section |
| `education` | Education section |
| `projects` | Projects section |
| `personal` | Personal info section |
### 2. semantic-release
@@ -139,6 +141,7 @@ Enforces conventional commits via:
- **CI job**: Validates commits in pull requests
Configuration in `commitlint.config.js`:
- Validates commit types
- Validates scopes
- Enforces lowercase subjects
@@ -146,10 +149,10 @@ Configuration in `commitlint.config.js`:
### 4. Husky Git Hooks
| Hook | Purpose | Command |
|------|---------|---------|
| `pre-commit` | Run linting before commit | `npm run lint` |
| `commit-msg` | Validate commit message | `commitlint --edit` |
| Hook | Purpose | Command |
| ------------ | ------------------------- | ------------------- |
| `pre-commit` | Run linting before commit | `npm run lint` |
| `commit-msg` | Validate commit message | `commitlint --edit` |
### 5. GitHub Actions Workflows
@@ -250,16 +253,16 @@ Runs on: Schedule (daily at 02:00 UTC)
## Docker Image Tags
| Tag | Registry | Description | Update Frequency |
|-----|----------|-------------|------------------|
| `latest` | Both | Latest stable release | Every release |
| `v1.0.0` | Both | Specific version | Immutable |
| `1.0` | Both | Major.minor | Points to latest patch |
| `1` | Both | Major version | Points to latest minor |
| `staging` | Both | Staging environment | Every staging push |
| `nightly` | Both | Latest nightly build | Daily |
| `edge` | Both | Alias for nightly | Daily |
| `2026-02-20` | Both | Date-specific nightly | Immutable |
| Tag | Registry | Description | Update Frequency |
| ------------ | -------- | --------------------- | ---------------------- |
| `latest` | Both | Latest stable release | Every release |
| `v1.0.0` | Both | Specific version | Immutable |
| `1.0` | Both | Major.minor | Points to latest patch |
| `1` | Both | Major version | Points to latest minor |
| `staging` | Both | Staging environment | Every staging push |
| `nightly` | Both | Latest nightly build | Daily |
| `edge` | Both | Alias for nightly | Daily |
| `2026-02-20` | Both | Date-specific nightly | Immutable |
### Pulling Images
@@ -277,11 +280,11 @@ docker pull ghcr.io/owner/cv-app:v1.0.0
## Environments
| Environment | Branch | Trigger | Docker Tag |
|-------------|--------|---------|------------|
| Production | `master` | semantic-release (feat/fix commits) | `latest`, `vX.Y.Z` |
| Staging | `staging` | Push to branch | `staging` |
| Nightly | `master` | Daily at 02:00 UTC | `nightly`, `edge`, `YYYY-MM-DD` |
| Environment | Branch | Trigger | Docker Tag |
| ----------- | --------- | ----------------------------------- | ------------------------------- |
| Production | `master` | semantic-release (feat/fix commits) | `latest`, `vX.Y.Z` |
| Staging | `staging` | Push to branch | `staging` |
| Nightly | `master` | Daily at 02:00 UTC | `nightly`, `edge`, `YYYY-MM-DD` |
## Release Flow Example
@@ -379,11 +382,11 @@ Developer commits: feat(admin): add export functionality
Configure these in GitHub repository settings → Secrets and variables → Actions:
| Secret | Description | Required For |
|--------|-------------|--------------|
| `DOCKERHUB_USERNAME` | Docker Hub username | Docker Hub push |
| `DOCKERHUB_TOKEN` | Docker Hub access token | Docker Hub push |
| `GITHUB_TOKEN` | GitHub token (automatic) | GHCR push, releases |
| Secret | Description | Required For |
| -------------------- | ------------------------ | ------------------- |
| `DOCKERHUB_USERNAME` | Docker Hub username | Docker Hub push |
| `DOCKERHUB_TOKEN` | Docker Hub access token | Docker Hub push |
| `GITHUB_TOKEN` | GitHub token (automatic) | GHCR push, releases |
### Creating Docker Hub Token
@@ -427,6 +430,7 @@ git commit -m "docs: update readme [skip ci]"
**Error**: `subject must be lower-case`
**Fix**: Use lowercase for the subject:
```bash
# Wrong
git commit -m "feat(ui): Add new button"
@@ -438,6 +442,7 @@ git commit -m "feat(ui): add new button"
### Release Not Triggering
**Causes**:
1. No release-worthy commits (only docs/style/refactor/test/build/ci/chore)
2. Commit message contains `[skip ci]`
3. Workflow already running
@@ -449,6 +454,7 @@ git commit -m "feat(ui): add new button"
**Error**: `denied: requested access to the resource is denied`
**Fix**:
1. Verify `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` secrets
2. Ensure Docker Hub token has Write permission
3. Check Docker Hub repository exists (or enable auto-create)
@@ -458,21 +464,22 @@ git commit -m "feat(ui): add new button"
**Cause**: semantic-release requires conventional commits with proper types.
**Fix**: Ensure commits follow:
- `feat:` for features (minor bump)
- `fix:` for bug fixes (patch bump)
- `feat!:` or `BREAKING CHANGE:` for breaking changes (major bump)
## File Reference
| File | Purpose |
|------|---------|
| `.releaserc.json` | semantic-release configuration |
| `commitlint.config.js` | Commitlint rules |
| `.husky/pre-commit` | Pre-commit hook (lint) |
| `.husky/commit-msg` | Commit message hook (commitlint) |
| `.github/workflows/ci.yml` | CI pipeline |
| `.github/workflows/release.yml` | Release pipeline |
| `.github/workflows/staging.yml` | Staging deployment |
| `.github/workflows/nightly.yml` | Nightly builds |
| `docker-bake.hcl` | Multi-platform Docker build config |
| `CHANGELOG.md` | Auto-generated changelog |
| File | Purpose |
| ------------------------------- | ---------------------------------- |
| `.releaserc.json` | semantic-release configuration |
| `commitlint.config.js` | Commitlint rules |
| `.husky/pre-commit` | Pre-commit hook (lint) |
| `.husky/commit-msg` | Commit message hook (commitlint) |
| `.github/workflows/ci.yml` | CI pipeline |
| `.github/workflows/release.yml` | Release pipeline |
| `.github/workflows/staging.yml` | Staging deployment |
| `.github/workflows/nightly.yml` | Nightly builds |
| `docker-bake.hcl` | Multi-platform Docker build config |
| `CHANGELOG.md` | Auto-generated changelog |

89
eslint.config.js Normal file
View File

@@ -0,0 +1,89 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]|^motion$', argsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_' }],
},
},
{
files: ['**/*.test.js', '**/__tests__/**/*.js'],
languageOptions: {
globals: {
...globals.node,
describe: 'readonly',
it: 'readonly',
expect: 'readonly',
beforeEach: 'readonly',
afterEach: 'readonly',
beforeAll: 'readonly',
afterAll: 'readonly',
vi: 'readonly',
process: 'readonly',
},
},
},
{
files: ['backend/**/*.js'],
languageOptions: {
globals: {
...globals.node,
process: 'readonly',
console: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
},
},
},
{
files: ['playwright.config.js', 'tests/e2e/**/*.js', 'tests/regression/visual.test.js'],
languageOptions: {
globals: {
...globals.node,
process: 'readonly',
},
},
},
{
files: ['tests/performance/k6/**/*.js'],
languageOptions: {
globals: {
__ENV: 'readonly',
},
},
rules: {
'no-undef': 'off',
},
},
{
files: ['tests/performance/lighthouserc.js'],
languageOptions: {
globals: {
module: 'readonly',
},
},
rules: {
'no-undef': 'off',
},
},
])

14
index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tuan-Dat Tran | Junior DevOps Engineer</title>
<meta name="description" content="CV von Tuan-Dat Tran - Junior DevOps Engineer mit Erfahrung in Docker, Kubernetes, Azure und CI/CD" />
</head>
<body class="bg-slate-50 text-slate-800">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

28
nginx.conf Normal file
View File

@@ -0,0 +1,28 @@
server {
resolver 127.0.0.11 valid=30s ipv6=off;
listen 80;
server_name localhost;
set $backend_upstream http://backend:3001;
root /usr/share/nginx/html;
location / {
try_files $uri $uri/ /index.html;
}
location /cv {
alias /usr/share/nginx/html;
try_files $uri $uri/ /cv/index.html;
}
location /api {
proxy_pass $backend_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "cv-app",
"private": true,
"version": "0.0.0-dev.1",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",

26
playwright.config.js Normal file
View File

@@ -0,0 +1,26 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: process.env.BASE_URL || 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
});

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

51
src/App.jsx Normal file
View File

@@ -0,0 +1,51 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Hero from './components/Hero';
import Experience from './components/Experience';
import Skills from './components/Skills';
import Education from './components/Education';
import Projects from './components/Projects';
import Contact from './components/Contact';
import AdminLayout from './admin/AdminLayout';
import AdminPersonal from './admin/AdminPersonal';
import AdminExperience from './admin/AdminExperience';
import AdminSkills from './admin/AdminSkills';
import AdminEducation from './admin/AdminEducation';
import AdminProjects from './admin/AdminProjects';
import { CVProvider } from './admin/hooks/CVContext';
import { AuthProvider } from './admin/hooks/AuthContext';
function CV() {
return (
<div className="min-h-screen">
<Hero />
<Experience />
<Skills />
<Education />
<Projects />
<Contact />
</div>
);
}
function App() {
return (
<AuthProvider>
<CVProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<CV />} />
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<AdminPersonal />} />
<Route path="experience" element={<AdminExperience />} />
<Route path="skills" element={<AdminSkills />} />
<Route path="education" element={<AdminEducation />} />
<Route path="projects" element={<AdminProjects />} />
</Route>
</Routes>
</BrowserRouter>
</CVProvider>
</AuthProvider>
);
}
export default App;

View File

@@ -0,0 +1,14 @@
import EducationForm from './sections/EducationForm';
import ExportButton from './components/ExportButton';
import { useCVData } from './hooks/CVContext';
export default function AdminEducation() {
const { data, updateEducation, exportJSON, importJSON, resetToDefault } = useCVData();
return (
<div className="space-y-6">
<ExportButton onExport={exportJSON} onImport={importJSON} onReset={resetToDefault} />
<EducationForm data={data.education} onUpdate={updateEducation} />
</div>
);
}

View File

@@ -0,0 +1,14 @@
import ExperienceForm from './sections/ExperienceForm';
import ExportButton from './components/ExportButton';
import { useCVData } from './hooks/CVContext';
export default function AdminExperience() {
const { data, updateExperience, exportJSON, importJSON, resetToDefault } = useCVData();
return (
<div className="space-y-6">
<ExportButton onExport={exportJSON} onImport={importJSON} onReset={resetToDefault} />
<ExperienceForm data={data.experience} onUpdate={updateExperience} />
</div>
);
}

89
src/admin/AdminLayout.jsx Normal file
View File

@@ -0,0 +1,89 @@
import { NavLink, Outlet } from 'react-router-dom';
import { User, Briefcase, Code, GraduationCap, FolderOpen, Settings, LogOut } from 'lucide-react';
import { useAuth } from './hooks/AuthContext';
import LoginPage from './pages/LoginPage';
const navItems = [
{ to: '/admin', label: 'Persönlich', icon: User, end: true },
{ to: '/admin/experience', label: 'Erfahrung', icon: Briefcase },
{ to: '/admin/skills', label: 'Skills', icon: Code },
{ to: '/admin/education', label: 'Ausbildung', icon: GraduationCap },
{ to: '/admin/projects', label: 'Projekte', icon: FolderOpen },
];
export default function AdminLayout() {
const { isAuthenticated, loading, logout } = useAuth();
if (loading) {
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
<div className="text-slate-600">Loading...</div>
</div>
);
}
if (!isAuthenticated) {
return <LoginPage />;
}
return (
<div className="min-h-screen bg-slate-50">
<header className="bg-white border-b border-slate-200 sticky top-0 z-10">
<div className="max-w-6xl mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Settings className="text-primary-600" size={24} />
<h1 className="text-xl font-bold text-slate-900">CV Admin</h1>
</div>
<div className="flex items-center gap-4">
<a
href="/cv/"
className="text-sm text-primary-600 hover:text-primary-700"
>
Zur CV Ansicht
</a>
<button
onClick={logout}
className="flex items-center gap-1 text-sm text-slate-600 hover:text-slate-900"
>
<LogOut size={16} />
Logout
</button>
</div>
</div>
</div>
</header>
<div className="max-w-6xl mx-auto px-6 py-8">
<div className="flex gap-8">
<nav className="w-48 flex-shrink-0">
<ul className="space-y-1 sticky top-24">
{navItems.map((item) => (
<li key={item.to}>
<NavLink
to={item.to}
end={item.end}
className={({ isActive }) =>
`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors ${
isActive
? 'bg-primary-100 text-primary-700'
: 'text-slate-600 hover:bg-slate-100'
}`
}
>
<item.icon size={18} />
{item.label}
</NavLink>
</li>
))}
</ul>
</nav>
<main className="flex-1">
<Outlet />
</main>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import PersonalForm from './sections/PersonalForm';
import ExportButton from './components/ExportButton';
import { useCVData } from './hooks/CVContext';
export default function AdminPersonal() {
const { data, updatePersonal, exportJSON, importJSON, resetToDefault } = useCVData();
return (
<div className="space-y-6">
<ExportButton onExport={exportJSON} onImport={importJSON} onReset={resetToDefault} />
<PersonalForm data={data.personal} onUpdate={updatePersonal} />
</div>
);
}

View File

@@ -0,0 +1,14 @@
import ProjectsForm from './sections/ProjectsForm';
import ExportButton from './components/ExportButton';
import { useCVData } from './hooks/CVContext';
export default function AdminProjects() {
const { data, updateProjects, exportJSON, importJSON, resetToDefault } = useCVData();
return (
<div className="space-y-6">
<ExportButton onExport={exportJSON} onImport={importJSON} onReset={resetToDefault} />
<ProjectsForm data={data.projects} onUpdate={updateProjects} />
</div>
);
}

14
src/admin/AdminSkills.jsx Normal file
View File

@@ -0,0 +1,14 @@
import SkillsForm from './sections/SkillsForm';
import ExportButton from './components/ExportButton';
import { useCVData } from './hooks/CVContext';
export default function AdminSkills() {
const { data, updateSkills, exportJSON, importJSON, resetToDefault } = useCVData();
return (
<div className="space-y-6">
<ExportButton onExport={exportJSON} onImport={importJSON} onReset={resetToDefault} />
<SkillsForm data={data.skills} onUpdate={updateSkills} />
</div>
);
}

View File

@@ -0,0 +1,104 @@
import { Download, Upload, Eye, RotateCcw, FileJson } from 'lucide-react';
import { useRef, useState } from 'react';
export default function ExportButton({ onExport, onImport, onReset }) {
const fileInputRef = useRef(null);
const [showInstructions, setShowInstructions] = useState(false);
const handleImport = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const result = onImport(event.target.result);
if (result.success) {
alert('Import erfolgreich!');
} else {
alert(`Import fehlgeschlagen: ${result.error}`);
}
};
reader.readAsText(file);
};
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-3">
<button
onClick={() => {
onExport();
setShowInstructions(true);
}}
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
<Download size={18} />
JSON Export
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors"
>
<Upload size={18} />
JSON Import
</button>
<a
href="/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors"
>
<Eye size={18} />
Vorschau
</a>
{onReset && (
<button
onClick={() => {
if (confirm('Alle Änderungen zurücksetzen?')) {
onReset();
}
}}
className="flex items-center gap-2 px-4 py-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-colors"
>
<RotateCcw size={18} />
Zurücksetzen
</button>
)}
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleImport}
className="hidden"
/>
</div>
{showInstructions && (
<div className="p-4 bg-amber-50 border border-amber-200 rounded-lg">
<div className="flex items-start gap-3">
<FileJson className="text-amber-600 flex-shrink-0" size={20} />
<div>
<h3 className="font-medium text-amber-800 mb-2">Deployment Instructions</h3>
<ol className="text-sm text-amber-700 space-y-1 list-decimal list-inside">
<li>JSON Datei wurde heruntergeladen</li>
<li>Inhalt der <code className="bg-amber-100 px-1 rounded">cv.json</code> kopieren</li>
<li>In <code className="bg-amber-100 px-1 rounded">src/data/cv.js</code> einfügen</li>
<li>Committen und pushen: <code className="bg-amber-100 px-1 rounded">git add . && git commit -m "update cv" && git push</code></li>
<li>GitHub Actions deployt automatisch</li>
</ol>
<button
onClick={() => setShowInstructions(false)}
className="mt-3 text-sm text-amber-600 hover:text-amber-800"
>
Schließen
</button>
</div>
</div>
</div>
)}
</div>
);
}

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

View File

@@ -0,0 +1,95 @@
import { useState } from 'react';
import { Lock, AlertCircle } from 'lucide-react';
import { useAuth } from '../hooks/AuthContext';
export default function LoginPage() {
const { authMode, keycloakConfig, login } = useAuth();
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await login(password);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleKeycloakLogin = () => {
if (!keycloakConfig?.url || !keycloakConfig?.realm || !keycloakConfig?.clientId) {
setError('Keycloak not configured');
return;
}
const redirectUri = encodeURIComponent(window.location.origin + '/admin/callback');
const loginUrl = `${keycloakConfig.url}/realms/${keycloakConfig.realm}/protocol/openid-connect/auth?client_id=${keycloakConfig.clientId}&redirect_uri=${redirectUri}&response_type=code`;
window.location.href = loginUrl;
};
if (authMode === 'keycloak') {
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
<div className="bg-white rounded-xl p-8 shadow-sm border border-slate-100 max-w-md w-full">
<div className="text-center">
<Lock className="mx-auto text-primary-600 mb-4" size={48} />
<h1 className="text-2xl font-bold text-slate-900 mb-2">Admin Login</h1>
<p className="text-slate-600 mb-6">Authenticating with Keycloak</p>
<button
onClick={handleKeycloakLogin}
className="w-full py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors font-medium"
>
Login with Keycloak
</button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
<div className="bg-white rounded-xl p-8 shadow-sm border border-slate-100 max-w-md w-full">
<div className="text-center mb-8">
<Lock className="mx-auto text-primary-600 mb-4" size={48} />
<h1 className="text-2xl font-bold text-slate-900 mb-2">Admin Login</h1>
<p className="text-slate-600">Enter the admin password to continue</p>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center gap-2 text-red-700">
<AlertCircle size={18} />
<span className="text-sm">{error}</span>
</div>
)}
<form onSubmit={handleSubmit}>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
className="w-full px-4 py-3 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500 mb-4"
autoFocus
/>
<button
type="submit"
disabled={loading || !password}
className="w-full py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
<p className="mt-4 text-center text-sm text-slate-500">
Password shown in server console on startup
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,167 @@
import { useState } from 'react';
import { Plus, Edit2, Trash2, X, Check } from 'lucide-react';
export default function EducationForm({ data, onUpdate }) {
const [editingId, setEditingId] = useState(null);
const [editForm, setEditForm] = useState(null);
const defaultForm = {
degree: '',
institution: '',
period: '',
status: ''
};
const startAdd = () => {
setEditingId('new');
setEditForm({ ...defaultForm, id: Date.now() });
};
const startEdit = (edu) => {
setEditingId(edu.id);
setEditForm({ ...edu });
};
const cancelEdit = () => {
setEditingId(null);
setEditForm(null);
};
const saveEdit = () => {
if (!editForm.degree || !editForm.institution) return;
if (editingId === 'new') {
onUpdate([...data, editForm]);
} else {
onUpdate(data.map(e => e.id === editingId ? editForm : e));
}
cancelEdit();
};
const deleteItem = (id) => {
onUpdate(data.filter(e => e.id !== id));
};
const handleChange = (e) => {
const { name, value } = e.target;
setEditForm(prev => ({ ...prev, [name]: value }));
};
return (
<div className="bg-white rounded-xl p-6 shadow-sm border border-slate-100">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-slate-900">Ausbildung</h2>
{editingId !== 'new' && (
<button
onClick={startAdd}
className="flex items-center gap-2 px-3 py-1.5 bg-primary-100 text-primary-700 rounded-lg hover:bg-primary-200 transition-colors text-sm"
>
<Plus size={16} />
Hinzufügen
</button>
)}
</div>
{editingId === 'new' && editForm && (
<div className="mb-4 p-4 bg-primary-50 rounded-lg border border-primary-200">
<FormFields form={editForm} onChange={handleChange} onSave={saveEdit} onCancel={cancelEdit} />
</div>
)}
<div className="space-y-3">
{data.map((edu) => (
<div key={edu.id}>
{editingId === edu.id ? (
<div className="p-4 bg-primary-50 rounded-lg border border-primary-200">
<FormFields form={editForm} onChange={handleChange} onSave={saveEdit} onCancel={cancelEdit} />
</div>
) : (
<div className="flex items-start gap-3 p-4 bg-slate-50 rounded-lg group">
<div className="flex-1">
<h3 className="font-medium text-slate-900">{edu.degree}</h3>
<p className="text-sm text-slate-600">{edu.institution} {edu.period}</p>
{edu.status && (
<span className="inline-block mt-1 px-2 py-0.5 text-xs bg-amber-100 text-amber-700 rounded">
{edu.status}
</span>
)}
</div>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => startEdit(edu)}
className="p-1.5 text-slate-500 hover:text-primary-600 hover:bg-primary-50 rounded"
>
<Edit2 size={16} />
</button>
<button
onClick={() => deleteItem(edu.id)}
className="p-1.5 text-slate-500 hover:text-red-600 hover:bg-red-50 rounded"
>
<Trash2 size={16} />
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
);
}
function FormFields({ form, onChange, onSave, onCancel }) {
return (
<div className="space-y-3">
<input
type="text"
name="degree"
value={form.degree || ''}
onChange={onChange}
placeholder="Abschluss (z.B. Bachelor Informatik)"
className="w-full px-3 py-2 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<div className="grid md:grid-cols-2 gap-3">
<input
type="text"
name="institution"
value={form.institution || ''}
onChange={onChange}
placeholder="Institution / Universität"
className="px-3 py-2 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<input
type="text"
name="period"
value={form.period || ''}
onChange={onChange}
placeholder="Zeitraum"
className="px-3 py-2 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<input
type="text"
name="status"
value={form.status || ''}
onChange={onChange}
placeholder="Status (z.B. Laufend, Abgeschlossen)"
className="w-full px-3 py-2 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<div className="flex gap-2 pt-2">
<button
onClick={onSave}
className="flex items-center gap-1 px-3 py-1.5 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm"
>
<Check size={16} />
Speichern
</button>
<button
onClick={onCancel}
className="flex items-center gap-1 px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 text-sm"
>
<X size={16} />
Abbrechen
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,248 @@
import { useState } from 'react';
import { Plus, Edit2, Trash2, X, Check } from 'lucide-react';
export default function ExperienceForm({ data, onUpdate }) {
const [editingId, setEditingId] = useState(null);
const [editForm, setEditForm] = useState(null);
const defaultForm = {
role: '',
company: '',
period: '',
type: 'Vollzeit',
description: '',
highlights: []
};
const startAdd = () => {
setEditingId('new');
setEditForm({ ...defaultForm, id: Date.now() });
};
const startEdit = (exp) => {
setEditingId(exp.id);
setEditForm({ ...exp });
};
const cancelEdit = () => {
setEditingId(null);
setEditForm(null);
};
const saveEdit = () => {
if (!editForm.role || !editForm.company) return;
if (editingId === 'new') {
onUpdate([...data, editForm]);
} else {
onUpdate(data.map(e => e.id === editingId ? editForm : e));
}
cancelEdit();
};
const deleteItem = (id) => {
onUpdate(data.filter(e => e.id !== id));
};
const addHighlight = () => {
setEditForm(prev => ({
...prev,
highlights: [...(prev.highlights || []), '']
}));
};
const updateHighlight = (index, value) => {
setEditForm(prev => ({
...prev,
highlights: prev.highlights.map((h, i) => i === index ? value : h)
}));
};
const removeHighlight = (index) => {
setEditForm(prev => ({
...prev,
highlights: prev.highlights.filter((_, i) => i !== index)
}));
};
return (
<div className="bg-white rounded-xl p-6 shadow-sm border border-slate-100">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-slate-900">Berufserfahrung</h2>
{editingId !== 'new' && (
<button
onClick={startAdd}
className="flex items-center gap-2 px-3 py-1.5 bg-primary-100 text-primary-700 rounded-lg hover:bg-primary-200 transition-colors text-sm"
>
<Plus size={16} />
Hinzufügen
</button>
)}
</div>
{editingId === 'new' && editForm && (
<div className="mb-4 p-4 bg-primary-50 rounded-lg border border-primary-200">
<EditForm
form={editForm}
setForm={setEditForm}
onSave={saveEdit}
onCancel={cancelEdit}
onAddHighlight={addHighlight}
onUpdateHighlight={updateHighlight}
onRemoveHighlight={removeHighlight}
/>
</div>
)}
<div className="space-y-3">
{data.map((exp) => (
<div key={exp.id}>
{editingId === exp.id ? (
<div className="p-4 bg-primary-50 rounded-lg border border-primary-200">
<EditForm
form={editForm}
setForm={setEditForm}
onSave={saveEdit}
onCancel={cancelEdit}
onAddHighlight={addHighlight}
onUpdateHighlight={updateHighlight}
onRemoveHighlight={removeHighlight}
/>
</div>
) : (
<div className="flex items-start gap-3 p-4 bg-slate-50 rounded-lg group">
<div className="flex-1">
<h3 className="font-medium text-slate-900">{exp.role}</h3>
<p className="text-sm text-slate-600">{exp.company} {exp.period}</p>
<span className="inline-block mt-1 px-2 py-0.5 text-xs bg-slate-200 text-slate-600 rounded">
{exp.type}
</span>
</div>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => startEdit(exp)}
className="p-1.5 text-slate-500 hover:text-primary-600 hover:bg-primary-50 rounded"
>
<Edit2 size={16} />
</button>
<button
onClick={() => deleteItem(exp.id)}
className="p-1.5 text-slate-500 hover:text-red-600 hover:bg-red-50 rounded"
>
<Trash2 size={16} />
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
);
}
function EditForm({ form, setForm, onSave, onCancel, onAddHighlight, onUpdateHighlight, onRemoveHighlight }) {
const handleChange = (e) => {
const { name, value } = e.target;
setForm(prev => ({ ...prev, [name]: value }));
};
return (
<div className="space-y-3">
<div className="grid md:grid-cols-2 gap-3">
<input
type="text"
name="role"
value={form.role || ''}
onChange={handleChange}
placeholder="Rolle / Position"
className="px-3 py-2 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<input
type="text"
name="company"
value={form.company || ''}
onChange={handleChange}
placeholder="Unternehmen"
className="px-3 py-2 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<div className="grid md:grid-cols-2 gap-3">
<input
type="text"
name="period"
value={form.period || ''}
onChange={handleChange}
placeholder="Zeitraum (z.B. 2021 - 2025)"
className="px-3 py-2 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<select
name="type"
value={form.type || 'Vollzeit'}
onChange={handleChange}
className="px-3 py-2 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option>Vollzeit</option>
<option>Teilzeit</option>
<option>Werkstudent</option>
<option>SHK</option>
<option>Freelance</option>
</select>
</div>
<textarea
name="description"
value={form.description || ''}
onChange={handleChange}
placeholder="Kurze Beschreibung"
rows={2}
className="w-full px-3 py-2 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Highlights</label>
{(form.highlights || []).map((h, i) => (
<div key={i} className="flex gap-2 mb-2">
<input
type="text"
value={h}
onChange={(e) => onUpdateHighlight(i, e.target.value)}
placeholder="Highlight..."
className="flex-1 px-3 py-2 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500 text-sm"
/>
<button
type="button"
onClick={() => onRemoveHighlight(i)}
className="p-2 text-slate-400 hover:text-red-500"
>
<X size={16} />
</button>
</div>
))}
<button
type="button"
onClick={onAddHighlight}
className="text-sm text-primary-600 hover:text-primary-700"
>
+ Highlight hinzufügen
</button>
</div>
<div className="flex gap-2 pt-2">
<button
onClick={onSave}
className="flex items-center gap-1 px-3 py-1.5 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm"
>
<Check size={16} />
Speichern
</button>
<button
onClick={onCancel}
className="flex items-center gap-1 px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 text-sm"
>
<X size={16} />
Abbrechen
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,169 @@
import { useState, useEffect } from 'react';
import { useFormValidation, validators } from '../hooks/useFormValidation';
import { Save, RotateCcw, Check } from 'lucide-react';
export default function PersonalForm({ data, onUpdate }) {
const [formData, setFormData] = useState(data);
const [saved, setSaved] = useState(false);
const { errors, validate, clearError } = useFormValidation({
name: [validators.required('Name ist erforderlich')],
title: [validators.required('Titel ist erforderlich')],
email: [validators.required('E-Mail ist erforderlich'), validators.email()],
github: [validators.url()],
linkedin: [validators.url()]
});
useEffect(() => {
setFormData(data);
}, [data]);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
clearError(name);
};
const handleSubmit = (e) => {
e.preventDefault();
if (validate(formData)) {
onUpdate(formData);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
};
const handleReset = () => {
setFormData(data);
};
return (
<form onSubmit={handleSubmit} className="bg-white rounded-xl p-6 shadow-sm border border-slate-100">
<h2 className="text-xl font-semibold text-slate-900 mb-6">Persönliche Daten</h2>
{saved && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg flex items-center gap-2 text-green-700">
<Check size={18} />
Gespeichert!
</div>
)}
<div className="grid gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Name *
</label>
<input
type="text"
name="name"
value={formData.name || ''}
onChange={handleChange}
className={`w-full px-3 py-2 rounded-lg border ${errors.name ? 'border-red-500' : 'border-slate-200'} focus:outline-none focus:ring-2 focus:ring-primary-500`}
/>
{errors.name && <p className="text-red-500 text-sm mt-1">{errors.name}</p>}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Titel *
</label>
<input
type="text"
name="title"
value={formData.title || ''}
onChange={handleChange}
className={`w-full px-3 py-2 rounded-lg border ${errors.title ? 'border-red-500' : 'border-slate-200'} focus:outline-none focus:ring-2 focus:ring-primary-500`}
/>
{errors.title && <p className="text-red-500 text-sm mt-1">{errors.title}</p>}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
E-Mail *
</label>
<input
type="email"
name="email"
value={formData.email || ''}
onChange={handleChange}
className={`w-full px-3 py-2 rounded-lg border ${errors.email ? 'border-red-500' : 'border-slate-200'} focus:outline-none focus:ring-2 focus:ring-primary-500`}
/>
{errors.email && <p className="text-red-500 text-sm mt-1">{errors.email}</p>}
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
GitHub URL
</label>
<input
type="url"
name="github"
value={formData.github || ''}
onChange={handleChange}
className={`w-full px-3 py-2 rounded-lg border ${errors.github ? 'border-red-500' : 'border-slate-200'} focus:outline-none focus:ring-2 focus:ring-primary-500`}
/>
{errors.github && <p className="text-red-500 text-sm mt-1">{errors.github}</p>}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
LinkedIn URL
</label>
<input
type="url"
name="linkedin"
value={formData.linkedin || ''}
onChange={handleChange}
className={`w-full px-3 py-2 rounded-lg border ${errors.linkedin ? 'border-red-500' : 'border-slate-200'} focus:outline-none focus:ring-2 focus:ring-primary-500`}
/>
{errors.linkedin && <p className="text-red-500 text-sm mt-1">{errors.linkedin}</p>}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Standort
</label>
<input
type="text"
name="location"
value={formData.location || ''}
onChange={handleChange}
className="w-full px-3 py-2 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Intro
</label>
<textarea
name="intro"
value={formData.intro || ''}
onChange={handleChange}
rows={3}
className="w-full px-3 py-2 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
type="submit"
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
<Save size={18} />
Speichern
</button>
<button
type="button"
onClick={handleReset}
className="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors"
>
<RotateCcw size={18} />
Zurücksetzen
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,227 @@
import { useState } from 'react';
import { Plus, Edit2, Trash2, X, Check } from 'lucide-react';
export default function ProjectsForm({ data, onUpdate }) {
const [editingId, setEditingId] = useState(null);
const [editForm, setEditForm] = useState(null);
const defaultForm = {
name: '',
description: '',
url: '',
tech: []
};
const startAdd = () => {
setEditingId('new');
setEditForm({ ...defaultForm, id: Date.now() });
};
const startEdit = (project) => {
setEditingId(project.id);
setEditForm({ ...project });
};
const cancelEdit = () => {
setEditingId(null);
setEditForm(null);
};
const saveEdit = () => {
if (!editForm.name) return;
if (editingId === 'new') {
onUpdate([...data, editForm]);
} else {
onUpdate(data.map(p => p.id === editingId ? editForm : p));
}
cancelEdit();
};
const deleteItem = (id) => {
onUpdate(data.filter(p => p.id !== id));
};
const handleChange = (e) => {
const { name, value } = e.target;
setEditForm(prev => ({ ...prev, [name]: value }));
};
const addTech = () => {
setEditForm(prev => ({
...prev,
tech: [...(prev.tech || []), '']
}));
};
const updateTech = (index, value) => {
setEditForm(prev => ({
...prev,
tech: prev.tech.map((t, i) => i === index ? value : t)
}));
};
const removeTech = (index) => {
setEditForm(prev => ({
...prev,
tech: prev.tech.filter((_, i) => i !== index)
}));
};
return (
<div className="bg-white rounded-xl p-6 shadow-sm border border-slate-100">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-slate-900">Projekte</h2>
{editingId !== 'new' && (
<button
onClick={startAdd}
className="flex items-center gap-2 px-3 py-1.5 bg-primary-100 text-primary-700 rounded-lg hover:bg-primary-200 transition-colors text-sm"
>
<Plus size={16} />
Hinzufügen
</button>
)}
</div>
{editingId === 'new' && editForm && (
<div className="mb-4 p-4 bg-primary-50 rounded-lg border border-primary-200">
<FormFields
form={editForm}
onChange={handleChange}
onSave={saveEdit}
onCancel={cancelEdit}
onAddTech={addTech}
onUpdateTech={updateTech}
onRemoveTech={removeTech}
/>
</div>
)}
<div className="space-y-3">
{data.map((project) => (
<div key={project.id}>
{editingId === project.id ? (
<div className="p-4 bg-primary-50 rounded-lg border border-primary-200">
<FormFields
form={editForm}
onChange={handleChange}
onSave={saveEdit}
onCancel={cancelEdit}
onAddTech={addTech}
onUpdateTech={updateTech}
onRemoveTech={removeTech}
/>
</div>
) : (
<div className="flex items-start gap-3 p-4 bg-slate-50 rounded-lg group">
<div className="flex-1">
<h3 className="font-medium text-slate-900">{project.name}</h3>
<p className="text-sm text-slate-600">{project.description}</p>
<div className="flex flex-wrap gap-1 mt-2">
{(project.tech || []).map((t, i) => (
<span key={i} className="px-2 py-0.5 text-xs bg-primary-50 text-primary-600 rounded">
{t}
</span>
))}
</div>
</div>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => startEdit(project)}
className="p-1.5 text-slate-500 hover:text-primary-600 hover:bg-primary-50 rounded"
>
<Edit2 size={16} />
</button>
<button
onClick={() => deleteItem(project.id)}
className="p-1.5 text-slate-500 hover:text-red-600 hover:bg-red-50 rounded"
>
<Trash2 size={16} />
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
);
}
function FormFields({ form, onChange, onSave, onCancel, onAddTech, onUpdateTech, onRemoveTech }) {
return (
<div className="space-y-3">
<input
type="text"
name="name"
value={form.name || ''}
onChange={onChange}
placeholder="Projektname"
className="w-full px-3 py-2 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<textarea
name="description"
value={form.description || ''}
onChange={onChange}
placeholder="Kurze Beschreibung"
rows={2}
className="w-full px-3 py-2 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<input
type="url"
name="url"
value={form.url || ''}
onChange={onChange}
placeholder="Projekt-URL"
className="w-full px-3 py-2 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Technologien</label>
<div className="flex flex-wrap gap-2 mb-2">
{(form.tech || []).map((t, i) => (
<div key={i} className="flex items-center gap-1">
<input
type="text"
value={t}
onChange={(e) => onUpdateTech(i, e.target.value)}
className="w-24 px-2 py-1 text-sm rounded border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<button
type="button"
onClick={() => onRemoveTech(i)}
className="p-1 text-slate-400 hover:text-red-500"
>
<X size={14} />
</button>
</div>
))}
</div>
<button
type="button"
onClick={onAddTech}
className="text-sm text-primary-600 hover:text-primary-700"
>
+ Technologie hinzufügen
</button>
</div>
<div className="flex gap-2 pt-2">
<button
onClick={onSave}
className="flex items-center gap-1 px-3 py-1.5 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm"
>
<Check size={16} />
Speichern
</button>
<button
onClick={onCancel}
className="flex items-center gap-1 px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 text-sm"
>
<X size={16} />
Abbrechen
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,160 @@
import { useState, useEffect } from 'react';
import { Plus, X } from 'lucide-react';
function CategoryHeader({ category, onRename, onDelete }) {
const [localValue, setLocalValue] = useState(category);
useEffect(() => {
setLocalValue(category);
}, [category]);
const handleBlur = () => {
onRename(category, localValue);
};
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
e.target.blur();
}
};
return (
<div className="flex items-center justify-between mb-3">
<input
type="text"
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className="flex-1 font-medium text-slate-900 bg-transparent border-none focus:outline-none focus:ring-0 mr-2"
/>
<button
onClick={onDelete}
className="p-1 text-slate-400 hover:text-red-500"
>
<X size={16} />
</button>
</div>
);
}
export default function SkillsForm({ data, onUpdate }) {
const [newCategory, setNewCategory] = useState('');
const [newSkill, setNewSkill] = useState('');
const [selectedCategory, setSelectedCategory] = useState(null);
const addCategory = () => {
if (!newCategory.trim()) return;
onUpdate({ ...data, [newCategory.trim()]: [] });
setNewCategory('');
};
const deleteCategory = (category) => {
const updated = { ...data };
delete updated[category];
onUpdate(updated);
};
const addSkill = (category) => {
if (!newSkill.trim()) return;
onUpdate({
...data,
[category]: [...(data[category] || []), newSkill.trim()]
});
setNewSkill('');
};
const deleteSkill = (category, skillIndex) => {
onUpdate({
...data,
[category]: data[category].filter((_, i) => i !== skillIndex)
});
};
const renameCategory = (oldName, newName) => {
if (!newName.trim() || oldName === newName) return;
const updated = { ...data };
updated[newName] = updated[oldName];
delete updated[oldName];
onUpdate(updated);
};
return (
<div className="bg-white rounded-xl p-6 shadow-sm border border-slate-100">
<h2 className="text-xl font-semibold text-slate-900 mb-6">Skills</h2>
<div className="mb-4 p-4 bg-slate-50 rounded-lg">
<label className="text-sm font-medium text-slate-700 mb-2 block">Neue Kategorie</label>
<div className="flex gap-2">
<input
type="text"
value={newCategory}
onChange={(e) => setNewCategory(e.target.value)}
placeholder="z.B. DevOps, Frontend, Backend..."
className="flex-1 px-3 py-2 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500"
onKeyPress={(e) => e.key === 'Enter' && addCategory()}
/>
<button
onClick={addCategory}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
<Plus size={18} />
</button>
</div>
</div>
<div className="space-y-4">
{Object.entries(data).map(([category, skills], index) => (
<div key={`category-${index}`} className="border border-slate-200 rounded-lg p-4">
<CategoryHeader
category={category}
onRename={renameCategory}
onDelete={() => deleteCategory(category)}
/>
<div className="flex flex-wrap gap-2 mb-3">
{(skills || []).map((skill, i) => (
<span
key={i}
className="flex items-center gap-1 px-3 py-1 bg-slate-100 text-slate-700 rounded-lg text-sm group"
>
{skill}
<button
onClick={() => deleteSkill(category, i)}
className="text-slate-400 hover:text-red-500 opacity-0 group-hover:opacity-100"
>
<X size={14} />
</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={selectedCategory === category ? newSkill : ''}
onChange={(e) => {
setSelectedCategory(category);
setNewSkill(e.target.value);
}}
placeholder="Skill hinzufügen..."
className="flex-1 px-3 py-1.5 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500 text-sm"
onKeyPress={(e) => {
if (e.key === 'Enter') {
addSkill(category);
}
}}
/>
<button
onClick={() => addSkill(category)}
className="px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 text-sm"
>
<Plus size={16} />
</button>
</div>
</div>
))}
</div>
</div>
);
}

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,61 @@
import { motion } from 'framer-motion';
import { Mail, Github, Linkedin, Heart } from 'lucide-react';
import { useCVData } from '../admin/hooks/CVContext';
export default function Contact() {
const { data } = useCVData();
const { personal } = data;
return (
<footer className="py-12 px-6 bg-slate-900 text-white">
<div className="max-w-4xl mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="text-center"
>
<h2 className="text-2xl font-bold mb-4">Kontakt</h2>
<p className="text-slate-400 mb-6">
Interessiert an einer Zusammenarbeit? Kontaktiere mich gerne!
</p>
<div className="flex justify-center gap-4 mb-8">
<a
href={`mailto:${personal.email}`}
className="p-3 rounded-lg bg-slate-800 hover:bg-primary-600 transition-colors"
aria-label="Email"
>
<Mail size={24} />
</a>
<a
href={personal.github}
target="_blank"
rel="noopener noreferrer"
className="p-3 rounded-lg bg-slate-800 hover:bg-primary-600 transition-colors"
aria-label="GitHub"
>
<Github size={24} />
</a>
<a
href={personal.linkedin}
target="_blank"
rel="noopener noreferrer"
className="p-3 rounded-lg bg-slate-800 hover:bg-primary-600 transition-colors"
aria-label="LinkedIn"
>
<Linkedin size={24} />
</a>
</div>
<div className="pt-8 border-t border-slate-800 text-sm text-slate-500">
<p className="flex items-center justify-center gap-1">
Built with <Heart size={14} className="text-red-500" /> using React & Tailwind CSS
</p>
<p className="mt-2">© {new Date().getFullYear()} {personal.name}</p>
</div>
</motion.div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,53 @@
import { motion } from 'framer-motion';
import { GraduationCap, Calendar } from 'lucide-react';
import { useCVData } from '../admin/hooks/CVContext';
export default function Education() {
const { data } = useCVData();
const { education } = data;
return (
<section id="education" className="py-20 px-6">
<div className="max-w-4xl mx-auto">
<motion.h2
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
className="text-3xl font-bold text-slate-900 mb-12 text-center"
>
Ausbildung
</motion.h2>
<div className="grid md:grid-cols-2 gap-6">
{education.map((edu, index) => (
<motion.div
key={edu.id}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: index * 0.2 }}
className="bg-white rounded-xl p-6 shadow-sm border border-slate-100"
>
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-lg bg-primary-100 flex items-center justify-center text-primary-600">
<GraduationCap size={24} />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-slate-900">{edu.degree}</h3>
<p className="text-slate-600">{edu.institution}</p>
<div className="flex items-center gap-2 mt-2 text-slate-500 text-sm">
<Calendar size={14} />
<span>{edu.period}</span>
</div>
<span className="inline-block mt-2 px-2 py-1 text-xs bg-amber-100 text-amber-700 rounded">
{edu.status}
</span>
</div>
</div>
</motion.div>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,72 @@
import { motion } from 'framer-motion';
import { Briefcase, Calendar } from 'lucide-react';
import { useCVData } from '../admin/hooks/CVContext';
export default function Experience() {
const { data } = useCVData();
const { experience } = data;
return (
<section id="experience" className="py-20 px-6">
<div className="max-w-4xl mx-auto">
<motion.h2
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
className="text-3xl font-bold text-slate-900 mb-12 text-center"
>
Berufserfahrung
</motion.h2>
<div className="relative">
{experience.map((exp, index) => (
<motion.div
key={exp.id}
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: index * 0.2 }}
className="relative pl-8 pb-12 last:pb-0"
>
<div className="absolute left-0 top-2 w-4 h-4 rounded-full bg-primary-500 border-4 border-primary-100" />
{index !== experience.length - 1 && (
<div className="absolute left-[7px] top-6 w-0.5 h-full bg-slate-200" />
)}
<div className="bg-white rounded-xl p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow">
<div className="flex flex-wrap items-start justify-between gap-2 mb-2">
<h3 className="text-xl font-semibold text-slate-900">{exp.role}</h3>
<span className="px-3 py-1 text-sm bg-primary-100 text-primary-700 rounded-full">
{exp.type}
</span>
</div>
<div className="flex items-center gap-4 text-slate-500 mb-4">
<span className="flex items-center gap-1">
<Briefcase size={16} />
{exp.company}
</span>
<span className="flex items-center gap-1">
<Calendar size={16} />
{exp.period}
</span>
</div>
<p className="text-slate-600 mb-4">{exp.description}</p>
<ul className="space-y-2">
{exp.highlights.map((highlight, i) => (
<li key={i} className="flex items-start gap-2 text-slate-600">
<span className="text-primary-500 mt-1"></span>
{highlight}
</li>
))}
</ul>
</div>
</motion.div>
))}
</div>
</div>
</section>
);
}

73
src/components/Hero.jsx Normal file
View File

@@ -0,0 +1,73 @@
import { motion } from 'framer-motion';
import { Github, Linkedin, Mail, MapPin } from 'lucide-react';
import { useCVData } from '../admin/hooks/CVContext';
export default function Hero() {
const { data } = useCVData();
const { personal } = data;
return (
<section className="min-h-screen flex items-center justify-center px-6 py-20">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="max-w-3xl text-center"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
className="w-32 h-32 mx-auto mb-8 rounded-full bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center text-white text-4xl font-bold"
>
{personal.name.split(' ').map(n => n[0]).join('')}
</motion.div>
<h1 className="text-4xl md:text-5xl font-bold text-slate-900 mb-2">
{personal.name}
</h1>
<h2 className="text-xl md:text-2xl text-primary-600 font-medium mb-4">
{personal.title}
</h2>
<div className="flex items-center justify-center gap-2 text-slate-500 mb-6">
<MapPin size={18} />
<span>{personal.location}</span>
</div>
<p className="text-lg text-slate-600 mb-8 max-w-2xl mx-auto">
{personal.intro}
</p>
<div className="flex justify-center gap-4">
<a
href={`mailto:${personal.email}`}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-100 hover:bg-slate-200 transition-colors text-slate-700"
>
<Mail size={20} />
<span>Email</span>
</a>
<a
href={personal.github}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-100 hover:bg-slate-200 transition-colors text-slate-700"
>
<Github size={20} />
<span>GitHub</span>
</a>
<a
href={personal.linkedin}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-100 hover:bg-slate-200 transition-colors text-slate-700"
>
<Linkedin size={20} />
<span>LinkedIn</span>
</a>
</div>
</motion.div>
</section>
);
}

View File

@@ -0,0 +1,57 @@
import { motion } from 'framer-motion';
import { ExternalLink } from 'lucide-react';
import { useCVData } from '../admin/hooks/CVContext';
export default function Projects() {
const { data } = useCVData();
const { projects } = data;
return (
<section id="projects" className="py-20 px-6 bg-white">
<div className="max-w-4xl mx-auto">
<motion.h2
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
className="text-3xl font-bold text-slate-900 mb-12 text-center"
>
Projekte
</motion.h2>
<div className="grid md:grid-cols-2 gap-6">
{projects.map((project, index) => (
<motion.a
key={project.id}
href={project.url}
target="_blank"
rel="noopener noreferrer"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: index * 0.2 }}
whileHover={{ y: -4 }}
className="group bg-slate-50 rounded-xl p-6 border border-slate-100 hover:border-primary-200 hover:shadow-md transition-all"
>
<div className="flex items-start justify-between mb-3">
<h3 className="text-lg font-semibold text-slate-900 group-hover:text-primary-600 transition-colors">
{project.name}
</h3>
<ExternalLink size={18} className="text-slate-400 group-hover:text-primary-500" />
</div>
<p className="text-slate-600 mb-4 text-sm">{project.description}</p>
<div className="flex flex-wrap gap-2">
{project.tech.map((t, i) => (
<span key={i} className="px-2 py-1 text-xs bg-primary-50 text-primary-600 rounded">
{t}
</span>
))}
</div>
</motion.a>
))}
</div>
</div>
</section>
);
}

72
src/components/Skills.jsx Normal file
View File

@@ -0,0 +1,72 @@
import { motion } from 'framer-motion';
import { useCVData } from '../admin/hooks/CVContext';
const skillIcons = {
"Docker": "🐳",
"Kubernetes": "☸️",
"Helm": "⛵",
"Jenkins": "🔧",
"Bitbucket": "📘",
"ArgoCD": "🔄",
"Git": "📝",
"Azure": "☁️",
"Azure Resource Manager": "🏗️",
"Release Engineering": "🚀",
"Linux Administration": "🐧",
"Python": "🐍",
"Bash": "💻",
"Rust": "🦀"
};
export default function Skills() {
const { data } = useCVData();
const { skills } = data;
return (
<section id="skills" className="py-20 px-6 bg-white">
<div className="max-w-4xl mx-auto">
<motion.h2
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
className="text-3xl font-bold text-slate-900 mb-12 text-center"
>
Skills
</motion.h2>
<div className="grid md:grid-cols-2 gap-8">
{Object.entries(skills).map(([category, items], catIndex) => (
<motion.div
key={category}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: catIndex * 0.1 }}
className="bg-slate-50 rounded-xl p-6"
>
<h3 className="text-lg font-semibold text-primary-600 mb-4">
{category}
</h3>
<div className="flex flex-wrap gap-2">
{items.map((skill, i) => (
<motion.span
key={skill}
initial={{ opacity: 0, scale: 0.8 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ delay: i * 0.05 }}
whileHover={{ scale: 1.05 }}
className="px-3 py-1.5 bg-white rounded-lg text-sm text-slate-700 shadow-sm border border-slate-100 flex items-center gap-1.5 cursor-default"
>
{skillIcons[skill] && <span>{skillIcons[skill]}</span>}
{skill}
</motion.span>
))}
</div>
</motion.div>
))}
</div>
</div>
</section>
);
}

3
src/data/cv.js Normal file
View File

@@ -0,0 +1,3 @@
import { DEFAULT_DATA } from './lib/cv-data.js';
export const cvData = DEFAULT_DATA;

26
src/index.css Normal file
View File

@@ -0,0 +1,26 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@import "tailwindcss";
@theme {
--color-primary-50: #eef2ff;
--color-primary-100: #e0e7ff;
--color-primary-200: #c7d2fe;
--color-primary-300: #a5b4fc;
--color-primary-400: #818cf8;
--color-primary-500: #6366f1;
--color-primary-600: #4f46e5;
--color-primary-700: #4338ca;
--color-primary-800: #3730a3;
--color-primary-900: #312e81;
--font-sans: 'Inter', system-ui, sans-serif;
}
html {
scroll-behavior: smooth;
}
body {
font-family: var(--font-sans);
margin: 0;
}

View File

@@ -0,0 +1,168 @@
import { describe, it, expect } from 'vitest';
import {
validateCV,
mergeCVData,
exportToJSON,
importFromJSON,
DEFAULT_DATA
} from '../cv-data';
describe('validateCV', () => {
it('validates correct CV data', () => {
const result = validateCV(DEFAULT_DATA);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('requires personal section', () => {
const result = validateCV({});
expect(result.valid).toBe(false);
expect(result.errors).toContain('personal is required');
});
it('requires personal.name', () => {
const result = validateCV({
personal: { title: 'Developer', email: 'test@example.com' }
});
expect(result.valid).toBe(false);
expect(result.errors).toContain('personal.name is required');
});
it('requires personal.title', () => {
const result = validateCV({
personal: { name: 'Test', email: 'test@example.com' }
});
expect(result.valid).toBe(false);
expect(result.errors).toContain('personal.title is required');
});
it('requires personal.email', () => {
const result = validateCV({
personal: { name: 'Test', title: 'Developer' }
});
expect(result.valid).toBe(false);
expect(result.errors).toContain('personal.email is required');
});
it('requires experience to be array', () => {
const result = validateCV({
personal: { name: 'Test', title: 'Dev', email: 'test@test.com' },
experience: 'not an array'
});
expect(result.valid).toBe(false);
expect(result.errors).toContain('experience must be an array');
});
it('requires skills to be object', () => {
const result = validateCV({
personal: { name: 'Test', title: 'Dev', email: 'test@test.com' },
experience: [],
skills: 'not an object'
});
expect(result.valid).toBe(false);
expect(result.errors).toContain('skills must be an object');
});
it('requires education to be array', () => {
const result = validateCV({
personal: { name: 'Test', title: 'Dev', email: 'test@test.com' },
experience: [],
skills: {},
education: 'not an array'
});
expect(result.valid).toBe(false);
expect(result.errors).toContain('education must be an array');
});
it('requires projects to be array', () => {
const result = validateCV({
personal: { name: 'Test', title: 'Dev', email: 'test@test.com' },
experience: [],
skills: {},
education: [],
projects: 'not an array'
});
expect(result.valid).toBe(false);
expect(result.errors).toContain('projects must be an array');
});
});
describe('mergeCVData', () => {
it('merges personal data', () => {
const base = { personal: { name: 'Old', title: 'Dev' } };
const updates = { personal: { name: 'New' } };
const result = mergeCVData(base, updates);
expect(result.personal.name).toBe('New');
expect(result.personal.title).toBe('Dev');
});
it('merges top-level fields', () => {
const base = { experience: [], skills: {} };
const updates = { experience: [{ id: 1 }] };
const result = mergeCVData(base, updates);
expect(result.experience).toHaveLength(1);
expect(result.skills).toEqual({});
});
});
describe('exportToJSON', () => {
it('exports valid JSON string', () => {
const data = { personal: { name: 'Test' } };
const json = exportToJSON(data);
expect(typeof json).toBe('string');
expect(JSON.parse(json)).toEqual(data);
});
it('formats JSON with indentation', () => {
const data = { personal: { name: 'Test' } };
const json = exportToJSON(data);
expect(json).toContain('\n');
expect(json).toContain(' ');
});
});
describe('importFromJSON', () => {
it('imports valid JSON', () => {
const json = JSON.stringify({
personal: { name: 'Test', title: 'Dev', email: 'test@test.com' },
experience: [],
skills: {},
education: [],
projects: []
});
const result = importFromJSON(json);
expect(result.success).toBe(true);
expect(result.data.personal.name).toBe('Test');
});
it('fails on invalid JSON', () => {
const result = importFromJSON('not valid json');
expect(result.success).toBe(false);
expect(result.errors).toBeDefined();
expect(result.errors[0]).toContain('JSON parse error');
});
it('fails on invalid CV structure', () => {
const json = JSON.stringify({ personal: {} });
const result = importFromJSON(json);
expect(result.success).toBe(false);
expect(result.errors).toContain('personal.name is required');
});
});
describe('DEFAULT_DATA', () => {
it('is valid', () => {
const result = validateCV(DEFAULT_DATA);
expect(result.valid).toBe(true);
});
it('has all required fields', () => {
expect(DEFAULT_DATA.personal.name).toBeDefined();
expect(DEFAULT_DATA.personal.title).toBeDefined();
expect(DEFAULT_DATA.personal.email).toBeDefined();
expect(Array.isArray(DEFAULT_DATA.experience)).toBe(true);
expect(typeof DEFAULT_DATA.skills).toBe('object');
expect(Array.isArray(DEFAULT_DATA.education)).toBe(true);
expect(Array.isArray(DEFAULT_DATA.projects)).toBe(true);
});
});

71
src/lib/api.js Normal file
View File

@@ -0,0 +1,71 @@
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
import { authHeaders } from './auth';
export async function fetchCV() {
const response = await fetch(`${API_URL}/api/cv`);
if (!response.ok) {
throw new Error(`Failed to fetch CV: ${response.statusText}`);
}
return response.json();
}
export async function updateCV(data) {
const response = await fetch(`${API_URL}/api/cv`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...authHeaders(),
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Failed to update CV: ${response.statusText}`);
}
return response.json();
}
export async function exportCV() {
const response = await fetch(`${API_URL}/api/cv/export`);
if (!response.ok) {
throw new Error(`Failed to export CV: ${response.statusText}`);
}
return response.blob();
}
export async function importCV(data) {
const response = await fetch(`${API_URL}/api/cv/import`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...authHeaders(),
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Failed to import CV: ${response.statusText}`);
}
return response.json();
}
export async function getAuthConfig() {
const response = await fetch(`${API_URL}/api/auth/config`);
if (!response.ok) {
throw new Error(`Failed to get auth config: ${response.statusText}`);
}
return response.json();
}
export async function login(password) {
const response = await fetch(`${API_URL}/api/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ password }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Login failed');
}
return response.json();
}

18
src/lib/auth.js Normal file
View File

@@ -0,0 +1,18 @@
const AUTH_TOKEN_KEY = 'cv_admin_token';
export function getToken() {
return localStorage.getItem(AUTH_TOKEN_KEY);
}
export function setToken(token) {
localStorage.setItem(AUTH_TOKEN_KEY, token);
}
export function clearToken() {
localStorage.removeItem(AUTH_TOKEN_KEY);
}
export function authHeaders() {
const token = getToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}

74
src/lib/cv-data.js Normal file
View File

@@ -0,0 +1,74 @@
import defaultData from './cv.json';
export const DEFAULT_DATA = defaultData;
export function validateCV(data) {
const errors = [];
if (!data.personal) {
errors.push('personal is required');
} else {
if (!data.personal.name?.trim()) errors.push('personal.name is required');
if (!data.personal.title?.trim()) errors.push('personal.title is required');
if (!data.personal.email?.trim()) errors.push('personal.email is required');
}
if (!Array.isArray(data.experience)) {
errors.push('experience must be an array');
}
if (!data.skills || typeof data.skills !== 'object') {
errors.push('skills must be an object');
}
if (!Array.isArray(data.education)) {
errors.push('education must be an array');
}
if (!Array.isArray(data.projects)) {
errors.push('projects must be an array');
}
return {
valid: errors.length === 0,
errors
};
}
export function mergeCVData(base, updates) {
return {
...base,
...updates,
personal: { ...base.personal, ...updates.personal }
};
}
export function exportToJSON(data) {
return JSON.stringify(data, null, 2);
}
export function importFromJSON(jsonString) {
try {
const parsed = JSON.parse(jsonString);
const validation = validateCV(parsed);
if (!validation.valid) {
return { success: false, errors: validation.errors };
}
return { success: true, data: parsed };
} catch (e) {
return { success: false, errors: [`JSON parse error: ${e.message}`] };
}
}
export function downloadJSON(data, filename = 'cv.json') {
const json = exportToJSON(data);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}

73
src/lib/cv.json Normal file
View File

@@ -0,0 +1,73 @@
{
"personal": {
"name": "Tuan-Dat Tran",
"title": "Junior DevOps Engineer",
"intro": "Passionierter DevOps Engineer mit Fokus auf Cloud-Infrastruktur, Container-Orchestrierung und automatisierte Deployment-Pipelines.",
"email": "tuan-dat.tran@example.com",
"github": "https://github.com/tuan-dat-tran",
"linkedin": "https://linkedin.com/in/tuan-dat-tran",
"location": "Deutschland"
},
"experience": [
{
"id": 1,
"role": "Junior DevOps Engineer",
"company": "Unternehmen",
"period": "Seit 2025",
"type": "Vollzeit",
"description": "Verantwortlich für CI/CD Pipelines, Container-Orchestrierung und Cloud-Infrastruktur auf Azure.",
"highlights": [
"Automatisierte Deployments mit Jenkins und Bitbucket Pipelines",
"Kubernetes Cluster Management mit Helm und ArgoCD",
"Infrastructure as Code mit Azure Resource Manager"
]
},
{
"id": 2,
"role": "Software Entwickler",
"company": "Verschiedene Unternehmen",
"period": "2021 - 2025",
"type": "Teilzeit / Werkstudent / SHK",
"description": "Entwicklung und Maintenance von Softwarelösungen in verschiedenen Rollen.",
"highlights": [
"Backend-Entwicklung mit Python",
"Linux System Administration",
"Git Version Control und Release Engineering"
]
}
],
"skills": {
"Container & Orchestration": ["Docker", "Kubernetes", "Helm"],
"CI/CD & GitOps": ["Jenkins", "Bitbucket", "ArgoCD", "Git"],
"Cloud": ["Azure"],
"Infrastructure as Code": ["Azure Resource Manager"],
"Release Engineering": ["Release Engineering"],
"Operating Systems": ["Linux Administration"],
"Programming": ["Python", "Bash", "Rust"]
},
"education": [
{
"id": 1,
"degree": "Bachelor Informatik",
"institution": "Universität",
"period": "Laufend",
"status": "Bachelorarbeit offen"
}
],
"projects": [
{
"id": 1,
"name": "Projekt 1",
"description": "Beschreibung folgt",
"url": "#",
"tech": ["Docker", "Kubernetes", "ArgoCD"]
},
{
"id": 2,
"name": "Projekt 2",
"description": "Beschreibung folgt",
"url": "#",
"tech": ["Azure", "ARM", "Jenkins"]
}
]
}

10
src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

1
src/test/setup.js Normal file
View File

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

38
tests/e2e/admin.spec.js Normal file
View File

@@ -0,0 +1,38 @@
import { test, expect } from '@playwright/test';
test.describe('Admin Panel', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/admin');
});
test('displays admin navigation', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'CV Admin' })).toBeVisible();
});
test('navigation links work', async ({ page }) => {
await page.click('text=Erfahrung');
await expect(page).toHaveURL(/.*experience/);
await page.click('text=Skills');
await expect(page).toHaveURL(/.*skills/);
await page.click('text=Persönlich');
await expect(page).toHaveURL(/.*admin$/);
});
test('personal form displays fields', async ({ page }) => {
const form = page.locator('form');
await expect(form.locator('input[name="name"]')).toBeVisible();
await expect(form.locator('input[name="title"]')).toBeVisible();
await expect(form.locator('input[name="email"]')).toBeVisible();
});
test('can navigate to experience section', async ({ page }) => {
await page.click('text=Erfahrung');
await expect(page.getByRole('heading', { name: 'Berufserfahrung' })).toBeVisible();
});
test('export button is present', async ({ page }) => {
await expect(page.getByRole('button', { name: /json export/i })).toBeVisible();
});
});

39
tests/e2e/cv.spec.js Normal file
View File

@@ -0,0 +1,39 @@
import { test, expect } from '@playwright/test';
test.describe('CV Page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('displays hero section with name', async ({ page }) => {
await expect(page.locator('h1')).toContainText('Tuan-Dat Tran');
});
test('displays job title', async ({ page }) => {
const heroTitle = page.locator('section').first().locator('h2');
await expect(heroTitle).toContainText('DevOps Engineer');
});
test('displays experience section', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Berufserfahrung' })).toBeVisible();
});
test('displays skills section', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Skills' })).toBeVisible();
});
test('displays education section', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Ausbildung' })).toBeVisible();
});
test('displays projects section', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Projekte' })).toBeVisible();
});
test('contact links are present', async ({ page }) => {
const heroSection = page.locator('section').first();
await expect(heroSection.getByRole('link', { name: 'Email' })).toBeVisible();
await expect(heroSection.getByRole('link', { name: 'GitHub' })).toBeVisible();
await expect(heroSection.getByRole('link', { name: 'LinkedIn' })).toBeVisible();
});
});

View File

@@ -0,0 +1,17 @@
version: '3.8'
services:
backend-test:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "3002:3001"
environment:
- PORT=3001
- DB_PATH=/app/data/test.db
volumes:
- test-data:/app/data
volumes:
test-data:

View File

@@ -0,0 +1,205 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import express from 'express';
import cors from 'cors';
import { mkdirSync, existsSync, rmSync } from 'fs';
import { join } from 'path';
import Database from 'better-sqlite3';
const testDbPath = join(process.cwd(), 'tests', 'integration', 'test.db');
let app;
let db;
function setupTestDB() {
const dbDir = join(process.cwd(), 'tests', 'integration');
if (!existsSync(dbDir)) {
mkdirSync(dbDir, { recursive: true });
}
if (existsSync(testDbPath)) {
rmSync(testDbPath);
}
db = new Database(testDbPath);
db.exec(`
CREATE TABLE IF NOT EXISTS cv_data (
id INTEGER PRIMARY KEY CHECK (id = 1),
data TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
const defaultData = {
personal: { name: 'Test User', title: 'Developer', email: 'test@test.com' },
experience: [],
skills: {},
education: [],
projects: []
};
db.prepare('INSERT INTO cv_data (id, data) VALUES (1, ?)').run(JSON.stringify(defaultData));
}
function createTestApp() {
const app = express();
app.use(cors());
app.use(express.json());
app.get('/api/cv', (req, res) => {
const row = db.prepare('SELECT data FROM cv_data WHERE id = 1').get();
if (!row) return res.status(404).json({ error: 'CV data not found' });
res.json(JSON.parse(row.data));
});
app.put('/api/cv', (req, res) => {
const data = req.body;
if (!data?.personal?.name) {
return res.status(400).json({ error: 'personal.name is required' });
}
db.prepare('UPDATE cv_data SET data = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1')
.run(JSON.stringify(data));
res.json({ success: true, message: 'CV data updated' });
});
app.get('/api/cv/export', (req, res) => {
const row = db.prepare('SELECT data FROM cv_data WHERE id = 1').get();
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', 'attachment; filename="cv.json"');
res.send(row.data);
});
app.post('/api/cv/import', (req, res) => {
const data = req.body;
if (!data?.personal?.name) {
return res.status(400).json({ error: 'personal.name is required' });
}
db.prepare('UPDATE cv_data SET data = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1')
.run(JSON.stringify(data));
res.json({ success: true, message: 'CV data imported' });
});
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
return app;
}
describe('Integration: Full Stack CV API', () => {
beforeAll(() => {
setupTestDB();
app = createTestApp();
});
afterAll(() => {
if (db) {
db.close();
}
if (existsSync(testDbPath)) {
rmSync(testDbPath);
}
});
describe('GET /api/cv', () => {
it('returns CV data from database', async () => {
const response = await request(app).get('/api/cv');
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('personal');
expect(response.body.personal.name).toBe('Test User');
});
it('returns all required CV sections', async () => {
const response = await request(app).get('/api/cv');
expect(response.body).toHaveProperty('personal');
expect(response.body).toHaveProperty('experience');
expect(response.body).toHaveProperty('skills');
expect(response.body).toHaveProperty('education');
expect(response.body).toHaveProperty('projects');
});
});
describe('PUT /api/cv', () => {
it('updates CV data in database', async () => {
const newData = {
personal: { name: 'Updated Name', title: 'Senior Dev', email: 'updated@test.com' },
experience: [{ id: 1, role: 'New Role' }],
skills: { 'Backend': ['Node.js'] },
education: [],
projects: []
};
const response = await request(app)
.put('/api/cv')
.send(newData);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
const verifyResponse = await request(app).get('/api/cv');
expect(verifyResponse.body.personal.name).toBe('Updated Name');
expect(verifyResponse.body.experience).toHaveLength(1);
});
it('validates required fields', async () => {
const response = await request(app)
.put('/api/cv')
.send({ personal: {} });
expect(response.status).toBe(400);
expect(response.body.error).toContain('name is required');
});
});
describe('GET /api/cv/export', () => {
it('exports CV as JSON file', async () => {
const response = await request(app).get('/api/cv/export');
expect(response.status).toBe(200);
expect(response.headers['content-type']).toContain('application/json');
expect(response.headers['content-disposition']).toContain('cv.json');
const data = JSON.parse(response.text);
expect(data).toHaveProperty('personal');
});
});
describe('POST /api/cv/import', () => {
it('imports valid CV data', async () => {
const importData = {
personal: { name: 'Imported User', title: 'Dev', email: 'import@test.com' },
experience: [],
skills: {},
education: [],
projects: []
};
const response = await request(app)
.post('/api/cv/import')
.send(importData);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
const verifyResponse = await request(app).get('/api/cv');
expect(verifyResponse.body.personal.name).toBe('Imported User');
});
it('rejects invalid CV data', async () => {
const response = await request(app)
.post('/api/cv/import')
.send({ personal: { title: 'No Name' } });
expect(response.status).toBe(400);
});
});
describe('GET /health', () => {
it('returns health status', async () => {
const response = await request(app).get('/health');
expect(response.status).toBe(200);
expect(response.body.status).toBe('ok');
expect(response.body).toHaveProperty('timestamp');
});
});
});

View File

@@ -0,0 +1,55 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
const errorRate = new Rate('errors');
const responseTime = new Trend('response_time');
export const options = {
stages: [
{ duration: '30s', target: 10 },
{ duration: '1m', target: 50 },
{ duration: '30s', target: 100 },
{ duration: '30s', target: 0 },
],
thresholds: {
errors: ['rate<0.1'],
http_req_duration: ['p(95)<500', 'p(99)<1000'],
response_time: ['p(95)<500'],
},
};
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001';
export default function () {
const response = http.get(`${BASE_URL}/api/cv`);
check(response, {
'status is 200': (r) => r.status === 200,
'has personal data': (r) => {
const body = r.json();
return body.personal && body.personal.name;
},
'has experience array': (r) => {
const body = r.json();
return Array.isArray(body.experience);
},
'has skills object': (r) => {
const body = r.json();
return body.skills && typeof body.skills === 'object';
},
'response time < 200ms': (r) => r.timings.duration < 200,
});
errorRate.add(response.status !== 200);
responseTime.add(response.timings.duration);
sleep(1);
}
export function handleSummary(data) {
return {
'stdout': JSON.stringify(data, null, 2),
'tests/performance/results.json': JSON.stringify(data, null, 2),
};
}

View File

@@ -0,0 +1,21 @@
module.exports = {
ci: {
collect: {
numberOfRuns: 3,
settings: {
preset: 'desktop',
},
},
assert: {
assertions: {
'categories:performance': ['warn', { minScore: 0.8 }],
'categories:accessibility': ['error', { minScore: 0.9 }],
'categories:best-practices': ['warn', { minScore: 0.8 }],
'categories:seo': ['warn', { minScore: 0.8 }],
},
},
upload: {
target: 'temporary-public-storage',
},
},
};

View File

@@ -0,0 +1,109 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Regression: API Snapshots > GET /api/cv response matches snapshot > cv-data-response 1`] = `
{
"education": [
{
"degree": "CS Degree",
"id": 1,
"institution": "Test University",
"period": "2016-2020",
"status": "Completed",
},
],
"experience": [
{
"company": "Test Corp",
"description": "Testing",
"highlights": [
"Automated tests",
],
"id": 1,
"period": "2020-2024",
"role": "QA Engineer",
"type": "Vollzeit",
},
],
"personal": {
"email": "regression@test.com",
"name": "Regression Test User",
"title": "Test Engineer",
},
"projects": [
{
"description": "Test framework",
"id": 1,
"name": "Test Project",
"tech": [
"Vitest",
],
"url": "https://test.com",
},
],
"skills": {
"Testing": [
"Vitest",
"Playwright",
],
},
}
`;
exports[`Regression: API Snapshots > education section matches snapshot > education-section 1`] = `
[
{
"degree": "CS Degree",
"id": 1,
"institution": "Test University",
"period": "2016-2020",
"status": "Completed",
},
]
`;
exports[`Regression: API Snapshots > experience section matches snapshot > experience-section 1`] = `
[
{
"company": "Test Corp",
"description": "Testing",
"highlights": [
"Automated tests",
],
"id": 1,
"period": "2020-2024",
"role": "QA Engineer",
"type": "Vollzeit",
},
]
`;
exports[`Regression: API Snapshots > personal section matches snapshot > personal-section 1`] = `
{
"email": "regression@test.com",
"name": "Regression Test User",
"title": "Test Engineer",
}
`;
exports[`Regression: API Snapshots > projects section matches snapshot > projects-section 1`] = `
[
{
"description": "Test framework",
"id": 1,
"name": "Test Project",
"tech": [
"Vitest",
],
"url": "https://test.com",
},
]
`;
exports[`Regression: API Snapshots > skills section matches snapshot > skills-section 1`] = `
{
"Testing": [
"Vitest",
"Playwright",
],
}
`;

View File

@@ -0,0 +1,104 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import express from 'express';
import cors from 'cors';
import { mkdirSync, existsSync, rmSync } from 'fs';
import { join } from 'path';
import Database from 'better-sqlite3';
const testDbPath = join(process.cwd(), 'tests', 'regression', 'api-snapshots', 'test.db');
const snapshotDir = join(process.cwd(), 'tests', 'regression', 'api-snapshots');
let app;
let db;
function setupTestDB() {
if (!existsSync(snapshotDir)) {
mkdirSync(snapshotDir, { recursive: true });
}
if (existsSync(testDbPath)) {
rmSync(testDbPath);
}
db = new Database(testDbPath);
db.exec(`
CREATE TABLE IF NOT EXISTS cv_data (
id INTEGER PRIMARY KEY CHECK (id = 1),
data TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
const defaultData = {
personal: { name: 'Regression Test User', title: 'Test Engineer', email: 'regression@test.com' },
experience: [{ id: 1, role: 'QA Engineer', company: 'Test Corp', period: '2020-2024', type: 'Vollzeit', description: 'Testing', highlights: ['Automated tests'] }],
skills: { 'Testing': ['Vitest', 'Playwright'] },
education: [{ id: 1, degree: 'CS Degree', institution: 'Test University', period: '2016-2020', status: 'Completed' }],
projects: [{ id: 1, name: 'Test Project', description: 'Test framework', url: 'https://test.com', tech: ['Vitest'] }]
};
db.prepare('INSERT INTO cv_data (id, data) VALUES (1, ?)').run(JSON.stringify(defaultData));
}
function createTestApp() {
const app = express();
app.use(cors());
app.use(express.json());
app.get('/api/cv', (req, res) => {
const row = db.prepare('SELECT data FROM cv_data WHERE id = 1').get();
if (!row) return res.status(404).json({ error: 'CV data not found' });
res.json(JSON.parse(row.data));
});
return app;
}
describe('Regression: API Snapshots', () => {
beforeAll(() => {
setupTestDB();
app = createTestApp();
});
afterAll(() => {
if (db) {
db.close();
}
if (existsSync(testDbPath)) {
rmSync(testDbPath);
}
});
it('GET /api/cv response matches snapshot', async () => {
const response = await request(app).get('/api/cv');
expect(response.status).toBe(200);
expect(response.body).toMatchSnapshot('cv-data-response');
});
it('personal section matches snapshot', async () => {
const response = await request(app).get('/api/cv');
expect(response.body.personal).toMatchSnapshot('personal-section');
});
it('experience section matches snapshot', async () => {
const response = await request(app).get('/api/cv');
expect(response.body.experience).toMatchSnapshot('experience-section');
});
it('skills section matches snapshot', async () => {
const response = await request(app).get('/api/cv');
expect(response.body.skills).toMatchSnapshot('skills-section');
});
it('education section matches snapshot', async () => {
const response = await request(app).get('/api/cv');
expect(response.body.education).toMatchSnapshot('education-section');
});
it('projects section matches snapshot', async () => {
const response = await request(app).get('/api/cv');
expect(response.body.projects).toMatchSnapshot('projects-section');
});
});

View File

@@ -0,0 +1,44 @@
import { test, expect } from '@playwright/test';
test.describe('Visual Regression: CV Page', () => {
test('hero section visual snapshot', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
const hero = page.locator('section').first();
await expect(hero).toHaveScreenshot('hero-section.png', {
maxDiffPixels: 100,
});
});
test('full page visual snapshot', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('full-page.png', {
fullPage: true,
maxDiffPixels: 500,
});
});
});
test.describe('Visual Regression: Admin Page', () => {
test('admin panel visual snapshot', async ({ page }) => {
await page.goto('/admin');
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('admin-panel.png', {
maxDiffPixels: 200,
});
});
test('personal form visual snapshot', async ({ page }) => {
await page.goto('/admin');
await page.waitForLoadState('networkidle');
const form = page.locator('form').first();
await expect(form).toHaveScreenshot('personal-form.png', {
maxDiffPixels: 100,
});
});
});

8
vite.config.js Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig(({ mode }) => ({
plugins: [react(), tailwindcss()],
base: mode === 'production' ? '/cv/' : '/',
}))