Compare commits
40 Commits
963b8eccf8
...
renovate/v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
738d85f927 | ||
|
|
649cf140fa | ||
|
|
38bbb5455c | ||
|
|
8bcf8ab041 | ||
|
|
ff4dd10b85 | ||
|
|
e9c7d52bde | ||
|
|
2a806326f1 | ||
|
|
4d505d1de0 | ||
|
|
9b32c413c6 | ||
|
|
c559f0757a | ||
|
|
6c9d4740e6 | ||
|
|
83fed13e56 | ||
|
|
c18e4dda10 | ||
| 8deb3dcd9f | |||
|
|
8b5e12fa0a | ||
|
|
d73e8769c0 | ||
|
|
8d7000eb31 | ||
|
|
dd3b83f7e1 | ||
|
|
bd44ea42d7 | ||
|
|
ad2012e6d5 | ||
|
|
a593dd53c5 | ||
|
|
6f01124ac5 | ||
|
|
de67a6d0ef | ||
|
|
af6fe5171f | ||
|
|
e9d2667c95 | ||
|
|
7aaca06ce7 | ||
|
|
c240aa3641 | ||
|
|
cd22a970cb | ||
|
|
60d0aa5ae2 | ||
|
|
ff26ff6570 | ||
|
|
baa57473ac | ||
|
|
31d58103fd | ||
|
|
15a5c0a59c | ||
|
|
36b5a62953 | ||
|
|
4198b91498 | ||
|
|
00af8862d3 | ||
|
|
7f06ee7f53 | ||
|
|
cbf40908a6 | ||
|
|
3d2ee7df7f | ||
|
|
c18410bf5e |
63
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
63
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
|
||||
46
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
46
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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
|
||||
40
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
40
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
## Description
|
||||
|
||||
<!-- Brief description of changes -->
|
||||
|
||||
## Type of Change
|
||||
|
||||
- [ ] `feat` - New feature (minor version bump)
|
||||
- [ ] `fix` - Bug fix (patch version bump)
|
||||
- [ ] `perf` - Performance improvement (patch version bump)
|
||||
- [ ] `docs` - Documentation only
|
||||
- [ ] `refactor` - Code refactoring
|
||||
- [ ] `test` - Adding/updating tests
|
||||
- [ ] `ci` - CI/CD changes
|
||||
- [ ] `chore` - Maintenance tasks
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
- [ ] This is a breaking change (major version bump)
|
||||
|
||||
**If breaking, describe the migration path:**
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Commit messages follow [Conventional Commits](CONTRIBUTING.md#commit-guidelines)
|
||||
- [ ] Lint passes (`npm run lint`)
|
||||
- [ ] Build succeeds (`npm run build`)
|
||||
- [ ] Tests pass (`npm run test:run`)
|
||||
- [ ] API documentation updated (if applicable)
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- Describe how you tested these changes -->
|
||||
|
||||
## Screenshots (if applicable)
|
||||
|
||||
<!-- Add screenshots for UI changes -->
|
||||
|
||||
## Related Issues
|
||||
|
||||
<!-- Link issues: Closes #123, Fixes #456 -->
|
||||
32
.github/dependabot.yml
vendored
Normal file
32
.github/dependabot.yml
vendored
Normal 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"
|
||||
136
.github/workflows/ci.yml
vendored
136
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
run: npm ci --prefer-offline --no-audit --no-fund
|
||||
|
||||
- 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
|
||||
|
||||
run: npm ci --prefer-offline --no-audit --no-fund
|
||||
|
||||
- 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
|
||||
|
||||
run: npm ci --prefer-offline --no-audit --no-fund
|
||||
|
||||
- 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
|
||||
|
||||
run: npm ci --prefer-offline --no-audit --no-fund
|
||||
|
||||
- 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
|
||||
|
||||
run: npm ci --prefer-offline --no-audit --no-fund
|
||||
|
||||
- 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
|
||||
|
||||
run: npm ci --prefer-offline --no-audit --no-fund
|
||||
|
||||
- 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
|
||||
|
||||
run: npm ci --prefer-offline --no-audit --no-fund
|
||||
|
||||
- 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
|
||||
|
||||
run: npm ci --prefer-offline --no-audit --no-fund
|
||||
|
||||
- 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
55
.github/workflows/deploy.yml
vendored
Normal 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
|
||||
33
.github/workflows/nightly.yml
vendored
33
.github/workflows/nightly.yml
vendored
@@ -2,7 +2,7 @@ name: Nightly Build
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *'
|
||||
- cron: "0 2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -14,52 +14,53 @@ jobs:
|
||||
name: Build Nightly Image
|
||||
runs-on: ubuntu-latest
|
||||
environment: nightly
|
||||
|
||||
if: false
|
||||
|
||||
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
|
||||
|
||||
run: npm ci --prefer-offline --no-audit --no-fund
|
||||
|
||||
- name: Install backend dependencies
|
||||
working-directory: ./backend
|
||||
run: npm ci
|
||||
|
||||
run: npm ci --prefer-offline --no-audit --no-fund
|
||||
|
||||
- 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:
|
||||
|
||||
42
.github/workflows/release.yml
vendored
42
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
run: npm ci --prefer-offline --no-audit --no-fund
|
||||
|
||||
- name: Install backend dependencies
|
||||
working-directory: ./backend
|
||||
run: npm ci
|
||||
|
||||
run: npm ci --prefer-offline --no-audit --no-fund
|
||||
|
||||
- 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 }}
|
||||
@@ -58,40 +58,40 @@ jobs:
|
||||
name: Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
if: needs.release.result == 'success'
|
||||
|
||||
if: false
|
||||
|
||||
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:
|
||||
|
||||
33
.github/workflows/staging.yml
vendored
33
.github/workflows/staging.yml
vendored
@@ -14,52 +14,53 @@ jobs:
|
||||
name: Build & Deploy to Staging
|
||||
runs-on: ubuntu-latest
|
||||
environment: staging
|
||||
|
||||
if: false
|
||||
|
||||
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
|
||||
|
||||
run: npm ci --prefer-offline --no-audit --no-fund
|
||||
|
||||
- name: Install backend dependencies
|
||||
working-directory: ./backend
|
||||
run: npm ci
|
||||
|
||||
run: npm ci --prefer-offline --no-audit --no-fund
|
||||
|
||||
- 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
37
.github/workflows/stale.yml
vendored
Normal 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'
|
||||
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
# 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?
|
||||
|
||||
# Worktrees
|
||||
.worktrees/
|
||||
|
||||
# Database
|
||||
backend/data/
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
|
||||
# Test files
|
||||
backend/__tests__/*.db
|
||||
coverage/
|
||||
@@ -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
|
||||
|
||||
@@ -64,9 +64,9 @@
|
||||
[
|
||||
"@semantic-release/github",
|
||||
{
|
||||
"assets": [
|
||||
{ "path": "dist/**/*", "label": "Distribution" }
|
||||
]
|
||||
"successCommentCondition": false,
|
||||
"failCommentCondition": false,
|
||||
"releasedLabels": false
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
78
CHANGELOG.md
78
CHANGELOG.md
@@ -2,6 +2,84 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [1.1.0](https://git.seyshiro.de/tudattr/kilo-cv/compare/v1.0.4...v1.1.0) (2026-02-25)
|
||||
|
||||
### Features
|
||||
|
||||
* Add Helm chart for Kubernetes deployment ([#1](https://git.seyshiro.de/tudattr/kilo-cv/issues/1)) ([8deb3dc](https://git.seyshiro.de/tudattr/kilo-cv/commit/8deb3dcd9fcacfacd29ffe7b6dc7862ac9c48267)), closes [CONTRIBUTING.md#commit-guidelines](https://git.seyshiro.de/tudattr/CONTRIBUTING.md/issues/commit-guidelines)
|
||||
|
||||
## [1.0.4](https://git.seyshiro.de/tudattr/kilo-cv/compare/v1.0.3...v1.0.4) (2026-02-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** disable docker jobs due to missing docker in gitea runner ([bd44ea4](https://git.seyshiro.de/tudattr/kilo-cv/commit/bd44ea42d76fe8f177accf039da8a37975436748))
|
||||
|
||||
### Documentation
|
||||
|
||||
* add architecture documentation ([8d7000e](https://git.seyshiro.de/tudattr/kilo-cv/commit/8d7000eb31d0417ed3318111aa64160306915b3c))
|
||||
* update all markdown documentation ([d73e876](https://git.seyshiro.de/tudattr/kilo-cv/commit/d73e8769c0207a248cf57898f2e20122c195a0b1))
|
||||
* update release-engineering documentation ([dd3b83f](https://git.seyshiro.de/tudattr/kilo-cv/commit/dd3b83f7e11fc0680e491615957963ce7b9dd842))
|
||||
|
||||
## [1.0.3](https://git.seyshiro.de/tudattr/kilo-cv/compare/v1.0.2...v1.0.3) (2026-02-23)
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **ci:** optimize npm install and fix deprecation warnings ([a593dd5](https://git.seyshiro.de/tudattr/kilo-cv/commit/a593dd53c5f9f7b170833c859025ff0aac2d17db))
|
||||
|
||||
## [1.0.2](https://git.seyshiro.de/tudattr/kilo-cv/compare/v1.0.1...v1.0.2) (2026-02-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **release:** disable github plugin comments for gitea compatibility ([de67a6d](https://git.seyshiro.de/tudattr/kilo-cv/commit/de67a6d0efc75baffd7f11f876427b5c5f48b229))
|
||||
|
||||
## [1.0.1](https://git.seyshiro.de/tudattr/kilo-cv/compare/v1.0.0...v1.0.1) (2026-02-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **release:** remove dist assets from github release config ([e9d2667](https://git.seyshiro.de/tudattr/kilo-cv/commit/e9d2667c959baf60c82519ecd5e24dac5953c4a0))
|
||||
|
||||
## 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
1
CODEOWNERS
Normal file
@@ -0,0 +1 @@
|
||||
* @tuan-dat-tran
|
||||
164
CONTRIBUTING.md
Normal file
164
CONTRIBUTING.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# Contributing Guidelines
|
||||
|
||||
Thank you for your interest in contributing to this project! This document provides guidelines and instructions for contributing.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Be respectful and constructive. Treat all contributors with courtesy.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 20+
|
||||
- npm 10+
|
||||
- Gitleaks (for secret scanning)
|
||||
|
||||
### Setup
|
||||
|
||||
1. Fork the repository
|
||||
2. Clone your fork
|
||||
3. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cd backend && npm install && cd ..
|
||||
```
|
||||
|
||||
4. Install Gitleaks (for pre-commit hooks):
|
||||
|
||||
```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/
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Branch Naming
|
||||
|
||||
- `feat/feature-name` - New features
|
||||
- `fix/bug-name` - Bug fixes
|
||||
- `docs/topic` - Documentation changes
|
||||
- `refactor/component` - Code refactoring
|
||||
- `test/test-name` - Test additions/changes
|
||||
|
||||
### Commit Guidelines
|
||||
|
||||
This project uses [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer]
|
||||
```
|
||||
|
||||
#### Types
|
||||
|
||||
| Type | Description | Version Impact |
|
||||
|------|-------------|----------------|
|
||||
| `feat` | New feature | Minor |
|
||||
| `fix` | Bug fix | Patch |
|
||||
| `perf` | Performance improvement | Patch |
|
||||
| `revert` | Revert previous commit | Patch |
|
||||
| `docs` | Documentation only | None |
|
||||
| `style` | Code style (formatting) | None |
|
||||
| `refactor` | Code refactoring | None |
|
||||
| `test` | Adding/updating tests | None |
|
||||
| `build` | Build system changes | None |
|
||||
| `ci` | CI/CD changes | None |
|
||||
| `chore` | Maintenance tasks | None |
|
||||
|
||||
#### 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 |
|
||||
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
feat(admin): add export to PDF functionality
|
||||
fix(api): resolve JWT token expiration issue
|
||||
docs(readme): update installation instructions
|
||||
ci(workflow): add staging deployment
|
||||
```
|
||||
|
||||
### Pre-commit Hooks
|
||||
|
||||
The project uses Husky for git hooks:
|
||||
|
||||
- **pre-commit**: Runs ESLint and Gitleaks secret scanning
|
||||
- **commit-msg**: Validates commit message format with commitlint
|
||||
|
||||
### Code Style
|
||||
|
||||
- ESLint configuration is enforced
|
||||
- Run `npm run lint` before committing
|
||||
- Fix issues with `npm run lint -- --fix`
|
||||
|
||||
### Testing
|
||||
|
||||
Run tests before submitting:
|
||||
|
||||
```bash
|
||||
# Unit tests
|
||||
npm run test:run
|
||||
|
||||
# Integration tests
|
||||
npm run test:integration
|
||||
|
||||
# E2E tests (requires running server)
|
||||
npm run test:e2e
|
||||
|
||||
# All tests with coverage
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. Create a feature branch from `master`
|
||||
2. Make your changes following the guidelines above
|
||||
3. Ensure all tests pass
|
||||
4. Ensure lint passes (`npm run lint`)
|
||||
5. Ensure build succeeds (`npm run build`)
|
||||
6. Push to your fork and create a pull request
|
||||
7. Fill out the PR template completely
|
||||
8. Wait for review
|
||||
|
||||
### PR Checklist
|
||||
|
||||
- [ ] Follows contributing guidelines
|
||||
- [ ] Commit messages follow conventional commits
|
||||
- [ ] Lint passes (`npm run lint`)
|
||||
- [ ] Tests pass (`npm run test:run`)
|
||||
- [ ] Build succeeds (`npm run build`)
|
||||
- [ ] Documentation updated if needed
|
||||
|
||||
## Release Process
|
||||
|
||||
Releases are automated via semantic-release:
|
||||
|
||||
1. Merge PR to `master`
|
||||
2. CI runs tests and build
|
||||
3. semantic-release analyzes commits
|
||||
4. Version bumped, changelog updated
|
||||
5. Git tag and release created
|
||||
|
||||
No manual release steps required.
|
||||
|
||||
## Questions?
|
||||
|
||||
Open an issue for questions or discussions about contributions.
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine as build
|
||||
FROM node:25-alpine as build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
||||
187
README.md
187
README.md
@@ -1,8 +1,8 @@
|
||||
# CV - Tuan-Dat Tran
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
A modern, minimalist CV/Resume single-page application with admin panel and persistent storage.
|
||||
@@ -29,32 +29,33 @@ A modern, minimalist CV/Resume single-page application with admin panel and pers
|
||||
|
||||
- Modern, responsive design
|
||||
- Smooth scroll animations with Framer Motion
|
||||
- Admin panel for easy CV editing (password protected)
|
||||
- Admin panel for CV editing (password protected)
|
||||
- Persistent storage with SQLite
|
||||
- Docker Compose deployment
|
||||
- API-based architecture
|
||||
- RESTful API with OpenAPI documentation
|
||||
- Optional Keycloak integration for SSO
|
||||
- Automated releases with semantic-release
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Frontend
|
||||
- **React 18** - UI Library
|
||||
- **Vite** - Build Tool
|
||||
- **React 19** - UI Library
|
||||
- **Vite 7** - Build Tool
|
||||
- **Tailwind CSS 4** - Styling
|
||||
- **Framer Motion** - Animations
|
||||
- **Framer Motion 12** - Animations
|
||||
- **Lucide React** - Icons
|
||||
|
||||
### Backend
|
||||
- **Express.js** - API Server
|
||||
- **SQLite** - Database
|
||||
- **better-sqlite3** - SQLite driver
|
||||
- **Express.js 4** - API Server
|
||||
- **Knex.js** - SQL Query Builder
|
||||
- **SQLite** with **better-sqlite3** - Database
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- npm 9+
|
||||
- Node.js 20+
|
||||
- npm 10+
|
||||
- Docker & Docker Compose (optional)
|
||||
|
||||
### Option 1: Docker Compose (Recommended)
|
||||
@@ -66,6 +67,7 @@ docker-compose up -d
|
||||
# Frontend: http://localhost:5173
|
||||
# Backend API: http://localhost:3001
|
||||
# Admin Panel: http://localhost:5173/admin
|
||||
# API Docs: http://localhost:3001/api/docs
|
||||
```
|
||||
|
||||
### Option 2: Local Development
|
||||
@@ -86,161 +88,76 @@ npm run dev
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Frontend (`.env`):
|
||||
```
|
||||
VITE_API_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
Backend (`backend/.env`):
|
||||
```
|
||||
PORT=3001
|
||||
DB_PATH=./data/cv.db
|
||||
USE_KEYCLOAK=false
|
||||
# Keycloak settings (required if USE_KEYCLOAK=true)
|
||||
AUTH_MODE=simple
|
||||
# Keycloak settings (required if AUTH_MODE=keycloak)
|
||||
KEYCLOAK_URL=https://keycloak.example.com
|
||||
KEYCLOAK_REALM=your-realm
|
||||
KEYCLOAK_CLIENT_ID=cv-admin
|
||||
KEYCLOAK_CLIENT_ID=cv-app
|
||||
```
|
||||
|
||||
## Admin Authentication
|
||||
## Development Setup
|
||||
|
||||
### Simple Mode (Default)
|
||||
- A random password is generated and printed to the console on server startup
|
||||
- Look for the "ADMIN PASSWORD" banner in the logs
|
||||
- Enter this password to access the admin panel
|
||||
### Pre-commit Setup
|
||||
|
||||
### Keycloak Mode
|
||||
Set `USE_KEYCLOAK=true` and configure Keycloak environment variables:
|
||||
```bash
|
||||
USE_KEYCLOAK=true
|
||||
KEYCLOAK_URL=https://keycloak.example.com
|
||||
KEYCLOAK_REALM=your-realm
|
||||
KEYCLOAK_CLIENT_ID=cv-admin
|
||||
```
|
||||
This project uses:
|
||||
- **ESLint** for code linting
|
||||
- **commitlint** for commit message validation
|
||||
- **Husky** for git hooks
|
||||
- **Gitleaks** for secret scanning
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| GET | `/api/cv` | No | Get CV data |
|
||||
| PUT | `/api/cv` | Yes | Update CV data |
|
||||
| GET | `/api/cv/export` | No | Export as JSON |
|
||||
| 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 | `/health` | No | Health check |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── src/ # Frontend source
|
||||
│ ├── components/ # CV components
|
||||
│ ├── admin/ # Admin panel
|
||||
│ ├── lib/ # Utilities
|
||||
│ └── App.jsx
|
||||
├── backend/ # Backend source
|
||||
│ ├── routes/ # API routes
|
||||
│ ├── db/ # Database
|
||||
│ └── server.js
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
└── nginx.conf
|
||||
```
|
||||
|
||||
## Testing
|
||||
Install Gitleaks before committing:
|
||||
|
||||
```bash
|
||||
# Frontend tests
|
||||
npm run test:run
|
||||
# macOS
|
||||
brew install gitleaks
|
||||
|
||||
# Backend tests
|
||||
cd backend && npm test
|
||||
# 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
|
||||
```
|
||||
|
||||
## Release Process
|
||||
### Commit Guidelines
|
||||
|
||||
This project uses [semantic-release](https://semantic-release.gitbook.io/) for automated versioning and releases.
|
||||
|
||||
### Conventional Commits
|
||||
|
||||
All commit messages must follow the [Conventional Commits](https://www.conventionalcommits.org/) specification:
|
||||
This project uses [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer]
|
||||
# Examples:
|
||||
feat(ui): add export button
|
||||
fix(api): resolve authentication issue
|
||||
docs(readme): update installation steps
|
||||
```
|
||||
|
||||
#### Commit Types
|
||||
### API Documentation
|
||||
|
||||
| Type | Description | Version Bump |
|
||||
|------|-------------|--------------|
|
||||
| `feat` | New feature | Minor |
|
||||
| `fix` | Bug fix | Patch |
|
||||
| `feat!` or `BREAKING CHANGE` | Breaking change | Major |
|
||||
| `docs`, `style`, `refactor`, `test`, `build`, `ci`, `chore` | Non-release changes | None |
|
||||
Interactive Swagger UI available at: `http://localhost:3001/api/docs`
|
||||
|
||||
#### Scopes
|
||||
## Admin Authentication
|
||||
|
||||
Available scopes: `admin`, `api`, `ui`, `docker`, `ci`, `deps`, `release`, `auth`, `skills`, `experience`, `education`, `projects`, `personal`
|
||||
### Simple Mode (Default)
|
||||
- Random password generated on server startup
|
||||
- Password displayed in console logs
|
||||
- JWT token-based authentication
|
||||
|
||||
### Release Workflow
|
||||
|
||||
1. Push to `master` branch
|
||||
2. CI runs tests and linting
|
||||
3. semantic-release analyzes commits
|
||||
4. If release needed:
|
||||
- Version is bumped in package.json
|
||||
- CHANGELOG.md is updated
|
||||
- Git tag is created
|
||||
- GitHub release is published
|
||||
- Docker images are built and pushed
|
||||
|
||||
### Docker Images
|
||||
|
||||
Images are published to both Docker Hub and GitHub Container Registry:
|
||||
|
||||
| Tag | Description |
|
||||
|-----|-------------|
|
||||
| `latest` | Latest stable release |
|
||||
| `v1.2.3` | Specific version |
|
||||
| `staging` | Staging environment |
|
||||
| `nightly`, `edge` | Daily builds from master |
|
||||
| `YYYY-MM-DD` | Dated nightly build |
|
||||
|
||||
```bash
|
||||
# Pull from Docker Hub
|
||||
docker pull username/cv-app:latest
|
||||
|
||||
# Pull from GHCR
|
||||
docker pull ghcr.io/owner/cv-app:latest
|
||||
```
|
||||
|
||||
### Environments
|
||||
|
||||
| Environment | Branch | Trigger |
|
||||
|-------------|--------|---------|
|
||||
| Production | `master` | semantic-release |
|
||||
| Staging | `staging` | Push to staging |
|
||||
| Nightly | `master` | Daily at 02:00 UTC |
|
||||
|
||||
For detailed documentation, see [Release Engineering Documentation](docs/release-engineering.md).
|
||||
### Keycloak Mode
|
||||
- SSO integration via Keycloak
|
||||
- Configure via `AUTH_MODE=keycloak` and related environment variables
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [Release Engineering](docs/release-engineering.md) | Comprehensive release pipeline documentation |
|
||||
| [Contributing](CONTRIBUTING.md) | Contribution guidelines |
|
||||
| [Security](SECURITY.md) | Security policy |
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please read our [Contributing Guide](CONTRIBUTING.md) for details.
|
||||
| [Architecture](docs/architecture.md) | System architecture and design patterns |
|
||||
| [Release Engineering](docs/release-engineering.md) | Release and deployment documentation |
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
MIT License - see [LICENSE](LICENSE) for details.
|
||||
|
||||
127
SECURITY.md
Normal file
127
SECURITY.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 1.x | :white_check_mark: |
|
||||
| < 1.0 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We take security seriously. If you discover a security vulnerability, please follow responsible disclosure.
|
||||
|
||||
### How to Report
|
||||
|
||||
**Preferred Method**: Open a security advisory
|
||||
|
||||
1. Go to the repository
|
||||
2. Click "Security" tab
|
||||
3. Click "Report a vulnerability"
|
||||
4. Fill out the advisory form
|
||||
|
||||
**Alternative**: Email the maintainer directly (if available in profile)
|
||||
|
||||
### What to Include
|
||||
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Affected versions
|
||||
- Potential impact
|
||||
- Suggested fix (if available)
|
||||
|
||||
### Response Timeline
|
||||
|
||||
| Action | Timeline |
|
||||
|--------|----------|
|
||||
| Initial response | Within 48 hours |
|
||||
| Vulnerability assessment | Within 7 days |
|
||||
| Fix timeline based on severity | See below |
|
||||
|
||||
### Fix Timeline by Severity
|
||||
|
||||
| Severity | Timeline |
|
||||
|----------|----------|
|
||||
| Critical | 24-72 hours |
|
||||
| High | 1 week |
|
||||
| Medium | Next release |
|
||||
| Low | Next release |
|
||||
|
||||
## Security Measures
|
||||
|
||||
This project implements several security measures:
|
||||
|
||||
### Code Quality
|
||||
|
||||
- ESLint for code analysis
|
||||
- Automated testing before merge
|
||||
- Code review required for all changes
|
||||
|
||||
### Secret Management
|
||||
|
||||
- Gitleaks pre-commit hook prevents committing secrets
|
||||
- Environment variables for sensitive configuration
|
||||
- No secrets in version control
|
||||
|
||||
### Authentication
|
||||
|
||||
- JWT tokens with configurable expiration
|
||||
- Password hashed in database
|
||||
- Token-based API authentication
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Regular dependency audits via `npm audit`
|
||||
- Dependabot for automated updates (if enabled)
|
||||
|
||||
## Best Practices for Contributors
|
||||
|
||||
### Do Not Commit
|
||||
|
||||
- API keys or tokens
|
||||
- Database credentials
|
||||
- JWT secrets
|
||||
- Password files
|
||||
- Private keys or certificates
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Store sensitive configuration in environment variables:
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
PORT=3001
|
||||
DB_PATH=./data/cv.db
|
||||
AUTH_MODE=simple
|
||||
JWT_SECRET=your-secret-here
|
||||
|
||||
# Keycloak (if used)
|
||||
KEYCLOAK_URL=https://keycloak.example.com
|
||||
KEYCLOAK_REALM=your-realm
|
||||
KEYCLOAK_CLIENT_ID=cv-app
|
||||
```
|
||||
|
||||
### Running Security Audit
|
||||
|
||||
```bash
|
||||
# Check for vulnerable dependencies
|
||||
npm audit
|
||||
|
||||
# Fix vulnerabilities
|
||||
npm audit fix
|
||||
|
||||
# Check backend dependencies
|
||||
cd backend && npm audit
|
||||
```
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
- Please allow reasonable time for the fix before public disclosure
|
||||
- Coordinated disclosure is appreciated
|
||||
- Credit will be given in the fix commit (if desired)
|
||||
|
||||
## Contact
|
||||
|
||||
For security concerns, use the vulnerability reporting process above.
|
||||
|
||||
Thank you for helping keep this project secure!
|
||||
3
backend/.dockerignore
Normal file
3
backend/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
*.log
|
||||
.git
|
||||
18
backend/Dockerfile
Normal file
18
backend/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:25-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"]
|
||||
166
backend/__tests__/api.test.js
Normal file
166
backend/__tests__/api.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
41
backend/middleware/auth.js
Normal file
41
backend/middleware/auth.js
Normal 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();
|
||||
}
|
||||
@@ -9,9 +9,9 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"better-sqlite3": "^12.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"express": "^5.0.0",
|
||||
"knex": "^3.1.0",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
|
||||
8
backend/vitest.config.js
Normal file
8
backend/vitest.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
},
|
||||
})
|
||||
26
docker-compose.yml
Normal file
26
docker-compose.yml
Normal 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:
|
||||
448
docs/architecture.md
Normal file
448
docs/architecture.md
Normal file
@@ -0,0 +1,448 @@
|
||||
<!-- markdownlint-disable MD013 -->
|
||||
|
||||
# Architecture Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
kilo-cv is a full-stack web application for managing and displaying a professional CV/resume. It features a public-facing CV display page and an authenticated admin panel for content management.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Public CV Display**: Responsive, animated CV page with sections for experience, skills, education, and projects
|
||||
- **Admin Panel**: Authenticated interface for editing CV content
|
||||
- **REST API**: Backend API for data management
|
||||
- **Export/Import**: JSON export and import functionality
|
||||
- **API Documentation**: Interactive Swagger UI
|
||||
|
||||
## Technology Stack
|
||||
|
||||
### Frontend
|
||||
|
||||
| Technology | Version | Purpose |
|
||||
|------------|---------|---------|
|
||||
| React | 19.x | UI library |
|
||||
| Vite | 7.x | Build tool and dev server |
|
||||
| Tailwind CSS | 4.x | Styling |
|
||||
| Framer Motion | 12.x | Animations |
|
||||
| Lucide React | 0.574.x | Icon library |
|
||||
| React Router DOM | 7.x | Client-side routing |
|
||||
|
||||
### Backend
|
||||
|
||||
| Technology | Version | Purpose |
|
||||
|------------|---------|---------|
|
||||
| Express.js | 4.x | REST API server |
|
||||
| Knex.js | 3.x | SQL query builder |
|
||||
| better-sqlite3 | 11.x | SQLite database driver |
|
||||
| swagger-jsdoc | 6.x | OpenAPI documentation generation |
|
||||
| swagger-ui-express | 5.x | Swagger UI serving |
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
kilo-cv/
|
||||
├── src/ # Frontend source
|
||||
│ ├── components/ # Public CV display components
|
||||
│ │ ├── Hero.jsx # Name, title, introduction
|
||||
│ │ ├── Experience.jsx # Work history section
|
||||
│ │ ├── Skills.jsx # Skills by category
|
||||
│ │ ├── Education.jsx # Education history
|
||||
│ │ ├── Projects.jsx # Project showcase
|
||||
│ │ └── Contact.jsx # Contact information
|
||||
│ ├── admin/ # Admin panel
|
||||
│ │ ├── AdminLayout.jsx # Admin layout with navigation
|
||||
│ │ ├── AdminPersonal.jsx # Personal info editor
|
||||
│ │ ├── AdminExperience.jsx # Experience editor
|
||||
│ │ ├── AdminSkills.jsx # Skills editor
|
||||
│ │ ├── AdminEducation.jsx # Education editor
|
||||
│ │ ├── AdminProjects.jsx # Projects editor
|
||||
│ │ ├── pages/
|
||||
│ │ │ └── LoginPage.jsx # Authentication page
|
||||
│ │ ├── sections/ # Reusable form components
|
||||
│ │ │ ├── PersonalForm.jsx
|
||||
│ │ │ ├── ExperienceForm.jsx
|
||||
│ │ │ ├── SkillsForm.jsx
|
||||
│ │ │ ├── EducationForm.jsx
|
||||
│ │ │ └── ProjectsForm.jsx
|
||||
│ │ ├── hooks/ # React contexts and custom hooks
|
||||
│ │ │ ├── CVContext.jsx # CV data state management
|
||||
│ │ │ ├── AuthContext.jsx # Authentication state
|
||||
│ │ │ ├── useCVData.js # CV data access hook
|
||||
│ │ │ └── useFormValidation.js
|
||||
│ │ └── components/
|
||||
│ │ └── ExportButton.jsx
|
||||
│ ├── lib/ # Utilities and API client
|
||||
│ │ ├── api.js # API client functions
|
||||
│ │ ├── auth.js # Token management
|
||||
│ │ ├── cv-data.js # CV validation utilities
|
||||
│ │ └── cv.json # Default CV data structure
|
||||
│ ├── data/
|
||||
│ │ └── cv.js
|
||||
│ ├── App.jsx # Root component with routing
|
||||
│ ├── main.jsx # React entry point
|
||||
│ └── index.css # Global styles (Tailwind)
|
||||
├── backend/ # Backend source
|
||||
│ ├── routes/ # API route handlers
|
||||
│ │ ├── cv.js # CV CRUD endpoints
|
||||
│ │ ├── auth.js # Authentication endpoints
|
||||
│ │ └── docs.js # Swagger documentation route
|
||||
│ ├── db/
|
||||
│ │ └── init.js # Database initialization
|
||||
│ ├── migrations/
|
||||
│ │ └── 20260220000001_initial_schema.js
|
||||
│ ├── middleware/
|
||||
│ │ └── auth.js # JWT authentication middleware
|
||||
│ ├── seeds/
|
||||
│ │ └── initial_cv_data.js # Initial data seeding
|
||||
│ ├── __tests__/
|
||||
│ │ └── api.test.js # API tests
|
||||
│ ├── server.js # Express server entry point
|
||||
│ └── knexfile.js # Knex configuration
|
||||
├── tests/ # Test suites
|
||||
│ ├── e2e/ # Playwright E2E tests
|
||||
│ ├── integration/ # Integration tests
|
||||
│ ├── regression/ # Snapshot/regression tests
|
||||
│ └── performance/ # k6 and Lighthouse tests
|
||||
├── docs/ # Documentation
|
||||
├── public/ # Static assets
|
||||
└── .github/ # CI/CD workflows
|
||||
```
|
||||
|
||||
## Frontend Architecture
|
||||
|
||||
### Routing Structure
|
||||
|
||||
```
|
||||
/ → Public CV display
|
||||
/admin → Admin panel (requires auth)
|
||||
/admin/login → Login page
|
||||
/admin/personal → Personal info editor
|
||||
/admin/experience → Experience editor
|
||||
/admin/skills → Skills editor
|
||||
/admin/education → Education editor
|
||||
/admin/projects → Projects editor
|
||||
```
|
||||
|
||||
### Component Hierarchy
|
||||
|
||||
```
|
||||
App
|
||||
├── CVProvider (context)
|
||||
│ └── AuthProvider (context)
|
||||
│ ├── Routes
|
||||
│ │ ├── "/" → CV Display Page
|
||||
│ │ │ └── Hero, Experience, Skills, Education, Projects, Contact
|
||||
│ │ └── "/admin/*" → AdminLayout
|
||||
│ │ ├── LoginPage (unauthenticated)
|
||||
│ │ └── Admin* Pages (authenticated)
|
||||
│ │ └── *Form components
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
The application uses React Context for state management:
|
||||
|
||||
**CVContext** (`src/admin/hooks/CVContext.jsx`)
|
||||
- Manages CV data state
|
||||
- Provides `useCVData()` hook for components
|
||||
- Handles API communication for CRUD operations
|
||||
|
||||
**AuthContext** (`src/admin/hooks/AuthContext.jsx`)
|
||||
- Manages authentication state
|
||||
- Handles token storage and validation
|
||||
- Provides `useAuth()` hook for protected routes
|
||||
|
||||
### Custom Hooks
|
||||
|
||||
| Hook | Purpose |
|
||||
|------|---------|
|
||||
| `useCVData()` | Access CV data and CRUD operations |
|
||||
| `useAuth()` | Access authentication state and methods |
|
||||
| `useFormValidation()` | Form validation with error handling |
|
||||
|
||||
### Styling
|
||||
|
||||
- **Tailwind CSS 4** with `@tailwindcss/vite` plugin
|
||||
- Utility-first approach throughout
|
||||
- Responsive design with mobile-first breakpoints
|
||||
- Animations via Framer Motion
|
||||
|
||||
## Backend Architecture
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Endpoint | Method | Description | Auth |
|
||||
|----------|--------|-------------|------|
|
||||
| `/api/auth/config` | GET | Get auth configuration | No |
|
||||
| `/api/auth/login` | POST | Authenticate with password | No |
|
||||
| `/api/cv` | GET | Get CV data | No |
|
||||
| `/api/cv` | PUT | Update CV data | Yes |
|
||||
| `/api/cv/export` | GET | Export CV as JSON | No |
|
||||
| `/api/cv/import` | POST | Import CV from JSON | Yes |
|
||||
| `/api/docs` | GET | Swagger UI documentation | No |
|
||||
| `/health` | GET | Health check endpoint | No |
|
||||
|
||||
### Request/Response Examples
|
||||
|
||||
**Get CV Data**
|
||||
```http
|
||||
GET /api/cv
|
||||
Response: {
|
||||
"personal": { "name": "...", "title": "...", ... },
|
||||
"experience": [...],
|
||||
"skills": {...},
|
||||
"education": [...],
|
||||
"projects": [...]
|
||||
}
|
||||
```
|
||||
|
||||
**Update CV Data**
|
||||
```http
|
||||
PUT /api/cv
|
||||
Authorization: Bearer <token>
|
||||
Body: { "personal": {...}, "experience": [...], ... }
|
||||
Response: { "success": true }
|
||||
```
|
||||
|
||||
**Login**
|
||||
```http
|
||||
POST /api/auth/login
|
||||
Body: { "password": "..." }
|
||||
Response: { "token": "..." }
|
||||
```
|
||||
|
||||
### Database Schema
|
||||
|
||||
Single-table design for simplicity:
|
||||
|
||||
```sql
|
||||
CREATE TABLE cv_data (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
data TEXT NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
The CV data is stored as a JSON document in the `data` column:
|
||||
|
||||
```json
|
||||
{
|
||||
"personal": {
|
||||
"name": "string",
|
||||
"title": "string",
|
||||
"intro": "string",
|
||||
"email": "string",
|
||||
"github": "string",
|
||||
"linkedin": "string",
|
||||
"location": "string"
|
||||
},
|
||||
"experience": [
|
||||
{
|
||||
"company": "string",
|
||||
"position": "string",
|
||||
"startDate": "YYYY-MM",
|
||||
"endDate": "YYYY-MM or null",
|
||||
"description": "string",
|
||||
"highlights": ["string", ...]
|
||||
}
|
||||
],
|
||||
"skills": {
|
||||
"Category Name": ["skill1", "skill2", ...]
|
||||
},
|
||||
"education": [
|
||||
{
|
||||
"institution": "string",
|
||||
"degree": "string",
|
||||
"field": "string",
|
||||
"startDate": "YYYY",
|
||||
"endDate": "YYYY"
|
||||
}
|
||||
],
|
||||
"projects": [
|
||||
{
|
||||
"name": "string",
|
||||
"description": "string",
|
||||
"technologies": ["tech1", ...],
|
||||
"url": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
Two authentication modes are supported:
|
||||
|
||||
**Simple Mode** (default)
|
||||
- Random password generated on server startup
|
||||
- Password logged to console
|
||||
- JWT token-based authentication
|
||||
- Suitable for personal/single-user deployments
|
||||
|
||||
**Keycloak Mode** (optional)
|
||||
- SSO integration via Keycloak
|
||||
- Configure via environment variables
|
||||
- Suitable for enterprise deployments
|
||||
|
||||
Environment variables for Keycloak:
|
||||
```bash
|
||||
AUTH_MODE=keycloak
|
||||
KEYCLOAK_URL=https://keycloak.example.com
|
||||
KEYCLOAK_REALM=your-realm
|
||||
KEYCLOAK_CLIENT_ID=cv-app
|
||||
```
|
||||
|
||||
### Middleware
|
||||
|
||||
**Auth Middleware** (`backend/middleware/auth.js`)
|
||||
- Validates JWT token from Authorization header
|
||||
- Attaches user info to request
|
||||
- Returns 401 for invalid/missing tokens
|
||||
|
||||
## Design Patterns
|
||||
|
||||
### Frontend Patterns
|
||||
|
||||
1. **Context Provider Pattern**
|
||||
- `CVProvider` and `AuthProvider` wrap the application
|
||||
- Provides global state without prop drilling
|
||||
|
||||
2. **Custom Hook Pattern**
|
||||
- `useCVData()`, `useAuth()` expose context values
|
||||
- Encapsulates complex logic
|
||||
|
||||
3. **Container/Presentational Pattern**
|
||||
- Admin pages fetch data from context (container)
|
||||
- Form components handle UI (presentational)
|
||||
|
||||
4. **Form Validation Pattern**
|
||||
- `useFormValidation` hook provides reusable validation
|
||||
- Error state management standardized
|
||||
|
||||
### Backend Patterns
|
||||
|
||||
1. **Route Handler Pattern**
|
||||
- Modular Express routers in `/routes`
|
||||
- Clean separation of concerns
|
||||
|
||||
2. **Middleware Pattern**
|
||||
- Auth middleware for protected routes
|
||||
- Composable request processing
|
||||
|
||||
3. **Repository Pattern**
|
||||
- `getDB()` returns Knex instance
|
||||
- Database access abstracted from route handlers
|
||||
|
||||
4. **Migration-based Schema**
|
||||
- Knex migrations for version control
|
||||
- Rollback capability
|
||||
|
||||
## Testing Architecture
|
||||
|
||||
| Layer | Tool | Location | Purpose |
|
||||
|-------|------|----------|---------|
|
||||
| Unit | Vitest | `src/lib/__tests__/`, `src/admin/hooks/__tests__/` | Test utilities and hooks |
|
||||
| API | Vitest + Supertest | `backend/__tests__/` | Test API endpoints |
|
||||
| Integration | Vitest | `tests/integration/` | Test full-stack interactions |
|
||||
| E2E | Playwright | `tests/e2e/` | Test user flows |
|
||||
| Regression | Vitest | `tests/regression/` | Snapshot tests |
|
||||
| Performance | k6, Lighthouse | `tests/performance/` | Load and performance testing |
|
||||
|
||||
### Test Commands
|
||||
|
||||
```bash
|
||||
npm run test:run # Run unit tests
|
||||
npm run test:coverage # Run tests with coverage
|
||||
npm run test:integration # Run integration tests
|
||||
npm run test:e2e # Run E2E tests (requires running server)
|
||||
npm run test:regression # Run regression tests
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Frontend Configuration
|
||||
|
||||
**Vite** (`vite.config.js`)
|
||||
- React plugin with SWC
|
||||
- Tailwind CSS plugin
|
||||
- Proxy configuration for API
|
||||
|
||||
**Tailwind** (`src/index.css`)
|
||||
- Tailwind imports
|
||||
- Custom CSS variables
|
||||
|
||||
### Backend Configuration
|
||||
|
||||
**Environment Variables**
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | 3001 | Server port |
|
||||
| `AUTH_MODE` | simple | Authentication mode (simple/keycloak) |
|
||||
| `KEYCLOAK_URL` | - | Keycloak server URL |
|
||||
| `KEYCLOAK_REALM` | - | Keycloak realm |
|
||||
| `KEYCLOAK_CLIENT_ID` | - | Keycloak client ID |
|
||||
|
||||
**Knex** (`backend/knexfile.js`)
|
||||
- SQLite database configuration
|
||||
- Migration and seed directories
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 20+
|
||||
- npm 10+
|
||||
|
||||
### Getting Started
|
||||
|
||||
```bash
|
||||
# Install frontend dependencies
|
||||
npm install
|
||||
|
||||
# Install backend dependencies
|
||||
cd backend && npm install
|
||||
|
||||
# Start backend server
|
||||
npm run dev:backend
|
||||
|
||||
# In another terminal, start frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
# Build frontend
|
||||
npm run build
|
||||
|
||||
# Build outputs to dist/
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# Build and run with Docker Compose
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
The Docker setup includes:
|
||||
- Frontend container with nginx
|
||||
- Backend container with Node.js
|
||||
- SQLite volume for persistence
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Authentication**: JWT tokens with configurable expiration
|
||||
2. **Secret Scanning**: Gitleaks pre-commit hook
|
||||
3. **Input Validation**: Form validation on frontend, schema validation on backend
|
||||
4. **CORS**: Configured for frontend origin
|
||||
5. **SQL Injection**: Knex query builder prevents injection
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- **Image Upload**: Profile picture support
|
||||
- **PDF Export**: Generate PDF from CV data
|
||||
- **Themes**: Customizable color schemes
|
||||
- **Multi-language**: Internationalization support
|
||||
- **Analytics**: View tracking for CV
|
||||
@@ -1,549 +0,0 @@
|
||||
# DevOps Features Design
|
||||
|
||||
**Date:** 2026-02-20
|
||||
**Status:** Approved
|
||||
**Author:** Claude (AI Assistant)
|
||||
|
||||
## Overview
|
||||
|
||||
Implement four DevOps features for the CV application:
|
||||
1. **Swagger/OpenAPI Documentation** - Interactive API docs at `/api/docs`
|
||||
2. **Database Migrations** - Knex.js-based migration system with auto-migration
|
||||
3. **Quality Gates** - Code coverage, bundle size, npm audit, Lighthouse CI
|
||||
4. **Secret Scanning** - Gitleaks pre-commit hook
|
||||
|
||||
## 1. Swagger/OpenAPI Documentation
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Route Files (JSDoc comments)
|
||||
│
|
||||
▼
|
||||
swagger-jsdoc
|
||||
(generates spec at runtime)
|
||||
│
|
||||
▼
|
||||
swagger-ui-express
|
||||
│
|
||||
▼
|
||||
GET /api/docs → Interactive Swagger UI
|
||||
GET /api/docs.json → Raw OpenAPI spec
|
||||
```
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/docs` | Swagger UI interface |
|
||||
| GET | `/api/docs.json` | Raw OpenAPI JSON spec |
|
||||
|
||||
### Implementation
|
||||
|
||||
**Dependencies:**
|
||||
- `swagger-jsdoc` - Generate OpenAPI spec from JSDoc
|
||||
- `swagger-ui-express` - Serve Swagger UI
|
||||
|
||||
**JSDoc format in route files:**
|
||||
```javascript
|
||||
/**
|
||||
* @openapi
|
||||
* /api/cv:
|
||||
* get:
|
||||
* summary: Get CV data
|
||||
* tags: [CV]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: CV data
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/CVData'
|
||||
* 404:
|
||||
* description: CV data not found
|
||||
*/
|
||||
router.get('/', (req, res) => { ... });
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/cv:
|
||||
* put:
|
||||
* summary: Update CV data
|
||||
* tags: [CV]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/CVData'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: CV data updated
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
router.put('/', authMiddleware, (req, res) => { ... });
|
||||
```
|
||||
|
||||
**Swagger setup:**
|
||||
```javascript
|
||||
// backend/routes/docs.js
|
||||
import swaggerJsdoc from 'swagger-jsdoc';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
|
||||
const options = {
|
||||
definition: {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'CV API',
|
||||
version: '1.0.0',
|
||||
description: 'API for CV/Resume management',
|
||||
},
|
||||
servers: [
|
||||
{ url: '/api', description: 'API server' }
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
CVData: { /* schema definition */ },
|
||||
},
|
||||
},
|
||||
},
|
||||
apis: ['./routes/*.js'],
|
||||
};
|
||||
|
||||
const specs = swaggerJsdoc(options);
|
||||
|
||||
router.use('/docs', swaggerUi.serve, swaggerUi.setup(specs));
|
||||
router.get('/docs.json', (req, res) => res.json(specs));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Database Migrations (Knex.js)
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
backend/
|
||||
├── knexfile.js # Knex configuration
|
||||
├── migrations/
|
||||
│ ├── 20260220000001_initial_schema.js
|
||||
│ └── 20260220000002_add_auth_tables.js
|
||||
├── seeds/
|
||||
│ └── initial_cv_data.js
|
||||
└── db/
|
||||
├── connection.js # Knex instance
|
||||
└── init.js # Auto-migration on startup
|
||||
```
|
||||
|
||||
### Knex Configuration
|
||||
|
||||
```javascript
|
||||
// knexfile.js
|
||||
export default {
|
||||
client: 'better-sqlite3',
|
||||
connection: {
|
||||
filename: process.env.DB_PATH || './data/cv.db'
|
||||
},
|
||||
migrations: {
|
||||
directory: './migrations',
|
||||
tableName: 'knex_migrations'
|
||||
},
|
||||
seeds: {
|
||||
directory: './seeds'
|
||||
},
|
||||
useNullAsDefault: true
|
||||
};
|
||||
```
|
||||
|
||||
### Migration File Format
|
||||
|
||||
```javascript
|
||||
// migrations/20260220000001_initial_schema.js
|
||||
export async function up(knex) {
|
||||
await knex.schema.createTable('cv_data', (table) => {
|
||||
table.integer('id').primary().checkTo('=', 1);
|
||||
table.text('data').notNullable();
|
||||
table.datetime('updated_at').defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex) {
|
||||
await knex.schema.dropTableIfExists('cv_data');
|
||||
}
|
||||
```
|
||||
|
||||
### Auto-Migration on Startup
|
||||
|
||||
```javascript
|
||||
// db/init.js
|
||||
import knex from 'knex';
|
||||
import config from '../knexfile.js';
|
||||
|
||||
let db = null;
|
||||
|
||||
export async function getDB() {
|
||||
if (!db) {
|
||||
db = knex(config);
|
||||
await db.migrate.latest();
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function closeDB() {
|
||||
if (db) {
|
||||
await db.destroy();
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Commands
|
||||
|
||||
```bash
|
||||
# Create new migration
|
||||
npx knex migrate:make migration_name --knexfile knexfile.js
|
||||
|
||||
# Run migrations manually
|
||||
npx knex migrate:latest --knexfile knexfile.js
|
||||
|
||||
# Rollback last migration
|
||||
npx knex migrate:rollback --knexfile knexfile.js
|
||||
|
||||
# Run seeds
|
||||
npx knex seed:run --knexfile knexfile.js
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `knex` - SQL query builder and migrations
|
||||
|
||||
---
|
||||
|
||||
## 3. Quality Gates
|
||||
|
||||
### Overview
|
||||
|
||||
| Gate | Tool | Threshold | Action |
|
||||
|------|------|-----------|--------|
|
||||
| Code Coverage | vitest + Codecov | 80% minimum | Fail CI |
|
||||
| Bundle Size | bundlesize | 500KB max | Fail CI |
|
||||
| npm audit | npm audit | Moderate+ | Fail CI |
|
||||
| Lighthouse | @lhci/cli | Performance 90+ | Fail CI |
|
||||
|
||||
### CI Workflow Integration
|
||||
|
||||
```yaml
|
||||
# .github/workflows/ci.yml additions
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
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'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# Code Coverage
|
||||
- name: Run tests with coverage
|
||||
run: npm run test:coverage
|
||||
|
||||
- name: Upload to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: true
|
||||
|
||||
# Bundle Size
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Check bundle size
|
||||
run: npx bundlesize
|
||||
|
||||
# npm audit
|
||||
- name: Security audit
|
||||
run: npm audit --audit-level=moderate
|
||||
|
||||
lighthouse:
|
||||
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'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Run Lighthouse CI
|
||||
run: npm run test:lighthouse
|
||||
```
|
||||
|
||||
### Configuration Files
|
||||
|
||||
**package.json:**
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"bundlesize": [
|
||||
{
|
||||
"path": "./dist/assets/*.js",
|
||||
"maxSize": "500kb"
|
||||
},
|
||||
{
|
||||
"path": "./dist/assets/*.css",
|
||||
"maxSize": "100kb"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**vitest.config.js:**
|
||||
```javascript
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'lcov', 'html'],
|
||||
exclude: ['node_modules/', 'tests/'],
|
||||
lines: 80,
|
||||
functions: 80,
|
||||
branches: 80,
|
||||
statements: 80
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**.lighthouserc.json:**
|
||||
```json
|
||||
{
|
||||
"ci": {
|
||||
"collect": {
|
||||
"url": ["http://localhost:4173/"],
|
||||
"numberOfRuns": 3
|
||||
},
|
||||
"assert": {
|
||||
"assertions": {
|
||||
"categories:performance": ["error", { "minScore": 0.9 }],
|
||||
"categories:accessibility": ["warn", { "minScore": 0.9 }],
|
||||
"categories:best-practices": ["warn", { "minScore": 0.9 }]
|
||||
}
|
||||
},
|
||||
"upload": {
|
||||
"target": "temporary-public-storage"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `@vitest/coverage-v8` - Coverage reporter
|
||||
- `bundlesize` - Bundle size checking
|
||||
|
||||
### Secrets Required
|
||||
|
||||
| Secret | Description |
|
||||
|--------|-------------|
|
||||
| `CODECOV_TOKEN` | Codecov upload token |
|
||||
|
||||
---
|
||||
|
||||
## 4. Secret Scanning (Gitleaks)
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Developer stages files
|
||||
│
|
||||
▼
|
||||
git commit
|
||||
│
|
||||
▼
|
||||
.husky/pre-commit
|
||||
│
|
||||
├─► npm run lint
|
||||
│
|
||||
└─► gitleaks protect --staged
|
||||
│
|
||||
▼
|
||||
Secrets found?
|
||||
│ │
|
||||
Yes No
|
||||
│ │
|
||||
▼ ▼
|
||||
Block commit Allow commit
|
||||
```
|
||||
|
||||
### Pre-commit Hook
|
||||
|
||||
```bash
|
||||
# .husky/pre-commit
|
||||
npm run lint
|
||||
|
||||
# Gitleaks secret scanning
|
||||
if command -v gitleaks &> /dev/null; 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
|
||||
```
|
||||
|
||||
### Gitleaks Configuration
|
||||
|
||||
```toml
|
||||
# .gitleaks.toml
|
||||
title = "Gitleaks Configuration"
|
||||
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
[[allowlists]]
|
||||
description = "Allowlisted files"
|
||||
paths = [
|
||||
'package-lock.json',
|
||||
'backend/package-lock.json',
|
||||
'.*\\.md$'
|
||||
]
|
||||
|
||||
[[allowlists]]
|
||||
description = "Allowlisted patterns"
|
||||
regexes = [
|
||||
'VITE_API_URL.*localhost',
|
||||
]
|
||||
```
|
||||
|
||||
### Developer Setup
|
||||
|
||||
Gitleaks must be installed on developer machines:
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install gitleaks
|
||||
|
||||
# Linux
|
||||
# Download from https://github.com/gitleaks/gitleaks/releases
|
||||
# Or use go install
|
||||
go install github.com/gitleaks/gitleaks/v8@latest
|
||||
|
||||
# Windows
|
||||
scoop install gitleaks
|
||||
# Or
|
||||
choco install gitleaks
|
||||
```
|
||||
|
||||
### Documentation
|
||||
|
||||
Add to README or CONTRIBUTING.md:
|
||||
|
||||
```markdown
|
||||
### 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/download/v8.18.1/gitleaks_8.18.1_linux_x64.tar.gz | tar -xz
|
||||
sudo mv gitleaks /usr/local/bin/
|
||||
```
|
||||
|
||||
The pre-commit hook will automatically scan for secrets before each commit.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Summary
|
||||
|
||||
### New Dependencies
|
||||
|
||||
**Backend:**
|
||||
- `knex` - Database migrations and query builder
|
||||
- `swagger-jsdoc` - OpenAPI spec generation
|
||||
- `swagger-ui-express` - Swagger UI middleware
|
||||
|
||||
**Frontend:**
|
||||
- `@vitest/coverage-v8` - Coverage reporting
|
||||
- `bundlesize` - Bundle size checking
|
||||
|
||||
**Dev:**
|
||||
- `gitleaks` (binary, not npm package)
|
||||
|
||||
### New Files
|
||||
|
||||
```
|
||||
backend/
|
||||
├── knexfile.js
|
||||
├── migrations/
|
||||
│ └── 20260220000001_initial_schema.js
|
||||
├── seeds/
|
||||
│ └── initial_cv_data.js
|
||||
├── routes/
|
||||
│ └── docs.js
|
||||
└── db/
|
||||
└── connection.js
|
||||
|
||||
.gitleaks.toml
|
||||
.lighthouserc.json
|
||||
```
|
||||
|
||||
### Modified Files
|
||||
|
||||
```
|
||||
package.json # Add scripts, bundlesize config
|
||||
vitest.config.js # Add coverage config
|
||||
.github/workflows/ci.yml # Add quality gates
|
||||
.husky/pre-commit # Add gitleaks
|
||||
backend/db/init.js # Use Knex instead of better-sqlite3 directly
|
||||
backend/routes/cv.js # Add JSDoc comments
|
||||
backend/routes/auth.js # Add JSDoc comments
|
||||
README.md # Add gitleaks setup instructions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Post-Implementation Checklist
|
||||
|
||||
- [ ] Install dependencies
|
||||
- [ ] Configure Knex and create initial migration
|
||||
- [ ] Add Swagger UI route
|
||||
- [ ] Add JSDoc comments to all API routes
|
||||
- [ ] Configure Codecov (add CODECOV_TOKEN secret)
|
||||
- [ ] Add bundlesize configuration
|
||||
- [ ] Update CI workflow with quality gates
|
||||
- [ ] Add gitleaks to pre-commit hook
|
||||
- [ ] Update documentation
|
||||
@@ -1,948 +0,0 @@
|
||||
# DevOps Features Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Implement Swagger docs, Knex migrations, quality gates, and secret scanning.
|
||||
|
||||
**Architecture:** Swagger UI served at /api/docs with JSDoc-annotated routes. Knex.js handles database migrations with auto-migration on startup. Quality gates integrated into CI workflow. Gitleaks pre-commit hook for secret scanning.
|
||||
|
||||
**Tech Stack:** swagger-jsdoc, swagger-ui-express, knex, vitest coverage, bundlesize, gitleaks
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Install Backend Dependencies
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/package.json`
|
||||
|
||||
**Step 1: Install Knex for migrations**
|
||||
|
||||
```bash
|
||||
cd backend && npm install knex swagger-jsdoc swagger-ui-express
|
||||
```
|
||||
|
||||
**Step 2: Verify installation**
|
||||
|
||||
Run: `cd backend && npm list knex swagger-jsdoc swagger-ui-express --depth=0`
|
||||
Expected: All packages listed with versions
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/package.json backend/package-lock.json
|
||||
git commit -m "build(deps): add knex, swagger-jsdoc, swagger-ui-express"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Install Frontend Quality Gate Dependencies
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json`
|
||||
|
||||
**Step 1: Install coverage and bundlesize packages**
|
||||
|
||||
```bash
|
||||
npm install --save-dev @vitest/coverage-v8 bundlesize
|
||||
```
|
||||
|
||||
**Step 2: Verify installation**
|
||||
|
||||
Run: `npm list @vitest/coverage-v8 bundlesize --depth=0`
|
||||
Expected: All packages listed with versions
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json package-lock.json
|
||||
git commit -m "build(deps): add coverage and bundlesize for quality gates"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Create Knex Configuration
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/knexfile.js`
|
||||
|
||||
**Step 1: Create knexfile.js**
|
||||
|
||||
Create `backend/knexfile.js`:
|
||||
|
||||
```javascript
|
||||
export default {
|
||||
client: 'better-sqlite3',
|
||||
connection: {
|
||||
filename: process.env.DB_PATH || './data/cv.db'
|
||||
},
|
||||
migrations: {
|
||||
directory: './migrations',
|
||||
tableName: 'knex_migrations'
|
||||
},
|
||||
seeds: {
|
||||
directory: './seeds'
|
||||
},
|
||||
useNullAsDefault: true
|
||||
};
|
||||
```
|
||||
|
||||
**Step 2: Verify syntax**
|
||||
|
||||
Run: `cd backend && node -e "import('./knexfile.js').then(m => console.log(m.default))"`
|
||||
Expected: Knex config object printed
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/knexfile.js
|
||||
git commit -m "feat(db): add knex configuration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Create Initial Migration
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/migrations/20260220000001_initial_schema.js`
|
||||
|
||||
**Step 1: Create migrations directory**
|
||||
|
||||
```bash
|
||||
mkdir -p backend/migrations
|
||||
```
|
||||
|
||||
**Step 2: Create initial migration**
|
||||
|
||||
Create `backend/migrations/20260220000001_initial_schema.js`:
|
||||
|
||||
```javascript
|
||||
export async function up(knex) {
|
||||
await knex.schema.createTable('cv_data', (table) => {
|
||||
table.integer('id').primary();
|
||||
table.text('data').notNullable();
|
||||
table.datetime('updated_at').defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex) {
|
||||
await knex.schema.dropTableIfExists('cv_data');
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/migrations/
|
||||
git commit -m "feat(db): add initial schema migration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Create Seed File
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/seeds/initial_cv_data.js`
|
||||
|
||||
**Step 1: Create seeds directory**
|
||||
|
||||
```bash
|
||||
mkdir -p backend/seeds
|
||||
```
|
||||
|
||||
**Step 2: Create seed file**
|
||||
|
||||
Create `backend/seeds/initial_cv_data.js`:
|
||||
|
||||
```javascript
|
||||
export async function seed(knex) {
|
||||
const existing = await knex('cv_data').where({ id: 1 }).first();
|
||||
|
||||
if (!existing) {
|
||||
await knex('cv_data').insert({
|
||||
id: 1,
|
||||
data: JSON.stringify({
|
||||
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: [],
|
||||
skills: {},
|
||||
education: [],
|
||||
projects: []
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/seeds/
|
||||
git commit -m "feat(db): add initial cv data seed"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Update Database Init to Use Knex
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/db/init.js`
|
||||
|
||||
**Step 1: Replace init.js with Knex-based version**
|
||||
|
||||
Replace `backend/db/init.js` with:
|
||||
|
||||
```javascript
|
||||
import knex from 'knex';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import config from '../knexfile.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
let db = null;
|
||||
|
||||
export async function getDB() {
|
||||
if (!db) {
|
||||
db = knex(config);
|
||||
await db.migrate.latest();
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function initDB() {
|
||||
const db = await getDB();
|
||||
|
||||
const existing = await db('cv_data').where({ id: 1 }).first();
|
||||
if (!existing) {
|
||||
await db('cv_data').insert({
|
||||
id: 1,
|
||||
data: JSON.stringify({
|
||||
personal: {
|
||||
name: "Tuan-Dat Tran",
|
||||
title: "Junior DevOps Engineer",
|
||||
intro: "Passionierter DevOps Engineer mit Fokus auf Cloud-Infrastruktur.",
|
||||
email: "tuan-dat.tran@example.com",
|
||||
github: "https://github.com/tuan-dat-tran",
|
||||
linkedin: "https://linkedin.com/in/tuan-dat-tran",
|
||||
location: "Deutschland"
|
||||
},
|
||||
experience: [],
|
||||
skills: {},
|
||||
education: [],
|
||||
projects: []
|
||||
})
|
||||
});
|
||||
console.log('Initialized database with default CV data');
|
||||
}
|
||||
|
||||
console.log(`Database initialized`);
|
||||
}
|
||||
|
||||
export async function closeDB() {
|
||||
if (db) {
|
||||
await db.destroy();
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Verify syntax**
|
||||
|
||||
Run: `cd backend && node -c db/init.js`
|
||||
Expected: No syntax errors
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/db/init.js
|
||||
git commit -m "refactor(db): use knex for database operations"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Update CV Routes for Knex
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/routes/cv.js`
|
||||
|
||||
**Step 1: Update routes to use Knex**
|
||||
|
||||
Replace `backend/routes/cv.js` with:
|
||||
|
||||
```javascript
|
||||
import { Router } from 'express';
|
||||
import { getDB } from '../db/init.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /cv:
|
||||
* get:
|
||||
* summary: Get CV data
|
||||
* tags: [CV]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: CV data
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* 404:
|
||||
* description: CV data not found
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const db = await getDB();
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /cv:
|
||||
* put:
|
||||
* summary: Update CV data
|
||||
* tags: [CV]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: CV data updated
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
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' });
|
||||
}
|
||||
|
||||
const db = await getDB();
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /cv/export:
|
||||
* get:
|
||||
* summary: Export CV data as JSON file
|
||||
* tags: [CV]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: CV JSON file
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
*/
|
||||
router.get('/export', async (req, res) => {
|
||||
try {
|
||||
const db = await getDB();
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /cv/import:
|
||||
* post:
|
||||
* summary: Import CV data from JSON
|
||||
* tags: [CV]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: CV data imported
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
router.post('/import', 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' });
|
||||
}
|
||||
|
||||
const db = await getDB();
|
||||
await db('cv_data').where({ id: 1 }).update({
|
||||
data: JSON.stringify(data),
|
||||
updated_at: db.fn.now()
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'CV data imported' });
|
||||
} catch (error) {
|
||||
console.error('Error importing CV:', error);
|
||||
res.status(500).json({ error: 'Failed to import CV data' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/routes/cv.js
|
||||
git commit -m "refactor(api): update cv routes for knex with openapi docs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Create Swagger Docs Route
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/routes/docs.js`
|
||||
|
||||
**Step 1: Create docs route**
|
||||
|
||||
Create `backend/routes/docs.js`:
|
||||
|
||||
```javascript
|
||||
import { Router } from 'express';
|
||||
import swaggerJsdoc from 'swagger-jsdoc';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const options = {
|
||||
definition: {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'CV API',
|
||||
version: '1.0.0',
|
||||
description: 'API for CV/Resume management',
|
||||
},
|
||||
servers: [
|
||||
{ url: '/api', description: 'API server' }
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
apis: ['./routes/*.js'],
|
||||
};
|
||||
|
||||
const specs = swaggerJsdoc(options);
|
||||
|
||||
router.use('/docs', swaggerUi.serve, swaggerUi.setup(specs, {
|
||||
customCss: '.swagger-ui .topbar { display: none }',
|
||||
customSiteTitle: 'CV API Documentation'
|
||||
}));
|
||||
|
||||
router.get('/docs.json', (req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.send(specs);
|
||||
});
|
||||
|
||||
export default router;
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/routes/docs.js
|
||||
git commit -m "feat(api): add swagger docs route"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Add OpenAPI Docs to Auth Routes
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/routes/auth.js`
|
||||
|
||||
**Step 1: Add JSDoc comments to auth routes**
|
||||
|
||||
Read `backend/routes/auth.js` and add OpenAPI JSDoc comments to each endpoint:
|
||||
|
||||
- Add `@openapi` comments for `/auth/config`, `/auth/login`
|
||||
- Document request bodies and responses
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/routes/auth.js
|
||||
git commit -m "docs(api): add openapi docs to auth routes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Register Swagger Route in Server
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/server.js`
|
||||
|
||||
**Step 1: Import and use docs route**
|
||||
|
||||
Add to `backend/server.js`:
|
||||
|
||||
```javascript
|
||||
import docsRoutes from './routes/docs.js';
|
||||
|
||||
// After other route imports
|
||||
app.use('/api', docsRoutes);
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/server.js
|
||||
git commit -m "feat(api): register swagger docs route"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Configure Vitest Coverage
|
||||
|
||||
**Files:**
|
||||
- Modify: `vitest.config.js`
|
||||
|
||||
**Step 1: Add coverage configuration**
|
||||
|
||||
Update `vitest.config.js` to include coverage settings:
|
||||
|
||||
```javascript
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'lcov', 'html'],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'tests/',
|
||||
'**/*.test.js',
|
||||
'**/*.config.js'
|
||||
],
|
||||
lines: 80,
|
||||
functions: 80,
|
||||
branches: 80,
|
||||
statements: 80
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Add coverage script to package.json**
|
||||
|
||||
Add script: `"test:coverage": "vitest run --coverage"`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add vitest.config.js package.json
|
||||
git commit -m "feat(ci): add coverage configuration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 12: Configure Bundle Size Checking
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json`
|
||||
|
||||
**Step 1: Add bundlesize configuration**
|
||||
|
||||
Add to `package.json`:
|
||||
|
||||
```json
|
||||
"bundlesize": [
|
||||
{
|
||||
"path": "./dist/assets/*.js",
|
||||
"maxSize": "500kb"
|
||||
},
|
||||
{
|
||||
"path": "./dist/assets/*.css",
|
||||
"maxSize": "100kb"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Step 2: Add bundle check script**
|
||||
|
||||
Add script: `"bundle:check": "npm run build && bundlesize"`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json
|
||||
git commit -m "feat(ci): add bundlesize configuration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 13: Create Lighthouse CI Configuration
|
||||
|
||||
**Files:**
|
||||
- Create: `.lighthouserc.json`
|
||||
|
||||
**Step 1: Create lighthouse config**
|
||||
|
||||
Create `.lighthouserc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"ci": {
|
||||
"collect": {
|
||||
"url": ["http://localhost:4173/cv/"],
|
||||
"numberOfRuns": 3
|
||||
},
|
||||
"assert": {
|
||||
"assertions": {
|
||||
"categories:performance": ["error", { "minScore": 0.8 }],
|
||||
"categories:accessibility": ["warn", { "minScore": 0.9 }],
|
||||
"categories:best-practices": ["warn", { "minScore": 0.9 }]
|
||||
}
|
||||
},
|
||||
"upload": {
|
||||
"target": "temporary-public-storage"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add .lighthouserc.json
|
||||
git commit -m "feat(ci): add lighthouse ci configuration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 14: Add Quality Gates to CI Workflow
|
||||
|
||||
**Files:**
|
||||
- Modify: `.github/workflows/ci.yml`
|
||||
|
||||
**Step 1: Add quality job**
|
||||
|
||||
Add a new `quality` job to `.github/workflows/ci.yml`:
|
||||
|
||||
```yaml
|
||||
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'
|
||||
|
||||
- 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
|
||||
```
|
||||
|
||||
**Step 2: Update lighthouse job**
|
||||
|
||||
Update the existing `lighthouse` job to use the new config and enforce thresholds.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .github/workflows/ci.yml
|
||||
git commit -m "feat(ci): add quality gates to ci workflow"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 15: Create Gitleaks Configuration
|
||||
|
||||
**Files:**
|
||||
- Create: `.gitleaks.toml`
|
||||
|
||||
**Step 1: Create gitleaks config**
|
||||
|
||||
Create `.gitleaks.toml`:
|
||||
|
||||
```toml
|
||||
title = "Gitleaks Configuration"
|
||||
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
[[allowlists]]
|
||||
description = "Allowlisted files"
|
||||
paths = [
|
||||
'package-lock.json',
|
||||
'backend/package-lock.json'
|
||||
]
|
||||
|
||||
[[allowlists]]
|
||||
description = "Test secrets"
|
||||
regexes = [
|
||||
'test.*password',
|
||||
'test.*secret',
|
||||
'test.*token'
|
||||
]
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add .gitleaks.toml
|
||||
git commit -m "feat(security): add gitleaks configuration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 16: Add Gitleaks to Pre-commit Hook
|
||||
|
||||
**Files:**
|
||||
- Modify: `.husky/pre-commit`
|
||||
|
||||
**Step 1: Update pre-commit hook**
|
||||
|
||||
Update `.husky/pre-commit` to:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
|
||||
# Gitleaks secret scanning
|
||||
if command -v gitleaks &> /dev/null; 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
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add .husky/pre-commit
|
||||
git commit -m "feat(security): add gitleaks to pre-commit hook"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 17: Update Backend Tests for Knex
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/__tests__/api.test.js`
|
||||
|
||||
**Step 1: Update tests to use async/await with Knex**
|
||||
|
||||
Update the test file to work with the new async Knex-based API.
|
||||
|
||||
**Step 2: Run tests**
|
||||
|
||||
Run: `cd backend && npm test`
|
||||
Expected: All tests pass
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/__tests__/
|
||||
git commit -m "test(api): update tests for knex"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 18: Update Documentation
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
|
||||
**Step 1: Add gitleaks setup instructions**
|
||||
|
||||
Add to README.md under a new "Development Setup" section:
|
||||
|
||||
```markdown
|
||||
### 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.
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add README.md
|
||||
git commit -m "docs(readme): add gitleaks setup and api docs info"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 19: Verify All Configurations
|
||||
|
||||
**Step 1: Run lint**
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Expected: No errors
|
||||
|
||||
**Step 2: Run tests**
|
||||
|
||||
```bash
|
||||
npm run test:run
|
||||
```
|
||||
|
||||
Expected: All tests pass
|
||||
|
||||
**Step 3: Run backend tests**
|
||||
|
||||
```bash
|
||||
cd backend && npm test
|
||||
```
|
||||
|
||||
Expected: All tests pass
|
||||
|
||||
**Step 4: Test coverage**
|
||||
|
||||
```bash
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
Expected: Coverage report generated
|
||||
|
||||
**Step 5: Build and check bundle**
|
||||
|
||||
```bash
|
||||
npm run build && npx bundlesize
|
||||
```
|
||||
|
||||
Expected: Bundle sizes within limits
|
||||
|
||||
---
|
||||
|
||||
## Task 20: Final Commit and Summary
|
||||
|
||||
**Step 1: Verify all files are committed**
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
|
||||
Expected: Nothing to commit
|
||||
|
||||
**Step 2: View commit history**
|
||||
|
||||
```bash
|
||||
git log --oneline -20
|
||||
```
|
||||
|
||||
**Step 3: Summary**
|
||||
|
||||
DevOps features implemented:
|
||||
- **Swagger UI** at `/api/docs` with OpenAPI 3.0 spec
|
||||
- **Knex migrations** with auto-migration on startup
|
||||
- **Quality gates**: coverage, bundle size, npm audit, Lighthouse CI
|
||||
- **Secret scanning**: Gitleaks pre-commit hook
|
||||
|
||||
---
|
||||
|
||||
## Post-Implementation Checklist
|
||||
|
||||
- [ ] Add `CODECOV_TOKEN` secret to GitHub
|
||||
- [ ] Test Swagger UI at `/api/docs`
|
||||
- [ ] Verify migrations run on startup
|
||||
- [ ] Install gitleaks on developer machine
|
||||
- [ ] Run full CI pipeline to verify quality gates
|
||||
@@ -1,382 +0,0 @@
|
||||
# Release Engineering Design
|
||||
|
||||
**Date:** 2026-02-20
|
||||
**Status:** Approved
|
||||
**Author:** Claude (AI Assistant)
|
||||
|
||||
## Overview
|
||||
|
||||
Implement a comprehensive release engineering pipeline for the CV application using semantic-release with Docker multi-registry publishing, automated changelog generation, and multi-environment deployment (production, staging, nightly).
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Versioning:** Semantic Versioning with Conventional Commits
|
||||
- **Changelog:** Automated from conventional commit messages
|
||||
- **Release Trigger:** Continuous Delivery (automatic from main branch)
|
||||
- **Deployment Targets:** Docker Hub + GitHub Container Registry (GHCR)
|
||||
- **Environments:** Production, Staging, Nightly/Edge
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ GitHub Repository │
|
||||
│ │
|
||||
┌──────────┐ │ main ─────────────────────────────────────────────────► │ Production Release
|
||||
│ Developer│───────►│ │ │ ├─ Git tag v1.0.0
|
||||
└──────────┘ │ └─────► semantic-release │ ├─ GitHub Release
|
||||
│ ├─ version bump │ ├─ Docker Hub: latest, v1.0.0
|
||||
│ ├─ CHANGELOG.md │ └─ GHCR: latest, v1.0.0
|
||||
│ ├─ git tag │
|
||||
│ └─ GitHub release │
|
||||
│ │
|
||||
│ staging ───────► Deploy to staging environment │ Staging Deployment
|
||||
│ (Docker tag: staging) │
|
||||
│ │
|
||||
│ nightly ────────► Build nightly from main │ Nightly/Edge Builds
|
||||
│ (cron job) (Docker tag: nightly, edge) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Versioning Strategy
|
||||
|
||||
### Semantic Versioning (SemVer)
|
||||
|
||||
Version format: `MAJOR.MINOR.PATCH` (e.g., `1.2.3`)
|
||||
|
||||
| Commit Type | Version Bump | Example |
|
||||
|-------------|--------------|---------|
|
||||
| `feat:` | Minor | 1.0.0 → 1.1.0 |
|
||||
| `fix:` | Patch | 1.0.0 → 1.0.1 |
|
||||
| `feat!:` or `BREAKING CHANGE:` | Major | 1.0.0 → 2.0.0 |
|
||||
| `chore:`, `docs:`, `test:`, `style:`, `refactor:`, `perf:` | None | No release |
|
||||
|
||||
### Conventional Commits Format
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer(s)]
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `feat(admin): add password protection for admin panel`
|
||||
- `fix(skills): prevent focus loss when editing category headers`
|
||||
- `docs(readme): update installation instructions`
|
||||
- `feat(api)!: remove deprecated endpoints`
|
||||
|
||||
### Commit Types
|
||||
|
||||
| Type | Description | Release? |
|
||||
|------|-------------|----------|
|
||||
| `feat` | New feature | Yes (minor) |
|
||||
| `fix` | Bug fix | Yes (patch) |
|
||||
| `docs` | Documentation changes | No |
|
||||
| `style` | Code style changes (formatting) | No |
|
||||
| `refactor` | Code refactoring | No |
|
||||
| `perf` | Performance improvements | No |
|
||||
| `test` | Adding/updating tests | No |
|
||||
| `build` | Build system changes | No |
|
||||
| `ci` | CI/CD changes | No |
|
||||
| `chore` | Maintenance tasks | No |
|
||||
| `revert` | Revert previous commit | Yes (if reverted commit was releasing) |
|
||||
|
||||
## Release Pipeline
|
||||
|
||||
### Production Release (main branch)
|
||||
|
||||
1. Developer pushes/merges to `main`
|
||||
2. CI workflow runs (lint, test, build, e2e, integration)
|
||||
3. semantic-release analyzes commits since last release
|
||||
4. If release-worthy commits found:
|
||||
- Calculates new version
|
||||
- Updates `package.json` version
|
||||
- Generates/updates `CHANGELOG.md`
|
||||
- Creates git tag (e.g., `v1.0.0`)
|
||||
- Creates GitHub release with release notes
|
||||
- Builds Docker image
|
||||
- Pushes to Docker Hub and GHCR with version tags
|
||||
|
||||
### Staging Deployment
|
||||
|
||||
1. Push to `staging` branch
|
||||
2. CI workflow runs (lint, test, build)
|
||||
3. Build Docker image
|
||||
4. Push to registries with `staging` tag
|
||||
5. Deploy to staging environment
|
||||
|
||||
### Nightly Builds
|
||||
|
||||
1. Scheduled workflow runs at 02:00 UTC daily
|
||||
2. Checkout `main` branch
|
||||
3. Build Docker image
|
||||
4. Push to registries with tags:
|
||||
- `nightly`
|
||||
- `edge`
|
||||
- `YYYY-MM-DD` (e.g., `2026-02-20`)
|
||||
|
||||
## Docker Registry Publishing
|
||||
|
||||
### Registry Configuration
|
||||
|
||||
**Docker Hub:**
|
||||
- Image: `DOCKERHUB_USERNAME/cv-app`
|
||||
- Requires: `DOCKERHUB_USERNAME`, `DOCKERHUB_TOKEN` secrets
|
||||
|
||||
**GitHub Container Registry (GHCR):**
|
||||
- Image: `ghcr.io/GITHUB_OWNER/cv-app`
|
||||
- Requires: `GITHUB_TOKEN` (automatic)
|
||||
|
||||
### Tag Strategy
|
||||
|
||||
| Event | Docker Hub Tags | GHCR Tags |
|
||||
|-------|-----------------|-----------|
|
||||
| Production release v1.2.3 | `latest`, `v1.2.3`, `1.2`, `1` | `latest`, `v1.2.3`, `1.2`, `1` |
|
||||
| Staging push | `staging` | `staging` |
|
||||
| Nightly build | `nightly`, `edge`, `2026-02-20` | `nightly`, `edge`, `2026-02-20` |
|
||||
|
||||
### Multi-architecture Support
|
||||
|
||||
Build for multiple platforms:
|
||||
- `linux/amd64` (x86_64)
|
||||
- `linux/arm64` (Apple Silicon, AWS Graviton)
|
||||
|
||||
## Changelog Generation
|
||||
|
||||
### Format
|
||||
|
||||
```markdown
|
||||
## [1.1.0] - 2026-02-20
|
||||
|
||||
### Features
|
||||
* **admin:** add password protection for admin panel (#123) ([abc1234](https://github.com/owner/repo/commit/abc1234))
|
||||
* **api:** add authentication endpoints (#120) ([def5678](https://github.com/owner/repo/commit/def5678))
|
||||
|
||||
### Bug Fixes
|
||||
* **skills:** prevent focus loss when editing category headers (#124) ([ghi9012](https://github.com/owner/repo/commit/ghi9012))
|
||||
|
||||
### Documentation
|
||||
* update installation instructions (#125) ([jkl3456](https://github.com/owner/repo/commit/jkl3456))
|
||||
|
||||
### BREAKING CHANGES
|
||||
* **api:** remove deprecated `/api/v1/*` endpoints
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] - 2026-02-15
|
||||
...
|
||||
```
|
||||
|
||||
### Tools
|
||||
|
||||
- `@semantic-release/changelog` - Generates CHANGELOG.md
|
||||
- `@semantic-release/git` - Commits updated files back to repo
|
||||
- `conventional-changelog-conventionalcommits` - Preset for conventional commits
|
||||
|
||||
## GitHub Environments
|
||||
|
||||
### Production
|
||||
|
||||
- **Protection:** Required reviewers (1+)
|
||||
- **Deploy Trigger:** semantic-release on main branch merge
|
||||
- **Secrets:** `DOCKERHUB_TOKEN`, `DOCKERHUB_USERNAME`
|
||||
- **URL:** Production deployment URL
|
||||
|
||||
### Staging
|
||||
|
||||
- **Protection:** Optional reviewers
|
||||
- **Deploy Trigger:** Push to `staging` branch
|
||||
- **URL:** Staging deployment URL
|
||||
|
||||
### Nightly
|
||||
|
||||
- **Protection:** None
|
||||
- **Deploy Trigger:** Scheduled (cron: `0 2 * * *`)
|
||||
- **URL:** Nightly build URL
|
||||
|
||||
## Branch Protection Rules
|
||||
|
||||
### main branch
|
||||
|
||||
| Rule | Setting |
|
||||
|------|---------|
|
||||
| Require PR reviews | 1+ |
|
||||
| Dismiss stale reviews | Yes |
|
||||
| Require linear history | Yes |
|
||||
| Restrict force pushes | Yes |
|
||||
| Allow deletions | No |
|
||||
| Required status checks | `frontend`, `backend`, `integration`, `e2e` |
|
||||
|
||||
### staging branch
|
||||
|
||||
| Rule | Setting |
|
||||
|------|---------|
|
||||
| Require PR reviews | No |
|
||||
| Require linear history | No |
|
||||
| Restrict force pushes | No |
|
||||
| Required status checks | `frontend`, `backend` |
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
.github/
|
||||
├── workflows/
|
||||
│ ├── ci.yml # Enhanced with commitlint
|
||||
│ ├── release.yml # semantic-release workflow
|
||||
│ ├── staging.yml # Staging deployment
|
||||
│ └── nightly.yml # Nightly builds
|
||||
├── commitlint.config.js # Conventional commits config
|
||||
└── .releaserc.json # semantic-release config
|
||||
|
||||
scripts/
|
||||
├── docker-tags.sh # Docker tag computation
|
||||
└── release-notes.sh # Custom release notes
|
||||
|
||||
docker/
|
||||
├── Dockerfile # Multi-stage build
|
||||
└── docker-compose.prod.yml # Production compose file
|
||||
|
||||
CHANGELOG.md # Generated by semantic-release
|
||||
package.json # Version updated by semantic-release
|
||||
```
|
||||
|
||||
## Implementation Components
|
||||
|
||||
### 1. semantic-release Configuration
|
||||
|
||||
`.releaserc.json`:
|
||||
```json
|
||||
{
|
||||
"branches": ["main"],
|
||||
"plugins": [
|
||||
"@semantic-release/commit-analyzer",
|
||||
"@semantic-release/release-notes-generator",
|
||||
"@semantic-release/changelog",
|
||||
"@semantic-release/npm",
|
||||
"@semantic-release/git",
|
||||
"@semantic-release/github"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. commitlint Configuration
|
||||
|
||||
`commitlint.config.js`:
|
||||
```javascript
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
rules: {
|
||||
'type-enum': [2, 'always', [
|
||||
'feat', 'fix', 'docs', 'style', 'refactor',
|
||||
'perf', 'test', 'build', 'ci', 'chore', 'revert'
|
||||
]],
|
||||
'scope-enum': [2, 'always', [
|
||||
'admin', 'api', 'ui', 'docker', 'ci', 'deps'
|
||||
]]
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Husky Git Hooks
|
||||
|
||||
```bash
|
||||
npx husky init
|
||||
echo "npx commitlint --edit \$1" > .husky/commit-msg
|
||||
```
|
||||
|
||||
### 4. GitHub Workflows
|
||||
|
||||
**release.yml:**
|
||||
- Triggers on push to main
|
||||
- Runs semantic-release
|
||||
- Builds and pushes Docker images
|
||||
|
||||
**staging.yml:**
|
||||
- Triggers on push to staging branch
|
||||
- Builds and pushes Docker images with staging tag
|
||||
|
||||
**nightly.yml:**
|
||||
- Triggers on schedule (daily at 02:00 UTC)
|
||||
- Builds from main with nightly/edge tags
|
||||
|
||||
### 5. Docker Configuration
|
||||
|
||||
Multi-stage Dockerfile with:
|
||||
- Build stage for frontend
|
||||
- Build stage for backend
|
||||
- Production stage with nginx
|
||||
|
||||
Multi-platform build using `docker buildx`
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Secrets Management:**
|
||||
- Use GitHub Secrets for sensitive values
|
||||
- Never commit secrets to repository
|
||||
- Use `GITHUB_TOKEN` for GHCR (automatic)
|
||||
|
||||
2. **Branch Protection:**
|
||||
- Require PR reviews for main
|
||||
- Require status checks to pass
|
||||
- Restrict force pushes
|
||||
|
||||
3. **Docker Image Security:**
|
||||
- Use minimal base images (alpine)
|
||||
- Run as non-root user
|
||||
- Scan images for vulnerabilities (optional: Trivy)
|
||||
|
||||
4. **Environment Protection:**
|
||||
- Required reviewers for production
|
||||
- Environment-specific secrets
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. **Phase 1: Setup Tooling**
|
||||
- Install semantic-release and plugins
|
||||
- Configure commitlint and husky
|
||||
- Create .releaserc.json
|
||||
|
||||
2. **Phase 2: Update Workflows**
|
||||
- Enhance ci.yml with commitlint
|
||||
- Create release.yml
|
||||
- Create staging.yml
|
||||
- Create nightly.yml
|
||||
|
||||
3. **Phase 3: Docker Publishing**
|
||||
- Configure Docker Hub credentials
|
||||
- Update Dockerfile for multi-platform
|
||||
- Test image publishing
|
||||
|
||||
4. **Phase 4: Branch Protection**
|
||||
- Create staging branch
|
||||
- Configure branch protection rules
|
||||
- Create GitHub environments
|
||||
|
||||
5. **Phase 5: First Release**
|
||||
- Make a conventional commit
|
||||
- Verify semantic-release runs
|
||||
- Check GitHub release created
|
||||
- Verify Docker images published
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Conventional commits enforced via commitlint
|
||||
- [ ] Version automatically bumped based on commit types
|
||||
- [ ] CHANGELOG.md automatically generated and updated
|
||||
- [ ] Git tags created for each release
|
||||
- [ ] GitHub releases created with release notes
|
||||
- [ ] Docker images published to Docker Hub and GHCR
|
||||
- [ ] Multi-platform Docker images (amd64, arm64)
|
||||
- [ ] Staging deployments on staging branch push
|
||||
- [ ] Nightly builds running daily
|
||||
- [ ] Branch protection rules enforced
|
||||
|
||||
## References
|
||||
|
||||
- [semantic-release Documentation](https://semantic-release.gitbook.io/)
|
||||
- [Conventional Commits Specification](https://www.conventionalcommits.org/)
|
||||
- [GitHub Actions Documentation](https://docs.github.com/en/actions)
|
||||
- [Docker Multi-platform Builds](https://docs.docker.com/build/building/multi-platform/)
|
||||
@@ -1,974 +0,0 @@
|
||||
# Release Engineering Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Implement comprehensive release engineering with semantic-release, Docker multi-registry publishing, and multi-environment deployment (production, staging, nightly).
|
||||
|
||||
**Architecture:** semantic-release handles versioning, changelog, and GitHub releases from conventional commits. GitHub Actions workflows build and publish Docker images to Docker Hub and GHCR. Separate workflows for staging and nightly deployments.
|
||||
|
||||
**Tech Stack:** semantic-release, commitlint, husky, Docker buildx, GitHub Actions
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Install Release Engineering Dependencies
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json`
|
||||
- Create: `.releaserc.json`
|
||||
- Create: `commitlint.config.js`
|
||||
|
||||
**Step 1: Install semantic-release and plugins**
|
||||
|
||||
```bash
|
||||
npm install --save-dev semantic-release @semantic-release/changelog @semantic-release/git @semantic-release/github @commitlint/cli @commitlint/config-conventional husky
|
||||
```
|
||||
|
||||
**Step 2: Verify installation**
|
||||
|
||||
Run: `npm list semantic-release @commitlint/cli husky --depth=0`
|
||||
Expected: All packages listed with versions
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json package-lock.json
|
||||
git commit -m "build: add semantic-release, commitlint, and husky dependencies"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Configure semantic-release
|
||||
|
||||
**Files:**
|
||||
- Create: `.releaserc.json`
|
||||
|
||||
**Step 1: Create semantic-release configuration file**
|
||||
|
||||
Create `.releaserc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"branches": ["main", { "name": "staging", "prerelease": true }],
|
||||
"plugins": [
|
||||
[
|
||||
"@semantic-release/commit-analyzer",
|
||||
{
|
||||
"preset": "conventionalcommits",
|
||||
"releaseRules": [
|
||||
{ "type": "feat", "release": "minor" },
|
||||
{ "type": "fix", "release": "patch" },
|
||||
{ "type": "perf", "release": "patch" },
|
||||
{ "type": "revert", "release": "patch" },
|
||||
{ "type": "docs", "release": false },
|
||||
{ "type": "style", "release": false },
|
||||
{ "type": "refactor", "release": false },
|
||||
{ "type": "test", "release": false },
|
||||
{ "type": "build", "release": false },
|
||||
{ "type": "ci", "release": false },
|
||||
{ "type": "chore", "release": false }
|
||||
]
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/release-notes-generator",
|
||||
{
|
||||
"preset": "conventionalcommits",
|
||||
"presetConfig": {
|
||||
"types": [
|
||||
{ "type": "feat", "section": "Features", "hidden": false },
|
||||
{ "type": "fix", "section": "Bug Fixes", "hidden": false },
|
||||
{ "type": "perf", "section": "Performance Improvements", "hidden": false },
|
||||
{ "type": "revert", "section": "Reverts", "hidden": false },
|
||||
{ "type": "docs", "section": "Documentation", "hidden": false },
|
||||
{ "type": "style", "section": "Styles", "hidden": true },
|
||||
{ "type": "refactor", "section": "Code Refactoring", "hidden": true },
|
||||
{ "type": "test", "section": "Tests", "hidden": true },
|
||||
{ "type": "build", "section": "Build System", "hidden": true },
|
||||
{ "type": "ci", "section": "Continuous Integration", "hidden": true },
|
||||
{ "type": "chore", "section": "Chores", "hidden": true }
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/changelog",
|
||||
{
|
||||
"changelogFile": "CHANGELOG.md",
|
||||
"changelogTitle": "# Changelog\n\nAll notable changes to this project will be documented in this file."
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/npm",
|
||||
{
|
||||
"npmPublish": false
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/git",
|
||||
{
|
||||
"assets": ["package.json", "CHANGELOG.md"],
|
||||
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/github",
|
||||
{
|
||||
"assets": [
|
||||
{ "path": "dist/**/*", "label": "Distribution" }
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Verify JSON is valid**
|
||||
|
||||
Run: `node -e "console.log(JSON.parse(require('fs').readFileSync('.releaserc.json', 'utf8')))"`
|
||||
Expected: JSON object printed without errors
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .releaserc.json
|
||||
git commit -m "chore(ci): configure semantic-release"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Configure commitlint
|
||||
|
||||
**Files:**
|
||||
- Create: `commitlint.config.js`
|
||||
|
||||
**Step 1: Create commitlint configuration file**
|
||||
|
||||
Create `commitlint.config.js`:
|
||||
|
||||
```javascript
|
||||
export default {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
rules: {
|
||||
'type-enum': [
|
||||
2,
|
||||
'always',
|
||||
[
|
||||
'feat',
|
||||
'fix',
|
||||
'docs',
|
||||
'style',
|
||||
'refactor',
|
||||
'perf',
|
||||
'test',
|
||||
'build',
|
||||
'ci',
|
||||
'chore',
|
||||
'revert',
|
||||
],
|
||||
],
|
||||
'scope-enum': [
|
||||
2,
|
||||
'always',
|
||||
[
|
||||
'admin',
|
||||
'api',
|
||||
'ui',
|
||||
'docker',
|
||||
'ci',
|
||||
'deps',
|
||||
'release',
|
||||
'auth',
|
||||
'skills',
|
||||
'experience',
|
||||
'education',
|
||||
'projects',
|
||||
'personal',
|
||||
],
|
||||
],
|
||||
'scope-empty': [1, 'never'],
|
||||
'subject-case': [2, 'always', 'lower-case'],
|
||||
'subject-max-length': [2, 'always', 72],
|
||||
'body-max-line-length': [2, 'always', 100],
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
**Step 2: Verify configuration is valid**
|
||||
|
||||
Run: `npx commitlint --from HEAD~1 --to HEAD --verbose` (skip if only one commit)
|
||||
Expected: Either passes or reports linting issues
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add commitlint.config.js
|
||||
git commit -m "chore(ci): configure commitlint for conventional commits"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Configure Husky Git Hooks
|
||||
|
||||
**Files:**
|
||||
- Create: `.husky/commit-msg`
|
||||
- Create: `.husky/pre-commit`
|
||||
|
||||
**Step 1: Initialize husky**
|
||||
|
||||
```bash
|
||||
npx husky init
|
||||
```
|
||||
|
||||
Expected: `.husky/` directory created
|
||||
|
||||
**Step 2: Create commit-msg hook**
|
||||
|
||||
Replace `.husky/commit-msg` content with:
|
||||
|
||||
```bash
|
||||
npx --no -- commitlint --edit "$1"
|
||||
```
|
||||
|
||||
**Step 3: Create pre-commit hook**
|
||||
|
||||
Replace `.husky/pre-commit` content with:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
**Step 4: Make hooks executable**
|
||||
|
||||
```bash
|
||||
chmod +x .husky/pre-commit .husky/commit-msg
|
||||
```
|
||||
|
||||
**Step 5: Verify husky is configured in package.json**
|
||||
|
||||
Run: `cat package.json | grep -A 2 '"scripts"'`
|
||||
Expected: `prepare` script present with `husky`
|
||||
|
||||
If not present, add to package.json scripts:
|
||||
```json
|
||||
"prepare": "husky"
|
||||
```
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add .husky package.json
|
||||
git commit -m "chore(ci): configure husky git hooks for commitlint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Create Docker Multi-Platform Configuration
|
||||
|
||||
**Files:**
|
||||
- Modify: `Dockerfile`
|
||||
- Create: `docker bake.hcl`
|
||||
|
||||
**Step 1: Update Dockerfile for multi-platform support**
|
||||
|
||||
Read current `Dockerfile` and ensure it supports multi-platform. The existing Dockerfile should already work, but verify:
|
||||
- No hardcoded architecture-specific paths
|
||||
- Uses multi-stage build
|
||||
- Backend builds better-sqlite3 from source
|
||||
|
||||
**Step 2: Create docker-bake.hcl for multi-registry publishing**
|
||||
|
||||
Create `docker-bake.hcl`:
|
||||
|
||||
```hcl
|
||||
variable "REGISTRY_USER" {
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "IMAGE_NAME" {
|
||||
default = "cv-app"
|
||||
}
|
||||
|
||||
variable "TAG" {
|
||||
default = "latest"
|
||||
}
|
||||
|
||||
variable "VERSION" {
|
||||
default = ""
|
||||
}
|
||||
|
||||
group "default" {
|
||||
targets = ["cv-app"]
|
||||
}
|
||||
|
||||
target "cv-app" {
|
||||
context = "."
|
||||
platforms = ["linux/amd64", "linux/arm64"]
|
||||
tags = [
|
||||
notequal("",VERSION) ? "${REGISTRY_USER}/${IMAGE_NAME}:${VERSION}" : "",
|
||||
notequal("",VERSION) ? "${REGISTRY_USER}/${IMAGE_NAME}:latest" : "",
|
||||
notequal("",VERSION) ? "ghcr.io/${REGISTRY_USER}/${IMAGE_NAME}:${VERSION}" : "",
|
||||
notequal("",VERSION) ? "ghcr.io/${REGISTRY_USER}/${IMAGE_NAME}:latest" : "",
|
||||
]
|
||||
args = {
|
||||
NODE_VERSION = "20"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add Dockerfile docker-bake.hcl
|
||||
git commit -m "feat(docker): add multi-platform build configuration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Create Release Workflow
|
||||
|
||||
**Files:**
|
||||
- Modify: `.github/workflows/release.yml`
|
||||
|
||||
**Step 1: Replace existing release.yml**
|
||||
|
||||
Replace `.github/workflows/release.yml` content with:
|
||||
|
||||
```yaml
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
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'
|
||||
|
||||
- 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 }}
|
||||
run: npx semantic-release
|
||||
|
||||
docker:
|
||||
name: Build & Push Docker Image
|
||||
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:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/cv-app:latest
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/cv-app:${{ steps.version.outputs.version }}
|
||||
ghcr.io/${{ github.repository_owner }}/cv-app:latest
|
||||
ghcr.io/${{ github.repository_owner }}/cv-app:${{ steps.version.outputs.version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
```
|
||||
|
||||
**Step 2: Verify YAML syntax**
|
||||
|
||||
Run: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/release.yml'))"`
|
||||
Expected: No errors
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .github/workflows/release.yml
|
||||
git commit -m "feat(ci): add semantic-release workflow with Docker publishing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Create Staging Workflow
|
||||
|
||||
**Files:**
|
||||
- Create: `.github/workflows/staging.yml`
|
||||
|
||||
**Step 1: Create staging workflow**
|
||||
|
||||
Create `.github/workflows/staging.yml`:
|
||||
|
||||
```yaml
|
||||
name: Staging Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [staging]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
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'
|
||||
|
||||
- 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:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/cv-app:staging
|
||||
ghcr.io/${{ github.repository_owner }}/cv-app:staging
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
```
|
||||
|
||||
**Step 2: Verify YAML syntax**
|
||||
|
||||
Run: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/staging.yml'))"`
|
||||
Expected: No errors
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .github/workflows/staging.yml
|
||||
git commit -m "feat(ci): add staging deployment workflow"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Create Nightly Build Workflow
|
||||
|
||||
**Files:**
|
||||
- Create: `.github/workflows/nightly.yml`
|
||||
|
||||
**Step 1: Create nightly workflow**
|
||||
|
||||
Create `.github/workflows/nightly.yml`:
|
||||
|
||||
```yaml
|
||||
name: Nightly Build
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
nightly:
|
||||
name: Build Nightly Image
|
||||
runs-on: ubuntu-latest
|
||||
environment: nightly
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- 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'
|
||||
|
||||
- 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:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/cv-app:nightly
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/cv-app:edge
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/cv-app:${{ steps.date.outputs.date }}
|
||||
ghcr.io/${{ github.repository_owner }}/cv-app:nightly
|
||||
ghcr.io/${{ github.repository_owner }}/cv-app:edge
|
||||
ghcr.io/${{ github.repository_owner }}/cv-app:${{ steps.date.outputs.date }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
```
|
||||
|
||||
**Step 2: Verify YAML syntax**
|
||||
|
||||
Run: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/nightly.yml'))"`
|
||||
Expected: No errors
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .github/workflows/nightly.yml
|
||||
git commit -m "feat(ci): add nightly build workflow"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Enhance CI Workflow with Commitlint
|
||||
|
||||
**Files:**
|
||||
- Modify: `.github/workflows/ci.yml`
|
||||
|
||||
**Step 1: Add commitlint job to CI**
|
||||
|
||||
Add this job to `.github/workflows/ci.yml` after the `jobs:` line:
|
||||
|
||||
```yaml
|
||||
commitlint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
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
|
||||
```
|
||||
|
||||
**Step 2: Update job dependencies**
|
||||
|
||||
Add `commitlint` to the `needs` array of downstream jobs. For example, change:
|
||||
```yaml
|
||||
integration:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [frontend, backend]
|
||||
```
|
||||
to:
|
||||
```yaml
|
||||
integration:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [frontend, backend, commitlint]
|
||||
```
|
||||
|
||||
Do the same for `e2e` and `regression` jobs.
|
||||
|
||||
**Step 3: Verify YAML syntax**
|
||||
|
||||
Run: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml'))"`
|
||||
Expected: No errors
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add .github/workflows/ci.yml
|
||||
git commit -m "feat(ci): add commitlint validation to CI workflow"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Create CHANGELOG.md
|
||||
|
||||
**Files:**
|
||||
- Create: `CHANGELOG.md`
|
||||
|
||||
**Step 1: Create initial CHANGELOG**
|
||||
|
||||
Create `CHANGELOG.md`:
|
||||
|
||||
```markdown
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Release engineering with semantic-release
|
||||
- Multi-platform Docker builds (amd64, arm64)
|
||||
- Docker Hub and GHCR publishing
|
||||
- Staging and nightly deployment workflows
|
||||
- Commitlint for conventional commits
|
||||
|
||||
## [0.0.0] - 2026-02-20
|
||||
|
||||
### Added
|
||||
- Initial CV application
|
||||
- React frontend with Tailwind CSS v4
|
||||
- Express.js backend with SQLite
|
||||
- Admin panel for CV editing
|
||||
- Password protection for admin
|
||||
- Docker Compose deployment
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add CHANGELOG.md
|
||||
git commit -m "docs: add initial CHANGELOG.md"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Update README with Release Information
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
|
||||
**Step 1: Add release engineering section to README**
|
||||
|
||||
Add this section after the "Testing" section:
|
||||
|
||||
```markdown
|
||||
## Release Process
|
||||
|
||||
This project uses [semantic-release](https://semantic-release.gitbook.io/) for automated versioning and releases.
|
||||
|
||||
### Conventional Commits
|
||||
|
||||
All commit messages must follow the [Conventional Commits](https://www.conventionalcommits.org/) specification:
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer]
|
||||
```
|
||||
|
||||
#### Commit Types
|
||||
|
||||
| Type | Description | Version Bump |
|
||||
|------|-------------|--------------|
|
||||
| `feat` | New feature | Minor |
|
||||
| `fix` | Bug fix | Patch |
|
||||
| `feat!` or `BREAKING CHANGE` | Breaking change | Major |
|
||||
| `docs`, `style`, `refactor`, `test`, `build`, `ci`, `chore` | Non-release changes | None |
|
||||
|
||||
#### Scopes
|
||||
|
||||
Available scopes: `admin`, `api`, `ui`, `docker`, `ci`, `deps`, `release`, `auth`, `skills`, `experience`, `education`, `projects`, `personal`
|
||||
|
||||
### Release Workflow
|
||||
|
||||
1. Push to `main` branch
|
||||
2. CI runs tests and linting
|
||||
3. semantic-release analyzes commits
|
||||
4. If release needed:
|
||||
- Version is bumped in package.json
|
||||
- CHANGELOG.md is updated
|
||||
- Git tag is created
|
||||
- GitHub release is published
|
||||
- Docker images are built and pushed
|
||||
|
||||
### Docker Images
|
||||
|
||||
Images are published to both Docker Hub and GitHub Container Registry:
|
||||
|
||||
| Tag | Description |
|
||||
|-----|-------------|
|
||||
| `latest` | Latest stable release |
|
||||
| `v1.2.3` | Specific version |
|
||||
| `staging` | Staging environment |
|
||||
| `nightly`, `edge` | Daily builds from main |
|
||||
| `YYYY-MM-DD` | Dated nightly build |
|
||||
|
||||
```bash
|
||||
# Pull from Docker Hub
|
||||
docker pull username/cv-app:latest
|
||||
|
||||
# Pull from GHCR
|
||||
docker pull ghcr.io/owner/cv-app:latest
|
||||
```
|
||||
|
||||
### Environments
|
||||
|
||||
| Environment | Branch | Trigger |
|
||||
|-------------|--------|---------|
|
||||
| Production | `main` | semantic-release |
|
||||
| Staging | `staging` | Push to staging |
|
||||
| Nightly | `main` | Daily at 02:00 UTC |
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add README.md
|
||||
git commit -m "docs: add release process documentation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 12: Update package.json Version
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json`
|
||||
|
||||
**Step 1: Update version field**
|
||||
|
||||
Change `"version": "0.0.0"` to `"version": "0.0.0-dev.1"` in package.json.
|
||||
|
||||
This indicates the project is in development before the first release. semantic-release will set the proper version on first release.
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json
|
||||
git commit -m "chore: set initial development version"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 13: Verify All Configurations
|
||||
|
||||
**Step 1: Run lint**
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Expected: No errors
|
||||
|
||||
**Step 2: Run tests**
|
||||
|
||||
```bash
|
||||
npm run test:run
|
||||
```
|
||||
|
||||
Expected: All tests pass
|
||||
|
||||
**Step 3: Test commitlint locally**
|
||||
|
||||
```bash
|
||||
echo "feat(test): test commit message" | npx commitlint
|
||||
```
|
||||
|
||||
Expected: No errors
|
||||
|
||||
```bash
|
||||
echo "invalid commit message" | npx commitlint
|
||||
```
|
||||
|
||||
Expected: Error message about invalid format
|
||||
|
||||
**Step 4: Verify husky hooks**
|
||||
|
||||
```bash
|
||||
ls -la .husky/
|
||||
```
|
||||
|
||||
Expected: `pre-commit` and `commit-msg` files exist and are executable
|
||||
|
||||
---
|
||||
|
||||
## Task 14: Final Commit and Summary
|
||||
|
||||
**Step 1: Verify all files are committed**
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
|
||||
Expected: "nothing to commit, working tree clean" or only untracked files that shouldn't be committed
|
||||
|
||||
**Step 2: View commit history**
|
||||
|
||||
```bash
|
||||
git log --oneline -15
|
||||
```
|
||||
|
||||
Expected: All 12+ commits from this plan visible
|
||||
|
||||
**Step 3: Summary**
|
||||
|
||||
The release engineering system is now configured:
|
||||
|
||||
- **semantic-release** handles automated versioning and releases
|
||||
- **commitlint** enforces conventional commits
|
||||
- **husky** provides git hooks for validation
|
||||
- **GitHub Actions** workflows for release, staging, and nightly builds
|
||||
- **Docker** multi-platform images published to Docker Hub and GHCR
|
||||
|
||||
---
|
||||
|
||||
## Required Secrets
|
||||
|
||||
Before the first release, configure these secrets in GitHub repository settings:
|
||||
|
||||
| Secret | Description |
|
||||
|--------|-------------|
|
||||
| `DOCKERHUB_USERNAME` | Docker Hub username |
|
||||
| `DOCKERHUB_TOKEN` | Docker Hub access token |
|
||||
| `GITHUB_TOKEN` | Automatic (no configuration needed) |
|
||||
|
||||
---
|
||||
|
||||
## Post-Implementation Checklist
|
||||
|
||||
- [ ] Configure Docker Hub secrets in GitHub
|
||||
- [ ] Create `staging` branch
|
||||
- [ ] Configure GitHub environments (production, staging, nightly)
|
||||
- [ ] Set up branch protection rules for `main`
|
||||
- [ ] Test release by merging a `feat:` commit to main
|
||||
491
docs/plans/2026-02-23-helm-chart-design.md
Normal file
491
docs/plans/2026-02-23-helm-chart-design.md
Normal file
@@ -0,0 +1,491 @@
|
||||
# Helm Chart Design
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add Helm chart for Kubernetes deployment as an alternative to Docker Compose.
|
||||
|
||||
**Architecture:** Single Helm chart deploying frontend (nginx) and backend (Node.js) services with PVC for SQLite persistence and Ingress for external access.
|
||||
|
||||
**Tech Stack:** Helm 3, Kubernetes
|
||||
|
||||
---
|
||||
|
||||
## Chart Structure
|
||||
|
||||
```
|
||||
helm/cv-app/
|
||||
├── Chart.yaml
|
||||
├── values.yaml
|
||||
├── templates/
|
||||
│ ├── _helpers.tpl
|
||||
│ ├── frontend-deployment.yaml
|
||||
│ ├── frontend-service.yaml
|
||||
│ ├── backend-deployment.yaml
|
||||
│ ├── backend-service.yaml
|
||||
│ ├── ingress.yaml
|
||||
│ ├── configmap.yaml
|
||||
│ ├── secret.yaml
|
||||
│ ├── pvc.yaml
|
||||
│ └── NOTES.txt
|
||||
└── .helmignore
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### Frontend
|
||||
|
||||
- **Deployment**: nginx serving built React static files
|
||||
- **Service**: ClusterIP on port 80
|
||||
- **Environment**: API URL from ConfigMap
|
||||
|
||||
### Backend
|
||||
|
||||
- **Deployment**: Node.js Express server
|
||||
- **Service**: ClusterIP on port 3001
|
||||
- **Volume**: PVC mount at `/app/data` for SQLite
|
||||
- **Environment**: Port, DB path, auth config from ConfigMap/Secret
|
||||
|
||||
### Ingress
|
||||
|
||||
- Routes `/` → frontend service
|
||||
- Routes `/api` → backend service
|
||||
- Configurable TLS
|
||||
- Supports nginx, traefik ingress controllers
|
||||
|
||||
### Persistence
|
||||
|
||||
- PVC for SQLite database
|
||||
- Configurable storage class
|
||||
- 1Gi default size
|
||||
|
||||
## Values Schema
|
||||
|
||||
```yaml
|
||||
frontend:
|
||||
replicaCount: 1
|
||||
image:
|
||||
repository: username/cv-app
|
||||
tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
resources: {}
|
||||
|
||||
backend:
|
||||
replicaCount: 1
|
||||
image:
|
||||
repository: username/cv-app-backend
|
||||
tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
auth:
|
||||
mode: simple
|
||||
keycloak:
|
||||
url: ""
|
||||
realm: ""
|
||||
clientId: ""
|
||||
resources: {}
|
||||
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 1Gi
|
||||
storageClass: ""
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: ""
|
||||
annotations: {}
|
||||
hosts:
|
||||
- host: cv.local
|
||||
paths:
|
||||
- path: /
|
||||
service: frontend
|
||||
- path: /api
|
||||
service: backend
|
||||
tls: []
|
||||
|
||||
imagePullSecrets: []
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
serviceAccount:
|
||||
create: true
|
||||
annotations: {}
|
||||
name: ""
|
||||
|
||||
podAnnotations: {}
|
||||
podSecurityContext: {}
|
||||
securityContext: {}
|
||||
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
```
|
||||
|
||||
## Templates
|
||||
|
||||
### _helpers.tpl
|
||||
|
||||
```yaml
|
||||
{{- define "cv-app.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "cv-app.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "cv-app.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "cv-app.labels" -}}
|
||||
helm.sh/chart: {{ include "cv-app.chart" . }}
|
||||
{{ include "cv-app.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "cv-app.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "cv-app.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "cv-app.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
{{- default (include "cv-app.fullname" .) .Values.serviceAccount.name }}
|
||||
{{- else }}
|
||||
{{- default "default" .Values.serviceAccount.name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
### frontend-deployment.yaml
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "cv-app.fullname" . }}-frontend
|
||||
labels:
|
||||
{{- include "cv-app.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: frontend
|
||||
spec:
|
||||
replicas: {{ .Values.frontend.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "cv-app.selectorLabels" . | nindent 6 }}
|
||||
app.kubernetes.io/component: frontend
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||||
labels:
|
||||
{{- include "cv-app.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: frontend
|
||||
spec:
|
||||
containers:
|
||||
- name: frontend
|
||||
image: "{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.frontend.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 80
|
||||
protocol: TCP
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ include "cv-app.fullname" . }}-config
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
resources:
|
||||
{{- toYaml .Values.frontend.resources | nindent 12 }}
|
||||
```
|
||||
|
||||
### frontend-service.yaml
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "cv-app.fullname" . }}-frontend
|
||||
labels:
|
||||
{{- include "cv-app.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: frontend
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "cv-app.selectorLabels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: frontend
|
||||
```
|
||||
|
||||
### backend-deployment.yaml
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "cv-app.fullname" . }}-backend
|
||||
labels:
|
||||
{{- include "cv-app.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: backend
|
||||
spec:
|
||||
replicas: {{ .Values.backend.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "cv-app.selectorLabels" . | nindent 6 }}
|
||||
app.kubernetes.io/component: backend
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||||
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
|
||||
labels:
|
||||
{{- include "cv-app.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: backend
|
||||
spec:
|
||||
containers:
|
||||
- name: backend
|
||||
image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.backend.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3001
|
||||
protocol: TCP
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ include "cv-app.fullname" . }}-config
|
||||
- secretRef:
|
||||
name: {{ include "cv-app.fullname" . }}-secret
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
resources:
|
||||
{{- toYaml .Values.backend.resources | nindent 12 }}
|
||||
volumes:
|
||||
- name: data
|
||||
{{- if .Values.persistence.enabled }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "cv-app.fullname" . }}-data
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
### backend-service.yaml
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "cv-app.fullname" . }}-backend
|
||||
labels:
|
||||
{{- include "cv-app.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: backend
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 3001
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "cv-app.selectorLabels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: backend
|
||||
```
|
||||
|
||||
### ingress.yaml
|
||||
|
||||
```yaml
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "cv-app.fullname" . }}
|
||||
labels:
|
||||
{{- include "cv-app.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- range .Values.ingress.tls }}
|
||||
- hosts:
|
||||
{{- range .hosts }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ .secretName }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ .path }}
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "cv-app.fullname" $ }}-{{ .service }}
|
||||
port:
|
||||
number: {{ if eq .service "frontend" }}80{{ else }}3001{{ end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
### configmap.yaml
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "cv-app.fullname" . }}-config
|
||||
labels:
|
||||
{{- include "cv-app.labels" . | nindent 4 }}
|
||||
data:
|
||||
PORT: "3001"
|
||||
DB_PATH: "/app/data/cv.db"
|
||||
AUTH_MODE: {{ .Values.backend.auth.mode | quote }}
|
||||
{{- if eq .Values.backend.auth.mode "keycloak" }}
|
||||
KEYCLOAK_URL: {{ .Values.backend.keycloak.url | quote }}
|
||||
KEYCLOAK_REALM: {{ .Values.backend.keycloak.realm | quote }}
|
||||
KEYCLOAK_CLIENT_ID: {{ .Values.backend.keycloak.clientId | quote }}
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
### secret.yaml
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "cv-app.fullname" . }}-secret
|
||||
labels:
|
||||
{{- include "cv-app.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
data:
|
||||
{{- if eq .Values.backend.auth.mode "simple" }}
|
||||
JWT_SECRET: {{ randAlphaNum 32 | b64enc | quote }}
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
### pvc.yaml
|
||||
|
||||
```yaml
|
||||
{{- if .Values.persistence.enabled -}}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "cv-app.fullname" . }}-data
|
||||
labels:
|
||||
{{- include "cv-app.labels" . | nindent 4 }}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
{{- if .Values.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.persistence.storageClass | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.size }}
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
### NOTES.txt
|
||||
|
||||
```
|
||||
Your CV application has been deployed!
|
||||
|
||||
Access your application at:
|
||||
{{- if .Values.ingress.enabled }}
|
||||
{{- range $host := .Values.ingress.hosts }}
|
||||
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}
|
||||
{{- end }}
|
||||
{{- else }}
|
||||
Frontend: http://{{ include "cv-app.fullname" . }}-frontend.{{ .Release.Namespace }}.svc.cluster.local
|
||||
Backend API: http://{{ include "cv-app.fullname" . }}-backend.{{ .Release.Namespace }}.svc.cluster.local:3001
|
||||
{{- end }}
|
||||
|
||||
{{- if eq .Values.backend.auth.mode "simple" }}
|
||||
Get the admin password:
|
||||
kubectl logs deployment/{{ include "cv-app.fullname" . }}-backend | grep "ADMIN PASSWORD"
|
||||
{{- end }}
|
||||
|
||||
API Documentation:
|
||||
http{{ if .Values.ingress.tls }}s{{ end }}://{{ (index .Values.ingress.hosts 0).host }}/api/docs
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Installation
|
||||
|
||||
```bash
|
||||
helm install cv-app ./helm/cv-app
|
||||
```
|
||||
|
||||
### With Custom Host
|
||||
|
||||
```bash
|
||||
helm install cv-app ./helm/cv-app \
|
||||
--set ingress.hosts[0].host=cv.example.com
|
||||
```
|
||||
|
||||
### With TLS
|
||||
|
||||
```bash
|
||||
helm install cv-app ./helm/cv-app \
|
||||
--set ingress.hosts[0].host=cv.example.com \
|
||||
--set ingress.tls[0].hosts[0]=cv.example.com \
|
||||
--set ingress.tls[0].secretName=cv-app-tls
|
||||
```
|
||||
|
||||
### With Keycloak
|
||||
|
||||
```bash
|
||||
helm install cv-app ./helm/cv-app \
|
||||
--set backend.auth.mode=keycloak \
|
||||
--set backend.keycloak.url=https://keycloak.example.com \
|
||||
--set backend.keycloak.realm=myrealm \
|
||||
--set backend.keycloak.clientId=cv-app
|
||||
```
|
||||
|
||||
### With Specific Storage Class
|
||||
|
||||
```bash
|
||||
helm install cv-app ./helm/cv-app \
|
||||
--set persistence.storageClass=local-path \
|
||||
--set persistence.size=5Gi
|
||||
```
|
||||
636
docs/plans/2026-02-23-helm-chart-implementation.md
Normal file
636
docs/plans/2026-02-23-helm-chart-implementation.md
Normal file
@@ -0,0 +1,636 @@
|
||||
# Helm Chart Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add Helm chart for Kubernetes deployment with frontend, backend, ingress, and persistence.
|
||||
|
||||
**Architecture:** Single Helm chart with templates for Deployments, Services, Ingress, ConfigMap, Secret, and PVC.
|
||||
|
||||
**Tech Stack:** Helm 3, Kubernetes
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create Helm chart structure
|
||||
|
||||
**Files:**
|
||||
- Create: `helm/cv-app/Chart.yaml`
|
||||
- Create: `helm/cv-app/.helmignore`
|
||||
|
||||
**Step 1: Create helm directory**
|
||||
|
||||
```bash
|
||||
mkdir -p helm/cv-app/templates
|
||||
```
|
||||
|
||||
**Step 2: Create Chart.yaml**
|
||||
|
||||
```yaml
|
||||
apiVersion: v2
|
||||
name: cv-app
|
||||
description: A Helm chart for CV application
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "1.0.0"
|
||||
maintainers:
|
||||
- name: Tuan-Dat Tran
|
||||
email: tuan-dat.tran@tudattr.dev
|
||||
```
|
||||
|
||||
**Step 3: Create .helmignore**
|
||||
|
||||
```
|
||||
# Patterns to ignore when building packages.
|
||||
.git/
|
||||
.github/
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Test files
|
||||
*_test.go
|
||||
tests/
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
!README.md
|
||||
docs/
|
||||
|
||||
# CI/CD
|
||||
.github/
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add helm/cv-app/Chart.yaml helm/cv-app/.helmignore
|
||||
git commit -m "feat(helm): add chart structure and metadata"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create helper templates
|
||||
|
||||
**Files:**
|
||||
- Create: `helm/cv-app/templates/_helpers.tpl`
|
||||
|
||||
**Step 1: Create _helpers.tpl**
|
||||
|
||||
```yaml
|
||||
{{- define "cv-app.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "cv-app.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "cv-app.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "cv-app.labels" -}}
|
||||
helm.sh/chart: {{ include "cv-app.chart" . }}
|
||||
{{ include "cv-app.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "cv-app.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "cv-app.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "cv-app.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
{{- default (include "cv-app.fullname" .) .Values.serviceAccount.name }}
|
||||
{{- else }}
|
||||
{{- default "default" .Values.serviceAccount.name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add helm/cv-app/templates/_helpers.tpl
|
||||
git commit -m "feat(helm): add template helpers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create default values
|
||||
|
||||
**Files:**
|
||||
- Create: `helm/cv-app/values.yaml`
|
||||
|
||||
**Step 1: Create values.yaml**
|
||||
|
||||
```yaml
|
||||
frontend:
|
||||
replicaCount: 1
|
||||
image:
|
||||
repository: username/cv-app
|
||||
tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
resources: {}
|
||||
|
||||
backend:
|
||||
replicaCount: 1
|
||||
image:
|
||||
repository: username/cv-app-backend
|
||||
tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
auth:
|
||||
mode: simple
|
||||
keycloak:
|
||||
url: ""
|
||||
realm: ""
|
||||
clientId: ""
|
||||
resources: {}
|
||||
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 1Gi
|
||||
storageClass: ""
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: ""
|
||||
annotations: {}
|
||||
hosts:
|
||||
- host: cv.local
|
||||
paths:
|
||||
- path: /
|
||||
service: frontend
|
||||
- path: /api
|
||||
service: backend
|
||||
tls: []
|
||||
|
||||
imagePullSecrets: []
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
serviceAccount:
|
||||
create: true
|
||||
annotations: {}
|
||||
name: ""
|
||||
|
||||
podAnnotations: {}
|
||||
podSecurityContext: {}
|
||||
securityContext: {}
|
||||
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add helm/cv-app/values.yaml
|
||||
git commit -m "feat(helm): add default values configuration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Create ConfigMap template
|
||||
|
||||
**Files:**
|
||||
- Create: `helm/cv-app/templates/configmap.yaml`
|
||||
|
||||
**Step 1: Create configmap.yaml**
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "cv-app.fullname" . }}-config
|
||||
labels:
|
||||
{{- include "cv-app.labels" . | nindent 4 }}
|
||||
data:
|
||||
PORT: "3001"
|
||||
DB_PATH: "/app/data/cv.db"
|
||||
AUTH_MODE: {{ .Values.backend.auth.mode | quote }}
|
||||
{{- if eq .Values.backend.auth.mode "keycloak" }}
|
||||
KEYCLOAK_URL: {{ .Values.backend.keycloak.url | quote }}
|
||||
KEYCLOAK_REALM: {{ .Values.backend.keycloak.realm | quote }}
|
||||
KEYCLOAK_CLIENT_ID: {{ .Values.backend.keycloak.clientId | quote }}
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add helm/cv-app/templates/configmap.yaml
|
||||
git commit -m "feat(helm): add ConfigMap template"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Create Secret template
|
||||
|
||||
**Files:**
|
||||
- Create: `helm/cv-app/templates/secret.yaml`
|
||||
|
||||
**Step 1: Create secret.yaml**
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "cv-app.fullname" . }}-secret
|
||||
labels:
|
||||
{{- include "cv-app.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
data:
|
||||
{{- if eq .Values.backend.auth.mode "simple" }}
|
||||
JWT_SECRET: {{ randAlphaNum 32 | b64enc | quote }}
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add helm/cv-app/templates/secret.yaml
|
||||
git commit -m "feat(helm): add Secret template for JWT"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Create PVC template
|
||||
|
||||
**Files:**
|
||||
- Create: `helm/cv-app/templates/pvc.yaml`
|
||||
|
||||
**Step 1: Create pvc.yaml**
|
||||
|
||||
```yaml
|
||||
{{- if .Values.persistence.enabled -}}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "cv-app.fullname" . }}-data
|
||||
labels:
|
||||
{{- include "cv-app.labels" . | nindent 4 }}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
{{- if .Values.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.persistence.storageClass | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.size }}
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add helm/cv-app/templates/pvc.yaml
|
||||
git commit -m "feat(helm): add PersistentVolumeClaim template"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Create backend Deployment and Service
|
||||
|
||||
**Files:**
|
||||
- Create: `helm/cv-app/templates/backend-deployment.yaml`
|
||||
- Create: `helm/cv-app/templates/backend-service.yaml`
|
||||
|
||||
**Step 1: Create backend-deployment.yaml**
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "cv-app.fullname" . }}-backend
|
||||
labels:
|
||||
{{- include "cv-app.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: backend
|
||||
spec:
|
||||
replicas: {{ .Values.backend.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "cv-app.selectorLabels" . | nindent 6 }}
|
||||
app.kubernetes.io/component: backend
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||||
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
|
||||
{{- with .Values.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "cv-app.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: backend
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "cv-app.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: backend
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.backend.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3001
|
||||
protocol: TCP
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ include "cv-app.fullname" . }}-config
|
||||
- secretRef:
|
||||
name: {{ include "cv-app.fullname" . }}-secret
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
resources:
|
||||
{{- toYaml .Values.backend.resources | nindent 12 }}
|
||||
volumes:
|
||||
- name: data
|
||||
{{- if .Values.persistence.enabled }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "cv-app.fullname" . }}-data
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
**Step 2: Create backend-service.yaml**
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "cv-app.fullname" . }}-backend
|
||||
labels:
|
||||
{{- include "cv-app.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: backend
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 3001
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "cv-app.selectorLabels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: backend
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add helm/cv-app/templates/backend-deployment.yaml helm/cv-app/templates/backend-service.yaml
|
||||
git commit -m "feat(helm): add backend Deployment and Service templates"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Create frontend Deployment and Service
|
||||
|
||||
**Files:**
|
||||
- Create: `helm/cv-app/templates/frontend-deployment.yaml`
|
||||
- Create: `helm/cv-app/templates/frontend-service.yaml`
|
||||
|
||||
**Step 1: Create frontend-deployment.yaml**
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "cv-app.fullname" . }}-frontend
|
||||
labels:
|
||||
{{- include "cv-app.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: frontend
|
||||
spec:
|
||||
replicas: {{ .Values.frontend.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "cv-app.selectorLabels" . | nindent 6 }}
|
||||
app.kubernetes.io/component: frontend
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||||
{{- with .Values.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "cv-app.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: frontend
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "cv-app.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: frontend
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.frontend.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 80
|
||||
protocol: TCP
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
resources:
|
||||
{{- toYaml .Values.frontend.resources | nindent 12 }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
**Step 2: Create frontend-service.yaml**
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "cv-app.fullname" . }}-frontend
|
||||
labels:
|
||||
{{- include "cv-app.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: frontend
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "cv-app.selectorLabels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: frontend
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add helm/cv-app/templates/frontend-deployment.yaml helm/cv-app/templates/frontend-service.yaml
|
||||
git commit -m "feat(helm): add frontend Deployment and Service templates"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Create Ingress template
|
||||
|
||||
**Files:**
|
||||
- Create: `helm/cv-app/templates/ingress.yaml`
|
||||
|
||||
**Step 1: Create ingress.yaml**
|
||||
|
||||
```yaml
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "cv-app.fullname" . }}
|
||||
labels:
|
||||
{{- include "cv-app.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- range .Values.ingress.tls }}
|
||||
- hosts:
|
||||
{{- range .hosts }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ .secretName }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ .path }}
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "cv-app.fullname" $ }}-{{ .service }}
|
||||
port:
|
||||
number: {{ if eq .service "frontend" }}80{{ else }}3001{{ end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add helm/cv-app/templates/ingress.yaml
|
||||
git commit -m "feat(helm): add Ingress template"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Create NOTES.txt and update documentation
|
||||
|
||||
**Files:**
|
||||
- Create: `helm/cv-app/templates/NOTES.txt`
|
||||
- Modify: `docs/architecture.md`
|
||||
|
||||
**Step 1: Create NOTES.txt**
|
||||
|
||||
```
|
||||
Your CV application has been deployed!
|
||||
|
||||
Access your application at:
|
||||
{{- if .Values.ingress.enabled }}
|
||||
{{- range $host := .Values.ingress.hosts }}
|
||||
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}
|
||||
{{- end }}
|
||||
{{- else }}
|
||||
Frontend: http://{{ include "cv-app.fullname" . }}-frontend.{{ .Release.Namespace }}.svc.cluster.local
|
||||
Backend API: http://{{ include "cv-app.fullname" . }}-backend.{{ .Release.Namespace }}.svc.cluster.local:3001
|
||||
{{- end }}
|
||||
|
||||
{{- if eq .Values.backend.auth.mode "simple" }}
|
||||
Get the admin password:
|
||||
kubectl logs deployment/{{ include "cv-app.fullname" . }}-backend | grep "ADMIN PASSWORD"
|
||||
{{- end }}
|
||||
|
||||
API Documentation:
|
||||
{{- if .Values.ingress.enabled }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
https://{{ (index .Values.ingress.hosts 0).host }}/api/docs
|
||||
{{- else }}
|
||||
http://{{ (index .Values.ingress.hosts 0).host }}/api/docs
|
||||
{{- end }}
|
||||
{{- else }}
|
||||
http://{{ include "cv-app.fullname" . }}-backend.{{ .Release.Namespace }}.svc.cluster.local:3001/api/docs
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add helm/cv-app/templates/NOTES.txt
|
||||
git commit -m "feat(helm): add post-install notes"
|
||||
```
|
||||
@@ -1,8 +1,10 @@
|
||||
<!-- markdownlint-disable MD013 -->
|
||||
|
||||
# Release Engineering Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
This project uses a fully automated release engineering pipeline powered by **semantic-release**, **commitlint**, and **GitHub Actions**. The pipeline handles versioning, changelog generation, Docker image publishing, and multi-environment deployments.
|
||||
This project uses a fully automated release engineering pipeline powered by **semantic-release**, **commitlint**, and **Gitea Actions**. The pipeline handles versioning, changelog generation, and releases. Docker publishing is currently disabled pending runner configuration.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -20,36 +22,20 @@ This project uses a fully automated release engineering pipeline powered by **se
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ GitHub Actions Pipeline │
|
||||
│ Gitea Actions Pipeline │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────────┐ ┌──────────────────────────┐ │
|
||||
│ │ CI Job │───►│ Release Job │───►│ Docker Build Job │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ • Lint │ │ • Analyze │ │ • Multi-platform build │ │
|
||||
│ │ • Test │ │ commits │ │ (amd64, arm64) │ │
|
||||
│ │ • Build │ │ • Bump version │ │ • Push to Docker Hub │ │
|
||||
│ │ • Commitlint│ │ • Update │ │ • Push to GHCR │ │
|
||||
│ │ │ │ CHANGELOG │ │ │ │
|
||||
│ │ │ │ • Create tag │ │ │ │
|
||||
│ │ │ │ • GitHub release│ │ │ │
|
||||
│ └─────────────┘ └─────────────────┘ └──────────────────────────┘ │
|
||||
│ ┌─────────────┐ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ CI Job │───►│ Release Job │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ • Lint │ │ • Analyze commits │ │
|
||||
│ │ • Test │ │ • Bump version │ │
|
||||
│ │ • Build │ │ • Update CHANGELOG │ │
|
||||
│ │ • Commitlint│ │ • Create tag │ │
|
||||
│ │ │ │ • Create Gitea release │ │
|
||||
│ └─────────────┘ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Registries │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ ┌─────────────────────────────────┐ │
|
||||
│ │ Docker Hub │ │ GitHub Container Registry │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ username/cv-app:latest │ │ ghcr.io/owner/cv-app:latest │ │
|
||||
│ │ username/cv-app:v1.0.0 │ │ ghcr.io/owner/cv-app:v1.0.0 │ │
|
||||
│ │ username/cv-app:staging │ │ ghcr.io/owner/cv-app:staging │ │
|
||||
│ │ username/cv-app:nightly │ │ ghcr.io/owner/cv-app:nightly │ │
|
||||
│ └─────────────────────────────┘ └─────────────────────────────────┘ │
|
||||
│ Note: Docker publishing temporarily disabled (runner lacks Docker) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -70,38 +56,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
|
||||
|
||||
@@ -126,11 +112,20 @@ Located in `.releaserc.json`:
|
||||
"@semantic-release/changelog",
|
||||
"@semantic-release/npm",
|
||||
"@semantic-release/git",
|
||||
"@semantic-release/github"
|
||||
[
|
||||
"@semantic-release/github",
|
||||
{
|
||||
"successCommentCondition": false,
|
||||
"failCommentCondition": false,
|
||||
"releasedLabels": false
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Note: The `successCommentCondition` and `failCommentCondition` are set to `false` for Gitea compatibility (Gitea lacks GitHub's GraphQL API).
|
||||
|
||||
### 3. commitlint
|
||||
|
||||
Enforces conventional commits via:
|
||||
@@ -139,6 +134,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,12 +142,14 @@ 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 + secret scan | `npm run lint` + `gitleaks protect` |
|
||||
| `commit-msg` | Validate commit message | `commitlint --edit` |
|
||||
|
||||
### 5. GitHub Actions Workflows
|
||||
Note: Gitleaks scans for secrets before commit. If not installed locally, it's skipped gracefully.
|
||||
|
||||
### 5. Gitea Actions Workflows
|
||||
|
||||
#### CI Workflow (`.github/workflows/ci.yml`)
|
||||
|
||||
@@ -190,8 +188,9 @@ Runs on: Push to master (excluding [skip ci] commits)
|
||||
│ Release Job │
|
||||
├───────────────────────────────────────────────────────────┤
|
||||
│ 1. Checkout (fetch-depth: 0 for full history) │
|
||||
│ 2. Setup Node.js 20 │
|
||||
│ 2. Setup Node.js 24 │
|
||||
│ 3. Install dependencies (root + backend) │
|
||||
│ - Uses --prefer-offline --no-audit --no-fund │
|
||||
│ 4. Lint │
|
||||
│ 5. Run tests (root + backend) │
|
||||
│ 6. Build │
|
||||
@@ -200,75 +199,41 @@ Runs on: Push to master (excluding [skip ci] commits)
|
||||
│ - Bump version │
|
||||
│ - Update CHANGELOG.md │
|
||||
│ - Create git tag │
|
||||
│ - Create GitHub release │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ Docker Build Job │
|
||||
├───────────────────────────────────────────────────────────┤
|
||||
│ 1. Checkout │
|
||||
│ 2. Get version from git tag │
|
||||
│ 3. Setup QEMU (for multi-platform) │
|
||||
│ 4. Setup Docker Buildx │
|
||||
│ 5. Login to Docker Hub │
|
||||
│ 6. Login to GHCR │
|
||||
│ 7. Build and push (amd64 + arm64) │
|
||||
│ - Create Gitea release │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Note: Docker build job temporarily disabled (runner lacks Docker support).
|
||||
|
||||
#### Staging Workflow (`.github/workflows/staging.yml`)
|
||||
|
||||
Runs on: Push to staging branch
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ Staging Deploy Job │
|
||||
├───────────────────────────────────────────────────────────┤
|
||||
│ 1. Checkout │
|
||||
│ 2. Install + Lint + Test + Build │
|
||||
│ 3. Build Docker image (multi-platform) │
|
||||
│ 4. Push with tag: staging │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
**Status: Temporarily disabled** - Requires Docker support on runner.
|
||||
|
||||
#### Nightly Workflow (`.github/workflows/nightly.yml`)
|
||||
|
||||
Runs on: Schedule (daily at 02:00 UTC)
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ Nightly Build Job │
|
||||
├───────────────────────────────────────────────────────────┤
|
||||
│ 1. Checkout master │
|
||||
│ 2. Get current date │
|
||||
│ 3. Install + Build │
|
||||
│ 4. Build Docker image (multi-platform) │
|
||||
│ 5. Push with tags: nightly, edge, YYYY-MM-DD │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
**Status: Temporarily disabled** - Requires Docker support on runner.
|
||||
|
||||
## 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 |
|
||||
**Status: Temporarily disabled** - Docker publishing requires runner with Docker-in-Docker support.
|
||||
|
||||
### Pulling Images
|
||||
| Tag | Registry | Description | Update Frequency |
|
||||
| ------------ | -------- | --------------------- | ---------------------- |
|
||||
| `latest` | Both | Latest stable release | Every release |
|
||||
| `v1.0.0` | Both | Specific version | Immutable |
|
||||
| `staging` | Both | Staging environment | Every staging push |
|
||||
| `nightly` | Both | Latest nightly build | Daily |
|
||||
|
||||
### Pulling Images (when Docker publishing is enabled)
|
||||
|
||||
```bash
|
||||
# Docker Hub
|
||||
docker pull username/cv-app:latest
|
||||
docker pull username/cv-app:v1.0.0
|
||||
docker pull username/cv-app:staging
|
||||
docker pull username/cv-app:nightly
|
||||
|
||||
# GitHub Container Registry
|
||||
docker pull ghcr.io/owner/cv-app:latest
|
||||
@@ -277,11 +242,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 | Status |
|
||||
| ----------- | --------- | ----------------------------------- | ---------------------------- |
|
||||
| Production | `master` | semantic-release (feat/fix commits) | Active (release only) |
|
||||
| Staging | `staging` | Push to branch | Disabled (Docker required) |
|
||||
| Nightly | `master` | Daily at 02:00 UTC | Disabled (Docker required) |
|
||||
|
||||
## Release Flow Example
|
||||
|
||||
@@ -293,6 +258,7 @@ Developer commits: feat(admin): add export functionality
|
||||
│ Git Hook Runs │
|
||||
│ (pre-commit) │
|
||||
│ • npm run lint │
|
||||
│ • gitleaks │
|
||||
└─────────────────┘
|
||||
│
|
||||
▼
|
||||
@@ -358,40 +324,23 @@ Developer commits: feat(admin): add export functionality
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ GitHub Release │
|
||||
│ Gitea Release │
|
||||
│ created │
|
||||
│ with notes │
|
||||
└─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ Docker images built and │
|
||||
│ pushed to: │
|
||||
│ │
|
||||
│ • username/cv-app:latest │
|
||||
│ • username/cv-app:v1.1.0 │
|
||||
│ • ghcr.io/owner/cv-app:latest │
|
||||
│ • ghcr.io/owner/cv-app:v1.1.0 │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Required Secrets
|
||||
|
||||
Configure these in GitHub repository settings → Secrets and variables → Actions:
|
||||
Configure these in Gitea repository settings → Settings → Secrets:
|
||||
|
||||
| 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` | Gitea token (automatic) | Releases |
|
||||
|
||||
### Creating Docker Hub Token
|
||||
|
||||
1. Go to Docker Hub → Account Settings → Security
|
||||
2. Click "New Access Token"
|
||||
3. Name: `cv-app-github-actions`
|
||||
4. Permissions: Read, Write, Delete
|
||||
5. Copy token and add to GitHub secrets
|
||||
Note: Docker publishing is currently disabled until runner supports Docker-in-Docker.
|
||||
|
||||
## Local Development
|
||||
|
||||
@@ -427,6 +376,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,41 +388,34 @@ 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
|
||||
|
||||
**Solution**: Ensure you have `feat`, `fix`, or `perf` commits since last release.
|
||||
|
||||
### Docker Push Fails
|
||||
|
||||
**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)
|
||||
|
||||
### Version Not Bumping
|
||||
|
||||
**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
89
eslint.config.js
Normal 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',
|
||||
},
|
||||
},
|
||||
])
|
||||
17
helm/cv-app/.helmignore
Normal file
17
helm/cv-app/.helmignore
Normal file
@@ -0,0 +1,17 @@
|
||||
# Patterns to ignore when building packages.
|
||||
.git/
|
||||
.github/
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Test files
|
||||
*_test.go
|
||||
tests/
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
!README.md
|
||||
docs/
|
||||
|
||||
# CI/CD
|
||||
.github/
|
||||
9
helm/cv-app/Chart.yaml
Normal file
9
helm/cv-app/Chart.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
apiVersion: v2
|
||||
name: cv-app
|
||||
description: A Helm chart for CV application
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "1.0.0"
|
||||
maintainers:
|
||||
- name: Tuan-Dat Tran
|
||||
email: tuan-dat.tran@tudattr.dev
|
||||
27
helm/cv-app/templates/NOTES.txt
Normal file
27
helm/cv-app/templates/NOTES.txt
Normal file
@@ -0,0 +1,27 @@
|
||||
Your CV application has been deployed!
|
||||
|
||||
Access your application at:
|
||||
{{- if .Values.ingress.enabled }}
|
||||
{{- range $host := .Values.ingress.hosts }}
|
||||
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}
|
||||
{{- end }}
|
||||
{{- else }}
|
||||
Frontend: http://{{ include "cv-app.fullname" . }}-frontend.{{ .Release.Namespace }}.svc.cluster.local
|
||||
Backend API: http://{{ include "cv-app.fullname" . }}-backend.{{ .Release.Namespace }}.svc.cluster.local:3001
|
||||
{{- end }}
|
||||
|
||||
{{- if eq .Values.backend.auth.mode "simple" }}
|
||||
Get the admin password:
|
||||
kubectl logs deployment/{{ include "cv-app.fullname" . }}-backend | grep "ADMIN PASSWORD"
|
||||
{{- end }}
|
||||
|
||||
API Documentation:
|
||||
{{- if .Values.ingress.enabled }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
https://{{ (index .Values.ingress.hosts 0).host }}/api/docs
|
||||
{{- else }}
|
||||
http://{{ (index .Values.ingress.hosts 0).host }}/api/docs
|
||||
{{- end }}
|
||||
{{- else }}
|
||||
http://{{ include "cv-app.fullname" . }}-backend.{{ .Release.Namespace }}.svc.cluster.local:3001/api/docs
|
||||
{{- end }}
|
||||
42
helm/cv-app/templates/_helpers.tpl
Normal file
42
helm/cv-app/templates/_helpers.tpl
Normal file
@@ -0,0 +1,42 @@
|
||||
{{- define "cv-app.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "cv-app.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "cv-app.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "cv-app.labels" -}}
|
||||
helm.sh/chart: {{ include "cv-app.chart" . }}
|
||||
{{ include "cv-app.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "cv-app.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "cv-app.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "cv-app.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
{{- default (include "cv-app.fullname" .) .Values.serviceAccount.name }}
|
||||
{{- else }}
|
||||
{{- default "default" .Values.serviceAccount.name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
80
helm/cv-app/templates/backend-deployment.yaml
Normal file
80
helm/cv-app/templates/backend-deployment.yaml
Normal file
@@ -0,0 +1,80 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "cv-app.fullname" . }}-backend
|
||||
labels:
|
||||
{{- include "cv-app.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: backend
|
||||
spec:
|
||||
replicas: {{ .Values.backend.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "cv-app.selectorLabels" . | nindent 6 }}
|
||||
app.kubernetes.io/component: backend
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||||
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
|
||||
{{- with .Values.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "cv-app.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: backend
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "cv-app.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: backend
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.backend.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3001
|
||||
protocol: TCP
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ include "cv-app.fullname" . }}-config
|
||||
- secretRef:
|
||||
name: {{ include "cv-app.fullname" . }}-secret
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
resources:
|
||||
{{- toYaml .Values.backend.resources | nindent 12 }}
|
||||
volumes:
|
||||
- name: data
|
||||
{{- if .Values.persistence.enabled }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "cv-app.fullname" . }}-data
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
17
helm/cv-app/templates/backend-service.yaml
Normal file
17
helm/cv-app/templates/backend-service.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "cv-app.fullname" . }}-backend
|
||||
labels:
|
||||
{{- include "cv-app.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: backend
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 3001
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "cv-app.selectorLabels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: backend
|
||||
15
helm/cv-app/templates/configmap.yaml
Normal file
15
helm/cv-app/templates/configmap.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "cv-app.fullname" . }}-config
|
||||
labels:
|
||||
{{- include "cv-app.labels" . | nindent 4 }}
|
||||
data:
|
||||
PORT: "3001"
|
||||
DB_PATH: "/app/data/cv.db"
|
||||
AUTH_MODE: {{ .Values.backend.auth.mode | quote }}
|
||||
{{- if eq .Values.backend.auth.mode "keycloak" }}
|
||||
KEYCLOAK_URL: {{ .Values.backend.keycloak.url | quote }}
|
||||
KEYCLOAK_REALM: {{ .Values.backend.keycloak.realm | quote }}
|
||||
KEYCLOAK_CLIENT_ID: {{ .Values.backend.keycloak.clientId | quote }}
|
||||
{{- end }}
|
||||
63
helm/cv-app/templates/frontend-deployment.yaml
Normal file
63
helm/cv-app/templates/frontend-deployment.yaml
Normal file
@@ -0,0 +1,63 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "cv-app.fullname" . }}-frontend
|
||||
labels:
|
||||
{{- include "cv-app.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: frontend
|
||||
spec:
|
||||
replicas: {{ .Values.frontend.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "cv-app.selectorLabels" . | nindent 6 }}
|
||||
app.kubernetes.io/component: frontend
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||||
{{- with .Values.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "cv-app.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: frontend
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "cv-app.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: frontend
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.frontend.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 80
|
||||
protocol: TCP
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
resources:
|
||||
{{- toYaml .Values.frontend.resources | nindent 12 }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
17
helm/cv-app/templates/frontend-service.yaml
Normal file
17
helm/cv-app/templates/frontend-service.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "cv-app.fullname" . }}-frontend
|
||||
labels:
|
||||
{{- include "cv-app.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: frontend
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "cv-app.selectorLabels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: frontend
|
||||
41
helm/cv-app/templates/ingress.yaml
Normal file
41
helm/cv-app/templates/ingress.yaml
Normal file
@@ -0,0 +1,41 @@
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "cv-app.fullname" . }}
|
||||
labels:
|
||||
{{- include "cv-app.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- range .Values.ingress.tls }}
|
||||
- hosts:
|
||||
{{- range .hosts }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ .secretName }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ .path }}
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "cv-app.fullname" $ }}-{{ .service }}
|
||||
port:
|
||||
number: {{ if eq .service "frontend" }}80{{ else }}3001{{ end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
17
helm/cv-app/templates/pvc.yaml
Normal file
17
helm/cv-app/templates/pvc.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
{{- if .Values.persistence.enabled -}}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "cv-app.fullname" . }}-data
|
||||
labels:
|
||||
{{- include "cv-app.labels" . | nindent 4 }}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
{{- if .Values.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.persistence.storageClass | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.size }}
|
||||
{{- end }}
|
||||
11
helm/cv-app/templates/secret.yaml
Normal file
11
helm/cv-app/templates/secret.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
{{- if eq .Values.backend.auth.mode "simple" }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "cv-app.fullname" . }}-secret
|
||||
labels:
|
||||
{{- include "cv-app.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
data:
|
||||
JWT_SECRET: {{ .Values.backend.auth.jwtSecret | default (randAlphaNum 32) | b64enc | quote }}
|
||||
{{- end }}
|
||||
57
helm/cv-app/values.yaml
Normal file
57
helm/cv-app/values.yaml
Normal file
@@ -0,0 +1,57 @@
|
||||
frontend:
|
||||
replicaCount: 1
|
||||
image:
|
||||
repository: username/cv-app
|
||||
tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
resources: {}
|
||||
|
||||
backend:
|
||||
replicaCount: 1
|
||||
image:
|
||||
repository: username/cv-app-backend
|
||||
tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
auth:
|
||||
mode: simple
|
||||
jwtSecret: ""
|
||||
keycloak:
|
||||
url: ""
|
||||
realm: ""
|
||||
clientId: ""
|
||||
resources: {}
|
||||
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 1Gi
|
||||
storageClass: ""
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: ""
|
||||
annotations: {}
|
||||
hosts:
|
||||
- host: cv.local
|
||||
paths:
|
||||
- path: /api
|
||||
service: backend
|
||||
- path: /
|
||||
service: frontend
|
||||
tls: []
|
||||
|
||||
imagePullSecrets: []
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
serviceAccount:
|
||||
create: true
|
||||
annotations: {}
|
||||
name: ""
|
||||
|
||||
podAnnotations: {}
|
||||
podSecurityContext: {}
|
||||
securityContext: {}
|
||||
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
14
index.html
Normal file
14
index.html
Normal 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
28
nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
14
package.json
14
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cv-app",
|
||||
"private": true,
|
||||
"version": "0.0.0-dev.1",
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -32,7 +32,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.34.1",
|
||||
"lucide-react": "^0.574.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.13.0"
|
||||
@@ -40,7 +40,7 @@
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.4.2",
|
||||
"@commitlint/config-conventional": "^20.4.2",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@eslint/js": "^10.0.0",
|
||||
"@lhci/cli": "^0.15.1",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
@@ -51,17 +51,17 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"bundlesize": "^0.18.2",
|
||||
"cors": "^2.8.6",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"eslint-plugin-react-refresh": "^0.5.0",
|
||||
"express": "^5.2.1",
|
||||
"globals": "^16.5.0",
|
||||
"globals": "^17.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^28.1.0",
|
||||
"postcss": "^8.5.6",
|
||||
|
||||
26
playwright.config.js
Normal file
26
playwright.config.js
Normal 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
1
public/vite.svg
Normal 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 |
3
renovate.json
Normal file
3
renovate.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||
}
|
||||
51
src/App.jsx
Normal file
51
src/App.jsx
Normal 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;
|
||||
14
src/admin/AdminEducation.jsx
Normal file
14
src/admin/AdminEducation.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
src/admin/AdminExperience.jsx
Normal file
14
src/admin/AdminExperience.jsx
Normal 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
89
src/admin/AdminLayout.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
src/admin/AdminPersonal.jsx
Normal file
14
src/admin/AdminPersonal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
src/admin/AdminProjects.jsx
Normal file
14
src/admin/AdminProjects.jsx
Normal 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
14
src/admin/AdminSkills.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
src/admin/components/ExportButton.jsx
Normal file
104
src/admin/components/ExportButton.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
src/admin/hooks/AuthContext.jsx
Normal file
61
src/admin/hooks/AuthContext.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { getAuthConfig, login } from '../../lib/api';
|
||||
import { getToken, setToken, clearToken } from '../../lib/auth';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [authMode, setAuthMode] = useState(null);
|
||||
const [keycloakConfig, setKeycloakConfig] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getAuthConfig()
|
||||
.then((config) => {
|
||||
setAuthMode(config.mode);
|
||||
setKeycloakConfig(config.keycloak);
|
||||
|
||||
if (config.mode === 'simple') {
|
||||
const token = getToken();
|
||||
setIsAuthenticated(!!token);
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const simpleLogin = useCallback(async (password) => {
|
||||
const result = await login(password);
|
||||
setToken(result.token);
|
||||
setIsAuthenticated(true);
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearToken();
|
||||
setIsAuthenticated(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{
|
||||
isAuthenticated,
|
||||
authMode,
|
||||
keycloakConfig,
|
||||
loading,
|
||||
login: simpleLogin,
|
||||
logout,
|
||||
}}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
132
src/admin/hooks/CVContext.jsx
Normal file
132
src/admin/hooks/CVContext.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { fetchCV as apiFetchCV, updateCV as apiUpdateCV, exportCV as apiExportCV, importCV as apiImportCV } from '../../lib/api';
|
||||
import { DEFAULT_DATA, validateCV } from '../../lib/cv-data';
|
||||
|
||||
const CVContext = createContext(null);
|
||||
|
||||
export function CVProvider({ children }) {
|
||||
const [data, setData] = useState(DEFAULT_DATA);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetchCV()
|
||||
.then(data => {
|
||||
setData(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to fetch CV:', err);
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updatePersonal = useCallback(async (personal) => {
|
||||
const newData = { ...data, personal };
|
||||
setData(newData);
|
||||
await apiUpdateCV(newData);
|
||||
}, [data]);
|
||||
|
||||
const updateExperience = useCallback(async (experience) => {
|
||||
const newData = { ...data, experience };
|
||||
setData(newData);
|
||||
await apiUpdateCV(newData);
|
||||
}, [data]);
|
||||
|
||||
const updateSkills = useCallback(async (skills) => {
|
||||
const newData = { ...data, skills };
|
||||
setData(newData);
|
||||
await apiUpdateCV(newData);
|
||||
}, [data]);
|
||||
|
||||
const updateEducation = useCallback(async (education) => {
|
||||
const newData = { ...data, education };
|
||||
setData(newData);
|
||||
await apiUpdateCV(newData);
|
||||
}, [data]);
|
||||
|
||||
const updateProjects = useCallback(async (projects) => {
|
||||
const newData = { ...data, projects };
|
||||
setData(newData);
|
||||
await apiUpdateCV(newData);
|
||||
}, [data]);
|
||||
|
||||
const exportJSON = useCallback(async () => {
|
||||
const validation = validateCV(data);
|
||||
if (!validation.valid) {
|
||||
console.warn('Exporting CV with validation warnings:', validation.errors);
|
||||
}
|
||||
|
||||
const blob = await apiExportCV();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'cv.json';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, [data]);
|
||||
|
||||
const importJSON = useCallback(async (jsonString) => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonString);
|
||||
const validation = validateCV(parsed);
|
||||
if (!validation.valid) {
|
||||
return { success: false, errors: validation.errors };
|
||||
}
|
||||
await apiImportCV(parsed);
|
||||
setData(parsed);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, errors: [`JSON parse error: ${e.message}`] };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetToDefault = useCallback(async () => {
|
||||
await apiUpdateCV(DEFAULT_DATA);
|
||||
setData(DEFAULT_DATA);
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await apiFetchCV();
|
||||
setData(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CVContext.Provider value={{
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
updatePersonal,
|
||||
updateExperience,
|
||||
updateSkills,
|
||||
updateEducation,
|
||||
updateProjects,
|
||||
exportJSON,
|
||||
importJSON,
|
||||
resetToDefault,
|
||||
refresh
|
||||
}}>
|
||||
{children}
|
||||
</CVContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useCVData() {
|
||||
const context = useContext(CVContext);
|
||||
if (!context) {
|
||||
throw new Error('useCVData must be used within a CVProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
89
src/admin/hooks/__tests__/CVContext.test.jsx
Normal file
89
src/admin/hooks/__tests__/CVContext.test.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { CVProvider, useCVData } from '../CVContext';
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
const mockUpdateCV = vi.fn();
|
||||
const mockExportCV = vi.fn();
|
||||
const mockImportCV = vi.fn();
|
||||
|
||||
vi.mock('../../../lib/api', () => ({
|
||||
fetchCV: () => mockFetch(),
|
||||
updateCV: (data) => mockUpdateCV(data),
|
||||
exportCV: () => mockExportCV(),
|
||||
importCV: (data) => mockImportCV(data),
|
||||
}));
|
||||
|
||||
const defaultMockData = {
|
||||
personal: { name: 'Test User', title: 'Developer', email: 'test@test.com' },
|
||||
experience: [],
|
||||
skills: {},
|
||||
education: [],
|
||||
projects: []
|
||||
};
|
||||
|
||||
describe('CVContext', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetch.mockResolvedValue(defaultMockData);
|
||||
mockUpdateCV.mockResolvedValue({ success: true });
|
||||
mockExportCV.mockResolvedValue(new Blob([JSON.stringify(defaultMockData)], { type: 'application/json' }));
|
||||
mockImportCV.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
const wrapper = ({ children }) => <CVProvider>{children}</CVProvider>;
|
||||
|
||||
it('provides default data after loading', async () => {
|
||||
const { result } = renderHook(() => useCVData(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.data).toBeDefined();
|
||||
expect(result.current.data.personal.name).toBe('Test User');
|
||||
});
|
||||
|
||||
it('updates personal data', async () => {
|
||||
const { result } = renderHook(() => useCVData(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
act(() => {
|
||||
result.current.updatePersonal({
|
||||
...result.current.data.personal,
|
||||
name: 'New Name'
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data.personal.name).toBe('New Name');
|
||||
});
|
||||
expect(mockUpdateCV).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates experience data', async () => {
|
||||
const { result } = renderHook(() => useCVData(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
const newExperience = [{ id: 1, role: 'New Role' }];
|
||||
act(() => {
|
||||
result.current.updateExperience(newExperience);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data.experience).toEqual(newExperience);
|
||||
});
|
||||
});
|
||||
|
||||
it('throws error when used outside CVProvider', () => {
|
||||
expect(() => {
|
||||
renderHook(() => useCVData());
|
||||
}).toThrow('useCVData must be used within a CVProvider');
|
||||
});
|
||||
});
|
||||
60
src/admin/hooks/useCVData.js
Normal file
60
src/admin/hooks/useCVData.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { cvData as initialData } from '../../data/cv';
|
||||
|
||||
export function useCVData() {
|
||||
const [data, setData] = useState(initialData);
|
||||
|
||||
const updatePersonal = useCallback((personal) => {
|
||||
setData(prev => ({ ...prev, personal }));
|
||||
}, []);
|
||||
|
||||
const updateExperience = useCallback((experience) => {
|
||||
setData(prev => ({ ...prev, experience }));
|
||||
}, []);
|
||||
|
||||
const updateSkills = useCallback((skills) => {
|
||||
setData(prev => ({ ...prev, skills }));
|
||||
}, []);
|
||||
|
||||
const updateEducation = useCallback((education) => {
|
||||
setData(prev => ({ ...prev, education }));
|
||||
}, []);
|
||||
|
||||
const updateProjects = useCallback((projects) => {
|
||||
setData(prev => ({ ...prev, projects }));
|
||||
}, []);
|
||||
|
||||
const exportJSON = useCallback(() => {
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'cv.json';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, [data]);
|
||||
|
||||
const importJSON = useCallback((jsonString) => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonString);
|
||||
setData(parsed);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
data,
|
||||
updatePersonal,
|
||||
updateExperience,
|
||||
updateSkills,
|
||||
updateEducation,
|
||||
updateProjects,
|
||||
exportJSON,
|
||||
importJSON
|
||||
};
|
||||
}
|
||||
69
src/admin/hooks/useFormValidation.js
Normal file
69
src/admin/hooks/useFormValidation.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export function useFormValidation(rules) {
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const validate = (data) => {
|
||||
const newErrors = {};
|
||||
|
||||
Object.entries(rules).forEach(([field, fieldRules]) => {
|
||||
for (const rule of fieldRules) {
|
||||
const error = rule(data[field], data);
|
||||
if (error) {
|
||||
newErrors[field] = error;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const clearError = (field) => {
|
||||
setErrors(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[field];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const clearAllErrors = () => setErrors({});
|
||||
|
||||
return { errors, validate, clearError, clearAllErrors };
|
||||
}
|
||||
|
||||
export const validators = {
|
||||
required: (message = 'Pflichtfeld') => (value) => {
|
||||
if (!value || (typeof value === 'string' && !value.trim())) {
|
||||
return message;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
email: (message = 'Ungültige E-Mail-Adresse') => (value) => {
|
||||
if (!value) return null;
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(value)) {
|
||||
return message;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
url: (message = 'Ungültige URL') => (value) => {
|
||||
if (!value) return null;
|
||||
try {
|
||||
new URL(value);
|
||||
return null;
|
||||
} catch {
|
||||
return message;
|
||||
}
|
||||
},
|
||||
|
||||
minLength: (min, message) => (value) => {
|
||||
if (!value || value.length < min) {
|
||||
return message || `Mindestens ${min} Zeichen`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
95
src/admin/pages/LoginPage.jsx
Normal file
95
src/admin/pages/LoginPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
167
src/admin/sections/EducationForm.jsx
Normal file
167
src/admin/sections/EducationForm.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
248
src/admin/sections/ExperienceForm.jsx
Normal file
248
src/admin/sections/ExperienceForm.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
169
src/admin/sections/PersonalForm.jsx
Normal file
169
src/admin/sections/PersonalForm.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
227
src/admin/sections/ProjectsForm.jsx
Normal file
227
src/admin/sections/ProjectsForm.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
src/admin/sections/SkillsForm.jsx
Normal file
160
src/admin/sections/SkillsForm.jsx
Normal 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
1
src/assets/react.svg
Normal 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 |
61
src/components/Contact.jsx
Normal file
61
src/components/Contact.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
src/components/Education.jsx
Normal file
53
src/components/Education.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
src/components/Experience.jsx
Normal file
72
src/components/Experience.jsx
Normal 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
73
src/components/Hero.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
src/components/Projects.jsx
Normal file
57
src/components/Projects.jsx
Normal 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
72
src/components/Skills.jsx
Normal 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
3
src/data/cv.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { DEFAULT_DATA } from './lib/cv-data.js';
|
||||
|
||||
export const cvData = DEFAULT_DATA;
|
||||
26
src/index.css
Normal file
26
src/index.css
Normal 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;
|
||||
}
|
||||
168
src/lib/__tests__/cv-data.test.js
Normal file
168
src/lib/__tests__/cv-data.test.js
Normal 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
71
src/lib/api.js
Normal 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
18
src/lib/auth.js
Normal 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
74
src/lib/cv-data.js
Normal 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
73
src/lib/cv.json
Normal 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
10
src/main.jsx
Normal 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
1
src/test/setup.js
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
38
tests/e2e/admin.spec.js
Normal file
38
tests/e2e/admin.spec.js
Normal 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
39
tests/e2e/cv.spec.js
Normal 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();
|
||||
});
|
||||
});
|
||||
17
tests/integration/docker-compose.test.yml
Normal file
17
tests/integration/docker-compose.test.yml
Normal 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:
|
||||
205
tests/integration/fullstack.test.js
Normal file
205
tests/integration/fullstack.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
55
tests/performance/k6/load.js
Normal file
55
tests/performance/k6/load.js
Normal 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),
|
||||
};
|
||||
}
|
||||
21
tests/performance/lighthouserc.js
Normal file
21
tests/performance/lighthouserc.js
Normal 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',
|
||||
},
|
||||
},
|
||||
};
|
||||
109
tests/regression/__snapshots__/api-snapshot.test.js.snap
Normal file
109
tests/regression/__snapshots__/api-snapshot.test.js.snap
Normal 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",
|
||||
],
|
||||
}
|
||||
`;
|
||||
104
tests/regression/api-snapshot.test.js
Normal file
104
tests/regression/api-snapshot.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
44
tests/regression/visual.test.js
Normal file
44
tests/regression/visual.test.js
Normal 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
8
vite.config.js
Normal 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/' : '/',
|
||||
}))
|
||||
Reference in New Issue
Block a user